Compare commits

..

173 Commits

Author SHA1 Message Date
jeffvli a177061f18 Bump to v0.6.1 2024-03-13 00:02:46 -07:00
jeffvli d806ade84c Potentially fix application stuck when Navidrome credential expires (#538) 2024-03-12 23:59:35 -07:00
jeffvli 854b76702b Downgrade to electron v26 (#534) 2024-03-12 23:33:33 -07:00
Hosted Weblate 1b08dfc5a5 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (549 of 549 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (543 of 543 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (519 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: TianMiao <tianmiao.work@foxmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2024-03-06 00:55:52 +01:00
Hosted Weblate 60fc552088 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (549 of 549 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (543 of 543 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: TianMiao <tianmiao.work@foxmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/
Translation: feishin/Translation
2024-03-06 00:55:52 +01:00
Hosted Weblate 86438fdb3d Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translation: feishin/Translation
2024-03-06 00:55:51 +01:00
Hosted Weblate 92beae6665 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (Persian)

Currently translated at 50.0% (260 of 519 strings)

Added translation using Weblate (Persian)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kaambiz <kambizx@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fa/
Translation: feishin/Translation
2024-03-06 00:55:51 +01:00
Hosted Weblate b110cfb94b Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translation: feishin/Translation
2024-03-06 00:55:50 +01:00
Hosted Weblate 08d4e80777 Translated using Weblate (Spanish)
Currently translated at 100.0% (549 of 549 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (Spanish)

Currently translated at 100.0% (519 of 519 strings)

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2024-03-06 00:55:49 +01:00
Hosted Weblate ba03c19439 Translated using Weblate (Dutch)
Currently translated at 46.6% (242 of 519 strings)

Translated using Weblate (Dutch)

Currently translated at 39.4% (205 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Idris Saklou <idrissaklou@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/nl/
Translation: feishin/Translation
2024-03-06 00:55:49 +01:00
Hosted Weblate 1c6346572b Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translation: feishin/Translation
2024-03-06 00:55:48 +01:00
Hosted Weblate b91c715b13 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (Polish)

Currently translated at 99.8% (518 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mistify <fabianszafranski@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translation: feishin/Translation
2024-03-06 00:55:48 +01:00
Hosted Weblate f22434a50c Translated using Weblate (Czech)
Currently translated at 100.0% (549 of 549 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (Czech)

Currently translated at 100.0% (519 of 519 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (519 of 519 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (519 of 519 strings)

Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2024-03-06 00:55:47 +01:00
Hosted Weblate 2086b57fb2 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translation: feishin/Translation
2024-03-06 00:55:47 +01:00
Hosted Weblate 2247ba08ea Translated using Weblate (German)
Currently translated at 98.5% (548 of 556 strings)

Translated using Weblate (German)

Currently translated at 98.5% (548 of 556 strings)

Translated using Weblate (German)

Currently translated at 98.5% (548 of 556 strings)

Translated using Weblate (German)

Currently translated at 93.3% (519 of 556 strings)

Translated using Weblate (German)

Currently translated at 93.3% (519 of 556 strings)

Translated using Weblate (German)

Currently translated at 92.2% (513 of 556 strings)

Translated using Weblate (German)

Currently translated at 92.2% (513 of 556 strings)

Translated using Weblate (German)

Currently translated at 91.5% (509 of 556 strings)

Translated using Weblate (German)

Currently translated at 91.5% (509 of 556 strings)

Translated using Weblate (German)

Currently translated at 91.1% (507 of 556 strings)

Translated using Weblate (German)

Currently translated at 91.1% (507 of 556 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (German)

Currently translated at 89.9% (467 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Lokke <accounts.lokke@googlemail.com>
Co-authored-by: Maik <maikguenes2003@gmail.com>
Co-authored-by: Rudi Mentaire <stoertebecker@byom.de>
Co-authored-by: full name <hosted-weblate.5w160@dralias.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/
Translation: feishin/Translation
2024-03-06 00:55:46 +01:00
jeffvli 5601613c3d Bump to v0.6.0 2024-03-05 15:55:28 -08:00
jeffvli d04d786951 Center auth loading spinner 2024-03-05 15:52:14 -08:00
jeffvli 613bfa7ae6 Various cleanup/fixes 2024-03-05 14:15:32 -08:00
jeffvli a7a5b92011 Adjust server feature naming convention 2024-03-05 14:12:37 -08:00
jeffvli a9315be259 Rename features types file 2024-03-05 14:05:01 -08:00
jeffvli e7b2f30718 Replace songId with albumId on dynamic image check 2024-03-05 00:45:45 -08:00
Kendall Garner 73845a9432 [enhancement]: better version checks for lyrics, Navidrome (#529)
- Actually make serverfeatures partial
- Navidrome: only set multiple structured lyrics if extension exists
- Navidrome/Subsonic: minor type checking of OS extension (Navidrome implementation detail)
- Jellyfin: add separate knob for lyrics. Note, this should also probably be behind some version check...
2024-03-05 00:31:51 -08:00
Benjamin d52d9136b8 [feat] Add a dynamic image option to the fullscreen player (#526)
* Add an option for a dynamic background image in the fullscreen player

* Center the background image and fix some more bugs

* More cleaning up the background image

* Add option for customizable blur amount

* Fix missing translation key for image blur

* Fix dynamic image shifting when player is opened

* Hide image blur size config if dynamic background is disabled

---------

Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2024-03-05 00:30:37 -08:00
Kendall Garner a45e7f24e4 [enhancements]: fix carousel, full screen metadata improvements (#524)
* fix carousel, full screen metadata improvements

* Add missing mapped keys on multiple artist

* Remove bg opacity on metadata section

* Improve visibility of currently playing song

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2024-03-04 18:53:00 -08:00
Jeff 742cef3d81 Merge pull request #519 from kgarner7/related-similar-songs
[enhancement]: Make related tab on full screen player useful
2024-03-04 05:29:27 -08:00
Jeff 132b0e173f Merge branch 'development' into related-similar-songs 2024-03-04 05:04:54 -08:00
Jeff f7f3c5fe30 Merge pull request #517 from kgarner7/navidrome-shared-playlists
[enhancement]: Differentiate shared and owner playlists for Navidrome
2024-03-04 02:41:40 -08:00
jeffvli 83b5afb187 Remove ServerType check on shared playlist display 2024-03-04 02:37:37 -08:00
Kendall Garner 237fb91a60 [enhancement]: Differentiate shared and owner playlists for Navidrome
Resolves #368. If Navidrome, post-process the playlist list and separate
into owned and shared (in that order).
2024-03-04 02:29:35 -08:00
Jeff 8265ce48c4 Merge pull request #510 from kgarner7/improve-navidrome-auth
[bugfix]: Check for Navidrome authentication on startup
2024-03-04 02:27:10 -08:00
Jeff bf3431cbc6 Merge pull request #514 from kgarner7/navidrome-version
[enhancement]: Better version checks
2024-03-04 01:49:33 -08:00
Jeff cc6cad1d70 Merge branch 'development' into navidrome-version 2024-03-04 01:49:13 -08:00
jeffvli c8b1e2312a Change smart playlist feature check 2024-03-04 01:44:11 -08:00
jeffvli 84837a6887 Prevent version check from running on every query in Navidrome 2024-03-03 22:16:25 -08:00
jeffvli f1f6ccfd02 Normalize server feature set 2024-03-03 22:15:49 -08:00
Kendall Garner f50d1e0a8c support opacity 0, spellcheck 2024-03-01 19:54:30 -08:00
Kendall Garner 753ca01d41 [bugfix]: fix update rating for items with no data node 2024-02-29 17:28:46 -08:00
jeffvli 960c126283 Fix incorrect album artist sidebar icon 2024-02-28 21:07:50 -08:00
Kendall Garner 94b649fefe don't transform artist/album for fullscreen player 2024-02-26 22:47:17 -08:00
Alberto Rodríguez 28bb699024 Add a pre-defined server for the docker version (#413)
* Moved build to docker stage.

* Do not copy node_modules to the docker image

* Optimize Docker builds

* Lock a predefined server with enviroment variables

* Added a example docker compose file

* Removed useless layer

* Fix error with empty server type

* pass process via preload, use file, strict server check

* remove duplicate content-type

* update readme, docker compose

* bugfix: server lock false, not jellyfin

* fix preload type definition

* fix docker, web server lock check

---------

Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
2024-02-24 06:55:23 +00:00
Kendall Garner 5caf0d439f [enhancement]: Start minimized (#522)
* [enhancement]: support starting minimized

* show window when dock clicked macos
2024-02-23 16:31:17 +00:00
dependabot[bot] 77fa723cf8 Bump the npm_and_yarn group across 2 directories with 1 update (#521)
Bumps the npm_and_yarn group with 1 update in the /. directory: [ip](https://github.com/indutny/node-ip).


Updates `ip` from 1.1.5 to 1.1.9
- [Commits](https://github.com/indutny/node-ip/compare/v1.1.5...v1.1.9)

---
updated-dependencies:
- dependency-name: ip
  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-02-22 03:49:44 +00:00
Kendall Garner 77e220c873 [enhancement]: add codec column for tracks 2024-02-19 23:04:41 -08:00
Kendall Garner 860dd8b499 [enhancement]: add codec column for tracks 2024-02-19 20:34:36 -08:00
Kendall Garner 9113c6cc2e [bugfix]: carousel fixes 2024-02-19 13:55:11 -08:00
Kendall Garner 12d0eca2dd remember that current song can be an object 2024-02-19 10:27:00 -08:00
Kendall Garner 3a116e938e omit song from similar list, handle subsonic error 2024-02-19 10:09:05 -08:00
Kendall Garner f81bea339b don't pass song all the way down 2024-02-19 09:55:37 -08:00
Kendall Garner c947d09615 show nothing if no song exists 2024-02-19 09:01:44 -08:00
Kendall Garner af90d07414 bugfix 2024-02-19 08:56:06 -08:00
Kendall Garner 025124c379 [enhancement]: Make related tab on full screen player useful
Resolves #50. Adds a new set of components for fetching similar songs
from the current playing song. For Jellyfin, use the `/items/{itemId}/similar`
endpoint (may not work well for small libraries), and for Navidrome/Subsonic
use `getSimilarSongs`. _In theory_, this component can be used to get similar
songs anywhere.
2024-02-19 08:53:50 -08:00
Kendall Garner 74075fc374 [bugfix]: remove window clear cache handler in main for MacOS 2024-02-18 17:05:45 -08:00
Kendall Garner dae2f9bd0a [feat]: actually include version checks 2024-02-17 00:57:10 -08:00
jeffvli 9a43ea0e4a Attempt to fix player unresponsive on first start 2024-02-16 22:12:28 -08:00
Kendall Garner 2f105956b9 Improve grid size, language 2024-02-16 21:42:15 -08:00
Kendall Garner ce9c03b0e1 [bugfix]: Macos trusted accessibility (#512)
* [bugfix]: macos trusted accessibility

* update readme
2024-02-17 03:55:57 +00:00
Kendall Garner 1e5d446ced [bugfix]: song list play count -> genre 2024-02-16 13:53:42 -08:00
Kendall Garner b2fce071a9 [bugfix]: Check for Navidrome authentication on startup
Resolves #403.
This PR introduces a startup check for Navidrome that tries a simple API request (/songs) before loading homepage.
If the check fails, Navidrome API will fallback to trying saved password (if available).

Notes:
- It might also be worthwhile to do a periodic poll?
2024-02-16 13:37:49 -08:00
Kendall Garner 20b161ee86 [enhancement]: prreserve current song position when shuffling queue 2024-02-14 22:59:44 -08:00
darkpixlz 6e677d7454 Image Resolution Setting (#492)
* Add customizable resolution for the fullscreen player image

---------

Co-authored-by: iiPython <ben@iipython.dev>
Co-authored-by: Benjamin <iipython@proton.me>
2024-02-13 18:00:59 -08:00
Kendall Garner f796a35f5c [enhancement]: support reordering homepage (#494)
* [enhancement]: support reordering homepage

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2024-02-13 17:56:08 -08:00
Jeff 83d5fee442 Merge pull request #484 from kgarner7/fix-structured-lyrics
[bugfix/enhancement]: Support Navidrome structured lyrics
2024-02-13 16:17:24 -08:00
Jeff eab11658bb Merge pull request #501 from kgarner7/allow-no-mpv
- OOBE default to web player
- Allow mpv to run using PATH env
- Add improved mpv error logging
- Add web player fallback on mpv error
2024-02-13 16:16:01 -08:00
jeffvli b4092c394a Set playback type to WEB by default 2024-02-13 05:50:38 -08:00
jeffvli 9b0c9ba3ac Fallback to web player if mpv fails to run 2024-02-13 02:05:59 -08:00
Kendall Garner e6b01d4e2b [bugfix]: set MPV queue when now or queue is empty 2024-02-13 00:04:28 -08:00
jeffvli fb08502e51 Add mpv path reload and clear functionality 2024-02-12 21:21:17 -08:00
jeffvli 8f4ff9286a Allow deletion on local settings keys 2024-02-12 20:50:50 -08:00
jeffvli dcd130fb6c Refactor mpv log to allow for custom toast 2024-02-12 20:50:09 -08:00
jeffvli 60105103f3 Improve mpv error logging 2024-02-12 20:11:55 -08:00
jeffvli ff4ce89bc9 Remove "disable mpv" setting and desktop requirement 2024-02-12 14:14:08 -08:00
jeffvli eb4d099804 Increase toast duration defaults 2024-02-12 14:04:19 -08:00
jeffvli b69290f9f2 Add listeners and preload for log/toast from main 2024-02-12 14:02:59 -08:00
jeffvli 69f82a9427 Add logger functions and move player functions to feature 2024-02-12 13:58:50 -08:00
jeffvli adf5fc348a Bump electron-log 2024-02-12 13:50:02 -08:00
Kendall Garner f82da2e76b [enhancement]: Support disabling MPV entirely
Supports running Feishin solely using web audio (useful for clients with problems with MPV).
Also moves save/restore queue to utils, as MPV object is now optional
2024-02-11 13:56:29 -08:00
Kendall Garner 8de21a707c Merge pull request #500 from jeffvli/dependabot/npm_and_yarn/npm_and_yarn-security-group-593a984358
Bump the npm_and_yarn group across 2 directories with 2 updates
2024-02-10 09:23:52 +00:00
dependabot[bot] 1f1916f005 Bump the npm_and_yarn group across 2 directories with 2 updates
Bumps the npm_and_yarn group with 2 updates in the /. directory: [semver](https://github.com/npm/node-semver) and [eslint-plugin-compat](https://github.com/amilajack/eslint-plugin-compat).
Bumps the npm_and_yarn group with 1 update in the /release/app directory: [semver](https://github.com/npm/node-semver).


Updates `semver` from 7.3.8 to 7.6.0
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.3.8...v7.6.0)

Updates `eslint-plugin-compat` from 4.1.4 to 4.2.0
- [Release notes](https://github.com/amilajack/eslint-plugin-compat/releases)
- [Changelog](https://github.com/amilajack/eslint-plugin-compat/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amilajack/eslint-plugin-compat/compare/v4.1.4...v4.2.0)

Updates `semver` from 6.3.0 to 6.3.1
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.3.8...v7.6.0)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
  dependency-group: npm_and_yarn-security-group
- dependency-name: eslint-plugin-compat
  dependency-type: direct:development
  dependency-group: npm_and_yarn-security-group
- dependency-name: semver
  dependency-type: indirect
  dependency-group: npm_and_yarn-security-group
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-10 08:43:06 +00:00
Kendall Garner ae8fc6df13 [bugfix]: improve play behavior
- when adding songs to queue, only `play()` if the queue was empty
- when adding next/last to empty queue, behavior should be same as now
2024-02-10 00:38:21 -08:00
Kendall Garner 0a658e3a22 [bugfix]: default go libsecret, support changing secret store (#493)
* [bugfix]: default go libsecret, support changing secret store

* update readme and rename libsecret
2024-02-09 23:20:01 +00:00
Kendall Garner 92478b5ca5 [bugfix]: update mpris volume when set by mpris 2024-02-07 20:51:07 -08:00
Kendall Garner 49cbef729b encode uri component for authorization header 2024-02-07 20:29:29 -08:00
Kendall Garner 73c6ddd116 Merge branch 'development' of github.com:jeffvli/feishin into development 2024-02-04 10:24:24 -08:00
Kendall Garner e3553074a3 [bugfix]: use accent color for cache clearing 2024-02-04 10:23:45 -08:00
lymnyx 29df2a6215 [bugfix/enhancement] Fixes german translations being sentence-case (#491)
* Fixes german translations' case (#490).

* Smaller improvements to german translations.

* Changes to sentenceCasePostProcessor.
2024-02-04 06:47:52 -08:00
Kendall Garner aba7cb302f add navidrome version check for smart playlists 2024-02-03 22:47:57 -08:00
Kendall Garner 46cc1a635f address changes 2024-02-03 21:22:03 -08:00
Kendall Garner 6520a105d2 Merge pull request #489 from lymnyx/mostplayed-bugfix
[bugfix] Fixes route of most played songs on home page
2024-02-04 02:29:16 +00:00
lymnyx 69cb63a8b0 Fixes route of most played songs in home (#434). 2024-02-04 02:27:26 +01:00
Benjamin 1fb7290603 [bugfix] Fix global hotkeys not working on startup (#488)
* Simple one line fix for global hotkeys

* Clean up codebase via default values
2024-02-04 00:37:53 +00:00
Kendall Garner 24bf7ae31f [enhancement/localization]: sort navidrome albums by year, add more language keys 2024-02-03 15:05:33 -08:00
Benjamin 86a93866d0 [enhancement] Add a button size setting (#486)
* Add a button size setting

* Reduce setting size and add px suffix

* Looks like I don't need || inside of control-settings

* Update translation

* Bump settings version to 7
2024-02-03 21:22:26 +00:00
lymnyx 933573b57f Order artist's Top Songs by play count (#410). (#487) 2024-02-03 21:21:15 +00:00
Jeff ccb0e14e48 Merge pull request #450 from kgarner7/more-metadata
[feature]: Show album comment, Last.fm/MusicBrainz links
2024-02-02 14:56:46 -08:00
jeffvli aca6826221 Refactor artist biography to use spoiler 2024-02-02 01:39:19 -08:00
jeffvli 095edfd49f Add custom spoiler component 2024-02-02 01:38:58 -08:00
Kendall Garner 73cd647486 os lyrics 2024-02-01 23:53:10 -08:00
Kendall Garner 9e4664a54c Merge branch 'development' into fix-structured-lyrics 2024-02-01 22:13:41 -08:00
Kendall Garner efa0d9ec35 [enhancement]: larger player buttons, clearer random icon 2024-02-01 20:59:00 -08:00
Kendall Garner 9720fcc202 add server info query 2024-02-01 08:17:31 -08:00
jeffvli 7c25d12639 Enable external links by default 2024-02-01 04:14:02 -08:00
jeffvli 3daa1aef4b Normalize section spacing on album/artist detail pages 2024-02-01 04:06:56 -08:00
jeffvli 6aba41c3d9 Add spoiler UI component 2024-02-01 03:58:36 -08:00
Kendall Garner eff1cee6a3 [bugfix]: Fix repeated track scrobbling (#480)
* [bugfix]: Fix repeated track scrobbling

This commit fixes scrobbling tracks that are repeated in the queue (either due
to a REPEAT_ONE or just enqueued multiple times). There are two extra cases:

1. The current player changes. The player alternates from 1 -> 2 -> 1 whenever playback
finishes normally, so this is a sort of epoch that can be used to detect both repeat one
and track being enqueued multiple times.
2. The current index changes. The player gets set back to 1 in a variety of cases (most often
previous/next), so the check in (1) is not enough. However, the index changing will help identify
that this is a new song.

* use unique id instead
2024-02-01 04:12:39 +00:00
Kendall Garner 9995b2e774 [enhancement]: support clearing query and http cache (#475)
* [enhancement]: support clearing query and http cache

  - Adds the ability to invalidate all queries (useful for forcing refresh, and clearing lyrics which are cached forever)
  - [Desktop only] adds the ability to clear the Electron HTTP cache (e.g. cached images)

* use clearer language

* move cache settings to general
2024-01-31 06:27:56 +00:00
Kendall Garner 04a468f8c9 [bugfix]: fetch higher resolution artist images from Navidrome 2024-01-28 20:01:57 -08:00
Kendall Garner afb8510cd7 [enhancement]: increase padding for synchronizer lyric container 2024-01-28 19:42:23 -08:00
Kendall Garner 8287347f91 expose more mpris metadata 2024-01-28 14:26:02 -08:00
Kendall Garner 5cc2276781 [bugfix]: web player edge cases
Two bugs addressed in response to Safari changes:
1. Only handle `onEnded` for real streams, preventing `autoNext()` spam when the last track finishes
2. `.play()` is not necessarily a safe operation (if empty wave was started, can cause exception later). Catch this exception and discard it. This also only occurred when playing the last track
2024-01-28 01:50:51 -08:00
Kendall Garner 47ce0ed47b [bugfix]: fix repeat one with shuffle and one track 2024-01-28 00:49:17 -08:00
Kendall Garner f467a85a86 [bugfix]: use proper language key for lyrics size 2024-01-27 10:07:10 -08:00
Kendall Garner 097211954c [enhancement]: default to space for play/pause toggle 2024-01-27 08:15:13 -08:00
Kendall Garner 0cdfc64023 [bugfix]: allow Jellyfin to have empty password on edit 2024-01-26 22:54:50 -08:00
Kendall Garner bc7f4a5722 [bugfix]: Fix safari audio context (#471)
* test 3

* comments

* [bugfix]: SAFARI WHY. Use GainNode volume instead for volume control

* force vercel refresh?

* Revert "force vercel refresh?"

This reverts commit af31f38e03.

* move volume scaling calculation to setGain
2024-01-27 01:10:34 +00:00
Kendall Garner 8e7356fa7b Merge pull request #473 from kgarner7/fix-playlist-add-message
[bugfix]: Fix add to playlist success message
2024-01-27 00:51:00 +00:00
Kendall Garner bbf59a4942 [bugfix]: Fix add to playlist success message
The prior code used `form.addToPlaylist`, not `.success`. Also fixes English pluralization and
uses the correct `entity.track` as opposed to `entity.song` for other languages (I am not sure
if the en syntax could be applied to other languages, so I will just leave pluralization as-is
for now).
2024-01-25 23:58:21 -08:00
Kendall Garner 45e589fbb1 [enhancement]: use ColorInput for text rgb selection 2024-01-24 23:39:50 -08:00
Kendall Garner 527e6a76b5 [bugfix]: disable play button when queue is empty 2024-01-24 21:35:58 -08:00
Kendall Garner 362a88b6bc [bugfix]: fix race condition for opening play queue/playlist details 2024-01-24 21:15:11 -08:00
Kendall Garner 26102bd70a [bugfix]: do not change scroll position when focus changes 2024-01-24 21:05:26 -08:00
Kendall Garner 5f1d0a3b5e [bugfix]: Validate audio sample range, catch AudioContext error (#470) 2024-01-24 20:36:20 -08:00
Kendall Garner 3bca85b3a8 Merge pull request #435 from kgarner7/fix-init-timing
[bugfix]: defer restore queue until mpv exists
2024-01-23 05:18:20 +00:00
Kendall Garner 61ecd3253e Merge pull request #209 from jeffvli/fix/#202
Add frame to macOS native window bar
2024-01-23 05:07:10 +00:00
Kendall Garner 5e9ef9f23f use type, remove console 2024-01-22 18:52:14 -08:00
David Rappo c8701d1da4 Fixed incorrect label for album sort by release date (#458) 2024-01-22 10:09:15 -08:00
Kendall Garner b3a9e7ccba default theme dark 2024-01-21 22:58:10 -08:00
Kendall Garner 33972c2a83 titlebar switching 2024-01-21 22:47:59 -08:00
Kendall Garner f0f2f54e5a Merge remote-tracking branch 'upstream/development' into origin/fix/#202 2024-01-21 22:22:04 -08:00
Kendall Garner 99a188a62d Merge pull request #461 from jeffvli/dependabot/npm_and_yarn/follow-redirects-1.15.5
Bump follow-redirects from 1.15.2 to 1.15.5
2024-01-22 05:29:51 +00:00
Kendall Garner 1760e14ac5 Merge pull request #464 from jeffvli/dependabot/npm_and_yarn/release/app/electron-25.8.4
Bump electron from 25.3.0 to 25.8.4 in /release/app
2024-01-22 05:28:54 +00:00
Kendall Garner 888b5f9e90 Merge pull request #463 from jeffvli/dependabot/npm_and_yarn/postcss-8.4.33
Bump postcss from 8.4.27 to 8.4.33
2024-01-22 05:28:46 +00:00
Kendall Garner 5f099bedc2 Merge pull request #462 from jeffvli/dependabot/npm_and_yarn/babel/traverse-7.23.7
Bump @babel/traverse from 7.22.19 to 7.23.7
2024-01-22 05:28:39 +00:00
Kendall Garner b2bb2f33a9 Merge pull request #460 from jeffvli/dependabot/npm_and_yarn/zod-3.22.3
Bump zod from 3.21.4 to 3.22.3
2024-01-22 05:28:30 +00:00
dependabot[bot] f169fc7f3b Bump follow-redirects from 1.15.2 to 1.15.5
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.2 to 1.15.5.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.2...v1.15.5)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-22 04:41:16 +00:00
Kendall Garner a970f967bc Merge pull request #459 from jeffvli/dependabot/npm_and_yarn/axios-1.6.0
Bump axios from 1.5.1 to 1.6.0
2024-01-22 04:40:18 +00:00
dependabot[bot] 7c0320d69a Bump electron from 25.3.0 to 25.8.4 in /release/app
Bumps [electron](https://github.com/electron/electron) from 25.3.0 to 25.8.4.
- [Release notes](https://github.com/electron/electron/releases)
- [Changelog](https://github.com/electron/electron/blob/main/docs/breaking-changes.md)
- [Commits](https://github.com/electron/electron/compare/v25.3.0...v25.8.4)

---
updated-dependencies:
- dependency-name: electron
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-22 04:33:28 +00:00
dependabot[bot] 8fcfbce0d5 Bump postcss from 8.4.27 to 8.4.33
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.27 to 8.4.33.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.27...8.4.33)

---
updated-dependencies:
- dependency-name: postcss
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-22 04:33:15 +00:00
dependabot[bot] 432a128b85 Bump @babel/traverse from 7.22.19 to 7.23.7
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.22.19 to 7.23.7.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.7/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-22 04:33:13 +00:00
dependabot[bot] 85e889a414 Bump zod from 3.21.4 to 3.22.3
Bumps [zod](https://github.com/colinhacks/zod) from 3.21.4 to 3.22.3.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Changelog](https://github.com/colinhacks/zod/blob/master/CHANGELOG.md)
- [Commits](https://github.com/colinhacks/zod/compare/v3.21.4...v3.22.3)

---
updated-dependencies:
- dependency-name: zod
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-22 04:33:07 +00:00
dependabot[bot] 4df5c555b0 Bump axios from 1.5.1 to 1.6.0
Bumps [axios](https://github.com/axios/axios) from 1.5.1 to 1.6.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.5.1...v1.6.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-22 04:33:03 +00:00
marksdestiny 372b96a349 Improve the playback speed control (#437)
* Change playback speed selector to slider

* Improve playback speed bar

Display BPM
Improve style
Add markers

* Improve playback speed slider style
2024-01-21 20:06:46 -08:00
SlyFabi dcccccea2f Use startIndex in Jellyfin getPlaylistSongList (#449) 2024-01-21 19:46:34 -08:00
Leonardo Salgueiro 2095ff6ab9 Update scrobble-settings.tsx (#440) 2024-01-21 19:43:09 -08:00
Tarulia a0b761c9ac AppImage: Set proper categories on desktop entry (#430) 2024-01-21 19:41:23 -08:00
Kendall Garner ea67a18962 include lastfm/mbz links 2024-01-15 22:10:50 -08:00
Kendall Garner 5516daab6e enable comments, safer note 2024-01-15 20:46:06 -08:00
jeffvli 2f28cb07bc Reverse workflow changes 2024-01-14 08:32:33 -08:00
jeffvli 21cd657f0c Temporarily set releaseType to release 2024-01-14 08:18:49 -08:00
jeffvli 1ef7968834 Test electron builder action 2024-01-14 08:11:09 -08:00
jeffvli 925b1b4f68 Add release publish builds 2024-01-14 07:51:53 -08:00
jeffvli e7c665b0a0 Fix arm64 build command 2024-01-14 07:42:37 -08:00
jeffvli 245213d08a Remove unused rpm build target 2024-01-14 07:35:32 -08:00
Kendall Garner c5e08b643d [bugfix]: defer restore queue until mpv exists 2024-01-06 19:24:29 -08:00
jeffvli 960427fce8 Remove deb 2023-12-13 20:33:44 -08:00
jeffvli 118a9f3257 Separate regular and arm64 build 2023-12-13 20:32:03 -08:00
jeffvli 971bfe3823 Bump to v0.5.3 2023-12-13 19:40:51 -08:00
jeffvli 7765f14110 Add rpm and deb build targets 2023-12-13 19:39:06 -08:00
jeffvli 36b465504f Add arm64 linux build 2023-12-13 19:38:36 -08:00
jeffvli 7b639b45f7 Add new translations 2023-12-13 18:19:24 -08:00
Hosted Weblate 85d9162b12 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (519 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate 36670b330f Translated using Weblate (Swedish)
Currently translated at 52.6% (273 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mattias <mattiasghodsian@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/sv/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate 9c380a8241 Translated using Weblate (French)
Currently translated at 99.4% (516 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <KosmoMoustache@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate c26820ee82 Translated using Weblate (Dutch)
Currently translated at 35.8% (186 of 519 strings)

Translated using Weblate (Dutch)

Currently translated at 32.3% (168 of 519 strings)

Translated using Weblate (Dutch)

Currently translated at 15.2% (79 of 519 strings)

Added translation using Weblate (Dutch)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Idris Saklou <idrissaklou@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/nl/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate dccd6afc3d Translated using Weblate (Italian)
Currently translated at 99.0% (514 of 519 strings)

Translated using Weblate (Italian)

Currently translated at 96.5% (501 of 519 strings)

Co-authored-by: Aurora <arci@anche.no>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: NicKoehler <grillinicola@proton.me>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/it/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate c6a520b0d7 Translated using Weblate (Polish)
Currently translated at 99.8% (518 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mistify <fabianszafranski@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate 1f4f3a5497 Translated using Weblate (Czech)
Currently translated at 100.0% (519 of 519 strings)

Co-authored-by: Fjuro <ifjuro@proton.me>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate 58d04b3126 Translated using Weblate (German)
Currently translated at 88.8% (461 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Maik <maikguenes2003@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Hosted Weblate fcac4a5547 Translated using Weblate (Portuguese (Brazil))
Currently translated at 28.3% (147 of 519 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Rafael Vieira <rafaelvieiras@pm.me>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pt_BR/
Translation: feishin/Translation
2023-12-13 09:28:56 +01:00
Kendall Garner c05b474827 fix navi null date (#408) 2023-12-13 00:28:53 -08:00
mcneb10 a8814d3e8a Fix 'undefined' in window title when song has no artist name (#402) 2023-12-05 19:05:08 -08:00
Kendall Garner 3f9cdab450 convert value to number on set (#390) 2023-12-04 20:20:19 -08:00
jeffvli 6f969294b0 Set macos titlebarstyle to default 2023-08-10 01:54:15 -07:00
jeffvli 3c278d5e17 Add frame to macOS native window bar 2023-08-09 21:07:27 -07:00
154 changed files with 5426 additions and 1718 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
Dockerfile
docker-compose.*
+158 -155
View File
@@ -16,7 +16,7 @@ import webpackPaths from './webpack.paths';
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
checkNodeEnv('development');
}
const port = process.env.PORT || 4343;
@@ -28,171 +28,174 @@ const requiredByDLLConfig = module.parent!.filename.includes('webpack.config.ren
* Warn if the DLL is not built
*/
if (!requiredByDLLConfig && !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))) {
console.log(
chalk.black.bgYellow.bold(
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
),
);
execSync('npm run postinstall');
console.log(
chalk.black.bgYellow.bold(
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
),
);
execSync('npm run postinstall');
}
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
devtool: 'inline-source-map',
mode: 'development',
mode: 'development',
target: ['web', 'electron-renderer'],
target: ['web', 'electron-renderer'],
entry: [
`webpack-dev-server/client?http://localhost:${port}/dist`,
'webpack/hot/only-dev-server',
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
],
output: {
path: webpackPaths.distRendererPath,
publicPath: '/',
filename: 'renderer.dev.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
entry: [
`webpack-dev-server/client?http://localhost:${port}/dist`,
'webpack/hot/only-dev-server',
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
],
},
plugins: [
...(requiredByDLLConfig
? []
: [
new webpack.DllReferencePlugin({
context: webpackPaths.dllPath,
manifest: require(manifest),
sourceType: 'var',
}),
]),
new webpack.NoEmitOnErrorsPlugin(),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
new ReactRefreshWebpackPlugin(),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
}),
],
node: {
__dirname: false,
__filename: false,
},
devServer: {
port,
compress: true,
hot: true,
headers: { 'Access-Control-Allow-Origin': '*' },
static: {
publicPath: '/',
output: {
path: webpackPaths.distRendererPath,
publicPath: '/',
filename: 'renderer.dev.js',
library: {
type: 'umd',
},
},
historyApiFallback: {
verbose: true,
},
setupMiddlewares(middlewares) {
console.log('Starting preload.js builder...');
const preloadProcess = spawn('npm', ['run', 'start:preload'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => process.exit(code!))
.on('error', (spawnError) => console.error(spawnError));
console.log('Starting remote.js builder...');
const remoteProcess = spawn('npm', ['run', 'start:remote'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => process.exit(code!))
.on('error', (spawnError) => console.error(spawnError));
console.log('Starting Main Process...');
spawn('npm', ['run', 'start:main'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => {
preloadProcess.kill();
remoteProcess.kill();
process.exit(code!);
})
.on('error', (spawnError) => console.error(spawnError));
return middlewares;
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
plugins: [
...(requiredByDLLConfig
? []
: [
new webpack.DllReferencePlugin({
context: webpackPaths.dllPath,
manifest: require(manifest),
sourceType: 'var',
}),
]),
new webpack.NoEmitOnErrorsPlugin(),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
new ReactRefreshWebpackPlugin(),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
templateParameters: {
web: false,
},
}),
],
node: {
__dirname: false,
__filename: false,
},
devServer: {
port,
compress: true,
hot: true,
headers: { 'Access-Control-Allow-Origin': '*' },
static: {
publicPath: '/',
},
historyApiFallback: {
verbose: true,
},
setupMiddlewares(middlewares) {
console.log('Starting preload.js builder...');
const preloadProcess = spawn('npm', ['run', 'start:preload'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => process.exit(code!))
.on('error', (spawnError) => console.error(spawnError));
console.log('Starting remote.js builder...');
const remoteProcess = spawn('npm', ['run', 'start:remote'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => process.exit(code!))
.on('error', (spawnError) => console.error(spawnError));
console.log('Starting Main Process...');
spawn('npm', ['run', 'start:main'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => {
preloadProcess.kill();
remoteProcess.kill();
process.exit(code!);
})
.on('error', (spawnError) => console.error(spawnError));
return middlewares;
},
},
},
};
export default merge(baseConfig, configuration);
+100 -97
View File
@@ -21,114 +21,117 @@ checkNodeEnv('production');
deleteSourceMaps();
const devtoolsConfig =
process.env.DEBUG_PROD === 'true'
? {
devtool: 'source-map',
}
: {};
process.env.DEBUG_PROD === 'true'
? {
devtool: 'source-map',
}
: {};
const configuration: webpack.Configuration = {
...devtoolsConfig,
...devtoolsConfig,
mode: 'production',
mode: 'production',
target: ['web', 'electron-renderer'],
target: ['web', 'electron-renderer'],
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
output: {
path: webpackPaths.distRendererPath,
publicPath: './',
filename: 'renderer.js',
library: {
type: 'umd',
output: {
path: webpackPaths.distRendererPath,
publicPath: './',
filename: 'renderer.js',
library: {
type: 'umd',
},
},
},
module: {
rules: [
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
sourceMap: true,
importLoaders: 1,
module: {
rules: [
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?(a|c)ss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?(a|c)ss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
}),
new CssMinimizerPlugin(),
],
},
plugins: [
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
}),
new MiniCssExtractPlugin({
filename: 'style.css',
}),
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
isDevelopment: process.env.NODE_ENV !== 'production',
templateParameters: {
web: false,
},
}),
],
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
}),
new CssMinimizerPlugin(),
],
},
plugins: [
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
}),
new MiniCssExtractPlugin({
filename: 'style.css',
}),
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
isDevelopment: process.env.NODE_ENV !== 'production',
}),
],
};
export default merge(baseConfig, configuration);
@@ -116,6 +116,9 @@ const configuration: webpack.Configuration = {
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
templateParameters: {
web: false, // with hot reload, we don't have NGINX injecting variables
},
}),
],
+3
View File
@@ -128,6 +128,9 @@ const configuration: webpack.Configuration = {
},
isBrowser: false,
isDevelopment: process.env.NODE_ENV !== 'production',
templateParameters: {
web: true,
},
}),
],
};
+14
View File
@@ -37,3 +37,17 @@ jobs:
npm run build
npm exec electron-builder -- --publish always --linux
on_retry_command: npm cache clean --force
- name: Publish releases (arm64)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --arm64
on_retry_command: npm cache clean --force
+4 -4
View File
@@ -11,10 +11,10 @@
],
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true,
"source.organizeImports": false,
"source.formatDocument": true
"source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit",
"source.organizeImports": "never",
"source.formatDocument": "explicit"
},
"css.validate": true,
"less.validate": false,
+5 -1
View File
@@ -1,16 +1,20 @@
# --- Builder stage
FROM node:18-alpine as builder
WORKDIR /app
COPY . /app
#Copy package.json first to cache node_modules
COPY package.json package-lock.json .
# Scripts include electron-specific dependencies, which we don't need
RUN npm install --legacy-peer-deps --ignore-scripts
#Copy code and build with cached modules
COPY . .
RUN npm run build:web
# --- Production stage
FROM nginx:alpine-slim
COPY --chown=nginx:nginx --from=builder /app/release/app/dist/web /usr/share/nginx/html
COPY ./settings.js.template /etc/nginx/templates/settings.js.template
COPY ng.conf.template /etc/nginx/templates/default.conf.template
ENV PUBLIC_PATH="/"
+8 -1
View File
@@ -49,8 +49,12 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases). The desktop client is the recommended way to use Feishin. It supports both the MPV and web player backends, as well as includes built-in fetching for lyrics.
#### MacOS Notes
If you're using a device running macOS 12 (Monterey) or higher, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.
For media keys to work, you will be prompted to allow Feishin to be a Trusted Accessibility Client. After allowing, you will need to restart Feishin for the privacy settings to take effect.
### Web and Docker
Visit [https://feishin.vercel.app](https://feishin.vercel.app) to use the hosted web version of Feishin. The web client only supports the web player backend.
@@ -73,9 +77,12 @@ docker run --name feishin -p 9180:9180 feishin
2. After restarting the app, you will be prompted to select a server. Click the `Open menu` button and select `Manage servers`. Click the `Add server` button in the popup and fill out all applicable details. You will need to enter the full URL to your server, including the protocol and port if applicable (e.g. `https://navidrome.my-server.com` or `http://192.168.0.1:4533`).
- **Navidrome** - For the best experience, select "Save password" when creating the server and configure the `SessionTimeout` setting in your Navidrome config to a larger value (e.g. 72h).
- **Linux users** - The default password store uses `libsecret`. `kwallet4/5/6` are also supported, but must be explicitly set in Settings > Window > Passwords/secret score.
3. _Optional_ - If you want to host Feishin on a subpath (not `/`), then pass in the following environment variable: `PUBLIC_PATH=PATH`. For example, to host on `/feishin`, pass in `PUBLIC_PATH=/feishin`.
4. _Optional_ - To hard code the server url, pass the following environment variables: `SERVER_NAME`, `SERVER_TYPE` (one of `jellyfin` or `navidrome`), `SERVER_URL`. To prevent users from changing these settings, pass `SERVER_LOCK=true`. This can only be set if all three of the previous values are set.
## FAQ
### MPV is either not working or is rapidly switching between pause/play states
@@ -86,7 +93,7 @@ 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)
- [Navidrome](https://github.com/navidrome/navidrome) version 0.48.0 and newer
- [Jellyfin](https://github.com/jellyfin/jellyfin)
- [Funkwhale](https://funkwhale.audio/) - TBD
- Subsonic-compatible servers - TBD
+13
View File
@@ -0,0 +1,13 @@
version: '3.5'
services:
feishin:
container_name: feishin
image: jeffvli/feishin
restart: unless-stopped
ports:
- 9180:9180
environment:
- SERVER_NAME=jellyfin # pre defined server name
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
- SERVER_TYPE=jellyfin # navidrome also works
- SERVER_URL= # http://address:port
+8
View File
@@ -16,4 +16,12 @@ server {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /index.html =404;
}
location ${PUBLIC_PATH}settings.js {
alias /etc/nginx/conf.d/settings.js;
}
location ${PUBLIC_PATH}/settings.js {
alias /etc/nginx/conf.d/settings.js;
}
}
+189 -258
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.5.2",
"version": "0.6.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.5.2",
"version": "0.6.1",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
@@ -28,13 +28,13 @@
"@tanstack/react-query-persist-client": "^4.32.1",
"@ts-rest/core": "^3.23.0",
"@xhayper/discord-rpc": "^1.0.24",
"axios": "^1.4.0",
"axios": "^1.6.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"dayjs": "^1.11.6",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
"electron-log": "^4.4.6",
"electron-log": "^5.1.1",
"electron-store": "^8.1.0",
"electron-updater": "^4.6.5",
"fast-average-color": "^9.3.0",
@@ -66,9 +66,10 @@
"react-virtualized-auto-sizer": "^1.0.17",
"react-window": "^1.8.9",
"react-window-infinite-loader": "^1.0.9",
"semver": "^7.5.4",
"styled-components": "^6.0.8",
"swiper": "^9.3.1",
"zod": "^3.21.4",
"zod": "^3.22.3",
"zustand": "^4.3.9"
},
"devDependencies": {
@@ -103,7 +104,7 @@
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^27.1.0",
"electron": "^26.6.10",
"electron-builder": "^24.9.0",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
@@ -113,7 +114,7 @@
"eslint-config-erb": "^4.0.3",
"eslint-import-resolver-typescript": "^2.7.1",
"eslint-import-resolver-webpack": "^0.13.2",
"eslint-plugin-compat": "^4.0.2",
"eslint-plugin-compat": "^4.2.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^26.1.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
@@ -286,11 +287,11 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.22.13",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
"version": "7.23.5",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz",
"integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==",
"dependencies": {
"@babel/highlight": "^7.22.13",
"@babel/highlight": "^7.23.4",
"chalk": "^2.4.2"
},
"engines": {
@@ -407,11 +408,11 @@
}
},
"node_modules/@babel/generator": {
"version": "7.22.15",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.15.tgz",
"integrity": "sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==",
"version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
"integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==",
"dependencies": {
"@babel/types": "^7.22.15",
"@babel/types": "^7.23.6",
"@jridgewell/gen-mapping": "^0.3.2",
"@jridgewell/trace-mapping": "^0.3.17",
"jsesc": "^2.5.1"
@@ -548,20 +549,20 @@
}
},
"node_modules/@babel/helper-environment-visitor": {
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz",
"integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==",
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
"integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-function-name": {
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz",
"integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==",
"version": "7.23.0",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz",
"integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==",
"dependencies": {
"@babel/template": "^7.22.5",
"@babel/types": "^7.22.5"
"@babel/template": "^7.22.15",
"@babel/types": "^7.23.0"
},
"engines": {
"node": ">=6.9.0"
@@ -703,17 +704,17 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
"integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.22.19",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.19.tgz",
"integrity": "sha512-Tinq7ybnEPFFXhlYOYFiSjespWQk0dq2dRNAiMdRTOYQzEGqnnNyrTxPYHP5r6wGjlF1rFgABdDV0g8EwD6Qbg==",
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
"engines": {
"node": ">=6.9.0"
}
@@ -753,11 +754,11 @@
}
},
"node_modules/@babel/highlight": {
"version": "7.22.13",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.13.tgz",
"integrity": "sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
"integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
"dependencies": {
"@babel/helper-validator-identifier": "^7.22.5",
"@babel/helper-validator-identifier": "^7.22.20",
"chalk": "^2.4.2",
"js-tokens": "^4.0.0"
},
@@ -830,9 +831,9 @@
}
},
"node_modules/@babel/parser": {
"version": "7.22.16",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.16.tgz",
"integrity": "sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==",
"version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz",
"integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==",
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -2178,19 +2179,19 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.22.19",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.19.tgz",
"integrity": "sha512-ZCcpVPK64krfdScRbpxF6xA5fz7IOsfMwx1tcACvCzt6JY+0aHkBk7eIU8FRDSZRU5Zei6Z4JfgAxN1bqXGECg==",
"version": "7.23.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz",
"integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==",
"dependencies": {
"@babel/code-frame": "^7.22.13",
"@babel/generator": "^7.22.15",
"@babel/helper-environment-visitor": "^7.22.5",
"@babel/helper-function-name": "^7.22.5",
"@babel/code-frame": "^7.23.5",
"@babel/generator": "^7.23.6",
"@babel/helper-environment-visitor": "^7.22.20",
"@babel/helper-function-name": "^7.23.0",
"@babel/helper-hoist-variables": "^7.22.5",
"@babel/helper-split-export-declaration": "^7.22.6",
"@babel/parser": "^7.22.16",
"@babel/types": "^7.22.19",
"debug": "^4.1.0",
"@babel/parser": "^7.23.6",
"@babel/types": "^7.23.6",
"debug": "^4.3.1",
"globals": "^11.1.0"
},
"engines": {
@@ -2206,12 +2207,12 @@
}
},
"node_modules/@babel/types": {
"version": "7.22.19",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.19.tgz",
"integrity": "sha512-P7LAw/LbojPzkgp5oznjE6tQEIWbp4PkkfrZDINTro9zgBRtI324/EYsiSI7lhPbpIQ+DCeR2NNmMWANGGfZsg==",
"version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz",
"integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==",
"dependencies": {
"@babel/helper-string-parser": "^7.22.5",
"@babel/helper-validator-identifier": "^7.22.19",
"@babel/helper-string-parser": "^7.23.4",
"@babel/helper-validator-identifier": "^7.22.20",
"to-fast-properties": "^2.0.0"
},
"engines": {
@@ -3861,9 +3862,9 @@
}
},
"node_modules/@mdn/browser-compat-data": {
"version": "5.3.5",
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.3.5.tgz",
"integrity": "sha512-aGZ/OZCv96fEvUNuOoHbWN9ySODB1Xf04+TFgK3QO/PaM1NI95INaf0YUBLUbbK9lMKWqUzyJk2TPLe3ybFFXg==",
"version": "5.5.10",
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.5.10.tgz",
"integrity": "sha512-s2GGND9oLhEuksOFtICYOBZdMWPANBXTMqAXh89q6g1Mi3+OuWEmp9WFzw2v/nmS175vqeewpC1kDJA7taaxyA==",
"dev": true
},
"node_modules/@nicolo-ribaudo/chokidar-2": {
@@ -6325,9 +6326,9 @@
}
},
"node_modules/axios": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz",
"integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz",
"integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
@@ -6445,30 +6446,6 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
"node_modules/babel-plugin-styled-components": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.0.7.tgz",
"integrity": "sha512-i7YhvPgVqRKfoQ66toiZ06jPNA3p6ierpfUuEWxNF+fV27Uv5gxBkf8KZLHUCc1nFA9j6+80pYoIpqCeyW3/bA==",
"optional": true,
"peer": true,
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.16.0",
"@babel/helper-module-imports": "^7.16.0",
"babel-plugin-syntax-jsx": "^6.18.0",
"lodash": "^4.17.11",
"picomatch": "^2.3.0"
},
"peerDependencies": {
"styled-components": ">= 2"
}
},
"node_modules/babel-plugin-syntax-jsx": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==",
"optional": true,
"peer": true
},
"node_modules/babel-preset-current-node-syntax": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz",
@@ -7097,9 +7074,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001517",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz",
"integrity": "sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==",
"version": "1.0.30001585",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001585.tgz",
"integrity": "sha512-yr2BWR1yLXQ8fMpdS/4ZZXpseBgE7o4g41x3a6AJOqZuOi+iE/WdJYAuZ6Y95i4Ohd2Y+9MzIWRR+uGABH4s3Q==",
"funding": [
{
"type": "opencollective",
@@ -8920,9 +8897,9 @@
}
},
"node_modules/electron": {
"version": "27.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-27.1.0.tgz",
"integrity": "sha512-XPdJiO475QJ8cx59/goWNNWnlV0vab+Ut3occymos7VDxkHV5mFrlW6tcGi+M3bW6gBfwpJocWMng8tw542vww==",
"version": "26.6.10",
"resolved": "https://registry.npmjs.org/electron/-/electron-26.6.10.tgz",
"integrity": "sha512-pV2SD0RXzAiNRb/2yZrsVmVkBOMrf+DVsPulIgRjlL0+My9BL5spFuhHVMQO9yHl9tFpWtuRpQv0ofM/i9P8xg==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@@ -9098,9 +9075,12 @@
}
},
"node_modules/electron-log": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.6.tgz",
"integrity": "sha512-nirYgRdY+F+vclr8ijdwy2vW03IzFpDHTaKNWu76dEN21Y76+smcES5knS7cgHUUB0qNLOi8vZO36taakjbSXA=="
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.1.1.tgz",
"integrity": "sha512-If7HU4Slbh2xfjOXOLxifkbgu6HmWDNJyXPLW+XNTOHMfFKisg0trA3d/7syyu25S+lHosfsd0VMfDSjGn1+Pw==",
"engines": {
"node": ">= 14"
}
},
"node_modules/electron-notarize": {
"version": "1.2.1",
@@ -9949,19 +9929,18 @@
}
},
"node_modules/eslint-plugin-compat": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-compat/-/eslint-plugin-compat-4.1.4.tgz",
"integrity": "sha512-RxySWBmzfIROLFKgeJBJue2BU/6vM2KJWXWAUq+oW4QtrsZXRxbjgxmO1OfF3sHcRuuIenTS/wgo3GyUWZF24w==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-compat/-/eslint-plugin-compat-4.2.0.tgz",
"integrity": "sha512-RDKSYD0maWy5r7zb5cWQS+uSPc26mgOzdORJ8hxILmWM7S/Ncwky7BcAtXVY5iRbKjBdHsWU8Yg7hfoZjtkv7w==",
"dev": true,
"dependencies": {
"@mdn/browser-compat-data": "^5.2.47",
"@tsconfig/node14": "^1.0.3",
"@mdn/browser-compat-data": "^5.3.13",
"ast-metadata-inferer": "^0.8.0",
"browserslist": "^4.21.5",
"caniuse-lite": "^1.0.30001473",
"browserslist": "^4.21.10",
"caniuse-lite": "^1.0.30001524",
"find-up": "^5.0.0",
"lodash.memoize": "4.1.2",
"semver": "7.3.8"
"lodash.memoize": "^4.1.2",
"semver": "^7.5.4"
},
"engines": {
"node": ">=14.x"
@@ -11056,9 +11035,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
"funding": [
{
"type": "individual",
@@ -12553,9 +12532,9 @@
}
},
"node_modules/ip": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
"integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=",
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
"integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==",
"dev": true
},
"node_modules/ipaddr.js": {
@@ -15083,9 +15062,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"funding": [
{
"type": "github",
@@ -15970,9 +15949,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.27",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz",
"integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==",
"version": "8.4.33",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz",
"integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==",
"funding": [
{
"type": "opencollective",
@@ -15988,7 +15967,7 @@
}
],
"dependencies": {
"nanoid": "^3.3.6",
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
@@ -17952,9 +17931,9 @@
}
},
"node_modules/semver": {
"version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
"integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dependencies": {
"lru-cache": "^6.0.0"
},
@@ -18251,21 +18230,6 @@
"node": ">=10"
}
},
"node_modules/simple-update-notifier/node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sirv": {
"version": "1.0.19",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.19.tgz",
@@ -19979,9 +19943,9 @@
}
},
"node_modules/tslib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
"integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"node_modules/tsutils": {
"version": "3.21.0",
@@ -21387,9 +21351,9 @@
}
},
"node_modules/zod": {
"version": "3.21.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
"integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz",
"integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -21510,11 +21474,11 @@
}
},
"@babel/code-frame": {
"version": "7.22.13",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
"version": "7.23.5",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz",
"integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==",
"requires": {
"@babel/highlight": "^7.22.13",
"@babel/highlight": "^7.23.4",
"chalk": "^2.4.2"
},
"dependencies": {
@@ -21604,11 +21568,11 @@
}
},
"@babel/generator": {
"version": "7.22.15",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.15.tgz",
"integrity": "sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==",
"version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
"integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==",
"requires": {
"@babel/types": "^7.22.15",
"@babel/types": "^7.23.6",
"@jridgewell/gen-mapping": "^0.3.2",
"@jridgewell/trace-mapping": "^0.3.17",
"jsesc": "^2.5.1"
@@ -21715,17 +21679,17 @@
}
},
"@babel/helper-environment-visitor": {
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz",
"integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q=="
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
"integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA=="
},
"@babel/helper-function-name": {
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz",
"integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==",
"version": "7.23.0",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz",
"integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==",
"requires": {
"@babel/template": "^7.22.5",
"@babel/types": "^7.22.5"
"@babel/template": "^7.22.15",
"@babel/types": "^7.23.0"
}
},
"@babel/helper-hoist-variables": {
@@ -21822,14 +21786,14 @@
}
},
"@babel/helper-string-parser": {
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw=="
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
"integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ=="
},
"@babel/helper-validator-identifier": {
"version": "7.22.19",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.19.tgz",
"integrity": "sha512-Tinq7ybnEPFFXhlYOYFiSjespWQk0dq2dRNAiMdRTOYQzEGqnnNyrTxPYHP5r6wGjlF1rFgABdDV0g8EwD6Qbg=="
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A=="
},
"@babel/helper-validator-option": {
"version": "7.22.15",
@@ -21857,11 +21821,11 @@
}
},
"@babel/highlight": {
"version": "7.22.13",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.13.tgz",
"integrity": "sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
"integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
"requires": {
"@babel/helper-validator-identifier": "^7.22.5",
"@babel/helper-validator-identifier": "^7.22.20",
"chalk": "^2.4.2",
"js-tokens": "^4.0.0"
},
@@ -21918,9 +21882,9 @@
}
},
"@babel/parser": {
"version": "7.22.16",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.16.tgz",
"integrity": "sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA=="
"version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz",
"integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ=="
},
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
"version": "7.22.15",
@@ -22789,19 +22753,19 @@
}
},
"@babel/traverse": {
"version": "7.22.19",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.19.tgz",
"integrity": "sha512-ZCcpVPK64krfdScRbpxF6xA5fz7IOsfMwx1tcACvCzt6JY+0aHkBk7eIU8FRDSZRU5Zei6Z4JfgAxN1bqXGECg==",
"version": "7.23.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz",
"integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==",
"requires": {
"@babel/code-frame": "^7.22.13",
"@babel/generator": "^7.22.15",
"@babel/helper-environment-visitor": "^7.22.5",
"@babel/helper-function-name": "^7.22.5",
"@babel/code-frame": "^7.23.5",
"@babel/generator": "^7.23.6",
"@babel/helper-environment-visitor": "^7.22.20",
"@babel/helper-function-name": "^7.23.0",
"@babel/helper-hoist-variables": "^7.22.5",
"@babel/helper-split-export-declaration": "^7.22.6",
"@babel/parser": "^7.22.16",
"@babel/types": "^7.22.19",
"debug": "^4.1.0",
"@babel/parser": "^7.23.6",
"@babel/types": "^7.23.6",
"debug": "^4.3.1",
"globals": "^11.1.0"
},
"dependencies": {
@@ -22813,12 +22777,12 @@
}
},
"@babel/types": {
"version": "7.22.19",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.19.tgz",
"integrity": "sha512-P7LAw/LbojPzkgp5oznjE6tQEIWbp4PkkfrZDINTro9zgBRtI324/EYsiSI7lhPbpIQ+DCeR2NNmMWANGGfZsg==",
"version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz",
"integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==",
"requires": {
"@babel/helper-string-parser": "^7.22.5",
"@babel/helper-validator-identifier": "^7.22.19",
"@babel/helper-string-parser": "^7.23.4",
"@babel/helper-validator-identifier": "^7.22.20",
"to-fast-properties": "^2.0.0"
}
},
@@ -24055,9 +24019,9 @@
"requires": {}
},
"@mdn/browser-compat-data": {
"version": "5.3.5",
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.3.5.tgz",
"integrity": "sha512-aGZ/OZCv96fEvUNuOoHbWN9ySODB1Xf04+TFgK3QO/PaM1NI95INaf0YUBLUbbK9lMKWqUzyJk2TPLe3ybFFXg==",
"version": "5.5.10",
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.5.10.tgz",
"integrity": "sha512-s2GGND9oLhEuksOFtICYOBZdMWPANBXTMqAXh89q6g1Mi3+OuWEmp9WFzw2v/nmS175vqeewpC1kDJA7taaxyA==",
"dev": true
},
"@nicolo-ribaudo/chokidar-2": {
@@ -26001,9 +25965,9 @@
"dev": true
},
"axios": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz",
"integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz",
"integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==",
"requires": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
@@ -26095,27 +26059,6 @@
"@babel/helper-define-polyfill-provider": "^0.4.2"
}
},
"babel-plugin-styled-components": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.0.7.tgz",
"integrity": "sha512-i7YhvPgVqRKfoQ66toiZ06jPNA3p6ierpfUuEWxNF+fV27Uv5gxBkf8KZLHUCc1nFA9j6+80pYoIpqCeyW3/bA==",
"optional": true,
"peer": true,
"requires": {
"@babel/helper-annotate-as-pure": "^7.16.0",
"@babel/helper-module-imports": "^7.16.0",
"babel-plugin-syntax-jsx": "^6.18.0",
"lodash": "^4.17.11",
"picomatch": "^2.3.0"
}
},
"babel-plugin-syntax-jsx": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==",
"optional": true,
"peer": true
},
"babel-preset-current-node-syntax": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz",
@@ -26597,9 +26540,9 @@
}
},
"caniuse-lite": {
"version": "1.0.30001517",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz",
"integrity": "sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA=="
"version": "1.0.30001585",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001585.tgz",
"integrity": "sha512-yr2BWR1yLXQ8fMpdS/4ZZXpseBgE7o4g41x3a6AJOqZuOi+iE/WdJYAuZ6Y95i4Ohd2Y+9MzIWRR+uGABH4s3Q=="
},
"chalk": {
"version": "4.1.2",
@@ -27958,9 +27901,9 @@
}
},
"electron": {
"version": "27.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-27.1.0.tgz",
"integrity": "sha512-XPdJiO475QJ8cx59/goWNNWnlV0vab+Ut3occymos7VDxkHV5mFrlW6tcGi+M3bW6gBfwpJocWMng8tw542vww==",
"version": "26.6.10",
"resolved": "https://registry.npmjs.org/electron/-/electron-26.6.10.tgz",
"integrity": "sha512-pV2SD0RXzAiNRb/2yZrsVmVkBOMrf+DVsPulIgRjlL0+My9BL5spFuhHVMQO9yHl9tFpWtuRpQv0ofM/i9P8xg==",
"dev": true,
"requires": {
"@electron/get": "^2.0.0",
@@ -28109,9 +28052,9 @@
}
},
"electron-log": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.6.tgz",
"integrity": "sha512-nirYgRdY+F+vclr8ijdwy2vW03IzFpDHTaKNWu76dEN21Y76+smcES5knS7cgHUUB0qNLOi8vZO36taakjbSXA=="
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.1.1.tgz",
"integrity": "sha512-If7HU4Slbh2xfjOXOLxifkbgu6HmWDNJyXPLW+XNTOHMfFKisg0trA3d/7syyu25S+lHosfsd0VMfDSjGn1+Pw=="
},
"electron-notarize": {
"version": "1.2.1",
@@ -28780,19 +28723,18 @@
}
},
"eslint-plugin-compat": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-compat/-/eslint-plugin-compat-4.1.4.tgz",
"integrity": "sha512-RxySWBmzfIROLFKgeJBJue2BU/6vM2KJWXWAUq+oW4QtrsZXRxbjgxmO1OfF3sHcRuuIenTS/wgo3GyUWZF24w==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-compat/-/eslint-plugin-compat-4.2.0.tgz",
"integrity": "sha512-RDKSYD0maWy5r7zb5cWQS+uSPc26mgOzdORJ8hxILmWM7S/Ncwky7BcAtXVY5iRbKjBdHsWU8Yg7hfoZjtkv7w==",
"dev": true,
"requires": {
"@mdn/browser-compat-data": "^5.2.47",
"@tsconfig/node14": "^1.0.3",
"@mdn/browser-compat-data": "^5.3.13",
"ast-metadata-inferer": "^0.8.0",
"browserslist": "^4.21.5",
"caniuse-lite": "^1.0.30001473",
"browserslist": "^4.21.10",
"caniuse-lite": "^1.0.30001524",
"find-up": "^5.0.0",
"lodash.memoize": "4.1.2",
"semver": "7.3.8"
"lodash.memoize": "^4.1.2",
"semver": "^7.5.4"
},
"dependencies": {
"ast-metadata-inferer": {
@@ -29589,9 +29531,9 @@
}
},
"follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw=="
},
"form-data": {
"version": "4.0.0",
@@ -30718,9 +30660,9 @@
}
},
"ip": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
"integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=",
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
"integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==",
"dev": true
},
"ipaddr.js": {
@@ -32603,9 +32545,9 @@
}
},
"nanoid": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA=="
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
},
"natural-compare": {
"version": "1.4.0",
@@ -33273,11 +33215,11 @@
}
},
"postcss": {
"version": "8.4.27",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz",
"integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==",
"version": "8.4.33",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz",
"integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==",
"requires": {
"nanoid": "^3.3.6",
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
}
@@ -34650,9 +34592,9 @@
}
},
"semver": {
"version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
"integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"requires": {
"lru-cache": "^6.0.0"
}
@@ -34895,17 +34837,6 @@
"dev": true,
"requires": {
"semver": "^7.5.3"
},
"dependencies": {
"semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dev": true,
"requires": {
"lru-cache": "^6.0.0"
}
}
}
},
"sirv": {
@@ -36220,9 +36151,9 @@
}
},
"tslib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
"integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"tsutils": {
"version": "3.21.0",
@@ -37255,9 +37186,9 @@
"dev": true
},
"zod": {
"version": "3.21.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
"integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw=="
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz",
"integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="
},
"zustand": {
"version": "4.3.9",
+9 -8
View File
@@ -2,14 +2,14 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.5.2",
"version": "0.6.1",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
"build:remote": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.prod.ts",
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"build:web": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.web.prod.ts",
"build:docker": "npm run build:web && docker build -t jeffvli/feishin .",
"build:docker": "docker build -t jeffvli/feishin .",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"",
"lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
@@ -132,7 +132,7 @@
"tar.xz"
],
"icon": "assets/icons/icon.png",
"category": "Development"
"category": "AudioVideo;Audio;Player"
},
"directories": {
"app": "release/app",
@@ -230,7 +230,7 @@
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^27.1.0",
"electron": "^26.6.10",
"electron-builder": "^24.9.0",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
@@ -240,7 +240,7 @@
"eslint-config-erb": "^4.0.3",
"eslint-import-resolver-typescript": "^2.7.1",
"eslint-import-resolver-webpack": "^0.13.2",
"eslint-plugin-compat": "^4.0.2",
"eslint-plugin-compat": "^4.2.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^26.1.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
@@ -307,13 +307,13 @@
"@tanstack/react-query-persist-client": "^4.32.1",
"@ts-rest/core": "^3.23.0",
"@xhayper/discord-rpc": "^1.0.24",
"axios": "^1.4.0",
"axios": "^1.6.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"dayjs": "^1.11.6",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
"electron-log": "^4.4.6",
"electron-log": "^5.1.1",
"electron-store": "^8.1.0",
"electron-updater": "^4.6.5",
"fast-average-color": "^9.3.0",
@@ -345,9 +345,10 @@
"react-virtualized-auto-sizer": "^1.0.17",
"react-window": "^1.8.9",
"react-window-infinite-loader": "^1.0.9",
"semver": "^7.5.4",
"styled-components": "^6.0.8",
"swiper": "^9.3.1",
"zod": "^3.21.4",
"zod": "^3.22.3",
"zustand": "^4.3.9"
},
"resolutions": {
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.5.2",
"version": "0.6.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.5.2",
"version": "0.6.1",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
@@ -15,7 +15,7 @@
"ws": "^8.13.0"
},
"devDependencies": {
"electron": "25.3.0"
"electron": "25.8.4"
}
},
"node_modules/@electron/get": {
@@ -453,9 +453,9 @@
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
},
"node_modules/electron": {
"version": "25.3.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-25.3.0.tgz",
"integrity": "sha512-cyqotxN+AroP5h2IxUsJsmehYwP5LrFAOO7O7k9tILME3Sa1/POAg3shrhx4XEnaAMyMqMLxzGvkzCVxzEErnA==",
"version": "25.8.4",
"resolved": "https://registry.npmjs.org/electron/-/electron-25.8.4.tgz",
"integrity": "sha512-hUYS3RGdaa6E1UWnzeGnsdsBYOggwMMg4WGxNGvAoWtmRrr6J1BsjFW/yRq4WsJHJce2HdzQXtz4OGXV6yUCLg==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@@ -647,9 +647,9 @@
}
},
"node_modules/global-agent/node_modules/semver": {
"version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
"integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dev": true,
"optional": true,
"dependencies": {
@@ -1166,9 +1166,9 @@
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
},
"node_modules/semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
@@ -1672,9 +1672,9 @@
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
},
"electron": {
"version": "25.3.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-25.3.0.tgz",
"integrity": "sha512-cyqotxN+AroP5h2IxUsJsmehYwP5LrFAOO7O7k9tILME3Sa1/POAg3shrhx4XEnaAMyMqMLxzGvkzCVxzEErnA==",
"version": "25.8.4",
"resolved": "https://registry.npmjs.org/electron/-/electron-25.8.4.tgz",
"integrity": "sha512-hUYS3RGdaa6E1UWnzeGnsdsBYOggwMMg4WGxNGvAoWtmRrr6J1BsjFW/yRq4WsJHJce2HdzQXtz4OGXV6yUCLg==",
"dev": true,
"requires": {
"@electron/get": "^2.0.0",
@@ -1818,9 +1818,9 @@
},
"dependencies": {
"semver": {
"version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
"integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dev": true,
"optional": true,
"requires": {
@@ -2198,9 +2198,9 @@
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true
},
"semver-compare": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.5.2",
"version": "0.6.1",
"description": "",
"main": "./dist/main/main.js",
"author": {
@@ -18,7 +18,7 @@
"ws": "^8.13.0"
},
"devDependencies": {
"electron": "25.3.0"
"electron": "25.8.4"
},
"license": "GPL-3.0"
}
+1
View File
@@ -0,0 +1 @@
"use strict";window.SERVER_URL="${SERVER_URL}";window.SERVER_NAME="${SERVER_NAME}";window.SERVER_TYPE="${SERVER_TYPE}";window.SERVER_LOCK=${SERVER_LOCK};
+20 -5
View File
@@ -1,4 +1,4 @@
import { PostProcessorModule } from 'i18next';
import { PostProcessorModule, TOptions, StringMap } from 'i18next';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
@@ -14,6 +14,8 @@ import ptBr from './locales/pt-BR.json';
import sr from './locales/sr.json';
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';
const resources = {
en: { translation: en },
@@ -29,6 +31,8 @@ const resources = {
sr: { translation: sr },
sv: { translation: sv },
cs: { translation: cs },
nl: { translation: nl },
'nb-NO': { translation: nbNO },
};
export const languages = [
@@ -61,9 +65,14 @@ export const languages = [
value: 'ja',
},
{
label: 'Русский',
value: 'ru',
label: 'Nederlands',
value: 'nl',
},
{
label: 'Norsk (Bokmål)',
value: 'nb-NO',
},
{
label: 'Português (Brasil)',
value: 'pt-BR',
@@ -72,6 +81,10 @@ export const languages = [
label: 'Polski',
value: 'pl',
},
{
label: 'Русский',
value: 'ru',
},
{
label: 'Srpski',
value: 'sr',
@@ -112,16 +125,18 @@ const titleCasePostProcessor: PostProcessorModule = {
},
};
const ignoreSentenceCaseLanguages = ['de']
const sentenceCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'sentenceCase',
process: (value: string) => {
process: (value: string, _key: string, _options: TOptions<StringMap>, translator: any) => {
const sentences = value.split('. ');
return sentences
.map((sentence) => {
return (
sentence.charAt(0).toLocaleUpperCase() + sentence.slice(1).toLocaleLowerCase()
sentence.charAt(0).toLocaleUpperCase() + (!ignoreSentenceCaseLanguages.includes(translator.language) ? sentence.slice(1).toLocaleLowerCase() : sentence.slice(1))
);
})
.join('. ');
+16 -11
View File
@@ -41,7 +41,6 @@
"hotkey_playbackPause": "pozastavení",
"replayGainFallback": "fallback {{ReplayGain}}",
"sidebarCollapsedNavigation_description": "zobrazit nebo skrýt navigaci ve sbaleném postranním panelu",
"mpvExecutablePath_help": "jedna na řádek",
"hotkey_volumeUp": "zvýšení hlasitosti",
"skipDuration": "doba k přeskočení",
"discordIdleStatus_description": "při povolení bude upraven stav když je přehrávač nečinný",
@@ -53,7 +52,7 @@
"skipDuration_description": "nastavení doby k přeskočení při použití tlačítek k přeskočení na liště přehrávače",
"enableRemote_description": "povolí vzdálený ovládací server pro umožnění ostatním zařízením ovládat aplikaci",
"fontType_optionSystem": "systémové písmo",
"mpvExecutablePath_description": "nastavení cesty ke spustitelnému souboru mpv",
"mpvExecutablePath_description": "nastavení cesty ke spustitelnému souboru mpv. pokud je prázdné, bude použita výchozí cesta",
"replayGainClipping_description": "Zabránění clippingu způsobenému funkcí {{ReplayGain}} automatickým snížením zesílení",
"replayGainPreamp": "před-zesílení {{ReplayGain}} (dB)",
"hotkey_favoriteCurrentSong": "oblíbit $t(common.currentSong)",
@@ -61,7 +60,7 @@
"crossfadeStyle": "způsob prolnutí",
"sidePlayQueueStyle_optionAttached": "připojené",
"sidebarConfiguration": "nastavení postranního panelu",
"sampleRate_description": "vyberte výstupní vzorkovací frekvenci k použití, když je vybraná vzorkovací frekvence jiná, než ta u aktuálního média",
"sampleRate_description": "vyberte výstupní vzorkovací frekvenci k použití, když je vybraná vzorkovací frekvence jiná, než ta u aktuálního média. hodnota nižší než 8000 použije výchozí frekvenci",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainClipping": "clipping {{ReplayGain}}",
"hotkey_zoomIn": "přiblížení",
@@ -191,7 +190,13 @@
"discordRichPresence": "{{discord}} rich presence",
"font_description": "nastavení písma použitého v aplikaci",
"savePlayQueue_description": "uložit frontu přehrávání, když je aplikace zavřena a obnovit ji při otevření aplikace",
"useSystemTheme": "použít systémový motiv"
"useSystemTheme": "použít systémový motiv",
"buttonSize": "velikost tlačítek lišty přehrávače",
"buttonSize_description": "velikost tlačítek na liště přehrávače",
"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"
},
"action": {
"editPlaylist": "upravit $t(entity.playlist_one)",
@@ -559,7 +564,7 @@
"error_savePassword": "při ukládání hesla se vyskytla chyba"
},
"addToPlaylist": {
"success": "přidáno {{message}} $t(entity.song_other) do {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "přidáno $t(entity.trackWithCount, {\"count\": {{message}} }) do $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "přidat do $t(entity.playlist_one)",
"input_skipDuplicates": "přeskočit duplicity",
"input_playlists": "$t(entity.playlist_other)"
@@ -584,16 +589,16 @@
"entity": {
"genre_one": "žánr",
"genre_few": "žánry",
"genre_other": "žánrů",
"genre_other": "žánry",
"playlistWithCount_one": "{{count}} playlist",
"playlistWithCount_few": "{{count}} playlisty",
"playlistWithCount_other": "{{count}} playlistů",
"playlist_one": "playlist",
"playlist_few": "playlisty",
"playlist_other": "playlistů",
"playlist_other": "playlisty",
"artist_one": "umělec",
"artist_few": "umělci",
"artist_other": "umělců",
"artist_other": "umělci",
"folderWithCount_one": "{{count}} složka",
"folderWithCount_few": "{{count}} složky",
"folderWithCount_other": "{{count}} složek",
@@ -602,7 +607,7 @@
"albumArtist_other": "umělců alba",
"track_one": "skladba",
"track_few": "skladby",
"track_other": "skladeb",
"track_other": "skladby",
"albumArtistCount_one": "{{count}} umělec alba",
"albumArtistCount_few": "{{count}} umělci alba",
"albumArtistCount_other": "{{count}} umělců alba",
@@ -617,11 +622,11 @@
"artistWithCount_other": "{{count}} umělců",
"folder_one": "složka",
"folder_few": "složky",
"folder_other": "složek",
"folder_other": "složky",
"smartPlaylist": "chytrý $t(entity.playlist_one)",
"album_one": "album",
"album_few": "alba",
"album_other": "alb",
"album_other": "alba",
"genreWithCount_one": "{{count}} žánr",
"genreWithCount_few": "{{count}} žánry",
"genreWithCount_other": "{{count}} žánrů",
+98 -27
View File
@@ -8,8 +8,8 @@
"deletePlaylist": "löschen $t(entity.playlist_one)",
"deselectAll": "Alle abwählen",
"goToPage": "Gehe zur Seite",
"moveToTop": "Nach Oben",
"moveToBottom": "Nach Unten",
"moveToTop": "Nach oben",
"moveToBottom": "Nach unten",
"removeFromPlaylist": "Entfernen von $t(entity.playlist_one)",
"viewPlaylists": "Ansicht $t(entity.playlist_other)",
"refresh": "$t(common.refresh)",
@@ -23,7 +23,7 @@
"increase": "erhöhen",
"rating": "Wertung",
"bpm": "bpm",
"refresh": "erneuern",
"refresh": "Aktualisieren",
"unknown": "Unbekannt",
"areYouSure": "Bist Du sicher?",
"edit": "Bearbeiten",
@@ -61,7 +61,9 @@
"delete": "Löschen",
"cancel": "Abbrechen",
"forceRestartRequired": "Neustarten um die Änderungen zu übernehmen... Schließe die Benachrichtigung zum Neustarten",
"setting": "Einstellung",
"setting": "Einstellungen",
"setting_one": "",
"setting_other": "Einstellungen",
"version": "Version",
"title": "Titel",
"filter_one": "Filter",
@@ -84,7 +86,7 @@
"sortOrder": "Reihenfolge",
"none": "keine",
"menu": "Menü",
"restartRequired": "Neustart benötigt",
"restartRequired": "(Neustart benötigt)",
"previousSong": "vorheriger $t(entity.track_one)",
"noResultsFromQuery": "Die Abfrage brachte keine Ergebnisse",
"quit": "Verlassen",
@@ -120,7 +122,7 @@
"loginRateError": "Zu viele Anmeldeversuche, bitte versuche es in einigen Sekunden erneut"
},
"filter": {
"mostPlayed": "Meist gespielt",
"mostPlayed": "Meistgespielt",
"comment": "Kommentar",
"playCount": "Anzahl abgespielt",
"recentlyUpdated": "kürzlich aktualisiert",
@@ -191,7 +193,7 @@
"error_savePassword": "Beim Versuch, das Passwort zu speichern, ist ein Fehler aufgetreten"
},
"addToPlaylist": {
"success": "{{message}} $t(entity.song_other) zu {{numOfPlaylists}} $t(entity.playlist_other) hinzugefügt",
"success": "{{message}} $t(entity.track_other) zu {{numOfPlaylists}} $t(entity.playlist_other) hinzugefügt",
"title": "Zu $t(entity.playlist_one) hinzufügen",
"input_skipDuplicates": "Duplikate überspringen",
"input_playlists": "$t(entity.playlist_other)"
@@ -224,12 +226,12 @@
"artist_other": "Interpreten",
"folderWithCount_one": "{{count}} Verzeichnis",
"folderWithCount_other": "{{count}} Verzeichnisse",
"albumArtist_one": "Album Interpret",
"albumArtist_other": "Album Interpreten",
"albumArtist_one": "Albuminterpret",
"albumArtist_other": "Albuminterpreten",
"track_one": "Track",
"track_other": "Tracks",
"albumArtistCount_one": "{{count}} Album Interpret",
"albumArtistCount_other": "{{count}} Album Interpreten",
"albumArtistCount_one": "{{count}} Albuminterpret",
"albumArtistCount_other": "{{count}} Albuminterpreten",
"albumWithCount_one": "{{count}} Album",
"albumWithCount_other": "{{count}} Alben",
"favorite_one": "Favorit",
@@ -249,8 +251,66 @@
"table": {
"config": {
"view": {
"table": "Tabelle"
"table": "Tabelle",
"card": "Karte",
"poster": "Poster"
},
"general": {
"tableColumns": "Tabellenspalten",
"gap": "$t(common.gap)",
"size": "$t(common.size)",
"displayType": "Anzeigestil"
},
"label": {
"dateAdded": "Hinzugefügt am",
"lastPlayed": "zuletzt gespielt",
"rowIndex": "Reihenindex",
"trackNumber": "Tracknummer",
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"albumArtist": "$t(entity.albumArtist_one)",
"artist": "$t(entity.artist_one)",
"favorite": "$t(common.favorite)",
"actions": "$t(common.action_other)",
"genre": "$t(entity.genre_one)",
"album": "$t(entity.album_one)",
"size": "$t(common.size)",
"bpm": "$t(common.bpm)",
"titleCombined": "$t(common.title) (kombiniert)",
"channels": "$t(common.channel_other)",
"duration": "$t(common.duration)",
"note": "$t(common.note)",
"owner": "$t(common.owner)",
"path": "$t(common.path)",
"rating": "$t(common.rating)",
"releaseDate": "Veröffentlichungsdatum",
"title": "$t(common.title)",
"year": "$t(common.year)"
}
},
"column": {
"releaseYear": "Jahr",
"biography": "Biografie",
"releaseDate": "Veröffentlichungsdatum",
"bitrate": "Bitrate",
"title": "Titel",
"path": "Pfad",
"album": "Album",
"albumArtist": "Albenkünstler",
"bpm": "bpm",
"favorite": "Favorit",
"lastPlayed": "zuletzt gespielt",
"rating": "Bewertung",
"albumCount": "$t(entity.album_other)",
"artist": "$t(entity.artist_one)",
"channels": "$t(common.channel_other)",
"comment": "Kommentar",
"dateAdded": "hinzugefügt am",
"playCount": "Abgespielt",
"discNumber": "Disk",
"genre": "$t(entity.genre_one)",
"songCount": "$t(entity.track_other)",
"trackNumber": "Nr."
}
},
"page": {
@@ -261,12 +321,12 @@
"synchronized": "synchronisiert",
"followCurrentLyric": "dem Songtext folgen",
"opacity": "Deckkraft",
"lyricSize": "Songtext Größe",
"lyricSize": "Songtext-Größe",
"showLyricProvider": "Songtext-Anbieter anzeigen",
"unsynchronized": "nicht synchronisiert",
"lyricAlignment": "Songtext Ausrichtung",
"lyricAlignment": "Songtext-Ausrichtung",
"useImageAspectRatio": "Bildseitenverhältnis verwenden",
"lyricGap": "Songtext Lücke"
"lyricGap": "Songtext-Lücke"
},
"upNext": "als nächstes",
"lyrics": "Songtexte",
@@ -278,21 +338,21 @@
"manageServers": "Server verwalten",
"expandSidebar": "Seitenleiste erweitern",
"collapseSidebar": "Seitenleiste einklappen",
"openBrowserDevtools": "Browser Entwicklungswerkzeuge öffnen",
"openBrowserDevtools": "Browser-Entwicklungswerkzeuge öffnen",
"goBack": "Gehe zurück",
"goForward": "Gehe vorwärts",
"settings": "$t(common.setting_other)",
"quit": "$t(common.quit)"
},
"home": {
"mostPlayed": "Meist gespielt",
"mostPlayed": "Meistgespielt",
"newlyAdded": "Neu hinzugefügte Veröffentlichungen",
"explore": "Entdecken Sie Ihre Bibliothek",
"explore": "Entdecke deine Bibliothek",
"recentlyPlayed": "Kürzlich gespielt",
"title": "$t(common.home)"
},
"albumDetail": {
"moreFromArtist": "Mehr von diesem $t(entity.genre_one)",
"moreFromArtist": "Mehr von diesem $t(entity.artist_one)",
"moreFromGeneric": "Mehr von {{item}}"
},
"globalSearch": {
@@ -389,7 +449,7 @@
},
"setting": {
"audioDevice_description": "Wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer).",
"audioExclusiveMode": "Audio Exklusiver Modus",
"audioExclusiveMode": "Audio-Exklusivmodus",
"audioDevice": "Audiogerät",
"accentColor": "Akzentfarbe",
"accentColor_description": "Legt die Akzentfarbe für die Anwendung fest",
@@ -418,23 +478,22 @@
"theme_description": "Legt das für die Anwendung zu verwendende Thema fest",
"hotkey_playbackPause": "Pause",
"sidebarCollapsedNavigation_description": "Zeigt die Navigation in der minimierten Seitenleiste an oder verbirgt sie",
"mpvExecutablePath_help": "eine pro Zeile",
"hotkey_volumeUp": "Lauter",
"skipDuration": "Sprung Dauer",
"skipDuration": "Sprungdauer",
"showSkipButtons": "Schaltflächen zum Überspringen anzeigen",
"playButtonBehavior_optionPlay": "$t(player.play)",
"minimumScrobblePercentage": "minimale Scrobble-Dauer (Prozentsatz)",
"lyricFetch": "Songtexte aus dem Internet abrufen",
"scrobble": "Scrobbeln",
"skipDuration_description": "Legt die zu überspringende Dauer fest, wenn die Überspringen-Schaltflächen in der Player-Leiste verwendet werden",
"mpvExecutablePath_description": "Legt den Pfad zur ausführbaren MPV-Datei fest",
"mpvExecutablePath_description": "Legt den Pfad zur ausführbaren MPV-Datei fest. Wenn leer gelassen, wird der Standard-Pfad verwendet",
"replayGainClipping_description": "Verhindern Sie durch {{ReplayGain}} verursachtes Clipping, indem Sie die Verstärkung automatisch verringern",
"replayGainPreamp": "{{ReplayGain}} Vorverstärker (db)",
"hotkey_favoriteCurrentSong": "Favorit $t(common.currentSong)",
"sampleRate": "Abtastrate",
"sidePlayQueueStyle_optionAttached": "angefügt",
"sidebarConfiguration": "Seitenleistenkonfiguration",
"sampleRate_description": "Wählen Sie die auszugebende Abtastrate aus, wenn sich die ausgewählte Abtastfrequenz von der des aktuellen Mediums unterscheidet",
"sampleRate_description": "Wähle die auszugebende Abtastrate aus, wenn sich die ausgewählte Abtastfrequenz von der des aktuellen Mediums unterscheidet. Ein Wert unter 8000 wird die Standard-Frequenz verwenden",
"replayGainMode_optionNone": "$t(common.none)",
"hotkey_zoomIn": "Hineinzoomen",
"scrobble_description": "Scrobble wird auf Ihrem Medienserver abgespielt",
@@ -484,7 +543,7 @@
"savePlayQueue": "Wiedergabe-Warteschlange speichern",
"minimumScrobbleSeconds_description": "die Mindestdauer in Sekunden, die das Lied abspielen muss, bevor es gescrobbelt wird",
"skipPlaylistPage_description": "Gehen Sie beim Navigieren zu einer Wiedergabeliste zur Titelseite der Wiedergabeliste und nicht zur Standardseite",
"fontType_description": "Die integrierte Schriftart wählt eine der von Feishin bereitgestellten Schriftarten aus. Mit der Systemschriftart können Sie jede von Ihrem Betriebssystem bereitgestellte Schriftart auswählen. Benutzerdefiniert erlaubt es eine eigene Schriftart bereitstellen",
"fontType_description": "Die integrierte Schriftart wählt eine der von Feishin bereitgestellten Schriftarten aus. Mit der Systemschriftart können Sie jede von Ihrem Betriebssystem bereitgestellte Schriftart auswählen. Benutzerdefiniert erlaubt es eine eigene Schriftart bereitzustellen",
"playButtonBehavior": "Verhalten der Wiedergabetaste",
"volumeWheelStep": "Lautstärkeregler Stufe",
"sidebarPlaylistList_description": "Ein- oder Ausblenden der Playlisten-Liste in der Seitenleiste",
@@ -499,7 +558,7 @@
"sidebarConfiguration_description": "Wählen Sie die Elemente und die Reihenfolge aus, in der sie in der Seitenleiste angezeigt werden",
"remotePort": "Port des Fernsteuerungsserver",
"hotkey_playbackNext": "Nächster Track",
"useSystemTheme_description": "der systemdefinierten Hell oder Dunkel Präferenz folgen",
"useSystemTheme_description": "der systemdefinierten Hell- oder Dunkelpräferenz folgen",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"lyricFetch_description": "Songtexte aus verschiedenen Internetquellen abrufen",
"lyricFetchProvider_description": "Wählen Sie die Anbieter aus, von denen Sie Liedtexte abrufen möchten. Die Reihenfolge der Anbieter ist die Reihenfolge, in der sie abgefragt werden",
@@ -537,6 +596,18 @@
"fontType": "Schriftartenquelle",
"followLyric": "Songtext synchronisieren",
"floatingQueueArea_description": "Zeige ein Icon auf der rechten Seite, um beim Darüberfahren die Wartschlange anzuzeigen",
"font_description": "Wähle die Schriftart für die Anwendung"
"font_description": "Wähle die Schriftart für die Anwendung",
"themeLight": "Thema (hell)",
"sidePlayQueueStyle_optionDetached": "lösgelöst",
"windowBarStyle_description": "Wähle den Stil der Windows-Leiste",
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) zu Favoriten hinzufügen",
"clearQueryCache_description": "\"Weiches\" Zurücksetzen. Dies wird Playlisten, Musik-Metadaten und gespeicherte Liedtexte zurücksetzen, Zugangsinformationen und zwischengespeicherte Bilder werden behalten",
"discordRichPresence_description": "Zeige deinen Wiedergabe-Status in {{discord}} als rich presence an. Angezeigte Bilder sind: {{icon}}, {{playing}}, und {{paused}} ",
"clearCache": "Browser-Zwischenspeicher löschen",
"clearQueryCache": "feishins Zwischenspeicher leeren",
"clearCache_description": "Hartes Zurücksetzen. Neben feishins Zwischenspeicher wird auch der des Browsers gelöscht (Bilder und andere Daten). Zugangsinformationen und Einstellungen werden behalten",
"sidePlayQueueStyle": "Wiedergabelistenstil in der Seitenleiste",
"zoom_description": "Setzt den Zoom (in %) für das Programm",
"zoom": "Zoom"
}
}
+49 -5
View File
@@ -16,7 +16,11 @@
"removeFromQueue": "remove from queue",
"setRating": "set rating",
"toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor",
"viewPlaylists": "view $t(entity.playlist_other)"
"viewPlaylists": "view $t(entity.playlist_other)",
"openIn": {
"lastfm": "Open in Last.fm",
"musicbrainz": "Open in MusicBrainz"
}
},
"common": {
"action_one": "action",
@@ -33,6 +37,8 @@
"channel_one": "channel",
"channel_other": "channels",
"clear": "clear",
"close": "close",
"codec": "codec",
"collapse": "collapse",
"comingSoon": "coming soon…",
"configure": "configure",
@@ -80,6 +86,7 @@
"random": "random",
"rating": "rating",
"refresh": "refresh",
"reload": "reload",
"reset": "reset",
"resetToDefault": "reset to default",
"restartRequired": "restart required",
@@ -216,7 +223,7 @@
"addToPlaylist": {
"input_playlists": "$t(entity.playlist_other)",
"input_skipDuplicates": "skip duplicates",
"success": "added {{message}} $t(entity.song_other) to {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "added $t(entity.trackWithCount, {\"count\": {{message}} }) to $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "add to $t(entity.playlist_one)"
},
"createPlaylist": {
@@ -250,6 +257,17 @@
}
},
"page": {
"albumArtistDetail": {
"about": "About {{artist}}",
"appearsOn": "appears on",
"recentReleases": "recent releases",
"viewDiscography": "view discography",
"relatedArtists": "related $t(entity.artist_other)",
"topSongs": "top songs",
"topSongsFrom": "Top songs from {{title}}",
"viewAll": "view all",
"viewAllTracks": "view all $t(entity.track_other)"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
},
@@ -293,6 +311,8 @@
"fullscreenPlayer": {
"config": {
"dynamicBackground": "dynamic background",
"dynamicImageBlur": "image blur size",
"dynamicIsImage": "enable background image",
"followCurrentLyric": "follow current lyric",
"lyricAlignment": "lyric alignment",
"lyricGap": "lyric gap",
@@ -346,9 +366,12 @@
"playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)",
"settings": "$t(common.setting_other)",
"shared": "shared $t(entity.playlist_other)",
"tracks": "$t(entity.track_other)"
},
"trackList": {
"artistTracks": "Tracks by {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track_other)",
"title": "$t(entity.track_other)"
}
},
@@ -396,6 +419,13 @@
"audioExclusiveMode_description": "enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio",
"audioPlayer": "audio player",
"audioPlayer_description": "select the audio player to use for playback",
"buttonSize": "player bar button size",
"buttonSize_description": "the size of the player bar buttons",
"clearCache": "clear browser cache",
"clearCache_description": "a 'hard clear' of feishin. in addition to clearing feishin's cache, empty the browser cache (saved images and other assets). server credentials and settings are preserved",
"clearQueryCache": "clear feishin cache",
"clearQueryCache_description": "a 'soft clear' of feishin. this will refresh playlists, track metadata, and reset saved lyrics. settings, server credentials and cached images are preserved",
"clearCacheSuccess": "cache cleared successfully",
"crossfadeDuration": "crossfade duration",
"crossfadeDuration_description": "sets the duration of the crossfade effect",
"crossfadeStyle": "crossfade style",
@@ -414,6 +444,8 @@
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
"enableRemote": "enable remote control server",
"enableRemote_description": "enables the remote control server to allow other devices to control the application",
"externalLinks": "show external links",
"externalLinks_description": "enables showing external links (Last.fm, MusicBrainz) on artist/album pages",
"exitToTray": "exit to tray",
"exitToTray_description": "exit the application to the system tray",
"floatingQueueArea": "show floating queue hover area",
@@ -432,6 +464,8 @@
"gaplessAudio_optionWeak": "weak (recommended)",
"globalMediaHotkeys": "global media hotkeys",
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
"homeConfiguration": "home page configuration",
"homeConfiguration_description": "configure what items are shown, and in what order, on the home page",
"hotkey_browserBack": "browser back",
"hotkey_browserForward": "browser forward",
"hotkey_favoriteCurrentSong": "favorite $t(common.currentSong)",
@@ -480,9 +514,11 @@
"minimumScrobbleSeconds": "minimum scrobble (seconds)",
"minimumScrobbleSeconds_description": "the minimum duration in seconds of the song that must be played before it is scrobbled",
"mpvExecutablePath": "mpv executable path",
"mpvExecutablePath_description": "sets the path to the mpv executable",
"mpvExecutablePath_help": "one per line",
"mpvExecutablePath_description": "sets the path to the mpv executable. if left empty, the default path will be used",
"mpvExtraParameters": "mpv parameters",
"mpvExtraParameters_help": "one per line",
"passwordStore": "passwords/secret store",
"passwordStore_description": "what password/secret store to use. change this if you are having issues storing passwords.",
"playbackStyle": "playback style",
"playbackStyle_description": "select the playback style to use for the audio player",
"playbackStyle_optionCrossFade": "crossfade",
@@ -492,6 +528,8 @@
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"playerAlbumArtResolution": "player album art resolution",
"playerAlbumArtResolution_description": "the resolution for the large player's album art preview. larger makes it look more crisp, but may slow loading down. defaults to 0, meaning auto",
"remotePassword": "remote control server password",
"remotePassword_description": "sets the password for the remote control server. These credentials are by default transferred insecurely, so you should use a unique password that you do not care about",
"remotePort": "remote control server port",
@@ -510,7 +548,7 @@
"replayGainPreamp": "{{ReplayGain}} preamp (dB)",
"replayGainPreamp_description": "adjust the preamp gain applied to the {{ReplayGain}} values",
"sampleRate": "sample rate",
"sampleRate_description": "select the output sample rate to be used if the sample frequency selected is different from that of the current media",
"sampleRate_description": "select the output sample rate to be used if the sample frequency selected is different from that of the current media. a value less than 8000 will use the default frequency",
"savePlayQueue": "save play queue",
"savePlayQueue_description": "save the play queue when the application is closed and restore it when the application is opened",
"scrobble": "scrobble",
@@ -533,6 +571,8 @@
"skipDuration_description": "sets the duration to skip when using the skip buttons on the player bar",
"skipPlaylistPage": "skip playlist page",
"skipPlaylistPage_description": "when navigating to a playlist, go to the playlist song list page instead of the default page",
"startMinimized": "start minimized",
"startMinimized_description": "start the application in system tray",
"theme": "theme",
"theme_description": "sets the theme to use for the application",
"themeDark": "theme (dark)",
@@ -558,6 +598,7 @@
"bitrate": "bitrate",
"bpm": "bpm",
"channels": "$t(common.channel_other)",
"codec": "$t(common.codec)",
"comment": "comment",
"dateAdded": "date added",
"discNumber": "disc",
@@ -578,6 +619,8 @@
"autoFitColumns": "auto fit columns",
"displayType": "display type",
"gap": "$t(common.gap)",
"itemGap": "item gap (px)",
"itemSize": "item size (px)",
"size": "$t(common.size)",
"tableColumns": "table columns"
},
@@ -590,6 +633,7 @@
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)",
"channels": "$t(common.channel_other)",
"codec": "$t(common.codec)",
"dateAdded": "date added",
"discNumber": "disc number",
"duration": "$t(common.duration)",
+10 -5
View File
@@ -40,7 +40,6 @@
"hotkey_playbackPause": "pausa",
"replayGainFallback": "{{ReplayGain}} alternativa",
"sidebarCollapsedNavigation_description": "mostrar u ocultar la navegación en la barra lateral contraída",
"mpvExecutablePath_help": "uno por línea",
"hotkey_volumeUp": "subir volumen",
"skipDuration": "duración de salto",
"discordIdleStatus_description": "cuando se activa, actualiza el estado mientras el reproductor está inactivo",
@@ -52,7 +51,7 @@
"skipDuration_description": "establece la duración a saltar cuando se usa los botones de saltar en la barra del reproductor",
"enableRemote_description": "activa el control remoto del servidor para permitir a otros dispositivos controlar la aplicación",
"fontType_optionSystem": "fuente del sistema",
"mpvExecutablePath_description": "establece la ruta del ejecutable mpv",
"mpvExecutablePath_description": "establece la ruta del ejecutable mpv. si se deja vacío, se usará la ruta predeterminada",
"replayGainClipping_description": "previene el recorte causado por {{ReplayGain}} bajando automáticamente la ganancia",
"replayGainPreamp": "preamplificador (dB) de {{ReplayGain}}",
"hotkey_favoriteCurrentSong": "$t(common.currentSong) favorita",
@@ -60,7 +59,7 @@
"crossfadeStyle": "estilo de crossfade",
"sidePlayQueueStyle_optionAttached": "acoplada",
"sidebarConfiguration": "configuración de la barra lateral",
"sampleRate_description": "selecciona el ratio de muestreo de salida a ser usado si la frecuencia de muestreo seleccionada es diferente de la del medio actual",
"sampleRate_description": "selecciona el ratio de muestreo de salida a ser usado si la frecuencia de muestreo seleccionada es diferente de la del medio actual. un valor inferior a 8000 usará la frecuencia predeterminada",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainClipping": "recortar {{ReplayGain}}",
"hotkey_zoomIn": "ampliar",
@@ -191,7 +190,13 @@
"accentColor_description": "establece el color de realce de la aplicación",
"skipPlaylistPage": "saltar página de lista de reproducción",
"hotkey_browserForward": "avance",
"hotkey_browserBack": "retroceso"
"hotkey_browserBack": "retroceso",
"clearCache": "limpiar la caché del navegador",
"clearQueryCache": "limpiar la caché de feishin",
"clearQueryCache_description": "una 'limpieza suave' de feishin. esto refrescará las listas de reproducción, metadatos de pistas y restablece las letras guardadas. se mantienen los ajustes, credenciales del servidor y las imágenes en caché",
"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"
},
"action": {
"editPlaylist": "editar $t(entity.playlist_one)",
@@ -491,7 +496,7 @@
"error_savePassword": "un error ocurrió cuando se intentó guardar la contraseña"
},
"addToPlaylist": {
"success": "añadido {{message}} $t(entity.song_other) a {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "añadido $t(entity.trackWithCount, {\"count\": {{message}} }) a $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "añadir a $t(entity.playlist_one)",
"input_skipDuplicates": "saltar duplicados",
"input_playlists": "$t(entity.playlist_other)"
+305
View File
@@ -0,0 +1,305 @@
{
"player": {
"repeat_all": "تکرار همه",
"stop": "توقف",
"repeat": "تکرار",
"skip": "رد کن",
"toggleFullscreenPlayer": "تغییر به پخش‌کنندهٔ تمام‌صفحه",
"skip_back": "برو عقب",
"shuffle": "شافل",
"repeat_off": "تکرار غیرفعال",
"pause": "pause",
"unfavorite": "حذف از موردعلاقه‌ها",
"shuffle_off": "شافل غیرفعال",
"skip_forward": "برو جلو"
},
"action": {
"editPlaylist": "ویرایش $t(entity.playlist_one)",
"goToPage": "برو به صفحهٔ",
"moveToTop": "انتقال به بالا",
"clearQueue": "خالی کردن صف",
"addToFavorites": "افزودن به $t(entity.favorite_other)",
"addToPlaylist": "افزودن به $t(entity.playlist_one)",
"createPlaylist": "ساخت $t(entity.playlist_one)",
"removeFromPlaylist": "حذف از $t(entity.playlist_one)",
"viewPlaylists": "نمایش $t(entity.playlist_other)",
"refresh": "$t(common.refresh)",
"deletePlaylist": "حذف $t(entity.playlist_one)",
"removeFromQueue": "حذف از صف",
"deselectAll": "لغو انتخاب همه",
"moveToBottom": "انتقال به پایین",
"setRating": "تعیین امتیاز",
"toggleSmartPlaylistEditor": "تغییر $t(entity.smartPlaylist) ویرایشگر",
"removeFromFavorites": "حذف از $t(entity.favorite_other)"
},
"setting": {
"hotkey_skipBackward": "برو عقب",
"audioDevice_description": "دستگاه صوتی را برای پخش انتخاب کنید (فقط پخش‌کنندهٔ تحت وب)",
"hotkey_playbackPause": "pause",
"hotkey_volumeUp": "زیاد کردن صدا",
"playButtonBehavior_optionPlay": "$t(player.play)",
"lyricFetch": "دریافت متن ترانه از اینترنت",
"enableRemote_description": "کنترل از راه دور سرویس‌دهنده را فعال کنید تا به دستگاه‌های دیگر اجازهٔ مدیریت اپلیکیشن را بدهید",
"mpvExecutablePath_description": "تعیین مسیر فایل اجرایی MPV",
"sampleRate": "sample rate",
"replayGainMode_optionNone": "$t(common.none)",
"hotkey_rate1": "امتیاز ۱ ستاره",
"hotkey_skipForward": "برو جلو",
"disableLibraryUpdateOnStartup": "غیرفعال کردن بررسی آخرین نسخه در آغاز به کار برنامه",
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"hotkey_playbackPlay": "پخش",
"hotkey_volumeDown": "کم کردن صدا",
"audioPlayer_description": "پخش‌کنندهٔ صدا را برای پخش انتخاب کنید",
"hotkey_globalSearch": "جست و جوی سراسری",
"disableAutomaticUpdates": "غیرفعال کردن به‌‌روزرسانی خودکار",
"exitToTray_description": "خروج از اپلیکیشن به system tray",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
"discordUpdateInterval_description": "فاصلهٔ بین هر به روزرسانی به ثانیه (حداقل ۱۵ ثانیه)",
"audioExclusiveMode": "حالت اختصاصی صدا",
"remotePassword": "رمز عبور کنترل از راه دور",
"language_description": "زبان اپلیکیشن را معین می‌کند $t(common.restartRequired)",
"hotkey_rate3": "امتیاز ۳ ستاره",
"font": "قلم",
"replayGainMode_optionTrack": "$t(entity.track_one)",
"hotkey_toggleFullScreenPlayer": "تغییر به پخش‌کنندهٔ تمام‌صفحه",
"hotkey_localSearch": "جست و جو در صفحه",
"hotkey_toggleQueue": "تغییر صف",
"hotkey_rate5": "امتیاز ۵ ستاره",
"hotkey_playbackPrevious": "قطعهٔ قبل",
"language": "زبان",
"hotkey_toggleShuffle": "تغییر شافل",
"mpvExecutablePath": "مسیر اجرای MPV",
"audioDevice": "دستگاه صوتی",
"hotkey_rate2": "امتیاز ۲ ستاره",
"playButtonBehavior_description": "رفتار پیش‌فرض دکمهٔ پخش را هنگامی که آهنگی به صف اضافه می‌شود معین می‌کند",
"exitToTray": "خروج به tray",
"hotkey_rate4": "امتیاز ۴ ستاره",
"enableRemote": "فعال کردن کنترل از راه دور سرویس‌دهنده",
"showSkipButton_description": "نمایش یا مخفی کردن دکمهٔ رد کردن روی نوار پخش‌کننده",
"playButtonBehavior": "رفتار دکمهٔ پخش",
"playbackStyle_optionNormal": "عادی",
"hotkey_toggleRepeat": "تغییر تکرار",
"fontType": "نوع قلم",
"hotkey_playbackNext": "قطعهٔ بعد",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"lyricFetch_description": "دریافت متن ترانه از منابع اینترنتی",
"customFontPath": "مسیر قلم سفارشی",
"audioPlayer": "پخش‌کنندهٔ صدا",
"hotkey_rate0": "حذف امتیاز",
"discordApplicationId": "{{discord}} application id",
"hotkey_volumeMute": "بستن صدا",
"showSkipButton": "نمایش دکمهٔ رد کردن",
"customFontPath_description": "مسیر قلم سفارشی را برای استفاده در اپلیکیشن مشخص کنید",
"gaplessAudio_optionWeak": "ضعیف (توصیه شده)",
"hotkey_playbackStop": "توقف",
"font_description": "قلم مورد استفادهٔ اپلیکیشن را معین می‌کند"
},
"common": {
"backward": "به عقب",
"increase": "افزایش",
"rating": "امتیاز",
"bpm": "bpm",
"refresh": "تازه‌سازی",
"unknown": "ناشناخته",
"areYouSure": "مطمئنید؟",
"edit": "ویرایش",
"favorite": "موردعلاقه",
"left": "چپ",
"save": "ذخیره",
"right": "راست",
"currentSong": "فعلی $t(entity.track_one)",
"collapse": "بستن",
"trackNumber": "قطعه",
"descending": "نزولی",
"add": "افزودن",
"gap": "فاصله",
"ascending": "صعودی",
"dismiss": "رد",
"year": "سال",
"manage": "مدیریت",
"limit": "محدود",
"minimize": "کمینه",
"modified": "ویراسته شده",
"duration": "مدت",
"name": "نام",
"maximize": "بیشینه",
"decrease": "کم کردن",
"ok": "باشه",
"description": "شرح",
"configure": "تنظیم",
"path": "مسیر",
"center": "وسط",
"no": "خیر",
"owner": "مالک",
"enable": "فعال",
"clear": "خالی",
"forward": "جلو",
"delete": "حذف",
"cancel": "لغو",
"forceRestartRequired": "برای اعمال تغییرها دوباره راه‌اندازی کنید… اعلان را برای راه‌اندازی دوباره ببندید",
"version": "نسخه",
"title": "عنوان",
"filter_one": "فیلتر",
"filter_other": "فیلتر",
"filters": "فیلتر",
"create": "ساختن",
"bitrate": "بیت‌ریت",
"saveAndReplace": "ذخیره و جایگزین",
"action_one": "عملیات",
"action_other": "عملیات",
"playerMustBePaused": "پخش‌کننده باید متوقف شود",
"confirm": "تایید",
"resetToDefault": "بازنشانی به پیش‌فرض",
"home": "خانه",
"comingSoon": "به زودی…",
"reset": "بازنشانی",
"channel_one": "کانال",
"channel_other": "کانال",
"disable": "غیرفعال",
"sortOrder": "ترتیب",
"none": "هیچ",
"menu": "منو",
"restartRequired": "راه‌اندازی دوباره لازم است",
"previousSong": "$t(entity.track_one) پیشین",
"noResultsFromQuery": "جست و جو نتیجه‌ای نداشت",
"quit": "خروج",
"expand": "گسترش",
"search": "جست و جو",
"saveAs": "ذخیره کن با اسم",
"disc": "دیسک",
"yes": "بله",
"random": "تصادفی",
"size": "حجم",
"biography": "زندگی‌نامه",
"note": "توجه"
},
"error": {
"remotePortWarning": "برای تعیین port تازه، سرویس دهنده را دوباره راه‌اندازی کنید",
"playbackError": "هنگام پخش خطایی رخ داد",
"remotePortError": "هنگام تعیین port سرویس دهنده خطایی رخ داد",
"serverRequired": "سرویس‌دهنده ضروری است",
"authenticationFailed": "احراز هویت شکست خورد",
"apiRouteError": "درخواست منتقل نشد",
"genericError": "خطایی رخ داد",
"credentialsRequired": "باید وارد شوید",
"sessionExpiredError": "جلسه شما منقضی شده است",
"remoteEnableError": "هنگام $t(common.enable) سرویس دهنده خطای رخ داد",
"serverNotSelectedError": "سرویس‌دهنده‌ای انتخاب نشده",
"remoteDisableError": "هنگام $t(common.disable) سرویس دهنده خطایی رخ داد",
"mpvRequired": "وجود MPV ضروری است",
"audioDeviceFetchError": "هنگام دسترسی به دستگاه صوتی خطایی رخ داد"
},
"filter": {
"mostPlayed": "بیشتر پخش شده",
"comment": "نظر",
"playCount": "تعداد پخش",
"recentlyUpdated": "به تازگی به روز شده",
"channels": "$t(common.channel_other)",
"recentlyPlayed": "به تازگی پخش شده",
"isRated": "امتیاز داده شده است",
"owner": "$t(common.owner)",
"title": "عنوان",
"rating": "امتیاز",
"search": "جست و جو",
"bitrate": "بیت‌ریت",
"genre": "$t(entity.genre_one)",
"recentlyAdded": "به تازگی اضافه شده",
"note": "توجه",
"name": "نام",
"dateAdded": "تاریخ اضافه شدن",
"releaseDate": "تاریخ انتشار",
"albumCount": "$t(entity.album_other) عدد",
"path": "مسیر",
"favorited": "موردعلاقه",
"albumArtist": "$t(entity.albumArtist_one)",
"isRecentlyPlayed": "به تازگی پخش شده است",
"isFavorited": "موردعلاقه است",
"bpm": "bpm",
"releaseYear": "سال انتشار",
"id": "id",
"disc": "دیسک",
"biography": "زندگی‌نامه",
"songCount": "تعداد ترانه",
"artist": "$t(entity.artist_one)",
"duration": "مدت",
"isPublic": "عمومی است",
"random": "تصادفی",
"lastPlayed": "به تازگی پخش شده",
"toYear": "تا سال",
"fromYear": "از سال",
"criticRating": "امتیاز منتقدین",
"album": "$t(entity.album_one)",
"trackNumber": "قطعه"
},
"form": {
"deletePlaylist": {
"title": "حذف $t(entity.playlist_one)",
"success": "$t(entity.playlist_one) حذف شد",
"input_confirm": "برای تایید، نام $t(entity.playlist_one) را وارد کنید"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"title": "ساخت $t(entity.playlist_one)",
"input_public": "عمومی",
"input_name": "$t(common.name)",
"success": "$t(entity.playlist_one) ساخته شد",
"input_owner": "$t(common.owner)"
},
"addServer": {
"title": "افزودن سرویس دهنده",
"input_username": "نام کاربری",
"input_url": "نشانی",
"input_password": "رمز عبور",
"input_name": "نام سرویس‌دهنده",
"success": "سرویس‌دهنده اضافه شد",
"input_savePassword": "ذخیرهٔ رمز",
"error_savePassword": "هنگام ذخیره رمز خطایی رخ داد"
},
"addToPlaylist": {
"success": "$t(entity.song_other) به {{numOfPlaylists}}$t(entity.playlist_other) اضافه شد",
"title": "افزودن به $t(entity.playlist_one)",
"input_playlists": "$t(entity.playlist_other)"
},
"lyricSearch": {
"input_name": "$t(common.name)",
"input_artist": "$t(entity.artist_one)"
},
"editPlaylist": {
"title": "ویرایش $t(entity.playlist_one)"
}
},
"entity": {
"genre_one": "ژانر",
"genre_other": "ژانر",
"playlistWithCount_one": "{{count}} فهرست پخش",
"playlistWithCount_other": "{{count}} فهرست پخش",
"playlist_one": "فهرست پخش",
"playlist_other": "فهرست پخش",
"artist_one": "هنرمند",
"artist_other": "هنرمند",
"folderWithCount_one": "{{count}} پوشه",
"folderWithCount_other": "{{count}} پوشه",
"albumArtist_one": "هنرمند آلبوم",
"albumArtist_other": "هنرمند آلبوم",
"track_one": "قطعه",
"track_other": "قطعه",
"albumArtistCount_one": "{{count}} هنرمند آلبوم",
"albumArtistCount_other": "{{count}} هنرمند آلبوم",
"albumWithCount_one": "{{count}} آلبوم",
"albumWithCount_other": "{{count}} آلبوم",
"favorite_one": "موردعلاقه",
"favorite_other": "موردعلاقه",
"artistWithCount_one": "{{count}} هنرمند",
"artistWithCount_other": "{{count}} هنرمند",
"folder_one": "پوشه",
"folder_other": "پوشه",
"smartPlaylist": "$t(entity.playlist_one) هوشمند",
"album_one": "آلبوم",
"album_other": "آلبوم",
"genreWithCount_one": "{{count}} ژانر",
"genreWithCount_other": "{{count}} ژانر",
"trackWithCount_one": "{{count}} قطعه",
"trackWithCount_other": "{{count}} قطعه"
}
}
+26 -15
View File
@@ -62,7 +62,7 @@
"left": "gauche",
"save": "sauvegarder",
"right": "droite",
"currentSong": "actuelle $t(entity.track_one)",
"currentSong": "$t(entity.track_one) actuelle",
"collapse": "réduire",
"trackNumber": "piste",
"descending": "décroisant",
@@ -225,7 +225,7 @@
"lyricSize": "Taille des paroles",
"lyricGap": "espacement des lettres"
},
"upNext": "suivant",
"upNext": "à suivre",
"lyrics": "paroles",
"related": "similaire"
},
@@ -320,7 +320,6 @@
"remotePort_description": "définit le port du serveur de contrôle à distance",
"hotkey_skipBackward": "reculer",
"hotkey_playbackPause": "pause",
"mpvExecutablePath_help": "line par line",
"hotkey_volumeUp": "monter le volume",
"discordIdleStatus_description": "quand activé, mettre à jour le status pendant que le lecteur est inactif",
"showSkipButtons": "affiche les boutons suivants et précédents",
@@ -332,7 +331,7 @@
"mpvExecutablePath_description": "définit le chemin vers l'exécutable mpv",
"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 du média actuel",
"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",
"hotkey_zoomIn": "zoom avant",
"scrobble_description": "scrobble les lectures à votre serveur multimédia",
"hotkey_browserForward": "avancer",
@@ -341,7 +340,7 @@
"hotkey_playbackPlayPause": "lecture / pause",
"hotkey_rate1": "noter 1 étoile",
"hotkey_skipForward": "avancer",
"disableLibraryUpdateOnStartup": "désactive la vérification de mise à jour au démarrage",
"disableLibraryUpdateOnStartup": "désactive la recherche de mise à jour au démarrage",
"gaplessAudio": "audio sans interruption",
"minimizeToTray_description": "réduit l'application vers la barre des tâches",
"hotkey_playbackPlay": "lecture",
@@ -402,10 +401,10 @@
"followLyric": "suivre les paroles actuelles",
"discordIdleStatus": "afficher l'état d'inactivité dans le status de l'activité",
"hotkey_zoomOut": "zoom arrière",
"hotkey_unfavoriteCurrentSong": "favorisé $t(common.currentSong)",
"hotkey_unfavoriteCurrentSong": "retirer des favoris la $t(common.currentSong)",
"hotkey_rate0": "supprimer la note",
"hotkey_volumeMute": "couper le son",
"hotkey_toggleCurrentSongFavorite": "basculer $t(common.currentSong) favori",
"hotkey_toggleCurrentSongFavorite": "basculer favori de la $t(common.currentSong)",
"remoteUsername": "nom d'utilisateur du serveur de contrôle à distance",
"hotkey_browserBack": "retour arrière",
"showSkipButton": "affiche les boutons suivants et précédents",
@@ -414,21 +413,21 @@
"minimumScrobbleSeconds": "scrobble minimum (secondes)",
"hotkey_playbackStop": "stop",
"font_description": "définit la police à utiliser pour l'application",
"savePlayQueue_description": "sauvegarde la liste de lecture quand l'application est fermée et restaure quand l'application est ouverte",
"savePlayQueue_description": "sauvegarde la liste de lecture quand l'application est fermée et la restaure quand l'application est ouverte",
"sidebarCollapsedNavigation_description": "affiche ou cache la navigation dans la barre latérale réduite",
"sidebarConfiguration": "configuration de la barre latérale",
"sidebarConfiguration_description": "sélectionnez les items et l'ordre dans lesquels ils seront affichaient dans la barre latérale",
"sidebarPlaylistList": "liste de playlist de la barre latérale",
"sidebarCollapsedNavigation": "navigation de la barre latéral (réduite)",
"skipDuration": "temps de l'avance rapide",
"skipDuration": "durée de l'avance rapide",
"sidePlayQueueStyle_optionAttached": "attaché",
"sidePlayQueueStyle": "style de la liste de lecture latérale",
"sidebarPlaylistList_description": "affiche ou cache la liste de playlist de la barre latérale",
"sidePlayQueueStyle_description": "définit le style de la liste de lecture latérale",
"sidePlayQueueStyle_optionDetached": "détaché",
"volumeWheelStep_description": "la quantité de volume à modifier lors du défilement de la molette de la souris sur le curseur de volume",
"volumeWheelStep_description": "la valeur de volume à modifier lors du défilement de la molette de la souris sur le curseur de volume",
"theme_description": "définit le thème à utiliser pour l'application",
"skipDuration_description": "définit le durée de l'avance rapide, lors de l'utilisation des boutons skip dans la barre de lecture",
"skipDuration_description": "définit le durée du saut rapide, lors de l'utilisation des boutons avancer/reculer de la barre de lecture",
"themeLight": "thème (clair)",
"zoom": "pourcentage de zoom",
"themeDark_description": "définit le thème sombre à utiliser pour l'application",
@@ -436,7 +435,7 @@
"zoom_description": "définit le pourcentage de zoom de l'application",
"theme": "thème",
"skipPlaylistPage_description": "lors de la navigation dans une playlist, aller directement vers le liste des morceaux, au lieu de la page par défaut",
"volumeWheelStep": "marche du curseur de volume",
"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)",
"skipPlaylistPage": "sauter la page de playlist",
@@ -453,7 +452,15 @@
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
"replayGainMode_optionTrack": "$t(entity.track_one)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)"
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"replayGainMode_description": "ajuste le gain de volume accordement à la valeur de {{ReplayGain}} sauvegardé dans les métadonnées du fichier",
"replayGainFallback": "{{ReplayGain}} fallback",
"replayGainClipping_description": "Préviens le clipping causé par {{ReplayGain}} en baissant automatiquement le gain",
"replayGainPreamp": "préamplificateur (dB) de {{ReplayGain}}",
"replayGainClipping": "{{ReplayGain}} clipping",
"replayGainMode": "mode de {{ReplayGain}}",
"replayGainFallback_description": "gain en dB à appliquer si le fichier n'a pas de tag {{ReplayGain}}",
"replayGainPreamp_description": "ajuste le gain de préampli appliqué a la valeur de {{ReplayGain}}"
},
"form": {
"deletePlaylist": {
@@ -475,7 +482,7 @@
"error_savePassword": "une erreur sest produite lors de la tentative de sauvegarde du mot de passe"
},
"addToPlaylist": {
"success": "{{message}} $t(entity.song_other) ajouté à {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "{{message}} $t(entity.track_other) ajouté à {{numOfPlaylists}} $t(entity.playlist_other)",
"title": "ajouter à $t(entity.playlist_one)",
"input_skipDuplicates": "sauter les doublons",
"input_playlists": "$t(entity.playlist_other)"
@@ -589,7 +596,11 @@
"rating": "$t(common.rating)",
"note": "$t(common.note)",
"owner": "$t(common.owner)",
"path": "$t(common.path)"
"path": "$t(common.path)",
"title": "$t(common.title)",
"size": "$t(common.size)",
"genre": "$t(entity.genre_one)",
"year": "$t(common.year)"
}
},
"column": {
+30 -16
View File
@@ -46,7 +46,7 @@
"left": "sinistra",
"save": "salva",
"right": "destra",
"currentSong": "$t(entity.track_one) corrent",
"currentSong": "$t(entity.track_one) corrente",
"trackNumber": "traccia",
"descending": "decrescente",
"gap": "gap",
@@ -102,9 +102,9 @@
"note": "nota"
},
"player": {
"repeat_all": "ripeti tutto",
"repeat_all": "ripeti coda",
"stop": "ferma",
"repeat": "ripeti",
"repeat": "ripeti traccia",
"queue_remove": "rimuovi selezionati",
"playRandom": "riproduci casuale",
"skip": "salta",
@@ -113,21 +113,21 @@
"skip_back": "salta indietro",
"favorite": "preferito",
"next": "successivo",
"shuffle": "mischia",
"shuffle": "mescola",
"playbackFetchNoResults": "nessuna canzone trovata",
"playbackFetchInProgress": "caricamento canzoni…",
"addNext": "aggiungi successivo",
"playbackSpeed": "velocità riproduzione",
"playbackSpeed": "velocità di riproduzione",
"playbackFetchCancel": "ci sta mettendo un po'... chiudi la notifica per annullare",
"play": "riproduci",
"repeat_off": "ripeti disabilitato",
"repeat_off": "non ripetere",
"pause": "pausa",
"queue_clear": "cancella coda",
"muted": "silenziato",
"unfavorite": "togli dai preferiti",
"queue_moveToTop": "sposta selezionati in fondo",
"queue_moveToBottom": "sposta selezionati in cima",
"shuffle_off": "mischia disabilitato",
"shuffle_off": "non mescolare",
"addLast": "aggiungi in coda",
"mute": "silenzia",
"skip_forward": "salta avanti"
@@ -140,7 +140,6 @@
"audioDevice_description": "seleziona il device audioda usare per la riproduzione (solo web player)",
"theme_description": "imposta il tema da usare per l'applicazione",
"hotkey_playbackPause": "pausa",
"mpvExecutablePath_help": "uno per linea",
"hotkey_volumeUp": "alza volume",
"skipDuration": "salta durata",
"discordIdleStatus_description": "quando è attivo, aggiorna lo stato mentre il player è inattivo",
@@ -156,8 +155,8 @@
"crossfadeStyle": "stile dissolvenza",
"sidebarConfiguration": "configurazione barra laterale",
"replayGainMode_optionNone": "$t(common.none)",
"hotkey_zoomIn": "ingrandisci",
"scrobble_description": "esegui scrobble delle riproduzioni al tuo media server",
"hotkey_zoomIn": "ingrandisci layout",
"scrobble_description": "invia lo scrobble delle riproduzioni al tuo media server",
"audioExclusiveMode_description": "abilità modalità output esclusiva. In questa modalità il sistema è di solito chiuso fuori, e solo mpv potrà riprodurre audio",
"discordUpdateInterval": "intervallo aggiornamento stato attività {{discord}}",
"themeLight": "tema (chiaro)",
@@ -207,7 +206,7 @@
"crossfadeDuration_description": "imposta la durata dell'effetto di dissolvenza",
"language": "lingua",
"playbackStyle": "stile riproduzione",
"hotkey_toggleShuffle": "attiva/disattiva mischia",
"hotkey_toggleShuffle": "attiva/disattiva mescolamento",
"theme": "tema",
"playbackStyle_description": "selezione lo stile di riproduzione da usare per il player audio",
"discordRichPresence_description": "abilita lo status del playback nello stato attività di {{discord}}. Le chiavi immagine sono: {{icon}}, {{playing}} e {{paused}} ",
@@ -247,7 +246,7 @@
"crossfadeDuration": "durata dissolvenza",
"discordIdleStatus": "visualizza lo stato attività in stato inattivo",
"audioPlayer": "player audio",
"hotkey_zoomOut": "rimpicciolisci",
"hotkey_zoomOut": "rimpicciolisci layout",
"hotkey_rate0": "rimuovi voto",
"discordApplicationId": "application id {{discord}}",
"applicationHotkeys_description": "configura tasti a scelta rapida dell'applicazione. attiva/disattiva la casella per impostare un tasto a scelta rapida globale (solo desktop)",
@@ -275,7 +274,21 @@
"showSkipButton_description": "mostra o nascondi i pulsanti per saltare nella barra del player",
"hotkey_unfavoriteCurrentSong": "rimuovi $t(common.currentSong) dai preferiti",
"hotkey_toggleCurrentSongFavorite": "imposta/rimuovi $t(common.currentSong) favorito",
"showSkipButton": "mostra pulsanti per saltare"
"showSkipButton": "mostra pulsanti per saltare",
"hotkey_browserForward": "Vai avanti di una pagina",
"hotkey_browserBack": "Torna indietro di una pagina",
"sidebarCollapsedNavigation_description": "mostra o nascondi la navigazione nella barra laterale collassata",
"replayGainClipping_description": "Previeni il clipping causato da {{ReplayGain}} abbassando automaticamente il gain",
"replayGainPreamp": "preamplificazione {{ReplayGain}} (dB)",
"sidePlayQueueStyle": "stile della coda di riproduzione laterale",
"showSkipButtons_description": "mostra o nascondi i pulsanti per saltare dalla barra di riproduzione",
"skipPlaylistPage_description": "quando si naviga in una playlist, si va alla pagina dell'elenco dei brani della playlist invece che alla pagina predefinita",
"sidePlayQueueStyle_description": "imposta lo stile della coda di riproduzione laterale",
"replayGainMode": "modalità {{ReplayGain}}",
"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)"
},
"error": {
"remotePortWarning": "riavvia il server per applicare la nuova porta",
@@ -378,7 +391,7 @@
"selectServer": "seleziona server",
"version": "versione {{version}}",
"settings": "$t(common.setting_other)",
"manageServers": "gestisci sever",
"manageServers": "gestisci server",
"expandSidebar": "espandi barra laterale",
"collapseSidebar": "collassa barra laterale",
"openBrowserDevtools": "apri devtools browser",
@@ -473,7 +486,7 @@
"error_savePassword": "si è verificato un errore quando si è provato a salvare la password"
},
"addToPlaylist": {
"success": "aggiunto {{message}} $t(entity.song_other) a {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "aggiunto {{message}} $t(entity.track_other) a {{numOfPlaylists}} $t(entity.playlist_other)",
"title": "aggiungi a $t(entity.playlist_one)",
"input_skipDuplicates": "salta duplicati",
"input_playlists": "$t(entity.playlist_other)"
@@ -505,7 +518,8 @@
"size": "$t(common.size)"
},
"view": {
"table": "tabella"
"table": "tabella",
"card": "Scheda"
},
"label": {
"releaseDate": "data rilascio",
+1 -2
View File
@@ -41,7 +41,6 @@
"hotkey_playbackPause": "一時停止",
"replayGainFallback": "{{ReplayGain}} フォールバック",
"sidebarCollapsedNavigation_description": "折りたたみサイドバーのナビゲーションを表示/非表示にします",
"mpvExecutablePath_help": "1行ごとに1アイテム",
"hotkey_volumeUp": "音量を上げる",
"skipDuration": "スキップの長さ",
"discordIdleStatus_description": "プレイヤーがアイドル状態でもステータスを更新します",
@@ -553,7 +552,7 @@
"error_savePassword": "パスワードを保存する際にエラーが発生しました"
},
"addToPlaylist": {
"success": "{{message}} $t(entity.song_other) を {{numOfPlaylists}} $t(entity.playlist_other) に追加しました",
"success": "{{message}} $t(entity.track_other) を {{numOfPlaylists}} $t(entity.playlist_other) に追加しました",
"title": "$t(entity.playlist_one) に追加",
"input_skipDuplicates": "重複をスキップ",
"input_playlists": "$t(entity.playlist_other)"
+314
View File
@@ -0,0 +1,314 @@
{
"action": {
"editPlaylist": "pas $t(entity.playlist_one) aan",
"goToPage": "ga naar pagina",
"moveToTop": "verplaats naar top",
"addToFavorites": "toevoegen aan $t(entity.favorite_other)",
"addToPlaylist": "toevoegen aan $t(entity.playlist_one)",
"createPlaylist": "maak $t(entity.playlist_one)",
"removeFromPlaylist": "verwijder van $t(entity.playlist_one)",
"viewPlaylists": "bekijk $t(entity.playlist_other)",
"refresh": "$t(common.refresh)",
"deletePlaylist": "verwijder $t(entity.playlist_one)",
"removeFromQueue": "verwijder van lijst",
"deselectAll": "deselecteer alles",
"moveToBottom": "verplaats naar bodem",
"setRating": "selecteer rating",
"toggleSmartPlaylistEditor": "editor $t(entity.smartPlaylist) schakelen",
"removeFromFavorites": "verwijder van $t(entity.favorite_other)",
"clearQueue": "lijst leegmaken"
},
"common": {
"backward": "achteruit",
"increase": "verhogen",
"rating": "rating",
"bpm": "bpm",
"areYouSure": "weet je het zeker?",
"edit": "aanpassen",
"favorite": "favoriet",
"left": "links",
"currentSong": "huidig $t(entity.track_one)",
"collapse": "samenvouwen",
"descending": "aflopend",
"add": "toevoegen",
"gap": "gat",
"ascending": "oplopend",
"dismiss": "negeren",
"manage": "beheren",
"limit": "limiet",
"minimize": "minimaliseren",
"modified": "aangepast",
"duration": "duur",
"name": "naam",
"maximize": "maximaliseren",
"decrease": "verminder",
"ok": "ok",
"description": "beschrijving",
"configure": "configureren",
"path": "pad",
"center": "centreren",
"no": "nee",
"owner": "eigenaar",
"enable": "activeren",
"clear": "opschonen",
"forward": "vooruit",
"delete": "verwijder",
"cancel": "annuleer",
"forceRestartRequired": "herstart om aanpassingen toe te passen... wanneer de notificatie gesloten wordt zal de applicatie herstarten",
"filter_one": "filter",
"filter_other": "filters",
"filters": "filters",
"create": "aanmaken",
"bitrate": "bitrate",
"action_one": "actie",
"action_other": "acties",
"playerMustBePaused": "player moet gepauzeerd zijn",
"confirm": "bevestig",
"home": "home",
"comingSoon": "komt binnenkort…",
"channel_one": "kanaal",
"channel_other": "kanalen",
"disable": "deactiveren",
"none": "geen",
"menu": "menu",
"previousSong": "vorige $t(entity.track_one)",
"noResultsFromQuery": "de zoekopdracht leverde geen resultaten op",
"quit": "sluiten",
"expand": "vergroten",
"disc": "disk",
"random": "willekeurig",
"biography": "biografie",
"note": "Opmerking",
"refresh": "verversen",
"unknown": "onbekend",
"save": "opslaan",
"right": "rechts",
"trackNumber": "track",
"year": "jaar",
"version": "versie",
"title": "titel",
"saveAndReplace": "opslaan en vervangen",
"resetToDefault": "herstellen naar standaard",
"reset": "terugzetten",
"sortOrder": "volgorde",
"restartRequired": "herstart is nodig",
"search": "zoeken",
"saveAs": "opslaan als",
"yes": "ja",
"size": "grootte"
},
"filter": {
"rating": "rating",
"communityRating": "community rating",
"criticRating": "criticus rating",
"mostPlayed": "meest gespeeld",
"comment": "commentaar",
"playCount": "aantal keer afgespeeld",
"recentlyUpdated": "recentelijk geüpdate",
"channels": "$t(common.channel_other)",
"isCompilation": "is compilatie",
"recentlyPlayed": "recentelijk afgespeeld",
"isRated": "is rated",
"owner": "$t(common.owner)",
"bitrate": "bitrate",
"genre": "$t(entity.genre_one)",
"recentlyAdded": "recentelijk toegevoegd",
"note": "notitie",
"name": "naam",
"dateAdded": "datum toegevoegd",
"albumCount": "$t(entity.album_other) totaal",
"path": "pad",
"favorited": "favoriet",
"albumArtist": "$t(entity.albumArtist_one)",
"isRecentlyPlayed": "is recentelijk afgespeeld",
"isFavorited": "is favoriet",
"bpm": "bpm",
"id": "id",
"disc": "disk",
"biography": "biografie",
"artist": "$t(entity.artist_one)",
"duration": "duratie",
"isPublic": "is publiek",
"random": "willekeurig",
"lastPlayed": "laatst gespeeld",
"fromYear": "van jaar",
"album": "$t(entity.album_one)",
"title": "titel",
"search": "zoeken",
"releaseDate": "releasedatum",
"releaseYear": "release jaar",
"songCount": "aantal nummers",
"toYear": "tot jaar",
"trackNumber": "track"
},
"page": {
"contextMenu": {
"setRating": "$t(action.setRating)",
"addToPlaylist": "$t(action.addToPlaylist)",
"addToFavorites": "$t(action.addToFavorites)",
"moveToTop": "$t(action.moveToTop)",
"deletePlaylist": "$t(action.deletePlaylist)",
"moveToBottom": "$t(action.moveToBottom)",
"createPlaylist": "$t(action.createPlaylist)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"addNext": "$t(player.addNext)",
"deselectAll": "$t(action.deselectAll)",
"addLast": "$t(player.addLast)",
"addFavorite": "$t(action.addToFavorites)",
"play": "$t(player.play)",
"numberSelected": "{{count}} geselecteerd",
"removeFromQueue": "$t(action.removeFromQueue)"
},
"appMenu": {
"selectServer": "selecteer server",
"version": "versie {{version}}",
"settings": "$t(common.setting_other)",
"manageServers": "beheer servers",
"expandSidebar": "sidebar uitklappen",
"collapseSidebar": "sidebar inklappen",
"openBrowserDevtools": "open browser devtools",
"quit": "$t(common.quit)",
"goBack": "terug",
"goForward": "vooruit"
},
"albumDetail": {
"moreFromArtist": "meer van deze $t(entity.artist_one)",
"moreFromGeneric": "meer van {{item}}"
},
"fullscreenPlayer": {
"config": {
"dynamicBackground": "dynamische achtergrond",
"followCurrentLyric": "volg de actuele songtekst",
"opacity": "opaciteit",
"lyricSize": "tekstgrootte",
"lyricAlignment": "songtekst uitlijning",
"lyricGap": "tekstkloof"
}
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
},
"albumList": {
"title": "$t(entity.album_other)"
}
},
"error": {
"remotePortWarning": "herstart de server om de nieuwe poort in te stellen",
"systemFontError": "er is iets fout gegaan tijdens het verkrijgen van systeem fonts",
"playbackError": "er is iets fout gegaan bij het afspelen van de media",
"endpointNotImplementedError": "endpoint {{endpoint}} is niet geïmplementeerd voor {{serverType}}",
"remotePortError": "er is iets fout gegaan tijdens het selecteren van de remote server",
"serverRequired": "server vereist",
"authenticationFailed": "authenticatie mislukt",
"apiRouteError": "verzoek kan niet doorgestuurd worden",
"genericError": "er is iets fout gegaan",
"credentialsRequired": "inloggegevens vereist",
"sessionExpiredError": "jouw sessie is verlopen",
"remoteEnableError": "er is iets fout gegaan tijdens het $t(common.enable) van de remote server",
"localFontAccessDenied": "toegang geweigerd tot lokale fonts",
"serverNotSelectedError": "geen server geselecteerd",
"remoteDisableError": "er is iets fout gegaan tijdens het $t(common.disable) van de remote server",
"mpvRequired": "MPV vereist",
"audioDeviceFetchError": "er is iets mis gegaan met het ophalen van de audioapparaten",
"invalidServer": "ongeldige server",
"loginRateError": "te veel login pogingen, probeer het opnieuw in een paar seconde"
},
"entity": {
"genre_one": "genre",
"genre_other": "genres",
"playlistWithCount_one": "{{count}} afspeellijst",
"playlistWithCount_other": "{{count}} afspeellijsten",
"playlist_one": "afspeellijst",
"playlist_other": "afspeellijsten",
"artist_one": "artiest",
"artist_other": "artiesten",
"folderWithCount_one": "{{count}} folder",
"folderWithCount_other": "{{count}} folders",
"albumArtist_one": "album artiest",
"albumArtist_other": "album artiesten",
"track_one": "track",
"track_other": "tracks",
"albumArtistCount_one": "{{count}} album artiest",
"albumArtistCount_other": "{{count}} album artiesten",
"albumWithCount_one": "{{count}} album",
"albumWithCount_other": "{{count}} albums",
"favorite_one": "favoriet",
"favorite_other": "favorieten",
"artistWithCount_one": "{{count}} artiest",
"artistWithCount_other": "{{count}} artiesten",
"folder_one": "folder",
"folder_other": "folders",
"smartPlaylist": "smart $t(entity.playlist_one)",
"album_one": "album",
"album_other": "albums",
"genreWithCount_one": "{{count}} genre",
"genreWithCount_other": "{{count}} genres",
"trackWithCount_one": "{{count}} track",
"trackWithCount_other": "{{count}} tracks"
},
"table": {
"column": {
"rating": "rating"
},
"config": {
"label": {
"rating": "$t(common.rating)"
}
}
},
"setting": {
"hotkey_rate5": "rating 5 sterren",
"hotkey_rate4": "rating 4 sterren"
},
"form": {
"addServer": {
"title": "server toevoegen",
"input_username": "gebruikersnaam",
"input_url": "url",
"input_password": "wachtwoord",
"input_legacyAuthentication": "activeer legacy authenticatie",
"input_name": "server naam",
"success": "server met succes toegevoegd",
"input_savePassword": "wachtwoord opslaan",
"ignoreSsl": "negeer ssl $t(common.restartRequired)",
"ignoreCors": "negeer cors $t(common.restartRequired)",
"error_savePassword": "er is iets mis gegaan met het opslaan van het wachtwoord"
},
"deletePlaylist": {
"title": "verwijder $t(entity.playlist_one)",
"success": "$t(entity.playlist_one) succesvol verwijdert",
"input_confirm": "Typ de naam van $t(entity.playlist_one) om te bevestigen"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"title": "$t(entity.playlist_one) aanmaken",
"input_public": "publiek",
"input_name": "$t(common.name)",
"success": "$t(entity.playlist_one) aangemaakt",
"input_owner": "$t(common.owner)"
},
"addToPlaylist": {
"success": "{{message}}$t(entity.song_other) aan {{numOfPlaylists}} $t(entity.playlist_other) toegevoegd",
"title": "aan $t(entity.playlist_one) toevoegen",
"input_skipDuplicates": "duplicaten overslaan",
"input_playlists": "$t(entity.playlist_other)"
},
"queryEditor": {
"input_optionMatchAll": "alles matchen",
"input_optionMatchAny": "elke match"
},
"lyricSearch": {
"input_name": "$t(common.name)",
"input_artist": "$t(entity.artist_one)",
"title": "tekst zoeken"
},
"editPlaylist": {
"title": "$t(entity.playlist_one) aanpassen"
},
"updateServer": {
"title": "update server",
"success": "server succesvol geüpdatet"
}
}
}
+5 -6
View File
@@ -242,7 +242,7 @@
"error_savePassword": "wystąpił błąd podczas próby zapisania hasła"
},
"addToPlaylist": {
"success": "dodano {{message}} $t(entity.song_other) do {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "dodano {{message}} $t(entity.track_other) do {{numOfPlaylists}} $t(entity.playlist_other)",
"title": "dodano do $t(entity.playlist_one)",
"input_skipDuplicates": "pomiń duplikaty",
"input_playlists": "$t(entity.playlist_other)"
@@ -372,7 +372,7 @@
"stop": "stop",
"repeat": "powtarzaj jeden",
"queue_remove": "usuń zaznaczone",
"playRandom": "odtwarzaj losowe",
"playRandom": "odtwarzaj losowo",
"skip": "pomiń",
"previous": "poprzedni",
"toggleFullscreenPlayer": "przełącz odtwarzacz pełnoekranowy",
@@ -487,7 +487,6 @@
"hotkey_playbackStop": "zatrzymaj",
"discordRichPresence": "{{discord}} obszernie obecny",
"font_description": "ustaw czcionkę dla aplikacji",
"mpvExecutablePath_help": "jedna na linnię",
"playButtonBehavior_optionPlay": "$t(player.play)",
"minimumScrobblePercentage": "minimalny czas trwania scrobble (procentowy)",
"mpvExecutablePath_description": "ustaw ścieżkę dla plików wykonywalnych mpv",
@@ -501,18 +500,18 @@
"mpvExecutablePath": "ścieżka pliku wykonywalnego mpv",
"playButtonBehavior_description": "ustaw domyślne zachowanie dla przycisku odtwarzania kiedy piosenka zostanie dodana do kolejki",
"minimumScrobblePercentage_description": "minimalny czas odtwarzania piosenki który musi upłynąć aby uznać ją za scrobble",
"minimumScrobbleSeconds_description": "minimalny czas odtwarzania piosenki w sekundach jaki musi upłynąć aby uznać ją za scrobble",
"minimumScrobbleSeconds_description": "minimalny czas odtwarzania piosenki w sekundach jaki musi upłynąć aby uznać ją za scrobbling",
"playButtonBehavior": "zachowanie przycisku odtwarzania",
"playbackStyle_optionNormal": "normalny",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"minimumScrobbleSeconds": "minimalne scrobble (sekund)",
"minimumScrobbleSeconds": "minimalne scrobble (w sekundach)",
"remotePort_description": "ustaw port dla serwera zdalnej kontroli",
"replayGainMode_description": "dostosuj wzmocnienie dźwięku zgodnie z wartościami {{ReplayGain}} przechowywanymi w metadanych do pliku",
"replayGainFallback": "rezerwowy {{ReplayGain}}",
"sidebarCollapsedNavigation_description": "pokaż lub ukryj nawigację na zwiniętym pasku bocznym",
"skipDuration": "czas trwania pominięcia",
"showSkipButtons": "pokaż przyciski pomijania",
"scrobble": "scrobble",
"scrobble": "scrobbling",
"skipDuration_description": "ustaw czas pominięcia kiedy zostanie użyty przycisk pominięcia na pasku odtwarzania",
"replayGainClipping_description": "Zapobiegaj wzmocnieniu spowodowanemu przez {{ReplayGain}} na automatyczne obniżanie wzmocnienia",
"replayGainPreamp": "przedwzmacniacz {{ReplayGain}} (db)",
+47 -11
View File
@@ -9,7 +9,7 @@
"bitrate": "taxa de bits",
"action_one": "ação",
"action_many": "ações",
"action_other": "(n == 0 || n == 1) ? ação : ações",
"action_other": "ações",
"biography": "biografia",
"bpm": "bpm",
"edit": "editar",
@@ -31,7 +31,7 @@
"comingSoon": "em breve…",
"channel_one": "canal",
"channel_many": "canais",
"channel_other": "(n == 0 || n == 1) ? canal : canais",
"channel_other": "canais",
"disable": "desabilitar",
"expand": "expandir",
"disc": "disco",
@@ -62,7 +62,7 @@
"version": "versão",
"filter_one": "filtro",
"filter_many": "filtros",
"filter_other": "(n == 0 || n == 1) ? filtro : filtros",
"filter_other": "filtros",
"filters": "filtros",
"saveAndReplace": "salvar e substituir",
"playerMustBePaused": "o player deve estar pausado",
@@ -184,27 +184,63 @@
"entity": {
"albumArtist_one": "artista do álbum",
"albumArtist_many": "artistas do álbum",
"albumArtist_other": "(n == 0 || n == 1) ? artista do álbum : artistas do álbum",
"albumArtist_other": "artistas do álbum",
"albumArtistCount_one": "{{count}} artista do álbum",
"albumArtistCount_many": "{{count}} artistas do álbum",
"albumArtistCount_other": "(n == 0 || n == 1) ? {{count}} artista do álbum : {{count}} artistas do álbum",
"albumArtistCount_other": "{{count}} artistas do álbum",
"album_one": "álbum",
"album_many": "álbuns",
"album_other": "(n == 0 || n == 1) ? álbum : álbuns",
"album_other": "álbuns",
"artist_one": "artista",
"artist_many": "artistas",
"artist_other": "(n == 0 || n == 1) ? artista : artistas",
"artist_other": "artistas",
"albumWithCount_one": "{{count}} álbum",
"albumWithCount_many": "{{count}} álbuns",
"albumWithCount_other": "(n == 0 || n == 1) ? {{count}} álbum : {{count}} álbuns",
"albumWithCount_other": "{{count}} álbuns",
"favorite_one": "favorito",
"favorite_many": "favoritos",
"favorite_other": "(n == 0 || n == 1) ? favorito : favoritos",
"favorite_other": "favoritos",
"artistWithCount_one": "{{count}} artista",
"artistWithCount_many": "{{count}} artistas",
"artistWithCount_other": "(n == 0 || n == 1) ? artista : artistas",
"artistWithCount_other": "{{count}} artistas",
"folder_one": "pasta",
"folder_many": "pastas",
"folder_other": "(n == 0 || n == 1) ? pasta : pastas"
"folder_other": "pastas",
"genre_one": "gênero",
"genre_many": "gêneros",
"genre_other": "gêneros",
"playlistWithCount_one": "{{count}} playlist",
"playlistWithCount_many": "{{count}} playlists",
"playlistWithCount_other": "{{count}} playlists",
"playlist_one": "playlist",
"playlist_many": "playlists",
"playlist_other": "playlists",
"folderWithCount_one": "{{count}} pasta",
"folderWithCount_many": "{{count}} pastas",
"folderWithCount_other": "{{count}} pastas",
"genreWithCount_one": "{{count}} gênero",
"genreWithCount_many": "{{count}} gêneros",
"genreWithCount_other": "{{count}} gêneros"
},
"error": {
"remotePortWarning": "reinicie o servidor para aplicar a nova porta",
"systemFontError": "ocorreu um erro ao tentar obter fontes do sistema",
"playbackError": "ocorreu um erro ao tentar reproduzir a mídia",
"endpointNotImplementedError": "endpoint {{endpoint}} não está implementado para {{serverType}}",
"remotePortError": "ocorreu um erro ao tentar definir a porta do servidor remoto",
"serverRequired": "servidor necessário",
"authenticationFailed": "falha na autenticação",
"apiRouteError": "não é possível encaminhar a solicitação",
"genericError": "um erro ocorreu",
"credentialsRequired": "credenciais necessárias",
"sessionExpiredError": "sua sessão expirou",
"remoteEnableError": "ocorreu um erro ao tentar $t(common.enable) o servidor remoto",
"localFontAccessDenied": "acesso negado a fontes locais",
"serverNotSelectedError": "nenhum servidor selecionado",
"remoteDisableError": "ocorreu um erro ao tentar $t(common.disable) o servidor remoto",
"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"
}
}
+1 -1
View File
@@ -434,7 +434,7 @@
"error_savePassword": "произошла ошибка во время попытки сохранения пароля"
},
"addToPlaylist": {
"success": "добавлено(а) {{message}} $t(entity.song_other) в {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "добавлено(а) {{message}} $t(entity.track_other) в {{numOfPlaylists}} $t(entity.playlist_other)",
"title": "добавить в $t(entity.playlist_one)",
"input_skipDuplicates": "пропустить дубликаты",
"input_playlists": "$t(entity.playlist_other)"
+1 -2
View File
@@ -41,7 +41,6 @@
"hotkey_playbackPause": "pauza",
"replayGainFallback": "{{ReplayGain}} alternativa",
"sidebarCollapsedNavigation_description": "prikaži ili sakrij navigaciju u sklopljenoj bočnoj traci",
"mpvExecutablePath_help": "po jedna po liniji",
"hotkey_volumeUp": "pojačaj glasnoću",
"skipDuration": "dužina preskakanja",
"discordIdleStatus_description": "kada je omogućeno, ažurira status dok je plejer u mirovanju",
@@ -559,7 +558,7 @@
"error_savePassword": "došlo je do greške prilikom pokušaja čuvanja lozinke"
},
"addToPlaylist": {
"success": "dodato {{message}} $t(entity.song_other) u {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "dodato {{message}} $t(entity.track_other) u {{numOfPlaylists}} $t(entity.playlist_other)",
"title": "dodaj u $t(entity.playlist_one)",
"input_skipDuplicates": "preskoči duplikate",
"input_playlists": "$t(entity.playlist_other)"
+71 -8
View File
@@ -95,7 +95,8 @@
"random": "slumpmässig",
"size": "storlek",
"biography": "biografi",
"note": "anteckning"
"note": "anteckning",
"center": "center"
},
"error": {
"remotePortWarning": "starta om servern för att tillämpa den nya porten",
@@ -157,7 +158,9 @@
"toYear": "till år",
"fromYear": "från år",
"album": "$t(entity.album_one)",
"trackNumber": "spår"
"trackNumber": "spår",
"songCount": "sångräkning",
"criticRating": "kritikerbetyg"
},
"form": {
"deletePlaylist": {
@@ -187,7 +190,7 @@
"error_savePassword": "ett fel uppstod när lösenordet skulle sparas"
},
"addToPlaylist": {
"success": "tillade {{message}} $t(entity.song_other) til {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "tillade {{message}} $t(entity.track_other) til {{numOfPlaylists}} $t(entity.playlist_other)",
"title": "lägg till i $t(entity.playlist_one)",
"input_skipDuplicates": "hoppa över dubbletter",
"input_playlists": "$t(entity.playlist_other)"
@@ -218,8 +221,13 @@
"opacity": "ogenomskinlighet",
"lyricSize": "låttext storlek",
"lyricAlignment": "låttext justering",
"lyricGap": "låttext mellanrum"
}
"lyricGap": "låttext mellanrum",
"synchronized": "synkroniserad",
"showLyricProvider": "visa sångtextleverantör",
"unsynchronized": "osynkroniserad"
},
"lyrics": "sångtext",
"related": "relaterad"
},
"appMenu": {
"selectServer": "välj server",
@@ -230,7 +238,8 @@
"openBrowserDevtools": "öppna webbläsarens utvecklingsverktyg",
"quit": "$t(common.quit)",
"goBack": "gå tillbaka",
"goForward": "gå framåt"
"goForward": "gå framåt",
"collapseSidebar": "växla sidofältet"
},
"contextMenu": {
"addToPlaylist": "$t(action.addToPlaylist)",
@@ -251,7 +260,7 @@
"removeFromQueue": "$t(action.removeFromQueue)"
},
"albumDetail": {
"moreFromArtist": "Mer från $t(entity.genre_one)",
"moreFromArtist": "mer från $t(entity.genre_one)",
"moreFromGeneric": "mer från {{item}}"
},
"albumArtistList": {
@@ -259,6 +268,29 @@
},
"albumList": {
"title": "$t(entity.album_other)"
},
"sidebar": {
"nowPlaying": "nu spelas"
},
"home": {
"mostPlayed": "mest spelade",
"newlyAdded": "nytillkomna utgåvor",
"explore": "utforska från ditt bibliotek",
"recentlyPlayed": "nyligen spelat"
},
"setting": {
"playbackTab": "uppspelning",
"generalTab": "allmänt",
"hotkeysTab": "snabbtangenter",
"windowTab": "fönster"
},
"globalSearch": {
"commands": {
"serverCommands": "serverkommandon",
"goToPage": "gå till sidan",
"searchFor": "sök efter {{query}}"
},
"title": "kommandon"
}
},
"entity": {
@@ -277,6 +309,37 @@
"folder_one": "mapp",
"folder_other": "mappar",
"album_one": "album",
"album_other": "album"
"album_other": "album",
"playlistWithCount_one": "{{count}} spellista",
"playlistWithCount_other": "{{count}} spellistor",
"folderWithCount_one": "{{count}} mapp",
"folderWithCount_other": "{{count}} mappar",
"track_one": "spår",
"track_other": "spår",
"trackWithCount_one": "{{count}} spår",
"trackWithCount_other": "{{count}} spår"
},
"player": {
"repeat_all": "repetera alla",
"repeat": "repetera",
"queue_remove": "ta bort markerad",
"playRandom": "spela slumpmässigt",
"previous": "föregående",
"favorite": "favorit",
"next": "nästa",
"shuffle": "blanda",
"playbackFetchNoResults": "inga låtar hittades",
"playbackFetchInProgress": "laddar låtar…",
"addNext": "lägg till nästa",
"playbackSpeed": "uppspelningshastighet",
"playbackFetchCancel": "det här tar ett tag... stäng aviseringen för att avbryta",
"play": "spela",
"repeat_off": "repetera inaktiverad",
"queue_clear": "rensa kö",
"muted": "mutad",
"queue_moveToTop": "flytta markerad till botten",
"queue_moveToBottom": "flytta markerad till toppen",
"addLast": "lägg till sist",
"mute": "muta"
}
}
+13 -8
View File
@@ -120,7 +120,7 @@
"queue_remove": "移除所选",
"playRandom": "随机播放",
"skip": "跳过",
"previous": "一首",
"previous": "一首",
"toggleFullscreenPlayer": "全屏",
"skip_back": "向后跳过",
"favorite": "收藏",
@@ -192,11 +192,11 @@
"scrobble": "记录播放信息(Scrobble",
"skipDuration_description": "设置每次按下跳过按钮将会跳过的时长",
"fontType_optionSystem": "系统字体",
"mpvExecutablePath_description": "设置 mpv 二进制文件的路径",
"mpvExecutablePath_description": "设置 mpv 二进制文件的路径。如果留空,则使用默认路径",
"sampleRate": "采样率",
"sidePlayQueueStyle_optionAttached": "吸附",
"sidebarConfiguration": "侧边栏设定",
"sampleRate_description": "所选的采样率与当前媒体的频率不同时,用于输出采样率",
"sampleRate_description": "如果选择的采样率与当前媒体的采样频率不同,请选择要使用的输出采样率。小于 8000 的值将使用默认频率",
"replayGainMode_optionNone": "$t(common.none)",
"hotkey_zoomIn": "放大",
"scrobble_description": "在你的社交媒体中记录播放信息",
@@ -208,7 +208,7 @@
"hotkey_skipForward": "向后跳过",
"sidePlayQueueStyle": "侧边播放列表样式",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"zoom": "放率",
"zoom": "放率",
"minimizeToTray_description": "将应用程序最小化到系统托盘",
"hotkey_playbackPlay": "播放",
"hotkey_togglePreviousSongFavorite": "收藏 / 取消收藏$t(common.previousSong)",
@@ -233,7 +233,7 @@
"hotkey_toggleFullScreenPlayer": "全屏播放",
"hotkey_localSearch": "页面内搜索",
"hotkey_toggleQueue": "显示 / 隐藏播放队列",
"zoom_description": "设置应用的放大率",
"zoom_description": "设置应用程序的缩放率",
"remotePassword_description": "设置远程控制服务器的密码。这些凭据默认以不安全的方式传输,因此您应该使用一个您不在意的唯一密码",
"hotkey_rate5": "评为 5 星",
"hotkey_playbackPrevious": "上一曲",
@@ -292,7 +292,6 @@
"windowBarStyle_description": "选择窗口顶栏的风格",
"savePlayQueue_description": "当应用程序关闭时保存播放队列,并在应用程序打开时恢复它",
"useSystemTheme": "跟随系统",
"mpvExecutablePath_help": "每行一个",
"discordIdleStatus_description": "启用后将会在播放器闲置时更新状态",
"replayGainClipping_description": "自动降低增益以防止{{ReplayGain}}造成削波",
"replayGainPreamp": "{{ReplayGain}}前置放大(分贝)",
@@ -305,7 +304,13 @@
"accentColor_description": "设置应用的强调色",
"replayGainPreamp_description": "调整应用在{{ReplayGain}}值上的前置放大增益",
"discordIdleStatus": "显示 rich presence 闲置状态",
"discordRichPresence": "{{discord}} rich presence"
"discordRichPresence": "{{discord}} rich presence",
"clearCache": "清除浏览器缓存",
"buttonSize": "播放器栏按钮大小",
"buttonSize_description": "播放器栏按钮大小",
"clearCache_description": "feishin的“硬清除”。除了清除feishin的缓存,清空浏览器缓存(保存的图像和其他资源)。会保留服务器凭据和设置",
"clearQueryCache_description": "feishin的“软清除”。这将会刷新播放列表、元数据并重置保存的歌词。会保留设置、服务器凭据和缓存图像",
"clearQueryCache": "清除feishin缓存"
},
"error": {
"remotePortWarning": "重启服务器使新端口生效",
@@ -495,7 +500,7 @@
"input_url": "url"
},
"addToPlaylist": {
"success": "添加 {{message}} $t(entity.song_other) 到 {{numOfPlaylists}} $t(entity.playlist_other)",
"success": "添加 $t(entity.trackWithCount, {\"count\": {{message}} }) 到 $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "添加到$t(entity.playlist_one)",
"input_skipDuplicates": "跳过重复",
"input_playlists": "$t(entity.playlist_other)"
+600 -1
View File
@@ -1 +1,600 @@
{}
{
"common": {
"backward": "返回",
"biography": "簡介",
"bitrate": "比特率",
"bpm": "bpm",
"clear": "清空",
"collapse": "折疊",
"comingSoon": "即將上線…",
"confirm": "確認",
"decrease": "降低",
"delete": "刪除",
"descending": "降序",
"description": "描述",
"forceRestartRequired": "重啓應用使更改生效…關閉通知即可重啓",
"menu": "菜單",
"action_other": "操作",
"add": "添加",
"areYouSure": "是否繼續?",
"ascending": "升序",
"disable": "禁用",
"disc": "盤",
"dismiss": "忽略",
"duration": "時長",
"edit": "編輯",
"enable": "啓用",
"expand": "展開",
"favorite": "收藏",
"filter_other": "篩選",
"filters": "篩選",
"forward": "前進",
"gap": "空隙",
"home": "主頁",
"increase": "增高",
"left": "左",
"limit": "限制",
"manage": "管理",
"maximize": "最大化",
"ok": "好",
"owner": "所有者",
"path": "路徑",
"playerMustBePaused": "播放器須被暫停",
"previousSong": "上壹首$t(entity.track_one)",
"quit": "退出",
"random": "隨機",
"rating": "評分",
"refresh": "刷新",
"reset": "重置",
"resetToDefault": "重置爲默認",
"restartRequired": "需要重啓應用",
"right": "右",
"save": "保存",
"saveAndReplace": "保存並替換",
"saveAs": "保存爲",
"search": "搜索",
"sortOrder": "順序",
"title": "標題",
"trackNumber": "音軌編號",
"unknown": "未知",
"size": "大小",
"version": "版本",
"year": "年份",
"yes": "是",
"cancel": "取消",
"center": "中央",
"channel_other": "頻道",
"configure": "配置",
"create": "創建",
"currentSong": "當前$t(entity.track_one)",
"minimize": "最小化",
"modified": "已修改",
"name": "名稱",
"no": "否",
"none": "無",
"noResultsFromQuery": "未查詢到匹配結果",
"note": "注釋"
},
"error": {
"endpointNotImplementedError": "{{serverType}} 尚未實現端點 {{endpoint}}",
"apiRouteError": "請求失敗:無法路由",
"audioDeviceFetchError": "無法獲取音頻設備",
"authenticationFailed": "認證失敗",
"credentialsRequired": "需要憑證",
"genericError": "發生了錯誤",
"invalidServer": "無效的服務器",
"localFontAccessDenied": "無法獲取本地字體",
"loginRateError": "登錄請求嘗試次數過多,請稍後再試",
"remoteDisableError": "$t(common.disable)遠程服務器時出現錯誤",
"remoteEnableError": "$t(common.enable)遠程服務器時出現錯誤",
"remotePortError": "設置遠程服務器端口時發生錯誤",
"remotePortWarning": "重啓服務器使新端口生效",
"serverRequired": "需要服務器",
"sessionExpiredError": "會話已過期",
"systemFontError": "獲取系統字體時出現錯誤",
"serverNotSelectedError": "未選擇服務器",
"mpvRequired": "需要 MPV",
"playbackError": "無法播放媒體"
},
"page": {
"contextMenu": {
"removeFromFavorites": "$t(action.removeFromFavorites)",
"addToFavorites": "$t(action.addToFavorites)",
"addToPlaylist": "$t(action.addToPlaylist)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"addFavorite": "$t(action.addToFavorites)",
"addLast": "$t(player.addLast)",
"addNext": "$t(player.addNext)",
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"moveToBottom": "$t(action.moveToBottom)",
"setRating": "$t(action.setRating)",
"moveToTop": "$t(action.moveToTop)",
"numberSelected": "{{count}} 已選擇",
"play": "$t(player.play)"
},
"globalSearch": {
"title": "命令",
"commands": {
"goToPage": "跳至頁面",
"searchFor": "搜索 {{query}}",
"serverCommands": "服務器命令"
}
},
"home": {
"explore": "從庫中搜索",
"recentlyPlayed": "最近播放",
"title": "$t(common.home)",
"mostPlayed": "最多播放",
"newlyAdded": "最近添加的發布"
},
"appMenu": {
"openBrowserDevtools": "打開浏覽器開發者工具",
"collapseSidebar": "折疊側邊欄",
"expandSidebar": "展開側邊欄",
"goBack": "返回",
"goForward": "前進",
"quit": "$t(common.quit)",
"selectServer": "選擇服務器",
"settings": "$t(common.setting_other)",
"version": "版本 {{version}}",
"manageServers": "管理服務器"
},
"fullscreenPlayer": {
"config": {
"showLyricProvider": "顯示歌詞提供者",
"useImageAspectRatio": "使用圖片縱橫比",
"dynamicBackground": "動態背景",
"followCurrentLyric": "跟隨當前歌詞",
"lyricAlignment": "歌詞對齊",
"lyricGap": "歌詞間距",
"lyricSize": "歌詞字體大小",
"synchronized": "已同步",
"unsynchronized": "未同步",
"opacity": "透明度",
"showLyricMatch": "顯示匹配的歌詞"
},
"lyrics": "歌詞",
"related": "相關",
"upNext": "即將播放"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"setting": {
"hotkeysTab": "快捷鍵",
"playbackTab": "播放",
"windowTab": "窗口",
"generalTab": "通用"
},
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
},
"albumDetail": {
"moreFromArtist": "更多該$t(entity.artist_one)作品",
"moreFromGeneric": "更多{{item}}作品"
},
"albumList": {
"title": "$t(entity.album_other)"
},
"genreList": {
"title": "$t(entity.genre_other)"
},
"sidebar": {
"albumArtists": "$t(entity.albumArtist_other)",
"albums": "$t(entity.album_other)",
"artists": "$t(entity.artist_other)",
"folders": "$t(entity.folder_other)",
"search": "$t(common.search)",
"settings": "$t(common.setting_other)",
"tracks": "$t(entity.track_other)",
"genres": "$t(entity.genre_other)",
"home": "$t(common.home)",
"nowPlaying": "正在播放",
"playlists": "$t(entity.playlist_other)"
},
"trackList": {
"title": "$t(entity.track_other)"
}
},
"player": {
"playbackFetchInProgress": "正在加載歌曲…",
"addLast": "添加到播放列表末尾",
"addNext": "添加爲播放列表下壹首",
"favorite": "收藏",
"mute": "靜音",
"muted": "已靜音",
"playbackFetchNoResults": "未找到歌曲",
"playbackSpeed": "播放速度",
"playRandom": "隨機播放",
"previous": "上壹首",
"queue_clear": "清空播放隊列",
"queue_remove": "移除所選",
"repeat": "循環",
"repeat_all": "全部循環",
"repeat_off": "不循環",
"shuffle": "隨機播放",
"shuffle_off": "未啓用隨機播放",
"skip": "跳過",
"skip_back": "向後跳過",
"skip_forward": "向前跳過",
"stop": "停止",
"toggleFullscreenPlayer": "全屏",
"unfavorite": "取消收藏",
"pause": "暫停",
"next": "下壹首",
"play": "播放",
"playbackFetchCancel": "請稍等…關閉通知以取消操作",
"queue_moveToBottom": "使所選置頂",
"queue_moveToTop": "使所選置底"
},
"setting": {
"audioPlayer_description": "選擇用于播放的音頻播放器",
"themeLight": "主題(淺色)",
"themeLight_description": "應用將使用淺色主題",
"discordRichPresence": "{{discord}} rich presence",
"hotkey_volumeDown": "音量降低",
"hotkey_volumeMute": "靜音",
"minimumScrobblePercentage": "最小 scrobble 時長(百分比)",
"minimumScrobblePercentage_description": "歌曲被記錄爲已播放(scrobble)所需的最小播放百分比",
"theme_description": "設置應用的主題",
"accentColor": "強調色",
"accentColor_description": "設置應用的強調色",
"applicationHotkeys": "應用快捷鍵",
"applicationHotkeys_description": "配置應用快捷鍵。勾選設爲全局快捷鍵(僅桌面端)",
"audioDevice": "音頻設備",
"audioDevice_description": "選擇用于播放的音頻設備(僅 web 播放器)",
"audioExclusiveMode": "音頻獨占模式",
"audioExclusiveMode_description": "啓用獨占輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音頻",
"audioPlayer": "音頻播放器",
"crossfadeDuration": "淡入淡出持續時間",
"crossfadeDuration_description": "設置淡入淡出持續時間",
"crossfadeStyle": "淡入淡出風格",
"crossfadeStyle_description": "選擇用于音頻播放器的淡入淡出風格",
"customFontPath": "自定義字體路徑",
"customFontPath_description": "設置應用使用的自定義字體路徑",
"disableAutomaticUpdates": "禁用自動更新",
"disableLibraryUpdateOnStartup": "禁用啓動時查找新版本",
"discordApplicationId": "{{discord}} 應用 id",
"discordApplicationId_description": "{{discord}} rich presence 應用 id(默認爲 {{defaultId}}",
"discordIdleStatus": "顯示 rich presence 閑置狀態",
"discordIdleStatus_description": "啓用後將會在播放器閑置時更新狀態",
"discordRichPresence_description": "在 {{discord}} rich presence 中顯示播放狀態。圖片鍵爲:{{icon}}、{{playing}} 和 {{paused}} ",
"discordUpdateInterval": "{{discord}} rich presence 更新間隔",
"discordUpdateInterval_description": "更新間隔秒數(至少 15 秒)",
"enableRemote": "啓用遠程控制服務器",
"enableRemote_description": "啓用遠程控制服務器,以允許其他設備控制此應用",
"exitToTray": "退出時最小化到托盤",
"floatingQueueArea_description": "在屏幕右側顯示壹個懸停圖標,以查看播放隊列",
"followLyric": "跟隨當前歌詞",
"font_description": "設置應用使用的字體",
"fontType": "字體類型",
"fontType_description": "內置字體可以選擇 Feishin 提供的字體之壹。系統字體允許您選擇操作系統提供的任何字體。自定義選項允許您使用自己的字體",
"fontType_optionBuiltIn": "內置字體",
"fontType_optionCustom": "自定義字體",
"fontType_optionSystem": "系統字體",
"gaplessAudio": "無縫音頻",
"gaplessAudio_description": "調整 mpv 無縫音頻設置",
"gaplessAudio_optionWeak": "弱(推薦)",
"globalMediaHotkeys": "全局媒體快捷鍵",
"hotkey_browserForward": "浏覽器前進",
"hotkey_favoritePreviousSong": "收藏 $t(common.previousSong)",
"hotkey_globalSearch": "全局搜索",
"hotkey_localSearch": "頁面內搜索",
"hotkey_playbackNext": "下壹曲",
"hotkey_playbackPause": "暫停",
"hotkey_playbackPlay": "播放",
"hotkey_playbackPlayPause": "播放/暫停",
"hotkey_playbackPrevious": "上壹曲",
"hotkey_rate2": "評爲 2 星",
"hotkey_rate1": "評爲 1 星",
"hotkey_rate3": "評爲 3 星",
"hotkey_rate4": "評爲 4 星",
"hotkey_rate5": "評爲 5 星",
"hotkey_skipBackward": "向回跳過",
"hotkey_skipForward": "向後跳過",
"hotkey_toggleCurrentSongFavorite": "收藏 / 取消收藏$t(common.currentSong)",
"hotkey_toggleFullScreenPlayer": "全屏播放",
"hotkey_togglePreviousSongFavorite": "收藏 / 取消收藏$t(common.previousSong)",
"hotkey_toggleQueue": "顯示 / 隱藏播放隊列",
"hotkey_toggleRepeat": "切換循環播放設定",
"hotkey_toggleShuffle": "切換隨機播放設定",
"hotkey_unfavoriteCurrentSong": "取消收藏$t(common.currentSong)",
"hotkey_unfavoritePreviousSong": "取消收藏$t(common.previousSong)",
"hotkey_zoomIn": "放大",
"hotkey_zoomOut": "縮小",
"language": "語言",
"language_description": "設置應用的語言($t(common.restartRequired)",
"lyricFetch": "從互聯網獲取歌詞",
"lyricFetch_description": "從多個互聯網源獲取歌詞",
"lyricFetchProvider": "歌詞源",
"lyricOffset": "歌詞偏移(毫秒)",
"lyricOffset_description": "將歌詞偏移指定的毫秒數",
"lyricFetchProvider_description": "選擇歌詞源。 歌詞源順序與查詢順序壹致",
"minimizeToTray": "最小化到托盤",
"minimizeToTray_description": "將應用程序最小化到系統托盤",
"minimumScrobbleSeconds": "最小 scrobble 時間(秒)",
"minimumScrobbleSeconds_description": "歌曲被記錄爲已播放(scrobble)所需的最小播放時間",
"mpvExecutablePath": "mpv 二進制文件路徑",
"playbackStyle_optionCrossFade": "交叉淡入淡出",
"playbackStyle_optionNormal": "通常",
"playButtonBehavior": "播放按鈕行爲",
"playButtonBehavior_description": "設置將歌曲添加到隊列時播放按鈕的默認行爲",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"remotePort": "遠程服務器端口",
"remoteUsername": "遠程服務器用戶名",
"replayGainClipping": "{{ReplayGain}}削波",
"replayGainFallback": "{{ReplayGain}}後備替代",
"replayGainFallback_description": "樂曲沒有{{ReplayGain}}標簽時應用的增益(以分貝爲單位)",
"replayGainMode": "{{ReplayGain}}模式",
"replayGainMode_description": "根據樂曲元數據中存儲的{{ReplayGain}}值調整音量增益",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainMode_optionTrack": "$t(entity.track_one)",
"replayGainPreamp": "{{ReplayGain}}前置放大(分貝)",
"replayGainPreamp_description": "調整應用在{{ReplayGain}}值上的前置放大增益",
"savePlayQueue": "保存播放列表",
"sampleRate_description": "如果選擇的采樣頻率與當前媒體的采樣頻率不同,請選擇要使用的輸出采樣率。小于 8000 的值將使用默認頻率",
"savePlayQueue_description": "當應用程序關閉時保存播放隊列,並在應用程序打開時恢複它",
"scrobble": "記錄播放信息(Scrobble",
"scrobble_description": "在妳的社交媒體中記錄播放信息",
"showSkipButton": "顯示跳過按鈕",
"showSkipButton_description": "在播放條上顯示/隱藏跳過按鈕",
"sidebarPlaylistList": "側邊欄歌單列表",
"sidebarCollapsedNavigation": "側邊欄(已折疊)導航",
"sidebarCollapsedNavigation_description": "在折疊的側邊欄中顯示或隱藏導航",
"sidebarConfiguration": "側邊欄設定",
"sidebarConfiguration_description": "選擇側邊欄包含的項目與順序",
"sidebarPlaylistList_description": "顯示或隱藏側邊欄歌單列表",
"sidePlayQueueStyle": "側邊播放列表樣式",
"sidePlayQueueStyle_description": "設置側邊播放列表樣式",
"sidePlayQueueStyle_optionAttached": "吸附",
"sidePlayQueueStyle_optionDetached": "不吸附",
"skipDuration": "跳過時長",
"skipDuration_description": "設置每次按下跳過按鈕將會跳過的時長",
"skipPlaylistPage": "跳過歌單頁面",
"skipPlaylistPage_description": "打開歌單時,直接查看歌曲列表而非查看默認頁面",
"theme": "主題",
"themeDark": "主題(深色)",
"useSystemTheme_description": "使用系統定義的淺色或深色主題",
"useSystemTheme": "跟隨系統",
"volumeWheelStep": "音量滾輪步長",
"volumeWheelStep_description": "在音量滑塊上滾動鼠標滾輪時要更改的音量大小",
"windowBarStyle": "窗口頂欄風格",
"windowBarStyle_description": "選擇窗口頂欄的風格",
"zoom": "縮放率",
"zoom_description": "設置應用程序的縮放率",
"hotkey_volumeUp": "音量增高",
"sampleRate": "采樣率",
"showSkipButtons_description": "在播放條顯示/隱藏播放按鈕",
"playbackStyle": "播放風格",
"exitToTray_description": "退出應用時最小化到系統托盤而非關閉",
"floatingQueueArea": "顯示浮動隊列懸停區域",
"followLyric_description": "滾動歌詞到當前播放位置",
"font": "字體",
"globalMediaHotkeys_description": "啓用或禁用系統媒體快捷鍵以控制播放",
"hotkey_browserBack": "浏覽器後退",
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
"hotkey_playbackStop": "停止",
"hotkey_rate0": "清除評分",
"mpvExecutablePath_description": "設置 mpv 二進制文件的路徑。如果留空,則使用默認路徑",
"mpvExtraParameters": "mpv 參數",
"playbackStyle_description": "選擇播放器的播放風格",
"playButtonBehavior_optionPlay": "$t(player.play)",
"remotePassword": "遠程控制服務器密碼",
"remotePassword_description": "設置遠程控制服務器的密碼。這些憑據默認以不安全的方式傳輸,因此您應該使用壹個您不在意的唯壹密碼",
"remotePort_description": "設置遠程服務器端口",
"remoteUsername_description": "設置遠程控制服務器的用戶名。如果用戶名和密碼都爲空,則身份驗證將被禁用",
"replayGainClipping_description": "自動降低增益以防止{{ReplayGain}}造成削波",
"showSkipButtons": "顯示跳過按鈕",
"themeDark_description": "應用將使用深色主題",
"clearQueryCache_description": "feishin的“軟清除”。這將會刷新播放列表、元數據並重置保存的歌詞。會保留設置、服務器憑據和緩存圖像",
"clearCache": "清除浏覽器緩存",
"clearCache_description": "feishin的“硬清除”。除了清除feishin的緩存,清空浏覽器緩存(保存的圖像和其他資源)。會保留服務器憑據和設置",
"clearQueryCache": "清除feishin緩存",
"buttonSize": "播放器欄按鈕大小",
"buttonSize_description": "播放器欄按鈕大小"
},
"table": {
"config": {
"general": {
"displayType": "顯示風格",
"gap": "$t(common.gap)",
"size": "$t(common.size)",
"tableColumns": "列",
"autoFitColumns": "列寬自適應"
},
"label": {
"actions": "$t(common.action_other)",
"album": "$t(entity.album_one)",
"albumArtist": "$t(entity.albumArtist_one)",
"artist": "$t(entity.artist_one)",
"bpm": "$t(common.bpm)",
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"channels": "$t(common.channel_other)",
"dateAdded": "添加日期",
"discNumber": "碟片編號",
"duration": "$t(common.duration)",
"favorite": "$t(common.favorite)",
"genre": "$t(entity.genre_one)",
"lastPlayed": "最後播放",
"note": "$t(common.note)",
"owner": "$t(common.owner)",
"path": "$t(common.path)",
"playCount": "播放數",
"releaseDate": "發布日期",
"rowIndex": "行號",
"size": "$t(common.size)",
"title": "$t(common.title)",
"titleCombined": "$t(common.title)(合並)",
"trackNumber": "音軌編號",
"year": "$t(common.year)",
"rating": "$t(common.rating)"
},
"view": {
"card": "卡片",
"poster": "海報",
"table": "表格"
}
},
"column": {
"album": "專輯",
"albumArtist": "專輯藝術家",
"albumCount": "$t(entity.album_other)",
"artist": "$t(entity.artist_one)",
"biography": "簡介",
"bitrate": "比特率",
"channels": "$t(common.channel_other)",
"comment": "評論",
"dateAdded": "添加日期",
"discNumber": "盤",
"favorite": "收藏",
"lastPlayed": "最後播放",
"path": "路徑",
"playCount": "播放次數",
"rating": "評價",
"releaseDate": "發布日期",
"releaseYear": "年份",
"genre": "$t(entity.genre_one)",
"bpm": "bpm",
"songCount": "$t(entity.track_other)",
"title": "標題",
"trackNumber": "音軌編號"
}
},
"action": {
"addToFavorites": "添加到$t(entity.favorite_other)",
"clearQueue": "清空播放隊列",
"createPlaylist": "創建$t(entity.playlist_one)",
"deletePlaylist": "刪除$t(entity.playlist_one)",
"addToPlaylist": "添加到$t(entity.playlist_one)",
"deselectAll": "取消全選",
"editPlaylist": "編輯 $t(entity.playlist_one)",
"goToPage": "轉到頁面",
"moveToBottom": "跳至底部",
"moveToTop": "跳至頂部",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "從$t(entity.favorite_other)移除",
"removeFromPlaylist": "從$t(entity.playlist_one)移除",
"removeFromQueue": "從播放隊列中移除",
"setRating": "評分",
"toggleSmartPlaylistEditor": "切換$t(entity.smartPlaylist)編輯器",
"viewPlaylists": "查看$t(entity.playlist_other)"
},
"entity": {
"album_other": "專輯",
"albumArtist_other": "專輯藝術家",
"albumArtistCount_other": "{{count}} 位專輯藝術家",
"artist_other": "藝術家",
"artistWithCount_other": "{{count}} 位藝術家",
"favorite_other": "收藏",
"folder_other": "文件夾",
"folderWithCount_other": "{{count}} 個文件夾",
"genre_other": "流派",
"genreWithCount_other": "{{count}} 種流派",
"playlist_other": "播放列表",
"playlistWithCount_other": "{{count}} 個播放列表",
"smartPlaylist": "智能$t(entity.playlist_one)",
"track_other": "樂曲",
"trackWithCount_other": "{{count}} 首樂曲",
"albumWithCount_other": "{{count}} 張專輯"
},
"filter": {
"albumCount": "$t(entity.album_other)數",
"artist": "$t(entity.artist_one)",
"biography": "個人簡介",
"bitrate": "比特率",
"bpm": "bpm",
"channels": "$t(common.channel_other)",
"comment": "評論",
"communityRating": "社區評分",
"criticRating": "評論家評分",
"dateAdded": "已添加日期",
"disc": "盤",
"duration": "時長",
"id": "id",
"fromYear": "從年份",
"genre": "$t(entity.genre_one)",
"isCompilation": "爲合輯",
"isFavorited": "已收藏",
"isPublic": "已公開",
"isRated": "已評分",
"name": "名稱",
"note": "注釋",
"isRecentlyPlayed": "最近播放過",
"lastPlayed": "上次播放過",
"mostPlayed": "播放最多",
"owner": "$t(common.owner)",
"path": "路徑",
"playCount": "播放次數",
"random": "隨機",
"rating": "評分",
"recentlyPlayed": "最近播放",
"recentlyUpdated": "最近更新",
"releaseDate": "發布日期",
"songCount": "曲目數",
"album": "$t(entity.album_one)",
"albumArtist": "$t(entity.albumArtist_one)",
"favorited": "已收藏",
"recentlyAdded": "最近添加",
"releaseYear": "發布年份",
"search": "搜索",
"title": "標題",
"toYear": "從年份",
"trackNumber": "曲目"
},
"form": {
"addServer": {
"input_legacyAuthentication": "啓用舊版認證方式",
"input_name": "服務器名",
"input_password": "密碼",
"input_savePassword": "保存密碼",
"input_url": "url",
"input_username": "用戶名",
"success": "服務器添加成功",
"title": "添加服務器",
"error_savePassword": "保存密碼時出現錯誤",
"ignoreCors": "忽略 cors $t(common.restartRequired)",
"ignoreSsl": "忽略 ssl $t(common.restartRequired)"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist_other)",
"input_skipDuplicates": "跳過重複",
"success": "添加 $t(entity.trackWithCount, {\"count\": {{message}} }) 到 $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "添加到$t(entity.playlist_one)"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"input_name": "$t(common.name)",
"input_owner": "$t(common.owner)",
"input_public": "公開",
"success": "已成功創建 $t(entity.playlist_one)",
"title": "創建$t(entity.playlist_one)"
},
"lyricSearch": {
"input_name": "$t(common.name)",
"title": "搜索歌詞",
"input_artist": "$t(entity.artist_one)"
},
"queryEditor": {
"input_optionMatchAll": "匹配全部",
"input_optionMatchAny": "匹配任何"
},
"updateServer": {
"success": "服務器已更新成功",
"title": "更新服務器"
},
"deletePlaylist": {
"input_confirm": "輸入$t(entity.playlist_one)的名稱進行確認",
"title": "刪除$t(entity.playlist_one)",
"success": "$t(entity.playlist_one)已成功刪除"
},
"editPlaylist": {
"title": "編輯$t(entity.playlist_one)"
}
}
}
+316 -104
View File
@@ -1,7 +1,11 @@
import console from 'console';
import { ipcMain } from 'electron';
import { getMpvInstance } from '../../../main';
import { app, ipcMain } from 'electron';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
import { getMainWindow, sendToastToRenderer } from '../../../main';
import { PlayerData } from '/@/renderer/store';
import { createLog, isWindows } from '../../../utils';
import { store } from '../settings';
declare module 'node-mpv';
@@ -13,6 +17,208 @@ declare module 'node-mpv';
// });
// }
let mpvInstance: MpvAPI | null = null;
const NodeMpvErrorCode = {
0: 'Unable to load file or stream',
1: 'Invalid argument',
2: 'Binary not found',
3: 'IPC command invalid',
4: 'Unable to bind IPC socket',
5: 'Connection timeout',
6: 'MPV is already running',
7: 'Could not send IPC message',
8: 'MPV is not running',
9: 'Unsupported protocol',
};
type NodeMpvError = {
errcode: number;
method: string;
stackTrace: string;
verbose: string;
};
const mpvLog = (
data: { action: string; toast?: 'info' | 'success' | 'warning' },
err?: NodeMpvError,
) => {
const { action, toast } = data;
if (err) {
const message = `[AUDIO PLAYER] ${action} - mpv errorcode ${err.errcode} - ${
NodeMpvErrorCode[err.errcode as keyof typeof NodeMpvErrorCode]
}`;
sendToastToRenderer({ message, type: 'error' });
createLog({ message, type: 'error' });
}
const message = `[AUDIO PLAYER] ${action}`;
createLog({ message, type: 'error' });
if (toast) {
sendToastToRenderer({ message, type: toast });
}
};
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
const isDevelopment = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
const prefetchPlaylistParams = [
'--prefetch-playlist=no',
'--prefetch-playlist=yes',
'--prefetch-playlist',
];
const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
const parameters = ['--idle=yes', '--no-config', '--load-scripts=no'];
if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {
parameters.push('--prefetch-playlist=yes');
}
return parameters;
};
const createMpv = async (data: {
binaryPath?: string;
extraParameters?: string[];
properties?: Record<string, any>;
}): Promise<MpvAPI> => {
const { extraParameters, properties, binaryPath } = data;
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
const extra = isDevelopment ? '-dev' : '';
const mpv = new MpvAPI(
{
audio_only: true,
auto_restart: false,
binary: binaryPath || MPV_BINARY_PATH || undefined,
socket: isWindows() ? `\\\\.\\pipe\\mpvserver${extra}` : `/tmp/node-mpv${extra}.sock`,
time_update: 1,
},
params,
);
try {
await mpv.start();
} catch (error: any) {
console.log('mpv failed to start', error);
} finally {
await mpv.setMultipleProperties(properties || {});
}
mpv.on('status', (status) => {
if (status.property === 'playlist-pos') {
if (status.value === -1) {
mpv?.stop();
}
if (status.value !== 0) {
getMainWindow()?.webContents.send('renderer-player-auto-next');
}
}
});
// Automatically updates the play button when the player is playing
mpv.on('resumed', () => {
getMainWindow()?.webContents.send('renderer-player-play');
});
// Automatically updates the play button when the player is stopped
mpv.on('stopped', () => {
getMainWindow()?.webContents.send('renderer-player-stop');
});
// Automatically updates the play button when the player is paused
mpv.on('paused', () => {
getMainWindow()?.webContents.send('renderer-player-pause');
});
// Event output every interval set by time_update, used to update the current time
mpv.on('timeposition', (time: number) => {
getMainWindow()?.webContents.send('renderer-player-current-time', time);
});
return mpv;
};
export const getMpvInstance = () => {
return mpvInstance;
};
const setAudioPlayerFallback = (isError: boolean) => {
getMainWindow()?.webContents.send('renderer-player-fallback', isError);
};
ipcMain.on('player-set-properties', async (_event, data: Record<string, any>) => {
mpvLog({ action: `Setting properties: ${JSON.stringify(data)}` });
if (data.length === 0) {
return;
}
try {
if (data.length === 1) {
getMpvInstance()?.setProperty(Object.keys(data)[0], Object.values(data)[0]);
} else {
getMpvInstance()?.setMultipleProperties(data);
}
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to set properties: ${JSON.stringify(data)}` }, err);
}
});
ipcMain.handle(
'player-restart',
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
try {
mpvLog({
action: `Attempting to initialize mpv with parameters: ${JSON.stringify(data)}`,
});
// Clean up previous mpv instance
getMpvInstance()?.stop();
getMpvInstance()?.quit();
mpvInstance = null;
mpvInstance = await createMpv(data);
mpvLog({ action: 'Restarted mpv', toast: 'success' });
setAudioPlayerFallback(false);
} catch (err: NodeMpvError | any) {
mpvLog({ action: 'Failed to restart mpv, falling back to web player' }, err);
setAudioPlayerFallback(true);
}
},
);
ipcMain.handle(
'player-initialize',
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
try {
mpvLog({
action: `Attempting to initialize mpv with parameters: ${JSON.stringify(data)}`,
});
mpvInstance = await createMpv(data);
setAudioPlayerFallback(false);
} catch (err: NodeMpvError | any) {
mpvLog({ action: 'Failed to initialize mpv, falling back to web player' }, err);
setAudioPlayerFallback(true);
}
},
);
ipcMain.on('player-quit', async () => {
try {
getMpvInstance()?.stop();
getMpvInstance()?.quit();
mpvInstance = null;
} catch (err: NodeMpvError | any) {
mpvLog({ action: 'Failed to quit mpv' }, err);
}
});
ipcMain.handle('player-is-running', async () => {
return getMpvInstance()?.isRunning();
});
@@ -23,99 +229,93 @@ ipcMain.handle('player-clean-up', async () => {
});
ipcMain.on('player-start', async () => {
await getMpvInstance()
?.play()
.catch((err) => {
console.log('MPV failed to play', err);
});
try {
await getMpvInstance()?.play();
} catch (err: NodeMpvError | any) {
mpvLog({ action: 'Failed to start mpv playback' }, err);
}
});
// Starts the player
ipcMain.on('player-play', async () => {
await getMpvInstance()
?.play()
.catch((err) => {
console.log('MPV failed to play', err);
});
try {
await getMpvInstance()?.play();
} catch (err: NodeMpvError | any) {
mpvLog({ action: 'Failed to start mpv playback' }, err);
}
});
// Pauses the player
ipcMain.on('player-pause', async () => {
await getMpvInstance()
?.pause()
.catch((err) => {
console.log('MPV failed to pause', err);
});
try {
await getMpvInstance()?.pause();
} catch (err: NodeMpvError | any) {
mpvLog({ action: 'Failed to pause mpv playback' }, err);
}
});
// Stops the player
ipcMain.on('player-stop', async () => {
await getMpvInstance()
?.stop()
.catch((err) => {
console.log('MPV failed to stop', err);
});
try {
await getMpvInstance()?.stop();
} catch (err: NodeMpvError | any) {
mpvLog({ action: 'Failed to stop mpv playback' }, err);
}
});
// Goes to the next track in the playlist
ipcMain.on('player-next', async () => {
await getMpvInstance()
?.next()
.catch((err) => {
console.log('MPV failed to go to next', err);
});
try {
await getMpvInstance()?.next();
} catch (err: NodeMpvError | any) {
mpvLog({ action: 'Failed to go to next track' }, err);
}
});
// Goes to the previous track in the playlist
ipcMain.on('player-previous', async () => {
await getMpvInstance()
?.prev()
.catch((err) => {
console.log('MPV failed to go to previous', err);
});
try {
await getMpvInstance()?.prev();
} catch (err: NodeMpvError | any) {
mpvLog({ action: 'Failed to go to previous track' }, err);
}
});
// Seeks forward or backward by the given amount of seconds
ipcMain.on('player-seek', async (_event, time: number) => {
await getMpvInstance()
?.seek(time)
.catch((err) => {
console.log('MPV failed to seek', err);
});
try {
await getMpvInstance()?.seek(time);
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to seek by ${time} seconds` }, err);
}
});
// Seeks to the given time in seconds
ipcMain.on('player-seek-to', async (_event, time: number) => {
await getMpvInstance()
?.goToPosition(time)
.catch((err) => {
console.log(`MPV failed to seek to ${time}`, err);
});
try {
await getMpvInstance()?.goToPosition(time);
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to seek to ${time} seconds` }, err);
}
});
// 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) {
await getMpvInstance()
?.clearPlaylist()
.catch((err) => {
console.log('MPV failed to clear playlist', err);
});
await getMpvInstance()
?.pause()
.catch((err) => {
console.log('MPV failed to pause', err);
});
return;
try {
await getMpvInstance()?.clearPlaylist();
await getMpvInstance()?.pause();
return;
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to clear play queue` }, err);
}
}
try {
if (data.queue.current) {
await getMpvInstance()
?.load(data.queue.current.streamUrl, 'replace')
.catch((err) => {
console.log('MPV failed to load song', err);
.catch(() => {
getMpvInstance()?.play();
});
@@ -123,41 +323,36 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
}
} catch (err) {
console.error(err);
}
if (pause) {
getMpvInstance()?.pause();
if (pause) {
await getMpvInstance()?.pause();
} else if (pause === false) {
// Only force play if pause is explicitly false
await getMpvInstance()?.play();
}
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to set play queue` }, err);
}
});
// Replaces the queue in position 1 to the given data
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
const size = await getMpvInstance()
?.getPlaylistSize()
.catch((err) => {
console.log('MPV failed to get playlist size', err);
});
try {
const size = await getMpvInstance()?.getPlaylistSize();
if (!size) {
return;
}
if (!size) {
return;
}
if (size > 1) {
await getMpvInstance()
?.playlistRemove(1)
.catch((err) => {
console.log('MPV failed to remove song from playlist', err);
});
}
if (size > 1) {
await getMpvInstance()?.playlistRemove(1);
}
if (data.queue.next) {
await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
.catch((err) => {
console.log('MPV failed to load next song', err);
});
if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to set play queue` }, err);
}
});
@@ -166,40 +361,57 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
// Always keep the current song as position 0 in the mpv queue
// This allows us to easily set update the next song in the queue without
// disturbing the currently playing song
await getMpvInstance()
?.playlistRemove(0)
.catch((err) => {
console.log('MPV failed to remove song from playlist', err);
getMpvInstance()?.pause();
});
if (data.queue.next) {
try {
await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
.catch((err) => {
console.log('MPV failed to load next song', err);
?.playlistRemove(0)
.catch(() => {
getMpvInstance()?.pause();
});
if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to load next song` }, err);
}
});
// Sets the volume to the given value (0-100)
ipcMain.on('player-volume', async (_event, value: number) => {
await getMpvInstance()
?.volume(value)
.catch((err) => {
console.log('MPV failed to set volume', err);
});
try {
if (!value || value < 0 || value > 100) {
return;
}
await getMpvInstance()?.volume(value);
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to set volume to ${value}` }, err);
}
});
// Toggles the mute status
ipcMain.on('player-mute', async (_event, mute: boolean) => {
await getMpvInstance()
?.mute(mute)
.catch((err) => {
console.log('MPV failed to toggle mute', err);
});
try {
await getMpvInstance()?.mute(mute);
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to set mute status` }, err);
}
});
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
return getMpvInstance()?.getTimePosition();
try {
return getMpvInstance()?.getTimePosition();
} catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to get current time` }, err);
return 0;
}
});
app.on('before-quit', () => {
getMpvInstance()?.stop();
getMpvInstance()?.quit();
});
app.on('window-all-closed', () => {
getMpvInstance()?.quit();
});
+20 -1
View File
@@ -1,7 +1,26 @@
/* eslint-disable promise/always-return */
import { BrowserWindow, globalShortcut } from 'electron';
import { BrowserWindow, globalShortcut, systemPreferences } from 'electron';
import { isMacOS } from '../../../utils';
import { store } from '../settings';
export const enableMediaKeys = (window: BrowserWindow | null) => {
if (isMacOS()) {
const shouldPrompt = store.get('should_prompt_accessibility', true) as boolean;
const trusted = systemPreferences.isTrustedAccessibilityClient(shouldPrompt);
if (shouldPrompt) {
store.set('should_prompt_accessibility', false);
}
if (!trusted) {
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',
});
}
}
globalShortcut.register('MediaStop', () => {
window?.webContents.send('renderer-player-stop');
});
+7 -1
View File
@@ -1,5 +1,6 @@
import { ipcMain, safeStorage } from 'electron';
import { ipcMain, nativeTheme, safeStorage } from 'electron';
import Store from 'electron-store';
import type { TitleTheme } from '/@/renderer/types';
export const store = new Store();
@@ -48,3 +49,8 @@ ipcMain.handle('password-set', (_event, password: string, server: string) => {
}
return false;
});
ipcMain.on('theme-set', (_event, theme: TitleTheme) => {
store.set('theme', theme);
nativeTheme.themeSource = theme;
});
+9
View File
@@ -70,6 +70,8 @@ mprisPlayer.on('volume', (vol: number) => {
getMainWindow()?.webContents.send('request-volume', {
volume,
});
mprisPlayer.volume = volume / 100;
});
mprisPlayer.on('shuffle', (event: boolean) => {
@@ -154,12 +156,19 @@ ipcMain.on('update-song', (_event, args: SongUpdate) => {
? song.albumArtists.map((artist) => artist.name)
: null,
'xesam:artist': song.artists?.length ? song.artists.map((artist) => artist.name) : null,
'xesam:audioBpm': song.bpm,
// Comment is a `list of strings` type
'xesam:comment': song.comment ? [song.comment] : null,
'xesam:contentCreated': song.releaseDate,
'xesam:discNumber': song.discNumber ? song.discNumber : null,
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
'xesam:lastUsed': song.lastPlayedAt,
'xesam:title': song.name || null,
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
'xesam:useCount':
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
// User ratings are only on Navidrome/Subsonic and are on a scale of 1-5
'xesam:userRating': song.userRating ? song.userRating / 5 : null,
};
} catch (err) {
console.log(err);
+70 -152
View File
@@ -20,27 +20,35 @@ import {
Tray,
Menu,
nativeImage,
nativeTheme,
BrowserWindowConstructorOptions,
protocol,
net,
} from 'electron';
import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log';
import log from 'electron-log/main';
import { autoUpdater } from 'electron-updater';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { store } from './features/core/settings/index';
import MenuBuilder from './menu';
import { hotkeyToElectronAccelerator, isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
import {
hotkeyToElectronAccelerator,
isLinux,
isMacOS,
isWindows,
resolveHtmlPath,
createLog,
autoUpdaterLogInterface,
} from './utils';
import './features';
import type { TitleTheme } from '/@/renderer/types';
declare module 'node-mpv';
export default class AppUpdater {
constructor() {
log.transports.file.level = 'info';
autoUpdater.logger = log;
autoUpdater.logger = autoUpdaterLogInterface;
autoUpdater.checkForUpdatesAndNotify();
}
}
@@ -55,6 +63,12 @@ if (store.get('ignore_ssl')) {
app.commandLine.appendSwitch('ignore-certificate-errors');
}
// From https://github.com/tutao/tutanota/commit/92c6ed27625fcf367f0fbcc755d83d7ff8fde94b
if (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {
const paswordStore = store.get('password_store', 'gnome-libsecret') as string;
app.commandLine.appendSwitch('password-store', paswordStore);
}
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let exitFromTray = false;
@@ -96,6 +110,19 @@ export const getMainWindow = () => {
return mainWindow;
};
export const sendToastToRenderer = ({
message,
type,
}: {
message: string;
type: 'success' | 'error' | 'warning' | 'info';
}) => {
getMainWindow()?.webContents.send('toast-from-main', {
message,
type,
});
};
const createWinThumbarButtons = () => {
if (isWindows()) {
getMainWindow()?.setThumbarButtons([
@@ -179,7 +206,7 @@ const createTray = () => {
tray.setContextMenu(contextMenu);
};
const createWindow = async () => {
const createWindow = async (first = true) => {
if (isDevelopment) {
await installExtensions();
}
@@ -194,8 +221,8 @@ const createWindow = async () => {
},
macOS: {
autoHideMenuBar: true,
frame: false,
titleBarStyle: 'hidden',
frame: true,
titleBarStyle: 'default',
trafficLightPosition: { x: 10, y: 10 },
},
windows: {
@@ -258,6 +285,10 @@ const createWindow = async () => {
app.exit();
});
ipcMain.handle('window-clear-cache', async () => {
return mainWindow?.webContents.session.clearCache();
});
ipcMain.on('app-restart', () => {
// Fix for .AppImage
if (process.env.APPIMAGE) {
@@ -304,34 +335,36 @@ const createWindow = async () => {
}
const queue = JSON.parse(data.toString());
getMainWindow()?.webContents.send('renderer-player-restore-queue', queue);
getMainWindow()?.webContents.send('renderer-restore-queue', queue);
});
});
});
}
});
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
if (globalMediaKeysEnabled !== false) {
if (globalMediaKeysEnabled) {
enableMediaKeys(mainWindow);
}
mainWindow.loadURL(resolveHtmlPath('index.html'));
const startWindowMinimized = store.get('window_start_minimized', false) as boolean;
mainWindow.on('ready-to-show', () => {
if (!mainWindow) {
throw new Error('"mainWindow" is not defined');
}
if (process.env.START_MINIMIZED) {
mainWindow.minimize();
} else {
if (!first || !startWindowMinimized) {
mainWindow.show();
createWinThumbarButtons();
}
});
mainWindow.on('closed', () => {
ipcMain.removeHandler('window-clear-cache');
mainWindow = null;
});
@@ -350,7 +383,7 @@ const createWindow = async () => {
event.preventDefault();
saved = true;
getMainWindow()?.webContents.send('renderer-player-save-queue');
getMainWindow()?.webContents.send('renderer-save-queue');
ipcMain.once('player-save-queue', async (_event, data: Record<string, any>) => {
const queueLocation = join(app.getPath('userData'), 'queue');
@@ -414,140 +447,13 @@ const createWindow = async () => {
// eslint-disable-next-line
new AppUpdater();
}
const theme = store.get('theme') as TitleTheme | undefined;
nativeTheme.themeSource = theme || 'dark';
};
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
const prefetchPlaylistParams = [
'--prefetch-playlist=no',
'--prefetch-playlist=yes',
'--prefetch-playlist',
];
const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
const parameters = ['--idle=yes', '--no-config', '--load-scripts=no'];
if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {
parameters.push('--prefetch-playlist=yes');
}
return parameters;
};
let mpvInstance: MpvAPI | null = null;
const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
const { extraParameters, properties } = data;
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
console.log('Setting mpv params: ', params);
const extra = isDevelopment ? '-dev' : '';
const mpv = new MpvAPI(
{
audio_only: true,
auto_restart: false,
binary: MPV_BINARY_PATH || '',
socket: isWindows() ? `\\\\.\\pipe\\mpvserver${extra}` : `/tmp/node-mpv${extra}.sock`,
time_update: 1,
},
params,
);
// eslint-disable-next-line promise/catch-or-return
mpv.start()
.catch((error) => {
console.log('MPV failed to start', error);
})
.finally(() => {
console.log('Setting MPV properties: ', properties);
mpv.setMultipleProperties(properties || {});
});
mpv.on('status', (status, ...rest) => {
console.log('MPV Event: status', status.property, status.value, rest);
if (status.property === 'playlist-pos') {
if (status.value === -1) {
mpv?.stop();
}
if (status.value !== 0) {
getMainWindow()?.webContents.send('renderer-player-auto-next');
}
}
});
// Automatically updates the play button when the player is playing
mpv.on('resumed', () => {
console.log('MPV Event: resumed');
getMainWindow()?.webContents.send('renderer-player-play');
});
// Automatically updates the play button when the player is stopped
mpv.on('stopped', () => {
console.log('MPV Event: stopped');
getMainWindow()?.webContents.send('renderer-player-stop');
});
// Automatically updates the play button when the player is paused
mpv.on('paused', () => {
console.log('MPV Event: paused');
getMainWindow()?.webContents.send('renderer-player-pause');
});
// Event output every interval set by time_update, used to update the current time
mpv.on('timeposition', (time: number) => {
getMainWindow()?.webContents.send('renderer-player-current-time', time);
});
mpv.on('quit', () => {
console.log('MPV Event: quit');
});
return mpv;
};
export const getMpvInstance = () => {
return mpvInstance;
};
ipcMain.on('player-set-properties', async (_event, data: Record<string, any>) => {
if (data.length === 0) {
return;
}
if (data.length === 1) {
getMpvInstance()?.setProperty(Object.keys(data)[0], Object.values(data)[0]);
} else {
getMpvInstance()?.setMultipleProperties(data);
}
});
ipcMain.on(
'player-restart',
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
mpvInstance?.quit();
mpvInstance = createMpv(data);
},
);
ipcMain.on(
'player-initialize',
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
console.log('Initializing MPV with data: ', data);
mpvInstance = createMpv(data);
},
);
ipcMain.on('player-quit', async () => {
mpvInstance?.stop();
mpvInstance?.quit();
mpvInstance = null;
});
// Must duplicate with the one in renderer process settings.store.ts
enum BindingActions {
GLOBAL_SEARCH = 'globalSearch',
@@ -622,7 +528,7 @@ ipcMain.on(
}
}
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
if (globalMediaKeysEnabled) {
enableMediaKeys(mainWindow);
@@ -630,17 +536,25 @@ ipcMain.on(
},
);
app.on('before-quit', () => {
getMpvInstance()?.stop();
getMpvInstance()?.quit();
});
ipcMain.on(
'logger',
(
_event,
data: {
message: string;
type: 'debug' | 'verbose' | 'success' | 'error' | 'warning' | 'info';
},
) => {
createLog(data);
},
);
app.on('window-all-closed', () => {
globalShortcut.unregisterAll();
getMpvInstance()?.quit();
// Respect the OSX convention of having the application in memory even
// after all windows have been closed
if (isMacOS()) {
ipcMain.removeHandler('window-clear-cache');
mainWindow = null;
} else {
app.quit();
@@ -695,7 +609,11 @@ if (!singleInstance) {
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (mainWindow === null) createWindow();
if (mainWindow === null) createWindow(false);
else if (!mainWindow.isVisible()) {
mainWindow.show();
createWinThumbarButtons();
}
});
})
.catch(console.log);
+7
View File
@@ -24,7 +24,12 @@ const devtools = () => {
ipcRenderer.send('window-dev-tools');
};
const clearCache = (): Promise<void> => {
return ipcRenderer.invoke('window-clear-cache');
};
export const browser = {
clearCache,
devtools,
exit,
maximize,
@@ -32,3 +37,5 @@ export const browser = {
quit,
unmaximize,
};
export type Browser = typeof browser;
+26 -1
View File
@@ -1,9 +1,18 @@
import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
import Store from 'electron-store';
import { toServerType, type TitleTheme } from '/@/renderer/types';
const store = new Store();
const set = (property: string, value: string | Record<string, unknown> | boolean | string[]) => {
const set = (
property: string,
value: string | Record<string, unknown> | boolean | string[] | undefined,
) => {
if (value === undefined) {
store.delete(property);
return;
}
store.set(`${property}`, value);
};
@@ -43,9 +52,24 @@ const fontError = (cb: (event: IpcRendererEvent, file: string) => void) => {
ipcRenderer.on('custom-font-error', cb);
};
const themeSet = (theme: TitleTheme): void => {
ipcRenderer.send('theme-set', theme);
};
const SERVER_TYPE = toServerType(process.env.SERVER_TYPE);
const env = {
SERVER_LOCK:
SERVER_TYPE !== null ? process.env.SERVER_LOCK?.toLocaleLowerCase() === 'true' : false,
SERVER_NAME: process.env.SERVER_NAME ?? '',
SERVER_TYPE,
SERVER_URL: process.env.SERVER_URL ?? 'http://',
};
export const localSettings = {
disableMediaKeys,
enableMediaKeys,
env,
fontError,
get,
passwordGet,
@@ -54,6 +78,7 @@ export const localSettings = {
restart,
set,
setZoomFactor,
themeSet,
};
export type LocalSettings = typeof localSettings;
+13 -27
View File
@@ -1,12 +1,16 @@
import { ipcRenderer, IpcRendererEvent } from 'electron';
import { PlayerData, PlayerState } from '/@/renderer/store';
import { PlayerData } from '/@/renderer/store';
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-initialize', data);
return ipcRenderer.invoke('player-initialize', data);
};
const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-restart', data);
const restart = (data: {
binaryPath?: string;
extraParameters?: string[];
properties?: Record<string, any>;
}) => {
return ipcRenderer.invoke('player-restart', data);
};
const isRunning = () => {
@@ -18,7 +22,6 @@ const cleanup = () => {
};
const setProperties = (data: Record<string, any>) => {
console.log('Setting property :>>', data);
ipcRenderer.send('player-set-properties', data);
};
@@ -50,14 +53,6 @@ const previous = () => {
ipcRenderer.send('player-previous');
};
const restoreQueue = () => {
ipcRenderer.send('player-restore-queue');
};
const saveQueue = (data: Record<string, any>) => {
ipcRenderer.send('player-save-queue', data);
};
const seek = (seconds: number) => {
ipcRenderer.send('player-seek', seconds);
};
@@ -154,20 +149,14 @@ const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-quit', cb);
};
const rendererSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-save-queue', cb);
};
const rendererRestoreQueue = (
cb: (event: IpcRendererEvent, data: Partial<PlayerState>) => void,
) => {
ipcRenderer.on('renderer-player-restore-queue', cb);
};
const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
ipcRenderer.on('renderer-player-error', cb);
};
const rendererPlayerFallback = (cb: (event: IpcRendererEvent, data: boolean) => void) => {
ipcRenderer.on('renderer-player-fallback', cb);
};
export const mpvPlayer = {
autoNext,
cleanup,
@@ -182,8 +171,6 @@ export const mpvPlayer = {
previous,
quit,
restart,
restoreQueue,
saveQueue,
seek,
seekTo,
setProperties,
@@ -201,10 +188,9 @@ export const mpvPlayerListener = {
rendererPause,
rendererPlay,
rendererPlayPause,
rendererPlayerFallback,
rendererPrevious,
rendererQuit,
rendererRestoreQueue,
rendererSaveQueue,
rendererSkipBackward,
rendererSkipForward,
rendererStop,
+50
View File
@@ -1,9 +1,59 @@
import { IpcRendererEvent, ipcRenderer } from 'electron';
import { isMacOS, isWindows, isLinux } from '../utils';
import { PlayerState } from '/@/renderer/store';
const saveQueue = (data: Record<string, any>) => {
ipcRenderer.send('player-save-queue', data);
};
const restoreQueue = () => {
ipcRenderer.send('player-restore-queue');
};
const onSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-save-queue', cb);
};
const onRestoreQueue = (cb: (event: IpcRendererEvent, data: Partial<PlayerState>) => void) => {
ipcRenderer.on('renderer-restore-queue', cb);
};
const playerErrorListener = (cb: (event: IpcRendererEvent, data: { code: number }) => void) => {
ipcRenderer.on('player-error-listener', cb);
};
const mainMessageListener = (
cb: (
event: IpcRendererEvent,
data: { message: string; type: 'success' | 'error' | 'warning' | 'info' },
) => void,
) => {
ipcRenderer.on('toast-from-main', cb);
};
const logger = (
cb: (
event: IpcRendererEvent,
data: {
message: string;
type: 'debug' | 'verbose' | 'error' | 'warning' | 'info';
},
) => void,
) => {
ipcRenderer.send('logger', cb);
};
export const utils = {
isLinux,
isMacOS,
isWindows,
logger,
mainMessageListener,
onRestoreQueue,
onSaveQueue,
playerErrorListener,
restoreQueue,
saveQueue,
};
export type Utils = typeof utils;
+44
View File
@@ -2,6 +2,7 @@
import path from 'path';
import process from 'process';
import { URL } from 'url';
import log from 'electron-log/main';
export let resolveHtmlPath: (htmlFileName: string) => string;
@@ -50,3 +51,46 @@ export const hotkeyToElectronAccelerator = (hotkey: string) => {
return accelerator;
};
const logMethod = {
debug: log.debug,
error: log.error,
info: log.info,
success: log.info,
verbose: log.verbose,
warning: log.warn,
};
const logColor = {
debug: 'blue',
error: 'red',
info: 'blue',
success: 'green',
verbose: 'blue',
warning: 'yellow',
};
export const createLog = (data: {
message: string;
type: 'debug' | 'verbose' | 'success' | 'error' | 'warning' | 'info';
}) => {
logMethod[data.type](`%c${data.message}`, `color: ${logColor[data.type]}`);
};
export const autoUpdaterLogInterface = {
debug: (message: string) => {
createLog({ message: `[SYSTEM] ${message}`, type: 'debug' });
},
error: (message: string) => {
createLog({ message: `[SYSTEM] ${message}`, type: 'error' });
},
info: (message: string) => {
createLog({ message: `[SYSTEM] ${message}`, type: 'info' });
},
warn: (message: string) => {
createLog({ message: `[SYSTEM] ${message}`, type: 'warning' });
},
};
+49 -1
View File
@@ -48,8 +48,14 @@ import type {
SearchResponse,
LyricsArgs,
LyricsResponse,
ServerInfo,
ServerInfoArgs,
StructuredLyricsArgs,
StructuredLyric,
SimilarSongsArgs,
Song,
ServerType,
} from '/@/renderer/api/types';
import { ServerType } from '/@/renderer/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
@@ -85,8 +91,11 @@ export type ControllerEndpoint = Partial<{
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getStructuredLyrics: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
@@ -129,8 +138,11 @@ const endpoints: ApiController = {
getPlaylistList: jfController.getPlaylistList,
getPlaylistSongList: jfController.getPlaylistSongList,
getRandomSongList: jfController.getRandomSongList,
getServerInfo: jfController.getServerInfo,
getSimilarSongs: jfController.getSimilarSongs,
getSongDetail: jfController.getSongDetail,
getSongList: jfController.getSongList,
getStructuredLyrics: undefined,
getTopSongs: jfController.getTopSongList,
getUserList: undefined,
removeFromPlaylist: jfController.removeFromPlaylist,
@@ -165,8 +177,11 @@ const endpoints: ApiController = {
getPlaylistList: ndController.getPlaylistList,
getPlaylistSongList: ndController.getPlaylistSongList,
getRandomSongList: ssController.getRandomSongList,
getServerInfo: ndController.getServerInfo,
getSimilarSongs: ssController.getSimilarSongs,
getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList,
getStructuredLyrics: ssController.getStructuredLyrics,
getTopSongs: ssController.getTopSongList,
getUserList: ndController.getUserList,
removeFromPlaylist: ndController.removeFromPlaylist,
@@ -198,8 +213,11 @@ const endpoints: ApiController = {
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: undefined,
getPlaylistList: undefined,
getServerInfo: ssController.getServerInfo,
getSimilarSongs: ssController.getSimilarSongs,
getSongDetail: undefined,
getSongList: undefined,
getStructuredLyrics: ssController.getStructuredLyrics,
getTopSongs: ssController.getTopSongList,
getUserList: undefined,
scrobble: ssController.scrobble,
@@ -481,6 +499,33 @@ const getLyrics = async (args: LyricsArgs) => {
)?.(args);
};
const getServerInfo = async (args: ServerInfoArgs) => {
return (
apiController(
'getServerInfo',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getServerInfo']
)?.(args);
};
const getStructuredLyrics = async (args: StructuredLyricsArgs) => {
return (
apiController(
'getStructuredLyrics',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getStructuredLyrics']
)?.(args);
};
const getSimilarSongs = async (args: SimilarSongsArgs) => {
return (
apiController(
'getSimilarSongs',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSimilarSongs']
)?.(args);
};
export const controller = {
addToPlaylist,
authenticate,
@@ -500,8 +545,11 @@ export const controller = {
getPlaylistList,
getPlaylistSongList,
getRandomSongList,
getServerInfo,
getSimilarSongs,
getSongDetail,
getSongList,
getStructuredLyrics,
getTopSongList,
getUserList,
removeFromPlaylist,
+9
View File
@@ -0,0 +1,9 @@
// Should follow a strict naming convention: "<FEATURE GROUP>_<FEATURE NAME>"
// For example: <FEATURE GROUP>: "Playlists", <FEATURE NAME>: "Smart" = "PLAYLISTS_SMART"
export enum ServerFeature {
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
PLAYLISTS_SMART = 'playlistsSmart',
}
export type ServerFeatures = Partial<Record<ServerFeature, boolean>>;
+18 -1
View File
@@ -3,7 +3,7 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import { initClient, initContract } from '@ts-rest/core';
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
import qs from 'qs';
import { ServerListItem } from '/@/renderer/types';
import { ServerListItem } from '/@/renderer/api/types';
import omitBy from 'lodash/omitBy';
import { z } from 'zod';
import { authenticationFailure } from '/@/renderer/api/utils';
@@ -150,6 +150,14 @@ export const contract = c.router({
400: jfType._response.error,
},
},
getServerInfo: {
method: 'GET',
path: 'system/info',
responses: {
200: jfType._response.serverInfo,
400: jfType._response.error,
},
},
getSimilarArtistList: {
method: 'GET',
path: 'artists/:id/similar',
@@ -159,6 +167,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
getSimilarSongs: {
method: 'GET',
path: 'items/:itemId/similar',
query: jfType._parameters.similarSongs,
responses: {
200: jfType._response.similarSongs,
400: jfType._response.error,
},
},
getSongDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
@@ -49,6 +49,10 @@ import {
genreListSortMap,
SongDetailArgs,
SongDetailResponse,
ServerInfo,
ServerInfoArgs,
SimilarSongsArgs,
Song,
} from '/@/renderer/api/types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from './jellyfin-normalize';
@@ -57,6 +61,7 @@ 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';
const formatCommaDelimitedString = (value: string[]) => {
return value.join(',');
@@ -102,9 +107,9 @@ const authenticate = async (
Username: body.username,
},
headers: {
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin-${getHostname()}-${
body.username
}", Version="${packageJson.version}"`,
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin-${getHostname()}-${encodeURIComponent(
body.username,
)}", Version="${packageJson.version}"`,
},
});
@@ -374,7 +379,7 @@ const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse>
IncludeItemTypes: 'Audio',
Limit: query.limit,
Recursive: true,
SortBy: 'CommunityRating,SortName',
SortBy: 'PlayCount,SortName',
SortOrder: 'Descending',
UserId: apiClientProps.server?.userId,
},
@@ -538,7 +543,7 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<SongList
Limit: query.limit,
SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
StartIndex: 0,
StartIndex: query.startIndex,
UserId: apiClientProps.server?.userId,
},
});
@@ -946,6 +951,53 @@ const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse>
return jfNormalize.song(res.body, apiClientProps.server, '');
};
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
const { apiClientProps } = args;
const res = await jfApiClient(apiClientProps).getServerInfo();
if (res.status !== 200) {
throw new Error('Failed to get server info');
}
const features: ServerFeatures = {
lyricsSingleStructured: true,
};
return {
features,
id: apiClientProps.server?.id,
version: res.body.Version,
};
};
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
const { apiClientProps, query } = args;
const res = await jfApiClient(apiClientProps).getSimilarSongs({
params: {
itemId: query.songId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId',
Limit: query.count,
UserId: apiClientProps.server?.userId || undefined,
},
});
if (res.status !== 200) {
throw new Error('Failed to get similar songs');
}
return res.body.Items.reduce<Song[]>((acc, song) => {
if (song.Id !== query.songId) {
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
}
return acc;
}, []);
};
export const jfController = {
addToPlaylist,
authenticate,
@@ -965,6 +1017,8 @@ export const jfController = {
getPlaylistList,
getPlaylistSongList,
getRandomSongList,
getServerInfo,
getSimilarSongs,
getSongDetail,
getSongList,
getTopSongList,
@@ -10,8 +10,9 @@ import {
Playlist,
MusicFolder,
Genre,
ServerListItem,
ServerType,
} from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
const getStreamUrl = (args: {
container?: string;
@@ -202,6 +203,7 @@ const normalizeAlbum = (
imageSize?: number,
): Album => {
return {
albumArtist: item.AlbumArtist,
albumArtists:
item.AlbumArtists.map((entry) => ({
id: entry.Id,
@@ -214,6 +216,7 @@ const normalizeAlbum = (
name: entry.Name,
})),
backdropImageUrl: null,
comment: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({
@@ -232,6 +235,7 @@ const normalizeAlbum = (
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
releaseDate: item.PremiereDate?.split('T')[0] || null,
@@ -287,6 +291,7 @@ const normalizeAlbumArtist = (
}),
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
mbz: item.ProviderIds?.MusicBrainzArtist || null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
serverId: server?.id || '',
@@ -422,6 +422,11 @@ const song = z.object({
UserData: userData.optional(),
});
const providerIds = z.object({
MusicBrainzAlbum: z.string().optional(),
MusicBrainzArtist: z.string().optional(),
});
const albumArtist = z.object({
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
@@ -435,6 +440,7 @@ const albumArtist = z.object({
LocationType: z.string(),
Name: z.string(),
Overview: z.string(),
ProviderIds: providerIds.optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
Type: z.string(),
@@ -466,6 +472,7 @@ const album = z.object({
ParentLogoItemId: z.string(),
PremiereDate: z.string().optional(),
ProductionYear: z.number(),
ProviderIds: providerIds.optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail
@@ -654,6 +661,24 @@ const lyrics = z.object({
Lyrics: z.array(lyricText),
});
const serverInfo = z.object({
Version: z.string(),
});
const similarSongsParameters = z.object({
Fields: z.string().optional(),
Limit: z.number().optional(),
UserId: z.string().optional(),
});
const similarSongs = pagination.extend({
Items: z.array(song),
});
export enum JellyfinExtensions {
SONG_LYRICS = 'songLyrics',
}
export const jfType = {
_enum: {
albumArtistList: albumArtistListSort,
@@ -683,6 +708,7 @@ export const jfType = {
scrobble: scrobbleParameters,
search: searchParameters,
similarArtistList: similarArtistListParameters,
similarSongs: similarSongsParameters,
songList: songListParameters,
updatePlaylist: updatePlaylistParameters,
},
@@ -707,6 +733,8 @@ export const jfType = {
removeFromPlaylist,
scrobble,
search,
serverInfo,
similarSongs,
song,
songList,
topSongsList,
+5 -4
View File
@@ -7,7 +7,7 @@ import qs from 'qs';
import { ndType } from './navidrome-types';
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
import { ServerListItem } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components';
import i18n from '/@/i18n/i18n';
@@ -286,9 +286,10 @@ axiosClient.interceptors.response.use(
});
const serverId = currentServer.id;
useAuthStore
.getState()
.actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.updateServer(serverId, {
credential: undefined,
ndCredential: undefined,
});
useAuthStore.getState().actions.setCurrentServer(null);
// special error to prevent sending a second message, and stop other messages that could be enqueued
@@ -1,3 +1,9 @@
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 { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import semverCoerce from 'semver/functions/coerce';
import semverGte from 'semver/functions/gte';
import {
AlbumArtistDetailArgs,
AlbumArtistDetailResponse,
@@ -39,11 +45,12 @@ import {
RemoveFromPlaylistResponse,
RemoveFromPlaylistArgs,
genreListSortMap,
ServerInfo,
ServerInfoArgs,
} from '../types';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { hasFeature } from '/@/renderer/api/utils';
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types';
const authenticate = async (
url: string,
@@ -355,6 +362,16 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise<DeletePlaylistR
const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResponse> => {
const { query, apiClientProps } = args;
const customQuery = query._custom?.navidrome;
// Smart playlists only became available in 0.48.0. Do not filter for previous versions
if (
customQuery &&
customQuery.smart !== undefined &&
!hasFeature(apiClientProps.server, ServerFeature.PLAYLISTS_SMART)
) {
customQuery.smart = undefined;
}
const res = await ndApiClient(apiClientProps).getPlaylistList({
query: {
@@ -363,7 +380,7 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
_start: query.startIndex,
q: query.searchTerm,
...query._custom?.navidrome,
...customQuery,
},
});
@@ -465,6 +482,71 @@ const removeFromPlaylist = async (
return null;
};
const VERSION_INFO: Array<[string, Record<string, number[]>]> = [
['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;
// Navidrome will always populate serverVersion
const ping = await ssApiClient(apiClientProps).ping();
if (ping.status !== 200) {
throw new Error('Failed to ping server');
}
const navidromeFeatures: Record<string, number[]> = getFeatures(ping.body.serverVersion!);
if (ping.body.openSubsonic) {
const res = await ssApiClient(apiClientProps).getServerInfo();
if (res.status !== 200) {
throw new Error('Failed to get server extensions');
}
// The type here isn't necessarily an array (even though it's supposed to be). This is
// an implementation detail of Navidrome 0.50. Do a type check to make sure it's actually
// an array, and not an empty object.
if (Array.isArray(res.body.openSubsonicExtensions)) {
for (const extension of res.body.openSubsonicExtensions) {
navidromeFeatures[extension.name] = extension.versions;
}
}
}
const features: ServerFeatures = {
lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
playlistsSmart: !!navidromeFeatures[NavidromeExtensions.SMART_PLAYLISTS],
};
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
};
export const ndController = {
addToPlaylist,
authenticate,
@@ -478,6 +560,7 @@ export const ndController = {
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getServerInfo,
getSongDetail,
getSongList,
getUserList,
@@ -7,8 +7,9 @@ import {
User,
AlbumArtist,
Genre,
ServerListItem,
ServerType,
} from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
import z from 'zod';
import { ndType } from './navidrome-types';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
@@ -45,6 +46,14 @@ const getCoverArtUrl = (args: {
);
};
interface WithDate {
playDate?: string;
}
const normalizePlayDate = (item: WithDate): string | null => {
return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate;
};
const normalizeSong = (
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
server: ServerListItem | null,
@@ -100,7 +109,7 @@ const normalizeSong = (
imagePlaceholderUrl,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
lastPlayedAt: normalizePlayDate(item),
lyrics: item.lyrics ? item.lyrics : null,
name: item.title,
path: item.path,
@@ -143,9 +152,11 @@ const normalizeAlbum = (
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
return {
albumArtist: item.albumArtist,
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
backdropImageUrl: imageBackdropUrl,
comment: item.comment || null,
createdAt: item.createdAt.split('T')[0],
duration: item.duration * 1000 || null,
genres: item.genres?.map((genre) => ({
@@ -159,7 +170,8 @@ const normalizeAlbum = (
imageUrl,
isCompilation: item.compilation,
itemType: LibraryItem.ALBUM,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
lastPlayedAt: normalizePlayDate(item),
mbzId: item.mbzAlbumId || null,
name: item.name,
playCount: item.playCount,
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
@@ -189,7 +201,7 @@ const normalizeAlbumArtist = (
baseUrl: server?.url,
coverArtId: `ar-${item.id}`,
credential: server?.credential,
size: 100,
size: 300,
});
}
@@ -207,7 +219,8 @@ const normalizeAlbumArtist = (
id: item.id,
imageUrl: imageUrl || null,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
lastPlayedAt: normalizePlayDate(item),
mbz: item.mbzArtistId || null,
name: item.name,
playCount: item.playCount,
serverId: server?.id || 'unknown',
@@ -78,7 +78,7 @@ const albumArtist = z.object({
name: z.string(),
orderArtistName: z.string(),
playCount: z.number(),
playDate: z.string(),
playDate: z.string().optional(),
rating: z.number(),
size: z.number(),
smallImageUrl: z.string().optional(),
@@ -111,6 +111,7 @@ const album = z.object({
allArtistIds: z.string(),
artist: z.string(),
artistId: z.string(),
comment: z.string().optional(),
compilation: z.boolean(),
coverArtId: z.string().optional(), // Removed after v0.48.0
coverArtPath: z.string().optional(), // Removed after v0.48.0
@@ -128,7 +129,7 @@ const album = z.object({
orderAlbumArtistName: z.string(),
orderAlbumName: z.string(),
playCount: z.number(),
playDate: z.string(),
playDate: z.string().optional(),
rating: z.number().optional(),
size: z.number(),
songCount: z.number(),
@@ -211,7 +212,7 @@ const song = z.object({
orderTitle: z.string(),
path: z.string(),
playCount: z.number(),
playDate: z.string(),
playDate: z.string().optional(),
rating: z.number().optional(),
rgAlbumGain: z.number().optional(),
rgAlbumPeak: z.number().optional(),
@@ -342,6 +343,10 @@ const removeFromPlaylistParameters = z.object({
id: z.array(z.string()),
});
export enum NavidromeExtensions {
SMART_PLAYLISTS = 'smartPlaylists',
}
export const ndType = {
_enum: {
albumArtistList: ndAlbumArtistListSort,
+5
View File
@@ -18,6 +18,7 @@ import type {
LyricsQuery,
LyricSearchQuery,
GenreListQuery,
SimilarSongsQuery,
} from './types';
export const splitPaginatedQuery = (key: any) => {
@@ -239,6 +240,10 @@ export const queryKeys: Record<
return [serverId, 'songs', 'randomSongList'] as const;
},
root: (serverId: string) => [serverId, 'songs'] as const,
similar: (serverId: string, query?: SimilarSongsQuery) => {
if (query) return [serverId, 'song', 'similar', query] as const;
return [serverId, 'song', 'similar'] as const;
},
},
users: {
list: (serverId: string, query?: UserListQuery) => {
+30
View File
@@ -50,6 +50,29 @@ export const contract = c.router({
200: ssType._response.randomSongList,
},
},
getServerInfo: {
method: 'GET',
path: 'getOpenSubsonicExtensions.view',
responses: {
200: ssType._response.serverInfo,
},
},
getSimilarSongs: {
method: 'GET',
path: 'getSimilarSongs',
query: ssType._parameters.similarSongs,
responses: {
200: ssType._response.similarSongs,
},
},
getStructuredLyrics: {
method: 'GET',
path: 'getLyricsBySongId.view',
query: ssType._parameters.structuredLyrics,
responses: {
200: ssType._response.structuredLyrics,
},
},
getTopSongsList: {
method: 'GET',
path: 'getTopSongs.view',
@@ -58,6 +81,13 @@ export const contract = c.router({
200: ssType._response.topSongsList,
},
},
ping: {
method: 'GET',
path: 'ping.view',
responses: {
200: ssType._response.ping,
},
},
removeFavorite: {
method: 'GET',
path: 'unstar.view',
@@ -2,7 +2,7 @@ import md5 from 'md5';
import { z } from 'zod';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { SubsonicExtensions, ssType } from '/@/renderer/api/subsonic/subsonic-types';
import {
ArtistInfoArgs,
AuthenticationResponse,
@@ -21,8 +21,15 @@ import {
SearchResponse,
RandomSongListResponse,
RandomSongListArgs,
ServerInfo,
ServerInfoArgs,
StructuredLyricsArgs,
StructuredLyric,
SimilarSongsArgs,
Song,
} from '/@/renderer/api/types';
import { randomString } from '/@/renderer/utils';
import { ServerFeatures } from '/@/renderer/api/features-types';
const authenticate = async (
url: string,
@@ -368,12 +375,122 @@ const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongLi
};
};
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
const { apiClientProps } = args;
const ping = await ssApiClient(apiClientProps).ping();
if (ping.status !== 200) {
throw new Error('Failed to ping server');
}
const features: ServerFeatures = {};
if (!ping.body.openSubsonic || !ping.body.serverVersion) {
return { features, version: ping.body.version };
}
const res = await ssApiClient(apiClientProps).getServerInfo();
if (res.status !== 200) {
throw new Error('Failed to get server extensions');
}
const subsonicFeatures: Record<string, number[]> = {};
if (Array.isArray(res.body.openSubsonicExtensions)) {
for (const extension of res.body.openSubsonicExtensions) {
subsonicFeatures[extension.name] = extension.versions;
}
}
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
features.lyricsMultipleStructured = true;
}
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
};
export const getStructuredLyrics = async (
args: StructuredLyricsArgs,
): Promise<StructuredLyric[]> => {
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getStructuredLyrics({
query: {
id: query.songId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get structured lyrics');
}
const lyrics = res.body.lyricsList?.structuredLyrics;
if (!lyrics) {
return [];
}
return lyrics.map((lyric) => {
const baseLyric = {
artist: lyric.displayArtist || '',
lang: lyric.lang,
name: lyric.displayTitle || '',
remote: false,
source: apiClientProps.server?.name || 'music server',
};
if (lyric.synced) {
return {
...baseLyric,
lyrics: lyric.line.map((line) => [line.start!, line.value]),
synced: true,
};
}
return {
...baseLyric,
lyrics: lyric.line.map((line) => [line.value]).join('\n'),
synced: false,
};
});
};
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).getSimilarSongs({
query: {
count: query.count,
id: query.songId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get similar songs');
}
if (!res.body.similarSongs) {
return [];
}
return res.body.similarSongs.song.reduce<Song[]>((acc, song) => {
if (song.id !== query.songId) {
acc.push(ssNormalize.song(song, apiClientProps.server, ''));
}
return acc;
}, []);
};
export const ssController = {
authenticate,
createFavorite,
getArtistInfo,
getMusicFolderList,
getRandomSongList,
getServerInfo,
getSimilarSongs,
getStructuredLyrics,
getTopSongList,
removeFavorite,
scrobble,
@@ -1,8 +1,14 @@
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
import {
QueueSong,
LibraryItem,
AlbumArtist,
Album,
ServerListItem,
ServerType,
} from '/@/renderer/api/types';
const getCoverArtUrl = (args: {
baseUrl: string | undefined;
@@ -126,6 +132,7 @@ const normalizeAlbumArtist = (
imageUrl,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
mbz: null,
name: item.name,
playCount: null,
serverId: server?.id || 'unknown',
@@ -150,11 +157,13 @@ const normalizeAlbum = (
}) || null;
return {
albumArtist: item.artist,
albumArtists: item.artistId
? [{ id: item.artistId, imageUrl: null, name: item.artist }]
: [],
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
backdropImageUrl: null,
comment: null,
createdAt: item.created,
duration: item.duration,
genres: item.genre
@@ -173,6 +182,7 @@ const normalizeAlbum = (
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
mbzId: null,
name: item.name,
playCount: null,
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
@@ -206,6 +206,66 @@ const randomSongList = z.object({
}),
});
const ping = z.object({
openSubsonic: z.boolean().optional(),
serverVersion: z.string().optional(),
version: z.string(),
});
const extension = z.object({
name: z.string(),
versions: z.number().array(),
});
const serverInfo = z.object({
openSubsonicExtensions: z.array(extension).optional(),
});
const structuredLyricsParameters = z.object({
id: z.string(),
});
const lyricLine = z.object({
start: z.number().optional(),
value: z.string(),
});
const structuredLyric = z.object({
displayArtist: z.string().optional(),
displayTitle: z.string().optional(),
lang: z.string(),
line: z.array(lyricLine),
offset: z.number().optional(),
synced: z.boolean(),
});
const structuredLyrics = z.object({
lyricsList: z
.object({
structuredLyrics: z.array(structuredLyric).optional(),
})
.optional(),
});
const similarSongsParameters = z.object({
count: z.number().optional(),
id: z.string(),
});
const similarSongs = z.object({
similarSongs: z
.object({
song: z.array(song),
})
.optional(),
});
export enum SubsonicExtensions {
FORM_POST = 'formPost',
SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset',
}
export const ssType = {
_parameters: {
albumList: albumListParameters,
@@ -217,6 +277,8 @@ export const ssType = {
scrobble: scrobbleParameters,
search3: search3Parameters,
setRating: setRatingParameters,
similarSongs: similarSongsParameters,
structuredLyrics: structuredLyricsParameters,
topSongsList: topSongsListParameters,
},
_response: {
@@ -229,12 +291,16 @@ export const ssType = {
baseResponse,
createFavorite,
musicFolderList,
ping,
randomSongList,
removeFavorite,
scrobble,
search3,
serverInfo,
setRating,
similarSongs,
song,
structuredLyrics,
topSongsList,
},
};
+49 -11
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { ServerFeatures } from './features-types';
import { jfType } from './jellyfin/jellyfin-types';
import {
JFSortOrder,
@@ -57,13 +58,16 @@ export type User = {
export type ServerListItem = {
credential: string;
features?: ServerFeatures;
id: string;
name: string;
ndCredential?: string;
savePassword?: boolean;
type: ServerType;
url: string;
userId: string | null;
username: string;
version?: string;
};
export enum ServerType {
@@ -144,9 +148,11 @@ export type Genre = {
};
export type Album = {
albumArtist: string;
albumArtists: RelatedArtist[];
artists: RelatedArtist[];
backdropImageUrl: string | null;
comment: string | null;
createdAt: string;
duration: number | null;
genres: Genre[];
@@ -156,6 +162,7 @@ export type Album = {
isCompilation: boolean | null;
itemType: LibraryItem.ALBUM;
lastPlayedAt: string | null;
mbzId: string | null;
name: string;
playCount: number | null;
releaseDate: string | null;
@@ -228,6 +235,7 @@ export type AlbumArtist = {
imageUrl: string | null;
itemType: LibraryItem.ALBUM_ARTIST;
lastPlayedAt: string | null;
mbz: string | null;
name: string;
playCount: number | null;
serverId: string;
@@ -417,7 +425,8 @@ export const albumListSortMap: AlbumListSortMap = {
rating: NDAlbumListSort.RATING,
recentlyAdded: NDAlbumListSort.RECENTLY_ADDED,
recentlyPlayed: NDAlbumListSort.PLAY_DATE,
releaseDate: undefined,
// Recent versions of Navidrome support release date, but fallback to year for now
releaseDate: NDAlbumListSort.YEAR,
songCount: NDAlbumListSort.SONG_COUNT,
year: NDAlbumListSort.YEAR,
},
@@ -1092,17 +1101,11 @@ export type InternetProviderLyricSearchResponse = {
source: LyricSource;
};
export type SynchronizedLyricMetadata = {
lyrics: SynchronizedLyricsArray;
export type FullLyricsMetadata = {
lyrics: LyricsResponse;
remote: boolean;
} & Omit<InternetProviderLyricResponse, 'lyrics'>;
export type UnsynchronizedLyricMetadata = {
lyrics: string;
remote: boolean;
} & Omit<InternetProviderLyricResponse, 'lyrics'>;
export type FullLyricsMetadata = SynchronizedLyricMetadata | UnsynchronizedLyricMetadata;
source: string;
} & Omit<InternetProviderLyricResponse, 'id' | 'lyrics' | 'source'>;
export type LyricOverride = Omit<InternetProviderLyricResponse, 'lyrics'>;
@@ -1139,3 +1142,38 @@ export type FontData = {
postscriptName: string;
style: string;
};
export type ServerInfoArgs = BaseEndpointArgs;
export type ServerInfo = {
features: ServerFeatures;
id?: string;
version: string;
};
export type StructuredLyricsArgs = {
query: LyricsQuery;
} & BaseEndpointArgs;
export type StructuredUnsyncedLyric = {
lyrics: string;
synced: false;
} & Omit<FullLyricsMetadata, 'lyrics'>;
export type StructuredSyncedLyric = {
lyrics: SynchronizedLyricsArray;
synced: true;
} & Omit<FullLyricsMetadata, 'lyrics'>;
export type StructuredLyric = {
lang: string;
} & (StructuredUnsyncedLyric | StructuredSyncedLyric);
export type SimilarSongsQuery = {
count?: number;
songId: string;
};
export type SimilarSongsArgs = {
query: SimilarSongsQuery;
} & BaseEndpointArgs;
+10 -1
View File
@@ -2,7 +2,8 @@ import { AxiosHeaders } from 'axios';
import { z } from 'zod';
import { toast } from '/@/renderer/components';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
import { ServerListItem } from '/@/renderer/api/types';
import { ServerFeature } from '/@/renderer/api/features-types';
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
@@ -38,3 +39,11 @@ export const authenticationFailure = (currentServer: ServerListItem | null) => {
useAuthStore.getState().actions.setCurrentServer(null);
}
};
export const hasFeature = (server: ServerListItem | null, feature: ServerFeature): boolean => {
if (!server || !server.features) {
return false;
}
return server.features[feature] ?? false;
};
+27 -22
View File
@@ -27,15 +27,16 @@ import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types';
import '@ag-grid-community/styles/ag-grid.css';
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
import i18n from '/@/i18n/i18n';
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
initSimpleImg({ threshold: 0.05 }, true);
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
const ipc = isElectron() ? window.electron.ipc : null;
const remote = isElectron() ? window.electron.remote : null;
const utils = isElectron() ? window.electron.utils : null;
export const App = () => {
const theme = useTheme();
@@ -49,6 +50,7 @@ export const App = () => {
const remoteSettings = useRemoteSettings();
const textStyleRef = useRef<HTMLStyleElement>();
useDiscordRpc();
useServerVersion();
useEffect(() => {
if (type === FontType.SYSTEM && system) {
@@ -97,28 +99,31 @@ export const App = () => {
// Start the mpv instance on startup
useEffect(() => {
const initializeMpv = async () => {
const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
if (playbackType === PlaybackType.LOCAL) {
const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
mpvPlayer?.stop();
mpvPlayer?.stop();
if (!isRunning) {
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
const properties: Record<string, any> = {
speed: usePlayerStore.getState().current.speed,
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
};
if (!isRunning) {
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
const properties: Record<string, any> = {
speed: usePlayerStore.getState().current.speed,
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
};
mpvPlayer?.initialize({
extraParameters,
properties,
});
await mpvPlayer?.initialize({
extraParameters,
properties,
});
mpvPlayer?.volume(properties.volume);
mpvPlayer?.volume(properties.volume);
}
}
mpvPlayer?.restoreQueue();
utils?.restoreQueue();
};
if (isElectron() && playbackType === PlaybackType.LOCAL) {
if (isElectron()) {
initializeMpv();
}
@@ -136,8 +141,8 @@ export const App = () => {
}, [bindings]);
useEffect(() => {
if (isElectron()) {
mpvPlayerListener!.rendererSaveQueue(() => {
if (utils) {
utils.onSaveQueue(() => {
const { current, queue } = usePlayerStore.getState();
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
current: {
@@ -146,10 +151,10 @@ export const App = () => {
},
queue,
};
mpvPlayer!.saveQueue(stateToSave);
utils.saveQueue(stateToSave);
});
mpvPlayerListener!.rendererRestoreQueue((_event: any, data) => {
utils.onRestoreQueue((_event: any, data) => {
const playerData = restoreQueue(data);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueue(playerData, true);
@@ -158,8 +163,8 @@ export const App = () => {
}
return () => {
ipc?.removeAllListeners('renderer-player-restore-queue');
ipc?.removeAllListeners('renderer-player-save-queue');
ipc?.removeAllListeners('renderer-restore-queue');
ipc?.removeAllListeners('renderer-save-queue');
};
}, [playbackType, restoreQueue]);
+70 -35
View File
@@ -7,10 +7,11 @@ import {
crossfadeHandler,
gaplessHandler,
} from '/@/renderer/components/audio-player/utils/list-handlers';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import type { CrossfadeStyle } from '/@/renderer/types';
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
import { useSpeed } from '/@/renderer/store';
import { toast } from '/@/renderer/components/toast';
interface AudioPlayerProps extends ReactPlayerProps {
crossfadeDuration: number;
@@ -39,6 +40,14 @@ type WebAudio = {
gain: GainNode;
};
// Credits: http://stackoverflow.com/questions/12150729/ddg
// This is used so that the player will always have an <audio> element. This means that
// player1Source and player2Source are connected BEFORE the user presses play for
// the first time. This workaround is important for Safari, which seems to require the
// source to be connected PRIOR to resuming audio context
const EMPTY_SOURCE =
'data:audio/wav;base64,UklGRjIAAABXQVZFZm10IBIAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==';
export const AudioPlayer = forwardRef(
(
{
@@ -60,6 +69,7 @@ export const AudioPlayer = forwardRef(
const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
const playback = useSettingsStore((state) => state.playback.mpvProperties);
const { resetSampleRate } = useSettingsStoreActions();
const playbackSpeed = useSpeed();
const [webAudio, setWebAudio] = useState<WebAudio | null>(null);
@@ -69,6 +79,7 @@ export const AudioPlayer = forwardRef(
const [player2Source, setPlayer2Source] = useState<MediaElementAudioSourceNode | null>(
null,
);
const calculateReplayGain = useCallback(
(song: Song): number => {
if (playback.replayGainMode === 'no') {
@@ -119,10 +130,21 @@ export const AudioPlayer = forwardRef(
useEffect(() => {
if ('AudioContext' in window) {
const context = new AudioContext({
latencyHint: 'playback',
sampleRate: playback.audioSampleRateHz || undefined,
});
let context: AudioContext;
try {
context = new AudioContext({
latencyHint: 'playback',
sampleRate: playback.audioSampleRateHz || undefined,
});
} catch (error) {
// In practice, this should never be hit because the UI should validate
// the range. However, the actual supported range is not guaranteed
toast.error({ message: (error as Error).message });
context = new AudioContext({ latencyHint: 'playback' });
resetSampleRate();
}
const gain = context.createGain();
gain.connect(context.destination);
@@ -154,9 +176,18 @@ export const AudioPlayer = forwardRef(
useEffect(() => {
if (status === PlayerStatus.PLAYING) {
if (currentPlayer === 1) {
player1Ref.current?.getInternalPlayer()?.play();
// calling play() is not necessarily a safe option (https://developer.chrome.com/blog/play-request-was-interrupted)
// In practice, this failure is only likely to happen when using the 0-second wav:
// play() + play() in rapid succession will cause problems as the frist one ends the track.
player1Ref.current
?.getInternalPlayer()
?.play()
.catch(() => {});
} else {
player2Ref.current?.getInternalPlayer()?.play();
player2Ref.current
?.getInternalPlayer()
?.play()
.catch(() => {});
}
} else {
player1Ref.current?.getInternalPlayer()?.pause();
@@ -243,32 +274,29 @@ export const AudioPlayer = forwardRef(
}, [audioDeviceId]);
useEffect(() => {
if (webAudio && player1Source) {
if (player1 === undefined) {
player1Source.disconnect();
setPlayer1Source(null);
} else if (currentPlayer === 1) {
webAudio.gain.gain.setValueAtTime(calculateReplayGain(player1), 0);
}
if (webAudio && player1Source && player1 && currentPlayer === 1) {
const newVolume = calculateReplayGain(player1) * volume;
webAudio.gain.gain.setValueAtTime(newVolume, 0);
}
}, [calculateReplayGain, currentPlayer, player1, player1Source, webAudio]);
}, [calculateReplayGain, currentPlayer, player1, player1Source, volume, webAudio]);
useEffect(() => {
if (webAudio && player2Source) {
if (player2 === undefined) {
player2Source.disconnect();
setPlayer2Source(null);
} else if (currentPlayer === 2) {
webAudio.gain.gain.setValueAtTime(calculateReplayGain(player2), 0);
}
if (webAudio && player2Source && player2 && currentPlayer === 2) {
const newVolume = calculateReplayGain(player2) * volume;
webAudio.gain.gain.setValueAtTime(newVolume, 0);
}
}, [calculateReplayGain, currentPlayer, player2, player2Source, webAudio]);
}, [calculateReplayGain, currentPlayer, player2, player2Source, volume, webAudio]);
const handlePlayer1Start = useCallback(
async (player: ReactPlayer) => {
if (!webAudio || player1Source) return;
if (webAudio.context.state !== 'running') {
await webAudio.context.resume();
if (!webAudio) return;
if (player1Source) {
// This should fire once, only if the source is real (meaning we
// saw the dummy source) and the context is not ready
if (webAudio.context.state !== 'running') {
await webAudio.context.resume();
}
return;
}
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
@@ -284,9 +312,12 @@ export const AudioPlayer = forwardRef(
const handlePlayer2Start = useCallback(
async (player: ReactPlayer) => {
if (!webAudio || player2Source) return;
if (webAudio.context.state !== 'running') {
await webAudio.context.resume();
if (!webAudio) return;
if (player2Source) {
if (webAudio.context.state !== 'running') {
await webAudio.context.resume();
}
return;
}
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
@@ -300,6 +331,9 @@ export const AudioPlayer = forwardRef(
[player2Source, webAudio],
);
// Bugfix for Safari: rather than use the `<audio>` volume (which doesn't work),
// use the GainNode to scale the volume. In this case, for compatibility with
// other browsers, set the `<audio>` volume to 1
return (
<>
<ReactPlayer
@@ -312,10 +346,11 @@ export const AudioPlayer = forwardRef(
playbackRate={playbackSpeed}
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player1?.streamUrl}
volume={volume}
url={player1?.streamUrl || EMPTY_SOURCE}
volume={webAudio ? 1 : volume}
width={0}
onEnded={handleOnEnded}
// If there is no stream url, we do not need to handle when the audio finishes
onEnded={player1?.streamUrl ? handleOnEnded : undefined}
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
}
@@ -331,10 +366,10 @@ export const AudioPlayer = forwardRef(
playbackRate={playbackSpeed}
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
url={player2?.streamUrl}
volume={volume}
url={player2?.streamUrl || EMPTY_SOURCE}
volume={webAudio ? 1 : volume}
width={0}
onEnded={handleOnEnded}
onEnded={player2?.streamUrl ? handleOnEnded : undefined}
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
}
@@ -23,7 +23,10 @@ export const gaplessHandler = (args: {
const durationPadding = isFlac ? 0.065 : 0.116;
if (currentTime + durationPadding >= duration) {
return nextPlayerRef.current.getInternalPlayer()?.play();
return nextPlayerRef.current
.getInternalPlayer()
?.play()
.catch(() => {});
}
return null;
@@ -61,7 +64,10 @@ export const crossfadeHandler = (args: {
if (shouldBeginTransition) {
setIsTransitioning(true);
return nextPlayerRef.current.getInternalPlayer().play();
return nextPlayerRef.current
.getInternalPlayer()
?.play()
.catch(() => {});
}
return null;
}
+48 -21
View File
@@ -4,6 +4,7 @@ import {
ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -114,9 +115,11 @@ export const SwiperGridCarousel = ({
isLoading,
uniqueId,
}: SwiperGridCarouselProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const swiperRef = useRef<SwiperCore | any>(null);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd();
const [slideCount, setSlideCount] = useState(4);
useEffect(() => {
swiperRef.current?.slideTo(0, 0);
@@ -191,23 +194,24 @@ export const SwiperGridCarousel = ({
const handleNext = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount));
swiperRef?.current?.slideTo(activeIndex + slidesPerView);
}, [swiperProps?.slidesPerView]);
}, [slideCount, swiperProps?.slidesPerView]);
const handlePrev = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount));
swiperRef?.current?.slideTo(activeIndex - slidesPerView);
}, [swiperProps?.slidesPerView]);
}, [slideCount, swiperProps?.slidesPerView]);
const handleOnSlideChange = useCallback((e: SwiperCore) => {
const { slides, isEnd, isBeginning, params } = e;
if (isEnd || isBeginning) return;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: (params?.slidesPerView || 4) < slides.length,
hasPreviousPage: (params?.slidesPerView || 4) < slides.length,
hasNextPage: slideCount < slides.length,
hasPreviousPage: slideCount < slides.length,
});
}, []);
@@ -215,42 +219,67 @@ export const SwiperGridCarousel = ({
const { slides, isEnd, isBeginning, params } = e;
if (isEnd || isBeginning) return;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: (params.slidesPerView || 4) < slides.length,
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
hasNextPage: slideCount < slides.length,
hasPreviousPage: slideCount < slides.length,
});
}, []);
const handleOnReachEnd = useCallback((e: SwiperCore) => {
const { slides, params } = e;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: false,
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
hasPreviousPage: slideCount < slides.length,
});
}, []);
const handleOnReachBeginning = useCallback((e: SwiperCore) => {
const { slides, params } = e;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: (params.slidesPerView || 4) < slides.length,
hasNextPage: slideCount < slides.length,
hasPreviousPage: false,
});
}, []);
const handleOnResize = useCallback((e: SwiperCore) => {
if (!e) return;
const { width } = e;
const slidesPerView = getSlidesPerView(width);
if (!e.params) return;
e.params.slidesPerView = slidesPerView;
}, []);
useLayoutEffect(() => {
const handleResize = () => {
// Use the container div ref and not swiper width, as this value is more accurate
const width = containerRef.current?.clientWidth;
const { activeIndex, params, slides } =
(swiperRef.current as SwiperCore | undefined) ?? {};
const throttledOnResize = throttle(handleOnResize, 200);
if (width) {
const slidesPerView = getSlidesPerView(width);
setSlideCount(slidesPerView);
}
if (activeIndex !== undefined && slides && params?.slidesPerView) {
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: activeIndex + slideCount < slides.length,
hasPreviousPage: activeIndex > 0,
});
}
};
handleResize();
const throttledResize = throttle(handleResize, 200);
window.addEventListener('resize', throttledResize);
return () => {
window.removeEventListener('resize', throttledResize);
};
}, []);
return (
<CarouselContainer
ref={containerRef}
className="grid-carousel"
spacing="md"
>
@@ -266,16 +295,14 @@ export const SwiperGridCarousel = ({
ref={swiperRef}
resizeObserver
modules={[Virtual]}
slidesPerView={4}
slidesPerView={slideCount}
spaceBetween={20}
style={{ height: '100%', width: '100%' }}
onBeforeInit={(swiper) => {
swiperRef.current = swiper;
}}
onBeforeResize={handleOnResize}
onReachBeginning={handleOnReachBeginning}
onReachEnd={handleOnReachEnd}
onResize={throttledOnResize}
onSlideChange={handleOnSlideChange}
onZoomChange={handleOnZoomChange}
{...swiperProps}
+1
View File
@@ -27,6 +27,7 @@ export * from './select';
export * from './skeleton';
export * from './slider';
export * from './spinner';
export * from './spoiler';
export * from './switch';
export * from './tabs';
export * from './text';
+39
View File
@@ -0,0 +1,39 @@
import clsx from 'clsx';
import { HTMLAttributes, ReactNode, useRef, useState } from 'react';
import styles from './spoiler.module.scss';
import { useIsOverflow } from '/@/renderer/hooks';
interface SpoilerProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
defaultOpened?: boolean;
maxHeight?: number;
}
export const Spoiler = ({ maxHeight, defaultOpened, children, ...props }: SpoilerProps) => {
const ref = useRef(null);
const isOverflow = useIsOverflow(ref);
const [isExpanded, setIsExpanded] = useState(!!defaultOpened);
const spoilerClassNames = clsx(styles.spoiler, {
[styles.canExpand]: isOverflow,
[styles.isExpanded]: isExpanded,
});
const handleToggleExpand = () => {
setIsExpanded((val) => !val);
};
return (
<div
ref={ref}
className={spoilerClassNames}
role="button"
style={{ maxHeight: maxHeight ?? '100px' }}
tabIndex={-1}
onClick={handleToggleExpand}
{...props}
>
{children}
</div>
);
};
@@ -0,0 +1,31 @@
.control:hover {
color: var(--btn-subtle-fg-hover);
text-decoration: none;
}
.spoiler {
position: relative;
text-align: justify;
width: 100%;
height: 100%;
overflow: hidden;
}
.spoiler:not(.is-expanded).can-expand:after {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 100%;
content: '';
background: linear-gradient(to top, var(--main-bg) 10%, transparent 60%);
pointer-events: none;
}
.spoiler.can-expand {
cursor: pointer;
}
.spoiler.is-expanded {
max-height: 2500px !important;
}
+1 -1
View File
@@ -30,7 +30,7 @@ const showToast = ({ type, ...props }: NotificationProps) => {
? 'Error'
: 'Info';
const defaultDuration = type === 'error' ? 2000 : 1000;
const defaultDuration = type === 'error' ? 5000 : 2000;
return showNotification({
autoClose: defaultDuration,
@@ -72,7 +72,7 @@ export const VirtualInfiniteGrid = forwardRef(
const [itemData, setItemData] = useState<any[]>(fetchInitialData?.() || []);
const { itemHeight, rowCount, columnCount } = useMemo(() => {
const itemsPerRow = width ? Math.floor(width / itemSize) : 5;
const itemsPerRow = width ? Math.floor(width / (itemSize + itemGap * 2)) : 5;
const widthPerItem = Number(width) / itemsPerRow;
const itemHeight = widthPerItem + cardRows.length * 26;
@@ -81,7 +81,7 @@ export const VirtualInfiniteGrid = forwardRef(
itemHeight,
rowCount: Math.ceil(itemCount / itemsPerRow),
};
}, [cardRows.length, itemCount, itemSize, width]);
}, [cardRows.length, itemCount, itemGap, itemSize, width]);
const isItemLoaded = useCallback(
(index: number) => {
@@ -10,7 +10,7 @@ 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/types';
import { ServerType } from '/@/renderer/api/types';
import { Skeleton } from '/@/renderer/components/skeleton';
const CellContainer = styled(motion.div)<{ height: number }>`
@@ -3,17 +3,7 @@ import { Skeleton } from '/@/renderer/components/skeleton';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { useMemo } from 'react';
import { Text } from '/@/renderer/components/text';
const URL_REGEX =
/((?:https?:\/\/)?(?:[\w-]{1,32}(?:\.[\w-]{1,32})+)(?:\/[\w\-./?%&=][^.|^\s]*)?)/g;
const replaceURLWithHTMLLinks = (text: string) => {
const urlRegex = new RegExp(URL_REGEX, 'g');
return text.replaceAll(
urlRegex,
(url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
);
};
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
export const NoteCell = ({ value }: ICellRendererParams) => {
const formattedValue = useMemo(() => {
@@ -39,9 +29,10 @@ export const NoteCell = ({ value }: ICellRendererParams) => {
<CellContainer $position="left">
<Text
$secondary
dangerouslySetInnerHTML={{ __html: formattedValue }}
overflow="hidden"
/>
>
{formattedValue}
</Text>
</CellContainer>
);
};
@@ -11,9 +11,9 @@ import {
LibraryItem,
AnyLibraryItems,
RatingResponse,
ServerType,
} from '/@/renderer/api/types';
import { useSetAlbumListItemDataById, useSetQueueRating, getServerById } from '/@/renderer/store';
import { ServerType } from '/@/renderer/types';
export const useUpdateRating = () => {
const queryClient = useQueryClient();
@@ -16,12 +16,12 @@ import orderBy from 'lodash/orderBy';
import { generatePath, useNavigate } from 'react-router';
import { api } from '/@/renderer/api';
import { QueryPagination, queryKeys } from '/@/renderer/api/query-keys';
import { BasePaginatedResponse, LibraryItem } from '/@/renderer/api/types';
import { BasePaginatedResponse, LibraryItem, ServerListItem } from '/@/renderer/api/types';
import { getColumnDefs, VirtualTableProps } from '/@/renderer/components/virtual-table';
import { SetContextMenuItems, useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { AppRoute } from '/@/renderer/router/routes';
import { useListStoreActions } from '/@/renderer/store';
import { ListDisplayType, ServerListItem, TablePagination } from '/@/renderer/types';
import { ListDisplayType, TablePagination } from '/@/renderer/types';
import { useSearchParams } from 'react-router-dom';
import { ListKey, useListStoreByKey } from '../../../store/list.store';
@@ -158,6 +158,14 @@ const tableColumns: { [key: string]: ColDef } = {
params.data ? params.data.channels : undefined,
width: 100,
},
codec: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.CODEC,
headerName: i18n.t('table.column.codec'),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.container : undefined,
width: 60,
},
comment: {
cellRenderer: NoteCell,
colId: TableColumn.COMMENT,
@@ -60,6 +60,10 @@ export const SONG_TABLE_COLUMNS = [
label: i18n.t('table.config.label.bitrate', { postProcess: 'titleCase' }),
value: TableColumn.BIT_RATE,
},
{
label: i18n.t('table.config.label.codec', { postProcess: 'titleCase' }),
value: TableColumn.CODEC,
},
{
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
value: TableColumn.LAST_PLAYED,
@@ -1,23 +1,36 @@
import { useEffect, useState } from 'react';
import isElectron from 'is-electron';
import { FileInput, Text, Button } from '/@/renderer/components';
import { FileInput, Text, Button, Checkbox } from '/@/renderer/components';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store';
import { PlaybackType } from '/@/renderer/types';
import { useTranslation } from 'react-i18next';
const localSettings = isElectron() ? window.electron.localSettings : null;
export const MpvRequired = () => {
const [mpvPath, setMpvPath] = useState('');
const settings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions();
const [disabled, setDisabled] = useState(false);
const { t } = useTranslation();
const handleSetMpvPath = (e: File) => {
localSettings?.set('mpv_path', e.path);
};
useEffect(() => {
const getMpvPath = async () => {
if (!localSettings) return setMpvPath('');
const mpvPath = localSettings.get('mpv_path') as string;
return setMpvPath(mpvPath);
};
const handleSetDisableMpv = (disabled: boolean) => {
setDisabled(disabled);
localSettings?.set('disable_mpv', disabled);
getMpvPath();
setSettings({
playback: { ...settings, type: disabled ? PlaybackType.WEB : PlaybackType.LOCAL },
});
};
useEffect(() => {
if (!localSettings) return setMpvPath('');
const mpvPath = localSettings.get('mpv_path') as string;
return setMpvPath(mpvPath);
}, []);
return (
@@ -34,9 +47,15 @@ export const MpvRequired = () => {
</a>
</Text>
<FileInput
disabled={disabled}
placeholder={mpvPath}
onChange={handleSetMpvPath}
/>
<Text>{t('setting.disable_mpv', { context: 'description' })}</Text>
<Checkbox
label={t('setting.disableMpv')}
onChange={(e) => handleSetDisableMpv(e.currentTarget.checked)}
/>
<Button onClick={() => localSettings?.restart()}>Restart</Button>
</>
);
@@ -1,48 +1,22 @@
import { useState, useEffect } from 'react';
import { Center, Group, Stack } from '@mantine/core';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { RiCheckFill } from 'react-icons/ri';
import { Link, Navigate } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { Button, PageHeader, Text } from '/@/renderer/components';
import { ActionRequiredContainer } from '/@/renderer/features/action-required/components/action-required-container';
import { MpvRequired } from '/@/renderer/features/action-required/components/mpv-required';
import { ServerCredentialRequired } from '/@/renderer/features/action-required/components/server-credential-required';
import { ServerRequired } from '/@/renderer/features/action-required/components/server-required';
import { AnimatedPage } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
const localSettings = isElectron() ? window.electron.localSettings : null;
const ActionRequiredRoute = () => {
const { t } = useTranslation();
const currentServer = useCurrentServer();
const [isMpvRequired, setIsMpvRequired] = useState(false);
const isServerRequired = !currentServer;
const isCredentialRequired = false;
useEffect(() => {
const getMpvPath = async () => {
if (!localSettings) return setIsMpvRequired(false);
const mpvPath = await localSettings.get('mpv_path');
if (mpvPath) {
return setIsMpvRequired(false);
}
return setIsMpvRequired(true);
};
getMpvPath();
}, []);
const isCredentialRequired = currentServer && !currentServer.credential;
const checks = [
{
component: <MpvRequired />,
title: t('error.mpvRequired', { postProcess: 'sentenceCase' }),
valid: !isMpvRequired,
},
{
component: <ServerCredentialRequired />,
title: t('error.credentialsRequired', { postProcess: 'sentenceCase' }),
@@ -76,7 +50,6 @@ const ActionRequiredRoute = () => {
<Stack mt="2rem">
{canReturnHome && (
<>
<Navigate to={AppRoute.HOME} />
<Group
noWrap
position="center"
@@ -4,13 +4,15 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { Box, Group, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { FaLastfmSquare } from 'react-icons/fa';
import { RiHeartFill, RiHeartLine, RiMoreFill, RiSettings2Fill } from 'react-icons/ri';
import { SiMusicbrainz } from 'react-icons/si';
import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
import { Button, Popover } from '/@/renderer/components';
import { Button, Popover, Spoiler } from '/@/renderer/components';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import {
TableConfigDropdown,
@@ -36,11 +38,13 @@ import { useAppFocus, useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useCurrentSong, useCurrentStatus } from '/@/renderer/store';
import {
useGeneralSettings,
usePlayButtonBehavior,
useSettingsStoreActions,
useTableSettings,
} from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
const isFullWidthRow = (node: RowNode) => {
return node.id?.startsWith('disc-');
@@ -54,6 +58,7 @@ const ContentContainer = styled.div`
const DetailContainer = styled.div`
display: flex;
flex-direction: column;
gap: 2rem;
padding: 1rem 2rem 5rem;
overflow: hidden;
`;
@@ -75,6 +80,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
const status = useCurrentStatus();
const isFocused = useAppFocus();
const currentSong = useCurrentSong();
const { externalLinks } = useGeneralSettings();
const columnDefs = useMemo(
() => getColumnDefs(tableConfig.columns, false, 'albumDetail'),
@@ -279,6 +285,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
};
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const comment = detailQuery?.data?.comment;
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.ALBUM,
@@ -313,6 +320,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
const mbzId = detailQuery?.data?.mbzId;
return (
<ContentContainer>
<LibraryBackgroundOverlay $backgroundColor={background} />
@@ -320,7 +329,6 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
<Box component="section">
<Group
position="apart"
py="1rem"
spacing="sm"
>
<Group>
@@ -372,10 +380,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
</Group>
</Box>
{showGenres && (
<Box
component="section"
py="1rem"
>
<Box component="section">
<Group spacing="sm">
{detailQuery?.data?.genres?.map((genre) => (
<Button
@@ -395,6 +400,51 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
</Group>
</Box>
)}
{externalLinks ? (
<Box component="section">
<Group spacing="sm">
<Button
compact
component="a"
href={`https://www.last.fm/music/${encodeURIComponent(
detailQuery?.data?.albumArtist || '',
)}/${encodeURIComponent(detailQuery.data?.name || '')}`}
radius="md"
rel="noopener noreferrer"
size="md"
target="_blank"
tooltip={{
label: t('action.openIn.lastfm'),
}}
variant="subtle"
>
<FaLastfmSquare size={25} />
</Button>
{mbzId ? (
<Button
compact
component="a"
href={`https://musicbrainz.org/release/${mbzId}`}
radius="md"
rel="noopener noreferrer"
size="md"
target="_blank"
tooltip={{
label: t('action.openIn.musicbrainz'),
}}
variant="subtle"
>
<SiMusicbrainz size={25} />
</Button>
) : null}
</Group>
</Box>
) : null}
{comment && (
<Box component="section">
<Spoiler maxHeight={75}>{replaceURLWithHTMLLinks(comment)}</Spoiler>
</Box>
)}
<Box style={{ minHeight: '300px' }}>
<VirtualTable
key={`table-${tableConfig.rowHeight}`}
@@ -15,7 +15,7 @@ import {
RiSettings3Fill,
} from 'react-icons/ri';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { AlbumListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
@@ -31,7 +31,7 @@ import {
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
const FILTERS = {
@@ -73,7 +73,7 @@ const FILTERS = {
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
name: i18n.t('filter.releaseDate', { postProcess: 'titleCase' }),
value: AlbumListSort.RELEASE_DATE,
},
],
@@ -538,7 +538,9 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
Table (paginated)
</DropdownMenu.Item> */}
<DropdownMenu.Divider />
<DropdownMenu.Label>Item size</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.itemSize', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
@@ -549,7 +551,11 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
</DropdownMenu.Item>
{isGrid && (
<>
<DropdownMenu.Label>Item gap</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.itemGap', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={grid?.itemGap || 0}
@@ -1,7 +1,10 @@
import { useMemo } from 'react';
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import { Box, Group, Stack } from '@mantine/core';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaLastfmSquare } from 'react-icons/fa';
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { SiMusicbrainz } from 'react-icons/si';
import { generatePath, useParams } from 'react-router';
import { createSearchParams, Link } from 'react-router-dom';
import styled from 'styled-components';
@@ -14,7 +17,7 @@ import {
ServerType,
SortOrder,
} from '/@/renderer/api/types';
import { Button, Text, TextTitle } from '/@/renderer/components';
import { Button, Spoiler, TextTitle } from '/@/renderer/components';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
@@ -34,7 +37,7 @@ import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { CardRow, Play, TableColumn } from '/@/renderer/types';
const ContentContainer = styled.div`
@@ -45,7 +48,7 @@ const ContentContainer = styled.div`
const DetailContainer = styled.div`
display: flex;
flex-direction: column;
gap: 3rem;
gap: 2rem;
padding: 1rem 2rem 5rem;
overflow: hidden;
@@ -59,6 +62,8 @@ interface AlbumArtistDetailContentProps {
}
export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailContentProps) => {
const { t } = useTranslation();
const { externalLinks } = useGeneralSettings();
const { albumArtistId } = useParams() as { albumArtistId: string };
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
@@ -208,7 +213,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
order={2}
weight={700}
>
Recent releases
{t('page.albumArtistDetail.recentReleases', {
postProcess: 'sentenceCase',
})}
</TextTitle>
<Button
compact
@@ -217,7 +224,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
to={artistDiscographyLink}
variant="subtle"
>
View discography
{t('page.albumArtistDetail.viewDiscography')}
</Button>
</Group>
),
@@ -233,7 +240,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
order={2}
weight={700}
>
Appears on
{t('page.albumArtistDetail.appearsOn', { postProcess: 'sentenceCase' })}
</TextTitle>
),
uniqueId: 'compilationAlbums',
@@ -247,7 +254,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
order={2}
weight={700}
>
Related artists
{t('page.albumArtistDetail.relatedArtists', {
postProcess: 'sentenceCase',
})}
</TextTitle>
),
uniqueId: 'similarArtists',
@@ -262,6 +271,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
recentAlbumsQuery?.data?.items,
recentAlbumsQuery.isFetching,
recentAlbumsQuery?.isLoading,
t,
]);
const playButtonBehavior = usePlayButtonBehavior();
@@ -324,6 +334,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
const showTopSongs = topSongsQuery?.data?.items?.length;
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const mbzId = detailQuery?.data?.mbz;
const isLoading =
detailQuery?.isLoading ||
@@ -335,61 +346,58 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
<ContentContainer ref={cq.ref}>
<LibraryBackgroundOverlay $backgroundColor={background} />
<DetailContainer>
<Stack spacing="lg">
<Group spacing="md">
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<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>
<Group spacing="md">
<Group spacing="md">
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<Button
compact
uppercase
component={Link}
to={artistDiscographyLink}
loading={
createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading
}
variant="subtle"
onClick={handleFavorite}
>
View discography
{detailQuery?.data?.userFavorite ? (
<RiHeartFill
color="red"
size={20}
/>
) : (
<RiHeartLine size={20} />
)}
</Button>
<Button
compact
uppercase
component={Link}
to={artistSongsLink}
variant="subtle"
onClick={(e) => {
if (!detailQuery?.data) return;
handleGeneralContextMenu(e, [detailQuery.data!]);
}}
>
View all songs
<RiMoreFill size={20} />
</Button>
</Group>
</Stack>
</Group>
<Group spacing="md">
<Button
compact
uppercase
component={Link}
to={artistDiscographyLink}
variant="subtle"
>
{t('page.albumArtistDetail.viewDiscography')}
</Button>
<Button
compact
uppercase
component={Link}
to={artistSongsLink}
variant="subtle"
>
{t('page.albumArtistDetail.viewAllTracks')}
</Button>
</Group>
{showGenres ? (
<Box component="section">
<Group spacing="sm">
@@ -411,6 +419,46 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
</Group>
</Box>
) : null}
{externalLinks ? (
<Box component="section">
<Group spacing="sm">
<Button
compact
component="a"
href={`https://www.last.fm/music/${encodeURIComponent(
detailQuery?.data?.name || '',
)}`}
radius="md"
rel="noopener noreferrer"
size="md"
target="_blank"
tooltip={{
label: t('action.openIn.lastfm'),
}}
variant="subtle"
>
<FaLastfmSquare size={25} />
</Button>
{mbzId ? (
<Button
compact
component="a"
href={`https://musicbrainz.org/artist/${mbzId}`}
radius="md"
rel="noopener noreferrer"
size="md"
target="_blank"
tooltip={{
label: t('action.openIn.musicbrainz'),
}}
variant="subtle"
>
<SiMusicbrainz size={25} />
</Button>
) : null}
</Group>
</Box>
) : null}
{showBiography ? (
<Box
component="section"
@@ -420,13 +468,12 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
order={2}
weight={700}
>
About {detailQuery?.data?.name}
{t('page.albumArtistDetail.about', {
artist: detailQuery?.data?.name,
})}
</TextTitle>
<Text
$secondary
component="p"
<Spoiler
dangerouslySetInnerHTML={{ __html: detailQuery?.data?.biography || '' }}
sx={{ textAlign: 'justify' }}
/>
</Box>
) : null}
@@ -444,7 +491,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
order={2}
weight={700}
>
Top Songs
{t('page.albumArtistDetail.topSongs', {
postProcess: 'sentenceCase',
})}
</TextTitle>
<Button
compact
@@ -458,7 +507,9 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
)}
variant="subtle"
>
View all
{t('page.albumArtistDetail.viewAll', {
postProcess: 'sentenceCase',
})}
</Button>
</Group>
</Group>
@@ -33,7 +33,9 @@ export const AlbumArtistDetailTopSongsListHeader = ({
<PageHeader p="1rem">
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<LibraryHeaderBar.Title>Top songs from {title}</LibraryHeaderBar.Title>
<LibraryHeaderBar.Title>
{t('page.albumArtistDetail.topSongsFrom', { title })}
</LibraryHeaderBar.Title>
<Paper
fw="600"
px="1rem"
@@ -57,7 +59,7 @@ export const AlbumArtistDetailTopSongsListHeader = ({
icon={<RiPlayFill />}
onClick={() => handlePlay(Play.NOW)}
>
{t('player.add', { postProcess: 'sentenceCase' })}
{t('player.play', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
@@ -9,7 +9,7 @@ import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react
import { useListContext } from '../../../context/list-context';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { AlbumArtistListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
@@ -21,7 +21,7 @@ import {
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType, ServerType, TableColumn } from '/@/renderer/types';
import { ListDisplayType, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
const FILTERS = {
@@ -472,7 +472,9 @@ export const AlbumArtistListHeaderFilters = ({
Table (paginated)
</DropdownMenu.Item> */}
<DropdownMenu.Divider />
<DropdownMenu.Label>Item size</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.itemSize', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
{display === ListDisplayType.CARD ||
display === ListDisplayType.POSTER ? (
@@ -493,7 +495,11 @@ export const AlbumArtistListHeaderFilters = ({
</DropdownMenu.Item>
{isGrid && (
<>
<DropdownMenu.Label>Item gap</DropdownMenu.Label>
<DropdownMenu.Label>
{t('table.config.general.itemGap', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={grid?.itemGap || 0}
@@ -51,7 +51,7 @@ import {
usePlayerStore,
useQueueControls,
} from '/@/renderer/store';
import { usePlayerType } from '/@/renderer/store/settings.store';
import { usePlaybackType } from '/@/renderer/store/settings.store';
import { Play, PlaybackType } from '/@/renderer/types';
type ContextMenuContextProps = {
@@ -522,7 +522,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const handleUpdateRating = useCallback(
(rating: number) => {
if (!ctx.dataNodes || !ctx.data) return;
if (!ctx.dataNodes && !ctx.data) return;
let uniqueServerIds: string[] = [];
let items: AnyLibraryItems = [];
@@ -575,7 +575,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
[ctx.data, ctx.dataNodes, updateRatingMutation],
);
const playerType = usePlayerType();
const playbackType = usePlaybackType();
const { moveToBottomOfQueue, moveToTopOfQueue, removeFromQueue } = useQueueControls();
const handleMoveToBottom = useCallback(() => {
@@ -584,10 +584,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const playerData = moveToBottomOfQueue(uniqueIds);
if (playerType === PlaybackType.LOCAL) {
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueueNext(playerData);
}
}, [ctx.dataNodes, moveToBottomOfQueue, playerType]);
}, [ctx.dataNodes, moveToBottomOfQueue, playbackType]);
const handleMoveToTop = useCallback(() => {
const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId);
@@ -595,10 +595,10 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const playerData = moveToTopOfQueue(uniqueIds);
if (playerType === PlaybackType.LOCAL) {
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueueNext(playerData);
}
}, [ctx.dataNodes, moveToTopOfQueue, playerType]);
}, [ctx.dataNodes, moveToTopOfQueue, playbackType]);
const handleRemoveSelected = useCallback(() => {
const uniqueIds = ctx.dataNodes?.map((row) => row.data.uniqueId);
@@ -608,7 +608,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const playerData = removeFromQueue(uniqueIds);
const isCurrentSongRemoved = currentSong && uniqueIds.includes(currentSong?.uniqueId);
if (playerType === PlaybackType.LOCAL) {
if (playbackType === PlaybackType.LOCAL) {
if (isCurrentSongRemoved) {
mpvPlayer!.setQueue(playerData);
} else {
@@ -621,7 +621,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
if (isCurrentSongRemoved) {
remote?.updateSong({ song: playerData.current.song });
}
}, [ctx.dataNodes, ctx.tableApi, playerType, removeFromQueue]);
}, [ctx.dataNodes, ctx.tableApi, playbackType, removeFromQueue]);
const handleDeselectAll = useCallback(() => {
ctx.tableApi?.deselectAll();
@@ -8,7 +8,8 @@ import {
usePlayerStore,
} from '/@/renderer/store';
import { SetActivity } from '@xhayper/discord-rpc';
import { PlayerStatus, ServerType } from '/@/renderer/types';
import { PlayerStatus } from '/@/renderer/types';
import { ServerType } from '/@/renderer/api/types';
const discordRpc = isElectron() ? window.electron.discordRpc : null;
@@ -40,7 +41,7 @@ export const useDiscordRpc = () => {
largeImageText: currentSong?.album || 'Unknown album',
smallImageKey: undefined,
smallImageText: currentStatus,
state: artists && `By ${artists}` || "Unknown artist",
state: (artists && `By ${artists}`) || 'Unknown artist',
};
if (currentStatus === PlayerStatus.PLAYING) {
@@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { RiFolder2Fill, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import { queryKeys } from '/@/renderer/api/query-keys';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { GenreListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
@@ -19,7 +19,7 @@ import {
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType, ServerType, TableColumn } from '/@/renderer/types';
import { ListDisplayType, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
const FILTERS = {
@@ -12,7 +12,12 @@ import { useAlbumList } from '/@/renderer/features/albums';
import { useRecentlyPlayed } from '/@/renderer/features/home/queries/recently-played-query';
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useWindowSettings } from '/@/renderer/store';
import {
HomeItem,
useCurrentServer,
useGeneralSettings,
useWindowSettings,
} from '/@/renderer/store';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import { Platform } from '/@/renderer/types';
import { useQueryClient } from '@tanstack/react-query';
@@ -28,6 +33,7 @@ const HomeRoute = () => {
const server = useCurrentServer();
const itemsPerPage = 15;
const { windowBarStyle } = useWindowSettings();
const { homeItems } = useGeneralSettings();
const feature = useAlbumList({
options: {
@@ -129,16 +135,15 @@ const HomeRoute = () => {
return <Spinner container />;
}
const carousels = [
{
const carousels = {
[HomeItem.RANDOM]: {
data: random?.data?.items,
itemType: LibraryItem.ALBUM,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
title: t('page.home.explore', { postProcess: 'sentenceCase' }),
uniqueId: 'random',
},
{
[HomeItem.RECENTLY_PLAYED]: {
data: recentlyPlayed?.data?.items,
itemType: LibraryItem.ALBUM,
pagination: {
@@ -147,9 +152,8 @@ const HomeRoute = () => {
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.DESC,
title: t('page.home.recentlyPlayed', { postProcess: 'sentenceCase' }),
uniqueId: 'recentlyPlayed',
},
{
[HomeItem.RECENTLY_ADDED]: {
data: recentlyAdded?.data?.items,
itemType: LibraryItem.ALBUM,
pagination: {
@@ -158,9 +162,8 @@ const HomeRoute = () => {
sortBy: AlbumListSort.RECENTLY_ADDED,
sortOrder: SortOrder.DESC,
title: t('page.home.newlyAdded', { postProcess: 'sentenceCase' }),
uniqueId: 'recentlyAdded',
},
{
[HomeItem.MOST_PLAYED]: {
data:
server?.type === ServerType.JELLYFIN
? mostPlayedSongs?.data?.items
@@ -175,9 +178,24 @@ const HomeRoute = () => {
: AlbumListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
title: t('page.home.mostPlayed', { postProcess: 'sentenceCase' }),
uniqueId: 'mostPlayed',
},
];
};
const sortedCarousel = homeItems
.filter((item) => {
if (item.disabled) {
return false;
}
if (server?.type === ServerType.JELLYFIN && item.id === HomeItem.RECENTLY_PLAYED) {
return false;
}
return true;
})
.map((item) => ({
...carousels[item.id],
uniqueId: item.id,
}));
const invalidateCarouselQuery = (carousel: {
itemType: LibraryItem;
@@ -232,69 +250,76 @@ const HomeRoute = () => {
spacing="lg"
>
<FeatureCarousel data={featureItemsWithImage} />
{carousels
.filter((carousel) => {
if (
server?.type === ServerType.JELLYFIN &&
carousel.uniqueId === 'recentlyPlayed'
) {
return null;
}
return carousel;
})
.map((carousel) => (
<MemoizedSwiperGridCarousel
key={`carousel-${carousel.uniqueId}`}
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
{sortedCarousel.map((carousel) => (
<MemoizedSwiperGridCarousel
key={`carousel-${carousel.uniqueId}`}
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [
{
idProperty:
server?.type === ServerType.JELLYFIN &&
carousel.itemType === LibraryItem.SONG
? 'albumId'
: 'id',
slugProperty: 'albumId',
},
],
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [
{
idProperty: 'id',
slugProperty: 'albumArtistId',
},
],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [
{
idProperty: 'id',
slugProperty: 'albumArtistId',
},
],
},
]}
data={carousel.data}
itemType={carousel.itemType}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
title={{
label: (
<Group>
<TextTitle
order={2}
weight={700}
>
{carousel.title}
</TextTitle>
},
]}
data={carousel.data}
itemType={carousel.itemType}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [
{
idProperty:
server?.type === ServerType.JELLYFIN &&
carousel.itemType === LibraryItem.SONG
? 'albumId'
: 'id',
slugProperty: 'albumId',
},
],
}}
title={{
label: (
<Group>
<TextTitle
order={2}
weight={700}
>
{carousel.title}
</TextTitle>
<ActionIcon
onClick={() => invalidateCarouselQuery(carousel)}
>
<RiRefreshLine />
</ActionIcon>
</Group>
),
}}
uniqueId={carousel.uniqueId}
/>
))}
<ActionIcon
onClick={() => invalidateCarouselQuery(carousel)}
>
<RiRefreshLine />
</ActionIcon>
</Group>
),
}}
uniqueId={carousel.uniqueId}
/>
))}
</Stack>
</NativeScrollArea>
</AnimatedPage>
@@ -1,4 +1,4 @@
import { Box, Group } from '@mantine/core';
import { Box, Center, Group, Select, SelectItem } from '@mantine/core';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { RiAddFill, RiSubtractFill } from 'react-icons/ri';
@@ -13,15 +13,22 @@ import {
} from '/@/renderer/store';
interface LyricsActionsProps {
index: number;
languages: SelectItem[];
onRemoveLyric: () => void;
onResetLyric: () => void;
onSearchOverride: (params: LyricsOverride) => void;
setIndex: (idx: number) => void;
}
export const LyricsActions = ({
index,
languages,
onRemoveLyric,
onResetLyric,
onSearchOverride,
setIndex,
}: LyricsActionsProps) => {
const { t } = useTranslation();
const currentSong = useCurrentSong();
@@ -42,6 +49,18 @@ export const LyricsActions = ({
return (
<Box style={{ position: 'relative', width: '100%' }}>
{languages.length > 1 && (
<Center>
<Select
clearable={false}
data={languages}
style={{ bottom: 30, position: 'absolute' }}
value={index.toString()}
onChange={(value) => setIndex(parseInt(value!, 10))}
/>
</Center>
)}
<Group position="center">
{isDesktop && sources.length ? (
<Button
+37 -30
View File
@@ -1,21 +1,19 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Center, Group } from '@mantine/core';
import { AnimatePresence, motion } from 'framer-motion';
import { ErrorBoundary } from 'react-error-boundary';
import { RiInformationFill } from 'react-icons/ri';
import styled from 'styled-components';
import { useSongLyricsByRemoteId, useSongLyricsBySong } from './queries/lyric-query';
import { SynchronizedLyrics } from './synchronized-lyrics';
import { SynchronizedLyrics, SynchronizedLyricsProps } from './synchronized-lyrics';
import { Spinner, TextTitle } from '/@/renderer/components';
import { ErrorFallback } from '/@/renderer/features/action-required';
import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics';
import { useCurrentSong, usePlayerStore } from '/@/renderer/store';
import {
FullLyricsMetadata,
LyricsOverride,
SynchronizedLyricMetadata,
UnsynchronizedLyricMetadata,
} from '/@/renderer/api/types';
UnsynchronizedLyrics,
UnsynchronizedLyricsProps,
} from '/@/renderer/features/lyrics/unsynchronized-lyrics';
import { useCurrentSong, usePlayerStore } from '/@/renderer/store';
import { FullLyricsMetadata, LyricSource, LyricsOverride } from '/@/renderer/api/types';
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
import { queryKeys } from '/@/renderer/api/query-keys';
import { queryClient } from '/@/renderer/lib/react-query';
@@ -84,17 +82,9 @@ const ScrollContainer = styled(motion.div)`
}
`;
function isSynchronized(
data: Partial<FullLyricsMetadata> | undefined,
): data is SynchronizedLyricMetadata {
// Type magic. The only difference between Synchronized and Unsynchhronized is
// the datatype of lyrics. This makes Typescript happier later...
if (!data) return false;
return Array.isArray(data.lyrics);
}
export const Lyrics = () => {
const currentSong = useCurrentSong();
const [index, setIndex] = useState(0);
const { data, isInitialLoading } = useSongLyricsBySong(
{
@@ -139,7 +129,7 @@ export const Lyrics = () => {
},
query: {
remoteSongId: override?.id,
remoteSource: override?.source,
remoteSource: override?.source as LyricSource | undefined,
song: currentSong,
},
serverId: currentSong?.serverId,
@@ -150,6 +140,7 @@ export const Lyrics = () => {
(state) => state.current.song,
() => {
setOverride(undefined);
setIndex(0);
},
{ equalityFn: (a, b) => a?.id === b?.id },
);
@@ -159,16 +150,29 @@ export const Lyrics = () => {
};
}, []);
const [lyrics, synced] = useMemo(() => {
if (Array.isArray(data)) {
if (data.length > 0) {
const selectedLyric = data[Math.min(index, data.length)];
return [selectedLyric, selectedLyric.synced];
}
} else if (data?.lyrics) {
return [data, Array.isArray(data.lyrics)];
}
return [undefined, false];
}, [data, index]);
const languages = useMemo(() => {
if (Array.isArray(data)) {
return data.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() }));
}
return [];
}, [data]);
const isLoadingLyrics = isInitialLoading || isOverrideLoading;
const hasNoLyrics = !data?.lyrics;
const lyricsMetadata:
| Partial<SynchronizedLyricMetadata>
| Partial<UnsynchronizedLyricMetadata>
| undefined = data;
const isSynchronizedLyrics = isSynchronized(lyricsMetadata);
const hasNoLyrics = !lyrics;
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
@@ -198,11 +202,11 @@ export const Lyrics = () => {
initial={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
{isSynchronizedLyrics ? (
<SynchronizedLyrics {...lyricsMetadata} />
{synced ? (
<SynchronizedLyrics {...(lyrics as SynchronizedLyricsProps)} />
) : (
<UnsynchronizedLyrics
{...(lyricsMetadata as UnsynchronizedLyricMetadata)}
{...(lyrics as UnsynchronizedLyricsProps)}
/>
)}
</ScrollContainer>
@@ -211,6 +215,9 @@ export const Lyrics = () => {
)}
<ActionsContainer>
<LyricsActions
index={index}
languages={languages}
setIndex={setIndex}
onRemoveLyric={handleOnRemoveLyric}
onResetLyric={handleOnResetLyric}
onSearchOverride={handleOnSearchOverride}
@@ -6,13 +6,16 @@ import {
InternetProviderLyricResponse,
FullLyricsMetadata,
LyricGetQuery,
StructuredLyric,
ServerType,
} from '/@/renderer/api/types';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById, useLyricsSettings } from '/@/renderer/store';
import { queryKeys } from '/@/renderer/api/query-keys';
import { ServerType } from '/@/renderer/types';
import { api } from '/@/renderer/api';
import isElectron from 'is-electron';
import { hasFeature } from '/@/renderer/api/utils';
import { ServerFeature } from '/@/renderer/api/features-types';
const lyricsIpc = isElectron() ? window.electron.lyrics : null;
@@ -80,7 +83,7 @@ export const useServerLyrics = (
export const useSongLyricsBySong = (
args: QueryHookArgs<LyricsQuery>,
song: QueueSong | undefined,
): UseQueryResult<FullLyricsMetadata> => {
): UseQueryResult<FullLyricsMetadata | StructuredLyric[]> => {
const { query } = args;
const { fetch } = useLyricsSettings();
const server = getServerById(song?.serverId);
@@ -89,21 +92,22 @@ export const useSongLyricsBySong = (
cacheTime: Infinity,
enabled: !!song && !!server,
onError: () => {},
queryFn: async ({ signal }) => {
queryFn: async ({ signal }): Promise<FullLyricsMetadata | StructuredLyric[] | null> => {
if (!server) throw new Error('Server not found');
if (!song) return null;
if (song.lyrics) {
return {
artist: song.artists?.[0]?.name,
lyrics: formatLyrics(song.lyrics),
name: song.name,
remote: false,
source: server?.name ?? 'music server',
};
}
if (hasFeature(server, ServerFeature.LYRICS_MULTIPLE_STRUCTURED)) {
const subsonicLyrics = await api.controller
.getStructuredLyrics({
apiClientProps: { server, signal },
query: { songId: song.id },
})
.catch(console.error);
if (server.type === ServerType.JELLYFIN) {
if (subsonicLyrics) {
return subsonicLyrics;
}
} else if (hasFeature(server, ServerFeature.LYRICS_SINGLE_STRUCTURED)) {
const jfLyrics = await api.controller
.getLyrics({
apiClientProps: { server, signal },
@@ -120,6 +124,14 @@ export const useSongLyricsBySong = (
source: server?.name ?? 'music server',
};
}
} else if (song.lyrics) {
return {
artist: song.artists?.[0]?.name,
lyrics: formatLyrics(song.lyrics),
name: song.name,
remote: false,
source: server?.name ?? 'music server',
};
}
if (fetch) {
@@ -3,7 +3,7 @@ import {
useCurrentStatus,
useCurrentTime,
useLyricsSettings,
usePlayerType,
usePlaybackType,
useSeeked,
} from '/@/renderer/store';
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
@@ -21,7 +21,7 @@ const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
gap: ${(props) => props.$gap || 5}px;
width: 100%;
height: 100%;
padding: 10vh 0 6vh;
padding: 10vh 0 50vh;
overflow: scroll;
transform: translateY(-2rem);
@@ -46,7 +46,7 @@ const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
}
`;
interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: SynchronizedLyricsArray;
}
@@ -59,7 +59,7 @@ export const SynchronizedLyrics = ({
}: SynchronizedLyricsProps) => {
const playersRef = PlayersRef;
const status = useCurrentStatus();
const playerType = usePlayerType();
const playbackType = usePlaybackType();
const now = useCurrentTime();
const settings = useLyricsSettings();
@@ -96,7 +96,7 @@ export const SynchronizedLyrics = ({
};
const getCurrentTime = useCallback(async () => {
if (isElectron() && playerType !== PlaybackType.WEB) {
if (isElectron() && playbackType !== PlaybackType.WEB) {
if (mpvPlayer) {
return mpvPlayer.getCurrentTime();
}
@@ -116,7 +116,7 @@ export const SynchronizedLyrics = ({
if (!player) return 0;
return player.currentTime;
}, [playerType, playersRef]);
}, [playbackType, playersRef]);
const setCurrentLyric = useCallback(
(timeInMs: number, epoch?: number, targetIndex?: number) => {
@@ -222,7 +222,7 @@ export const SynchronizedLyrics = ({
}
return () => {};
}, [getCurrentTime, lyrics, playerType, setCurrentLyric, status]);
}, [getCurrentTime, lyrics, playbackType, setCurrentLyric, status]);
useEffect(() => {
// This handler is used to deal with changes to the current delay. If the offset
@@ -4,7 +4,7 @@ import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
import { FullLyricsMetadata } from '/@/renderer/api/types';
import { useLyricsSettings } from '/@/renderer/store';
interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: string;
}
@@ -15,7 +15,7 @@ import {
import { Song } from '/@/renderer/api/types';
import { usePlayerControls, useQueueControls } from '/@/renderer/store';
import { PlaybackType, TableType } from '/@/renderer/types';
import { usePlayerType } from '/@/renderer/store/settings.store';
import { usePlaybackType } from '/@/renderer/store/settings.store';
import { usePlayerStore, useSetCurrentTime } from '../../../store/player.store';
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
@@ -34,7 +34,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
const { pause } = usePlayerControls();
const playerType = usePlayerType();
const playbackType = usePlaybackType();
const setCurrentTime = useSetCurrentTime();
const handleMoveToBottom = () => {
@@ -44,7 +44,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
const playerData = moveToBottomOfQueue(uniqueIds);
if (playerType === PlaybackType.LOCAL) {
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueueNext(playerData);
}
};
@@ -56,7 +56,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
const playerData = moveToTopOfQueue(uniqueIds);
if (playerType === PlaybackType.LOCAL) {
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueueNext(playerData);
}
};
@@ -70,7 +70,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
const playerData = removeFromQueue(uniqueIds);
const isCurrentSongRemoved = currentSong && uniqueIds.includes(currentSong.uniqueId);
if (playerType === PlaybackType.LOCAL) {
if (playbackType === PlaybackType.LOCAL) {
if (isCurrentSongRemoved) {
mpvPlayer!.setQueue(playerData);
} else {
@@ -86,7 +86,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
const handleClearQueue = () => {
const playerData = clearQueue();
if (playerType === PlaybackType.LOCAL) {
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueue(playerData);
mpvPlayer!.pause();
}
@@ -100,7 +100,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
const handleShuffleQueue = () => {
const playerData = shuffleQueue();
if (playerType === PlaybackType.LOCAL) {
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueueNext(playerData);
}
};
@@ -19,7 +19,7 @@ import {
useVolume,
} from '/@/renderer/store';
import {
usePlayerType,
usePlaybackType,
useSettingsStore,
useSettingsStoreActions,
useTableSettings,
@@ -56,10 +56,11 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
const { setAppStore } = useAppStoreActions();
const tableConfig = useTableSettings(type);
const [gridApi, setGridApi] = useState<AgGridReactType | undefined>();
const playerType = usePlayerType();
const playbackType = usePlaybackType();
const { play } = usePlayerControls();
const volume = useVolume();
const isFocused = useAppFocus();
const isFocusedRef = useRef<boolean>(isFocused);
useEffect(() => {
if (tableRef.current) {
@@ -86,10 +87,9 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
status: PlayerStatus.PLAYING,
});
if (playerType === PlaybackType.LOCAL) {
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.volume(volume);
mpvPlayer!.setQueue(playerData);
mpvPlayer!.play();
mpvPlayer!.setQueue(playerData, false);
}
play();
@@ -110,7 +110,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
const playerData = reorderQueue(selectedUniqueIds as string[], e.overNode?.data?.uniqueId);
if (playerType === PlaybackType.LOCAL) {
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.setQueueNext(playerData);
}
@@ -172,7 +172,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
const handleGridSizeChange = () => {
if (tableConfig.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
tableRef?.current?.api?.sizeColumnsToFit();
}
};
@@ -211,7 +211,29 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
}
}
}
}, [currentSong, previousSong, tableConfig.followCurrentSong, status, isFocused]);
}, [currentSong, previousSong, tableConfig.followCurrentSong, status]);
// As a separate rule, update the current row when focus changes. This is
// to prevent queue scrolling when the application loses and then gains focus.
// The body should only fire when focus changes, even though it depends on current song
useEffect(() => {
if (isFocused !== isFocusedRef.current && tableRef?.current) {
const { api, columnApi } = tableRef.current;
if (api == null || columnApi == null) {
return;
}
const currentNode = currentSong?.uniqueId
? api.getRowNode(currentSong.uniqueId)
: undefined;
if (currentNode) {
api.redrawRows({ rowNodes: [currentNode] });
}
isFocusedRef.current = isFocused;
}
}, [currentSong, isFocused]);
const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
@@ -6,7 +6,6 @@ import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { IoIosPause } from 'react-icons/io';
import {
RiMenuAddFill,
RiPlayFill,
RiRepeat2Line,
RiRepeatOneLine,
@@ -17,6 +16,7 @@ import {
RiSpeedFill,
RiStopFill,
} from 'react-icons/ri';
import { BsDice3 } from 'react-icons/bs';
import styled from 'styled-components';
import { Text } from '/@/renderer/components';
import { useCenterControls } from '../hooks/use-center-controls';
@@ -32,7 +32,7 @@ import {
} from '/@/renderer/store';
import {
useHotkeySettings,
usePlayerType,
usePlaybackType,
useSettingsStore,
} from '/@/renderer/store/settings.store';
import { PlayerStatus, PlaybackType, PlayerShuffle, PlayerRepeat } from '/@/renderer/types';
@@ -98,7 +98,8 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
const [isSeeking, setIsSeeking] = useState(false);
const currentSong = useCurrentSong();
const skip = useSettingsStore((state) => state.general.skipButtons);
const playerType = usePlayerType();
const buttonSize = useSettingsStore((state) => state.general.buttonSize);
const playbackType = usePlaybackType();
const player1 = playersRef?.current?.player1;
const player2 = playersRef?.current?.player2;
const status = useCurrentStatus();
@@ -133,7 +134,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
let interval: any;
if (status === PlayerStatus.PLAYING && !isSeeking) {
if (!isElectron() || playerType === PlaybackType.WEB) {
if (!isElectron() || playbackType === PlaybackType.WEB) {
interval = setInterval(() => {
setCurrentTime(currentPlayerRef.getCurrentTime());
}, 1000);
@@ -143,7 +144,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
}
return () => clearInterval(interval);
}, [currentPlayerRef, isSeeking, setCurrentTime, playerType, status]);
}, [currentPlayerRef, isSeeking, setCurrentTime, playbackType, status]);
const [seekValue, setSeekValue] = useState(0);
@@ -171,17 +172,16 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
<ControlsContainer>
<ButtonsContainer>
<PlayerButton
icon={<RiStopFill size={15} />}
icon={<RiStopFill size={buttonSize} />}
tooltip={{
label: t('player.stop', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="tertiary"
onClick={handleStop}
/>
<PlayerButton
$isActive={shuffle !== PlayerShuffle.NONE}
icon={<RiShuffleFill size={15} />}
icon={<RiShuffleFill size={buttonSize} />}
tooltip={{
label:
shuffle === PlayerShuffle.NONE
@@ -190,40 +190,38 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
postProcess: 'sentenceCase',
})
: t('player.shuffle', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="tertiary"
onClick={handleToggleShuffle}
/>
<PlayerButton
icon={<RiSkipBackFill size={15} />}
icon={<RiSkipBackFill size={buttonSize} />}
tooltip={{
label: t('player.previous', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="secondary"
onClick={handlePrevTrack}
/>
{skip?.enabled && (
<PlayerButton
icon={<RiRewindFill size={15} />}
icon={<RiRewindFill size={buttonSize} />}
tooltip={{
label: t('player.skip', {
context: 'back',
postProcess: 'sentenceCase',
}),
openDelay: 500,
}}
variant="secondary"
onClick={() => handleSkipBackward(skip?.skipBackwardSeconds)}
/>
)}
<PlayerButton
disabled={currentSong?.id === undefined}
icon={
status === PlayerStatus.PAUSED ? (
<RiPlayFill size={20} />
<RiPlayFill size={buttonSize} />
) : (
<IoIosPause size={20} />
<IoIosPause size={buttonSize} />
)
}
tooltip={{
@@ -231,30 +229,27 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
status === PlayerStatus.PAUSED
? t('player.play', { postProcess: 'sentenceCase' })
: t('player.pause', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="main"
onClick={handlePlayPause}
/>
{skip?.enabled && (
<PlayerButton
icon={<RiSpeedFill size={15} />}
icon={<RiSpeedFill size={buttonSize} />}
tooltip={{
label: t('player.skip', {
context: 'forward',
postProcess: 'sentenceCase',
}),
openDelay: 500,
}}
variant="secondary"
onClick={() => handleSkipForward(skip?.skipForwardSeconds)}
/>
)}
<PlayerButton
icon={<RiSkipForwardFill size={15} />}
icon={<RiSkipForwardFill size={buttonSize} />}
tooltip={{
label: t('player.next', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="secondary"
onClick={handleNextTrack}
@@ -263,9 +258,9 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
$isActive={repeat !== PlayerRepeat.NONE}
icon={
repeat === PlayerRepeat.ONE ? (
<RiRepeatOneLine size={15} />
<RiRepeatOneLine size={buttonSize} />
) : (
<RiRepeat2Line size={15} />
<RiRepeat2Line size={buttonSize} />
)
}
tooltip={{
@@ -285,17 +280,15 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
postProcess: 'sentenceCase',
})
}`,
openDelay: 500,
}}
variant="tertiary"
onClick={handleToggleRepeat}
/>
<PlayerButton
icon={<RiMenuAddFill size={15} />}
icon={<BsDice3 size={buttonSize} />}
tooltip={{
label: t('player.playRandom', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="tertiary"
onClick={() =>
@@ -1,7 +1,7 @@
import { Flex, Stack, Group, Center } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'framer-motion';
import { useEffect, useRef, useLayoutEffect, useState, useCallback } from 'react';
import { useEffect, useRef, useLayoutEffect, useState, useCallback, Fragment } from 'react';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
@@ -16,6 +16,7 @@ import {
usePlayerData,
usePlayerStore,
} from '/@/renderer/store';
import { useSettingsStore } from '/@/renderer/store/settings.store';
const Image = styled(motion.img)<{ $useAspectRatio: boolean }>`
position: absolute;
@@ -130,8 +131,10 @@ export const FullScreenPlayerImage = () => {
const mainImageRef = useRef<HTMLImageElement | null>(null);
const [mainImageDimensions, setMainImageDimensions] = useState({ idealSize: 1 });
const albumArtRes = useSettingsStore((store) => store.general.albumArtRes);
const { queue } = usePlayerData();
const { opacity, useImageAspectRatio } = useFullScreenPlayerStore();
const { useImageAspectRatio } = useFullScreenPlayerStore();
const currentSong = queue.current;
const { color: background } = useFastAverageColor({
algorithm: 'dominant',
@@ -149,6 +152,7 @@ export const FullScreenPlayerImage = () => {
if (mainImageRef.current) {
setMainImageDimensions({
idealSize:
albumArtRes ||
Math.ceil((mainImageRef.current as HTMLDivElement).offsetHeight / 100) * 100,
});
@@ -158,7 +162,7 @@ export const FullScreenPlayerImage = () => {
topImage: scaleImageUrl(mainImageDimensions.idealSize, queue.current?.imageUrl),
});
}
}, [mainImageDimensions.idealSize, queue, setImageState]);
}, [mainImageDimensions.idealSize, queue, setImageState, albumArtRes]);
useLayoutEffect(() => {
updateImageSize();
@@ -246,14 +250,12 @@ export const FullScreenPlayerImage = () => {
<MetadataContainer
className="full-screen-player-image-metadata"
maw="100%"
opacity={opacity}
spacing="xs"
>
<TextTitle
align="center"
order={1}
overflow="hidden"
pb="0.5rem"
style={{
textShadow: 'var(--fullscreen-player-text-shadow)',
}}
@@ -274,49 +276,53 @@ export const FullScreenPlayerImage = () => {
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: currentSong?.albumId || '',
})}
transform="uppercase"
w="100%"
weight={600}
>
{currentSong?.album}{' '}
</TextTitle>
{currentSong?.artists?.map((artist, index) => (
<TextTitle
key={`fs-artist-${artist.id}`}
align="center"
order={3}
style={{
textShadow: 'var(--fullscreen-player-text-shadow)',
}}
transform="uppercase"
>
{index > 0 && (
<TextTitle
key="fs-artists"
align="center"
order={3}
style={{
textShadow: 'var(--fullscreen-player-text-shadow)',
}}
>
{currentSong?.artists?.map((artist, index) => (
<Fragment key={`fs-artist-${artist.id}`}>
{index > 0 && (
<Text
$secondary
sx={{
display: 'inline-block',
padding: '0 0.5rem',
}}
>
</Text>
)}
<Text
sx={{
display: 'inline-block',
padding: '0 0.5rem',
$link
$secondary
component={Link}
style={{
textShadow: 'var(--fullscreen-player-text-shadow)',
}}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
weight={600}
>
{artist.name}
</Text>
)}
<Text
$link
component={Link}
style={{
textShadow: 'var(--fullscreen-player-text-shadow)',
}}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
transform="uppercase"
weight={600}
>
{artist.name}
</Text>
</TextTitle>
))}
<Group position="center">
</Fragment>
))}
</TextTitle>
<Group
mt="sm"
position="center"
>
{currentSong?.container && (
<Badge size="lg">
{currentSong?.container} {currentSong?.bitRate}
@@ -1,16 +1,17 @@
import { Group, Center } from '@mantine/core';
import { Group } from '@mantine/core';
import { motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { HiOutlineQueueList } from 'react-icons/hi2';
import { RiFileMusicLine, RiFileTextLine, RiInformationFill } from 'react-icons/ri';
import { RiFileMusicLine, RiFileTextLine } from 'react-icons/ri';
import styled from 'styled-components';
import { Button, TextTitle } from '/@/renderer/components';
import { Button } from '/@/renderer/components';
import { PlayQueue } from '/@/renderer/features/now-playing';
import {
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
} from '/@/renderer/store/full-screen-player.store';
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
import { FullScreenSimilarSongs } from '/@/renderer/features/player/components/full-screen-similar-songs';
const QueueContainer = styled.div`
position: relative;
@@ -42,11 +43,11 @@ const HeaderItemWrapper = styled.div`
z-index: 2;
`;
interface TransparendGridContainerProps {
interface TransparentGridContainerProps {
opacity: number;
}
const GridContainer = styled.div<TransparendGridContainerProps>`
const GridContainer = styled.div<TransparentGridContainerProps>`
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: 1fr;
@@ -82,8 +83,6 @@ export const FullScreenPlayerQueue = () => {
},
];
console.log('opacity', opacity);
return (
<GridContainer
className="full-screen-player-queue-container"
@@ -123,17 +122,9 @@ export const FullScreenPlayerQueue = () => {
<PlayQueue type="fullScreen" />
</QueueContainer>
) : activeTab === 'related' ? (
<Center>
<Group>
<RiInformationFill size="2rem" />
<TextTitle
order={3}
weight={700}
>
{t('common.comingSoon', { postProcess: 'upperCase' })}
</TextTitle>
</Group>
</Center>
<QueueContainer>
<FullScreenSimilarSongs />
</QueueContainer>
) : activeTab === 'lyrics' ? (
<Lyrics />
) : null}
@@ -60,7 +60,11 @@ const ResponsiveContainer = styled.div`
}
`;
const BackgroundImageOverlay = styled.div`
interface BackgroundImageOverlayProps {
$blur: number;
}
const BackgroundImageOverlay = styled.div<BackgroundImageOverlayProps>`
position: absolute;
top: 0;
left: 0;
@@ -68,12 +72,21 @@ const BackgroundImageOverlay = styled.div`
width: 100%;
height: 100%;
background: var(--bg-header-overlay);
backdrop-filter: blur(${({ $blur }) => $blur}rem);
`;
const mainBackground = 'var(--main-bg)';
const Controls = () => {
const { t } = useTranslation();
const { dynamicBackground, expanded, opacity, useImageAspectRatio } =
useFullScreenPlayerStore();
const {
dynamicBackground,
dynamicImageBlur,
dynamicIsImage,
expanded,
opacity,
useImageAspectRatio,
} = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const { setSettings } = useSettingsStoreActions();
const lyricConfig = useLyricsSettings();
@@ -141,6 +154,45 @@ const Controls = () => {
/>
</Option.Control>
</Option>
{dynamicBackground && (
<Option>
<Option.Label>
{t('page.fullscreenPlayer.config.dynamicIsImage', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Switch
defaultChecked={dynamicIsImage}
onChange={(e) =>
setStore({
dynamicIsImage: e.target.checked,
})
}
/>
</Option.Control>
</Option>
)}
{dynamicBackground && dynamicIsImage && (
<Option>
<Option.Label>
{t('page.fullscreenPlayer.config.dynamicImageBlur', {
postProcess: 'sentenceCase',
})}
</Option.Label>
<Option.Control>
<Slider
defaultValue={dynamicImageBlur}
label={(e) => `${e} rem`}
max={6}
min={0}
step={0.5}
w="100%"
onChangeEnd={(e) => setStore({ dynamicImageBlur: Number(e) })}
/>
</Option.Control>
</Option>
)}
{dynamicBackground && (
<Option>
<Option.Label>
@@ -153,7 +205,7 @@ const Controls = () => {
defaultValue={opacity}
label={(e) => `${e} %`}
max={100}
min={1}
min={0}
w="100%"
onChangeEnd={(e) => setStore({ opacity: Number(e) })}
/>
@@ -225,7 +277,7 @@ const Controls = () => {
</Option>
<Option>
<Option.Label>
{t('page.fullscreenPlayer.config.lyric', {
{t('page.fullscreenPlayer.config.lyricSize', {
postProcess: 'sentenceCase',
})}
</Option.Label>
@@ -368,9 +420,13 @@ const containerVariants: Variants = {
};
},
open: (custom) => {
const { dynamicBackground, background, windowBarStyle } = custom;
const { background, backgroundImage, dynamicBackground, windowBarStyle } = custom;
return {
background: dynamicBackground ? background : 'var(--main-bg)',
background: dynamicBackground ? backgroundImage : mainBackground,
backgroundColor: dynamicBackground ? background : mainBackground,
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
height:
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? 'calc(100vh - 120px)'
@@ -394,7 +450,7 @@ const containerVariants: Variants = {
};
export const FullScreenPlayer = () => {
const { dynamicBackground } = useFullScreenPlayerStore();
const { dynamicBackground, dynamicImageBlur, dynamicIsImage } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const { windowBarStyle } = useWindowSettings();
@@ -416,17 +472,25 @@ export const FullScreenPlayer = () => {
srcLoaded: true,
});
const imageUrl = currentSong?.imageUrl;
const backgroundImage =
imageUrl && dynamicIsImage
? `url("${imageUrl
.replace(/size=\d+/g, 'size=500')
.replace(currentSong.id, currentSong.albumId)}`
: mainBackground;
return (
<Container
animate="open"
custom={{ background, dynamicBackground, windowBarStyle }}
custom={{ background, backgroundImage, dynamicBackground, windowBarStyle }}
exit="closed"
initial="closed"
transition={{ duration: 2 }}
variants={containerVariants}
>
<Controls />
{dynamicBackground && <BackgroundImageOverlay />}
{dynamicBackground && <BackgroundImageOverlay $blur={dynamicImageBlur} />}
<ResponsiveContainer>
<FullScreenPlayerImage />
<FullScreenPlayerQueue />
@@ -0,0 +1,13 @@
import { SimilarSongsList } from '/@/renderer/features/similar-songs/components/similar-songs-list';
import { useCurrentSong } from '/@/renderer/store';
export const FullScreenSimilarSongs = () => {
const currentSong = useCurrentSong();
return currentSong?.id ? (
<SimilarSongsList
fullScreen
song={currentSong}
/>
) : null;
};
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import isElectron from 'is-electron';
import styled from 'styled-components';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import { usePlaybackType, useSettingsStore } from '/@/renderer/store/settings.store';
import { PlaybackType } from '/@/renderer/types';
import { AudioPlayer } from '/@/renderer/components';
import {
@@ -64,6 +64,7 @@ const remote = isElectron() ? window.electron.remote : null;
export const Playerbar = () => {
const playersRef = PlayersRef;
const settings = useSettingsStore((state) => state.playback);
const playbackType = usePlaybackType();
const volume = useVolume();
const player1 = usePlayer1Data();
const player2 = usePlayer2Data();
@@ -96,7 +97,7 @@ export const Playerbar = () => {
<RightControls />
</RightGridItem>
</PlayerbarControlsGrid>
{settings.type === PlaybackType.WEB && (
{playbackType === PlaybackType.WEB && (
<AudioPlayer
ref={playersRef}
autoNext={autoNextFn}
@@ -28,12 +28,11 @@ import { LibraryItem, QueueSong, ServerType, Song } from '/@/renderer/api/types'
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
import { DropdownMenu, Rating } from '/@/renderer/components';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
import { Slider } from '/@/renderer/components/slider';
const ipc = isElectron() ? window.electron.ipc : null;
const remote = isElectron() ? window.electron.remote : null;
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
export const RightControls = () => {
const { t } = useTranslation();
const isMinWidth = useMediaQuery('(max-width: 480px)');
@@ -110,6 +109,14 @@ export const RightControls = () => {
setSideBar({ rightExpanded: !isQueueExpanded });
};
const formatPlaybackSpeedSliderLabel = (value: number) => {
const bpm = Number(currentSong?.bpm);
if (bpm > 0) {
return `${value} x / ${(bpm * value).toFixed(1)} BPM`;
}
return `${value} x`;
};
const isSongDefined = Boolean(currentSong?.id);
const showRating = isSongDefined && server?.type === ServerType.NAVIDROME;
@@ -210,7 +217,13 @@ export const RightControls = () => {
align="center"
spacing="xs"
>
<DropdownMenu>
<DropdownMenu
withArrow
arrowOffset={12}
offset={0}
position="top-end"
width={425}
>
<DropdownMenu.Target>
<PlayerButton
icon={<>{speed} x</>}
@@ -222,14 +235,30 @@ export const RightControls = () => {
/>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAYBACK_SPEEDS.map((speed) => (
<DropdownMenu.Item
key={`speed-select-${speed}`}
onClick={() => handleSpeed(Number(speed))}
>
{speed}
</DropdownMenu.Item>
))}
<Slider
label={formatPlaybackSpeedSliderLabel}
marks={[
{ label: '0.5', value: 0.5 },
{ label: '0.75', value: 0.75 },
{ label: '1', value: 1 },
{ label: '1.25', value: 1.25 },
{ label: '1.5', value: 1.5 },
]}
max={1.5}
min={0.5}
step={0.01}
styles={{
markLabel: {
paddingTop: '0.5rem',
},
root: {
margin: '1rem 1rem 2rem 1rem',
},
}}
value={speed}
onChange={handleSpeed}
onDoubleClick={() => handleSpeed(1)}
/>
</DropdownMenu.Dropdown>
</DropdownMenu>
<PlayerButton

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