Compare commits

...

45 Commits

Author SHA1 Message Date
Hosted Weblate 324936e0c8 Translated using Weblate
Currently translated at 100.0% (1208 of 1208 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

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

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

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

Translated using Weblate

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

Translated using Weblate

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

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

Translated using Weblate

Currently translated at 100.0% (1206 of 1206 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 85.6% (1033 of 1206 strings) (Basque)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/eu/

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

Translated using Weblate

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

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

* force aspect ratio to be square

* prevent image container from expanding

---------

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

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

Translated using Weblate

Currently translated at 100.0% (1205 of 1205 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

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

Translated using Weblate

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

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

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

Translated using Weblate

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

Translated using Weblate

Currently translated at 100.0% (1203 of 1203 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

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

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

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

Translated using Weblate

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

Translated using Weblate

Currently translated at 100.0% (1201 of 1201 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

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

Translated using Weblate

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

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

* increase node memory limit on macOS build

* fix invalid dynamic imports in renderer

* remove discord-rpc import in renderer
2026-04-10 01:54:11 -07:00
Kendall Garner 2a6e9b6ad3 add extendInfo to alpha/beta builders 2026-04-08 07:28:34 -07:00
Kendall Garner 167b42df2b Merge pull request #1926 from noctuum/fix/wayland-screen-share-dialog
fix(linux): remove unnecessary screen capture from audio loopback handler
2026-04-08 02:11:36 +00:00
jeffvli e6a2bc3acf disable useTransition in router again 2026-04-07 18:21:32 -07:00
jeffvli ca3c7015c6 add fallback to direct streamURL if getTranscodeDecision fails 2026-04-07 18:14:47 -07:00
jeffvli c7c15d917a isolate item card control renders 2026-04-07 18:14:47 -07:00
noctuum 6adb29bc38 fix(linux): remove unnecessary desktopCapturer call from display media handler
The setDisplayMediaRequestHandler was calling desktopCapturer.getSources()
to provide a video source that the renderer never uses (it requests
video: false and only consumes audio tracks). On Wayland, this created a
new xdg-desktop-portal ScreenCast session on every launch, showing an
unavoidable screen share dialog because Electron does not persist
PipeWire restore tokens across desktopCapturer sessions.

Simplified the handler to return only { audio: 'loopback' }, which
captures system audio via PipeWire/PulseAudio monitor source without
any portal interaction.
2026-04-08 04:56:05 +07:00
Hosted Weblate 2c3cd7af24 Translated using Weblate
Currently translated at 94.6% (1136 of 1200 strings) (German)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/de/

Translated using Weblate

Currently translated at 100.0% (1200 of 1200 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 100.0% (1200 of 1200 strings) (Japanese)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ja/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: Zarakkas <kaz@users.noreply.hosted.weblate.org>
Co-authored-by: karigane <169052233+karigane-cha@users.noreply.github.com>
Co-authored-by: lorduskordus <lorduskordus@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-04-07 08:10:05 +02:00
jeffvli 3c442a2d40 update to v1.11.0 2026-04-06 17:10:18 -07:00
Andrzej Voss d67c185c93 feat: Make "Clear" button "Refresh" when there are no lyrics found. (#1920)
Ref: effvli/feishin#1919 - tl;dr: Button actually reloads/refreshes
lyrics info from the server too, it makes it, well, clearer what it does
in that case - allows to reread lyrics from server without clearing whole cache.
2026-04-06 16:59:01 -07:00
jeffvli ff96a5f121 lint 2026-04-06 12:06:55 -07:00
jeffvli 6fc7b6b271 support image drop for upload 2026-04-06 11:41:33 -07:00
jeffvli 918f453066 support navidrome artist image upload/delete 2026-04-06 11:41:26 -07:00
jeffvli 4a986069f8 set flac as default transcoding profile 2026-04-06 10:58:37 -07:00
jeffvli 11d26af893 remove arm/v7 from container build 2026-04-06 09:47:28 -07:00
108 changed files with 2307 additions and 866 deletions
+2 -2
View File
@@ -121,7 +121,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [windows-latest, macos-latest, ubuntu-latest] os: [windows-latest, macos-26, ubuntu-latest]
steps: steps:
- name: Checkout git repo - name: Checkout git repo
@@ -156,7 +156,7 @@ jobs:
on_retry_command: pnpm cache delete on_retry_command: pnpm cache delete
- name: Build and Publish to R2 (macOS) - name: Build and Publish to R2 (macOS)
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-26'
uses: nick-invision/retry@v3.0.2 uses: nick-invision/retry@v3.0.2
with: with:
timeout_minutes: 30 timeout_minutes: 30
+2 -2
View File
@@ -115,7 +115,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [windows-latest, macos-latest, ubuntu-latest] os: [windows-latest, macos-26, ubuntu-latest]
steps: steps:
- name: Checkout git repo - name: Checkout git repo
@@ -156,7 +156,7 @@ jobs:
on_retry_command: pnpm cache delete on_retry_command: pnpm cache delete
- name: Build and Publish releases (macOS) - name: Build and Publish releases (macOS)
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-26'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v3.0.2 uses: nick-invision/retry@v3.0.2
@@ -51,5 +51,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: | platforms: |
linux/amd64 linux/amd64
linux/arm/v7
linux/arm64/v8 linux/arm64/v8
+2 -1
View File
@@ -8,7 +8,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [macos-latest] os: [macos-26]
steps: steps:
- name: Checkout git repo - name: Checkout git repo
@@ -24,6 +24,7 @@ jobs:
- name: Build and Publish releases - name: Build and Publish releases
env: env:
NODE_OPTIONS: --max-old-space-size=4096
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v3.0.2 uses: nick-invision/retry@v3.0.2
with: with:
+4 -4
View File
@@ -30,7 +30,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [macos-latest, ubuntu-latest, windows-latest] os: [macos-26, ubuntu-latest, windows-latest]
steps: steps:
- name: Checkout git repo - name: Checkout git repo
@@ -65,7 +65,7 @@ jobs:
pnpm run package:linux:pr pnpm run package:linux:pr
- name: Build for MacOS - name: Build for MacOS
if: ${{ matrix.os == 'macos-latest' }} if: ${{ matrix.os == 'macos-26' }}
uses: nick-invision/retry@v3.0.2 uses: nick-invision/retry@v3.0.2
with: with:
timeout_minutes: 30 timeout_minutes: 30
@@ -86,7 +86,7 @@ jobs:
zip -r dist/linux-binaries.zip dist/*.{AppImage,deb,rpm} zip -r dist/linux-binaries.zip dist/*.{AppImage,deb,rpm}
- name: Zip MacOS Binaries - name: Zip MacOS Binaries
if: ${{ matrix.os == 'macos-latest' }} if: ${{ matrix.os == 'macos-26' }}
run: | run: |
zip -r dist/macos-binaries.zip dist/*.dmg zip -r dist/macos-binaries.zip dist/*.dmg
@@ -105,7 +105,7 @@ jobs:
path: dist/linux-binaries.zip path: dist/linux-binaries.zip
- name: Upload MacOS Binaries - name: Upload MacOS Binaries
if: ${{ matrix.os == 'macos-latest' }} if: ${{ matrix.os == 'macos-26' }}
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: macos-binaries name: macos-binaries
+2 -2
View File
@@ -8,7 +8,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [windows-latest, macos-latest, ubuntu-latest] os: [windows-latest, macos-26, ubuntu-latest]
steps: steps:
- name: Checkout git repo - name: Checkout git repo
@@ -36,7 +36,7 @@ jobs:
on_retry_command: pnpm cache delete on_retry_command: pnpm cache delete
- name: Build and Publish releases (macOS) - name: Build and Publish releases (macOS)
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-26'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v3.0.2 uses: nick-invision/retry@v3.0.2
Binary file not shown.
+6 -4
View File
@@ -40,13 +40,15 @@ mac:
arch: arch:
- arm64 - arm64
- x64 - x64
icon: assets/icons/icon.icns icon: media/feishin.icon
type: distribution type: distribution
hardenedRuntime: false hardenedRuntime: false
identity: "-" identity: '-'
gatekeeperAssess: false gatekeeperAssess: false
notarize: false notarize: false
extendInfo:
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg: dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }] contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -61,7 +63,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext} artifactName: ${productName}-${os}-${arch}.${ext}
toolsets: toolsets:
appimage: "1.0.2" appimage: '1.0.2'
npmRebuild: false npmRebuild: false
+6 -3
View File
@@ -40,12 +40,15 @@ mac:
arch: arch:
- arm64 - arm64
- x64 - x64
icon: assets/icons/icon.icns icon: media/feishin.icon
type: distribution type: distribution
hardenedRuntime: false hardenedRuntime: false
identity: "-" identity: '-'
gatekeeperAssess: false gatekeeperAssess: false
notarize: false notarize: false
extendInfo:
NSAudioCaptureUsageDescription: "System audio access is required for mpv visualizer capture in Feishin"
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg: dmg:
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }] contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
@@ -60,7 +63,7 @@ linux:
artifactName: ${productName}-${os}-${arch}.${ext} artifactName: ${productName}-${os}-${arch}.${ext}
toolsets: toolsets:
appimage: "1.0.2" appimage: '1.0.2'
npmRebuild: false npmRebuild: false
publish: publish:
+2 -1
View File
@@ -40,13 +40,14 @@ mac:
arch: arch:
- arm64 - arm64
- x64 - x64
icon: assets/icons/icon.icns icon: media/feishin.icon
type: distribution type: distribution
hardenedRuntime: false hardenedRuntime: false
identity: '-' identity: '-'
gatekeeperAssess: false gatekeeperAssess: false
notarize: false notarize: false
extendInfo: extendInfo:
NSAudioCaptureUsageDescription: 'System audio access is required for mpv visualizer capture in Feishin'
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin' NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
dmg: dmg:
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512"><g style="display:inline" transform="translate(-53.452 -43.352)scale(1.11813)"><circle cx="256" cy="240.312" r="21.5" style="opacity:1;fill:#000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.19597;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers fill stroke;filter:url(#filter249)"/><path d="M220.85 277.951 183.5 315.6l36 36.1 20-19.7s5.856-6.2 16.5-6.2 16.5 6.2 16.5 6.2l20 19.7 36-36.1-37.35-37.649A51.5 51.5 0 0 1 256 291.812a51.5 51.5 0 0 1-35.15-13.86" style="opacity:1;fill:#000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter249)"/><path d="M256 145.4a25.7 25.7 0 0 0-18.229 7.551L66.97 323.47A25.42 25.42 0 0 0 59.5 341.5c0 14.083 11.417 25.5 25.5 25.5a25.42 25.42 0 0 0 18.031-7.469l103.895-103.597a51.5 51.5 0 0 1-2.426-15.621 51.5 51.5 0 0 1 51.5-51.5 51.5 51.5 0 0 1 51.5 51.5 51.5 51.5 0 0 1-2.426 15.62L408.97 359.532A25.42 25.42 0 0 0 427 367c14.083 0 25.5-11.417 25.5-25.5a25.42 25.42 0 0 0-7.469-18.031L274.23 152.95a25.7 25.7 0 0 0-18.229-7.55" style="display:inline;opacity:1;fill:#000;fill-opacity:1;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;paint-order:markers fill stroke;filter:url(#filter249)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+202
View File
@@ -0,0 +1,202 @@
{
"fill-specializations" : [
{
"value" : {
"linear-gradient" : [
"display-p3:0.87416,0.87416,0.87416,1.00000",
"display-p3:0.99575,0.99575,0.99575,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 1
},
"stop" : {
"x" : 0.5,
"y" : 0.3
}
}
}
},
{
"appearance" : "dark",
"value" : "system-dark"
}
],
"groups" : [
{
"blend-mode-specializations" : [
{
"appearance" : "tinted",
"value" : "normal"
}
],
"blur-material-specializations" : [
{
"value" : 0.7
},
{
"appearance" : "dark",
"value" : 0.7
},
{
"appearance" : "tinted",
"value" : null
}
],
"hidden" : false,
"layers" : [
{
"blend-mode-specializations" : [
{
"appearance" : "tinted",
"value" : "normal"
}
],
"fill-specializations" : [
{
"value" : {
"solid" : "extended-gray:0.00000,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"linear-gradient" : [
"display-p3:0.78674,0.78674,0.78674,1.00000",
"display-p3:0.87416,0.87416,0.87416,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 1
},
"stop" : {
"x" : 0.5,
"y" : 0
}
}
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "gray:1.00000,1.00000"
}
}
],
"glass-specializations" : [
{
"value" : true
},
{
"appearance" : "dark",
"value" : true
},
{
"appearance" : "tinted",
"value" : true
}
],
"hidden" : false,
"image-name" : "feishin.svg",
"name" : "feishin",
"opacity-specializations" : [
{
"value" : 1
},
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.79,
"translation-in-points" : [
18,
-2
]
}
}
],
"lighting-specializations" : [
{
"value" : "individual"
},
{
"appearance" : "tinted",
"value" : "combined"
}
],
"position" : {
"scale" : 2.2,
"translation-in-points" : [
0,
0
]
},
"shadow-specializations" : [
{
"value" : {
"kind" : "neutral",
"opacity" : 1
}
},
{
"appearance" : "dark",
"value" : {
"kind" : "layer-color",
"opacity" : 0.5
}
},
{
"appearance" : "tinted",
"value" : {
"kind" : "neutral",
"opacity" : 1
}
}
],
"specular-specializations" : [
{
"value" : false
},
{
"appearance" : "dark",
"value" : false
},
{
"appearance" : "tinted",
"value" : true
}
],
"translucency-specializations" : [
{
"value" : {
"enabled" : true,
"value" : 0.29
}
},
{
"appearance" : "dark",
"value" : {
"enabled" : false,
"value" : 0.29
}
},
{
"appearance" : "tinted",
"value" : {
"enabled" : true,
"value" : 0.5
}
}
]
}
],
"supported-platforms" : {
"squares" : [
"macOS"
]
}
}
+8 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "feishin", "name": "feishin",
"version": "1.10.0", "version": "1.11.0",
"description": "A modern self-hosted music player.", "description": "A modern self-hosted music player.",
"keywords": [ "keywords": [
"subsonic", "subsonic",
@@ -78,13 +78,13 @@
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@mantine/colors-generator": "^8.3.18", "@mantine/colors-generator": "^9.1.1",
"@mantine/core": "^8.3.18", "@mantine/core": "^9.1.1",
"@mantine/dates": "^8.3.18", "@mantine/dates": "^9.1.1",
"@mantine/form": "^8.3.18", "@mantine/form": "^9.1.1",
"@mantine/hooks": "^8.3.18", "@mantine/hooks": "^9.1.1",
"@mantine/modals": "^8.3.18", "@mantine/modals": "^9.1.1",
"@mantine/notifications": "^8.3.18", "@mantine/notifications": "^9.1.1",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@tanstack/react-query": "^5.96.2", "@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-devtools": "^5.96.2", "@tanstack/react-query-devtools": "^5.96.2",
+96 -131
View File
@@ -28,26 +28,26 @@ importers:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0(electron@39.8.6) version: 4.0.0(electron@39.8.6)
'@mantine/colors-generator': '@mantine/colors-generator':
specifier: ^8.3.18 specifier: ^9.1.1
version: 8.3.18(chroma-js@3.1.2) version: 9.1.1(chroma-js@3.1.2)
'@mantine/core': '@mantine/core':
specifier: ^8.3.18 specifier: ^9.1.1
version: 8.3.18(@mantine/hooks@8.3.18(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/dates': '@mantine/dates':
specifier: ^8.3.18 specifier: ^9.1.1
version: 8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.18(react@19.2.4))(dayjs@1.11.20)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@9.1.1(react@19.2.4))(dayjs@1.11.20)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/form': '@mantine/form':
specifier: ^8.3.18 specifier: ^9.1.1
version: 8.3.18(react@19.2.4) version: 9.1.1(react@19.2.4)
'@mantine/hooks': '@mantine/hooks':
specifier: ^8.3.18 specifier: ^9.1.1
version: 8.3.18(react@19.2.4) version: 9.1.1(react@19.2.4)
'@mantine/modals': '@mantine/modals':
specifier: ^8.3.18 specifier: ^9.1.1
version: 8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.18(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@9.1.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/notifications': '@mantine/notifications':
specifier: ^8.3.18 specifier: ^9.1.1
version: 8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.18(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@9.1.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-context-menu': '@radix-ui/react-context-menu':
specifier: ^2.2.16 specifier: ^2.2.16
version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1441,57 +1441,57 @@ packages:
resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
'@mantine/colors-generator@8.3.18': '@mantine/colors-generator@9.1.1':
resolution: {integrity: sha512-u7gNAuVD/WPvB49uNszfkn7lQr85OXTI0Ijkbutcymhv0/utqlapQvZlQAYHwdrrWpQgqPNLsDBt3VSheVe9jw==} resolution: {integrity: sha512-mzEkW9oBrtvnq3zHW1jISa5hNWGi2wRgo4/N0W96DKxTqS0E1IMQ13KWJM0I9er8MK9xZo69jlpR7SyztRYFaw==}
peerDependencies: peerDependencies:
chroma-js: '>=2.4.2' chroma-js: '>=2.4.2'
'@mantine/core@8.3.18': '@mantine/core@9.1.1':
resolution: {integrity: sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA==} resolution: {integrity: sha512-vClOZdCeZ4oLYuA/3jAOgKGQ6dXbF6ZkzpYz09Gied9nZpB7HcQeb3dcMh8UPBE4f+EM7KlYWk6dch7GoASeaA==}
peerDependencies: peerDependencies:
'@mantine/hooks': 8.3.18 '@mantine/hooks': 9.1.1
react: ^18.x || ^19.x react: ^19.2.0
react-dom: ^18.x || ^19.x react-dom: ^19.2.0
'@mantine/dates@8.3.18': '@mantine/dates@9.1.1':
resolution: {integrity: sha512-FHx5teJOhupI0gO2o5evtVYQEdqOjayOkLRhEQfB5Nc5DvcysfPfmNILGkc1Nrp9ZQeQWKLT9qr+CkcCXwHOaw==} resolution: {integrity: sha512-P1tr/Hr+EVxppbOVpTLvaZZnM1W/r0TNpqNNMeM81xfyuKYzd7zt2/SQYb6BuudgEQfRJnAee+7bIJLEsrb0uA==}
peerDependencies: peerDependencies:
'@mantine/core': 8.3.18 '@mantine/core': 9.1.1
'@mantine/hooks': 8.3.18 '@mantine/hooks': 9.1.1
dayjs: '>=1.0.0' dayjs: '>=1.0.0'
react: ^18.x || ^19.x react: ^19.2.0
react-dom: ^18.x || ^19.x react-dom: ^19.2.0
'@mantine/form@8.3.18': '@mantine/form@9.1.1':
resolution: {integrity: sha512-r5OGLJWTkmIruFjRZRZy9oA7maNYlyt50jB4Pmd2X5360WOmJLd4KH8MFhHZQC7vN+z8/rmBl3t3XGAR2I8xig==} resolution: {integrity: sha512-xmebZ3s8GGMrCOPOaOwA+gQkdgNVfT2F9kBtkjAbRoZrMoY+vYFbiPWbIvWFl8pU1jBslYZrj+M0PIawJmFOdQ==}
peerDependencies: peerDependencies:
react: ^18.x || ^19.x react: ^19.2.0
'@mantine/hooks@8.3.18': '@mantine/hooks@9.1.1':
resolution: {integrity: sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==} resolution: {integrity: sha512-tTJK73nGFyy1v214TLdvBq0be7QCoc6osfbXVuJgOH3YG85lWk9Mvvor6k+w6hC6HXSqKMqLKePyiGm83xGcMg==}
peerDependencies: peerDependencies:
react: ^18.x || ^19.x react: ^19.2.0
'@mantine/modals@8.3.18': '@mantine/modals@9.1.1':
resolution: {integrity: sha512-JfPDS4549L314SxFPC1x6CbKwzh82OdnIzwgMxPCVNsWLKV2vEHHUH/fzUYj4Wli6IBrsW4cufjMj9BTj3hm3Q==} resolution: {integrity: sha512-SjJ2kIheJaWoKMsSuYQlLFvuJTxCQTOl3gr+wDj/bLmGBgfUykLStRNm9s1H7vFxMIWtN20N8mwtcZV2dGeYBg==}
peerDependencies: peerDependencies:
'@mantine/core': 8.3.18 '@mantine/core': 9.1.1
'@mantine/hooks': 8.3.18 '@mantine/hooks': 9.1.1
react: ^18.x || ^19.x react: ^19.2.0
react-dom: ^18.x || ^19.x react-dom: ^19.2.0
'@mantine/notifications@8.3.18': '@mantine/notifications@9.1.1':
resolution: {integrity: sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw==} resolution: {integrity: sha512-ZfcEMMDp0BQ+yKmVp8ifPXLKej8pv9TcaRnmy2CZ07USD61E9LH5ClRAP/hxQuCyf/qLb5BPHsI7+f3K8uhj4Q==}
peerDependencies: peerDependencies:
'@mantine/core': 8.3.18 '@mantine/core': 9.1.1
'@mantine/hooks': 8.3.18 '@mantine/hooks': 9.1.1
react: ^18.x || ^19.x react: ^19.2.0
react-dom: ^18.x || ^19.x react-dom: ^19.2.0
'@mantine/store@8.3.18': '@mantine/store@9.1.1':
resolution: {integrity: sha512-i+QRTLmZzLldea0egtUVnGALd6UMIu8jd44nrNWBSNIXJU/8B6rMlC6gyX+l4szopZSuOaaNJIXkqRdC1gQsVg==} resolution: {integrity: sha512-kbxEU8wVGbobHlmQmk0lu9M+xCILKjuAPcMAshgzPznGLfXeE9zrB0gNT2cbk11Ik8dlV9J6Vsn9cuACyOSpfQ==}
peerDependencies: peerDependencies:
react: ^18.x || ^19.x react: ^19.2.0
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
@@ -1912,66 +1912,79 @@ packages:
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.1': '@rollup/rollup-linux-arm-musleabihf@4.60.1':
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.1': '@rollup/rollup-linux-arm64-gnu@4.60.1':
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.1': '@rollup/rollup-linux-arm64-musl@4.60.1':
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.1': '@rollup/rollup-linux-loong64-gnu@4.60.1':
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.1': '@rollup/rollup-linux-loong64-musl@4.60.1':
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.1': '@rollup/rollup-linux-ppc64-gnu@4.60.1':
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.1': '@rollup/rollup-linux-ppc64-musl@4.60.1':
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.1': '@rollup/rollup-linux-riscv64-gnu@4.60.1':
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.1': '@rollup/rollup-linux-riscv64-musl@4.60.1':
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.1': '@rollup/rollup-linux-s390x-gnu@4.60.1':
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.1': '@rollup/rollup-linux-x64-gnu@4.60.1':
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.1': '@rollup/rollup-linux-x64-musl@4.60.1':
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.1': '@rollup/rollup-openbsd-x64@4.60.1':
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
@@ -2015,6 +2028,9 @@ packages:
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@surma/rollup-plugin-off-main-thread@2.2.3': '@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -4710,12 +4726,6 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-textarea-autosize@8.5.9:
resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==}
engines: {node: '>=10'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-transition-group@4.4.5: react-transition-group@4.4.5:
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies: peerDependencies:
@@ -5358,6 +5368,10 @@ packages:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tar@7.5.13: tar@7.5.13:
resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -5454,9 +5468,9 @@ packages:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'} engines: {node: '>=12.20'}
type-fest@4.41.0: type-fest@5.6.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
engines: {node: '>=16'} engines: {node: '>=20'}
typed-array-buffer@1.0.3: typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
@@ -5569,33 +5583,6 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
use-composed-ref@1.4.0:
resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
use-isomorphic-layout-effect@1.2.1:
resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
use-latest@1.3.0:
resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
use-sidecar@1.1.3: use-sidecar@1.1.3:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -7111,60 +7098,60 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@mantine/colors-generator@8.3.18(chroma-js@3.1.2)': '@mantine/colors-generator@9.1.1(chroma-js@3.1.2)':
dependencies: dependencies:
chroma-js: 3.1.2 chroma-js: 3.1.2
'@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@floating-ui/react': 0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.18(react@19.2.4) '@mantine/hooks': 9.1.1(react@19.2.4)
clsx: 2.1.1 clsx: 2.1.1
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
react-number-format: 5.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-number-format: 5.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
react-textarea-autosize: 8.5.9(@types/react@19.2.14)(react@19.2.4) type-fest: 5.6.0
type-fest: 4.41.0
transitivePeerDependencies: transitivePeerDependencies:
- '@types/react' - '@types/react'
'@mantine/dates@8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.18(react@19.2.4))(dayjs@1.11.20)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@mantine/dates@9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@9.1.1(react@19.2.4))(dayjs@1.11.20)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@mantine/core': 8.3.18(@mantine/hooks@8.3.18(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@mantine/core': 9.1.1(@mantine/hooks@9.1.1(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.18(react@19.2.4) '@mantine/hooks': 9.1.1(react@19.2.4)
clsx: 2.1.1 clsx: 2.1.1
dayjs: 1.11.20 dayjs: 1.11.20
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
'@mantine/form@8.3.18(react@19.2.4)': '@mantine/form@9.1.1(react@19.2.4)':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
klona: 2.0.6 klona: 2.0.6
react: 19.2.4 react: 19.2.4
'@mantine/hooks@8.3.18(react@19.2.4)': '@mantine/hooks@9.1.1(react@19.2.4)':
dependencies: dependencies:
react: 19.2.4 react: 19.2.4
'@mantine/modals@8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.18(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@mantine/modals@9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@9.1.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@mantine/core': 8.3.18(@mantine/hooks@8.3.18(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@mantine/core': 9.1.1(@mantine/hooks@9.1.1(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.18(react@19.2.4) '@mantine/hooks': 9.1.1(react@19.2.4)
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
'@mantine/notifications@8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.18(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@mantine/notifications@9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@9.1.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@mantine/core': 8.3.18(@mantine/hooks@8.3.18(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@mantine/core': 9.1.1(@mantine/hooks@9.1.1(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.18(react@19.2.4) '@mantine/hooks': 9.1.1(react@19.2.4)
'@mantine/store': 8.3.18(react@19.2.4) '@mantine/store': 9.1.1(react@19.2.4)
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
react-transition-group: 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-transition-group: 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/store@8.3.18(react@19.2.4)': '@mantine/store@9.1.1(react@19.2.4)':
dependencies: dependencies:
react: 19.2.4 react: 19.2.4
@@ -7611,6 +7598,8 @@ snapshots:
'@sindresorhus/is@4.6.0': {} '@sindresorhus/is@4.6.0': {}
'@standard-schema/spec@1.1.0': {}
'@surma/rollup-plugin-off-main-thread@2.2.3': '@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies: dependencies:
ejs: 3.1.10 ejs: 3.1.10
@@ -10672,15 +10661,6 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@babel/runtime': 7.29.2
react: 19.2.4
use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4)
use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4)
transitivePeerDependencies:
- '@types/react'
react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
@@ -11435,6 +11415,8 @@ snapshots:
string-width: 4.2.3 string-width: 4.2.3
strip-ansi: 6.0.1 strip-ansi: 6.0.1
tagged-tag@1.0.0: {}
tar@7.5.13: tar@7.5.13:
dependencies: dependencies:
'@isaacs/fs-minipass': 4.0.1 '@isaacs/fs-minipass': 4.0.1
@@ -11544,7 +11526,9 @@ snapshots:
type-fest@2.19.0: {} type-fest@2.19.0: {}
type-fest@4.41.0: {} type-fest@5.6.0:
dependencies:
tagged-tag: 1.0.0
typed-array-buffer@1.0.3: typed-array-buffer@1.0.3:
dependencies: dependencies:
@@ -11664,25 +11648,6 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.4):
dependencies:
react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.4):
dependencies:
react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
use-latest@1.3.0(@types/react@19.2.14)(react@19.2.4):
dependencies:
react: 19.2.4
use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4):
dependencies: dependencies:
detect-node-es: 1.1.0 detect-node-es: 1.1.0
+42 -7
View File
@@ -139,7 +139,9 @@
"lyricOffset": "demora de la lletra (ms)", "lyricOffset": "demora de la lletra (ms)",
"showLyricMatch": "mosta coincidències de lletres", "showLyricMatch": "mosta coincidències de lletres",
"showLyricProvider": "mostra el proveïdor de la lletra", "showLyricProvider": "mostra el proveïdor de la lletra",
"lyricGap": "espera entre lletres" "lyricGap": "espera entre lletres",
"lyricOpacityNonActive": "Opacitat de la lletra inactiva",
"lyricScaleNonActive": "Escala de la lletra inactiva"
}, },
"lyrics": "lletres", "lyrics": "lletres",
"visualizer": "visualitzador", "visualizer": "visualitzador",
@@ -337,7 +339,8 @@
"filter_single": "senzill", "filter_single": "senzill",
"filter_multiple": "multi", "filter_multiple": "multi",
"rename": "reanomena", "rename": "reanomena",
"newVersionAvailable": "hi ha una nova versió disponible" "newVersionAvailable": "hi ha una nova versió disponible",
"numberOfResults": "{{numberOfResults}} resultats"
}, },
"entity": { "entity": {
"album_one": "àlbum", "album_one": "àlbum",
@@ -406,7 +409,8 @@
"input_skipDuplicates": "salta't els duplicats", "input_skipDuplicates": "salta't els duplicats",
"success": "s'ha afegit $t(entity.trackWithCount, {\"count\": {{message}} }) a $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })", "success": "s'ha afegit $t(entity.trackWithCount, {\"count\": {{message}} }) a $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"create": "crea $t(entity.playlist, {\"count\": 1}) {{playlist}}", "create": "crea $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "cerca $t(entity.playlist, {\"count\": 2}) o escriu per crear-ne una de nova" "searchOrCreate": "cerca $t(entity.playlist, {\"count\": 2}) o escriu per crear-ne una de nova",
"noneAdded": "no s'han afegit pistes a la $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
}, },
"createPlaylist": { "createPlaylist": {
"input_description": "$t(common.description)", "input_description": "$t(common.description)",
@@ -506,6 +510,9 @@
"export": "exporta la lletra", "export": "exporta la lletra",
"input_synced": "exporta la lletra sincronitzada", "input_synced": "exporta la lletra sincronitzada",
"input_offset": "$t(setting.lyricOffset)" "input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "Emissora de ràdio actualitzada amb èxit"
} }
}, },
"action": { "action": {
@@ -522,7 +529,10 @@
"goToPage": "anar a la pàgina", "goToPage": "anar a la pàgina",
"openIn": { "openIn": {
"lastfm": "Obrir a Last.fm", "lastfm": "Obrir a Last.fm",
"musicbrainz": "Obrir a MusicBrainz" "musicbrainz": "Obrir a MusicBrainz",
"listenbrainz": "Obre a ListenBrainz",
"qobuz": "Obre a Qobuz",
"spotify": "Obre a Spotify"
}, },
"deselectAll": "deselecciona-ho tot", "deselectAll": "deselecciona-ho tot",
"viewPlaylists": "veure $t(entity.playlist, {\"count\": 2})", "viewPlaylists": "veure $t(entity.playlist, {\"count\": 2})",
@@ -597,7 +607,7 @@
"artistConfiguration": "configuració de la pàgina de l'artista de l'àlbum", "artistConfiguration": "configuració de la pàgina de l'artista de l'àlbum",
"artistConfiguration_description": "configura quins elements es mostren i el seu ordre de la pàgina de l'artista de l'àlbum", "artistConfiguration_description": "configura quins elements es mostren i el seu ordre de la pàgina de l'artista de l'àlbum",
"audioExclusiveMode": "mode d'àudio exclusiu", "audioExclusiveMode": "mode d'àudio exclusiu",
"audioExclusiveMode_description": "activa el mode d'àudio exclusiu. En aquest mode, el sistema normalment estarà bloquejat i només mpv podrà emetre àudio", "audioExclusiveMode_description": "activa el mode d'àudio exclusiu. En aquest mode, el sistema normalment estarà bloquejat i només mpv podrà emetre àudio. El sistema visualitzador de captura d'àudio no funcionarà mentre això estigui activat",
"buttonSize": "mida dels botons de la barra de reproducció", "buttonSize": "mida dels botons de la barra de reproducció",
"buttonSize_description": "la mida dels botons de la barra de reproducció", "buttonSize_description": "la mida dels botons de la barra de reproducció",
"clearCache": "neteja la memòria del navegador", "clearCache": "neteja la memòria del navegador",
@@ -910,7 +920,25 @@
"primaryShade": "ombra primària", "primaryShade": "ombra primària",
"primaryShade_description": "substitueix el to primari (09) utilitzat per a botons, enllaços i altres elements de color primari", "primaryShade_description": "substitueix el to primari (09) utilitzat per a botons, enllaços i altres elements de color primari",
"playerItemConfiguration_description": "configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa", "playerItemConfiguration_description": "configurar quins elements es mostren i en quin ordre al reproductor de pantalla completa",
"playerItemConfiguration": "configuració d'elements del jugador" "playerItemConfiguration": "configuració d'elements del jugador",
"listenbrainz_description": "mostra enllaços a ListenBrainz a les pàgines d'artista/àlbum",
"listenbrainz": "mostra enllaços a ListenBrainz",
"qobuz_description": "mostra enllaços a Qobuz a les pàgines d'artista/àlbum",
"qobuz": "mostra enllaços a Qobuz",
"spotify_description": "mostra enllaços a Spotify a les pàgines d'artista/àlbum",
"spotify": "mostra enllaços a Spotify",
"nativeSpotify_description": "obre amb Spotify en lloc del vostre navegador",
"nativeSpotify": "fes servir Spotify",
"playerbarWaveformStretch": "extensió de la forma d'ona",
"playerbarWaveformStretch_description": "estén la forma d'ona per omplir l'espai disponible",
"sidePlayQueueLayout": "disposició de la cua de reproducció lateral",
"sidePlayQueueLayout_description": "estableix la disposició de la cua de reproducció lateral adjunta",
"sidePlayQueueLayout_optionHorizontal": "horitzontal",
"sidePlayQueueLayout_optionVertical": "vertical",
"waveformLoadingDelay": "demora de càrrega de la forma d'ona",
"waveformLoadingDelay_description": "demora en segons abans de carregar la forma d'ona. incrementeu aquest valor si patiu interrupcions en fer servir el reproductor web.",
"preventSuspendOnPlayback_description": "evita que l'aplicació quedi suspesa mentre es reprodueix música",
"preventSuspendOnPlayback": "evita la suspensió durant la reproducció"
}, },
"table": { "table": {
"column": { "column": {
@@ -1360,6 +1388,13 @@
} }
}, },
"pasteGradient": "enganxa degradat", "pasteGradient": "enganxa degradat",
"pasteGradientPlaceholder": "enganxa el degradat JSON aquí..." "pasteGradientPlaceholder": "enganxa el degradat JSON aquí...",
"systemAudioConsentAllow": "permetre",
"systemAudioConsentBody": "el visualitzador necessita accés a l'àudio del sistema per funcionar",
"systemAudioConsentDecline": "denega",
"systemAudioConsentTitle": "Voleu permetre accés al sistema d'àudio?",
"systemAudioCaptureFailed": "No s'ha pogut iniciar la captura: {{message}}",
"systemAudioNoAudioTrack": "No s'ha tornat cap pista d'àudio. Comproveu que la captura d'àudio estigui activada quan se sol·liciti.",
"systemAudioExclusiveModeNotSupported": "El visualitzador no està disponible amb el mode d'àudio exclusiu activat. Desactiveu-lo a la configuració d'MPV i torneu-ho a intentar."
} }
} }
+19 -5
View File
@@ -85,7 +85,7 @@
"hotkey_zoomIn": "přiblížení", "hotkey_zoomIn": "přiblížení",
"scrobble_description": "scrobblovat přehrání na váš multimediální server", "scrobble_description": "scrobblovat přehrání na váš multimediální server",
"hotkey_browserForward": "vpřed v prohlížeči", "hotkey_browserForward": "vpřed v prohlížeči",
"audioExclusiveMode_description": "zapnout režim výhradního výstupu. V tomto režimu bude obvykle v systému schopný přehrávat zvuk pouze přehrávač mpv", "audioExclusiveMode_description": "zapnout režim výhradního výstupu. v tomto režimu bude obvykle v systému schopný přehrávat zvuk pouze přehrávač mpv. záznam systémového zvuku ve vizualizéru nebude fungovat",
"discordUpdateInterval": "interval aktualizací {{discord}} rich presence", "discordUpdateInterval": "interval aktualizací {{discord}} rich presence",
"themeLight": "motiv (světlý)", "themeLight": "motiv (světlý)",
"fontType_optionBuiltIn": "vestavěné písmo", "fontType_optionBuiltIn": "vestavěné písmo",
@@ -425,7 +425,11 @@
"sidePlayQueueLayout_optionHorizontal": "na šířku", "sidePlayQueueLayout_optionHorizontal": "na šířku",
"sidePlayQueueLayout_optionVertical": "na výšku", "sidePlayQueueLayout_optionVertical": "na výšku",
"waveformLoadingDelay": "zpoždění načítání vlnové křivky", "waveformLoadingDelay": "zpoždění načítání vlnové křivky",
"waveformLoadingDelay_description": "zpoždění v sekundách před načtením vlnové křivky. zvyšte, pokud jste během používání webového přehrávače zaznamenali záseky." "waveformLoadingDelay_description": "zpoždění v sekundách před načtením vlnové křivky. zvyšte, pokud jste během používání webového přehrávače zaznamenali záseky.",
"playerbarWaveformStretch": "natáhnutí vlnové křivky",
"playerbarWaveformStretch_description": "natáhně vlnovou křivku tak, aby vyplnila dostupný prostor",
"preventSuspendOnPlayback_description": "zabránit aplikaci v uspání během přehrávání hudby",
"preventSuspendOnPlayback": "zabránit uspání při přehrávání"
}, },
"action": { "action": {
"editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})", "editPlaylist": "upravit $t(entity.playlist, {\"count\": 1})",
@@ -825,7 +829,9 @@
"lyricGap": "mezera textů", "lyricGap": "mezera textů",
"dynamicImageBlur": "velikost rozostření obrázku", "dynamicImageBlur": "velikost rozostření obrázku",
"dynamicIsImage": "povolit obrázek na pozadí", "dynamicIsImage": "povolit obrázek na pozadí",
"lyricOffset": "posunutí textů (ms)" "lyricOffset": "posunutí textů (ms)",
"lyricOpacityNonActive": "neprůhlednost neaktivních textů",
"lyricScaleNonActive": "velikost neaktivních textů"
}, },
"upNext": "další", "upNext": "další",
"lyrics": "texty", "lyrics": "texty",
@@ -1041,7 +1047,8 @@
"input_skipDuplicates": "přeskočit duplicity", "input_skipDuplicates": "přeskočit duplicity",
"input_playlists": "$t(entity.playlist, {\"count\": 2})", "input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "vytvořit $t(entity.playlist, {\"count\": 1}) {{playlist}}", "create": "vytvořit $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "vyhledejte $t(entity.playlist, {\"count\": 2}) nebo pište pro vytvoření nového" "searchOrCreate": "vyhledejte $t(entity.playlist, {\"count\": 2}) nebo pište pro vytvoření nového",
"noneAdded": "do $t(entity.playlist, {\"count\": 1}) '{{playlist}}' nebyly přidány žádné skladby"
}, },
"updateServer": { "updateServer": {
"title": "upravit server", "title": "upravit server",
@@ -1381,6 +1388,13 @@
} }
}, },
"pasteGradient": "Vložit přechod", "pasteGradient": "Vložit přechod",
"pasteGradientPlaceholder": "Sem vložte JSON přechodu…" "pasteGradientPlaceholder": "Sem vložte JSON přechodu…",
"systemAudioConsentAllow": "Povolit",
"systemAudioConsentBody": "Vizualizér potřebuje pro svou činnost přístup k systémovému zvuku",
"systemAudioConsentTitle": "Povolit přístup k systémovému zvuku?",
"systemAudioCaptureFailed": "Nepodařilo se spustit zachytávání: {{message}}",
"systemAudioNoAudioTrack": "Nebyla zachycena žádná zvuková stopa. Ujistěte se, že jste při výzvě povolili zachytávání zvuku.",
"systemAudioConsentDecline": "Zamítnout",
"systemAudioExclusiveModeNotSupported": "Vizualizér není dostupný při zapnutém režimu výhradního výstupu zvuku. Zakažte Režim výhradního výstupu zvuku v nastavení MPV a zkuste to znovu."
} }
} }
+40 -27
View File
@@ -242,7 +242,7 @@
"criticRating": "Kritikerbewertung", "criticRating": "Kritikerbewertung",
"album": "$t(entity.album, {\"count\": 1})", "album": "$t(entity.album, {\"count\": 1})",
"trackNumber": "Track", "trackNumber": "Track",
"channels": "$t(common.channel_other)", "channels": "$t(common.channel,{\"count\":2})",
"owner": "$t(common.owner)", "owner": "$t(common.owner)",
"genre": "$t(entity.genre, {\"count\": 1})", "genre": "$t(entity.genre, {\"count\": 1})",
"artist": "$t(entity.artist, {\"count\": 1})", "artist": "$t(entity.artist, {\"count\": 1})",
@@ -273,13 +273,13 @@
"input_name": "Servername", "input_name": "Servername",
"success": "Server erfolgreich hinzugefügt", "success": "Server erfolgreich hinzugefügt",
"input_savePassword": "Passwort speichern", "input_savePassword": "Passwort speichern",
"ignoreSsl": "SSL ignorieren $t(common.restartRequired)", "ignoreSsl": "SSL ignorieren ($t(common.restartRequired))",
"ignoreCors": "CORS ignorieren $t(common.restartRequired)", "ignoreCors": "CORS ignorieren ($t(common.restartRequired))",
"error_savePassword": "Beim Speichern des Passworts ist ein Fehler aufgetreten", "error_savePassword": "Beim Speichern des Passworts ist ein Fehler aufgetreten",
"input_preferInstantMix": "Instant-Mix bevorzugen", "input_preferInstantMix": "Instant-Mix bevorzugen",
"input_preferInstantMixDescription": "nur Instant-Mix verwenden, um ähnliche Songs zu erhalten. Nützlich bei Verwendung von Plugins, die in dieses Verhalten eingreifen", "input_preferInstantMixDescription": "nur Instant-Mix verwenden, um ähnliche Songs zu erhalten. Nützlich bei Verwendung von Plugins, die in dieses Verhalten eingreifen",
"input_preferRemoteUrl": "öffentliche URL bevorzugen", "input_preferRemoteUrl": "öffentliche URL bevorzugen",
"input_remoteUrl": "Öffentliche URL", "input_remoteUrl": "öffentliche URL",
"input_remoteUrlPlaceholder": "Optional: öffentliche URL für externe Funktionen" "input_remoteUrlPlaceholder": "Optional: öffentliche URL für externe Funktionen"
}, },
"addToPlaylist": { "addToPlaylist": {
@@ -357,6 +357,9 @@
"input_offset": "$t(setting.lyricOffset)", "input_offset": "$t(setting.lyricOffset)",
"export": "Songtexte exportieren", "export": "Songtexte exportieren",
"input_synced": "Synchronisierte Songtexte exportieren" "input_synced": "Synchronisierte Songtexte exportieren"
},
"editRadioStation": {
"success": "Radiosender erfolgreich aktualisiert"
} }
}, },
"entity": { "entity": {
@@ -426,8 +429,8 @@
"pagination": "Seitenzahlen", "pagination": "Seitenzahlen",
"pagination_itemsPerPage": "Elemente pro Seite", "pagination_itemsPerPage": "Elemente pro Seite",
"pagination_infinite": "unendlich", "pagination_infinite": "unendlich",
"moveUp": "Nach oben bewegen", "moveUp": "nach oben",
"moveDown": "Nach unten bewegen", "moveDown": "nach unten",
"pinToLeft": "links anheften", "pinToLeft": "links anheften",
"pinToRight": "rechts anheften", "pinToRight": "rechts anheften",
"itemGap": "Item Abstand (px)", "itemGap": "Item Abstand (px)",
@@ -450,13 +453,13 @@
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})", "albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"artist": "$t(entity.artist, {\"count\": 1})", "artist": "$t(entity.artist, {\"count\": 1})",
"favorite": "$t(common.favorite)", "favorite": "$t(common.favorite)",
"actions": "$t(common.action_other)", "actions": "$t(common.action,{\"count\":2})",
"genre": "$t(entity.genre, {\"count\": 1})", "genre": "$t(entity.genre, {\"count\": 1})",
"album": "$t(entity.album, {\"count\": 1})", "album": "$t(entity.album, {\"count\": 1})",
"size": "$t(common.size)", "size": "$t(common.size)",
"bpm": "$t(common.bpm)", "bpm": "$t(common.bpm)",
"titleCombined": "$t(common.title) (kombiniert)", "titleCombined": "$t(common.title) (kombiniert)",
"channels": "$t(common.channel_other)", "channels": "$t(common.channel,{\"count\":2})",
"duration": "$t(common.duration)", "duration": "$t(common.duration)",
"note": "$t(common.note)", "note": "$t(common.note)",
"owner": "$t(common.owner)", "owner": "$t(common.owner)",
@@ -493,7 +496,7 @@
"rating": "Bewertung", "rating": "Bewertung",
"albumCount": "$t(entity.album, {\"count\": 2})", "albumCount": "$t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})", "artist": "$t(entity.artist, {\"count\": 1})",
"channels": "$t(common.channel_other)", "channels": "$t(common.channel,{\"count\":2})",
"comment": "Kommentar", "comment": "Kommentar",
"dateAdded": "hinzugefügt am", "dateAdded": "hinzugefügt am",
"playCount": "Abgespielt", "playCount": "Abgespielt",
@@ -716,7 +719,8 @@
}, },
"releasenotes": { "releasenotes": {
"commitsSinceStable": "Commits seit {{stable}}", "commitsSinceStable": "Commits seit {{stable}}",
"noStableReleaseToCompare": "Kein stable Relase zum vergleichen verfügbar" "noStableReleaseToCompare": "Kein stable Relase zum vergleichen verfügbar",
"noNewCommits": "keine neuen Beiträge in diesem Bereich"
} }
}, },
"player": { "player": {
@@ -766,12 +770,12 @@
"sleepTimer": "Sleep Timer", "sleepTimer": "Sleep Timer",
"sleepTimer_custom": "Benutzerdefiniert", "sleepTimer_custom": "Benutzerdefiniert",
"sleepTimer_hours": "{{count}} std", "sleepTimer_hours": "{{count}} std",
"sleepTimer_minutes": "{{count}} min", "sleepTimer_minutes": "{{count}} Min",
"trackRadio": "Song Radio", "trackRadio": "Song Radio",
"albumRadio": "Album Radio" "albumRadio": "Album Radio"
}, },
"setting": { "setting": {
"audioDevice_description": "wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer)", "audioDevice_description": "das für die Wiedergabe zu verwendende Audiogerät auswählen",
"audioExclusiveMode": "Audio-Exklusivmodus", "audioExclusiveMode": "Audio-Exklusivmodus",
"audioDevice": "Audiogerät", "audioDevice": "Audiogerät",
"accentColor": "Akzentfarbe", "accentColor": "Akzentfarbe",
@@ -789,7 +793,7 @@
"crossfadeDuration": "Dauer der Überblendung", "crossfadeDuration": "Dauer der Überblendung",
"discordIdleStatus": "rich presence status im Leerlauf", "discordIdleStatus": "rich presence status im Leerlauf",
"audioPlayer": "Audio-Player", "audioPlayer": "Audio-Player",
"discordApplicationId": "{{discord}} Anwendungs ID", "discordApplicationId": "{{discord}} Anwendungs-ID",
"customFontPath_description": "Legt den Pfad zur benutzerdefinierten Schriftart fest, welche für die Anwendung verwendet werden soll", "customFontPath_description": "Legt den Pfad zur benutzerdefinierten Schriftart fest, welche für die Anwendung verwendet werden soll",
"remotePort_description": "Legt den Port des Fernsteuerungsserver fest", "remotePort_description": "Legt den Port des Fernsteuerungsserver fest",
"hotkey_skipBackward": "rückwärts springen", "hotkey_skipBackward": "rückwärts springen",
@@ -1009,7 +1013,7 @@
"transcodeFormat": "Format für Umwandlung", "transcodeFormat": "Format für Umwandlung",
"startMinimized_description": "Startet die Anwendung im Info-Bereich", "startMinimized_description": "Startet die Anwendung im Info-Bereich",
"startMinimized": "Im Info-Bereich starten", "startMinimized": "Im Info-Bereich starten",
"mediaSession_description": "Aktiviert die Windows Media Session-Integration, zeigt Mediensteuerelemente und Metadaten im Systemlautstärke-Overlay und auf dem Sperrbildschirm an (nur Windows)", "mediaSession_description": "aktiviert die Media Session Integration. Dies ermöglicht die Steuerung und Anzeige der Medien in der Systemlautstärkeoption und auf dem Sperrbildschirm",
"mediaSession": "Media Session aktivieren", "mediaSession": "Media Session aktivieren",
"artistBackgroundBlur": "Unschärfegrad für Künstlerhintergründe", "artistBackgroundBlur": "Unschärfegrad für Künstlerhintergründe",
"artistBackgroundBlur_description": "Legt den Grad der Unschärfe fest, der auf das Hintergrundbild des Künstlers angewendet wird", "artistBackgroundBlur_description": "Legt den Grad der Unschärfe fest, der auf das Hintergrundbild des Künstlers angewendet wird",
@@ -1017,11 +1021,11 @@
"contextMenu_description": "Legt die Einträge fest, die im Rechtsklick-Menü angezeigt werden sollen. Abgewählte Einträge werden ausgeblendet", "contextMenu_description": "Legt die Einträge fest, die im Rechtsklick-Menü angezeigt werden sollen. Abgewählte Einträge werden ausgeblendet",
"crossfadeStyle": "Art der Überblende", "crossfadeStyle": "Art der Überblende",
"customCss_description": "Benutzerdefinierter CSS-Inhalt. Hinweis: Inhalte und Remote-URLs sind nicht zulässige Eigenschaften. Unten siehst du eine Vorschau deines Inhalts. Aufgrund von Bereinigung werden womöglich zusätzliche, nicht von dir definierte Felder angezeigt", "customCss_description": "Benutzerdefinierter CSS-Inhalt. Hinweis: Inhalte und Remote-URLs sind nicht zulässige Eigenschaften. Unten siehst du eine Vorschau deines Inhalts. Aufgrund von Bereinigung werden womöglich zusätzliche, nicht von dir definierte Felder angezeigt",
"customCssNotice": "Warnung: Obwohl eine gewisse Bereinigung erfolgt (url() und content: sind nicht zulässig), kann die Verwendung von benutzerdefiniertem CSS dennoch Risiken mit sich bringen, da dadurch die Benutzeroberfläche verändert wird", "customCssNotice": "Warnung: Obwohl eine gewisse Bereinigung erfolgt (nicht zulässig sind z. B. \"url()\" und \"content:\"), kann ein benutzerdefiniertes CSS Risiken mit sich bringen, da die Benutzeroberfläche dadurch verändert wird",
"releaseChannel_optionBeta": "Beta", "releaseChannel_optionBeta": "Beta",
"releaseChannel_optionLatest": "Stabil", "releaseChannel_optionLatest": "Stabil",
"releaseChannel": "Veröffentlichungskanal", "releaseChannel": "Veröffentlichungskanal",
"releaseChannel_description": "Zwischen stabilen und beta Veröffentlichungen für automatische Aktualisierungen wählen", "releaseChannel_description": "zwischen stabilen, Beta- oder Alpha-Versionen (Nightly) für automatische Updates wählen",
"discordDisplayType_artistname": "Künstlername(n)", "discordDisplayType_artistname": "Künstlername(n)",
"discordDisplayType_description": "Ändert den aktuellen Titel im Zuhör-Status", "discordDisplayType_description": "Ändert den aktuellen Titel im Zuhör-Status",
"discordDisplayType_songname": "Songtitel", "discordDisplayType_songname": "Songtitel",
@@ -1121,7 +1125,13 @@
"nativeSpotify": "Spotify App benutzen", "nativeSpotify": "Spotify App benutzen",
"qobuz_description": "Zeige Links zu Qobuz auf den Interpreten/Alben Seiten", "qobuz_description": "Zeige Links zu Qobuz auf den Interpreten/Alben Seiten",
"spotify_description": "Zeige Links zu Spotify auf den Interpreten/Alben Seiten", "spotify_description": "Zeige Links zu Spotify auf den Interpreten/Alben Seiten",
"artistReleaseTypeConfiguration": "Interpreten Release Typ Einstellung" "artistReleaseTypeConfiguration": "Interpreten Release Typ Einstellung",
"discordStateIcon": "Play Icon anzeigen",
"homeFeatureStyle_optionSingle": "Einzeln",
"nativeSpotify_description": "in der Spotify App statt im Browser öffnen",
"imageResolution_optionFullScreenPlayer": "Wiedergabe im Vollbildmodus",
"sidePlayQueueLayout_optionHorizontal": "horizontal",
"sidePlayQueueLayout_optionVertical": "vertikal"
}, },
"dragDropZone": { "dragDropZone": {
"error_oneFileOnly": "Bitte wähle nur 1 Datei", "error_oneFileOnly": "Bitte wähle nur 1 Datei",
@@ -1211,7 +1221,7 @@
"dualVertical": "Dual-Vertikal" "dualVertical": "Dual-Vertikal"
} }
}, },
"minimumFrequency": "Mindestfrequenz", "minimumFrequency": "Minimale Frequenz",
"minimumDecibels": "Minimale Dezibel", "minimumDecibels": "Minimale Dezibel",
"visualizerType": "Visualizer Art", "visualizerType": "Visualizer Art",
"cyclePresets": "Vorlagen durchrotieren", "cyclePresets": "Vorlagen durchrotieren",
@@ -1246,10 +1256,10 @@
"channelLayout": "Kanallayout", "channelLayout": "Kanallayout",
"maxFPS": "Max FPS", "maxFPS": "Max FPS",
"opacity": "Deckkraft", "opacity": "Deckkraft",
"customGradients": "Benutzerdefinierte Gradienten", "customGradients": "Benutzerdefinierter Farbverlauf",
"addCustomGradient": "Benutzerdefinierten Gradienten hinzufügen", "addCustomGradient": "Benutzerdefinierten Gradienten hinzufügen",
"gradientName": "Gradientenname", "gradientName": "Name Farbverlauf",
"gradientNamePlaceholder": "Gradientenname", "gradientNamePlaceholder": "Name Farbverlauf",
"vertical": "Vertikal", "vertical": "Vertikal",
"horizontal": "Horizontal", "horizontal": "Horizontal",
"addColor": "Farbe hinzufügen", "addColor": "Farbe hinzufügen",
@@ -1262,9 +1272,9 @@
"builtIn": "Eingebaut", "builtIn": "Eingebaut",
"colors": "Farben", "colors": "Farben",
"colorMode": "Farbmodus", "colorMode": "Farbmodus",
"gradient": "Gradienten", "gradient": "Farbverlauf",
"gradientLeft": "Gradienten links", "gradientLeft": "Farberverlauf links",
"gradientRight": "Gradienten rechts", "gradientRight": "Farbverlauf rechts",
"fft": "FFT", "fft": "FFT",
"fftSize": "FFT Größe", "fftSize": "FFT Größe",
"smoothing": "Glätten", "smoothing": "Glätten",
@@ -1273,17 +1283,20 @@
"sensitivity": "Empfindlichkeit", "sensitivity": "Empfindlichkeit",
"weightingFilter": "Gewichtungsfilter", "weightingFilter": "Gewichtungsfilter",
"maximumDecibels": "Maximale Dezibel", "maximumDecibels": "Maximale Dezibel",
"linearAmplitude": "Lineare Amplitude", "linearAmplitude": "Linearer Ausschlag",
"linearBoost": "Linearer Boost", "linearBoost": "Linearer Boost",
"radialSpectrum": "Radiales Spektrum", "radialSpectrum": "Radiales Spektrum",
"radial": "Radial", "radial": "Radial",
"radialInvert": "Radial invertiert", "radialInvert": "Radial invertiert",
"radius": "Radius", "radius": "Radius",
"miscellaneousSettings": "Verschiedenes Einstellungen", "miscellaneousSettings": "Verschiedene Einstellungen",
"ansiBands": "ANSI Bänder", "ansiBands": "ANSI Bänder",
"lowResolution": "Niedrige Auflösung", "lowResolution": "Niedrige Auflösung",
"showFPS": "FPS anzeigen", "showFPS": "FPS anzeigen",
"fadePeaks": "Spitzen abblenden", "fadePeaks": "Spitzen abblenden",
"showPeaks": "Spitzen anzeigen" "showPeaks": "Spitzen anzeigen",
"systemAudioConsentAllow": "Erlauben",
"systemAudioConsentDecline": "Ablehnen",
"frequencyScale": "Frequenzskala"
} }
} }
+9 -1
View File
@@ -347,6 +347,7 @@
"input_skipDuplicates": "skip duplicates", "input_skipDuplicates": "skip duplicates",
"searchOrCreate": "search $t(entity.playlist, {\"count\": 2}) or type to create a new one", "searchOrCreate": "search $t(entity.playlist, {\"count\": 2}) or type to create a new one",
"success": "added $t(entity.trackWithCount, {\"count\": {{message}} }) to $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })", "success": "added $t(entity.trackWithCount, {\"count\": {{message}} }) to $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"noneAdded": "no tracks were added to $t(entity.playlist, {\"count\": 1}) '{{playlist}}'",
"title": "add to $t(entity.playlist, {\"count\": 1})" "title": "add to $t(entity.playlist, {\"count\": 1})"
}, },
"createPlaylist": { "createPlaylist": {
@@ -540,6 +541,8 @@
"lyricOffset": "lyrics offset (ms)", "lyricOffset": "lyrics offset (ms)",
"lyricGap": "lyric gap", "lyricGap": "lyric gap",
"lyricSize": "lyric size", "lyricSize": "lyric size",
"lyricOpacityNonActive": "non-active lyric opacity",
"lyricScaleNonActive": "non-active lyric scale",
"opacity": "opacity", "opacity": "opacity",
"showLyricMatch": "show lyric match", "showLyricMatch": "show lyric match",
"showLyricProvider": "show lyric provider", "showLyricProvider": "show lyric provider",
@@ -761,7 +764,7 @@
"artistReleaseTypeConfiguration_description": "configure what release types are shown, and in what order, on the album artist page", "artistReleaseTypeConfiguration_description": "configure what release types are shown, and in what order, on the album artist page",
"audioDevice_description": "select the audio device to use for playback", "audioDevice_description": "select the audio device to use for playback",
"audioDevice": "audio device", "audioDevice": "audio device",
"audioExclusiveMode_description": "enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio", "audioExclusiveMode_description": "enable exclusive output mode. in this mode, the system is usually locked out, and only mpv will be able to output audio. visualizer system audio capture will not work while this is enabled",
"audioExclusiveMode": "audio exclusive mode", "audioExclusiveMode": "audio exclusive mode",
"audioPlayer_description": "select the audio player to use for playback", "audioPlayer_description": "select the audio player to use for playback",
"audioPlayer": "audio player", "audioPlayer": "audio player",
@@ -983,6 +986,8 @@
"playerbarWaveformBarWidth": "waveform bar width", "playerbarWaveformBarWidth": "waveform bar width",
"playerbarWaveformGap": "waveform gap", "playerbarWaveformGap": "waveform gap",
"playerbarWaveformRadius": "waveform radius", "playerbarWaveformRadius": "waveform radius",
"playerbarWaveformStretch": "waveform stretch",
"playerbarWaveformStretch_description": "stretches the waveform to fill the available space",
"preferLocalLyrics_description": "prefer local lyrics over remote lyrics when available", "preferLocalLyrics_description": "prefer local lyrics over remote lyrics when available",
"preferLocalLyrics": "prefer local lyrics", "preferLocalLyrics": "prefer local lyrics",
"showLyricsInSidebar_description": "a panel will be added to the attached play queue that displays the lyrics", "showLyricsInSidebar_description": "a panel will be added to the attached play queue that displays the lyrics",
@@ -1003,6 +1008,8 @@
"audioFadeOnStatusChange_description": "enables fade out and fade in when play/pause status changes", "audioFadeOnStatusChange_description": "enables fade out and fade in when play/pause status changes",
"preventSleepOnPlayback_description": "prevent the display from sleeping while music is playing", "preventSleepOnPlayback_description": "prevent the display from sleeping while music is playing",
"preventSleepOnPlayback": "prevent sleep on playback", "preventSleepOnPlayback": "prevent sleep on playback",
"preventSuspendOnPlayback_description": "prevent the application from suspending while music is playing",
"preventSuspendOnPlayback": "prevent suspend on playback",
"remotePassword_description": "sets the password for the remote control server. These credentials are by default transferred insecurely, so you should use a unique password that you do not care about", "remotePassword_description": "sets the password for the remote control server. These credentials are by default transferred insecurely, so you should use a unique password that you do not care about",
"remotePassword": "remote control server password", "remotePassword": "remote control server password",
"remotePort_description": "sets the port for the remote control server", "remotePort_description": "sets the port for the remote control server",
@@ -1220,6 +1227,7 @@
"systemAudioConsentTitle": "Allow access to system audio?", "systemAudioConsentTitle": "Allow access to system audio?",
"systemAudioCaptureFailed": "Could not start capture: {{message}}", "systemAudioCaptureFailed": "Could not start capture: {{message}}",
"systemAudioNoAudioTrack": "No audio track was returned. Ensure audio capture is enabled when prompted.", "systemAudioNoAudioTrack": "No audio track was returned. Ensure audio capture is enabled when prompted.",
"systemAudioExclusiveModeNotSupported": "Visualizer is unavailable while audio exclusive mode is enabled. Disable Audio Exclusive Mode in MPV settings and try again.",
"visualizerType": "Visualizer Type", "visualizerType": "Visualizer Type",
"cyclePresets": "Cycle Presets", "cyclePresets": "Cycle Presets",
"cycleTime": "Cycle Time (seconds)", "cycleTime": "Cycle Time (seconds)",
+22 -5
View File
@@ -83,7 +83,7 @@
"replayGainClipping": "recortar {{ReplayGain}}", "replayGainClipping": "recortar {{ReplayGain}}",
"hotkey_zoomIn": "ampliar", "hotkey_zoomIn": "ampliar",
"scrobble_description": "hace scrobble de las reproducciones en tu servidor de medios", "scrobble_description": "hace scrobble de las reproducciones en tu servidor de medios",
"audioExclusiveMode_description": "activa el modo de audio exclusivo. En este modo, el sistema es normalmente bloqueado, y solo se permitirá mpv en la salida de audio", "audioExclusiveMode_description": "activa el modo de audio exclusivo. En este modo, el sistema es normalmente bloqueado, y solo se permitirá mpv en la salida de audio. El sistema visualizador de captura de audio no funcionará mientras esto esté activado",
"discordUpdateInterval": "intervalo de actualización del estado de actividad de {{discord}}", "discordUpdateInterval": "intervalo de actualización del estado de actividad de {{discord}}",
"themeLight": "tema (claro)", "themeLight": "tema (claro)",
"fontType_optionBuiltIn": "fuente incorporada", "fontType_optionBuiltIn": "fuente incorporada",
@@ -425,7 +425,11 @@
"sidePlayQueueLayout": "Diseño de la cola de reproducción lateral", "sidePlayQueueLayout": "Diseño de la cola de reproducción lateral",
"sidePlayQueueLayout_description": "Establece el diseño de la cola de reproducción lateral adjunta", "sidePlayQueueLayout_description": "Establece el diseño de la cola de reproducción lateral adjunta",
"waveformLoadingDelay": "Retraso de carga de la forma de onda", "waveformLoadingDelay": "Retraso de carga de la forma de onda",
"waveformLoadingDelay_description": "Retraso en segundos antes de cargar la forma de onda. Incrementa este valor si estás experimentando tartamudeos al usar el reproductor web." "waveformLoadingDelay_description": "Retraso en segundos antes de cargar la forma de onda. Incrementa este valor si estás experimentando tartamudeos al usar el reproductor web.",
"playerbarWaveformStretch": "Estiramiento de la forma de onda",
"playerbarWaveformStretch_description": "Estira la forma de onda para rellenar el espacio disponible",
"preventSuspendOnPlayback": "Evitar la suspensión durante la reproducción",
"preventSuspendOnPlayback_description": "Evita que la aplicación se suspenda mientras se reproduce música"
}, },
"action": { "action": {
"editPlaylist": "editar $t(entity.playlist, {\"count\": 1})", "editPlaylist": "editar $t(entity.playlist, {\"count\": 1})",
@@ -772,7 +776,9 @@
"lyricGap": "desfase de letra", "lyricGap": "desfase de letra",
"dynamicImageBlur": "tamaño de desenfoque de imagen", "dynamicImageBlur": "tamaño de desenfoque de imagen",
"dynamicIsImage": "habilitar imagen de fondo", "dynamicIsImage": "habilitar imagen de fondo",
"lyricOffset": "desplazamiento de letras (ms)" "lyricOffset": "desplazamiento de letras (ms)",
"lyricScaleNonActive": "Escala de letra no activa",
"lyricOpacityNonActive": "Opacidad de letra no activa"
}, },
"lyrics": "letras", "lyrics": "letras",
"related": "relacionado", "related": "relacionado",
@@ -932,7 +938,8 @@
"input_skipDuplicates": "saltar duplicados", "input_skipDuplicates": "saltar duplicados",
"input_playlists": "$t(entity.playlist, {\"count\": 2})", "input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "Crear $t(entity.playlist, {\"count\": 1}) {{playlist}}", "create": "Crear $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "Buscar $t(entity.playlist, {\"count\": 2}) o escribir para crear uno nuevo" "searchOrCreate": "Buscar $t(entity.playlist, {\"count\": 2}) o escribir para crear uno nuevo",
"noneAdded": "Ninguna pista fue añadida a $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
}, },
"updateServer": { "updateServer": {
"title": "actualizar servidor", "title": "actualizar servidor",
@@ -1001,6 +1008,9 @@
"export": "Exportar letras", "export": "Exportar letras",
"input_synced": "Exportar letras sincronizadas", "input_synced": "Exportar letras sincronizadas",
"input_offset": "$t(setting.lyricOffset)" "input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "Estación de radio actualizada con éxito"
} }
}, },
"table": { "table": {
@@ -1378,6 +1388,13 @@
"lowResolution": "Baja resolución", "lowResolution": "Baja resolución",
"splitGradient": "Dividir degradado", "splitGradient": "Dividir degradado",
"noteLabels": "Etiquetas de notas", "noteLabels": "Etiquetas de notas",
"lumiBars": "Barras luminiscentes" "lumiBars": "Barras luminiscentes",
"systemAudioConsentAllow": "Permitir",
"systemAudioConsentDecline": "Denegar",
"systemAudioConsentTitle": "¿Permitir acceso al sistema de audio?",
"systemAudioConsentBody": "El visualizador requiere acceso al sistema de audio para funcionar",
"systemAudioCaptureFailed": "No se pudo iniciar la captura: {{message}}",
"systemAudioNoAudioTrack": "Ninguna pista de audio devuelta. Asegúrate de que la captura de audio está activada cuando se solicite.",
"systemAudioExclusiveModeNotSupported": "El visualizador no está disponible mientras el modo de audio exclusivo esté activado. Desactiva el modo de audio exclusivo en la configuración de MPV e inténtalo de nuevo."
} }
} }
+50 -9
View File
@@ -157,7 +157,8 @@
"doNotShowAgain": "ez erakutsi hau berriro", "doNotShowAgain": "ez erakutsi hau berriro",
"numberOfResults": "{{numberOfResults}} emaitza", "numberOfResults": "{{numberOfResults}} emaitza",
"rename": "berrizendatu", "rename": "berrizendatu",
"newVersionAvailable": "bertsio berri bat eskuragarri dago" "newVersionAvailable": "bertsio berri bat eskuragarri dago",
"gridRows": "sareta-errenkadak"
}, },
"player": { "player": {
"repeat": "errepikatu", "repeat": "errepikatu",
@@ -409,7 +410,8 @@
"fromYear": "urtetik aurrera", "fromYear": "urtetik aurrera",
"explicitStatus": "$t(common.explicitStatus)", "explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "eta", "matchAnd": "eta",
"matchOr": "edo" "matchOr": "edo",
"sortName": "izenaren arabera ordenatu"
}, },
"setting": { "setting": {
"hotkey_playbackPause": "pausatu", "hotkey_playbackPause": "pausatu",
@@ -643,7 +645,7 @@
"passwordStore_description": "zein pasahitz/sekretu biltegi erabili. aldatu hau pasahitzak gordetzeko arazoak badituzu", "passwordStore_description": "zein pasahitz/sekretu biltegi erabili. aldatu hau pasahitzak gordetzeko arazoak badituzu",
"playerFilters": "Iragazi ilarako abestiak", "playerFilters": "Iragazi ilarako abestiak",
"sidePlayQueueStyle_description": "alboko erreprodukzio-ilararen estiloa ezartzen du", "sidePlayQueueStyle_description": "alboko erreprodukzio-ilararen estiloa ezartzen du",
"mediaSession_description": "Windows Media Session integrazioa gaitzen du, multimedia kontrolak eta metadatuak sistemaren bolumenaren gainjartzean eta blokeo pantailan bistaratuz (Windows bakarrik)", "mediaSession_description": "Media Session integrazioa gaitzen du, multimedia kontrolak eta metadatuak sistemaren bolumenaren gainjartzean eta blokeo pantailan bistaratuz",
"sidePlayQueueStyle": "alboko erreprodukzio-ilarako estiloa", "sidePlayQueueStyle": "alboko erreprodukzio-ilarako estiloa",
"skipPlaylistPage": "saltatu erreprodukzio-zerrenda orria", "skipPlaylistPage": "saltatu erreprodukzio-zerrenda orria",
"startMinimized_description": "abiarazi aplikazioa sistemaren erretiluan", "startMinimized_description": "abiarazi aplikazioa sistemaren erretiluan",
@@ -714,7 +716,19 @@
"preservePitch": "mantendu tonua", "preservePitch": "mantendu tonua",
"preventSleepOnPlayback": "erreprodukzioan loa saihestu", "preventSleepOnPlayback": "erreprodukzioan loa saihestu",
"replayGainClipping_description": "Saihestu {{ReplayGain}}-k eragindako mozketa irabazpena automatikoki jaitsiz", "replayGainClipping_description": "Saihestu {{ReplayGain}}-k eragindako mozketa irabazpena automatikoki jaitsiz",
"replayGainMode_description": "doitu bolumenaren irabazia fitxategiaren metadatuetan gordetako {{ReplayGain}} balioen arabera" "replayGainMode_description": "doitu bolumenaren irabazia fitxategiaren metadatuetan gordetako {{ReplayGain}} balioen arabera",
"listenbrainz": "erakutsi ListenBrainz estekak",
"sidebarPlaylistSorting": "alboko barrako erreprodukzio-zerrenda ordenatzea",
"sidebarPlaylistListFilterRegex_description": "ezkutatu alboko barran adierazpen erregular honekin bat datozen erreprodukzio-zerrendak",
"sidebarPlaylistListFilterRegex_placeholder": "adib. ^Eguneroko Nahasketa.*",
"sidebarPlaylistListFilterRegex": "erreprodukzio-zerrenda iragazteko adierazpen erregularra",
"sidePlayQueueLayout": "alboko erreprodukzio-ilararen diseinua",
"sidePlayQueueLayout_description": "erantsitako alboko erreprodukzio-ilararen diseinua ezartzen du",
"sidePlayQueueLayout_optionHorizontal": "horizontala",
"sidePlayQueueLayout_optionVertical": "bertikala",
"skipDuration_description": "erreproduzitzailearen barran saltatzeko botoiak erabiltzean saltatzeko iraupena ezartzen du",
"skipPlaylistPage_description": "erreprodukzio-zerrenda batera nabigatzean, joan erreprodukzio-zerrendako abestien zerrendara lehenetsitako orrialdera joan beharrean",
"translationApiKey_description": "itzulpenerako api gakoa (zerbitzu globalaren amaiera-puntua soilik)"
}, },
"form": { "form": {
"addServer": { "addServer": {
@@ -731,7 +745,9 @@
"success": "zerbitzaria behar bezala gehitu da", "success": "zerbitzaria behar bezala gehitu da",
"input_preferInstantMix": "nahiago izan berehalako nahasketa", "input_preferInstantMix": "nahiago izan berehalako nahasketa",
"input_preferInstantMixDescription": "erabili berehalako nahasketa soilik antzeko abestiak lortzeko. erabilgarria portaera hau aldatzen duten pluginak badituzu", "input_preferInstantMixDescription": "erabili berehalako nahasketa soilik antzeko abestiak lortzeko. erabilgarria portaera hau aldatzen duten pluginak badituzu",
"input_remoteUrl": "URL publikoa" "input_remoteUrl": "URL publikoa",
"input_preferRemoteUrl": "url publikoa nahiago izan",
"input_remoteUrlPlaceholder": "aukerakoa: kanpoko funtzioetarako url publikoa"
}, },
"addToPlaylist": { "addToPlaylist": {
"input_playlists": "$t(entity.playlist, {\"count\": 2})", "input_playlists": "$t(entity.playlist, {\"count\": 2})",
@@ -739,7 +755,8 @@
"input_skipDuplicates": "saltatu bikoiztuak", "input_skipDuplicates": "saltatu bikoiztuak",
"title": "gehitu $t(entity.playlist, {\"count\": 1})-(a)ri", "title": "gehitu $t(entity.playlist, {\"count\": 1})-(a)ri",
"create": "sortu $t(entity.playlist, {\"count\": 1}) {{playlist}}", "create": "sortu $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "bilatu $t(entity.playlist, {\"count\": 2}) edo idatzi berri bat sortzeko" "searchOrCreate": "bilatu $t(entity.playlist, {\"count\": 2}) edo idatzi berri bat sortzeko",
"noneAdded": "ez da pistarik gehitu honi $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
}, },
"createPlaylist": { "createPlaylist": {
"input_description": "$t(common.description)", "input_description": "$t(common.description)",
@@ -760,7 +777,9 @@
"success": "partekatzeko esteka arbelera kopiatu da (edo egin klik hemen irekitzeko)", "success": "partekatzeko esteka arbelera kopiatu da (edo egin klik hemen irekitzeko)",
"expireInvalid": "iraungitze-data etorkizunean izan behar da", "expireInvalid": "iraungitze-data etorkizunean izan behar da",
"allowDownloading": "baimendu deskargatzea", "allowDownloading": "baimendu deskargatzea",
"createFailed": "partekatzea sortzeak huts egin du (partekatzea gaituta al dago?)" "createFailed": "partekatzea sortzeak huts egin du (partekatzea gaituta al dago?)",
"copyToClipboard": "Kopiatu arbelean: Ctrl+C, Enter",
"successMustClick": "partekatzea behar bezala sortu da. egin klik hemen irekitzeko"
}, },
"deletePlaylist": { "deletePlaylist": {
"success": "$t(entity.playlist, {\"count\": 1}) behar bezala ezabatu da", "success": "$t(entity.playlist, {\"count\": 1}) behar bezala ezabatu da",
@@ -988,7 +1007,10 @@
"recentReleases": "azken argitalpenak", "recentReleases": "azken argitalpenak",
"viewDiscography": "ikusi diskografia", "viewDiscography": "ikusi diskografia",
"groupingTypeAll": "argitalpen mota guztiak", "groupingTypeAll": "argitalpen mota guztiak",
"groupingTypePrimary": "argitalpen mota nagusiak" "groupingTypePrimary": "argitalpen mota nagusiak",
"favoriteSongs": "abesti gogokoenak",
"topSongsCommunity": "komunitatea",
"topSongsPersonal": "pertsonala"
}, },
"itemDetail": { "itemDetail": {
"copyPath": "kopiatu bidea arbelean", "copyPath": "kopiatu bidea arbelean",
@@ -1006,6 +1028,13 @@
}, },
"radioList": { "radioList": {
"title": "irrati-kateak" "title": "irrati-kateak"
},
"releasenotes": {
"noStableReleaseToCompare": "ez dago bertsio egonkorrik alderatzeko"
},
"windowBar": {
"paused": "(Pausatuta) ",
"privateMode": "(Modu pribatua)"
} }
}, },
"releaseType": { "releaseType": {
@@ -1038,7 +1067,19 @@
"notContains": "ez dauka", "notContains": "ez dauka",
"startsWith": "honekin hasten da", "startsWith": "honekin hasten da",
"endsWith": "honekin amaitzen da", "endsWith": "honekin amaitzen da",
"isNot": "ez da" "isNot": "ez da",
"afterDate": "(data) ondoren da",
"after": "ondoren da",
"before": "lehenago da",
"beforeDate": "(data) baino lehenagokoa da",
"inTheLast": "azkenengoan dago",
"inPlaylist": "bertan dago",
"inTheRange": "tartean dago",
"inTheRangeDate": "(data) tartean dago",
"isGreaterThan": "hau baino handiagoa da",
"isLessThan": "hau baino gutxiago da",
"notInPlaylist": "ez dago barruan",
"notInTheLast": "ez dago azkenengoan"
}, },
"visualizer": { "visualizer": {
"general": "Orokorra", "general": "Orokorra",
+19 -7
View File
@@ -246,10 +246,10 @@
"loginRateError": "trop de tentatives de connexion, merci de réessayer dans quelques secondes", "loginRateError": "trop de tentatives de connexion, merci de réessayer dans quelques secondes",
"openError": "impossible d'ouvrir le fichier", "openError": "impossible d'ouvrir le fichier",
"networkError": "une erreur de réseau est survenue", "networkError": "une erreur de réseau est survenue",
"badAlbum": "vous voyez cette page parce que ce titre ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez un titre à la racine de votre dossier musique. Jellyfin gère les chansons uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\"", "badAlbum": "vous voyez cette page parce que ce titre ne fait pas parti d'un album. vous rencontrez probablement cette erreur si vous avez un titre à la racine de votre dossier musique. Jellyfin gère les pistes uniquement si elles sont dans un sous-dossier, qui est lui-même dans un dossier \"Musique(s)\"",
"badValue": "option {{value}} invalide. cette valeur n'existe plus", "badValue": "option {{value}} invalide. cette valeur n'existe plus",
"notificationDenied": "les autorisations pour les notifications ont été refusées. ce paramètre n'a aucun effet", "notificationDenied": "les autorisations pour les notifications ont été refusées. ce paramètre n'a aucun effet",
"multipleServerSaveQueueError": "la file d'attente contient un ou plusieurs morceaux qui ne proviennent pas du serveur actuel. Ceci n'est pas prise en charge", "multipleServerSaveQueueError": "la file d'attente contient un ou plusieurs titres qui ne proviennent pas du serveur actuel. Ceci n'est pas prise en charge",
"saveQueueFailed": "échec de l'enregistrement de la file d'attente", "saveQueueFailed": "échec de l'enregistrement de la file d'attente",
"settingsSyncError": "des incohérences ont été détectées entre les paramètres du moteur de rendu et ceux du processus principal. redémarrez l'application pour appliquer les modifications", "settingsSyncError": "des incohérences ont été détectées entre les paramètres du moteur de rendu et ceux du processus principal. redémarrez l'application pour appliquer les modifications",
"noNetwork": "serveur indisponible", "noNetwork": "serveur indisponible",
@@ -340,7 +340,9 @@
"lyricGap": "espacement des lettres", "lyricGap": "espacement des lettres",
"dynamicIsImage": "activer l'image d'arrière-plan", "dynamicIsImage": "activer l'image d'arrière-plan",
"dynamicImageBlur": "intensité du flou sur l'image d'arrière-plan", "dynamicImageBlur": "intensité du flou sur l'image d'arrière-plan",
"lyricOffset": "décalage des paroles (ms)" "lyricOffset": "décalage des paroles (ms)",
"lyricScaleNonActive": "mise à l'échelle des paroles inactives",
"lyricOpacityNonActive": "opacité des paroles inactive"
}, },
"upNext": "à suivre", "upNext": "à suivre",
"lyrics": "paroles", "lyrics": "paroles",
@@ -477,7 +479,7 @@
"groupingTypePrimary": "types de parution principale", "groupingTypePrimary": "types de parution principale",
"topSongsCommunity": "communauté", "topSongsCommunity": "communauté",
"topSongsPersonal": "personnel", "topSongsPersonal": "personnel",
"favoriteSongsFrom": "titres favori de {{title}}" "favoriteSongsFrom": "titres favoris de {{title}}"
}, },
"itemDetail": { "itemDetail": {
"copyPath": "copier le chemin dans le presse-papiers", "copyPath": "copier le chemin dans le presse-papiers",
@@ -520,7 +522,7 @@
}, },
"setting": { "setting": {
"audioDevice_description": "sélectionnez le périphérique audio à utiliser pour la lecture", "audioDevice_description": "sélectionnez le périphérique audio à utiliser pour la lecture",
"audioExclusiveMode_description": "activer le mode de sortie exclusif. Dans ce mode, le système est généralement verrouillé et seul mpv pourra émettre de l'audio", "audioExclusiveMode_description": "activer le mode de sortie exclusif. Dans ce mode, le système est généralement verrouillé et seul mpv pourra émettre de l'audio. La capture audio du système de visualisation ne fonctionnera pas lorsque cette option est activée",
"audioPlayer_description": "sélectionnez le lecteur audio à utiliser pour la lecture", "audioPlayer_description": "sélectionnez le lecteur audio à utiliser pour la lecture",
"crossfadeDuration_description": "définit la durée du fondu enchaîné", "crossfadeDuration_description": "définit la durée du fondu enchaîné",
"audioDevice": "périphérique audio", "audioDevice": "périphérique audio",
@@ -914,7 +916,7 @@
"ignoreCors": "ignorer cors $t(common.restartRequired)", "ignoreCors": "ignorer cors $t(common.restartRequired)",
"error_savePassword": "une erreur sest produite lors de la tentative de sauvegarde du mot de passe", "error_savePassword": "une erreur sest produite lors de la tentative de sauvegarde du mot de passe",
"input_preferInstantMix": "préférer le mix instantané", "input_preferInstantMix": "préférer le mix instantané",
"input_preferInstantMixDescription": "utiliser uniquement le mix instantané pour jouer des pistes similaires. utile si vous avez des plugins qui modifient ce comportement", "input_preferInstantMixDescription": "utiliser uniquement le mix instantané pour jouer des titres similaires. utile si vous avez des plugins qui modifient ce comportement",
"input_preferRemoteUrl": "préférer une URL publique", "input_preferRemoteUrl": "préférer une URL publique",
"input_remoteUrl": "URL publique", "input_remoteUrl": "URL publique",
"input_remoteUrlPlaceholder": "optionnel : URL publique pour les fonctionnalités externes" "input_remoteUrlPlaceholder": "optionnel : URL publique pour les fonctionnalités externes"
@@ -1002,6 +1004,9 @@
"export": "exporter les paroles", "export": "exporter les paroles",
"input_synced": "exporter les paroles synchronisées", "input_synced": "exporter les paroles synchronisées",
"input_offset": "$t(setting.lyricOffset)" "input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "station de radio a été mise à jour avec succès"
} }
}, },
"entity": { "entity": {
@@ -1379,6 +1384,13 @@
}, },
"lumiBars": "Lumi Bars", "lumiBars": "Lumi Bars",
"outlineBars": "Outline Bars", "outlineBars": "Outline Bars",
"splitGradient": "Split Gradient" "splitGradient": "Split Gradient",
"systemAudioNoAudioTrack": "Aucune piste audio n'a été renvoyée. Assurez-vous que la capture audio est activée lorsque vous y êtes invité.",
"systemAudioConsentAllow": "Permettre",
"systemAudioConsentBody": "Le visualiseur nécessite un accès au système audio pour fonctionner",
"systemAudioConsentDecline": "Refuser",
"systemAudioConsentTitle": "Permettre l'accès au système audio?",
"systemAudioCaptureFailed": "Impossible de démarrer la capture : {{message}}",
"systemAudioExclusiveModeNotSupported": "Le visualiseur est indisponible lorsque le mode audio exclusif est activé. Désactivez ce mode dans les paramètres MPV et réessayez."
} }
} }
+11 -2
View File
@@ -1037,7 +1037,7 @@
}, },
"updateServer": { "updateServer": {
"title": "サーバーをアップデート", "title": "サーバーをアップデート",
"success": "サーバーがアップデートされました" "success": "サーバーの更新に成功しました"
}, },
"queryEditor": { "queryEditor": {
"input_optionMatchAll": "すべて一致", "input_optionMatchAll": "すべて一致",
@@ -1102,6 +1102,9 @@
}, },
"saveQueue": { "saveQueue": {
"success": "プレイキューをサーバーに保存しました" "success": "プレイキューをサーバーに保存しました"
},
"editRadioStation": {
"success": "ラジオ局の更新に成功しました"
} }
}, },
"entity": { "entity": {
@@ -1332,6 +1335,12 @@
"d": "D", "d": "D",
"z": "Z" "z": "Z"
} }
} },
"systemAudioConsentAllow": "許可",
"systemAudioConsentBody": "ビジュアライザーを機能させるためには、システムオーディオへのアクセスが必要です",
"systemAudioConsentDecline": "拒否",
"systemAudioConsentTitle": "システムオーディオへのアクセスを許可しますか?",
"systemAudioCaptureFailed": "キャプチャを開始できませんでした: {{message}}",
"systemAudioNoAudioTrack": "音声トラックが返されませんでした。プロンプトが表示されたら、音声キャプチャが有効になっていることを確認してください。"
} }
} }
+22 -6
View File
@@ -143,7 +143,7 @@
"doNotShowAgain": "niet opnieuw tonen", "doNotShowAgain": "niet opnieuw tonen",
"externalLinks": "externe links", "externalLinks": "externe links",
"faster": "sneller", "faster": "sneller",
"preview": "voorvertoning", "preview": "voorbeeld",
"private": "privé", "private": "privé",
"public": "publiekelijk", "public": "publiekelijk",
"recordLabel": "platenlabel", "recordLabel": "platenlabel",
@@ -152,7 +152,7 @@
"sort": "sorteer", "sort": "sorteer",
"trackGain": "trackvolume", "trackGain": "trackvolume",
"trackPeak": "piekniveau", "trackPeak": "piekniveau",
"clean": "schoon", "clean": "opschonen",
"gridRows": "rasterrijen", "gridRows": "rasterrijen",
"tableColumns": "tabelkolommen", "tableColumns": "tabelkolommen",
"itemsMore": "{{count}} meer", "itemsMore": "{{count}} meer",
@@ -283,7 +283,9 @@
"unsynchronized": "niet gesynchronizeerd", "unsynchronized": "niet gesynchronizeerd",
"useImageAspectRatio": "gebruik aspect ratio van de afbeelding", "useImageAspectRatio": "gebruik aspect ratio van de afbeelding",
"lyricOffset": "songtekst-vertraging (ms)", "lyricOffset": "songtekst-vertraging (ms)",
"showLyricProvider": "toon songtekstaanbieder" "showLyricProvider": "toon songtekstaanbieder",
"lyricOpacityNonActive": "opaciteit van niet-actieve songtekst",
"lyricScaleNonActive": "schaal niet-actieve songtekst"
}, },
"lyrics": "liedtekst", "lyrics": "liedtekst",
"related": "gerelateerd", "related": "gerelateerd",
@@ -680,7 +682,7 @@
"artistReleaseTypeConfiguration_description": "configureer welke uitgavesoorten worden getoond op de albumartiestpagina en in welke volgorde", "artistReleaseTypeConfiguration_description": "configureer welke uitgavesoorten worden getoond op de albumartiestpagina en in welke volgorde",
"audioDevice_description": "kies het audioapparaat dat wordt gebruikt om af te spelen", "audioDevice_description": "kies het audioapparaat dat wordt gebruikt om af te spelen",
"audioDevice": "audioapparaat", "audioDevice": "audioapparaat",
"audioExclusiveMode_description": "schakel exclusieve uitvoermodus in. In deze modus wordt het systeem normaliter uitgesloten en zal enkel mpv audio kunnen uitvoeren", "audioExclusiveMode_description": "schakel exclusieve uitvoermodus in. in deze modus wordt het systeem normaliter uitgesloten en zal enkel mpv audio kunnen uitvoeren. geluidsopname in de visualiseerder zal niet werken als dit is ingeschakeld",
"audioExclusiveMode": "audio-exclusieve modus", "audioExclusiveMode": "audio-exclusieve modus",
"audioPlayer_description": "kies de audiospeler om te gebruiken bij het afspelen", "audioPlayer_description": "kies de audiospeler om te gebruiken bij het afspelen",
"audioPlayer": "audiospeler", "audioPlayer": "audiospeler",
@@ -985,7 +987,11 @@
"sidePlayQueueLayout": "uitlijning afspeelwachtrij aan zijkant", "sidePlayQueueLayout": "uitlijning afspeelwachtrij aan zijkant",
"sidePlayQueueLayout_description": "stel de uitlijning in voor de afspeelwachtrij die aan de zijkant is gekoppeld", "sidePlayQueueLayout_description": "stel de uitlijning in voor de afspeelwachtrij die aan de zijkant is gekoppeld",
"sidePlayQueueLayout_optionHorizontal": "horizontaal", "sidePlayQueueLayout_optionHorizontal": "horizontaal",
"sidePlayQueueLayout_optionVertical": "verticaal" "sidePlayQueueLayout_optionVertical": "verticaal",
"playerbarWaveformStretch": "rekking van golfvorm",
"playerbarWaveformStretch_description": "rekt de golfvorm om de beschikbare ruimte op te vullen",
"waveformLoadingDelay": "laadvertraging van golfvorm",
"waveformLoadingDelay_description": "vertraging in seconden voordat de golfvorm wordt geladen. vergroot deze waarde als je stotteringen ervaart bij gebruik van de webspeler."
}, },
"form": { "form": {
"addServer": { "addServer": {
@@ -1094,6 +1100,9 @@
}, },
"saveQueue": { "saveQueue": {
"success": "wachtrij opgeslagen op server" "success": "wachtrij opgeslagen op server"
},
"editRadioStation": {
"success": "radiozender succesvol bijgewerkt"
} }
}, },
"player": { "player": {
@@ -1353,6 +1362,13 @@
"d": "D", "d": "D",
"z": "Z" "z": "Z"
} }
} },
"systemAudioConsentAllow": "Toestaan",
"systemAudioConsentBody": "De visualiseerder heeft toegang tot het audiosysteem nodig om te werken",
"systemAudioConsentDecline": "Weigeren",
"systemAudioConsentTitle": "Toegang tot audiosysteem toestaan?",
"systemAudioCaptureFailed": "Kon opname niet starten: {{message}}",
"systemAudioNoAudioTrack": "Geen geluidsspoor ontvangen. Verifieer dat audio-opname is ingeschakeld als daar om wordt gevraagd.",
"systemAudioExclusiveModeNotSupported": "De visualiseerder is niet beschikbaar als audio-exclusieve modus is ingeschakeld. Schakel audio-exclusieve modus in de MPV-instellingen uit en probeer het opnieuw."
} }
} }
+19 -5
View File
@@ -352,7 +352,8 @@
"input_skipDuplicates": "pomiń duplikaty", "input_skipDuplicates": "pomiń duplikaty",
"input_playlists": "$t(entity.playlist, {\"count\": 2})", "input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "utwórz $t(entity.playlist, {\"count\": 1}) {{playlist}}", "create": "utwórz $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "wyszukaj $t(entity.playlist, {\"count\": 2}) lub wpisz, aby utworzyć nową" "searchOrCreate": "wyszukaj $t(entity.playlist, {\"count\": 2}) lub wpisz, aby utworzyć nową",
"noneAdded": "nie dodano utworów do $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
}, },
"updateServer": { "updateServer": {
"title": "uaktualnij serwer", "title": "uaktualnij serwer",
@@ -442,7 +443,9 @@
"lyricGap": "odstępy tekstu", "lyricGap": "odstępy tekstu",
"dynamicImageBlur": "rozmiar rozmycia obrazu", "dynamicImageBlur": "rozmiar rozmycia obrazu",
"dynamicIsImage": "włącz obraz w tle", "dynamicIsImage": "włącz obraz w tle",
"lyricOffset": "opóźnienie tekstów (ms)" "lyricOffset": "opóźnienie tekstów (ms)",
"lyricOpacityNonActive": "przezroczystość nieaktywnego tekstu",
"lyricScaleNonActive": "skala nieaktywnego tekstu"
}, },
"upNext": "następne", "upNext": "następne",
"lyrics": "tekst", "lyrics": "tekst",
@@ -702,7 +705,7 @@
"hotkey_favoriteCurrentSong": "ulubiona $t(common.currentSong)", "hotkey_favoriteCurrentSong": "ulubiona $t(common.currentSong)",
"hotkey_zoomIn": "przybliż", "hotkey_zoomIn": "przybliż",
"hotkey_browserForward": "przeglądarka w przód", "hotkey_browserForward": "przeglądarka w przód",
"audioExclusiveMode_description": "włącz wyłączny tryb wyjścia. W tym trybie, system zwykle jest zablokowany i może odtwarzać tylko poprzez mpv", "audioExclusiveMode_description": "włącz wyłączny tryb wyjścia. W tym trybie, system zwykle jest zablokowany i może odtwarzać tylko poprzez mpv. wizualizer i przechwytywanie audio przez sysytem nie będzie działać gdy jest to włączone",
"discordUpdateInterval": "{{discord}} interwał aktualizacji rich presence", "discordUpdateInterval": "{{discord}} interwał aktualizacji rich presence",
"fontType_optionBuiltIn": "wbudowana czcionka", "fontType_optionBuiltIn": "wbudowana czcionka",
"hotkey_playbackPlayPause": "odtwarzaj / wstrzymaj", "hotkey_playbackPlayPause": "odtwarzaj / wstrzymaj",
@@ -1064,7 +1067,11 @@
"sidePlayQueueLayout_optionHorizontal": "poziomy", "sidePlayQueueLayout_optionHorizontal": "poziomy",
"sidePlayQueueLayout_optionVertical": "pionowy", "sidePlayQueueLayout_optionVertical": "pionowy",
"waveformLoadingDelay": "opóźnienie załadowania fali", "waveformLoadingDelay": "opóźnienie załadowania fali",
"waveformLoadingDelay_description": "opóźnienie w sekundach przed załadowaniem fali. zwiększ tą wartość jeżeli doświadczasz zawieszania się odtwarzacza przeglądarkowego." "waveformLoadingDelay_description": "opóźnienie w sekundach przed załadowaniem fali. zwiększ tą wartość jeżeli doświadczasz zawieszania się odtwarzacza przeglądarkowego.",
"playerbarWaveformStretch": "rozciąganie przebiegu",
"playerbarWaveformStretch_description": "rozciąga przebieg aby wypełnić dostępną przestrzeń",
"preventSuspendOnPlayback_description": "powstrzymuj wstrzymanie podczas odtwarzania muzyki",
"preventSuspendOnPlayback": "powstrzymuje wstrzymanie przy odtwarzaniu"
}, },
"table": { "table": {
"config": { "config": {
@@ -1381,6 +1388,13 @@
}, },
"pasteGradient": "Wklej Gradient", "pasteGradient": "Wklej Gradient",
"pasteGradientPlaceholder": "Wklej tutaj JSON gradientu...", "pasteGradientPlaceholder": "Wklej tutaj JSON gradientu...",
"ansiBands": "Paski ANSI" "ansiBands": "Paski ANSI",
"systemAudioConsentAllow": "Zezwól",
"systemAudioConsentBody": "Wizualizer wymaga dostępu do audio systemu do działania",
"systemAudioConsentDecline": "Odmów",
"systemAudioConsentTitle": "Przyznać dostęp do audio systemu?",
"systemAudioCaptureFailed": "Nie udało się rozpocząć przechwytywania: {{message}}",
"systemAudioNoAudioTrack": "Nie została zwrócona żadna ścieżka audio. Sprawdź czy przechwytywanie audio będzie włączone, gdy będzie o to prośba.",
"systemAudioExclusiveModeNotSupported": "Wizualizer jest niedostępny w gdy tryb wyłącznie z dźwiękiek jest włączony. Wyłącz tryb wyłącznie audio w ustawieniach MPV i spróbuj ponownie."
} }
} }
+14 -3
View File
@@ -91,7 +91,7 @@
"share": "compartilhar", "share": "compartilhar",
"close": "fechar", "close": "fechar",
"translation": "tradução", "translation": "tradução",
"albumGain": "ganho do album", "albumGain": "ganho do álbum",
"trackPeak": "peak da faixa", "trackPeak": "peak da faixa",
"albumPeak": "pico do álbum", "albumPeak": "pico do álbum",
"trackGain": "ganho da faixa", "trackGain": "ganho da faixa",
@@ -121,10 +121,21 @@
"removeFromFavorites": "remover de $t(entity.favorite, {\"count\": 2})", "removeFromFavorites": "remover de $t(entity.favorite, {\"count\": 2})",
"openIn": { "openIn": {
"lastfm": "Abrir em Last.fm", "lastfm": "Abrir em Last.fm",
"musicbrainz": "Abrir em MusicBrainz" "musicbrainz": "Abrir em MusicBrainz",
"listenbrainz": "Abrir no ListenBrainz",
"qobuz": "Abrir no Qobuz"
}, },
"toggleSmartPlaylistEditor": "alternar editor $t(entity.smartPlaylist)", "toggleSmartPlaylistEditor": "alternar editor $t(entity.smartPlaylist)",
"moveToNext": "mover para o próximo" "moveToNext": "mover para o próximo",
"addOrRemoveFromSelection": "adicionar ou remover da seleção",
"goToCurrent": "ir para o item atual",
"createRadioStation": "criar $t(entity.radioStation, {\"count\": 1})",
"deleteRadioStation": "deletar $t(entity.radioStation, {\"count\": 1})",
"selectAll": "selecionar tudo",
"holdToMoveToTop": "segure para ir ao topo",
"moveItems": "mover itens",
"viewMore": "ver mais",
"openApplicationDirectory": "abrir a pasta do aplicativo"
}, },
"form": { "form": {
"deletePlaylist": { "deletePlaylist": {
+47 -13
View File
@@ -18,8 +18,11 @@
"toggleSmartPlaylistEditor": "вкл./откл. редактор $t(entity.smartPlaylist)", "toggleSmartPlaylistEditor": "вкл./откл. редактор $t(entity.smartPlaylist)",
"removeFromFavorites": "удалить из $t(entity.favorite, {\"count\": 2})", "removeFromFavorites": "удалить из $t(entity.favorite, {\"count\": 2})",
"openIn": { "openIn": {
"lastfm": "открыть на Last.fm", "lastfm": "Открыть на Last.fm",
"musicbrainz": "открыть на MusicBrainz" "musicbrainz": "открыть на MusicBrainz",
"listenbrainz": "Открыть на ListenBrainz",
"qobuz": "Открыть в Qobuz",
"spotify": "Открыть в Spotify"
}, },
"moveToNext": "следующий", "moveToNext": "следующий",
"addOrRemoveFromSelection": "добавить или удалить из выделения", "addOrRemoveFromSelection": "добавить или удалить из выделения",
@@ -167,7 +170,8 @@
"explicit": "нецензурная лексика", "explicit": "нецензурная лексика",
"externalLinks": "внешние ссылки", "externalLinks": "внешние ссылки",
"explicitStatus": "признак нецензурного контента", "explicitStatus": "признак нецензурного контента",
"newVersionAvailable": "доступна новая версия" "newVersionAvailable": "доступна новая версия",
"numberOfResults": "{{numberOfResults}} результатов"
}, },
"entity": { "entity": {
"album_one": "альбом", "album_one": "альбом",
@@ -435,7 +439,17 @@
"home": "$t(common.home)", "home": "$t(common.home)",
"myLibrary": "Моя библиотека", "myLibrary": "Моя библиотека",
"shared": "Публичные плейлисты $t(entity.playlist, {\"count\": 2})", "shared": "Публичные плейлисты $t(entity.playlist, {\"count\": 2})",
"collections": "коллекции" "collections": "коллекции",
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
"albums": "$t(entity.album, {\"count\": 2})",
"artists": "$t(entity.artist, {\"count\": 2})",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"folders": "$t(entity.folder, {\"count\": 2})",
"genres": "$t(entity.genre, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})",
"playlists": "$t(entity.playlist, {\"count\": 2})",
"settings": "$t(common.setting, {\"count\": 2})",
"tracks": "$t(entity.track, {\"count\": 2})"
}, },
"fullscreenPlayer": { "fullscreenPlayer": {
"config": { "config": {
@@ -452,7 +466,9 @@
"useImageAspectRatio": "использовать соотношение сторон изображения", "useImageAspectRatio": "использовать соотношение сторон изображения",
"lyricGap": "пробел между словами", "lyricGap": "пробел между словами",
"dynamicIsImage": "включить фоновое изображение", "dynamicIsImage": "включить фоновое изображение",
"dynamicImageBlur": "сила размытия изображения" "dynamicImageBlur": "сила размытия изображения",
"lyricOpacityNonActive": "непрозрачность неактивных слов",
"lyricScaleNonActive": "размер неактивных слов"
}, },
"upNext": "играет", "upNext": "играет",
"lyrics": "слова", "lyrics": "слова",
@@ -475,7 +491,8 @@
"selectMusicFolder": "выбрать папку с музыкой", "selectMusicFolder": "выбрать папку с музыкой",
"noMusicFolder": "папка с музыкой не выбрана", "noMusicFolder": "папка с музыкой не выбрана",
"multipleMusicFolders": "{{count}} выбрано музыкальных папок", "multipleMusicFolders": "{{count}} выбрано музыкальных папок",
"commandPalette": "открыть командную строку" "commandPalette": "открыть командную строку",
"settings": "$t(common.setting, {\"count\": 2})"
}, },
"manageServers": { "manageServers": {
"title": "сервера", "title": "сервера",
@@ -510,7 +527,8 @@
"goTo": "перейти в", "goTo": "перейти в",
"moveToNext": "$t(action.moveToNext)", "moveToNext": "$t(action.moveToNext)",
"playShuffled": "$t(player.shuffle)", "playShuffled": "$t(player.shuffle)",
"moveItems": "$t(action.moveItems)" "moveItems": "$t(action.moveItems)",
"playSimilarSongs": "$t(player.playSimilarSongs)"
}, },
"home": { "home": {
"mostPlayed": "слушают чаще всего", "mostPlayed": "слушают чаще всего",
@@ -518,7 +536,8 @@
"title": "$t(common.home)", "title": "$t(common.home)",
"explore": "откройте новое", "explore": "откройте новое",
"recentlyPlayed": "игралось недавно", "recentlyPlayed": "игралось недавно",
"recentlyReleased": "Новинки" "recentlyReleased": "Новинки",
"genres": "$t(entity.genre, {\"count\": 2})"
}, },
"albumDetail": { "albumDetail": {
"moreFromArtist": "больше от $t(entity.artist, {\"count\": 1})", "moreFromArtist": "больше от $t(entity.artist, {\"count\": 1})",
@@ -552,10 +571,13 @@
}, },
"genreList": { "genreList": {
"showAlbums": "показать $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})", "showAlbums": "показать $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
"showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})" "showTracks": "показать $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})",
"title": "$t(entity.genre, {\"count\": 2})"
}, },
"trackList": { "trackList": {
"artistTracks": "Треки {{artist}}" "artistTracks": "Треки {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
"title": "$t(entity.track, {\"count\": 2})"
}, },
"globalSearch": { "globalSearch": {
"commands": { "commands": {
@@ -616,6 +638,12 @@
}, },
"favorites": { "favorites": {
"title": "$t(entity.favorite, {\"count\": 2})" "title": "$t(entity.favorite, {\"count\": 2})"
},
"folderList": {
"title": "$t(entity.folder, {\"count\": 2})"
},
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
} }
}, },
"form": { "form": {
@@ -656,7 +684,8 @@
"input_skipDuplicates": "не добавлять дубликаты", "input_skipDuplicates": "не добавлять дубликаты",
"create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}", "create": "создать $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст", "searchOrCreate": "для создания нового списка выполните поиск по $t(entity.playlist, {\"count\": 2}) или введите соответствующий текст",
"input_playlists": "$t(entity.playlist, {\"count\": 2})" "input_playlists": "$t(entity.playlist, {\"count\": 2})",
"noneAdded": "в плейлист $t(entity.playlist, {\"count\": 1}) '{{playlist}}' не было добавлено ни одного трека"
}, },
"updateServer": { "updateServer": {
"title": "обновление сервера", "title": "обновление сервера",
@@ -725,6 +754,9 @@
"input_played_optionUnplayed": "только не игранные треки", "input_played_optionUnplayed": "только не игранные треки",
"input_played_optionPlayed": "только воспроизведённые треки", "input_played_optionPlayed": "только воспроизведённые треки",
"input_genre": "$t(entity.genre, {\"count\": 1})" "input_genre": "$t(entity.genre, {\"count\": 1})"
},
"editRadioStation": {
"success": "радиостанция успешно обновлена"
} }
}, },
"setting": { "setting": {
@@ -1053,7 +1085,8 @@
"audioFadeOnStatusChange": "плавное изменение звука", "audioFadeOnStatusChange": "плавное изменение звука",
"audioFadeOnStatusChange_description": "включает эффекты затухания и появления звука при изменении статуса (пауза/проигрывание)", "audioFadeOnStatusChange_description": "включает эффекты затухания и появления звука при изменении статуса (пауза/проигрывание)",
"preventSleepOnPlayback_description": "запрещает спящий режим экрана, пока играет музыка", "preventSleepOnPlayback_description": "запрещает спящий режим экрана, пока играет музыка",
"preventSleepOnPlayback": "не переходить в спящий режим" "preventSleepOnPlayback": "не переходить в спящий режим",
"discordLinkType_none": "$t(common.none)"
}, },
"releaseType": { "releaseType": {
"secondary": { "secondary": {
@@ -1074,7 +1107,8 @@
"other": "другие", "other": "другие",
"broadcast": "транслировать", "broadcast": "транслировать",
"ep": "эп", "ep": "эп",
"single": "сингл" "single": "сингл",
"album": "$t(entity.album, {\"count\": 1})"
} }
}, },
"datetime": { "datetime": {
+1
View File
@@ -0,0 +1 @@
{}
+66 -6
View File
@@ -36,7 +36,10 @@
"openApplicationDirectory": "відкрити каталог додатків", "openApplicationDirectory": "відкрити каталог додатків",
"openIn": { "openIn": {
"lastfm": "Відкрити в Last.fm", "lastfm": "Відкрити в Last.fm",
"musicbrainz": "Відкрити в MusicBrainz" "musicbrainz": "Відкрити в MusicBrainz",
"listenbrainz": "Відкрити у ListenBrainz",
"qobuz": "Відкрити у Qobuz",
"spotify": "Відкрити у Spotify"
} }
}, },
"common": { "common": {
@@ -164,7 +167,9 @@
"explicit": "Експліцитний зміст", "explicit": "Експліцитний зміст",
"gridRows": "рядки сітки", "gridRows": "рядки сітки",
"tableColumns": "стовпці таблиці", "tableColumns": "стовпці таблиці",
"itemsMore": "{{count}} більше" "itemsMore": "{{count}} більше",
"numberOfResults": "{{numberOfResults}} результатів",
"newVersionAvailable": "доступна нова версія"
}, },
"entity": { "entity": {
"album_one": "альбом", "album_one": "альбом",
@@ -255,7 +260,9 @@
"serverRequired": "потрібен сервер", "serverRequired": "потрібен сервер",
"sessionExpiredError": "ваша сесія закінчилася", "sessionExpiredError": "ваша сесія закінчилася",
"systemFontError": "сталася помилка під час спроби отримати системні шрифти", "systemFontError": "сталася помилка під час спроби отримати системні шрифти",
"settingsSyncError": "виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни" "settingsSyncError": "виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни",
"invalidJson": "недійсний JSON",
"playbackPausedDueToError": "відтворення було призупинено через помилку"
}, },
"filter": { "filter": {
"album": "$t(entity.album, {\"count\": 1})", "album": "$t(entity.album, {\"count\": 1})",
@@ -281,7 +288,7 @@
"isPublic": "є публічним", "isPublic": "є публічним",
"isRated": "є оціненим", "isRated": "є оціненим",
"isRecentlyPlayed": "нещодавно відтворено", "isRecentlyPlayed": "нещодавно відтворено",
"lastPlayed": "нещодавно відтворені", "lastPlayed": "останнє відтворене",
"mostPlayed": "найбільш відтворювані", "mostPlayed": "найбільш відтворювані",
"name": "назва", "name": "назва",
"note": "примітка", "note": "примітка",
@@ -301,7 +308,9 @@
"title": "назва", "title": "назва",
"toYear": "до року", "toYear": "до року",
"trackNumber": "трек", "trackNumber": "трек",
"explicitStatus": "$t(common.explicitStatus)" "explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "і",
"matchOr": "або"
}, },
"datetime": { "datetime": {
"minuteShort": "хв.", "minuteShort": "хв.",
@@ -414,7 +423,9 @@
"setExpiration": "встановити термін дії", "setExpiration": "встановити термін дії",
"success": "посилання для спільного використання скопійовано в буфер обміну (натисніть тут, щоб відкрити)", "success": "посилання для спільного використання скопійовано в буфер обміну (натисніть тут, щоб відкрити)",
"expireInvalid": "термін дії повинен бути в майбутньому", "expireInvalid": "термін дії повинен бути в майбутньому",
"createFailed": "не вдалося створити спільний доступ (чи ввімкнено спільний доступ?)" "createFailed": "не вдалося створити спільний доступ (чи ввімкнено спільний доступ?)",
"copyToClipboard": "Скопіювати до буфера обміну: Ctrl+C, Enter",
"successMustClick": "посилання успішно створено, натисніть сюди, щоб відкрити"
}, },
"shuffleAll": { "shuffleAll": {
"title": "відтворити випадково", "title": "відтворити випадково",
@@ -435,6 +446,9 @@
"enabled": "приватний режим увімкнено, стан відтворення тепер приховано від зовнішніх інтеграцій", "enabled": "приватний режим увімкнено, стан відтворення тепер приховано від зовнішніх інтеграцій",
"disabled": "приватний режим вимкнено, стан відтворення тепер видно для увімкнених зовнішніх інтеграцій", "disabled": "приватний режим вимкнено, стан відтворення тепер видно для увімкнених зовнішніх інтеграцій",
"title": "приватний режим" "title": "приватний режим"
},
"editRadioStation": {
"success": "радіо станція успішно оновлена"
} }
}, },
"player": { "player": {
@@ -539,6 +553,52 @@
"goToAlbum": "перейти до $t(entity.album, {\"count\": 1})", "goToAlbum": "перейти до $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "перейти до $t(entity.albumArtist, {\"count\": 1})", "goToAlbumArtist": "перейти до $t(entity.albumArtist, {\"count\": 1})",
"showDetails": "отримати інформацію" "showDetails": "отримати інформацію"
},
"fullscreenPlayer": {
"config": {
"dynamicBackground": "динамічний фон",
"dynamicImageBlur": "розмір розмиття зображення",
"dynamicIsImage": "включити фонове зображення",
"followCurrentLyric": "слідкувати за поточним рядком",
"lyricAlignment": "вирівнювання тексту",
"lyricOffset": "затримка тексту (мс)",
"lyricGap": "розмір між рядками",
"lyricSize": "розмір тексту",
"opacity": "непрозорість",
"showLyricMatch": "показувати збіг тексту пісень",
"showLyricProvider": "показувати джерело тексту пісень",
"synchronized": "синхронізовано",
"unsynchronized": "несинхронізовано",
"useImageAspectRatio": "використовувати співвідношення сторін зображення"
},
"lyrics": "текст пісні",
"related": "пов'язані",
"upNext": "далі",
"visualizer": "візуалізатор",
"noLyrics": "текст пісні не знайдено"
},
"genreList": {
"showAlbums": "показати $t(entity.genre, {\"count\": 1}) $t(entity.album, {\"count\": 2})",
"showTracks": "показати $t(entity.genre, {\"count\": 1}) $t(entity.track, {\"count\": 2})",
"title": "$t(entity.genre, {\"count\": 2})"
},
"folderList": {
"title": "$t(entity.folder, {\"count\": 2})"
},
"globalSearch": {
"commands": {
"goToPage": "перейти до сторінки",
"searchFor": "шукати на {{query}}",
"serverCommands": "команди сервера"
},
"title": "команди"
},
"home": {
"explore": "дослідити з вашої бібліотеки",
"genres": "$t(entity.genre, {\"count\": 2})",
"mostPlayed": "найбільш відтворені",
"newlyAdded": "нещодавно додані релізи",
"recentlyPlayed": "нещодавно відтворені"
} }
} }
} }
+3
View File
@@ -1015,6 +1015,9 @@
"input_played_optionPlayed": "仅已播放的曲目", "input_played_optionPlayed": "仅已播放的曲目",
"input_limit": "有多少首歌?", "input_limit": "有多少首歌?",
"input_played": "播放筛选器" "input_played": "播放筛选器"
},
"editRadioStation": {
"success": "电台更新成功"
} }
}, },
"table": { "table": {
+17 -5
View File
@@ -233,7 +233,9 @@
"showLyricMatch": "顯示匹配的歌詞", "showLyricMatch": "顯示匹配的歌詞",
"dynamicImageBlur": "圖片模糊大小", "dynamicImageBlur": "圖片模糊大小",
"dynamicIsImage": "啟用背景圖片", "dynamicIsImage": "啟用背景圖片",
"lyricOffset": "歌詞偏移時間 (ms)" "lyricOffset": "歌詞偏移時間 (ms)",
"lyricOpacityNonActive": "非活躍歌詞的不透明度",
"lyricScaleNonActive": "非活躍歌詞的比例"
}, },
"lyrics": "歌詞", "lyrics": "歌詞",
"related": "相關", "related": "相關",
@@ -433,7 +435,7 @@
"audioDevice": "音訊設備", "audioDevice": "音訊設備",
"audioDevice_description": "選擇用於播放的音訊設備", "audioDevice_description": "選擇用於播放的音訊設備",
"audioExclusiveMode": "音訊獨佔模式", "audioExclusiveMode": "音訊獨佔模式",
"audioExclusiveMode_description": "啟用獨佔輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音訊", "audioExclusiveMode_description": "啟用獨佔輸出模式。在此模式下,系統通常被鎖定,只有 mpv 能夠輸出音訊。視覺化音訊截取在此選項啟用時不會作用",
"audioPlayer": "音訊播放器", "audioPlayer": "音訊播放器",
"crossfadeDuration": "淡入淡出持續時間", "crossfadeDuration": "淡入淡出持續時間",
"crossfadeDuration_description": "設定淡入淡出持續時間", "crossfadeDuration_description": "設定淡入淡出持續時間",
@@ -792,7 +794,11 @@
"qobuz_description": "在藝術家/專輯頁面上顯示 Qobuz 的連結", "qobuz_description": "在藝術家/專輯頁面上顯示 Qobuz 的連結",
"qobuz": "顯示 Qobuz 連結", "qobuz": "顯示 Qobuz 連結",
"waveformLoadingDelay": "波形載入延遲", "waveformLoadingDelay": "波形載入延遲",
"waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。" "waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。",
"playerbarWaveformStretch": "波形拉伸",
"playerbarWaveformStretch_description": "拉伸波形來填補可用空間",
"preventSuspendOnPlayback_description": "音樂播放時防止應用程式進入休眠",
"preventSuspendOnPlayback": "在播放時防止應用程式暫停"
}, },
"table": { "table": {
"config": { "config": {
@@ -1042,7 +1048,8 @@
"success": "新增 $t(entity.trackWithCount, {\"count\": {{message}} }) 到 $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })", "success": "新增 $t(entity.trackWithCount, {\"count\": {{message}} }) 到 $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
"title": "新增到$t(entity.playlist, {\"count\": 1})", "title": "新增到$t(entity.playlist, {\"count\": 1})",
"create": "建立 $t(entity.playlist, {\"count\": 1}) {{playlist}}", "create": "建立 $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "搜尋$t(entity.playlist, {\"count\": 2}) 或輸入內容以建立新項目" "searchOrCreate": "搜尋$t(entity.playlist, {\"count\": 2}) 或輸入內容以建立新項目",
"noneAdded": "沒有曲目新增到 $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
}, },
"createPlaylist": { "createPlaylist": {
"input_description": "$t(common.description)", "input_description": "$t(common.description)",
@@ -1337,6 +1344,11 @@
} }
}, },
"systemAudioCaptureFailed": "無法開始擷取:{{message}}", "systemAudioCaptureFailed": "無法開始擷取:{{message}}",
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。" "systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。",
"systemAudioConsentAllow": "允許",
"systemAudioConsentBody": "此視覺化器需要存取系統音訊才能運作",
"systemAudioConsentDecline": "拒絕",
"systemAudioConsentTitle": "允許存取系統音訊?",
"systemAudioExclusiveModeNotSupported": "啟用音訊獨佔模式時,視覺化不可用。 在MPV設定中停用音訊獨佔模式,然後再試一次。"
} }
} }
+30 -3
View File
@@ -1,13 +1,13 @@
import console from 'console'; import console from 'console';
import { app, ipcMain } from 'electron'; import { app, ipcMain } from 'electron';
import { rm } from 'fs/promises'; import { access, rm } from 'fs/promises';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv'; import MpvAPI from 'node-mpv';
import { pid } from 'node:process'; import { pid } from 'node:process';
import process from 'process'; import process from 'process';
import { getMainWindow, sendToastToRenderer } from '../../../index'; import { getMainWindow, sendToastToRenderer } from '../../../index';
import { createLog, isWindows } from '../../../utils'; import { createLog, isMacOS, isWindows } from '../../../utils';
import { store } from '../settings'; import { store } from '../settings';
import { PlayerData } from '/@/shared/types/domain-types'; import { PlayerData } from '/@/shared/types/domain-types';
@@ -69,6 +69,7 @@ const mpvLog = (
}; };
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined; const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
const MACOS_MPV_BINARY_PATHS = ['/opt/homebrew/bin/mpv', '/usr/local/bin/mpv'];
const prefetchPlaylistParams = [ const prefetchPlaylistParams = [
'--prefetch-playlist=no', '--prefetch-playlist=no',
@@ -86,12 +87,38 @@ const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
return parameters; return parameters;
}; };
const resolveMpvBinaryPath = async (binaryPath?: string) => {
if (binaryPath) {
return binaryPath;
}
if (MPV_BINARY_PATH) {
return MPV_BINARY_PATH;
}
if (!isMacOS()) {
return undefined;
}
for (const candidate of MACOS_MPV_BINARY_PATHS) {
try {
await access(candidate);
return candidate;
} catch {
// Try the next common Homebrew location.
}
}
return undefined;
};
const createMpv = async (data: { const createMpv = async (data: {
binaryPath?: string; binaryPath?: string;
extraParameters?: string[]; extraParameters?: string[];
properties?: Record<string, any>; properties?: Record<string, any>;
}): Promise<MpvAPI> => { }): Promise<MpvAPI> => {
const { binaryPath, extraParameters, properties } = data; const { binaryPath, extraParameters, properties } = data;
const resolvedBinaryPath = await resolveMpvBinaryPath(binaryPath);
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]); const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
@@ -99,7 +126,7 @@ const createMpv = async (data: {
{ {
audio_only: true, audio_only: true,
auto_restart: false, auto_restart: false,
binary: binaryPath || MPV_BINARY_PATH || undefined, binary: resolvedBinaryPath,
socket: socketPath, socket: socketPath,
time_update: 1, time_update: 1,
}, },
+8 -1
View File
@@ -1,2 +1,9 @@
import './core'; import './core';
import(`./${process.platform}`);
if (process.platform === 'linux') {
import('./linux');
} else if (process.platform === 'darwin') {
import('./darwin');
} else if (process.platform === 'win32') {
import('./win32');
}
+1
View File
@@ -0,0 +1 @@
export {};
+18 -9
View File
@@ -734,14 +734,21 @@ async function createWindow(first = true): Promise<void> {
}); });
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => { mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
desktopCapturer if (!isMacOS()) {
.getSources({ types: ['screen'] }) callback({ audio: 'loopback' });
.then((sources) => { return;
if (sources.length > 0) {
callback({ audio: 'loopback', video: sources[0] });
} else {
callback({});
} }
desktopCapturer
.getSources({ thumbnailSize: { height: 0, width: 0 }, types: ['screen'] })
.then((sources) => {
const source = sources[0];
if (!source) {
callback({});
return;
}
callback({ audio: 'loopback', video: source });
}) })
.catch((err) => { .catch((err) => {
log.warn('desktopCapturer.getSources failed', err); log.warn('desktopCapturer.getSources failed', err);
@@ -924,12 +931,14 @@ ipcMain.on(
}, },
); );
ipcMain.handle('power-save-blocker-start', () => { ipcMain.handle('power-save-blocker-start', (_event, { full }: { full: boolean }) => {
if (powerSaveBlockerId !== null) { if (powerSaveBlockerId !== null) {
return powerSaveBlockerId; return powerSaveBlockerId;
} }
powerSaveBlockerId = powerSaveBlocker.start('prevent-display-sleep'); powerSaveBlockerId = powerSaveBlocker.start(
full ? 'prevent-display-sleep' : 'prevent-app-suspension',
);
return powerSaveBlockerId; return powerSaveBlockerId;
}); });
+2 -1
View File
@@ -1,4 +1,5 @@
import { SetActivity } from '@xhayper/discord-rpc'; import type { SetActivity } from '@xhayper/discord-rpc';
import { ipcRenderer } from 'electron'; import { ipcRenderer } from 'electron';
const initialize = (clientId: string) => { const initialize = (clientId: string) => {
+28
View File
@@ -147,6 +147,20 @@ export const controller: GeneralController = {
server.type, server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
}, },
deleteArtistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteArtistImage`,
);
}
return apiController(
'deleteArtistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deleteFavorite(args) { deleteFavorite(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
@@ -988,6 +1002,20 @@ export const controller: GeneralController = {
server.type, server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
}, },
uploadArtistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadArtistImage`,
);
}
return apiController(
'uploadArtistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadInternetRadioStationImage(args) { uploadInternetRadioStationImage(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
@@ -409,6 +409,8 @@ export const JellyfinController: InternalControllerEndpoint = {
return jfNormalize.album( return jfNormalize.album(
{ ...res.body, Songs: songsRes.body.Items }, { ...res.body, Songs: songsRes.body.Items },
apiClientProps.server, apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
); );
}, },
getAlbumList: async (args) => { getAlbumList: async (args) => {
@@ -580,7 +582,8 @@ export const JellyfinController: InternalControllerEndpoint = {
return `${apiClientProps.server?.url}/items/${query.id}/download?apiKey=${apiClientProps.server?.credential}`; return `${apiClientProps.server?.url}/items/${query.id}/download?apiKey=${apiClientProps.server?.credential}`;
}, },
getFolder: async ({ apiClientProps, query }) => { getFolder: async (args) => {
const { apiClientProps, query } = args;
const userId = apiClientProps.server?.userId; const userId = apiClientProps.server?.userId;
if (!userId) throw new Error('No userId found'); if (!userId) throw new Error('No userId found');
@@ -742,6 +745,8 @@ export const JellyfinController: InternalControllerEndpoint = {
jfNormalize.song( jfNormalize.song(
item as unknown as z.infer<typeof jfType._response.song>, item as unknown as z.infer<typeof jfType._response.song>,
apiClientProps.server, apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
), ),
); );
@@ -46,6 +46,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
deleteArtistImage: {
body: null,
method: 'DELETE',
path: 'artist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deleteArtistImage),
500: resultWithHeaders(ndType._response.error),
},
},
deleteInternetRadioStation: { deleteInternetRadioStation: {
body: null, body: null,
method: 'DELETE', method: 'DELETE',
@@ -259,6 +268,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
}, },
}, },
uploadArtistImage: {
body: ndType._parameters.uploadArtistImage,
method: 'POST',
path: 'artist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadArtistImage),
500: resultWithHeaders(ndType._response.error),
},
},
uploadInternetRadioStationImage: { uploadInternetRadioStationImage: {
body: ndType._parameters.uploadInternetRadioStationImage, body: ndType._parameters.uploadInternetRadioStationImage,
method: 'POST', method: 'POST',
@@ -13,6 +13,8 @@ import {
albumArtistListSortMap, albumArtistListSortMap,
albumListSortMap, albumListSortMap,
AuthenticationResponse, AuthenticationResponse,
DeleteArtistImageArgs,
DeleteArtistImageResponse,
DeleteInternetRadioStationImageArgs, DeleteInternetRadioStationImageArgs,
DeleteInternetRadioStationImageResponse, DeleteInternetRadioStationImageResponse,
DeletePlaylistImageArgs, DeletePlaylistImageArgs,
@@ -28,6 +30,8 @@ import {
SortOrder, SortOrder,
sortOrderMap, sortOrderMap,
tagListSortMap, tagListSortMap,
UploadArtistImageArgs,
UploadArtistImageResponse,
UploadInternetRadioStationImageArgs, UploadInternetRadioStationImageArgs,
UploadInternetRadioStationImageResponse, UploadInternetRadioStationImageResponse,
UploadPlaylistImageArgs, UploadPlaylistImageArgs,
@@ -42,6 +46,7 @@ const VERSION_INFO: VersionInfo = [
[ [
'0.61.0', '0.61.0',
{ {
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
[ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1], [ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1],
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1], [ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
}, },
@@ -186,6 +191,21 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id, id: res.body.data.id,
}; };
}, },
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deleteArtistImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete artist image');
}
return res.body.data.status === 'ok';
},
deleteFavorite: SubsonicController.deleteFavorite, deleteFavorite: SubsonicController.deleteFavorite,
deleteInternetRadioStation: async (args) => { deleteInternetRadioStation: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -476,7 +496,12 @@ export const NavidromeController: InternalControllerEndpoint = {
} }
return res.body.similarSongs.song.map((song) => return res.body.similarSongs.song.map((song) =>
ssNormalize.song(song, apiClientProps.server), ssNormalize.song(
song,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
); );
}, },
getArtistList: async (args) => { getArtistList: async (args) => {
@@ -546,7 +571,12 @@ export const NavidromeController: InternalControllerEndpoint = {
} }
return res.body.similarSongs2.song.map((song) => return res.body.similarSongs2.song.map((song) =>
ssNormalize.song(song, apiClientProps.server), ssNormalize.song(
song,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
); );
}, },
getDownloadUrl: SubsonicController.getDownloadUrl, getDownloadUrl: SubsonicController.getDownloadUrl,
@@ -803,7 +833,14 @@ export const NavidromeController: InternalControllerEndpoint = {
return ( return (
(res.body.similarSongs?.song || []) (res.body.similarSongs?.song || [])
.filter((song) => song.id !== query.songId) .filter((song) => song.id !== query.songId)
.map((song) => ssNormalize.song(song, apiClientProps.server)) || [] .map((song) =>
ssNormalize.song(
song,
apiClientProps.server,
args.context?.pathReplace,
args.context?.pathReplaceWith,
),
) || []
); );
}, },
getSongDetail: async (args) => { getSongDetail: async (args) => {
@@ -1002,6 +1039,7 @@ export const NavidromeController: InternalControllerEndpoint = {
const res = await NavidromeController.getSongList({ const res = await NavidromeController.getSongList({
apiClientProps, apiClientProps,
context: args.context,
query: { query: {
artistIds: [query.artistId], artistIds: [query.artistId],
sortBy: SongListSort.PLAY_COUNT, sortBy: SongListSort.PLAY_COUNT,
@@ -1270,6 +1308,40 @@ export const NavidromeController: InternalControllerEndpoint = {
return null; return null;
}, },
uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
const { apiClientProps, body, query } = args;
const server = apiClientProps.server;
const serverUrl = server?.url?.replace(/\/$/, '');
if (!serverUrl) {
throw new Error('Server is required');
}
const form = new FormData();
const bytes = body.image as Uint8Array<ArrayBuffer>;
const fileLike =
typeof File !== 'undefined'
? new File([bytes], 'image', { type: 'application/octet-stream' })
: new Blob([bytes], { type: 'application/octet-stream' });
form.append('image', fileLike as any);
const res = await axios.post(`${serverUrl}/api/artist/${query.id}/image`, form, {
headers: {
'Content-Type': 'multipart/form-data',
...(server?.ndCredential && {
'x-nd-authorization': `Bearer ${server.ndCredential}`,
}),
},
signal: apiClientProps.signal,
});
if (res.status !== 200) {
throw new Error('Failed to upload artist image');
}
return res.data?.status === 'ok';
},
uploadInternetRadioStationImage: async ( uploadInternetRadioStationImage: async (
args: UploadInternetRadioStationImageArgs, args: UploadInternetRadioStationImageArgs,
): Promise<UploadInternetRadioStationImageResponse> => { ): Promise<UploadInternetRadioStationImageResponse> => {
+30 -3
View File
@@ -1,6 +1,5 @@
import { initClient, initContract } from '@ts-rest/core'; import { initClient, initContract } from '@ts-rest/core';
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, isAxiosError } from 'axios'; import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, isAxiosError } from 'axios';
import omitBy from 'lodash/omitBy';
import qs from 'qs'; import qs from 'qs';
import { z } from 'zod'; import { z } from 'zod';
@@ -377,11 +376,39 @@ axiosClient.interceptors.response.use(
}, },
); );
const keysToSkipEmptyCheck = new Set([
'artist',
'comment',
'genre',
'name',
'query',
'u',
'username',
]);
const parsePath = (fullPath: string) => { const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?'); const [path, params] = fullPath.split('?');
const parsedParams = qs.parse(params, { arrayLimit: 99999, parameterLimit: 99999 }); const url = new URLSearchParams(params);
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null'); const notNilParams: Record<string, string[]> = {};
for (const [key, value] of url) {
if (!keysToSkipEmptyCheck.has(key) && (value === 'undefined' || value === 'null')) {
continue;
}
let realKey = key;
if (key.includes('[') && key.includes(']')) {
realKey = key.split('[')[0];
}
if (realKey in notNilParams) {
notNilParams[realKey].push(value);
} else {
notNilParams[realKey] = [value];
}
}
return { return {
params: notNilParams, params: notNilParams,
@@ -2015,8 +2015,12 @@ export const SubsonicController: InternalControllerEndpoint = {
}, },
}); });
// If the server returns an error for transcodeDecision, fall back to direct stream so that we don't break the player
if (transcodeDecision.status !== 200) { if (transcodeDecision.status !== 200) {
throw new Error('Failed to get transcode decision'); logFn.error(
`Failed to get transcode decision for song ${id}, falling back to direct stream`,
);
return streamUrl;
} }
const td = transcodeDecision.body.transcodeDecision; const td = transcodeDecision.body.transcodeDecision;
@@ -2121,6 +2125,7 @@ export const SubsonicController: InternalControllerEndpoint = {
const res = await SubsonicController.getSongList({ const res = await SubsonicController.getSongList({
apiClientProps, apiClientProps,
context,
query: { query: {
artistIds: [query.artistId], artistIds: [query.artistId],
sortBy: SongListSort.PLAY_COUNT, sortBy: SongListSort.PLAY_COUNT,
+2 -7
View File
@@ -22,12 +22,7 @@ import { WebAudio } from '/@/shared/types/types';
import '/@/shared/styles/global.css'; import '/@/shared/styles/global.css';
import { PlayerProvider } from '/@/renderer/features/player/context/player-context'; import { PlayerProvider } from '/@/renderer/features/player/context/player-context';
import { AudioPlayers } from '/@/renderer/features/player/components/audio-players'; import { AudioPlayers } from '/@/renderer/features/player/components/audio-players';
import { ReleaseNotesModal } from '/@/renderer/release-notes-modal';
const ReleaseNotesModal = lazy(() =>
import('./release-notes-modal').then((module) => ({
default: module.ReleaseNotesModal,
})),
);
const UpdateAvailableDialog = lazy(() => const UpdateAvailableDialog = lazy(() =>
import('./update-available-dialog').then((module) => ({ import('./update-available-dialog').then((module) => ({
@@ -82,8 +77,8 @@ const AppShell = memo(function AppShell() {
<AppRouter /> <AppRouter />
</PlayerProvider> </PlayerProvider>
</WebAudioContext.Provider> </WebAudioContext.Provider>
<Suspense fallback={null}>
<ReleaseNotesModal /> <ReleaseNotesModal />
<Suspense fallback={null}>
<UpdateAvailableDialog /> <UpdateAvailableDialog />
</Suspense> </Suspense>
</> </>
+340 -302
View File
@@ -169,6 +169,292 @@ export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
showRating: boolean; showRating: boolean;
} }
type ItemCardData = NonNullable<ItemCardProps['data']>;
const ItemCardStandardImageArea = memo(function ItemCardStandardImageArea({
controls,
data,
enableExpansion,
enableImageViewport = true,
enableNavigation,
handleContextMenu,
handleImageClick,
handleLinkDragStart,
imageAsLink,
imageFetchPriority,
internalState,
isRound,
itemType,
navigationPath,
showRating,
variant,
withControls,
}: {
controls?: ItemControls;
data: ItemCardData;
enableExpansion?: boolean;
enableImageViewport?: boolean;
enableNavigation?: boolean;
handleContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
handleImageClick: (e: React.MouseEvent<HTMLElement>) => void;
handleLinkDragStart: (e: React.DragEvent<HTMLAnchorElement>) => void;
imageAsLink?: boolean;
imageFetchPriority?: 'auto' | 'high' | 'low';
internalState?: ItemListStateActions;
isRound?: boolean;
itemType: LibraryItem;
navigationPath: null | string;
showRating: boolean;
variant: 'default' | 'poster';
withControls?: boolean;
}) {
const [showControls, setShowControls] = useState(false);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
{...(variant === 'poster' ? { enableViewport: enableImageViewport } : {})}
explicitStatus={'explicitStatus' in data && data ? data.explicitStatus : null}
fetchPriority={imageFetchPriority}
id={(data as { imageId?: string })?.imageId}
itemType={itemType}
src={(data as { imageUrl?: string })?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
{...(variant === 'poster' ? { internalState } : {})}
item={data}
itemType={itemType}
showRating={showRating}
type={variant}
/>
)}
</AnimatePresence>
</>
);
return enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
);
});
ItemCardStandardImageArea.displayName = 'ItemCardStandardImageArea';
const CompactItemCardImageArea = memo(function CompactItemCardImageArea({
controls,
data,
enableExpansion,
enableNavigation,
handleContextMenu,
handleImageClick,
handleLinkDragStart,
imageAsLink,
imageFetchPriority,
internalState,
isRound,
itemType,
navigationPath,
rows,
showRating,
withControls,
}: {
controls?: ItemControls;
data: ItemCardData;
enableExpansion?: boolean;
enableNavigation?: boolean;
handleContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
handleImageClick: (e: React.MouseEvent<HTMLElement>) => void;
handleLinkDragStart: (e: React.DragEvent<HTMLAnchorElement>) => void;
imageAsLink?: boolean;
imageFetchPriority?: 'auto' | 'high' | 'low';
internalState?: ItemListStateActions;
isRound?: boolean;
itemType: LibraryItem;
navigationPath: null | string;
rows: DataRow[];
showRating: boolean;
withControls?: boolean;
}) {
const [showControls, setShowControls] = useState(false);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, {
[styles.isRound]: isRound,
})}
enableDebounce={false}
explicitStatus={'explicitStatus' in data && data ? data.explicitStatus : null}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="compact"
/>
)}
</AnimatePresence>
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows
.filter(
(row): row is NonNullable<typeof row> => row !== null && row !== undefined,
)
.map((row, index) => (
<ItemCardRow
data={data!}
index={index}
key={row.id}
row={row}
type="compact"
/>
))}
</div>
</>
);
return enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
);
});
CompactItemCardImageArea.displayName = 'CompactItemCardImageArea';
const CompactItemCard = ({ const CompactItemCard = ({
controls, controls,
data, data,
@@ -185,7 +471,6 @@ const CompactItemCard = ({
showRating, showRating,
withControls, withControls,
}: ItemCardDerivativeProps) => { }: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const itemRowId = const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data data && internalState && typeof data === 'object' && 'id' in data
? internalState.extractRowId(data) ? internalState.extractRowId(data)
@@ -297,18 +582,6 @@ const CompactItemCard = ({
if (data) { if (data) {
const navigationPath = getItemNavigationPath(data, itemType); const navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => { const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) { if (!data || !controls) {
return; return;
@@ -338,81 +611,6 @@ const CompactItemCard = ({
e.stopPropagation(); e.stopPropagation();
}; };
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, {
[styles.isRound]: isRound,
})}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="compact"
/>
)}
</AnimatePresence>
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows
.filter(
(row): row is NonNullable<typeof row> =>
row !== null && row !== undefined,
)
.map((row, index) => (
<ItemCardRow
data={data!}
index={index}
key={row.id}
row={row}
type="compact"
/>
))}
</div>
</>
);
return ( return (
<div <div
className={clsx(styles.container, styles.compact, { className={clsx(styles.container, styles.compact, {
@@ -421,31 +619,24 @@ const CompactItemCard = ({
})} })}
ref={ref} ref={ref}
> >
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? ( <CompactItemCardImageArea
<Link controls={controls}
className={imageContainerClassName} data={data}
draggable={false} enableExpansion={enableExpansion}
onClick={handleImageClick} enableNavigation={enableNavigation}
onContextMenu={handleContextMenu} handleContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart} handleImageClick={handleImageClick}
onMouseEnter={handleMouseEnter} handleLinkDragStart={handleLinkDragStart}
onMouseLeave={handleMouseLeave} imageAsLink={imageAsLink}
state={{ item: data }} imageFetchPriority={imageFetchPriority}
to={navigationPath} internalState={internalState}
> isRound={isRound}
{imageContainerContent} itemType={itemType}
</Link> navigationPath={navigationPath}
) : ( rows={rows}
<div showRating={showRating}
className={imageContainerClassName} withControls={withControls}
onClick={handleImageClick} />
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
)}
</div> </div>
); );
} }
@@ -491,7 +682,6 @@ const DefaultItemCard = ({
showRating, showRating,
withControls, withControls,
}: ItemCardDerivativeProps) => { }: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const itemRowId = const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data data && internalState && typeof data === 'object' && 'id' in data
? internalState.extractRowId(data) ? internalState.extractRowId(data)
@@ -538,18 +728,6 @@ const DefaultItemCard = ({
if (data) { if (data) {
const navigationPath = getItemNavigationPath(data, itemType); const navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => { const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) { if (!data || !controls) {
return; return;
@@ -579,93 +757,30 @@ const DefaultItemCard = ({
e.stopPropagation(); e.stopPropagation();
}; };
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={data?.imageId}
itemType={itemType}
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
item={data}
itemType={itemType}
showRating={showRating}
type="default"
/>
)}
</AnimatePresence>
</>
);
return ( return (
<div <div
className={clsx(styles.container, { className={clsx(styles.container, {
[styles.selected]: isSelected, [styles.selected]: isSelected,
})} })}
> >
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? ( <ItemCardStandardImageArea
<Link controls={controls}
className={imageContainerClassName} data={data}
draggable={false} enableExpansion={enableExpansion}
onClick={handleImageClick} enableNavigation={enableNavigation}
onContextMenu={handleContextMenu} handleContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart} handleImageClick={handleImageClick}
onMouseEnter={handleMouseEnter} handleLinkDragStart={handleLinkDragStart}
onMouseLeave={handleMouseLeave} imageAsLink={imageAsLink}
state={{ item: data }} imageFetchPriority={imageFetchPriority}
to={navigationPath} internalState={internalState}
> isRound={isRound}
{imageContainerContent} itemType={itemType}
</Link> navigationPath={navigationPath}
) : ( showRating={showRating}
<div variant="default"
className={imageContainerClassName} withControls={withControls}
onClick={handleImageClick} />
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
)}
<div className={styles.detailContainer}> <div className={styles.detailContainer}>
{rows {rows
.filter( .filter(
@@ -728,7 +843,6 @@ const PosterItemCard = ({
showRating, showRating,
withControls, withControls,
}: ItemCardDerivativeProps) => { }: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const itemRowId = const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data data && internalState && typeof data === 'object' && 'id' in data
? internalState.extractRowId(data) ? internalState.extractRowId(data)
@@ -840,18 +954,6 @@ const PosterItemCard = ({
if (data) { if (data) {
const navigationPath = getItemNavigationPath(data, itemType); const navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => { const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) { if (!data || !controls) {
return; return;
@@ -881,63 +983,6 @@ const PosterItemCard = ({
e.stopPropagation(); e.stopPropagation();
}; };
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
'userRating' in data &&
typeof (data as { userRating: null | number }).userRating === 'number'
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const imageContainerContent = (
<>
{itemType === LibraryItem.GENRE &&
data &&
'name' in data &&
typeof (data as Genre).name === 'string' ? (
<GenreImagePlaceholder
className={clsx(styles.image, styles.genrePlaceholder, {
[styles.isRound]: isRound,
})}
name={(data as Genre).name}
/>
) : (
<ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false}
explicitStatus={
'explicitStatus' in data && data ? data.explicitStatus : null
}
fetchPriority={imageFetchPriority}
id={(data as { imageId: string })?.imageId}
itemType={itemType}
src={(data as { imageUrl: string })?.imageUrl}
type="itemCard"
/>
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
showRating={showRating}
type="poster"
/>
)}
</AnimatePresence>
</>
);
return ( return (
<div <div
className={clsx(styles.container, styles.poster, { className={clsx(styles.container, styles.poster, {
@@ -946,31 +991,24 @@ const PosterItemCard = ({
})} })}
ref={ref} ref={ref}
> >
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? ( <ItemCardStandardImageArea
<Link controls={controls}
className={imageContainerClassName} data={data}
draggable={false} enableExpansion={enableExpansion}
onClick={handleImageClick} enableNavigation={enableNavigation}
onContextMenu={handleContextMenu} handleContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart} handleImageClick={handleImageClick}
onMouseEnter={handleMouseEnter} handleLinkDragStart={handleLinkDragStart}
onMouseLeave={handleMouseLeave} imageAsLink={imageAsLink}
state={{ item: data }} imageFetchPriority={imageFetchPriority}
to={navigationPath} internalState={internalState}
> isRound={isRound}
{imageContainerContent} itemType={itemType}
</Link> navigationPath={navigationPath}
) : ( showRating={showRating}
<div variant="poster"
className={imageContainerClassName} withControls={withControls}
onClick={handleImageClick} />
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</div>
)}
{data && ( {data && (
<div className={styles.detailContainer}> <div className={styles.detailContainer}>
{rows {rows
@@ -6,8 +6,8 @@ import {
ItemListStateItemWithRequiredProperties, ItemListStateItemWithRequiredProperties,
} from '/@/renderer/components/item-list/helpers/item-list-state'; } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types'; import { ItemControls } from '/@/renderer/components/item-list/types';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { useHotkeySettings, usePlayButtonBehavior } from '/@/renderer/store'; import { useHotkeySettings, usePlayButtonBehavior } from '/@/renderer/store';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types'; import { Play } from '/@/shared/types/types';
@@ -385,8 +385,8 @@ const BaseItemGridList = ({
rows, rows,
size = 'default', size = 'default',
}: ItemGridListProps) => { }: ItemGridListProps) => {
const rootRef = useRef(null); const rootRef = useRef<HTMLDivElement | null>(null);
const outerRef = useRef(null); const outerRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<FixedSizeList<GridItemProps>>(null); const listRef = useRef<FixedSizeList<GridItemProps>>(null);
const { ref: containerRef, width: containerWidth } = useElementSize(); const { ref: containerRef, width: containerWidth } = useElementSize();
const { focused, ref: containerFocusRef } = useFocusWithin(); const { focused, ref: containerFocusRef } = useFocusWithin();
@@ -486,7 +486,7 @@ const BaseItemGridList = ({
}, [itemsPerRow, rows?.length, size]); }, [itemsPerRow, rows?.length, size]);
useLayoutEffect(() => { useLayoutEffect(() => {
const { current: container } = containerRef; const container = rootRef.current;
if (!container) return; if (!container) return;
throttledSetTableMeta(containerWidth, resolvedItemCount, (meta) => { throttledSetTableMeta(containerWidth, resolvedItemCount, (meta) => {
@@ -500,13 +500,15 @@ const BaseItemGridList = ({
current.rowCount !== meta.rowCount current.rowCount !== meta.rowCount
) { ) {
tableMetaRef.current = meta; tableMetaRef.current = meta;
container.style.setProperty('--grid-column-count', String(meta.columnCount)); const el = rootRef.current;
container.style.setProperty('--grid-item-height', `${meta.itemHeight}px`); if (!el) return;
container.style.setProperty('--grid-row-count', String(meta.rowCount)); el.style.setProperty('--grid-column-count', String(meta.columnCount));
el.style.setProperty('--grid-item-height', `${meta.itemHeight}px`);
el.style.setProperty('--grid-row-count', String(meta.rowCount));
setTableMetaVersion((v) => v + 1); setTableMetaVersion((v) => v + 1);
} }
}); });
}, [containerWidth, resolvedItemCount, throttledSetTableMeta, containerRef]); }, [containerWidth, resolvedItemCount, throttledSetTableMeta]);
const controls = useDefaultItemListControls({ const controls = useDefaultItemListControls({
enableMultiSelect, enableMultiSelect,
@@ -31,6 +31,7 @@ import {
} from '/@/renderer/features/shared/components/list-sort-by-dropdown'; } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils'; import { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, usePlayerSong } from '/@/renderer/store'; import { useCurrentServer, usePlayerSong } from '/@/renderer/store';
import { useExternalLinks, useSettingsStore } from '/@/renderer/store/settings.store'; import { useExternalLinks, useSettingsStore } from '/@/renderer/store/settings.store';
@@ -49,7 +50,6 @@ import { Stack } from '/@/shared/components/stack/stack';
import { TextInput } from '/@/shared/components/text-input/text-input'; import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value'; import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { import {
Album, Album,
AlbumListSort, AlbumListSort,
@@ -43,6 +43,7 @@ import { searchLibraryItems } from '/@/renderer/features/shared/utils';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { useContainerQuery } from '/@/renderer/hooks'; import { useContainerQuery } from '/@/renderer/hooks';
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { import {
ArtistItem, ArtistItem,
@@ -75,7 +76,6 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
import { TextTitle } from '/@/shared/components/text-title/text-title'; import { TextTitle } from '/@/shared/components/text-title/text-title';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value'; import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { import {
Album, Album,
@@ -1214,7 +1214,7 @@ export const AlbumArtistDetailContent = ({
artistSongsLink={artistSongsLink} artistSongsLink={artistSongsLink}
onArtistRadio={handleArtistRadio} onArtistRadio={handleArtistRadio}
/> />
<Grid gutter="2xl"> <Grid gap="2xl">
<AlbumArtistMetadataGenres <AlbumArtistMetadataGenres
genres={detailQuery.data?.genres} genres={detailQuery.data?.genres}
order={genresOrder} order={genresOrder}
@@ -1,5 +1,5 @@
import { useQuery, useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query'; import { useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';
import { forwardRef, Fragment, useCallback, useMemo } from 'react'; import { forwardRef, Fragment, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
@@ -8,6 +8,8 @@ import styles from './album-artist-detail-header.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { getArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped'; import { getArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';
import { useDeleteArtistImage } from '/@/renderer/features/artists/mutations/delete-artist-image-mutation';
import { useUploadArtistImage } from '/@/renderer/features/artists/mutations/upload-artist-image-mutation';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { import {
@@ -20,17 +22,80 @@ import { AppRoute } from '/@/renderer/router/routes';
import { useAppStore, useCurrentServer, useShowRatings } from '/@/renderer/store'; import { useAppStore, useCurrentServer, useShowRatings } from '/@/renderer/store';
import { useArtistReleaseTypeItems, usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { useArtistReleaseTypeItems, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDurationString } from '/@/renderer/utils'; import { formatDurationString } from '/@/renderer/utils';
import { SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils'; import { hasFeature, SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { AlbumListResponse, LibraryItem, ServerType } from '/@/shared/types/domain-types'; import {
AlbumArtistDetailResponse,
AlbumListResponse,
LibraryItem,
ServerType,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { Play } from '/@/shared/types/types'; import { Play } from '/@/shared/types/types';
interface AlbumArtistDetailHeaderProps { interface AlbumArtistDetailHeaderProps {
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>; albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
} }
function ArtistImageUploadOverlay({
data,
onUploadFile,
}: {
data?: AlbumArtistDetailResponse;
onUploadFile: (file: File) => Promise<void>;
}) {
const deleteArtistImageMutation = useDeleteArtistImage({});
const server = useCurrentServer();
if (!data) return null;
if (!hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD)) return null;
return (
<Group gap="xs">
<FileButton
accept="image/*"
onChange={async (file) => {
if (!file) return;
await onUploadFile(file);
}}
>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="xs"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={!data?.uploadedImage}
icon="delete"
iconProps={{ size: 'lg' }}
onClick={(e) => {
e.stopPropagation();
if (!data?._serverId) return;
deleteArtistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
query: { id: data.id },
});
}}
radius="xl"
size="xs"
variant="default"
/>
</Group>
);
}
export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDetailHeaderProps>( export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDetailHeaderProps>(
({ albumsQuery }, ref) => { ({ albumsQuery }, ref) => {
const { albumArtistId, artistId } = useParams() as { const { albumArtistId, artistId } = useParams() as {
@@ -78,6 +143,7 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
const playButtonBehavior = usePlayButtonBehavior(); const playButtonBehavior = usePlayButtonBehavior();
const setFavorite = useSetFavorite(); const setFavorite = useSetFavorite();
const setRating = useSetRating(); const setRating = useSetRating();
const uploadArtistImageMutation = useUploadArtistImage({});
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort); const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
const sortBy = albumArtistDetailSort.sortBy; const sortBy = albumArtistDetailSort.sortBy;
@@ -167,40 +233,52 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
[detailQuery.data], [detailQuery.data],
); );
const imageUrl = useItemImageUrl({ const headerImageUrl = useItemImageUrl({
id: detailQuery.data?.imageId || undefined, id: detailQuery.data?.imageId || undefined,
imageUrl: detailQuery.data?.imageUrl, imageUrl: detailQuery.data?.imageUrl,
itemType: LibraryItem.ALBUM_ARTIST, itemType: LibraryItem.ALBUM_ARTIST,
type: 'itemCard', type: 'header',
});
const artistInfoQuery = useQuery({
...artistsQueries.albumArtistInfo({
query: { id: routeId, limit: 10 },
serverId: server?.id,
}),
enabled: Boolean(server?.id && routeId),
}); });
const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME; const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME;
const selectedImageUrl = useMemo(() => { const canUploadArtistImage =
return detailQuery.data?.imageUrl || imageUrl; hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD) &&
}, [detailQuery.data?.imageUrl, imageUrl]); Boolean(detailQuery.data?._serverId);
const alternateImageUrl = artistInfoQuery.data?.imageUrl; const handleArtistImageUpload = useCallback(
const hasImageId = Boolean(detailQuery.data?.imageId); async (file: File) => {
const fallbackHeaderImageUrl = alternateImageUrl || selectedImageUrl; const artist = detailQuery.data;
if (!artist?._serverId) return;
const buffer = await file.arrayBuffer();
uploadArtistImageMutation.mutate({
apiClientProps: {
serverId: artist._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: artist.id },
});
},
[detailQuery.data, uploadArtistImageMutation],
);
return ( return (
<LibraryHeader <LibraryHeader
imageUrl={hasImageId ? undefined : fallbackHeaderImageUrl} imageOverlay={
<ArtistImageUploadOverlay
data={detailQuery.data}
onUploadFile={handleArtistImageUpload}
/>
}
imageUrl={headerImageUrl}
item={{ item={{
imageId: detailQuery.data?.imageId, imageId: detailQuery.data?.imageId,
imageUrl: hasImageId ? undefined : fallbackHeaderImageUrl, imageUrl: detailQuery.data?.imageUrl,
route: AppRoute.LIBRARY_ALBUM_ARTISTS, route: AppRoute.LIBRARY_ALBUM_ARTISTS,
type: LibraryItem.ALBUM_ARTIST, type: LibraryItem.ALBUM_ARTIST,
}} }}
onImageFileDrop={canUploadArtistImage ? handleArtistImageUpload : undefined}
ref={ref} ref={ref}
title={detailQuery.data?.name || ''} title={detailQuery.data?.name || ''}
> >
@@ -13,6 +13,15 @@ export type GroupingType = 'all' | 'primary';
const PRIMARY_RELEASE_TYPES = ['album', 'broadcast', 'ep', 'other', 'single']; const PRIMARY_RELEASE_TYPES = ['album', 'broadcast', 'ep', 'other', 'single'];
const getNormalizedReleaseTypes = (album: Album): string[] => {
const rawReleaseTypes = [...(album.releaseTypes || []), album.releaseType || ''];
const normalizedReleaseTypes = rawReleaseTypes
.map((type) => type.trim().toLowerCase())
.filter(Boolean);
return [...new Set(normalizedReleaseTypes)];
};
export const groupAlbumsByReleaseType = ( export const groupAlbumsByReleaseType = (
albums: Album[], albums: Album[],
routeId: string, routeId: string,
@@ -44,10 +53,9 @@ export const groupAlbumsByReleaseType = (
} }
// Group by all release types // Group by all release types
const releaseTypes = album.releaseTypes || []; const normalizedTypes = getNormalizedReleaseTypes(album);
if (releaseTypes.length > 0) { if (normalizedTypes.length > 0) {
// Sort release types: primaries first (alphabetically), then secondaries (alphabetically) // Sort release types: primaries first (alphabetically), then secondaries (alphabetically)
const normalizedTypes = releaseTypes.map((type) => type.toLowerCase());
const primaryTypes = normalizedTypes const primaryTypes = normalizedTypes
.filter((type) => PRIMARY_RELEASE_TYPES.includes(type)) .filter((type) => PRIMARY_RELEASE_TYPES.includes(type))
.sort(); .sort();
@@ -92,8 +100,7 @@ export const groupAlbumsByReleaseType = (
return acc; return acc;
} }
const releaseTypes = album.releaseTypes || []; const normalizedTypes = getNormalizedReleaseTypes(album);
const normalizedTypes = releaseTypes.map((type) => type.toLowerCase());
let matchedType: null | string = null; let matchedType: null | string = null;
@@ -107,6 +114,8 @@ export const groupAlbumsByReleaseType = (
matchedType = 'broadcast'; matchedType = 'broadcast';
} else if (normalizedTypes.includes('other')) { } else if (normalizedTypes.includes('other')) {
matchedType = 'other'; matchedType = 'other';
} else if (normalizedTypes.length > 0) {
matchedType = normalizedTypes[0];
} else { } else {
matchedType = 'album'; matchedType = 'album';
} }
@@ -292,11 +301,11 @@ export const getArtistAlbumsGrouped = (
const types = releaseType.split('/'); const types = releaseType.split('/');
return types.some((type) => { return types.some((type) => {
const enumValue = releaseTypeToEnumMap[type]; const enumValue = releaseTypeToEnumMap[type];
return enumValue ? enabledReleaseTypeEnums.has(enumValue) : false; return enumValue ? enabledReleaseTypeEnums.has(enumValue) : true;
}); });
} }
const enumValue = releaseTypeToEnumMap[releaseType]; const enumValue = releaseTypeToEnumMap[releaseType];
return enumValue ? enabledReleaseTypeEnums.has(enumValue) : false; return enumValue ? enabledReleaseTypeEnums.has(enumValue) : true;
}; };
const releaseTypeEntries = Object.entries(albumsByReleaseType) const releaseTypeEntries = Object.entries(albumsByReleaseType)
@@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { DeleteArtistImageArgs, DeleteArtistImageResponse } from '/@/shared/types/domain-types';
export const useDeleteArtistImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<DeleteArtistImageResponse, AxiosError, DeleteArtistImageArgs, null>({
mutationFn: (args) => {
return api.controller.deleteArtistImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.list(serverId),
});
if (query?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }),
});
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }),
});
}
},
...options,
});
};
@@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { UploadArtistImageArgs, UploadArtistImageResponse } from '/@/shared/types/domain-types';
export const useUploadArtistImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<UploadArtistImageResponse, AxiosError, UploadArtistImageArgs, null>({
mutationFn: (args) => {
return api.controller.uploadArtistImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.list(serverId),
});
if (query?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }),
});
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }),
});
}
},
...options,
});
};
@@ -165,7 +165,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
); );
const handleAddToPlaylist = useCallback( const handleAddToPlaylist = useCallback(
async (playlistId: string) => { async (playlistId: string, playlistName: string) => {
if (items.length === 0 || !serverId) return; if (items.length === 0 || !serverId) return;
try { try {
@@ -202,10 +202,9 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
} }
if (allSongIds.length === 0) { if (allSongIds.length === 0) {
toast.success({ toast.info({
message: t('form.addToPlaylist.success', { message: t('form.addToPlaylist.noneAdded', {
message: 0, playlist: playlistName,
numOfPlaylists: 1,
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
}); });
@@ -245,10 +244,9 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
} }
if (songsToAdd.length === 0) { if (songsToAdd.length === 0) {
toast.success({ toast.info({
message: t('form.addToPlaylist.success', { message: t('form.addToPlaylist.noneAdded', {
message: 0, playlist: playlistName,
numOfPlaylists: 1,
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
}); });
@@ -413,7 +411,9 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
<> <>
<ContextMenu.Item <ContextMenu.Item
key={recentPlaylist.id} key={recentPlaylist.id}
onSelect={() => handleAddToPlaylist(recentPlaylist.id)} onSelect={() =>
handleAddToPlaylist(recentPlaylist.id, recentPlaylist.name)
}
> >
{recentPlaylist.name} {recentPlaylist.name}
</ContextMenu.Item> </ContextMenu.Item>
@@ -428,7 +428,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
{filteredPlaylists.map((playlist) => ( {filteredPlaylists.map((playlist) => (
<ContextMenu.Item <ContextMenu.Item
key={playlist.id} key={playlist.id}
onSelect={() => handleAddToPlaylist(playlist.id)} onSelect={() => handleAddToPlaylist(playlist.id, playlist.name)}
> >
{playlist.name} {playlist.name}
</ContextMenu.Item> </ContextMenu.Item>
@@ -1,4 +1,5 @@
import { SetActivity, StatusDisplayType } from '@xhayper/discord-rpc'; import type { SetActivity } from '@xhayper/discord-rpc';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
@@ -27,6 +28,13 @@ import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types
import { PlayerStatus } from '/@/shared/types/types'; import { PlayerStatus } from '/@/shared/types/types';
const discordRpc = isElectron() ? window.api.discordRpc : null; const discordRpc = isElectron() ? window.api.discordRpc : null;
const DiscordStatusDisplayType = {
DETAILS: 2,
NAME: 0,
STATE: 1,
} as const;
type ActivityState = [QueueSong | undefined, number, PlayerStatus]; type ActivityState = [QueueSong | undefined, number, PlayerStatus];
const MAX_FIELD_LENGTH = 127; const MAX_FIELD_LENGTH = 127;
@@ -122,7 +130,7 @@ export const useDiscordRpc = () => {
: undefined : undefined
: sentenceCase(current[2]), : sentenceCase(current[2]),
state: truncate(artist), state: truncate(artist),
statusDisplayType: StatusDisplayType.STATE, statusDisplayType: DiscordStatusDisplayType.STATE,
type: discordSettings.showAsListening ? 2 : 0, type: discordSettings.showAsListening ? 2 : 0,
}; };
@@ -196,9 +204,9 @@ export const useDiscordRpc = () => {
const artists = song?.artists.map((artist) => artist.name).join(', '); const artists = song?.artists.map((artist) => artist.name).join(', ');
const statusDisplayMap = { const statusDisplayMap = {
[DiscordDisplayType.ARTIST_NAME]: StatusDisplayType.STATE, [DiscordDisplayType.ARTIST_NAME]: DiscordStatusDisplayType.STATE,
[DiscordDisplayType.FEISHIN]: StatusDisplayType.NAME, [DiscordDisplayType.FEISHIN]: DiscordStatusDisplayType.NAME,
[DiscordDisplayType.SONG_NAME]: StatusDisplayType.DETAILS, [DiscordDisplayType.SONG_NAME]: DiscordStatusDisplayType.DETAILS,
}; };
const activity: SetActivity = { const activity: SetActivity = {
@@ -17,6 +17,7 @@ import { MultiSelect } from '/@/shared/components/multi-select/multi-select';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select'; import { Select } from '/@/shared/components/select/select';
import { Slider } from '/@/shared/components/slider/slider';
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 { TextInput } from '/@/shared/components/text-input/text-input'; import { TextInput } from '/@/shared/components/text-input/text-input';
@@ -185,6 +186,48 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
}, },
{
control: (
<Slider
defaultValue={displaySettings.opacityNonActive}
label={(e) => (e * 100).toFixed(0) + '%'}
max={1.0}
min={0.0}
onChangeEnd={(e) => {
updateDisplaySetting({
opacityNonActive: e,
});
}}
step={0.01}
w={100}
/>
),
description: '',
title: t(`${t('page.fullscreenPlayer.config.lyricOpacityNonActive')}`, {
postProcess: 'sentenceCase',
}),
},
{
control: (
<Slider
defaultValue={displaySettings.scaleNonActive}
label={(e) => (e * 100).toFixed(0) + '%'}
max={1.0}
min={0.5}
onChangeEnd={(e) => {
updateDisplaySetting({
scaleNonActive: e,
});
}}
step={0.01}
w={100}
/>
),
description: '',
title: t(`${t('page.fullscreenPlayer.config.lyricScaleNonActive')}`, {
postProcess: 'sentenceCase',
}),
},
{ {
control: ( control: (
<Switch <Switch
@@ -4,14 +4,18 @@
line-height: 1.2; line-height: 1.2;
color: var(--theme-colors-foreground); color: var(--theme-colors-foreground);
word-break: normal; word-break: normal;
opacity: 0.35; opacity: var(--lyric-opacity);
transform: scale(var(--lyric-scale));
transform-origin: var(--lyric-scale-origin) center;
transition: transition:
opacity 0.3s ease-in-out, opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.3s ease-in-out; transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform;
} }
.lyric-line:global(.active) { .lyric-line:global(.active) {
opacity: 1 !important; opacity: 1 !important;
transform: scale(1);
} }
.lyric-line:global(.unsynchronized) { .lyric-line:global(.unsynchronized) {
@@ -131,7 +131,9 @@ export const LyricsActions = ({
uppercase uppercase
variant="subtle" variant="subtle"
> >
{t('common.clear', { postProcess: 'sentenceCase' })} {hasLyrics
? t('common.clear', { postProcess: 'sentenceCase' })
: t('common.refresh', { postProcess: 'sentenceCase' })}
</Button> </Button>
) : null} ) : null}
</Group> </Group>
@@ -49,6 +49,11 @@ export const SynchronizedLyrics = ({
? displaySettings.fontSize ? displaySettings.fontSize
: 24, : 24,
gap: displaySettings.gap && displaySettings.gap !== 0 ? displaySettings.gap : 24, gap: displaySettings.gap && displaySettings.gap !== 0 ? displaySettings.gap : 24,
opacityNonActive: displaySettings.opacityNonActive,
scaleNonActive:
displaySettings.scaleNonActive && displaySettings.scaleNonActive !== 0
? displaySettings.scaleNonActive
: 0.95,
}; };
const { mediaSeekToTimestamp } = usePlayerActions(); const { mediaSeekToTimestamp } = usePlayerActions();
const status = usePlayerStatus(); const status = usePlayerStatus();
@@ -90,18 +95,20 @@ export const SynchronizedLyrics = ({
const programmaticScrollRef = useRef(false); const programmaticScrollRef = useRef(false);
const getCurrentLyric = (timeInMs: number) => { const getCurrentLyric = (timeInMs: number) => {
if (lyricRef.current) {
const activeLyrics = lyricRef.current; const activeLyrics = lyricRef.current;
for (let idx = 0; idx < activeLyrics.length; idx += 1) { if (!activeLyrics?.length) {
if (timeInMs <= activeLyrics[idx][0]) {
return idx === 0 ? idx : idx - 1;
}
}
return activeLyrics.length - 1;
}
return -1; return -1;
}
let index = -1;
for (let idx = 0; idx < activeLyrics.length; idx += 1) {
if (timeInMs < activeLyrics[idx][0]) {
break;
}
index = idx;
}
return index;
}; };
const setCurrentLyricRef = useRef< const setCurrentLyricRef = useRef<
@@ -136,7 +143,20 @@ export const SynchronizedLyrics = ({
.forEach((node) => node.classList.remove('active')); .forEach((node) => node.classList.remove('active'));
if (index === -1) { if (index === -1) {
lyricRef.current = null; const activeLyrics = lyricRef.current;
if (!activeLyrics?.length) {
return;
}
const firstTime = activeLyrics[0][0];
if (timeInMs < firstTime) {
const elapsed = performance.now() - start;
const delay = Math.max(0, firstTime - timeInMs - elapsed);
lyricTimer.current = setTimeout(() => {
setCurrentLyricRef.current(firstTime, nextEpoch, 0);
}, delay);
}
return; return;
} }
@@ -148,7 +168,6 @@ export const SynchronizedLyrics = ({
const offsetTop = currentLyric?.offsetTop - doc?.clientHeight / 2 || 0; const offsetTop = currentLyric?.offsetTop - doc?.clientHeight / 2 || 0;
if (currentLyric === null) { if (currentLyric === null) {
lyricRef.current = null;
return; return;
} }
@@ -308,7 +327,18 @@ export const SynchronizedLyrics = ({
onMouseEnter={showScrollbar} onMouseEnter={showScrollbar}
onMouseLeave={hideScrollbar} onMouseLeave={hideScrollbar}
ref={containerRef} ref={containerRef}
style={{ gap: `${settings.gap}px`, ...style }} style={
{
// opacity/scale is set here for every lyric,
// and then overwritten by CSS for active lyrics
// to prevent expensive rerenders each lyric
'--lyric-opacity': settings.opacityNonActive,
'--lyric-scale': settings.scaleNonActive,
'--lyric-scale-origin': settings.alignment,
gap: `${settings.gap}px`,
...style,
} as React.CSSProperties
}
> >
{settings.showProvider && source && ( {settings.showProvider && source && (
<LyricLine <LyricLine
@@ -15,6 +15,7 @@ import { eventEmitter } from '/@/renderer/events/event-emitter';
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context'; import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
import { searchLibraryItems } from '/@/renderer/features/shared/utils'; import { searchLibraryItems } from '/@/renderer/features/shared/utils';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { import {
isShuffleEnabled, isShuffleEnabled,
mapShuffledToQueueIndex, mapShuffledToQueueIndex,
@@ -30,7 +31,6 @@ import { Flex } from '/@/shared/components/flex/flex';
import { LoadingOverlay } from '/@/shared/components/loading-overlay/loading-overlay'; import { LoadingOverlay } from '/@/shared/components/loading-overlay/loading-overlay';
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value'; import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import { useFocusWithin } from '/@/shared/hooks/use-focus-within'; import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref'; import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types'; import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
import { DragTarget } from '/@/shared/types/drag-and-drop'; import { DragTarget } from '/@/shared/types/drag-and-drop';
@@ -140,9 +140,15 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
}; };
}, },
seekTo(seekTo: number) { seekTo(seekTo: number) {
let type: 'fraction' | 'seconds' | undefined = undefined;
if (seekTo < 1) {
type = 'seconds';
}
playerNum === 1 playerNum === 1
? player1Ref.current?.seekTo(seekTo) ? player1Ref.current?.seekTo(seekTo, type)
: player2Ref.current?.seekTo(seekTo); : player2Ref.current?.seekTo(seekTo, type);
}, },
setVolume(volume: number) { setVolume(volume: number) {
setInternalVolume1(volume / 100 || 0); setInternalVolume1(volume / 100 || 0);
@@ -240,10 +240,16 @@ export function WebPlayer() {
} }
} }
let type: 'fraction' | 'seconds' | undefined = undefined;
if (timestamp < 1) {
type = 'seconds';
}
if (num === 1) { if (num === 1) {
playerRef.current?.player1()?.ref?.seekTo(timestamp); playerRef.current?.player1()?.ref?.seekTo(timestamp, type);
} else { } else {
playerRef.current?.player2()?.ref?.seekTo(timestamp); playerRef.current?.player2()?.ref?.seekTo(timestamp, type);
} }
}, },
onPlayerStatus: async (properties) => { onPlayerStatus: async (properties) => {
@@ -60,6 +60,7 @@ const CODEC_PROBES = [
]; ];
const DEFAULT_TRANSCODING_PROFILES = [ const DEFAULT_TRANSCODING_PROFILES = [
{ audioCodec: 'flac', container: 'flac', protocol: 'http' },
{ audioCodec: 'opus', container: 'ogg', protocol: 'http' }, { audioCodec: 'opus', container: 'ogg', protocol: 'http' },
{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' }, { audioCodec: 'mp3', container: 'mp3', protocol: 'http' },
]; ];
@@ -26,6 +26,7 @@ import {
SONG_DISPLAY_TYPES, SONG_DISPLAY_TYPES,
} from '/@/renderer/features/shared/components/list-config-menu'; } from '/@/renderer/features/shared/components/list-config-menu';
import { useFastAverageColor } from '/@/renderer/hooks'; import { useFastAverageColor } from '/@/renderer/hooks';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { import {
useFullScreenPlayerStore, useFullScreenPlayerStore,
useFullScreenPlayerStoreActions, useFullScreenPlayerStoreActions,
@@ -46,7 +47,6 @@ import { Popover } from '/@/shared/components/popover/popover';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Slider } from '/@/shared/components/slider/slider'; import { Slider } from '/@/shared/components/slider/slider';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, Platform } from '/@/shared/types/types'; import { ItemListKey, ListDisplayType, Platform } from '/@/shared/types/types';
@@ -5,6 +5,7 @@ import { useLocation } from 'react-router';
import styles from './full-screen-visualizer.module.css'; import styles from './full-screen-visualizer.module.css';
import { FullScreenVisualizerSongInfo } from '/@/renderer/features/player/components/full-screen-visualizer-song-info'; import { FullScreenVisualizerSongInfo } from '/@/renderer/features/player/components/full-screen-visualizer-song-info';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { useIsMobile } from '/@/renderer/hooks/use-is-mobile'; import { useIsMobile } from '/@/renderer/hooks/use-is-mobile';
import { useFullScreenPlayerStoreActions } from '/@/renderer/store/full-screen-player.store'; import { useFullScreenPlayerStoreActions } from '/@/renderer/store/full-screen-player.store';
import { import {
@@ -12,7 +13,6 @@ import {
useSettingsStore, useSettingsStore,
useWindowSettings, useWindowSettings,
} from '/@/renderer/store/settings.store'; } from '/@/renderer/store/settings.store';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { Platform } from '/@/shared/types/types'; import { Platform } from '/@/shared/types/types';
const AudioMotionAnalyzerVisualizer = lazy(() => const AudioMotionAnalyzerVisualizer = lazy(() =>
@@ -18,6 +18,7 @@ import {
useIsRadioActive, useIsRadioActive,
useRadioPlayer, useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player'; } from '/@/renderer/features/radio/hooks/use-radio-player';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { import {
useAppStore, useAppStore,
@@ -34,7 +35,6 @@ import { Icon } from '/@/shared/components/icon/icon';
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 { PlaybackSelectors } from '/@/shared/constants/playback-selectors'; import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
export const LeftControls = () => { export const LeftControls = () => {
@@ -58,7 +58,7 @@ export const PlayerbarWaveform = () => {
height: 18, height: 18,
interact: false, interact: false,
media: audioElementRef.current, media: audioElementRef.current,
normalize: false, normalize: playerbarSlider?.stretched ?? false,
progressColor: primaryColor, progressColor: primaryColor,
waveColor, waveColor,
}); });
@@ -10,6 +10,7 @@ import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating'; import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { import {
useAppStoreActions, useAppStoreActions,
useAutoDJSettings, useAutoDJSettings,
@@ -34,7 +35,6 @@ import { Button } from '/@/shared/components/button/button';
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 { Rating } from '/@/shared/components/rating/rating'; import { Rating } from '/@/shared/components/rating/rating';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { useMediaQuery } from '/@/shared/hooks/use-media-query'; import { useMediaQuery } from '/@/shared/hooks/use-media-query';
import { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback'; import { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback';
import { useThrottledValue } from '/@/shared/hooks/use-throttled-value'; import { useThrottledValue } from '/@/shared/hooks/use-throttled-value';
@@ -273,7 +273,7 @@ export const SleepTimerButton = () => {
<Divider my="md" /> <Divider my="md" />
<Grid gutter="xs"> <Grid gap="xs">
{PRESET_OPTIONS.filter((option) => option.mode === 'timed').map( {PRESET_OPTIONS.filter((option) => option.mode === 'timed').map(
(option, index) => ( (option, index) => (
<Grid.Col key={index} span={4}> <Grid.Col key={index} span={4}>
@@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { HotkeyItem, useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { useHotkeySettings, usePlayerStore } from '/@/renderer/store'; import { useHotkeySettings, usePlayerStore } from '/@/renderer/store';
import { HotkeyItem, useHotkeys } from '/@/shared/hooks/use-hotkeys';
export const usePlaybackHotkeys = () => { export const usePlaybackHotkeys = () => {
const { bindings } = useHotkeySettings(); const { bindings } = useHotkeySettings();
@@ -8,17 +8,17 @@ const ipc = isElectron() ? window.api.ipc : null;
export const usePowerSaveBlocker = () => { export const usePowerSaveBlocker = () => {
const status = usePlayerStatus(); const status = usePlayerStatus();
const { preventSleepOnPlayback } = useWindowSettings(); const { preventSleepOnPlayback, preventSuspendOnPlayback } = useWindowSettings();
const startPowerSaveBlocker = useCallback(async () => { const startPowerSaveBlocker = useCallback(async () => {
if (!ipc) return; if (!ipc) return;
try { try {
await ipc.invoke('power-save-blocker-start'); await ipc.invoke('power-save-blocker-start', { full: preventSleepOnPlayback });
} catch (error) { } catch (error) {
console.error('Failed to start power save blocker:', error); console.error('Failed to start power save blocker:', error);
} }
}, []); }, [preventSleepOnPlayback]);
const stopPowerSaveBlocker = useCallback(async () => { const stopPowerSaveBlocker = useCallback(async () => {
if (!ipc) return; if (!ipc) return;
@@ -31,16 +31,21 @@ export const usePowerSaveBlocker = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!preventSleepOnPlayback) return; if (!preventSleepOnPlayback || !preventSuspendOnPlayback) return;
if (status === PlayerStatus.PLAYING) { if (status === PlayerStatus.PLAYING) {
startPowerSaveBlocker(); startPowerSaveBlocker();
} else { } else {
stopPowerSaveBlocker(); stopPowerSaveBlocker();
} }
}, [status, preventSleepOnPlayback, startPowerSaveBlocker, stopPowerSaveBlocker]); }, [
status,
preventSleepOnPlayback,
startPowerSaveBlocker,
stopPowerSaveBlocker,
preventSuspendOnPlayback,
]);
// Clean up on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
stopPowerSaveBlocker(); stopPowerSaveBlocker();
@@ -56,8 +61,11 @@ const PowerSaveBlockerHookInner = () => {
export const PowerSaveBlockerHook = () => { export const PowerSaveBlockerHook = () => {
const isElectronEnv = isElectron(); const isElectronEnv = isElectron();
const preventSleepOnPlayback = useSettingsStore((state) => state.window.preventSleepOnPlayback); const preventSleepOnPlayback = useSettingsStore((state) => state.window.preventSleepOnPlayback);
const preventSuspendOnPlayback = useSettingsStore(
(state) => state.window.preventSuspendOnPlayback,
);
if (!isElectronEnv || !preventSleepOnPlayback) { if (!isElectronEnv || !preventSleepOnPlayback || !preventSuspendOnPlayback) {
return null; return null;
} }
@@ -19,6 +19,7 @@ export function useVisualizerSystemAudio(options: {
onDeniedRef.current = onSystemAudioCaptureDenied; onDeniedRef.current = onSystemAudioCaptureDenied;
onSuccessRef.current = onSystemAudioCaptureSuccess; onSuccessRef.current = onSystemAudioCaptureSuccess;
const playbackType = usePlaybackType(); const playbackType = usePlaybackType();
const isMacOS = Boolean(window.api?.utils?.isMacOS?.());
const { setWebAudio, webAudio } = useWebAudio(); const { setWebAudio, webAudio } = useWebAudio();
const webAudioRef = useRef(webAudio); const webAudioRef = useRef(webAudio);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
@@ -80,7 +81,7 @@ export function useVisualizerSystemAudio(options: {
try { try {
const stream = await navigator.mediaDevices.getDisplayMedia({ const stream = await navigator.mediaDevices.getDisplayMedia({
audio: true, audio: true,
video: false, video: isMacOS, // On macOS, getDisplayMedia requires video to be requested in order to capture system audio
}); });
const audioTracks = stream.getAudioTracks(); const audioTracks = stream.getAudioTracks();
@@ -124,7 +125,7 @@ export function useVisualizerSystemAudio(options: {
} finally { } finally {
connectInFlightRef.current = false; connectInFlightRef.current = false;
} }
}, [disconnect, setWebAudio]); }, [disconnect, isMacOS, setWebAudio]);
const connectRef = useRef(connect); const connectRef = useRef(connect);
connectRef.current = connect; connectRef.current = connect;
@@ -552,7 +552,7 @@ const PlaylistTableItem = memo(
return ( return (
<Box className={styles.container} w="100%"> <Box className={styles.container} w="100%">
<Grid align="center" gutter="xs" w="100%"> <Grid align="center" gap="xs" w="100%">
<Grid.Col span="content"> <Grid.Col span="content">
<Flex align="center" justify="center" px="sm"> <Flex align="center" justify="center" px="sm">
<ItemImage <ItemImage
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router'; import { useLocation, useParams } from 'react-router';
@@ -40,8 +41,13 @@ interface PlaylistDetailSongListHeaderProps {
onToggleQueryBuilder?: () => void; onToggleQueryBuilder?: () => void;
} }
function ImageUploadOverlay({ data }: { data?: Playlist }) { function ImageUploadOverlay({
const uploadPlaylistImageMutation = useUploadPlaylistImage({}); data,
onUploadFile,
}: {
data?: Playlist;
onUploadFile: (file: File) => Promise<void>;
}) {
const deletePlaylistImageMutation = useDeletePlaylistImage({}); const deletePlaylistImageMutation = useDeletePlaylistImage({});
const server = useCurrentServer(); const server = useCurrentServer();
@@ -53,16 +59,8 @@ function ImageUploadOverlay({ data }: { data?: Playlist }) {
<FileButton <FileButton
accept="image/*" accept="image/*"
onChange={async (file) => { onChange={async (file) => {
if (!file || !data?._serverId) return; if (!file) return;
await onUploadFile(file);
const buffer = await file.arrayBuffer();
uploadPlaylistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: data.id },
});
}} }}
> >
{(props) => ( {(props) => (
@@ -121,11 +119,33 @@ export const PlaylistDetailSongListHeader = ({
}); });
const player = usePlayer(); const player = usePlayer();
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
const handlePlay = (type?: Play) => { const handlePlay = (type?: Play) => {
player.addToQueueByData(listData as Song[], type || Play.NOW); player.addToQueueByData(listData as Song[], type || Play.NOW);
}; };
const canUploadPlaylistImage =
hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD) &&
Boolean(detailQuery?.data?._serverId);
const handlePlaylistImageUpload = useCallback(
async (file: File) => {
const playlist = detailQuery?.data;
if (!playlist?._serverId) return;
const buffer = await file.arrayBuffer();
uploadPlaylistImageMutation.mutate({
apiClientProps: {
serverId: playlist._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: playlist.id },
});
},
[detailQuery?.data, uploadPlaylistImageMutation],
);
const imageUrl = useItemImageUrl({ const imageUrl = useItemImageUrl({
id: detailQuery?.data?.imageId || undefined, id: detailQuery?.data?.imageId || undefined,
itemType: LibraryItem.PLAYLIST, itemType: LibraryItem.PLAYLIST,
@@ -163,7 +183,12 @@ export const PlaylistDetailSongListHeader = ({
) : ( ) : (
<LibraryHeader <LibraryHeader
compact compact
imageOverlay={<ImageUploadOverlay data={detailQuery?.data} />} imageOverlay={
<ImageUploadOverlay
data={detailQuery?.data}
onUploadFile={handlePlaylistImageUpload}
/>
}
imageUrl={imageUrl} imageUrl={imageUrl}
item={{ item={{
imageId: detailQuery?.data?.imageId, imageId: detailQuery?.data?.imageId,
@@ -171,6 +196,7 @@ export const PlaylistDetailSongListHeader = ({
route: AppRoute.PLAYLISTS, route: AppRoute.PLAYLISTS,
type: LibraryItem.PLAYLIST, type: LibraryItem.PLAYLIST,
}} }}
onImageFileDrop={canUploadPlaylistImage ? handlePlaylistImageUpload : undefined}
title={detailQuery?.data?.name || ''} title={detailQuery?.data?.name || ''}
topRight={<ListSearchInput />} topRight={<ListSearchInput />}
> >
@@ -13,6 +13,7 @@ import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/rendere
import { hasFeature } from '/@/shared/api/utils'; import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box'; import { Box } from '/@/shared/components/box/box';
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
import { FileButton } from '/@/shared/components/file-button/file-button'; import { FileButton } from '/@/shared/components/file-button/file-button';
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';
@@ -270,16 +271,20 @@ function PlaylistCoverField({
const iconControls = ( const iconControls = (
<> <>
<FileButton accept="image/*" onChange={onFileSelect}> <FileButton accept="image/*" onChange={onFileSelect}>
{(props) => ( {(props) => {
const { ...triggerRest } = props;
return (
<ActionIcon <ActionIcon
icon="uploadImage" icon="uploadImage"
iconProps={{ size: 'lg' }} iconProps={{ size: 'lg' }}
radius="xl" radius="xl"
size="sm" size="sm"
variant="default" variant="default"
{...props} {...triggerRest}
style={{ pointerEvents: 'auto' }}
/> />
)} );
}}
</FileButton> </FileButton>
<ActionIcon <ActionIcon
disabled={secondaryDisabled} disabled={secondaryDisabled}
@@ -288,22 +293,12 @@ function PlaylistCoverField({
onClick={secondaryAction} onClick={secondaryAction}
radius="xl" radius="xl"
size="sm" size="sm"
style={{ pointerEvents: 'auto' }}
variant="default" variant="default"
/> />
</> </>
); );
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.PLAYLIST}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return ( return (
<Box <Box
style={{ style={{
@@ -315,21 +310,41 @@ function PlaylistCoverField({
width: COVER_SIZE, width: COVER_SIZE,
}} }}
> >
{coverArt} <DragDropZone
accept="image/*"
mode="file"
onFileSelected={(file) => onFileSelect(file)}
style={{
height: '100%',
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
>
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.PLAYLIST}
serverId={server?.id}
src={previewSrc}
type="header"
/>
<Group <Group
gap={4} gap={4}
style={{ style={{
background: 'rgba(0, 0, 0, 0.55)', background: 'rgba(0, 0, 0, 0.55)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6, bottom: 6,
padding: 4, padding: 4,
pointerEvents: 'none',
position: 'absolute', position: 'absolute',
right: 6, right: 6,
zIndex: 2,
}} }}
wrap="nowrap" wrap="nowrap"
> >
{iconControls} {iconControls}
</Group> </Group>
</DragDropZone>
</Box> </Box>
); );
} }
@@ -12,6 +12,7 @@ import { logMsg } from '/@/renderer/utils/logger-message';
import { hasFeature } from '/@/shared/api/utils'; import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box'; import { Box } from '/@/shared/components/box/box';
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
import { FileButton } from '/@/shared/components/file-button/file-button'; import { FileButton } from '/@/shared/components/file-button/file-button';
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';
@@ -241,16 +242,20 @@ function RadioStationCoverField({
const iconControls = ( const iconControls = (
<> <>
<FileButton accept="image/*" onChange={onFileSelect}> <FileButton accept="image/*" onChange={onFileSelect}>
{(props) => ( {(props) => {
const { ...triggerRest } = props;
return (
<ActionIcon <ActionIcon
icon="uploadImage" icon="uploadImage"
iconProps={{ size: 'lg' }} iconProps={{ size: 'lg' }}
radius="xl" radius="xl"
size="sm" size="sm"
variant="default" variant="default"
{...props} {...triggerRest}
style={{ pointerEvents: 'auto' }}
/> />
)} );
}}
</FileButton> </FileButton>
<ActionIcon <ActionIcon
disabled={secondaryDisabled} disabled={secondaryDisabled}
@@ -259,22 +264,12 @@ function RadioStationCoverField({
onClick={secondaryAction} onClick={secondaryAction}
radius="xl" radius="xl"
size="sm" size="sm"
style={{ pointerEvents: 'auto' }}
variant="default" variant="default"
/> />
</> </>
); );
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return ( return (
<Box <Box
style={{ style={{
@@ -286,21 +281,41 @@ function RadioStationCoverField({
width: COVER_SIZE, width: COVER_SIZE,
}} }}
> >
{coverArt} <DragDropZone
accept="image/*"
mode="file"
onFileSelected={(file) => onFileSelect(file)}
style={{
height: '100%',
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
>
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={previewSrc}
type="header"
/>
<Group <Group
gap={4} gap={4}
style={{ style={{
background: 'rgba(0, 0, 0, 0.55)', background: 'rgba(0, 0, 0, 0.55)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6, bottom: 6,
padding: 4, padding: 4,
pointerEvents: 'none',
position: 'absolute', position: 'absolute',
right: 6, right: 6,
zIndex: 2,
}} }}
wrap="nowrap" wrap="nowrap"
> >
{iconControls} {iconControls}
</Group> </Group>
</DragDropZone>
</Box> </Box>
); );
} }
@@ -477,6 +477,32 @@ export const ControlSettings = memo(() => {
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),
}, },
{
control: (
<Switch
defaultChecked={playerbarSlider?.stretched ?? false}
onChange={(e) =>
setSettings({
general: {
...settings,
playerbarSlider: {
...playerbarSlider,
stretched: e.currentTarget.checked,
},
},
})
}
/>
),
description: t('setting.playerbarWaveformStretch', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.playerbarWaveformStretch', {
postProcess: 'sentenceCase',
}),
},
{ {
control: ( control: (
<NumberInput <NumberInput
@@ -146,6 +146,7 @@ export const ThemeSettings = memo(() => {
} }
}} }}
renderOption={renderThemeOption} renderOption={renderThemeOption}
searchable
width={240} width={240}
/> />
), ),
@@ -184,7 +184,7 @@ export const WindowSettings = memo(() => {
<Switch <Switch
aria-label="Toggle prevent sleep on playback" aria-label="Toggle prevent sleep on playback"
defaultChecked={settings.preventSleepOnPlayback} defaultChecked={settings.preventSleepOnPlayback}
disabled={!isElectron()} disabled={!isElectron() || settings.preventSuspendOnPlayback}
onChange={(e) => { onChange={(e) => {
if (!e) return; if (!e) return;
localSettings?.set( localSettings?.set(
@@ -206,6 +206,33 @@ export const WindowSettings = memo(() => {
isHidden: !isElectron(), isHidden: !isElectron(),
title: t('setting.preventSleepOnPlayback', { postProcess: 'sentenceCase' }), title: t('setting.preventSleepOnPlayback', { postProcess: 'sentenceCase' }),
}, },
{
control: (
<Switch
aria-label="Toggle prevent suspend on playback"
defaultChecked={settings.preventSuspendOnPlayback}
disabled={!isElectron() || settings.preventSleepOnPlayback}
onChange={(e) => {
if (!e) return;
localSettings?.set(
'window_prevent_suspend_on_playback',
e.currentTarget.checked,
);
setSettings({
window: {
preventSuspendOnPlayback: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.preventSuspendOnPlayback', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.preventSuspendOnPlayback', { postProcess: 'sentenceCase' }),
},
]; ];
return ( return (
@@ -6,6 +6,8 @@
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
background-image: var(--theme-overlay-subheader); background-image: var(--theme-overlay-subheader);
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
} }
.background-overlay { .background-overlay {
@@ -30,6 +32,18 @@
var(--dither); var(--dither);
} }
.background-image-stack {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
pointer-events: none;
isolation: isolate;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
}
.background-image { .background-image {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -37,6 +51,7 @@
width: 100%; width: 100%;
background-position: center !important; background-position: center !important;
background-size: cover !important; background-size: cover !important;
isolation: isolate;
opacity: 0.7; opacity: 0.7;
} }
@@ -76,7 +76,7 @@ export const LibraryBackgroundImage = ({ blur, headerRef, imageUrl }: LibraryBac
const url = imageUrl ? `url(${imageUrl})` : undefined; const url = imageUrl ? `url(${imageUrl})` : undefined;
const height = useHeaderHeight(headerRef); const height = useHeaderHeight(headerRef);
return ( return (
<> <div className={styles.backgroundImageStack}>
<div <div
className={styles.backgroundImage} className={styles.backgroundImage}
style={{ style={{
@@ -91,7 +91,7 @@ export const LibraryBackgroundImage = ({ blur, headerRef, imageUrl }: LibraryBac
height: height ? `${height + 64}px` : undefined, height: height ? `${height + 64}px` : undefined,
}} }}
/> />
</> </div>
); );
}; };
@@ -1,3 +1,5 @@
import type { KeyboardEvent } from 'react';
import { closeAllModals, openModal } from '@mantine/modals'; import { closeAllModals, openModal } from '@mantine/modals';
import clsx from 'clsx'; import clsx from 'clsx';
import { forwardRef, ReactNode, Ref, useCallback } from 'react'; import { forwardRef, ReactNode, Ref, useCallback } from 'react';
@@ -22,6 +24,7 @@ import { useGeneralSettings } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center'; import { Center } from '/@/shared/components/center/center';
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
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 { BaseImage } from '/@/shared/components/image/image'; import { BaseImage } from '/@/shared/components/image/image';
@@ -47,6 +50,7 @@ interface LibraryHeaderProps {
type?: LibraryItem; type?: LibraryItem;
}; };
loading?: boolean; loading?: boolean;
onImageFileDrop?: (file: File) => Promise<void> | void;
title: string; title: string;
topRight?: ReactNode; topRight?: ReactNode;
} }
@@ -60,6 +64,7 @@ export const LibraryHeader = forwardRef(
imageOverlay, imageOverlay,
imageUrl, imageUrl,
item, item,
onImageFileDrop,
title, title,
topRight, topRight,
}: LibraryHeaderProps, }: LibraryHeaderProps,
@@ -136,6 +141,17 @@ export const LibraryHeader = forwardRef(
}); });
}, [blurExplicitImages, item.explicitStatus, item.imageId, item.type]); }, [blurExplicitImages, item.explicitStatus, item.imageId, item.type]);
const imageSectionSharedProps = {
onClick: () => {
openImage();
},
onKeyDown: (event: KeyboardEvent) =>
[' ', 'Enter', 'Spacebar'].includes(event.key) && openImage(),
role: 'button' as const,
style: { cursor: 'pointer' as const },
tabIndex: 0,
};
return ( return (
<div <div
className={clsx( className={clsx(
@@ -146,17 +162,13 @@ export const LibraryHeader = forwardRef(
ref={ref} ref={ref}
> >
{topRight && <div className={styles.topRight}>{topRight}</div>} {topRight && <div className={styles.topRight}>{topRight}</div>}
<div {onImageFileDrop ? (
<DragDropZone
accept="image/*"
className={styles.imageSection} className={styles.imageSection}
onClick={() => { mode="file"
openImage(); onFileSelected={(file) => void onImageFileDrop(file)}
}} {...imageSectionSharedProps}
onKeyDown={(event) =>
[' ', 'Enter', 'Spacebar'].includes(event.key) && openImage()
}
role="button"
style={{ cursor: 'pointer' }}
tabIndex={0}
> >
<ItemImage <ItemImage
className={styles.image} className={styles.image}
@@ -180,7 +192,33 @@ export const LibraryHeader = forwardRef(
{imageOverlay} {imageOverlay}
</div> </div>
)} )}
</DragDropZone>
) : (
<div className={styles.imageSection} {...imageSectionSharedProps}>
<ItemImage
className={styles.image}
containerClassName={styles.image}
enableDebounce={false}
enableViewport={false}
explicitStatus={item.explicitStatus ?? null}
fetchPriority="high"
id={item.imageId}
itemType={item.type as LibraryItem}
src={imageUrl || ''}
type="header"
/>
{imageOverlay && (
<div
className={styles.imageOverlay}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
{imageOverlay}
</div> </div>
)}
</div>
)}
{title && ( {title && (
<div className={styles.metadataSection}> <div className={styles.metadataSection}>
{item.children ? ( {item.children ? (
@@ -9,12 +9,12 @@ import {
} from 'react'; } from 'react';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { useSettingsStore } from '/@/renderer/store'; import { useSettingsStore } from '/@/renderer/store';
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box'; import { Box } from '/@/shared/components/box/box';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { TextInput, TextInputProps } from '/@/shared/components/text-input/text-input'; import { TextInput, TextInputProps } from '/@/shared/components/text-input/text-input';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
interface SearchInputProps extends TextInputProps { interface SearchInputProps extends TextInputProps {
buttonProps?: Partial<ActionIconProps>; buttonProps?: Partial<ActionIconProps>;
@@ -18,12 +18,7 @@ export const ActionBar = () => {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Grid <Grid display="flex" gap="sm" style={{ padding: '0 var(--theme-spacing-md)' }} w="100%">
display="flex"
gutter="sm"
style={{ padding: '0 var(--theme-spacing-md)' }}
w="100%"
>
<Grid.Col span={7}> <Grid.Col span={7}>
<TextInput <TextInput
leftSection={<Icon icon="search" />} leftSection={<Icon icon="search" />}
@@ -6,7 +6,7 @@ import styles from './sidebar-item.module.css';
import { Button, ButtonProps } from '/@/shared/components/button/button'; import { Button, ButtonProps } from '/@/shared/components/button/button';
interface SidebarItemProps extends ButtonProps { interface SidebarItemProps extends Omit<ButtonProps, 'component' | 'ref'> {
to: LinkProps['to']; to: LinkProps['to'];
} }
@@ -31,8 +31,7 @@
.image-container { .image-container {
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
width: var(--sidebar-image-height); min-height: 0;
height: var(--sidebar-image-height);
padding: var(--theme-spacing-md); padding: var(--theme-spacing-md);
cursor: pointer; cursor: pointer;
animation: fade-in 0.2s ease-in-out; animation: fade-in 0.2s ease-in-out;
@@ -1,6 +1,6 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { CSSProperties, MouseEvent, useMemo } from 'react'; import { MouseEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import styles from './sidebar.module.css'; import styles from './sidebar.module.css';
@@ -159,7 +159,6 @@ export const Sidebar = () => {
const SidebarImage = () => { const SidebarImage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const leftWidth = useAppStore((state) => state.sidebar.leftWidth);
const { setSideBar } = useAppStoreActions(); const { setSideBar } = useAppStoreActions();
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const isRadioActive = useIsRadioActive(); const isRadioActive = useIsRadioActive();
@@ -216,11 +215,7 @@ const SidebarImage = () => {
onClick={expandFullScreenPlayer} onClick={expandFullScreenPlayer}
onContextMenu={handleToggleContextMenu} onContextMenu={handleToggleContextMenu}
role="button" role="button"
style={ style={{ aspectRatio: 1 }}
{
'--sidebar-image-height': leftWidth,
} as CSSProperties
}
transition={{ duration: 0.3, ease: 'easeInOut' }} transition={{ duration: 0.3, ease: 'easeInOut' }}
> >
<Tooltip <Tooltip
@@ -1,16 +1,17 @@
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useIsLocalVisualizerSurfaceVisible } from '/@/renderer/features/player/hooks/use-is-local-visualizer-surface-visible'; import { useIsLocalVisualizerSurfaceVisible } from '/@/renderer/features/player/hooks/use-is-local-visualizer-surface-visible';
import { useVisualizerSystemAudio } from '/@/renderer/features/player/hooks/use-visualizer-system-audio'; import { useVisualizerSystemAudio } from '/@/renderer/features/player/hooks/use-visualizer-system-audio';
import { closeLocalVisualizerSurfaces } from '/@/renderer/features/player/utils/close-local-visualizer-surfaces'; import { closeLocalVisualizerSurfaces } from '/@/renderer/features/player/utils/close-local-visualizer-surfaces';
import { usePlaybackType } from '/@/renderer/store'; import { useMpvSettings, usePlaybackType } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Modal } from '/@/shared/components/modal/modal'; import { Modal } from '/@/shared/components/modal/modal';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { useDisclosure } from '/@/shared/hooks/use-disclosure'; import { useDisclosure } from '/@/shared/hooks/use-disclosure';
import { PlayerType } from '/@/shared/types/types'; import { PlayerType } from '/@/shared/types/types';
@@ -31,12 +32,21 @@ export function VisualizerSystemAudioBridgeHook() {
function VisualizerSystemAudioBridge() { function VisualizerSystemAudioBridge() {
const { t } = useTranslation(); const { t } = useTranslation();
const playbackType = usePlaybackType(); const playbackType = usePlaybackType();
const { audioExclusiveMode } = useMpvSettings();
const isVisualizerSurfaceVisible = useIsLocalVisualizerSurfaceVisible(); const isVisualizerSurfaceVisible = useIsLocalVisualizerSurfaceVisible();
const [promptState, setPromptState] = useState<PromptState>('loading'); const [promptState, setPromptState] = useState<PromptState>('loading');
const [sessionAllowCapture, setSessionAllowCapture] = useState(false); const [sessionAllowCapture, setSessionAllowCapture] = useState(false);
const wasBlockedByExclusiveModeRef = useRef(false);
const [isPromptOpen, { close: closePrompt, open: openPrompt, toggle: togglePrompt }] = const [isPromptOpen, { close: closePrompt, open: openPrompt, toggle: togglePrompt }] =
useDisclosure(false); useDisclosure(false);
const isExclusiveModeEnabled = audioExclusiveMode === 'yes';
const isVisualizerBlockedByExclusiveMode =
isElectron() &&
playbackType === PlayerType.LOCAL &&
isVisualizerSurfaceVisible &&
isExclusiveModeEnabled;
const persistConsent = useCallback((granted: boolean) => { const persistConsent = useCallback((granted: boolean) => {
if (!isElectron() || !window.api.localSettings) { if (!isElectron() || !window.api.localSettings) {
return; return;
@@ -67,6 +77,7 @@ function VisualizerSystemAudioBridge() {
const eligibleForPrompt = const eligibleForPrompt =
isElectron() && isElectron() &&
playbackType === PlayerType.LOCAL && playbackType === PlayerType.LOCAL &&
!isExclusiveModeEnabled &&
isVisualizerSurfaceVisible && isVisualizerSurfaceVisible &&
promptState !== 'loading' && promptState !== 'loading' &&
!promptState.consent && !promptState.consent &&
@@ -80,9 +91,25 @@ function VisualizerSystemAudioBridge() {
} }
}, [eligibleForPrompt, closePrompt, openPrompt]); }, [eligibleForPrompt, closePrompt, openPrompt]);
useEffect(() => {
if (isVisualizerBlockedByExclusiveMode && !wasBlockedByExclusiveModeRef.current) {
toast.error({
message: t('visualizer.systemAudioExclusiveModeNotSupported', {
postProcess: 'sentenceCase',
}),
});
setSessionAllowCapture(false);
closePrompt();
closeLocalVisualizerSurfaces();
}
wasBlockedByExclusiveModeRef.current = isVisualizerBlockedByExclusiveMode;
}, [closePrompt, isVisualizerBlockedByExclusiveMode, t]);
const shouldAttemptConnection = const shouldAttemptConnection =
isElectron() && isElectron() &&
playbackType === PlayerType.LOCAL && playbackType === PlayerType.LOCAL &&
!isExclusiveModeEnabled &&
isVisualizerSurfaceVisible && isVisualizerSurfaceVisible &&
promptState !== 'loading' && promptState !== 'loading' &&
(promptState.consent || sessionAllowCapture); (promptState.consent || sessionAllowCapture);
+1
View File
@@ -3,5 +3,6 @@ export * from './use-check-for-updates';
export * from './use-container-query'; export * from './use-container-query';
export * from './use-fast-average-color'; export * from './use-fast-average-color';
export * from './use-hide-scrollbar'; export * from './use-hide-scrollbar';
export * from './use-hotkeys';
export * from './use-is-mounted'; export * from './use-is-mounted';
export * from './use-should-pad-titlebar'; export * from './use-should-pad-titlebar';
+23
View File
@@ -0,0 +1,23 @@
import {
type HotkeyItem as MantineHotkeyItem,
useHotkeys as useMantineHotkeys,
} from '@mantine/hooks';
import { useAppStore } from '/@/renderer/store';
const EMPTY_HOTKEYS: MantineHotkeyItem[] = [];
export const useHotkeys = (
hotkeys: MantineHotkeyItem[],
tagsToIgnore?: string[],
triggerOnContentEditable?: boolean,
) => {
const commandPaletteOpened = useAppStore((state) => state.commandPalette.opened);
useMantineHotkeys(
commandPaletteOpened ? EMPTY_HOTKEYS : hotkeys,
tagsToIgnore,
triggerOnContentEditable,
);
};
export type HotkeyItem = MantineHotkeyItem;
@@ -1,6 +1,6 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence } from 'motion/react'; import { AnimatePresence } from 'motion/react';
import { lazy, Suspense } from 'react'; import { Suspense } from 'react';
import { Outlet } from 'react-router'; import { Outlet } from 'react-router';
import styles from './mobile-layout.module.css'; import styles from './mobile-layout.module.css';
@@ -10,6 +10,7 @@ import { FullScreenVisualizer } from '/@/renderer/features/player/components/ful
import { MobileFullscreenPlayer } from '/@/renderer/features/player/components/mobile-fullscreen-player'; import { MobileFullscreenPlayer } from '/@/renderer/features/player/components/mobile-fullscreen-player';
import { MobileSidebar } from '/@/renderer/features/sidebar/components/mobile-sidebar'; import { MobileSidebar } from '/@/renderer/features/sidebar/components/mobile-sidebar';
import { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar'; import { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar';
import { WindowBar } from '/@/renderer/layouts/window-bar';
import { useFullScreenPlayerOverlayState, useWindowBarStyle } from '/@/renderer/store'; import { useFullScreenPlayerOverlayState, useWindowBarStyle } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Drawer } from '/@/shared/components/drawer/drawer'; import { Drawer } from '/@/shared/components/drawer/drawer';
@@ -17,12 +18,6 @@ import { Spinner } from '/@/shared/components/spinner/spinner';
import { useDisclosure } from '/@/shared/hooks/use-disclosure'; import { useDisclosure } from '/@/shared/hooks/use-disclosure';
import { Platform } from '/@/shared/types/types'; import { Platform } from '/@/shared/types/types';
const WindowBar = lazy(() =>
import('/@/renderer/layouts/window-bar').then((module) => ({
default: module.WindowBar,
})),
);
interface MobileLayoutProps { interface MobileLayoutProps {
shell?: boolean; shell?: boolean;
} }
+1 -1
View File
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router';
import { useAppTracker } from '/@/renderer/features/analytics/hooks/use-app-tracker'; import { useAppTracker } from '/@/renderer/features/analytics/hooks/use-app-tracker';
import { CommandPalette } from '/@/renderer/features/search/components/command-palette'; import { CommandPalette } from '/@/renderer/features/search/components/command-palette';
import { useGarbageCollection } from '/@/renderer/hooks/use-garbage-collection'; import { useGarbageCollection } from '/@/renderer/hooks/use-garbage-collection';
import { HotkeyItem, useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import { useIsMobile } from '/@/renderer/hooks/use-is-mobile'; import { useIsMobile } from '/@/renderer/hooks/use-is-mobile';
import { DefaultLayout } from '/@/renderer/layouts/default-layout'; import { DefaultLayout } from '/@/renderer/layouts/default-layout';
import { MobileLayout } from '/@/renderer/layouts/mobile-layout/mobile-layout'; import { MobileLayout } from '/@/renderer/layouts/mobile-layout/mobile-layout';
@@ -15,7 +16,6 @@ import {
useSettingsStoreActions, useSettingsStoreActions,
useZoomFactor, useZoomFactor,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { HotkeyItem, useHotkeys } from '/@/shared/hooks/use-hotkeys';
interface ResponsiveLayoutProps { interface ResponsiveLayoutProps {
shell?: boolean; shell?: boolean;
+2 -13
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { HashRouter, Route, Routes } from 'react-router'; import { HashRouter, Route, Routes } from 'react-router';
import { ShuffleAllContextModal } from '/@/renderer/features/player/components/shuffle-all-modal';
import { RouterErrorBoundary } from '/@/renderer/features/shared/components/router-error-boundary'; import { RouterErrorBoundary } from '/@/renderer/features/shared/components/router-error-boundary';
import { AuthenticationOutlet } from '/@/renderer/layouts/authentication-outlet'; import { AuthenticationOutlet } from '/@/renderer/layouts/authentication-outlet';
import { ResponsiveLayout } from '/@/renderer/layouts/responsive-layout'; import { ResponsiveLayout } from '/@/renderer/layouts/responsive-layout';
@@ -96,18 +97,6 @@ const LyricsSettingsContextModal = (props: any) => (
</Suspense> </Suspense>
); );
const LazyShuffleAllContextModal = lazy(() =>
import('/@/renderer/features/player/components/shuffle-all-modal').then((module) => ({
default: module.ShuffleAllContextModal,
})),
);
const ShuffleAllContextModal = (props: any) => (
<Suspense fallback={<Spinner container />}>
<LazyShuffleAllContextModal {...props} />
</Suspense>
);
const LazyAddToPlaylistContextModal = lazy(() => const LazyAddToPlaylistContextModal = lazy(() =>
import('/@/renderer/features/playlists/components/add-to-playlist-context-modal').then( import('/@/renderer/features/playlists/components/add-to-playlist-context-modal').then(
(module) => ({ (module) => ({
@@ -200,7 +189,7 @@ const appRouterModals = {
export const AppRouter = () => { export const AppRouter = () => {
const router = ( const router = (
<HashRouter unstable_useTransitions> <HashRouter unstable_useTransitions={false}>
<ModalsProvider modals={appRouterModals}> <ModalsProvider modals={appRouterModals}>
<RouterErrorBoundary> <RouterErrorBoundary>
<Routes> <Routes>
@@ -10,6 +10,8 @@ const APP_THEMES = new Set([
'defaultDark', 'defaultDark',
'defaultLight', 'defaultLight',
'dracula', 'dracula',
'everforestDark',
'everforestLight',
'githubDark', 'githubDark',
'githubLight', 'githubLight',
'glassyDark', 'glassyDark',
+4 -6
View File
@@ -776,7 +776,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
// For nextSong calculation, we need to consider the shuffled order // For nextSong calculation, we need to consider the shuffled order
let nextSong: QueueSong | undefined; let nextSong: QueueSong | undefined;
if (isShuffleEnabled(state)) { if (isShuffleEnabled(state) && repeat !== PlayerRepeat.ONE) {
// Calculate next in shuffled order // Calculate next in shuffled order
const nextShuffledIndex = index + 1; const nextShuffledIndex = index + 1;
if (nextShuffledIndex < state.queue.shuffled.length) { if (nextShuffledIndex < state.queue.shuffled.length) {
@@ -925,7 +925,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const currentSong = queue.items[currentQueueIndex]; const currentSong = queue.items[currentQueueIndex];
let nextSong: QueueSong | undefined; let nextSong: QueueSong | undefined;
if (isShuffle) { if (isShuffle && repeat !== PlayerRepeat.ONE) {
const nextShuffledIndex = nextPlaybackIndex + 1; const nextShuffledIndex = nextPlaybackIndex + 1;
if (nextShuffledIndex < stateSnapshot.queue.shuffled.length) { if (nextShuffledIndex < stateSnapshot.queue.shuffled.length) {
const nextQueueIndex = stateSnapshot.queue.shuffled[nextShuffledIndex]; const nextQueueIndex = stateSnapshot.queue.shuffled[nextShuffledIndex];
@@ -933,8 +933,6 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
} else if (repeat === PlayerRepeat.ALL) { } else if (repeat === PlayerRepeat.ALL) {
const firstQueueIndex = stateSnapshot.queue.shuffled[0]; const firstQueueIndex = stateSnapshot.queue.shuffled[0];
nextSong = queue.items[firstQueueIndex]; nextSong = queue.items[firstQueueIndex];
} else if (repeat === PlayerRepeat.ONE) {
nextSong = currentSong;
} }
} else { } else {
nextSong = calculateNextSong(currentQueueIndex, queue.items, repeat); nextSong = calculateNextSong(currentQueueIndex, queue.items, repeat);
@@ -1733,7 +1731,7 @@ export const subscribeNextSongInsertion = (onChange: (song: QueueSong | undefine
// Calculate next song based on shuffle and repeat settings // Calculate next song based on shuffle and repeat settings
let nextSong: QueueSong | undefined; let nextSong: QueueSong | undefined;
if (isShuffleEnabled(state)) { if (isShuffleEnabled(state) && repeat !== PlayerRepeat.ONE) {
// Calculate next in shuffled order // Calculate next in shuffled order
const nextShuffledIndex = state.player.index + 1; const nextShuffledIndex = state.player.index + 1;
if (nextShuffledIndex < state.queue.shuffled.length) { if (nextShuffledIndex < state.queue.shuffled.length) {
@@ -1940,7 +1938,7 @@ export const usePlayerData = (): PlayerData => {
// For nextSong calculation, we need to consider the shuffled order // For nextSong calculation, we need to consider the shuffled order
let nextSong: QueueSong | undefined; let nextSong: QueueSong | undefined;
if (isShuffleEnabled(state)) { if (isShuffleEnabled(state) && repeat !== PlayerRepeat.ONE) {
// Calculate next in shuffled order // Calculate next in shuffled order
const nextShuffledIndex = index + 1; const nextShuffledIndex = index + 1;
if (nextShuffledIndex < state.queue.shuffled.length) { if (nextShuffledIndex < state.queue.shuffled.length) {
+12 -1
View File
@@ -306,6 +306,7 @@ const PlayerbarSliderSchema = z.object({
barRadius: z.number(), barRadius: z.number(),
barWidth: z.number(), barWidth: z.number(),
loadingDelay: z.number(), loadingDelay: z.number(),
stretched: z.boolean(),
type: PlayerbarSliderTypeSchema, type: PlayerbarSliderTypeSchema,
}); });
@@ -536,6 +537,8 @@ const LyricsDisplaySettingsSchema = z.object({
fontSizeUnsync: z.number(), fontSizeUnsync: z.number(),
gap: z.number(), gap: z.number(),
gapUnsync: z.number(), gapUnsync: z.number(),
opacityNonActive: z.number(),
scaleNonActive: z.number(),
}); });
const LyricsSettingsSchema = z.object({ const LyricsSettingsSchema = z.object({
@@ -635,6 +638,7 @@ const WindowSettingsSchema = z.object({
exitToTray: z.boolean(), exitToTray: z.boolean(),
minimizeToTray: z.boolean(), minimizeToTray: z.boolean(),
preventSleepOnPlayback: z.boolean(), preventSleepOnPlayback: z.boolean(),
preventSuspendOnPlayback: z.boolean(),
releaseChannel: z.enum(['alpha', 'beta', 'latest']), releaseChannel: z.enum(['alpha', 'beta', 'latest']),
startMinimized: z.boolean(), startMinimized: z.boolean(),
tray: z.boolean(), tray: z.boolean(),
@@ -1150,6 +1154,7 @@ const initialState: SettingsState = {
barRadius: 4, barRadius: 4,
barWidth: 2, barWidth: 2,
loadingDelay: 2, loadingDelay: 2,
stretched: false,
type: PlayerbarSliderType.SLIDER, type: PlayerbarSliderType.SLIDER,
}, },
playerItems, playerItems,
@@ -1794,6 +1799,8 @@ const initialState: SettingsState = {
fontSizeUnsync: 24, fontSizeUnsync: 24,
gap: 24, gap: 24,
gapUnsync: 24, gapUnsync: 24,
opacityNonActive: 0.2,
scaleNonActive: 0.95,
}, },
}, },
playback: { playback: {
@@ -1908,6 +1915,7 @@ const initialState: SettingsState = {
exitToTray: false, exitToTray: false,
minimizeToTray: false, minimizeToTray: false,
preventSleepOnPlayback: false, preventSleepOnPlayback: false,
preventSuspendOnPlayback: false,
releaseChannel: 'latest', releaseChannel: 'latest',
startMinimized: false, startMinimized: false,
tray: true, tray: true,
@@ -2211,7 +2219,10 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
state.lyrics = mainSettings; state.lyrics = mainSettings;
state.lyricsDisplay = { state.lyricsDisplay = {
default: displaySettings, default: {
...state.lyricsDisplay.default,
...displaySettings,
},
}; };
} }
} }
+2
View File
@@ -23,6 +23,8 @@ export const THEME_DATA = [
{ label: 'One Dark', type: 'dark', value: AppTheme.ONE_DARK }, { label: 'One Dark', type: 'dark', value: AppTheme.ONE_DARK },
{ label: 'Solarized Dark', type: 'dark', value: AppTheme.SOLARIZED_DARK }, { label: 'Solarized Dark', type: 'dark', value: AppTheme.SOLARIZED_DARK },
{ label: 'Solarized Light', type: 'light', value: AppTheme.SOLARIZED_LIGHT }, { label: 'Solarized Light', type: 'light', value: AppTheme.SOLARIZED_LIGHT },
{ label: 'Everforest Dark', type: 'dark', value: AppTheme.EVERFOREST_DARK },
{ label: 'Everforest Light', type: 'light', value: AppTheme.EVERFOREST_LIGHT },
{ label: 'GitHub Dark', type: 'dark', value: AppTheme.GITHUB_DARK }, { label: 'GitHub Dark', type: 'dark', value: AppTheme.GITHUB_DARK },
{ label: 'GitHub Light', type: 'light', value: AppTheme.GITHUB_LIGHT }, { label: 'GitHub Light', type: 'light', value: AppTheme.GITHUB_LIGHT },
{ label: 'Glassy Dark', type: 'dark', value: AppTheme.GLASSY_DARK }, { label: 'Glassy Dark', type: 'dark', value: AppTheme.GLASSY_DARK },
@@ -278,6 +278,8 @@ const normalizeSong = (
const normalizeAlbum = ( const normalizeAlbum = (
item: z.infer<typeof jfType._response.album>, item: z.infer<typeof jfType._response.album>,
server: null | ServerListItem, server: null | ServerListItem,
pathReplace?: string,
pathReplaceWith?: string,
): Album => { ): Album => {
const { originalYear, releaseDate, releaseYear } = jellyfinPremiereFields(item); const { originalYear, releaseDate, releaseYear } = jellyfinPremiereFields(item);
@@ -340,7 +342,7 @@ const normalizeAlbum = (
releaseYear, releaseYear,
size: null, size: null,
songCount: item?.ChildCount || null, songCount: item?.ChildCount || null,
songs: item.Songs?.map((song) => normalizeSong(song, server)), songs: item.Songs?.map((song) => normalizeSong(song, server, pathReplace, pathReplaceWith)),
sortName: item.SortName || item.Name, sortName: item.SortName || item.Name,
tags: getTags(item), tags: getTags(item),
updatedAt: item?.DateLastMediaAdded || item.DateCreated, updatedAt: item?.DateLastMediaAdded || item.DateCreated,
+25 -12
View File
@@ -18,14 +18,20 @@ import {
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ServerListItem, ServerType } from '/@/shared/types/types'; import { ServerListItem, ServerType } from '/@/shared/types/types';
const getImageUrl = (args: { url: null | string }) => { // const getImageUrl = (args: { url: null | string }) => {
const { url } = args; // const { url } = args;
if (url === '/app/artist-placeholder.webp') { // if (url === '/app/artist-placeholder.webp') {
return null; // return null;
} // }
return url; // return url;
}; // };
const navidromeImageIdWithCacheBust = (
id: string,
uploadedImage: string | undefined,
updatedAt: string | undefined,
): string => (!uploadedImage ? id : `${id}&_=${updatedAt ?? ''}`);
interface WithDate { interface WithDate {
playDate?: string; playDate?: string;
@@ -397,7 +403,7 @@ const normalizeAlbumArtist = (
}, },
server?: null | ServerListItem, server?: null | ServerListItem,
): AlbumArtist => { ): AlbumArtist => {
const imageUrl = getImageUrl({ url: item?.largeImageUrl?.replace(/\?size=\d+/, '') || null }); // const imageUrl = getImageUrl({ url: item?.largeImageUrl?.replace(/\?size=\d+/, '') || null });
let albumCount: number; let albumCount: number;
let songCount: number; let songCount: number;
@@ -416,6 +422,12 @@ const normalizeAlbumArtist = (
songCount = item.songCount; songCount = item.songCount;
} }
const imageId = navidromeImageIdWithCacheBust(
item.id,
item.uploadedImage,
item.updatedAt ?? item.externalInfoUpdatedAt,
);
return { return {
_itemType: LibraryItem.ALBUM_ARTIST, _itemType: LibraryItem.ALBUM_ARTIST,
_serverId: server?.id || 'unknown', _serverId: server?.id || 'unknown',
@@ -435,8 +447,8 @@ const normalizeAlbumArtist = (
songCount: null, songCount: null,
})), })),
id: item.id, id: item.id,
imageId: item.id, imageId,
imageUrl: imageUrl || null, imageUrl: null,
lastPlayedAt: normalizePlayDate(item), lastPlayedAt: normalizePlayDate(item),
mbz: item.mbzArtistId || null, mbz: item.mbzArtistId || null,
name: item.name, name: item.name,
@@ -451,6 +463,7 @@ const normalizeAlbumArtist = (
userRating: artist.userRating || null, userRating: artist.userRating || null,
})) || [], })) || [],
songCount, songCount,
uploadedImage: item.uploadedImage,
userFavorite: item.starred || false, userFavorite: item.starred || false,
userRating: item.rating || null, userRating: item.rating || null,
}; };
@@ -460,7 +473,7 @@ const normalizePlaylist = (
item: z.infer<typeof ndType._response.playlist>, item: z.infer<typeof ndType._response.playlist>,
server?: null | ServerListItem, server?: null | ServerListItem,
): Playlist => { ): Playlist => {
const imageId = !item.uploadedImage ? item.id : `${item.id}&_=${item.updatedAt}`; const imageId = navidromeImageIdWithCacheBust(item.id, item.uploadedImage, item.updatedAt);
return { return {
_itemType: LibraryItem.PLAYLIST, _itemType: LibraryItem.PLAYLIST,
@@ -517,7 +530,7 @@ const normalizeInternetRadioStation = (
item: z.infer<typeof ndType._response.radioStation>, item: z.infer<typeof ndType._response.radioStation>,
): InternetRadioStation => { ): InternetRadioStation => {
const homepageUrl = item.homePageUrl?.trim() ? item.homePageUrl : null; const homepageUrl = item.homePageUrl?.trim() ? item.homePageUrl : null;
const imageId = item.uploadedImage ? `${item.id}&_=${item.updatedAt}` : item.id; const imageId = navidromeImageIdWithCacheBust(item.id, item.uploadedImage, item.updatedAt);
return { return {
homepageUrl, homepageUrl,
@@ -428,6 +428,7 @@ const albumArtist = z.object({
starredAt: z.string(), starredAt: z.string(),
stats: z.record(z.string(), stats).optional(), stats: z.record(z.string(), stats).optional(),
updatedAt: z.string().optional(), updatedAt: z.string().optional(),
uploadedImage: z.string().optional(),
}); });
const albumArtistList = z.array(albumArtist); const albumArtistList = z.array(albumArtist);
@@ -683,6 +684,9 @@ const deletePlaylistImage = z.object({
const uploadInternetRadioStationImage = uploadPlaylistImage; const uploadInternetRadioStationImage = uploadPlaylistImage;
const uploadInternetRadioStationImageParameters = uploadPlaylistImageParameters; const uploadInternetRadioStationImageParameters = uploadPlaylistImageParameters;
const uploadArtistImage = uploadPlaylistImage;
const uploadArtistImageParameters = uploadPlaylistImageParameters;
const deleteArtistImage = deletePlaylistImage;
const deleteInternetRadioStationImage = deletePlaylistImage; const deleteInternetRadioStationImage = deletePlaylistImage;
const deletePlaylist = z.null(); const deletePlaylist = z.null();
@@ -813,6 +817,7 @@ export const ndType = {
tagList: tagListParameters, tagList: tagListParameters,
updateInternetRadioStation: updateInternetRadioStationParameters, updateInternetRadioStation: updateInternetRadioStationParameters,
updatePlaylist: updatePlaylistParameters, updatePlaylist: updatePlaylistParameters,
uploadArtistImage: uploadArtistImageParameters,
uploadInternetRadioStationImage: uploadInternetRadioStationImageParameters, uploadInternetRadioStationImage: uploadInternetRadioStationImageParameters,
uploadPlaylistImage: uploadPlaylistImageParameters, uploadPlaylistImage: uploadPlaylistImageParameters,
userList: userListParameters, userList: userListParameters,
@@ -825,6 +830,7 @@ export const ndType = {
albumList, albumList,
authenticate, authenticate,
createPlaylist, createPlaylist,
deleteArtistImage,
deleteInternetRadioStation, deleteInternetRadioStation,
deleteInternetRadioStationImage, deleteInternetRadioStationImage,
deletePlaylist, deletePlaylist,
@@ -848,6 +854,7 @@ export const ndType = {
tagList, tagList,
updateInternetRadioStation, updateInternetRadioStation,
updatePlaylist, updatePlaylist,
uploadArtistImage,
uploadInternetRadioStationImage, uploadInternetRadioStationImage,
uploadPlaylistImage, uploadPlaylistImage,
user, user,
+1 -1
View File
@@ -28,4 +28,4 @@ const BaseBadge = ({ children, classNames, variant = 'default', ...props }: Badg
); );
}; };
export const Badge = createPolymorphicComponent<'button', BadgeProps>(BaseBadge); export const Badge = createPolymorphicComponent<'div', BadgeProps>(BaseBadge);
@@ -0,0 +1,18 @@
/*
* Inset outline on the root is hidden behind a full-bleed ItemImage; a ::after layer paints
* above the image. Keep z-index below overlay controls (e.g. z-index: 2).
* Avoid positive outline-offset so ancestors with overflow:hidden do not clip the indicator.
*/
.file-target-drag-over {
position: relative;
}
.file-target-drag-over::after {
position: absolute;
inset: calc(var(--theme-spacing-sm) * -1);
z-index: 1;
pointer-events: none;
content: '';
border-radius: var(--theme-radius-md);
box-shadow: inset 0 0 0 3px var(--theme-colors-primary);
}

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