Compare commits

...

60 Commits

Author SHA1 Message Date
jeffvli dde48335cd fix word-break overflow for CJK characters on lyrics 2025-06-30 00:47:13 -07:00
jeffvli 8611f08f54 right-align is-updated dialog buttons 2025-06-30 00:43:59 -07:00
Kendall Garner cd18e683bf yesnofilter null when not provided 2025-06-29 22:34:39 -07:00
Kendall Garner 286441c1b1 Merge branch 'development' of github.com:jeffvli/feishin into development 2025-06-29 22:30:36 -07:00
Kendall Garner 5456c2c2b8 Improve Jellyfin/Navidrome Album/Song filter, Navidrome artist recent release
- Use `compilation=false` for Navidrome recent releases with artist credit
- Add `YesNoSelect` (yes, no, undefined) for `favorite` for Navidrome/Jellyfin `album`/`track`, and Navidrome `compilation`
- Fix folderButton translation
2025-06-29 22:14:06 -07:00
jeffvli 5cd4fc227e update to v0.17.0 2025-06-29 21:36:36 -07:00
Hosted Weblate 737d672918 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (680 of 680 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (680 of 680 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (676 of 676 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
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
2025-06-30 05:35:41 +02:00
Hosted Weblate a6ac4c8f67 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 76.9% (523 of 680 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/
Translation: feishin/Translation
2025-06-30 05:35:41 +02:00
Hosted Weblate c9217827ab Translated using Weblate (Serbian)
Currently translated at 75.8% (516 of 680 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/sr/
Translation: feishin/Translation
2025-06-30 05:35:40 +02:00
Hosted Weblate 0ff8fad071 Translated using Weblate (Finnish)
Currently translated at 100.0% (680 of 680 strings)

Translated using Weblate (Finnish)

Currently translated at 100.0% (680 of 680 strings)

Translated using Weblate (Finnish)

Currently translated at 100.0% (680 of 680 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Co-authored-by: jonoafi <joona@jonottaa.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fi/
Translation: feishin/Translation
2025-06-30 05:35:39 +02:00
Hosted Weblate f3cb15eae2 Translated using Weblate (French)
Currently translated at 100.0% (680 of 680 strings)

Translated using Weblate (French)

Currently translated at 100.0% (680 of 680 strings)

Translated using Weblate (French)

Currently translated at 100.0% (676 of 676 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
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
2025-06-30 05:35:39 +02:00
Hosted Weblate 5b34b287e2 Translated using Weblate (Spanish)
Currently translated at 100.0% (680 of 680 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (680 of 680 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (676 of 676 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/
Translation: feishin/Translation
2025-06-30 05:35:38 +02:00
Hosted Weblate dc461a253f Translated using Weblate (Indonesian)
Currently translated at 96.6% (657 of 680 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/id/
Translation: feishin/Translation
2025-06-30 05:35:37 +02:00
Hosted Weblate 958416af4c Translated using Weblate (Italian)
Currently translated at 75.7% (515 of 680 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/it/
Translation: feishin/Translation
2025-06-30 05:35:37 +02:00
Hosted Weblate 1dd8eec4a5 Translated using Weblate (Polish)
Currently translated at 96.6% (657 of 680 strings)

Translated using Weblate (Polish)

Currently translated at 96.6% (657 of 680 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/
Translation: feishin/Translation
2025-06-30 05:35:36 +02:00
Hosted Weblate b263db5483 Translated using Weblate (Hungarian)
Currently translated at 30.5% (208 of 680 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/hu/
Translation: feishin/Translation
2025-06-30 05:35:35 +02:00
Hosted Weblate 528f60c5f3 Translated using Weblate (Czech)
Currently translated at 100.0% (680 of 680 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (680 of 680 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (676 of 676 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Co-authored-by: Fjuro <git@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/
Translation: feishin/Translation
2025-06-30 05:35:35 +02:00
Hosted Weblate 007b0166ab Translated using Weblate (Japanese)
Currently translated at 75.7% (515 of 680 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/
Translation: feishin/Translation
2025-06-30 05:35:34 +02:00
Hosted Weblate d3fb2374ff Translated using Weblate (Russian)
Currently translated at 92.5% (629 of 680 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ru/
Translation: feishin/Translation
2025-06-30 05:35:34 +02:00
Hosted Weblate 676c091d28 Translated using Weblate (English)
Currently translated at 100.0% (680 of 680 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/en/
Translation: feishin/Translation
2025-06-30 05:35:33 +02:00
Hosted Weblate 58b7572a8b Translated using Weblate (German)
Currently translated at 86.0% (585 of 680 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/
Translation: feishin/Translation
2025-06-30 05:35:32 +02:00
Hosted Weblate fc77c32a0e Translated using Weblate (Tamil)
Currently translated at 96.6% (657 of 680 strings)

Co-authored-by: ENDzZ <godzmichael@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ta/
Translation: feishin/Translation
2025-06-30 05:35:32 +02:00
Kendall Garner b5bdea1845 actually fix subsonic album count 2025-06-29 20:35:06 -07:00
jeffvli 8eb591bd08 properly handle overflow on sidebar items (#971) 2025-06-29 18:56:46 -07:00
jeffvli 88be98f703 cleanup unneeded div wrapper on lyric lines 2025-06-29 18:31:43 -07:00
jeffvli df6b6d514d update netease translation lyric line handling (#979)
- lyric should be appended to the original lyric line with a custom splitter
- the custom splitter is now handled in LyricLine
2025-06-29 18:29:59 -07:00
Lyall b6d902e425 fix: discord presence not clearing after pausing player (#973)
* add show rich presence when paused option
2025-06-28 13:46:12 -07:00
jeffvli d922d8b034 fix sidebar height when using custom window bar 2025-06-28 13:42:33 -07:00
jeffvli f4db8fdb84 fix background color of collapsed sidebar 2025-06-28 13:34:16 -07:00
Lyall 81ca6937bc add preserve pitch option (#972) 2025-06-28 13:18:08 -07:00
Kendall Garner c382e01f64 fix regex in proxy 2025-06-28 07:29:42 -07:00
Kendall Garner fb80b66310 update remote regex 2025-06-28 06:48:04 -07:00
Kendall Garner 63e3b97bca log -> error, remove unnecesary logs 2025-06-26 21:17:59 -07:00
Kendall Garner fb584b35a9 handle Navidrome login loop error 2025-06-26 21:14:20 -07:00
jeffvli bdc372636b update issue templates 2025-06-26 09:52:11 -07:00
jeffvli 2c5671cf38 update to v0.16.0 2025-06-26 01:39:47 -07:00
Hosted Weblate bd12fbecac Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (674 of 674 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
2025-06-26 10:37:11 +02:00
Hosted Weblate c1d88ada91 Translated using Weblate (Finnish)
Currently translated at 100.0% (676 of 676 strings)

Co-authored-by: jonoafi <joona@jonottaa.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fi/
Translation: feishin/Translation
2025-06-26 10:37:11 +02:00
Hosted Weblate d6a3e1d90b Translated using Weblate (French)
Currently translated at 100.0% (674 of 674 strings)

Translated using Weblate (French)

Currently translated at 99.2% (669 of 674 strings)

Translated using Weblate (French)

Currently translated at 99.2% (669 of 674 strings)

Co-authored-by: Dylan MONTIGAUD <dylanmontigaud17@gmail.com>
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
2025-06-26 10:37:11 +02:00
Hosted Weblate 789c7f3d81 Translated using Weblate (Spanish)
Currently translated at 100.0% (674 of 674 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/es/
Translation: feishin/Translation
2025-06-26 10:37:11 +02:00
Hosted Weblate f3c785d0fa Translated using Weblate (German)
Currently translated at 86.7% (585 of 674 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Slincess <kisacikdevran0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/
Translation: feishin/Translation
2025-06-26 10:37:10 +02:00
jeffvli 062c1c2b61 decrease brightness of header overlay on dark 2025-06-26 01:36:54 -07:00
jeffvli eb078d62cd more adjustments to styles on the fullscreen player 2025-06-26 01:36:42 -07:00
jeffvli c429ac9223 move fonts to assets folder 2025-06-26 01:36:16 -07:00
jeffvli bd26967ff2 fix word breaks on lyrics (#969) 2025-06-26 01:11:46 -07:00
jeffvli 620b810191 add option to set local lyric priority 2025-06-25 21:25:29 -07:00
jeffvli 64866c59bd adjust styles on fullscreen player image section
- fix image transition
- fix image aspect ratio
- adjust text sizes and shadow
2025-06-25 20:40:45 -07:00
jeffvli 0afbe4c0a2 improve visibility of fullscreen player buttons 2025-06-25 19:53:49 -07:00
jeffvli 6782cd0dcc re-add presence animation when collapsing sidebar image 2025-06-25 19:48:59 -07:00
jeffvli 8f585a5be9 adjust styles to better support light theme 2025-06-25 19:44:28 -07:00
jeffvli ac0c396712 fix sidebar image height when using Windows or macOS window bar 2025-06-25 09:02:22 -07:00
Kendall Garner b989a66991 only show owned playlists on playlist sidebar 2025-06-25 08:19:22 -07:00
Kendall Garner 2814b623e7 fix player button light theme and tooltip 2025-06-25 08:05:57 -07:00
jeffvli 7d29a692ef remove unused import 2025-06-24 22:34:06 -07:00
jeffvli 3f9eb446f7 update to v0.15.1 2025-06-24 22:27:12 -07:00
jeffvli d8f7b49ab6 increase size of play button icon 2025-06-24 22:22:15 -07:00
jeffvli 35e70a3eff fix synchronized lyric styles not applying 2025-06-24 22:20:26 -07:00
jeffvli ef9c16e940 attempt fix on docker build 2025-06-24 22:16:16 -07:00
Kendall Garner 0b39c35132 make item modal links have heavier font width 2025-06-24 21:39:47 -07:00
Kendall Garner 9f5b4e5410 remove unused length in visualizer 2025-06-24 21:20:41 -07:00
102 changed files with 959 additions and 448 deletions
@@ -0,0 +1,33 @@
name: Feature request
description: Request a feature to be added to Feishin
title: '[Feature]: '
labels: ['enhancement']
body:
- type: checkboxes
id: check-duplicate
attributes:
label: I have already checked through the existing feature requests and found no duplicates
options:
- label: 'Yes'
required: true
- type: dropdown
id: server-specific
attributes:
label: Is this a server-specific feature?
options:
- Not server-specific
- OpenSubsonic
- Jellyfin
- Navidrome
default: 0
validations:
required: true
- type: textarea
id: solution
attributes:
label: What do you want to be added?
placeholder: I would like to see [...]
validations:
required: true
+74
View File
@@ -0,0 +1,74 @@
name: Bug report
description: You're having technical issues.
title: '[Bug]: '
labels: ['bug']
body:
- type: checkboxes
id: check-duplicate
attributes:
label: I have already checked through the existing bug reports and found no duplicates
options:
- label: 'Yes'
required: true
- type: input
id: version
attributes:
label: App Version
description: What version of the app are you running?
placeholder: ex. 1.0.0
validations:
required: true
- type: input
id: server-version
attributes:
label: Music Server and Version
description: What music server are you using?
placeholder: ex. Navidrome v0.55.0, LMS v3.67.0, Jellyfin v10.10.7, etc.
validations:
required: true
- type: dropdown
id: environments
attributes:
label: What local environments are you seeing the problem on?
multiple: true
options:
- Desktop Windows
- Desktop macOS
- Desktop Linux
- Web Firefox
- Web Chrome
- Web Safari
- Web Microsoft Edge
- Other (please specify in the next field)
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Include screenshots and error logs if possible. The browser devtools can be opened using CTRL + SHIFT + I (Windows/Linux) or CMD + SHIFT + I (macOS).
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: How can we reproduce this issue? Are there any specific settings that are enabled that could be the cause?
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code.
render: shell
-63
View File
@@ -1,63 +0,0 @@
name: Bug report
description: You're having technical issues. 🐞
labels: ['bug']
body:
- type: textarea
attributes:
label: Expected Behavior
description: What should have happened?
validations:
required: true
- type: textarea
attributes:
label: Current Behavior
description: What went wrong? Add screenshots to help explain your problem. (Open the browser dev tools in the menu or using CTRL + SHIFT + I)
validations:
required: true
- type: textarea
attributes:
label: Steps to Reproduce
placeholder: |
<!-- Add relevant code and/or a live example -->
<!-- Add stack traces -->
1.
2.
3.
4.
validations:
required: true
- type: textarea
attributes:
label: Possible Solution
description: Suggest a reason for the bug or how to fix it.
validations:
required: false
- type: textarea
attributes:
label: Context
description: How has this issue affected you? What are you trying to accomplish?
validations:
required: false
- type: input
attributes:
label: Application version
placeholder: (e.g. v0.1.0)
validations:
required: true
- type: input
attributes:
label: Operating System and version
placeholder: (e.g. Windows 11 desktop, Webapp in Firefox)
validations:
required: true
- type: input
attributes:
label: Server and Version
placeholder: (e.g. Navidrome v0.48.0)
validations:
required: true
- type: input
attributes:
label: Node Version (if developing locally)
validations:
required: false
+9 -3
View File
@@ -1,5 +1,11 @@
blank_issues_enabled: true blank_issues_enabled: false
contact_links: contact_links:
- name: Question - name: Questions or help
url: https://github.com/jeffvli/feishin/discussions url: https://github.com/jeffvli/feishin/discussions
about: Please ask and answer questions here. about: Ask questions or get help in the discussions section
- name: Discord Community
url: https://discord.gg/FVKpcMDy5f
about: The discord/matrix servers are bridged so you can join whichever you prefer
- name: Matrix Community
url: https://matrix.to/#/#sonixd:matrix.org
about: The discord/matrix servers are bridged so you can join whichever you prefer
@@ -1,20 +0,0 @@
name: Feature request - NOT ACCEPTING NEW FEATURE REQUESTS
description: Feature requests are currently closed. The application is actively being rewritten https://github.com/audioling/audioling.
labels: ['enhancement']
body:
- type: textarea
attributes:
label: What do you want to be added?
validations:
required: true
- type: textarea
attributes:
label: Additional context
validations:
required: false
- type: checkboxes
attributes:
label: Is this a server-specific feature? (e.g. Jellyfin only)
options:
- label: 'Yes'
required: false
+4 -6
View File
@@ -24,11 +24,9 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx - name: Setup Docker buildx
@@ -41,6 +39,6 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: | platforms: |
linux/amd64 linux/amd64
linux/arm/v7 linux/arm/v7
linux/arm64/v8 linux/arm64/v8
+1 -1
View File
@@ -10,7 +10,7 @@
"selector-type-no-unknown": [true, { "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }], "selector-type-no-unknown": [true, { "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }],
"declaration-block-no-shorthand-property-overrides": null, "declaration-block-no-shorthand-property-overrides": null,
"declaration-block-no-redundant-longhand-properties": null, "declaration-block-no-redundant-longhand-properties": null,
"at-rule-no-unknown": [true, { "ignoreAtRules": ["mixin"] }], "at-rule-no-unknown": [true, { "ignoreAtRules": ["mixin", "value"] }],
"function-no-unknown": [true, { "ignoreFunctions": ["darken", "alpha", "lighten"] }], "function-no-unknown": [true, { "ignoreFunctions": ["darken", "alpha", "lighten"] }],
"declaration-property-value-no-unknown": null, "declaration-property-value-no-unknown": null,
"no-descending-specificity": null, "no-descending-specificity": null,
Binary file not shown.
+1 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "feishin", "name": "feishin",
"version": "0.15.0", "version": "0.17.0",
"description": "A modern self-hosted music player.", "description": "A modern self-hosted music player.",
"keywords": [ "keywords": [
"subsonic", "subsonic",
@@ -170,9 +170,7 @@
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"abstract-socket",
"electron", "electron",
"electron-winstaller",
"esbuild" "esbuild"
] ]
}, },
+20 -8
View File
@@ -124,7 +124,7 @@
"hotkey_toggleShuffle": "přepnutí náhodného přehrávání", "hotkey_toggleShuffle": "přepnutí náhodného přehrávání",
"theme": "motiv", "theme": "motiv",
"playbackStyle_description": "nastavení způsobu přehrávání pro přehrávač zvuku", "playbackStyle_description": "nastavení způsobu přehrávání pro přehrávač zvuku",
"discordRichPresence_description": "povolit stav přehrávání v {{discord}} rich presence. Klíče obrázků jsou: {{icon}}, {{playing}}, {{paused}} ", "discordRichPresence_description": "povolit stav přehrávání v {{discord}} rich presence. Klíče obrázků jsou: {{icon}}, {{playing}}, {{paused}}",
"mpvExecutablePath": "cesta ke spustitelnému souboru mpv", "mpvExecutablePath": "cesta ke spustitelnému souboru mpv",
"audioDevice": "zvukové zařízení", "audioDevice": "zvukové zařízení",
"hotkey_rate2": "hodnocení 2 hvězdami", "hotkey_rate2": "hodnocení 2 hvězdami",
@@ -171,7 +171,7 @@
"hotkey_zoomOut": "oddálení", "hotkey_zoomOut": "oddálení",
"hotkey_unfavoriteCurrentSong": "zrušení oblíbení u $t(common.currentSong)", "hotkey_unfavoriteCurrentSong": "zrušení oblíbení u $t(common.currentSong)",
"hotkey_rate0": "vymazání hodnocení", "hotkey_rate0": "vymazání hodnocení",
"discordApplicationId": "aplikační id pro {{discord}}", "discordApplicationId": "id aplikace pro {{discord}}",
"applicationHotkeys_description": "nastavení klávesových zkratek aplikace. přepněte pole pro nastavení jako globální zkratku (pouze na počítači)", "applicationHotkeys_description": "nastavení klávesových zkratek aplikace. přepněte pole pro nastavení jako globální zkratku (pouze na počítači)",
"floatingQueueArea_description": "zobrazit ikonu přejetí myší na pravé straně obrazovky pro zobrazení fronty", "floatingQueueArea_description": "zobrazit ikonu přejetí myší na pravé straně obrazovky pro zobrazení fronty",
"hotkey_volumeMute": "ztlumení", "hotkey_volumeMute": "ztlumení",
@@ -265,7 +265,13 @@
"musicbrainz": "zobrazit odkazy na musicbrainz", "musicbrainz": "zobrazit odkazy na musicbrainz",
"musicbrainz_description": "na stránkách umělců a alb, kde existuje mbid, zobrazit odkazy na musicbrainz", "musicbrainz_description": "na stránkách umělců a alb, kde existuje mbid, zobrazit odkazy na musicbrainz",
"neteaseTranslation": "Povolit překlady NetEase", "neteaseTranslation": "Povolit překlady NetEase",
"neteaseTranslation_description": "Pokud je povoleno, načte a zobrazí přeložené texty ze služby NetEase, pokud jsou dostupné." "neteaseTranslation_description": "Pokud je povoleno, načte a zobrazí přeložené texty ze služby NetEase, pokud jsou dostupné.",
"preferLocalLyrics": "preferovat místní texty",
"preferLocalLyrics_description": "preferovat místní texty před vzdálenými, pokud jsou dostupné",
"discordPausedStatus": "zobrazit rich presence při pozastavení",
"discordPausedStatus_description": "pokud je povoleno, bude při pozastavení přehrávače zobrazen stav",
"preservePitch": "zachovat výšku",
"preservePitch_description": "zachová výšku při úpravě rychlosti přehrávání"
}, },
"action": { "action": {
"editPlaylist": "upravit $t(entity.playlist_one)", "editPlaylist": "upravit $t(entity.playlist_one)",
@@ -373,7 +379,7 @@
"size": "velikost", "size": "velikost",
"biography": "biografie", "biography": "biografie",
"note": "poznámka", "note": "poznámka",
"albumGain": "zisk (gain) alba", "albumGain": "gain alba",
"albumPeak": "vrchol alba", "albumPeak": "vrchol alba",
"close": "zavřít", "close": "zavřít",
"mbid": "ID MusicBrainz", "mbid": "ID MusicBrainz",
@@ -385,14 +391,18 @@
"preview": "náhled", "preview": "náhled",
"translation": "překlad", "translation": "překlad",
"additionalParticipants": "další přispívající", "additionalParticipants": "další přispívající",
"tags": "štítky" "tags": "štítky",
"viewReleaseNotes": "zobrazit seznam změn",
"newVersion": "byla nainstalována nová verze ({{version}})"
}, },
"table": { "table": {
"config": { "config": {
"view": { "view": {
"card": "karta", "card": "karta",
"table": "tabulka", "table": "tabulka",
"poster": "plakát" "poster": "plakát",
"list": "seznam",
"grid": "mřížka"
}, },
"general": { "general": {
"displayType": "typ zobrazení", "displayType": "typ zobrazení",
@@ -544,7 +554,8 @@
"home": "$t(common.home)", "home": "$t(common.home)",
"artists": "$t(entity.artist_other)", "artists": "$t(entity.artist_other)",
"albumArtists": "$t(entity.albumArtist_other)", "albumArtists": "$t(entity.albumArtist_other)",
"shared": "$t(entity.playlist_other) sdíleny" "shared": "$t(entity.playlist_other) sdíleny",
"myLibrary": "moje knihovna"
}, },
"fullscreenPlayer": { "fullscreenPlayer": {
"config": { "config": {
@@ -720,7 +731,8 @@
}, },
"queryEditor": { "queryEditor": {
"input_optionMatchAll": "shoda všeho", "input_optionMatchAll": "shoda všeho",
"input_optionMatchAny": "shoda libovolného" "input_optionMatchAny": "shoda libovolného",
"title": "editor dotazů"
}, },
"lyricSearch": { "lyricSearch": {
"input_name": "$t(common.name)", "input_name": "$t(common.name)",
+21 -6
View File
@@ -113,7 +113,9 @@
"trackPeak": "Track-Spitzenpegel", "trackPeak": "Track-Spitzenpegel",
"codec": "Codec", "codec": "Codec",
"albumPeak": "Album-Spitzenpegel", "albumPeak": "Album-Spitzenpegel",
"albumGain": "Album-Pegelverstärkung" "albumGain": "Album-Pegelverstärkung",
"tags": "tags",
"viewReleaseNotes": "Release Notes anzeigen"
}, },
"error": { "error": {
"remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden", "remotePortWarning": "Starten Sie den Server neu, um den neuen Port anzuwenden",
@@ -237,7 +239,8 @@
"description": "Beschreibung", "description": "Beschreibung",
"setExpiration": "Ablaufdatum setzen", "setExpiration": "Ablaufdatum setzen",
"expireInvalid": "Ablaufdatum muss in der Zukunft liegen", "expireInvalid": "Ablaufdatum muss in der Zukunft liegen",
"allowDownloading": "Herunterladen zulassen" "allowDownloading": "Herunterladen zulassen",
"success": "Link in die Zwischenablage kopiert (oder hier klicken um zu öffnen)"
} }
}, },
"entity": { "entity": {
@@ -429,7 +432,8 @@
"home": "$t(common.home)", "home": "$t(common.home)",
"artists": "$t(entity.artist_other)", "artists": "$t(entity.artist_other)",
"albumArtists": "$t(entity.albumArtist_other)", "albumArtists": "$t(entity.albumArtist_other)",
"shared": "$t(entity.playlist_other) geteilt" "shared": "$t(entity.playlist_other) geteilt",
"myLibrary": "meine bibliothek"
}, },
"setting": { "setting": {
"playbackTab": "Wiedergabe", "playbackTab": "Wiedergabe",
@@ -516,7 +520,7 @@
"playSimilarSongs": "Ähnliche Lieder abspielen" "playSimilarSongs": "Ähnliche Lieder abspielen"
}, },
"setting": { "setting": {
"audioDevice_description": "Wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer).", "audioDevice_description": "Wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer)",
"audioExclusiveMode": "Audio-Exklusivmodus", "audioExclusiveMode": "Audio-Exklusivmodus",
"audioDevice": "Audiogerät", "audioDevice": "Audiogerät",
"accentColor": "Akzentfarbe", "accentColor": "Akzentfarbe",
@@ -670,12 +674,23 @@
"windowBarStyle_description": "Wähle den Stil der Windows-Leiste", "windowBarStyle_description": "Wähle den Stil der Windows-Leiste",
"hotkey_toggleCurrentSongFavorite": "$t(common.currentSong) zu Favoriten hinzufügen", "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", "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}} ", "discordRichPresence_description": "Zeige deinen Wiedergabe-Status in {{discord}} als rich presence an. Angezeigte Bilder sind: {{icon}}, {{playing}}, und {{paused}}",
"clearCache": "Browser-Zwischenspeicher löschen", "clearCache": "Browser-Zwischenspeicher löschen",
"clearQueryCache": "feishins Zwischenspeicher leeren", "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", "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", "sidePlayQueueStyle": "Wiedergabelistenstil in der Seitenleiste",
"zoom_description": "Setzt den Zoom (in %) für das Programm", "zoom_description": "Setzt den Zoom (in %) für das Programm",
"zoom": "Zoom" "zoom": "Zoom",
"albumBackground": "Album Hintergrund",
"customCss": "Benutzerdefiniert css",
"homeConfiguration": "Startseite Konfiguration",
"lastfmApiKey": "{{lastfm}} API-Schlüssel",
"lastfmApiKey_description": "Der API-Schlüssel für {{lastfm}}. wird für benötigt",
"discordListening": "Status als hört zu anzeigen",
"discordListening_description": "Status als hört zu statt als spielt anzeigen",
"lastfm": "zeige last.fm links",
"lastfm_description": "zeige links zu last.fm auf dem Künstler/Album-Seiten",
"musicbrainz": "Zeig musicbrainz links",
"customCssEnable": "aktiviere Benutzerdefinierte css"
} }
} }
+7 -1
View File
@@ -514,12 +514,14 @@
"disableLibraryUpdateOnStartup": "disable checking for new versions on startup", "disableLibraryUpdateOnStartup": "disable checking for new versions on startup",
"discordApplicationId": "{{discord}} application id", "discordApplicationId": "{{discord}} application id",
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})", "discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})",
"discordPausedStatus": "show rich presence when paused",
"discordPausedStatus_description": "when enabled, status will show when player is paused",
"discordIdleStatus": "show rich presence idle status", "discordIdleStatus": "show rich presence idle status",
"discordIdleStatus_description": "when enabled, update status while player is idle", "discordIdleStatus_description": "when enabled, update status while player is idle",
"discordListening": "show status as listening", "discordListening": "show status as listening",
"discordListening_description": "show status as listening instead of playing", "discordListening_description": "show status as listening instead of playing",
"discordRichPresence": "{{discord}} rich presence", "discordRichPresence": "{{discord}} rich presence",
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}} ", "discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}}",
"discordServeImage": "serve {{discord}} images from server", "discordServeImage": "serve {{discord}} images from server",
"discordServeImage_description": "share cover art for {{discord}} rich presence from server itself, only available for jellyfin and navidrome", "discordServeImage_description": "share cover art for {{discord}} rich presence from server itself, only available for jellyfin and navidrome",
"discordUpdateInterval": "{{discord}} rich presence update interval", "discordUpdateInterval": "{{discord}} rich presence update interval",
@@ -536,6 +538,8 @@
"floatingQueueArea_description": "display a hover icon on the right side of the screen to view the play queue", "floatingQueueArea_description": "display a hover icon on the right side of the screen to view the play queue",
"followLyric": "follow current lyric", "followLyric": "follow current lyric",
"followLyric_description": "scroll the lyric to the current playing position", "followLyric_description": "scroll the lyric to the current playing position",
"preferLocalLyrics": "prefer local lyrics",
"preferLocalLyrics_description": "prefer local lyrics over remote lyrics when available",
"font": "font", "font": "font",
"font_description": "sets the font to use for the application", "font_description": "sets the font to use for the application",
"fontType": "font type", "fontType": "font type",
@@ -703,6 +707,8 @@
"volumeWidth_description": "the width of the volume slider", "volumeWidth_description": "the width of the volume slider",
"webAudio": "use web audio", "webAudio": "use web audio",
"webAudio_description": "use web audio. this enables advanced features like replaygain. disable if you experience otherwise", "webAudio_description": "use web audio. this enables advanced features like replaygain. disable if you experience otherwise",
"preservePitch": "preserve pitch",
"preservePitch_description": "preserves pitch when modifying playback speed",
"windowBarStyle": "window bar style", "windowBarStyle": "window bar style",
"windowBarStyle_description": "select the style of the window bar", "windowBarStyle_description": "select the style of the window bar",
"zoom": "zoom percentage", "zoom": "zoom percentage",
+18 -6
View File
@@ -120,7 +120,7 @@
"hotkey_toggleShuffle": "alterna aleatorio", "hotkey_toggleShuffle": "alterna aleatorio",
"theme": "tema", "theme": "tema",
"playbackStyle_description": "selecciona el estilo de reproducción a usar por el reproductor de audio", "playbackStyle_description": "selecciona el estilo de reproducción a usar por el reproductor de audio",
"discordRichPresence_description": "activa el estado de reproducción en el estado de actividad de {{discord}}. Las teclas de imagen son: {{icon}}, {{playing}}, y {{paused}} ", "discordRichPresence_description": "activa el estado de reproducción en el estado de actividad de {{discord}}. Las teclas de imagen son: {{icon}}, {{playing}}, y {{paused}}",
"mpvExecutablePath": "ruta del ejecutable mpv", "mpvExecutablePath": "ruta del ejecutable mpv",
"audioDevice": "dispositivo de audio", "audioDevice": "dispositivo de audio",
"hotkey_rate2": "calificar con 2 estrellas", "hotkey_rate2": "calificar con 2 estrellas",
@@ -265,7 +265,13 @@
"musicbrainz": "Mostrar enlaces de MusicBrainz", "musicbrainz": "Mostrar enlaces de MusicBrainz",
"musicbrainz_description": "Muestra enlaces a MusicBrainz en las páginas de artistas/álbumes, donde exista mbid", "musicbrainz_description": "Muestra enlaces a MusicBrainz en las páginas de artistas/álbumes, donde exista mbid",
"neteaseTranslation": "Activar traducciones de NetEase", "neteaseTranslation": "Activar traducciones de NetEase",
"neteaseTranslation_description": "Cuando se habilita, busca y muestra letras traducidas desde NetEase si está disponible." "neteaseTranslation_description": "Cuando se habilita, busca y muestra letras traducidas desde NetEase si está disponible.",
"preferLocalLyrics_description": "Prefiere letras locales sobre letras remotas cuando esté disponible",
"preferLocalLyrics": "Preferir letras locales",
"discordPausedStatus": "Mostrar estado de actividad cuando esté en pausa",
"discordPausedStatus_description": "Cuando está activado, el estado mostrará cuando el reproductor esté en pausa",
"preservePitch": "Mantener el tono",
"preservePitch_description": "Mantiene el tono cuando se modifica la velocidad de reproducción"
}, },
"action": { "action": {
"editPlaylist": "editar $t(entity.playlist_one)", "editPlaylist": "editar $t(entity.playlist_one)",
@@ -385,7 +391,9 @@
"preview": "Vista previa", "preview": "Vista previa",
"translation": "traducción", "translation": "traducción",
"additionalParticipants": "Participantes adicionales", "additionalParticipants": "Participantes adicionales",
"tags": "Etiquetas" "tags": "Etiquetas",
"newVersion": "Una nueva versión ha sido instalada ({{version}})",
"viewReleaseNotes": "Ver notas de lanzamiento"
}, },
"error": { "error": {
"remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto", "remotePortWarning": "reiniciar el servidor para aplicar el nuevo puerto",
@@ -469,7 +477,8 @@
"home": "$t(common.home)", "home": "$t(common.home)",
"artists": "$t(entity.artist_other)", "artists": "$t(entity.artist_other)",
"albumArtists": "$t(entity.albumArtist_other)", "albumArtists": "$t(entity.albumArtist_other)",
"shared": "compartido $t(entity.playlist_other)" "shared": "compartido $t(entity.playlist_other)",
"myLibrary": "Mi biblioteca"
}, },
"appMenu": { "appMenu": {
"selectServer": "seleccionar servidor", "selectServer": "seleccionar servidor",
@@ -655,7 +664,8 @@
}, },
"queryEditor": { "queryEditor": {
"input_optionMatchAll": "coincidir todos", "input_optionMatchAll": "coincidir todos",
"input_optionMatchAny": "coincidir cualquiera" "input_optionMatchAny": "coincidir cualquiera",
"title": "Editor de consultas"
}, },
"shareItem": { "shareItem": {
"createFailed": "No se pudo crear el recurso compartido (¿está habilitado el uso compartido?)", "createFailed": "No se pudo crear el recurso compartido (¿está habilitado el uso compartido?)",
@@ -737,7 +747,9 @@
"view": { "view": {
"card": "tarjeta", "card": "tarjeta",
"table": "tabla", "table": "tabla",
"poster": "cartel" "poster": "cartel",
"list": "Lista",
"grid": "Cuadrícula"
} }
} }
}, },
+18 -6
View File
@@ -90,7 +90,9 @@
"trackGain": "raidan vahvistus (gain)", "trackGain": "raidan vahvistus (gain)",
"trackPeak": "kappaleen huippu (peak)", "trackPeak": "kappaleen huippu (peak)",
"additionalParticipants": "muut osallistujat", "additionalParticipants": "muut osallistujat",
"tags": "tägit" "tags": "tägit",
"newVersion": "uusi versio on asennettu ({{version}})",
"viewReleaseNotes": "katsele julkaisutietoja"
}, },
"entity": { "entity": {
"album_one": "albumi", "album_one": "albumi",
@@ -279,7 +281,8 @@
}, },
"queryEditor": { "queryEditor": {
"input_optionMatchAny": "sovita joku", "input_optionMatchAny": "sovita joku",
"input_optionMatchAll": "sovita kaikki" "input_optionMatchAll": "sovita kaikki",
"title": "kyselyeditori"
} }
}, },
"setting": { "setting": {
@@ -359,7 +362,7 @@
"doubleClickBehavior": "lisää kaikki haetut kappaleet soittojonoon tuplaklikkauksella", "doubleClickBehavior": "lisää kaikki haetut kappaleet soittojonoon tuplaklikkauksella",
"discordUpdateInterval_description": "päivitysväli sekunnteina (vähintään 15 sekunttia)", "discordUpdateInterval_description": "päivitysväli sekunnteina (vähintään 15 sekunttia)",
"discordRichPresence": "{{discord}} rich presence", "discordRichPresence": "{{discord}} rich presence",
"discordRichPresence_description": "ota toiston tila käyttöön {{discord}}n rich presence-toiminnossa. Kuvakkeiden avaimet ovat {{icon}}, {{playing}} ja {{paused}}. ", "discordRichPresence_description": "ota toiston tila käyttöön {{discord}}n rich presence-toiminnossa. Kuvakkeiden avaimet ovat {{icon}}, {{playing}} ja {{paused}}",
"discordUpdateInterval": "{{discord}} rich presencen päivitysväli", "discordUpdateInterval": "{{discord}} rich presencen päivitysväli",
"enableRemote": "aktivoi etäohjauspalvelin", "enableRemote": "aktivoi etäohjauspalvelin",
"externalLinks_description": "ottaa ulkoiset linkit (Last.fm, MusicBrainz) artistien/albumien sivuilla", "externalLinks_description": "ottaa ulkoiset linkit (Last.fm, MusicBrainz) artistien/albumien sivuilla",
@@ -515,7 +518,13 @@
"lastfm_description": "näytä linkit last.fm sivulle artistin/albumin sivuilla", "lastfm_description": "näytä linkit last.fm sivulle artistin/albumin sivuilla",
"musicbrainz": "näytä musicbrainz linkit", "musicbrainz": "näytä musicbrainz linkit",
"neteaseTranslation": "Ota NetEasen käännökset käyttöön", "neteaseTranslation": "Ota NetEasen käännökset käyttöön",
"neteaseTranslation_description": "Käytöss ollessa noutaa ja näyttää käännetyt sanat NetEasesta, jos ne ovat saatavilla." "neteaseTranslation_description": "Käytöss ollessa noutaa ja näyttää käännetyt sanat NetEasesta, jos ne ovat saatavilla.",
"preferLocalLyrics_description": "suosi paikallisia sanoituksia ulkoisten sijasta, kun saatavilla",
"preferLocalLyrics": "suosi paikallisia sanoituksia",
"discordPausedStatus": "näytä rich presence tauotettuna",
"discordPausedStatus_description": "ollessak käytössä, status näyttää milloin soitin on tautotettuna",
"preservePitch": "säilytä sävelkorkeus",
"preservePitch_description": "säilytä sävelkorkeus toistonopeutta muokatessa"
}, },
"page": { "page": {
"itemDetail": { "itemDetail": {
@@ -584,7 +593,8 @@
"home": "$t(common.home)", "home": "$t(common.home)",
"nowPlaying": "nyt soi", "nowPlaying": "nyt soi",
"playlists": "$t(entity.playlist_other)", "playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)" "search": "$t(common.search)",
"myLibrary": "oma kirjasto"
}, },
"setting": { "setting": {
"generalTab": "yleinen", "generalTab": "yleinen",
@@ -745,7 +755,9 @@
"view": { "view": {
"table": "taulukko", "table": "taulukko",
"card": "kortti", "card": "kortti",
"poster": "juliste" "poster": "juliste",
"grid": "ruudukko",
"list": "lista"
} }
}, },
"column": { "column": {
+20 -6
View File
@@ -150,7 +150,9 @@
"codec": "codec", "codec": "codec",
"translation": "traduction", "translation": "traduction",
"additionalParticipants": "participants additionnels", "additionalParticipants": "participants additionnels",
"tags": "tags" "tags": "tags",
"newVersion": "une nouvelle version vient d'être installé ({{version}})",
"viewReleaseNotes": "voir la note de version"
}, },
"error": { "error": {
"remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port", "remotePortWarning": "redémarrer le serveur pour appliquer le nouveau port",
@@ -234,7 +236,8 @@
"home": "$t(common.home)", "home": "$t(common.home)",
"artists": "$t(entity.artist_other)", "artists": "$t(entity.artist_other)",
"albumArtists": "$t(entity.albumArtist_other)", "albumArtists": "$t(entity.albumArtist_other)",
"shared": "partagé $t(entity.playlist_other)" "shared": "partagé $t(entity.playlist_other)",
"myLibrary": "ma bibliothèque"
}, },
"fullscreenPlayer": { "fullscreenPlayer": {
"config": { "config": {
@@ -446,7 +449,7 @@
"playbackStyle": "style de lecture", "playbackStyle": "style de lecture",
"hotkey_toggleShuffle": "basculer la lecture aléatoire", "hotkey_toggleShuffle": "basculer la lecture aléatoire",
"playbackStyle_description": "sélectionnez le style de lecture à utiliser pour le lecteur audio", "playbackStyle_description": "sélectionnez le style de lecture à utiliser pour le lecteur audio",
"discordRichPresence_description": "active l'état de lecteur dans le status d'activité {{discord}}. Les images clés sont: {{icon}}, {{playing}}, et {{paused}} ", "discordRichPresence_description": "active l'état de lecteur dans le status d'activité {{discord}}. Les images clés sont : {{icon}}, {{playing}}, et {{paused}}",
"mpvExecutablePath": "chemin de l'exécutable mpv", "mpvExecutablePath": "chemin de l'exécutable mpv",
"hotkey_rate2": "noter 2 étoiles", "hotkey_rate2": "noter 2 étoiles",
"playButtonBehavior_description": "définit le comportement par défaut du bouton play, lors de l'ajout de chanson à la file d'attente", "playButtonBehavior_description": "définit le comportement par défaut du bouton play, lors de l'ajout de chanson à la file d'attente",
@@ -602,7 +605,15 @@
"lastfm": "affiche les liens de last.fm", "lastfm": "affiche les liens de last.fm",
"musicbrainz_description": "affiches les liens vers musicbrainz sur les pages des artistes/albums, quand mbid existes", "musicbrainz_description": "affiches les liens vers musicbrainz sur les pages des artistes/albums, quand mbid existes",
"lastfm_description": "affiche les liens vers last.fm sur les pages des artistes/albums", "lastfm_description": "affiche les liens vers last.fm sur les pages des artistes/albums",
"musicbrainz": "affiches les liens musicbrainz" "musicbrainz": "affiches les liens musicbrainz",
"neteaseTranslation": "Activer les traductions NetEase",
"neteaseTranslation_description": "Lorsque cette option est activée, récupère et affiche les paroles traduites de NetEase si elles sont disponibles.",
"preferLocalLyrics_description": "privilégier les paroles locales aux paroles distantes lorsqu'elles sont disponibles",
"preferLocalLyrics": "privilégier les paroles locales",
"discordPausedStatus_description": "quand activé, le status s'affichera lorsque le lecteur est en pause",
"discordPausedStatus": "afficher le status d'activité en pause",
"preservePitch": "préserver la hauteur",
"preservePitch_description": "préserver la hauteur lors du changement de la vitesse de lecture"
}, },
"form": { "form": {
"deletePlaylist": { "deletePlaylist": {
@@ -643,7 +654,8 @@
}, },
"queryEditor": { "queryEditor": {
"input_optionMatchAll": "correspondre à tous", "input_optionMatchAll": "correspondre à tous",
"input_optionMatchAny": "correspondre à n'importe quel" "input_optionMatchAny": "correspondre à n'importe quel",
"title": "éditeur de requête"
}, },
"editPlaylist": { "editPlaylist": {
"title": "modifier $t(entity.playlist_one)", "title": "modifier $t(entity.playlist_one)",
@@ -733,7 +745,9 @@
"view": { "view": {
"table": "liste", "table": "liste",
"poster": "poster", "poster": "poster",
"card": "Carte" "card": "Carte",
"grid": "grille",
"list": "liste"
}, },
"label": { "label": {
"releaseDate": "date de sortie", "releaseDate": "date de sortie",
+1 -1
View File
@@ -163,7 +163,7 @@
"remotePortWarning": "indítsd újra a szervert az új PORT használatához", "remotePortWarning": "indítsd újra a szervert az új PORT használatához",
"genericError": "hiba történt", "genericError": "hiba történt",
"endpointNotImplementedError": "a(z) {{endpoint}} végpont nincs implementálva a következőhöz: {{serverType}}", "endpointNotImplementedError": "a(z) {{endpoint}} végpont nincs implementálva a következőhöz: {{serverType}}",
"badAlbum": "azért látod ezt az oldalt mert ez a zeneszám nem része egy albumnak. ez általában akkor történik amikor egy szám a zenekönyvtárad gyökerébe kerül. a Jellyfin csak mappákba rendezett számokat csoportosít", "badAlbum": "azért látod ezt az oldalt mert ez a zeneszám nem része egy albumnak. ez általában akkor történik amikor egy szám a zenekönyvtárad gyökerébe kerül. a Jellyfin csak mappákba rendezett számokat csoportosít.",
"loginRateError": "túl sok bejelentkezési kísérlet, kérlek próbáld újra pár másodperc múlva", "loginRateError": "túl sok bejelentkezési kísérlet, kérlek próbáld újra pár másodperc múlva",
"mpvRequired": "MPV szükséges", "mpvRequired": "MPV szükséges",
"invalidServer": "érvénytelen szerver", "invalidServer": "érvénytelen szerver",
+1 -1
View File
@@ -491,7 +491,7 @@
"discordListening": "Tampilkan status sebagai mendengarkan", "discordListening": "Tampilkan status sebagai mendengarkan",
"discordListening_description": "tampilkan status sebagai mendengarkan alih-alih bermain", "discordListening_description": "tampilkan status sebagai mendengarkan alih-alih bermain",
"discordRichPresence": "status aktivitas {{discord}}", "discordRichPresence": "status aktivitas {{discord}}",
"discordRichPresence_description": "aktifkan status pemutaran di status aktivitas {{discord}}. Gambar tombol adalah: {{icon}}, {{playing}}, dan {{paused}} ", "discordRichPresence_description": "aktifkan status pemutaran di status aktivitas {{discord}}. Gambar tombol adalah: {{icon}}, {{playing}}, dan {{paused}}",
"discordUpdateInterval": "interval pembaruan status aktivitas {{discord}}", "discordUpdateInterval": "interval pembaruan status aktivitas {{discord}}",
"discordUpdateInterval_description": "waktu dalam detik antara setiap pembaruan (minimal 15 detik)", "discordUpdateInterval_description": "waktu dalam detik antara setiap pembaruan (minimal 15 detik)",
"doubleClickBehavior": "masukkan semua lagu yang dicari saat mengklik dua kali", "doubleClickBehavior": "masukkan semua lagu yang dicari saat mengklik dua kali",
+1 -1
View File
@@ -209,7 +209,7 @@
"hotkey_toggleShuffle": "attiva/disattiva mescolamento", "hotkey_toggleShuffle": "attiva/disattiva mescolamento",
"theme": "tema", "theme": "tema",
"playbackStyle_description": "selezione lo stile di riproduzione da usare per il player audio", "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}} ", "discordRichPresence_description": "abilita lo status del playback nello stato attività di {{discord}}. Le chiavi immagine sono: {{icon}}, {{playing}} e {{paused}}",
"mpvExecutablePath": "percorso eseguibile mpv", "mpvExecutablePath": "percorso eseguibile mpv",
"audioDevice": "device audio", "audioDevice": "device audio",
"hotkey_rate2": "voto 2 stelle", "hotkey_rate2": "voto 2 stelle",
+1 -1
View File
@@ -122,7 +122,7 @@
"hotkey_toggleShuffle": "シャッフルの切り替え", "hotkey_toggleShuffle": "シャッフルの切り替え",
"theme": "テーマ", "theme": "テーマ",
"playbackStyle_description": "オーディオプレーヤーに使用する再生スタイルを選択します", "playbackStyle_description": "オーディオプレーヤーに使用する再生スタイルを選択します",
"discordRichPresence_description": "{{discord}} のRich Presenceに再生ステータスを表示するようにします。画像キー: {{icon}}, {{playing}}, {{paused}} ", "discordRichPresence_description": "{{discord}} のRich Presenceに再生ステータスを表示するようにします。画像キー: {{icon}}, {{playing}}, {{paused}}",
"mpvExecutablePath": "mpv 実行ファイルパス", "mpvExecutablePath": "mpv 実行ファイルパス",
"audioDevice": "オーディオデバイス", "audioDevice": "オーディオデバイス",
"hotkey_rate2": "2つ星で評価", "hotkey_rate2": "2つ星で評価",
+1 -1
View File
@@ -533,7 +533,7 @@
"crossfadeDuration_description": "ustaw czas trwania efektu przenikania", "crossfadeDuration_description": "ustaw czas trwania efektu przenikania",
"language": "język", "language": "język",
"hotkey_toggleShuffle": "przełącz kolejność losową", "hotkey_toggleShuffle": "przełącz kolejność losową",
"discordRichPresence_description": "włącz status odtwarzania w {{discord}} (rich presence). Dzięki temu będą wyświetlane informacje takie jak: {{icon}}, {{playing}} i {{paused}}. ", "discordRichPresence_description": "włącz status odtwarzania w {{discord}} (rich presence). Dzięki temu będą wyświetlane informacje takie jak: {{icon}}, {{playing}} i {{paused}}",
"audioDevice": "urządzenia dźwiękowe", "audioDevice": "urządzenia dźwiękowe",
"hotkey_rate2": "oceń na 2 gwiazdki", "hotkey_rate2": "oceń na 2 gwiazdki",
"exitToTray": "zamknij do zasobnika", "exitToTray": "zamknij do zasobnika",
+3 -3
View File
@@ -86,7 +86,7 @@
"confirm": "подтвердить", "confirm": "подтвердить",
"resetToDefault": "сбросить настройки", "resetToDefault": "сбросить настройки",
"home": "главная", "home": "главная",
"comingSoon": "скоро...", "comingSoon": "скоро",
"reset": "сбросить", "reset": "сбросить",
"channel_one": "канал", "channel_one": "канал",
"channel_few": "канала", "channel_few": "канала",
@@ -333,7 +333,7 @@
"next": "следующий", "next": "следующий",
"shuffle": "перемешать", "shuffle": "перемешать",
"playbackFetchNoResults": "песни не найдены", "playbackFetchNoResults": "песни не найдены",
"playbackFetchInProgress": "загрузка песен..", "playbackFetchInProgress": "загрузка песен",
"addNext": "воспроизвести следующим", "addNext": "воспроизвести следующим",
"playbackSpeed": "скорость воспроизведения", "playbackSpeed": "скорость воспроизведения",
"playbackFetchCancel": "пожалуйста, подождите немного... закройте уведомление для отмены", "playbackFetchCancel": "пожалуйста, подождите немного... закройте уведомление для отмены",
@@ -759,7 +759,7 @@
"artistConfiguration": "конфигурация страницы альбомов исполнителей", "artistConfiguration": "конфигурация страницы альбомов исполнителей",
"artistConfiguration_description": "позволяет настроить видимость и порядок элементов на странице альбомов исполнителей", "artistConfiguration_description": "позволяет настроить видимость и порядок элементов на странице альбомов исполнителей",
"fontType_description": "встроенный позволяет выбрать один из шрифтов, предоставляемых Feishin. системный позволяет выбрать любой шрифт, предоставляемый вашей операционной системой. пользовательский позволяет выбрать свой собственный шрифт", "fontType_description": "встроенный позволяет выбрать один из шрифтов, предоставляемых Feishin. системный позволяет выбрать любой шрифт, предоставляемый вашей операционной системой. пользовательский позволяет выбрать свой собственный шрифт",
"discordRichPresence_description": "включить статус воспроизведения в статус профиля в {{discord}}. Ключи изображений: {{icon}}, {{playing}} и {{paused}} ", "discordRichPresence_description": "включить статус воспроизведения в статус профиля в {{discord}}. Ключи изображений: {{icon}}, {{playing}} и {{paused}}",
"lyricOffset": "синхронизация текста треков (мс)" "lyricOffset": "синхронизация текста треков (мс)"
} }
} }
+3 -3
View File
@@ -112,7 +112,7 @@
"hotkey_localSearch": "pretraživanje na stranici", "hotkey_localSearch": "pretraživanje na stranici",
"hotkey_toggleQueue": "promeni listu za reprodukciju", "hotkey_toggleQueue": "promeni listu za reprodukciju",
"zoom_description": "postavlja stepen zumiranja za aplikaciju", "zoom_description": "postavlja stepen zumiranja za aplikaciju",
"remotePassword_description": "postavlja lozinku za daljinsku kontrolu servera. Ove informacije se prenose nezaštićeno, pa biste trebali koristiti jedinstvenu lozinku koja vam nije važna.", "remotePassword_description": "postavlja lozinku za daljinsku kontrolu servera. Ove informacije se prenose nezaštićeno, pa biste trebali koristiti jedinstvenu lozinku koja vam nije važna",
"hotkey_rate5": "oceni sa 5 zvezdica", "hotkey_rate5": "oceni sa 5 zvezdica",
"hotkey_playbackPrevious": "prethodna pesma", "hotkey_playbackPrevious": "prethodna pesma",
"showSkipButtons_description": "prikaži ili sakrij dugmad za preskakanje na traci za reprodukciju", "showSkipButtons_description": "prikaži ili sakrij dugmad za preskakanje na traci za reprodukciju",
@@ -122,7 +122,7 @@
"hotkey_toggleShuffle": "promeni slučajan redosled", "hotkey_toggleShuffle": "promeni slučajan redosled",
"theme": "tema", "theme": "tema",
"playbackStyle_description": "izaberite stil reprodukcije za audio plejer", "playbackStyle_description": "izaberite stil reprodukcije za audio plejer",
"discordRichPresence_description": "omogućava status reprodukcije u {{discord}} bogatom prikazu. Ključevi slika su: {{icon}}, {{playing}}, i {{paused}} ", "discordRichPresence_description": "omogućava status reprodukcije u {{discord}} bogatom prikazu. Ključevi slika su: {{icon}}, {{playing}}, i {{paused}}",
"mpvExecutablePath": "putanja do mpv izvršne datoteke", "mpvExecutablePath": "putanja do mpv izvršne datoteke",
"audioDevice": "audio uređaj", "audioDevice": "audio uređaj",
"hotkey_rate2": "oceni sa 2 zvezdice", "hotkey_rate2": "oceni sa 2 zvezdice",
@@ -158,7 +158,7 @@
"useSystemTheme_description": "prati sistemski određene postavke za svetlu ili tamnu temu", "useSystemTheme_description": "prati sistemski određene postavke za svetlu ili tamnu temu",
"playButtonBehavior_optionAddNext": "$t(player.addNext)", "playButtonBehavior_optionAddNext": "$t(player.addNext)",
"lyricFetch_description": "preuzimanje tekstova sa različitih izvora na internetu", "lyricFetch_description": "preuzimanje tekstova sa različitih izvora na internetu",
"lyricFetchProvider_description": "izaberite pružatelje tekstova za preuzimanje. Redosled pružatelja je redosled upita.", "lyricFetchProvider_description": "izaberite pružatelje tekstova za preuzimanje. Redosled pružatelja je redosled upita",
"globalMediaHotkeys_description": "omogućava ili onemogućava korišćenje medijskih tastera sistema za kontrolu reprodukcije", "globalMediaHotkeys_description": "omogućava ili onemogućava korišćenje medijskih tastera sistema za kontrolu reprodukcije",
"customFontPath": "prilagođena putanja fonta", "customFontPath": "prilagođena putanja fonta",
"followLyric": "prati trenutni tekst pesme", "followLyric": "prati trenutni tekst pesme",
+5 -5
View File
@@ -224,7 +224,7 @@
"input_password": "கடவுச்சொல்", "input_password": "கடவுச்சொல்",
"error_savePassword": "கடவுச்சொல்லை சேமிக்க முயற்சிக்கும்போது பிழை ஏற்பட்டது", "error_savePassword": "கடவுச்சொல்லை சேமிக்க முயற்சிக்கும்போது பிழை ஏற்பட்டது",
"ignoreCors": "CORS ஐ புறக்கணிக்கவும் ($ t (Common.RestartRequired))", "ignoreCors": "CORS ஐ புறக்கணிக்கவும் ($ t (Common.RestartRequired))",
"ignoreSsl": "SSL ஐ புறக்கணிக்கவும் ($ t (பொதுவானது.", "ignoreSsl": "SSL ஐ புறக்கணிக்கவும் ($ t (பொதுவானது",
"input_legacyAuthentication": "மரபு அங்கீகாரத்தை இயக்கவும்", "input_legacyAuthentication": "மரபு அங்கீகாரத்தை இயக்கவும்",
"input_name": "சேவையக பெயர்", "input_name": "சேவையக பெயர்",
"input_savePassword": "கடவுச்சொல்லைச் சேமிக்கவும்", "input_savePassword": "கடவுச்சொல்லைச் சேமிக்கவும்",
@@ -521,7 +521,7 @@
"hotkey_volumeMute": "தொகுதி முடக்கு", "hotkey_volumeMute": "தொகுதி முடக்கு",
"hotkey_volumeUp": "தொகுதி", "hotkey_volumeUp": "தொகுதி",
"language": "மொழி", "language": "மொழி",
"language_description": "பயன்பாட்டிற்கான மொழியை அமைக்கிறது ($ t (பொதுவானது.", "language_description": "பயன்பாட்டிற்கான மொழியை அமைக்கிறது ($ t (பொதுவானது",
"lastfmApiKey": "{{lastfm}} பநிஇ key", "lastfmApiKey": "{{lastfm}} பநிஇ key",
"lastfmApiKey_description": "{{lastfm} க்கு க்கான பநிஇ விசை. கவர் கலைக்கு தேவை", "lastfmApiKey_description": "{{lastfm} க்கு க்கான பநிஇ விசை. கவர் கலைக்கு தேவை",
"lyricFetch": "இணையத்திலிருந்து வரிகளை பெறுங்கள்", "lyricFetch": "இணையத்திலிருந்து வரிகளை பெறுங்கள்",
@@ -615,7 +615,7 @@
"discordIdleStatus_description": "இயக்கப்பட்டால், பிளேயர் சும்மா இருக்கும்போது நிலையைப் புதுப்பிக்கவும்", "discordIdleStatus_description": "இயக்கப்பட்டால், பிளேயர் சும்மா இருக்கும்போது நிலையைப் புதுப்பிக்கவும்",
"discordListening_description": "விளையாடுவதற்குப் பதிலாக கேட்பது என்று அந்த நிலையைக் காட்டுங்கள்", "discordListening_description": "விளையாடுவதற்குப் பதிலாக கேட்பது என்று அந்த நிலையைக் காட்டுங்கள்",
"discordRichPresence": "{{discord}} பணக்கார இருப்பு", "discordRichPresence": "{{discord}} பணக்கார இருப்பு",
"discordRichPresence_description": "{{discord}} பணக்கார இருப்பில் பின்னணி நிலையை இயக்கவும். பட விசைகள்: {{icon}}, {{playing}}, மற்றும் {{paused}} ", "discordRichPresence_description": "{{discord}} பணக்கார இருப்பில் பின்னணி நிலையை இயக்கவும். பட விசைகள்: {{icon}}, {{playing}}, மற்றும் {{paused}}",
"customCss_description": "தனிப்பயன் சிஎச்எச் உள்ளடக்கம். குறிப்பு: உள்ளடக்கம் மற்றும் தொலைநிலை முகவரி கள் அனுமதிக்கப்படாத பண்புகள். உங்கள் உள்ளடக்கத்தின் முன்னோட்டம் கீழே காட்டப்பட்டுள்ளது. நீங்கள் அமைக்காத கூடுதல் புலங்கள் சுத்திகரிப்பு காரணமாக உள்ளன.", "customCss_description": "தனிப்பயன் சிஎச்எச் உள்ளடக்கம். குறிப்பு: உள்ளடக்கம் மற்றும் தொலைநிலை முகவரி கள் அனுமதிக்கப்படாத பண்புகள். உங்கள் உள்ளடக்கத்தின் முன்னோட்டம் கீழே காட்டப்பட்டுள்ளது. நீங்கள் அமைக்காத கூடுதல் புலங்கள் சுத்திகரிப்பு காரணமாக உள்ளன.",
"doubleClickBehavior": "இரட்டை சொடுக்கு செய்யும் போது தேடப்பட்ட அனைத்து தடங்களையும் வரிசைப்படுத்தவும்", "doubleClickBehavior": "இரட்டை சொடுக்கு செய்யும் போது தேடப்பட்ட அனைத்து தடங்களையும் வரிசைப்படுத்தவும்",
"doubleClickBehavior_description": "உண்மை என்றால், தட தேடலில் பொருந்தக்கூடிய அனைத்து தடங்களும் வரிசையில் நிற்கப்படும். இல்லையெனில், சொடுக்கு செய்யப்பட்ட ஒன்று மட்டுமே வரிசையில் நிற்கப்படும்", "doubleClickBehavior_description": "உண்மை என்றால், தட தேடலில் பொருந்தக்கூடிய அனைத்து தடங்களும் வரிசையில் நிற்கப்படும். இல்லையெனில், சொடுக்கு செய்யப்பட்ட ஒன்று மட்டுமே வரிசையில் நிற்கப்படும்",
@@ -705,14 +705,14 @@
"rowIndex": "வரிசை அட்டவணை", "rowIndex": "வரிசை அட்டவணை",
"size": "$ t (common.size)", "size": "$ t (common.size)",
"trackNumber": "ட்ராக் எண்", "trackNumber": "ட்ராக் எண்",
"year": "$ t (பொதுவானது.", "year": "$ t (பொதுவானது",
"lastPlayed": "கடைசியாக விளையாடியது", "lastPlayed": "கடைசியாக விளையாடியது",
"note": "$ t (பொதுவானது. குறிப்பு)", "note": "$ t (பொதுவானது. குறிப்பு)",
"owner": "$ t (பொதுவானவர்)", "owner": "$ t (பொதுவானவர்)",
"actions": "$ t (common.action_other)", "actions": "$ t (common.action_other)",
"albumArtist": "$ t (entity.albumartist_one)", "albumArtist": "$ t (entity.albumartist_one)",
"discNumber": "வட்டு எண்", "discNumber": "வட்டு எண்",
"duration": "$ t (பொதுவானது.", "duration": "$ t (பொதுவானது",
"favorite": "$ t (common.foavorite)", "favorite": "$ t (common.foavorite)",
"genre": "$ t (entity.genre_one)", "genre": "$ t (entity.genre_one)",
"path": "$ t (common.path)", "path": "$ t (common.path)",
+18 -6
View File
@@ -111,7 +111,9 @@
"preview": "预览", "preview": "预览",
"translation": "翻译", "translation": "翻译",
"additionalParticipants": "其他参与者", "additionalParticipants": "其他参与者",
"tags": "标签" "tags": "标签",
"viewReleaseNotes": "查看发行说明",
"newVersion": "已安装新版本 ({{version}})"
}, },
"entity": { "entity": {
"albumArtist_other": "专辑艺术家", "albumArtist_other": "专辑艺术家",
@@ -323,7 +325,7 @@
"discordUpdateInterval": "{{discord}} rich presence 更新间隔", "discordUpdateInterval": "{{discord}} rich presence 更新间隔",
"discordApplicationId_description": "{{discord}} rich presence 应用 id(默认为 {{defaultId}}", "discordApplicationId_description": "{{discord}} rich presence 应用 id(默认为 {{defaultId}}",
"discordUpdateInterval_description": "更新间隔秒数(至少 15 秒)", "discordUpdateInterval_description": "更新间隔秒数(至少 15 秒)",
"discordRichPresence_description": "在 {{discord}} rich presence 中显示播放状态。图片键为:{{icon}}、{{playing}} 和 {{paused}} ", "discordRichPresence_description": "在 {{discord}} rich presence 中显示播放状态。图片键为:{{icon}}、{{playing}} 和 {{paused}}",
"accentColor": "强调色", "accentColor": "强调色",
"accentColor_description": "设置应用的强调色", "accentColor_description": "设置应用的强调色",
"replayGainPreamp_description": "调整应用在{{ReplayGain}}值上的前置放大增益", "replayGainPreamp_description": "调整应用在{{ReplayGain}}值上的前置放大增益",
@@ -399,7 +401,13 @@
"musicbrainz": "显示 musicbrainz 链接", "musicbrainz": "显示 musicbrainz 链接",
"musicbrainz_description": "在 mbid 的艺术家/专辑页面上显示 musicbrainz 的链接", "musicbrainz_description": "在 mbid 的艺术家/专辑页面上显示 musicbrainz 的链接",
"lastfm": "显示 last.fm 链接", "lastfm": "显示 last.fm 链接",
"lastfm_description": "在艺术家/专辑页面上显示 last.fm 的链接" "lastfm_description": "在艺术家/专辑页面上显示 last.fm 的链接",
"preferLocalLyrics_description": "优先选择本地歌词(如有),而不是远程歌词",
"preferLocalLyrics": "首选本地歌词",
"discordPausedStatus": "暂停时显示rich presence",
"discordPausedStatus_description": "启用后将在播放器暂停时显示状态",
"preservePitch": "保持音高",
"preservePitch_description": "在调整播放速度时保持音高"
}, },
"error": { "error": {
"remotePortWarning": "重启服务器使新端口生效", "remotePortWarning": "重启服务器使新端口生效",
@@ -483,7 +491,8 @@
"home": "$t(common.home)", "home": "$t(common.home)",
"artists": "$t(entity.artist_other)", "artists": "$t(entity.artist_other)",
"albumArtists": "$t(entity.albumArtist_other)", "albumArtists": "$t(entity.albumArtist_other)",
"shared": "共享$t(entity.playlist_other)" "shared": "共享$t(entity.playlist_other)",
"myLibrary": "我的媒体库"
}, },
"fullscreenPlayer": { "fullscreenPlayer": {
"config": { "config": {
@@ -659,7 +668,8 @@
}, },
"queryEditor": { "queryEditor": {
"input_optionMatchAll": "匹配全部", "input_optionMatchAll": "匹配全部",
"input_optionMatchAny": "匹配任何" "input_optionMatchAny": "匹配任何",
"title": "查询编辑器"
}, },
"editPlaylist": { "editPlaylist": {
"title": "编辑$t(entity.playlist_one)", "title": "编辑$t(entity.playlist_one)",
@@ -695,7 +705,9 @@
"view": { "view": {
"table": "表格", "table": "表格",
"poster": "海报", "poster": "海报",
"card": "卡片" "card": "卡片",
"grid": "网格",
"list": "列表"
}, },
"label": { "label": {
"releaseDate": "发布日期", "releaseDate": "发布日期",
+1 -1
View File
@@ -261,7 +261,7 @@
"discordApplicationId_description": "{{discord}} rich presence 應用 id(默認爲 {{defaultId}}", "discordApplicationId_description": "{{discord}} rich presence 應用 id(默認爲 {{defaultId}}",
"discordIdleStatus": "顯示 rich presence 閑置狀態", "discordIdleStatus": "顯示 rich presence 閑置狀態",
"discordIdleStatus_description": "啓用後將會在播放器閑置時更新狀態", "discordIdleStatus_description": "啓用後將會在播放器閑置時更新狀態",
"discordRichPresence_description": "在 {{discord}} rich presence 中顯示播放狀態。圖片鍵爲:{{icon}}、{{playing}} 和 {{paused}} ", "discordRichPresence_description": "在 {{discord}} rich presence 中顯示播放狀態。圖片鍵爲:{{icon}}、{{playing}} 和 {{paused}}",
"discordUpdateInterval": "{{discord}} rich presence 更新間隔", "discordUpdateInterval": "{{discord}} rich presence 更新間隔",
"discordUpdateInterval_description": "更新間隔秒數(至少 15 秒)", "discordUpdateInterval_description": "更新間隔秒數(至少 15 秒)",
"enableRemote": "啓用遠程控制服務器", "enableRemote": "啓用遠程控制服務器",
+8 -10
View File
@@ -201,25 +201,23 @@ function mergeLyrics(original: string | undefined, translated: string | undefine
return original; return original;
} }
// Iterate through each line of the original LRC. If a translation exists for // Iterate through each line of the original LRC. If a translation exists for the same timestamp, append the translated text after the original text.
// the same timestamp, insert it as a new, fully-formatted LRC line. const finalLines = original.split('\n').map((line) => {
const finalLines = original.split('\n').flatMap((line) => {
const match = line.match(lrcLineRegex); const match = line.match(lrcLineRegex);
if (match) { if (match) {
const timestamp = match[1]; const timestamp = match[1];
const originalText = match[2].trim();
const translatedText = translatedMap.get(timestamp); const translatedText = translatedMap.get(timestamp);
if (translatedText) { if (translatedText && originalText) {
// Return an array containing both the original line and the new translated line. // Append and add a break delimiter to separate the original and translated text
// flatMap will flatten this into the final array of lines. return [`[${timestamp}]${originalText}`, translatedText].join('_BREAK_');
const translatedLine = `[${timestamp}]${translatedText}`;
return [line, translatedLine];
} }
} }
// If no match or no translation is found, return only the original line. // If no match or no translation is found, return the original line unchanged.
return [line]; return line;
}); });
return finalLines.join('\n'); return finalLines.join('\n');
+1 -1
View File
@@ -105,7 +105,7 @@ const createMpv = async (data: {
try { try {
await mpv.start(); await mpv.start();
} catch (error: any) { } catch (error: any) {
console.log('mpv failed to start', error); console.error('mpv failed to start', error);
} finally { } finally {
await mpv.setMultipleProperties(properties || {}); await mpv.setMultipleProperties(properties || {});
} }
+1 -1
View File
@@ -405,7 +405,7 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
} }
case 'proxy': { case 'proxy': {
const toFetch = currentState.song?.imageUrl?.replaceAll( const toFetch = currentState.song?.imageUrl?.replaceAll(
/&(size|width|height=\d+)/g, /&(size|width|height)=\d+/g,
'', '',
); );
+1 -1
View File
@@ -177,7 +177,7 @@ ipcMain.on('update-song', (_event, song: QueueSong | undefined) => {
'xesam:userRating': song.userRating ? song.userRating / 5 : null, 'xesam:userRating': song.userRating ? song.userRating / 5 : null,
}; };
} catch (err) { } catch (err) {
console.log(err); console.error(err);
} }
}); });
+1 -1
View File
@@ -49,7 +49,7 @@ export default class AppUpdater {
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]); protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
process.on('uncaughtException', (error: any) => { process.on('uncaughtException', (error: any) => {
console.log('Error in main process', error); console.error('Error in main process', error);
}); });
if (store.get('ignore_ssl')) { if (store.get('ignore_ssl')) {
+1 -1
View File
@@ -12,7 +12,7 @@ export const PlayerImage = ({ src }: PlayerImageProps) => {
<img <img
className={styles.container} className={styles.container}
onError={() => send({ event: 'proxy' })} onError={() => send({ event: 'proxy' })}
src={src?.replaceAll(/&(size|width|height=\d+)/g, '')} src={src?.replaceAll(/&(size|width|height)=\d+/g, '')}
/> />
); );
}; };
@@ -398,8 +398,124 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount: null, totalRecordCount: null,
}; };
}, },
getAlbumListCount: async (args) => getAlbumListCount: async (args) => {
SubsonicController.getAlbumList(args).then((res) => res!.totalRecordCount!), const { apiClientProps, query } = args;
if (query.searchTerm) {
let fetchNextPage = true;
let startIndex = 0;
let totalRecordCount = 0;
while (fetchNextPage) {
const res = await ssApiClient(apiClientProps).search3({
query: {
albumCount: 500,
albumOffset: startIndex,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
songCount: 0,
songOffset: 0,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album list count');
}
const albumCount = (res.body.searchResult3?.album || [])?.length;
totalRecordCount += albumCount;
startIndex += albumCount;
// The max limit size for Subsonic is 500
fetchNextPage = albumCount === 500;
}
return totalRecordCount;
}
if (query.favorite) {
const res = await ssApiClient(apiClientProps).getStarred({
query: {
musicFolderId: query.musicFolderId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album list');
}
return (res.body.starred?.album || []).length || 0;
}
let type = AlbumListSortType.ALPHABETICAL_BY_NAME;
let fetchNextPage = true;
let startIndex = 0;
let totalRecordCount = 0;
if (query.genres?.length) {
type = AlbumListSortType.BY_GENRE;
}
if (query.minYear || query.maxYear) {
type = AlbumListSortType.BY_YEAR;
}
let fromYear: number | undefined;
let toYear: number | undefined;
if (query.minYear) {
fromYear = query.minYear;
toYear = dayjs().year();
}
if (query.maxYear) {
toYear = query.maxYear;
if (!query.minYear) {
fromYear = 0;
}
}
while (fetchNextPage) {
const res = await ssApiClient(apiClientProps).getAlbumList2({
query: {
fromYear,
genre: query.genres?.length ? query.genres[0] : undefined,
musicFolderId: query.musicFolderId,
offset: startIndex,
size: 500,
toYear,
type,
},
});
const headers = res.headers;
// Navidrome returns the total count in the header
if (headers.get('x-total-count')) {
fetchNextPage = false;
totalRecordCount = Number(headers.get('x-total-count'));
break;
}
if (res.status !== 200) {
throw new Error('Failed to get album list count');
}
const albumCount = res.body.albumList2.album.length;
totalRecordCount += albumCount;
startIndex += albumCount;
// The max limit size for Subsonic is 500
fetchNextPage = albumCount === 500;
}
return totalRecordCount;
},
getArtistList: async (args) => { getArtistList: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
+1 -1
View File
@@ -10,7 +10,7 @@ export const authenticationFailure = (currentServer: null | ServerListItem) => {
if (currentServer) { if (currentServer) {
const serverId = currentServer.id; const serverId = currentServer.id;
const token = currentServer.ndCredential; const token = currentServer.ndCredential;
console.log(`token is expired: ${token}`); console.error(`token is expired: ${token}`);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined }); useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null); useAuthStore.getState().actions.setCurrentServer(null);
} }
+12 -9
View File
@@ -120,6 +120,7 @@ export const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>((props,
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId); const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
const playback = useSettingsStore((state) => state.playback.mpvProperties); const playback = useSettingsStore((state) => state.playback.mpvProperties);
const shouldUseWebAudio = useSettingsStore((state) => state.playback.webAudio); const shouldUseWebAudio = useSettingsStore((state) => state.playback.webAudio);
const preservesPitch = useSettingsStore((state) => state.playback.preservePitch);
const { resetSampleRate } = useSettingsStoreActions(); const { resetSampleRate } = useSettingsStoreActions();
const playbackSpeed = useSpeed(); const playbackSpeed = useSpeed();
const { transcode } = usePlaybackSettings(); const { transcode } = usePlaybackSettings();
@@ -230,21 +231,23 @@ export const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>((props,
// calling play() is not necessarily a safe option (https://developer.chrome.com/blog/play-request-was-interrupted) // 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: // 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. // play() + play() in rapid succession will cause problems as the frist one ends the track.
player1Ref.current const internalPlayer = player1Ref.current?.getInternalPlayer();
?.getInternalPlayer() if (internalPlayer) {
?.play() internalPlayer.preservesPitch = preservesPitch;
.catch(() => {}); internalPlayer.play().catch(() => {});
}
} else { } else {
player2Ref.current const internalPlayer = player2Ref.current?.getInternalPlayer();
?.getInternalPlayer() if (internalPlayer) {
?.play() internalPlayer.preservesPitch = preservesPitch;
.catch(() => {}); internalPlayer.play().catch(() => {});
}
} }
} else { } else {
player1Ref.current?.getInternalPlayer()?.pause(); player1Ref.current?.getInternalPlayer()?.pause();
player2Ref.current?.getInternalPlayer()?.pause(); player2Ref.current?.getInternalPlayer()?.pause();
} }
}, [currentPlayer, status]); }, [currentPlayer, status, preservesPitch]);
const handleCrossfade1 = useCallback( const handleCrossfade1 = useCallback(
(e: AudioPlayerProgress) => { (e: AudioPlayerProgress) => {
@@ -251,7 +251,7 @@ export const useVirtualTable = <TFilter extends BaseQuery<any>>({
properties.table.pagination.itemsPerPage; properties.table.pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top'); event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
} catch (err) { } catch (err) {
console.log(err); console.error(err);
} }
if (isSearchParams) { if (isSearchParams) {
@@ -17,7 +17,7 @@ const RouteErrorBoundary = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const error = useRouteError() as any; const error = useRouteError() as any;
console.log('error', error); console.error('error', error);
const handleReload = () => { const handleReload = () => {
navigate(0); navigate(0);
@@ -62,7 +62,8 @@ const ActionRequiredRoute = () => {
</Group> </Group>
<Stack mt="2rem"> <Stack mt="2rem">
{canReturnHome && <Navigate to={AppRoute.HOME} />} {canReturnHome && <Navigate to={AppRoute.HOME} />}
{!displayedCheck && ( {/* This should be displayed if a credential is required */}
{isCredentialRequired && (
<Group <Group
justify="center" justify="center"
wrap="nowrap" wrap="nowrap"
@@ -414,18 +414,23 @@ export const AlbumListHeaderFilters = ({
Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined); Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined);
const isSubsonicFilterApplied = const isSubsonicFilterApplied =
server?.type === ServerType.SUBSONIC && server?.type === ServerType.SUBSONIC && (filter.maxYear || filter.minYear);
(filter.maxYear || filter.minYear || filter.favorite);
const isCompilationFilterApplied =
server?.type === ServerType.NAVIDROME && filter.compilation !== undefined;
return ( return (
isNavidromeFilterApplied || isNavidromeFilterApplied ||
isJellyfinFilterApplied || isJellyfinFilterApplied ||
isSubsonicFilterApplied || isSubsonicFilterApplied ||
filter.genres?.length filter.genres?.length ||
filter.favorite !== undefined ||
isCompilationFilterApplied
); );
}, [ }, [
filter?._custom?.jellyfin, filter?._custom?.jellyfin,
filter?._custom?.navidrome, filter?._custom?.navidrome,
filter.compilation,
filter.favorite, filter.favorite,
filter.genres?.length, filter.genres?.length,
filter.maxYear, filter.maxYear,
@@ -1,5 +1,5 @@
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@@ -12,8 +12,8 @@ import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { import {
AlbumArtistListSort, AlbumArtistListSort,
AlbumListQuery, AlbumListQuery,
@@ -72,15 +72,15 @@ export const JellyfinAlbumFilters = ({
return filter?._custom?.jellyfin?.Tags?.split('|'); return filter?._custom?.jellyfin?.Tags?.split('|');
}, [filter?._custom?.jellyfin?.Tags]); }, [filter?._custom?.jellyfin?.Tags]);
const toggleFilters = [ const yesNoFilter = [
{ {
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => { onChange: (favorite?: boolean) => {
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: filter?._custom, _custom: filter?._custom,
favorite: e.currentTarget.checked ? true : undefined, favorite,
}, },
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
key: pageKey, key: pageKey,
@@ -189,16 +189,16 @@ export const JellyfinAlbumFilters = ({
return ( return (
<Stack p="0.8rem"> <Stack p="0.8rem">
{toggleFilters.map((filter) => ( {yesNoFilter.map((filter) => (
<Group <Group
justify="space-between" justify="space-between"
key={`nd-filter-${filter.label}`} key={`nd-filter-${filter.label}`}
> >
<Text>{filter.label}</Text> <Text>{filter.label}</Text>
<Switch <YesNoSelect
checked={filter?.value || false}
onChange={filter.onChange} onChange={filter.onChange}
size="xs" size="xs"
value={filter.value}
/> />
</Group> </Group>
))} ))}
@@ -250,7 +250,7 @@ export const JellyfinAlbumFilters = ({
searchValue={albumArtistSearchTerm} searchValue={albumArtistSearchTerm}
/> />
</Group> </Group>
{tagsQuery.data?.boolTags?.length && ( {tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
<Group grow> <Group grow>
<MultiSelectWithInvalidData <MultiSelectWithInvalidData
clearable clearable
@@ -14,6 +14,7 @@ import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { import {
AlbumArtistListSort, AlbumArtistListSort,
AlbumListQuery, AlbumListQuery,
@@ -78,6 +79,41 @@ export const NavidromeAlbumFilters = ({
serverId, serverId,
}); });
const yesNoUndefinedFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (favorite?: boolean) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: filter._custom,
favorite,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter.favorite,
},
{
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
onChange: (compilation?: boolean) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: filter._custom,
compilation,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter.compilation,
},
];
const toggleFilters = [ const toggleFilters = [
{ {
label: t('filter.isRated', { postProcess: 'sentenceCase' }), label: t('filter.isRated', { postProcess: 'sentenceCase' }),
@@ -100,38 +136,6 @@ export const NavidromeAlbumFilters = ({
}, },
value: filter._custom?.navidrome?.has_rating, value: filter._custom?.navidrome?.has_rating,
}, },
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: filter._custom,
favorite: e.currentTarget.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter.favorite,
},
{
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: filter._custom,
compilation: e.currentTarget.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter.compilation,
},
{ {
label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }), label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => { onChange: (e: ChangeEvent<HTMLInputElement>) => {
@@ -236,6 +240,19 @@ export const NavidromeAlbumFilters = ({
return ( return (
<Stack p="0.8rem"> <Stack p="0.8rem">
{yesNoUndefinedFilters.map((filter) => (
<Group
justify="space-between"
key={`nd-filter-${filter.label}`}
>
<Text>{filter.label}</Text>
<YesNoSelect
onChange={filter.onChange}
size="xs"
value={filter.value}
/>
</Group>
))}
{toggleFilters.map((filter) => ( {toggleFilters.map((filter) => (
<Group <Group
justify="space-between" justify="space-between"
@@ -103,6 +103,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
}, },
query: { query: {
artistIds: [routeId], artistIds: [routeId],
compilation: false,
limit: 15, limit: 15,
sortBy: AlbumListSort.RELEASE_DATE, sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC, sortOrder: SortOrder.DESC,
@@ -24,8 +24,13 @@ export const useDiscordRpc = () => {
current: (number | PlayerStatus | QueueSong | undefined)[], current: (number | PlayerStatus | QueueSong | undefined)[],
previous: (number | PlayerStatus | QueueSong | undefined)[], previous: (number | PlayerStatus | QueueSong | undefined)[],
) => { ) => {
// No current song, or we switched to a new track and the player was paused (end of album, etc.) if (
if (!current[0] || (current[0] && current[2] === 'paused' && current[1] === 0)) !current[0] || // No track
(current[0] &&
current[2] === 'paused' && // Track paused
(discordSettings.showPaused ? current[1] === 0 : true)) || // Beginning of track (only if show paused setting enabled)
(discordSettings.showPaused ? false : current[1] === 0) // Beginning of track (only if show paused setting disabled)
)
return discordRpc?.clearActivity(); return discordRpc?.clearActivity();
// Handle change detection // Handle change detection
@@ -122,6 +127,7 @@ export const useDiscordRpc = () => {
[ [
discordSettings.showAsListening, discordSettings.showAsListening,
discordSettings.showServerImage, discordSettings.showServerImage,
discordSettings.showPaused,
generalSettings.lastfmApiKey, generalSettings.lastfmApiKey,
lastUniqueId, lastUniqueId,
], ],
@@ -66,7 +66,7 @@ const formatArtists = (artists: null | RelatedArtist[] | undefined) =>
{artist.id ? ( {artist.id ? (
<Text <Text
component={Link} component={Link}
fw={500} fw={700}
isLink isLink
overflow="visible" overflow="visible"
size="md" size="md"
@@ -106,7 +106,7 @@ const FormatGenre = (item: Album | AlbumArtist | Playlist | Song) => {
{index > 0 && <Separator />} {index > 0 && <Separator />}
<Text <Text
component={Link} component={Link}
fw={500} fw={700}
isLink isLink
overflow="visible" overflow="visible"
size="md" size="md"
@@ -260,7 +260,7 @@ const SongPropertyMapping: ItemDetailRow<Song>[] = [
song.album && ( song.album && (
<Text <Text
component={Link} component={Link}
fw={500} fw={700}
isLink isLink
overflow="visible" overflow="visible"
size="md" size="md"
@@ -1,21 +1,23 @@
.lyric-line { .lyric-line {
padding: 0 1rem; padding: 0 1rem;
font-weight: 600; font-weight: 600;
line-height: 1.2;
color: var(--theme-colors-foreground); color: var(--theme-colors-foreground);
word-break: normal;
opacity: 0.5; opacity: 0.5;
transition: transition:
opacity 0.3s ease-in-out, opacity 0.3s ease-in-out,
transform 0.3s ease-in-out; transform 0.3s ease-in-out;
}
&.active {
opacity: 1; .lyric-line:global(.active) {
} opacity: 1 !important;
}
&.unsynchronized {
opacity: 1; .lyric-line:global(.unsynchronized) {
} opacity: 1;
}
&.synchronized {
cursor: pointer; .lyric-line:global(.synchronized) {
} cursor: pointer;
} }
+11 -4
View File
@@ -3,7 +3,8 @@ import { ComponentPropsWithoutRef } from 'react';
import styles from './lyric-line.module.css'; import styles from './lyric-line.module.css';
import { TextTitle } from '/@/shared/components/text-title/text-title'; import { Box } from '/@/shared/components/box/box';
import { Stack } from '/@/shared/components/stack/stack';
interface LyricLineProps extends ComponentPropsWithoutRef<'div'> { interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
alignment: 'center' | 'left' | 'right'; alignment: 'center' | 'left' | 'right';
@@ -12,8 +13,10 @@ interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
} }
export const LyricLine = ({ alignment, className, fontSize, text, ...props }: LyricLineProps) => { export const LyricLine = ({ alignment, className, fontSize, text, ...props }: LyricLineProps) => {
const lines = text.split('_BREAK_');
return ( return (
<TextTitle <Box
className={clsx(styles.lyricLine, className)} className={clsx(styles.lyricLine, className)}
style={{ style={{
fontSize, fontSize,
@@ -21,7 +24,11 @@ export const LyricLine = ({ alignment, className, fontSize, text, ...props }: Ly
}} }}
{...props} {...props}
> >
{text} <Stack gap={0}>
</TextTitle> {lines.map((line, index) => (
<span key={index}>{line}</span>
))}
</Stack>
</Box>
); );
}; };
@@ -86,7 +86,7 @@ export const useSongLyricsBySong = (
song: QueueSong | undefined, song: QueueSong | undefined,
): UseQueryResult<FullLyricsMetadata | StructuredLyric[]> => { ): UseQueryResult<FullLyricsMetadata | StructuredLyric[]> => {
const { query } = args; const { query } = args;
const { fetch } = useLyricsSettings(); const { fetch, preferLocalLyrics } = useLyricsSettings();
const server = getServerById(song?.serverId); const server = getServerById(song?.serverId);
return useQuery({ return useQuery({
@@ -97,6 +97,9 @@ export const useSongLyricsBySong = (
if (!server) throw new Error('Server not found'); if (!server) throw new Error('Server not found');
if (!song) return null; if (!song) return null;
let localLyrics: FullLyricsMetadata | null | StructuredLyric[] = null;
let remoteLyrics: FullLyricsMetadata | null | StructuredLyric[] = null;
if (hasFeature(server, ServerFeature.LYRICS_MULTIPLE_STRUCTURED)) { if (hasFeature(server, ServerFeature.LYRICS_MULTIPLE_STRUCTURED)) {
const subsonicLyrics = await api.controller const subsonicLyrics = await api.controller
.getStructuredLyrics({ .getStructuredLyrics({
@@ -106,7 +109,7 @@ export const useSongLyricsBySong = (
.catch(console.error); .catch(console.error);
if (subsonicLyrics?.length) { if (subsonicLyrics?.length) {
return subsonicLyrics; localLyrics = subsonicLyrics;
} }
} else if (hasFeature(server, ServerFeature.LYRICS_SINGLE_STRUCTURED)) { } else if (hasFeature(server, ServerFeature.LYRICS_SINGLE_STRUCTURED)) {
const jfLyrics = await api.controller const jfLyrics = await api.controller
@@ -114,10 +117,10 @@ export const useSongLyricsBySong = (
apiClientProps: { server, signal }, apiClientProps: { server, signal },
query: { songId: song.id }, query: { songId: song.id },
}) })
.catch((err) => console.log(err)); .catch((err) => console.error(err));
if (jfLyrics) { if (jfLyrics) {
return { localLyrics = {
artist: song.artists?.[0]?.name, artist: song.artists?.[0]?.name,
lyrics: jfLyrics, lyrics: jfLyrics,
name: song.name, name: song.name,
@@ -126,7 +129,7 @@ export const useSongLyricsBySong = (
}; };
} }
} else if (song.lyrics) { } else if (song.lyrics) {
return { localLyrics = {
artist: song.artists?.[0]?.name, artist: song.artists?.[0]?.name,
lyrics: formatLyrics(song.lyrics), lyrics: formatLyrics(song.lyrics),
name: song.name, name: song.name,
@@ -135,12 +138,16 @@ export const useSongLyricsBySong = (
}; };
} }
if (preferLocalLyrics && localLyrics) {
return localLyrics;
}
if (fetch) { if (fetch) {
const remoteLyricsResult: InternetProviderLyricResponse | null = const remoteLyricsResult: InternetProviderLyricResponse | null =
await lyricsIpc?.getRemoteLyricsBySong(song); await lyricsIpc?.getRemoteLyricsBySong(song);
if (remoteLyricsResult) { if (remoteLyricsResult) {
return { remoteLyrics = {
...remoteLyricsResult, ...remoteLyricsResult,
lyrics: formatLyrics(remoteLyricsResult.lyrics), lyrics: formatLyrics(remoteLyricsResult.lyrics),
remote: true, remote: true,
@@ -148,6 +155,14 @@ export const useSongLyricsBySong = (
} }
} }
if (remoteLyrics) {
return remoteLyrics;
}
if (localLyrics) {
return localLyrics;
}
return null; return null;
}, },
queryKey: queryKeys.songs.lyrics(server?.id || '', query), queryKey: queryKeys.songs.lyrics(server?.id || '', query),
@@ -183,9 +198,7 @@ export const useSongLyricsByRemoteId = (
); );
}, },
queryFn: async () => { queryFn: async () => {
const remoteLyricsResult = await lyricsIpc?.getRemoteLyricsByRemoteId( const remoteLyricsResult = await lyricsIpc?.getRemoteLyricsByRemoteId(query as any);
query as LyricGetQuery,
);
if (remoteLyricsResult) { if (remoteLyricsResult) {
return formatLyrics(remoteLyricsResult); return formatLyrics(remoteLyricsResult);
@@ -1,3 +0,0 @@
.active {
opacity: 1;
}
@@ -1,9 +1,8 @@
import clsx from 'clsx'; import clsx from 'clsx';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useCallback, useEffect, useRef } from 'react'; import { Fragment, useCallback, useEffect, useRef } from 'react';
import styles from './synchronized-lyrics.module.css'; import styles from './synchronized-lyrics.module.css';
import './synchronized-lyrics.css';
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble'; import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
@@ -339,7 +338,7 @@ export const SynchronizedLyrics = ({
/> />
)} )}
{lyrics.map(([time, text], idx) => ( {lyrics.map(([time, text], idx) => (
<div key={idx}> <Fragment key={idx}>
<LyricLine <LyricLine
alignment={settings.alignment} alignment={settings.alignment}
className="lyric-line synchronized" className="lyric-line synchronized"
@@ -357,7 +356,7 @@ export const SynchronizedLyrics = ({
text={translatedLyrics.split('\n')[idx]} text={translatedLyrics.split('\n')[idx]}
/> />
)} )}
</div> </Fragment>
))} ))}
</div> </div>
); );
@@ -2,8 +2,6 @@
position: absolute; position: absolute;
max-width: 100%; max-width: 100%;
height: 100%; height: 100%;
object-fit: var(--theme-image-fit);
object-position: 50% 100%;
border-radius: 5px; border-radius: 5px;
filter: drop-shadow(0 0 5px rgb(0 0 0 / 40%)) drop-shadow(0 0 5px rgb(0 0 0 / 40%)); filter: drop-shadow(0 0 5px rgb(0 0 0 / 40%)) drop-shadow(0 0 5px rgb(0 0 0 / 40%));
} }
@@ -24,8 +22,13 @@
justify-content: center; justify-content: center;
padding: 1rem; padding: 1rem;
text-align: center; text-align: center;
cursor: default;
border-radius: 5px; border-radius: 5px;
a {
cursor: pointer;
}
h1 { h1 {
font-size: 3.5vh; font-size: 3.5vh;
} }
@@ -16,9 +16,7 @@ import { Center } from '/@/shared/components/center/center';
import { Flex } from '/@/shared/components/flex/flex'; import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { PlayerData, QueueSong } from '/@/shared/types/domain-types'; import { PlayerData, QueueSong } from '/@/shared/types/domain-types';
@@ -52,9 +50,14 @@ const scaleImageUrl = (imageSize: number, url?: null | string) => {
.replace(/&height=\d+/, `&height=${imageSize}`); .replace(/&height=\d+/, `&height=${imageSize}`);
}; };
const MotionImage = motion.create(Image); const MotionImage = motion.img;
const ImageWithPlaceholder = ({
className,
...props
}: HTMLMotionProps<'img'> & { placeholder?: string }) => {
const nativeAspectRatio = useSettingsStore((store) => store.general.nativeAspectRatio);
const ImageWithPlaceholder = ({ ...props }: HTMLMotionProps<'img'> & { placeholder?: string }) => {
if (!props.src) { if (!props.src) {
return ( return (
<Center <Center
@@ -76,7 +79,11 @@ const ImageWithPlaceholder = ({ ...props }: HTMLMotionProps<'img'> & { placehold
return ( return (
<MotionImage <MotionImage
className={styles.image} className={clsx(styles.image, className)}
style={{
objectFit: nativeAspectRatio ? 'contain' : 'cover',
width: nativeAspectRatio ? 'auto' : '100%',
}}
{...props} {...props}
/> />
); );
@@ -201,45 +208,35 @@ export const FullScreenPlayerImage = () => {
</div> </div>
<Stack <Stack
className={styles.metadataContainer} className={styles.metadataContainer}
gap="xs" gap="md"
maw="100%" maw="100%"
> >
<TextTitle <Text
fw={900} fw={900}
order={1} lh="1.2"
overflow="hidden" overflow="hidden"
size="4xl"
w="100%" w="100%"
> >
{currentSong?.name} {currentSong?.name}
</TextTitle> </Text>
<TextTitle <Text
component={Link} component={Link}
fw={600}
isLink isLink
order={3}
overflow="hidden" overflow="hidden"
style={{ size="xl"
textShadow: 'var(--theme-fullscreen-player-text-shadow)',
}}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: currentSong?.albumId || '', albumId: currentSong?.albumId || '',
})} })}
w="100%" w="100%"
> >
{currentSong?.album}{' '} {currentSong?.album}
</TextTitle> </Text>
<TextTitle <Text key="fs-artists">
key="fs-artists"
order={3}
style={{
textShadow: 'var(--theme-fullscreen-player-text-shadow)',
}}
>
{currentSong?.artists?.map((artist, index) => ( {currentSong?.artists?.map((artist, index) => (
<Fragment key={`fs-artist-${artist.id}`}> <Fragment key={`fs-artist-${artist.id}`}>
{index > 0 && ( {index > 0 && (
<Text <Text
isMuted
style={{ style={{
display: 'inline-block', display: 'inline-block',
padding: '0 0.5rem', padding: '0 0.5rem',
@@ -250,12 +247,7 @@ export const FullScreenPlayerImage = () => {
)} )}
<Text <Text
component={Link} component={Link}
fw={600}
isLink isLink
isMuted
style={{
textShadow: 'var(--theme-fullscreen-player-text-shadow)',
}}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id, albumArtistId: artist.id,
})} })}
@@ -264,7 +256,7 @@ export const FullScreenPlayerImage = () => {
</Text> </Text>
</Fragment> </Fragment>
))} ))}
</TextTitle> </Text>
<Group <Group
justify="center" justify="center"
mt="sm" mt="sm"
@@ -1,6 +1,6 @@
import { useHotkeys } from '@mantine/hooks'; import { useHotkeys } from '@mantine/hooks';
import { motion, Variants } from 'motion/react'; import { motion, Variants } from 'motion/react';
import { CSSProperties, useLayoutEffect, useRef } from 'react'; import { CSSProperties, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
@@ -32,7 +32,11 @@ import { Platform } from '/@/shared/types/types';
const mainBackground = 'var(--theme-colors-background)'; const mainBackground = 'var(--theme-colors-background)';
const Controls = () => { interface ControlsProps {
isPageHovered: boolean;
}
const Controls = ({ isPageHovered }: ControlsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
dynamicBackground, dynamicBackground,
@@ -77,7 +81,7 @@ const Controls = () => {
iconProps={{ size: 'lg' }} iconProps={{ size: 'lg' }}
onClick={handleToggleFullScreenPlayer} onClick={handleToggleFullScreenPlayer}
tooltip={{ label: t('common.minimize', { postProcess: 'titleCase' }) }} tooltip={{ label: t('common.minimize', { postProcess: 'titleCase' }) }}
variant="subtle" variant={isPageHovered ? 'default' : 'subtle'}
/> />
<Popover position="bottom-start"> <Popover position="bottom-start">
<Popover.Target> <Popover.Target>
@@ -85,7 +89,7 @@ const Controls = () => {
icon="settings" icon="settings"
iconProps={{ size: 'lg' }} iconProps={{ size: 'lg' }}
tooltip={{ label: t('common.configure', { postProcess: 'titleCase' }) }} tooltip={{ label: t('common.configure', { postProcess: 'titleCase' }) }}
variant="subtle" variant={isPageHovered ? 'default' : 'subtle'}
/> />
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
@@ -410,6 +414,8 @@ export const FullScreenPlayer = () => {
const { setStore } = useFullScreenPlayerStoreActions(); const { setStore } = useFullScreenPlayerStoreActions();
const { windowBarStyle } = useWindowSettings(); const { windowBarStyle } = useWindowSettings();
const [isPageHovered, setIsPageHovered] = useState(false);
const location = useLocation(); const location = useLocation();
const isOpenedRef = useRef<boolean | null>(null); const isOpenedRef = useRef<boolean | null>(null);
@@ -441,10 +447,12 @@ export const FullScreenPlayer = () => {
custom={{ background, backgroundImage, dynamicBackground, windowBarStyle }} custom={{ background, backgroundImage, dynamicBackground, windowBarStyle }}
exit="closed" exit="closed"
initial="closed" initial="closed"
onMouseEnter={() => setIsPageHovered(true)}
onMouseLeave={() => setIsPageHovered(false)}
transition={{ duration: 2 }} transition={{ duration: 2 }}
variants={containerVariants} variants={containerVariants}
> >
<Controls /> <Controls isPageHovered={isPageHovered} />
{dynamicBackground && ( {dynamicBackground && (
<div <div
className={styles.backgroundImageOverlay} className={styles.backgroundImageOverlay}
@@ -71,7 +71,7 @@ export const LeftControls = () => {
<LayoutGroup> <LayoutGroup>
<AnimatePresence <AnimatePresence
initial={false} initial={false}
mode="wait" mode="popLayout"
> >
{!hideImage && ( {!hideImage && (
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
@@ -83,7 +83,7 @@ export const LeftControls = () => {
key="playerbar-image" key="playerbar-image"
onClick={handleToggleFullScreenPlayer} onClick={handleToggleFullScreenPlayer}
role="button" role="button"
transition={{ duration: 0.3, ease: 'easeInOut' }} transition={{ duration: 0.2, ease: 'easeIn' }}
> >
<Tooltip <Tooltip
label={t('player.toggleFullscreenPlayer', { label={t('player.toggleFullscreenPlayer', {
@@ -37,6 +37,7 @@
} }
.main { .main {
background: var(--theme-colors-foreground) !important;
border-radius: 50%; border-radius: 50%;
svg { svg {
@@ -15,7 +15,7 @@ interface PlayerButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> {
} }
export const PlayerButton = forwardRef<HTMLButtonElement, PlayerButtonProps>( export const PlayerButton = forwardRef<HTMLButtonElement, PlayerButtonProps>(
({ icon, isActive, tooltip, variant, ...rest }: PlayerButtonProps) => { ({ icon, isActive, tooltip, variant, ...rest }: PlayerButtonProps, ref) => {
if (tooltip) { if (tooltip) {
return ( return (
<Tooltip {...tooltip}> <Tooltip {...tooltip}>
@@ -23,6 +23,7 @@ export const PlayerButton = forwardRef<HTMLButtonElement, PlayerButtonProps>(
className={clsx({ className={clsx({
[styles.active]: isActive, [styles.active]: isActive,
})} })}
ref={ref}
{...rest} {...rest}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -41,6 +42,7 @@ export const PlayerButton = forwardRef<HTMLButtonElement, PlayerButtonProps>(
className={clsx(styles.playerButton, styles[variant], { className={clsx(styles.playerButton, styles[variant], {
[styles.active]: isActive, [styles.active]: isActive,
})} })}
ref={ref}
{...rest} {...rest}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -58,18 +60,23 @@ interface PlayButtonProps extends Omit<ActionIconProps, 'icon' | 'variant'> {
isPaused?: boolean; isPaused?: boolean;
} }
export const PlayButton = ({ isPaused, ...props }: PlayButtonProps) => { export const PlayButton = forwardRef<HTMLButtonElement, PlayButtonProps>(
return ( ({ isPaused, ...props }: PlayButtonProps, ref) => {
<ActionIcon return (
className={styles.main} <ActionIcon
icon={isPaused ? 'mediaPlay' : 'mediaPause'} className={styles.main}
tooltip={ icon={isPaused ? 'mediaPlay' : 'mediaPause'}
isPaused iconProps={{
? t('player.play', { postProcess: 'sentenceCase' }) size: 'lg',
: t('player.pause', { postProcess: 'sentenceCase' }) }}
} ref={ref}
variant="white" tooltip={{
{...props} label: isPaused
/> ? (t('player.play', { postProcess: 'sentenceCase' }) as string)
); : (t('player.pause', { postProcess: 'sentenceCase' }) as string),
}; }}
{...props}
/>
);
},
);
@@ -1,5 +1,5 @@
import AudioMotionAnalyzer from 'audiomotion-analyzer'; import AudioMotionAnalyzer from 'audiomotion-analyzer';
import { createRef, useCallback, useEffect, useState } from 'react'; import { createRef, useEffect, useState } from 'react';
import styles from './visualizer.module.css'; import styles from './visualizer.module.css';
@@ -12,8 +12,6 @@ export const Visualizer = () => {
const accent = useSettingsStore((store) => store.general.accent); const accent = useSettingsStore((store) => store.general.accent);
const [motion, setMotion] = useState<AudioMotionAnalyzer>(); const [motion, setMotion] = useState<AudioMotionAnalyzer>();
const [, setLength] = useState(500);
useEffect(() => { useEffect(() => {
const { context, gain } = webAudio || {}; const { context, gain } = webAudio || {};
if (gain && context && canvasRef.current && !motion) { if (gain && context && canvasRef.current && !motion) {
@@ -35,28 +33,6 @@ export const Visualizer = () => {
return () => {}; return () => {};
}, [accent, canvasRef, motion, webAudio]); }, [accent, canvasRef, motion, webAudio]);
const resize = useCallback(() => {
const body = document.querySelector('.full-screen-player-queue-container');
const header = document.querySelector('.full-screen-player-queue-header');
if (body && header) {
const width = body.clientWidth - 30;
const height = body.clientHeight - header.clientHeight - 30;
setLength(Math.min(width, height));
}
}, []);
useEffect(() => {
resize();
window.addEventListener('resize', resize);
return () => {
window.removeEventListener('resize', resize);
};
}, [resize]);
return ( return (
<div <div
className={styles.container} className={styles.container}
@@ -207,7 +207,7 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage; const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top'); event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
} catch (err) { } catch (err) {
console.log(err); console.error(err);
} }
setPagination(playlistId, { setPagination(playlistId, {
@@ -155,6 +155,26 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
}, },
{
control: (
<Switch
defaultChecked={settings.preservePitch}
onChange={(e) => {
setSettings({
playback: { ...settings, preservePitch: e.currentTarget.checked },
});
}}
/>
),
description: t('setting.preservePitch', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlaybackType.WEB,
title: t('setting.preservePitch', {
postProcess: 'sentenceCase',
}),
},
{ {
control: ( control: (
<Slider <Slider
@@ -43,6 +43,28 @@ export const LyricSettings = () => {
}), }),
title: t('setting.followLyric', { postProcess: 'sentenceCase' }), title: t('setting.followLyric', { postProcess: 'sentenceCase' }),
}, },
{
control: (
<Switch
aria-label="Prefer local lyrics"
defaultChecked={settings.preferLocalLyrics}
onChange={(e) => {
setSettings({
lyrics: {
...settings,
preferLocalLyrics: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.preferLocalLyrics', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.preferLocalLyrics', { postProcess: 'sentenceCase' }),
},
{ {
control: ( control: (
<Switch <Switch
@@ -74,6 +74,29 @@ export const DiscordSettings = () => {
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
}, },
{
control: (
<Switch
checked={settings.showPaused}
onChange={(e) => {
setSettings({
discord: {
...settings,
showPaused: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.discordPausedStatus', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.discordPausedStatus', {
postProcess: 'sentenceCase',
}),
},
{ {
control: ( control: (
<Switch <Switch
@@ -18,7 +18,7 @@ export const FolderButton = ({ isActive, ...props }: FolderButtonProps) => {
...props.iconProps, ...props.iconProps,
}} }}
tooltip={{ tooltip={{
label: t('entity.folder', { postProcess: 'sentenceCase' }), label: t('entity.folder', { count: 1, postProcess: 'sentenceCase' }),
...props.tooltip, ...props.tooltip,
}} }}
variant="subtle" variant="subtle"
@@ -14,8 +14,7 @@ export const PlayButton = ({ className, ...props }: PlayButtonProps) => {
className={clsx(styles.button, className)} className={clsx(styles.button, className)}
icon="mediaPlay" icon="mediaPlay"
iconProps={{ iconProps={{
fill: 'default', size: 'xl',
size: 'lg',
}} }}
variant="filled" variant="filled"
{...props} {...props}
@@ -4,7 +4,6 @@
height: 100%; height: 100%;
max-height: calc(100vh - 119px); max-height: calc(100vh - 119px);
user-select: none; user-select: none;
background: var(--theme-colors-background-alternate);
} }
.sidebar-container.web, .sidebar-container.web,
@@ -9,6 +9,20 @@
} }
} }
.inner {
display: flex;
justify-content: flex-start;
width: 100%;
}
.label {
display: block;
align-content: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link { .link {
display: flex; display: flex;
width: 100%; width: 100%;
@@ -10,13 +10,20 @@ interface SidebarItemProps extends ButtonProps {
to: LinkProps['to']; to: LinkProps['to'];
} }
export const SidebarItem = ({ children, to, ...props }: SidebarItemProps) => { export const SidebarItem = ({ children, className, to, ...props }: SidebarItemProps) => {
return ( return (
<Button <Button
className={clsx({ className={clsx(
[styles.disabled]: props.disabled, {
[styles.link]: true, [styles.disabled]: props.disabled,
})} [styles.link]: true,
},
className,
)}
classNames={{
inner: styles.inner,
label: styles.label,
}}
component={Link} component={Link}
to={to} to={to}
variant="subtle" variant="subtle"
@@ -1,3 +1,5 @@
@value label from './sidebar-item.module.css';
.list { .list {
padding: var(--theme-spacing-sm) var(--theme-spacing-md); padding: var(--theme-spacing-sm) var(--theme-spacing-md);
} }
@@ -8,6 +10,12 @@
width: 100%; width: 100%;
} }
.row-hover {
:global(.label) {
margin-right: 135px;
}
}
.controls { .controls {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -1,4 +1,5 @@
import { closeAllModals, openModal } from '@mantine/modals'; import { closeAllModals, openModal } from '@mantine/modals';
import clsx from 'clsx';
import { MouseEvent, useCallback, useMemo, useState } from 'react'; import { MouseEvent, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generatePath } from 'react-router'; import { generatePath } from 'react-router';
@@ -43,6 +44,9 @@ const PlaylistRowButton = ({ name, onPlay, to, ...props }: PlaylistRowButtonProp
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<SidebarItem <SidebarItem
className={clsx({
[styles.rowHover]: isHovered,
})}
to={url} to={url}
variant="subtle" variant="subtle"
{...props} {...props}
@@ -181,7 +185,9 @@ export const SidebarPlaylistList = () => {
const owned: Array<[boolean, () => void] | Playlist> = []; const owned: Array<[boolean, () => void] | Playlist> = [];
for (const playlist of data.items) { for (const playlist of data.items) {
owned.push(playlist); if (!playlist.owner || playlist.owner === server.username) {
owned.push(playlist);
}
} }
return { ...base, items: owned }; return { ...base, items: owned };
@@ -8,6 +8,10 @@
background: var(--theme-colors-background-alternate); background: var(--theme-colors-background-alternate);
} }
.container.custom-bar {
max-height: calc(100vh - 120px);
}
.scroll-area { .scroll-area {
padding: 0 var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md); padding: 0 var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md);
} }
@@ -24,6 +28,7 @@
.image-container { .image-container {
position: relative; position: relative;
width: var(--sidebar-image-height);
height: var(--sidebar-image-height); height: var(--sidebar-image-height);
cursor: pointer; cursor: pointer;
animation: fade-in 0.2s ease-in-out; animation: fade-in 0.2s ease-in-out;
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { CSSProperties, useMemo } from 'react'; import { CSSProperties, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -19,13 +20,18 @@ import {
useSetFullScreenPlayerStore, useSetFullScreenPlayerStore,
useSidebarStore, useSidebarStore,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { SidebarItemType, useGeneralSettings } from '/@/renderer/store/settings.store'; import {
SidebarItemType,
useGeneralSettings,
useWindowSettings,
} from '/@/renderer/store/settings.store';
import { Accordion } from '/@/shared/components/accordion/accordion'; import { Accordion } from '/@/shared/components/accordion/accordion';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { Platform } from '/@/shared/types/types';
export const Sidebar = () => { export const Sidebar = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -64,6 +70,7 @@ export const Sidebar = () => {
}; };
const { sidebarItems } = useGeneralSettings(); const { sidebarItems } = useGeneralSettings();
const { windowBarStyle } = useWindowSettings();
const sidebarItemsWithRoute: SidebarItemType[] = useMemo(() => { const sidebarItemsWithRoute: SidebarItemType[] = useMemo(() => {
if (!sidebarItems) return []; if (!sidebarItems) return [];
@@ -80,9 +87,26 @@ export const Sidebar = () => {
return items; return items;
}, [sidebarItems, translatedSidebarItemMap]); }, [sidebarItems, translatedSidebarItemMap]);
const scrollAreaHeight = useMemo(() => {
if (showImage) {
if (windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS) {
return `calc(100% - 105px - ${sidebar.leftWidth})`;
}
return `calc(100% - ${sidebar.leftWidth})`;
}
return '100%';
}, [showImage, sidebar.leftWidth, windowBarStyle]);
const isCustomWindowBar =
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS;
return ( return (
<div <div
className={styles.container} className={clsx(styles.container, {
[styles.customBar]: isCustomWindowBar,
})}
id="left-sidebar" id="left-sidebar"
> >
<Group <Group
@@ -95,7 +119,7 @@ export const Sidebar = () => {
allowDragScroll allowDragScroll
className={styles.scrollArea} className={styles.scrollArea}
style={{ style={{
maxHeight: showImage ? `calc(100vh - 90px - ${sidebar.leftWidth})` : '100%', height: scrollAreaHeight,
}} }}
> >
<Accordion <Accordion
@@ -1,5 +1,5 @@
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@@ -10,8 +10,8 @@ import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types'; import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types';
interface JellyfinSongFiltersProps { interface JellyfinSongFiltersProps {
@@ -69,10 +69,10 @@ export const JellyfinSongFilters = ({
return filter?._custom?.jellyfin?.Tags?.split('|'); return filter?._custom?.jellyfin?.Tags?.split('|');
}, [filter?._custom?.jellyfin?.Tags]); }, [filter?._custom?.jellyfin?.Tags]);
const toggleFilters = [ const yesNoFilters = [
{ {
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => { onChange: (favorite?: boolean) => {
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
@@ -83,7 +83,7 @@ export const JellyfinSongFilters = ({
IncludeItemTypes: 'Audio', IncludeItemTypes: 'Audio',
}, },
}, },
favorite: e.currentTarget.checked ? true : undefined, favorite,
}, },
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
key: pageKey, key: pageKey,
@@ -174,15 +174,16 @@ export const JellyfinSongFilters = ({
return ( return (
<Stack p="0.8rem"> <Stack p="0.8rem">
{toggleFilters.map((filter) => ( {yesNoFilters.map((filter) => (
<Group <Group
justify="space-between" justify="space-between"
key={`nd-filter-${filter.label}`} key={`nd-filter-${filter.label}`}
> >
<Text>{filter.label}</Text> <Text>{filter.label}</Text>
<Switch <YesNoSelect
checked={filter?.value || false}
onChange={filter.onChange} onChange={filter.onChange}
size="xs"
value={filter.value}
/> />
</Group> </Group>
))} ))}
@@ -218,7 +219,7 @@ export const JellyfinSongFilters = ({
/> />
</Group> </Group>
)} )}
{tagsQuery.data?.boolTags?.length && ( {tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
<Group grow> <Group grow>
<MultiSelectWithInvalidData <MultiSelectWithInvalidData
clearable clearable
@@ -1,5 +1,5 @@
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@@ -10,8 +10,8 @@ import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types'; import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types';
interface NavidromeSongFiltersProps { interface NavidromeSongFiltersProps {
@@ -93,12 +93,12 @@ export const NavidromeSongFilters = ({
const toggleFilters = [ const toggleFilters = [
{ {
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => { onChange: (favorite: boolean | undefined) => {
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: filter._custom, _custom: filter._custom,
favorite: e.currentTarget.checked ? true : undefined, favorite,
}, },
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
key: pageKey, key: pageKey,
@@ -137,10 +137,10 @@ export const NavidromeSongFilters = ({
key={`nd-filter-${filter.label}`} key={`nd-filter-${filter.label}`}
> >
<Text>{filter.label}</Text> <Text>{filter.label}</Text>
<Switch <YesNoSelect
checked={filter?.value || false}
onChange={filter.onChange} onChange={filter.onChange}
size="xs" size="xs"
value={filter.value}
/> />
</Group> </Group>
))} ))}
@@ -467,7 +467,7 @@ export const SongListHeaderFilters = ({
.filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio .filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio
.some((value) => value !== undefined); .some((value) => value !== undefined);
const isGenericFilterApplied = filter?.favorite || filter?.genreIds?.length; const isGenericFilterApplied = filter?.favorite !== undefined || filter?.genreIds?.length;
return isNavidromeFilterApplied || isJellyfinFilterApplied || isGenericFilterApplied; return isNavidromeFilterApplied || isJellyfinFilterApplied || isGenericFilterApplied;
}, [ }, [
@@ -69,7 +69,7 @@ export const SubsonicSongFilters = ({
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
favorite: e.target.checked, favorite: e.target.checked ? true : undefined,
}, },
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
key: pageKey, key: pageKey,
@@ -49,7 +49,7 @@ export const useFastAverageColor = (args: {
return setBackground(color.rgb); return setBackground(color.rgb);
}) })
.catch((e) => { .catch((e) => {
console.log('Error fetching average color', e); console.error('Error fetching average color', e);
idRef.current = id; idRef.current = id;
return setBackground('rgba(0, 0, 0, 0)'); return setBackground('rgba(0, 0, 0, 0)');
}); });
+39 -22
View File
@@ -1,40 +1,57 @@
import isElectron from 'is-electron';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { useCurrentServer } from '/@/renderer/store'; import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { AuthState, ServerListItem, ServerType } from '/@/shared/types/types'; import { AuthState, ServerListItem, ServerType } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null;
export const useServerAuthenticated = () => { export const useServerAuthenticated = () => {
const priorServerId = useRef<string | undefined>(undefined); const priorServerId = useRef<string | undefined>(undefined);
const server = useCurrentServer(); const server = useCurrentServer();
const [ready, setReady] = useState( const [ready, setReady] = useState(
server?.type === ServerType.NAVIDROME ? AuthState.LOADING : AuthState.VALID, server?.type === ServerType.NAVIDROME ? AuthState.VALID : AuthState.VALID,
); );
const authenticateNavidrome = useCallback(async (server: ServerListItem) => { const { updateServer } = useAuthStoreActions();
// This trick works because navidrome-api.ts will internally check for authentication
// failures and try to log in again (where available). So, all that's necessary is
// making one request first
try {
await api.controller.getSongList({
apiClientProps: { server },
query: {
limit: 1,
sortBy: SongListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
});
setReady(AuthState.VALID); const authenticateNavidrome = useCallback(
} catch (error) { async (server: ServerListItem) => {
toast.error({ message: (error as Error).message }); // This trick works because navidrome-api.ts will internally check for authentication
setReady(AuthState.INVALID); // failures and try to log in again (where available). So, all that's necessary is
} // making one request first
}, []); try {
await api.controller.getSongList({
apiClientProps: { server },
query: {
limit: 1,
sortBy: SongListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
});
setReady(AuthState.VALID);
} catch (error) {
// Clear server credentials (and saved password).
if (server.savePassword && localSettings) {
localSettings.passwordRemove(server.id);
}
server.credential = '';
updateServer(server.id, server);
toast.error({ message: (error as Error).message });
setReady(AuthState.INVALID);
}
},
[updateServer],
);
const debouncedAuth = debounce((server: ServerListItem) => { const debouncedAuth = debounce((server: ServerListItem) => {
authenticateNavidrome(server).catch(console.error); authenticateNavidrome(server).catch(console.error);
+4 -1
View File
@@ -34,7 +34,10 @@ export function IsUpdatedDialog() {
> >
<Stack> <Stack>
<Text>{t('common.newVersion', { postProcess: 'sentenceCase', version })}</Text> <Text>{t('common.newVersion', { postProcess: 'sentenceCase', version })}</Text>
<Group wrap="nowrap"> <Group
justify="flex-end"
wrap="nowrap"
>
<Button <Button
component="a" component="a"
href={`https://github.com/jeffvli/feishin/releases/tag/v${version}`} href={`https://github.com/jeffvli/feishin/releases/tag/v${version}`}
@@ -1,5 +1,6 @@
.container { .container {
position: relative; position: relative;
grid-area: sidebar; grid-area: sidebar;
background: var(--theme-colors-background-alternate);
border-right: 1px solid alpha(var(--theme-colors-border), 0.3); border-right: 1px solid alpha(var(--theme-colors-border), 0.3);
} }
+6
View File
@@ -200,6 +200,7 @@ export interface SettingsState {
clientId: string; clientId: string;
enabled: boolean; enabled: boolean;
showAsListening: boolean; showAsListening: boolean;
showPaused: boolean;
showServerImage: boolean; showServerImage: boolean;
}; };
font: { font: {
@@ -266,6 +267,7 @@ export interface SettingsState {
fontSizeUnsync: number; fontSizeUnsync: number;
gap: number; gap: number;
gapUnsync: number; gapUnsync: number;
preferLocalLyrics: boolean;
showMatch: boolean; showMatch: boolean;
showProvider: boolean; showProvider: boolean;
sources: LyricSource[]; sources: LyricSource[];
@@ -280,6 +282,7 @@ export interface SettingsState {
mpvExtraParameters: string[]; mpvExtraParameters: string[];
mpvProperties: MpvSettings; mpvProperties: MpvSettings;
muted: boolean; muted: boolean;
preservePitch: boolean;
scrobble: { scrobble: {
enabled: boolean; enabled: boolean;
scrobbleAtDuration: number; scrobbleAtDuration: number;
@@ -351,6 +354,7 @@ const initialState: SettingsState = {
clientId: '1165957668758900787', clientId: '1165957668758900787',
enabled: false, enabled: false,
showAsListening: false, showAsListening: false,
showPaused: true,
showServerImage: false, showServerImage: false,
}, },
font: { font: {
@@ -448,6 +452,7 @@ const initialState: SettingsState = {
fontSizeUnsync: 24, fontSizeUnsync: 24,
gap: 24, gap: 24,
gapUnsync: 24, gapUnsync: 24,
preferLocalLyrics: true,
showMatch: true, showMatch: true,
showProvider: true, showProvider: true,
sources: [LyricSource.NETEASE, LyricSource.LRCLIB], sources: [LyricSource.NETEASE, LyricSource.LRCLIB],
@@ -471,6 +476,7 @@ const initialState: SettingsState = {
replayGainPreampDB: 0, replayGainPreampDB: 0,
}, },
muted: false, muted: false,
preservePitch: true,
scrobble: { scrobble: {
enabled: true, enabled: true,
scrobbleAtDuration: 240, scrobbleAtDuration: 240,
+12 -3
View File
@@ -50,7 +50,10 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
useEffect(() => { useEffect(() => {
if (type === FontType.SYSTEM && system) { if (type === FontType.SYSTEM && system) {
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty('--theme-content-font-family', 'dynamic-font'); root.style.setProperty(
'--theme-content-font-family',
'dynamic-font, "Noto Sans JP", sans-serif',
);
if (!textStyleRef.current) { if (!textStyleRef.current) {
textStyleRef.current = document.createElement('style'); textStyleRef.current = document.createElement('style');
@@ -64,7 +67,10 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
}`; }`;
} else if (type === FontType.CUSTOM && custom) { } else if (type === FontType.CUSTOM && custom) {
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty('--theme-content-font-family', 'dynamic-font'); root.style.setProperty(
'--theme-content-font-family',
'dynamic-font, "Noto Sans JP", sans-serif',
);
if (!textStyleRef.current) { if (!textStyleRef.current) {
textStyleRef.current = document.createElement('style'); textStyleRef.current = document.createElement('style');
@@ -78,7 +84,10 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
}`; }`;
} else { } else {
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty('--theme-content-font-family', builtIn); root.style.setProperty(
'--theme-content-font-family',
`${builtIn}, "Noto Sans JP", sans-serif`,
);
} }
}, [builtIn, custom, system, type]); }, [builtIn, custom, system, type]);
@@ -53,6 +53,11 @@
&:focus-visible { &:focus-visible {
background: darken(var(--theme-colors-primary-filled), 10%); background: darken(var(--theme-colors-primary-filled), 10%);
} }
svg {
color: var(--theme-colors-primary-contrast);
fill: var(--theme-colors-primary-contrast);
}
} }
&[data-variant='subtle'] { &[data-variant='subtle'] {
@@ -60,8 +65,15 @@
background: transparent; background: transparent;
&:hover, &:hover,
&:active,
&:focus-visible { &:focus-visible {
background: lighten(var(--theme-colors-surface), 10%); @mixin dark {
background: lighten(var(--theme-colors-background), 5%);
}
@mixin light {
background: darken(var(--theme-colors-background), 5%);
}
} }
} }
@@ -92,7 +92,13 @@
&:hover, &:hover,
&:active, &:active,
&:focus-visible { &:focus-visible {
background-color: lighten(var(--button-bg), 10%); @mixin dark {
background-color: lighten(var(--theme-colors-background), 10%);
}
@mixin light {
background-color: darken(var(--theme-colors-background), 5%);
}
} }
} }
@@ -100,11 +106,11 @@
border: 1px solid transparent; border: 1px solid transparent;
&:hover { &:hover {
background-color: darken(var(--button-bg), 5%); background-color: darken(var(--theme-colors-background), 5%);
} }
&:focus-visible { &:focus-visible {
background-color: darken(var(--button-bg), 10%); background-color: darken(var(--theme-colors-background), 10%);
} }
} }
@@ -70,6 +70,10 @@
fill: var(--theme-colors-foreground); fill: var(--theme-colors-foreground);
} }
.fill-contrast {
fill: var(--theme-colors-primary-contrast);
}
.fill-inherit { .fill-inherit {
fill: inherit; fill: inherit;
} }
+12 -2
View File
@@ -224,11 +224,21 @@ export const AppIcon = {
export interface IconProps extends Omit<IconBaseProps, 'color' | 'fill' | 'size'> { export interface IconProps extends Omit<IconBaseProps, 'color' | 'fill' | 'size'> {
animate?: 'pulse' | 'spin'; animate?: 'pulse' | 'spin';
color?: 'default' | 'error' | 'info' | 'inherit' | 'muted' | 'primary' | 'success' | 'warn'; color?: IconColor;
fill?: 'default' | 'error' | 'info' | 'inherit' | 'muted' | 'primary' | 'success' | 'warn'; fill?: IconColor;
icon: keyof typeof AppIcon; icon: keyof typeof AppIcon;
size?: '2xl' | '3xl' | '4xl' | '5xl' | 'lg' | 'md' | 'sm' | 'xl' | 'xs' | number | string; size?: '2xl' | '3xl' | '4xl' | '5xl' | 'lg' | 'md' | 'sm' | 'xl' | 'xs' | number | string;
} }
type IconColor =
| 'contrast'
| 'default'
| 'error'
| 'info'
| 'inherit'
| 'muted'
| 'primary'
| 'success'
| 'warn';
export const Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => { export const Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => {
const { animate, className, color, fill, icon, size = 'md' } = props; const { animate, className, color, fill, icon, size = 'md' } = props;
+37 -7
View File
@@ -1,16 +1,18 @@
import type { ImgHTMLAttributes } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { motion, MotionConfigProps } from 'motion/react';
import { type ImgHTMLAttributes } from 'react';
import { Img } from 'react-image'; import { Img } from 'react-image';
import styles from './image.module.css'; import styles from './image.module.css';
import { animationProps } from '/@/shared/components/animations/animation-props';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Skeleton } from '/@/shared/components/skeleton/skeleton';
interface ImageContainerProps { interface ImageContainerProps extends MotionConfigProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
enableAnimation?: boolean;
} }
interface ImageLoaderProps { interface ImageLoaderProps {
@@ -19,6 +21,8 @@ interface ImageLoaderProps {
interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> { interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
containerClassName?: string; containerClassName?: string;
enableAnimation?: boolean;
imageContainerProps?: Omit<ImageContainerProps, 'children'>;
includeLoader?: boolean; includeLoader?: boolean;
includeUnloader?: boolean; includeUnloader?: boolean;
src: string | string[] | undefined; src: string | string[] | undefined;
@@ -32,6 +36,8 @@ interface ImageUnloaderProps {
export function Image({ export function Image({
className, className,
containerClassName, containerClassName,
enableAnimation,
imageContainerProps,
includeLoader = true, includeLoader = true,
includeUnloader = true, includeUnloader = true,
src, src,
@@ -41,7 +47,13 @@ export function Image({
<Img <Img
className={clsx(styles.image, className)} className={clsx(styles.image, className)}
container={(children) => ( container={(children) => (
<ImageContainer className={containerClassName}>{children}</ImageContainer> <ImageContainer
className={containerClassName}
enableAnimation={enableAnimation}
{...imageContainerProps}
>
{children}
</ImageContainer>
)} )}
loader={ loader={
includeLoader ? ( includeLoader ? (
@@ -50,7 +62,6 @@ export function Image({
</ImageContainer> </ImageContainer>
) : null ) : null
} }
loading="lazy"
src={src} src={src}
unloader={ unloader={
includeUnloader ? ( includeUnloader ? (
@@ -66,8 +77,27 @@ export function Image({
return <ImageUnloader />; return <ImageUnloader />;
} }
function ImageContainer({ children, className }: ImageContainerProps) { function ImageContainer({ children, className, enableAnimation, ...props }: ImageContainerProps) {
return <div className={clsx(styles.imageContainer, className)}>{children}</div>; if (!enableAnimation) {
return (
<div
className={clsx(styles.imageContainer, className)}
{...props}
>
{children}
</div>
);
}
return (
<motion.div
className={clsx(styles.imageContainer, className)}
{...animationProps.fadeIn}
{...props}
>
{children}
</motion.div>
);
} }
function ImageLoader({ className }: ImageLoaderProps) { function ImageLoader({ className }: ImageLoaderProps) {
@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next';
import { Select, SelectProps } from '/@/shared/components/select/select';
export interface YesNoSelectProps extends Omit<SelectProps, 'data' | 'onChange' | 'value'> {
onChange: (e?: boolean) => void;
value?: boolean;
}
export const YesNoSelect = ({ onChange, value, ...props }: YesNoSelectProps) => {
const { t } = useTranslation();
return (
<Select
clearable
data={[
{
label: t('common.no', { postProcess: 'sentenceCase' }),
value: 'false',
},
{
label: t('common.yes', { postProcess: 'sentenceCase' }),
value: 'true',
},
]}
onChange={(e) => {
onChange(e ? e === 'true' : undefined);
}}
value={value !== undefined ? value.toString() : null}
{...props}
/>
);
};
+20 -29
View File
@@ -11,7 +11,8 @@
text-rendering: optimizelegibility; text-rendering: optimizelegibility;
-webkit-tap-highlight-color: rgb(0 0 0 / 0%); -webkit-tap-highlight-color: rgb(0 0 0 / 0%);
text-size-adjust: none; text-size-adjust: none;
outline: none; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
body { body {
@@ -122,59 +123,56 @@ button {
@font-face { @font-face {
font-family: Archivo; font-family: Archivo;
font-weight: 100 1000; font-weight: 100 1000;
src: url('../../renderer/fonts/Archivo-VariableFont_wdth,wght.ttf') src: url('../../../assets/fonts/Archivo-VariableFont_wdth,wght.ttf');
format('truetype-variations');
} }
@font-face { @font-face {
font-family: Raleway; font-family: Raleway;
font-weight: 100 1000; font-weight: 100 1000;
src: url('../../renderer/fonts/Raleway-VariableFont_wght.ttf') format('truetype-variations'); src: url('../../../assets/fonts/Raleway-VariableFont_wght.ttf');
} }
@font-face { @font-face {
font-family: Fredoka; font-family: Fredoka;
font-weight: 100 1000; font-weight: 100 1000;
src: url('../../renderer/fonts/Fredoka-VariableFont_wdth,wght.ttf') src: url('../../../assets/fonts/Fredoka-VariableFont_wdth,wght.ttf');
format('truetype-variations');
} }
@font-face { @font-face {
font-family: 'League Spartan'; font-family: 'League Spartan';
font-weight: 100 1000; font-weight: 100 1000;
src: url('../../renderer/fonts/LeagueSpartan-VariableFont_wght.ttf') src: url('../../../assets/fonts/LeagueSpartan-VariableFont_wght.ttf');
format('truetype-variations');
} }
@font-face { @font-face {
font-family: Lexend; font-family: Lexend;
font-weight: 100 1000; font-weight: 100 1000;
src: url('../../renderer/fonts/Lexend-VariableFont_wght.ttf') format('truetype-variations'); src: url('../../../assets/fonts/Lexend-VariableFont_wght.ttf');
} }
@font-face { @font-face {
font-family: Inter; font-family: Inter;
font-weight: 100 1000; font-weight: 100 1000;
src: url('../../renderer/fonts/Inter-VariableFont_slnt,wght.ttf') format('truetype-variations'); src: url('../../../assets/fonts/Inter-VariableFont_slnt,wght.ttf');
} }
@font-face { @font-face {
font-family: Sora; font-family: Sora;
font-weight: 100 1000; font-weight: 100 1000;
src: url('../../renderer/fonts/Sora-VariableFont_wght.ttf') format('truetype-variations'); src: url('../../../assets/fonts/Sora-VariableFont_wght.ttf');
} }
@font-face { @font-face {
font-family: 'Work Sans'; font-family: 'Work Sans';
font-weight: 100 1000; font-weight: 100 1000;
src: url('../../renderer/fonts/WorkSans-VariableFont_wght.ttf') format('truetype-variations'); src: url('../../../assets/fonts/WorkSans-VariableFont_wght.ttf');
} }
@font-face { @font-face {
font-family: Poppins; font-family: Poppins;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: url('../../renderer/fonts/Poppins-Regular.ttf') format('truetype'); src: url('../../../assets/fonts/Poppins-Regular.ttf');
font-display: swap; font-display: swap;
} }
@@ -182,7 +180,7 @@ button {
font-family: Poppins; font-family: Poppins;
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
src: url('../../renderer/fonts/Poppins-SemiBold.ttf') format('truetype'); src: url('../../../assets/fonts/Poppins-SemiBold.ttf');
font-display: swap; font-display: swap;
} }
@@ -190,7 +188,7 @@ button {
font-family: Poppins; font-family: Poppins;
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
src: url('../../renderer/fonts/Poppins-Bold.ttf') format('truetype'); src: url('../../../assets/fonts/Poppins-Bold.ttf');
font-display: swap; font-display: swap;
} }
@@ -198,7 +196,7 @@ button {
font-family: Poppins; font-family: Poppins;
font-style: normal; font-style: normal;
font-weight: 800; font-weight: 800;
src: url('../../renderer/fonts/Poppins-ExtraBold.ttf') format('truetype'); src: url('../../../assets/fonts/Poppins-ExtraBold.ttf');
font-display: swap; font-display: swap;
} }
@@ -206,28 +204,21 @@ button {
font-family: Poppins; font-family: Poppins;
font-style: normal; font-style: normal;
font-weight: 900; font-weight: 900;
src: url('../../renderer/fonts/Poppins-Black.ttf') format('truetype'); src: url('../../../assets/fonts/Poppins-Black.ttf');
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: Raleway; font-family: Raleway;
font-weight: 100 1000; font-weight: 100 1000;
src: url('../../renderer/fonts/Raleway-VariableFont_wght.ttf') format('truetype-variations'); src: url('../../../assets/fonts/Raleway-VariableFont_wght.ttf');
} }
@font-face { @font-face {
font-family: DroidSerif; font-family: 'Noto Sans JP';
src: url('https://rawgit.com/google/fonts/master/ufl/ubuntumono/UbuntuMono-Italic.ttf') font-weight: 100 900;
format('truetype'); src: url('../../../assets/fonts/NotoSansJP-VariableFont_wght.ttf');
unicode-range: U+000-5FF; /* Latin glyphs */ unicode-range: U+3000-9FFF, U+FF00-FFEF; /* Japanese characters */
}
@font-face {
font-family: DroidSerif;
src: url('https://fonts.gstatic.com/ea/notosansjp/v5/NotoSansJP-Regular.woff2')
format('truetype');
unicode-range: U+3000-9FFF, U+ff??; /* Japanese glyphs */
} }
:root { :root {

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