Compare commits

...

180 Commits

Author SHA1 Message Date
Hosted Weblate c7214fc7ce Update translation files
Updated by "Remove blank strings" hook in Weblate.

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.8% (652 of 653 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-09-29 06:44:18 +02:00
Hosted Weblate 84bcfb6eb9 Translated using Weblate (Spanish)
Currently translated at 99.8% (652 of 653 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2024-09-29 06:44:17 +02:00
Hosted Weblate 0ca7a0efc9 Translated using Weblate (Czech)
Currently translated at 100.0% (653 of 653 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2024-09-29 06:44:17 +02:00
Torsten Curdt cb76436a2a point help menu items to feishin instead of just electron (#767) 2024-09-28 21:44:13 -07:00
jeffvli 88a951e2e7 Update to v0.10.1 2024-09-28 21:39:30 -07:00
jeffvli 6f1b78c2d6 Fix Subsonic servertype lock from docker configuration 2024-09-28 21:37:09 -07:00
jeffvli 107074b240 Fix subsonic album list sort options 2024-09-28 21:35:32 -07:00
Kendall Garner 6e8ca7e035 fix navidrome getPlaylistSOngList end 2024-09-28 21:22:14 -07:00
jeffvli 3c99a662e8 Fix album detail header track count 2024-09-26 21:23:48 -07:00
jeffvli fc8110ca79 Fix layout shift on lyrics container hover 2024-09-26 21:21:54 -07:00
jeffvli 715f800788 Add getStructuredLyrics to navidrome controller 2024-09-26 12:13:29 -07:00
jeffvli 244aee45cd Handle potential null response on genre results from Navidrome 2024-09-26 12:13:03 -07:00
jeffvli c96f5b207d Handle subsonic endpoints that potentially return optional response when no items 2024-09-26 11:14:06 -07:00
Hosted Weblate 0e8b2aed72 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (642 of 642 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-09-26 06:57:33 +02:00
Hosted Weblate f2accd63fd Translated using Weblate (French)
Currently translated at 96.5% (620 of 642 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jucgshu <brewal.bouvet@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translation: feishin/Translation
2024-09-26 06:57:33 +02:00
Hosted Weblate c024e975fb Translated using Weblate (Spanish)
Currently translated at 100.0% (642 of 642 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2024-09-26 06:57:33 +02:00
Hosted Weblate a3f725b0ef Translated using Weblate (Czech)
Currently translated at 100.0% (642 of 642 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (642 of 642 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2024-09-26 06:57:33 +02:00
Hosted Weblate f137f487aa Translated using Weblate (English)
Currently translated at 98.1% (641 of 653 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/en/
Translation: feishin/Translation
2024-09-26 06:57:33 +02:00
jeffvli 8e59514524 Update to v.10.0 2024-09-25 21:57:19 -07:00
Mikhail Tsarev 7bcfe30a8e Improved translations for English and Russian versions. (#760)
* First version of Russian translation

* Improvements

---------

Co-authored-by: Suoslex <mtsarev06@gmail.com>
2024-09-25 21:42:41 -07:00
Kendall Garner 8cddbef701 Subsonic 2, general rework (#758) 2024-09-25 21:23:08 -07:00
Xudong Zhou 31492fa9ef Lyrics Translation and Romaji (Fulfill #732) [Translation Part] (#747) 2024-09-23 20:25:17 -07:00
Jeff e3946a9413 Update Navidrome list sort mappings for ND v0.53.2 (#754)
* Update navidrome list sort mappings

* Rename ownerName to owner_name

* Remove deprecated client-side sort
2024-09-22 18:37:13 -07:00
Jamie King 28c12496f1 Removed references to "ElectronReact" in MacOS menu bar (#756) 2024-09-20 13:26:28 +00:00
jeffvli f8c2ff735b Replace "SortName" for "Name" in Jellyfin song sort 2024-09-19 20:39:47 -07:00
Kendall Garner 22e4974191 refactor navidrome-types 2024-09-18 20:43:35 -07:00
Kendall Garner 6eecc3c0fd handle sanitized sort and filter post-fact 2024-09-18 18:00:25 -07:00
Kendall Garner b628b684ae require limit to specified (nonzero) for shuffle all 2024-09-18 07:31:58 -07:00
Kendall Garner 4c49e403ab make headers optional? 2024-09-16 19:57:59 -07:00
Kendall Garner 730683fe25 different date formats based off of metadata 2024-09-16 17:33:06 -07:00
Kendall Garner 96b4f8dd89 update album play count 2024-09-15 21:48:32 -07:00
Kendall Garner f82889e5ec npm audit fix 2024-09-15 20:05:48 -07:00
Benjamin 8d8826a9b7 use utc for absolute date formatting (#743)
* use utc for date formatting

* add seperate utc function and call that instead

* swap date format to be a constant

* make dateadded use non-utc
2024-09-13 01:35:57 +00:00
Kendall Garner 660c9744bf clear queue when shuffle now 2024-09-11 20:36:46 -07:00
Kendall Garner 8221af9a8f break by newline for comment 2024-09-11 07:41:15 -07:00
jeffvli 93f2573847 Update to v0.9.0 2024-09-10 22:37:48 -07:00
Kendall Garner 03d97c6b1e use unique id for paginated playlist 2024-09-10 22:37:24 -07:00
Hosted Weblate 0b86cb51d3 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (634 of 634 strings)

Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-09-10 04:01:31 +02:00
Hosted Weblate ee54b8219b Translated using Weblate (French)
Currently translated at 96.8% (610 of 630 strings)

Co-authored-by: Evan <evan_g@orange.fr>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translation: feishin/Translation
2024-09-10 04:01:31 +02:00
Hosted Weblate 25b593aadd Translated using Weblate (Spanish)
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (632 of 632 strings)

Translated using Weblate (Spanish)

Currently translated at 99.8% (631 of 632 strings)

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2024-09-10 04:01:31 +02:00
Hosted Weblate 5253e32b67 Translated using Weblate (Czech)
Currently translated at 100.0% (634 of 634 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (632 of 632 strings)

Translated using Weblate (Czech)

Currently translated at 99.6% (630 of 632 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2024-09-10 04:01:31 +02:00
Hosted Weblate b0b558c90a Translated using Weblate (German)
Currently translated at 85.0% (536 of 630 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Schroti <schrotihd@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/
Translation: feishin/Translation
2024-09-10 04:01:31 +02:00
Kendall Garner f11a53c1a4 fix suspense 2024-09-09 19:01:07 -07:00
Kendall Garner e2a05f4204 add track normalization for jellyfin as well 2024-09-09 07:15:26 -07:00
Kendall Garner fcc010eb54 move transcoding placeholder 2024-09-08 22:05:44 -07:00
Kendall Garner 1b41a5a674 enable disabling tray 2024-09-08 20:55:07 -07:00
Kendall Garner 74aa88e082 add web visualizer (#314)
* add web visualizer

* fallback to simple model

* less samples, hopefully more efficient

* Use audiomotion analyzer

- Note: fixed to 4.1.1 because 4.2.0 uses esm which breaks in the current workflow...

* revert publish changes

* r2

* don't massively change package.json

* lazy
2024-09-09 01:25:01 +00:00
Kendall Garner fbac33ceba add shuffle context menu item 2024-09-07 21:31:01 -07:00
Kendall Garner 42ba5a531c use feishin switch instead of default 2024-09-05 18:08:37 -07:00
Kendall Garner 257e1e2cd9 ... 2024-09-05 07:06:37 -07:00
Kendall Garner 3025e84c58 remove height everywhere for jellyfin images 2024-09-04 22:30:50 -07:00
Kendall Garner 4a111d9cf2 don't make artist clickable if no id 2024-09-04 20:01:45 -07:00
Kendall Garner e6bd8deb0c use unique id for places that may have duplicates 2024-09-04 19:34:07 -07:00
jeffvli 6b0c57998b Update to v0.8.1 2024-09-03 21:53:19 -07:00
jeffvli 6587e9cac8 Fix invalid DOM prop on playerbar 2024-09-03 21:51:50 -07:00
jeffvli 2e3c69e61c Fix song index skip when viewing synchronized lyrics 2024-09-03 21:51:18 -07:00
jeffvli 4a8cd63046 Update to v0.8.0 2024-09-02 22:48:52 -07:00
Hosted Weblate 549b53b4a4 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (630 of 630 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (630 of 630 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (630 of 630 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (610 of 610 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (595 of 595 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Shawn <xiaxinx@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-09-03 05:31:30 +00:00
Hosted Weblate f33d13f574 Translated using Weblate (French)
Currently translated at 100.0% (593 of 593 strings)

Co-authored-by: Dylan MONTIGAUD <dylanmontigaud17@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translation: feishin/Translation
2024-09-03 05:31:29 +00:00
Hosted Weblate 4da51a16c9 Translated using Weblate (Spanish)
Currently translated at 100.0% (630 of 630 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (622 of 622 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (612 of 612 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (610 of 610 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (604 of 604 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (602 of 602 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (602 of 602 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (602 of 602 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (599 of 599 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (595 of 595 strings)

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2024-09-03 05:31:29 +00:00
Hosted Weblate 8d138ff974 Translated using Weblate (Dutch)
Currently translated at 40.1% (245 of 610 strings)

Translated using Weblate (Dutch)

Currently translated at 40.1% (245 of 610 strings)

Translated using Weblate (Dutch)

Currently translated at 40.0% (244 of 610 strings)

Translated using Weblate (Dutch)

Currently translated at 40.7% (244 of 599 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Idris Saklou <idrissaklou@hotmail.com>
Co-authored-by: Joren Vansteenkiste <vansteenkiste.joren@telenet.be>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/nl/
Translation: feishin/Translation
2024-09-03 05:31:28 +00:00
Hosted Weblate 9373937436 Translated using Weblate (Czech)
Currently translated at 100.0% (630 of 630 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (621 of 621 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (612 of 612 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (610 of 610 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (604 of 604 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (599 of 599 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (595 of 595 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2024-09-03 05:31:27 +00:00
Hosted Weblate 46bbe6b95f Translated using Weblate (German)
Currently translated at 88.2% (525 of 595 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: elia <me@elia.li>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/
Translation: feishin/Translation
2024-09-03 05:31:26 +00:00
Kendall Garner 56c229a5e0 [slightly less scuffed bugfix]: Update table rating/favorite when updated anywhere … (#707)
* [scuffed bugfix]: Update table rating/favorite when updated anywhere else

Modify player store to have temporary state for favorite/rating update
Add effect handler for `virtual-table` to update rating/favorite for players

Note that this does not handle song grid view.
Using a similar handler for gird view did not work, as it appeared to result in inconsistent state.

Finally, this is probably not the optimal solution.
Performance appears fine for ~20k items, but no guarantees.

* restore should update song

* update song rating/favorite/played everywhere except playlist

* special rule for playlists

* use iterator instead
2024-09-02 22:31:20 -07:00
Kendall Garner 9d44f0fc08 [bugfix]: don't be loading if top songs disabled 2024-09-02 19:26:47 -07:00
Benjamin 903d1479a4 adjust rules for user selection (#723) 2024-09-03 00:48:52 +00:00
Kendall Garner 7299bcefb2 Merge branch 'development' of github.com:jeffvli/feishin into development 2024-09-02 10:57:21 -07:00
Kendall Garner 6b7c69e90a fix seeking between 0-1 seconds 2024-09-02 10:56:46 -07:00
dependabot[bot] 4601838afe Bump electron-updater in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [electron-updater](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-updater).


Updates `electron-updater` from 6.3.0 to 6.3.1
- [Release notes](https://github.com/electron-userland/electron-builder/releases)
- [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/CHANGELOG.md)
- [Commits](https://github.com/electron-userland/electron-builder/commits/electron-updater@6.3.1/packages/electron-updater)

---
updated-dependencies:
- dependency-name: electron-updater
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-01 17:09:50 -07:00
Kendall Garner f7dd634f67 reorder album artist page 2024-09-01 16:48:43 -07:00
Pyx eb50c69a35 Album blur, allow clicking the playerbar to toggle the player, misc changes (#717)
* Album blur, allow clicking the playerbar to toggle the player

* Fix stopProporagion, sync package with upsteam, update translation

* recommit my existing changes

* Update default albumBackgroundBlur to 6

* according to git this commit resets the package files

* merge with our fork because pyx forgot to add it

* try adding a setting

* change the playerbar animation

* make the animation quicker bc its choppy

* change playerbar to use a bool instead

* requested opacity fix

* Refactor classes to use clsx

---------

Co-authored-by: iiPython <ben@iipython.dev>
Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2024-09-01 23:42:01 +00:00
Kendall Garner b93ad40571 Merge pull request #720 from pyxfluff/patch-1
Add play count to albums
2024-09-01 12:50:19 -07:00
Kendall Garner 748db032c7 add translation 2024-09-01 12:48:11 -07:00
Kendall Garner 80931d1b19 jellyfin random play filter 2024-09-01 12:25:50 -07:00
Kendall Garner 93377dcc4f fix jellyfin playlists, add is public 2024-09-01 09:37:37 -07:00
Kendall Garner 528bef01f0 provide transcoding support 2024-09-01 08:26:30 -07:00
Kendall Garner da95a644c8 upgrade webpack 2024-08-29 21:42:44 -07:00
Pyx f5a04980a4 Add play count to albums 2024-08-29 23:03:00 -04:00
Kendall Garner 93055b3bf1 allow disabling web audio 2024-08-29 19:44:24 -07:00
Kendall Garner e68847f50a slightly better error handling 2024-08-27 21:27:49 -07:00
Kendall Garner 43fe1a235e fix context menu original item 2024-08-27 21:21:44 -07:00
Kendall Garner 62c372d0c7 dont't move to 0 when removing current item from queue 2024-08-27 21:14:08 -07:00
Kendall Garner 279842b894 bump node mpv 2024-08-27 20:11:08 -07:00
Kendall Garner 6125901023 [enhancement]: custom css 2024-08-27 08:26:34 -07:00
Kendall Garner 004c9a8d06 allow hiding context menu items 2024-08-26 21:35:12 -07:00
Kendall Garner f746114041 increase metadata size of library header 2024-08-26 20:26:37 -07:00
Kendall Garner 9f4861a78a use context menu instead of button 2024-08-25 22:17:11 -07:00
Kendall Garner 32b984a18b add playlist context menu button to sidebar 2024-08-25 22:08:07 -07:00
Kendall Garner 8a8542ddb1 simplify disc/subtitle for album list 2024-08-25 21:34:43 -07:00
Kendall Garner b41a1a8b15 [bugfix]: properly update song when restoring queue 2024-08-25 20:02:44 -07:00
Kendall Garner 9923c021fa better album dates 2024-08-25 19:52:44 -07:00
Kendall Garner 8c929d0dc3 fixed size for different sizes 2024-08-25 18:07:51 -07:00
Kendall Garner fb1e33fad5 autosize library item text 2024-08-25 17:50:46 -07:00
Kendall Garner c4677a63f6 [enhancement]: allow downloading individual tracks for external use 2024-08-25 17:11:24 -07:00
Kendall Garner 10fca2dc12 enable reordering non-smart playlists 2024-08-25 15:21:56 -07:00
Kendall Garner 0b383b758e support collapsing shared playlists 2024-08-24 21:09:44 -07:00
Kendall Garner ccb6f2c8b0 very niche error handling for no audio device id but still have error checking 2024-08-24 20:36:04 -07:00
Kendall Garner a44071fedd add error checking for set sink id (case of no devices at all) 2024-08-24 20:13:30 -07:00
Kendall Garner b527d579fd Revert upgrade of discord-rpc
Some horrible magic can result in this upgrade causing compiler errors.
No idea why.
2024-08-24 15:22:55 -07:00
Kendall Garner 5b2977e5e8 [enhancement]: support viewing current/setting current time in remote 2024-08-24 13:26:45 -07:00
Kendall Garner b347b794b9 fix micromatch 2024-08-23 19:54:39 -07:00
Kendall Garner ad81790c90 add more places for share in context menu 2024-08-23 19:53:40 -07:00
Kendall Garner 906ffee8bc thanks discord [support changing listen type] 2024-08-23 10:34:18 -07:00
Kendall Garner 284db988c9 [enhancement]: use discord activity type listening 2024-08-23 08:27:40 -07:00
Kendall Garner 271be93a96 fix prettier/lint 2024-08-23 08:19:27 -07:00
Kendall Garner 121b036aaf bump i18next-parser 2024-08-23 08:00:59 -07:00
Kendall Garner 028ccfb1cd fix album art res 0 and allow resizing volume bar 2024-08-22 21:57:58 -07:00
Kendall Garner 37b0407188 simplify webaudio replaygain to reduce pop-in 2024-08-21 23:04:37 -07:00
Kendall Garner 616fd45734 add minimum duration check for crossfade 2024-08-21 22:47:35 -07:00
Kendall Garner a537642990 [bugfix]: set index to current track when unshuffling 2024-08-20 19:10:05 -07:00
Kendall Garner 9c6abcb32c Use break-word over break-all 2024-08-20 16:35:08 +00:00
Kendall Garner af69a58418 [bugfix]: use chrome-specific implementation for web audio sink 2024-08-19 22:38:51 -07:00
Kendall Garner ebebdc1e03 forcefully break long lines 2024-08-19 22:12:32 -07:00
Kendall Garner 7d185f6563 clarify text 2024-08-19 22:02:54 -07:00
Kendall Garner 88741a8616 add ability to configure double click behavior 2024-08-19 21:56:55 -07:00
Kendall Garner 94edda1856 better handling of grid refresh 2024-08-19 21:36:56 -07:00
Kendall Garner 886786d428 [enhancement]: add background opacity to buttons in full screen player 2024-08-19 19:03:19 -07:00
Kendall Garner e4ca0164fa [bugfix]: Jellyfin do not force width = height for images 2024-08-15 23:35:29 -07:00
Jeff 08db18359a Merge pull request #687 from jeffvli/dependabot/npm_and_yarn/npm_and_yarn-bb2c7e2e3f
Bump electron-updater from 4.6.5 to 6.3.0 in the npm_and_yarn group across 1 directory
2024-07-30 11:53:25 -07:00
jeffvli f2beeef0e9 Bump to v0.7.3 2024-07-30 03:26:21 -07:00
dependabot[bot] 6daba77bae Bump electron-updater in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [electron-updater](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-updater).


Updates `electron-updater` from 4.6.5 to 6.3.0
- [Release notes](https://github.com/electron-userland/electron-builder/releases)
- [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/CHANGELOG.md)
- [Commits](https://github.com/electron-userland/electron-builder/commits/electron-updater@6.3.0/packages/electron-updater)

---
updated-dependencies:
- dependency-name: electron-updater
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-30 10:19:42 +00:00
jeffvli fd893224b3 Bump electronVersion in build configuration (#686) 2024-07-30 03:17:32 -07:00
Hosted Weblate 8815246221 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (593 of 593 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-07-25 19:36:24 +00:00
Hosted Weblate 5e353649a4 Translated using Weblate (Korean)
Currently translated at 1.6% (10 of 593 strings)

Co-authored-by: 박용현 <pyh5523@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ko/
Translation: feishin/Translation
2024-07-25 19:36:24 +00:00
Jeff 4ca7b4221c Merge pull request #680 from dragonish/metadata
Fix full screen player metadata may not change when switching queue
2024-07-25 12:36:19 -07:00
dragonish 6c61ea898f Fix full screen player metadata may not change when switching queue 2024-07-26 01:52:40 +08:00
Kendall Garner 0b786b025f Merge pull request #676 from dragonish/lyrics
Fix synchronized lyrics that may become unaligned during playback after re-rendering
2024-07-23 04:09:11 +00:00
dragonish e106fb324f Fix synchronized lyrics that may become unaligned during playback after re-rendering 2024-07-22 21:33:01 +08:00
Jeff 3edc6bab04 Merge pull request #668 from yuygfgg/patch-1
fix blank screen when reopening window on macos
2024-07-21 04:16:17 -07:00
yuygfgg 0113ef2582 restore comments 2024-07-14 21:15:58 +08:00
yuygfgg 493b81875a fix blank screen when reopening window on macos 2024-07-14 20:46:04 +08:00
Jeff ed8e5e69ba Merge pull request #664 from sel10ut/bugfix/fix-minimize-setting
Fix minimize to tray setting toggle
2024-07-14 02:53:48 -07:00
jeffvli d27a656568 Bump electron to v31 (again) 2024-07-13 01:30:18 -07:00
sel10ut 582739a091 fix(settings): set proper default minimize to tray check 2024-07-04 13:29:47 +03:00
Jeff fb930f1197 Merge pull request #654 from minicoz/feat/docker-compose-readme
Adding in docker compose instructions to README
2024-07-03 16:30:45 -07:00
Jeff 849aa97e63 Merge pull request #663 from jeffvli/dependabot/npm_and_yarn/release/app/npm_and_yarn-99150a289a
Bump ws from 8.17.1 to 8.18.0 in /release/app in the npm_and_yarn group across 1 directory
2024-07-03 16:29:51 -07:00
dependabot[bot] bc5abe3ec1 Bump ws in /release/app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /release/app directory: [ws](https://github.com/websockets/ws).


Updates `ws` from 8.17.1 to 8.18.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.17.1...8.18.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-03 23:21:13 +00:00
Jeff 46cd783fa3 Merge pull request #662 from jeffvli/dependabot/npm_and_yarn/npm_and_yarn-8fcff2be61
Bump the npm_and_yarn group across 2 directories with 2 updates
2024-07-03 16:20:05 -07:00
jeffvli a93173f55a Bump to v0.7.2 2024-07-03 15:57:57 -07:00
jeffvli bd04168209 Add languages 2024-07-03 15:56:48 -07:00
dependabot[bot] 76ffdb6627 Bump the npm_and_yarn group across 2 directories with 2 updates
Bumps the npm_and_yarn group with 2 updates in the / directory: [ws](https://github.com/websockets/ws) and [braces](https://github.com/micromatch/braces).
Bumps the npm_and_yarn group with 1 update in the /release/app directory: [ws](https://github.com/websockets/ws).


Updates `ws` from 7.5.7 to 7.5.10
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.5.7...7.5.10)

Updates `braces` from 3.0.2 to 3.0.3
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

Updates `ws` from 8.13.0 to 8.17.1
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.5.7...7.5.10)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: braces
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: ws
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-03 22:31:09 +00:00
Hosted Weblate f674260df3 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.4% (588 of 591 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: SunSpring <yearnsun@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-07-03 22:29:28 +00:00
Hosted Weblate e9315886b7 Translated using Weblate (Korean)
Currently translated at 1.1% (7 of 591 strings)

Added translation using Weblate (Korean)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: hubag <pyosy17@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ko/
Translation: feishin/Translation
2024-07-03 22:29:28 +00:00
Hosted Weblate 4741dd0d77 Translated using Weblate (Finnish)
Currently translated at 19.6% (116 of 591 strings)

Added translation using Weblate (Finnish)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jonne Saloranta <saloranta.jonne@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fi/
Translation: feishin/Translation
2024-07-03 22:29:27 +00:00
Hosted Weblate 7a1c4f5082 Translated using Weblate (Spanish)
Currently translated at 100.0% (593 of 593 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (586 of 586 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2024-07-03 22:29:27 +00:00
Hosted Weblate abf339bb58 Translated using Weblate (Polish)
Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mistify <fabianszafranski@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translation: feishin/Translation
2024-07-03 22:29:26 +00:00
Hosted Weblate bb8f67c4c1 Translated using Weblate (Czech)
Currently translated at 100.0% (593 of 593 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2024-07-03 22:29:26 +00:00
Hosted Weblate e416c2a3b6 Translated using Weblate (Russian)
Currently translated at 97.2% (575 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 97.2% (575 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 95.0% (562 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 95.0% (562 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 70.7% (418 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 70.7% (418 of 591 strings)

Co-authored-by: Blueberry <igory.ygr200@gmail.com>
Co-authored-by: Eugeniy <zamelane@vk.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ShaDream <mogilnikovshadream@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/
Translation: feishin/Translation
2024-07-03 22:29:25 +00:00
Hosted Weblate 5af4344168 Translated using Weblate (English)
Currently translated at 100.0% (586 of 586 strings)

Co-authored-by: Benjamin <iipython@proton.me>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/en/
Translation: feishin/Translation
2024-07-03 22:29:25 +00:00
Hosted Weblate 55e958b5da Translated using Weblate (Portuguese (Brazil))
Currently translated at 34.3% (201 of 586 strings)

Co-authored-by: Cyber Hippie <neves.j@protonmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pt_BR/
Translation: feishin/Translation
2024-07-03 22:29:24 +00:00
Jeff 2a231ed7af Merge pull request #659 from kgarner7/fix-electron-31-build
[bugfix]: use tsx instead of ts-node, update @electron/rebuild
2024-07-03 15:29:17 -07:00
jeffvli a26f7feb31 Readd transpile-only on dev 2024-07-03 15:13:54 -07:00
jeffvli 90e267d9c7 Bump node version from 16 -> 18 in builders 2024-07-03 15:08:14 -07:00
jeffvli 1ab09c5488 Move tsx and electron/rebuild to devDependencies 2024-07-03 15:05:01 -07:00
jeffvli eadbf3ad7b Readd ts-node 2024-07-03 15:00:56 -07:00
Jeff 42bcc4190c Merge pull request #660 from sel10ut/bugfix/jellyfin-multiple-sessions
(Jellyfin) Allow multiple sessions from the same user with different instances
2024-07-03 14:49:41 -07:00
jeffvli 3c44db377b Revert "Bump electron to v31 (#643)"
This reverts commit 0c14427bdb.
2024-07-03 14:40:09 -07:00
sel10ut ba64f4c467 refactor(jellyfin): migrate auth method 2024-07-03 22:41:35 +03:00
sel10ut 596bf3a378 fix(jellyfin): allow multiple sessions from the same client type
Allow multiple sessions from the same user with different instances.
Instead of sending a hard-coded string, send a randomly generated
string `deviceId`, which already exists and is created for each
new installation.
2024-07-03 22:12:15 +03:00
Kendall Garner 91e6119afa use tsx instead of ts-node, update @electron/rebuild 2024-07-03 11:53:59 -07:00
mlnl b053538f94 Update README.md
removed unused volume
2024-07-03 18:51:46 +00:00
jeffvli 0c14427bdb Bump electron to v31 (#643) 2024-07-03 02:10:13 -07:00
Kendall Garner 110a1a63f0 simplify remote/media session (#632) 2024-07-03 01:47:26 -07:00
Kendall Garner d57b4b4b68 [bugfix]: properly clean up MPV on quit, use pid for socket (#627)
* fix cleanup

* don't delete file if windows
2024-07-03 01:36:01 -07:00
Benjamin 4191edb88c fix cache settings not being sentence cased (#657) 2024-07-03 01:29:53 -07:00
sel10ut b38930a277 tweak(jellyfin): fetch actual recent album releases (#629) 2024-07-03 01:29:34 -07:00
Gelaechter ea865f44b1 Allow jumping to lyrics (#656) 2024-07-03 01:24:31 -07:00
isaiahfuller 0768ce80a7 Add option to play similar tracks from the context menu (#650)
* Add option to play similar songs from context menu

* remove console.log
2024-07-03 01:17:56 -07:00
minicoz c960cc44b7 adding env options 2024-06-28 09:10:47 -07:00
minicoz a84276579b adding in docker compose instructions 2024-06-28 09:08:09 -07:00
Kendall Garner b30fadd149 navidrome album artist covoer art bodge 2024-05-29 02:53:41 -07:00
Kendall Garner aa89c5e80e [enhancement]: apply formatting to card values 2024-05-26 12:20:01 -07:00
Kendall Garner 38ed083693 [enhancement]: support using native image aspect ratio 2024-05-25 11:15:30 -07:00
Kendall Garner 961d1838c0 reopen window if exit to tray 2024-05-25 08:14:01 -07:00
Kendall Garner 67deb3e3d8 [bugfix]: only restart time when now for web 2024-05-23 20:41:01 -07:00
Kendall Garner 79384fa4ed add songCount to table localization 2024-05-17 23:17:31 -07:00
Kendall Garner bb2f8461ed [enhancement]: support toggling feature carousel 2024-05-15 21:48:20 -07:00
Kendall Garner 168153b211 [bugfix]: restart timestamp when adding to queue 2024-05-10 12:27:33 -07:00
Kendall Garner c5e8472746 [bugfix]: handle song grid sparse array 2024-05-09 00:14:59 -07:00
Kendall Garner a9e0689619 activate tray on single click 2024-05-08 19:10:46 -07:00
215 changed files with 15952 additions and 9009 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
node-version: 18
cache: npm
- name: Install dependencies
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
node-version: 18
cache: npm
- name: Install dependencies
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Install Node and NPM
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
cache: npm
- name: Install dependencies
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
node-version: 18
cache: npm
- name: Install dependencies
+26
View File
@@ -70,6 +70,30 @@ docker build -t feishin .
docker run --name feishin -p 9180:9180 feishin
```
#### Docker Compose
To install via Docker Compose use the following snippit. This also works on Portainer.
```
version: '3'
services:
feishin:
container_name: feishin
image: 'ghcr.io/jeffvli/feishin:latest'
environment:
- SERVER_NAME=jellyfin # pre defined server name
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
- SERVER_TYPE=jellyfin # navidrome also works
- SERVER_URL= # http://address:port
- PUID=1000
- PGID=1000
- UMASK=002
- TZ=America/Los_Angeles
ports:
- 9180:9180
restart: unless-stopped
```
### Configuration
1. Upon startup you will be greeted with a prompt to select the path to your MPV binary. If you do not have MPV installed, you can download it [here](https://mpv.io/installation/) or install it using any package manager supported by your OS. After inputting the path, restart the app.
@@ -107,6 +131,8 @@ chmod 4755 chrome-sandbox
sudo chown root:root chrome-sandbox
```
Ubunutu 24.04 specifically introduced breaking changes that affect how namespaces work. Please see https://discourse.ubuntu.com/t/ubuntu-24-04-lts-noble-numbat-release-notes/39890#:~:text=security%20improvements%20 for possible fixes.
## Development
Built and tested using Node `v16.15.0`.
+6469 -3959
View File
File diff suppressed because it is too large Load Diff
+22 -19
View File
@@ -2,7 +2,7 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.7.1",
"version": "0.10.1",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
@@ -14,12 +14,12 @@
"lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"",
"lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"lint:styles": "npx stylelint **/*.tsx --fix",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
"package": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:dev": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"postinstall": "node --import tsx .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"start": "node --import tsx ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"start:main": "cross-env NODE_ENV=development NODE_OPTIONS=\"--import tsx\" electron -r ts-node/register/transpile-only ./src/main/main.ts",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:remote": "cross-env NODE_ENV=developemnt TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
@@ -56,7 +56,7 @@
"package.json"
],
"afterSign": ".erb/scripts/notarize.js",
"electronVersion": "27.1.0",
"electronVersion": "31.2.0",
"mac": {
"target": {
"target": "default",
@@ -199,12 +199,13 @@
]
},
"devDependencies": {
"@electron/rebuild": "^3.2.10",
"@electron/rebuild": "^3.6.0",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
"@stylelint/postcss-css-in-js": "^0.38.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.0",
"@types/dompurify": "^3.0.5",
"@types/electron-localshortcut": "^3.1.0",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.188",
@@ -216,7 +217,6 @@
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/react-window-infinite-loader": "^1.0.6",
"@types/sanitize-html": "^2.11.0",
"@types/styled-components": "^5.1.26",
"@types/terser-webpack-plugin": "^5.0.4",
"@types/webpack-bundle-analyzer": "^4.4.1",
@@ -231,7 +231,7 @@
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^26.6.10",
"electron": "^31.2.0",
"electron-builder": "^24.13.3",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
@@ -253,7 +253,7 @@
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^7.0.4",
"i18next-parser": "^6.6.0",
"i18next-parser": "^9.0.2",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"lint-staged": "^12.3.7",
@@ -261,7 +261,7 @@
"postcss-scss": "^4.0.4",
"postcss-styled-syntax": "^0.5.0",
"postcss-syntax": "^0.36.2",
"prettier": "^2.6.2",
"prettier": "^3.3.3",
"react-refresh": "^0.12.0",
"react-refresh-typescript": "^2.0.4",
"react-test-renderer": "^18.0.0",
@@ -278,12 +278,13 @@
"terser-webpack-plugin": "^5.3.1",
"ts-jest": "^27.1.4",
"ts-loader": "^9.2.8",
"ts-node": "^10.7.0",
"ts-node": "^10.9.2",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"tsx": "^4.16.2",
"typescript": "^5.2.2",
"typescript-plugin-styled-components": "^3.0.0",
"url-loader": "^4.1.1",
"webpack": "^5.71.0",
"webpack": "^5.94.0",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.8.0",
@@ -308,15 +309,18 @@
"@tanstack/react-query-persist-client": "^4.32.1",
"@ts-rest/core": "^3.23.0",
"@xhayper/discord-rpc": "^1.0.24",
"audiomotion-analyzer": "^4.5.0",
"auto-text-size": "^0.2.3",
"axios": "^1.6.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"dayjs": "^1.11.6",
"dompurify": "^3.1.6",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
"electron-log": "^5.1.1",
"electron-store": "^8.1.0",
"electron-updater": "^4.6.5",
"electron-updater": "^6.3.1",
"fast-average-color": "^9.3.0",
"format-duration": "^2.0.0",
"framer-motion": "^11.0.0",
@@ -331,7 +335,7 @@
"memoize-one": "^6.0.0",
"nanoid": "^3.3.3",
"net": "^1.0.2",
"node-mpv": "github:jeffvli/Node-MPV",
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
"overlayscrollbars": "^2.2.1",
"overlayscrollbars-react": "^0.5.1",
"react": "^18.2.0",
@@ -346,7 +350,6 @@
"react-virtualized-auto-sizer": "^1.0.17",
"react-window": "^1.8.9",
"react-window-infinite-loader": "^1.0.9",
"sanitize-html": "^2.13.0",
"semver": "^7.5.4",
"styled-components": "^6.0.8",
"swiper": "^9.3.1",
@@ -357,7 +360,7 @@
"styled-components": "^6"
},
"devEngines": {
"node": ">=14.x",
"node": ">=18.x",
"npm": ">=7.x"
},
"browserslist": [],
+44 -27
View File
@@ -1,21 +1,21 @@
{
"name": "feishin",
"version": "0.7.1",
"version": "0.10.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.7.1",
"version": "0.10.1",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
"cheerio": "^1.0.0-rc.12",
"mpris-service": "^2.1.2",
"ws": "^8.13.0"
"ws": "^8.18.0"
},
"devDependencies": {
"electron": "25.8.4"
"electron": "31.1.0"
}
},
"node_modules/@electron/get": {
@@ -99,10 +99,13 @@
}
},
"node_modules/@types/node": {
"version": "18.16.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
"dev": true
"version": "20.14.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz",
"integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/responselike": {
"version": "1.0.0",
@@ -453,14 +456,14 @@
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
},
"node_modules/electron": {
"version": "25.8.4",
"resolved": "https://registry.npmjs.org/electron/-/electron-25.8.4.tgz",
"integrity": "sha512-hUYS3RGdaa6E1UWnzeGnsdsBYOggwMMg4WGxNGvAoWtmRrr6J1BsjFW/yRq4WsJHJce2HdzQXtz4OGXV6yUCLg==",
"version": "31.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-31.1.0.tgz",
"integrity": "sha512-TBOwqLxSxnx6+pH6GMri7R3JPH2AkuGJHfWZS0p1HsmN+Qr1T9b0IRJnnehSd/3NZAmAre4ft9Ljec7zjyKFJA==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^18.11.18",
"@types/node": "^20.9.0",
"extract-zip": "^2.0.1"
},
"bin": {
@@ -1270,6 +1273,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
@@ -1286,9 +1295,9 @@
"dev": true
},
"node_modules/ws": {
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"engines": {
"node": ">=10.0.0"
},
@@ -1408,10 +1417,13 @@
}
},
"@types/node": {
"version": "18.16.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
"dev": true
"version": "20.14.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz",
"integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==",
"dev": true,
"requires": {
"undici-types": "~5.26.4"
}
},
"@types/responselike": {
"version": "1.0.0",
@@ -1672,13 +1684,13 @@
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
},
"electron": {
"version": "25.8.4",
"resolved": "https://registry.npmjs.org/electron/-/electron-25.8.4.tgz",
"integrity": "sha512-hUYS3RGdaa6E1UWnzeGnsdsBYOggwMMg4WGxNGvAoWtmRrr6J1BsjFW/yRq4WsJHJce2HdzQXtz4OGXV6yUCLg==",
"version": "31.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-31.1.0.tgz",
"integrity": "sha512-TBOwqLxSxnx6+pH6GMri7R3JPH2AkuGJHfWZS0p1HsmN+Qr1T9b0IRJnnehSd/3NZAmAre4ft9Ljec7zjyKFJA==",
"dev": true,
"requires": {
"@electron/get": "^2.0.0",
"@types/node": "^18.11.18",
"@types/node": "^20.9.0",
"extract-zip": "^2.0.1"
}
},
@@ -2278,6 +2290,12 @@
"dev": true,
"optional": true
},
"undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
},
"universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
@@ -2291,10 +2309,9 @@
"dev": true
},
"ws": {
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
"requires": {}
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="
},
"xml2js": {
"version": "0.4.23",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.7.1",
"version": "0.10.1",
"description": "",
"main": "./dist/main/main.js",
"author": {
@@ -15,10 +15,10 @@
"dependencies": {
"cheerio": "^1.0.0-rc.12",
"mpris-service": "^2.1.2",
"ws": "^8.13.0"
"ws": "^8.18.0"
},
"devDependencies": {
"electron": "25.8.4"
"electron": "31.1.0"
},
"license": "GPL-3.0"
}
+6
View File
@@ -18,6 +18,7 @@ import nbNO from './locales/nb-NO.json';
import nl from './locales/nl.json';
import zhHant from './locales/zh-Hant.json';
import fa from './locales/fa.json';
import ko from './locales/ko.json';
const resources = {
en: { translation: en },
@@ -29,6 +30,7 @@ const resources = {
fa: { translation: fa },
fr: { translation: fr },
ja: { translation: ja },
ko: { translation: ko },
pl: { translation: pl },
'zh-Hans': { translation: zhHans },
'zh-Hant': { translation: zhHant },
@@ -68,6 +70,10 @@ export const languages = [
label: '日本語',
value: 'ja',
},
{
label: '한국어',
value: 'ko',
},
{
label: 'Nederlands',
value: 'nl',
+3 -3
View File
@@ -5,7 +5,9 @@ module.exports = {
createOldCatalogs: true,
customValueTemplate: null,
defaultNamespace: 'translation',
defaultValue: '',
defaultValue: function (locale, namespace, key, value) {
return key;
},
failOnUpdate: false,
failOnWarnings: false,
i18nextOptions: null,
@@ -37,8 +39,6 @@ module.exports = {
output: 'src/renderer/i18n/locales/$LOCALE.json',
pluralSeparator: '_',
resetDefaultValueLocale: 'en',
skipDefaultValues: false,
sort: true,
useKeysAsDefaultValue: true,
verbose: false,
};
+91 -16
View File
@@ -11,7 +11,7 @@
"skip_back": "přeskočit dozadu",
"favorite": "oblíbené",
"next": "další",
"shuffle": "náhodně",
"shuffle": "přehrát náhodně",
"playbackFetchNoResults": "nenalezeny žádné skladby",
"playbackFetchInProgress": "načítání skladeb…",
"addNext": "přidat další",
@@ -28,7 +28,9 @@
"shuffle_off": "náhodně zakázáno",
"addLast": "přidat poslední",
"mute": "ztlumit",
"skip_forward": "přeskočit dopředu"
"skip_forward": "přeskočit dopředu",
"playSimilarSongs": "přehrát podobné skladby",
"viewQueue": "zobrazit frontu"
},
"setting": {
"crossfadeStyle_description": "vyberte způsob prolnutí u přehrávače zvuku",
@@ -210,7 +212,50 @@
"clearCacheSuccess": "mezipaměť úspěšně vymazána",
"externalLinks": "zobrazit externí odkazy",
"startMinimized_description": "spustit aplikaci do systémové lišty",
"passwordStore_description": "který způsob ukládání hesel / tajných klíčů použít. změňte tuto možnost, pokud máte problémy s ukládáním hesel."
"passwordStore_description": "který způsob ukládání hesel / tajných klíčů použít. změňte tuto možnost, pokud máte problémy s ukládáním hesel.",
"homeFeature": "carousel doporučení na domovské stránce",
"homeFeature_description": "ovládá, zda se má zobrazovat velký carousel s doporučenými alby na domovské stránce",
"imageAspectRatio": "použít nativní poměr stran obalů alb",
"imageAspectRatio_description": "pokud je povoleno, budou obaly alb zobrazeny s jejich nativním poměrem stran. u obalů, které nemají poměr 1:1, bude zbývající místo prázdné",
"doubleClickBehavior": "dvojitým kliknutím zařadit všechny vyhledané skladby do fronty",
"doubleClickBehavior_description": "pokud je zapnuto, budou všechny odpovídající skladby ve vyhledávání zařazeny do fronty. v opačném případě bude zařazena pouze ta, na kterou kliknete",
"volumeWidth": "šířka posuvníku hlasitosti",
"volumeWidth_description": "horizontální velikost posuvníku hlasitosti",
"discordListening": "zobrazit stav jako „Poslouchá“",
"discordListening_description": "zobrazit stav jako „Poslouchá“ namísto „Hraje“. tato funkce v současné době není kompatibilní s lištou s časem",
"contextMenu": "nastavení kontextové nabídky (kliknutí pravým)",
"contextMenu_description": "umožňuje skrýt položky, které se zobrazí v nabídce po kliknutí pravým tlačítkem myši na položku. položky, které nejsou zaškrtnuté, se skryjí",
"customCssEnable": "povolit vlastní CSS",
"customCssEnable_description": "povolit vlastní CSS.",
"customCssNotice": "Varování: i když provádíme určitou sanitizaci (zakázáním url() a content:), může používání CSS stále představovat riziko změnami rozhraní.",
"customCss_description": "vlastní CSS obsah. Upozornění: vlastnosti content a vzdálené url jsou zakázané. Níže je zobrazen náhled vašeho obsahu. Další pole, která jste nenastavili, jsou přítomna z důvodu sanitizace.",
"customCss": "vlastní CSS",
"webAudio": "použít webový zvuk",
"webAudio_description": "použít webový zvuk. tím povolíte pokročilé funkce jako replaygain. zakažte, pokud se objeví problémy",
"transcodeNote": "projeví se po 1 (web) - 2 (mpv) skladbách",
"transcode": "povolit překódování",
"transcode_description": "zapnout překódování do různých formátů",
"transcodeFormat_description": "vybere formát k překódování. pokud chcete nechat rozhodnout server, ponechte prázdné",
"transcodeFormat": "formát k překódování",
"transcodeBitrate": "datový tok k překódování",
"transcodeBitrate_description": "vybere datový tok k překódování. 0 znamená, že necháte server vybrat",
"albumBackground": "obrázek alba na pozadí",
"albumBackground_description": "přidá obrázek alba na pozadí pro stránky alba obsahující obrázky alba",
"albumBackgroundBlur": "velikost rozostření obrázku alba na pozadí",
"albumBackgroundBlur_description": "upraví množství rozostření použité na obrázek alba na pozadí",
"playerbarOpenDrawer": "lišta přehrávače jako přepínač celé obrazovky",
"playerbarOpenDrawer_description": "umožňuje kliknutí na lištu přehrávače pro otevření celoobrazovkového přehrávače",
"artistConfiguration": "nastavení stránky umělce alba",
"artistConfiguration_description": "nastavit, které položky na stránce umělce alba budou zobrazeny a v jakém pořadí",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"trayEnabled": "zobrazit v oznamovací oblasti",
"trayEnabled_description": "zobrazit/skrýt ikonu/nabídku v oznamovací oblasti. pokud je zakázáno, vypne také minimalizaci/ukončení do oznamovací oblasti",
"translationApiProvider": "poskytovatel api překladů",
"translationApiProvider_description": "poskytovatel api pro překlady",
"translationApiKey": "klíč api překladů",
"translationApiKey_description": "klíč api pro překlady (podporuje pouze koncový bod globální služby)",
"translationTargetLanguage": "cílový jazyk překladu",
"translationTargetLanguage_description": "cílový jazyk pro překlad"
},
"action": {
"editPlaylist": "upravit $t(entity.playlist_one)",
@@ -326,7 +371,8 @@
"share": "sdílet",
"codec": "kodek",
"trackPeak": "vrchol skladby",
"preview": "náhled"
"preview": "náhled",
"translation": "překlad"
},
"table": {
"config": {
@@ -342,7 +388,8 @@
"autoFitColumns": "automaticky přizpůsobit sloupce",
"size": "$t(common.size)",
"itemGap": "mezera mezi položkami (px)",
"itemSize": "velikost položek (px)"
"itemSize": "velikost položek (px)",
"followCurrentSong": "následovat aktuální skladbu"
},
"label": {
"releaseDate": "datum vydání",
@@ -371,7 +418,8 @@
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist_one)",
"codec": "$t(common.codec)"
"codec": "$t(common.codec)",
"songCount": "$t(entity.track_other)"
}
},
"column": {
@@ -498,11 +546,14 @@
"useImageAspectRatio": "použít poměr stran obrázku",
"lyricGap": "mezera textů",
"dynamicImageBlur": "velikost rozostření obrázku",
"dynamicIsImage": "povolit obrázek na pozadí"
"dynamicIsImage": "povolit obrázek na pozadí",
"lyricOffset": "posunutí textů (ms)"
},
"upNext": "další",
"lyrics": "texty",
"related": "související"
"related": "související",
"visualizer": "vizualizér",
"noLyrics": "nenalezeny žádné texty"
},
"appMenu": {
"selectServer": "vybrat server",
@@ -534,7 +585,10 @@
"numberSelected": "vybráno {{count}}",
"removeFromQueue": "$t(action.removeFromQueue)",
"showDetails": "získat informace",
"shareItem": "sdílet položku"
"shareItem": "sdílet položku",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"download": "stáhnout",
"playShuffled": "$t(player.shuffle)"
},
"home": {
"mostPlayed": "nejpřehrávanější",
@@ -545,13 +599,15 @@
},
"albumDetail": {
"moreFromArtist": "více od tohoto umělce",
"moreFromGeneric": "více od {{item}}"
"moreFromGeneric": "více od {{item}}",
"released": "vydáno"
},
"setting": {
"playbackTab": "přehrávání",
"generalTab": "obecné",
"hotkeysTab": "klávesové zkratky",
"windowTab": "okno"
"windowTab": "okno",
"advanced": "pokročilé"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
@@ -563,7 +619,7 @@
},
"trackList": {
"title": "$t(entity.track_other)",
"artistTracks": "Skladby od umělce {{artist}}",
"artistTracks": "skladby od umělce {{artist}}",
"genreTracks": "$t(entity.track_other) s žánrem „{{genre}}“"
},
"globalSearch": {
@@ -579,7 +635,7 @@
},
"albumList": {
"title": "$t(entity.album_other)",
"artistAlbums": "Alba od umělce {{artist}}",
"artistAlbums": "alba od umělce {{artist}}",
"genreAlbums": "$t(entity.album_other) s žánrem „{{genre}}“"
},
"albumArtistDetail": {
@@ -588,7 +644,7 @@
"about": "O umělci {{artist}}",
"appearsOn": "také v",
"topSongs": "nejlepší skladby",
"topSongsFrom": "Nejlepší skladby od umělce {{title}}",
"topSongsFrom": "nejlepší skladby od umělce {{title}}",
"relatedArtists": "podobní $t(entity.artist_other)",
"viewAllTracks": "zobrazit všechny $t(entity.track_other)",
"viewAll": "zobrazit vše"
@@ -597,6 +653,17 @@
"copiedPath": "cesta úspěšně zkopírována",
"copyPath": "kopírovat cestu do schránky",
"openFile": "zobrazit skladbu ve správci souborů"
},
"playlist": {
"reorder": "změna pořadí povolena pouze při řazení podle id"
},
"manageServers": {
"url": "URL",
"username": "uživatelské jméno",
"editServerDetailsTooltip": "upravit podrobnosti o serveru",
"removeServer": "odstranit server",
"serverDetails": "podrobnosti o serveru",
"title": "správa serverů"
}
},
"form": {
@@ -646,7 +713,9 @@
"title": "Hledat texty"
},
"editPlaylist": {
"title": "upravit $t(entity.playlist_one)"
"title": "upravit $t(entity.playlist_one)",
"success": "$t(entity.playlist_one) úspěšně aktualizován",
"publicJellyfinNote": "Jellyfin z nějakého důvodu neukazuje, zda je seznam skladeb veřejný, nebo ne. Pokud si přejete, aby zůstal veřejný, zvolte prosím následující vstup"
},
"shareItem": {
"allowDownloading": "umožnit stahování",
@@ -703,6 +772,12 @@
"genreWithCount_other": "{{count}} žánrů",
"trackWithCount_one": "{{count}} skladba",
"trackWithCount_few": "{{count}} skladby",
"trackWithCount_other": "{{count}} skladeb"
"trackWithCount_other": "{{count}} skladeb",
"play_one": "{{count}} přehrání",
"play_few": "{{count}} přehrání",
"play_other": "{{count}} přehrání",
"song_one": " ",
"song_few": " ",
"song_other": " "
}
}
+33 -6
View File
@@ -16,7 +16,11 @@
"removeFromQueue": "Von Warteschlange entfernen",
"setRating": "Bewertung festlegen",
"toggleSmartPlaylistEditor": "Editor $t(entity.smartPlaylist) umschalten",
"removeFromFavorites": "Entfernen von $t(entity.favorite_other)"
"removeFromFavorites": "Entfernen von $t(entity.favorite_other)",
"openIn": {
"lastfm": "In Last.fm öffnen",
"musicbrainz": "In MusicBrainz öffnen"
}
},
"common": {
"backward": "rückwärts",
@@ -62,7 +66,7 @@
"cancel": "Abbrechen",
"forceRestartRequired": "Neustarten um die Änderungen zu übernehmen... Schließe die Benachrichtigung zum Neustarten",
"setting": "Einstellungen",
"setting_one": "",
"setting_one": "Einstellung",
"setting_other": "Einstellungen",
"version": "Version",
"title": "Titel",
@@ -98,7 +102,12 @@
"random": "zufällig",
"size": "Größe",
"biography": "Biografie",
"note": "Hinweis"
"note": "Hinweis",
"preview": "Vorschau",
"reload": "Neu Laden",
"mbid": "MusicBrainz ID",
"close": "schliessen",
"share": "Teilen"
},
"error": {
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
@@ -119,7 +128,10 @@
"mpvRequired": "MPV benötigt",
"audioDeviceFetchError": "Beim Versuch, Audiogeräte abzurufen, ist ein Fehler aufgetreten",
"invalidServer": "Ungültiger Server",
"loginRateError": "Zu viele Anmeldeversuche, bitte versuche es in einigen Sekunden erneut"
"loginRateError": "Zu viele Anmeldeversuche, bitte versuche es in einigen Sekunden erneut",
"badAlbum": "Sie sehen diese Seite, weil dieses Lied nicht Teil eines Albums ist. Wahrscheinlich sehen Sie dieses Problem, wenn Sie einen Song in Ihrem Musikordner auf oberster Ebene haben. Jellyfin gruppiert nur Songs, wenn sie sich in einem Ordner befinden.",
"networkError": "ein Netzwerkfehler ist aufgetreten",
"openError": "datei kann nicht geöffnet werden"
},
"filter": {
"mostPlayed": "Meistgespielt",
@@ -207,12 +219,19 @@
"input_optionMatchAny": "Treffer Einige"
},
"editPlaylist": {
"title": "Bearbeite $t(entity.playlist_one)"
"title": "Bearbeite $t(entity.playlist_one)",
"success": "$t(entity.playlist_one) erfolgreich aktualisiert"
},
"lyricSearch": {
"title": "Songtext Suche",
"input_name": "$t(common.name)",
"input_artist": "$t(entity.artist_one)"
},
"shareItem": {
"description": "Beschreibung",
"setExpiration": "Ablaufdatum setzen",
"expireInvalid": "Ablaufdatum muss in der Zukunft liegen",
"allowDownloading": "Herunterladen zulassen"
}
},
"entity": {
@@ -246,7 +265,9 @@
"genreWithCount_other": "{{count}} Genres",
"trackWithCount_one": "{{count}} Track",
"trackWithCount_other": "{{count}} Tracks",
"smartPlaylist": "Smart $t(entity.playlist_one)"
"smartPlaylist": "Smart $t(entity.playlist_one)",
"play_one": "{{count}} Wiedergabe",
"play_other": "{{count}} Wiedergaben"
},
"table": {
"config": {
@@ -415,6 +436,12 @@
},
"albumList": {
"title": "$t(entity.album_other)"
},
"albumArtistDetail": {
"about": "Über {{artist}}",
"appearsOn": "erscheint auf",
"recentReleases": "Kürzliche Veröffentlichungen",
"viewDiscography": "Diskographie ansehen"
}
},
"player": {
+80 -7
View File
@@ -109,6 +109,7 @@
"trackNumber": "track",
"trackGain": "track gain",
"trackPeak": "track peak",
"translation": "translation",
"unknown": "unknown",
"version": "version",
"year": "year",
@@ -139,11 +140,15 @@
"genreWithCount_other": "{{count}} genres",
"playlist_one": "playlist",
"playlist_other": "playlists",
"play_one": "{{count}} play",
"play_other": "{{count}} plays",
"playlistWithCount_one": "{{count}} playlist",
"playlistWithCount_other": "{{count}} playlists",
"smartPlaylist": "smart $t(entity.playlist_one)",
"track_one": "track",
"track_other": "tracks",
"song_one": "",
"song_other": "",
"trackWithCount_one": "{{count}} track",
"trackWithCount_other": "{{count}} tracks"
},
@@ -249,6 +254,8 @@
"title": "delete $t(entity.playlist_one)"
},
"editPlaylist": {
"publicJellyfinNote": "Jellyfin for some reason does not expose whether a playlist is public or not. If you wish for this to remain public, please have the following input selected",
"success": "$t(entity.playlist_one) updated successfully",
"title": "edit $t(entity.playlist_one)"
},
"lyricSearch": {
@@ -281,7 +288,7 @@
"viewDiscography": "view discography",
"relatedArtists": "related $t(entity.artist_other)",
"topSongs": "top songs",
"topSongsFrom": "Top songs from {{title}}",
"topSongsFrom": "top songs from {{title}}",
"viewAll": "view all",
"viewAllTracks": "view all $t(entity.track_other)"
},
@@ -290,10 +297,11 @@
},
"albumDetail": {
"moreFromArtist": "more from this $t(entity.artist_one)",
"moreFromGeneric": "more from {{item}}"
"moreFromGeneric": "more from {{item}}",
"released": "released"
},
"albumList": {
"artistAlbums": "Albums by {{artist}}",
"artistAlbums": "albums by {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
"title": "$t(entity.album_other)"
},
@@ -309,6 +317,14 @@
"settings": "$t(common.setting_other)",
"version": "version {{version}}"
},
"manageServers": {
"title": "manage servers",
"serverDetails": "server details",
"url": "URL",
"username": "username",
"editServerDetailsTooltip": "edit server details",
"removeServer": "remove server"
},
"contextMenu": {
"addFavorite": "$t(action.addToFavorites)",
"addLast": "$t(player.addLast)",
@@ -318,14 +334,17 @@
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"download": "download",
"moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)",
"numberSelected": "{{count}} selected",
"play": "$t(player.play)",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)",
"playShuffled": "$t(player.shuffle)",
"shareItem": "share item",
"showDetails": "get info"
},
@@ -336,6 +355,7 @@
"dynamicIsImage": "enable background image",
"followCurrentLyric": "follow current lyric",
"lyricAlignment": "lyric alignment",
"lyricOffset": "lyrics offset (ms)",
"lyricGap": "lyric gap",
"lyricSize": "lyric size",
"opacity": "opacity",
@@ -347,7 +367,9 @@
},
"lyrics": "lyrics",
"related": "related",
"upNext": "up next"
"upNext": "up next",
"visualizer": "visualizer",
"noLyrics": "no lyrics found"
},
"genreList": {
"showAlbums": "show $t(entity.genre_one) $t(entity.album_other)",
@@ -374,10 +396,14 @@
"copiedPath": "path copied successfully",
"openFile": "show track in file manager"
},
"playlist": {
"reorder": "reordering only enabled when sorting by id"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"setting": {
"advanced": "advanced",
"generalTab": "general",
"hotkeysTab": "hotkeys",
"playbackTab": "playback",
@@ -398,7 +424,7 @@
"tracks": "$t(entity.track_other)"
},
"trackList": {
"artistTracks": "Tracks by {{artist}}",
"artistTracks": "tracks by {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
"title": "$t(entity.track_other)"
}
@@ -416,6 +442,7 @@
"playbackFetchNoResults": "no songs found",
"playbackSpeed": "playback speed",
"playRandom": "play random",
"playSimilarSongs": "play similar songs",
"previous": "previous",
"queue_clear": "clear queue",
"queue_moveToBottom": "move selected to top",
@@ -426,7 +453,7 @@
"repeat_off": "repeat disabled",
"repeat_one": "repeat one",
"repeat_other": "",
"shuffle": "shuffle",
"shuffle": "play shuffled",
"shuffle_off": "shuffle disabled",
"skip": "skip",
"skip_back": "skip backwards",
@@ -434,13 +461,20 @@
"stop": "stop",
"toggleFullscreenPlayer": "toggle fullscreen player",
"unfavorite": "unfavorite",
"pause": "pause"
"pause": "pause",
"viewQueue": "view queue"
},
"setting": {
"accentColor": "accent color",
"accentColor_description": "sets the accent color for the application",
"albumBackground": "album background image",
"albumBackground_description": "adds a background image for album pages containing the album art",
"albumBackgroundBlur": "album background image blur size",
"albumBackgroundBlur_description": "adjusts the amount of blur applied to the album background image",
"applicationHotkeys": "application hotkeys",
"applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)",
"artistConfiguration": "album artist page configuration",
"artistConfiguration_description": "configure what items are shown, and in what order, on the album artist page",
"audioDevice": "audio device",
"audioDevice_description": "select the audio device to use for playback (web player only)",
"audioExclusiveMode": "audio exclusive mode",
@@ -454,10 +488,17 @@
"clearQueryCache": "clear feishin cache",
"clearQueryCache_description": "a 'soft clear' of feishin. this will refresh playlists, track metadata, and reset saved lyrics. settings, server credentials and cached images are preserved",
"clearCacheSuccess": "cache cleared successfully",
"contextMenu": "context menu (right click) configuration",
"contextMenu_description": "allows you to hide items that are shown in the menu when you right click on an item. items that are unchecked will be hidden",
"crossfadeDuration": "crossfade duration",
"crossfadeDuration_description": "sets the duration of the crossfade effect",
"crossfadeStyle": "crossfade style",
"crossfadeStyle_description": "select the crossfade style to use for the audio player",
"customCssEnable": "enable custom css",
"customCssEnable_description": "allow for writing custom css.",
"customCssNotice": "Warning: while there is some sanitization (disallowing url() and content:), using custom CSS can still pose risks by changing the interface.",
"customCss": "custom css",
"customCss_description": "custom css content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization.",
"customFontPath": "custom font path",
"customFontPath_description": "sets the path to the custom font to use for the application",
"disableAutomaticUpdates": "disable automatic updates",
@@ -466,10 +507,14 @@
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})",
"discordIdleStatus": "show rich presence idle status",
"discordIdleStatus_description": "when enabled, update status while player is idle",
"discordListening": "show status as listening",
"discordListening_description": "show status as listening instead of playing. note that this currently breaks timer bar",
"discordRichPresence": "{{discord}} rich presence",
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}} ",
"discordUpdateInterval": "{{discord}} rich presence update interval",
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
"doubleClickBehavior": "queue all searched tracks when double clicking",
"doubleClickBehavior_description": "if true, all matching tracks in a track search will be queued. otherwise, only the clicked one will be queued",
"enableRemote": "enable remote control server",
"enableRemote_description": "enables the remote control server to allow other devices to control the application",
"externalLinks": "show external links",
@@ -496,6 +541,8 @@
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
"homeConfiguration": "home page configuration",
"homeConfiguration_description": "configure what items are shown, and in what order, on the home page",
"homeFeature": "home featured carousel",
"homeFeature_description": "controls whether to show the large featured carousel on the home page",
"hotkey_browserBack": "browser back",
"hotkey_browserForward": "browser forward",
"hotkey_favoriteCurrentSong": "favorite $t(common.currentSong)",
@@ -529,6 +576,8 @@
"hotkey_volumeUp": "volume up",
"hotkey_zoomIn": "zoom in",
"hotkey_zoomOut": "zoom out",
"imageAspectRatio": "use native cover art aspect ratio",
"imageAspectRatio_description": "if enabled, cover art will be shown using their native aspect ratio. for art that is not 1:1, the remaining space will be empty",
"language": "language",
"language_description": "sets the language for the application ($t(common.restartRequired))",
"lyricFetch": "fetch lyrics from the internet",
@@ -558,8 +607,11 @@
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"playerAlbumArtResolution": "player album art resolution",
"playerAlbumArtResolution_description": "the resolution for the large player's album art preview. larger makes it look more crisp, but may slow loading down. defaults to 0, meaning auto",
"playerbarOpenDrawer": "playerbar fullscreen toggle",
"playerbarOpenDrawer_description": "allows clicking of the playerbar to open the full screen player",
"remotePassword": "remote control server password",
"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",
"remotePort": "remote control server port",
@@ -609,10 +661,29 @@
"themeDark_description": "sets the dark theme to use for the application",
"themeLight": "theme (light)",
"themeLight_description": "sets the light theme to use for the application",
"transcodeNote": "takes effect after 1 (web) - 2 (mpv) songs",
"transcode": "enable transcoding",
"transcode_description": "enables transcoding to different formats",
"transcodeBitrate": "bitrate to transcode",
"transcodeBitrate_description": "selects the bitrate to transcode. 0 means let the server pick",
"transcodeFormat": "format to transcode",
"transcodeFormat_description": "selects the format to transcode. leave empty to let the server decide",
"translationApiProvider": "translation api provider",
"translationApiProvider_description": "api provider for translation",
"translationApiKey": "translation api key",
"translationApiKey_description": "api key for translation (Support global service endpoint only)",
"translationTargetLanguage": "translation target language",
"translationTargetLanguage_description": "target language for translation",
"trayEnabled": "show tray",
"trayEnabled_description": "show/hide tray icon/menu. if disabled, also disables minimize/exit to tray",
"useSystemTheme": "use system theme",
"useSystemTheme_description": "follow the system-defined light or dark preference",
"volumeWheelStep": "volume wheel step",
"volumeWheelStep_description": "the amount of volume to change when scrolling the mouse wheel on the volume slider",
"volumeWidth": "volume slider width",
"volumeWidth_description": "the width of the volume slider",
"webAudio": "use web audio",
"webAudio_description": "use web audio. this enables advanced features like replaygain. disable if you experience otherwise",
"windowBarStyle": "window bar style",
"windowBarStyle_description": "select the style of the window bar",
"zoom": "zoom percentage",
@@ -648,6 +719,7 @@
"config": {
"general": {
"autoFitColumns": "auto fit columns",
"followCurrentSong": "follow current song",
"displayType": "display type",
"gap": "$t(common.gap)",
"itemGap": "item gap (px)",
@@ -679,6 +751,7 @@
"releaseDate": "release date",
"rowIndex": "row index",
"size": "$t(common.size)",
"songCount": "$t(entity.track_other)",
"title": "$t(common.title)",
"titleCombined": "$t(common.title) (combined)",
"trackNumber": "track number",
+100 -28
View File
@@ -8,10 +8,10 @@
"skip": "saltar",
"previous": "anterior",
"toggleFullscreenPlayer": "activar el reproductor a pantalla completa",
"skip_back": "saltar hacia atrás",
"skip_back": "retroceder",
"favorite": "favorito",
"next": "siguiente",
"shuffle": "mezclar",
"shuffle": "Reproducir aleatoriamente",
"playbackFetchNoResults": "ninguna canción encontrada",
"playbackFetchInProgress": "cargando canciones…",
"addNext": "añadir siguiente",
@@ -28,12 +28,14 @@
"addLast": "añadir último",
"mute": "silencio",
"skip_forward": "saltar hacia delante",
"pause": "pausa"
"pause": "pausa",
"playSimilarSongs": "Reproducir canciones similares",
"viewQueue": "ver cola"
},
"setting": {
"crossfadeStyle_description": "selecciona el estilo de crossfade a usar por el reproductor de audio",
"remotePort_description": "establece el puerto para el control remoto del servidor",
"hotkey_skipBackward": "saltar hacia atrás",
"hotkey_skipBackward": "retroceder",
"replayGainMode_description": "ajusta el volumen de ganancia acorde a los valores de {{ReplayGain}} almacenados en los metadatos del archivo",
"audioDevice_description": "selecciona el dispositivo de audio para usar en la reproducción (solo reproductor web)",
"theme_description": "establece el tema a usar por la aplicación",
@@ -159,7 +161,7 @@
"audioPlayer": "reproductor de audio",
"hotkey_zoomOut": "reducir",
"hotkey_unfavoriteCurrentSong": "$t(common.currentSong) no favorito",
"hotkey_rate0": "limpiar calificación",
"hotkey_rate0": "Limpiar calificación",
"discordApplicationId": "id de aplicación {{discord}}",
"applicationHotkeys_description": "configura las teclas de acceso rápido de la aplicación. marca la casilla para establecerlas como teclas de acceso rápido globales (solo escritorio)",
"floatingQueueArea_description": "muestra un icono flotante en el lado derecho de la pantalla para ver la cola de reproducción",
@@ -191,11 +193,11 @@
"skipPlaylistPage": "saltar página de lista de reproducción",
"hotkey_browserForward": "avance",
"hotkey_browserBack": "retroceso",
"clearCache": "limpiar la caché del navegador",
"clearQueryCache": "limpiar la caché de feishin",
"clearQueryCache_description": "una 'limpieza suave' de feishin. esto refrescará las listas de reproducción, metadatos de pistas y restablece las letras guardadas. se mantienen los ajustes, credenciales del servidor y las imágenes en caché",
"clearCache": "Limpiar la caché del navegador",
"clearQueryCache": "Limpiar la caché de Feishin",
"clearQueryCache_description": "Una 'limpieza suave' de Feishin. Esto refrescará las listas de reproducción, metadatos de pistas y restablece las letras guardadas. Se mantienen los ajustes, credenciales del servidor y las imágenes en caché",
"buttonSize": "tamaño del botón de la barra de reproducción",
"clearCache_description": "una 'limpieza fuerte' de feishin. para limpiar la caché de feishin, vacía la caché del navegador (imágenes guardadas y otros elementos). se mantienen las credenciales y ajustes del servidor",
"clearCache_description": "Una 'limpieza fuerte' de Feishin. Para limpiar la caché de Feishin, vacía la caché del navegador (imágenes guardadas y otros elementos). Se mantienen las credenciales y ajustes del servidor",
"buttonSize_description": "el tamaño de los botones de la barra de reproducción",
"passwordStore_description": "qué método de almacenamiento de contraseñas/claves secretas utilizar. cambie esta opción si tiene problemas para guardar contraseñas.",
"startMinimized_description": "iniciar la aplicación en la bandeja del sistema",
@@ -210,7 +212,50 @@
"genreBehavior_description": "Determina si al pulsar en un género se abre por defecto la lista de pistas o de álbumes",
"homeConfiguration_description": "Configura qué elementos son mostrados y en qué orden en la página de inicio",
"clearCacheSuccess": "Caché limpiada correctamente",
"externalLinks": "Mostrar enlaces externos"
"externalLinks": "Mostrar enlaces externos",
"homeFeature": "Carrusel destacado de inicio",
"homeFeature_description": "Controla si se muestra el gran carrusel destacado en la página de inicio",
"imageAspectRatio_description": "Si está habilitado, la portada será mostrada usando su relación de aspecto nativa. Para arte que no es 1:1, el espacio restante estará vacío",
"imageAspectRatio": "Usar relación de aspecto nativa de portada",
"doubleClickBehavior": "poner en cola todas las pistas buscadas al hacer doble clic",
"doubleClickBehavior_description": "si es true, se pondrán en cola todas las pistas que coincidan en una búsqueda de pistas. De lo contrario, solo se pondrá en cola la pista seleccionada",
"volumeWidth": "Ancho del deslizador de volumen",
"volumeWidth_description": "La anchura del deslizador de volumen",
"discordListening_description": "Muestra el estado como escuchando en lugar de reproduciendo. Ten en cuenta que esto actualmente rompe la barra de tiempo",
"discordListening": "Mostrar estado como escuchando",
"contextMenu": "Configuración del menú de contexto (clic derecho)",
"contextMenu_description": "Te permite esconder elementos que son mostrados en el menú cuando haces clic derecho en un elemento. Los elementos que no estén seleccionados serán escondidos",
"customCssEnable": "Habilitar CSS personalizado",
"customCssEnable_description": "Permite la escritura de CSS personalizado.",
"customCss": "CSS personalizado",
"customCssNotice": "Aviso: mientras hay alguna sanitización (rechazar url() y content:), usar CSS personalizado puede aún entrañar riesgos cambiando la interfaz.",
"customCss_description": "Content CSS personalizado. Nota: content y urls remotas son propiedades rechazadas. Una vista previa de tu content se muestra debajo. Las entradas adicionales que no estableciste están presentes debido a la sanitización.",
"webAudio": "usar audio web",
"webAudio_description": "Utilizar audio web. Esto habilita funciones avanzadas como Replaygain. Desactiva esta opción si tienes problemas",
"transcode": "activar la transcodificación",
"transcode_description": "permite la transcodificación a distintos formatos",
"transcodeBitrate": "tasa de bits a transcodificar",
"transcodeBitrate_description": "selecciona el bitrate a transcodificar. 0 significa dejar que el servidor elija",
"transcodeNote": "Se mostrará después de 1 (web) - 2 (mpv) pistas",
"transcodeFormat": "formato a transcodificar",
"transcodeFormat_description": "selecciona el formato a transcodificar. dejar vacío para que el servidor decida",
"albumBackground": "imagen de fondo del álbum",
"albumBackground_description": "Agregar una imagen de fondo a las páginas del álbum que contienen la carátula del álbum",
"albumBackgroundBlur": "Tamaño de desenfoque de la imagen de fondo del álbum",
"albumBackgroundBlur_description": "Ajustar el nivel de desenfoque de la imagen de fondo del álbum",
"playerbarOpenDrawer": "Cambiar la barra del reproductor a pantalla completa",
"playerbarOpenDrawer_description": "Permitir hacer clic en la barra del reproductor para abrir el reproductor en pantalla completa",
"artistConfiguration": "Configuración de la página del artista del álbum",
"artistConfiguration_description": "Configurar qué elementos se muestran y en qué orden en la página del artista del álbum",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"trayEnabled": "Mostrar en el área de notificación",
"trayEnabled_description": "mostrar/ocultar el icono/menú del área de notificación. si está deshabilitado, también deshabilita minimizar/salir a la bandeja",
"translationApiProvider": "Proveedor de API de traducción",
"translationApiProvider_description": "Proveedor de API para traducción",
"translationApiKey": "clave api de traducción",
"translationApiKey_description": "Clave API para la traducción (solo para el punto final del servicio global)",
"translationTargetLanguage": "idioma final de la traducción",
"translationTargetLanguage_description": "lengua de destino de la traducción"
},
"action": {
"editPlaylist": "editar $t(entity.playlist_one)",
@@ -239,7 +284,7 @@
"backward": "hacia atrás",
"increase": "aumentar",
"rating": "calificación",
"bpm": "bpm",
"bpm": "lpm",
"refresh": "actualizar",
"unknown": "desconocido",
"areYouSure": "estás seguro?",
@@ -298,7 +343,7 @@
"previousSong": "anterior $t(entity.track_one)",
"noResultsFromQuery": "la petición no devolvió resultados",
"quit": "salir",
"expand": "ampliar",
"expand": "expandir",
"search": "buscar",
"saveAs": "guardar como",
"disc": "disco",
@@ -326,7 +371,8 @@
"reload": "Recargar",
"share": "Compartir",
"trackGain": "Ganancia de pista",
"preview": "Vista previa"
"preview": "Vista previa",
"translation": "traducción"
},
"error": {
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
@@ -372,7 +418,7 @@
"albumArtist": "$t(entity.albumArtist_one)",
"isRecentlyPlayed": "reproducido recientemente",
"isFavorited": "es favorito",
"bpm": "bpm",
"bpm": "lpm",
"releaseYear": "año de lanzamiento",
"disc": "disco",
"biography": "biografía",
@@ -441,7 +487,10 @@
"numberSelected": "{{count}} seleccionado",
"removeFromQueue": "$t(action.removeFromQueue)",
"shareItem": "Compartir elemento",
"showDetails": "Obtener información"
"showDetails": "Obtener información",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"download": "descargar",
"playShuffled": "$t(player.shuffle)"
},
"home": {
"mostPlayed": "más reproducidos",
@@ -465,20 +514,25 @@
"showLyricMatch": "mostrar coincidencia de letras",
"lyricGap": "desfase de letra",
"dynamicImageBlur": "tamaño de desenfoque de imagen",
"dynamicIsImage": "habilitar imagen de fondo"
"dynamicIsImage": "habilitar imagen de fondo",
"lyricOffset": "compensación de letras (ms)"
},
"lyrics": "letras",
"related": "relacionado"
"related": "relacionado",
"visualizer": "visualizador",
"noLyrics": "sin letras"
},
"albumDetail": {
"moreFromArtist": "más de este $t(entity.artist_one)",
"moreFromGeneric": "más de {{item}}"
"moreFromGeneric": "más de {{item}}",
"released": "publicado"
},
"setting": {
"playbackTab": "reproducción",
"generalTab": "general",
"hotkeysTab": "teclas de acceso rápido",
"windowTab": "ventana"
"windowTab": "ventana",
"advanced": "Avanzado"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
@@ -491,7 +545,7 @@
"trackList": {
"title": "$t(entity.track_other)",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
"artistTracks": "Pistas de {{artist}}"
"artistTracks": "pistas por {{artist}}"
},
"globalSearch": {
"commands": {
@@ -507,13 +561,13 @@
"albumList": {
"title": "$t(entity.album_other)",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
"artistAlbums": "Álbumes de {{artist}}"
"artistAlbums": "álbumes de {{artist}}"
},
"albumArtistDetail": {
"viewAllTracks": "ver todo de $t(entity.track_other)",
"relatedArtists": "similar a $t(entity.artist_other)",
"relatedArtists": "$t(entity.artist_other) similar",
"topSongs": "mejores canciones",
"topSongsFrom": "Las mejores canciones de {{title}}",
"topSongsFrom": "las mejores canciones de {{title}}",
"viewAll": "Ver todo",
"recentReleases": "Lanzamientos recientes",
"viewDiscography": "Ver discografía",
@@ -524,6 +578,17 @@
"copiedPath": "Ruta copiada correctamente",
"openFile": "Mostrar pista en el gestor de archivos",
"copyPath": "Copiar ruta al portapapeles"
},
"playlist": {
"reorder": "la reordenación solo se activa al ordenar por id"
},
"manageServers": {
"removeServer": "eliminar servidor",
"title": "administrar servidores",
"serverDetails": "detalles del servidor",
"username": "nombre de usuario",
"editServerDetailsTooltip": "editar detalles del servidor",
"url": "URL"
}
},
"form": {
@@ -569,7 +634,9 @@
"title": "buscar letras"
},
"editPlaylist": {
"title": "editar $t(entity.playlist_one)"
"title": "editar $t(entity.playlist_one)",
"success": "$t(entity.playlist_one) actualizada correctamente",
"publicJellyfinNote": "Jellyfin por alguna razón no expone si una lista de reproducción es pública o no. Si deseas que ésta siga siendo pública, por favor ten seleccionada la siguiente entrada"
},
"queryEditor": {
"input_optionMatchAll": "coincidir todos",
@@ -598,7 +665,7 @@
"releaseDate": "fecha de lanzamiento",
"bitrate": "tasa de bits",
"title": "título",
"bpm": "bpm",
"bpm": "lpm",
"dateAdded": "fecha de adición",
"artist": "$t(entity.artist_one)",
"songCount": "$t(entity.track_other)",
@@ -639,7 +706,8 @@
"genre": "$t(entity.genre_one)",
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"codec": "$t(common.codec)"
"codec": "$t(common.codec)",
"songCount": "$t(entity.track_other)"
},
"general": {
"gap": "$t(common.gap)",
@@ -648,7 +716,8 @@
"size": "$t(common.size)",
"displayType": "tipo de visualización",
"itemGap": "espacio entre elementos (px)",
"itemSize": "tamaño del elemento (px)"
"itemSize": "tamaño del elemento (px)",
"followCurrentSong": "seguir la canción actual"
},
"view": {
"card": "tarjeta",
@@ -703,6 +772,9 @@
"genreWithCount_other": "{{count}} géneros",
"trackWithCount_one": "{{count}} pista",
"trackWithCount_many": "{{count}} pistas",
"trackWithCount_other": "{{count}} pistas"
"trackWithCount_other": "{{count}} pistas",
"play_one": "Reproducir {{count}}",
"play_many": "Reproducir {{count}}",
"play_other": "Reproducir {{count}}"
}
}
+144
View File
@@ -0,0 +1,144 @@
{
"common": {
"size": "koko",
"search": "etsi",
"sortOrder": "järjestys",
"setting": "asetus",
"title": "otsikko",
"trackNumber": "raita",
"action_one": "toiminto",
"action_other": "toiminnot",
"add": "lisää",
"areYouSure": "oletko varma?",
"ascending": "nouseva",
"backward": "takaperin",
"bitrate": "bittinopeus",
"channel_one": "kanava",
"channel_other": "kanavat",
"collapse": "luhista",
"comingSoon": "tulossa pian…",
"configure": "konfiguroi",
"confirm": "hyväksy",
"disable": "poista käytöstä",
"disc": "levy",
"dismiss": "hylkää",
"favorite": "suosikki",
"filter_one": "suodatin",
"filter_other": "suodatinta",
"filters": "suodattimet",
"forceRestartRequired": "käynnistä uudelleen ottaaksesi muutokset käyttöön… sulje ilmoitus käynnistääksesi uudelleen",
"gap": "väli",
"home": "koti",
"left": "vasen",
"limit": "raja",
"manage": "hallitse",
"menu": "valikko",
"minimize": "minimoi",
"modified": "muokattu",
"name": "nimi",
"no": "ei",
"none": "ei mitään",
"noResultsFromQuery": "kysely palautti ei tuloksia",
"note": "huomautus",
"ok": "ok",
"owner": "omistaja",
"path": "reitti",
"preview": "esikatsele",
"previousSong": "edellinen $t(entity.track_one)",
"resetToDefault": "palauta oletusarvoihin",
"restartRequired": "uudelleen käynnistys vaaditaan",
"right": "oikea",
"save": "tallenna",
"saveAndReplace": "tallenna ja korvaa",
"saveAs": "tallenna nimellä",
"unknown": "tuntematon",
"version": "versio",
"year": "vuosi",
"yes": "kyllä",
"close": "sulje",
"descending": "laskeva",
"biography": "elämänkerta",
"cancel": "peruuta",
"bpm": "bpm",
"decrease": "pienennä",
"center": "keskitä",
"clear": "tyhjennä",
"codec": "koodekki",
"create": "luo",
"description": "kuvaus",
"currentSong": "nykyinen $t(entity.track_one)",
"delete": "poista",
"duration": "kesto",
"edit": "muokkaa",
"enable": "ota käyttöön",
"expand": "laajenna",
"increase": "lisää",
"forward": "eteenpäin",
"maximize": "maksimoi",
"mbid": "MusicBrainz ID",
"share": "jaa",
"random": "satunnainen",
"reload": "lataa uudelleen",
"quit": "poistu",
"rating": "arvostelu",
"refresh": "virkistä",
"reset": "nollaa",
"playerMustBePaused": "soitin täytyy olla pysäytetty"
},
"entity": {
"album_one": "albumi",
"album_other": "albumia",
"albumArtist_one": "albumi artisti",
"albumArtist_other": "albumi artistia",
"artistWithCount_one": "{{count}} artisti",
"artistWithCount_other": "{{count}} artistia",
"playlist_one": "soittolista",
"playlist_other": "soittolistaa",
"playlistWithCount_one": "{{count}} soittolista",
"playlistWithCount_other": "{{count}} soittolistaa",
"albumArtistCount_one": "{{count}} albumi artisti",
"albumArtistCount_other": "{{count}} albumi artistia",
"albumWithCount_one": "{{count}} albumi",
"albumWithCount_other": "{{count}} albumia",
"artist_one": "artisti",
"artist_other": "artistia",
"favorite_one": "suosikki",
"favorite_other": "suosikkia",
"folder_one": "kansio",
"folder_other": "kansiota",
"folderWithCount_one": "{{count}} kansio",
"folderWithCount_other": "{{count}} kansiota",
"genre_one": "genre",
"genre_other": "genreä",
"genreWithCount_one": "{{count}} genre",
"genreWithCount_other": "{{count}} genreä",
"smartPlaylist": "älykäs $t(entity.playlist_one)",
"track_one": "raita",
"track_other": "raitaa",
"trackWithCount_one": "{{count}} raita",
"trackWithCount_other": "{{count}} raitaa"
},
"action": {
"clearQueue": "tyhjennä jono",
"createPlaylist": "luo $t(entity.playlist_one)",
"deselectAll": "poista valinta kaikista",
"editPlaylist": "muokkaa $t(entity.playlist_one)",
"removeFromQueue": "poista jonosta",
"viewPlaylists": "katsele $t(entity.playlist_other)",
"openIn": {
"lastfm": "Avaa Last.fm:ssä",
"musicbrainz": "Avaa MusicBrainz:ssä"
},
"goToPage": "mene sivulle",
"moveToBottom": "siirry pohjalle",
"moveToTop": "siirry ylös",
"addToFavorites": "lisää $t(entity.favorite_other)",
"addToPlaylist": "lisää $t(entity.playlist_one)",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "poista kohteesta $t(entity.favorite_other)",
"toggleSmartPlaylistEditor": "kytke $t(entity.smartPlaylist) editori",
"deletePlaylist": "poista $t(entity.playlist_one)",
"removeFromPlaylist": "poista kohteesta $t(entity.playlist_one)",
"setRating": "aseta arvostelu"
}
}
+137 -30
View File
@@ -28,7 +28,8 @@
"mute": "muet",
"skip_forward": "avancer",
"pause": "pause",
"unfavorite": "dé-favori"
"unfavorite": "retirer des favoris",
"playSimilarSongs": "jouer des chansons similaires"
},
"action": {
"editPlaylist": "éditer $t(entity.playlist_one)",
@@ -47,16 +48,20 @@
"moveToBottom": "déplacer en bas",
"setRating": "noter",
"toggleSmartPlaylistEditor": "basculer l'éditeur de $t(entity.smartPlaylist)",
"removeFromFavorites": "retirer des $t(entity.favorite_other)"
"removeFromFavorites": "retirer des $t(entity.favorite_other)",
"openIn": {
"lastfm": "Ouvrir dans Last.fm",
"musicbrainz": "Ouvrir dans MusicBrainz"
}
},
"common": {
"backward": "reculer",
"backward": "en arrière",
"increase": "augmenter",
"rating": "note",
"bpm": "bpm",
"refresh": "rafraichir",
"unknown": "inconnu",
"areYouSure": "êtes vous sûr ?",
"areYouSure": "êtes-vous sûr?",
"edit": "éditer",
"favorite": "favoris",
"left": "gauche",
@@ -130,7 +135,18 @@
"random": "aléatoire",
"size": "taille",
"biography": "biographie",
"note": "note"
"note": "note",
"albumGain": "gain de l'album",
"albumPeak": "crête de l'album",
"close": "fermer",
"mbid": "Identifiants MusicBrainz",
"preview": "aperçu",
"share": "partager",
"reload": "recharger",
"trackGain": "gain de la piste",
"trackPeak": "crête de la piste",
"codec": "codec",
"translation": "traduction"
},
"error": {
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
@@ -151,7 +167,10 @@
"mpvRequired": "MPV requis",
"audioDeviceFetchError": "une erreur sest produite lors de la tentative dobtenir les périphériques audio",
"invalidServer": "serveur invalide",
"loginRateError": "trop de tentative de connexion, merci d'essayer dans quelque secondes"
"loginRateError": "trop de tentative de connexion, merci d'essayer dans quelque secondes",
"openError": "impossible d'ouvrir le fichier",
"networkError": "une erreur de réseau est survenue",
"badAlbum": "vous voyez cette page parce que cette chanson ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez une chanson qui n'est pas dans votre répertoire de musique. jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\"."
},
"filter": {
"mostPlayed": "plus joués",
@@ -209,7 +228,8 @@
"settings": "$t(common.setting_other)",
"home": "$t(common.home)",
"artists": "$t(entity.artist_other)",
"albumArtists": "$t(entity.albumArtist_other)"
"albumArtists": "$t(entity.albumArtist_other)",
"shared": "partagé $t(entity.playlist_other)"
},
"fullscreenPlayer": {
"config": {
@@ -221,13 +241,16 @@
"unsynchronized": "désynchronisé",
"lyricAlignment": "alignement des paroles",
"useImageAspectRatio": "utiliser le ratio de l'image",
"opacity": "opacitée",
"opacity": "opacité",
"lyricSize": "Taille des paroles",
"lyricGap": "espacement des lettres"
"lyricGap": "espacement des lettres",
"dynamicIsImage": "activer l'image d'arrière-plan",
"dynamicImageBlur": "intensité de flou sur image d'arrière-plan"
},
"upNext": "à suivre",
"lyrics": "paroles",
"related": "similaire"
"related": "similaire",
"visualizer": "visualisateur"
},
"appMenu": {
"selectServer": "sélectionner le serveur",
@@ -254,9 +277,10 @@
},
"setting": {
"generalTab": "général",
"hotkeysTab": "raccourci",
"hotkeysTab": "raccourcis",
"windowTab": "fenêtre",
"playbackTab": "lecteur"
"playbackTab": "lecteur",
"advanced": "avancé"
},
"globalSearch": {
"commands": {
@@ -282,22 +306,51 @@
"addLast": "$t(player.addLast)",
"addFavorite": "$t(action.addToFavorites)",
"play": "$t(player.play)",
"removeFromQueue": "$t(action.removeFromQueue)"
"removeFromQueue": "$t(action.removeFromQueue)",
"shareItem": "partager un élément",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"showDetails": "obtenir des informations",
"download": "télécharger"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
},
"genreList": {
"title": "$t(entity.genre_other)"
"title": "$t(entity.genre_other)",
"showAlbums": "afficher $t(entity.genre_one) $t(entity.album_other)",
"showTracks": "afficher $t(entity.genre_one) $t(entity.track_other)"
},
"trackList": {
"title": "$t(entity.track_other)"
"title": "$t(entity.track_other)",
"artistTracks": "pistes par {{artist}}",
"genreTracks": "'{{genre}}' $t(entity.track_other)"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"albumList": {
"title": "$t(entity.album_other)"
"title": "$t(entity.album_other)",
"artistAlbums": "albums par {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
},
"albumArtistDetail": {
"about": "À propos de {{artist}}",
"appearsOn": "apparaît sur",
"topSongsFrom": "meilleures chansons de {{title}}",
"viewAll": "voir tout",
"viewAllTracks": "voir tout $t(entity.track_other)",
"recentReleases": "sorties récentes",
"viewDiscography": "voir la discographie",
"relatedArtists": "en rapport avec $t(entity.artist_other)",
"topSongs": "meilleures chansons"
},
"itemDetail": {
"copyPath": "copier le chemin dans le presse-papiers",
"openFile": "afficher la piste dans le gestionnaire de fichiers",
"copiedPath": "chemin copié avec succès"
},
"playlist": {
"reorder": "le tri n'est possible que lorsque l'on trie par identifiant"
}
},
"setting": {
@@ -331,7 +384,7 @@
"mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv, si vide, le chemin par défaut sera utilisé",
"hotkey_favoriteCurrentSong": "favori $t(common.currentSong)",
"sampleRate": "taux d'échantillonnage",
"sampleRate_description": "sélectionner le taux d'échantillonnage de sortie utilisé si la fréquence d'échantillonnage sélectionnée est différente de celle du média actuel. une valeur en inférieur à 8000 utilisera la fréquence par défaut",
"sampleRate_description": "sélectionne le taux d'échantillonnage de sortie utilisé si la fréquence d'échantillonnage sélectionnée est différente de celle du média actuel. une valeur inférieure à 8000 utilisera la fréquence par défaut",
"hotkey_zoomIn": "zoom avant",
"scrobble_description": "scrobble les lectures à votre serveur multimédia",
"hotkey_browserForward": "avancer",
@@ -359,7 +412,7 @@
"fontType_optionCustom": "police personnalisée",
"remotePassword": "mot de passe du serveur de contrôle à distance",
"lyricFetchProvider": "fournisseur depuis lequel récupérer les paroles",
"language_description": "définit la langue de l'application $t(common.restartRequired)",
"language_description": "définit la langue de l'application ($t(common.restartRequired))",
"playbackStyle_optionCrossFade": "fondu enchaîné",
"hotkey_rate3": "noter 3 étoiles",
"font": "police",
@@ -371,7 +424,7 @@
"hotkey_rate5": "noter 5 étoiles",
"hotkey_playbackPrevious": "piste précédente",
"showSkipButtons_description": "affiche ou cache les boutons suivants et précédents de la barre de lecture",
"language": "language",
"language": "langage",
"playbackStyle": "style de lecture",
"hotkey_toggleShuffle": "basculer la lecture aléatoire",
"playbackStyle_description": "sélectionnez le style de lecture à utiliser pour le lecteur audio",
@@ -416,7 +469,7 @@
"savePlayQueue_description": "sauvegarde la liste de lecture quand l'application est fermée et la restaure quand l'application est ouverte",
"sidebarCollapsedNavigation_description": "affiche ou cache la navigation dans la barre latérale réduite",
"sidebarConfiguration": "configuration de la barre latérale",
"sidebarConfiguration_description": "sélectionnez les items et l'ordre dans lesquels ils seront affichaient dans la barre latérale",
"sidebarConfiguration_description": "sélectionnez les éléments et l'ordre dans lequel ils seront affichés dans la barre latérale",
"sidebarPlaylistList": "liste de playlist de la barre latérale",
"sidebarCollapsedNavigation": "navigation de la barre latéral (réduite)",
"skipDuration": "durée de l'avance rapide",
@@ -434,13 +487,13 @@
"themeLight_description": "définit le thème clair à utiliser pour l'application",
"zoom_description": "définit le pourcentage de zoom de l'application",
"theme": "thème",
"skipPlaylistPage_description": "lors de la navigation dans une playlist, aller directement vers le liste des morceaux, au lieu de la page par défaut",
"skipPlaylistPage_description": "lors de la navigation dans une playlist, aller directement vers la liste des morceaux, au lieu de la page par défaut",
"volumeWheelStep": "valeur du pas de volume",
"windowBarStyle": "style de la barre de la fenêtre",
"useSystemTheme_description": "suivre les préférences du système (sombre ou clair)",
"useSystemTheme_description": "suivre les préférences du système (mode clair ou sombre)",
"skipPlaylistPage": "sauter la page de playlist",
"themeDark": "thème (sombre)",
"windowBarStyle_description": "sélectionner le style de la barre de la fenêtre",
"windowBarStyle_description": "ajuster le style de la barre de la fenêtre",
"useSystemTheme": "utiliser le thème du système",
"discordApplicationId_description": "l'identifiant de l'application pour le statut d'activité {{discord}} (par défaut à {{defaultId}})",
"audioExclusiveMode": "mode de sortie audio exclusif",
@@ -455,18 +508,58 @@
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"replayGainMode_description": "ajuste le gain de volume accordement à la valeur de {{ReplayGain}} sauvegardé dans les métadonnées du fichier",
"replayGainFallback": "{{ReplayGain}} fallback",
"replayGainClipping_description": "Préviens le clipping causé par {{ReplayGain}} en baissant automatiquement le gain",
"replayGainClipping_description": "Prévient le clipping causé par {{ReplayGain}} en baissant automatiquement le gain",
"replayGainPreamp": "préamplificateur (dB) de {{ReplayGain}}",
"replayGainClipping": "{{ReplayGain}} clipping",
"replayGainMode": "mode de {{ReplayGain}}",
"replayGainFallback_description": "gain en dB à appliquer si le fichier n'a pas de tag {{ReplayGain}}",
"replayGainPreamp_description": "ajuste le gain de préampli appliqué a la valeur de {{ReplayGain}}",
"clearQueryCache": "vide le cache de feishin",
"clearCache": "Vider le cache navigateur",
"clearCache": "vider le cache navigateur",
"buttonSize_description": "la taille des boutons de la barre de lecture",
"clearQueryCache_description": "un 'soft clear' de feishin. cela actualisera les playlists, les métadonnées des pistes, et réinitialisera les paroles enregistrées. les paramètres, identifiants serveurs et les images mises en cache sont conservés",
"clearCache_description": "un 'hard clear' de feishin. en plus de vider le cache de feishin, vide le cache du navigateur (images sauvegardées et autres ressources). les identifiants serveurs et paramètres sont conservés",
"buttonSize": "taille des boutons de la barre de lecture"
"buttonSize": "taille des boutons du lecteur",
"clearCacheSuccess": "le cache a été vidé",
"externalLinks_description": "activer l'affichage de liens externes (Last.fm, MusicBrainz) sur la page de l'artiste/album",
"genreBehavior": "comportement par défaut de la page des genres",
"startMinimized_description": "démarrer l'application dans la barre des tâches",
"externalLinks": "afficher les liens externes",
"homeConfiguration": "configuration de la page d'accueil",
"homeFeature": "carrousel de la page d'accueil",
"homeFeature_description": "active ou désactive le carrousel sur la page d'accueil",
"imageAspectRatio": "utiliser le rapport hauteur/largeur natif de la pochette",
"imageAspectRatio_description": "si cette option est activée, les pochettes seront affichées en utilisant leur rapport hauteur/largeur natif. pour les pochettes qui n'ont pas un rapport 1:1 (carré), l'espace restant sera vide",
"mpvExtraParameters_help": "un par ligne",
"passwordStore_description": "quel mot de passe utiliser. changez cela si vous rencontrez des problèmes pour stocker les mots de passe.",
"playerAlbumArtResolution": "résolution de la pochette de l'album du lecteur",
"passwordStore": "mots de passe",
"playerAlbumArtResolution_description": "la résolution pour l'aperçu de la pochette d'album agrandie du lecteur. plus grand le rend plus net, mais peut ralentir le chargement. la valeur par défaut est 0 (automatique)",
"homeConfiguration_description": "configurer quels éléments sont affichés sur la page d'accueil, et dans quel ordre",
"startMinimized": "démarrer l'application en mode réduit",
"genreBehavior_description": "détermine si cliquer sur un genre ouvre par défaut la liste des pistes ou des albums",
"transcode": "activer le transcodage",
"transcode_description": "permet le transcodage vers différents formats",
"transcodeBitrate_description": "sélectionne le débit du transcodage. 0 signifie que le serveur choisit",
"transcodeFormat_description": "sélectionne le format du transcodage. laisser vide pour laisser le serveur décider",
"volumeWidth": "largeur de la barre de volume",
"volumeWidth_description": "la largeur de la barre de volume",
"customCssEnable": "activer le css personnalisé",
"customCssEnable_description": "permet d'écrire du css personnalisé.",
"customCssNotice": "Attention: bien qu'il y ait un certain assainissement (blocage de url() et de content:), l'utilisation de CSS personnalisé peut toujours présenter des risques en modifiant l'interface.",
"customCss": "css personnalisé",
"webAudio": "utiliser l'audio web",
"transcodeBitrate": "débit binaire du transcodage",
"transcodeFormat": "format de transcodage",
"webAudio_description": "utiliser l'audio web. cela permet d'utiliser des fonctions avancées comme le replaygain. désactivez si vous rencontrez d'autres problèmes",
"artistConfiguration": "page de configuration de l'artiste de l'album",
"artistConfiguration_description": "configurer les éléments et l'ordre à afficher, sur la page de l'artiste de l'album",
"doubleClickBehavior": "mettre en file d'attente toutes les pistes recherchées lors d'un double clic",
"contextMenu": "configuration du menu contexte (clic droit)",
"contextMenu_description": "permet de masquer les éléments qui s'affichent dans le menu lorsque vous cliquez avec le bouton droit de la souris sur un élément. les éléments qui ne sont pas cochés seront masqués",
"albumBackground": "image d'arrière-plan de l'album",
"albumBackground_description": "ajoute une image d'arrière-plan pour les pages de l'album contenant les illustrations de l'album",
"albumBackgroundBlur_description": "ajuste le niveau de flou appliqué à l'image d'arrière-plan de l'album"
},
"form": {
"deletePlaylist": {
@@ -510,12 +603,21 @@
"input_optionMatchAny": "correspondre à n'importe quel"
},
"editPlaylist": {
"title": "modifier $t(entity.playlist_one)"
"title": "modifier $t(entity.playlist_one)",
"publicJellyfinNote": "Jellyfin n'indique pas si une playlist est publique ou non. Si vous souhaitez que cette playlist reste publique, veuillez sélectionner l'entrée suivante"
},
"lyricSearch": {
"title": "rechercher parole",
"input_name": "$t(common.name)",
"input_artist": "$t(entity.artist_one)"
},
"shareItem": {
"allowDownloading": "autoriser le téléchargement",
"description": "description",
"setExpiration": "définir une expiration",
"success": "lien de partage copié dans le presse-papier (ou cliquez ici pour ouvrir)",
"expireInvalid": "l'expiration doit être définie à une date ultérieure",
"createFailed": "échec de la création du lien de partage (le partage est-il activé ?)"
}
},
"entity": {
@@ -573,7 +675,9 @@
"tableColumns": "colonnes de la liste",
"autoFitColumns": "colonnes à ajustement automatique",
"gap": "$t(common.gap)",
"size": "$t(common.size)"
"size": "$t(common.size)",
"itemGap": "écart entre les éléments (en pixel)",
"itemSize": "taille des élements (en pixel)"
},
"view": {
"table": "liste",
@@ -606,7 +710,9 @@
"title": "$t(common.title)",
"size": "$t(common.size)",
"genre": "$t(entity.genre_one)",
"year": "$t(common.year)"
"year": "$t(common.year)",
"songCount": "$t(entity.track_other)",
"codec": "$t(common.codec)"
}
},
"column": {
@@ -632,7 +738,8 @@
"genre": "$t(entity.genre_one)",
"songCount": "$t(entity.track_other)",
"channels": "$t(common.channel_other)",
"size": "$t(common.size)"
"size": "$t(common.size)",
"codec": "$t(common.codec)"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"action": {
"createPlaylist": "$t(entity.playlist_one) 생성",
"addToFavorites": "$t(entity.favorite_other)에 추가",
"addToPlaylist": "$t(entity.playlist_one)에 추가",
"clearQueue": "대기열 지우기",
"deletePlaylist": "$t(entity.playlist_one) 삭제",
"deselectAll": "모두 선택 해제",
"editPlaylist": "$t(entity.playlist_one) 편집",
"goToPage": "페이지 이동",
"moveToBottom": "맨 아래로 이동",
"moveToTop": "맨 위로 이동"
}
}
+10 -3
View File
@@ -2,7 +2,7 @@
"action": {
"editPlaylist": "pas $t(entity.playlist_one) aan",
"goToPage": "ga naar pagina",
"moveToTop": "verplaats naar top",
"moveToTop": "verplaats naar boven",
"addToFavorites": "toevoegen aan $t(entity.favorite_other)",
"addToPlaylist": "toevoegen aan $t(entity.playlist_one)",
"createPlaylist": "maak $t(entity.playlist_one)",
@@ -16,7 +16,11 @@
"setRating": "selecteer rating",
"toggleSmartPlaylistEditor": "editor $t(entity.smartPlaylist) schakelen",
"removeFromFavorites": "verwijder van $t(entity.favorite_other)",
"clearQueue": "lijst leegmaken"
"clearQueue": "verwijder lijst",
"openIn": {
"lastfm": "Open in Last.fm",
"musicbrainz": "Open in MusicBrainz"
}
},
"common": {
"backward": "achteruit",
@@ -95,7 +99,10 @@
"search": "zoeken",
"saveAs": "opslaan als",
"yes": "ja",
"size": "grootte"
"size": "grootte",
"reload": "herlaad",
"setting": "instelling",
"close": "sluiten"
},
"filter": {
"rating": "rating",
+87 -17
View File
@@ -16,7 +16,11 @@
"createPlaylist": "utwórz $t(entity.playlist_one)",
"deletePlaylist": "usuń $t(entity.playlist_one)",
"moveToBottom": "przesuń na dół",
"setRating": "oceń"
"setRating": "oceń",
"openIn": {
"lastfm": "Otwórz w Last.fm",
"musicbrainz": "Otwórz w MusicBrainz"
}
},
"common": {
"increase": "zwiększ",
@@ -99,7 +103,17 @@
"decrease": "obniż",
"path": "ścieżka",
"center": "środkowy",
"note": "notatka"
"note": "notatka",
"albumPeak": "spadek albumu",
"albumGain": "wzrost albumu",
"mbid": "ID MusicBrainz",
"reload": "przeładuj",
"share": "udostępnij",
"trackGain": "gain utworu",
"trackPeak": "peak utworu",
"codec": "kodek",
"preview": "podgląd",
"close": "zamknij"
},
"entity": {
"genre_one": "gatunek",
@@ -168,7 +182,10 @@
"mpvRequired": "wymagane MPV",
"audioDeviceFetchError": "wystąpił błąd podczas próby znalezienia urządzeń dźwiękowych",
"invalidServer": "nieprawidłowy serwer",
"loginRateError": "zbyt dużo prób logowania, poczekaj chwilę i spróbuj ponownie"
"loginRateError": "zbyt dużo prób logowania, poczekaj chwilę i spróbuj ponownie",
"badAlbum": "ta strona jest wyświetlana, ponieważ ten utwór nie jest częścią albumu. najprawdopodobniej ten problem występuje, jeśli utwór znajduje się w nadrzędnym folderze plików z muzyką. jellyfin grupuje utwory tylko wtedy, gdy znajdują się one w folderze.",
"networkError": "wystąpił błąd sieciowy",
"openError": "nie można otworzyć pliku"
},
"filter": {
"mostPlayed": "najczęściej odtwarzane",
@@ -262,6 +279,14 @@
},
"editPlaylist": {
"title": "edytuj $t(entity.playlist_one)"
},
"shareItem": {
"allowDownloading": "zezwól na pobieranie",
"description": "opis",
"setExpiration": "ustaw czas wygaśnięcia",
"success": "link do udostępniania skopiowany do schowka (lub kliknij tutaj, aby otworzyć)",
"createFailed": "nie udało się utworzyć linku do udostępniania (czy udostępnianie jest włączone?)",
"expireInvalid": "ustawiony czas wygaśnięcia musi być w przyszłości"
}
},
"page": {
@@ -277,7 +302,9 @@
"unsynchronized": "niezsynchronizowane",
"lyricAlignment": "wyrównaj tekst",
"useImageAspectRatio": "użyj współczynnika proporcji obrazu",
"lyricGap": "odstępy tekstu"
"lyricGap": "odstępy tekstu",
"dynamicImageBlur": "rozmiar rozmycia obrazu",
"dynamicIsImage": "włącz obraz w tle"
},
"upNext": "następny",
"lyrics": "tekst",
@@ -311,7 +338,9 @@
"addFavorite": "$t(action.addToFavorites)",
"play": "$t(player.play)",
"numberSelected": "zaznaczono {{count}}",
"removeFromQueue": "$t(action.removeFromQueue)"
"removeFromQueue": "$t(action.removeFromQueue)",
"shareItem": "udostępnij pozycję",
"showDetails": "zobacz informacje"
},
"albumDetail": {
"moreFromArtist": "więcej od $t(entity.artist_one)",
@@ -321,10 +350,14 @@
"title": "$t(entity.albumArtist_other)"
},
"genreList": {
"title": "$t(entity.genre_other)"
"title": "$t(entity.genre_other)",
"showAlbums": "pokaż $t(entity.genre_one) $t(entity.album_other)",
"showTracks": "pokaż $t(entity.genre_one) $t(entity.track_other)"
},
"albumList": {
"title": "$t(entity.album_other)"
"title": "$t(entity.album_other)",
"artistAlbums": "albumy artysty {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
},
"sidebar": {
"nowPlaying": "teraz odtwarzane",
@@ -337,7 +370,8 @@
"settings": "$t(common.setting_other)",
"home": "$t(common.home)",
"artists": "$t(entity.artist_other)",
"albumArtists": "$t(entity.albumArtist_other)"
"albumArtists": "$t(entity.albumArtist_other)",
"shared": "udostępnione $t(entity.playlist_other)"
},
"home": {
"mostPlayed": "najczęściej odtwarzane",
@@ -353,7 +387,9 @@
"windowTab": "okno"
},
"trackList": {
"title": "$t(entity.track_other)"
"title": "$t(entity.track_other)",
"artistTracks": "utwory przez {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)"
},
"globalSearch": {
"commands": {
@@ -365,6 +401,22 @@
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"albumArtistDetail": {
"topSongs": "popularne utwory",
"topSongsFrom": "popularne utwory z {{title}}",
"about": "O {{artist}}",
"recentReleases": "ostatnie wydania",
"viewAll": "zobacz wszystko",
"viewDiscography": "przeglądaj dyskografię",
"relatedArtists": "powiązane z $t(entity.artist_other)",
"appearsOn": "pojawia się na",
"viewAllTracks": "zobacz wszystko $t(entity.track_other)"
},
"itemDetail": {
"copyPath": "kopiuj ścieżkę do schowka",
"copiedPath": "ścieżka została skopiowana pomyślnie",
"openFile": "pokaż utwór w menedżerze plików"
}
},
"player": {
@@ -413,7 +465,7 @@
"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",
"discordUpdateInterval": "{{discord}} interwał aktualizacji obszernej obecności",
"discordUpdateInterval": "{{discord}} interwał aktualizacji rich presence",
"fontType_optionBuiltIn": "wbudowana czcionka",
"hotkey_playbackPlayPause": "odtwarzaj / wstrzymaj",
"hotkey_rate1": "oceń na 1 gwiazdkę",
@@ -449,7 +501,7 @@
"crossfadeDuration_description": "ustaw czas trwania efektu przenikania",
"language": "język",
"hotkey_toggleShuffle": "przełącz kolejność losową",
"discordRichPresence_description": "włącz status odtwarzania w {{discord}} obszernie obecny. Klucze obrazów to {{icon}}, {{playing}} i {{paused}}. ",
"discordRichPresence_description": "włącz status odtwarzania w {{discord}} rich presence. Dzięki temu będą wyświetlane informacje takie jak: {{icon}}, {{playing}} i {{paused}}. ",
"audioDevice": "urządzenia dźwiękowe",
"hotkey_rate2": "oceń na 2 gwiazdki",
"exitToTray": "zamknij do zasobnika",
@@ -475,7 +527,7 @@
"hotkey_zoomOut": "oddal",
"hotkey_unfavoriteCurrentSong": "usuń $t(common.currentSong) z ulubionych",
"hotkey_rate0": "wyczyść oceny",
"discordApplicationId": "id aplikacji {{discord}}",
"discordApplicationId": "ID aplikacji {{discord}}",
"applicationHotkeys_description": "ustaw skróty klawiszowe aplikacji. przełącz pole wyboru aby ustawić skrót globalny (tylko komputery)",
"floatingQueueArea_description": "wyświetl ikonę najechania kursorem po prawej stronie ekranu, aby wyświetlić kolejkę odtwarzania",
"hotkey_volumeMute": "wycisz",
@@ -521,7 +573,7 @@
"sampleRate_description": "wybierz wyjściową częstotliwość próbkowania, która ma być używana, jeśli wybrana częstotliwość próbkowania różni się od częstotliwości bieżącego utworu. wartość mniejsza niż 8000 spowoduje użycie częstotliwości domyślnej",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainClipping": "wzmocnienie {{ReplayGain}}",
"scrobble_description": "odtwarzanie scrobble na serwerze multimediów",
"scrobble_description": "przekazywanie informacji o odtwarzaniu (scrobbling) do twojego serwera multimediów",
"sidePlayQueueStyle": "boczny styl kolejki odtwarzania",
"remoteUsername_description": "ustaw nazwę użytkownika dla serwera zdalnej kontroli. Jeśli nazwa użytkownika i hasło są puste, autoryzacja będzie wyłączona",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
@@ -564,7 +616,21 @@
"clearCache_description": "\"twarde wyczyszczenie\" feishin. oprócz wyczyszczenia pamięci podręcznej feishin, opróżnij pamięć podręczną przeglądarki (zapisane obrazy i inne zasoby). dane i ustawienia serwera zostaną zachowane",
"clearQueryCache_description": "\"miękkie wyczyszczenie\" feishin. spowoduje to odświeżenie list odtwarzania, metadanych utworów i zresetowanie zapisanych tekstów. ustawienia, dane uwierzytelniające serwera i obrazy w pamięci podręcznej zostaną zachowane",
"buttonSize_description": "rozmiar przycisków paska odtwarzacza",
"clearCache": "wyczyść pamięć podręczną przeglądarki"
"clearCache": "wyczyść pamięć podręczną przeglądarki",
"playerAlbumArtResolution": "rozdzielczość okładki albumu odtwarzacza",
"externalLinks": "pokaż zewnętrzne linki",
"genreBehavior_description": "określa, czy kliknięcie gatunku domyślnie otwiera listę utworów czy albumów",
"mpvExtraParameters_help": "po jednym na linię",
"passwordStore": "hasła",
"passwordStore_description": "jakie hasło ma być używane. zmień to, jeśli masz problemy z przechowywaniem haseł.",
"playerAlbumArtResolution_description": "rozdzielczość podglądu okładki albumu w dużym odtwarzaczu. większa sprawia, że wygląda bardziej wyraziście, ale może spowolnić ładowanie. domyślnie 0, czyli auto",
"startMinimized": "uruchom zminimalizowany",
"startMinimized_description": "uruchom aplikację w zasobniku systemowym",
"clearCacheSuccess": "pamięć podręczna została wyczyszczona pomyślnie",
"genreBehavior": "domyślne zachowanie strony gatunek",
"externalLinks_description": "umożliwia wyświetlanie linków zewnętrznych (Last.fm, MusicBrainz) na stronach artystów/albumów",
"homeConfiguration": "konfiguracja strony głównej",
"homeConfiguration_description": "konfiguracja elementów wyświetlanych na stronie głównej i ich kolejności"
},
"table": {
"config": {
@@ -578,7 +644,9 @@
"gap": "$t(common.gap)",
"tableColumns": "kolumny tabeli",
"autoFitColumns": "automatyczne dopasowanie kolumn",
"size": "$t(common.size)"
"size": "$t(common.size)",
"itemSize": "rozmiar elementu (px)",
"itemGap": "odstęp między elementami (px)"
},
"label": {
"releaseDate": "data premiery",
@@ -606,7 +674,8 @@
"discNumber": "numer płyty",
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist_one)"
"albumArtist": "$t(entity.albumArtist_one)",
"codec": "$t(common.codec)"
}
},
"column": {
@@ -632,7 +701,8 @@
"path": "ścieżka",
"discNumber": "płyta",
"channels": "$t(common.channel_other)",
"size": "$t(common.size)"
"size": "$t(common.size)",
"codec": "$t(common.codec)"
}
}
}
+24 -2
View File
@@ -212,7 +212,23 @@
"songCount": "contador de músicas",
"toYear": "até o ano",
"random": "aleatório",
"search": "buscar"
"search": "buscar",
"lastPlayed": "última tocada",
"isCompilation": "é compilação",
"trackNumber": "faixa",
"communityRating": "Nota da comunidade",
"isPublic": "é público",
"playCount": "contador de execuções",
"recentlyUpdated": "atualizado recentemente",
"dateAdded": "data de adição",
"isRecentlyPlayed": "foi tocado recentemente",
"albumArtist": "$t(entity.albumArtist_one)",
"recentlyAdded": "adicionado recentemente",
"releaseDate": "data de lançamento",
"recentlyPlayed": "tocado recentemente",
"criticRating": "Nota da crítica",
"isFavorited": "é favoritado",
"releaseYear": "ano de lançamento"
},
"player": {
"playbackFetchNoResults": "nenhuma música encontrada",
@@ -257,7 +273,13 @@
"folderWithCount_other": "{{count}} pastas",
"genreWithCount_one": "{{count}} gênero",
"genreWithCount_many": "{{count}} gêneros",
"genreWithCount_other": "{{count}} gêneros"
"genreWithCount_other": "{{count}} gêneros",
"trackWithCount_one": "faixa",
"trackWithCount_many": "faixas",
"trackWithCount_other": "faixas",
"track_one": "faixa",
"track_many": "faixas",
"track_other": "faixas"
},
"error": {
"remotePortWarning": "reinicie o servidor para aplicar a nova porta",
+391 -119
View File
@@ -7,22 +7,26 @@
"addToFavorites": "добавить в $t(entity.favorite_other)",
"addToPlaylist": "добавить в $t(entity.playlist_one)",
"createPlaylist": "создать $t(entity.playlist_one)",
"removeFromPlaylist": "удалить из $t(entity.playlist_one)",
"viewPlaylists": "просмотреть $t(entity.playlist_other)",
"removeFromPlaylist": "удалить из $t(entity.playlist_few)",
"viewPlaylists": "показать $t(entity.playlist_other)",
"refresh": "$t(common.refresh)",
"deletePlaylist": "удалить $t(entity.playlist_one)",
"removeFromQueue": "удалить из очереди",
"deselectAll": "снять выделение",
"moveToBottom": "вниз",
"setRating": "оценить",
"toggleSmartPlaylistEditor": "вкл/выкл $t(entity.smartPlaylist) редактор",
"removeFromFavorites": "удалить из $t(entity.favorite_other)"
"toggleSmartPlaylistEditor": "вкл./откл. редактор $t(entity.smartPlaylist)",
"removeFromFavorites": "удалить из $t(entity.favorite_few)",
"openIn": {
"lastfm": "открыть на Last.fm",
"musicbrainz": "открыть на MusicBrainz"
}
},
"common": {
"backward": "назад",
"increase": "увеличить",
"rating": "рейтинг",
"bpm": "ударов в мин.",
"bpm": "уд./мин.",
"refresh": "обновить",
"unknown": "неизвестно",
"areYouSure": "вы уверены?",
@@ -34,19 +38,19 @@
"currentSong": "текущий $t(entity.track_one)",
"collapse": "закрыть",
"trackNumber": "трек",
"descending": "убывающий",
"descending": "по убыванию",
"add": "добавить",
"gap": "промежуток",
"ascending": "возрастающий",
"ascending": "по возрастанию",
"dismiss": "отклонить",
"year": "год",
"manage": "управлять",
"limit": "лимит",
"minimize": "минимизировать",
"manage": "управление",
"limit": "ограничение",
"minimize": "свернуть",
"modified": "изменено",
"duration": "продолжительность",
"duration": "длительность",
"name": "имя",
"maximize": "максимизировать",
"maximize": "развернуть",
"decrease": "уменьшить",
"ok": "ок",
"description": "описание",
@@ -60,8 +64,10 @@
"forward": "вперёд",
"delete": "удалить",
"cancel": "отменить",
"forceRestartRequired": "перезапустите приложение, чтобы применить изменения... закройте уведомление, чтобы перезапустить приложение",
"forceRestartRequired": "перезапустите приложение, чтобы применить изменения... закройте уведомление для перезапуска",
"setting": "настройка",
"setting_one": "настройка",
"setting_other": "настройки",
"version": "версия",
"title": "название",
"filter_one": "фильтр",
@@ -74,39 +80,47 @@
"action_one": "действие",
"action_few": "действия",
"action_many": "действий",
"playerMustBePaused": "воспроизведение должно быть остановлено",
"playerMustBePaused": "необходимо остановить воспроизведение",
"confirm": "подтвердить",
"resetToDefault": "сбросить к настройкам по умолчанию",
"home": "Главная страница",
"comingSoon": "скоро будет…",
"resetToDefault": "сбросить настройки",
"home": "главная",
"comingSoon": "скоро...",
"reset": "сбросить",
"channel_one": "канал",
"channel_few": "канала",
"channel_many": "каналов",
"disable": "выключить",
"disable": "отключить",
"sortOrder": "порядок",
"menu": "меню",
"restartRequired": "необходим перезапуск приложения",
"previousSong": "предыдущий $t(entity.track_one)",
"noResultsFromQuery": ет результатов",
"noResultsFromQuery": ичего не найдено",
"quit": "выйти",
"expand": "расширить",
"search": "Поиск",
"expand": "раскрыть",
"search": "поиск",
"saveAs": "сохранить как",
"disc": "диск",
"yes": "да",
"random": "случайный",
"random": "случайно",
"size": "размер",
"biography": "биография",
"note": "заметка",
"none": "нет"
"none": "нет",
"mbid": "MusicBrainz ID",
"reload": "перезагрузить",
"preview": "просмотр",
"codec": "кодек",
"share": "поделиться",
"close": "закрыть"
},
"entity": {
"album_one": "альбом",
"album_few": "альбома",
"album_other": "альбомы",
"album_many": "альбомов",
"genre_one": "жанр",
"genre_few": "жанра",
"genre_other": "жанры",
"genre_many": "жанров",
"playlistWithCount_one": "{{count}} плейлист",
"playlistWithCount_few": "{{count}} плейлиста",
@@ -114,18 +128,26 @@
"playlist_one": "плейлист",
"playlist_few": "плейлиста",
"playlist_many": "плейлистов",
"playlist_other": "плейлисты",
"play": "{{count}} прослушиваний",
"play_one": "{{count}} прослушивание",
"play_other": "{{count}} прослушиваний",
"artist_one": "автор",
"artist_few": "автора",
"artist_many": "авторов",
"artist_other": "исполнители",
"artist_many": "исполнителей",
"folderWithCount_one": "{{count}} папка",
"folderWithCount_few": "{{count}} папки",
"folderWithCount_many": "{{count}} папок",
"albumArtist_one": "автор альбома",
"albumArtist_few": "автора альбома",
"albumArtist_many": "авторов альбома",
"albumArtist_one": "исполнитель альбома",
"albumArtist_few": "исполнители альбома",
"albumArtist_other": "исполнители альбомов",
"albumArtist_many": "исполнителей альбома",
"track_one": "трек",
"track_few": "трека",
"track_many": "треков",
"track_other": "треки",
"song_many": "{{ count }} композиций",
"albumArtistCount_one": "{{count}} автор альбома",
"albumArtistCount_few": "{{count}} автора альбома",
"albumArtistCount_many": "{{count}} авторов альбома",
@@ -135,24 +157,27 @@
"favorite_one": "любимый",
"favorite_few": "любимых",
"favorite_many": "любимые",
"favorite_other": "любимые",
"artistWithCount_one": "{{count}} автор",
"artistWithCount_few": "{{count}} автора",
"artistWithCount_many": "{{count}} авторов",
"folder_one": "папка",
"folder_few": "папки",
"folder_many": "папок",
"folder_other": "папки",
"smartPlaylist": "умный $t(entity.playlist_one)",
"genreWithCount_one": "{{count}} жанр",
"genreWithCount_few": "{{count}} жанра",
"genreWithCount_many": "{{count}} жанров",
"trackWithCount_one": "{{count}} трек",
"trackWithCount_few": "{{count}} трека",
"trackWithCount_many": "{{count}} треков"
"trackWithCount_many": "{{count}} треков",
"trackWithCount_other": "{{count}} треков"
},
"table": {
"config": {
"view": {
"card": "карта",
"card": "карточки",
"table": "таблица",
"poster": "постер"
},
@@ -161,7 +186,10 @@
"gap": "$t(common.gap)",
"tableColumns": "столбцы таблицы",
"autoFitColumns": "автоматически расставить столбцы",
"size": "$t(common.size)"
"followCurrentSong": "следовать за исполняемым треком",
"size": "$t(common.size)",
"itemSize": "размер элементов (px)",
"itemGap": "отступ между элементами (px)"
},
"label": {
"releaseDate": "дата выхода",
@@ -173,7 +201,7 @@
"bpm": "$t(common.bpm)",
"lastPlayed": "последний",
"trackNumber": "номер трека",
"rowIndex": "индекс ряда",
"rowIndex": "номер строки",
"rating": "$t(common.rating)",
"artist": "$t(entity.artist_one)",
"album": "$t(entity.album_one)",
@@ -189,7 +217,9 @@
"discNumber": "номер диска",
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist_one)"
"albumArtist": "$t(entity.albumArtist_one)",
"codec": "$t(common.codec)",
"songCount": "$t(entity.track_other)"
}
},
"column": {
@@ -205,29 +235,43 @@
"genre": "$t(entity.genre_one)",
"path": "путь",
"discNumber": "диск",
"size": "$t(common.size)"
"size": "$t(common.size)",
"dateAdded": "дата добавления",
"album": "альбом",
"albumArtist": "исполнитель альбома",
"biography": "биография",
"codec": "$t(common.codec)",
"comment": "комментарий",
"albumCount": "$t(entity.album_other)",
"artist": "$t(entity.artist_one)",
"bitrate": "битрейт",
"channels": "$t(common.channel_other)",
"bpm": "bpm"
}
},
"error": {
"remotePortWarning": "перезапустить сервер для применения нового порта",
"remotePortWarning": "необходимо перезапустить сервер для применения нового порта",
"systemFontError": "произошла ошибка при попытке получить системные шрифты",
"playbackError": "произошла ошибка при попытке проиграть медиа",
"endpointNotImplementedError": "запрос {{endpoint}} is not implemented for {{serverType}}",
"playbackError": "произошла ошибка при попытке проигрывания медиа",
"endpointNotImplementedError": "запрос {{endpoint}} не реализован для {{serverType}}",
"remotePortError": "произошла ошибка при попытке установить порт удаленного сервера",
"serverRequired": "необходим сервер",
"authenticationFailed": "аутентификация завершилась с ошибкой",
"serverRequired": "сервер не выбран",
"authenticationFailed": "не удалось авторизироваться",
"apiRouteError": "невозможно выполнить запрос",
"genericError": "произошла ошибка",
"credentialsRequired": "необходимы учётные данные",
"sessionExpiredError": "ваш сеанс истек",
"remoteEnableError": "ошибка произошла при попытке $t(common.enable) удаленного сервера",
"credentialsRequired": "введите данные для входа",
"sessionExpiredError": "ваш сеанс истёк",
"remoteEnableError": "произошла ошибка при попытке $t(common.enable) удалённый сервер",
"localFontAccessDenied": "не получилось получить доступ к шрифтам",
"serverNotSelectedError": "не выбран сервер",
"remoteDisableError": "ошибка произошла при попытке $t(common.disable) удаленного сервера",
"mpvRequired": "Необходим MPV",
"remoteDisableError": "произошла ошибка при попытке $t(common.disable) удалённый сервер",
"mpvRequired": "необходим MPV",
"audioDeviceFetchError": "произошла ошибка с аудиоустройством",
"invalidServer": "недействительный сервер",
"loginRateError": "слишком много попыток входа, пожалуйста, попробуйте еще раз через несколько секунд"
"loginRateError": "превышено максимальное количество попыток входа, пожалуйста, попробуйте ещё раз через несколько секунд",
"openError": "не удалось открыть файл",
"badAlbum": "вы видите эту страницу из-за того, что эта песня не входит в альбом. скорее всего, вы видите эту ошибку, так как песня находится в корневой директории папки с музыкой. jellyfin группирует треки только по папкам.",
"networkError": "возникла ошибка сети"
},
"filter": {
"isCompilation": "сборник",
@@ -237,76 +281,78 @@
"communityRating": "рейтинг сообщества",
"favorited": "любимый",
"albumArtist": "$t(entity.albumArtist_one)",
"isFavorited": "любимый",
"bpm": "ударов в мин.",
"isFavorited": "любимые",
"bpm": "уд./мин.",
"disc": "диск",
"biography": "биография",
"artist": "$t(entity.artist_one)",
"duration": "продолжительность",
"fromYear": "из года",
"duration": "длительность",
"fromYear": "год",
"criticRating": "рейтинг критиков",
"mostPlayed": "наибольшое кол-во воспроизведений",
"mostPlayed": "слушают чаще всего",
"comment": "комментировать",
"playCount": "кол-во воспроизведений",
"recentlyUpdated": "недавно обновлено",
"playCount": "количество воспроизведений",
"recentlyUpdated": "обновленные недавно",
"channels": "$t(common.channel_other)",
"recentlyPlayed": "недавно проиграно",
"recentlyPlayed": "проигрывались недавно",
"owner": "$t(common.owner)",
"title": "название",
"rating": "рейтинг",
"search": "Поиск",
"search": "поиск",
"genre": "$t(entity.genre_one)",
"recentlyAdded": "недавно добавлено",
"recentlyAdded": "недавно добавленные",
"note": "заметка",
"name": "название",
"releaseDate": "дата выхода",
"albumCount": "$t(entity.album_other) кол-во",
"albumCount": "количество $t(entity.album_many)",
"path": "путь",
"isRecentlyPlayed": "недавно проигрывалась",
"isRecentlyPlayed": "недавно проигрывался",
"releaseYear": "год выхода",
"id": "#",
"songCount": "кол-во песен",
"id": "",
"songCount": "количество песен",
"isPublic": "публичный",
"random": "случайный",
"random": "случайно",
"lastPlayed": "последний раз проигрывалась",
"toYear": "до года",
"album": "$t(entity.album_one)",
"trackNumber": "трек"
},
"player": {
"repeat_all": "повтор всех",
"repeat_all": "повторять все",
"stop": "остановить",
"repeat": "повтор",
"queue_remove": "удалить выделенные",
"playRandom": "случайные песни",
"repeat": "повторять текущий",
"queue_remove": "удалить выбранное",
"playRandom": "играть случайные песни",
"playSimilarSongs": "играть похожие песни",
"skip": "пропустить",
"previous": "предыдущий",
"toggleFullscreenPlayer": "включить полноэкранный режим",
"skip_back": "назад",
"favorite": "любимый",
"next": "следующее",
"next": "следующий",
"shuffle": "перемешать",
"playbackFetchNoResults": "нет песен",
"playbackFetchNoResults": "песни не найдены",
"playbackFetchInProgress": "загрузка песен..",
"addNext": "добавить следующий",
"addNext": "воспроизвести следующим",
"playbackSpeed": "скорость воспроизведения",
"playbackFetchCancel": "это занимает некоторое время... закрыть уведомление для отмены",
"play": "прослушать",
"playbackFetchCancel": "пожалуйста, подождите немного... закройте уведомление для отмены",
"play": "играть",
"repeat_off": "повтор выключен",
"pause": "пауза",
"queue_clear": "очистить очередь",
"muted": "звук отключён",
"unfavorite": "убрать из любимых",
"queue_moveToTop": "переместить выделение вниз",
"queue_moveToBottom": "переместить выделение вверх",
"queue_moveToTop": "переместить выделенное вниз",
"queue_moveToBottom": "переместить выделенное вверх",
"shuffle_off": "перемешивание выключено",
"addLast": "добавить последний",
"addLast": "воспроизвести после всех",
"mute": "отключить звук",
"skip_forward": "вперёд"
"skip_forward": "вперёд",
"viewQueue": "показать очередь"
},
"page": {
"sidebar": {
"nowPlaying": "Cейчас проигрывается",
"nowPlaying": "сейчас играет",
"playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)",
"tracks": "$t(entity.track_other)",
@@ -326,28 +372,41 @@
"followCurrentLyric": "следовать за текущими словами песни",
"opacity": "непрозрачность",
"lyricSize": "размер слов",
"showLyricProvider": "показать провайдера слов",
"unsynchronized": "несинхронизировано",
"showLyricProvider": "показать источник слов",
"unsynchronized": "не синхронизировано",
"lyricAlignment": "выравнивание слов песни",
"lyricOffset": "задержка слов (мсек)",
"useImageAspectRatio": "использовать соотношение сторон изображения",
"lyricGap": "пробел между словами"
"lyricGap": "пробел между словами",
"dynamicIsImage": "включить фоновое изображение",
"dynamicImageBlur": "сила размытия изображения"
},
"upNext": "следующее",
"lyrics": "слова песни",
"related": "схожие"
"upNext": "играет",
"lyrics": "слова",
"related": "похожие",
"visualizer": "визуализатор",
"noLyrics": "слова для песни не найдены"
},
"appMenu": {
"selectServer": "выбрать сервер",
"selectServer": "список серверов",
"version": "версия {{version}}",
"settings": "$t(common.setting_other)",
"manageServers": "настроить список серверов",
"expandSidebar": "развернуть",
"manageServers": "редактировать список серверов",
"expandSidebar": "развернуть боковую панель",
"collapseSidebar": "Скрыть боковую панель",
"openBrowserDevtools": "открыть инструменты разработчика",
"quit": "$t(common.quit)",
"goBack": "назад",
"goForward": "вперёд"
},
"manageServers": {
"title": "сервера",
"serverDetails": "информация о сервере",
"url": "адрес",
"username": "пользователь",
"editServerDetailsTooltip": "изменить настройки сервера",
"removeServer": "удалить сервер"
},
"contextMenu": {
"addToPlaylist": "$t(action.addToPlaylist)",
"addToFavorites": "$t(action.addToFavorites)",
@@ -360,21 +419,24 @@
"removeFromFavorites": "$t(action.removeFromFavorites)",
"addNext": "$t(player.addNext)",
"deselectAll": "$t(action.deselectAll)",
"download": "скачать",
"addLast": "$t(player.addLast)",
"addFavorite": "$t(action.addToFavorites)",
"play": "$t(player.play)",
"numberSelected": "{{count}} выбрано",
"removeFromQueue": "$t(action.removeFromQueue)"
"removeFromQueue": "$t(action.removeFromQueue)",
"showDetails": "получить информацию",
"shareItem": "поделиться"
},
"home": {
"mostPlayed": "наибольшее кол-во воспроизведений",
"mostPlayed": "слушают чаще всего",
"newlyAdded": "недавно добавленные релизы",
"title": "$t(common.home)",
"explore": "изучите вашу медиатеку",
"recentlyPlayed": "недавно прослушано"
"explore": "откройте новое",
"recentlyPlayed": "игралось недавно"
},
"albumDetail": {
"moreFromArtist": "больше из жанра $t(entity.genre_one)",
"moreFromArtist": "больше от $t(entity.artist_one)",
"moreFromGeneric": "больше из {{item}}"
},
"setting": {
@@ -387,10 +449,13 @@
"title": "$t(entity.albumArtist_other)"
},
"genreList": {
"title": "$t(entity.genre_other)"
"title": "$t(entity.genre_other)",
"showAlbums": "показать $t(entity.genre_one) $t(entity.album_many)",
"showTracks": "показать $t(entity.genre_one) $t(entity.track_many)"
},
"trackList": {
"title": "$t(entity.track_other)"
"title": "$t(entity.track_other)",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)"
},
"globalSearch": {
"commands": {
@@ -400,18 +465,39 @@
},
"title": "комманды"
},
"playlist": {
"reorder": "сортировка доступна только по ID"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"albumList": {
"title": "$t(entity.album_other)"
"title": "$t(entity.album_other)",
"artistAlbums": "альбомы {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
},
"albumArtistDetail": {
"topSongs": "популярные треки",
"viewAll": "посмотреть всё",
"appearsOn": "появляется в",
"viewDiscography": "посмотреть дискографию",
"relatedArtists": "похож на $t(entity.artist_many)",
"viewAllTracks": "посмотреть все $t(entity.track_other)",
"recentReleases": "недавние релизы",
"about": "О {{artist}}",
"topSongsFrom": "популярные треки из {{title}}"
},
"itemDetail": {
"copyPath": "скопировать путь в буфер обмена",
"openFile": "открыть трек в менеджере файлов",
"copiedPath": "путь успешно скопирован"
}
},
"form": {
"deletePlaylist": {
"title": "удалить $t(entity.playlist_one)",
"success": "$t(entity.playlist_one) успешно удалён",
"input_confirm": "напишите название $t(entity.playlist_one), чтобы подтвердить действие"
"input_confirm": "напишите название $t(entity.playlist_few) для подтверждения"
},
"createPlaylist": {
"input_description": "$t(common.description)",
@@ -424,24 +510,24 @@
"addServer": {
"title": "добавить сервер",
"input_username": "пользователь",
"input_url": "url",
"input_url": "адрес",
"input_password": "пароль",
"input_legacyAuthentication": "включить старую аутентификацию",
"input_legacyAuthentication": "включить старую авторизацию",
"input_name": "название сервера",
"success": "сервер добавлен успешно",
"success": "сервер успешно добавлен",
"input_savePassword": "сохранить пароль",
"ignoreSsl": "ignore ssl $t(common.restartRequired)",
"ignoreCors": "$t(common.restartRequired)",
"error_savePassword": "произошла ошибка во время попытки сохранения пароля"
"ignoreSsl": "игнорировать ssl ($t(common.restartRequired))",
"ignoreCors": "игнорировать CORS ($t(common.restartRequired))",
"error_savePassword": "произошла ошибка при сохранении пароля"
},
"addToPlaylist": {
"success": "добавлено(а) {{message}} $t(entity.track_other) в {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "добавлено: $t(entity.trackWithCount, {\"count\": {{message}} }) в $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "добавить в $t(entity.playlist_one)",
"input_skipDuplicates": "пропустить дубликаты",
"input_skipDuplicates": "не добавлять дубликаты",
"input_playlists": "$t(entity.playlist_other)"
},
"updateServer": {
"title": "обновить сервер",
"title": "обновление сервера",
"success": "сервер успешно обновлён"
},
"queryEditor": {
@@ -455,35 +541,221 @@
},
"editPlaylist": {
"title": "редактировать $t(entity.playlist_one)"
},
"shareItem": {
"success": "ссылка скопирована в буфер обмена (нажмите здесь, чтобы открыть)",
"expireInvalid": "время истечения срока действия должно быть в будущем",
"createFailed": "не удалось создать ссылку для общего доступа (проверьте, включен ли общий доступ?)",
"allowDownloading": "разрешить скачивание",
"setExpiration": "установить срок действия",
"description": "описание"
}
},
"setting": {
"accentColor": "цвет акцента",
"accentColor_description": "устанавливает цвет акцента для приложения",
"albumBackground": "фоновое изображение альбомов",
"albumBackground_description": "добавляет фоновое изображение для страниц альбомов, содержащих обложку",
"albumBackgroundBlur": "размытие фонового изображения альбома",
"albumBackgroundBlur_description": "определяет степень размытия фонового изображения на странице альбомов",
"applicationHotkeys": "горячие клавиши приложения",
"crossfadeStyle_description": "Выберите вид эффекта crossfade для аудиоплеера",
"enableRemote_description": "Включает сервер удалённого управления для управления воспроизведением с помощью других устройств",
"fontType_optionSystem": "Системный шрифт",
"mpvExecutablePath_description": "Укажите папку, в которой находится исполняющий файл аудиоплеера MPV",
"crossfadeStyle": "Вид эффекта crossfade",
"fontType_optionBuiltIn": "Встроенный в приложение",
"disableLibraryUpdateOnStartup": "Отключить проверку новых версий при запуске приложения",
"minimizeToTray_description": "Сворачивать приложение в панель уведомлений",
"audioPlayer_description": "Укажите - какой аудиоплеер использовать для воспроизведения",
"disableAutomaticUpdates": "Отключить проверку обновлений",
"crossfadeStyle_description": "выберите вид эффекта crossfade для аудиоплеера",
"customCssEnable": "использовать кастомные css",
"customCssEnable_description": "разрешить использование кастомных css.",
"enableRemote_description": "включает сервер удалённого управления для управления воспроизведением с помощью других устройств",
"fontType_optionSystem": "системный",
"mpvExecutablePath_description": "укажите папку, в которой находится исполняющий файл аудиоплеера MPV. если оставить пустым, будет использоваться путь по умолчанию",
"crossfadeStyle": "вид эффекта crossfade",
"fontType_optionBuiltIn": "встроенный",
"disableLibraryUpdateOnStartup": "отключить проверку новых версий при запуске приложения",
"minimizeToTray_description": "сворачивать приложение в панель уведомлений",
"audioPlayer_description": "укажите, какой аудиоплеер использовать для воспроизведения",
"disableAutomaticUpdates": "отключить проверку обновлений",
"exitToTray_description": "При закрытии приложения - оно останется в панели уведомлений",
"fontType_optionCustom": "Пользовательский шрифт",
"remotePassword": "Пароль к серверу удалённого управления",
"fontType_optionCustom": "пользовательский",
"remotePassword": "пароль к серверу удалённого управления",
"font": "Шрифт",
"crossfadeDuration_description": "Укажите длительность эффекта crossfade",
"mpvExecutablePath": "Папка с аудиоплеером MPV",
"exitToTray": "Сворачивать в панель уведомлений при закрытии",
"enableRemote": "Включить сервер удалённого управления",
"fontType": "Источник шрифта",
"mpvExecutablePath": "папка с аудиоплеером MPV",
"exitToTray": "сворачивать в панель уведомлений при закрытии",
"enableRemote": "включить сервер удалённого управления",
"fontType": "тип шрифта",
"crossfadeDuration": "Длительность эффекта crossfade",
"audioPlayer": "Аудиоплеер",
"minimizeToTray": "Сворачивать в панель уведомлений",
"font_description": "Выберите - какой шрифт использовать в приложении",
"remoteUsername": "Имя пользователя для доступа к серверу удалённого управления"
"minimizeToTray": "сворачивать в панель уведомлений",
"font_description": "Выберите, какой шрифт использовать в приложении",
"remoteUsername": "имя пользователя для доступа к серверу удалённого управления",
"buttonSize_description": "размер кнопок в панели управления воспроизведением",
"clearCache": "очистить кэш браузера",
"clearQueryCache": "очистить кэш feishin",
"audioDevice": "устройство воспроизведения",
"audioDevice_description": "выберите устройство воспроизведения (только в режиме аудиоплеера web)",
"buttonSize": "размер кнопок панели управления воспроизведением",
"hotkey_volumeDown": "уменьшить громкость",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"theme_description": "устанавливает тему, которая будет использоваться в приложении",
"passwordStore": "хранилище паролей/секретов",
"sidebarPlaylistList": "список плейлистов в боковой панели",
"windowBarStyle_description": "выберите стиль заголовка окна",
"followLyric": "следовать за текстом трека",
"volumeWheelStep": "шаг регулировки громкости колёсиком мыши",
"windowBarStyle": "стиль заголовка окна",
"hotkey_zoomOut": "уменьшить масштаб",
"playbackStyle_optionCrossFade": "затухание",
"replayGainMode": "режим {{ReplayGain}}",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainMode_optionTrack": "$t(entity.track_one)",
"clearQueryCache_description": "так называемая \"мягкая очистка\" feishin: обновляются плейлисты, метаданные треков, но сохранённые тексты треков сбрасываются. настройки, учётные данные и кэшированные изображения сохраняются",
"hotkey_favoriteCurrentSong": "добавить $t(common.currentSong) в избранное",
"genreBehavior": "поведения страницы жанров",
"globalMediaHotkeys": "глобальные мультимедийные горячие клавиши",
"hotkey_browserForward": "кнопка браузера \"вперёд\"",
"hotkey_favoritePreviousSong": "добавить $t(common.previousSong) в избранное",
"hotkey_globalSearch": "глобальный поиск",
"hotkey_playbackNext": "следующий трек",
"hotkey_playbackPause": "пауза",
"hotkey_playbackPlay": "играть",
"hotkey_playbackPlayPause": "играть / пауза",
"hotkey_playbackPrevious": "предыдущий трек",
"hotkey_playbackStop": "остановить",
"hotkey_rate0": "убрать оценку",
"hotkey_rate1": "оценить в 1 звезду",
"hotkey_rate2": "оценить в 2 звезды",
"hotkey_rate3": "оценить в 3 звезды",
"hotkey_rate4": "оценить в 4 звезды",
"hotkey_rate5": "оценить в 5 звёзд",
"hotkey_skipForward": "перемотать вперёд",
"hotkey_toggleCurrentSongFavorite": "добавить/удалить $t(common.currentSong) в избранное",
"hotkey_toggleFullScreenPlayer": "включение/выключение полноэкранного плеера",
"hotkey_togglePreviousSongFavorite": "добавить/удалить $t(common.previousSong) в избранное",
"hotkey_toggleRepeat": "переключить режим повтора",
"hotkey_toggleShuffle": "переключить перемешивание",
"hotkey_unfavoriteCurrentSong": "удалить $t(common.currentSong) из избранного",
"hotkey_unfavoritePreviousSong": "удалить $t(common.previousSong) из избранного",
"hotkey_volumeUp": "увеличить громкость",
"hotkey_zoomIn": "увеличить масштаб",
"language": "язык",
"lyricFetchProvider_description": "выберите источники для получения текстов песен. порядок источников соответствует очередности их запросов",
"minimumScrobblePercentage_description": "минимальный процент прослушивания трека, прежде чем он будет засчитан как прослушанный",
"minimumScrobbleSeconds_description": "минимальное время прослушивания трека в секундах, прежде чем он будет засчитан как прослушанный",
"playbackStyle_optionNormal": "нормальный",
"mpvExtraParameters": "параметры mpv",
"mpvExtraParameters_help": "по одному на строчку",
"playbackStyle_description": "выберите стиль воспроизведения, который будет использоваться аудиоплеером",
"playButtonBehavior_description": "устанавливает поведение кнопки воспроизведения при добавлении треков в очередь",
"playButtonBehavior": "поведение кнопки воспроизведения",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playerAlbumArtResolution_description": "разрешение большой версии обложки альбома в проигрывателе. при большем разрешении она выглядит более четкой, но может замедлить загрузку. по умолчанию равно 0 - устанавливает разрешение автоматически",
"playerbarOpenDrawer": "полноэкранный переключатель по панели проигрывателя",
"playerbarOpenDrawer_description": "позволяет перейти в полноэкранный режим воспроизведения нажатием на панель проигрывателя",
"remotePort": "порт сервера удалённого управления",
"remotePort_description": "устанавливает порт для сервера удалённого управления",
"replayGainClipping": "{{ReplayGain}} клиппинг",
"replayGainFallback": "{{ReplayGain}} по умолчанию",
"sampleRate_description": "выберите выходную частоту дискретизации, которая будет использоваться, если выбранная частота дискретизации отличается от частоты дискретизации текущего трека. при значении меньше 8000 будет использоваться частота по умолчанию",
"savePlayQueue_description": "сохранять очередь воспроизведения при закрытии приложения и восстанавливать при запуске приложения",
"showSkipButton_description": "показывать или скрывать кнопки перемотки на панели управления воспроизведением",
"sidebarConfiguration": "конфигурация боковой панели",
"sidebarConfiguration_description": "выбрать элементы и порядок их отображения на боковой панели",
"sidebarCollapsedNavigation": "кнопки навигации в боковой панели (в свёрнутом режиме)",
"showSkipButtons": "показывать кнопки перемотки",
"showSkipButtons_description": "показывать или скрывать кнопки перемотки на панели управления воспроизведением",
"sidebarPlaylistList_description": "показать или скрыть список плейлистов на боковой панели",
"sidePlayQueueStyle": "вид отображения боковой очереди",
"sidePlayQueueStyle_description": "определяет вид отображения боковой очереди",
"sidePlayQueueStyle_optionAttached": "закрепленная",
"sidePlayQueueStyle_optionDetached": "плавающая",
"skipDuration": "время перемотки",
"skipDuration_description": "задает время перемотки при использовании кнопок перемотки на панели проигрывателя",
"useSystemTheme": "использовать тему системы",
"themeLight": "тема (светлая)",
"themeLight_description": "устанавливает светлую тему приложения",
"transcodeNote": "эффект применяется после 1 (для веб) - 2 (для mpv) песни",
"transcode": "включить транскодинг",
"transcode_description": "активирует транскодинг в другие форматы",
"transcodeBitrate": "битрейт транскодинга",
"transcodeBitrate_description": "выберите битрейт транскодинга. 0 - автоматическое определение сервером",
"transcodeFormat": "формат транкодинга",
"useSystemTheme_description": "использует тему, заданную в системе (светлую/тёмную)",
"zoom": "процент масштабирования",
"zoom_description": "устанавливает процент масштабирования приложения",
"floatingQueueArea": "показать область наведения для всплывающей очереди",
"genreBehavior_description": "определяет, что отобразится при открытии на жанр — список треков или альбомов",
"globalMediaHotkeys_description": "включить или отключить использование системных мультимедийных горячих клавиш для управления воспроизведением",
"homeConfiguration_description": "позволяет настроить видимость и порядок элементов на домашней странице",
"homeFeature": "улучшенная карусель на главной",
"homeFeature_description": "определяет, показывать ли улучшенную карусель на главной странице",
"hotkey_toggleQueue": "показать/скрыть очередь воспроизведения",
"imageAspectRatio": "использовать оригинальное соотношение сторон обложки",
"imageAspectRatio_description": "если эта опция включена, обложки будут отображаться в соответствии с их собственным соотношением сторон. для обложек не 1:1 оставшееся пространство будет пустым",
"minimumScrobblePercentage": "минимальное время для скробблинга (в процентах)",
"playbackStyle": "стиль воспроизведения",
"playerAlbumArtResolution": "разрешение обложки альбома",
"remotePassword_description": "задает пароль для сервера удалённого управления. По умолчанию эти учетные данные передаются небезопасным способом, поэтому следует использовать уникальный пароль, который вам неважен",
"replayGainClipping_description": "Предотвращение клиппинга, вызванного {{ReplayGain}}, путём автоматического снижения усиления",
"replayGainFallback_description": "усиление в db для применения, если у файла нет тегов {{ReplayGain}}",
"replayGainMode_description": "регулировать усиление громкости в соответствии со значениями {{ReplayGain}}, хранящимися в метаданных файла",
"savePlayQueue": "сохранять очередь воспроизведения",
"showSkipButton": "показывать кнопки перемотки",
"theme": "тема",
"themeDark": "тема (тёмная)",
"externalLinks": "показывать ссылки на внешние ресурсы",
"gaplessAudio": "бесшовное воспроизведение",
"gaplessAudio_optionWeak": "слабое (рекомендуется)",
"gaplessAudio_description": "устанавливает настройку mpv для бесшовного воспроизведение",
"hotkey_browserBack": "кнопка браузера \"назад\"",
"hotkey_localSearch": "поиск на странице",
"hotkey_skipBackward": "перемотать назад",
"startMinimized": "запуск в свёрнутом режиме",
"themeDark_description": "устанавливает тёмную тему приложения",
"hotkey_volumeMute": "отключить звук",
"clearCache_description": "\"жесткая очистка\" feishin: кроме очистки кэша feishin, также очищает кэш браузера (сохранённые картинки и другие ресурсы). учётные данные и настройки сохраняются",
"clearCacheSuccess": "кэш успешно удалён",
"contextMenu": "конфигурация контекстного меню (нажатие правой кнопкой мыши)",
"contextMenu_description": "позволяет скрыть элементы, отображаемые в меню, появляющемся при нажатии правой кнопки мыши на элемент. все, что не отмечено, будет скрыто",
"customFontPath": "путь к пользовательскому шрифту",
"customFontPath_description": "укажите путь к пользовательскому шрифту, который будет использоваться в приложении",
"externalLinks_description": "включает отображение внешних ссылок (Last.fm, MusicBrainz) на страницах альбомов и артистов",
"floatingQueueArea_description": "включить отображение иконки наведения на правой части экрана, чтобы показать очередь воспроизведения",
"followLyric_description": "прокручивать текст трека до текущей позиции воспроизведения",
"language_description": "устанавливает язык приложения ($t(common.restartRequired))",
"lyricFetch_description": "получать тексты треков из различных интернет-источников",
"lyricFetchProvider": "источник текстов треков",
"minimumScrobbleSeconds": "минимальное время для скробблинга (в секундах)",
"replayGainPreamp": "предусиление {{ReplayGain}} (дБ)",
"sidebarCollapsedNavigation_description": "показать или скрыть кнопки навигации в свёрнутой боковой панели",
"homeConfiguration": "конфигурация домашней страницы",
"remoteUsername_description": "задает имя пользователя для сервера удалённого управления. если имя пользователя и пароль пусты, аутентификация будет отключена",
"scrobble": "скробблинг",
"replayGainPreamp_description": "настройка усиления предусилителя, применяемого к значениям {{ReplayGain}}",
"passwordStore_description": "какое хранилище паролей/секретов использовать. измените это значение, если у вас есть проблемы с хранением паролей.",
"lyricFetch": "получать тексты треков из интернета",
"sampleRate": "частота дискретизации",
"scrobble_description": "скробблинг треков на вашем медиасервере",
"startMinimized_description": "запуск приложения в области уведомлений",
"volumeWheelStep_description": "количество громкости, изменяемое при прокрутке колёсика мыши над ползунком громкости",
"volumeWidth": "ширина слайдера звука",
"volumeWidth_description": "ширина слайдера звука (в px)",
"webAudio": "использовать веб аудио",
"webAudio_description": "использование веб аудио. включение активирует продвинутые возможности (например, replaygain). отключите, если вам это не нужно",
"discordRichPresence": "состояние профиля {{discord}}",
"discordApplicationId": "{{discord}} application id",
"discordApplicationId_description": "application id приложения {{discord}} которое будет отображаться в статусе профиля (по умолчанию {{defaultId}})",
"discordIdleStatus": "показывать состояние простоя",
"discordIdleStatus_description": "если включено, то обновляет статус, когда пользователь бездействует",
"discordUpdateInterval": "интервал обновления статуса профиля {{discord}}",
"discordUpdateInterval_description": "время в секундах между каждым обновлением (минимум 15 секунд)",
"doubleClickBehavior": "добавить в очередь все найденные треки при двойном клике",
"doubleClickBehavior_description": "есть включено: все найденные в поиске треки будут добавлены в очередь при двойном клике (иначе - только выбранный)",
"lyricOffset_description": "Смещение появления текста треков на указанное количество миллисекунд",
"skipPlaylistPage": "пропускать страницу плейлиста",
"applicationHotkeys_description": "настройка горячих клавиш приложения. поставьте галочку, чтобы сделать горячую клавишу глобальной (только для ПК)",
"artistConfiguration": "конфигурация страницы альбомов исполнителей",
"artistConfiguration_description": "позволяет настроить видимость и порядок элементов на странице альбомов исполнителей",
"fontType_description": "встроенный позволяет выбрать один из шрифтов, предоставляемых Feishin. системный позволяет выбрать любой шрифт, предоставляемый вашей операционной системой. пользовательский позволяет выбрать свой собственный шрифт",
"discordRichPresence_description": "включить статус воспроизведения в статус профиля в {{discord}}. Ключи изображений: {{icon}}, {{playing}} и {{paused}} ",
"lyricOffset": "синхронизация текста треков (мс)"
}
}
+159 -89
View File
@@ -1,7 +1,7 @@
{
"action": {
"editPlaylist": "编辑 $t(entity.playlist_one)",
"moveToTop": "至顶部",
"editPlaylist": "编辑$t(entity.playlist_one)",
"moveToTop": "至顶部",
"clearQueue": "清空播放队列",
"addToFavorites": "添加到$t(entity.favorite_other)",
"addToPlaylist": "添加到$t(entity.playlist_one)",
@@ -12,11 +12,11 @@
"deletePlaylist": "删除$t(entity.playlist_one)",
"removeFromQueue": "从播放队列中移除",
"deselectAll": "取消全选",
"moveToBottom": "至底部",
"moveToBottom": "至底部",
"setRating": "评分",
"toggleSmartPlaylistEditor": "切换$t(entity.smartPlaylist)编辑器",
"removeFromFavorites": "从$t(entity.favorite_other)移除",
"goToPage": "转到页面",
"goToPage": "前往页面",
"openIn": {
"lastfm": "在 Last.fm 中打开",
"musicbrainz": "在 MusicBrainz 中打开"
@@ -67,8 +67,8 @@
"bitrate": "比特率",
"saveAndReplace": "保存并替换",
"action_other": "操作",
"confirm": "确",
"resetToDefault": "重置为默认",
"confirm": "确",
"resetToDefault": "重置为默认状态",
"home": "主页",
"comingSoon": "即将上线…",
"reset": "重置",
@@ -80,34 +80,35 @@
"quit": "退出",
"expand": "展开",
"search": "搜索",
"saveAs": "存为",
"saveAs": "存为",
"random": "随机",
"biography": "简介",
"sortOrder": "顺序",
"backward": "返回",
"backward": "后退",
"gap": "空隙",
"limit": "限制",
"duration": "时长",
"ok": "好",
"no": "否",
"playerMustBePaused": "播放器须暂停",
"playerMustBePaused": "播放器须暂停",
"channel_other": "频道",
"none": "无",
"disc": "",
"disc": "碟片",
"yes": "是",
"size": "大小",
"areYouSure": "是否继续",
"areYouSure": "是否确定",
"note": "注释",
"close": "关闭",
"albumPeak": "专辑峰值",
"mbid": "MusicBrainz ID",
"reload": "重新加载",
"reload": "重载",
"trackGain": "音轨增益",
"trackPeak": "音轨峰值",
"albumGain": "专辑增益",
"codec": "编解码器",
"share": "分享",
"preview": "预览"
"preview": "预览",
"translation": "翻译"
},
"entity": {
"albumArtist_other": "专辑艺术家",
@@ -125,13 +126,14 @@
"folder_other": "文件夹",
"smartPlaylist": "智能$t(entity.playlist_one)",
"genreWithCount_other": "{{count}} 种流派",
"trackWithCount_other": "{{count}} 首乐曲"
"trackWithCount_other": "{{count}} 首乐曲",
"play_other": "{{count}} 次播放"
},
"player": {
"repeat_all": "全部循环",
"repeat_all": "循环全部",
"stop": "停止",
"repeat": "循环",
"queue_remove": "移除所选",
"queue_remove": "移除所选",
"playRandom": "随机播放",
"skip": "跳过",
"previous": "上一首",
@@ -145,25 +147,27 @@
"addNext": "添加为播放列表下一首",
"playbackFetchCancel": "请稍等…关闭通知以取消操作",
"play": "播放",
"repeat_off": "循环",
"repeat_off": "循环关闭",
"queue_clear": "清空播放队列",
"muted": "已静音",
"unfavorite": "取消收藏",
"queue_moveToTop": "使所选置底",
"queue_moveToBottom": "使所选置顶",
"shuffle_off": "未启用随机播放",
"addLast": "添加播放列表末尾",
"queue_moveToTop": "所选项移至底部",
"queue_moveToBottom": "所选项移至顶部",
"shuffle_off": "用随机播放",
"addLast": "添加播放列表末尾",
"mute": "静音",
"skip_forward": "向前跳过",
"playbackSpeed": "播放速度",
"pause": "暂停"
"pause": "暂停",
"playSimilarSongs": "播放类似的曲目",
"viewQueue": "查看播放队列"
},
"setting": {
"crossfadeStyle_description": "选择用于音频播放器的淡入淡出风格",
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
"hotkey_favoriteCurrentSong": "收藏$t(common.currentSong)",
"crossfadeStyle": "淡入淡出风格",
"audioExclusiveMode_description": "启用独占输出模式。在此模式下,系统通常被锁定只有 mpv 能够输出音频",
"disableLibraryUpdateOnStartup": "禁用启动时查新版本",
"audioExclusiveMode_description": "启用独占输出模式。在此模式下,系统通常被锁定只有 mpv 能够输出音频",
"disableLibraryUpdateOnStartup": "禁用启动时查新版本",
"gaplessAudio": "无缝音频",
"audioPlayer_description": "选择用于播放的音频播放器",
"globalMediaHotkeys": "全局媒体快捷键",
@@ -190,48 +194,48 @@
"audioDevice_description": "选择用于播放的音频设备(仅 web 播放器)",
"enableRemote_description": "启用远程控制服务器,以允许其他设备控制此应用",
"remotePort_description": "设置远程服务器端口",
"hotkey_skipBackward": "向跳过",
"hotkey_skipBackward": "向跳过",
"replayGainMode_description": "根据乐曲元数据中存储的{{ReplayGain}}值调整音量增益",
"volumeWheelStep_description": "在音量滑块上滚动鼠标滚轮时要更改的音量大小",
"theme_description": "设置应用的主题",
"hotkey_playbackPause": "暂停",
"replayGainFallback": "{{ReplayGain}}后备替代",
"replayGainFallback": "{{ReplayGain}}后备方案",
"sidebarCollapsedNavigation_description": "在折叠的侧边栏中显示或隐藏导航",
"hotkey_volumeUp": "音量增高",
"skipDuration": "跳过时长",
"showSkipButtons": "显示跳过按钮",
"playButtonBehavior_optionPlay": "$t(player.play)",
"minimumScrobblePercentage": "最小 scrobble 时长(百分比)",
"minimumScrobblePercentage": "最小记录时长(百分比)",
"lyricFetch": "从互联网获取歌词",
"scrobble": "记录播放信息Scrobble",
"scrobble": "记录播放信息",
"skipDuration_description": "设置每次按下跳过按钮将会跳过的时长",
"fontType_optionSystem": "系统字体",
"mpvExecutablePath_description": "设置 mpv 二进制文件的路径。如果留空,则使用默认路径",
"mpvExecutablePath_description": "设置 mpv 可执行文件的路径。如果留空,则使用默认路径",
"sampleRate": "采样率",
"sidePlayQueueStyle_optionAttached": "吸附",
"sidebarConfiguration": "侧边栏设定",
"sampleRate_description": "如果选择的采样频率与当前媒体的采样频率不同,请选择要使用的输出采样率。小于 8000 的值将使用默认频率",
"replayGainMode_optionNone": "$t(common.none)",
"hotkey_zoomIn": "放大",
"scrobble_description": "在你的社交媒体中记录播放信息",
"scrobble_description": "在你的媒体服务器中记录播放信息",
"hotkey_browserForward": "浏览器前进",
"themeLight": "主题(浅色)",
"fontType_optionBuiltIn": "内置字体",
"hotkey_playbackPlayPause": "播放/暂停",
"hotkey_rate1": "评为 1 星",
"hotkey_skipForward": "向跳过",
"hotkey_skipForward": "向跳过",
"sidePlayQueueStyle": "侧边播放列表样式",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"zoom": "缩放率",
"minimizeToTray_description": "将应用程序最小化到系统托盘",
"hotkey_playbackPlay": "播放",
"hotkey_togglePreviousSongFavorite": "收藏 / 取消收藏$t(common.previousSong)",
"hotkey_togglePreviousSongFavorite": "切换收藏$t(common.previousSong)",
"hotkey_volumeDown": "音量降低",
"hotkey_unfavoritePreviousSong": "取消收藏$t(common.previousSong)",
"hotkey_globalSearch": "全局搜索",
"remoteUsername_description": "设置远程控制服务器的用户名。如果用户名和密码都为空,则身份验证将被禁用",
"exitToTray_description": "退出应用时最小化到系统托盘而非关闭",
"hotkey_favoritePreviousSong": "收藏 $t(common.previousSong)",
"exitToTray_description": "退出应用时最小化到系统托盘",
"hotkey_favoritePreviousSong": "收藏$t(common.previousSong)",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
"lyricOffset": "歌词偏移(毫秒)",
"fontType_optionCustom": "自定义字体",
@@ -239,49 +243,49 @@
"remotePassword": "远程控制服务器密码",
"lyricFetchProvider": "歌词源",
"language_description": "设置应用的语言($t(common.restartRequired)",
"playbackStyle_optionCrossFade": "交叉淡入淡出",
"playbackStyle_optionCrossFade": "淡入淡出",
"hotkey_rate3": "评为 3 星",
"mpvExtraParameters": "mpv 参数",
"replayGainMode_optionTrack": "$t(entity.track_one)",
"themeLight_description": "应用将使用浅色主题",
"hotkey_toggleFullScreenPlayer": "全屏播放",
"hotkey_localSearch": "页面内搜索",
"hotkey_toggleQueue": "显示 / 隐藏播放队列",
"hotkey_toggleQueue": "切换播放队列",
"zoom_description": "设置应用程序的缩放率",
"remotePassword_description": "设置远程控制服务器的密码。这些凭据默认以不安全的方式传输,因此您应该使用一个您不在意的唯一密码",
"hotkey_rate5": "评为 5 星",
"hotkey_playbackPrevious": "上一",
"showSkipButtons_description": "在播放条显示/隐藏播放按钮",
"hotkey_playbackPrevious": "上一",
"showSkipButtons_description": "在播放条显示隐藏播放按钮",
"language": "语言",
"playbackStyle": "播放风格",
"hotkey_toggleShuffle": "切换随机播放设定",
"hotkey_toggleShuffle": "切换随机",
"theme": "主题",
"playbackStyle_description": "选择播放器的播放风格",
"mpvExecutablePath": "mpv 二进制文件路径",
"playbackStyle_description": "选择音频播放器的播放风格",
"mpvExecutablePath": "mpv 可执行文件路径",
"hotkey_rate2": "评为 2 星",
"playButtonBehavior_description": "设置将歌曲添加到队列时播放按钮的默认行为",
"minimumScrobblePercentage_description": "歌曲被记录为已播放scrobble所需的最小播放百分比",
"playButtonBehavior_description": "设置将歌曲添加到播放队列时播放按钮的默认行为",
"minimumScrobblePercentage_description": "歌曲被记录为已播放所需的最小播放百分比",
"exitToTray": "退出时最小化到托盘",
"hotkey_rate4": "评为 4 星",
"showSkipButton_description": "在播放条上显示/隐藏跳过按钮",
"savePlayQueue": "保存播放列",
"minimumScrobbleSeconds_description": "歌曲被记录为已播放scrobble所需的最小播放时间",
"showSkipButton_description": "在播放条上显示隐藏跳过按钮",
"savePlayQueue": "保存播放列",
"minimumScrobbleSeconds_description": "歌曲被记录为已播放所需的最小播放时间",
"skipPlaylistPage_description": "打开歌单时,直接查看歌曲列表而非查看默认页面",
"fontType_description": "内置字体可以选择 Feishin 提供的字体之一。系统字体允许您选择操作系统提供的任何字体。自定义选项允许您使用自己的字体",
"playButtonBehavior": "播放按钮行为",
"volumeWheelStep": "音量滚轮步长",
"volumeWheelStep": "音量滚轮分度",
"sidebarPlaylistList_description": "显示或隐藏侧边栏歌单列表",
"sidePlayQueueStyle_description": "设置侧边播放列表样式",
"replayGainMode": "{{ReplayGain}}模式",
"playbackStyle_optionNormal": "常",
"playbackStyle_optionNormal": "常",
"windowBarStyle": "窗口顶栏风格",
"floatingQueueArea": "显示浮动队列悬停区域",
"replayGainFallback_description": "乐曲没有{{ReplayGain}}标签时应用的增益(以分贝为单位)",
"hotkey_toggleRepeat": "切换循环播放设定",
"hotkey_toggleRepeat": "切换循环",
"lyricOffset_description": "将歌词偏移指定的毫秒数",
"sidebarConfiguration_description": "选择侧边栏包含的项目与顺序",
"remotePort": "远程服务器端口",
"hotkey_playbackNext": "下一",
"hotkey_playbackNext": "下一",
"useSystemTheme_description": "使用系统定义的浅色或深色主题",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"lyricFetch_description": "从多个互联网源获取歌词",
@@ -292,16 +296,16 @@
"hotkey_rate0": "清除评分",
"floatingQueueArea_description": "在屏幕右侧显示一个悬停图标,以查看播放队列",
"hotkey_volumeMute": "静音",
"hotkey_toggleCurrentSongFavorite": "收藏 / 取消收藏$t(common.currentSong)",
"remoteUsername": "远程服务器用户名",
"hotkey_toggleCurrentSongFavorite": "切换收藏$t(common.currentSong)",
"remoteUsername": "远程控制服务器用户名",
"hotkey_browserBack": "浏览器后退",
"showSkipButton": "显示跳过按钮",
"sidebarPlaylistList": "侧边栏歌单列表",
"minimizeToTray": "最小化到托盘",
"skipPlaylistPage": "跳过歌单页面",
"skipPlaylistPage": "跳过播放列表页面",
"themeDark": "主题(深色)",
"sidebarCollapsedNavigation": "侧边栏(已折叠)导航",
"minimumScrobbleSeconds": "最小 scrobble 时间(秒)",
"minimumScrobbleSeconds": "最小记录时间(秒)",
"hotkey_playbackStop": "停止",
"windowBarStyle_description": "选择窗口顶栏的风格",
"savePlayQueue_description": "当应用程序关闭时保存播放队列,并在应用程序打开时恢复它",
@@ -322,15 +326,15 @@
"clearCache": "清除浏览器缓存",
"buttonSize": "播放器栏按钮大小",
"buttonSize_description": "播放器栏按钮大小",
"clearCache_description": "feishin的“硬清除”。除了清除feishin的缓存,清空浏览器缓存(保存的图像和其他资源)。会保留服务器凭据和设置",
"clearQueryCache_description": "feishin的“软清除”。这将会刷新播放列表、元数据并重置保存的歌词。会保留设置、服务器凭据和缓存图像",
"clearCache_description": "feishin的“硬清除”。除了清除feishin的缓存,清空浏览器缓存(保存的图像和其他资源)。服务器凭据和设置会被保留",
"clearQueryCache_description": "feishin的“软清除”。这将会刷新播放列表、元数据并重置保存的歌词。设置、服务器凭据和缓存图像会被保留",
"clearQueryCache": "清除feishin缓存",
"externalLinks": "显示外部链接",
"externalLinks_description": "允许在艺术家/专辑页面上显示外部链接(Last.fm、MusicBrainz",
"mpvExtraParameters_help": "每行一个",
"startMinimized": "启动最小化",
"startMinimized_description": "在系统托盘中启动应用程序",
"passwordStore_description": "使用什么密码/密存储。如果您在存储密码时遇到问题,请更改此设置。",
"passwordStore_description": "使用什么密码/密存储。如果您在存储密码时遇到问题,请更改此设置。",
"clearCacheSuccess": "缓存清除成功",
"playerAlbumArtResolution": "播放器专辑封面分辨率",
"playerAlbumArtResolution_description": "大型播放器专辑封面预览的分辨率。较大使其看起来更清晰,但可能会减慢加载速度。默认为0,表示自动",
@@ -338,7 +342,50 @@
"genreBehavior_description": "确定单击流派是否默认在曲目或专辑列表中打开",
"homeConfiguration": "主页配置",
"homeConfiguration_description": "配置主页上显示的项目以及显示顺序",
"passwordStore": "密码/密存储"
"passwordStore": "密码/密存储",
"homeFeature_description": "控制是否在主页上显示大型特色轮播",
"homeFeature": "首页 精选 轮播",
"imageAspectRatio": "保留封面图像纵横比",
"imageAspectRatio_description": "如果启用,封面图像将保留纵横比显示。对于不是1:1的图像,剩余的空间将是空的",
"doubleClickBehavior_description": "如果为真,则曲目搜索中所有匹配的曲目都将被加入播放队列。否则,只有单击的曲目才会被加入播放队列",
"doubleClickBehavior": "双击时将所有搜索到的曲目加入播放队列",
"volumeWidth": "音量滑块宽度",
"volumeWidth_description": "音量滑块的宽度",
"discordListening": "显示状态为正在监听",
"discordListening_description": "将状态显示为 “正在监听”,而不是 “正在播放”。请注意,这当前会破坏计时器栏",
"contextMenu_description": "允许您隐藏右键单击项目时显示在菜单中的项目。未选中的项目将被隐藏",
"customCssEnable_description": "允许编写自定义 css。",
"customCss": "自定义css",
"customCss_description": "自定义css内容。注意:内容和远程url是不允许的属性。内容预览展示如下。出于安全考虑,您未设置的其它字段也会显示。",
"contextMenu": "上下文菜单(右键单击)配置",
"customCssEnable": "启用自定义 css",
"customCssNotice": "警告:虽然预设了一些安全限制(不允许 url() 和 content:),但使用自定义 CSS 仍然会因更改界面而带来风险。",
"transcodeNote": "1web-2mpv)首歌曲后生效",
"transcode": "启用转码",
"transcode_description": "可以转码为不同的格式",
"transcodeBitrate": "转码比特率",
"albumBackground": "专辑背景图片",
"albumBackground_description": "为包含专辑封面的专辑页面添加背景图像",
"albumBackgroundBlur": "专辑背景图像模糊大小",
"albumBackgroundBlur_description": "调整相册背景图片的模糊程度",
"playerbarOpenDrawer": "播放器栏全屏切换",
"playerbarOpenDrawer_description": "允许点击播放器栏打开全屏播放器",
"transcodeBitrate_description": "选择要转码的比特率。0 表示让服务器选择",
"transcodeFormat": "转码格式",
"transcodeFormat_description": "选择要转码的格式。留空让服务器决定",
"webAudio_description": "使用 web 音频。这将启用重播增益等高级功能。如果您遇到其他情况,请禁用",
"artistConfiguration_description": "配置专辑艺术家页面上显示的项目及其显示顺序",
"webAudio": "使用 web 音频",
"artistConfiguration": "专辑艺术家页面配置",
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
"trayEnabled_description": "显示/隐藏托盘图标/菜单。如果禁用,也会禁用最小化/退出到托盘",
"trayEnabled": "显示托盘",
"translationApiProvider": "翻译api提供商",
"translationApiProvider_description": "翻译api提供商",
"translationApiKey": "翻译api密钥",
"translationApiKey_description": "翻译api密钥(仅支持全球服务节点)",
"translationTargetLanguage": "目标翻译语言",
"translationTargetLanguage_description": "目标翻译语言"
},
"error": {
"remotePortWarning": "重启服务器使新端口生效",
@@ -348,7 +395,7 @@
"remotePortError": "设置远程服务器端口时发生错误",
"serverRequired": "需要服务器",
"authenticationFailed": "认证失败",
"apiRouteError": "请求失败:无法路由",
"apiRouteError": "无法路由请求",
"genericError": "发生了错误",
"credentialsRequired": "需要凭证",
"sessionExpiredError": "会话已过期",
@@ -365,7 +412,7 @@
"openError": "无法打开文件"
},
"filter": {
"mostPlayed": "播放最多",
"mostPlayed": "最多播放过",
"playCount": "播放次数",
"recentlyPlayed": "最近播放",
"title": "标题",
@@ -382,7 +429,7 @@
"albumArtist": "$t(entity.albumArtist_one)",
"releaseYear": "发布年份",
"biography": "个人简介",
"songCount": "曲目数",
"songCount": "曲目数",
"random": "随机",
"lastPlayed": "上次播放过",
"toYear": "从年份",
@@ -404,7 +451,7 @@
"note": "注释",
"albumCount": "$t(entity.album_other)数",
"id": "id",
"disc": "",
"disc": "碟片",
"duration": "时长",
"album": "$t(entity.album_one)"
},
@@ -421,7 +468,7 @@
"home": "$t(common.home)",
"artists": "$t(entity.artist_other)",
"albumArtists": "$t(entity.albumArtist_other)",
"shared": "共享 $t(entity.playlist_other)"
"shared": "共享$t(entity.playlist_other)"
},
"fullscreenPlayer": {
"config": {
@@ -437,11 +484,14 @@
"lyricGap": "歌词间距",
"followCurrentLyric": "跟随当前歌词",
"dynamicImageBlur": "图像模糊大小",
"dynamicIsImage": "启用背景图像"
"dynamicIsImage": "启用背景图像",
"lyricOffset": "歌词延迟补偿(毫秒)"
},
"lyrics": "歌词",
"related": "相关",
"upNext": "即将播放"
"upNext": "即将播放",
"visualizer": "可视化",
"noLyrics": "未找到歌词"
},
"appMenu": {
"selectServer": "选择服务器",
@@ -464,19 +514,21 @@
},
"albumDetail": {
"moreFromArtist": "更多该$t(entity.artist_one)作品",
"moreFromGeneric": "更多{{item}}作品"
"moreFromGeneric": "更多{{item}}作品",
"released": "已发布"
},
"setting": {
"playbackTab": "播放",
"generalTab": "通用",
"hotkeysTab": "快捷键",
"windowTab": "窗口"
"windowTab": "窗口",
"advanced": "高级"
},
"globalSearch": {
"commands": {
"serverCommands": "服务器命令",
"goToPage": "跳至页面",
"searchFor": "搜索 {{query}}"
"searchFor": "搜索{{query}}"
},
"title": "命令"
},
@@ -498,25 +550,28 @@
"addLast": "$t(player.addLast)",
"addFavorite": "$t(action.addToFavorites)",
"showDetails": "获取信息",
"shareItem": "分享项目"
"shareItem": "分享项目",
"playSimilarSongs": "$t(player.playSimilarSongs)",
"download": "下载",
"playShuffled": "$t(player.shuffle)"
},
"trackList": {
"title": "$t(entity.track_other)",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
"artistTracks": "{{artist}} 的曲目"
"artistTracks": "{{artist}}的曲目"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
},
"albumList": {
"title": "$t(entity.album_other)",
"artistAlbums": "{{artist}} 的专辑",
"artistAlbums": "{{artist}}的专辑",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
},
"genreList": {
"title": "$t(entity.genre_other)",
"showAlbums": "显示 $t(entity.genre_one) $t(entity.album_other)",
"showTracks": "显示 $t(entity.genre_one) $t(entity.track_other)"
"showAlbums": "显示$t(entity.genre_one) $t(entity.album_other)",
"showTracks": "显示$t(entity.genre_one) $t(entity.track_other)"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
@@ -524,11 +579,11 @@
"albumArtistDetail": {
"recentReleases": "最近发布",
"viewDiscography": "查看唱片目录",
"relatedArtists": "相关 $t(entity.artist_other)",
"relatedArtists": "相关$t(entity.artist_other)",
"topSongs": "热门歌曲",
"topSongsFrom": "{{title}} 的热门歌曲",
"viewAllTracks": "查看所有 $t(entity.track_other)",
"about": "关于 {{artist}}",
"topSongsFrom": "{{title}}的热门歌曲",
"viewAllTracks": "查看所有$t(entity.track_other)",
"about": "关于{{artist}}",
"appearsOn": "出现在",
"viewAll": "查看全部"
},
@@ -536,6 +591,17 @@
"copyPath": "将路径复制到剪贴板",
"copiedPath": "路径复制成功",
"openFile": "在文件管理器中显示曲目"
},
"playlist": {
"reorder": "仅在按 ID 排序时启用重排序"
},
"manageServers": {
"url": "URL",
"title": "管理服务器",
"serverDetails": "服务器详细信息",
"username": "用户名",
"editServerDetailsTooltip": "编辑服务器详细信息",
"removeServer": "移除服务器"
}
},
"form": {
@@ -549,7 +615,7 @@
"input_username": "用户名",
"input_password": "密码",
"input_legacyAuthentication": "启用旧版认证方式",
"input_name": "服务器名",
"input_name": "服务器名",
"success": "服务器添加成功",
"input_savePassword": "保存密码",
"ignoreSsl": "忽略 ssl $t(common.restartRequired)",
@@ -558,7 +624,7 @@
"input_url": "url"
},
"addToPlaylist": {
"success": "添加 $t(entity.trackWithCount, {\"count\": {{message}} })$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"success": "添加$t(entity.trackWithCount, {\"count\": {{message}} })$t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "添加到$t(entity.playlist_one)",
"input_skipDuplicates": "跳过重复",
"input_playlists": "$t(entity.playlist_other)"
@@ -580,7 +646,9 @@
"input_optionMatchAny": "匹配任何"
},
"editPlaylist": {
"title": "编辑$t(entity.playlist_one)"
"title": "编辑$t(entity.playlist_one)",
"publicJellyfinNote": "Jellyfin 出于某种原因不会显示播放列表是否公开。如果您希望保持公开,请选择以下输入",
"success": "$t(entity.playlist_one)更新成功"
},
"lyricSearch": {
"title": "搜索歌词",
@@ -589,7 +657,7 @@
},
"shareItem": {
"expireInvalid": "过期时间必须是将来的时间",
"createFailed": "创建共享失败(是否启用共享?)",
"createFailed": "创建共享失败(是否启用共享?)",
"allowDownloading": "允许下载",
"description": "描述",
"setExpiration": "设置过期时间",
@@ -599,13 +667,14 @@
"table": {
"config": {
"general": {
"displayType": "显示风格",
"displayType": "显示类型",
"gap": "$t(common.gap)",
"tableColumns": "列",
"autoFitColumns": "列宽自适应",
"size": "$t(common.size)",
"itemGap": "项目间隙(px",
"itemSize": "项目大小 (px)"
"itemSize": "项目大小 (px)",
"followCurrentSong": "关注当前播放的歌曲"
},
"view": {
"table": "表格",
@@ -621,7 +690,7 @@
"bpm": "$t(common.bpm)",
"lastPlayed": "最后播放",
"trackNumber": "音轨编号",
"rowIndex": "行",
"rowIndex": "行索引",
"rating": "$t(common.rating)",
"artist": "$t(entity.artist_one)",
"album": "$t(entity.album_one)",
@@ -630,7 +699,7 @@
"owner": "$t(common.owner)",
"path": "$t(common.path)",
"channels": "$t(common.channel_other)",
"playCount": "播放数",
"playCount": "播放数",
"bitrate": "$t(common.bitrate)",
"actions": "$t(common.action_other)",
"genre": "$t(entity.genre_one)",
@@ -639,13 +708,14 @@
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist_one)",
"titleCombined": "$t(common.title)(合并)",
"codec": "$t(common.codec)"
"codec": "$t(common.codec)",
"songCount": "$t(entity.track_other)"
}
},
"column": {
"comment": "评论",
"album": "专辑",
"rating": "评",
"rating": "评",
"favorite": "收藏",
"playCount": "播放次数",
"albumCount": "$t(entity.album_other)",
@@ -664,7 +734,7 @@
"albumArtist": "专辑艺术家",
"path": "路径",
"channels": "$t(common.channel_other)",
"discNumber": "",
"discNumber": "碟片",
"size": "$t(common.size)",
"codec": "$t(common.codec)"
}
+59 -35
View File
@@ -1,9 +1,10 @@
import console from 'console';
import { rm } from 'fs/promises';
import { pid } from 'node:process';
import { app, ipcMain } from 'electron';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
import { getMainWindow, sendToastToRenderer } from '../../../main';
import { PlayerData } from '/@/renderer/store';
import { createLog, isWindows } from '../../../utils';
import { store } from '../settings';
@@ -18,6 +19,7 @@ declare module 'node-mpv';
// }
let mpvInstance: MpvAPI | null = null;
const socketPath = isWindows() ? `\\\\.\\pipe\\mpvserver-${pid}` : `/tmp/node-mpv-${pid}.sock`;
const NodeMpvErrorCode = {
0: 'Unable to load file or stream',
@@ -62,7 +64,6 @@ const mpvLog = (
};
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
const isDevelopment = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
const prefetchPlaylistParams = [
'--prefetch-playlist=no',
@@ -89,14 +90,12 @@ const createMpv = async (data: {
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
const extra = isDevelopment ? '-dev' : '';
const mpv = new MpvAPI(
{
audio_only: true,
auto_restart: false,
binary: binaryPath || MPV_BINARY_PATH || undefined,
socket: isWindows() ? `\\\\.\\pipe\\mpvserver${extra}` : `/tmp/node-mpv${extra}.sock`,
socket: socketPath,
time_update: 1,
},
params,
@@ -149,6 +148,16 @@ export const getMpvInstance = () => {
return mpvInstance;
};
const quit = async () => {
const instance = getMpvInstance();
if (instance) {
await instance.quit();
if (!isWindows()) {
await rm(socketPath);
}
}
};
const setAudioPlayerFallback = (isError: boolean) => {
getMainWindow()?.webContents.send('renderer-player-fallback', isError);
};
@@ -216,7 +225,7 @@ ipcMain.handle(
ipcMain.on('player-quit', async () => {
try {
await getMpvInstance()?.stop();
await getMpvInstance()?.quit();
await quit();
} catch (err: NodeMpvError | any) {
mpvLog({ action: 'Failed to quit mpv' }, err);
} finally {
@@ -305,8 +314,8 @@ ipcMain.on('player-seek-to', async (_event, time: number) => {
});
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) => {
if (!data.queue.current?.id && !data.queue.next?.id) {
ipcMain.on('player-set-queue', async (_event, current?: string, next?: string, pause?: boolean) => {
if (!current && !next) {
try {
await getMpvInstance()?.clearPlaylist();
await getMpvInstance()?.pause();
@@ -317,15 +326,15 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
}
try {
if (data.queue.current?.streamUrl) {
await getMpvInstance()
?.load(data.queue.current.streamUrl, 'replace')
.catch(() => {
getMpvInstance()?.play();
});
if (current) {
try {
await getMpvInstance()?.load(current, 'replace');
} catch (error) {
await getMpvInstance()?.play();
}
if (data.queue.next?.streamUrl) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
if (next) {
await getMpvInstance()?.load(next, 'append');
}
}
@@ -341,7 +350,7 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
});
// Replaces the queue in position 1 to the given data
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
ipcMain.on('player-set-queue-next', async (_event, url?: string) => {
try {
const size = await getMpvInstance()?.getPlaylistSize();
@@ -353,8 +362,8 @@ ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
await getMpvInstance()?.playlistRemove(1);
}
if (data.queue.next?.streamUrl) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
if (url) {
await getMpvInstance()?.load(url, 'append');
}
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to set play queue` }, err);
@@ -362,7 +371,7 @@ ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
});
// Sets the next song in the queue when reaching the end of the queue
ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
ipcMain.on('player-auto-next', async (_event, url?: string) => {
// Always keep the current song as position 0 in the mpv queue
// This allows us to easily set update the next song in the queue without
// disturbing the currently playing song
@@ -373,8 +382,8 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
getMpvInstance()?.pause();
});
if (data.queue.next?.streamUrl) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
if (url) {
await getMpvInstance()?.load(url, 'append');
}
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to load next song` }, err);
@@ -412,19 +421,34 @@ ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
}
});
app.on('before-quit', async () => {
try {
await getMpvInstance()?.stop();
await getMpvInstance()?.quit();
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to cleanly before-quit` }, err);
}
});
enum MpvState {
STARTED,
IN_PROGRESS,
DONE,
}
app.on('window-all-closed', async () => {
try {
await getMpvInstance()?.quit();
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to cleanly exit` }, err);
let mpvState = MpvState.STARTED;
app.on('before-quit', async (event) => {
switch (mpvState) {
case MpvState.DONE:
return;
case MpvState.IN_PROGRESS:
event.preventDefault();
break;
case MpvState.STARTED: {
try {
mpvState = MpvState.IN_PROGRESS;
event.preventDefault();
await getMpvInstance()?.stop();
await quit();
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to cleanly before-quit` }, err);
} finally {
mpvState = MpvState.DONE;
app.quit();
}
break;
}
}
});
+59 -55
View File
@@ -8,9 +8,10 @@ import { app, ipcMain } from 'electron';
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
import manifest from './manifest.json';
import { ClientEvent, ServerEvent } from '../../../../remote/types';
import { PlayerRepeat, SongUpdate } from '../../../../renderer/types';
import { PlayerRepeat, PlayerStatus, SongState } from '../../../../renderer/types';
import { getMainWindow } from '../../../main';
import { isLinux } from '../../../utils';
import type { QueueSong } from '/@/renderer/api/types';
let mprisPlayer: any | undefined;
@@ -33,13 +34,14 @@ interface MimeType {
js: string;
}
interface StatefulWebSocket extends WebSocket {
declare class StatefulWebSocket extends WebSocket {
alive: boolean;
auth: boolean;
}
let server: Server | undefined;
let wsServer: WsServer<StatefulWebSocket> | undefined;
let wsServer: WsServer<typeof StatefulWebSocket> | undefined;
const settings: RemoteConfig = {
enabled: false,
@@ -100,9 +102,7 @@ enum Encoding {
const GZIP_REGEX = /\bgzip\b/;
const ZLIB_REGEX = /bdeflate\b/;
let currentSong: SongUpdate = {
currentTime: 0,
};
const currentState: SongState = {};
const getEncoding = (encoding: string | string[]): Encoding => {
const encodingArray = Array.isArray(encoding) ? encoding : [encoding];
@@ -328,9 +328,9 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
});
server.listen(config.port, resolve);
wsServer = new WebSocketServer({ server });
wsServer = new WebSocketServer<typeof StatefulWebSocket>({ server });
wsServer.on('connection', (ws) => {
wsServer!.on('connection', (ws: StatefulWebSocket) => {
let authFail: number | undefined;
ws.alive = true;
@@ -388,7 +388,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
break;
}
case 'proxy': {
const toFetch = currentSong.song?.imageUrl?.replaceAll(
const toFetch = currentState.song?.imageUrl?.replaceAll(
/&(size|width|height=\d+)/g,
'',
);
@@ -438,9 +438,9 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
volume = 0;
}
currentSong.volume = volume;
currentState.volume = volume;
broadcast({ data: { volume }, event: 'song' });
broadcast({ data: volume, event: 'volume' });
getMainWindow()?.webContents.send('request-volume', {
volume,
});
@@ -452,26 +452,35 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
}
case 'favorite': {
const { favorite, id } = json;
if (id && id === currentSong.song?.id) {
if (id && id === currentState.song?.id) {
getMainWindow()?.webContents.send('request-favorite', {
favorite,
id,
serverId: currentSong.song.serverId,
serverId: currentState.song.serverId,
});
}
break;
}
case 'rating': {
const { rating, id } = json;
if (id && id === currentSong.song?.id) {
if (id && id === currentState.song?.id) {
getMainWindow()?.webContents.send('request-rating', {
id,
rating,
serverId: currentSong.song.serverId,
serverId: currentState.song.serverId,
});
}
break;
}
case 'position': {
const { position } = json;
if (mprisPlayer) {
mprisPlayer.getPosition = () => position * 1e6;
}
getMainWindow()?.webContents.send('request-position', {
position,
});
}
}
} catch (error) {
console.error(error);
@@ -482,7 +491,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
ws.alive = true;
});
ws.send(JSON.stringify({ data: currentSong, event: 'song' }));
ws.send(JSON.stringify({ data: currentState, event: 'state' }));
});
const heartBeat = setInterval(() => {
@@ -497,7 +506,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
});
}, PING_TIMEOUT_MS);
wsServer.on('close', () => {
wsServer!.on('close', () => {
clearInterval(heartBeat);
});
@@ -564,13 +573,13 @@ ipcMain.on('remote-username', (_event, username: string) => {
});
ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {
if (currentSong.song?.serverId !== serverId) return;
if (currentState.song?.serverId !== serverId) return;
const id = currentSong.song.id;
const id = currentState.song.id;
for (const songId of ids) {
if (songId === id) {
currentSong.song.userFavorite = favorite;
currentState.song.userFavorite = favorite;
broadcast({ data: { favorite, id: songId }, event: 'favorite' });
return;
}
@@ -578,13 +587,13 @@ ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids:
});
ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {
if (currentSong.song?.serverId !== serverId) return;
if (currentState.song?.serverId !== serverId) return;
const id = currentSong.song.id;
const id = currentState.song.id;
for (const songId of ids) {
if (songId === id) {
currentSong.song.userRating = rating;
currentState.song.userRating = rating;
broadcast({ data: { id: songId, rating }, event: 'rating' });
return;
}
@@ -592,42 +601,32 @@ ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: stri
});
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
currentSong.repeat = repeat;
broadcast({ data: { repeat }, event: 'song' });
currentState.repeat = repeat;
broadcast({ data: repeat, event: 'repeat' });
});
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
currentSong.shuffle = shuffle;
broadcast({ data: { shuffle }, event: 'song' });
currentState.shuffle = shuffle;
broadcast({ data: shuffle, event: 'shuffle' });
});
ipcMain.on('update-song', (_event, data: SongUpdate) => {
const { song, ...rest } = data;
const songChanged = song?.id !== currentSong.song?.id;
ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
currentState.status = status;
broadcast({ data: status, event: 'playback' });
});
if (!song?.id) {
currentSong = {
...currentSong,
...data,
song: undefined,
};
} else {
currentSong = {
...currentSong,
...data,
};
}
ipcMain.on('update-song', (_event, song: QueueSong | undefined) => {
const songChanged = song?.id !== currentState.song?.id;
currentState.song = song;
if (songChanged) {
broadcast({ data: { ...rest, song: song || null }, event: 'song' });
} else {
broadcast({ data: rest, event: 'song' });
broadcast({ data: song || null, event: 'song' });
}
});
ipcMain.on('update-volume', (_event, volume: number) => {
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
currentState.volume = volume;
broadcast({ data: volume, event: 'volume' });
});
if (mprisPlayer) {
@@ -636,16 +635,16 @@ if (mprisPlayer) {
event === 'Playlist'
? PlayerRepeat.ALL
: event === 'Track'
? PlayerRepeat.ONE
: PlayerRepeat.NONE;
? PlayerRepeat.ONE
: PlayerRepeat.NONE;
currentSong.repeat = repeat;
broadcast({ data: { repeat }, event: 'song' });
currentState.repeat = repeat;
broadcast({ data: repeat, event: 'repeat' });
});
mprisPlayer.on('shuffle', (shuffle: boolean) => {
currentSong.shuffle = shuffle;
broadcast({ data: { shuffle }, event: 'song' });
currentState.shuffle = shuffle;
broadcast({ data: shuffle, event: 'shuffle' });
});
mprisPlayer.on('volume', (vol: number) => {
@@ -656,7 +655,12 @@ if (mprisPlayer) {
} else if (volume < 0) {
volume = 0;
}
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
currentState.volume = volume;
broadcast({ data: volume, event: 'volume' });
});
}
ipcMain.on('update-position', (_event, position: number) => {
currentState.position = position;
broadcast({ data: position, event: 'position' });
});
+9 -16
View File
@@ -1,7 +1,8 @@
import { ipcMain } from 'electron';
import Player from 'mpris-service';
import { PlayerRepeat, PlayerStatus, SongUpdate } from '../../../renderer/types';
import { PlayerRepeat, PlayerStatus } from '../../../renderer/types';
import { getMainWindow } from '../../main';
import { QueueSong } from '/@/renderer/api/types';
const mprisPlayer = Player({
identity: 'Feishin',
@@ -105,7 +106,7 @@ mprisPlayer.on('seek', (event: number) => {
});
});
ipcMain.on('mpris-update-position', (_event, arg) => {
ipcMain.on('update-position', (_event, arg: number) => {
mprisPlayer.getPosition = () => arg * 1e6;
});
@@ -117,6 +118,10 @@ ipcMain.on('update-volume', (_event, volume) => {
mprisPlayer.volume = Number(volume) / 100;
});
ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
});
const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
[PlayerRepeat.ALL]: 'Playlist',
[PlayerRepeat.ONE]: 'Track',
@@ -131,21 +136,9 @@ ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
mprisPlayer.shuffle = shuffle;
});
ipcMain.on('update-song', (_event, args: SongUpdate) => {
const { song, status, repeat, shuffle } = args || {};
ipcMain.on('update-song', (_event, song: QueueSong | undefined) => {
try {
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
if (repeat) {
mprisPlayer.loopStatus = REPEAT_TO_MPRIS[repeat];
}
if (shuffle) {
mprisPlayer.shuffle = shuffle;
}
if (!song) {
if (!song?.id) {
mprisPlayer.metadata = {};
return;
}
+29 -18
View File
@@ -67,8 +67,8 @@ if (store.get('ignore_ssl')) {
// From https://github.com/tutao/tutanota/commit/92c6ed27625fcf367f0fbcc755d83d7ff8fde94b
if (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {
const paswordStore = store.get('password_store', 'gnome-libsecret') as string;
app.commandLine.appendSwitch('password-store', paswordStore);
const passwordStore = store.get('password_store', 'gnome-libsecret') as string;
app.commandLine.appendSwitch('password-store', passwordStore);
}
let mainWindow: BrowserWindow | null = null;
@@ -199,7 +199,7 @@ const createTray = () => {
},
]);
tray.on('double-click', () => {
tray.on('click', () => {
mainWindow?.show();
createWinThumbarButtons();
});
@@ -210,7 +210,7 @@ const createTray = () => {
const createWindow = async (first = true) => {
if (isDevelopment) {
await installExtensions();
await installExtensions().catch(console.log);
}
const nativeFrame = store.get('window_window_bar_style') === 'linux';
@@ -364,18 +364,8 @@ const createWindow = async (first = true) => {
}
});
ipcMain.handle('open-item', async (_event, path: string) => {
return new Promise<void>((resolve, reject) => {
access(path, constants.F_OK, (error) => {
if (error) {
reject(error);
return;
}
shell.showItemInFolder(path);
resolve();
});
});
ipcMain.on('download-url', (_event, url: string) => {
mainWindow?.webContents.downloadURL(url);
});
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
@@ -487,7 +477,7 @@ const createWindow = async (first = true) => {
const menuBuilder = new MenuBuilder(mainWindow);
menuBuilder.buildMenu();
// Open urls in the user's browser
// Open URLs in the user's browser
mainWindow.webContents.setWindowOpenHandler((edata) => {
shell.openExternal(edata.url);
return { action: 'deny' };
@@ -629,6 +619,8 @@ if (!singleInstance) {
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
} else if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
@@ -655,7 +647,9 @@ if (!singleInstance) {
});
createWindow();
createTray();
if (store.get('window_enable_tray', true)) {
createTray();
}
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
@@ -668,3 +662,20 @@ if (!singleInstance) {
})
.catch(console.log);
}
// Register 'open-item' handler globally, ensuring it is only registered once
if (!ipcMain.eventNames().includes('open-item')) {
ipcMain.handle('open-item', async (_event, path: string) => {
return new Promise<void>((resolve, reject) => {
access(path, constants.F_OK, (error) => {
if (error) {
reject(error);
return;
}
shell.showItemInFolder(path);
resolve();
});
});
});
}
+10 -10
View File
@@ -48,7 +48,7 @@ export default class MenuBuilder {
label: 'Electron',
submenu: [
{
label: 'About ElectronReact',
label: 'About Feishin',
selector: 'orderFrontStandardAboutPanel:',
},
{ type: 'separator' },
@@ -56,7 +56,7 @@ export default class MenuBuilder {
{ type: 'separator' },
{
accelerator: 'Command+H',
label: 'Hide ElectronReact',
label: 'Hide Feishin',
selector: 'hide:',
},
{
@@ -147,27 +147,27 @@ export default class MenuBuilder {
submenu: [
{
click() {
shell.openExternal('https://electronjs.org');
shell.openExternal('https://github.com/jeffvli/feishin');
},
label: 'Learn More',
},
{
click() {
shell.openExternal(
'https://github.com/electron/electron/tree/main/docs#readme',
'https://github.com/jeffvli/feishin?tab=readme-ov-file#getting-started',
);
},
label: 'Documentation',
},
{
click() {
shell.openExternal('https://www.electronjs.org/community');
shell.openExternal('https://github.com/jeffvli/feishin/discussions');
},
label: 'Community Discussions',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/issues');
shell.openExternal('https://github.com/jeffvli/feishin/issues');
},
label: 'Search Issues',
},
@@ -246,27 +246,27 @@ export default class MenuBuilder {
submenu: [
{
click() {
shell.openExternal('https://electronjs.org');
shell.openExternal('https://github.com/jeffvli/feishin');
},
label: 'Learn More',
},
{
click() {
shell.openExternal(
'https://github.com/electron/electron/tree/main/docs#readme',
'https://github.com/jeffvli/feishin?tab=readme-ov-file#getting-started',
);
},
label: 'Documentation',
},
{
click() {
shell.openExternal('https://www.electronjs.org/community');
shell.openExternal('https://github.com/jeffvli/feishin/discussions');
},
label: 'Community Discussions',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/issues');
shell.openExternal('https://github.com/jeffvli/feishin/issues');
},
label: 'Search Issues',
},
+6 -6
View File
@@ -25,8 +25,8 @@ const setProperties = (data: Record<string, any>) => {
ipcRenderer.send('player-set-properties', data);
};
const autoNext = (data: PlayerData) => {
ipcRenderer.send('player-auto-next', data);
const autoNext = (url?: string) => {
ipcRenderer.send('player-auto-next', url);
};
const currentTime = () => {
@@ -61,12 +61,12 @@ const seekTo = (seconds: number) => {
ipcRenderer.send('player-seek-to', seconds);
};
const setQueue = (data: PlayerData, pause?: boolean) => {
ipcRenderer.send('player-set-queue', data, pause);
const setQueue = (current?: string, next?: string, pause?: boolean) => {
ipcRenderer.send('player-set-queue', current, next, pause);
};
const setQueueNext = (data: PlayerData) => {
ipcRenderer.send('player-set-queue-next', data);
const setQueueNext = (url?: string) => {
ipcRenderer.send('player-set-queue-next', url);
};
const stop = () => {
+13 -2
View File
@@ -1,5 +1,6 @@
import { IpcRendererEvent, ipcRenderer } from 'electron';
import { SongUpdate } from '/@/renderer/types';
import { QueueSong } from '/@/renderer/api/types';
import { PlayerStatus } from '/@/renderer/types';
const requestFavorite = (
cb: (
@@ -46,6 +47,10 @@ const updatePassword = (password: string) => {
ipcRenderer.send('remote-password', password);
};
const updatePlayback = (playback: PlayerStatus) => {
ipcRenderer.send('update-playback', playback);
};
const updateSetting = (
enabled: boolean,
port: number,
@@ -67,7 +72,7 @@ const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('update-shuffle', shuffle);
};
const updateSong = (args: SongUpdate) => {
const updateSong = (args: QueueSong | undefined) => {
ipcRenderer.send('update-song', args);
};
@@ -79,6 +84,10 @@ const updateVolume = (volume: number) => {
ipcRenderer.send('update-volume', volume);
};
const updatePosition = (timeSec: number) => {
ipcRenderer.send('update-position', timeSec);
};
export const remote = {
requestFavorite,
requestPosition,
@@ -89,6 +98,8 @@ export const remote = {
setRemotePort,
updateFavorite,
updatePassword,
updatePlayback,
updatePosition,
updateRating,
updateRepeat,
updateSetting,
+5
View File
@@ -47,7 +47,12 @@ const logger = (
ipcRenderer.send('logger', cb);
};
const download = (url: string) => {
ipcRenderer.send('download-url', url);
};
export const utils = {
download,
isLinux,
isMacOS,
isWindows,
+21 -11
View File
@@ -18,10 +18,10 @@ import {
import { PlayerRepeat, PlayerStatus } from '/@/renderer/types';
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
import { Tooltip } from '/@/renderer/components/tooltip';
import { Rating } from '/@/renderer/components';
import { Rating } from '/@/renderer/components/rating';
export const RemoteContainer = () => {
const { repeat, shuffle, song, status, volume } = useInfo();
const { position, repeat, shuffle, song, status, volume } = useInfo();
const send = useSend();
const showImage = useShowImage();
@@ -38,7 +38,7 @@ export const RemoteContainer = () => {
return (
<>
{song && (
{id && (
<>
<Title order={1}>{song.name}</Title>
<Group align="flex-end">
@@ -61,7 +61,7 @@ export const RemoteContainer = () => {
spacing={0}
>
<RemoteButton
disabled={!song}
disabled={!id}
tooltip="Previous track"
variant="default"
onClick={() => send({ event: 'previous' })}
@@ -69,8 +69,8 @@ export const RemoteContainer = () => {
<RiSkipBackFill size={25} />
</RemoteButton>
<RemoteButton
disabled={!song}
tooltip={song && status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
disabled={!id}
tooltip={id && status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
variant="default"
onClick={() => {
if (status === PlayerStatus.PLAYING) {
@@ -80,14 +80,14 @@ export const RemoteContainer = () => {
}
}}
>
{song && status === PlayerStatus.PLAYING ? (
{id && status === PlayerStatus.PLAYING ? (
<RiPauseFill size={25} />
) : (
<RiPlayFill size={25} />
)}
</RemoteButton>
<RemoteButton
disabled={!song}
disabled={!id}
tooltip="Next track"
variant="default"
onClick={() => send({ event: 'next' })}
@@ -113,8 +113,8 @@ export const RemoteContainer = () => {
repeat === PlayerRepeat.ONE
? 'One'
: repeat === PlayerRepeat.ALL
? 'all'
: 'none'
? 'all'
: 'none'
}`}
variant="default"
onClick={() => send({ event: 'repeat' })}
@@ -127,7 +127,7 @@ export const RemoteContainer = () => {
</RemoteButton>
<RemoteButton
$active={song?.userFavorite}
disabled={!song}
disabled={!id}
tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'}
variant="default"
onClick={() => {
@@ -154,6 +154,16 @@ export const RemoteContainer = () => {
</div>
)}
</Group>
{id && position !== undefined && (
<WrapperSlider
label={(value) => formatDuration(value * 1e3)}
leftLabel={formatDuration(position * 1e3)}
max={song.duration / 1e3}
rightLabel={formatDuration(song.duration)}
value={position}
onChangeEnd={(e) => send({ event: 'position', position: e })}
/>
)}
<WrapperSlider
leftLabel={<RiVolumeUpFill size={20} />}
max={100}
+1 -1
View File
@@ -44,7 +44,7 @@ export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: Wrappe
{...props}
min={0}
size={6}
value={!isSeeking ? value ?? 0 : seek}
value={!isSeeking ? (value ?? 0) : seek}
w="100%"
onChange={(e) => {
setIsSeeking(true);
+41 -7
View File
@@ -4,7 +4,7 @@ import merge from 'lodash/merge';
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/remote/types';
import type { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/remote/types';
interface StatefulWebSocket extends WebSocket {
natural: boolean;
@@ -133,6 +133,18 @@ export const useRemoteStore = create<SettingsSlice>()(
});
break;
}
case 'playback': {
set((state) => {
state.info.status = data;
});
break;
}
case 'position': {
set((state) => {
state.info.position = data;
});
break;
}
case 'proxy': {
set((state) => {
if (state.info.song) {
@@ -149,9 +161,33 @@ export const useRemoteStore = create<SettingsSlice>()(
});
break;
}
case 'repeat': {
set((state) => {
state.info.repeat = data;
});
break;
}
case 'shuffle': {
set((state) => {
state.info.shuffle = data;
});
break;
}
case 'song': {
set((nested) => {
nested.info = { ...nested.info, ...data };
set((state) => {
state.info.song = data;
});
break;
}
case 'state': {
set((state) => {
state.info = data;
});
break;
}
case 'volume': {
set((state) => {
state.info.volume = data;
});
}
}
@@ -212,11 +248,9 @@ export const useRemoteStore = create<SettingsSlice>()(
{ name: 'store_settings' },
),
{
merge: (persistedState, currentState) => {
return merge(currentState, persistedState);
},
merge: (persistedState, currentState) => merge(currentState, persistedState),
name: 'store_settings',
version: 6,
version: 7,
},
),
);
+51 -4
View File
@@ -1,7 +1,8 @@
import type { QueueSong } from '/@/renderer/api/types';
import type { SongUpdate } from '/@/renderer/types';
import type { PlayerRepeat, PlayerStatus, SongState } from '/@/renderer/types';
export interface SongUpdateSocket extends Omit<SongUpdate, 'song'> {
export interface SongUpdateSocket extends Omit<SongState, 'song'> {
position?: number;
song?: QueueSong | null;
}
@@ -15,6 +16,15 @@ export interface ServerFavorite {
event: 'favorite';
}
export interface ServerPlayStatus {
data: PlayerStatus;
event: 'playback';
}
export interface ServerPosition {
data: number;
event: 'position';
}
export interface ServerProxy {
data: string;
event: 'proxy';
@@ -25,12 +35,43 @@ export interface ServerRating {
event: 'rating';
}
export interface ServerRepeat {
data: PlayerRepeat;
event: 'repeat';
}
export interface ServerShuffle {
data: boolean;
event: 'shuffle';
}
export interface ServerSong {
data: SongUpdateSocket;
data: QueueSong | null;
event: 'song';
}
export type ServerEvent = ServerError | ServerFavorite | ServerRating | ServerSong | ServerProxy;
export interface ServerState {
data: SongState;
event: 'state';
}
export interface ServerVolume {
data: number;
event: 'volume';
}
export type ServerEvent =
| ServerError
| ServerFavorite
| ServerPlayStatus
| ServerPosition
| ServerRating
| ServerRepeat
| ServerShuffle
| ServerSong
| ServerState
| ServerProxy
| ServerVolume;
export interface ClientSimpleEvent {
event: 'next' | 'pause' | 'play' | 'previous' | 'proxy' | 'repeat' | 'shuffle';
@@ -58,8 +99,14 @@ export interface ClientAuth {
header: string;
}
export interface ClientPosition {
event: 'position';
position: number;
}
export type ClientEvent =
| ClientAuth
| ClientPosition
| ClientSimpleEvent
| ClientFavorite
| ClientRating
+131 -530
View File
@@ -1,113 +1,11 @@
import { useAuthStore } from '/@/renderer/store';
import { toast } from '/@/renderer/components/toast/index';
import type {
AlbumDetailArgs,
AlbumListArgs,
SongListArgs,
SongDetailArgs,
AlbumArtistDetailArgs,
AlbumArtistListArgs,
SetRatingArgs,
ShareItemArgs,
GenreListArgs,
CreatePlaylistArgs,
DeletePlaylistArgs,
PlaylistDetailArgs,
PlaylistListArgs,
MusicFolderListArgs,
PlaylistSongListArgs,
ArtistListArgs,
UpdatePlaylistArgs,
UserListArgs,
FavoriteArgs,
TopSongListArgs,
AddToPlaylistArgs,
AddToPlaylistResponse,
RemoveFromPlaylistArgs,
RemoveFromPlaylistResponse,
ScrobbleArgs,
ScrobbleResponse,
AlbumArtistDetailResponse,
FavoriteResponse,
CreatePlaylistResponse,
AlbumArtistListResponse,
AlbumDetailResponse,
AlbumListResponse,
ArtistListResponse,
GenreListResponse,
MusicFolderListResponse,
PlaylistDetailResponse,
PlaylistListResponse,
RatingResponse,
SongDetailResponse,
SongListResponse,
TopSongListResponse,
UpdatePlaylistResponse,
UserListResponse,
AuthenticationResponse,
SearchArgs,
SearchResponse,
LyricsArgs,
LyricsResponse,
ServerInfo,
ServerInfoArgs,
StructuredLyricsArgs,
StructuredLyric,
SimilarSongsArgs,
Song,
ServerType,
ShareItemResponse,
} from '/@/renderer/api/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
import type { ServerType, ControllerEndpoint, AuthenticationResponse } from '/@/renderer/api/types';
import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller';
import i18n from '/@/i18n/i18n';
export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
authenticate: (
url: string,
body: { password: string; username: string },
) => Promise<AuthenticationResponse>;
clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getArtistDetail: () => void;
getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getFavoritesList: () => void;
getFolderItemList: () => void;
getFolderList: () => void;
getFolderSongs: () => void;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getStructuredLyrics: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
shareItem: (args: ShareItemArgs) => Promise<ShareItemResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
}>;
type ApiController = {
jellyfin: ControllerEndpoint;
navidrome: ControllerEndpoint;
@@ -115,125 +13,15 @@ type ApiController = {
};
const endpoints: ApiController = {
jellyfin: {
addToPlaylist: jfController.addToPlaylist,
authenticate: jfController.authenticate,
clearPlaylist: undefined,
createFavorite: jfController.createFavorite,
createPlaylist: jfController.createPlaylist,
deleteFavorite: jfController.deleteFavorite,
deletePlaylist: jfController.deletePlaylist,
getAlbumArtistDetail: jfController.getAlbumArtistDetail,
getAlbumArtistList: jfController.getAlbumArtistList,
getAlbumDetail: jfController.getAlbumDetail,
getAlbumList: jfController.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: jfController.getGenreList,
getLyrics: jfController.getLyrics,
getMusicFolderList: jfController.getMusicFolderList,
getPlaylistDetail: jfController.getPlaylistDetail,
getPlaylistList: jfController.getPlaylistList,
getPlaylistSongList: jfController.getPlaylistSongList,
getRandomSongList: jfController.getRandomSongList,
getServerInfo: jfController.getServerInfo,
getSimilarSongs: jfController.getSimilarSongs,
getSongDetail: jfController.getSongDetail,
getSongList: jfController.getSongList,
getStructuredLyrics: undefined,
getTopSongs: jfController.getTopSongList,
getUserList: undefined,
removeFromPlaylist: jfController.removeFromPlaylist,
scrobble: jfController.scrobble,
search: jfController.search,
setRating: undefined,
shareItem: undefined,
updatePlaylist: jfController.updatePlaylist,
},
navidrome: {
addToPlaylist: ndController.addToPlaylist,
authenticate: ndController.authenticate,
clearPlaylist: undefined,
createFavorite: ssController.createFavorite,
createPlaylist: ndController.createPlaylist,
deleteFavorite: ssController.removeFavorite,
deletePlaylist: ndController.deletePlaylist,
getAlbumArtistDetail: ndController.getAlbumArtistDetail,
getAlbumArtistList: ndController.getAlbumArtistList,
getAlbumDetail: ndController.getAlbumDetail,
getAlbumList: ndController.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: ndController.getGenreList,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: ndController.getPlaylistDetail,
getPlaylistList: ndController.getPlaylistList,
getPlaylistSongList: ndController.getPlaylistSongList,
getRandomSongList: ssController.getRandomSongList,
getServerInfo: ndController.getServerInfo,
getSimilarSongs: ndController.getSimilarSongs,
getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList,
getStructuredLyrics: ssController.getStructuredLyrics,
getTopSongs: ssController.getTopSongList,
getUserList: ndController.getUserList,
removeFromPlaylist: ndController.removeFromPlaylist,
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: ssController.setRating,
shareItem: ndController.shareItem,
updatePlaylist: ndController.updatePlaylist,
},
subsonic: {
authenticate: ssController.authenticate,
clearPlaylist: undefined,
createFavorite: ssController.createFavorite,
createPlaylist: undefined,
deleteFavorite: ssController.removeFavorite,
deletePlaylist: undefined,
getAlbumArtistDetail: undefined,
getAlbumArtistList: undefined,
getAlbumDetail: undefined,
getAlbumList: undefined,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: undefined,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: undefined,
getPlaylistList: undefined,
getServerInfo: ssController.getServerInfo,
getSimilarSongs: ssController.getSimilarSongs,
getSongDetail: undefined,
getSongList: undefined,
getStructuredLyrics: ssController.getStructuredLyrics,
getTopSongs: ssController.getTopSongList,
getUserList: undefined,
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: undefined,
shareItem: undefined,
updatePlaylist: undefined,
},
jellyfin: JellyfinController,
navidrome: NavidromeController,
subsonic: SubsonicController,
};
const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => {
const apiController = <K extends keyof ControllerEndpoint>(
endpoint: K,
type?: ServerType,
): NonNullable<ControllerEndpoint[K]> => {
const serverType = type || useAuthStore.getState().currentServer?.type;
if (!serverType) {
@@ -263,314 +51,127 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) =>
);
}
return endpoints[serverType][endpoint];
return controllerFn;
};
const authenticate = async (
url: string,
body: { legacy?: boolean; password: string; username: string },
type: ServerType,
) => {
return (apiController('authenticate', type) as ControllerEndpoint['authenticate'])?.(url, body);
};
export interface GeneralController extends Omit<Required<ControllerEndpoint>, 'authenticate'> {
authenticate: (
url: string,
body: { legacy?: boolean; password: string; username: string },
type: ServerType,
) => Promise<AuthenticationResponse>;
}
const getAlbumList = async (args: AlbumListArgs) => {
return (
apiController(
'getAlbumList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumList']
)?.(args);
};
const getAlbumDetail = async (args: AlbumDetailArgs) => {
return (
apiController(
'getAlbumDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumDetail']
)?.(args);
};
const getSongList = async (args: SongListArgs) => {
return (
apiController(
'getSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongList']
)?.(args);
};
const getSongDetail = async (args: SongDetailArgs) => {
return (
apiController(
'getSongDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongDetail']
)?.(args);
};
const getMusicFolderList = async (args: MusicFolderListArgs) => {
return (
apiController(
'getMusicFolderList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getMusicFolderList']
)?.(args);
};
const getGenreList = async (args: GenreListArgs) => {
return (
apiController(
'getGenreList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getGenreList']
)?.(args);
};
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs) => {
return (
apiController(
'getAlbumArtistDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistDetail']
)?.(args);
};
const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
return (
apiController(
'getAlbumArtistList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistList']
)?.(args);
};
const getArtistList = async (args: ArtistListArgs) => {
return (
apiController(
'getArtistList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getArtistList']
)?.(args);
};
const getPlaylistList = async (args: PlaylistListArgs) => {
return (
apiController(
'getPlaylistList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistList']
)?.(args);
};
const createPlaylist = async (args: CreatePlaylistArgs) => {
return (
apiController(
'createPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['createPlaylist']
)?.(args);
};
const updatePlaylist = async (args: UpdatePlaylistArgs) => {
return (
apiController(
'updatePlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['updatePlaylist']
)?.(args);
};
const deletePlaylist = async (args: DeletePlaylistArgs) => {
return (
apiController(
'deletePlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['deletePlaylist']
)?.(args);
};
const addToPlaylist = async (args: AddToPlaylistArgs) => {
return (
apiController(
'addToPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['addToPlaylist']
)?.(args);
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
return (
apiController(
'removeFromPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['removeFromPlaylist']
)?.(args);
};
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
return (
apiController(
'getPlaylistDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistDetail']
)?.(args);
};
const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
return (
apiController(
'getPlaylistSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistSongList']
)?.(args);
};
const getUserList = async (args: UserListArgs) => {
return (
apiController(
'getUserList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getUserList']
)?.(args);
};
const createFavorite = async (args: FavoriteArgs) => {
return (
apiController(
'createFavorite',
args.apiClientProps.server?.type,
) as ControllerEndpoint['createFavorite']
)?.(args);
};
const deleteFavorite = async (args: FavoriteArgs) => {
return (
apiController(
'deleteFavorite',
args.apiClientProps.server?.type,
) as ControllerEndpoint['deleteFavorite']
)?.(args);
};
const updateRating = async (args: SetRatingArgs) => {
return (
apiController(
'setRating',
args.apiClientProps.server?.type,
) as ControllerEndpoint['setRating']
)?.(args);
};
const shareItem = async (args: ShareItemArgs) => {
return (
apiController(
'shareItem',
args.apiClientProps.server?.type,
) as ControllerEndpoint['shareItem']
)?.(args);
};
const getTopSongList = async (args: TopSongListArgs) => {
return (
apiController(
'getTopSongs',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getTopSongs']
)?.(args);
};
const scrobble = async (args: ScrobbleArgs) => {
return (
apiController(
'scrobble',
args.apiClientProps.server?.type,
) as ControllerEndpoint['scrobble']
)?.(args);
};
const search = async (args: SearchArgs) => {
return (
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
)?.(args);
};
const getRandomSongList = async (args: RandomSongListArgs) => {
return (
apiController(
'getRandomSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getRandomSongList']
)?.(args);
};
const getLyrics = async (args: LyricsArgs) => {
return (
apiController(
'getLyrics',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getLyrics']
)?.(args);
};
const getServerInfo = async (args: ServerInfoArgs) => {
return (
apiController(
'getServerInfo',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getServerInfo']
)?.(args);
};
const getStructuredLyrics = async (args: StructuredLyricsArgs) => {
return (
apiController(
'getStructuredLyrics',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getStructuredLyrics']
)?.(args);
};
const getSimilarSongs = async (args: SimilarSongsArgs) => {
return (
apiController(
'getSimilarSongs',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSimilarSongs']
)?.(args);
};
export const controller = {
addToPlaylist,
authenticate,
createFavorite,
createPlaylist,
deleteFavorite,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getArtistList,
getGenreList,
getLyrics,
getMusicFolderList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getRandomSongList,
getServerInfo,
getSimilarSongs,
getSongDetail,
getSongList,
getStructuredLyrics,
getTopSongList,
getUserList,
removeFromPlaylist,
scrobble,
search,
shareItem,
updatePlaylist,
updateRating,
export const controller: GeneralController = {
addToPlaylist(args) {
return apiController('addToPlaylist', args.apiClientProps.server?.type)?.(args);
},
authenticate(url, body, type) {
return apiController('authenticate', type)(url, body);
},
createFavorite(args) {
return apiController('createFavorite', args.apiClientProps.server?.type)?.(args);
},
createPlaylist(args) {
return apiController('createPlaylist', args.apiClientProps.server?.type)?.(args);
},
deleteFavorite(args) {
return apiController('deleteFavorite', args.apiClientProps.server?.type)?.(args);
},
deletePlaylist(args) {
return apiController('deletePlaylist', args.apiClientProps.server?.type)?.(args);
},
getAlbumArtistDetail(args) {
return apiController('getAlbumArtistDetail', args.apiClientProps.server?.type)?.(args);
},
getAlbumArtistList(args) {
return apiController('getAlbumArtistList', args.apiClientProps.server?.type)?.(args);
},
getAlbumArtistListCount(args) {
return apiController('getAlbumArtistListCount', args.apiClientProps.server?.type)?.(args);
},
getAlbumDetail(args) {
return apiController('getAlbumDetail', args.apiClientProps.server?.type)?.(args);
},
getAlbumList(args) {
return apiController('getAlbumList', args.apiClientProps.server?.type)?.(args);
},
getAlbumListCount(args) {
return apiController('getAlbumListCount', args.apiClientProps.server?.type)?.(args);
},
getDownloadUrl(args) {
return apiController('getDownloadUrl', args.apiClientProps.server?.type)?.(args);
},
getGenreList(args) {
return apiController('getGenreList', args.apiClientProps.server?.type)?.(args);
},
getLyrics(args) {
return apiController('getLyrics', args.apiClientProps.server?.type)?.(args);
},
getMusicFolderList(args) {
return apiController('getMusicFolderList', args.apiClientProps.server?.type)?.(args);
},
getPlaylistDetail(args) {
return apiController('getPlaylistDetail', args.apiClientProps.server?.type)?.(args);
},
getPlaylistList(args) {
return apiController('getPlaylistList', args.apiClientProps.server?.type)?.(args);
},
getPlaylistListCount(args) {
return apiController('getPlaylistListCount', args.apiClientProps.server?.type)?.(args);
},
getPlaylistSongList(args) {
return apiController('getPlaylistSongList', args.apiClientProps.server?.type)?.(args);
},
getRandomSongList(args) {
return apiController('getRandomSongList', args.apiClientProps.server?.type)?.(args);
},
getServerInfo(args) {
return apiController('getServerInfo', args.apiClientProps.server?.type)?.(args);
},
getSimilarSongs(args) {
return apiController('getSimilarSongs', args.apiClientProps.server?.type)?.(args);
},
getSongDetail(args) {
return apiController('getSongDetail', args.apiClientProps.server?.type)?.(args);
},
getSongList(args) {
return apiController('getSongList', args.apiClientProps.server?.type)?.(args);
},
getSongListCount(args) {
return apiController('getSongListCount', args.apiClientProps.server?.type)?.(args);
},
getStructuredLyrics(args) {
return apiController('getStructuredLyrics', args.apiClientProps.server?.type)?.(args);
},
getTopSongs(args) {
return apiController('getTopSongs', args.apiClientProps.server?.type)?.(args);
},
getTranscodingUrl(args) {
return apiController('getTranscodingUrl', args.apiClientProps.server?.type)?.(args);
},
getUserList(args) {
return apiController('getUserList', args.apiClientProps.server?.type)?.(args);
},
movePlaylistItem(args) {
return apiController('movePlaylistItem', args.apiClientProps.server?.type)?.(args);
},
removeFromPlaylist(args) {
return apiController('removeFromPlaylist', args.apiClientProps.server?.type)?.(args);
},
scrobble(args) {
return apiController('scrobble', args.apiClientProps.server?.type)?.(args);
},
search(args) {
return apiController('search', args.apiClientProps.server?.type)?.(args);
},
setRating(args) {
return apiController('setRating', args.apiClientProps.server?.type)?.(args);
},
shareItem(args) {
return apiController('shareItem', args.apiClientProps.server?.type)?.(args);
},
updatePlaylist(args) {
return apiController('updatePlaylist', args.apiClientProps.server?.type)?.(args);
},
};
+1
View File
@@ -4,6 +4,7 @@ export enum ServerFeature {
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
PLAYLISTS_SMART = 'playlistsSmart',
PUBLIC_PLAYLIST = 'publicPlaylist',
SHARING_ALBUM_SONG = 'sharingAlbumSong',
}
+1 -1
View File
@@ -574,7 +574,7 @@ export enum JFSongListSort {
ARTIST = 'Artist,Album,SortName',
COMMUNITY_RATING = 'CommunityRating,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'SortName,Name',
NAME = 'Name',
PLAY_COUNT = 'PlayCount,SortName',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
+22 -7
View File
@@ -6,8 +6,9 @@ import qs from 'qs';
import { ServerListItem } from '/@/renderer/api/types';
import omitBy from 'lodash/omitBy';
import { z } from 'zod';
import { authenticationFailure } from '/@/renderer/api/utils';
import { authenticationFailure, getClientType } from '/@/renderer/api/utils';
import i18n from '/@/i18n/i18n';
import packageJson from '../../../../package.json';
const c = initContract();
@@ -24,9 +25,6 @@ export const contract = c.router({
},
authenticate: {
body: jfType._parameters.authenticate,
headers: z.object({
'X-Emby-Authorization': z.string(),
}),
method: 'POST',
path: 'users/authenticatebyname',
responses: {
@@ -228,6 +226,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
movePlaylistItem: {
body: null,
method: 'POST',
path: 'playlists/:playlistId/items/:itemId/move/:newIdx',
responses: {
200: jfType._response.moveItem,
400: jfType._response.error,
},
},
removeFavorite: {
body: jfType._parameters.favorite,
method: 'DELETE',
@@ -285,8 +292,8 @@ export const contract = c.router({
},
updatePlaylist: {
body: jfType._parameters.updatePlaylist,
method: 'PUT',
path: 'items/:id',
method: 'POST',
path: 'playlists/:id',
responses: {
200: jfType._response.updatePlaylist,
400: jfType._response.error,
@@ -333,6 +340,12 @@ const parsePath = (fullPath: string) => {
};
};
export const createAuthHeader = (): string => {
return `MediaBrowser Client="Feishin", Device="${getClientType()}", DeviceId="${
useAuthStore.getState().deviceId
}", Version="${packageJson.version}"`;
};
export const jfApiClient = (args: {
server: ServerListItem | null;
signal?: AbortSignal;
@@ -359,7 +372,9 @@ export const jfApiClient = (args: {
data: body,
headers: {
...headers,
...(token && { 'X-MediaBrowser-Token': token }),
...(token
? { Authorization: createAuthHeader().concat(`, Token="${token}"`) }
: { Authorization: createAuthHeader() }),
},
method: method as Method,
params,
File diff suppressed because it is too large Load Diff
+24 -15
View File
@@ -30,11 +30,11 @@ const getStreamUrl = (args: {
`?userId=${server?.userId}` +
`&deviceId=${deviceId}` +
'&audioCodec=aac' +
`&api_key=${server?.credential}` +
`&apiKey=${server?.credential}` +
`&playSessionId=${deviceId}` +
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
'&transcodingContainer=ts' +
'&transcodingProtocol=hls'
'&transcodingProtocol=http'
);
};
@@ -53,7 +53,7 @@ const getAlbumArtistCoverArtUrl = (args: {
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
`?width=${size}` +
'&quality=96'
);
};
@@ -69,7 +69,7 @@ const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: numbe
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
`?width=${size}` +
'&quality=96'
);
};
@@ -86,7 +86,7 @@ const getSongCoverArtUrl = (args: {
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
`?width=${size}` +
'&quality=96'
);
}
@@ -97,7 +97,7 @@ const getSongCoverArtUrl = (args: {
`${args.baseUrl}/Items` +
`/${args.item?.AlbumId}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
`?width=${size}` +
'&quality=96'
);
}
@@ -116,7 +116,7 @@ const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size:
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
`?width=${size}` +
'&quality=96'
);
};
@@ -153,11 +153,16 @@ const normalizeSong = (
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
discSubtitle: null,
duration: item.RunTimeTicks / 10000,
gain: item.LUFS
? {
track: -18 - item.LUFS,
}
: null,
gain:
item.NormalizationGain !== undefined
? {
track: item.NormalizationGain,
}
: item.LUFS
? {
track: -18 - item.LUFS,
}
: null,
genres: item.GenreItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
@@ -175,8 +180,11 @@ const normalizeSong = (
peak: null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId,
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
releaseDate: null,
releaseDate: item.PremiereDate
? new Date(item.PremiereDate).toISOString()
: item.ProductionYear
? new Date(item.ProductionYear, 0, 1).toISOString()
: null,
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
@@ -237,6 +245,7 @@ const normalizeAlbum = (
lastPlayedAt: null,
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
name: item.Name,
originalDate: null,
playCount: item.UserData?.PlayCount || 0,
releaseDate: item.PremiereDate?.split('T')[0] || null,
releaseYear: item.ProductionYear || null,
@@ -384,7 +393,7 @@ const getGenreCoverArtUrl = (args: {
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
`?width=${size}` +
'&quality=96'
);
};
+8 -3
View File
@@ -413,6 +413,7 @@ const song = z.object({
MediaSources: z.array(mediaSources),
MediaType: z.string(),
Name: z.string(),
NormalizationGain: z.number().optional(),
ParentIndexNumber: z.number(),
PlaylistItemId: z.string().optional(),
PremiereDate: z.string().optional(),
@@ -544,7 +545,7 @@ const songListSort = {
ARTIST: 'Artist,Album,SortName',
COMMUNITY_RATING: 'CommunityRating,SortName',
DURATION: 'Runtime,AlbumArtist,Album,SortName',
NAME: 'SortName,Name',
NAME: 'Name',
PLAY_COUNT: 'PlayCount,SortName',
RANDOM: 'Random,SortName',
RECENTLY_ADDED: 'DateCreated,SortName',
@@ -561,6 +562,7 @@ const songListParameters = paginationParameters.merge(
GenreIds: z.string().optional(),
Genres: z.string().optional(),
IsFavorite: z.boolean().optional(),
IsPlayed: z.boolean().optional(),
SearchTerm: z.string().optional(),
SortBy: z.nativeEnum(songListSort).optional(),
Tags: z.string().optional(),
@@ -581,9 +583,9 @@ const playlistDetailParameters = baseParameters.extend({
});
const createPlaylistParameters = z.object({
IsPublic: z.boolean().optional(),
MediaType: z.literal('Audio'),
Name: z.string(),
Overview: z.string(),
UserId: z.string(),
});
@@ -595,9 +597,9 @@ const updatePlaylist = z.null();
const updatePlaylistParameters = z.object({
Genres: z.array(genreItem),
IsPublic: z.boolean().optional(),
MediaType: z.literal('Audio'),
Name: z.string(),
Overview: z.string(),
PremiereDate: z.null(),
ProviderIds: z.object({}),
Tags: z.array(genericItem),
@@ -681,6 +683,8 @@ export enum JellyfinExtensions {
SONG_LYRICS = 'songLyrics',
}
const moveItem = z.null();
export const jfType = {
_enum: {
albumArtistList: albumArtistListSort,
@@ -729,6 +733,7 @@ export const jfType = {
genre,
genreList,
lyrics,
moveItem,
musicFolderList,
playlist,
playlistList,
+10 -10
View File
@@ -199,17 +199,17 @@ export type NDGenreListParams = {
NDOrder;
export enum NDAlbumListSort {
ALBUM_ARTIST = 'albumArtist',
ALBUM_ARTIST = 'album_artist',
ARTIST = 'artist',
DURATION = 'duration',
NAME = 'name',
PLAY_COUNT = 'playCount',
PLAY_COUNT = 'play_count',
PLAY_DATE = 'play_date',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'recently_added',
SONG_COUNT = 'songCount',
STARRED = 'starred',
STARRED = 'starred_at',
YEAR = 'max_year',
}
@@ -229,15 +229,15 @@ export type NDAlbumListParams = {
NDOrder;
export enum NDSongListSort {
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
ALBUM_ARTIST = 'order_album_artist_name, album, disc_number, track_number, title',
ALBUM_SONGS = 'album, discNumber, trackNumber',
ALBUM = 'album',
ALBUM_ARTIST = 'order_album_artist_name',
ALBUM_SONGS = 'album',
ARTIST = 'artist',
BPM = 'bpm',
CHANNELS = 'channels',
COMMENT = 'comment',
DURATION = 'duration',
FAVORITED = 'starred ASC, starredAt ASC',
FAVORITED = 'starred_at',
GENRE = 'genre',
ID = 'id',
PLAY_COUNT = 'playCount',
@@ -247,7 +247,7 @@ export enum NDSongListSort {
RECENTLY_ADDED = 'createdAt',
TITLE = 'title',
TRACK = 'track',
YEAR = 'year, album, discNumber, trackNumber',
YEAR = 'year',
}
export type NDSongListParams = {
@@ -261,7 +261,7 @@ export type NDSongListParams = {
export enum NDAlbumArtistListSort {
ALBUM_COUNT = 'albumCount',
FAVORITED = 'starred ASC, starredAt ASC',
FAVORITED = 'starred_at',
NAME = 'name',
PLAY_COUNT = 'playCount',
RATING = 'rating',
@@ -353,7 +353,7 @@ export type NDPlaylistListResponse = NDPlaylist[];
export enum NDPlaylistListSort {
DURATION = 'duration',
NAME = 'name',
OWNER = 'ownerName',
OWNER = 'owner_name',
PUBLIC = 'public',
SONG_COUNT = 'songCount',
UPDATED_AT = 'updatedAt',
+10 -1
View File
@@ -8,7 +8,7 @@ import { ndType } from './navidrome-types';
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components';
import { toast } from '/@/renderer/components/toast';
import i18n from '/@/i18n/i18n';
const localSettings = isElectron() ? window.electron.localSettings : null;
@@ -147,6 +147,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
movePlaylistItem: {
body: ndType._parameters.moveItem,
method: 'PUT',
path: 'playlist/:playlistId/tracks/:trackNumber',
responses: {
200: resultWithHeaders(ndType._response.moveItem),
400: resultWithHeaders(ndType._response.error),
},
},
removeFromPlaylist: {
body: null,
method: 'DELETE',
File diff suppressed because it is too large Load Diff
@@ -99,7 +99,7 @@ const normalizeSong = (
item.rgAlbumGain || item.rgTrackGain
? { album: item.rgAlbumGain, track: item.rgTrackGain }
: null,
genres: item.genres?.map((genre) => ({
genres: (item.genres || []).map((genre) => ({
id: genre.id,
imageUrl: null,
itemType: LibraryItem.GENRE,
@@ -119,7 +119,10 @@ const normalizeSong = (
: null,
playCount: item.playCount,
playlistItemId,
releaseDate: new Date(item.year, 0, 1).toISOString(),
releaseDate: (item.releaseDate
? new Date(item.releaseDate)
: new Date(item.year, 0, 1)
).toISOString(),
releaseYear: String(item.year),
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
@@ -159,7 +162,7 @@ const normalizeAlbum = (
comment: item.comment || null,
createdAt: item.createdAt.split('T')[0],
duration: item.duration * 1000 || null,
genres: item.genres?.map((genre) => ({
genres: (item.genres || []).map((genre) => ({
id: genre.id,
imageUrl: null,
itemType: LibraryItem.GENRE,
@@ -173,8 +176,16 @@ const normalizeAlbum = (
lastPlayedAt: normalizePlayDate(item),
mbzId: item.mbzAlbumId || null,
name: item.name,
originalDate: item.originalDate
? new Date(item.originalDate).toISOString()
: item.originalYear
? new Date(item.originalYear, 0, 1).toISOString()
: null,
playCount: item.playCount,
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
releaseDate: (item.releaseDate
? new Date(item.releaseDate)
: new Date(item.minYear, 0, 1)
).toISOString(),
releaseYear: item.minYear,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
@@ -210,7 +221,7 @@ const normalizeAlbumArtist = (
backgroundImageUrl: null,
biography: item.biography || null,
duration: null,
genres: item.genres?.map((genre) => ({
genres: (item.genres || []).map((genre) => ({
id: genre.id,
imageUrl: null,
itemType: LibraryItem.GENRE,
+30 -66
View File
@@ -1,4 +1,10 @@
import { z } from 'zod';
import {
NDAlbumArtistListSort,
NDAlbumListSort,
NDPlaylistListSort,
NDSongListSort,
} from '/@/renderer/api/navidrome.types';
const sortOrderValues = ['ASC', 'DESC'] as const;
@@ -70,7 +76,7 @@ const albumArtist = z.object({
externalInfoUpdatedAt: z.string(),
externalUrl: z.string(),
fullText: z.string(),
genres: z.array(genre),
genres: z.array(genre).nullable(),
id: z.string(),
largeImageUrl: z.string().optional(),
mbzArtistId: z.string().optional(),
@@ -89,17 +95,8 @@ const albumArtist = z.object({
const albumArtistList = z.array(albumArtist);
const ndAlbumArtistListSort = {
ALBUM_COUNT: 'albumCount',
FAVORITED: 'starred ASC, starredAt ASC',
NAME: 'name',
PLAY_COUNT: 'playCount',
RATING: 'rating',
SONG_COUNT: 'songCount',
} as const;
const albumArtistListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndAlbumArtistListSort).optional(),
_sort: z.nativeEnum(NDAlbumArtistListSort).optional(),
genre_id: z.string().optional(),
name: z.string().optional(),
starred: z.boolean().optional(),
@@ -119,7 +116,7 @@ const album = z.object({
duration: z.number(),
fullText: z.string(),
genre: z.string(),
genres: z.array(genre),
genres: z.array(genre).nullable(),
id: z.string(),
maxYear: z.number(),
mbzAlbumArtistId: z.string().optional(),
@@ -128,9 +125,12 @@ const album = z.object({
name: z.string(),
orderAlbumArtistName: z.string(),
orderAlbumName: z.string(),
originalDate: z.string().optional(),
originalYear: z.number().optional(),
playCount: z.number(),
playDate: z.string().optional(),
rating: z.number().optional(),
releaseDate: z.string().optional(),
size: z.number(),
songCount: z.number(),
sortAlbumArtistName: z.string(),
@@ -142,23 +142,8 @@ const album = z.object({
const albumList = z.array(album);
const ndAlbumListSort = {
ALBUM_ARTIST: 'albumArtist',
ARTIST: 'artist',
DURATION: 'duration',
NAME: 'name',
PLAY_COUNT: 'playCount',
PLAY_DATE: 'play_date',
RANDOM: 'random',
RATING: 'rating',
RECENTLY_ADDED: 'recently_added',
SONG_COUNT: 'songCount',
STARRED: 'starred',
YEAR: 'max_year',
} as const;
const albumListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndAlbumListSort).optional(),
_sort: z.nativeEnum(NDAlbumListSort).optional(),
album_id: z.string().optional(),
artist_id: z.string().optional(),
compilation: z.boolean().optional(),
@@ -195,7 +180,7 @@ const song = z.object({
externalUrl: z.string().optional(),
fullText: z.string(),
genre: z.string(),
genres: z.array(genre),
genres: z.array(genre).nullable(),
hasCoverArt: z.boolean(),
id: z.string(),
imageFiles: z.string().optional(),
@@ -214,6 +199,7 @@ const song = z.object({
playCount: z.number(),
playDate: z.string().optional(),
rating: z.number().optional(),
releaseDate: z.string().optional(),
rgAlbumGain: z.number().optional(),
rgAlbumPeak: z.number().optional(),
rgTrackGain: z.number().optional(),
@@ -233,33 +219,12 @@ const song = z.object({
const songList = z.array(song);
const ndSongListSort = {
ALBUM: 'album, order_album_artist_name, disc_number, track_number, title',
ALBUM_ARTIST: 'order_album_artist_name, album, disc_number, track_number, title',
ALBUM_SONGS: 'album, discNumber, trackNumber',
ARTIST: 'artist',
BPM: 'bpm',
CHANNELS: 'channels',
COMMENT: 'comment',
DURATION: 'duration',
FAVORITED: 'starred ASC, starredAt ASC',
GENRE: 'genre',
ID: 'id',
PLAY_COUNT: 'playCount',
PLAY_DATE: 'playDate',
RATING: 'rating',
RECENTLY_ADDED: 'createdAt',
TITLE: 'title',
TRACK: 'track',
YEAR: 'year, album, discNumber, trackNumber',
};
const songListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndSongListSort).optional(),
_sort: z.nativeEnum(NDSongListSort).optional(),
album_artist_id: z.array(z.string()).optional(),
album_id: z.array(z.string()).optional(),
artist_id: z.array(z.string()).optional(),
genre_id: z.string().optional(),
genre_id: z.array(z.string()).optional(),
path: z.string().optional(),
starred: z.boolean().optional(),
title: z.string().optional(),
@@ -286,17 +251,8 @@ const playlist = z.object({
const playlistList = z.array(playlist);
const ndPlaylistListSort = {
DURATION: 'duration',
NAME: 'name',
OWNER: 'ownerName',
PUBLIC: 'public',
SONG_COUNT: 'songCount',
UPDATED_AT: 'updatedAt',
} as const;
const playlistListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndPlaylistListSort).optional(),
_sort: z.nativeEnum(NDPlaylistListSort).optional(),
owner_id: z.string().optional(),
q: z.string().optional(),
smart: z.boolean().optional(),
@@ -355,13 +311,19 @@ const shareItemParameters = z.object({
resourceType: z.string(),
});
const moveItemParameters = z.object({
insert_before: z.string(),
});
const moveItem = z.null();
export const ndType = {
_enum: {
albumArtistList: ndAlbumArtistListSort,
albumList: ndAlbumListSort,
albumArtistList: NDAlbumArtistListSort,
albumList: NDAlbumListSort,
genreList: genreListSort,
playlistList: ndPlaylistListSort,
songList: ndSongListSort,
playlistList: NDPlaylistListSort,
songList: NDSongListSort,
userList: ndUserListSort,
},
_parameters: {
@@ -371,6 +333,7 @@ export const ndType = {
authenticate: authenticateParameters,
createPlaylist: createPlaylistParameters,
genreList: genreListParameters,
moveItem: moveItemParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
shareItem: shareItemParameters,
@@ -390,6 +353,7 @@ export const ndType = {
error,
genre,
genreList,
moveItem,
playlist,
playlistList,
playlistSong,
+46
View File
@@ -50,6 +50,19 @@ export const queryKeys: Record<
Record<string, (...props: any) => QueryFunctionContext['queryKey']>
> = {
albumArtists: {
count: (serverId: string, query?: AlbumArtistListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'albumArtists', 'count', filter, pagination] as const;
}
if (query) {
return [serverId, 'albumArtists', 'count', filter] as const;
}
return [serverId, 'albumArtists', 'count'] as const;
},
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
return [serverId, 'albumArtists', 'detail'] as const;
@@ -73,6 +86,27 @@ export const queryKeys: Record<
},
},
albums: {
count: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination && artistId) {
return [serverId, 'albums', 'count', artistId, filter, pagination] as const;
}
if (query && pagination) {
return [serverId, 'albums', 'count', filter, pagination] as const;
}
if (query && artistId) {
return [serverId, 'albums', 'count', artistId, filter] as const;
}
if (query) {
return [serverId, 'albums', 'count', filter] as const;
}
return [serverId, 'albums', 'count'] as const;
},
detail: (serverId: string, query?: AlbumDetailQuery) =>
[serverId, 'albums', 'detail', query] as const,
list: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
@@ -208,6 +242,18 @@ export const queryKeys: Record<
root: (serverId: string) => [serverId] as const,
},
songs: {
count: (serverId: string, query?: SongListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'songs', 'count', filter, pagination] as const;
}
if (query) {
return [serverId, 'songs', 'count', filter] as const;
}
return [serverId, 'songs', 'count'] as const;
},
detail: (serverId: string, query?: SongDetailQuery) => {
if (query) return [serverId, 'songs', 'detail', query] as const;
return [serverId, 'songs', 'detail'] as const;
+105 -1
View File
@@ -27,6 +27,46 @@ export const contract = c.router({
200: ssType._response.createFavorite,
},
},
createPlaylist: {
method: 'GET',
path: 'createPlaylist.view',
query: ssType._parameters.createPlaylist,
responses: {
200: ssType._response.createPlaylist,
},
},
deletePlaylist: {
method: 'GET',
path: 'deletePlaylist.view',
query: ssType._parameters.deletePlaylist,
responses: {
200: ssType._response.baseResponse,
},
},
getAlbum: {
method: 'GET',
path: 'getAlbum.view',
query: ssType._parameters.getAlbum,
responses: {
200: ssType._response.getAlbum,
},
},
getAlbumList2: {
method: 'GET',
path: 'getAlbumList2.view',
query: ssType._parameters.getAlbumList2,
responses: {
200: ssType._response.getAlbumList2,
},
},
getArtist: {
method: 'GET',
path: 'getArtist.view',
query: ssType._parameters.getArtist,
responses: {
200: ssType._response.getArtist,
},
},
getArtistInfo: {
method: 'GET',
path: 'getArtistInfo.view',
@@ -35,6 +75,22 @@ export const contract = c.router({
200: ssType._response.artistInfo,
},
},
getArtists: {
method: 'GET',
path: 'getArtists.view',
query: ssType._parameters.getArtists,
responses: {
200: ssType._response.getArtists,
},
},
getGenres: {
method: 'GET',
path: 'getGenres.view',
query: ssType._parameters.getGenres,
responses: {
200: ssType._response.getGenres,
},
},
getMusicFolderList: {
method: 'GET',
path: 'getMusicFolders.view',
@@ -42,6 +98,22 @@ export const contract = c.router({
200: ssType._response.musicFolderList,
},
},
getPlaylist: {
method: 'GET',
path: 'getPlaylist.view',
query: ssType._parameters.getPlaylist,
responses: {
200: ssType._response.getPlaylist,
},
},
getPlaylists: {
method: 'GET',
path: 'getPlaylists.view',
query: ssType._parameters.getPlaylists,
responses: {
200: ssType._response.getPlaylists,
},
},
getRandomSongList: {
method: 'GET',
path: 'getRandomSongs.view',
@@ -65,6 +137,30 @@ export const contract = c.router({
200: ssType._response.similarSongs,
},
},
getSong: {
method: 'GET',
path: 'getSong.view',
query: ssType._parameters.getSong,
responses: {
200: ssType._response.getSong,
},
},
getSongsByGenre: {
method: 'GET',
path: 'getSongsByGenre.view',
query: ssType._parameters.getSongsByGenre,
responses: {
200: ssType._response.getSongsByGenre,
},
},
getStarred: {
method: 'GET',
path: 'getStarred.view',
query: ssType._parameters.getStarred,
responses: {
200: ssType._response.getStarred,
},
},
getStructuredLyrics: {
method: 'GET',
path: 'getLyricsBySongId.view',
@@ -120,6 +216,14 @@ export const contract = c.router({
200: ssType._response.setRating,
},
},
updatePlaylist: {
method: 'GET',
path: 'updatePlaylist.view',
query: ssType._parameters.updatePlaylist,
responses: {
200: ssType._response.baseResponse,
},
},
});
const axiosClient = axios.create({});
@@ -242,7 +346,7 @@ export const ssApiClient = (args: {
return {
body: response?.data,
headers: response.headers as any,
headers: response?.headers as any,
status: response?.status,
};
}
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,8 @@ import {
Album,
ServerListItem,
ServerType,
Playlist,
Genre,
} from '/@/renderer/api/types';
const getCoverArtUrl = (args: {
@@ -36,13 +38,14 @@ const normalizeSong = (
item: z.infer<typeof ssType._response.song>,
server: ServerListItem | null,
deviceId: string,
size?: number,
): QueueSong => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 100,
size: size || 300,
}) || null;
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
@@ -66,7 +69,7 @@ const normalizeSong = (
},
],
bitRate: item.bitRate || 0,
bpm: null,
bpm: item.bpm || null,
channels: null,
comment: null,
compilation: null,
@@ -123,15 +126,18 @@ const normalizeSong = (
};
const normalizeAlbumArtist = (
item: z.infer<typeof ssType._response.albumArtist>,
item:
| z.infer<typeof ssType._response.albumArtist>
| z.infer<typeof ssType._response.artistListEntry>,
server: ServerListItem | null,
imageSize?: number,
): AlbumArtist => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 100,
size: imageSize || 100,
}) || null;
return {
@@ -157,15 +163,16 @@ const normalizeAlbumArtist = (
};
const normalizeAlbum = (
item: z.infer<typeof ssType._response.album>,
item: z.infer<typeof ssType._response.album> | z.infer<typeof ssType._response.albumListEntry>,
server: ServerListItem | null,
imageSize?: number,
): Album => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 300,
size: imageSize || 300,
}) || null;
return {
@@ -177,7 +184,7 @@ const normalizeAlbum = (
backdropImageUrl: null,
comment: null,
createdAt: item.created,
duration: item.duration,
duration: item.duration * 1000,
genres: item.genre
? [
{
@@ -196,6 +203,7 @@ const normalizeAlbum = (
lastPlayedAt: null,
mbzId: null,
name: item.name,
originalDate: null,
playCount: null,
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
releaseYear: item.year ? Number(item.year) : null,
@@ -203,7 +211,10 @@ const normalizeAlbum = (
serverType: ServerType.SUBSONIC,
size: null,
songCount: item.songCount,
songs: [],
songs:
(item as z.infer<typeof ssType._response.album>).song?.map((song) =>
normalizeSong(song, server, ''),
) || [],
uniqueId: nanoid(),
updatedAt: item.created,
userFavorite: item.starred || false,
@@ -211,8 +222,51 @@ const normalizeAlbum = (
};
};
const normalizePlaylist = (
item:
| z.infer<typeof ssType._response.playlist>
| z.infer<typeof ssType._response.playlistListEntry>,
server: ServerListItem | null,
): Playlist => {
return {
description: item.comment || null,
duration: item.duration,
genres: [],
id: item.id,
imagePlaceholderUrl: null,
imageUrl: getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 300,
}),
itemType: LibraryItem.PLAYLIST,
name: item.name,
owner: item.owner,
ownerId: item.owner,
public: item.public,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: null,
songCount: item.songCount,
};
};
const normalizeGenre = (item: z.infer<typeof ssType._response.genre>): Genre => {
return {
albumCount: item.albumCount,
id: item.value,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: item.value,
songCount: item.songCount,
};
};
export const ssNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
genre: normalizeGenre,
playlist: normalizePlaylist,
song: normalizeSong,
};
+267 -11
View File
@@ -60,6 +60,10 @@ const songGain = z.object({
trackPeak: z.number().optional(),
});
const genreItem = z.object({
name: z.string(),
});
const song = z.object({
album: z.string().optional(),
albumId: z.string().optional(),
@@ -67,15 +71,18 @@ const song = z.object({
artistId: z.string().optional(),
averageRating: z.number().optional(),
bitRate: z.number().optional(),
bpm: z.number().optional(),
contentType: z.string(),
coverArt: z.string().optional(),
created: z.string(),
discNumber: z.number(),
duration: z.number().optional(),
genre: z.string().optional(),
genres: z.array(genreItem).optional(),
id: z.string(),
isDir: z.boolean(),
isVideo: z.boolean(),
musicBrainzId: z.string().optional(),
parent: z.string(),
path: z.string(),
playCount: z.number().optional(),
@@ -99,6 +106,7 @@ const album = z.object({
duration: z.number(),
genre: z.string().optional(),
id: z.string(),
isCompilation: z.boolean().optional(),
isDir: z.boolean(),
isVideo: z.boolean(),
name: z.string(),
@@ -111,6 +119,10 @@ const album = z.object({
year: z.number().optional(),
});
const albumListEntry = album.omit({
song: true,
});
const albumListParameters = z.object({
fromYear: z.number().optional(),
genre: z.string().optional(),
@@ -124,11 +136,13 @@ const albumListParameters = z.object({
const albumList = z.array(album.omit({ song: true }));
const albumArtist = z.object({
album: z.array(album),
albumCount: z.string(),
artistImageUrl: z.string().optional(),
coverArt: z.string().optional(),
id: z.string(),
name: z.string(),
starred: z.string().optional(),
});
const albumArtistList = z.object({
@@ -136,6 +150,14 @@ const albumArtistList = z.object({
name: z.string(),
});
const artistListEntry = albumArtist.pick({
albumCount: true,
coverArt: true,
id: true,
name: true,
starred: true,
});
const artistInfoParameters = z.object({
count: z.number().optional(),
id: z.string(),
@@ -168,9 +190,11 @@ const topSongsListParameters = z.object({
});
const topSongsList = z.object({
topSongs: z.object({
song: z.array(song),
}),
topSongs: z
.object({
song: z.array(song),
})
.optional(),
});
const scrobbleParameters = z.object({
@@ -182,11 +206,13 @@ const scrobbleParameters = z.object({
const scrobble = z.null();
const search3 = z.object({
searchResult3: z.object({
album: z.array(album),
artist: z.array(albumArtist),
song: z.array(song),
}),
searchResult3: z
.object({
album: z.array(album).optional(),
artist: z.array(albumArtist).optional(),
song: z.array(song).optional(),
})
.optional(),
});
const search3Parameters = z.object({
@@ -209,9 +235,11 @@ const randomSongListParameters = z.object({
});
const randomSongList = z.object({
randomSongs: z.object({
song: z.array(song),
}),
randomSongs: z
.object({
song: z.array(song),
})
.optional(),
});
const ping = z.object({
@@ -274,12 +302,223 @@ export enum SubsonicExtensions {
TRANSCODE_OFFSET = 'transcodeOffset',
}
const updatePlaylistParameters = z.object({
comment: z.string().optional(),
name: z.string().optional(),
playlistId: z.string(),
public: z.boolean().optional(),
songIdToAdd: z.array(z.string()).optional(),
songIndexToRemove: z.array(z.string()).optional(),
});
const getStarredParameters = z.object({
musicFolderId: z.string().optional(),
});
const getStarred = z.object({
starred: z
.object({
album: z.array(albumListEntry),
artist: z.array(artistListEntry),
song: z.array(song),
})
.optional(),
});
const getSongsByGenreParameters = z.object({
count: z.number().optional(),
genre: z.string(),
musicFolderId: z.string().optional(),
offset: z.number().optional(),
});
const getSongsByGenre = z.object({
songsByGenre: z
.object({
song: z.array(song),
})
.optional(),
});
const getAlbumParameters = z.object({
id: z.string(),
musicFolderId: z.string().optional(),
});
const getAlbum = z.object({
album,
});
const getArtistParameters = z.object({
id: z.string(),
});
const getArtist = z.object({
artist: albumArtist,
});
const getSongParameters = z.object({
id: z.string(),
});
const getSong = z.object({
song,
});
const getArtistsParameters = z.object({
musicFolderId: z.string().optional(),
});
const getArtists = z.object({
artists: z.object({
ignoredArticles: z.string(),
index: z.array(
z.object({
artist: z.array(artistListEntry),
name: z.string(),
}),
),
}),
});
const deletePlaylistParameters = z.object({
id: z.string(),
});
const createPlaylistParameters = z.object({
name: z.string(),
playlistId: z.string().optional(),
songId: z.array(z.string()).optional(),
});
const playlist = z.object({
changed: z.string().optional(),
comment: z.string().optional(),
coverArt: z.string().optional(),
created: z.string(),
duration: z.number(),
entry: z.array(song).optional(),
id: z.string(),
name: z.string(),
owner: z.string(),
public: z.boolean(),
songCount: z.number(),
});
const createPlaylist = z.object({
playlist,
});
const getPlaylistsParameters = z.object({
username: z.string().optional(),
});
const playlistListEntry = playlist.omit({
entry: true,
});
const getPlaylists = z.object({
playlists: z
.object({
playlist: z.array(playlistListEntry),
})
.optional(),
});
const getPlaylistParameters = z.object({
id: z.string(),
});
const getPlaylist = z.object({
playlist,
});
const genre = z.object({
albumCount: z.number(),
songCount: z.number(),
value: z.string(),
});
const getGenresParameters = z.object({});
const getGenres = z.object({
genres: z
.object({
genre: z.array(genre),
})
.optional(),
});
export enum AlbumListSortType {
ALPHABETICAL_BY_ARTIST = 'alphabeticalByArtist',
ALPHABETICAL_BY_NAME = 'alphabeticalByName',
BY_GENRE = 'byGenre',
BY_YEAR = 'byYear',
FREQUENT = 'frequent',
NEWEST = 'newest',
RANDOM = 'random',
RECENT = 'recent',
STARRED = 'starred',
}
const getAlbumList2Parameters = z
.object({
fromYear: z.number().optional(),
genre: z.string().optional(),
musicFolderId: z.string().optional(),
offset: z.number().optional(),
size: z.number().optional(),
toYear: z.number().optional(),
type: z.nativeEnum(AlbumListSortType),
})
.refine(
(val) => {
if (val.type === AlbumListSortType.BY_YEAR) {
return val.fromYear !== undefined && val.toYear !== undefined;
}
return true;
},
{
message: 'Parameters "fromYear" and "toYear" are required when using sort "byYear"',
},
)
.refine(
(val) => {
if (val.type === AlbumListSortType.BY_GENRE) {
return val.genre !== undefined;
}
return true;
},
{ message: 'Parameter "genre" is required when using sort "byGenre"' },
);
const getAlbumList2 = z.object({
albumList2: z.object({
album: z.array(albumListEntry),
}),
});
export const ssType = {
_parameters: {
albumList: albumListParameters,
artistInfo: artistInfoParameters,
authenticate: authenticateParameters,
createFavorite: createFavoriteParameters,
createPlaylist: createPlaylistParameters,
deletePlaylist: deletePlaylistParameters,
getAlbum: getAlbumParameters,
getAlbumList2: getAlbumList2Parameters,
getArtist: getArtistParameters,
getArtists: getArtistsParameters,
getGenre: getGenresParameters,
getGenres: getGenresParameters,
getPlaylist: getPlaylistParameters,
getPlaylists: getPlaylistsParameters,
getSong: getSongParameters,
getSongsByGenre: getSongsByGenreParameters,
getStarred: getStarredParameters,
randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters,
scrobble: scrobbleParameters,
@@ -288,18 +527,35 @@ export const ssType = {
similarSongs: similarSongsParameters,
structuredLyrics: structuredLyricsParameters,
topSongsList: topSongsListParameters,
updatePlaylist: updatePlaylistParameters,
},
_response: {
album,
albumArtist,
albumArtistList,
albumList,
albumListEntry,
artistInfo,
artistListEntry,
authenticate,
baseResponse,
createFavorite,
createPlaylist,
genre,
getAlbum,
getAlbumList2,
getArtist,
getArtists,
getGenres,
getPlaylist,
getPlaylists,
getSong,
getSongsByGenre,
getStarred,
musicFolderList,
ping,
playlist,
playlistListEntry,
randomSongList,
removeFavorite,
scrobble,
+293 -39
View File
@@ -1,3 +1,6 @@
import orderBy from 'lodash/orderBy';
import reverse from 'lodash/reverse';
import shuffle from 'lodash/shuffle';
import { z } from 'zod';
import { ServerFeatures } from './features-types';
import { jfType } from './jellyfin/jellyfin-types';
@@ -128,7 +131,7 @@ export interface BasePaginatedResponse<T> {
error?: string | any;
items: T;
startIndex: number;
totalRecordCount: number;
totalRecordCount: number | null;
}
export type AuthenticationResponse = {
@@ -164,6 +167,7 @@ export type Album = {
lastPlayedAt: string | null;
mbzId: string | null;
name: string;
originalDate: string | null;
playCount: number | null;
releaseDate: string | null;
releaseYear: number | null;
@@ -308,6 +312,11 @@ type BaseEndpointArgs = {
};
};
export interface BaseQuery<T> {
sortBy: T;
sortOrder: SortOrder;
}
// Genre List
export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefined;
@@ -317,7 +326,7 @@ export enum GenreListSort {
NAME = 'name',
}
export type GenreListQuery = {
export interface GenreListQuery extends BaseQuery<GenreListSort> {
_custom?: {
jellyfin?: null;
navidrome?: null;
@@ -325,10 +334,8 @@ export type GenreListQuery = {
limit?: number;
musicFolderId?: string;
searchTerm?: string;
sortBy: GenreListSort;
sortOrder: SortOrder;
startIndex: number;
};
}
type GenreListSortMap = {
jellyfin: Record<GenreListSort, JFGenreListSort | undefined>;
@@ -369,22 +376,22 @@ export enum AlbumListSort {
YEAR = 'year',
}
export type AlbumListQuery = {
export interface AlbumListQuery extends BaseQuery<AlbumListSort> {
_custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumList>> & {
maxYear?: number;
minYear?: number;
};
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumList>>;
navidrome?: Partial<z.infer<typeof ndType._parameters.albumList>>;
};
artistIds?: string[];
compilation?: boolean;
favorite?: boolean;
genres?: string[];
limit?: number;
maxYear?: number;
minYear?: number;
musicFolderId?: string;
searchTerm?: string;
sortBy: AlbumListSort;
sortOrder: SortOrder;
startIndex: number;
};
}
export type AlbumListArgs = { query: AlbumListQuery } & BaseEndpointArgs;
@@ -480,24 +487,23 @@ export enum SongListSort {
YEAR = 'year',
}
export type SongListQuery = {
export interface SongListQuery extends BaseQuery<SongListSort> {
_custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.songList>> & {
maxYear?: number;
minYear?: number;
};
jellyfin?: Partial<z.infer<typeof jfType._parameters.songList>>;
navidrome?: Partial<z.infer<typeof ndType._parameters.songList>>;
};
albumIds?: string[];
artistIds?: string[];
favorite?: boolean;
genreIds?: string[];
imageSize?: number;
limit?: number;
maxYear?: number;
minYear?: number;
musicFolderId?: string;
searchTerm?: string;
sortBy: SongListSort;
sortOrder: SortOrder;
startIndex: number;
};
}
export type SongListArgs = { query: SongListQuery } & BaseEndpointArgs;
@@ -594,7 +600,7 @@ export enum AlbumArtistListSort {
SONG_COUNT = 'songCount',
}
export type AlbumArtistListQuery = {
export interface AlbumArtistListQuery extends BaseQuery<AlbumArtistListSort> {
_custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
@@ -602,10 +608,8 @@ export type AlbumArtistListQuery = {
limit?: number;
musicFolderId?: string;
searchTerm?: string;
sortBy: AlbumArtistListSort;
sortOrder: SortOrder;
startIndex: number;
};
}
export type AlbumArtistListArgs = { query: AlbumArtistListQuery } & BaseEndpointArgs;
@@ -682,17 +686,15 @@ export enum ArtistListSort {
SONG_COUNT = 'songCount',
}
export type ArtistListQuery = {
export interface ArtistListQuery extends BaseQuery<ArtistListSort> {
_custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
};
limit?: number;
musicFolderId?: string;
sortBy: ArtistListSort;
sortOrder: SortOrder;
startIndex: number;
};
}
export type ArtistListArgs = { query: ArtistListQuery } & BaseEndpointArgs;
@@ -817,13 +819,13 @@ export type CreatePlaylistBody = {
navidrome?: {
owner?: string;
ownerId?: string;
public?: boolean;
rules?: Record<string, any>;
sync?: boolean;
};
};
comment?: string;
name: string;
public?: boolean;
};
export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs;
@@ -840,7 +842,6 @@ export type UpdatePlaylistBody = {
navidrome?: {
owner?: string;
ownerId?: string;
public?: boolean;
rules?: Record<string, any>;
sync?: boolean;
};
@@ -848,6 +849,7 @@ export type UpdatePlaylistBody = {
comment?: string;
genres?: Genre[];
name: string;
public?: boolean;
};
export type UpdatePlaylistArgs = {
@@ -878,17 +880,15 @@ export enum PlaylistListSort {
UPDATED_AT = 'updatedAt',
}
export type PlaylistListQuery = {
export interface PlaylistListQuery extends BaseQuery<PlaylistListSort> {
_custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.playlistList>>;
navidrome?: Partial<z.infer<typeof ndType._parameters.playlistList>>;
};
limit?: number;
searchTerm?: string;
sortBy: PlaylistListSort;
sortOrder: SortOrder;
startIndex: number;
};
}
export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs;
@@ -962,7 +962,7 @@ export enum UserListSort {
NAME = 'name',
}
export type UserListQuery = {
export interface UserListQuery extends BaseQuery<UserListSort> {
_custom?: {
navidrome?: {
owner_id?: string;
@@ -970,10 +970,8 @@ export type UserListQuery = {
};
limit?: number;
searchTerm?: string;
sortBy: UserListSort;
sortOrder: SortOrder;
startIndex: number;
};
}
export type UserListArgs = { query: UserListQuery } & BaseEndpointArgs;
@@ -1072,12 +1070,19 @@ export type SearchResponse = {
songs: Song[];
};
export enum Played {
All = 'all',
Never = 'never',
Played = 'played',
}
export type RandomSongListQuery = {
genre?: string;
limit?: number;
maxYear?: number;
minYear?: number;
musicFolderId?: string;
played: Played;
};
export type RandomSongListArgs = {
@@ -1191,3 +1196,252 @@ export type SimilarSongsQuery = {
export type SimilarSongsArgs = {
query: SimilarSongsQuery;
} & BaseEndpointArgs;
export type MoveItemQuery = {
endingIndex: number;
playlistId: string;
startingIndex: number;
trackId: string;
};
export type MoveItemArgs = {
query: MoveItemQuery;
} & BaseEndpointArgs;
export type DownloadQuery = {
id: string;
};
export type DownloadArgs = {
query: DownloadQuery;
} & BaseEndpointArgs;
export type TranscodingQuery = {
base: string;
bitrate?: number;
format?: string;
};
export type TranscodingArgs = {
query: TranscodingQuery;
} & BaseEndpointArgs;
export type ControllerEndpoint = {
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
authenticate: (
url: string,
body: { legacy?: boolean; password: string; username: string },
) => Promise<AuthenticationResponse>;
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumArtistListCount: (args: AlbumArtistListArgs) => Promise<number>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getAlbumListCount: (args: AlbumListArgs) => Promise<number>;
// getArtistInfo?: (args: any) => void;
// getArtistList?: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getDownloadUrl: (args: DownloadArgs) => string;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistListCount: (args: PlaylistListArgs) => Promise<number>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getSongListCount: (args: SongListArgs) => Promise<number>;
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getTranscodingUrl: (args: TranscodingArgs) => string;
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;
shareItem?: (args: ShareItemArgs) => Promise<ShareItemResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
};
export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: SortOrder) => {
let results = albums;
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
switch (sortBy) {
case AlbumListSort.ALBUM_ARTIST:
results = orderBy(
results,
['albumArtist', (v) => v.name.toLowerCase()],
[order, 'asc'],
);
break;
case AlbumListSort.DURATION:
results = orderBy(results, ['duration'], [order]);
break;
case AlbumListSort.FAVORITED:
results = orderBy(results, ['starred'], [order]);
break;
case AlbumListSort.NAME:
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
break;
case AlbumListSort.PLAY_COUNT:
results = orderBy(results, ['playCount'], [order]);
break;
case AlbumListSort.RANDOM:
results = shuffle(results);
break;
case AlbumListSort.RECENTLY_ADDED:
results = orderBy(results, ['createdAt'], [order]);
break;
case AlbumListSort.RECENTLY_PLAYED:
results = orderBy(results, ['lastPlayedAt'], [order]);
break;
case AlbumListSort.RATING:
results = orderBy(results, ['userRating'], [order]);
break;
case AlbumListSort.YEAR:
results = orderBy(results, ['releaseYear'], [order]);
break;
case AlbumListSort.SONG_COUNT:
results = orderBy(results, ['songCount'], [order]);
break;
default:
break;
}
return results;
};
export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder: SortOrder) => {
let results = songs;
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
switch (sortBy) {
case SongListSort.ALBUM:
results = orderBy(
results,
[(v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, 'asc', 'asc'],
);
break;
case SongListSort.ALBUM_ARTIST:
results = orderBy(
results,
['albumArtist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
);
break;
case SongListSort.ARTIST:
results = orderBy(
results,
['artist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
);
break;
case SongListSort.DURATION:
results = orderBy(results, ['duration'], [order]);
break;
case SongListSort.FAVORITED:
results = orderBy(results, ['userFavorite', (v) => v.name.toLowerCase()], [order]);
break;
case SongListSort.GENRE:
results = orderBy(
results,
[
(v) => v.genres?.[0].name.toLowerCase(),
(v) => v.album?.toLowerCase(),
'discNumber',
'trackNumber',
],
[order, order, 'asc', 'asc'],
);
break;
case SongListSort.ID:
if (order === 'desc') {
results = reverse(results);
}
break;
case SongListSort.NAME:
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
break;
case SongListSort.PLAY_COUNT:
results = orderBy(results, ['playCount'], [order]);
break;
case SongListSort.RANDOM:
results = shuffle(results);
break;
case SongListSort.RATING:
results = orderBy(results, ['userRating', (v) => v.name.toLowerCase()], [order]);
break;
case SongListSort.RECENTLY_ADDED:
results = orderBy(results, ['created'], [order]);
break;
case SongListSort.YEAR:
results = orderBy(
results,
['year', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
[order, 'asc', 'asc', 'asc'],
);
break;
default:
break;
}
return results;
};
export const sortAlbumArtistList = (
artists: AlbumArtist[],
sortBy: AlbumArtistListSort,
sortOrder: SortOrder,
) => {
const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc';
let results = artists;
switch (sortBy) {
case AlbumArtistListSort.ALBUM_COUNT:
results = orderBy(artists, ['albumCount', (v) => v.name.toLowerCase()], [order, 'asc']);
break;
case AlbumArtistListSort.NAME:
results = orderBy(artists, [(v) => v.name.toLowerCase()], [order]);
break;
case AlbumArtistListSort.FAVORITED:
results = orderBy(artists, ['starred'], [order]);
break;
case AlbumArtistListSort.RATING:
results = orderBy(artists, ['userRating'], [order]);
break;
default:
break;
}
return results;
};
+27 -1
View File
@@ -1,8 +1,9 @@
import { AxiosHeaders } from 'axios';
import isElectron from 'is-electron';
import semverCoerce from 'semver/functions/coerce';
import semverGte from 'semver/functions/gte';
import { z } from 'zod';
import { toast } from '/@/renderer/components';
import { toast } from '/@/renderer/components/toast';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/api/types';
import { ServerFeature } from '/@/renderer/api/features-types';
@@ -99,4 +100,29 @@ export const getFeatures = (
return features;
};
export const getClientType = (): string => {
if (isElectron()) {
return 'Desktop Client';
}
const agent = navigator.userAgent;
switch (true) {
case agent.toLowerCase().indexOf('edge') > -1:
return 'Microsoft Edge';
case agent.toLowerCase().indexOf('edg/') > -1:
return 'Edge Chromium'; // Match also / to avoid matching for the older Edge
case agent.toLowerCase().indexOf('opr') > -1:
return 'Opera';
case agent.toLowerCase().indexOf('chrome') > -1:
return 'Chrome';
case agent.toLowerCase().indexOf('trident') > -1:
return 'Internet Explorer';
case agent.toLowerCase().indexOf('firefox') > -1:
return 'Firefox';
case agent.toLowerCase().indexOf('safari') > -1:
return 'Safari';
default:
return 'PC';
}
};
export const SEPARATOR_STRING = ' · ';
+46 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useState, useRef } from 'react';
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
import { ModuleRegistry } from '@ag-grid-community/core';
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
@@ -20,12 +20,16 @@ import { ContextMenuProvider } from '/@/renderer/features/context-menu';
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
import { PlayerState, useCssSettings, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { FontType, PlaybackType, PlayerStatus, WebAudio } from '/@/renderer/types';
import '@ag-grid-community/styles/ag-grid.css';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
import i18n from '/@/i18n/i18n';
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { sanitizeCss } from '/@/renderer/utils/sanitize';
import { setQueue } from '/@/renderer/utils/set-transcoded-queue-data';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
@@ -40,13 +44,16 @@ export const App = () => {
const theme = useTheme();
const accent = useSettingsStore((store) => store.general.accent);
const language = useSettingsStore((store) => store.general.language);
const nativeImageAspect = useSettingsStore((store) => store.general.nativeAspectRatio);
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
const { enabled, content } = useCssSettings();
const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd();
const { clearQueue, restoreQueue } = useQueueControls();
const remoteSettings = useRemoteSettings();
const textStyleRef = useRef<HTMLStyleElement>();
const cssRef = useRef<HTMLStyleElement>();
useDiscordRpc();
useServerVersion();
@@ -85,15 +92,46 @@ export const App = () => {
}
}, [builtIn, custom, system, type]);
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]);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--primary-color', accent);
}, [accent]);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--image-fit', nativeImageAspect ? 'contain' : 'cover');
}, [nativeImageAspect]);
const providerValue = useMemo(() => {
return { handlePlayQueueAdd };
}, [handlePlayQueueAdd]);
const webAudioProvider = useMemo(() => {
return { setWebAudio, webAudio };
}, [webAudio]);
// Start the mpv instance on startup
useEffect(() => {
const initializeMpv = async () => {
@@ -155,8 +193,9 @@ export const App = () => {
utils.onRestoreQueue((_event: any, data) => {
const playerData = restoreQueue(data);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueue(playerData, true);
setQueue(playerData, true);
}
updateSong(playerData.current.song);
});
}
@@ -246,7 +285,9 @@ export const App = () => {
>
<PlayQueueHandlerContext.Provider value={providerValue}>
<ContextMenuProvider>
<AppRouter />
<WebAudioContext.Provider value={webAudioProvider}>
<AppRouter />
</WebAudioContext.Provider>{' '}
</ContextMenuProvider>
</PlayQueueHandlerContext.Provider>
<IsUpdatedDialog />
+111 -36
View File
@@ -1,4 +1,12 @@
import { useImperativeHandle, forwardRef, useRef, useState, useCallback, useEffect } from 'react';
import {
useImperativeHandle,
forwardRef,
useRef,
useState,
useCallback,
useEffect,
useMemo,
} from 'react';
import isElectron from 'is-electron';
import type { ReactPlayerProps } from 'react-player';
import ReactPlayer from 'react-player/lazy';
@@ -10,16 +18,18 @@ import {
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import type { CrossfadeStyle } from '/@/renderer/types';
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
import { useSpeed } from '/@/renderer/store';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import { getServerById, TranscodingConfig, usePlaybackSettings, useSpeed } from '/@/renderer/store';
import { toast } from '/@/renderer/components/toast';
import { api } from '/@/renderer/api';
interface AudioPlayerProps extends ReactPlayerProps {
crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle;
currentPlayer: 1 | 2;
playbackStyle: PlaybackStyle;
player1: Song;
player2: Song;
player1?: Song;
player2?: Song;
status: PlayerStatus;
volume: number;
}
@@ -35,18 +45,51 @@ const getDuration = (ref: any) => {
return ref.current?.player?.player?.player?.duration;
};
type WebAudio = {
context: AudioContext;
gain: GainNode;
};
// Credits: http://stackoverflow.com/questions/12150729/ddg
// Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393
// This is used so that the player will always have an <audio> element. This means that
// player1Source and player2Source are connected BEFORE the user presses play for
// the first time. This workaround is important for Safari, which seems to require the
// source to be connected PRIOR to resuming audio context
const EMPTY_SOURCE =
'data:audio/wav;base64,UklGRjIAAABXQVZFZm10IBIAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==';
'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV';
const useSongUrl = (transcode: TranscodingConfig, current: boolean, song?: Song): string | null => {
const prior = useRef(['', '']);
return useMemo(() => {
if (song?.serverId) {
// If we are the current track, we do not want a transcoding
// reconfiguration to force a restart.
if (current && prior.current[0] === song.uniqueId) {
return prior.current[1];
}
if (!transcode.enabled) {
// transcoding disabled; save the result
prior.current = [song.uniqueId, song.streamUrl];
return song.streamUrl;
}
const result = api.controller.getTranscodingUrl({
apiClientProps: {
server: getServerById(song.serverId),
},
query: {
base: song.streamUrl,
...transcode,
},
})!;
// transcoding enabled; save the updated result
prior.current = [song.uniqueId, result];
return result;
}
// no track; clear result
prior.current = ['', ''];
return null;
}, [current, song?.uniqueId, song?.serverId, song?.streamUrl, transcode]);
};
export const AudioPlayer = forwardRef(
(
@@ -69,10 +112,15 @@ export const AudioPlayer = forwardRef(
const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
const playback = useSettingsStore((state) => state.playback.mpvProperties);
const shouldUseWebAudio = useSettingsStore((state) => state.playback.webAudio);
const { resetSampleRate } = useSettingsStoreActions();
const playbackSpeed = useSpeed();
const { transcode } = usePlaybackSettings();
const [webAudio, setWebAudio] = useState<WebAudio | null>(null);
const stream1 = useSongUrl(transcode, currentPlayer === 1, player1);
const stream2 = useSongUrl(transcode, currentPlayer === 2, player2);
const { webAudio, setWebAudio } = useWebAudio();
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(
null,
);
@@ -129,7 +177,7 @@ export const AudioPlayer = forwardRef(
);
useEffect(() => {
if ('AudioContext' in window) {
if (shouldUseWebAudio && 'AudioContext' in window) {
let context: AudioContext;
try {
@@ -148,7 +196,7 @@ export const AudioPlayer = forwardRef(
const gain = context.createGain();
gain.connect(context.destination);
setWebAudio({ context, gain });
setWebAudio!({ context, gain });
return () => {
return context.close();
@@ -262,30 +310,57 @@ export const AudioPlayer = forwardRef(
);
useEffect(() => {
if (isElectron()) {
if (audioDeviceId) {
player1Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
player2Ref.current?.getInternalPlayer()?.setSinkId(audioDeviceId);
} else {
player1Ref.current?.getInternalPlayer()?.setSinkId('');
player2Ref.current?.getInternalPlayer()?.setSinkId('');
}
// Not standard, just used in chromium-based browsers. See
// https://developer.chrome.com/blog/audiocontext-setsinkid/.
// If the isElectron() check is every removed, fix this.
if (isElectron() && webAudio && 'setSinkId' in webAudio.context && audioDeviceId) {
const setSink = async () => {
try {
if (audioDeviceId !== 'default') {
// @ts-ignore
await webAudio.context.setSinkId(audioDeviceId);
} else {
// @ts-ignore
await webAudio.context.setSinkId('');
}
} catch (error) {
toast.error({ message: `Error setting sink: ${(error as Error).message}` });
}
};
setSink();
}
}, [audioDeviceId]);
}, [audioDeviceId, webAudio]);
useEffect(() => {
if (webAudio && player1Source && player1 && currentPlayer === 1) {
const newVolume = calculateReplayGain(player1) * volume;
webAudio.gain.gain.setValueAtTime(newVolume, 0);
}
}, [calculateReplayGain, currentPlayer, player1, player1Source, volume, webAudio]);
if (!webAudio) return;
useEffect(() => {
if (webAudio && player2Source && player2 && currentPlayer === 2) {
const newVolume = calculateReplayGain(player2) * volume;
const sources = [player1Source ? player1 : null, player2Source ? player2 : null];
const current = sources[currentPlayer - 1];
// Set the current replaygain
if (current) {
const newVolume = calculateReplayGain(current) * volume;
webAudio.gain.gain.setValueAtTime(newVolume, 0);
}
}, [calculateReplayGain, currentPlayer, player2, player2Source, volume, webAudio]);
// Set the next track replaygain right before the end of this track
// Attempt to prevent pop-in for web audio.
const next = sources[3 - currentPlayer];
if (next && current) {
const newVolume = calculateReplayGain(next) * volume;
webAudio.gain.gain.setValueAtTime(newVolume, (current.duration - 1) / 1000);
}
}, [
calculateReplayGain,
currentPlayer,
player1,
player1Source,
player2,
player2Source,
volume,
webAudio,
]);
const handlePlayer1Start = useCallback(
async (player: ReactPlayer) => {
@@ -346,11 +421,11 @@ export const AudioPlayer = forwardRef(
playbackRate={playbackSpeed}
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player1?.streamUrl || EMPTY_SOURCE}
url={stream1 || EMPTY_SOURCE}
volume={webAudio ? 1 : volume}
width={0}
// If there is no stream url, we do not need to handle when the audio finishes
onEnded={player1?.streamUrl ? handleOnEnded : undefined}
onEnded={stream1 ? handleOnEnded : undefined}
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
}
@@ -366,10 +441,10 @@ export const AudioPlayer = forwardRef(
playbackRate={playbackSpeed}
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player2?.streamUrl || EMPTY_SOURCE}
url={stream2 || EMPTY_SOURCE}
volume={webAudio ? 1 : volume}
width={0}
onEnded={player2?.streamUrl ? handleOnEnded : undefined}
onEnded={stream2 ? handleOnEnded : undefined}
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
}
@@ -60,7 +60,8 @@ export const crossfadeHandler = (args: {
} = args;
if (!isTransitioning || currentPlayer !== player) {
const shouldBeginTransition = currentTime >= duration - fadeDuration;
// check for a large-enough duration, as the default audio element has some dummy audio
const shouldBeginTransition = duration > 0.5 && currentTime >= duration - fadeDuration;
if (shouldBeginTransition) {
setIsTransitioning(true);
@@ -100,10 +101,10 @@ export const crossfadeHandler = (args: {
fadeType === 'constantPower'
? 0
: fadeType === 'constantPowerSlowFade'
? 1
: fadeType === 'constantPowerSlowCut'
? 3
: 10;
? 1
: fadeType === 'constantPowerSlowCut'
? 3
: 10;
percentageOfFadeLeft = timeLeft / fadeDuration;
currentPlayerVolumeCalculation =
+4 -1
View File
@@ -24,7 +24,10 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
background: ${(props) => `var(--btn-${props.variant}-bg)`};
border: ${(props) => `var(--btn-${props.variant}-border)`};
border-radius: ${(props) => `var(--btn-${props.variant}-radius)`};
transition: background 0.2s ease-in-out, color 0.2s ease-in-out, border 0.2s ease-in-out;
transition:
background 0.2s ease-in-out,
color 0.2s ease-in-out,
border 0.2s ease-in-out;
svg {
fill: ${(props) => `var(--btn-${props.variant}-fg)`};
+3 -1
View File
@@ -17,7 +17,9 @@ const CardWrapper = styled.div<{
cursor: ${({ link }) => link && 'pointer'};
background: var(--card-default-bg);
border-radius: var(--card-default-radius);
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
transition:
border 0.2s ease-in-out,
background 0.2s ease-in-out;
&:hover {
background: var(--card-default-bg-hover);
+24 -8
View File
@@ -1,4 +1,5 @@
import React from 'react';
import formatDuration from 'format-duration';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
@@ -6,6 +7,7 @@ import { Album, AlbumArtist, Artist, Playlist, Song } from '/@/renderer/api/type
import { Text } from '/@/renderer/components/text';
import { AppRoute } from '/@/renderer/router/routes';
import { CardRow } from '/@/renderer/types';
import { formatDateAbsolute, formatDateRelative, formatRating } from '/@/renderer/utils/format';
const Row = styled.div<{ $secondary?: boolean }>`
width: 100%;
@@ -69,7 +71,10 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
)}
onClick={(e) => e.stopPropagation()}
>
{row.arrayProperty && item[row.arrayProperty]}
{row.arrayProperty &&
(row.format
? row.format(item)
: item[row.arrayProperty])}
</Text>
</React.Fragment>
))}
@@ -88,7 +93,8 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
overflow="hidden"
size={index > 0 ? 'sm' : 'md'}
>
{row.arrayProperty && item[row.arrayProperty]}
{row.arrayProperty &&
(row.format ? row.format(item) : item[row.arrayProperty])}
</Text>
))}
</Row>
@@ -114,7 +120,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
)}
onClick={(e) => e.stopPropagation()}
>
{data && data[row.property]}
{data && (row.format ? row.format(data) : data[row.property])}
</Text>
) : (
<Text
@@ -123,7 +129,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
overflow="hidden"
size={index > 0 ? 'sm' : 'md'}
>
{data && data[row.property]}
{data && (row.format ? row.format(data) : data[row.property])}
</Text>
)}
</Row>
@@ -151,12 +157,15 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
},
},
createdAt: {
format: (song) => formatDateAbsolute(song.createdAt),
property: 'createdAt',
},
duration: {
format: (album) => (album.duration === null ? null : formatDuration(album.duration)),
property: 'duration',
},
lastPlayedAt: {
format: (album) => formatDateRelative(album.lastPlayedAt),
property: 'lastPlayedAt',
},
name: {
@@ -170,6 +179,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
property: 'playCount',
},
rating: {
format: (album) => formatRating(album),
property: 'userRating',
},
releaseDate: {
@@ -208,12 +218,15 @@ export const SONG_CARD_ROWS: { [key: string]: CardRow<Song> } = {
},
},
createdAt: {
format: (song) => formatDateAbsolute(song.createdAt),
property: 'createdAt',
},
duration: {
format: (song) => (song.duration === null ? null : formatDuration(song.duration)),
property: 'duration',
},
lastPlayedAt: {
format: (song) => formatDateRelative(song.lastPlayedAt),
property: 'lastPlayedAt',
},
name: {
@@ -227,6 +240,7 @@ export const SONG_CARD_ROWS: { [key: string]: CardRow<Song> } = {
property: 'playCount',
},
rating: {
format: (song) => formatRating(song),
property: 'userRating',
},
releaseDate: {
@@ -242,12 +256,14 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
property: 'albumCount',
},
duration: {
format: (artist) => (artist.duration === null ? null : formatDuration(artist.duration)),
property: 'duration',
},
genres: {
property: 'genres',
},
lastPlayedAt: {
format: (artist) => formatDateRelative(artist.lastPlayedAt),
property: 'lastPlayedAt',
},
name: {
@@ -261,6 +277,7 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
property: 'playCount',
},
rating: {
format: (artist) => formatRating(artist),
property: 'userRating',
},
songCount: {
@@ -270,12 +287,14 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow<Playlist> } = {
duration: {
format: (playlist) =>
playlist.duration === null ? null : formatDuration(playlist.duration),
property: 'duration',
},
name: {
property: 'name',
route: {
route: AppRoute.PLAYLISTS_DETAIL,
route: AppRoute.PLAYLISTS_DETAIL_SONGS,
slugs: [{ idProperty: 'id', slugProperty: 'playlistId' }],
},
},
@@ -295,7 +314,4 @@ export const PLAYLIST_CARD_ROWS: { [key: string]: CardRow<Playlist> } = {
songCount: {
property: 'songCount',
},
updatedAt: {
property: 'songCount',
},
};
+1 -1
View File
@@ -92,7 +92,7 @@ const Image = styled(SimpleImg)`
img {
height: 100%;
object-fit: cover;
object-fit: var(--image-fit);
}
`;
@@ -63,7 +63,7 @@ const BackgroundImage = styled.img`
height: 150%;
user-select: none;
filter: blur(24px);
object-fit: cover;
object-fit: var(--image-fit);
object-position: 0 30%;
`;
@@ -207,7 +207,11 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
</Badge>
))}
<Badge size="lg">{currentItem?.releaseYear}</Badge>
<Badge size="lg">{currentItem?.songCount} tracks</Badge>
<Badge size="lg">
{t('entity.trackWithCount', {
count: currentItem?.songCount || 0,
})}
</Badge>
</Group>
<Group position="apart">
<Button
@@ -232,8 +236,8 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
playType === Play.NOW
? 'player.play'
: playType === Play.NEXT
? 'player.addNext'
: 'player.addLast',
? 'player.addNext'
: 'player.addLast',
{ postProcess: 'titleCase' },
)}
</Button>
+3 -1
View File
@@ -9,7 +9,9 @@ const StyledPagination = styled(MantinePagination)<PaginationProps>`
color: var(--btn-default-fg);
background-color: var(--btn-default-bg);
border: none;
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
transition:
background 0.2s ease-in-out,
color 0.2s ease-in-out;
&[data-active] {
color: var(--btn-primary-fg);
+1 -1
View File
@@ -28,7 +28,7 @@ export const Spoiler = ({ maxHeight, defaultOpened, children, ...props }: Spoile
ref={ref}
className={spoilerClassNames}
role="button"
style={{ maxHeight: maxHeight ?? '100px' }}
style={{ maxHeight: maxHeight ?? '100px', whiteSpace: 'pre-wrap' }}
tabIndex={-1}
onClick={handleToggleExpand}
{...props}
+3 -1
View File
@@ -32,7 +32,9 @@ const StyledTabs = styled(MantineTabs)`
background: var(--btn-subtle-bg-hover);
}
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
transition:
background 0.2s ease-in-out,
color 0.2s ease-in-out;
}
button[data-active] {
+8 -8
View File
@@ -16,19 +16,19 @@ const showToast = ({ type, ...props }: NotificationProps) => {
type === 'success'
? 'var(--success-color)'
: type === 'warning'
? 'var(--warning-color)'
: type === 'error'
? 'var(--danger-color)'
: 'var(--primary-color)';
? 'var(--warning-color)'
: type === 'error'
? 'var(--danger-color)'
: 'var(--primary-color)';
const defaultTitle =
type === 'success'
? 'Success'
: type === 'warning'
? 'Warning'
: type === 'error'
? 'Error'
: 'Info';
? 'Warning'
: type === 'error'
? 'Error'
: 'Info';
const defaultDuration = type === 'error' ? 5000 : 2000;
@@ -122,7 +122,7 @@ const Image = styled(SimpleImg)`
img {
height: 100%;
object-fit: cover;
object-fit: var(--image-fit);
}
`;
@@ -160,8 +160,8 @@ export const GridCardControls = ({
itemType === LibraryItem.ALBUM
? ALBUM_CONTEXT_MENU_ITEMS
: itemType === LibraryItem.PLAYLIST
? PLAYLIST_CONTEXT_MENU_ITEMS
: ARTIST_CONTEXT_MENU_ITEMS,
? PLAYLIST_CONTEXT_MENU_ITEMS
: ARTIST_CONTEXT_MENU_ITEMS,
resetInfiniteLoaderCache,
);
@@ -110,7 +110,7 @@ const Image = styled(SimpleImg)`
img {
height: 100%;
object-fit: cover;
object-fit: var(--image-fit);
}
`;
@@ -13,13 +13,15 @@ import InfiniteLoader from 'react-window-infinite-loader';
import { VirtualGridWrapper } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
import type { CardRoute, CardRow, PlayQueueAddOptions } from '/@/renderer/types';
import { ListDisplayType } from '/@/renderer/types';
import { LibraryItem } from '/@/renderer/api/types';
import { AnyLibraryItem, Genre, LibraryItem } from '/@/renderer/api/types';
type LibraryItemOrGenre = AnyLibraryItem | Genre;
export type VirtualInfiniteGridRef = {
resetLoadMoreItemsCache: () => void;
scrollTo: (index: number) => void;
setItemData: (data: any[]) => void;
updateItemData: (rule: (item: any) => any) => void;
setItemData: (data: LibraryItemOrGenre[]) => void;
updateItemData: (rule: (item: LibraryItemOrGenre) => LibraryItemOrGenre) => void;
};
interface VirtualGridProps
@@ -27,7 +29,7 @@ interface VirtualGridProps
cardRows: CardRow<any>[];
display?: ListDisplayType;
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
fetchInitialData?: () => any;
fetchInitialData?: () => LibraryItemOrGenre[];
handleFavorite?: (options: {
id: string[];
isFavorite: boolean;
@@ -69,8 +71,12 @@ export const VirtualInfiniteGrid = forwardRef(
) => {
const listRef = useRef<any>(null);
const loader = useRef<InfiniteLoader>(null);
const minItemCount = useRef(0);
const [itemData, setItemData] = useState<any[]>(fetchInitialData?.() || []);
// itemData can be a sparse array. Treat the intermediate elements as being undefined
const [itemData, setItemData] = useState<Array<LibraryItemOrGenre | undefined>>(
fetchInitialData?.() || [],
);
const { itemHeight, rowCount, columnCount } = useMemo(() => {
const itemsPerRow = width ? Math.floor(width / (itemSize + itemGap * 2)) : 5;
@@ -95,8 +101,17 @@ export const VirtualInfiniteGrid = forwardRef(
const loadMoreItems = useCallback(
async (startIndex: number, stopIndex: number) => {
// Fixes a caching bug(?) when switching between filters and the itemCount increases
if (startIndex === 1) return;
if (
// Fixes a caching bug(?) when switching between filters and the itemCount increases
startIndex === 1 ||
// Fixes a caching bug when refreshing items. Prevents a second
// refetch from happening if:
// 1: we are already in a refresh (-1)
// 2: we just had a refresh, and we are index 0
minItemCount.current === -1 ||
(minItemCount.current > 0 && startIndex === 0)
)
return;
// Need to multiply by columnCount due to the grid layout
const start = startIndex * columnCount;
@@ -109,7 +124,7 @@ export const VirtualInfiniteGrid = forwardRef(
});
setItemData((itemData) => {
const newData: any[] = [...itemData];
const newData = [...itemData];
let itemIndex = 0;
for (let rowIndex = start; rowIndex < itemCount; rowIndex += 1) {
@@ -129,17 +144,19 @@ export const VirtualInfiniteGrid = forwardRef(
resetLoadMoreItemsCache: () => {
if (loader.current) {
loader.current.resetloadMoreItemsCache(false);
minItemCount.current = -1;
setItemData([]);
}
},
scrollTo: (index: number) => {
listRef?.current?.scrollToItem(index);
},
setItemData: (data: any[]) => {
setItemData: (data: LibraryItemOrGenre[]) => {
setItemData(data);
minItemCount.current = data.length;
},
updateItemData: (rule) => {
setItemData((data) => data.map(rule));
setItemData((data) => data.map((item) => item && rule(item)));
},
}));
@@ -44,7 +44,7 @@ const MetadataWrapper = styled.div`
const StyledImage = styled(SimpleImg)`
img {
object-fit: cover;
object-fit: var(--image-fit);
}
`;
@@ -19,8 +19,11 @@ export const FullWidthDiscCell = ({ node, data, api }: ICellRendererParams) => {
const handleToggleDiscNodes = () => {
if (!data) return;
const discNumber = Number(node.data.id.split('-')[1]);
const nodes = getNodesByDiscNumber({ api, discNumber });
const split: string[] = node.data.id.split('-');
const discNumber = Number(split[1]);
// the subtitle could have '-' in it; make sure to have all remaining items
const subtitle = split.length === 3 ? split.slice(2).join('-') : null;
const nodes = getNodesByDiscNumber({ api, discNumber, subtitle });
setNodeSelection({ isSelected: !isSelected, nodes });
setIsSelected((prev) => !prev);
@@ -11,8 +11,8 @@ export const CellContainer = styled.div<{ $position?: 'left' | 'center' | 'right
props.$position === 'right'
? 'flex-end'
: props.$position === 'center'
? 'center'
: 'flex-start'};
? 'center'
: 'flex-start'};
width: 100%;
height: 100%;
letter-spacing: 0.5px;
@@ -20,8 +20,8 @@ export const HeaderWrapper = styled.div<{ $position: Options['position'] }>`
props.$position === 'right'
? 'flex-end'
: props.$position === 'center'
? 'center'
: 'flex-start'};
? 'center'
: 'flex-start'};
width: 100%;
font-family: var(--content-font-family);
text-transform: uppercase;
@@ -37,8 +37,8 @@ const HeaderText = styled(_Text)<{ $position: Options['position'] }>`
props.$position === 'right'
? 'flex-end'
: props.$position === 'center'
? 'center'
: 'flex-start'};
? 'center'
: 'flex-start'};
text-transform: uppercase;
`;
@@ -1,130 +0,0 @@
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { NDAlbumDetail, NDAlbumArtistDetail } from '/@/renderer/api/navidrome.types';
import { queryKeys } from '/@/renderer/api/query-keys';
import { SSAlbumDetail, SSAlbumArtistDetail } from '/@/renderer/api/subsonic.types';
import {
SetRatingArgs,
Album,
AlbumArtist,
LibraryItem,
AnyLibraryItems,
RatingResponse,
ServerType,
} from '/@/renderer/api/types';
import { useSetAlbumListItemDataById, useSetQueueRating, getServerById } from '/@/renderer/store';
export const useUpdateRating = () => {
const queryClient = useQueryClient();
const setAlbumListData = useSetAlbumListItemDataById();
const setQueueRating = useSetQueueRating();
return useMutation<
RatingResponse,
AxiosError,
Omit<SetRatingArgs, 'server' | 'apiClientProps'>,
{ previous: { items: AnyLibraryItems } | undefined }
>({
mutationFn: (args) => {
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.updateRating({ ...args, apiClientProps: { server } });
},
onError: (_error, _variables, context) => {
for (const item of context?.previous?.items || []) {
switch (item.itemType) {
case LibraryItem.ALBUM:
setAlbumListData(item.id, { userRating: item.userRating });
break;
case LibraryItem.SONG:
setQueueRating([item.id], item.userRating);
break;
}
}
},
onMutate: (variables) => {
for (const item of variables.query.item) {
switch (item.itemType) {
case LibraryItem.ALBUM:
setAlbumListData(item.id, { userRating: variables.query.rating });
break;
case LibraryItem.SONG:
setQueueRating([item.id], variables.query.rating);
break;
}
}
return { previous: { items: variables.query.item } };
},
onSuccess: (_data, variables) => {
// We only need to set if we're already on the album detail page
const isAlbumDetailPage =
variables.query.item.length === 1 &&
variables.query.item[0].itemType === LibraryItem.ALBUM;
if (isAlbumDetailPage) {
const { serverType, id: albumId, serverId } = variables.query.item[0] as Album;
const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId });
const previous = queryClient.getQueryData<any>(queryKey);
if (previous) {
switch (serverType) {
case ServerType.NAVIDROME:
queryClient.setQueryData<NDAlbumDetail>(queryKey, {
...previous,
userRating: variables.query.rating,
});
break;
case ServerType.SUBSONIC:
queryClient.setQueryData<SSAlbumDetail>(queryKey, {
...previous,
userRating: variables.query.rating,
});
break;
case ServerType.JELLYFIN:
// Jellyfin does not support ratings
break;
}
}
}
// We only need to set if we're already on the album detail page
const isAlbumArtistDetailPage =
variables.query.item.length === 1 &&
variables.query.item[0].itemType === LibraryItem.ALBUM_ARTIST;
if (isAlbumArtistDetailPage) {
const {
serverType,
id: albumArtistId,
serverId,
} = variables.query.item[0] as AlbumArtist;
const queryKey = queryKeys.albumArtists.detail(serverId || '', {
id: albumArtistId,
});
const previous = queryClient.getQueryData<any>(queryKey);
if (previous) {
switch (serverType) {
case ServerType.NAVIDROME:
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
...previous,
userRating: variables.query.rating,
});
break;
case ServerType.SUBSONIC:
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
...previous,
userRating: variables.query.rating,
});
break;
case ServerType.JELLYFIN:
// Jellyfin does not support ratings
break;
}
}
}
},
});
};
@@ -7,7 +7,6 @@ import {
IDatasource,
PaginationChangedEvent,
RowDoubleClickedEvent,
RowModelType,
} from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { QueryKey, useQueryClient } from '@tanstack/react-query';
@@ -16,7 +15,12 @@ import orderBy from 'lodash/orderBy';
import { generatePath, useNavigate } from 'react-router';
import { api } from '/@/renderer/api';
import { QueryPagination, queryKeys } from '/@/renderer/api/query-keys';
import { BasePaginatedResponse, LibraryItem, ServerListItem } from '/@/renderer/api/types';
import {
BasePaginatedResponse,
BaseQuery,
LibraryItem,
ServerListItem,
} from '/@/renderer/api/types';
import { getColumnDefs, VirtualTableProps } from '/@/renderer/components/virtual-table';
import { SetContextMenuItems, useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { AppRoute } from '/@/renderer/router/routes';
@@ -34,6 +38,7 @@ interface UseAgGridProps<TFilter> {
columnType?: 'albumDetail' | 'generic';
contextMenu: SetContextMenuItems;
customFilters?: Partial<TFilter>;
isClientSide?: boolean;
isClientSideSort?: boolean;
isSearchParams?: boolean;
itemCount?: number;
@@ -43,7 +48,9 @@ interface UseAgGridProps<TFilter> {
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const useVirtualTable = <TFilter>({
const BLOCK_SIZE = 500;
export const useVirtualTable = <TFilter extends BaseQuery<any>>({
server,
tableRef,
pageKey,
@@ -52,13 +59,14 @@ export const useVirtualTable = <TFilter>({
itemCount,
customFilters,
isSearchParams,
isClientSide,
isClientSideSort,
columnType,
}: UseAgGridProps<TFilter>) => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { setTable, setTablePagination } = useListStoreActions();
const properties = useListStoreByKey({ filter: customFilters, key: pageKey });
const properties = useListStoreByKey<TFilter>({ filter: customFilters, key: pageKey });
const [searchParams, setSearchParams] = useSearchParams();
const scrollOffset = searchParams.get('scrollOffset');
@@ -182,6 +190,19 @@ export const useVirtualTable = <TFilter>({
return;
}
if (results.totalRecordCount === null) {
const hasMoreRows = results?.items?.length === BLOCK_SIZE;
const lastRowIndex = hasMoreRows
? undefined
: params.startRow + results.items.length;
params.successCallback(
results?.items || [],
hasMoreRows ? undefined : lastRowIndex,
);
return;
}
params.successCallback(results?.items || [], results?.totalRecordCount || 0);
},
rowCount: undefined,
@@ -321,6 +342,7 @@ export const useVirtualTable = <TFilter>({
alwaysShowHorizontalScroll: true,
autoFitColumns: properties.table.autoFit,
blockLoadDebounceMillis: 200,
cacheBlockSize: BLOCK_SIZE,
getRowId: (data: GetRowIdParams<any>) => data.data.id,
infiniteInitialRowCount: itemCount || 100,
pagination: isPaginationEnabled,
@@ -335,10 +357,11 @@ export const useVirtualTable = <TFilter>({
: undefined,
rowBuffer: 20,
rowHeight: properties.table.rowHeight || 40,
rowModelType: 'infinite' as RowModelType,
rowModelType: isClientSide ? 'clientSide' : 'infinite',
suppressRowDrag: true,
};
}, [
isClientSide,
isPaginationEnabled,
isSearchParams,
itemCount,
@@ -370,7 +393,9 @@ export const useVirtualTable = <TFilter>({
);
break;
case LibraryItem.PLAYLIST:
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
navigate(
generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: e.data.id }),
);
break;
default:
break;
+14 -11
View File
@@ -15,8 +15,6 @@ import type { AgGridReactProps } from '@ag-grid-community/react';
import { AgGridReact } from '@ag-grid-community/react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useClickOutside, useMergedRef } from '@mantine/hooks';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import formatDuration from 'format-duration';
import { AnimatePresence } from 'framer-motion';
import { generatePath } from 'react-router';
@@ -43,7 +41,13 @@ import { useFixedTableHeader } from '/@/renderer/components/virtual-table/hooks/
import { NoteCell } from '/@/renderer/components/virtual-table/cells/note-cell';
import { RowIndexCell } from '/@/renderer/components/virtual-table/cells/row-index-cell';
import i18n from '/@/i18n/i18n';
import { formatSizeString } from '/@/renderer/utils/format-size-string';
import {
formatDateAbsolute,
formatDateAbsoluteUTC,
formatDateRelative,
formatSizeString,
} from '/@/renderer/utils/format';
import { useTableChange } from '/@/renderer/hooks/use-song-change';
export * from './table-config-dropdown';
export * from './table-pagination';
@@ -64,8 +68,6 @@ const DummyHeader = styled.div<{ height?: number }>`
height: ${({ height }) => height || 36}px;
`;
dayjs.extend(relativeTime);
const tableColumns: { [key: string]: ColDef } = {
actions: {
cellClass: 'ag-cell-favorite',
@@ -182,8 +184,7 @@ const tableColumns: { [key: string]: ColDef } = {
GenericTableHeader(params, { position: 'center' }),
headerName: i18n.t('table.column.dateAdded'),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
valueFormatter: (params: ValueFormatterParams) => formatDateAbsolute(params.value),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.createdAt : undefined,
width: 130,
@@ -225,8 +226,7 @@ const tableColumns: { [key: string]: ColDef } = {
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: i18n.t('table.column.lastPlayed'),
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).fromNow() : '',
valueFormatter: (params: ValueFormatterParams) => formatDateRelative(params.value),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.lastPlayedAt : undefined,
width: 130,
@@ -258,8 +258,7 @@ const tableColumns: { [key: string]: ColDef } = {
GenericTableHeader(params, { position: 'center' }),
headerName: i18n.t('table.column.releaseDate'),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
valueFormatter: (params: ValueFormatterParams) => formatDateAbsoluteUTC(params.value),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.releaseDate : undefined,
width: 130,
@@ -482,6 +481,7 @@ export interface VirtualTableProps extends AgGridReactProps {
pagination: TablePaginationType;
setPagination: any;
};
shouldUpdateSong?: boolean;
stickyHeader?: boolean;
transparentHeader?: boolean;
}
@@ -499,6 +499,7 @@ export const VirtualTable = forwardRef(
onGridReady,
onGridSizeChanged,
paginationProps,
shouldUpdateSong,
...rest
}: VirtualTableProps,
ref: Ref<AgGridReactType | null>,
@@ -513,6 +514,8 @@ export const VirtualTable = forwardRef(
}
});
useTableChange(tableRef, shouldUpdateSong === true);
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
@@ -6,6 +6,7 @@ import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/set
import { TableColumn, TableType } from '/@/renderer/types';
import { Option } from '/@/renderer/components/option';
import i18n from '/@/i18n/i18n';
import { useTranslation } from 'react-i18next';
export const SONG_TABLE_COLUMNS = [
{
@@ -285,6 +286,7 @@ interface TableConfigDropdownProps {
}
export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
const { t } = useTranslation();
const { setSettings } = useSettingsStoreActions();
const tableConfig = useSettingsStore((state) => state.tables);
@@ -374,7 +376,9 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
return (
<>
<Option>
<Option.Label>Auto-fit Columns</Option.Label>
<Option.Label>
{t('table.config.general.autoFitColumns', { postProcess: 'sentenceCase' })}
</Option.Label>
<Option.Control>
<Switch
defaultChecked={tableConfig[type]?.autoFit}
@@ -384,7 +388,11 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
</Option>
{type !== 'albumDetail' && (
<Option>
<Option.Label>Follow current song</Option.Label>
<Option.Label>
{t('table.config.general.followCurrentSong', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Switch
defaultChecked={tableConfig[type]?.followCurrentSong}
@@ -1,11 +1,16 @@
import { GridApi, RowNode } from '@ag-grid-community/core';
export const getNodesByDiscNumber = (args: { api: GridApi; discNumber: number }) => {
const { api, discNumber } = args;
export const getNodesByDiscNumber = (args: {
api: GridApi;
discNumber: number;
subtitle: string | null;
}) => {
const { api, discNumber, subtitle } = args;
const nodes: RowNode<any>[] = [];
api.forEachNode((node) => {
if (node.data.discNumber === discNumber) nodes.push(node);
if (node.data.discNumber === discNumber && node.data.discSubtitle === subtitle)
nodes.push(node);
});
return nodes;
@@ -37,7 +37,7 @@ const ActionRequiredRoute = () => {
const handleManageServersModal = () => {
openModal({
children: <ServerList />,
title: 'Manage Servers',
title: t('page.appMenu.manageServers', { postProcess: 'sentenceCase' }),
});
};
@@ -11,7 +11,13 @@ import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
import {
AlbumListQuery,
AlbumListSort,
LibraryItem,
QueueSong,
SortOrder,
} from '/@/renderer/api/types';
import { Button, Popover, Spoiler } from '/@/renderer/components';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import {
@@ -105,28 +111,32 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
return [];
}
const uniqueDiscNumbers = new Set(detailQuery.data?.songs.map((s) => s.discNumber));
let discNumber = -1;
let discSubtitle: string | null = null;
const rowData: (QueueSong | { id: string; name: string })[] = [];
const discTranslated = t('common.disc', { postProcess: 'upperCase' });
for (const discNumber of uniqueDiscNumbers.values()) {
const songsByDiscNumber = detailQuery.data?.songs.filter(
(s) => s.discNumber === discNumber,
);
for (const song of detailQuery.data.songs) {
if (song.discNumber !== discNumber || song.discSubtitle !== discSubtitle) {
discNumber = song.discNumber;
discSubtitle = song.discSubtitle;
const discSubtitle = songsByDiscNumber?.[0]?.discSubtitle;
const discName = [`Disc ${discNumber}`.toLocaleUpperCase(), discSubtitle]
.filter(Boolean)
.join(': ');
let id = `disc-${discNumber}`;
let name = `${discTranslated} ${discNumber}`;
rowData.push({
id: `disc-${discNumber}`,
name: discName,
});
rowData.push(...songsByDiscNumber);
if (discSubtitle) {
id += `-${discSubtitle}`;
name += `: ${discSubtitle}`;
}
rowData.push({ id, name });
}
rowData.push(song);
}
return rowData;
}, [detailQuery.data?.songs]);
}, [detailQuery.data?.songs, t]);
const [pagination, setPagination] = useSetState({
artist: 0,
@@ -160,13 +170,12 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
query: {
_custom: {
jellyfin: {
AlbumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
ExcludeItemIds: detailQuery?.data?.id,
},
navidrome: {
artist_id: detailQuery?.data?.albumArtists[0]?.id,
},
},
artistIds: detailQuery?.data?.albumArtists.length
? [detailQuery?.data?.albumArtists[0].id]
: undefined,
limit: 15,
sortBy: AlbumListSort.YEAR,
sortOrder: SortOrder.DESC,
@@ -175,15 +184,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
serverId: server?.id,
});
const relatedAlbumGenresRequest = {
_custom: {
jellyfin: {
GenreIds: detailQuery?.data?.genres?.[0]?.id,
},
navidrome: {
genre_id: detailQuery?.data?.genres?.[0]?.id,
},
},
const relatedAlbumGenresRequest: AlbumListQuery = {
genres: detailQuery.data?.genres.length ? [detailQuery.data.genres[0].id] : undefined,
limit: 15,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
@@ -452,6 +454,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
key={`table-${tableConfig.rowHeight}`}
ref={tableRef}
autoHeight
shouldUpdateSong
stickyHeader
suppressCellFocus
suppressLoadingOverlay
@@ -1,18 +1,25 @@
import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react';
import { Group, Stack } from '@mantine/core';
import { forwardRef, Fragment, Ref } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { AlbumDetailResponse, LibraryItem, ServerType } from '/@/renderer/api/types';
import { Rating, Text } from '/@/renderer/components';
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
import { LibraryHeader, useSetRating } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { formatDurationString } from '/@/renderer/utils';
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
import { useSongChange } from '/@/renderer/hooks/use-song-change';
import { queryKeys } from '/@/renderer/api/query-keys';
import { queryClient } from '/@/renderer/lib/react-query';
interface AlbumDetailHeaderProps {
background: string;
background: {
background: string;
blur: number;
};
}
export const AlbumDetailHeader = forwardRef(
@@ -21,26 +28,82 @@ export const AlbumDetailHeader = forwardRef(
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
const cq = useContainerQuery();
const { t } = useTranslation();
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
const originalDifferentFromRelease =
detailQuery.data?.originalDate &&
detailQuery.data.originalDate !== detailQuery.data.releaseDate;
const releasePrefix = originalDifferentFromRelease
? t('page.albumDetail.released', { postProcess: 'sentenceCase' })
: '♫';
const songIds = useMemo(() => {
return new Set(detailQuery.data?.songs?.map((song) => song.id));
}, [detailQuery.data?.songs]);
const handleSongChange = useCallback(
(id: string) => {
if (songIds.has(id)) {
const queryKey = queryKeys.albums.detail(server?.id, { id: albumId });
queryClient.setQueryData<AlbumDetailResponse | undefined>(
queryKey,
(previous) => {
if (!previous) return undefined;
return {
...previous,
playCount: previous.playCount ? previous.playCount + 1 : 1,
};
},
);
}
},
[albumId, server?.id, songIds],
);
useSongChange((ids, event) => {
if (event.event === 'play') {
handleSongChange(ids[0]);
}
}, detailQuery.data !== undefined);
const metadataItems = [
{
id: 'releaseYear',
secondary: false,
value: detailQuery?.data?.releaseYear,
id: 'releaseDate',
value:
detailQuery?.data?.releaseDate &&
`${releasePrefix} ${formatDateAbsoluteUTC(detailQuery?.data?.releaseDate)}`,
},
{
id: 'songCount',
secondary: false,
value: `${detailQuery?.data?.songCount} songs`,
value: `${detailQuery?.data?.songCount} ${t('entity.track_other', {
count: detailQuery?.data?.songCount as number,
})}`,
},
{
id: 'duration',
secondary: false,
value:
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
{
id: 'playCount',
value: t('entity.play', {
count: detailQuery?.data?.playCount as number,
}),
},
];
if (originalDifferentFromRelease) {
const formatted = `${formatDateAbsoluteUTC(detailQuery!.data!.originalDate)}`;
metadataItems.splice(0, 0, {
id: 'originalDate',
value: formatted,
});
}
const updateRatingMutation = useSetRating({});
const handleUpdateRating = (rating: number) => {
@@ -55,23 +118,21 @@ export const AlbumDetailHeader = forwardRef(
});
};
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
return (
<Stack ref={cq.ref}>
<LibraryHeader
ref={ref}
background={background}
imageUrl={detailQuery?.data?.imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
title={detailQuery?.data?.name || ''}
{...background}
>
<Stack spacing="sm">
<Group spacing="sm">
{metadataItems.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
<Text>{item.value}</Text>
</Fragment>
))}
{showRating && (
@@ -103,7 +164,6 @@ export const AlbumDetailHeader = forwardRef(
$link
component={Link}
fw={600}
size="md"
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
@@ -29,7 +29,7 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd();
const { pageKey, customFilters, id } = useListContext();
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
const { grid, display, filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
const { setGrid } = useListStoreActions();
const [searchParams, setSearchParams] = useSearchParams();
@@ -162,9 +162,9 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
const query: AlbumListQuery = {
limit: take,
startIndex: skip,
...filter,
...customFilters,
startIndex: skip,
};
const queryKey = queryKeys.albums.list(server?.id || '', query, id);
@@ -15,13 +15,20 @@ import {
RiSettings3Fill,
} from 'react-icons/ri';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
import {
AlbumListQuery,
AlbumListSort,
LibraryItem,
ServerType,
SortOrder,
} from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { useListContext } from '/@/renderer/context/list-context';
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters';
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
@@ -139,26 +146,74 @@ const FILTERS = {
value: AlbumListSort.YEAR,
},
],
subsonic: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
value: AlbumListSort.PLAY_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: AlbumListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
value: AlbumListSort.RANDOM,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: AlbumListSort.RECENTLY_ADDED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
value: AlbumListSort.RECENTLY_PLAYED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),
value: AlbumListSort.FAVORITED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
value: AlbumListSort.YEAR,
},
],
};
interface AlbumListHeaderFiltersProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount: number | undefined;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => {
export const AlbumListHeaderFilters = ({
gridRef,
itemCount,
tableRef,
}: AlbumListHeaderFiltersProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { pageKey, customFilters, handlePlay } = useListContext();
const server = useCurrentServer();
const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions();
const { display, filter, table, grid } = useListStoreByKey({
const { display, filter, table, grid } = useListStoreByKey<AlbumListQuery>({
filter: customFilters,
key: pageKey,
});
const cq = useContainerQuery();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.ALBUM,
server,
});
@@ -176,36 +231,50 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
const onFilterChange = useCallback(
(filter: AlbumListFilter) => {
if (isGrid) {
handleRefreshGrid(gridRef, filter);
handleRefreshGrid(gridRef, {
...filter,
...customFilters,
});
} else {
handleRefreshTable(tableRef, {
...filter,
...customFilters,
});
}
handleRefreshTable(tableRef, filter);
},
[gridRef, handleRefreshGrid, handleRefreshTable, isGrid, tableRef],
[customFilters, gridRef, handleRefreshGrid, handleRefreshTable, isGrid, tableRef],
);
const handleOpenFiltersModal = () => {
let FilterComponent;
switch (server?.type) {
case ServerType.NAVIDROME:
FilterComponent = NavidromeAlbumFilters;
break;
case ServerType.JELLYFIN:
FilterComponent = JellyfinAlbumFilters;
break;
case ServerType.SUBSONIC:
FilterComponent = SubsonicAlbumFilters;
break;
default:
break;
}
if (!FilterComponent) {
return;
}
openModal({
children: (
<>
{server?.type === ServerType.NAVIDROME ? (
<NavidromeAlbumFilters
customFilters={customFilters}
disableArtistFilter={!!customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
) : (
<JellyfinAlbumFilters
customFilters={customFilters}
disableArtistFilter={!!customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
)}
</>
<FilterComponent
customFilters={customFilters}
disableArtistFilter={!!customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
),
title: 'Album Filters',
});
@@ -341,8 +410,20 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
filter?._custom?.jellyfin &&
Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined);
return isNavidromeFilterApplied || isJellyfinFilterApplied;
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]);
const isSubsonicFilterApplied =
server?.type === ServerType.SUBSONIC &&
(filter.maxYear || filter.minYear || filter.genres?.length || filter.favorite);
return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied;
}, [
filter?._custom?.jellyfin,
filter?._custom?.navidrome,
filter.favorite,
filter.genres?.length,
filter.maxYear,
filter.minYear,
server?.type,
]);
const isFolderFilterApplied = useMemo(() => {
return filter.musicFolderId !== undefined;
@@ -430,7 +511,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
},
}}
tooltip={{
label: t('common.filter', { count: 2, postProcess: 'sentenceCase' }),
label: t('common.filters', { count: 2, postProcess: 'sentenceCase' }),
}}
variant="subtle"
onClick={handleOpenFiltersModal}
@@ -508,7 +589,9 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
@@ -3,7 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { LibraryItem } from '/@/renderer/api/types';
import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
@@ -33,8 +33,9 @@ export const AlbumListHeader = ({
const cq = useContainerQuery();
const playButtonBehavior = usePlayButtonBehavior();
const genreRef = useRef<string>();
const { filter, handlePlay, refresh, search } = useDisplayRefresh({
const { filter, handlePlay, refresh, search } = useDisplayRefresh<AlbumListQuery>({
gridRef,
itemCount,
itemType: LibraryItem.ALBUM,
server,
tableRef,
@@ -90,6 +91,7 @@ export const AlbumListHeader = ({
<FilterBar>
<AlbumListHeaderFilters
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</FilterBar>
@@ -3,7 +3,13 @@ import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { useListFilterByKey } from '../../../store/list.store';
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import {
AlbumArtistListSort,
AlbumListQuery,
GenreListSort,
LibraryItem,
SortOrder,
} from '/@/renderer/api/types';
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { useGenreList } from '/@/renderer/features/genres';
@@ -25,7 +31,7 @@ export const JellyfinAlbumFilters = ({
serverId,
}: JellyfinAlbumFiltersProps) => {
const { t } = useTranslation();
const filter = useListFilterByKey({ key: pageKey });
const filter = useListFilterByKey<AlbumListQuery>({ key: pageKey });
const { setFilter } = useListStoreActions();
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
@@ -47,10 +53,6 @@ export const JellyfinAlbumFilters = ({
}));
}, [genreListQuery.data]);
const selectedGenres = useMemo(() => {
return filter?._custom?.jellyfin?.GenreIds?.split(',');
}, [filter?._custom?.jellyfin?.GenreIds]);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
@@ -58,20 +60,15 @@ export const JellyfinAlbumFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
IsFavorite: e.currentTarget.checked ? true : undefined,
},
},
_custom: filter?._custom,
favorite: e.currentTarget.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter?._custom?.jellyfin?.IsFavorite,
value: filter?.favorite,
},
];
@@ -80,13 +77,8 @@ export const JellyfinAlbumFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
minYear: e === '' ? undefined : (e as number),
},
},
_custom: filter?._custom,
minYear: e === '' ? undefined : (e as number),
},
itemType: LibraryItem.ALBUM,
key: pageKey,
@@ -99,13 +91,8 @@ export const JellyfinAlbumFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
maxYear: e === '' ? undefined : (e as number),
},
},
_custom: filter?._custom,
maxYear: e === '' ? undefined : (e as number),
},
itemType: LibraryItem.ALBUM,
key: pageKey,
@@ -114,17 +101,11 @@ export const JellyfinAlbumFilters = ({
}, 500);
const handleGenresFilter = debounce((e: string[] | undefined) => {
const genreFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
GenreIds: genreFilterString,
},
},
_custom: filter?._custom,
genres: e,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
@@ -157,17 +138,11 @@ export const JellyfinAlbumFilters = ({
}, [albumArtistListQuery?.data?.items]);
const handleAlbumArtistFilter = (e: string[] | null) => {
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
AlbumArtistIds: albumArtistFilterString,
},
},
_custom: filter?._custom,
artistIds: e || undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
@@ -193,21 +168,21 @@ export const JellyfinAlbumFilters = ({
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter?._custom?.jellyfin?.minYear}
defaultValue={filter?.minYear}
hideControls={false}
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
required={!!filter?._custom?.jellyfin?.maxYear}
required={!!filter?.maxYear}
onChange={(e) => handleMinYearFilter(e)}
/>
<NumberInput
defaultValue={filter?._custom?.jellyfin?.maxYear}
defaultValue={filter?.maxYear}
hideControls={false}
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
required={!!filter?._custom?.jellyfin?.minYear}
required={!!filter?.minYear}
onChange={(e) => handleMaxYearFilter(e)}
/>
</Group>
@@ -216,7 +191,7 @@ export const JellyfinAlbumFilters = ({
clearable
searchable
data={genreList}
defaultValue={selectedGenres}
defaultValue={filter.genres}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={handleGenresFilter}
/>
@@ -5,7 +5,13 @@ import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/rend
import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import {
AlbumArtistListSort,
AlbumListQuery,
GenreListSort,
LibraryItem,
SortOrder,
} from '/@/renderer/api/types';
import { useTranslation } from 'react-i18next';
interface NavidromeAlbumFiltersProps {
@@ -24,7 +30,7 @@ export const NavidromeAlbumFilters = ({
serverId,
}: NavidromeAlbumFiltersProps) => {
const { t } = useTranslation();
const { filter } = useListStoreByKey({ key: pageKey });
const { filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
const { setFilter } = useListStoreActions();
const genreListQuery = useGenreList({
@@ -48,13 +54,8 @@ export const NavidromeAlbumFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
genre_id: e || undefined,
},
},
_custom: filter._custom,
genres: e ? [e] : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
@@ -90,20 +91,15 @@ export const NavidromeAlbumFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
starred: e.currentTarget.checked ? true : undefined,
},
},
_custom: filter._custom,
favorite: e.currentTarget.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.starred,
value: filter.favorite,
},
{
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
@@ -111,20 +107,15 @@ export const NavidromeAlbumFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
compilation: e.currentTarget.checked ? true : undefined,
},
},
_custom: filter._custom,
compilation: e.currentTarget.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.compilation,
value: filter.compilation,
},
{
label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }),
@@ -0,0 +1,141 @@
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AlbumListQuery, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
interface SubsonicAlbumFiltersProps {
onFilterChange: (filters: AlbumListFilter) => void;
pageKey: string;
serverId?: string;
}
export const SubsonicAlbumFilters = ({
onFilterChange,
pageKey,
serverId,
}: SubsonicAlbumFiltersProps) => {
const { t } = useTranslation();
const { filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
const { setFilter } = useListStoreActions();
const genreListQuery = useGenreList({
query: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
});
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.items.map((genre) => ({
label: genre.name,
value: genre.id,
}));
}, [genreListQuery.data]);
const handleGenresFilter = debounce((e: string | null) => {
const updatedFilters = setFilter({
data: {
genres: e ? [e] : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
}, 250);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
favorite: e.target.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter.favorite,
},
];
const handleYearFilter = debounce((e: number | string, type: 'min' | 'max') => {
let data: Partial<AlbumListQuery> = {};
if (type === 'min') {
data = {
minYear: e ? Number(e) : undefined,
};
} else {
data = {
maxYear: e ? Number(e) : undefined,
};
}
const updatedFilters = setFilter({
data,
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
}, 500);
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
key={`nd-filter-${filter.label}`}
position="apart"
>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter.minYear}
disabled={filter.genres?.length !== undefined}
hideControls={false}
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e, 'min')}
/>
<NumberInput
defaultValue={filter.maxYear}
disabled={filter.genres?.length !== undefined}
hideControls={false}
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e, 'max')}
/>
</Group>
<Group grow>
<Select
clearable
searchable
data={genreList}
defaultValue={filter.genres?.length ? filter.genres[0] : undefined}
disabled={Boolean(filter.minYear || filter.maxYear)}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={handleGenresFilter}
/>
</Group>
</Stack>
);
};
@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { AlbumListQuery } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useAlbumListCount = (args: QueryHookArgs<AlbumListQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
enabled: !!serverId,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumListCount({
apiClientProps: {
server,
signal,
},
query,
});
},
queryKey: queryKeys.albums.count(
serverId || '',
Object.keys(query).length === 0 ? undefined : query,
),
...options,
});
};
@@ -10,17 +10,18 @@ import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem } from '/@/renderer/api/types';
import { useCurrentServer } from '/@/renderer/store';
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
const AlbumDetailRoute = () => {
const tableRef = useRef<AgGridReactType | null>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const { albumBackground, albumBackgroundBlur } = useGeneralSettings();
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
const { color: background, colorId } = useFastAverageColor({
const { color: backgroundColor, colorId } = useFastAverageColor({
id: albumId,
src: detailQuery.data?.imageUrl,
srcLoaded: !detailQuery.isLoading,
@@ -38,16 +39,19 @@ const AlbumDetailRoute = () => {
});
};
if (!background || colorId !== albumId) {
if (!backgroundColor || colorId !== albumId) {
return <Spinner container />;
}
const backgroundUrl = detailQuery.data?.imageUrl || '';
const background = (albumBackground && `url(${backgroundUrl})`) || backgroundColor;
return (
<AnimatedPage key={`album-detail-${albumId}`}>
<NativeScrollArea
ref={scrollAreaRef}
pageHeaderProps={{
backgroundColor: background,
backgroundColor: backgroundColor || undefined,
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
@@ -62,7 +66,10 @@ const AlbumDetailRoute = () => {
>
<AlbumDetailHeader
ref={headerRef}
background={background}
background={{
background,
blur: (albumBackground && albumBackgroundBlur) || 0,
}}
/>
<AlbumDetailContent
background={background}
@@ -5,12 +5,11 @@ import { useTranslation } from 'react-i18next';
import { useParams, useSearchParams } from 'react-router-dom';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { AlbumListQuery, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ListContext } from '/@/renderer/context/list-context';
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AnimatedPage } from '/@/renderer/features/shared';
import { queryClient } from '/@/renderer/lib/react-query';
@@ -18,6 +17,7 @@ import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
import { Play } from '/@/renderer/types';
import { useGenreList } from '/@/renderer/features/genres';
import { titleCase } from '/@/renderer/utils';
import { useAlbumListCount } from '/@/renderer/features/albums/queries/album-list-count-query';
const AlbumListRoute = () => {
const { t } = useTranslation();
@@ -33,14 +33,7 @@ const AlbumListRoute = () => {
const value = {
...(albumArtistId && { artistIds: [albumArtistId] }),
...(genreId && {
_custom: {
jellyfin: {
GenreIds: genreId,
},
navidrome: {
genre_id: genreId,
},
},
genres: [genreId],
}),
};
@@ -51,7 +44,7 @@ const AlbumListRoute = () => {
return value;
}, [albumArtistId, genreId]);
const albumListFilter = useListFilterByKey({
const albumListFilter = useListFilterByKey<AlbumListQuery>({
filter: customFilters,
key: pageKey,
});
@@ -78,32 +71,27 @@ const AlbumListRoute = () => {
return genre?.name;
}, [genreId, genreList.data]);
const itemCountCheck = useAlbumList({
const itemCountCheck = useAlbumListCount({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: {
limit: 1,
startIndex: 0,
...albumListFilter,
},
serverId: server?.id,
});
const itemCount =
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
const handlePlay = useCallback(
async (args: { initialSongId?: string; playType: Play }) => {
if (!itemCount || itemCount === 0) return;
const { playType } = args;
const query = {
startIndex: 0,
...albumListFilter,
...customFilters,
startIndex: 0,
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
@@ -143,8 +131,8 @@ const AlbumListRoute = () => {
const title = artist
? t('page.albumList.artistAlbums', { artist })
: genreId
? t('page.albumList.genreAlbums', { genre: titleCase(genreTitle) })
: undefined;
? t('page.albumList.genreAlbums', { genre: titleCase(genreTitle) })
: undefined;
return (
<AnimatedPage>
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import { Box, Group, Stack } from '@mantine/core';
import { Box, Grid, Group, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { FaLastfmSquare } from 'react-icons/fa';
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
@@ -36,7 +36,7 @@ import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/fe
import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { ArtistItem, useCurrentServer } from '/@/renderer/store';
import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { CardRow, Play, TableColumn } from '/@/renderer/types';
import { sanitize } from '/@/renderer/utils/sanitize';
@@ -65,13 +65,25 @@ interface AlbumArtistDetailContentProps {
export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailContentProps) => {
const { t } = useTranslation();
const { externalLinks } = useGeneralSettings();
const { artistItems, externalLinks } = useGeneralSettings();
const { albumArtistId } = useParams() as { albumArtistId: string };
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
const server = useCurrentServer();
const genrePath = useGenreRoute();
const [enabledItem, itemOrder] = useMemo(() => {
const enabled: { [key in ArtistItem]?: boolean } = {};
const order: { [key in ArtistItem]?: number } = {};
for (const [idx, item] of artistItems.entries()) {
enabled[item.id] = !item.disabled;
order[item.id] = idx + 1;
}
return [enabled, order];
}, [artistItems]);
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
serverId: server?.id,
@@ -95,19 +107,11 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
})}`;
const recentAlbumsQuery = useAlbumList({
options: {
enabled: enabledItem.recentAlbums,
},
query: {
_custom: {
jellyfin: {
...(server?.type === ServerType.JELLYFIN
? { ArtistIds: albumArtistId }
: undefined),
},
navidrome: {
...(server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: false }
: undefined),
},
},
artistIds: [albumArtistId],
limit: 15,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
@@ -117,19 +121,12 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
});
const compilationAlbumsQuery = useAlbumList({
options: {
enabled: enabledItem.compilations && server?.type !== ServerType.SUBSONIC,
},
query: {
_custom: {
jellyfin: {
...(server?.type === ServerType.JELLYFIN
? { ContributingArtistIds: albumArtistId }
: undefined),
},
navidrome: {
...(server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: true }
: undefined),
},
},
artistIds: [albumArtistId],
compilation: true,
limit: 15,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
@@ -140,7 +137,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
const topSongsQuery = useTopSongsList({
options: {
enabled: !!detailQuery?.data?.name,
enabled: !!detailQuery?.data?.name && enabledItem.topSongs,
},
query: {
artist: detailQuery?.data?.name || '',
@@ -207,9 +204,10 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
return [
{
data: recentAlbumsQuery?.data?.items,
isHidden: !recentAlbumsQuery?.data?.items?.length,
isHidden: !recentAlbumsQuery?.data?.items?.length || !enabledItem.recentAlbums,
itemType: LibraryItem.ALBUM,
loading: recentAlbumsQuery?.isLoading || recentAlbumsQuery.isFetching,
order: itemOrder.recentAlbums,
title: (
<Group align="flex-end">
<TextTitle
@@ -235,9 +233,13 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
},
{
data: compilationAlbumsQuery?.data?.items,
isHidden: !compilationAlbumsQuery?.data?.items?.length,
isHidden:
!compilationAlbumsQuery?.data?.items?.length ||
!enabledItem.compilations ||
server?.type === ServerType.SUBSONIC,
itemType: LibraryItem.ALBUM,
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
order: itemOrder.compilations,
title: (
<TextTitle
order={2}
@@ -250,8 +252,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
},
{
data: detailQuery?.data?.similarArtists || [],
isHidden: !detailQuery?.data?.similarArtists,
isHidden: !detailQuery?.data?.similarArtists || !enabledItem.similarArtists,
itemType: LibraryItem.ALBUM_ARTIST,
order: itemOrder.similarArtists,
title: (
<TextTitle
order={2}
@@ -271,9 +274,16 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
compilationAlbumsQuery.isFetching,
compilationAlbumsQuery?.isLoading,
detailQuery?.data?.similarArtists,
enabledItem.compilations,
enabledItem.recentAlbums,
enabledItem.similarArtists,
itemOrder.compilations,
itemOrder.recentAlbums,
itemOrder.similarArtists,
recentAlbumsQuery?.data?.items,
recentAlbumsQuery.isFetching,
recentAlbumsQuery?.isLoading,
server?.type,
t,
]);
@@ -336,17 +346,17 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
const biography = useMemo(() => {
const bio = detailQuery?.data?.biography;
if (!bio) return null;
if (!bio || !enabledItem.biography) return null;
return sanitize(bio);
}, [detailQuery?.data?.biography]);
}, [detailQuery?.data?.biography, enabledItem.biography]);
const showTopSongs = topSongsQuery?.data?.items?.length;
const showTopSongs = topSongsQuery?.data?.items?.length && enabledItem.topSongs;
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const mbzId = detailQuery?.data?.mbz;
const isLoading =
detailQuery?.isLoading ||
(server?.type === ServerType.NAVIDROME && topSongsQuery?.isLoading);
(server?.type === ServerType.NAVIDROME && enabledItem.topSongs && topSongsQuery?.isLoading);
if (isLoading) return <ContentContainer ref={cq.ref} />;
@@ -467,103 +477,128 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
</Group>
</Box>
) : null}
{biography ? (
<Box
component="section"
maw="1280px"
>
<TextTitle
order={2}
weight={700}
<Grid>
{biography ? (
<Grid.Col
order={itemOrder.biography}
span={12}
>
{t('page.albumArtistDetail.about', {
artist: detailQuery?.data?.name,
})}
</TextTitle>
<Spoiler dangerouslySetInnerHTML={{ __html: biography }} />
</Box>
) : null}
{showTopSongs ? (
<Box component="section">
<Group
noWrap
position="apart"
>
<Group
noWrap
align="flex-end"
<Box
component="section"
maw="1280px"
>
<TextTitle
order={2}
weight={700}
>
{t('page.albumArtistDetail.topSongs', {
postProcess: 'sentenceCase',
{t('page.albumArtistDetail.about', {
artist: detailQuery?.data?.name,
})}
</TextTitle>
<Button
compact
uppercase
component={Link}
to={generatePath(
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS,
{
albumArtistId,
},
)}
variant="subtle"
<Spoiler dangerouslySetInnerHTML={{ __html: biography }} />
</Box>
</Grid.Col>
) : null}
{showTopSongs ? (
<Grid.Col
order={itemOrder.topSongs}
span={12}
>
<Box component="section">
<Group
noWrap
position="apart"
>
{t('page.albumArtistDetail.viewAll', {
postProcess: 'sentenceCase',
})}
</Button>
</Group>
</Group>
<VirtualTable
autoFitColumns
autoHeight
deselectOnClickOutside
stickyHeader
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={topSongsColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
rowData={topSongs}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
) : null}
<Box component="section">
<Stack spacing="xl">
{carousels
.filter((c) => !c.isHidden)
.map((carousel) => (
<MemoizedSwiperGridCarousel
key={`carousel-${carousel.uniqueId}`}
cardRows={cardRows[carousel.itemType as keyof typeof cardRows]}
data={carousel.data}
isLoading={carousel.loading}
itemType={carousel.itemType}
route={cardRoutes[carousel.itemType as keyof typeof cardRoutes]}
swiperProps={{
grid: {
rows: 2,
},
}}
title={{
label: carousel.title,
}}
uniqueId={carousel.uniqueId}
<Group
noWrap
align="flex-end"
>
<TextTitle
order={2}
weight={700}
>
{t('page.albumArtistDetail.topSongs', {
postProcess: 'sentenceCase',
})}
</TextTitle>
<Button
compact
uppercase
component={Link}
to={generatePath(
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS,
{
albumArtistId,
},
)}
variant="subtle"
>
{t('page.albumArtistDetail.viewAll', {
postProcess: 'sentenceCase',
})}
</Button>
</Group>
</Group>
<VirtualTable
autoFitColumns
autoHeight
deselectOnClickOutside
shouldUpdateSong
stickyHeader
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={topSongsColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
rowData={topSongs}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
))}
</Stack>
</Box>
</Box>
</Grid.Col>
) : null}
{carousels
.filter((c) => !c.isHidden)
.map((carousel) => (
<Grid.Col
key={`carousel-${carousel.uniqueId}`}
order={carousel.order}
span={12}
>
<Box component="section">
<Stack spacing="xl">
<MemoizedSwiperGridCarousel
cardRows={
cardRows[carousel.itemType as keyof typeof cardRows]
}
data={carousel.data}
isLoading={carousel.loading}
itemType={carousel.itemType}
route={
cardRoutes[
carousel.itemType as keyof typeof cardRoutes
]
}
swiperProps={{
grid: {
rows: 2,
},
}}
title={{
label: carousel.title,
}}
uniqueId={carousel.uniqueId}
/>
</Stack>
</Box>
</Grid.Col>
))}
</Grid>
</DetailContainer>
</ContentContainer>
);
@@ -1,5 +1,6 @@
import { forwardRef, Fragment, Ref } from 'react';
import { Group, Rating, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components';
@@ -17,6 +18,7 @@ export const AlbumArtistDetailHeader = forwardRef(
({ background }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
const { albumArtistId } = useParams() as { albumArtistId: string };
const server = useCurrentServer();
const { t } = useTranslation();
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
serverId: server?.id,
@@ -24,16 +26,19 @@ export const AlbumArtistDetailHeader = forwardRef(
const metadataItems = [
{
enabled: detailQuery?.data?.albumCount,
id: 'albumCount',
secondary: false,
value: detailQuery?.data?.albumCount && `${detailQuery?.data?.albumCount} albums`,
value: t('entity.albumWithCount', { count: detailQuery?.data?.albumCount || 0 }),
},
{
enabled: detailQuery?.data?.songCount,
id: 'songCount',
secondary: false,
value: detailQuery?.data?.songCount && `${detailQuery?.data?.songCount} songs`,
value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }),
},
{
enabled: detailQuery.data?.duration,
id: 'duration',
secondary: true,
value:
@@ -68,7 +73,7 @@ export const AlbumArtistDetailHeader = forwardRef(
<Stack>
<Group>
{metadataItems
.filter((i) => i.value)
.filter((i) => i.enabled)
.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
@@ -64,6 +64,7 @@ export const AlbumArtistDetailTopSongsListContent = ({
<VirtualTable
key={`table-${tableProps.rowHeight}-${server?.id}`}
ref={tableRef}
shouldUpdateSong
{...tableProps}
getRowId={(data) => data.data.uniqueId}
rowClassRules={rowClassRules}
@@ -11,7 +11,6 @@ import {
AlbumArtistListQuery,
AlbumArtistListResponse,
AlbumArtistListSort,
ArtistListQuery,
LibraryItem,
} from '/@/renderer/api/types';
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
@@ -34,7 +33,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
const handlePlayQueueAdd = usePlayQueueAdd();
const { pageKey } = useListContext();
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
const { grid, display, filter } = useListStoreByKey<AlbumArtistListQuery>({ key: pageKey });
const { setGrid } = useListStoreActions();
const handleFavorite = useHandleFavorite({ gridRef, server });
@@ -73,7 +72,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
const fetch = useCallback(
async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => {
const query: ArtistListQuery = {
const query: AlbumArtistListQuery = {
...filter,
limit,
startIndex,
@@ -91,7 +90,6 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
},
query: {
limit,
startIndex,
...filter,
},
}),
@@ -9,7 +9,13 @@ import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react
import { useListContext } from '../../../context/list-context';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumArtistListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
import {
AlbumArtistListQuery,
AlbumArtistListSort,
LibraryItem,
ServerType,
SortOrder,
} from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
@@ -85,6 +91,28 @@ const FILTERS = {
value: AlbumArtistListSort.SONG_COUNT,
},
],
subsonic: [
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }),
value: AlbumArtistListSort.ALBUM_COUNT,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
value: AlbumArtistListSort.FAVORITED,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: AlbumArtistListSort.NAME,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
value: AlbumArtistListSort.RATING,
},
],
};
interface AlbumArtistListHeaderFiltersProps {
@@ -100,7 +128,9 @@ export const AlbumArtistListHeaderFilters = ({
const queryClient = useQueryClient();
const server = useCurrentServer();
const { pageKey } = useListContext();
const { display, table, grid, filter } = useListStoreByKey({ key: pageKey });
const { display, table, grid, filter } = useListStoreByKey<AlbumArtistListQuery>({
key: pageKey,
});
const { setFilter, setTable, setTablePagination, setDisplayType, setGrid } =
useListStoreActions();
const cq = useContainerQuery();
@@ -416,7 +446,9 @@ export const AlbumArtistListHeaderFilters = ({
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
{t('common.refresh', {
postProcess: 'titleCase',
})}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
@@ -436,7 +468,9 @@ export const AlbumArtistListHeaderFilters = ({
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
@@ -4,7 +4,7 @@ import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { FilterBar } from '../../shared/components/filter-bar';
import { LibraryItem } from '/@/renderer/api/types';
import { AlbumArtistListQuery, LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
@@ -28,8 +28,9 @@ export const AlbumArtistListHeader = ({
const server = useCurrentServer();
const cq = useContainerQuery();
const { filter, refresh, search } = useDisplayRefresh({
const { filter, refresh, search } = useDisplayRefresh<AlbumArtistListQuery>({
gridRef,
itemCount,
itemType: LibraryItem.ALBUM_ARTIST,
server,
tableRef,
@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumArtistListQuery } from '/@/renderer/api/types';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useAlbumArtistListCount = (args: QueryHookArgs<AlbumArtistListQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
enabled: !!serverId,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumArtistListCount({
apiClientProps: {
server,
signal,
},
query,
});
},
queryKey: queryKeys.albumArtists.count(
serverId || '',
Object.keys(query).length === 0 ? undefined : query,
),
...options,
});
};
@@ -13,7 +13,7 @@ export const useTopSongsList = (args: QueryHookArgs<TopSongListQuery>) => {
enabled: !!server?.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getTopSongList({ apiClientProps: { server, signal }, query });
return api.controller.getTopSongs({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albumArtists.topSongs(server?.id || '', query),
...options,
@@ -2,13 +2,13 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { useMemo, useRef } from 'react';
import { useCurrentServer } from '../../../store/auth.store';
import { useListFilterByKey } from '../../../store/list.store';
import { LibraryItem } from '/@/renderer/api/types';
import { AlbumArtistListQuery, LibraryItem } from '/@/renderer/api/types';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ListContext } from '/@/renderer/context/list-context';
import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content';
import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { AnimatedPage } from '/@/renderer/features/shared';
import { useAlbumArtistListCount } from '/@/renderer/features/artists/queries/album-artist-list-count-query';
const AlbumArtistListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
@@ -16,25 +16,18 @@ const AlbumArtistListRoute = () => {
const pageKey = LibraryItem.ALBUM_ARTIST;
const server = useCurrentServer();
const albumArtistListFilter = useListFilterByKey({ key: pageKey });
const albumArtistListFilter = useListFilterByKey<AlbumArtistListQuery>({ key: pageKey });
const itemCountCheck = useAlbumArtistList({
const itemCountCheck = useAlbumArtistListCount({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: {
limit: 1,
startIndex: 0,
...albumArtistListFilter,
},
query: albumArtistListFilter,
serverId: server?.id,
});
const itemCount =
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
const providerValue = useMemo(() => {
return {

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