Compare commits

...

75 Commits

Author SHA1 Message Date
jeffvli 6858485e41 Add new languages 2024-05-06 19:56:16 -07:00
Hosted Weblate ebd97c253b Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (586 of 586 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: HanaO00 <lwin24452@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <KosmoMoustache@users.noreply.hosted.weblate.org>
Co-authored-by: Leonardo Pizio <pizio.leonardo@gmail.com>
Co-authored-by: Mistify <fabianszafranski@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/it/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-05-07 04:22:27 +02:00
Hosted Weblate 2c834cd3a8 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: HanaO00 <lwin24452@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <KosmoMoustache@users.noreply.hosted.weblate.org>
Co-authored-by: Leonardo Pizio <pizio.leonardo@gmail.com>
Co-authored-by: Mistify <fabianszafranski@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/it/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-05-07 04:22:27 +02:00
Hosted Weblate dff6d27c23 Translated using Weblate (Spanish)
Currently translated at 100.0% (586 of 586 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: HanaO00 <lwin24452@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <KosmoMoustache@users.noreply.hosted.weblate.org>
Co-authored-by: Leonardo Pizio <pizio.leonardo@gmail.com>
Co-authored-by: Mistify <fabianszafranski@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/it/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-05-07 04:22:27 +02:00
Hosted Weblate 3aed97c139 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: HanaO00 <lwin24452@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <KosmoMoustache@users.noreply.hosted.weblate.org>
Co-authored-by: Leonardo Pizio <pizio.leonardo@gmail.com>
Co-authored-by: Mistify <fabianszafranski@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/it/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-05-07 04:22:27 +02:00
Hosted Weblate 8fe93b4b2e Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: HanaO00 <lwin24452@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <KosmoMoustache@users.noreply.hosted.weblate.org>
Co-authored-by: Leonardo Pizio <pizio.leonardo@gmail.com>
Co-authored-by: Mistify <fabianszafranski@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/it/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-05-07 04:22:27 +02:00
Hosted Weblate c634a07c5d Translated using Weblate (Czech)
Currently translated at 100.0% (586 of 586 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 89.7% (525 of 585 strings)

Translated using Weblate (French)

Currently translated at 100.0% (570 of 570 strings)

Translated using Weblate (French)

Currently translated at 100.0% (559 of 559 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (585 of 585 strings)

Translated using Weblate (Italian)

Currently translated at 98.6% (576 of 584 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (565 of 566 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (585 of 585 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: HanaO00 <lwin24452@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <KosmoMoustache@users.noreply.hosted.weblate.org>
Co-authored-by: Leonardo Pizio <pizio.leonardo@gmail.com>
Co-authored-by: Mistify <fabianszafranski@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/it/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-05-07 04:22:27 +02:00
Hosted Weblate 0235a569a0 Translated using Weblate (Portuguese (Brazil))
Currently translated at 32.0% (188 of 586 strings)

Co-authored-by: Cyber Hippie <neves.j@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pt_BR/
Translation: feishin/Translation
2024-05-07 04:22:27 +02:00
jeffvli cbe1c878e7 Bump to v0.7.0 2024-05-06 19:21:48 -07:00
Kendall Garner 4afb893ce5 remove stray log 2024-05-05 19:56:35 -07:00
Kendall Garner 645697367d [enhancement]: support serach on settings page 2024-05-05 13:25:05 -07:00
jeffvli 683bb0222c Filter out current playlist on playlist operators 2024-05-02 23:04:57 -07:00
jeffvli ce0c07ebdb Add JSON preview for smart playlist query 2024-05-02 23:04:57 -07:00
jeffvli 785f0ef77f Add inPlaylist and notInPlaylist operators 2024-05-02 23:04:57 -07:00
jeffvli 7bfdbb5d92 Reduce min grid size on remaining list pages 2024-05-02 23:04:31 -07:00
Dylan Lathrum abdb2fee85 Allow smaller album covers in card/poster display 2024-05-02 23:04:31 -07:00
Kendall Garner d1bcd2b2fb [bugfix]: fix jellyfin add to playlist 2024-05-02 18:42:49 -07:00
Vukanović Stefan 297d6f0d2e LrcLib.net expects durations in seconds, not ms (#603) 2024-05-02 14:14:10 +00:00
dependabot[bot] 78ac5af178 Bump ejs in the npm_and_yarn group across 1 directory (#602)
Bumps the npm_and_yarn group with 1 update in the / directory: [ejs](https://github.com/mde/ejs).


Updates `ejs` from 3.1.9 to 3.1.10
- [Release notes](https://github.com/mde/ejs/releases)
- [Commits](https://github.com/mde/ejs/compare/v3.1.9...v3.1.10)

---
updated-dependencies:
- dependency-name: ejs
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-02 14:03:29 +00:00
Kendall Garner 9cd8807a75 [bugfix]: Handle top-level songs for Jellyfin (#553)
* [bugfix]: Handle top-level songs for Jellyfin

If a song is at the top level of a music folder, Jellyfin will not
group that into an album (See https://jellyfin.org/docs/general/server/media/music/).

This PR introduces a few changes:
- Gives tracks with no album ID a special route (`/dummy/${id}`)
- Gives a new route for dummy albums, warning about the error. This is designed to look _like_ the album detail page

* `are are` > `are`

* revert name changes
2024-04-30 03:18:43 +00:00
Kendall Garner 620cca9ce3 Revert "Upgrade dependencies"
This reverts commit 89688455e0.
2024-04-28 21:03:31 -07:00
Kendall Garner 89688455e0 Upgrade dependencies
- mpris-service: migrate to @jellybrick/mpris-service, which has upgraded dependencies and uses class
- i18next-parser: 6 -> 8. This requires a small change to i18next-parser.config.js
2024-04-28 20:50:52 -07:00
Kendall Garner 5259f2401b upgrade framer-motion to 11 2024-04-28 18:59:05 -07:00
Kendall Garner c36f0a055d [enhancement]: parse replaygain from subsonic endpoints where available 2024-04-27 22:20:42 -07:00
Kendall Garner ef87a8c2a7 remove security from config 2024-04-24 07:38:59 -07:00
Kendall Garner 5da68d4243 Use issue templates 2024-04-24 07:37:38 -07:00
Kendall Garner dc95a3c66b [bugfix]: use persistent columns def instead of default merge behavior 2024-04-23 23:25:32 -07:00
Kendall Garner 087ea44737 [enhancement]: use jellyfin 10.9.0 lyrics 2024-04-22 19:44:10 -07:00
Benjamin cb2597d2c8 Implement Navidrome sharing (#575)
* add share item feature

* take care of (mostly) everything

* bugfixes

* allow clicking on notification to open url

* readd the missing modal after router migration

* remove unnecessary extension

---------

Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
2024-04-22 03:03:22 +00:00
Kendall Garner 0d03b66fe5 prevent change of media state with empty queue 2024-04-20 22:01:29 -07:00
Kendall Garner ba531505af [enhancement]: Support toggling Album/Track view for gneres (#591)
* [enhancement]: Support toggling Album/Track view for gneres

The _primary_ purpose of this PR is to enable viewing tracks OR albums for genres.
This has a few requirements:
1. Ability to set default route for genres, **except** when already on song/album page
2. Ability to toggle between album and genre view
3. Fixed refresh for genre ID

Additionally, there was some refactoring:
- Since the *-list-headers had very similar functions for search, export that as a hook instead

* also use hook for album artist

* support switching albumartist tracks/albums

* remove toggle on song/album list, simplify logic
2024-04-20 06:14:31 +00:00
Kendall Garner 595eba152a [jellyfin]: prefer sort name over name 2024-04-19 23:11:26 -07:00
Kendall Garner ebd2f07447 show macOS warning one, don't show artist link if invalid 2024-04-17 22:44:35 -07:00
Kendall Garner 5d6503c1f4 [bugfix]: do not show now playing for duplicate item in play queue 2024-04-17 21:51:39 -07:00
Kendall Garner d03a3a11eb [enhancement]: Support react-router links in Modal (#586) 2024-04-17 14:29:46 +00:00
Kaydax 04b4d92f69 Fix portrait mode detection (#582)
* Fix portrait mode detection

* Revert changes done on playbar
2024-04-17 06:21:49 +00:00
Kendall Garner ec69cc22f9 use clearer separator character 2024-04-14 21:58:25 -07:00
Kendall Garner 19a88fea86 [bodge]: deal with Jellyfin returning dupliate tracks for album query 2024-04-13 16:28:36 -07:00
Kendall Garner 729538d885 [bugfix]: restart synchronized lyrics on repeat one (or track queued multiple times) 2024-04-12 20:52:10 -07:00
Kendall Garner 9f86a8179f fix clipping description, update docker compose sample 2024-04-12 19:44:52 -07:00
Kendall Garner 3976f5e5bf don't assume ref exists 2024-04-12 09:33:48 -07:00
Kendall Garner 90d3fb219d [bugfix]: restart track in queue for web player 2024-04-12 09:29:36 -07:00
Kendall Garner cabd69772e [bugfix]: mantine bodge 2024-04-11 08:25:53 -07:00
Kendall Garner 9339c08777 [bugfix]: handle unclean MPV exit with existing content 2024-04-10 21:18:47 -07:00
Kendall Garner f5e047c7f5 update readme 2024-04-10 20:03:59 -07:00
Kendall Garner f79f9cc79e [bugfix]: deal with broken jellyfin 2024-04-09 22:49:44 -07:00
Kendall Garner c3fcb7487c [bugfix]: fix album artist order and mild race protection 2024-04-09 22:11:29 -07:00
Kendall Garner 15c6ef382a [bugfix]: fix combined title for artist, favoriting on grid pages 2024-04-08 23:15:59 -07:00
Kendall Garner 14086ebc9c improve similar items fallback, make ND album artist for song actually album artist, fix full screen race 2024-04-08 08:49:55 -07:00
Kendall Garner 2257e439a4 [navidrome]: prefer gerArtistInfo higher quality image 2024-04-06 21:36:30 -07:00
Kendall Garner 6824a5db7a [enhancement]: also save fullscreen/maximize 2024-04-06 21:14:05 -07:00
Kendall Garner c0110eff82 [enhancement]: save/restore screen position 2024-04-06 19:05:20 -07:00
Kendall Garner 2c17458fdf [enhancement]: allow copying/opening path in song modal 2024-04-06 16:13:09 -07:00
Kendall Garner c1345802aa bump size cell size 2024-04-03 21:28:27 -07:00
Kendall Garner 197497df05 [enhancement]: Show item details (#573)
* start

* More details, don't show manage server when other modal
2024-04-04 04:19:46 +00:00
Kendall Garner 7bebe286d5 sanitize album artist biography 2024-04-03 07:36:13 -07:00
Kendall Garner 24394fa858 Merge pull request #571 from iiPythonx/dynamicbg
[bugfix]: Add a fallback image to the dynamic background url
2024-04-03 01:54:15 +00:00
iiPython f7c6088cca add a fallback image to the dynamic background url 2024-04-02 12:58:26 -05:00
Kendall Garner 65eca32de3 [bugfix]: do not update mpris status unnecessarily 2024-04-02 08:46:38 -07:00
Kendall Garner ae167e63fd [bugfix]: shared only if owner exists 2024-04-01 22:31:59 -07:00
Kendall Garner ab17ba8add [bugfix]: fix scrobble race conditions 2024-04-01 22:13:06 -07:00
Kendall Garner 2854a91700 [bugfix]: actually implement size column 2024-04-01 20:53:00 -07:00
Kendall Garner 6bc778fa53 [bugfix]: fix smart playlist, do not error when trying to edit playlist as non-admin 2024-03-31 19:34:33 -07:00
Kendall Garner 44fcc33825 [enhancement]: add server menu on Navidrome error page 2024-03-31 17:47:17 -07:00
Kendall Garner e0e967385f Merge pull request #566 from kgarner7/fix-mpv-race-and-make-consistent
[bugfix]: Resolve MPV next/prev race condition
2024-03-31 23:12:03 +00:00
Kendall Garner 8900d8126c [bugfix]: queue all songs on search, consistent sort, clearing
- Previously, the search page would render initial page in one order, but search itself would be different order
This is resolved by having both virtual-table and search-header using listStoreKey
- When double clicking, now enqueue all the songs using the same sort
- Reset the search when clearing
2024-03-31 13:20:01 -07:00
Kendall Garner 65b045df03 [bugfix]: Resolve MPV next/prev race condition
Resolves #536.

With the previous implementation, next/previous would first update
the current queue and then call next/previous. However, since these were
asynchronous calls it was very likely that the second calls would fail
(and a test of adding delay showed that it actually caused a double skip).
This PR resolves this by just removing the prev/next.

Small other fixes:
- setQueue + pause -> setQueue(..., true)
- make MPV and web player have the same behavior for (pause/stop) where appropriate
2024-03-30 21:48:09 -07:00
Kendall Garner 918842e3a5 [bugfix]: use proper check for OS lyric existence 2024-03-30 20:36:49 -07:00
Kendall Garner a3573d4f9a add light theme for non-native titlebar 2024-03-30 14:11:57 -07:00
Kendall Garner 46fdacad81 Make home page modal play button use default behavior 2024-03-27 21:15:23 -07:00
Kendall Garner 67b8c7f1c0 Merge pull request #561 from jeffvli/dependabot/npm_and_yarn/npm_and_yarn-security-group-e0cd778f82
Bump the npm_and_yarn group across 1 directory with 1 update
2024-03-27 06:14:49 +00:00
dependabot[bot] 43f28317f6 Bump the npm_and_yarn group across 1 directory with 1 update
Bumps the npm_and_yarn group with 1 update in the / directory: [express](https://github.com/expressjs/express).


Updates `express` from 4.18.0 to 4.19.2
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.18.0...4.19.2)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
  dependency-group: npm_and_yarn-security-group
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-27 02:11:54 +00:00
dependabot[bot] f61cf8c331 Bump the npm_and_yarn group across 1 directory with 1 update (#557)
Bumps the npm_and_yarn group with 1 update in the / directory: [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware).


Updates `webpack-dev-middleware` from 5.3.1 to 5.3.4
- [Release notes](https://github.com/webpack/webpack-dev-middleware/releases)
- [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.1...v5.3.4)

---
updated-dependencies:
- dependency-name: webpack-dev-middleware
  dependency-type: indirect
  dependency-group: npm_and_yarn-security-group
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-27 02:10:24 +00:00
dependabot[bot] 340344b791 Bump the npm_and_yarn group across 1 directory with 1 update (#551)
Bumps the npm_and_yarn group with 1 update in the / directory: [follow-redirects](https://github.com/follow-redirects/follow-redirects).


Updates `follow-redirects` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
  dependency-group: npm_and_yarn-security-group
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-19 06:08:14 +00:00
dependabot[bot] ba1a2d5495 Bump the npm_and_yarn group across 1 directory with 2 updates (#542)
Bumps the npm_and_yarn group with 2 updates in the / directory: [app-builder-lib](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/app-builder-lib) and [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder).


Updates `app-builder-lib` from 24.9.0 to 24.13.3
- [Release notes](https://github.com/electron-userland/electron-builder/releases)
- [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/app-builder-lib/CHANGELOG.md)
- [Commits](https://github.com/electron-userland/electron-builder/commits/v24.13.3/packages/app-builder-lib)

Updates `electron-builder` from 24.9.0 to 24.13.3
- [Release notes](https://github.com/electron-userland/electron-builder/releases)
- [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md)
- [Commits](https://github.com/electron-userland/electron-builder/commits/v24.13.3/packages/electron-builder)

---
updated-dependencies:
- dependency-name: app-builder-lib
  dependency-type: indirect
  dependency-group: npm_and_yarn-security-group
- dependency-name: electron-builder
  dependency-type: direct:development
  dependency-group: npm_and_yarn-security-group
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-19 06:00:54 +00:00
153 changed files with 4990 additions and 1596 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ root = true
[*]
indent_style = space
indent_size = 2
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
-45
View File
@@ -1,45 +0,0 @@
---
name: Bug report
about: You're having technical issues. 🐞
labels: 'bug'
---
## Expected Behavior
<!--- What should have happened? -->
## Current Behavior
<!--- What went wrong? -->
<!-- Add screenshots to help explain your problem -->
<!-- (Open the browser dev tools in the menu or using CTRL + SHIFT + I) -->
## Steps to Reproduce
<!-- Add relevant code and/or a live example -->
<!-- Add stack traces -->
1.
2.
3.
4.
## Possible Solution (Not obligatory)
<!--- Suggest a reason for the bug or how to fix it. -->
## Context
<!--- How has this issue affected you? What are you trying to accomplish? -->
## Your Environment
<!--- Include as many relevant details about the environment you experienced the bug in -->
- Application version (e.g. v0.1.0) :
- Operating System and version (e.g. Windows 10) :
- Server and version (e.g. Navidrome v0.48.0) :
- Node version (if developing locally) :
-9
View File
@@ -1,9 +0,0 @@
---
name: Question
about: Ask a question.❓
labels: 'question'
---
<!-- Question issues will be closed. -->
<!-- Ask questions in the discussions tab: Please use discussions https://github.com/jeffvli/feishin/discussions -->
<!-- Or join the Discord/Matrix servers: https://discord.gg/FVKpcMDy5f https://matrix.to/#/#sonixd:matrix.org -->
@@ -1,11 +0,0 @@
---
name: Feature request
about: Request a feature to be added to Feishin 🎉
labels: 'enhancement'
---
## What do you want to be added?
## Additional context
<!-- Is this a server-specific feature? (e.g. Jellyfin only). -->
+63
View File
@@ -0,0 +1,63 @@
name: Bug report
description: You're having technical issues. 🐞
labels: ['bug']
body:
- type: textarea
attributes:
label: Expected Behavior
description: What should have happened?
validations:
required: true
- type: textarea
attributes:
label: Current Behavior
description: What went wrong? Add screenshots to help explain your problem. (Open the browser dev tools in the menu or using CTRL + SHIFT + I)
validations:
required: true
- type: textarea
attributes:
label: Steps to Reproduce
placeholder: |
<!-- Add relevant code and/or a live example -->
<!-- Add stack traces -->
1.
2.
3.
4.
validations:
required: true
- type: textarea
attributes:
label: Possible Solution
description: Suggest a reason for the bug or how to fix it.
validations:
required: false
- type: textarea
attributes:
label: Context
description: How has this issue affected you? What are you trying to accomplish?
validations:
required: false
- type: input
attributes:
label: Application version
placeholder: (e.g. v0.1.0)
validations:
required: true
- type: input
attributes:
label: Operating System and version
placeholder: (e.g. Windows 11 desktop, Webapp in Firefox)
validations:
required: true
- type: input
attributes:
label: Server and Version
placeholder: (e.g. Navidrome v0.48.0)
validations:
required: true
- type: input
attributes:
label: Node Version (if developing locally)
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Question
url: https://github.com/jeffvli/feishin/discussions
about: Please ask and answer questions here.
@@ -0,0 +1,22 @@
name: Feature request
description: Request a feature to be added to Feishin 🎉
labels: ['enhancement']
body:
- type: textarea
attributes:
label: What do you want to be added?
validations:
required: true
- type: textarea
attributes:
label: Additional context
validations:
required: false
- type: checkboxes
attributes:
label: Is this a server-specific feature? (e.g. Jellyfin only)
options:
- label: 'Yes'
required: false
validations:
required: false
+10 -1
View File
@@ -93,11 +93,20 @@ First thing to do is check that your MPV binary path is correct. Navigate to the
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. **Subsonic API is not currently supported**. This will likely be added in [later when the new Subsonic API is decided on](https://support.symfonium.app/t/subsonic-servers-participation/1233).
- [Navidrome](https://github.com/navidrome/navidrome) version 0.48.0 and newer
- [Navidrome](https://github.com/navidrome/navidrome)
- [Jellyfin](https://github.com/jellyfin/jellyfin)
- [Funkwhale](https://funkwhale.audio/) - TBD
- Subsonic-compatible servers - TBD
### I have the issue "The SUID sandbox helper binary was found, but is not configured correctly" on Linux
This happens when you have user (unprivileged) namespaces disabled (`sysctl kernel.unprivileged_userns_clone` returns 0). You can fix this by either enabling unprivileged namespaces, or by making the `chrome-sandbox` Setuid.
```bash
chmod 4755 chrome-sandbox
sudo chown root:root chrome-sandbox
```
## Development
Built and tested using Node `v16.15.0`.
+1 -1
View File
@@ -2,7 +2,7 @@ version: '3.5'
services:
feishin:
container_name: feishin
image: jeffvli/feishin
image: ghcr.io/jeffvli/feishin:latest
restart: unless-stopped
ports:
- 9180:9180
+1680 -302
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -2,7 +2,7 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.6.1",
"version": "0.7.0",
"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",
@@ -216,6 +216,7 @@
"@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 +232,7 @@
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^26.6.10",
"electron-builder": "^24.9.0",
"electron-builder": "^24.13.3",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
"electronmon": "^2.0.2",
@@ -318,7 +319,7 @@
"electron-updater": "^4.6.5",
"fast-average-color": "^9.3.0",
"format-duration": "^2.0.0",
"framer-motion": "^10.13.0",
"framer-motion": "^11.0.0",
"fuse.js": "^6.6.2",
"history": "^5.3.0",
"i18next": "^21.10.0",
@@ -345,6 +346,7 @@
"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",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.6.1",
"version": "0.7.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.6.1",
"version": "0.7.0",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.6.1",
"version": "0.7.0",
"description": "",
"main": "./dist/main/main.js",
"author": {
+17 -3
View File
@@ -16,6 +16,8 @@ import sv from './locales/sv.json';
import cs from './locales/cs.json';
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';
const resources = {
en: { translation: en },
@@ -24,10 +26,12 @@ const resources = {
it: { translation: it },
ru: { translation: ru },
'pt-BR': { translation: ptBr },
fa: { translation: fa },
fr: { translation: fr },
ja: { translation: ja },
pl: { translation: pl },
'zh-Hans': { translation: zhHans },
'zh-Hant': { translation: zhHant },
sr: { translation: sr },
sv: { translation: sv },
cs: { translation: cs },
@@ -72,7 +76,10 @@ export const languages = [
label: 'Norsk (Bokmål)',
value: 'nb-NO',
},
{
label: 'فارسی',
value: 'fa',
},
{
label: 'Português (Brasil)',
value: 'pt-BR',
@@ -97,6 +104,10 @@ export const languages = [
label: '简体中文',
value: 'zh-Hans',
},
{
label: '繁體中文',
value: 'zh-Hant',
},
];
const lowerCasePostProcessor: PostProcessorModule = {
@@ -125,7 +136,7 @@ const titleCasePostProcessor: PostProcessorModule = {
},
};
const ignoreSentenceCaseLanguages = ['de']
const ignoreSentenceCaseLanguages = ['de'];
const sentenceCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
@@ -136,7 +147,10 @@ const sentenceCasePostProcessor: PostProcessorModule = {
return sentences
.map((sentence) => {
return (
sentence.charAt(0).toLocaleUpperCase() + (!ignoreSentenceCaseLanguages.includes(translator.language) ? sentence.slice(1).toLocaleLowerCase() : sentence.slice(1))
sentence.charAt(0).toLocaleUpperCase() +
(!ignoreSentenceCaseLanguages.includes(translator.language)
? sentence.slice(1).toLocaleLowerCase()
: sentence.slice(1))
);
})
.join('. ');
+84 -13
View File
@@ -196,7 +196,21 @@
"clearCache": "vymazat mezipaměť prohlížeče",
"clearCache_description": "„tvrdé pročištění“ aplikace feishin. kromě mezipaměti aplikace feishin vymaže i mezipaměť prohlížeče (uložené obrázky a další zdroje). přihlašovací údaje k serveru a nastavení nebudou ovlivněny",
"clearQueryCache": "vymazat mezipaměť aplikace feishin",
"clearQueryCache_description": "„lehké pročištění“ aplikace feishin. tímto obnovíte seznamy skladeb, metadata skladeb a resetujete uložené texty. nastavení, přihlašovací údaje k serveru a obrázky v mezipaměti nebudou ovlivněny"
"clearQueryCache_description": "„lehké pročištění“ aplikace feishin. tímto obnovíte seznamy skladeb, metadata skladeb a resetujete uložené texty. nastavení, přihlašovací údaje k serveru a obrázky v mezipaměti nebudou ovlivněny",
"startMinimized": "spustit minimalizované",
"homeConfiguration_description": "nastavte, které položky a v jakém pořadí mají být zobrazeny na domovské stránce",
"passwordStore": "ukládání hesel / tajných klíčů",
"mpvExtraParameters_help": "jeden na řádek",
"homeConfiguration": "nastavení domovské stránky",
"playerAlbumArtResolution_description": "rozlišení náhledu obalu alba ve velkém přehrávači. větší hodnota znamená kvalitnější obrázek, ale může se déle načítat. výchozí hodnota je 0, což znamená automatické rozlišení",
"playerAlbumArtResolution": "rozlišení obalu alba v přehrávači",
"genreBehavior": "výchozí chování stránky žánrů",
"externalLinks_description": "zapne zobrazování externích odkazů (Last.fm, MusicBrainz) na stránce umělce/alba",
"genreBehavior_description": "určuje, zda kliknutí na žánr otevře seznam skladeb nebo alb",
"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."
},
"action": {
"editPlaylist": "upravit $t(entity.playlist_one)",
@@ -215,7 +229,11 @@
"moveToBottom": "přesunout dolů",
"setRating": "nastavit hodnocení",
"toggleSmartPlaylistEditor": "přepnout editor $t(entity.smartPlaylist)",
"removeFromFavorites": "odebrat z $t(entity.favorite_other)"
"removeFromFavorites": "odebrat z $t(entity.favorite_other)",
"openIn": {
"lastfm": "Otevřít v Last.fm",
"musicbrainz": "Otevřít v MusicBrainz"
}
},
"common": {
"backward": "zpátky",
@@ -298,7 +316,17 @@
"random": "náhodně",
"size": "velikost",
"biography": "biografie",
"note": "poznámka"
"note": "poznámka",
"albumGain": "zisk (gain) alba",
"albumPeak": "vrchol alba",
"close": "zavřít",
"mbid": "ID MusicBrainz",
"trackGain": "zisk (gain) skladby",
"reload": "znovu načíst",
"share": "sdílet",
"codec": "kodek",
"trackPeak": "vrchol skladby",
"preview": "náhled"
},
"table": {
"config": {
@@ -312,7 +340,9 @@
"gap": "$t(common.gap)",
"tableColumns": "sloupce tabulky",
"autoFitColumns": "automaticky přizpůsobit sloupce",
"size": "$t(common.size)"
"size": "$t(common.size)",
"itemGap": "mezera mezi položkami (px)",
"itemSize": "velikost položek (px)"
},
"label": {
"releaseDate": "datum vydání",
@@ -340,7 +370,8 @@
"discNumber": "číslo disku",
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist_one)"
"albumArtist": "$t(entity.albumArtist_one)",
"codec": "$t(common.codec)"
}
},
"column": {
@@ -365,7 +396,9 @@
"albumArtist": "umělec alba",
"path": "cesta",
"discNumber": "disk",
"channels": "$t(common.channel_other)"
"channels": "$t(common.channel_other)",
"size": "$t(common.size)",
"codec": "$t(common.codec)"
}
},
"error": {
@@ -387,7 +420,10 @@
"mpvRequired": "vyžadován přehrávač MPV",
"audioDeviceFetchError": "při pokusu o přístup ke zvukovým zařízením se vyskytla chyba",
"invalidServer": "neplatný server",
"loginRateError": "příliš mnoho pokusů o přihlášení, zkuste to znovu za pár vteřin"
"loginRateError": "příliš mnoho pokusů o přihlášení, zkuste to znovu za pár vteřin",
"badAlbum": "tuto stránku vidíte, protože tato skladba není součástí alba. tento problém může nastat, pokud máte skladbu na nejvyšší úrovni vaší složky s hudbou. jellyfin seskupuje skladby pouze, pokud se nacházejí ve složce.",
"networkError": "vyskytla se chyba sítě",
"openError": "nepodařilo se otevřít soubor"
},
"filter": {
"mostPlayed": "nejvíce přehráváno",
@@ -445,7 +481,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": "$t(entity.playlist_other) sdíleny"
},
"fullscreenPlayer": {
"config": {
@@ -459,7 +496,9 @@
"unsynchronized": "nesynchronizováno",
"lyricAlignment": "zarovnání textů",
"useImageAspectRatio": "použít poměr stran obrázku",
"lyricGap": "mezera textů"
"lyricGap": "mezera textů",
"dynamicImageBlur": "velikost rozostření obrázku",
"dynamicIsImage": "povolit obrázek na pozadí"
},
"upNext": "další",
"lyrics": "texty",
@@ -493,7 +532,9 @@
"addFavorite": "$t(action.addToFavorites)",
"play": "$t(player.play)",
"numberSelected": "vybráno {{count}}",
"removeFromQueue": "$t(action.removeFromQueue)"
"removeFromQueue": "$t(action.removeFromQueue)",
"showDetails": "získat informace",
"shareItem": "sdílet položku"
},
"home": {
"mostPlayed": "nejpřehrávanější",
@@ -516,10 +557,14 @@
"title": "$t(entity.albumArtist_other)"
},
"genreList": {
"title": "$t(entity.genre_other)"
"title": "$t(entity.genre_other)",
"showTracks": "zobrazit $t(entity.track_other) s žánrem",
"showAlbums": "zobrazit $t(entity.album_other) s žánrem"
},
"trackList": {
"title": "$t(entity.track_other)"
"title": "$t(entity.track_other)",
"artistTracks": "Skladby od umělce {{artist}}",
"genreTracks": "$t(entity.track_other) s žánrem „{{genre}}“"
},
"globalSearch": {
"commands": {
@@ -533,7 +578,25 @@
"title": "$t(entity.playlist_other)"
},
"albumList": {
"title": "$t(entity.album_other)"
"title": "$t(entity.album_other)",
"artistAlbums": "Alba od umělce {{artist}}",
"genreAlbums": "$t(entity.album_other) s žánrem „{{genre}}“"
},
"albumArtistDetail": {
"recentReleases": "nedávno vydáno",
"viewDiscography": "zobrazit diskografii",
"about": "O umělci {{artist}}",
"appearsOn": "také v",
"topSongs": "nejlepší skladby",
"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"
},
"itemDetail": {
"copiedPath": "cesta úspěšně zkopírována",
"copyPath": "kopírovat cestu do schránky",
"openFile": "zobrazit skladbu ve správci souborů"
}
},
"form": {
@@ -584,6 +647,14 @@
},
"editPlaylist": {
"title": "upravit $t(entity.playlist_one)"
},
"shareItem": {
"allowDownloading": "umožnit stahování",
"success": "odkaz ke sdílení zkopírován do schránky (klikněte sem pro otevření)",
"description": "popis",
"expireInvalid": "čas vypršení musí být v budoucnosti",
"setExpiration": "nastavit vypršení",
"createFailed": "nepodařilo se vytvořit sdílení (je sdílení povoleno?)"
}
},
"entity": {
+2 -1
View File
@@ -310,7 +310,8 @@
"discNumber": "Disk",
"genre": "$t(entity.genre_one)",
"songCount": "$t(entity.track_other)",
"trackNumber": "Nr."
"trackNumber": "Nr.",
"size": "$t(common.size)"
}
},
"page": {
+32 -1
View File
@@ -26,6 +26,8 @@
"action_one": "action",
"action_other": "actions",
"add": "add",
"albumGain": "album gain",
"albumPeak": "album peak",
"areYouSure": "are you sure?",
"ascending": "ascending",
"backward": "backward",
@@ -72,6 +74,7 @@
"menu": "menu",
"minimize": "minimize",
"modified": "modified",
"mbid": "MusicBrainz ID",
"name": "name",
"no": "no",
"none": "none",
@@ -81,6 +84,7 @@
"owner": "owner",
"path": "path",
"playerMustBePaused": "player must be paused",
"preview": "preview",
"previousSong": "previous $t(entity.track_one)",
"quit": "quit",
"random": "random",
@@ -98,10 +102,13 @@
"setting": "setting",
"setting_one": "setting",
"setting_other": "settings",
"share": "share",
"size": "size",
"sortOrder": "order",
"title": "title",
"trackNumber": "track",
"trackGain": "track gain",
"trackPeak": "track peak",
"unknown": "unknown",
"version": "version",
"year": "year",
@@ -144,6 +151,7 @@
"apiRouteError": "unable to route request",
"audioDeviceFetchError": "an error occurred when trying to get audio devices",
"authenticationFailed": "authentication failed",
"badAlbum": "you are seeing this page because this song is not part of an album. you are most likely seeing this issue if you have a song at the top level of your music folder. jellyfin only groups tracks if they are in a folder.",
"credentialsRequired": "credentials required",
"endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}",
"genericError": "an error occurred",
@@ -152,6 +160,7 @@
"loginRateError": "too many login attempts, please try again in a few seconds",
"mpvRequired": "MPV required",
"networkError": "a network error occurred",
"openError": "could not open file",
"playbackError": "an error occurred when trying to play the media",
"remoteDisableError": "an error occurred when trying to $t(common.disable) the remote server",
"remoteEnableError": "an error occurred when trying to $t(common.enable) the remote server",
@@ -251,6 +260,14 @@
"input_optionMatchAll": "match all",
"input_optionMatchAny": "match any"
},
"shareItem": {
"allowDownloading": "allow downloading",
"description": "description",
"setExpiration": "set expiration",
"success": "share link copied to clipboard (or click here to open)",
"expireInvalid": "expiration must be in the future",
"createFailed": "failed to create share (is sharing enabled?)"
},
"updateServer": {
"success": "server updated successfully",
"title": "update server"
@@ -276,6 +293,8 @@
"moreFromGeneric": "more from {{item}}"
},
"albumList": {
"artistAlbums": "Albums by {{artist}}",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
"title": "$t(entity.album_other)"
},
"appMenu": {
@@ -306,7 +325,9 @@
"removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)"
"setRating": "$t(action.setRating)",
"shareItem": "share item",
"showDetails": "get info"
},
"fullscreenPlayer": {
"config": {
@@ -329,6 +350,8 @@
"upNext": "up next"
},
"genreList": {
"showAlbums": "show $t(entity.genre_one) $t(entity.album_other)",
"showTracks": "show $t(entity.genre_one) $t(entity.track_other)",
"title": "$t(entity.genre_other)"
},
"globalSearch": {
@@ -346,6 +369,11 @@
"recentlyPlayed": "recently played",
"title": "$t(common.home)"
},
"itemDetail": {
"copyPath": "copy path to clipboard",
"copiedPath": "path copied successfully",
"openFile": "show track in file manager"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
@@ -462,6 +490,8 @@
"gaplessAudio": "gapless audio",
"gaplessAudio_description": "sets the gapless audio setting for mpv",
"gaplessAudio_optionWeak": "weak (recommended)",
"genreBehavior": "genre page default behavior",
"genreBehavior_description": "determines whether clicking on a genre opens by default in track or album list",
"globalMediaHotkeys": "global media hotkeys",
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
"homeConfiguration": "home page configuration",
@@ -610,6 +640,7 @@
"rating": "rating",
"releaseDate": "release date",
"releaseYear": "year",
"size": "$t(common.size)",
"songCount": "$t(entity.track_other)",
"title": "title",
"trackNumber": "track"
+84 -13
View File
@@ -196,7 +196,21 @@
"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",
"buttonSize_description": "el tamaño de los botones de la barra de reproducción"
"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",
"startMinimized": "iniciar minimizado",
"passwordStore": "contraseñas/almacenamiento secreto",
"playerAlbumArtResolution_description": "la resolución para la vista previa de la carátula del álbum del reproductor grande. más grande hace que parezca más nítido, pero puede ralentizar la carga. El valor predeterminado es 0, lo que significa automático",
"playerAlbumArtResolution": "resolución de la carátula del álbum del reproductor",
"homeConfiguration": "Configuración de la página de inicio",
"mpvExtraParameters_help": "Uno por línea",
"genreBehavior": "Comportamiento predeterminado de la página de géneros",
"externalLinks_description": "Permite mostrar enlaces externos (Last.fm, MusicBrainz) en páginas de artista/álbum",
"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"
},
"action": {
"editPlaylist": "editar $t(entity.playlist_one)",
@@ -215,7 +229,11 @@
"moveToBottom": "mover al fondo",
"setRating": "establecer calificación",
"toggleSmartPlaylistEditor": "cambiar editor $t(entity.smartPlaylist)",
"removeFromFavorites": "eliminar de $t(entity.favorite_other)"
"removeFromFavorites": "eliminar de $t(entity.favorite_other)",
"openIn": {
"lastfm": "Abrir en Last.fm",
"musicbrainz": "Abrir en MusicBrainz"
}
},
"common": {
"backward": "hacia atrás",
@@ -298,7 +316,17 @@
"action_other": "acciones",
"channel_one": "Canal",
"channel_many": "Canales",
"channel_other": "Canales"
"channel_other": "Canales",
"trackPeak": "la más alta de la canción",
"albumPeak": "lo más destacado del álbum",
"albumGain": "Ganancia de álbum",
"mbid": "ID de MusicBrainz",
"codec": "Códec",
"close": "Cerrar",
"reload": "Recargar",
"share": "Compartir",
"trackGain": "Ganancia de pista",
"preview": "Vista previa"
},
"error": {
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
@@ -319,7 +347,10 @@
"mpvRequired": "MPV requerido",
"audioDeviceFetchError": "un error ocurrió cuando se intentó obtener los dispositivos de audio",
"invalidServer": "servidor inválido",
"loginRateError": "demasiados intentos de inicio de sesión, por favor inténtalo en unos segundos"
"loginRateError": "demasiados intentos de inicio de sesión, por favor inténtalo en unos segundos",
"badAlbum": "Estás viendo esta página porque esta canción no forma parte de un álbum. Este problema puede ocurrir si tiene una canción en el nivel superior de su carpeta de música. Jellyfin solo agrupa pistas si están en una carpeta.",
"networkError": "Ocurrió un error de red",
"openError": "No se pudo abrir el archivo"
},
"filter": {
"mostPlayed": "más reproducido",
@@ -377,7 +408,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": "compartido $t(entity.playlist_other)"
},
"appMenu": {
"selectServer": "seleccionar servidor",
@@ -407,7 +439,9 @@
"addFavorite": "$t(action.addToFavorites)",
"play": "$t(player.play)",
"numberSelected": "{{count}} seleccionado",
"removeFromQueue": "$t(action.removeFromQueue)"
"removeFromQueue": "$t(action.removeFromQueue)",
"shareItem": "Compartir elemento",
"showDetails": "Obtener información"
},
"home": {
"mostPlayed": "más reproducidos",
@@ -429,7 +463,9 @@
"lyricAlignment": "alineación de letra",
"useImageAspectRatio": "usar ratio de aspecto de imagen",
"showLyricMatch": "mostrar coincidencia de letras",
"lyricGap": "desfase de letra"
"lyricGap": "desfase de letra",
"dynamicImageBlur": "tamaño de desenfoque de imagen",
"dynamicIsImage": "habilitar imagen de fondo"
},
"lyrics": "letras",
"related": "relacionado"
@@ -448,10 +484,14 @@
"title": "$t(entity.albumArtist_other)"
},
"genreList": {
"title": "$t(entity.genre_other)"
"title": "$t(entity.genre_other)",
"showAlbums": "Mostrar $t(entity.genre_one) $t(entity.album_other)",
"showTracks": "Mostrar $t(entity.genre_one) $t(entity.track_other)"
},
"trackList": {
"title": "$t(entity.track_other)"
"title": "$t(entity.track_other)",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
"artistTracks": "Pistas de {{artist}}"
},
"globalSearch": {
"commands": {
@@ -465,7 +505,25 @@
"title": "$t(entity.playlist_other)"
},
"albumList": {
"title": "$t(entity.album_other)"
"title": "$t(entity.album_other)",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)",
"artistAlbums": "Álbumes de {{artist}}"
},
"albumArtistDetail": {
"viewAllTracks": "ver todo de $t(entity.track_other)",
"relatedArtists": "similar a $t(entity.artist_other)",
"topSongs": "mejores canciones",
"topSongsFrom": "Las mejores canciones de {{title}}",
"viewAll": "Ver todo",
"recentReleases": "Lanzamientos recientes",
"viewDiscography": "Ver discografía",
"about": "Sobre {{artist}}",
"appearsOn": "Aparece en"
},
"itemDetail": {
"copiedPath": "Ruta copiada correctamente",
"openFile": "Mostrar pista en el gestor de archivos",
"copyPath": "Copiar ruta al portapapeles"
}
},
"form": {
@@ -516,6 +574,14 @@
"queryEditor": {
"input_optionMatchAll": "coincidir todos",
"input_optionMatchAny": "coincidir cualquiera"
},
"shareItem": {
"createFailed": "No se pudo crear el recurso compartido (¿está habilitado el uso compartido?)",
"allowDownloading": "Permitir la descarga",
"description": "Descripción",
"setExpiration": "Establecer expiración",
"success": "Enlace de compartición copiado al portapapeles (o pulsa aquí para abrir)",
"expireInvalid": "La expiración debe ser en el futuro"
}
},
"table": {
@@ -541,7 +607,9 @@
"albumArtist": "artista de álbum",
"path": "ruta",
"discNumber": "disco",
"channels": "$t(common.channel_other)"
"channels": "$t(common.channel_other)",
"size": "$t(common.size)",
"codec": "$t(common.codec)"
},
"config": {
"label": {
@@ -570,14 +638,17 @@
"playCount": "número de reproducción",
"genre": "$t(entity.genre_one)",
"favorite": "$t(common.favorite)",
"year": "$t(common.year)"
"year": "$t(common.year)",
"codec": "$t(common.codec)"
},
"general": {
"gap": "$t(common.gap)",
"tableColumns": "columnas de la tabla",
"autoFitColumns": "ajuste automático de columnas",
"size": "$t(common.size)",
"displayType": "tipo de visualización"
"displayType": "tipo de visualización",
"itemGap": "espacio entre elementos (px)",
"itemSize": "tamaño del elemento (px)"
},
"view": {
"card": "tarjeta",
+15 -8
View File
@@ -253,7 +253,7 @@
"moreFromGeneric": "plus de {{item}}"
},
"setting": {
"generalTab": "générale",
"generalTab": "général",
"hotkeysTab": "raccourci",
"windowTab": "fenêtre",
"playbackTab": "lecteur"
@@ -328,10 +328,10 @@
"scrobble": "scrobble",
"enableRemote_description": "activer le serveur de contrôle à distance, qui permet à d'autres appareils de contrôler l'application",
"fontType_optionSystem": "police système",
"mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv",
"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électionnez 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",
"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",
"hotkey_zoomIn": "zoom avant",
"scrobble_description": "scrobble les lectures à votre serveur multimédia",
"hotkey_browserForward": "avancer",
@@ -399,7 +399,7 @@
"lyricFetchProvider_description": "sélectionnez le fournisseur auprès desquels récupérer les paroles. l'ordre des fournisseurs et l'ordre dans lequel ils seront interrogés",
"globalMediaHotkeys_description": "active ou désactive l'utilisation des raccourcis clavier multimédia système pour contrôler la lecture",
"followLyric": "suivre les paroles actuelles",
"discordIdleStatus": "afficher l'état d'inactivité dans le status de l'activité",
"discordIdleStatus": "afficher l'état d'inactivité dans le statut de l'activité",
"hotkey_zoomOut": "zoom arrière",
"hotkey_unfavoriteCurrentSong": "retirer des favoris la $t(common.currentSong)",
"hotkey_rate0": "supprimer la note",
@@ -437,7 +437,7 @@
"skipPlaylistPage_description": "lors de la navigation dans une playlist, aller directement vers le 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érence du système (sombre ou clair)",
"useSystemTheme_description": "suivre les préférences du système (sombre ou clair)",
"skipPlaylistPage": "sauter la page de playlist",
"themeDark": "thème (sombre)",
"windowBarStyle_description": "sélectionner le style de la barre de la fenêtre",
@@ -460,7 +460,13 @@
"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}}"
"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",
"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"
},
"form": {
"deletePlaylist": {
@@ -482,7 +488,7 @@
"error_savePassword": "une erreur sest produite lors de la tentative de sauvegarde du mot de passe"
},
"addToPlaylist": {
"success": "{{message}} $t(entity.track_other) ajouté à {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) ajouté à $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "ajouter à $t(entity.playlist_one)",
"input_skipDuplicates": "sauter les doublons",
"input_playlists": "$t(entity.playlist_other)"
@@ -625,7 +631,8 @@
"artist": "$t(entity.artist_one)",
"genre": "$t(entity.genre_one)",
"songCount": "$t(entity.track_other)",
"channels": "$t(common.channel_other)"
"channels": "$t(common.channel_other)",
"size": "$t(common.size)"
}
}
}
+8 -2
View File
@@ -288,7 +288,12 @@
"replayGainFallback_description": "gain in db da applicare se il file non possiede tag {{ReplayGain}}",
"replayGainPreamp_description": "aggiusta la preamplificazione del gain applicato sui valori {{ReplayGain}}",
"skipPlaylistPage": "Salta la pagina playlist",
"sidebarCollapsedNavigation": "navigazione con barra laterale (collassata)"
"sidebarCollapsedNavigation": "navigazione con barra laterale (collassata)",
"clearCache_description": "pulitura \"forzata\" di feishin. Oltre a pulire la cache di feishin, elimina la cache del browser(immagini salvate e altri elementi). credenziali e impostazioni del server saranno mantenute",
"clearQueryCache": "pulisci cache di feishin",
"buttonSize_description": "Dimensione bottoni nella barra di riproduzione",
"clearCache": "pulisci la cache del browser",
"clearQueryCache_description": "\"leggera\" pulizia di feishin. verranno aggiornate le playlist, metadata delle tracce e i testi salvati. impostazioni, credenziali del server e le immagini salvate saranno mantenute"
},
"error": {
"remotePortWarning": "riavvia il server per applicare la nuova porta",
@@ -572,7 +577,8 @@
"albumArtist": "artista album",
"path": "percorso",
"discNumber": "disco",
"channels": "$t(common.channel_other)"
"channels": "$t(common.channel_other)",
"size": "$t(common.size)"
}
},
"entity": {
+2 -1
View File
@@ -353,7 +353,8 @@
"albumArtist": "アルバムアーティスト",
"path": "パス",
"discNumber": "ディスク",
"channels": "$t(common.channel_other)"
"channels": "$t(common.channel_other)",
"size": "$t(common.size)"
}
},
"error": {
+2 -1
View File
@@ -249,7 +249,8 @@
},
"table": {
"column": {
"rating": "rating"
"rating": "rating",
"size": "$t(common.size)"
},
"config": {
"label": {
+11 -4
View File
@@ -489,7 +489,7 @@
"font_description": "ustaw czcionkę dla aplikacji",
"playButtonBehavior_optionPlay": "$t(player.play)",
"minimumScrobblePercentage": "minimalny czas trwania scrobble (procentowy)",
"mpvExecutablePath_description": "ustaw ścieżkę dla plików wykonywalnych mpv",
"mpvExecutablePath_description": "ustaw ścieżkę dla plików wykonywalnych mpv. gdy puste, zostanie użyta domyślna ścieżka",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"minimizeToTray_description": "zminimalizuj aplikację do zasobnika systemowego",
"remotePassword": "hasło dla serwera zdalnej kontroli",
@@ -518,7 +518,7 @@
"sampleRate": "częstotliwość próbkowania",
"sidePlayQueueStyle_optionAttached": "przyłączony",
"sidebarConfiguration": "konfiguracja paska bocznego",
"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",
"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",
@@ -558,7 +558,13 @@
"skipPlaylistPage": "pomiń stronę list odtwarzania",
"themeDark": "motyw (ciemny)",
"windowBarStyle_description": "wybierz styl paska okna",
"useSystemTheme": "użyj motywu systemowego"
"useSystemTheme": "użyj motywu systemowego",
"buttonSize": "Rozmiar przycisku paska odtwarzacza",
"clearQueryCache": "wyczyść pamięć podręczną feishin",
"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"
},
"table": {
"config": {
@@ -625,7 +631,8 @@
"albumArtist": "artysta albumu",
"path": "ścieżka",
"discNumber": "płyta",
"channels": "$t(common.channel_other)"
"channels": "$t(common.channel_other)",
"size": "$t(common.size)"
}
}
}
+49 -9
View File
@@ -80,7 +80,13 @@
"yes": "sim",
"random": "aleatório",
"size": "tamanho",
"note": "observação"
"note": "observação",
"mbid": "ID no MusicBrainz",
"reload": "recarregar",
"codec": "codec",
"preview": "pré-visualizar",
"share": "compartilhar",
"close": "fechar"
},
"action": {
"goToPage": "vá para página",
@@ -98,17 +104,34 @@
"removeFromPlaylist": "remover da $t(entity.playlist_one)",
"deletePlaylist": "deletar $t(entity.playlist_one)",
"deselectAll": "desmarcar todos",
"removeFromFavorites": "remover de $t(entity.favorite_other)"
"removeFromFavorites": "remover de $t(entity.favorite_other)",
"openIn": {
"lastfm": "Abrir em Last.fm",
"musicbrainz": "Abrir em MusicBrainz"
}
},
"form": {
"deletePlaylist": {
"title": "deletar $t(entity.playlist_one)"
"title": "deletar $t(entity.playlist_one)",
"input_confirm": "escreva o nome da $t(entity.playlist_one) para confirmar"
},
"addServer": {
"title": "adicionar servidor"
"title": "adicionar servidor",
"input_password": "senha",
"input_legacyAuthentication": "habilitar autenticação legada",
"error_savePassword": "um erro ocorreu ao tentar salvar a senha",
"ignoreSsl": "ignorar ssl ($t(common.restartRequired))",
"input_savePassword": "salvar senha",
"input_url": "url",
"success": "servidor adicionado com sucesso",
"input_name": "nome do servidor",
"input_username": "nome de usuário"
},
"createPlaylist": {
"title": "criar $t(entity.playlist_one)"
"title": "criar $t(entity.playlist_one)",
"input_public": "público",
"input_description": "$t(common.description)",
"success": "$t(entity.playlist_one) criada com sucesso"
},
"updateServer": {
"title": "atualizar servidor"
@@ -117,7 +140,10 @@
"title": "editar $t(entity.playlist_one)"
},
"addToPlaylist": {
"title": "adicionar à $t(entity.playlist_one)"
"title": "adicionar à $t(entity.playlist_one)",
"input_playlists": "$t(entity.playlist_other)",
"input_skipDuplicates": "pular duplicadas",
"success": "adicionado $t(entity.trackWithCount, {\"count\": {{message}} }) para $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })"
},
"lyricSearch": {
"title": "pesquisa de letras"
@@ -139,7 +165,8 @@
},
"column": {
"title": "titulo",
"discNumber": "disco"
"discNumber": "disco",
"size": "$t(common.size)"
}
},
"page": {
@@ -175,7 +202,17 @@
"filter": {
"title": "titulo",
"disc": "disco",
"mostPlayed": "mais tocado"
"mostPlayed": "mais tocado",
"album": "$t(entity.album_one)",
"name": "nome",
"biography": "bibliografia",
"duration": "duração",
"favorited": "favoritado",
"fromYear": "a partir do ano",
"songCount": "contador de músicas",
"toYear": "até o ano",
"random": "aleatório",
"search": "buscar"
},
"player": {
"playbackFetchNoResults": "nenhuma música encontrada",
@@ -241,6 +278,9 @@
"mpvRequired": "MPV necessário",
"audioDeviceFetchError": "ocorreu um erro ao tentar obter dispositivos de áudio",
"invalidServer": "servidor inválido",
"loginRateError": "muitas tentativas de login, tente novamente em alguns segundos"
"loginRateError": "muitas tentativas de login, tente novamente em alguns segundos",
"badAlbum": "você está vendo este erro por que está música não é parte de algum album. um motivo comum para você estar vendo este erro é se a sua música estiver na raiz da sua pasta de músicas. o jellyfin apenas agrupa as músicas se elas estiveram na mesma pasta.",
"networkError": "ocorreu um erro na internet",
"openError": "não foi possível abrir o arquivo"
}
}
+2 -1
View File
@@ -204,7 +204,8 @@
"trackNumber": "трек",
"genre": "$t(entity.genre_one)",
"path": "путь",
"discNumber": "диск"
"discNumber": "диск",
"size": "$t(common.size)"
}
},
"error": {
+2 -1
View File
@@ -359,7 +359,8 @@
"albumArtist": "album artist",
"path": "putanja",
"discNumber": "disk",
"channels": "$t(common.channel_other)"
"channels": "$t(common.channel_other)",
"size": "$t(common.size)"
}
},
"error": {
+84 -13
View File
@@ -16,7 +16,11 @@
"setRating": "评分",
"toggleSmartPlaylistEditor": "切换$t(entity.smartPlaylist)编辑器",
"removeFromFavorites": "从$t(entity.favorite_other)移除",
"goToPage": "转到页面"
"goToPage": "转到页面",
"openIn": {
"lastfm": "在 Last.fm 中打开",
"musicbrainz": "在 MusicBrainz 中打开"
}
},
"common": {
"increase": "增高",
@@ -93,7 +97,17 @@
"yes": "是",
"size": "大小",
"areYouSure": "是否继续?",
"note": "注释"
"note": "注释",
"close": "关闭",
"albumPeak": "专辑峰值",
"mbid": "MusicBrainz ID",
"reload": "重新加载",
"trackGain": "音轨增益",
"trackPeak": "音轨峰值",
"albumGain": "专辑增益",
"codec": "编解码器",
"share": "分享",
"preview": "预览"
},
"entity": {
"albumArtist_other": "专辑艺术家",
@@ -310,7 +324,21 @@
"buttonSize_description": "播放器栏按钮大小",
"clearCache_description": "feishin的“硬清除”。除了清除feishin的缓存,清空浏览器缓存(保存的图像和其他资源)。会保留服务器凭据和设置",
"clearQueryCache_description": "feishin的“软清除”。这将会刷新播放列表、元数据并重置保存的歌词。会保留设置、服务器凭据和缓存图像",
"clearQueryCache": "清除feishin缓存"
"clearQueryCache": "清除feishin缓存",
"externalLinks": "显示外部链接",
"externalLinks_description": "允许在艺术家/专辑页面上显示外部链接(Last.fm、MusicBrainz",
"mpvExtraParameters_help": "每行一个",
"startMinimized": "启动最小化",
"startMinimized_description": "在系统托盘中启动应用程序",
"passwordStore_description": "使用什么密码/秘密存储。如果您在存储密码时遇到问题,请更改此设置。",
"clearCacheSuccess": "缓存清除成功",
"playerAlbumArtResolution": "播放器专辑封面分辨率",
"playerAlbumArtResolution_description": "大型播放器专辑封面预览的分辨率。较大使其看起来更清晰,但可能会减慢加载速度。默认为0,表示自动",
"genreBehavior": "类型页面默认行为",
"genreBehavior_description": "确定单击流派是否默认在曲目或专辑列表中打开",
"homeConfiguration": "主页配置",
"homeConfiguration_description": "配置主页上显示的项目以及显示顺序",
"passwordStore": "密码/秘密存储"
},
"error": {
"remotePortWarning": "重启服务器使新端口生效",
@@ -331,7 +359,10 @@
"mpvRequired": "需要 MPV",
"audioDeviceFetchError": "无法获取音频设备",
"invalidServer": "无效的服务器",
"loginRateError": "登录请求尝试次数过多,请稍后再试"
"loginRateError": "登录请求尝试次数过多,请稍后再试",
"badAlbum": "您看到此页面是因为这首歌不是专辑的一部分。如果您的音乐文件夹顶层有一首歌曲,您很可能会遇到此问题。jellyfin 仅对位于文件夹中的曲目进行分组。",
"networkError": "发生网络错误",
"openError": "无法打开文件"
},
"filter": {
"mostPlayed": "播放最多",
@@ -389,7 +420,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": "共享 $t(entity.playlist_other)"
},
"fullscreenPlayer": {
"config": {
@@ -403,7 +435,9 @@
"lyricAlignment": "歌词对齐",
"useImageAspectRatio": "使用图片纵横比",
"lyricGap": "歌词间距",
"followCurrentLyric": "跟随当前歌词"
"followCurrentLyric": "跟随当前歌词",
"dynamicImageBlur": "图像模糊大小",
"dynamicIsImage": "启用背景图像"
},
"lyrics": "歌词",
"related": "相关",
@@ -462,22 +496,46 @@
"addNext": "$t(player.addNext)",
"deselectAll": "$t(action.deselectAll)",
"addLast": "$t(player.addLast)",
"addFavorite": "$t(action.addToFavorites)"
"addFavorite": "$t(action.addToFavorites)",
"showDetails": "获取信息",
"shareItem": "分享项目"
},
"trackList": {
"title": "$t(entity.track_other)"
"title": "$t(entity.track_other)",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
"artistTracks": "{{artist}} 的曲目"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
},
"albumList": {
"title": "$t(entity.album_other)"
"title": "$t(entity.album_other)",
"artistAlbums": "{{artist}} 的专辑",
"genreAlbums": "\"{{genre}}\" $t(entity.album_other)"
},
"genreList": {
"title": "$t(entity.genre_other)"
"title": "$t(entity.genre_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)"
},
"albumArtistDetail": {
"recentReleases": "最近发布",
"viewDiscography": "查看唱片目录",
"relatedArtists": "相关 $t(entity.artist_other)",
"topSongs": "热门歌曲",
"topSongsFrom": "{{title}} 的热门歌曲",
"viewAllTracks": "查看所有 $t(entity.track_other)",
"about": "关于 {{artist}}",
"appearsOn": "出现在",
"viewAll": "查看全部"
},
"itemDetail": {
"copyPath": "将路径复制到剪贴板",
"copiedPath": "路径复制成功",
"openFile": "在文件管理器中显示曲目"
}
},
"form": {
@@ -528,6 +586,14 @@
"title": "搜索歌词",
"input_name": "$t(common.name)",
"input_artist": "$t(entity.artist_one)"
},
"shareItem": {
"expireInvalid": "过期时间必须是将来的时间",
"createFailed": "创建共享失败(是否启用共享?)",
"allowDownloading": "允许下载",
"description": "描述",
"setExpiration": "设置过期时间",
"success": "共享链接已复制到剪贴板(或单击此处打开)"
}
},
"table": {
@@ -537,7 +603,9 @@
"gap": "$t(common.gap)",
"tableColumns": "列",
"autoFitColumns": "列宽自适应",
"size": "$t(common.size)"
"size": "$t(common.size)",
"itemGap": "项目间隙(px",
"itemSize": "项目大小 (px)"
},
"view": {
"table": "表格",
@@ -570,7 +638,8 @@
"favorite": "$t(common.favorite)",
"year": "$t(common.year)",
"albumArtist": "$t(entity.albumArtist_one)",
"titleCombined": "$t(common.title)(合并)"
"titleCombined": "$t(common.title)(合并)",
"codec": "$t(common.codec)"
}
},
"column": {
@@ -595,7 +664,9 @@
"albumArtist": "专辑艺术家",
"path": "路径",
"channels": "$t(common.channel_other)",
"discNumber": "盘"
"discNumber": "盘",
"size": "$t(common.size)",
"codec": "$t(common.codec)"
}
}
}
+2 -1
View File
@@ -463,7 +463,8 @@
"bpm": "bpm",
"songCount": "$t(entity.track_other)",
"title": "標題",
"trackNumber": "音軌編號"
"trackNumber": "音軌編號",
"size": "$t(common.size)"
}
},
"action": {
+1 -1
View File
@@ -72,7 +72,7 @@ const getRemoteLyrics = async (song: QueueSong) => {
const params = {
album: song.album || song.name,
artist: song.artistName,
duration: song.duration,
duration: song.duration / 1000.0,
name: song.name,
};
const response = await FETCHERS[source](params);
+27 -14
View File
@@ -180,7 +180,11 @@ ipcMain.handle(
// Clean up previous mpv instance
getMpvInstance()?.stop();
getMpvInstance()?.quit();
getMpvInstance()
?.quit()
.catch((error) => {
mpvLog({ action: 'Failed to quit existing MPV' }, error);
});
mpvInstance = null;
mpvInstance = await createMpv(data);
@@ -211,11 +215,12 @@ ipcMain.handle(
ipcMain.on('player-quit', async () => {
try {
getMpvInstance()?.stop();
getMpvInstance()?.quit();
mpvInstance = null;
await getMpvInstance()?.stop();
await getMpvInstance()?.quit();
} catch (err: NodeMpvError | any) {
mpvLog({ action: 'Failed to quit mpv' }, err);
} finally {
mpvInstance = null;
}
});
@@ -301,7 +306,7 @@ 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 && !data.queue.next) {
if (!data.queue.current?.id && !data.queue.next?.id) {
try {
await getMpvInstance()?.clearPlaylist();
await getMpvInstance()?.pause();
@@ -312,14 +317,14 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
}
try {
if (data.queue.current) {
if (data.queue.current?.streamUrl) {
await getMpvInstance()
?.load(data.queue.current.streamUrl, 'replace')
.catch(() => {
getMpvInstance()?.play();
});
if (data.queue.next) {
if (data.queue.next?.streamUrl) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
}
@@ -348,7 +353,7 @@ ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
await getMpvInstance()?.playlistRemove(1);
}
if (data.queue.next) {
if (data.queue.next?.streamUrl) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
} catch (err: NodeMpvError | any) {
@@ -368,7 +373,7 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
getMpvInstance()?.pause();
});
if (data.queue.next) {
if (data.queue.next?.streamUrl) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
} catch (err: NodeMpvError | any) {
@@ -407,11 +412,19 @@ ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
}
});
app.on('before-quit', () => {
getMpvInstance()?.stop();
getMpvInstance()?.quit();
app.on('before-quit', async () => {
try {
await getMpvInstance()?.stop();
await getMpvInstance()?.quit();
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to cleanly before-quit` }, err);
}
});
app.on('window-all-closed', () => {
getMpvInstance()?.quit();
app.on('window-all-closed', async () => {
try {
await getMpvInstance()?.quit();
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to cleanly exit` }, err);
}
});
+3 -1
View File
@@ -6,18 +6,20 @@ import { store } from '../settings';
export const enableMediaKeys = (window: BrowserWindow | null) => {
if (isMacOS()) {
const shouldPrompt = store.get('should_prompt_accessibility', true) as boolean;
const shownWarning = store.get('shown_accessibility_warning', false) as boolean;
const trusted = systemPreferences.isTrustedAccessibilityClient(shouldPrompt);
if (shouldPrompt) {
store.set('should_prompt_accessibility', false);
}
if (!trusted) {
if (!trusted && !shownWarning) {
window?.webContents.send('toast-from-main', {
message:
'Feishin is not a trusted accessibility client. Media keys will not work until this setting is changed',
type: 'warning',
});
store.set('shown_accessibility_warning', true);
}
}
+13 -1
View File
@@ -18,22 +18,29 @@ mprisPlayer.on('quit', () => {
process.exit();
});
const hasData = (): boolean => {
return mprisPlayer.metadata && !!mprisPlayer.metadata['mpris:length'];
};
mprisPlayer.on('stop', () => {
getMainWindow()?.webContents.send('renderer-player-stop');
mprisPlayer.playbackStatus = 'Paused';
});
mprisPlayer.on('pause', () => {
if (!hasData()) return;
getMainWindow()?.webContents.send('renderer-player-pause');
mprisPlayer.playbackStatus = 'Paused';
});
mprisPlayer.on('play', () => {
if (!hasData()) return;
getMainWindow()?.webContents.send('renderer-player-play');
mprisPlayer.playbackStatus = 'Playing';
});
mprisPlayer.on('playpause', () => {
if (!hasData()) return;
getMainWindow()?.webContents.send('renderer-player-play-pause');
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing';
@@ -43,6 +50,7 @@ mprisPlayer.on('playpause', () => {
});
mprisPlayer.on('next', () => {
if (!hasData()) return;
getMainWindow()?.webContents.send('renderer-player-next');
if (mprisPlayer.playbackStatus !== 'Playing') {
@@ -51,6 +59,7 @@ mprisPlayer.on('next', () => {
});
mprisPlayer.on('previous', () => {
if (!hasData()) return;
getMainWindow()?.webContents.send('renderer-player-previous');
if (mprisPlayer.playbackStatus !== 'Playing') {
@@ -136,7 +145,10 @@ ipcMain.on('update-song', (_event, args: SongUpdate) => {
mprisPlayer.shuffle = shuffle;
}
if (!song) return;
if (!song) {
mprisPlayer.metadata = {};
return;
}
const upsizedImageUrl = song.imageUrl
? song.imageUrl
+50
View File
@@ -24,6 +24,8 @@ import {
BrowserWindowConstructorOptions,
protocol,
net,
Rectangle,
screen,
} from 'electron';
import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log/main';
@@ -256,6 +258,26 @@ const createWindow = async (first = true) => {
...(nativeFrame && isWindows() && nativeFrameConfig.windows),
});
// From https://github.com/electron/electron/issues/526#issuecomment-1663959513
const bounds = store.get('bounds') as Rectangle | undefined;
if (bounds) {
const screenArea = screen.getDisplayMatching(bounds).workArea;
if (
bounds.x > screenArea.x + screenArea.width ||
bounds.x < screenArea.x ||
bounds.y < screenArea.y ||
bounds.y > screenArea.y + screenArea.height
) {
if (bounds.width < screenArea.width && bounds.height < screenArea.height) {
mainWindow.setBounds({ height: bounds.height, width: bounds.width });
} else {
mainWindow.setBounds({ height: 900, width: 1440 });
}
} else {
mainWindow.setBounds(bounds);
}
}
electronLocalShortcut.register(mainWindow, 'Ctrl+Shift+I', () => {
mainWindow?.webContents.openDevTools();
});
@@ -342,6 +364,20 @@ 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();
});
});
});
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
if (globalMediaKeysEnabled) {
@@ -358,6 +394,16 @@ const createWindow = async (first = true) => {
}
if (!first || !startWindowMinimized) {
const maximized = store.get('maximized');
const fullScreen = store.get('fullscreen');
if (maximized) {
mainWindow.maximize();
}
if (fullScreen) {
mainWindow.setFullScreen(true);
}
mainWindow.show();
createWinThumbarButtons();
}
@@ -371,6 +417,10 @@ const createWindow = async (first = true) => {
let saved = false;
mainWindow.on('close', (event) => {
store.set('bounds', mainWindow?.getNormalBounds());
store.set('maximized', mainWindow?.isMaximized());
store.set('fullscreen', mainWindow?.isFullScreen());
if (!exitFromTray && store.get('window_exit_to_tray')) {
if (isMacOS() && !forceQuit) {
exitFromTray = true;
+1
View File
@@ -64,6 +64,7 @@ const env = {
SERVER_NAME: process.env.SERVER_NAME ?? '',
SERVER_TYPE,
SERVER_URL: process.env.SERVER_URL ?? 'http://',
START_MAXIMIZED: store.get('maximized'),
};
export const localSettings = {
+5
View File
@@ -10,6 +10,10 @@ const restoreQueue = () => {
ipcRenderer.send('player-restore-queue');
};
const openItem = async (path: string) => {
return ipcRenderer.invoke('open-item', path);
};
const onSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-save-queue', cb);
};
@@ -51,6 +55,7 @@ export const utils = {
mainMessageListener,
onRestoreQueue,
onSaveQueue,
openItem,
playerErrorListener,
restoreQueue,
saveQueue,
+5 -2
View File
@@ -61,6 +61,7 @@ export const RemoteContainer = () => {
spacing={0}
>
<RemoteButton
disabled={!song}
tooltip="Previous track"
variant="default"
onClick={() => send({ event: 'previous' })}
@@ -68,7 +69,8 @@ export const RemoteContainer = () => {
<RiSkipBackFill size={25} />
</RemoteButton>
<RemoteButton
tooltip={status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
disabled={!song}
tooltip={song && status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
variant="default"
onClick={() => {
if (status === PlayerStatus.PLAYING) {
@@ -78,13 +80,14 @@ export const RemoteContainer = () => {
}
}}
>
{status === PlayerStatus.PLAYING ? (
{song && status === PlayerStatus.PLAYING ? (
<RiPauseFill size={25} />
) : (
<RiPlayFill size={25} />
)}
</RemoteButton>
<RemoteButton
disabled={!song}
tooltip="Next track"
variant="default"
onClick={() => send({ event: 'next' })}
+17 -1
View File
@@ -8,6 +8,7 @@ import type {
AlbumArtistDetailArgs,
AlbumArtistListArgs,
SetRatingArgs,
ShareItemArgs,
GenreListArgs,
CreatePlaylistArgs,
DeletePlaylistArgs,
@@ -55,6 +56,7 @@ import type {
SimilarSongsArgs,
Song,
ServerType,
ShareItemResponse,
} from '/@/renderer/api/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
@@ -102,6 +104,7 @@ export type ControllerEndpoint = Partial<{
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>;
}>;
@@ -149,6 +152,7 @@ const endpoints: ApiController = {
scrobble: jfController.scrobble,
search: jfController.search,
setRating: undefined,
shareItem: undefined,
updatePlaylist: jfController.updatePlaylist,
},
navidrome: {
@@ -178,7 +182,7 @@ const endpoints: ApiController = {
getPlaylistSongList: ndController.getPlaylistSongList,
getRandomSongList: ssController.getRandomSongList,
getServerInfo: ndController.getServerInfo,
getSimilarSongs: ssController.getSimilarSongs,
getSimilarSongs: ndController.getSimilarSongs,
getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList,
getStructuredLyrics: ssController.getStructuredLyrics,
@@ -188,6 +192,7 @@ const endpoints: ApiController = {
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: ssController.setRating,
shareItem: ndController.shareItem,
updatePlaylist: ndController.updatePlaylist,
},
subsonic: {
@@ -223,6 +228,7 @@ const endpoints: ApiController = {
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: undefined,
shareItem: undefined,
updatePlaylist: undefined,
},
};
@@ -457,6 +463,15 @@ const updateRating = async (args: SetRatingArgs) => {
)?.(args);
};
const shareItem = async (args: ShareItemArgs) => {
return (
apiController(
'shareItem',
args.apiClientProps.server?.type,
) as ControllerEndpoint['shareItem']
)?.(args);
};
const getTopSongList = async (args: TopSongListArgs) => {
return (
apiController(
@@ -555,6 +570,7 @@ export const controller = {
removeFromPlaylist,
scrobble,
search,
shareItem,
updatePlaylist,
updateRating,
};
+1
View File
@@ -4,6 +4,7 @@ export enum ServerFeature {
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
PLAYLISTS_SMART = 'playlistsSmart',
SHARING_ALBUM_SONG = 'sharingAlbumSong',
}
export type ServerFeatures = Partial<Record<ServerFeature, boolean>>;
+3 -3
View File
@@ -574,7 +574,7 @@ export enum JFSongListSort {
ARTIST = 'Artist,Album,SortName',
COMMUNITY_RATING = 'CommunityRating,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'Name,SortName',
NAME = 'SortName,Name',
PLAY_COUNT = 'PlayCount,SortName',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
@@ -601,7 +601,7 @@ export type JFSongListParams = {
export enum JFAlbumArtistListSort {
ALBUM = 'Album,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'Name,SortName',
NAME = 'SortName,Name',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
@@ -618,7 +618,7 @@ export type JFAlbumArtistListParams = {
export enum JFArtistListSort {
ALBUM = 'Album,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'Name,SortName',
NAME = 'SortName,Name',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
+19 -1
View File
@@ -115,6 +115,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
getInstantMix: {
method: 'GET',
path: 'songs/:itemId/InstantMix',
query: jfType._parameters.similarSongs,
responses: {
200: jfType._response.songList,
400: jfType._response.error,
},
},
getMusicFolderList: {
method: 'GET',
path: 'users/:userId/items',
@@ -176,6 +185,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
getSongData: {
method: 'GET',
path: 'users/:userId/items/:id',
query: jfType._parameters.songDetail,
responses: {
200: jfType._response.song,
400: jfType._response.error,
},
},
getSongDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
@@ -195,7 +213,7 @@ export const contract = c.router({
},
getSongLyrics: {
method: 'GET',
path: 'users/:userId/Items/:id/Lyrics',
path: 'audio/:id/Lyrics',
responses: {
200: jfType._response.lyrics,
404: jfType._response.error,
@@ -61,7 +61,9 @@ import packageJson from '../../../../package.json';
import { z } from 'zod';
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
import isElectron from 'is-electron';
import { ServerFeatures } from '/@/renderer/api/features-types';
import { ServerFeature } from '/@/renderer/api/features-types';
import { VersionInfo, getFeatures } from '/@/renderer/api/utils';
import chunk from 'lodash/chunk';
const formatCommaDelimitedString = (value: string[]) => {
return value.join(',');
@@ -231,7 +233,7 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtis
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
UserId: apiClientProps.server?.userId || undefined,
@@ -257,7 +259,7 @@ const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListRespo
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
SortBy: artistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
},
@@ -445,8 +447,26 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
throw new Error('Failed to get song list');
}
let items: z.infer<typeof jfType._response.song>[];
// Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622
// If the Album ID filter is passed, Jellyfin will search for
// 1. the matching album id
// 2. An album with the name of the album.
// It is this second condition causing issues,
if (query.albumIds) {
const albumIdSet = new Set(query.albumIds);
items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId));
if (items.length < res.body.Items.length) {
res.body.TotalRecordCount -= res.body.Items.length - items.length;
}
} else {
items = res.body.Items;
}
return {
items: res.body.Items.map((item) =>
items: items.map((item) =>
jfNormalize.song(item, apiClientProps.server, '', query.imageSize),
),
startIndex: query.startIndex,
@@ -454,6 +474,11 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
};
};
// Limit the query to 50 at a time to be *extremely* conservative on the
// length of the full URL, since the ids are part of the query string and
// not the POST body
const MAX_ITEMS_PER_PLAYLIST_ADD = 50;
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => {
const { query, body, apiClientProps } = args;
@@ -461,19 +486,23 @@ const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResp
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).addToPlaylist({
body: null,
params: {
id: query.id,
},
query: {
Ids: body.songId,
UserId: apiClientProps.server?.userId,
},
});
const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD);
if (res.status !== 204) {
throw new Error('Failed to add to playlist');
for (const chunk of chunks) {
const res = await jfApiClient(apiClientProps).addToPlaylist({
body: null,
params: {
id: query.id,
},
query: {
Ids: chunk.join(','),
UserId: apiClientProps.server?.userId,
},
});
if (res.status !== 204) {
throw new Error('Failed to add to playlist');
}
}
return null;
@@ -484,18 +513,22 @@ const removeFromPlaylist = async (
): Promise<RemoveFromPlaylistResponse> => {
const { query, apiClientProps } = args;
const res = await jfApiClient(apiClientProps).removeFromPlaylist({
body: null,
params: {
id: query.id,
},
query: {
EntryIds: query.songId,
},
});
const chunks = chunk(query.songId, MAX_ITEMS_PER_PLAYLIST_ADD);
if (res.status !== 204) {
throw new Error('Failed to remove from playlist');
for (const chunk of chunks) {
const res = await jfApiClient(apiClientProps).removeFromPlaylist({
body: null,
params: {
id: query.id,
},
query: {
EntryIds: chunk.join(','),
},
});
if (res.status !== 204) {
throw new Error('Failed to remove from playlist');
}
}
return null;
@@ -919,7 +952,6 @@ const getLyrics = async (args: LyricsArgs): Promise<LyricsResponse> => {
const res = await jfApiClient(apiClientProps).getSongLyrics({
params: {
id: query.songId,
userId: apiClientProps.server?.userId,
},
});
@@ -951,6 +983,8 @@ const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse>
return jfNormalize.song(res.body, apiClientProps.server, '');
};
const VERSION_INFO: VersionInfo = [['10.9.0', { [ServerFeature.LYRICS_SINGLE_STRUCTURED]: [1] }]];
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
const { apiClientProps } = args;
@@ -960,9 +994,7 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
throw new Error('Failed to get server info');
}
const features: ServerFeatures = {
lyricsSingleStructured: true,
};
const features = getFeatures(VERSION_INFO, res.body.Version);
return {
features,
@@ -974,6 +1006,8 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
const { apiClientProps, query } = args;
// Prefer getSimilarSongs, where possible. Fallback to InstantMix
// where no similar songs were found.
const res = await jfApiClient(apiClientProps).getSimilarSongs({
params: {
itemId: query.songId,
@@ -985,11 +1019,36 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
},
});
if (res.status !== 200) {
if (res.status === 200 && res.body.Items.length) {
const results = res.body.Items.reduce<Song[]>((acc, song) => {
if (song.Id !== query.songId) {
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
}
return acc;
}, []);
if (results.length > 0) {
return results;
}
}
const mix = await jfApiClient(apiClientProps).getInstantMix({
params: {
itemId: query.songId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId',
Limit: query.count,
UserId: apiClientProps.server?.userId || undefined,
},
});
if (mix.status !== 200) {
throw new Error('Failed to get similar songs');
}
return res.body.Items.reduce<Song[]>((acc, song) => {
return mix.body.Items.reduce<Song[]>((acc, song) => {
if (song.Id !== query.songId) {
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
}
@@ -134,7 +134,7 @@ const normalizeSong = (
imageUrl: null,
name: entry.Name,
})),
albumId: item.AlbumId,
albumId: item.AlbumId || `dummy/${item.Id}`,
artistName: item?.ArtistItems?.[0]?.Name,
artists: item?.ArtistItems?.map((entry) => ({
id: entry.Id,
+8 -5
View File
@@ -387,11 +387,13 @@ const genericItem = z.object({
Name: z.string(),
});
const songDetailParameters = baseParameters;
const song = z.object({
Album: z.string(),
AlbumArtist: z.string(),
AlbumArtists: z.array(genericItem),
AlbumId: z.string(),
AlbumId: z.string().optional(),
AlbumPrimaryImageTag: z.string(),
ArtistItems: z.array(genericItem),
Artists: z.array(z.string()),
@@ -512,7 +514,7 @@ const albumList = pagination.extend({
const albumArtistListSort = {
ALBUM: 'Album,SortName',
DURATION: 'Runtime,AlbumArtist,Album,SortName',
NAME: 'Name,SortName',
NAME: 'SortName,Name',
RANDOM: 'Random,SortName',
RECENTLY_ADDED: 'DateCreated,SortName',
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
@@ -542,7 +544,7 @@ const songListSort = {
ARTIST: 'Artist,Album,SortName',
COMMUNITY_RATING: 'CommunityRating,SortName',
DURATION: 'Runtime,AlbumArtist,Album,SortName',
NAME: 'Name,SortName',
NAME: 'SortName,Name',
PLAY_COUNT: 'PlayCount,SortName',
RANDOM: 'Random,SortName',
RECENTLY_ADDED: 'DateCreated,SortName',
@@ -607,14 +609,14 @@ const addToPlaylist = z.object({
});
const addToPlaylistParameters = z.object({
Ids: z.array(z.string()),
Ids: z.string(),
UserId: z.string(),
});
const removeFromPlaylist = z.null();
const removeFromPlaylistParameters = z.object({
EntryIds: z.array(z.string()),
EntryIds: z.string(),
});
const deletePlaylist = z.null();
@@ -709,6 +711,7 @@ export const jfType = {
search: searchParameters,
similarArtistList: similarArtistListParameters,
similarSongs: similarSongsParameters,
songDetail: songDetailParameters,
songList: songListParameters,
updatePlaylist: updatePlaylistParameters,
},
+7
View File
@@ -242,6 +242,7 @@ export enum NDSongListSort {
ID = 'id',
PLAY_COUNT = 'playCount',
PLAY_DATE = 'playDate',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'createdAt',
TITLE = 'title',
@@ -399,6 +400,7 @@ export const NDSongQueryFields = [
{ label: 'File Type', type: 'string', value: 'filetype' },
{ label: 'Genre', type: 'string', value: 'genre' },
{ label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
{ label: 'Playlist', type: 'playlist', value: 'id' },
{ label: 'Is Compilation', type: 'boolean', value: 'compilation' },
{ label: 'Is Favorite', type: 'boolean', value: 'loved' },
{ label: 'Lyrics', type: 'string', value: 'lyrics' },
@@ -414,6 +416,11 @@ export const NDSongQueryFields = [
{ label: 'Year', type: 'number', value: 'year' },
];
export const NDSongQueryPlaylistOperators = [
{ label: 'is in', value: 'inPlaylist' },
{ label: 'is not in', value: 'notInPlaylist' },
];
export const NDSongQueryDateOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
@@ -157,6 +157,16 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
shareItem: {
body: ndType._parameters.shareItem,
method: 'POST',
path: 'share',
responses: {
200: resultWithHeaders(ndType._response.shareItem),
404: resultWithHeaders(ndType._response.error),
500: resultWithHeaders(ndType._response.error),
},
},
updatePlaylist: {
body: ndType._parameters.updatePlaylist,
method: 'PUT',
@@ -1,9 +1,7 @@
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
import { NavidromeExtensions, ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import semverCoerce from 'semver/functions/coerce';
import semverGte from 'semver/functions/gte';
import {
AlbumArtistDetailArgs,
AlbumArtistDetailResponse,
@@ -47,10 +45,16 @@ import {
genreListSortMap,
ServerInfo,
ServerInfoArgs,
ShareItemArgs,
ShareItemResponse,
SimilarSongsArgs,
Song,
} from '../types';
import { hasFeature } from '/@/renderer/api/utils';
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types';
import { NDSongListSort } from '/@/renderer/api/navidrome.types';
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
const authenticate = async (
url: string,
@@ -151,20 +155,18 @@ const getAlbumArtistDetail = async (
throw new Error('Server is required');
}
// Prefer images from getArtistInfo first (which should be proxied)
// Prioritize large > medium > small
return ndNormalize.albumArtist(
{
...res.body.data,
...(artistInfoRes.status === 200 && {
largeImageUrl:
artistInfoRes.body.artistInfo.largeImageUrl ||
artistInfoRes.body.artistInfo.mediumImageUrl ||
artistInfoRes.body.artistInfo.smallImageUrl ||
res.body.data.largeImageUrl,
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
...(!res.body.data.largeImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl,
}),
...(!res.body.data.mediumImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl,
}),
...(!res.body.data.smallImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl,
}),
}),
},
apiClientProps.server,
@@ -482,34 +484,11 @@ const removeFromPlaylist = async (
return null;
};
const VERSION_INFO: Array<[string, Record<string, number[]>]> = [
const VERSION_INFO: VersionInfo = [
['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
];
const getFeatures = (version: string): Record<string, number[]> => {
const cleanVersion = semverCoerce(version);
const features: Record<string, number[]> = {};
let matched = cleanVersion === null;
for (const [version, supportedFeatures] of VERSION_INFO) {
if (!matched) {
matched = semverGte(cleanVersion!, version);
}
if (matched) {
for (const [feature, feat] of Object.entries(supportedFeatures)) {
if (feature in features) {
features[feature].push(...feat);
} else {
features[feature] = feat;
}
}
}
}
return features;
};
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
const { apiClientProps } = args;
@@ -520,7 +499,10 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
throw new Error('Failed to ping server');
}
const navidromeFeatures: Record<string, number[]> = getFeatures(ping.body.serverVersion!);
const navidromeFeatures: Record<string, number[]> = getFeatures(
VERSION_INFO,
ping.body.serverVersion!,
);
if (ping.body.openSubsonic) {
const res = await ssApiClient(apiClientProps).getServerInfo();
@@ -541,12 +523,87 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
const features: ServerFeatures = {
lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
playlistsSmart: !!navidromeFeatures[NavidromeExtensions.SMART_PLAYLISTS],
playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
};
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
};
const shareItem = async (args: ShareItemArgs): Promise<ShareItemResponse> => {
const { body, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).shareItem({
body: {
description: body.description,
downloadable: body.downloadable,
expires: body.expires,
resourceIds: body.resourceIds,
resourceType: body.resourceType,
},
});
if (res.status !== 200) {
throw new Error('Failed to share item');
}
return {
id: res.body.data.id,
};
};
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
const { apiClientProps, query } = args;
// Prefer getSimilarSongs (which queries last.fm) where available
// otherwise find other tracks by the same album artist
const res = await ssApiClient({
...apiClientProps,
silent: true,
}).getSimilarSongs({
query: {
count: query.count,
id: query.songId,
},
});
if (res.status === 200 && res.body.similarSongs?.song) {
const similar = res.body.similarSongs.song.reduce<Song[]>((acc, song) => {
if (song.id !== query.songId) {
acc.push(ssNormalize.song(song, apiClientProps.server, ''));
}
return acc;
}, []);
if (similar.length > 0) {
return similar;
}
}
const fallback = await ndApiClient(apiClientProps).getSongList({
query: {
_end: 50,
_order: 'ASC',
_sort: NDSongListSort.RANDOM,
_start: 0,
album_artist_id: query.albumArtistIds,
},
});
if (fallback.status !== 200) {
throw new Error('Failed to get similar songs');
}
return fallback.body.data.reduce<Song[]>((acc, song) => {
if (song.id !== query.songId) {
acc.push(ndNormalize.song(song, apiClientProps.server, ''));
}
return acc;
}, []);
};
export const ndController = {
addToPlaylist,
authenticate,
@@ -561,9 +618,11 @@ export const ndController = {
getPlaylistList,
getPlaylistSongList,
getServerInfo,
getSimilarSongs,
getSongDetail,
getSongList,
getUserList,
removeFromPlaylist,
shareItem,
updatePlaylist,
};
@@ -81,7 +81,7 @@ const normalizeSong = (
const imagePlaceholderUrl = null;
return {
album: item.album,
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
albumId: item.albumId,
artistName: item.artist,
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
+13 -3
View File
@@ -343,9 +343,17 @@ const removeFromPlaylistParameters = z.object({
id: z.array(z.string()),
});
export enum NavidromeExtensions {
SMART_PLAYLISTS = 'smartPlaylists',
}
const shareItem = z.object({
id: z.string(),
});
const shareItemParameters = z.object({
description: z.string(),
downloadable: z.boolean(),
expires: z.number(),
resourceIds: z.string(),
resourceType: z.string(),
});
export const ndType = {
_enum: {
@@ -365,6 +373,7 @@ export const ndType = {
genreList: genreListParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
shareItem: shareItemParameters,
songList: songListParameters,
updatePlaylist: updatePlaylistParameters,
userList: userListParameters,
@@ -386,6 +395,7 @@ export const ndType = {
playlistSong,
playlistSongList,
removeFromPlaylist,
shareItem,
song,
songList,
updatePlaylist,
+15 -2
View File
@@ -131,7 +131,6 @@ axiosClient.defaults.paramsSerializer = (params) => {
axiosClient.interceptors.response.use(
(response) => {
const data = response.data;
if (data['subsonic-response'].status !== 'ok') {
// Suppress code related to non-linked lastfm or spotify from Navidrome
if (data['subsonic-response'].error.code !== 0) {
@@ -161,12 +160,24 @@ const parsePath = (fullPath: string) => {
};
};
const silentlyTransformResponse = (data: any) => {
const jsonBody = JSON.parse(data);
const status = jsonBody ? jsonBody['subsonic-response']?.status : undefined;
if (status && status !== 'ok') {
jsonBody['subsonic-response'].error.code = 0;
}
return jsonBody;
};
export const ssApiClient = (args: {
server: ServerListItem | null;
signal?: AbortSignal;
silent?: boolean;
url?: string;
}) => {
const { server, url, signal } = args;
const { server, url, signal, silent } = args;
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
@@ -206,6 +217,8 @@ export const ssApiClient = (args: {
...params,
},
signal,
// In cases where we have a fallback, don't notify the error
transformResponse: silent ? silentlyTransformResponse : undefined,
url: `${baseUrl}/${api}`,
});
@@ -469,7 +469,7 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
throw new Error('Failed to get similar songs');
}
if (!res.body.similarSongs) {
if (!res.body.similarSongs?.song) {
return [];
}
@@ -75,7 +75,13 @@ const normalizeSong = (
discNumber: item.discNumber || 1,
discSubtitle: null,
duration: item.duration ? item.duration * 1000 : 0,
gain: null,
gain:
item.replayGain && (item.replayGain.albumGain || item.replayGain.trackGain)
? {
album: item.replayGain.albumGain,
track: item.replayGain.trackGain,
}
: null,
genres: item.genre
? [
{
@@ -94,7 +100,13 @@ const normalizeSong = (
lyrics: null,
name: item.title,
path: item.path,
peak: null,
peak:
item.replayGain && (item.replayGain.albumPeak || item.replayGain.trackPeak)
? {
album: item.replayGain.albumPeak,
track: item.replayGain.trackPeak,
}
: null,
playCount: item?.playCount || 0,
releaseDate: null,
releaseYear: item.year ? String(item.year) : null,
@@ -53,6 +53,13 @@ const musicFolderList = z.object({
}),
});
const songGain = z.object({
albumGain: z.number().optional(),
albumPeak: z.number().optional(),
trackGain: z.number().optional(),
trackPeak: z.number().optional(),
});
const song = z.object({
album: z.string().optional(),
albumId: z.string().optional(),
@@ -72,6 +79,7 @@ const song = z.object({
parent: z.string(),
path: z.string(),
playCount: z.number().optional(),
replayGain: songGain.optional(),
size: z.number(),
starred: z.boolean().optional(),
suffix: z.string(),
+15 -1
View File
@@ -541,7 +541,7 @@ export const songListSortMap: SongListSortMap = {
id: NDSongListSort.ID,
name: NDSongListSort.TITLE,
playCount: NDSongListSort.PLAY_COUNT,
random: undefined,
random: NDSongListSort.RANDOM,
rating: NDSongListSort.RATING,
recentlyAdded: NDSongListSort.RECENTLY_ADDED,
recentlyPlayed: NDSongListSort.PLAY_DATE,
@@ -766,6 +766,19 @@ export type RatingQuery = {
export type SetRatingArgs = { query: RatingQuery; serverId?: string } & BaseEndpointArgs;
// Sharing
export type ShareItemResponse = { id: string } | undefined;
export type ShareItemBody = {
description: string;
downloadable: boolean;
expires: number;
resourceIds: string;
resourceType: string;
};
export type ShareItemArgs = { body: ShareItemBody; serverId?: string } & BaseEndpointArgs;
// Add to playlist
export type AddToPlaylistResponse = null | undefined;
@@ -1170,6 +1183,7 @@ export type StructuredLyric = {
} & (StructuredUnsyncedLyric | StructuredSyncedLyric);
export type SimilarSongsQuery = {
albumArtistIds: string[];
count?: number;
songId: string;
};
+53
View File
@@ -1,4 +1,6 @@
import { AxiosHeaders } from 'axios';
import semverCoerce from 'semver/functions/coerce';
import semverGte from 'semver/functions/gte';
import { z } from 'zod';
import { toast } from '/@/renderer/components';
import { useAuthStore } from '/@/renderer/store';
@@ -47,3 +49,54 @@ export const hasFeature = (server: ServerListItem | null, feature: ServerFeature
return server.features[feature] ?? false;
};
export type VersionInfo = ReadonlyArray<[string, Record<string, readonly number[]>]>;
/**
* Returns the available server features given the version string.
* @param versionInfo a list, in DECREASING VERSION order, of the features supported by the server.
* The first version match will automatically consider the rest matched.
* @example
* ```
* // The CORRECT way to order
* const VERSION_INFO: VersionInfo = [
* ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
* ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
* ];
* // INCORRECT way to order
* const VERSION_INFO: VersionInfo = [
* ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }],
* ['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
* ];
* ```
* @param version the version string (SemVer)
* @returns a Record containing the matched features (if any) and their versions
*/
export const getFeatures = (
versionInfo: VersionInfo,
version: string,
): Record<string, number[]> => {
const cleanVersion = semverCoerce(version);
const features: Record<string, number[]> = {};
let matched = cleanVersion === null;
for (const [version, supportedFeatures] of versionInfo) {
if (!matched) {
matched = semverGte(cleanVersion!, version);
}
if (matched) {
for (const [feature, feat] of Object.entries(supportedFeatures)) {
if (feature in features) {
features[feature].push(...feat);
} else {
features[feature] = [...feat];
}
}
}
}
return features;
};
export const SEPARATOR_STRING = ' · ';
+6 -24
View File
@@ -3,10 +3,9 @@ import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-mod
import { ModuleRegistry } from '@ag-grid-community/core';
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
import { MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import isElectron from 'is-electron';
import { initSimpleImg } from 'react-simple-img';
import { BaseContextModal, toast } from './components';
import { toast } from './components';
import { useTheme } from './hooks';
import { IsUpdatedDialog } from './is-updated-dialog';
import { AppRouter } from './router/app-router';
@@ -20,7 +19,6 @@ import './styles/global.scss';
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 { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
@@ -246,27 +244,11 @@ export const App = () => {
},
}}
>
<ModalsProvider
modalProps={{
centered: true,
styles: {
body: { position: 'relative' },
content: { overflow: 'auto' },
},
transitionProps: {
duration: 300,
exitDuration: 300,
transition: 'fade',
},
}}
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
>
<PlayQueueHandlerContext.Provider value={providerValue}>
<ContextMenuProvider>
<AppRouter />
</ContextMenuProvider>
</PlayQueueHandlerContext.Provider>
</ModalsProvider>
<PlayQueueHandlerContext.Provider value={providerValue}>
<ContextMenuProvider>
<AppRouter />
</ContextMenuProvider>
</PlayQueueHandlerContext.Provider>
<IsUpdatedDialog />
</MantineProvider>
);
@@ -14,6 +14,7 @@ import { Badge } from '/@/renderer/components/badge';
import { AppRoute } from '/@/renderer/router/routes';
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
import { Play } from '/@/renderer/types';
import { usePlayButtonBehavior } from '/@/renderer/store';
const Carousel = styled(motion.div)`
position: relative;
@@ -114,6 +115,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
const handlePlayQueueAdd = usePlayQueueAdd();
const [itemIndex, setItemIndex] = useState(0);
const [direction, setDirection] = useState(0);
const playType = usePlayButtonBehavior();
const currentItem = data?.[itemIndex];
@@ -222,11 +224,18 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
id: [currentItem.id],
type: LibraryItem.ALBUM,
},
playType: Play.NOW,
playType,
});
}}
>
{t('player.play', { postProcess: 'titleCase' })}
{t(
playType === Play.NOW
? 'player.play'
: playType === Play.NEXT
? 'player.addNext'
: 'player.addLast',
{ postProcess: 'titleCase' },
)}
</Button>
<Group spacing="sm">
<Button
@@ -54,8 +54,10 @@ interface QueryBuilderProps {
boolean: { label: string; value: string }[];
date: { label: string; value: string }[];
number: { label: string; value: string }[];
playlist: { label: string; value: string }[];
string: { label: string; value: string }[];
};
playlists?: { label: string; value: string }[];
uniqueId: string;
}
@@ -73,6 +75,7 @@ export const QueryBuilder = ({
onChangeValue,
onClearFilters,
onResetFilters,
playlists,
groupIndex,
uniqueId,
filters,
@@ -180,6 +183,7 @@ export const QueryBuilder = ({
level={level}
noRemove={data?.rules?.length === 1}
operators={operators}
selectData={playlists}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeValue={onChangeValue}
@@ -204,6 +208,7 @@ export const QueryBuilder = ({
groupIndex={[...(groupIndex || []), index]}
level={level + 1}
operators={operators}
playlists={playlists}
uniqueId={group.uniqueId}
onAddRule={onAddRule}
onAddRuleGroup={onAddRuleGroup}
@@ -28,9 +28,10 @@ interface QueryOptionProps {
number: { label: string; value: string }[];
string: { label: string; value: string }[];
};
selectData?: { label: string; value: string }[];
}
const QueryValueInput = ({ onChange, type, ...props }: any) => {
const QueryValueInput = ({ onChange, type, data, ...props }: any) => {
const [numberRange, setNumberRange] = useState([0, 0]);
switch (type) {
@@ -59,7 +60,6 @@ const QueryValueInput = ({ onChange, type, ...props }: any) => {
{...props}
/>
);
case 'dateRange':
return (
<>
@@ -87,7 +87,6 @@ const QueryValueInput = ({ onChange, type, ...props }: any) => {
/>
</>
);
case 'boolean':
return (
<Select
@@ -99,6 +98,14 @@ const QueryValueInput = ({ onChange, type, ...props }: any) => {
{...props}
/>
);
case 'playlist':
return (
<Select
data={data}
onChange={onChange}
{...props}
/>
);
default:
return <></>;
@@ -116,6 +123,7 @@ export const QueryBuilderOption = ({
onChangeField,
onChangeOperator,
onChangeValue,
selectData,
}: QueryOptionProps) => {
const { field, operator, uniqueId, value } = data;
@@ -133,10 +141,7 @@ export const QueryBuilderOption = ({
const handleChangeValue = (e: any) => {
const isDirectValue =
typeof e === 'string' ||
typeof e === 'number' ||
typeof e === 'undefined' ||
typeof e === null;
typeof e === 'string' || typeof e === 'number' || typeof e === 'undefined';
if (isDirectValue) {
return onChangeValue({
@@ -207,6 +212,7 @@ export const QueryBuilderOption = ({
/>
{field ? (
<QueryValueInput
data={selectData || []}
defaultValue={value}
maxWidth={170}
size="sm"
@@ -0,0 +1,15 @@
import { SEPARATOR_STRING } from '/@/renderer/api/utils';
import { Text } from '/@/renderer/components/text';
export const Separator = () => {
return (
<Text
$noSelect
$secondary
size="md"
style={{ display: 'inline-block', padding: '0px 3px' }}
>
{SEPARATOR_STRING}
</Text>
);
};
@@ -19,6 +19,7 @@ export type VirtualInfiniteGridRef = {
resetLoadMoreItemsCache: () => void;
scrollTo: (index: number) => void;
setItemData: (data: any[]) => void;
updateItemData: (rule: (item: any) => any) => void;
};
interface VirtualGridProps
@@ -107,17 +108,19 @@ export const VirtualInfiniteGrid = forwardRef(
take: end - start,
});
const newData: any[] = [...itemData];
setItemData((itemData) => {
const newData: any[] = [...itemData];
let itemIndex = 0;
for (let rowIndex = start; rowIndex < end; rowIndex += 1) {
newData[rowIndex] = data.items[itemIndex];
itemIndex += 1;
}
let itemIndex = 0;
for (let rowIndex = start; rowIndex < itemCount; rowIndex += 1) {
newData[rowIndex] = data.items[itemIndex];
itemIndex += 1;
}
setItemData(newData);
return newData;
});
},
[columnCount, fetchFn, itemData, setItemData],
[columnCount, fetchFn, itemCount],
);
const debouncedLoadMoreItems = debounce(loadMoreItems, 500);
@@ -135,6 +138,9 @@ export const VirtualInfiniteGrid = forwardRef(
setItemData: (data: any[]) => {
setItemData(data);
},
updateItemData: (rule) => {
setItemData((data) => data.map(rule));
},
}));
if (loading) return null;
@@ -7,6 +7,7 @@ import { Text } from '/@/renderer/components/text';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { AppRoute } from '/@/renderer/router/routes';
import { Skeleton } from '/@/renderer/components/skeleton';
import { Separator } from '/@/renderer/components/separator';
export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
if (value === undefined) {
@@ -29,15 +30,7 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
>
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$secondary
size="md"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
{index > 0 && <Separator />}
{item.id ? (
<Text
$link
@@ -7,6 +7,7 @@ import { Text } from '/@/renderer/components/text';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { AppRoute } from '/@/renderer/router/routes';
import { Skeleton } from '/@/renderer/components/skeleton';
import { Separator } from '/@/renderer/components/separator';
export const ArtistCell = ({ value, data }: ICellRendererParams) => {
if (value === undefined) {
@@ -29,15 +30,7 @@ export const ArtistCell = ({ value, data }: ICellRendererParams) => {
>
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$secondary
size="md"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
{index > 0 && <Separator />}
{item.id ? (
<Text
$link
@@ -10,8 +10,8 @@ import styled from 'styled-components';
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components/text';
import { AppRoute } from '/@/renderer/router/routes';
import { ServerType } from '/@/renderer/api/types';
import { Skeleton } from '/@/renderer/components/skeleton';
import { SEPARATOR_STRING } from '/@/renderer/api/utils';
const CellContainer = styled(motion.div)<{ height: number }>`
display: grid;
@@ -51,7 +51,7 @@ const StyledImage = styled(SimpleImg)`
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
const artists = useMemo(() => {
if (!value) return null;
return value?.type === ServerType.JELLYFIN ? value.artists : value.albumArtists;
return value.artists?.length ? value.artists : value.albumArtists;
}, [value]);
if (value === undefined) {
@@ -119,7 +119,7 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
{artists?.length ? (
artists.map((artist: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`queue-${rowIndex}-artist-${artist.id}`}>
{index > 0 ? ', ' : null}
{index > 0 ? SEPARATOR_STRING : null}
{artist.id ? (
<Text
$link
@@ -4,9 +4,11 @@ import { generatePath, Link } from 'react-router-dom';
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components/text';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { AppRoute } from '/@/renderer/router/routes';
import { Separator } from '/@/renderer/components/separator';
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
export const GenreCell = ({ value, data }: ICellRendererParams) => {
const genrePath = useGenreRoute();
return (
<CellContainer $position="left">
<Text
@@ -16,24 +18,14 @@ export const GenreCell = ({ value, data }: ICellRendererParams) => {
>
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$secondary
size="md"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
{index > 0 && <Separator />}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="md"
to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, {
genreId: item.id,
})}
to={generatePath(genrePath, { genreId: item.id })}
>
{item.name || '—'}
</Text>
@@ -43,6 +43,7 @@ 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';
export * from './table-config-dropdown';
export * from './table-pagination';
@@ -320,6 +321,16 @@ const tableColumns: { [key: string]: ColDef } = {
},
width: 65,
},
size: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.SIZE,
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center' }),
headerName: i18n.t('table.column.size'),
valueGetter: (params: ValueGetterParams) =>
params.data ? formatSizeString(params.data.size) : undefined,
width: 80,
},
songCount: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.SONG_COUNT,
@@ -1,6 +1,6 @@
import { Center, Group, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { RiCheckFill } from 'react-icons/ri';
import { RiCheckFill, RiEdit2Line, RiHome4Line } from 'react-icons/ri';
import { Link } from 'react-router-dom';
import { Button, PageHeader, Text } from '/@/renderer/components';
import { ActionRequiredContainer } from '/@/renderer/features/action-required/components/action-required-container';
@@ -9,6 +9,8 @@ import { ServerRequired } from '/@/renderer/features/action-required/components/
import { AnimatedPage } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { openModal } from '@mantine/modals';
import { ServerList } from '/@/renderer/features/servers';
const ActionRequiredRoute = () => {
const { t } = useTranslation();
@@ -32,6 +34,13 @@ const ActionRequiredRoute = () => {
const canReturnHome = checks.every((c) => c.valid);
const displayedCheck = checks.find((c) => !c.valid);
const handleManageServersModal = () => {
openModal({
children: <ServerList />,
title: 'Manage Servers',
});
};
return (
<AnimatedPage>
<PageHeader />
@@ -63,6 +72,7 @@ const ActionRequiredRoute = () => {
<Button
component={Link}
disabled={!canReturnHome}
leftIcon={<RiHome4Line />}
to={AppRoute.HOME}
variant="filled"
>
@@ -70,6 +80,23 @@ const ActionRequiredRoute = () => {
</Button>
</>
)}
{!displayedCheck && (
<Group
noWrap
position="center"
>
<Button
fullWidth
leftIcon={<RiEdit2Line />}
variant="filled"
onClick={handleManageServersModal}
>
{t('page.appMenu.manageServers', {
postProcess: 'sentenceCase',
})}
</Button>
</Group>
)}
</Stack>
</Stack>
</Center>
@@ -45,6 +45,7 @@ import {
} from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
const isFullWidthRow = (node: RowNode) => {
return node.id?.startsWith('disc-');
@@ -81,6 +82,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
const isFocused = useAppFocus();
const currentSong = useCurrentSong();
const { externalLinks } = useGeneralSettings();
const genreRoute = useGenreRoute();
const columnDefs = useMemo(
() => getColumnDefs(tableConfig.columns, false, 'albumDetail'),
@@ -389,7 +391,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
component={Link}
radius={0}
size="md"
to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, {
to={generatePath(genreRoute, {
genreId: genre.id,
})}
variant="outline"
@@ -19,10 +19,10 @@ import {
} from '/@/renderer/components/virtual-grid';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
import { CardRow, ListDisplayType } from '/@/renderer/types';
import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite';
export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
const queryClient = useQueryClient();
@@ -36,33 +36,7 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
const scrollOffset = searchParams.get('scrollOffset');
const initialScrollOffset = Number(id ? scrollOffset : grid?.scrollOffset) || 0;
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = (options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
}) => {
const { id, itemType, isFavorite } = options;
if (isFavorite) {
deleteFavoriteMutation.mutate({
query: {
id,
type: itemType,
},
serverId: server?.id,
});
} else {
createFavoriteMutation.mutate({
query: {
id,
type: itemType,
},
serverId: server?.id,
});
}
};
const handleFavorite = useHandleFavorite({ gridRef, server });
const cardRows = useMemo(() => {
const rows: CardRow<Album>[] = [ALBUM_CARD_ROWS.name];
@@ -545,7 +545,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
<Slider
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
max={isGrid ? 300 : 100}
min={isGrid ? 150 : 25}
min={isGrid ? 100 : 25}
onChangeEnd={handleItemSize}
/>
</DropdownMenu.Item>
@@ -1,63 +1,59 @@
import type { ChangeEvent, MutableRefObject } from 'react';
import { useEffect, useRef, type ChangeEvent, type MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh';
import { LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { useListContext } from '/@/renderer/context/list-context';
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import {
AlbumListFilter,
useCurrentServer,
useListStoreActions,
useListStoreByKey,
usePlayButtonBehavior,
} from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { AlbumListFilter, useCurrentServer, usePlayButtonBehavior } from '/@/renderer/store';
import { titleCase } from '/@/renderer/utils';
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
interface AlbumListHeaderProps {
genreId?: string;
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
title?: string;
}
export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumListHeaderProps) => {
export const AlbumListHeader = ({
genreId,
itemCount,
gridRef,
tableRef,
title,
}: AlbumListHeaderProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const { setFilter, setTablePagination } = useListStoreActions();
const cq = useContainerQuery();
const { pageKey, handlePlay } = useListContext();
const { display, filter } = useListStoreByKey({ key: pageKey });
const playButtonBehavior = usePlayButtonBehavior();
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
const genreRef = useRef<string>();
const { filter, handlePlay, refresh, search } = useDisplayRefresh({
gridRef,
itemType: LibraryItem.ALBUM,
server,
tableRef,
});
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({
data: { searchTerm },
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
const updatedFilters = search(e) as AlbumListFilter;
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
handleRefreshTable(tableRef, updatedFilters);
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
} else {
handleRefreshGrid(gridRef, updatedFilters);
}
refresh(updatedFilters);
}, 500);
useEffect(() => {
if (genreRef.current && genreRef.current !== genreId) {
refresh(filter);
}
genreRef.current = genreId;
}, [filter, genreId, refresh, tableRef]);
return (
<Stack
ref={cq.ref}
@@ -1,10 +1,11 @@
import { useCallback, useMemo, useRef } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import isEmpty from 'lodash/isEmpty';
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 { LibraryItem } from '/@/renderer/api/types';
import { 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';
@@ -15,19 +16,32 @@ import { AnimatedPage } from '/@/renderer/features/shared';
import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
import { Play } from '/@/renderer/types';
import { useGenreList } from '/@/renderer/features/genres';
import { titleCase } from '/@/renderer/utils';
const AlbumListRoute = () => {
const { t } = useTranslation();
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
const tableRef = useRef<AgGridReactType | null>(null);
const server = useCurrentServer();
const [searchParams] = useSearchParams();
const { albumArtistId } = useParams();
const { albumArtistId, genreId } = useParams();
const pageKey = albumArtistId ? `albumArtistAlbum` : 'album';
const handlePlayQueueAdd = usePlayQueueAdd();
const customFilters = useMemo(() => {
const value = {
...(albumArtistId && { artistIds: [albumArtistId] }),
...(genreId && {
_custom: {
jellyfin: {
GenreIds: genreId,
},
navidrome: {
genre_id: genreId,
},
},
}),
};
if (isEmpty(value)) {
@@ -35,13 +49,35 @@ const AlbumListRoute = () => {
}
return value;
}, [albumArtistId]);
}, [albumArtistId, genreId]);
const albumListFilter = useListFilterByKey({
filter: customFilters,
key: pageKey,
});
const genreList = useGenreList({
options: {
cacheTime: 1000 * 60 * 60,
enabled: !!genreId,
},
query: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server?.id,
});
const genreTitle = useMemo(() => {
if (!genreList.data) return '';
const genre = genreList.data.items.find((g) => g.id === genreId);
if (!genre) return 'Unknown';
return genre?.name;
}, [genreId, genreList.data]);
const itemCountCheck = useAlbumList({
options: {
cacheTime: 1000 * 60,
@@ -98,19 +134,27 @@ const AlbumListRoute = () => {
return {
customFilters,
handlePlay,
id: albumArtistId ?? undefined,
id: albumArtistId ?? genreId,
pageKey,
};
}, [albumArtistId, customFilters, handlePlay, pageKey]);
}, [albumArtistId, customFilters, genreId, handlePlay, pageKey]);
const artist = searchParams.get('artistName');
const title = artist
? t('page.albumList.artistAlbums', { artist })
: genreId
? t('page.albumList.genreAlbums', { genre: titleCase(genreTitle) })
: undefined;
return (
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<AlbumListHeader
genreId={genreId}
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
title={searchParams.get('artistName') || undefined}
title={title}
/>
<AlbumListContent
gridRef={gridRef}
@@ -0,0 +1,260 @@
import { Button, Spinner, Spoiler, Text } from '/@/renderer/components';
import {
AnimatedPage,
LibraryHeader,
PlayButton,
useCreateFavorite,
useDeleteFavorite,
} from '/@/renderer/features/shared';
import { Fragment } from 'react';
import { generatePath, useParams } from 'react-router';
import { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem, SongDetailResponse } from '/@/renderer/api/types';
import { useCurrentServer } from '/@/renderer/store';
import { Stack, Group, Box, Center } from '@mantine/core';
import { Link } from 'react-router-dom';
import { AppRoute } from '/@/renderer/router/routes';
import { formatDurationString } from '/@/renderer/utils';
import { RiErrorWarningLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
import { SONG_ALBUM_PAGE } from '/@/renderer/features/context-menu/context-menu-items';
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu';
import { styled } from 'styled-components';
import { queryClient } from '/@/renderer/lib/react-query';
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useTranslation } from 'react-i18next';
const DetailContainer = styled.div`
display: flex;
flex-direction: column;
gap: 2rem;
padding: 1rem 2rem 5rem;
overflow: hidden;
`;
const DummyAlbumDetailRoute = () => {
const cq = useContainerQuery();
const { t } = useTranslation();
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const queryKey = queryKeys.songs.detail(server?.id || '', albumId);
const detailQuery = useQuery({
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getSongDetail({
apiClientProps: { server, signal },
query: { id: albumId },
});
},
queryKey,
});
const { color: background, colorId } = useFastAverageColor({
id: albumId,
src: detailQuery.data?.imageUrl,
srcLoaded: !detailQuery.isLoading,
});
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = async () => {
if (!detailQuery?.data) return;
const wasFavorite = detailQuery.data.userFavorite;
try {
if (wasFavorite) {
await deleteFavoriteMutation.mutateAsync({
query: {
id: [detailQuery.data.id],
type: LibraryItem.SONG,
},
serverId: detailQuery.data.serverId,
});
} else {
await createFavoriteMutation.mutateAsync({
query: {
id: [detailQuery.data.id],
type: LibraryItem.SONG,
},
serverId: detailQuery.data.serverId,
});
}
queryClient.setQueryData<SongDetailResponse>(queryKey, {
...detailQuery.data,
userFavorite: !wasFavorite,
});
} catch (error) {
console.error(error);
}
};
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const comment = detailQuery?.data?.comment;
const handleGeneralContextMenu = useHandleGeneralContextMenu(LibraryItem.SONG, SONG_ALBUM_PAGE);
const handlePlay = () => {
handlePlayQueueAdd?.({
byItemType: {
id: [albumId],
type: LibraryItem.SONG,
},
playType: playButtonBehavior,
});
};
if (!background || colorId !== albumId) {
return <Spinner container />;
}
const metadataItems = [
{
id: 'releaseYear',
secondary: false,
value: detailQuery?.data?.releaseYear,
},
{
id: 'duration',
secondary: false,
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
},
];
return (
<AnimatedPage key={`dummy-album-detail-${albumId}`}>
<Stack ref={cq.ref}>
<LibraryHeader
background={background}
imageUrl={detailQuery?.data?.imageUrl}
item={{ route: AppRoute.LIBRARY_SONGS, type: LibraryItem.SONG }}
title={detailQuery?.data?.name || ''}
>
<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>
</Fragment>
))}
</Group>
<Group
mah="4rem"
spacing="md"
sx={{
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
}}
>
{detailQuery?.data?.albumArtists.map((artist) => (
<Text
key={`artist-${artist.id}`}
$link
component={Link}
fw={600}
size="md"
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
variant="subtle"
>
{artist.name}
</Text>
))}
</Group>
</Stack>
</LibraryHeader>
</Stack>
<DetailContainer>
<Box component="section">
<Group
position="apart"
spacing="sm"
>
<Group>
<PlayButton onClick={() => handlePlay()} />
<Button
compact
loading={
createFavoriteMutation.isLoading ||
deleteFavoriteMutation.isLoading
}
variant="subtle"
onClick={handleFavorite}
>
{detailQuery?.data?.userFavorite ? (
<RiHeartFill
color="red"
size={20}
/>
) : (
<RiHeartLine size={20} />
)}
</Button>
<Button
compact
variant="subtle"
onClick={(e) => {
if (!detailQuery?.data) return;
handleGeneralContextMenu(e, [detailQuery.data!]);
}}
>
<RiMoreFill size={20} />
</Button>
</Group>
</Group>
</Box>
{showGenres && (
<Box component="section">
<Group spacing="sm">
{detailQuery?.data?.genres?.map((genre) => (
<Button
key={`genre-${genre.id}`}
compact
component={Link}
radius={0}
size="md"
to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, {
genreId: genre.id,
})}
variant="outline"
>
{genre.name}
</Button>
))}
</Group>
</Box>
)}
{comment && (
<Box component="section">
<Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>
</Box>
)}
<Box component="section">
<Center>
<Group mr={5}>
<RiErrorWarningLine
color="var(--danger-color)"
size={30}
/>
</Group>
<h2>{t('error.badAlbum', { postProcess: 'sentenceCase' })}</h2>
</Center>
</Box>
</DetailContainer>
</AnimatedPage>
);
};
export default DummyAlbumDetailRoute;
@@ -39,6 +39,8 @@ import { AppRoute } from '/@/renderer/router/routes';
import { 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';
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
const ContentContainer = styled.div`
position: relative;
@@ -68,6 +70,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
const server = useCurrentServer();
const genrePath = useGenreRoute();
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
@@ -330,8 +333,13 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
const showBiography =
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
const biography = useMemo(() => {
const bio = detailQuery?.data?.biography;
if (!bio) return null;
return sanitize(bio);
}, [detailQuery?.data?.biography]);
const showTopSongs = topSongsQuery?.data?.items?.length;
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const mbzId = detailQuery?.data?.mbz;
@@ -408,7 +416,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
component={Link}
radius="md"
size="md"
to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, {
to={generatePath(genrePath, {
genreId: genre.id,
})}
variant="outline"
@@ -459,7 +467,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
</Group>
</Box>
) : null}
{showBiography ? (
{biography ? (
<Box
component="section"
maw="1280px"
@@ -472,9 +480,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
artist: detailQuery?.data?.name,
})}
</TextTitle>
<Spoiler
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
/>
<Spoiler dangerouslySetInnerHTML={{ __html: biography }} />
</Box>
) : null}
{showTopSongs ? (
@@ -11,6 +11,7 @@ import {
AlbumArtistListQuery,
AlbumArtistListResponse,
AlbumArtistListSort,
ArtistListQuery,
LibraryItem,
} from '/@/renderer/api/types';
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
@@ -20,6 +21,7 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useListStoreActions } from '/@/renderer/store';
import { CardRow, ListDisplayType } from '/@/renderer/types';
import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite';
interface AlbumArtistListGridViewProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
@@ -34,6 +36,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
const { pageKey } = useListContext();
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
const { setGrid } = useListStoreActions();
const handleFavorite = useHandleFavorite({ gridRef, server });
const fetchInitialData = useCallback(() => {
const query: Omit<AlbumArtistListQuery, 'startIndex' | 'limit'> = {
@@ -70,16 +73,13 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
const fetch = useCallback(
async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => {
const queryKey = queryKeys.albumArtists.list(
server?.id || '',
{
...filter,
},
{
limit,
startIndex,
},
);
const query: ArtistListQuery = {
...filter,
limit,
startIndex,
};
const queryKey = queryKeys.albumArtists.list(server?.id || '', query);
const albumArtistsRes = await queryClient.fetchQuery(
queryKey,
@@ -154,6 +154,7 @@ export const AlbumArtistListGridView = ({ itemCount, gridRef }: AlbumArtistListG
display={display || ListDisplayType.CARD}
fetchFn={fetch}
fetchInitialData={fetchInitialData}
handleFavorite={handleFavorite}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}
initialScrollOffset={grid?.scrollOffset || 0}
@@ -3,8 +3,6 @@ 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 { useListContext } from '../../../context/list-context';
import { useListStoreByKey } from '../../../store/list.store';
import { FilterBar } from '../../shared/components/filter-bar';
import { LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
@@ -12,9 +10,8 @@ import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
import { LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
import { AlbumArtistListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { AlbumArtistListFilter, useCurrentServer } from '/@/renderer/store';
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
interface AlbumArtistListHeaderProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
@@ -29,30 +26,18 @@ export const AlbumArtistListHeader = ({
}: AlbumArtistListHeaderProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const { pageKey } = useListContext();
const { display, filter } = useListStoreByKey({ key: pageKey });
const { setFilter, setTablePagination } = useListStoreActions();
const cq = useContainerQuery();
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
const { filter, refresh, search } = useDisplayRefresh({
gridRef,
itemType: LibraryItem.ALBUM_ARTIST,
server,
tableRef,
});
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({
data: { searchTerm },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
handleRefreshTable(tableRef, updatedFilters);
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
} else {
handleRefreshGrid(gridRef, updatedFilters);
}
const updatedFilters = search(e) as AlbumArtistListFilter;
refresh(updatedFilters);
}, 500);
return (
@@ -9,6 +9,7 @@ export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ disabled: false, id: 'deselectAll' },
{ divider: true, id: 'showDetails' },
];
export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@@ -18,7 +19,16 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ children: true, disabled: false, divider: true, id: 'setRating' },
{ divider: true, id: 'shareItem' },
{ divider: true, id: 'showDetails' },
];
export const SONG_ALBUM_PAGE: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ divider: true, id: 'addToPlaylist' },
];
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@@ -30,6 +40,7 @@ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ divider: true, id: 'showDetails' },
];
export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@@ -40,6 +51,7 @@ export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ divider: true, id: 'showDetails' },
];
export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@@ -49,7 +61,9 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ children: true, disabled: false, divider: true, id: 'setRating' },
{ divider: true, id: 'shareItem' },
{ divider: true, id: 'showDetails' },
];
export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@@ -67,6 +81,7 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ children: true, disabled: false, id: 'setRating' },
{ divider: true, id: 'showDetails' },
];
export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@@ -11,6 +11,8 @@ import {
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
import { AnimatePresence } from 'framer-motion';
import isElectron from 'is-electron';
import { ServerFeature } from '/@/renderer/api/features-types';
import { hasFeature } from '/@/renderer/api/utils';
import { useTranslation } from 'react-i18next';
import {
RiAddBoxFill,
@@ -25,6 +27,8 @@ import {
RiPlayListAddFill,
RiStarFill,
RiCloseCircleLine,
RiShareForwardFill,
RiInformationFill,
} from 'react-icons/ri';
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
import {
@@ -53,6 +57,7 @@ import {
} from '/@/renderer/store';
import { usePlaybackType } from '/@/renderer/store/settings.store';
import { Play, PlaybackType } from '/@/renderer/types';
import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal';
type ContextMenuContextProps = {
closeContextMenu: () => void;
@@ -76,7 +81,7 @@ const ContextMenuContext = createContext<ContextMenuContextProps>({
},
});
const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating'];
const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating', 'shareItem'];
// const NAVIDROME_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
@@ -600,6 +605,22 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
}
}, [ctx.dataNodes, moveToTopOfQueue, playbackType]);
const handleShareItem = useCallback(() => {
if (!ctx.dataNodes && !ctx.data) return;
const uniqueIds = ctx.data.map((node) => node.id);
openContextModal({
innerProps: {
itemIds: uniqueIds,
resourceType: ctx.data[0].itemType,
},
modal: 'shareItem',
size: 'md',
title: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }),
});
}, [ctx.data, ctx.dataNodes, t]);
const handleRemoveSelected = useCallback(() => {
const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId);
if (!uniqueIds?.length) return;
@@ -627,6 +648,16 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
ctx.tableApi?.deselectAll();
}, [ctx.tableApi]);
const handleOpenItemDetails = useCallback(() => {
const item = ctx.data[0];
openModal({
children: <ItemDetailsModal item={item} />,
size: 'xl',
title: t('page.contextMenu.showDetails', { postProcess: 'titleCase' }),
});
}, [ctx.data, t]);
const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
return {
addToFavorites: {
@@ -775,20 +806,38 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
onClick: () => {},
rightIcon: <RiArrowRightSFill size="1.2rem" />,
},
shareItem: {
disabled: !hasFeature(server, ServerFeature.SHARING_ALBUM_SONG),
id: 'shareItem',
label: t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' }),
leftIcon: <RiShareForwardFill size="1.1rem" />,
onClick: handleShareItem,
},
showDetails: {
disabled: ctx.data?.length !== 1 || !ctx.data[0].itemType,
id: 'showDetails',
label: t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }),
leftIcon: <RiInformationFill />,
onClick: handleOpenItemDetails,
},
};
}, [
t,
handleAddToFavorites,
handleAddToPlaylist,
openDeletePlaylistModal,
handleDeselectAll,
handleMoveToBottom,
handleMoveToTop,
handlePlay,
handleRemoveFromFavorites,
handleRemoveFromPlaylist,
handleRemoveSelected,
ctx.data,
handleOpenItemDetails,
handlePlay,
handleUpdateRating,
openDeletePlaylistModal,
t,
handleShareItem,
server,
]);
const mergedRef = useMergedRef(ref, clickOutsideRef);
@@ -819,72 +868,80 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
>
{ctx.menuItems?.map((item) => {
return (
<Fragment key={`context-menu-${item.id}`}>
{item.children ? (
<HoverCard
offset={5}
position="right"
>
<HoverCard.Target>
<ContextMenuButton
disabled={item.disabled}
leftIcon={
contextMenuItems[item.id]
.leftIcon
}
rightIcon={
contextMenuItems[item.id]
.rightIcon
}
onClick={
contextMenuItems[item.id]
.onClick
}
>
{contextMenuItems[item.id].label}
</ContextMenuButton>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack spacing={0}>
{contextMenuItems[
item.id
].children?.map((child) => (
<ContextMenuButton
key={`sub-${child.id}`}
disabled={child.disabled}
leftIcon={child.leftIcon}
rightIcon={child.rightIcon}
onClick={child.onClick}
>
{child.label}
</ContextMenuButton>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
) : (
<ContextMenuButton
disabled={item.disabled}
leftIcon={
contextMenuItems[item.id].leftIcon
}
rightIcon={
contextMenuItems[item.id].rightIcon
}
onClick={contextMenuItems[item.id].onClick}
>
{contextMenuItems[item.id].label}
</ContextMenuButton>
)}
!contextMenuItems[item.id].disabled && (
<Fragment key={`context-menu-${item.id}`}>
{item.children ? (
<HoverCard
offset={5}
position="right"
>
<HoverCard.Target>
<ContextMenuButton
leftIcon={
contextMenuItems[item.id]
.leftIcon
}
rightIcon={
contextMenuItems[item.id]
.rightIcon
}
onClick={
contextMenuItems[item.id]
.onClick
}
>
{
contextMenuItems[item.id]
.label
}
</ContextMenuButton>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack spacing={0}>
{contextMenuItems[
item.id
].children?.map((child) => (
<ContextMenuButton
key={`sub-${child.id}`}
leftIcon={
child.leftIcon
}
rightIcon={
child.rightIcon
}
onClick={child.onClick}
>
{child.label}
</ContextMenuButton>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
) : (
<ContextMenuButton
leftIcon={
contextMenuItems[item.id].leftIcon
}
rightIcon={
contextMenuItems[item.id].rightIcon
}
onClick={
contextMenuItems[item.id].onClick
}
>
{contextMenuItems[item.id].label}
</ContextMenuButton>
)}
{item.divider && (
<Divider
key={`context-menu-divider-${item.id}`}
color="rgb(62, 62, 62)"
size="sm"
/>
)}
</Fragment>
{item.divider && (
<Divider
key={`context-menu-divider-${item.id}`}
color="rgb(62, 62, 62)"
size="sm"
/>
)}
</Fragment>
)
);
})}
</Stack>
+3 -1
View File
@@ -28,12 +28,14 @@ export type ContextMenuItemType =
| 'addToFavorites'
| 'removeFromFavorites'
| 'setRating'
| 'shareItem'
| 'deletePlaylist'
| 'createPlaylist'
| 'moveToBottomOfQueue'
| 'moveToTopOfQueue'
| 'removeFromQueue'
| 'deselectAll';
| 'deselectAll'
| 'showDetails';
export type SetContextMenuItems = {
children?: boolean;
@@ -13,9 +13,9 @@ import {
} from '/@/renderer/components/virtual-grid';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
import { CardRow, ListDisplayType } from '/@/renderer/types';
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
export const GenreListGridView = ({ gridRef, itemCount }: any) => {
const queryClient = useQueryClient();
@@ -24,6 +24,7 @@ export const GenreListGridView = ({ gridRef, itemCount }: any) => {
const { pageKey, id } = useListContext();
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
const { setGrid } = useListStoreActions();
const genrePath = useGenreRoute();
const [searchParams, setSearchParams] = useSearchParams();
const scrollOffset = searchParams.get('scrollOffset');
@@ -137,7 +138,7 @@ export const GenreListGridView = ({ gridRef, itemCount }: any) => {
loading={itemCount === undefined || itemCount === null}
minimumBatchSize={40}
route={{
route: AppRoute.LIBRARY_GENRES_SONGS,
route: genrePath,
slugs: [{ idProperty: 'id', slugProperty: 'genreId' }],
}}
width={width}
@@ -3,7 +3,14 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { RiFolder2Fill, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import {
RiAlbumLine,
RiFolder2Fill,
RiMoreFill,
RiMusic2Line,
RiRefreshLine,
RiSettings3Fill,
} from 'react-icons/ri';
import { queryKeys } from '/@/renderer/api/query-keys';
import { GenreListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
@@ -15,9 +22,12 @@ import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
import {
GenreListFilter,
GenreTarget,
useCurrentServer,
useGeneralSettings,
useListStoreActions,
useListStoreByKey,
useSettingsStoreActions,
} from '/@/renderer/store';
import { ListDisplayType, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
@@ -52,6 +62,8 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions();
const { display, filter, table, grid } = useListStoreByKey({ key: pageKey });
const cq = useContainerQuery();
const { genreTarget } = useGeneralSettings();
const { setGenreBehavior } = useSettingsStoreActions();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemType: LibraryItem.GENRE,
@@ -208,6 +220,11 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
return filter.musicFolderId !== undefined;
}, [filter.musicFolderId]);
const handleGenreToggle = useCallback(() => {
const newState = genreTarget === GenreTarget.ALBUM ? GenreTarget.TRACK : GenreTarget.ALBUM;
setGenreBehavior(newState);
}, [genreTarget, setGenreBehavior]);
return (
<Flex justify="space-between">
<Group
@@ -309,6 +326,23 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
{t('common.refresh', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
<Divider orientation="vertical" />
<Button
compact
size="md"
tooltip={{
label: t(
genreTarget === GenreTarget.ALBUM
? 'page.genreList.showAlbums'
: 'page.genreList.showTracks',
{ postProcess: 'sentenceCase' },
),
}}
variant="subtle"
onClick={handleGenreToggle}
>
{genreTarget === GenreTarget.ALBUM ? <RiAlbumLine /> : <RiMusic2Line />}
</Button>
</DropdownMenu>
</Group>
<Group
@@ -364,7 +398,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil
<Slider
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
max={isGrid ? 300 : 100}
min={isGrid ? 150 : 25}
min={isGrid ? 100 : 25}
onChangeEnd={handleItemSize}
/>
</DropdownMenu.Item>
@@ -5,19 +5,12 @@ import debounce from 'lodash/debounce';
import { LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { useListContext } from '/@/renderer/context/list-context';
import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
import {
GenreListFilter,
useCurrentServer,
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { GenreListFilter, useCurrentServer } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
interface GenreListHeaderProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
@@ -29,34 +22,18 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade
const { t } = useTranslation();
const cq = useContainerQuery();
const server = useCurrentServer();
const { pageKey } = useListContext();
const { display, filter } = useListStoreByKey({ key: pageKey });
const { setFilter, setTablePagination } = useListStoreActions();
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
const { filter, refresh, search } = useDisplayRefresh({
gridRef,
itemType: LibraryItem.GENRE,
server,
tableRef,
});
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({
data: { searchTerm },
itemType: LibraryItem.GENRE,
key: pageKey,
}) as GenreListFilter;
const filterWithCustom = {
...updatedFilters,
};
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
handleRefreshTable(tableRef, filterWithCustom);
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
} else {
handleRefreshGrid(gridRef, filterWithCustom);
}
const updatedFilters = search(e) as GenreListFilter;
refresh(updatedFilters);
}, 500);
return (
<Stack
ref={cq.ref}
@@ -9,7 +9,7 @@ import { useCurrentServer } from '/@/renderer/store';
import { MutableRefObject, useCallback } from 'react';
import { RowDoubleClickedEvent } from '@ag-grid-community/core';
import { generatePath, useNavigate } from 'react-router';
import { AppRoute } from '/@/renderer/router/routes';
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
interface GenreListTableViewProps {
itemCount?: number;
@@ -20,6 +20,7 @@ export const GenreListTableView = ({ tableRef, itemCount }: GenreListTableViewPr
const server = useCurrentServer();
const { pageKey, customFilters } = useListContext();
const navigate = useNavigate();
const genrePath = useGenreRoute();
const tableProps = useVirtualTable({
contextMenu: GENRE_CONTEXT_MENU_ITEMS,
@@ -36,9 +37,9 @@ export const GenreListTableView = ({ tableRef, itemCount }: GenreListTableViewPr
const { data } = e;
if (!data) return;
navigate(generatePath(AppRoute.LIBRARY_GENRES_SONGS, { genreId: data.id }));
navigate(generatePath(genrePath, { genreId: data.id }));
},
[navigate],
[genrePath, navigate],
);
return (
@@ -0,0 +1,314 @@
import { Group, Table } from '@mantine/core';
import dayjs from 'dayjs';
import { RiCheckFill, RiCloseFill } from 'react-icons/ri';
import { TFunction, useTranslation } from 'react-i18next';
import { ReactNode } from 'react';
import { Album, AlbumArtist, AnyLibraryItem, LibraryItem, Song } from '/@/renderer/api/types';
import { formatDurationString } from '/@/renderer/utils';
import { formatSizeString } from '/@/renderer/utils/format-size-string';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
import { Rating, Spoiler, Text } from '/@/renderer/components';
import { sanitize } from '/@/renderer/utils/sanitize';
import { SongPath } from '/@/renderer/features/item-details/components/song-path';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import { AppRoute } from '/@/renderer/router/routes';
import { Separator } from '/@/renderer/components/separator';
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
export type ItemDetailsModalProps = {
item: Album | AlbumArtist | Song;
};
type ItemDetailRow<T> = {
key?: keyof T;
label: string;
postprocess?: string[];
render?: (item: T) => ReactNode;
};
const handleRow = <T extends AnyLibraryItem>(t: TFunction, item: T, rule: ItemDetailRow<T>) => {
let value: ReactNode;
if (rule.render) {
value = rule.render(item);
} else {
const prop = item[rule.key!];
value = prop !== undefined && prop !== null ? String(prop) : null;
}
if (!value) return null;
return (
<tr key={rule.label}>
<td>{t(rule.label, { postProcess: rule.postprocess || 'sentenceCase' })}</td>
<td>{value}</td>
</tr>
);
};
const formatArtists = (isAlbumArtist: boolean) => (item: Album | Song) =>
(isAlbumArtist ? item.albumArtists : item.artists)?.map((artist, index) => (
<span key={artist.id || artist.name}>
{index > 0 && <Separator />}
{artist.id ? (
<Text
$link
component={Link}
overflow="visible"
size="md"
to={
artist.id
? generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})
: ''
}
weight={500}
>
{artist.name || '—'}
</Text>
) : (
<Text
overflow="visible"
size="md"
>
{artist.name || '-'}
</Text>
)}
</span>
));
const formatComment = (item: Album | Song) =>
item.comment ? <Spoiler maxHeight={50}>{replaceURLWithHTMLLinks(item.comment)}</Spoiler> : null;
const formatDate = (key: string | null) => (key ? dayjs(key).fromNow() : '');
const FormatGenre = (item: Album | AlbumArtist | Song) => {
const genreRoute = useGenreRoute();
return item.genres?.map((genre, index) => (
<span key={genre.id}>
{index > 0 && <Separator />}
<Text
$link
component={Link}
overflow="visible"
size="md"
to={genre.id ? generatePath(genreRoute, { genreId: genre.id }) : ''}
weight={500}
>
{genre.name || '—'}
</Text>
</span>
));
};
const formatRating = (item: Album | AlbumArtist | Song) =>
item.userRating !== null ? (
<Rating
readOnly
value={item.userRating}
/>
) : null;
const BoolField = (key: boolean) =>
key ? <RiCheckFill size="1.1rem" /> : <RiCloseFill size="1.1rem" />;
const AlbumPropertyMapping: ItemDetailRow<Album>[] = [
{ key: 'name', label: 'common.title' },
{ label: 'entity.albumArtist_one', render: formatArtists(true) },
{ label: 'entity.genre_other', render: FormatGenre },
{
label: 'common.duration',
render: (album) => album.duration && formatDurationString(album.duration),
},
{ key: 'releaseYear', label: 'filter.releaseYear' },
{ key: 'songCount', label: 'filter.songCount' },
{ label: 'filter.isCompilation', render: (album) => BoolField(album.isCompilation || false) },
{
key: 'size',
label: 'common.size',
render: (album) => album.size && formatSizeString(album.size),
},
{
label: 'common.favorite',
render: (album) => BoolField(album.userFavorite),
},
{ label: 'common.rating', render: formatRating },
{ key: 'playCount', label: 'filter.playCount' },
{
label: 'filter.lastPlayed',
render: (song) => formatDate(song.lastPlayedAt),
},
{
label: 'common.modified',
render: (song) => formatDate(song.updatedAt),
},
{ label: 'filter.comment', render: formatComment },
{
label: 'common.mbid',
postprocess: [],
render: (album) =>
album.mbzId ? (
<a
href={`https://musicbrainz.org/release/${album.mbzId}`}
rel="noopener noreferrer"
target="_blank"
>
{album.mbzId}
</a>
) : null,
},
];
const AlbumArtistPropertyMapping: ItemDetailRow<AlbumArtist>[] = [
{ key: 'name', label: 'common.name' },
{ label: 'entity.genre_other', render: FormatGenre },
{
label: 'common.duration',
render: (artist) => artist.duration && formatDurationString(artist.duration),
},
{ key: 'songCount', label: 'filter.songCount' },
{
label: 'common.favorite',
render: (artist) => BoolField(artist.userFavorite),
},
{ label: 'common.rating', render: formatRating },
{ key: 'playCount', label: 'filter.playCount' },
{
label: 'filter.lastPlayed',
render: (song) => formatDate(song.lastPlayedAt),
},
{
label: 'common.mbid',
postprocess: [],
render: (artist) =>
artist.mbz ? (
<a
href={`https://musicbrainz.org/artist/${artist.mbz}`}
rel="noopener noreferrer"
target="_blank"
>
{artist.mbz}
</a>
) : null,
},
{
label: 'common.biography',
render: (artist) =>
artist.biography ? (
<Spoiler
dangerouslySetInnerHTML={{ __html: sanitize(artist.biography) }}
maxHeight={50}
/>
) : null,
},
];
const SongPropertyMapping: ItemDetailRow<Song>[] = [
{ key: 'name', label: 'common.title' },
{ key: 'path', label: 'common.path', render: SongPath },
{ label: 'entity.albumArtist_one', render: formatArtists(true) },
{ key: 'artists', label: 'entity.artist_other', render: formatArtists(false) },
{
key: 'album',
label: 'entity.album_one',
render: (song) =>
song.albumId &&
song.album && (
<Text
$link
component={Link}
overflow="visible"
size="md"
to={
song.albumId
? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: song.albumId,
})
: ''
}
weight={500}
>
{song.album}
</Text>
),
},
{ key: 'discNumber', label: 'common.disc' },
{ key: 'trackNumber', label: 'common.trackNumber' },
{ key: 'releaseYear', label: 'filter.releaseYear' },
{ label: 'entity.genre_other', render: FormatGenre },
{
label: 'common.duration',
render: (song) => formatDurationString(song.duration),
},
{ label: 'filter.isCompilation', render: (song) => BoolField(song.compilation || false) },
{ key: 'container', label: 'common.codec' },
{ key: 'bitRate', label: 'common.bitrate', render: (song) => `${song.bitRate} kbps` },
{ key: 'channels', label: 'common.channel_other' },
{ key: 'size', label: 'common.size', render: (song) => formatSizeString(song.size) },
{
label: 'common.favorite',
render: (song) => BoolField(song.userFavorite),
},
{ label: 'common.rating', render: formatRating },
{ key: 'playCount', label: 'filter.playCount' },
{
label: 'filter.lastPlayed',
render: (song) => formatDate(song.lastPlayedAt),
},
{
label: 'common.modified',
render: (song) => formatDate(song.updatedAt),
},
{
label: 'common.albumGain',
render: (song) => (song.gain?.album !== undefined ? `${song.gain.album} dB` : null),
},
{
label: 'common.trackGain',
render: (song) => (song.gain?.track !== undefined ? `${song.gain.track} dB` : null),
},
{
label: 'common.albumPeak',
render: (song) => (song.peak?.album !== undefined ? `${song.peak.album}` : null),
},
{
label: 'common.trackPeak',
render: (song) => (song.peak?.track !== undefined ? `${song.peak.track}` : null),
},
{ label: 'filter.comment', render: formatComment },
];
export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => {
const { t } = useTranslation();
let body: ReactNode;
switch (item.itemType) {
case LibraryItem.ALBUM:
body = AlbumPropertyMapping.map((rule) => handleRow(t, item, rule));
break;
case LibraryItem.ALBUM_ARTIST:
body = AlbumArtistPropertyMapping.map((rule) => handleRow(t, item, rule));
break;
case LibraryItem.SONG:
body = SongPropertyMapping.map((rule) => handleRow(t, item, rule));
break;
default:
body = null;
}
return (
<Group>
<Table
highlightOnHover
horizontalSpacing="sm"
sx={{ userSelect: 'text' }}
verticalSpacing="sm"
>
<tbody>{body}</tbody>
</Table>
</Group>
);
};
@@ -0,0 +1,67 @@
import { ActionIcon, CopyButton, Group } from '@mantine/core';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { RiCheckFill, RiClipboardFill, RiExternalLinkFill } from 'react-icons/ri';
import { Tooltip, toast } from '/@/renderer/components';
import styled from 'styled-components';
const util = isElectron() ? window.electron.utils : null;
export type SongPathProps = {
path: string | null;
};
const PathText = styled.div`
user-select: all;
`;
export const SongPath = ({ path }: SongPathProps) => {
const { t } = useTranslation();
if (!path) return null;
return (
<Group>
<CopyButton
timeout={2000}
value={path}
>
{({ copied, copy }) => (
<Tooltip
withinPortal
label={t(
copied ? 'page.itemDetail.copiedPath' : 'page.itemDetail.copyPath',
{ postProcess: 'sentenceCase' },
)}
>
<ActionIcon onClick={copy}>
{copied ? <RiCheckFill /> : <RiClipboardFill />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
{util && (
<Tooltip
withinPortal
label={t('page.itemDetail.openFile', { postProcess: 'sentenceCase' })}
>
<ActionIcon>
<RiExternalLinkFill
onClick={() => {
util.openItem(path).catch((error) => {
toast.error({
message: (error as Error).message,
title: t('error.openError', {
postProcess: 'sentenceCase',
}),
});
});
}}
/>
</ActionIcon>
</Tooltip>
)}
<PathText>{path}</PathText>
</Group>
);
};
@@ -104,7 +104,7 @@ export const useSongLyricsBySong = (
})
.catch(console.error);
if (subsonicLyrics) {
if (subsonicLyrics?.length) {
return subsonicLyrics;
}
} else if (hasFeature(server, ServerFeature.LYRICS_SINGLE_STRUCTURED)) {
@@ -41,7 +41,7 @@ const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
transparent 95%
);
@media screen and (width <= 768px) {
@media screen and (orientation: portrait) {
padding: 5vh 0;
}
`;
@@ -271,7 +271,12 @@ export const SynchronizedLyrics = ({
return;
}
if (!seeked) {
// If the time goes back to 0 and we are still playing, this suggests that
// we may be playing the same track (repeat one). In this case, we also
// need to restart playback
const restarted = status === PlayerStatus.PLAYING && now === 0;
if (!seeked && !restarted) {
return;
}
@@ -34,7 +34,7 @@ const UnsynchronizedLyricsContainer = styled.div<{ $gap: number }>`
transparent 95%
);
@media screen and (width <= 768px) {
@media screen and (orientation: portrait) {
padding: 5vh 0;
}
`;
@@ -14,7 +14,7 @@ import {
} from 'react-icons/ri';
import { Song } from '/@/renderer/api/types';
import { usePlayerControls, useQueueControls } from '/@/renderer/store';
import { PlaybackType, TableType } from '/@/renderer/types';
import { PlaybackType, PlayerStatus, TableType } from '/@/renderer/types';
import { usePlaybackType } from '/@/renderer/store/settings.store';
import { usePlayerStore, useSetCurrentTime } from '../../../store/player.store';
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
@@ -91,7 +91,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
mpvPlayer!.pause();
}
remote?.updateSong({ song: undefined });
remote?.updateSong({ song: undefined, status: PlayerStatus.PAUSED });
setCurrentTime(0);
pause();
@@ -36,6 +36,7 @@ import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { useAppFocus } from '/@/renderer/hooks';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const remote = isElectron() ? window.electron.remote : null;
@@ -90,6 +91,15 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.volume(volume);
mpvPlayer!.setQueue(playerData, false);
} else {
const player =
playerData.current.player === 1
? PlayersRef.current?.player1
: PlayersRef.current?.player2;
const underlying = player?.getInternalPlayer();
if (underlying) {
underlying.currentTime = 0;
}
}
play();
@@ -324,8 +324,13 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
setSeekValue(e);
}}
onChangeEnd={(e) => {
handleSeekSlider(e);
setIsSeeking(false);
// There is a timing bug in Mantine in which the onChangeEnd
// event fires before onChange. Add a small delay to force
// onChangeEnd to happen after onCHange
setTimeout(() => {
handleSeekSlider(e);
setIsSeeking(false);
}, 50);
}}
/>
</SliderWrapper>
@@ -206,10 +206,7 @@ export const FullScreenPlayerImage = () => {
justify="flex-start"
p="1rem"
>
<ImageContainer
ref={mainImageRef}
onLoad={updateImageSize}
>
<ImageContainer ref={mainImageRef}>
<AnimatePresence
initial={false}
mode="sync"
@@ -39,7 +39,7 @@ const Container = styled(motion.div)`
justify-content: center;
padding: 2rem;
@media screen and (width <= 768px) {
@media screen and (orientation: portrait) {
padding: 2rem 2rem 1rem;
}
`;
@@ -53,7 +53,7 @@ const ResponsiveContainer = styled.div`
max-width: 2560px;
margin-top: 5rem;
@media screen and (width <= 768px) {
@media screen and (orientation: portrait) {
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
margin-top: 0;
@@ -472,12 +472,10 @@ export const FullScreenPlayer = () => {
srcLoaded: true,
});
const imageUrl = currentSong?.imageUrl;
const imageUrl = currentSong?.imageUrl && currentSong.imageUrl.replace(/size=\d+/g, 'size=500');
const backgroundImage =
imageUrl && dynamicIsImage
? `url("${imageUrl
.replace(/size=\d+/g, 'size=500')
.replace(currentSong.id, currentSong.albumId)}`
? `url("${imageUrl.replace(currentSong.id, currentSong.albumId)}"), url("${imageUrl}")`
: mainBackground;
return (
@@ -20,6 +20,7 @@ import { fadeIn } from '/@/renderer/styles';
import { LibraryItem } from '/@/renderer/api/types';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import { Separator } from '/@/renderer/components/separator';
const ImageWrapper = styled.div`
position: relative;
@@ -236,16 +237,7 @@ export const LeftControls = () => {
<LineItem $secondary>
{artists?.map((artist, index) => (
<React.Fragment key={`bar-${artist.id}`}>
{index > 0 && (
<Text
$link
$secondary
size="md"
style={{ display: 'inline-block' }}
>
,
</Text>
)}{' '}
{index > 0 && <Separator />}
<Text
$link
component={Link}
@@ -309,13 +309,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
const handleRepeatAll = {
local: () => {
const playerData = next();
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
mprisUpdateSong({ song: playerData.current.song });
mpvPlayer!.setQueue(playerData);
mpvPlayer!.next();
},
web: () => {
const playerData = next();
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
mprisUpdateSong({ song: playerData.current.song });
},
};
@@ -324,17 +323,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
if (isLastTrack) {
const playerData = setCurrentIndex(0);
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
mpvPlayer!.setQueue(playerData);
mpvPlayer!.pause();
mpvPlayer!.setQueue(playerData, true);
pause();
} else {
const playerData = next();
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
mprisUpdateSong({ song: playerData.current.song });
mpvPlayer!.setQueue(playerData);
mpvPlayer!.next();
}
},
web: () => {
@@ -342,16 +336,13 @@ export const useCenterControls = (args: { playersRef: any }) => {
const playerData = setCurrentIndex(0);
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
status: PlayerStatus.PAUSED,
});
resetPlayers();
pause();
} else {
const playerData = next();
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
mprisUpdateSong({ song: playerData.current.song });
resetPlayers();
}
},
@@ -359,18 +350,16 @@ export const useCenterControls = (args: { playersRef: any }) => {
const handleRepeatOne = {
local: () => {
const playerData = next();
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
mpvPlayer!.setQueue(playerData);
mpvPlayer!.next();
if (!isLastTrack) {
const playerData = next();
mprisUpdateSong({ song: playerData.current.song });
mpvPlayer!.setQueue(playerData);
}
},
web: () => {
if (!isLastTrack) {
const playerData = next();
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
mprisUpdateSong({ song: playerData.current.song });
}
},
};
@@ -424,36 +413,22 @@ export const useCenterControls = (args: { playersRef: any }) => {
local: () => {
if (!isFirstTrack) {
const playerData = previous();
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
mprisUpdateSong({ song: playerData.current.song });
mpvPlayer!.setQueue(playerData);
mpvPlayer!.previous();
} else {
const playerData = setCurrentIndex(queue.length - 1);
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
mprisUpdateSong({ song: playerData.current.song });
mpvPlayer!.setQueue(playerData);
mpvPlayer!.previous();
}
},
web: () => {
if (isFirstTrack) {
const playerData = setCurrentIndex(queue.length - 1);
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
mprisUpdateSong({ song: playerData.current.song });
resetPlayers();
} else {
const playerData = previous();
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
mprisUpdateSong({ song: playerData.current.song });
resetPlayers();
}
},
@@ -461,13 +436,19 @@ export const useCenterControls = (args: { playersRef: any }) => {
const handleRepeatNone = {
local: () => {
const playerData = previous();
remote?.updateSong({
currentTime: usePlayerStore.getState().current.time,
song: playerData.current.song,
});
mpvPlayer!.setQueue(playerData);
mpvPlayer!.previous();
if (isFirstTrack) {
const playerData = setCurrentIndex(0);
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PAUSED });
mpvPlayer!.setQueue(playerData, true);
pause();
} else {
const playerData = previous();
mprisUpdateSong({
currentTime: usePlayerStore.getState().current.time,
song: playerData.current.song,
});
mpvPlayer!.setQueue(playerData);
}
},
web: () => {
if (isFirstTrack) {
@@ -476,10 +457,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
pause();
} else {
const playerData = previous();
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
mprisUpdateSong({ song: playerData.current.song });
resetPlayers();
}
},
@@ -487,21 +465,13 @@ export const useCenterControls = (args: { playersRef: any }) => {
const handleRepeatOne = {
local: () => {
if (!isFirstTrack) {
const playerData = previous();
mprisUpdateSong({
song: playerData.current.song,
status: PlayerStatus.PLAYING,
});
mpvPlayer!.setQueue(playerData);
mpvPlayer!.previous();
} else {
mpvPlayer!.stop();
}
const playerData = previous();
mprisUpdateSong({ song: playerData.current.song });
mpvPlayer!.setQueue(playerData);
},
web: () => {
const playerData = previous();
mprisUpdateSong({ song: playerData.current.song, status: PlayerStatus.PLAYING });
mprisUpdateSong({ song: playerData.current.song });
resetPlayers();
},
};
@@ -538,7 +508,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
]);
const handlePlayPause = useCallback(() => {
if (queue) {
if (queue.length > 0) {
if (playerStatus === PlayerStatus.PAUSED) {
return handlePlay();
}
@@ -1,7 +1,7 @@
import { useEffect, useCallback, useState, useRef } from 'react';
import { QueueSong, ServerType } from '/@/renderer/api/types';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
import { useCurrentStatus, usePlayerStore } from '/@/renderer/store';
import { usePlayerStore } from '/@/renderer/store';
import { usePlaybackSettings } from '/@/renderer/store/settings.store';
import { PlayerStatus } from '/@/renderer/types';
@@ -52,7 +52,6 @@ const checkScrobbleConditions = (args: {
};
export const useScrobble = () => {
const status = useCurrentStatus();
const scrobbleSettings = usePlaybackSettings().scrobble;
const isScrobbleEnabled = scrobbleSettings?.enabled;
const sendScrobble = useSendScrobble();
@@ -94,6 +93,7 @@ export const useScrobble = () => {
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
progressIntervalId.current = null;
}
// const currentSong = current[0] as QueueSong | undefined;
@@ -135,9 +135,13 @@ export const useScrobble = () => {
clearTimeout(songChangeTimeoutId.current as ReturnType<typeof setTimeout>);
songChangeTimeoutId.current = setTimeout(() => {
const currentSong = current[0] as QueueSong | undefined;
// Get the current status from the state, not variable. This is because
// of a timing issue where, when playing the first track, the first
// event is song, and then the event is play
const currentStatus = usePlayerStore.getState().current.status;
// Send start scrobble when song changes and the new song is playing
if (status === PlayerStatus.PLAYING && currentSong?.id) {
if (currentStatus === PlayerStatus.PLAYING && currentSong?.id) {
sendScrobble.mutate({
query: {
event: 'start',
@@ -149,6 +153,12 @@ export const useScrobble = () => {
});
if (currentSong?.serverType === ServerType.JELLYFIN) {
// It is possible that another function sets an interval.
// We only want one running, so clear the existing interval
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
}
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
@@ -163,7 +173,6 @@ export const useScrobble = () => {
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
status,
handleScrobbleFromSeek,
],
);
@@ -200,8 +209,14 @@ export const useScrobble = () => {
});
if (currentSong?.serverType === ServerType.JELLYFIN) {
// It is possible that another function sets an interval.
// We only want one running, so clear the existing interval
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
}
progressIntervalId.current = setInterval(() => {
const currentTime = currentTimeSec;
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
@@ -220,6 +235,7 @@ export const useScrobble = () => {
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
progressIntervalId.current = null;
}
} else {
const isLastTrackInQueue = usePlayerStore.getState().actions.checkIsLastTrack();
@@ -20,10 +20,10 @@ import {
VirtualInfiniteGridRef,
} from '/@/renderer/components/virtual-grid';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useGeneralSettings, useListStoreByKey } from '/@/renderer/store';
import { CardRow, ListDisplayType } from '/@/renderer/types';
import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite';
interface PlaylistListGridViewProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
@@ -38,34 +38,7 @@ export const PlaylistListGridView = ({ gridRef, itemCount }: PlaylistListGridVie
const { display, grid, filter } = useListStoreByKey({ key: pageKey });
const { setGrid } = useListStoreActions();
const { defaultFullPlaylist } = useGeneralSettings();
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = (options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
}) => {
const { id, itemType, isFavorite } = options;
if (isFavorite) {
deleteFavoriteMutation.mutate({
query: {
id,
type: itemType,
},
serverId: server?.id,
});
} else {
createFavoriteMutation.mutate({
query: {
id,
type: itemType,
},
serverId: server?.id,
});
}
};
const handleFavorite = useHandleFavorite({ gridRef, server });
const cardRows = useMemo(() => {
const rows: CardRow<Playlist>[] = defaultFullPlaylist
@@ -406,7 +406,7 @@ export const PlaylistListHeaderFilters = ({
<Slider
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
max={isGrid ? 300 : 100}
min={isGrid ? 150 : 25}
min={isGrid ? 100 : 25}
onChangeEnd={handleItemSize}
/>
</DropdownMenu.Item>
@@ -8,15 +8,12 @@ import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/cr
import { PlaylistListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-list-header-filters';
import { LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { PlaylistListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
import { PlaylistListFilter, useCurrentServer } from '/@/renderer/store';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { RiFileAddFill } from 'react-icons/ri';
import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh';
import { useListContext } from '/@/renderer/context/list-context';
import { useListStoreByKey } from '../../../store/list.store';
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
interface PlaylistListHeaderProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
@@ -26,11 +23,8 @@ interface PlaylistListHeaderProps {
export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistListHeaderProps) => {
const { t } = useTranslation();
const { pageKey } = useListContext();
const cq = useContainerQuery();
const server = useCurrentServer();
const { setFilter, setTablePagination } = useListStoreActions();
const { display, filter } = useListStoreByKey({ key: pageKey });
const handleCreatePlaylistModal = () => {
openModal({
@@ -43,25 +37,16 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis
});
};
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
const { filter, refresh, search } = useDisplayRefresh({
gridRef,
itemType: LibraryItem.PLAYLIST,
server,
tableRef,
});
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({
data: { searchTerm },
itemType: LibraryItem.PLAYLIST,
key: pageKey,
}) as PlaylistListFilter;
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
handleRefreshTable(tableRef, updatedFilters);
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
} else {
handleRefreshGrid(gridRef, updatedFilters);
}
const updatedFilters = search(e) as PlaylistListFilter;
refresh(updatedFilters);
}, 500);
return (
@@ -1,6 +1,7 @@
import { forwardRef, Ref, useImperativeHandle, useState } from 'react';
import { forwardRef, Ref, useImperativeHandle, useMemo, useState } from 'react';
import { Group } from '@mantine/core';
import { useForm } from '@mantine/form';
import { openModal } from '@mantine/modals';
import clone from 'lodash/clone';
import get from 'lodash/get';
import setWith from 'lodash/setWith';
@@ -21,14 +22,18 @@ import {
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
import { useTranslation } from 'react-i18next';
import { RiMore2Fill, RiSaveLine } from 'react-icons/ri';
import { SongListSort } from '/@/renderer/api/types';
import { PlaylistListSort, SongListSort, SortOrder } from '/@/renderer/api/types';
import {
NDSongQueryBooleanOperators,
NDSongQueryDateOperators,
NDSongQueryFields,
NDSongQueryNumberOperators,
NDSongQueryPlaylistOperators,
NDSongQueryStringOperators,
} from '/@/renderer/api/navidrome.types';
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
import { useCurrentServer } from '/@/renderer/store';
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
type AddArgs = {
groupIndex: number[];
@@ -52,6 +57,7 @@ interface PlaylistQueryBuilderProps {
parsedFilter: any,
extraFilters: { limit?: number; sortBy?: string; sortOrder?: string },
) => void;
playlistId?: string;
query: any;
sortBy: SongListSort;
sortOrder: 'asc' | 'desc';
@@ -84,14 +90,43 @@ export type PlaylistQueryBuilderRef = {
export const PlaylistQueryBuilder = forwardRef(
(
{ sortOrder, sortBy, limit, isSaving, query, onSave, onSaveAs }: PlaylistQueryBuilderProps,
{
sortOrder,
sortBy,
limit,
isSaving,
query,
onSave,
onSaveAs,
playlistId,
}: PlaylistQueryBuilderProps,
ref: Ref<PlaylistQueryBuilderRef>,
) => {
const { t } = useTranslation();
const server = useCurrentServer();
const [filters, setFilters] = useState<QueryBuilderGroup>(
query ? convertNDQueryToQueryGroup(query) : DEFAULT_QUERY,
);
const { data: playlists } = usePlaylistList({
query: { sortBy: PlaylistListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0 },
serverId: server?.id,
});
const playlistData = useMemo(() => {
if (!playlists) return [];
return playlists.items
.filter((p) => {
if (!playlistId) return true;
return p.id !== playlistId;
})
.map((p) => ({
label: p.name,
value: p.id,
}));
}, [playlistId, playlists]);
const extraFiltersForm = useForm({
initialValues: {
limit,
@@ -131,6 +166,16 @@ export const PlaylistQueryBuilder = forwardRef(
onSaveAs?.(convertQueryGroupToNDQuery(filters), extraFiltersForm.values);
};
const openPreviewModal = () => {
const previewValue = convertQueryGroupToNDQuery(filters);
openModal({
children: <JsonPreview value={previewValue} />,
size: 'xl',
title: t('common.preview', { postProcess: 'titleCase' }),
});
};
const handleAddRuleGroup = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = clone(filters);
@@ -367,7 +412,7 @@ export const PlaylistQueryBuilder = forwardRef(
return (
<MotionFlex
direction="column"
h="calc(100% - 2.5rem)"
h="calc(100% - 3.5rem)"
justify="space-between"
>
<ScrollArea
@@ -383,8 +428,10 @@ export const PlaylistQueryBuilder = forwardRef(
boolean: NDSongQueryBooleanOperators,
date: NDSongQueryDateOperators,
number: NDSongQueryNumberOperators,
playlist: NDSongQueryPlaylistOperators,
string: NDSongQueryStringOperators,
}}
playlists={playlistData}
uniqueId={filters.uniqueId}
onAddRule={handleAddRule}
onAddRuleGroup={handleAddRuleGroup}
@@ -428,7 +475,7 @@ export const PlaylistQueryBuilder = forwardRef(
value: 'desc',
},
]}
label={t('common.order', { postProcess: 'titleCase' })}
label={t('common.sortOrder', { postProcess: 'titleCase' })}
maxWidth="20%"
width={125}
{...extraFiltersForm.getInputProps('sortOrder')}
@@ -452,6 +499,13 @@ export const PlaylistQueryBuilder = forwardRef(
>
{t('common.saveAs', { postProcess: 'titleCase' })}
</Button>
<Button
p="0.5em"
variant="default"
onClick={openPreviewModal}
>
{t('common.preview', { postProcess: 'titleCase' })}
</Button>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
@@ -76,7 +76,7 @@ export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlayl
});
const isPublicDisplayed = server?.type === ServerType.NAVIDROME;
const isOwnerDisplayed = server?.type === ServerType.NAVIDROME;
const isOwnerDisplayed = server?.type === ServerType.NAVIDROME && userList;
const isSubmitDisabled = !form.values.name || mutation.isLoading;
return (
@@ -154,11 +154,17 @@ export const openUpdatePlaylistModal = async (args: {
const users =
server?.type === ServerType.NAVIDROME
? await queryClient.fetchQuery({
queryFn: ({ signal }) =>
api.controller.getUserList({ apiClientProps: { server, signal }, query }),
queryKey: queryKeys.users.list(server?.id || '', query),
})
? await queryClient
.fetchQuery({
queryFn: ({ signal }) =>
api.controller.getUserList({ apiClientProps: { server, signal }, query }),
queryKey: queryKeys.users.list(server?.id || '', query),
})
.catch((error) => {
// This eror most likely happens if the user is not an admin
console.error(error);
return null;
})
: null;
openModal({

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