Compare commits

..

31 Commits

Author SHA1 Message Date
Psychotoxical 01f71cf50c chore(release): bump version to 1.34.10
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:21:07 +02:00
Psychotoxical 39966d1c65 Create FUNDING.yml 2026-04-13 22:07:56 +02:00
Psychotoxical cbf72c7eba chore(about): add cucadmuh PR #176 to contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:54:02 +02:00
cucadmuh 4207455440 fix(audio): Linux output device selection and watcher (#176)
* fix(audio): stabilize Linux output device picker and watcher

Keep pinned ALSA/cpal device ids stable when enumeration omits the active
sink or returns an equivalent name. Stop Linux device-watcher from clearing
the pin based on missing list entries; macOS and Windows still treat repeated
absence as unplugged. Settings refresh flow calls canonicalize and refetches
the list; add i18n for the out-of-list device label.

* fix(settings): sort audio output devices by label

cpal enumeration order is arbitrary; order the dropdown by readable label
and place the current OS default device first among concrete outputs.
2026-04-13 21:48:21 +02:00
Psychotoxical 9983d13122 chore(about): add kilyabin PR #175 to contributors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:48:13 +02:00
kilyabin 1b55e88cd5 fix(fullscreen-player): stop mesh blob and portrait animations in no-compositing mode (#175)
In software compositing (WEBKIT_DISABLE_COMPOSITING_MODE=1), WebKit renders
entirely on the CPU. The mesh blob animations were not covered by the
no-compositing overrides: blob-a occupies 130×130% of the viewport, so each
frame the CPU had to composite a surface larger than the screen. On high-DPI
displays (e.g. 8K) this drops the fullscreen player to ~10 FPS.

- Stop mesh blob pan animations (static gradients are preserved)
- Stop portrait drift animation (filter was already removed)
- Remove box-shadow from the seekbar played bar (its width changes on every
  playback tick; in software mode each change triggers a box-shadow repaint)
2026-04-13 20:46:56 +02:00
Psychotoxical 2326f1f94b feat(linux): AppImage bundle + force X11/XWayland on all Linux packages
- CI: add squashfs-tools dep, APPIMAGE_EXTRACT_AND_RUN=1, appimage to
  --bundles, and *.AppImage to upload glob
- main.rs: set GDK_BACKEND=x11 and WEBKIT_DISABLE_COMPOSITING_MODE=1
  on Linux before Tauri init — WebKitGTK on Wayland is unstable;
  both vars are overridable by setting them before launch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:45:00 +02:00
Psychotoxical 27ea221130 chore(about): update contributors, remove feature tagline, limit changelog to 3
- Add missing contributions: cucadmuh (PR #144, #167, #173, #174),
  kveld9 (PR #168), nisarg-78 (PR #115)
- Remove aboutFeatures tag line from all 8 locales (too dynamic to maintain)
- Changelog section now shows only the 3 most recent versions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:23:22 +02:00
cucadmuh 9cd4743d1c feat(settings): audio output device picker (labels, OS default, live refresh) (#173)
* feat(settings): clearer audio device labels for duplicate ALSA names

Show HDMI outputs as "Card (HDMI n)" from hdmi:DEV indices; include PCM and
optional subdevice for hw/plughw/sysdefault; label other ALSA plugins with
iface and PCM. When labels still collide, append a structured hint
(iface · card · PCM) instead of only truncating the raw device string.

* feat(settings): improve audio output device picker

Parse ALSA-style ids into clearer labels (HDMI with DEV index, PCM/subdevice
for hw/plughw/sysdefault). Disambiguate colliding labels; share stderr
suppression for Linux device enumeration.

Add audio_default_output_device_name and tag the matching list entry as the
current system output (i18n). While the Audio tab is open, refresh list and
mark on audio:device-changed and audio:device-reset without toggling the
refresh spinner. Show an error toast if listing devices fails.
2026-04-13 20:13:22 +02:00
cucadmuh e75fda168e fix(folder-browser): arrow navigation only without modifiers (#174)
Skip column/list arrow handling when any modifier is down. Detect modifiers
via nativeEvent and getModifierState (WebKit/WebView), and match arrow keys
by both key and code. On row buttons, preventDefault for plain arrows only
to avoid native focus/scroll stealing navigation. Filter field ArrowDown uses
the same modifier check.
2026-04-13 20:13:10 +02:00
Psychotoxical ec021516c7 fix(audio): device-reset false positive + stderr noise + i18n tooltip
- Device watcher requires 3 consecutive misses (~9 s) before triggering
  audio:device-reset, preventing false positives when ALSA temporarily
  hides a busy device from output_devices() enumeration
- Add stderr suppression to open_stream_for_device_and_rate (last source
  of ALSA terminal noise on Linux)
- Fix tooltip showing raw key 'common.refresh' — replaced with
  settings.audioOutputDeviceRefresh (added to all 8 locales)
- Device switch restarts track from beginning (no seek-back)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:00:36 +02:00
Psychotoxical 82e1b458f3 fix(audio): clean up ALSA device names for display on Linux
Raw ALSA names like "sysdefault:CARD=U192k" are replaced with
readable labels ("U192k") in the device dropdown. The underlying
value stays unchanged so rodio can still open the correct device.

Covers: sysdefault, hw, plughw, iec958, front, surround prefixes.
Names without ALSA structure (pipewire, pulse, default) are kept as-is.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:46:53 +02:00
Psychotoxical fc7a5815ef feat(audio): add refresh button for device list in Settings
The module-level cache prevented newly connected USB audio devices
from appearing in the dropdown. Replace with a manual refresh button
(RotateCcw icon, spins while loading) next to the dropdown.

Device enumeration now runs on every Audio tab open and on explicit
refresh. ALSA stderr noise is already suppressed by the dup2 guard,
so re-enumeration is silent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:38:58 +02:00
Psychotoxical 1cf5faff21 fix(audio): suppress ALSA stderr noise during device enumeration
Device watcher (3 s loop) and audio_list_devices both called
cpal output_devices(), triggering ALSA to probe unavailable backends
(JACK, OSS, dmix) and spam stderr with error messages.

Fix: redirect fd 2 to /dev/null for the duration of each enumeration
on Unix via libc dup/dup2 + RAII guard. Also add a module-level
cache in Settings.tsx so audio_list_devices is only invoked once
per app session instead of on every Audio tab activation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:30:24 +02:00
Psychotoxical 16cb4f5a6f feat(audio): audio output device selection (closes #169)
Adds the ability to choose which audio output device Psysonic plays
through — useful for USB DACs, dedicated soundcards, and multi-device setups.

Rust (audio.rs):
- open_stream_for_device_and_rate(device_name, rate): opens a named
  device by cpal name, falls back to system default if not found
- AudioEngine.selected_device: persists the chosen device across
  stream reopens so hi-res rate switches stay on the right device
- audio_list_devices: Tauri command — returns all output device names
- audio_set_device: Tauri command — switches device immediately,
  drops old sinks, emits audio:device-changed
- start_device_watcher: now handles two cases:
    1. No pinned device + system default changed → reopen on new default
    2. Pinned device disappeared (DAC unplugged) → clear selected_device,
       fall back to system default, emit audio:device-reset

Frontend:
- authStore: audioOutputDevice (string | null), persisted
- playerStore: applies stored device on cold start
- App.tsx: listens to audio:device-reset, clears authStore device
  and restarts playback on the fallback device
- Settings → Audio tab: device dropdown at top (above Hi-Res and EQ),
  uses CustomSelect (portal-based, styled), all 8 locales
- CustomSelect: added disabled prop

Note: exclusive mode (WASAPI exclusive, CoreAudio exclusive) is out
of scope for now.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:20:23 +02:00
Psychotoxical 9b22327bb0 fix(login): clarify that https:// URLs are accepted in server URL field (#171)
https:// was already supported by the code (startsWith('http') check) but
the placeholder text only showed bare host:port examples, giving no hint
that a full URL with protocol is valid.

- Updated serverUrlPlaceholder in login namespace (all 8 locales) to show
  https://music.example.com as the domain example
- Added settings.serverUrlPlaceholder i18n key (all 8 locales) and wired it
  into AddServerForm — previously the placeholder was a hardcoded English string

Closes #171

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:59:19 +02:00
Psychotoxical dba89cc4e4 fix(offline): replace blocking overlay with slim banner; add server settings link (#170)
When offline with no cached content, the full-screen OfflineOverlay blocked
all navigation including Settings, making it impossible to fix a broken
server config. Replace it with the same slim banner used in offline-cache mode.

- OfflineBanner now handles both cases: cache (existing) and no-cache (new)
- No-cache banner shows server name and a direct link to Settings → Server tab
- OfflineOverlay component is no longer used (import removed from App.tsx)
- All 8 locales: added offlineNoCacheBanner and serverSettings keys

Fixes #170

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:52:17 +02:00
Psychotoxical 91d4cb58b8 feat: playlist management enhancements & UX improvements (#168)
- Multi-selection for Albums, Artists, Playlists with context menu bulk-add
- Collapsible playlist section in sidebar
- Infinite scroll on Artists page (IntersectionObserver)
- Submenu flip-up on viewport overflow
- Remove from Playlist in context menu
- All 8 locales synced

Fixes applied:
- title= → data-tooltip on playlist toggle button (CLAUDE.md)
- Hardcoded Spanish aria-label → i18n (sidebar.expandPlaylists/collapsePlaylists)
- AddToPlaylistSubmenu fetches playlists on first open if store empty

Co-Authored-By: kveld9 <kveld9@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:37:50 +02:00
Psychotoxical 374ebce9b5 feat(keybindings): in-app modifier chords; fix seek shortcut units (#167)
feat(keybindings): in-app modifier chords; fix seek shortcut units
2026-04-13 17:35:45 +02:00
Psychotoxical 083c5f62cb fix(pr168): title→data-tooltip, i18n playlist toggle, cold-start playlist fetch
- Replace title= with data-tooltip on sidebar playlist expand button (CLAUDE.md)
- Replace hardcoded Spanish aria-label strings with i18n keys (sidebar.expandPlaylists / collapsePlaylists) across all 8 locales
- AddToPlaylistSubmenu now fetches playlists on first open if store is empty (regression fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:33:56 +02:00
kveld9 592a479c30 fixed some bugs 2026-04-12 20:43:31 -03:00
Maxim Isaev 6439200e95 feat(keybindings): in-app modifier chords; fix seek shortcut units
Persist chords as ctrl/alt/shift/super plus key code; legacy single-key
bindings still match without modifiers. Settings capture uses buildInAppBinding;
App uses matchInAppBinding and skips chords also registered as global shortcuts.
Share MODIFIER_KEY_CODES and formatBinding with global shortcut formatting.

Fix seek-forward/backward hotkeys: seek() expects 0-1 progress, not seconds.
2026-04-13 01:01:50 +03:00
kveld9 9cb1471170 chore: apply stashed changes 2026-04-12 17:55:13 -03:00
kveld9 21e26e2604 chore: remove tauri generated schemas from tracking 2026-04-12 17:54:16 -03:00
kveld9 94323e91fa fixed bugs 2026-04-12 17:53:52 -03:00
kveld9 bef6941a2b feat: add multi-selection and context menu for playlist management 2026-04-12 17:53:52 -03:00
Psychotoxical 805b6bf163 chore(gen): update Tauri schema files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:44:51 +02:00
Psychotoxical fee454da5d fix(folder-browser): correct --bg-base to --bg-app in filter bar; add cucadmuh PR #165
--bg-base is undefined — sticky filter bar would have rendered transparent.
Also added PR #165 to cucadmuh's contributions in Settings About.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:44:40 +02:00
Psychotoxical a765bcd5d0 feat(themes): add Vision Dark & Vision Navy colorblind-safe themes (closes #166)
Purple & Gold palette (Deuteranopia / Protanopia / Tritanopia safe).
Vision Dark: near-black #0D0B12 + Gold #FFD700 (~14.7:1 WCAG AAA).
Vision Navy: deep navy #0A1628 + Gold #FFD700 (~14.5:1 WCAG AAA).
New 'Accessibility' group in ThemePicker.
Removed stale theme count from aboutFeatures across all locales.

This is an initial step toward colorblind accessibility — color variables
alone cannot cover all CVD use cases. Structural improvements (secondary
indicators, pattern/shape cues) are still needed in future iterations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:44:29 +02:00
Psychotoxical 7e50c7af40 feat(folder-browser): per-column filter keyboard flow
feat(folder-browser): per-column filter keyboard flow
2026-04-12 18:41:08 +02:00
Maxim Isaev a12d6a4015 feat(folder-browser): add per-column filter and queue append hotkey flow
Add Ctrl+F filtering for the active Folder Browser column with keyboard handoff between filter and rows, and clear right-side filters when parent selection changes.

When pressing Shift+Enter on a filtered track list, append visible tracks to the queue instead of replacing the current queue.
2026-04-12 14:08:36 +03:00
47 changed files with 3193 additions and 13332 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: psychotoxic
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+4 -3
View File
@@ -125,7 +125,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
libasound2-dev
libasound2-dev squashfs-tools
- name: setup node
uses: actions/setup-node@v5
@@ -148,7 +148,8 @@ jobs:
env:
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
run: npm run tauri:build -- --bundles deb,rpm
APPIMAGE_EXTRACT_AND_RUN: 1
run: npm run tauri:build -- --bundles deb,rpm,appimage
- name: upload Linux artifacts
env:
@@ -156,5 +157,5 @@ jobs:
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" \) \
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
| xargs gh release upload "app-v${VERSION}" --clobber
+1
View File
@@ -31,6 +31,7 @@ dist-ssr
# Tauri
src-tauri/target/
src-tauri/gen/
# Documentation
CLAUDE.md
+37
View File
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.34.10] - 2026-04-13
### Added
- **AppImage bundle for Linux** + X11/XWayland enforcement on all Linux packages: CI now builds `.AppImage` in addition to `.deb` and `.rpm`. `GDK_BACKEND=x11` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` are set automatically at startup on all Linux packages — WebKitGTK on Wayland is unstable. Both environment variables are still overridable by setting them before launch.
- **Audio output device selection** *(Issue [#169](https://github.com/Psychotoxical/psysonic/issues/169))*: Settings → Audio now shows a dropdown of all available output devices. The current OS default is pinned at the top with a label; a Refresh button re-enumerates silently. A device watcher detects hot-plug events and emits `audio:device-reset` after ~9 s of consecutive misses, preventing false positives on busy ALSA devices. On Linux, technical ALSA prefixes are stripped for display (`sysdefault:CARD=U192k``U192k`).
- **Vision Dark & Vision Navy — colorblind-safe themes** *(Issue [#166](https://github.com/Psychotoxical/psysonic/issues/166))*: Two new themes using a Purple & Gold palette designed to be safe for Deuteranopia, Protanopia, and Tritanopia. Vision Dark pairs near-black `#0D0B12` with Gold `#FFD700` (~14.7:1 WCAG AAA); Vision Navy uses deep navy `#0A1628` + Gold (~14.5:1 WCAG AAA). Both appear under a new **Accessibility** group in the Theme Picker. These themes are a first step toward proper colorblind support and will be revised and expanded in upcoming releases — structural improvements such as secondary indicators and pattern/shape cues are still on the roadmap.
- **Folder Browser — per-column filter & Shift+Enter queue append** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#165](https://github.com/Psychotoxical/psysonic/pull/165))*: Press Ctrl+F to open a filter field for the active Folder Browser column. Focus hands off cleanly between the filter input and the row list. Clearing a parent-column selection clears all right-side filters automatically. Press Shift+Enter on a filtered track list to **append** the visible tracks to the queue without replacing it.
- **Keybindings — in-app modifier chords** *(contributed by [@kveld9](https://github.com/kveld9), PR [#167](https://github.com/Psychotoxical/psysonic/pull/167))*: In-app keybindings now support Ctrl/Alt/Shift+Key chords in addition to bare keys. The settings capture flow uses `buildInAppBinding`; the runtime handler uses `matchInAppBinding` and skips any chord already claimed as a global shortcut. Bare-key bindings still match without modifiers. Additionally, the seek forward/backward shortcuts now correctly interpret the configured value as seconds — previously the value was treated as a 01 progress fraction.
- **Playlist management enhancements** *(contributed by [@kveld9](https://github.com/kveld9), PR [#168](https://github.com/Psychotoxical/psysonic/pull/168))*: Multi-select context-menu actions for Albums, Artists, and Playlists now include a bulk **Add to Playlist** submenu. The sidebar playlist section is now collapsible. The Artists page gains infinite scroll via `IntersectionObserver`. Submenus flip upward automatically when they would overflow the viewport bottom. A **Remove from Playlist** entry is now available in the Playlist Detail context menu.
### Fixed
- **Fullscreen Player — animation overhead in no-compositing mode** *(contributed by [@kilyabin](https://github.com/kilyabin), PR [#175](https://github.com/Psychotoxical/psysonic/pull/175))*: In software-rendering mode (`WEBKIT_DISABLE_COMPOSITING_MODE=1`) the mesh blob pan animations are now stopped (static gradients are preserved), the portrait drift animation is stopped, and `box-shadow` is removed from the seekbar played bar. The seekbar played bar width changes on every playback tick; triggering a full shadow repaint in software mode caused significant CPU overhead.
- **Folder Browser — arrow keys with modifier keys** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#174](https://github.com/Psychotoxical/psysonic/pull/174))*: Column and list arrow-key handling is now skipped when any modifier key is held, preventing conflicts with browser focus navigation and OS-level shortcuts. Modifier detection uses both `nativeEvent` and `getModifierState` for WebKit/WebView2 compatibility.
- **Audio output device — Linux stability** *(contributed by [@cucadmuh](https://github.com/cucadmuh), PR [#176](https://github.com/Psychotoxical/psysonic/pull/176))*: Pinned ALSA/cpal device IDs now stay stable when enumeration temporarily omits the active sink or returns an equivalent name. The Linux device-watcher no longer clears the pin based solely on missing list entries — only macOS and Windows treat repeated absence as "device unplugged". The Settings refresh flow calls `canonicalize` and refetches the list; an i18n label is now shown when the active device is no longer in the enumerated list.
- **Login — server URL field** *(Issue [#171](https://github.com/Psychotoxical/psysonic/issues/171))*: The placeholder text in the Add Server form was previously a hardcoded English string. It is now fully localised and clarifies that `https://` URLs are accepted.
- **Offline mode — non-blocking banner** *(Issue [#170](https://github.com/Psychotoxical/psysonic/issues/170))*: The full-screen blocking overlay shown when Psysonic starts without a cached library is replaced with a slim banner at the top of the content area. The banner includes a direct link to Server Settings so the user can fix the connection without navigating manually.
---
*Special thanks to everyone who contributed to this release:*
*[@cucadmuh](https://github.com/cucadmuh) for two significant Folder Browser improvements and the Linux audio stability fixes — three PRs in one release cycle, remarkable.*
*[@kilyabin](https://github.com/kilyabin) for continuing to hunt down no-compositing performance issues.*
*[@kveld9](https://github.com/kveld9) for the modifier-chord keybindings and the playlist management overhaul.*
---
## [1.34.9] - 2026-04-12
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.34.9",
"version": "1.34.10",
"private": true,
"scripts": {
"dev": "vite",
+9 -8
View File
@@ -2467,9 +2467,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.184"
version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "libloading"
@@ -3558,12 +3558,13 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.34.9"
version = "1.34.10"
dependencies = [
"biquad",
"discord-rich-presence",
"futures-util",
"id3",
"libc",
"lofty",
"md5",
"reqwest 0.12.28",
@@ -3633,7 +3634,7 @@ dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.3",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
@@ -3707,9 +3708,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.3"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
@@ -4057,9 +4058,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
dependencies = [
"once_cell",
"ring",
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.34.9"
version = "1.34.10"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -47,6 +47,9 @@ thread-priority = "1"
lofty = "0.22"
id3 = "1.16.4"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-start-dragging","core:window:allow-create","core:webview:allow-create-webview-window","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+307 -37
View File
@@ -2,6 +2,8 @@ use std::io::{Cursor, Read, Seek, SeekFrom};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant};
#[cfg(unix)]
use libc;
use ringbuf::{HeapConsumer, HeapProducer, HeapRb};
@@ -1448,9 +1450,11 @@ pub struct AudioEngine {
/// The rate the device was opened at on cold start — used to restore the
/// stream when Hi-Res is toggled off while a hi-res rate is active.
pub device_default_rate: u32,
/// Sends `(desired_rate, is_hi_res, reply_tx)` to the audio-stream thread to
/// re-open the output device. `is_hi_res` controls thread-priority escalation.
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>,
/// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream
/// thread to re-open the output device. `device_name = None` → system default.
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>,
/// User-selected output device name (None = follow system default).
pub selected_device: Arc<Mutex<Option<String>>>,
pub current: Arc<Mutex<AudioCurrent>>,
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
pub generation: Arc<AtomicU64>,
@@ -1511,7 +1515,10 @@ impl AudioCurrent {
}
}
/// Open the system default output device at `desired_rate` Hz (0 = device default).
/// Open an output device at `desired_rate` Hz (0 = device default).
///
/// `device_name`: exact name from `audio_list_devices`. `None` → system default.
/// Falls back to the system default if the named device is not found.
///
/// Resolution order:
/// 1. Exact rate match in the device's supported config ranges.
@@ -1520,12 +1527,31 @@ impl AudioCurrent {
/// 4. System default (last resort).
///
/// Returns `(OutputStream, OutputStreamHandle, actual_sample_rate)`.
fn open_stream_for_rate(desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) {
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
// Suppress ALSA stderr noise while enumerating devices on Unix.
#[cfg(unix)]
let _guard = unsafe {
struct StderrGuard(i32);
impl Drop for StderrGuard {
fn drop(&mut self) { unsafe { libc::dup2(self.0, 2); libc::close(self.0); } }
}
let saved = libc::dup(2);
let devnull = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_WRONLY);
libc::dup2(devnull, 2);
libc::close(devnull);
StderrGuard(saved)
};
let host = rodio::cpal::default_host();
if let Some(device) = host.default_output_device() {
// Resolve the target device: named device first, fall back to system default.
let device = device_name.and_then(|name| {
host.output_devices().ok()?.find(|d| d.name().ok().as_deref() == Some(name))
}).or_else(|| host.default_output_device());
if let Some(device) = device {
if desired_rate > 0 {
if let Ok(supported) = device.supported_output_configs() {
let configs: Vec<_> = supported.collect();
@@ -1608,7 +1634,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
let (init_tx, init_rx) =
std::sync::mpsc::sync_channel::<(rodio::OutputStreamHandle, u32)>(0);
let (reopen_tx, reopen_rx) =
std::sync::mpsc::sync_channel::<(u32, bool, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4);
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4);
let thread = std::thread::Builder::new()
.name("psysonic-audio-stream".into())
@@ -1627,11 +1653,11 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
// Thread priority is kept at default during standard-mode playback.
// It is escalated to Max only when a Hi-Res stream reopen is requested,
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
let (mut _stream, handle, rate) = open_stream_for_rate(0);
let (mut _stream, handle, rate) = open_stream_for_device_and_rate(None, 0);
init_tx.send((handle, rate)).ok();
// Keep the stream alive and handle sample-rate switch requests.
while let Ok((desired_rate, is_hi_res, reply_tx)) = reopen_rx.recv() {
// Keep the stream alive and handle sample-rate / device-switch requests.
while let Ok((desired_rate, is_hi_res, device_name, reply_tx)) = reopen_rx.recv() {
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
// real-time scheduling to avoid underruns). No escalation for
// standard mode — the thread blocks on recv() between reopens so
@@ -1657,7 +1683,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
}
let (new_stream, new_handle, _actual) = open_stream_for_rate(desired_rate);
let (new_stream, new_handle, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
_stream = new_stream;
reply_tx.send(new_handle).ok();
}
@@ -1671,6 +1697,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)),
device_default_rate: initial_rate,
stream_reopen_tx: reopen_tx,
selected_device: Arc::new(Mutex::new(None)),
current: Arc::new(Mutex::new(AudioCurrent {
sink: None,
duration_secs: 0.0,
@@ -2018,7 +2045,8 @@ pub async fn audio_play(
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
if needs_switch {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, reply_tx)).is_ok() {
let dev = state.selected_device.lock().unwrap().clone();
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() {
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(new_handle) => {
*state.stream_handle.lock().unwrap() = new_handle;
@@ -2883,6 +2911,167 @@ pub async fn audio_play_radio(
Ok(())
}
/// ALSA probes noisy plugins during device queries — suppress stderr on Unix.
#[cfg(unix)]
fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
struct StderrGuard(i32);
impl Drop for StderrGuard {
fn drop(&mut self) {
unsafe { libc::dup2(self.0, 2); libc::close(self.0); }
}
}
let _guard = unsafe {
let saved = libc::dup(2);
let devnull = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_WRONLY);
libc::dup2(devnull, 2);
libc::close(devnull);
StderrGuard(saved)
};
f()
}
#[cfg(not(unix))]
#[inline]
fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
f()
}
fn enumerate_output_device_names() -> Vec<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
with_suppressed_alsa_stderr(|| {
let host = rodio::cpal::default_host();
host.output_devices()
.map(|iter| iter.filter_map(|d| d.name().ok()).collect())
.unwrap_or_default()
})
}
/// Linux ALSA-style cpal names: same physical sink can appear with different suffixes;
/// busy devices are sometimes omitted from `output_devices()` while playback works.
#[cfg(target_os = "linux")]
fn linux_alsa_sink_fingerprint(name: &str) -> Option<(String, String, u32)> {
const IFACES: &[&str] = &[
"hdmi", "hw", "plughw", "sysdefault", "iec958", "front", "dmix", "surround40",
"surround51", "surround71",
];
let colon = name.find(':')?;
let iface = name[..colon].to_ascii_lowercase();
if !IFACES.iter().any(|&i| i == iface.as_str()) {
return None;
}
let card = name.split("CARD=").nth(1)?.split(',').next()?.to_string();
let dev = name
.split("DEV=")
.nth(1)
.and_then(|s| s.split(',').next())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
Some((iface, card, dev))
}
#[cfg(not(target_os = "linux"))]
#[inline]
fn linux_alsa_sink_fingerprint(_name: &str) -> Option<(String, String, u32)> {
None
}
fn output_devices_logically_same(a: &str, b: &str) -> bool {
if a == b {
return true;
}
match (
linux_alsa_sink_fingerprint(a),
linux_alsa_sink_fingerprint(b),
) {
(Some(fa), Some(fb)) => fa == fb,
_ => false,
}
}
/// True if `pinned` is the same sink as some entry (exact or Linux ALSA logical match).
fn output_enumeration_includes_pinned(available: &[String], pinned: &str) -> bool {
available
.iter()
.any(|d| output_devices_logically_same(d, pinned))
}
/// If the pinned id is missing from cpal's list but another listed id is the same
/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
#[tauri::command]
pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option<String> {
let pinned = state.selected_device.lock().unwrap().clone()?;
if pinned.is_empty() {
return None;
}
let list = enumerate_output_device_names();
if list.iter().any(|d| d == &pinned) {
return None;
}
let canon = list
.iter()
.find(|d| output_devices_logically_same(d, &pinned))?
.clone();
*state.selected_device.lock().unwrap() = Some(canon.clone());
Some(canon)
}
/// Returns the names of all available audio output devices on the current host.
/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
///
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
#[tauri::command]
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
let mut list = enumerate_output_device_names();
if let Some(ref name) = *state.selected_device.lock().unwrap() {
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
list.push(name.clone());
}
}
list
}
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
#[tauri::command]
pub fn audio_default_output_device_name() -> Option<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
with_suppressed_alsa_stderr(|| {
let host = rodio::cpal::default_host();
host.default_output_device().and_then(|d| d.name().ok())
})
}
/// Switch the audio output device. `device_name = null` → follow system default.
/// Reopens the stream immediately; frontend must restart playback via audio:device-changed.
#[tauri::command]
pub async fn audio_set_device(
device_name: Option<String>,
state: State<'_, AudioEngine>,
app: tauri::AppHandle,
) -> Result<(), String> {
*state.selected_device.lock().unwrap() = device_name.clone();
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
state.stream_reopen_tx
.send((rate, false, device_name, reply_tx))
.map_err(|e| e.to_string())?;
let new_handle = tauri::async_runtime::spawn_blocking(move || {
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
}).await.unwrap_or(None).ok_or("device open timed out")?;
*state.stream_handle.lock().unwrap() = new_handle;
// Drop active sinks — they were bound to the old stream.
if let Some(s) = state.current.lock().unwrap().sink.take() { s.stop(); }
if let Some(s) = state.fading_out_sink.lock().unwrap().take() { s.stop(); }
app.emit("audio:device-changed", ()).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
@@ -2896,45 +3085,129 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
// ─── Device-change watcher ────────────────────────────────────────────────────
//
// Polls the OS default output device every 3 s. When it changes (Bluetooth
// headphones connecting, USB DAC plugging in, etc.) the stream is reopened on
// the new device and `audio:device-changed` is emitted so the frontend can
// restart playback. The old Sink is dropped here — it was bound to the
// now-closed OutputStream and can no longer produce audio on any device.
// Polls every 3 s for two conditions:
// 1. System default device changed (Bluetooth, USB DAC plug/unplug) while no
// device is pinned → reopen on new default, emit audio:device-changed.
// 2. (macOS / Windows only) User-pinned device disappeared from cpal's list →
// fall back to system default, clear selected_device, emit audio:device-reset.
// Linux: case 2 is disabled — ALSA/cpal often omit the active sink from
// enumeration while streaming, which caused false resets to system default.
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let reopen_tx = engine.stream_reopen_tx.clone();
let stream_handle = engine.stream_handle.clone();
let stream_rate = engine.stream_sample_rate.clone();
let current = engine.current.clone();
let fading_out = engine.fading_out_sink.clone();
let reopen_tx = engine.stream_reopen_tx.clone();
let stream_handle = engine.stream_handle.clone();
let stream_rate = engine.stream_sample_rate.clone();
let current = engine.current.clone();
let fading_out = engine.fading_out_sink.clone();
let selected_device = engine.selected_device.clone();
tauri::async_runtime::spawn(async move {
let mut last_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
let mut last_default: Option<String> = tauri::async_runtime::spawn_blocking(|| {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}).await.unwrap_or(None);
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
#[cfg(not(target_os = "linux"))]
let mut pinned_miss_count: u32 = 0;
loop {
tokio::time::sleep(Duration::from_secs(3)).await;
let current_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
// Enumerate all available output devices and the current default.
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}).await.unwrap_or(None);
#[cfg(unix)]
let _guard = unsafe {
struct StderrGuard(i32);
impl Drop for StderrGuard {
fn drop(&mut self) { unsafe { libc::dup2(self.0, 2); libc::close(self.0); } }
}
let saved = libc::dup(2);
let devnull = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_WRONLY);
libc::dup2(devnull, 2);
libc::close(devnull);
StderrGuard(saved)
};
let host = rodio::cpal::default_host();
let default = host.default_output_device().and_then(|d| d.name().ok());
let available: Vec<String> = host
.output_devices()
.map(|iter| iter.filter_map(|d| d.name().ok()).collect())
.unwrap_or_default();
(default, available)
}).await.unwrap_or((None, vec![]));
if current_name == last_name {
// Empty list almost always means a transient enumeration failure, not
// that every output device vanished. Treating it as "pinned missing"
// caused false audio:device-reset (UI jumped back to system default)
// when switching to external USB / class-compliant interfaces.
if available.is_empty() {
continue;
}
last_name = current_name.clone();
let pinned = selected_device.lock().unwrap().clone();
// Only act if there is actually a device to open.
let Some(_new_name) = current_name else { continue };
#[cfg(target_os = "linux")]
if pinned.is_some() {
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
// ALSA/cpal often omit the active HDMI/USB sink from enumeration for the
// whole session — any miss counter eventually tripped audio:device-reset.
// Clearing the pin is left to the user (Settings → System Default) or
// to a future explicit error signal from the output stream.
continue;
}
// ── Case 2 (non-Linux): pinned device disappeared from enumeration ─
#[cfg(not(target_os = "linux"))]
if let Some(ref dev_name) = pinned {
if !output_enumeration_includes_pinned(&available, dev_name) {
pinned_miss_count += 1;
if pinned_miss_count < 3 {
continue;
}
eprintln!("[psysonic] device-watcher: pinned device '{dev_name}' disconnected, falling back to system default");
pinned_miss_count = 0;
*selected_device.lock().unwrap() = None;
tokio::time::sleep(Duration::from_millis(500)).await;
let rate = stream_rate.load(Ordering::Relaxed);
let reopen_tx2 = reopen_tx.clone();
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
return None;
}
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
}).await.unwrap_or(None);
if let Some(handle) = new_handle {
*stream_handle.lock().unwrap() = handle;
if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); }
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
app.emit("audio:device-reset", ()).ok();
}
last_default = current_default;
} else {
pinned_miss_count = 0;
}
continue;
}
// ── Case 1: no pinned device, system default changed ──────────────
if current_default == last_default {
continue;
}
last_default = current_default.clone();
let Some(_new_name) = current_default else { continue };
// Debounce: give the OS time to finish configuring the new device.
tokio::time::sleep(Duration::from_millis(500)).await;
@@ -2944,8 +3217,8 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
if reopen_tx2.send((rate, false, reply_tx)).is_err() {
return None; // audio thread exited
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
return None;
}
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
}).await.unwrap_or(None);
@@ -2956,11 +3229,8 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
};
*stream_handle.lock().unwrap() = handle;
// Drop the old Sink — it was bound to the now-closed OutputStream.
if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); }
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
app.emit("audio:device-changed", ()).ok();
}
});
+4
View File
@@ -1713,6 +1713,10 @@ pub fn run() {
audio::audio_play_radio,
audio::audio_set_crossfade,
audio::audio_set_gapless,
audio::audio_list_devices,
audio::audio_canonicalize_selected_device,
audio::audio_default_output_device_name,
audio::audio_set_device,
audio::audio_chain_preload,
discord::discord_update_presence,
discord::discord_clear_presence,
+11
View File
@@ -2,5 +2,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// WebKitGTK on Wayland is unstable — force X11/XWayland on all Linux packages.
// Users can still override by setting these vars before launch.
#[cfg(target_os = "linux")]
unsafe {
if std::env::var("GDK_BACKEND").is_err() {
std::env::set_var("GDK_BACKEND", "x11");
}
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
}
}
psysonic_lib::run();
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.34.9",
"version": "1.34.10",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+34 -21
View File
@@ -45,7 +45,6 @@ import { DragDropProvider } from './contexts/DragDropContext';
import TooltipPortal from './components/TooltipPortal';
import ConnectionIndicator from './components/ConnectionIndicator';
import LastfmIndicator from './components/LastfmIndicator';
import OfflineOverlay from './components/OfflineOverlay';
import OfflineBanner from './components/OfflineBanner';
import OfflineLibrary from './pages/OfflineLibrary';
import Genres from './pages/Genres';
@@ -66,7 +65,7 @@ import { useThemeStore } from './store/themeStore';
import { useThemeScheduler } from './hooks/useThemeScheduler';
import { useFontStore } from './store/fontStore';
import { useEqStore } from './store/eqStore';
import { useKeybindingsStore } from './store/keybindingsStore';
import { useKeybindingsStore, matchInAppBinding, buildInAppBinding } from './store/keybindingsStore';
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
import { useZipDownloadStore } from './store/zipDownloadStore';
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
@@ -341,17 +340,10 @@ function AppShell() {
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
</button>
</header>
{connStatus === 'disconnected' && hasOfflineContent && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} />
{connStatus === 'disconnected' && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
)}
<div className="content-body" style={{ padding: 0, position: 'relative' }}>
{connStatus === 'disconnected' && !hasOfflineContent && (
<OfflineOverlay
serverName={serverName}
onRetry={connRetry}
isChecking={connRetrying}
/>
)}
<Routes>
<Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} />
@@ -429,13 +421,10 @@ function TauriEventBridge() {
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-changed', () => {
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
const pos = currentTime;
const dur = currentTrack.duration || 1;
playTrack(currentTrack);
setTimeout(() => usePlayerStore.getState().seek(pos / dur), 600);
} else {
// Paused: clear warm-pause flag so the next resume uses the cold path
// (audio_play + seek) which creates a new Sink on the new device.
@@ -445,6 +434,23 @@ function TauriEventBridge() {
return () => { unlisten?.(); };
}, []);
// Pinned output device was unplugged — Rust already fell back to system default.
// Clear the stored device so the Settings dropdown resets to "System Default".
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-reset', () => {
useAuthStore.getState().setAudioOutputDevice(null);
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
playTrack(currentTrack);
} else {
resetAudioPause();
}
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// Sync tray-icon visibility with the user's stored setting.
// Runs once on mount (initial sync) and again whenever the setting changes.
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
@@ -457,15 +463,18 @@ function TauriEventBridge() {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
// Global shortcuts use modifier combos — skip in-app bindings for those
// (X11 GrabModeAsync delivers the key to both the grabber and the focused WebView)
if (e.ctrlKey || e.altKey || e.metaKey) return;
const chord = buildInAppBinding(e);
if (chord) {
const registered = Object.values(useGlobalShortcutsStore.getState().shortcuts);
if (registered.includes(chord)) return;
}
const { bindings } = useKeybindingsStore.getState();
const { togglePlay, next, previous, setVolume, seek, toggleQueue, toggleFullscreen } = usePlayerStore.getState();
const action = (Object.entries(bindings) as [string, string | null][])
.find(([, code]) => code === e.code)?.[0];
.find(([, b]) => matchInAppBinding(e, b))?.[0];
if (!action) return;
e.preventDefault();
@@ -478,12 +487,16 @@ function TauriEventBridge() {
case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break;
case 'seek-forward': {
const s = usePlayerStore.getState();
seek(Math.min(s.currentTrack?.duration ?? 0, s.currentTime + 10));
const dur = s.currentTrack?.duration ?? 0;
if (!dur) break;
seek(Math.min(1, (s.currentTime + 10) / dur));
break;
}
case 'seek-backward': {
const s = usePlayerStore.getState();
seek(Math.max(0, s.currentTime - 10));
const dur = s.currentTrack?.duration ?? 0;
if (!dur) break;
seek(Math.max(0, (s.currentTime - 10) / dur));
break;
}
case 'toggle-queue': toggleQueue(); break;
+7 -2
View File
@@ -15,9 +15,10 @@ interface AlbumCardProps {
selectionMode?: boolean;
onToggleSelect?: (id: string) => void;
showRating?: boolean;
selectedAlbums?: SubsonicAlbum[];
}
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false }: AlbumCardProps) {
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) {
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -44,7 +45,11 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album');
if (selectionMode && selectedAlbums.length > 0) {
openContextMenu(e.clientX, e.clientY, selectedAlbums, 'multi-album');
} else {
openContextMenu(e.clientX, e.clientY, album, 'album');
}
}}
onMouseDown={e => {
if (selectionMode || e.button !== 0) return;
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -15,9 +15,10 @@ interface Props {
onChange: (value: string) => void;
className?: string;
style?: React.CSSProperties;
disabled?: boolean;
}
export default function CustomSelect({ value, options, onChange, className = '', style }: Props) {
export default function CustomSelect({ value, options, onChange, className = '', style, disabled }: Props) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLDivElement>(null);
@@ -80,7 +81,8 @@ export default function CustomSelect({ value, options, onChange, className = '',
type="button"
className={`custom-select-trigger ${className}`}
style={style}
onClick={() => setOpen(v => !v)}
disabled={disabled}
onClick={() => { if (!disabled) setOpen(v => !v); }}
aria-haspopup="listbox"
aria-expanded={open}
>
+19 -3
View File
@@ -1,18 +1,34 @@
import React from 'react';
import { WifiOff, RefreshCw } from 'lucide-react';
import { WifiOff, RefreshCw, Settings } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
interface Props {
onRetry: () => void;
isChecking: boolean;
showSettingsLink?: boolean;
serverName?: string;
}
export default function OfflineBanner({ onRetry, isChecking }: Props) {
export default function OfflineBanner({ onRetry, isChecking, showSettingsLink, serverName }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const message = showSettingsLink
? t('connection.offlineNoCacheBanner', { server: serverName })
: t('connection.offlineModeBanner');
return (
<div className="offline-banner">
<WifiOff size={14} />
<span>{t('connection.offlineModeBanner')}</span>
<span>{message}</span>
{showSettingsLink && (
<button
className="offline-banner-retry"
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
>
<Settings size={12} />
{t('connection.serverSettings')}
</button>
)}
<button
className="offline-banner-retry"
onClick={onRetry}
+5 -5
View File
@@ -1,7 +1,8 @@
import React, { useState, useRef, useMemo } from 'react';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { useCachedUrl } from './CachedImage';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
@@ -655,10 +656,9 @@ export default function QueuePanel() {
onClose={() => setSaveModalOpen(false)}
onSave={async (name) => {
try {
await createPlaylist(name, queue.map(t => t.id));
const playlists = await getPlaylists();
const created = playlists.find(p => p.name === name);
if (created) setActivePlaylist({ id: created.id, name: created.name });
const createPlaylist = usePlaylistStore.getState().createPlaylist;
const pl = await createPlaylist(name, queue.map(t => t.id));
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
setSaveModalOpen(false);
} catch (e) {
console.error('Failed to save playlist', e);
+81 -13
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback } from 'react';
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
@@ -10,10 +10,12 @@ import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3,
PanelLeftClose, PanelLeft, HelpCircle, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2,
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2, ChevronRight, PlayCircle,
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
import { getPlaylists } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
// All configurable nav items — order and visibility controlled by sidebarStore.
// Exported so Settings can render the same item metadata.
@@ -56,6 +58,14 @@ export default function Sidebar({
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const sidebarItems = useSidebarStore(s => s.items);
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
const playlistsRaw = usePlaylistStore(s => s.playlists);
const playlistsLoading = usePlaylistStore(s => s.playlistsLoading);
const fetchPlaylists = usePlaylistStore(s => s.fetchPlaylists);
// Sort playlists alphabetically by name
const playlists = useMemo(() => {
return [...playlistsRaw].sort((a, b) => a.name.localeCompare(b.name));
}, [playlistsRaw]);
const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 });
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
@@ -113,6 +123,12 @@ export default function Sidebar({
setLibraryDropdownOpen(false);
};
// Fetch playlists when expanded
useEffect(() => {
if (!playlistsExpanded || !isLoggedIn) return;
fetchPlaylists();
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
// Resolve ordered, visible items per section from store config
const visibleLibrary = sidebarItems
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
@@ -216,17 +232,69 @@ export default function Sidebar({
<span className="nav-section-label">{t('sidebar.library')}</span>
))}
{visibleLibrary.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
item.to === '/playlists' ? (
// Playlists item with expand button
<div key={item.to} className="sidebar-playlists-wrapper">
<div className="sidebar-playlists-header-row">
<NavLink
to={item.to}
className={({ isActive }) => `nav-link sidebar-playlists-main-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
{!isCollapsed && (
<button
className={`sidebar-playlists-toggle ${playlistsExpanded ? 'expanded' : ''}`}
onClick={() => setPlaylistsExpanded(!playlistsExpanded)}
aria-expanded={playlistsExpanded}
aria-label={playlistsExpanded ? t('sidebar.collapsePlaylists') : t('sidebar.expandPlaylists')}
data-tooltip={playlistsExpanded ? t('sidebar.collapsePlaylists') : t('sidebar.expandPlaylists')}
>
<ChevronRight size={14} />
</button>
)}
</div>
{!isCollapsed && playlistsExpanded && (
<div className="sidebar-playlists-list">
{playlistsLoading ? (
<div className="sidebar-playlists-loading">
<div className="spinner" style={{ width: 14, height: 14 }} />
</div>
) : playlists.length === 0 ? (
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
) : (
playlists.map((pl: { id: string; name: string }) => (
<NavLink
key={pl.id}
to={`/playlists/${pl.id}`}
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? pl.name : undefined}
data-tooltip-pos="bottom"
>
<PlayCircle size={12} />
<span>{pl.name}</span>
</NavLink>
))
)}
</div>
)}
</div>
) : (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
)
))}
{/* Now Playing — fixed, always visible */}
+7
View File
@@ -123,6 +123,13 @@ export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ id: 'powerslave', label: 'Powerslave (inspired)', bg: '#F0DFB0', card: '#2A1808', accent: '#C8960C' },
],
},
{
group: 'Accessibility',
themes: [
{ id: 'vision-dark', label: 'Vision Dark', bg: '#0d0b12', card: '#16131e', accent: '#ffd700' },
{ id: 'vision-navy', label: 'Vision Navy', bg: '#0a1628', card: '#112038', accent: '#ffd700' },
],
},
{
group: 'Social Media',
themes: [
+49 -2
View File
@@ -26,6 +26,8 @@ export const deTranslation = {
folderBrowser: 'Ordner-Browser',
libraryScope: 'Bibliotheksumfang',
allLibraries: 'Alle Bibliotheken',
expandPlaylists: 'Playlists ausklappen',
collapsePlaylists: 'Playlists einklappen',
},
home: {
hero: 'Featured',
@@ -106,10 +108,14 @@ export const deTranslation = {
unfavoriteArtist: 'Künstler aus Favoriten entfernen',
unfavoriteAlbum: 'Album aus Favoriten entfernen',
removeFromQueue: 'Diesen Song entfernen',
removeFromPlaylist: 'Aus Playlist entfernen',
openAlbum: 'Album öffnen',
goToArtist: 'Zum Künstler',
download: 'Herunterladen (ZIP)',
addToPlaylist: 'Zur Playlist hinzufügen',
selectedPlaylists: '{{count}} Playlists ausgewählt',
selectedAlbums: '{{count}} Alben ausgewählt',
selectedArtists: '{{count}} Künstler ausgewählt',
songInfo: 'Song-Infos',
},
albumDetail: {
@@ -296,13 +302,18 @@ export const deTranslation = {
notFound: 'Keine Künstler gefunden.',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Alben',
selectionCount: '{{count}} ausgewählt',
select: 'Mehrfachauswahl',
startSelect: 'Mehrfachauswahl aktivieren',
cancelSelect: 'Abbrechen',
addToPlaylist: 'Zur Playlist hinzufügen',
},
login: {
subtitle: 'Dein Navidrome Desktop Player',
serverName: 'Server-Name (optional)',
serverNamePlaceholder: 'Mein Navidrome',
serverUrl: 'Server-URL',
serverUrlPlaceholder: '192.168.1.100:4533 oder music.example.com',
serverUrlPlaceholder: '192.168.1.100:4533 oder https://music.example.com',
username: 'Benutzername',
usernamePlaceholder: 'admin',
password: 'Passwort',
@@ -326,6 +337,7 @@ export const deTranslation = {
offlineTitle: 'Keine Serververbindung',
offlineSubtitle: '{{server}} ist nicht erreichbar. Prüfe Netzwerk oder Server.',
offlineModeBanner: 'Offline-Modus — Wiedergabe aus lokalem Cache',
offlineNoCacheBanner: 'Keine Serververbindung — {{server}} nicht erreichbar',
offlineLibraryTitle: 'Offline-Bibliothek',
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
offlineAlbumCount: '{{n}} Album',
@@ -335,6 +347,7 @@ export const deTranslation = {
offlineFilterPlaylists: 'Playlists',
offlineFilterArtists: 'Diskografien',
retry: 'Erneut versuchen',
serverSettings: 'Server-Einstellungen',
lastfmConnected: 'Last.fm verbunden als @{{user}}',
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
},
@@ -401,6 +414,7 @@ export const deTranslation = {
servers: 'Server',
serverName: 'Server-Name',
serverUrl: 'Server-URL',
serverUrlPlaceholder: '192.168.1.100:4533 oder https://music.example.com',
serverUsername: 'Benutzername',
serverPassword: 'Passwort',
addServer: 'Server hinzufügen',
@@ -485,6 +499,13 @@ export const deTranslation = {
hotCacheDebounceImmediate: 'Sofort',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Hot-Cache leeren',
audioOutputDevice: 'Audio-Ausgabegerät',
audioOutputDeviceDesc: 'Wähle das Audiogerät, über das Psysonic spielt. Änderungen werden sofort übernommen und starten den aktuellen Track neu.',
audioOutputDeviceDefault: 'Systemstandard',
audioOutputDeviceRefresh: 'Geräteliste aktualisieren',
audioOutputDeviceOsDefaultNow: 'aktuelle Systemausgabe',
audioOutputDeviceListError: 'Audiogeräteliste konnte nicht geladen werden.',
audioOutputDeviceNotInCurrentList: 'nicht in der aktuellen Liste',
hiResTitle: 'Native Hi-Res-Wiedergabe',
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren',
hiResDesc: "Standardmäßig wird auf 44,1 kHz begrenzt (maximale Stabilität). Nur aktivieren, wenn Hardware und Netzwerk zuverlässig hohe Abtastraten (88,2 kHz+) unterstützen.",
@@ -520,7 +541,6 @@ export const deTranslation = {
logout: 'Abmelden',
aboutTitle: 'Über Psysonic',
aboutDesc: 'Ein moderner Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Basiert auf Tauri v2 mit einer nativen Rust-Audio-Engine — schlank und schnell, aber vollgepackt mit Features: Wellenform-Seekbar, synchronisierte Lyrics, Last.fm-Integration, 10-Band-EQ, Crossfade, nahtlose Wiedergabe, Replay Gain, Genre-Browser und eine große Theme-Bibliothek.',
aboutFeatures: 'Multi-Server · Last.fm Scrobbling & Love · Vollbild Ambient Stage · Wellenform-Seek · Sync-Lyrics · 10-Band-EQ · Crossfade & Gapless · Replay Gain · Genres · 63 Themes · 5 Sprachen',
aboutLicense: 'Lizenz',
aboutLicenseText: 'GNU GPL v3 — kostenlos nutzbar, veränderbar und unter gleicher Lizenz weiterzugeben.',
aboutRepo: 'Quellcode auf GitHub',
@@ -921,6 +941,33 @@ export const deTranslation = {
coverUpdated: 'Cover aktualisiert',
metaSaved: 'Playlist aktualisiert',
downloadZip: 'Herunterladen (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} Songs zu {{playlist}} hinzugefügt',
addPartial: '{{added}} hinzugefügt, {{skipped}} übersprungen (Duplikate)',
addAllSkipped: 'Alle übersprungen ({{count}} Duplikate)',
addError: 'Fehler beim Hinzufügen von Songs',
createAndAddSuccess: 'Playlist "{{playlist}}" mit {{count}} Songs erstellt',
createError: 'Fehler beim Erstellen der Playlist',
deleteSuccess: '{{count}} Playlists gelöscht',
deleteFailed: 'Fehler beim Löschen von {{name}}',
deleteSelected: 'Ausgewählte löschen',
mergeSuccess: '{{count}} Songs in {{playlist}} zusammengeführt',
mergeNoNewSongs: 'Keine neuen Songs zum Hinzufügen',
mergeError: 'Fehler beim Zusammenführen von Playlists',
mergeInto: 'Zusammenführen in',
selectionCount: '{{count}} ausgewählt',
select: 'Auswählen',
startSelect: 'Auswahl aktivieren',
cancelSelect: 'Abbrechen',
loadingAlbums: '{{count}} Alben werden aufgelöst…',
loadingArtists: '{{count}} Künstler werden aufgelöst…',
myPlaylists: 'Meine Playlists',
noOtherPlaylists: 'Keine anderen Playlists verfügbar',
addToPlaylistSuccess: '{{count}} Songs zu {{playlist}} hinzugefügt',
addToPlaylistNoNew: 'Keine neuen Songs für {{playlist}}',
addToPlaylistError: 'Fehler beim Hinzufügen zur Playlist',
removeSuccess: 'Song aus Playlist entfernt',
removeError: 'Fehler beim Entfernen aus der Playlist',
},
mostPlayed: {
title: 'Meistgehört',
+49 -2
View File
@@ -27,6 +27,8 @@ export const enTranslation = {
folderBrowser: 'Folder Browser',
libraryScope: 'Library scope',
allLibraries: 'All libraries',
expandPlaylists: 'Expand playlists',
collapsePlaylists: 'Collapse playlists',
},
home: {
hero: 'Featured',
@@ -107,10 +109,14 @@ export const enTranslation = {
unfavoriteArtist: 'Remove Artist from Favorites',
unfavoriteAlbum: 'Remove Album from Favorites',
removeFromQueue: 'Remove from Queue',
removeFromPlaylist: 'Remove from Playlist',
openAlbum: 'Open Album',
goToArtist: 'Go to Artist',
download: 'Download (ZIP)',
addToPlaylist: 'Add to Playlist',
selectedPlaylists: '{{count}} playlists selected',
selectedAlbums: '{{count}} albums selected',
selectedArtists: '{{count}} artists selected',
songInfo: 'Song Info',
},
albumDetail: {
@@ -297,13 +303,18 @@ export const enTranslation = {
notFound: 'No artists found.',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Albums',
selectionCount: '{{count}} selected',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
addToPlaylist: 'Add to Playlist',
},
login: {
subtitle: 'Your Navidrome Desktop Player',
serverName: 'Server Name (optional)',
serverNamePlaceholder: 'My Navidrome',
serverUrl: 'Server URL',
serverUrlPlaceholder: '192.168.1.100:4533 or music.example.com',
serverUrlPlaceholder: '192.168.1.100:4533 or https://music.example.com',
username: 'Username',
usernamePlaceholder: 'admin',
password: 'Password',
@@ -327,6 +338,7 @@ export const enTranslation = {
offlineTitle: 'No server connection',
offlineSubtitle: 'Cannot reach {{server}}. Check your network or server.',
offlineModeBanner: 'Offline Mode — playing from local cache',
offlineNoCacheBanner: 'No server connection — cannot reach {{server}}',
offlineLibraryTitle: 'Offline Library',
offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".',
offlineAlbumCount: '{{n}} album',
@@ -336,6 +348,7 @@ export const enTranslation = {
offlineFilterPlaylists: 'Playlists',
offlineFilterArtists: 'Discographies',
retry: 'Retry',
serverSettings: 'Server Settings',
lastfmConnected: 'Last.fm connected as @{{user}}',
lastfmSessionInvalid: 'Session invalid — click to re-connect',
},
@@ -403,6 +416,7 @@ export const enTranslation = {
servers: 'Servers',
serverName: 'Server Name',
serverUrl: 'Server URL',
serverUrlPlaceholder: '192.168.1.100:4533 or https://music.example.com',
serverUsername: 'Username',
serverPassword: 'Password',
addServer: 'Add Server',
@@ -487,6 +501,13 @@ export const enTranslation = {
hotCacheDebounceImmediate: 'Immediate',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Clear hot cache',
audioOutputDevice: 'Audio Output Device',
audioOutputDeviceDesc: 'Select which audio device Psysonic plays through. Changes take effect immediately and restart the current track.',
audioOutputDeviceDefault: 'System Default',
audioOutputDeviceRefresh: 'Refresh device list',
audioOutputDeviceOsDefaultNow: 'current system output',
audioOutputDeviceListError: 'Could not load the audio device list.',
audioOutputDeviceNotInCurrentList: 'not in current list',
hiResTitle: 'Native Hi-Res Playback',
hiResEnabled: 'Enable native hi-res playback',
hiResDesc: "Forces 44.1 kHz output by default for maximum stability. Enable only if your hardware and network reliably support high sample rates (88.2 kHz+).",
@@ -522,7 +543,6 @@ export const enTranslation = {
logout: 'Logout',
aboutTitle: 'About Psysonic',
aboutDesc: 'A modern desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Built on Tauri v2 with a native Rust audio engine — lightweight and fast, yet packed with features: waveform seekbar, synchronized lyrics, Last.fm integration, 10-band EQ, crossfade, gapless playback, Replay Gain, genre browsing, and a large library of themes.',
aboutFeatures: 'Multi-server · Last.fm scrobbling & love · Fullscreen Ambient Stage · Waveform seek · Synced lyrics · 10-band EQ · Crossfade & Gapless · Replay Gain · Genres · 63 themes · 8 languages',
aboutLicense: 'License',
aboutLicenseText: 'GNU GPL v3 — free to use, modify, and distribute under the same license.',
aboutRepo: 'Source Code on GitHub',
@@ -923,6 +943,33 @@ export const enTranslation = {
coverUpdated: 'Cover updated',
metaSaved: 'Playlist updated',
downloadZip: 'Download (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} songs added to {{playlist}}',
addPartial: '{{added}} added, {{skipped}} skipped (duplicates)',
addAllSkipped: 'All skipped ({{count}} duplicates)',
addError: 'Error adding songs',
createAndAddSuccess: 'Playlist "{{playlist}}" created with {{count}} songs',
createError: 'Error creating playlist',
deleteSuccess: '{{count}} playlists deleted',
deleteFailed: 'Error deleting {{name}}',
deleteSelected: 'Delete selected',
mergeSuccess: '{{count}} songs merged into {{playlist}}',
mergeNoNewSongs: 'No new songs to add',
mergeError: 'Error merging playlists',
mergeInto: 'Merge into',
selectionCount: '{{count}} selected',
select: 'Select',
startSelect: 'Enable selection',
cancelSelect: 'Cancel',
loadingAlbums: 'Resolving {{count}} albums…',
loadingArtists: 'Resolving {{count}} artists…',
myPlaylists: 'My Playlists',
noOtherPlaylists: 'No other playlists available',
addToPlaylistSuccess: '{{count}} songs added to {{playlist}}',
addToPlaylistNoNew: 'No new songs to add to {{playlist}}',
addToPlaylistError: 'Error adding to playlist',
removeSuccess: 'Song removed from playlist',
removeError: 'Error removing song from playlist',
},
mostPlayed: {
title: 'Most Played',
+63 -2
View File
@@ -7,6 +7,7 @@ export const esTranslation = {
randomAlbums: 'Álbumes Aleatorios',
artists: 'Artistas',
randomMix: 'Mezcla Aleatoria',
randomPicker: 'Crear Mezcla',
favorites: 'Favoritos',
nowPlaying: 'Reproduciendo Ahora',
system: 'Sistema',
@@ -26,6 +27,8 @@ export const esTranslation = {
folderBrowser: 'Explorar Carpetas',
libraryScope: 'Ámbito de biblioteca',
allLibraries: 'Todas las bibliotecas',
expandPlaylists: 'Expandir listas',
collapsePlaylists: 'Colapsar listas',
},
home: {
hero: 'Destacado',
@@ -106,10 +109,14 @@ export const esTranslation = {
unfavoriteArtist: 'Quitar Artista de Favoritos',
unfavoriteAlbum: 'Quitar Álbum de Favoritos',
removeFromQueue: 'Quitar de la Cola',
removeFromPlaylist: 'Quitar de la Playlist',
openAlbum: 'Abrir Álbum',
goToArtist: 'Ir al Artista',
download: 'Descargar (ZIP)',
addToPlaylist: 'Agregar a Lista de Reproducción',
selectedPlaylists: '{{count}} listas seleccionadas',
selectedAlbums: '{{count}} álbumes seleccionados',
selectedArtists: '{{count}} artistas seleccionados',
songInfo: 'Información de la Canción',
},
albumDetail: {
@@ -204,6 +211,13 @@ export const esTranslation = {
removeSong: 'Quitar de favoritos',
stations: 'Estaciones de Radio',
},
randomLanding: {
title: 'Crear Mezcla',
mixByTracks: 'Mezcla por Canciones',
mixByTracksDesc: 'Selección aleatoria de canciones de toda tu biblioteca',
mixByAlbums: 'Mezcla por Álbumes',
mixByAlbumsDesc: 'Álbumes aleatorios para tu próximo descubrimiento',
},
randomAlbums: {
title: 'Álbumes Aleatorios',
refresh: 'Refrescar',
@@ -276,6 +290,7 @@ export const esTranslation = {
downloadZipFailed: 'Error al descargar {{name}}',
offlineQueuing: 'Encolando {{count}} álbum(es) para offline…',
offlineFailed: 'Error al agregar {{name}} offline',
addToPlaylist: 'Agregar a Lista de Reproducción',
},
artists: {
title: 'Artistas',
@@ -289,13 +304,18 @@ export const esTranslation = {
notFound: 'No se encontraron artistas.',
albumCount_one: '{{count}} Álbum',
albumCount_other: '{{count}} Álbumes',
selectionCount: '{{count}} seleccionados',
select: 'Selección múltiple',
startSelect: 'Activar selección múltiple',
cancelSelect: 'Cancelar',
addToPlaylist: 'Agregar a Lista',
},
login: {
subtitle: 'Tu Reproductor Navidrome para Escritorio',
serverName: 'Nombre del Servidor (opcional)',
serverNamePlaceholder: 'Mi Navidrome',
serverUrl: 'URL del Servidor',
serverUrlPlaceholder: '192.168.1.100:4533 o music.example.com',
serverUrlPlaceholder: '192.168.1.100:4533 o https://music.example.com',
username: 'Usuario',
usernamePlaceholder: 'admin',
password: 'Contraseña',
@@ -319,6 +339,7 @@ export const esTranslation = {
offlineTitle: 'Sin conexión al servidor',
offlineSubtitle: 'No se puede alcanzar {{server}}. Verifica tu red o servidor.',
offlineModeBanner: 'Modo Offline — reproduciendo desde caché local',
offlineNoCacheBanner: 'Sin conexión al servidor — no se puede acceder a {{server}}',
offlineLibraryTitle: 'Biblioteca Offline',
offlineLibraryEmpty: 'No hay álbumes en caché aún. Conéctate, abre un álbum y click en "Disponible offline".',
offlineAlbumCount: '{{n}} álbum',
@@ -328,6 +349,7 @@ export const esTranslation = {
offlineFilterPlaylists: 'Listas',
offlineFilterArtists: 'Discografías',
retry: 'Reintentar',
serverSettings: 'Configuración del servidor',
lastfmConnected: 'Last.fm conectado como @{{user}}',
lastfmSessionInvalid: 'Sesión inválida — click para reconectar',
},
@@ -395,6 +417,7 @@ export const esTranslation = {
servers: 'Servidores',
serverName: 'Nombre del Servidor',
serverUrl: 'URL del Servidor',
serverUrlPlaceholder: '192.168.1.100:4533 o https://music.example.com',
serverUsername: 'Usuario',
serverPassword: 'Contraseña',
addServer: 'Agregar Servidor',
@@ -479,6 +502,13 @@ export const esTranslation = {
hotCacheDebounceImmediate: 'Inmediato',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Limpiar caché activa',
audioOutputDevice: 'Dispositivo de salida de audio',
audioOutputDeviceDesc: 'Selecciona el dispositivo de audio que usa Psysonic. Los cambios surten efecto de inmediato y reinician la pista actual.',
audioOutputDeviceDefault: 'Predeterminado del sistema',
audioOutputDeviceRefresh: 'Actualizar lista de dispositivos',
audioOutputDeviceOsDefaultNow: 'salida actual del sistema',
audioOutputDeviceListError: 'No se pudo cargar la lista de dispositivos de audio.',
audioOutputDeviceNotInCurrentList: 'no está en la lista actual',
hiResTitle: 'Reproducción Nativa Hi-Res',
hiResEnabled: 'Habilitar reproducción nativa hi-res',
hiResDesc: "Fuerza 44.1 kHz de salida por defecto para máxima estabilidad. Habilita solo si tu hardware y red soportan confiablemente altas tasas de muestreo (88.2 kHz+).",
@@ -514,7 +544,6 @@ export const esTranslation = {
logout: 'Cerrar Sesión',
aboutTitle: 'Acerca de Psysonic',
aboutDesc: 'Un reproductor de música moderno para escritorio compatible con servidores Subsonic (Navidrome, Gonic y otros). Construido en Tauri v2 con motor de audio nativo en Rust — ligero y rápido, pero lleno de características: barra de progreso tipo onda, letras sincronizadas, integración Last.fm, ecualizador de 10 bandas, crossfade, reproducción gapless, Replay Gain, navegación por géneros, y una gran biblioteca de temas.',
aboutFeatures: 'Multi-servidor · Scrobbling Last.fm y favoritos · Escenario Ambiente Pantalla Completa · Barra de progreso onda · Letras sincronizadas · Ecualizador 10 bandas · Crossfade y Gapless · Replay Gain · Géneros · 63 temas · 8 idiomas',
aboutLicense: 'Licencia',
aboutLicenseText: 'GNU GPL v3 — libre para usar, modificar y distribuir bajo la misma licencia.',
aboutRepo: 'Código Fuente en GitHub',
@@ -580,6 +609,7 @@ export const esTranslation = {
shortcutSeekForward: 'Avanzar 10s',
shortcutSeekBackward: 'Retroceder 10s',
shortcutToggleQueue: 'Alternar cola',
shortcutOpenFolderBrowser: 'Abrir {{folderBrowser}}',
shortcutFullscreenPlayer: 'Reproductor pantalla completa',
shortcutNativeFullscreen: 'Pantalla completa nativa',
playbackTitle: 'Reproducción',
@@ -609,6 +639,10 @@ export const esTranslation = {
infiniteQueue: 'Cola Infinita',
infiniteQueueDesc: 'Agregar automáticamente pistas aleatorias cuando la cola termina',
experimental: 'Experimental',
fsPlayerSection: 'Reproductor Pantalla Completa',
fsShowArtistPortrait: 'Mostrar foto del artista',
fsShowArtistPortraitDesc: 'Muestra la foto del artista (o portada del álbum) en el lado derecho del reproductor pantalla completa.',
fsPortraitDim: 'Oscurecimiento de foto',
seekbarStyle: 'Estilo de Barra de Progreso',
seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor',
seekbarWaveform: 'Forma de Onda',
@@ -910,6 +944,33 @@ export const esTranslation = {
coverUpdated: 'Portada actualizada',
metaSaved: 'Lista actualizada',
downloadZip: 'Descargar (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} canciones agregadas a {{playlist}}',
addPartial: '{{added}} agregadas, {{skipped}} skippeadas por duplicadas',
addAllSkipped: 'Todas skippeadas ({{count}} duplicadas)',
addError: 'Error al agregar canciones',
createAndAddSuccess: 'Lista "{{playlist}}" creada con {{count}} canciones',
createError: 'Error al crear lista',
deleteSuccess: '{{count}} listas eliminadas',
deleteFailed: 'Error al eliminar {{name}}',
deleteSelected: 'Eliminar seleccionadas',
mergeSuccess: '{{count}} canciones unificadas en {{playlist}}',
mergeNoNewSongs: 'No hay canciones nuevas para agregar',
mergeError: 'Error al unificar listas',
mergeInto: 'Unificar en',
selectionCount: '{{count}} seleccionadas',
select: 'Seleccionar',
startSelect: 'Activar selección',
cancelSelect: 'Cancelar',
loadingAlbums: 'Resolviendo {{count}} álbumes…',
loadingArtists: 'Resolviendo {{count}} artistas…',
myPlaylists: 'Mis Listas',
noOtherPlaylists: 'No hay otras listas disponibles',
addToPlaylistSuccess: '{{count}} canciones agregadas a {{playlist}}',
addToPlaylistNoNew: 'No hay canciones nuevas para agregar a {{playlist}}',
addToPlaylistError: 'Error al agregar a la lista',
removeSuccess: 'Canción quitada de la playlist',
removeError: 'Error al quitar la canción de la playlist',
},
mostPlayed: {
title: 'Más Reproducidos',
+63 -13
View File
@@ -26,6 +26,8 @@ export const frTranslation = {
folderBrowser: 'Explorateur de dossiers',
libraryScope: 'Portée de la bibliothèque',
allLibraries: 'Toutes les bibliothèques',
expandPlaylists: 'Développer les playlists',
collapsePlaylists: 'Réduire les playlists',
},
home: {
hero: 'En vedette',
@@ -106,10 +108,14 @@ export const frTranslation = {
unfavoriteArtist: 'Retirer l\'artiste des favoris',
unfavoriteAlbum: 'Retirer l\'album des favoris',
removeFromQueue: 'Retirer de la file',
removeFromPlaylist: 'Retirer de la playlist',
openAlbum: 'Ouvrir l\'album',
goToArtist: 'Aller à l\'artiste',
download: 'Télécharger (ZIP)',
addToPlaylist: 'Ajouter à la playlist',
selectedPlaylists: '{{count}} playlists sélectionnées',
selectedAlbums: '{{count}} albums sélectionnés',
selectedArtists: '{{count}} artistes sélectionnés',
songInfo: 'Infos du morceau',
},
albumDetail: {
@@ -272,17 +278,17 @@ export const frTranslation = {
yearTo: 'À',
yearFilterClear: 'Effacer le filtre année',
yearFilterLabel: 'Année',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: 'Sélection multiple',
startSelect: 'Activer la sélection multiple',
cancelSelect: 'Annuler',
selectionCount: '{{count}} sélectionnés',
downloadZips: 'Télécharger les ZIPs',
addOffline: 'Ajouter hors ligne',
downloadingZip: 'Téléchargement {{current}}/{{total}} : {{name}}',
downloadZipDone: '{{count}} ZIP(s) téléchargé(s)',
downloadZipFailed: 'Échec du téléchargement de {{name}}',
offlineQueuing: 'Mise en file d\'attente de {{count}} album(s) hors ligne…',
offlineFailed: 'Échec de l\'ajout de {{name}} hors ligne',
},
artists: {
title: 'Artistes',
@@ -296,13 +302,18 @@ export const frTranslation = {
notFound: 'Aucun artiste trouvé.',
albumCount_one: '{{count}} album',
albumCount_other: '{{count}} albums',
selectionCount: '{{count}} sélectionnés',
select: 'Sélection multiple',
startSelect: 'Activer la sélection multiple',
cancelSelect: 'Annuler',
addToPlaylist: 'Ajouter à la playlist',
},
login: {
subtitle: 'Votre lecteur de bureau Navidrome',
serverName: 'Nom du serveur (facultatif)',
serverNamePlaceholder: 'Mon Navidrome',
serverUrl: 'URL du serveur',
serverUrlPlaceholder: '192.168.1.100:4533 ou music.exemple.com',
serverUrlPlaceholder: '192.168.1.100:4533 ou https://music.exemple.com',
username: 'Nom d\'utilisateur',
usernamePlaceholder: 'admin',
password: 'Mot de passe',
@@ -326,6 +337,7 @@ export const frTranslation = {
offlineTitle: 'Pas de connexion au serveur',
offlineSubtitle: 'Impossible d\'atteindre {{server}}. Vérifiez votre réseau ou le serveur.',
offlineModeBanner: 'Mode hors ligne — lecture depuis le cache local',
offlineNoCacheBanner: 'Pas de connexion au serveur — {{server}} inaccessible',
offlineLibraryTitle: 'Bibliothèque hors ligne',
offlineLibraryEmpty: 'Aucun album en cache. Connectez-vous, ouvrez un album et cliquez sur "Rendre disponible hors ligne".',
offlineAlbumCount: '{{n}} album',
@@ -335,6 +347,7 @@ export const frTranslation = {
offlineFilterPlaylists: 'Playlists',
offlineFilterArtists: 'Discographies',
retry: 'Réessayer',
serverSettings: 'Paramètres serveur',
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
},
@@ -401,6 +414,7 @@ export const frTranslation = {
servers: 'Serveurs',
serverName: 'Nom du serveur',
serverUrl: 'URL du serveur',
serverUrlPlaceholder: '192.168.1.100:4533 ou https://music.exemple.com',
serverUsername: 'Nom d\'utilisateur',
serverPassword: 'Mot de passe',
addServer: 'Ajouter un serveur',
@@ -485,6 +499,13 @@ export const frTranslation = {
hotCacheDebounceImmediate: 'Immédiat',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Vider le cache à chaud',
audioOutputDevice: 'Périphérique de sortie audio',
audioOutputDeviceDesc: 'Choisissez le périphérique audio utilisé par Psysonic. Les changements sont immédiats et relancent la piste en cours.',
audioOutputDeviceDefault: 'Défaut système',
audioOutputDeviceRefresh: 'Actualiser la liste',
audioOutputDeviceOsDefaultNow: 'sortie système actuelle',
audioOutputDeviceListError: 'Impossible de charger la liste des périphériques audio.',
audioOutputDeviceNotInCurrentList: 'absent de la liste actuelle',
hiResTitle: 'Lecture haute résolution native',
hiResEnabled: 'Activer la lecture haute résolution native',
hiResDesc: "Force une sortie à 44,1 kHz par défaut pour une stabilité maximale. N'activer que si le matériel et le réseau prennent en charge les hautes fréquences d'échantillonnage (88,2 kHz+).",
@@ -518,7 +539,6 @@ export const frTranslation = {
logout: 'Se déconnecter',
aboutTitle: 'À propos de Psysonic',
aboutDesc: 'Un lecteur de musique de bureau moderne pour les serveurs compatibles Subsonic (Navidrome, Gonic, et autres). Construit sur Tauri v2 avec un moteur audio Rust natif — léger et rapide, mais riche en fonctionnalités : barre waveform, paroles synchronisées, intégration Last.fm, égaliseur 10 bandes, crossfade, lecture sans blanc, Replay Gain, navigation par genre et une grande bibliothèque de thèmes.',
aboutFeatures: 'Multi-serveur · Scrobbling & love Last.fm · Ambient Stage plein écran · Waveform seek · Paroles synchro · EQ 10 bandes · Crossfade & Gapless · Replay Gain · Genres · 63 thèmes · 5 langues',
aboutLicense: 'Licence',
aboutLicenseText: 'GNU GPL v3 — libre d\'utilisation, de modification et de distribution sous la même licence.',
aboutRepo: 'Code source sur GitHub',
@@ -919,6 +939,33 @@ export const frTranslation = {
coverUpdated: 'Pochette mise à jour',
metaSaved: 'Playlist mise à jour',
downloadZip: 'Télécharger (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} morceaux ajoutés à {{playlist}}',
addPartial: '{{added}} ajoutés, {{skipped}} ignorés (doublons)',
addAllSkipped: 'Tous ignorés ({{count}} doublons)',
addError: 'Erreur lors de l\'ajout des morceaux',
createAndAddSuccess: 'Playlist "{{playlist}}" créée avec {{count}} morceaux',
createError: 'Erreur lors de la création de la playlist',
deleteSuccess: '{{count}} playlists supprimées',
deleteFailed: 'Erreur lors de la suppression de {{name}}',
deleteSelected: 'Supprimer la sélection',
mergeSuccess: '{{count}} morceaux fusionnés dans {{playlist}}',
mergeNoNewSongs: 'Aucun nouveau morceau à ajouter',
mergeError: 'Erreur lors de la fusion des playlists',
mergeInto: 'Fusionner dans',
selectionCount: '{{count}} sélectionnés',
select: 'Sélectionner',
startSelect: 'Activer la sélection',
cancelSelect: 'Annuler',
loadingAlbums: 'Résolution de {{count}} albums…',
loadingArtists: 'Résolution de {{count}} artistes…',
myPlaylists: 'Mes Playlists',
noOtherPlaylists: 'Aucune autre playlist disponible',
addToPlaylistSuccess: '{{count}} morceaux ajoutés à {{playlist}}',
addToPlaylistNoNew: 'Aucun nouveau morceau pour {{playlist}}',
addToPlaylistError: 'Erreur lors de l\'ajout à la playlist',
removeSuccess: 'Morceau retiré de la playlist',
removeError: 'Erreur lors du retrait de la playlist',
},
mostPlayed: {
title: 'Les plus joués',
@@ -929,6 +976,9 @@ export const frTranslation = {
sortLeast: 'Moins joués en premier',
loadMore: 'Charger plus d\'albums',
noData: 'Aucune donnée d\'écoute. Commencez à écouter !',
noArtists: 'Tous les artistes filtrés.',
filterCompilations: 'Masquer les artistes de compilations (Various Artists, Soundtracks, etc.)',
filterCompilationsShort: 'Masquer les compilations',
},
radio: {
title: 'Radio Internet',
+63 -13
View File
@@ -26,6 +26,8 @@ export const nbTranslation = {
folderBrowser: 'Mappeleser',
libraryScope: 'Biblioteksomfang',
allLibraries: 'Alle biblioteker',
expandPlaylists: 'Utvid spillelister',
collapsePlaylists: 'Skjul spillelister',
},
home: {
hero: 'Utvalgt',
@@ -106,10 +108,14 @@ export const nbTranslation = {
unfavoriteArtist: 'Fjern artist fra favoritter',
unfavoriteAlbum: 'Fjern album fra favoritter',
removeFromQueue: 'Fjern fra kø',
removeFromPlaylist: 'Fjern fra spilleliste',
openAlbum: 'Åpne album',
goToArtist: 'Gå til artist',
download: 'Last ned (ZIP)',
addToPlaylist: 'Legg til i spilleliste',
selectedPlaylists: '{{count}} spillelister valgt',
selectedAlbums: '{{count}} album valgt',
selectedArtists: '{{count}} artister valgt',
songInfo: 'Sanginfo',
},
albumDetail: {
@@ -272,17 +278,17 @@ export const nbTranslation = {
yearTo: 'Til',
yearFilterClear: 'Tøm år filteret',
yearFilterLabel: 'År',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: 'Multivalg',
startSelect: 'Aktiver multivalg',
cancelSelect: 'Avbryt',
selectionCount: '{{count}} valgt',
downloadZips: 'Last ned ZIPs',
addOffline: 'Legg til offline',
downloadingZip: 'Laster ned {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) lastet ned',
downloadZipFailed: 'Kunne ikke laste ned {{name}}',
offlineQueuing: 'Legger {{count}} album i kø for offline…',
offlineFailed: 'Kunne ikke legge til {{name}} offline',
},
artists: {
title: 'Artister',
@@ -296,13 +302,18 @@ export const nbTranslation = {
notFound: 'Ingen artister funnet.',
albumCount_one: '{{count}} album',
albumCount_other: '{{count}} album',
selectionCount: '{{count}} valgt',
select: 'Multivalg',
startSelect: 'Aktiver multivalg',
cancelSelect: 'Avbryt',
addToPlaylist: 'Legg til i spilleliste',
},
login: {
subtitle: 'Din Navidrome-mediaspiller',
serverName: 'Tjenernavn (valgfritt)',
serverNamePlaceholder: 'Mitt mediabibliotek',
serverUrl: 'Tjener-URL',
serverUrlPlaceholder: '192.168.1.100:4533 eller feks. musikk.eksempel.com',
serverUrlPlaceholder: '192.168.1.100:4533 eller https://musikk.eksempel.com',
username: 'Brukernavn',
usernamePlaceholder: 'admin',
password: 'Passord',
@@ -326,6 +337,7 @@ export const nbTranslation = {
offlineTitle: 'Ingen tjenertilkobling',
offlineSubtitle: 'Kan ikke nå {{server}}. Sjekk nettverket eller tjeneren din.',
offlineModeBanner: 'Frakoblet modus - spiller fra lokal hurtigbuffer',
offlineNoCacheBanner: 'Ingen servertilkobling — kan ikke nå {{server}}',
offlineLibraryTitle: 'Frakoblet bibliotek',
offlineLibraryEmpty: 'Ingen album bufret ennå. Kobl deg til nettverket, åpne et album og klikk "Gjør tilgjengelig frakoblet".',
offlineAlbumCount: '{{n}} album',
@@ -335,6 +347,7 @@ export const nbTranslation = {
offlineFilterPlaylists: 'Spillelister',
offlineFilterArtists: 'Diskografier',
retry: 'Prøv igjen',
serverSettings: 'Serverinnstillinger',
lastfmConnected: 'Last.fm tilkoblet som bruker @{{user}}',
lastfmSessionInvalid: 'Sesjonen er ugyldig - klikk her for å koble til på nytt',
},
@@ -401,6 +414,7 @@ export const nbTranslation = {
servers: 'Tjenere',
serverName: 'Tjenernavn',
serverUrl: 'Tjener-URL',
serverUrlPlaceholder: '192.168.1.100:4533 eller https://musikk.eksempel.com',
serverUsername: 'Brukernavn',
serverPassword: 'Passord',
addServer: 'Legg til tjener',
@@ -486,6 +500,13 @@ export const nbTranslation = {
hotCacheDebounceImmediate: 'Umiddelbart',
hotCacheDebounceSeconds: '{{n}} sek',
hotCacheClearBtn: 'Tøm varm buffer',
audioOutputDevice: 'Lydutgangsenhet',
audioOutputDeviceDesc: 'Velg hvilken lydenhet Psysonic spiller gjennom. Endringer trer i kraft umiddelbart og starter gjeldende spor på nytt.',
audioOutputDeviceDefault: 'Systemstandard',
audioOutputDeviceRefresh: 'Oppdater enhetsliste',
audioOutputDeviceOsDefaultNow: 'gjeldende systemutgang',
audioOutputDeviceListError: 'Kunne ikke laste listen over lydenheter.',
audioOutputDeviceNotInCurrentList: 'ikke i gjeldende liste',
hiResTitle: 'Innebygd hi-res-avspilling',
hiResEnabled: 'Aktiver innebygd hi-res-avspilling',
hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.",
@@ -517,7 +538,6 @@ export const nbTranslation = {
logout: 'Logg ut',
aboutTitle: 'Om Psysonic',
aboutDesc: 'En moderne musikkspiller for Subsonic-kompatible tjenere (Navidrome, Gonic og andre). Bygget på Tauri v2 med en innebygd Rust-lydmotor - lett og rask, men fullpakket med funksjoner: bølgeform-søkelinje, synkroniserte sangtekster, Last.fm-integrasjon, 10-bånds jevnstiller, crossfade, gapless-avspilling, Replay Gain, sjangerlesing og et stort bibliotek med temaer.',
aboutFeatures: 'Multi-tjener · Last.fm scrobbling og liker · Fullskjerm Ambient Stage · Bølgeformsøk · Synkroniserte tekster · 10-bånds jevnstiller · Crossfade og gapless · Replay Gain · Sjangre · 63 temaer · 6 språk',
aboutLicense: 'Lisens',
aboutLicenseText: 'GNU GPL v3 - gratis å bruke, endre og distribuere under samme lisens.',
aboutRepo: 'Kildekode på GitHub',
@@ -918,6 +938,33 @@ export const nbTranslation = {
coverUpdated: 'Omslag oppdatert',
metaSaved: 'Spillelisten er oppdatert',
downloadZip: 'Last ned (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} sanger lagt til i {{playlist}}',
addPartial: '{{added}} lagt til, {{skipped}} hoppet over (duplikater)',
addAllSkipped: 'Alle hoppet over ({{count}} duplikater)',
addError: 'Feil ved å legge til sanger',
createAndAddSuccess: 'Spilleliste "{{playlist}}" opprettet med {{count}} sanger',
createError: 'Feil ved oppretting av spilleliste',
deleteSuccess: '{{count}} spillelister slettet',
deleteFailed: 'Feil ved sletting av {{name}}',
deleteSelected: 'Slett valgte',
mergeSuccess: '{{count}} sanger slått sammen i {{playlist}}',
mergeNoNewSongs: 'Ingen nye sanger å legge til',
mergeError: 'Feil ved sammenslåing av spillelister',
mergeInto: 'Slå sammen i',
selectionCount: '{{count}} valgt',
select: 'Velg',
startSelect: 'Aktiver valg',
cancelSelect: 'Avbryt',
loadingAlbums: 'Løser {{count}} album…',
loadingArtists: 'Løser {{count}} artister…',
myPlaylists: 'Mine spillelister',
noOtherPlaylists: 'Ingen andre spillelister tilgjengelig',
addToPlaylistSuccess: '{{count}} sanger lagt til i {{playlist}}',
addToPlaylistNoNew: 'Ingen nye sanger for {{playlist}}',
addToPlaylistError: 'Feil ved å legge til i spilleliste',
removeSuccess: 'Sang fjernet fra spilleliste',
removeError: 'Feil ved fjerning fra spilleliste',
},
mostPlayed: {
title: 'Mest spilt',
@@ -928,6 +975,9 @@ export const nbTranslation = {
sortLeast: 'Minst spilt først',
loadMore: 'Last inn flere album',
noData: 'Ingen avspillingsdata ennå. Begynn å høre!',
noArtists: 'Alle artister filtrert bort.',
filterCompilations: 'Skjul kompilasjonsartister (Various Artists, Soundtracks, etc.)',
filterCompilationsShort: 'Skjul kompilasjoner',
},
radio: {
title: 'Internettradio',
+60 -13
View File
@@ -26,6 +26,8 @@ export const nlTranslation = {
folderBrowser: 'Mappenverkenner',
libraryScope: 'Bibliotheekbereik',
allLibraries: 'Alle bibliotheken',
expandPlaylists: 'Afspeellijsten uitklappen',
collapsePlaylists: 'Afspeellijsten inklappen',
},
home: {
hero: 'Uitgelicht',
@@ -110,6 +112,9 @@ export const nlTranslation = {
goToArtist: 'Naar artiest',
download: 'Downloaden (ZIP)',
addToPlaylist: 'Toevoegen aan playlist',
selectedPlaylists: '{{count}} playlists geselecteerd',
selectedAlbums: '{{count}} albums geselecteerd',
selectedArtists: '{{count}} artiesten geselecteerd',
songInfo: 'Nummerinfo',
},
albumDetail: {
@@ -272,17 +277,17 @@ export const nlTranslation = {
yearTo: 'Tot',
yearFilterClear: 'Jaarfilter wissen',
yearFilterLabel: 'Jaar',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: 'Meervoudige selectie',
startSelect: 'Meervoudige selectie inschakelen',
cancelSelect: 'Annuleren',
selectionCount: '{{count}} geselecteerd',
downloadZips: 'ZIPs downloaden',
addOffline: 'Offline toevoegen',
downloadingZip: 'Downloaden {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) gedownload',
downloadZipFailed: 'Downloaden van {{name}} mislukt',
offlineQueuing: '{{count}} album(s) in wachtrij voor offline…',
offlineFailed: 'Toevoegen van {{name}} offline mislukt',
},
artists: {
title: 'Artiesten',
@@ -296,13 +301,18 @@ export const nlTranslation = {
notFound: 'Geen artiesten gevonden.',
albumCount_one: '{{count}} album',
albumCount_other: '{{count}} albums',
selectionCount: '{{count}} geselecteerd',
select: 'Meervoudige selectie',
startSelect: 'Meervoudige selectie inschakelen',
cancelSelect: 'Annuleren',
addToPlaylist: 'Toevoegen aan playlist',
},
login: {
subtitle: 'Jouw Navidrome-desktopspeler',
serverName: 'Servernaam (optioneel)',
serverNamePlaceholder: 'Mijn Navidrome',
serverUrl: 'Server-URL',
serverUrlPlaceholder: '192.168.1.100:4533 of music.voorbeeld.nl',
serverUrlPlaceholder: '192.168.1.100:4533 of https://music.voorbeeld.nl',
username: 'Gebruikersnaam',
usernamePlaceholder: 'admin',
password: 'Wachtwoord',
@@ -326,6 +336,7 @@ export const nlTranslation = {
offlineTitle: 'Geen serververbinding',
offlineSubtitle: 'Kan {{server}} niet bereiken. Controleer je netwerk of server.',
offlineModeBanner: 'Offline modus — afspelen vanuit lokale cache',
offlineNoCacheBanner: 'Geen serververbinding — {{server}} niet bereikbaar',
offlineLibraryTitle: 'Offline bibliotheek',
offlineLibraryEmpty: 'Nog geen albums gecached. Ga online, open een album en klik op "Offline beschikbaar maken".',
offlineAlbumCount: '{{n}} album',
@@ -335,6 +346,7 @@ export const nlTranslation = {
offlineFilterPlaylists: 'Afspeellijsten',
offlineFilterArtists: 'Discografieën',
retry: 'Opnieuw proberen',
serverSettings: 'Serverinstellingen',
lastfmConnected: 'Last.fm verbonden als @{{user}}',
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
},
@@ -401,6 +413,7 @@ export const nlTranslation = {
servers: 'Servers',
serverName: 'Servernaam',
serverUrl: 'Server-URL',
serverUrlPlaceholder: '192.168.1.100:4533 of https://music.voorbeeld.nl',
serverUsername: 'Gebruikersnaam',
serverPassword: 'Wachtwoord',
addServer: 'Server toevoegen',
@@ -485,6 +498,13 @@ export const nlTranslation = {
hotCacheDebounceImmediate: 'Direct',
hotCacheDebounceSeconds: '{{n}} s',
hotCacheClearBtn: 'Warme cache wissen',
audioOutputDevice: 'Audio-uitvoerapparaat',
audioOutputDeviceDesc: 'Kies via welk audioapparaat Psysonic afspeelt. Wijzigingen worden direct toegepast en starten het huidige nummer opnieuw.',
audioOutputDeviceDefault: 'Systeemstandaard',
audioOutputDeviceRefresh: 'Apparatenlijst vernieuwen',
audioOutputDeviceOsDefaultNow: 'huidige systeemuitvoer',
audioOutputDeviceListError: 'De lijst met audio-apparaten kon niet worden geladen.',
audioOutputDeviceNotInCurrentList: 'staat niet in de huidige lijst',
hiResTitle: 'Natieve hi-res-weergave',
hiResEnabled: 'Natieve hi-res-weergave inschakelen',
hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.",
@@ -518,7 +538,6 @@ export const nlTranslation = {
logout: 'Uitloggen',
aboutTitle: 'Over Psysonic',
aboutDesc: 'Een moderne desktopmuziekspeeler voor Subsonic-compatibele servers (Navidrome, Gonic en anderen). Gebouwd op Tauri v2 met een native Rust audio-engine — licht en snel, maar boordevol functies: golfvorm-zoekbalk, gesynchroniseerde songteksten, Last.fm-integratie, 10-bands equalizer, crossfade, naadloos afspelen, Replay Gain, genres en een uitgebreide themabibliotheek.',
aboutFeatures: 'Multi-server · Last.fm scrobbling & love · Fullscreen Ambient Stage · Waveform seek · Syncteksten · 10-bands EQ · Crossfade & Gapless · Replay Gain · Genres · 63 thema\'s · 5 talen',
aboutLicense: 'Licentie',
aboutLicenseText: 'GNU GPL v3 — vrij te gebruiken, wijzigen en verspreiden onder dezelfde licentie.',
aboutRepo: 'Broncode op GitHub',
@@ -919,6 +938,31 @@ export const nlTranslation = {
coverUpdated: 'Omslag bijgewerkt',
metaSaved: 'Playlist bijgewerkt',
downloadZip: 'Downloaden (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} nummers toegevoegd aan {{playlist}}',
addPartial: '{{added}} toegevoegd, {{skipped}} overgeslagen (duplicaten)',
addAllSkipped: 'Alles overgeslagen ({{count}} duplicaten)',
addError: 'Fout bij toevoegen van nummers',
createAndAddSuccess: 'Playlist "{{playlist}}" aangemaakt met {{count}} nummers',
createError: 'Fout bij aanmaken van playlist',
deleteSuccess: '{{count}} playlists verwijderd',
deleteFailed: 'Fout bij verwijderen van {{name}}',
deleteSelected: 'Geselecteerde verwijderen',
mergeSuccess: '{{count}} nummers samengevoegd in {{playlist}}',
mergeNoNewSongs: 'Geen nieuwe nummers om toe te voegen',
mergeError: 'Fout bij samenvoegen van playlists',
mergeInto: 'Samenvoegen in',
selectionCount: '{{count}} geselecteerd',
select: 'Selecteren',
startSelect: 'Selectie inschakelen',
cancelSelect: 'Annuleren',
loadingAlbums: '{{count}} albums oplossen…',
loadingArtists: '{{count}} artiesten oplossen…',
myPlaylists: 'Mijn playlists',
noOtherPlaylists: 'Geen andere playlists beschikbaar',
addToPlaylistSuccess: '{{count}} nummers toegevoegd aan {{playlist}}',
addToPlaylistNoNew: 'Geen nieuwe nummers voor {{playlist}}',
addToPlaylistError: 'Fout bij toevoegen aan playlist',
},
mostPlayed: {
title: 'Meest gespeeld',
@@ -929,6 +973,9 @@ export const nlTranslation = {
sortLeast: 'Minst gespeeld eerst',
loadMore: 'Meer albums laden',
noData: 'Nog geen afspeelgegevens. Begin met luisteren!',
noArtists: 'Alle artiesten gefilterd.',
filterCompilations: 'Compilatie-artiesten verbergen (Various Artists, Soundtracks, etc.)',
filterCompilationsShort: 'Compilaties verbergen',
},
radio: {
title: 'Internetradio',
+64 -15
View File
@@ -27,6 +27,8 @@ export const ruTranslation = {
folderBrowser: 'Браузер папок',
libraryScope: 'Область медиатеки',
allLibraries: 'Все библиотеки',
expandPlaylists: 'Развернуть плейлисты',
collapsePlaylists: 'Свернуть плейлисты',
},
home: {
hero: 'Подборка',
@@ -107,10 +109,14 @@ export const ruTranslation = {
unfavoriteArtist: 'Убрать исполнителя из избранного',
unfavoriteAlbum: 'Убрать альбом из избранного',
removeFromQueue: 'Убрать из очереди',
removeFromPlaylist: 'Убрать из плейлиста',
openAlbum: 'Открыть альбом',
goToArtist: 'К исполнителю',
download: 'Скачать (ZIP)',
addToPlaylist: 'В плейлист',
selectedPlaylists: '{{count}} плейлистов выбрано',
selectedAlbums: '{{count}} альбомов выбрано',
selectedArtists: '{{count}} исполнителей выбрано',
songInfo: 'Сведения о треке',
},
albumDetail: {
@@ -281,17 +287,17 @@ export const ruTranslation = {
yearTo: 'По',
yearFilterClear: 'Сбросить год',
yearFilterLabel: 'Год',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: 'Множественный выбор',
startSelect: 'Включить множественный выбор',
cancelSelect: 'Отмена',
selectionCount: '{{count}} выбрано',
downloadZips: 'Скачать ZIP-архивы',
addOffline: 'Добавить офлайн',
downloadingZip: 'Загрузка {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP-архив(ов) загружено',
downloadZipFailed: 'Не удалось скачать {{name}}',
offlineQueuing: 'Добавление {{count}} альбом(ов) в офлайн…',
offlineFailed: 'Не удалось добавить {{name}} офлайн',
},
artists: {
title: 'Исполнители',
@@ -307,13 +313,18 @@ export const ruTranslation = {
albumCount_few: '{{count}} альбома',
albumCount_many: '{{count}} альбомов',
albumCount_other: '{{count}} альбомов',
selectionCount: '{{count}} выбрано',
select: 'Множественный выбор',
startSelect: 'Включить множественный выбор',
cancelSelect: 'Отмена',
addToPlaylist: 'В плейлист',
},
login: {
subtitle: 'Десктопный клиент для Navidrome',
serverName: 'Название сервера (необязательно)',
serverNamePlaceholder: 'Мой Navidrome',
serverUrl: 'Адрес сервера',
serverUrlPlaceholder: '192.168.1.100:4533 или music.example.com',
serverUrlPlaceholder: '192.168.1.100:4533 или https://music.example.com',
username: 'Логин',
usernamePlaceholder: 'admin',
password: 'Пароль',
@@ -337,6 +348,7 @@ export const ruTranslation = {
offlineTitle: 'Нет связи с сервером',
offlineSubtitle: 'Сервер {{server}} недоступен. Проверьте сеть и адрес.',
offlineModeBanner: 'Офлайн — воспроизведение из локального кэша',
offlineNoCacheBanner: 'Нет соединения с сервером — {{server}} недоступен',
offlineLibraryTitle: 'Офлайн-библиотека',
offlineLibraryEmpty:
'Пока ничего не сохранено. Подключитесь к сети, откройте альбом и нажмите «Сохранить офлайн».',
@@ -349,6 +361,7 @@ export const ruTranslation = {
offlineFilterPlaylists: 'Плейлисты',
offlineFilterArtists: 'Дискографии',
retry: 'Повторить',
serverSettings: 'Настройки сервера',
lastfmConnected: 'Last.fm: @{{user}}',
lastfmSessionInvalid: 'Сессия недействительна — подключите снова',
},
@@ -416,6 +429,7 @@ export const ruTranslation = {
servers: 'Серверы',
serverName: 'Название',
serverUrl: 'Адрес',
serverUrlPlaceholder: '192.168.1.100:4533 или https://music.example.com',
serverUsername: 'Логин',
serverPassword: 'Пароль',
addServer: 'Добавить сервер',
@@ -502,6 +516,13 @@ export const ruTranslation = {
hotCacheDebounceImmediate: 'Сразу',
hotCacheDebounceSeconds: '{{n}} с',
hotCacheClearBtn: 'Очистить горячий кэш',
audioOutputDevice: 'Устройство вывода звука',
audioOutputDeviceDesc: 'Выберите аудиоустройство для воспроизведения. Изменения применяются немедленно и перезапускают текущий трек.',
audioOutputDeviceDefault: 'Системное по умолчанию',
audioOutputDeviceRefresh: 'Обновить список устройств',
audioOutputDeviceOsDefaultNow: 'текущий системный вывод',
audioOutputDeviceListError: 'Не удалось загрузить список аудиоустройств.',
audioOutputDeviceNotInCurrentList: 'нет в текущем списке',
hiResTitle: 'Нативное воспроизведение Hi-Res',
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
@@ -542,8 +563,6 @@ export const ruTranslation = {
aboutTitle: 'О Psysonic',
aboutDesc:
'Современный десктопный плеер для серверов с протоколом Subsonic (Navidrome, Gonic и др.). На Tauri v2 и нативном аудиодвижке на Rust — лёгкий и быстрый: волновая шкала, синхронные тексты, Last.fm, 10-полосный EQ, кроссфейд, воспроизведение без пауз, Replay Gain, жанры и много тем оформления.',
aboutFeatures:
'Несколько серверов · Last.fm · полноэкранный режим · волна · тексты · 10 полос EQ · кроссфейд и воспроизведение без пауз · Replay Gain · жанры · десятки тем · несколько языков',
aboutLicense: 'Лицензия',
aboutLicenseText: 'GNU GPL v3 — свободное использование и изменение на тех же условиях.',
aboutRepo: 'Исходный код на GitHub',
@@ -978,6 +997,33 @@ export const ruTranslation = {
coverUpdated: 'Обложка обновлена',
metaSaved: 'Плейлист сохранён',
downloadZip: 'Скачать (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} треков добавлено в {{playlist}}',
addPartial: '{{added}} добавлено, {{skipped}} пропущено (дубликаты)',
addAllSkipped: 'Все пропущены ({{count}} дубликатов)',
addError: 'Ошибка при добавлении треков',
createAndAddSuccess: 'Плейлист "{{playlist}}" создан с {{count}} треками',
createError: 'Ошибка при создании плейлиста',
deleteSuccess: '{{count}} плейлистов удалено',
deleteFailed: 'Ошибка при удалении {{name}}',
deleteSelected: 'Удалить выбранные',
mergeSuccess: '{{count}} треков объединено в {{playlist}}',
mergeNoNewSongs: 'Нет новых треков для добавления',
mergeError: 'Ошибка при объединении плейлистов',
mergeInto: 'Объединить в',
selectionCount: '{{count}} выбрано',
select: 'Выбрать',
startSelect: 'Включить выбор',
cancelSelect: 'Отмена',
loadingAlbums: 'Загрузка {{count}} альбомов…',
loadingArtists: 'Загрузка {{count}} исполнителей…',
myPlaylists: 'Мои плейлисты',
noOtherPlaylists: 'Нет других доступных плейлистов',
addToPlaylistSuccess: '{{count}} треков добавлено в {{playlist}}',
addToPlaylistNoNew: 'Нет новых треков для {{playlist}}',
addToPlaylistError: 'Ошибка при добавлении в плейлист',
removeSuccess: 'Трек убран из плейлиста',
removeError: 'Ошибка при удалении из плейлиста',
},
mostPlayed: {
title: 'Популярное',
@@ -987,7 +1033,10 @@ export const ruTranslation = {
sortMost: 'Сначала популярные',
sortLeast: 'Сначала малоизвестные',
loadMore: 'Загрузить больше альбомов',
noData: 'Нет данных о прослушиваниях. Начните слушать!',
noData: 'Пока нет данных о прослушивании. Начните слушать!',
noArtists: 'Все исполнители отфильтрованы.',
filterCompilations: 'Скрыть исполнителей сборников (Various Artists, Soundtracks и др.)',
filterCompilationsShort: 'Скрыть сборники',
},
radio: {
title: 'Онлайн-радио',
+61 -14
View File
@@ -26,6 +26,8 @@ export const zhTranslation = {
folderBrowser: '文件夹浏览器',
libraryScope: '资料库范围',
allLibraries: '所有资料库',
expandPlaylists: '展开播放列表',
collapsePlaylists: '收起播放列表',
},
home: {
hero: '精选',
@@ -110,6 +112,9 @@ export const zhTranslation = {
goToArtist: '前往艺术家',
download: '下载 (ZIP)',
addToPlaylist: '添加到播放列表',
selectedPlaylists: '已选择 {{count}} 个播放列表',
selectedAlbums: '已选择 {{count}} 个专辑',
selectedArtists: '已选择 {{count}} 个艺术家',
songInfo: '歌曲信息',
},
albumDetail: {
@@ -272,17 +277,17 @@ export const zhTranslation = {
yearTo: '到',
yearFilterClear: '清除年份筛选',
yearFilterLabel: '年份',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: '多选',
startSelect: '启用多选',
cancelSelect: '取消',
selectionCount: '{{count}} 已选择',
downloadZips: '下载 ZIP',
addOffline: '添加离线',
downloadingZip: '正在下载 {{current}}/{{total}}: {{name}}',
downloadZipDone: '已下载 {{count}} ZIP',
downloadZipFailed: '下载 {{name}} 失败',
offlineQueuing: '正在将 {{count}} 张专辑加入离线队列…',
offlineFailed: '添加 {{name}} 离线失败',
},
artists: {
title: '艺术家',
@@ -296,13 +301,18 @@ export const zhTranslation = {
notFound: '未找到艺术家。',
albumCount_one: '{{count}} 张专辑',
albumCount_other: '{{count}} 张专辑',
selectionCount: '{{count}} 已选择',
select: '多选',
startSelect: '启用多选',
cancelSelect: '取消',
addToPlaylist: '添加到播放列表',
},
login: {
subtitle: '您的 Navidrome 桌面播放器',
serverName: '服务器名称(可选)',
serverNamePlaceholder: '我的 Navidrome',
serverUrl: '服务器地址',
serverUrlPlaceholder: '192.168.1.100:4533 或 music.example.com',
serverUrlPlaceholder: '192.168.1.100:4533 或 https://music.example.com',
username: '用户名',
usernamePlaceholder: 'admin',
password: '密码',
@@ -326,11 +336,13 @@ export const zhTranslation = {
offlineTitle: '无服务器连接',
offlineSubtitle: '无法连接到 {{server}}。请检查您的网络或服务器。',
offlineModeBanner: '离线模式 — 正在从本地缓存播放',
offlineNoCacheBanner: '无服务器连接 — 无法访问 {{server}}',
offlineLibraryTitle: '离线音乐库',
offlineLibraryEmpty: '尚未缓存任何专辑。请联网,打开专辑并点击"设为离线可用"。',
offlineAlbumCount_one: '{{n}} 张专辑',
offlineAlbumCount_plural: '{{n}} 张专辑',
retry: '重试',
serverSettings: '服务器设置',
lastfmConnected: 'Last.fm 已连接为 @{{user}}',
lastfmSessionInvalid: '会话无效 — 点击重新连接',
},
@@ -397,6 +409,7 @@ export const zhTranslation = {
servers: '服务器',
serverName: '服务器名称',
serverUrl: '服务器地址',
serverUrlPlaceholder: '192.168.1.100:4533 或 https://music.example.com',
serverUsername: '用户名',
serverPassword: '密码',
addServer: '添加服务器',
@@ -481,6 +494,13 @@ export const zhTranslation = {
hotCacheDebounceImmediate: '立即',
hotCacheDebounceSeconds: '{{n}} 秒',
hotCacheClearBtn: '清空热缓存',
audioOutputDevice: '音频输出设备',
audioOutputDeviceDesc: '选择 Psysonic 播放音频的设备。更改立即生效并重新开始当前曲目。',
audioOutputDeviceDefault: '系统默认',
audioOutputDeviceRefresh: '刷新设备列表',
audioOutputDeviceOsDefaultNow: '当前系统输出',
audioOutputDeviceListError: '无法加载音频设备列表。',
audioOutputDeviceNotInCurrentList: '不在当前列表中',
hiResTitle: '原生高清晰度播放',
hiResEnabled: '启用原生高清晰度播放',
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
@@ -514,7 +534,6 @@ export const zhTranslation = {
logout: '退出登录',
aboutTitle: '关于 Psysonic',
aboutDesc: '适用于 Subsonic 兼容服务器(Navidrome、Gonic 等)的现代桌面音乐播放器。基于 Tauri v2 和原生 Rust 音频引擎构建——轻量快速,功能丰富:波形进度条、同步歌词、Last.fm 集成、10 段均衡器、交叉淡入淡出、无缝播放、响度增益、流派浏览以及大量精美主题。',
aboutFeatures: '多服务器 · Last.fm 记录 & 喜爱 · 全屏环境舞台 · 波形跳转 · 同步歌词 · 10 段 EQ · 交叉淡入淡出 & 无缝播放 · 响度增益 · 流派 · 63 个主题 · 5 种语言',
aboutLicense: '许可证',
aboutLicenseText: 'GNU GPL v3 — 在相同许可证下可自由使用、修改和分发。',
aboutRepo: 'GitHub 上的源代码',
@@ -915,6 +934,31 @@ export const zhTranslation = {
coverUpdated: '封面已更新',
metaSaved: '播放列表已更新',
downloadZip: '下载 (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} 首歌曲已添加到 {{playlist}}',
addPartial: '{{added}} 首已添加,{{skipped}} 首跳过(重复)',
addAllSkipped: '全部跳过({{count}} 首重复)',
addError: '添加歌曲时出错',
createAndAddSuccess: '播放列表 "{{playlist}}" 已创建,包含 {{count}} 首歌曲',
createError: '创建播放列表时出错',
deleteSuccess: '{{count}} 个播放列表已删除',
deleteFailed: '删除 {{name}} 时出错',
deleteSelected: '删除所选',
mergeSuccess: '{{count}} 首歌曲已合并到 {{playlist}}',
mergeNoNewSongs: '没有新歌曲可添加',
mergeError: '合并播放列表时出错',
mergeInto: '合并到',
selectionCount: '{{count}} 已选择',
select: '选择',
startSelect: '启用选择',
cancelSelect: '取消',
loadingAlbums: '正在解析 {{count}} 张专辑…',
loadingArtists: '正在解析 {{count}} 位艺术家…',
myPlaylists: '我的播放列表',
noOtherPlaylists: '没有其他可用播放列表',
addToPlaylistSuccess: '{{count}} 首歌曲已添加到 {{playlist}}',
addToPlaylistNoNew: '{{playlist}} 没有新歌曲',
addToPlaylistError: '添加到播放列表时出错',
},
mostPlayed: {
title: '最常播放',
@@ -924,7 +968,10 @@ export const zhTranslation = {
sortMost: '最多播放在前',
sortLeast: '最少播放在前',
loadMore: '加载更多专辑',
noData: '暂无播放数据开始听吧!',
noData: '暂无播放数据开始听吧!',
noArtists: '所有艺术家已过滤。',
filterCompilations: '隐藏合辑艺术家(Various Artists、原声带等)',
filterCompilationsShort: '隐藏合辑',
},
radio: {
title: '网络电台',
+4 -1
View File
@@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { usePlayerStore } from '../store/playerStore';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import { X, CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
@@ -68,6 +69,7 @@ export default function Albums() {
};
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
@@ -284,6 +286,7 @@ export default function Albums() {
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
+131 -42
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { LayoutGrid, List, Images, ChevronDown } from 'lucide-react';
import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from '../components/CachedImage';
@@ -81,25 +81,55 @@ export default function Artists() {
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [visibleCount, setVisibleCount] = useState(50);
const showArtistImages = useAuthStore(s => s.showArtistImages);
const PAGE_SIZE = showArtistImages ? 50 : 100; // Menor con imágenes para reducir I/O
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [loadingMore, setLoadingMore] = useState(false);
const observerTarget = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const showArtistImages = useAuthStore(s => s.showArtistImages);
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
setSelectedIds(new Set());
};
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const clearSelection = () => {
setSelectionMode(false);
setSelectedIds(new Set());
};
const selectedArtists = artists.filter(a => selectedIds.has(a.id));
useEffect(() => {
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
}, [musicLibraryFilterVersion]);
const loadMore = useCallback(() => {
setVisibleCount(prev => prev + 50);
}, []);
if (loadingMore) return;
setLoadingMore(true);
setVisibleCount(prev => prev + PAGE_SIZE);
setTimeout(() => setLoadingMore(false), 100);
}, [loadingMore, PAGE_SIZE]);
// Reset infinite scroll when filters change
// Reset infinite scroll when filters or image setting change
useEffect(() => {
setVisibleCount(50);
}, [filter, letterFilter, viewMode]);
setVisibleCount(PAGE_SIZE);
}, [filter, letterFilter, viewMode, PAGE_SIZE]);
// Filter pipeline
let filtered = artists;
@@ -120,6 +150,16 @@ export default function Artists() {
const visible = filtered.slice(0, visibleCount);
const hasMore = visibleCount < filtered.length;
// Intersection Observer for infinite scroll (after hasMore declaration)
useEffect(() => {
const observer = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) loadMore(); },
{ rootMargin: '200px' }
);
if (observerTarget.current) observer.observe(observerTarget.current);
return () => observer.disconnect();
}, [loadMore, hasMore]);
// Group by first letter (for list view)
const groups: Record<string, SubsonicArtist[]> = {};
visible.forEach(a => {
@@ -134,7 +174,11 @@ export default function Artists() {
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('artists.title')}</h1>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('artists.selectionCount', { count: selectedIds.size })
: t('artists.title')}
</h1>
<input
className="input"
style={{ maxWidth: 220 }}
@@ -146,30 +190,43 @@ export default function Artists() {
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{!(selectionMode && selectedIds.size > 0) && (<>
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
</>
)}
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
<CheckSquare2 size={15} />
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
</button>
</div>
</div>
@@ -193,13 +250,33 @@ export default function Artists() {
{visible.map(artist => (
<div
key={artist.id}
className="artist-card"
onClick={() => navigate(`/artist/${artist.id}`)}
className={`artist-card${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}${selectionMode ? ' artist-card--selectable' : ''}`}
onClick={() => {
if (selectionMode) {
toggleSelect(artist.id);
} else {
navigate(`/artist/${artist.id}`);
}
}}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
if (selectionMode && selectedIds.size > 0) {
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
} else {
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}
}}
style={selectionMode && selectedIds.has(artist.id) ? {
outline: '2px solid var(--accent)',
outlineOffset: '2px',
borderRadius: 'var(--radius-md)'
} : {}}
>
{selectionMode && (
<div className={`artist-card-select-check${selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
{selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
</div>
)}
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'center' }}>
<div className="artist-card-name">{artist.name}</div>
@@ -221,13 +298,27 @@ export default function Artists() {
{groups[letter].map(artist => (
<button
key={artist.id}
className="artist-row"
onClick={() => navigate(`/artist/${artist.id}`)}
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
onClick={() => {
if (selectionMode) {
toggleSelect(artist.id);
} else {
navigate(`/artist/${artist.id}`);
}
}}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
if (selectionMode && selectedIds.size > 0) {
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
} else {
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}
}}
id={`artist-${artist.id}`}
style={selectionMode && selectedIds.has(artist.id) ? {
background: 'var(--accent-dim)',
color: 'var(--accent)'
} : {}}
>
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'left' }}>
@@ -245,10 +336,8 @@ export default function Artists() {
)}
{!loading && hasMore && (
<div style={{ marginTop: 32, marginBottom: '2rem', display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={loadMore}>
<ChevronDown size={16} /> {t('artists.loadMore')}
</button>
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
+208 -33
View File
@@ -66,12 +66,44 @@ function entryToTrack(e: SubsonicDirectoryEntry): Track {
};
}
function isFolderBrowserArrowKey(e: React.KeyboardEvent): boolean {
return (
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.code === 'ArrowUp' ||
e.code === 'ArrowDown' ||
e.code === 'ArrowLeft' ||
e.code === 'ArrowRight'
);
}
/** Modifiers from native event + getModifierState (WebKit/WebView can miss flags on the synthetic event). */
function folderBrowserHasKeyModifiers(e: React.KeyboardEvent): boolean {
const n = e.nativeEvent;
if (n.ctrlKey || n.altKey || n.shiftKey || n.metaKey) return true;
if (typeof n.getModifierState === 'function') {
return (
n.getModifierState('Control') ||
n.getModifierState('Alt') ||
n.getModifierState('Shift') ||
n.getModifierState('Meta') ||
n.getModifierState('OS')
);
}
return false;
}
export default function FolderBrowser() {
const { t } = useTranslation();
const [columns, setColumns] = useState<Column[]>([]);
const [columnFilters, setColumnFilters] = useState<Record<number, string>>({});
const [filterFocusCol, setFilterFocusCol] = useState<number | null>(null);
const [keyboardNavActive, setKeyboardNavActive] = useState(false);
const [playingPathIds, setPlayingPathIds] = useState<string[]>(persistedPlayingPathIds);
const wrapperRef = useRef<HTMLDivElement>(null);
const filterInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
const pendingNavColRef = useRef<number | null>(null);
const autoResolvedTrackRef = useRef<string | null>(null);
const prevTrackIdRef = useRef<string | null>(null);
@@ -83,6 +115,7 @@ export default function FolderBrowser() {
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const isContextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
@@ -179,6 +212,20 @@ export default function FolderBrowser() {
return () => window.removeEventListener('mousemove', onMouseMove);
}, [keyboardNavActive]);
useEffect(() => {
setColumnFilters(prev => {
const next: Record<number, string> = {};
let changed = false;
Object.entries(prev).forEach(([k, v]) => {
const idx = Number(k);
if (idx < columns.length) next[idx] = v;
else changed = true;
});
return changed ? next : prev;
});
setFilterFocusCol(prev => (prev !== null && prev >= columns.length ? null : prev));
}, [columns.length]);
useEffect(() => {
if (!isContextMenuOpen) setContextAnchorPos(null);
}, [isContextMenuOpen]);
@@ -218,18 +265,31 @@ export default function FolderBrowser() {
persistedPlayingPathIds = playingPathIds;
}, [playingPathIds]);
const preferredRowIndex = useCallback((col: Column): number => {
if (col.items.length === 0) return -1;
if (col.selectedId) {
const selectedIdx = col.items.findIndex(it => it.id === col.selectedId);
const filteredItemsByCol = useMemo(() => {
return columns.map((col, colIndex) => {
const query = (columnFilters[colIndex] ?? '').trim().toLowerCase();
if (!query) return col.items;
return col.items.filter(item => {
const haystack = `${item.title} ${item.artist ?? ''} ${item.album ?? ''}`.toLowerCase();
return haystack.includes(query);
});
});
}, [columns, columnFilters]);
const preferredRowIndex = useCallback((colIndex: number): number => {
const items = filteredItemsByCol[colIndex] ?? [];
if (items.length === 0) return -1;
const selectedId = columns[colIndex]?.selectedId;
if (selectedId) {
const selectedIdx = items.findIndex(it => it.id === selectedId);
if (selectedIdx >= 0) return selectedIdx;
}
return 0;
}, []);
}, [filteredItemsByCol, columns]);
const fallbackNavPos = useCallback((cols: Column[]): NavPos | null => {
for (let c = 0; c < cols.length; c++) {
const rowIndex = preferredRowIndex(cols[c]);
const rowIndex = preferredRowIndex(c);
if (rowIndex >= 0) return { colIndex: c, rowIndex };
}
return null;
@@ -239,15 +299,17 @@ export default function FolderBrowser() {
if (pendingNavColRef.current !== null) {
const targetColIndex = pendingNavColRef.current;
const targetCol = columns[targetColIndex];
if (targetCol && targetCol.items.length > 0 && !targetCol.loading && !targetCol.error) {
const rowIndex = preferredRowIndex(targetCol);
const targetItem = targetCol.items[rowIndex];
const targetItems = filteredItemsByCol[targetColIndex] ?? [];
if (targetCol && targetItems.length > 0 && !targetCol.loading && !targetCol.error) {
const rowIndex = preferredRowIndex(targetColIndex);
const safeRowIndex = Math.min(Math.max(0, rowIndex), targetItems.length - 1);
const targetItem = targetItems[safeRowIndex];
setColumns(prev =>
prev.map((c, i) => (i === targetColIndex ? { ...c, selectedId: targetItem.id } : c)),
);
setKeyboardPos({
colIndex: targetColIndex,
rowIndex,
rowIndex: safeRowIndex,
});
pendingNavColRef.current = null;
return;
@@ -258,15 +320,31 @@ export default function FolderBrowser() {
if (!prev) return fallbackNavPos(columns);
if (prev.colIndex >= columns.length) return fallbackNavPos(columns);
const col = columns[prev.colIndex];
if (col.loading || col.error || col.items.length === 0) return fallbackNavPos(columns);
if (prev.rowIndex >= col.items.length) {
return { colIndex: prev.colIndex, rowIndex: col.items.length - 1 };
const visibleItems = filteredItemsByCol[prev.colIndex] ?? [];
if (col.loading || col.error || visibleItems.length === 0) return fallbackNavPos(columns);
if (prev.rowIndex >= visibleItems.length) {
return { colIndex: prev.colIndex, rowIndex: visibleItems.length - 1 };
}
return prev;
});
}, [columns, fallbackNavPos, preferredRowIndex]);
}, [columns, fallbackNavPos, preferredRowIndex, filteredItemsByCol]);
const clearFiltersRightOf = useCallback((colIndex: number) => {
setColumnFilters(prev => {
const next: Record<number, string> = {};
let changed = false;
Object.entries(prev).forEach(([k, v]) => {
const idx = Number(k);
if (idx <= colIndex) next[idx] = v;
else changed = true;
});
return changed ? next : prev;
});
setFilterFocusCol(prev => (prev !== null && prev > colIndex ? null : prev));
}, []);
const handleDirClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
clearFiltersRightOf(colIndex);
const nextKind: ColumnKind = colIndex === 0 ? 'indexes' : 'directory';
setColumns(prev => [
...prev.slice(0, colIndex + 1).map((c, i) =>
@@ -305,7 +383,7 @@ export default function FolderBrowser() {
return next;
});
});
}, []);
}, [clearFiltersRightOf]);
const handleFileClick = useCallback(
(colIndex: number, item: SubsonicDirectoryEntry) => {
@@ -317,18 +395,22 @@ export default function FolderBrowser() {
item.id,
];
setPlayingPathIds(path);
const col = columns[colIndex];
const queue = col.items.filter(it => !it.isDir).map(entryToTrack);
const visibleItems = filteredItemsByCol[colIndex] ?? columns[colIndex]?.items ?? [];
const queue = visibleItems.filter(it => !it.isDir).map(entryToTrack);
playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]);
},
[columns, playTrack],
[columns, filteredItemsByCol, playTrack],
);
const setSelectedInColumn = useCallback((colIndex: number, itemId: string) => {
setColumns(prev =>
prev.map((c, i) => (i === colIndex ? { ...c, selectedId: itemId } : c)),
);
}, []);
setColumns(prev => {
const prevSelectedId = prev[colIndex]?.selectedId ?? null;
if (prevSelectedId !== itemId) {
clearFiltersRightOf(colIndex);
}
return prev.map((c, i) => (i === colIndex ? { ...c, selectedId: itemId } : c));
});
}, [clearFiltersRightOf]);
const clearSelectedInColumn = useCallback((colIndex: number) => {
setColumns(prev =>
@@ -336,6 +418,7 @@ export default function FolderBrowser() {
);
}, []);
const handleActivate = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
if (item.isDir) {
handleDirClick(colIndex, item);
@@ -376,14 +459,34 @@ export default function FolderBrowser() {
const onColumnsKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (isContextMenuOpen) return;
const target = e.target as HTMLElement;
const inFilterInput =
target instanceof HTMLInputElement && target.dataset.folderFilterInput === 'true';
if (inFilterInput) return;
const key = e.key;
if (e.ctrlKey && e.code === 'KeyF') {
e.preventDefault();
const current = keyboardPos ?? fallbackNavPos(columns);
if (!current) return;
const colIndex = current.colIndex;
setFilterFocusCol(colIndex);
requestAnimationFrame(() => {
const input = filterInputRefs.current[colIndex];
if (!input) return;
input.focus();
input.select();
});
return;
}
if (isFolderBrowserArrowKey(e) && folderBrowserHasKeyModifiers(e)) return;
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return;
setKeyboardNavActive(true);
const current = keyboardPos ?? fallbackNavPos(columns);
if (!current) return;
const col = columns[current.colIndex];
const item = col?.items[current.rowIndex];
const visibleItems = filteredItemsByCol[current.colIndex] ?? [];
const item = visibleItems[current.rowIndex];
if (!col || !item) return;
e.preventDefault();
@@ -403,17 +506,28 @@ export default function FolderBrowser() {
if (key === 'ArrowUp') {
if (current.rowIndex > 0) {
const nextRowIndex = current.rowIndex - 1;
const nextItem = col.items[nextRowIndex];
const nextItem = visibleItems[nextRowIndex];
setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex });
if (nextItem.isDir) handleDirClick(current.colIndex, nextItem);
else setSelectedInColumn(current.colIndex, nextItem.id);
} else if (
current.rowIndex === 0 &&
(filterFocusCol === current.colIndex || !!columnFilters[current.colIndex])
) {
setFilterFocusCol(current.colIndex);
requestAnimationFrame(() => {
const input = filterInputRefs.current[current.colIndex];
if (!input) return;
input.focus();
input.select();
});
}
return;
}
if (key === 'ArrowDown') {
if (current.rowIndex < col.items.length - 1) {
if (current.rowIndex < visibleItems.length - 1) {
const nextRowIndex = current.rowIndex + 1;
const nextItem = col.items[nextRowIndex];
const nextItem = visibleItems[nextRowIndex];
setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex });
if (nextItem.isDir) handleDirClick(current.colIndex, nextItem);
else setSelectedInColumn(current.colIndex, nextItem.id);
@@ -424,7 +538,8 @@ export default function FolderBrowser() {
if (current.colIndex > 0) {
clearSelectedInColumn(current.colIndex);
const nextColIndex = current.colIndex - 1;
const rowIndex = preferredRowIndex(columns[nextColIndex]);
clearFiltersRightOf(nextColIndex);
const rowIndex = preferredRowIndex(nextColIndex);
if (rowIndex >= 0) setKeyboardPos({ colIndex: nextColIndex, rowIndex });
}
return;
@@ -432,9 +547,10 @@ export default function FolderBrowser() {
if (key === 'ArrowRight') {
const nextColIndex = current.colIndex + 1;
if (nextColIndex < columns.length) {
const rowIndex = preferredRowIndex(columns[nextColIndex]);
const nextVisibleItems = filteredItemsByCol[nextColIndex] ?? [];
const rowIndex = Math.min(preferredRowIndex(nextColIndex), nextVisibleItems.length - 1);
if (rowIndex >= 0) {
const nextItem = columns[nextColIndex].items[rowIndex];
const nextItem = nextVisibleItems[rowIndex];
setSelectedInColumn(nextColIndex, nextItem.id);
setKeyboardPos({ colIndex: nextColIndex, rowIndex });
return;
@@ -444,9 +560,16 @@ export default function FolderBrowser() {
return;
}
if (key === 'Enter') {
if (e.shiftKey && !item.isDir) {
const toAppend = (filteredItemsByCol[current.colIndex] ?? [])
.filter(it => !it.isDir)
.map(entryToTrack);
if (toAppend.length > 0) enqueue(toAppend);
return;
}
handleActivate(current.colIndex, item);
}
}, [keyboardPos, fallbackNavPos, columns, preferredRowIndex, handleActivate, handleDirClick, setSelectedInColumn, clearSelectedInColumn, openContextMenuForEntry, isContextMenuOpen]);
}, [keyboardPos, fallbackNavPos, columns, preferredRowIndex, handleActivate, handleDirClick, setSelectedInColumn, clearSelectedInColumn, openContextMenuForEntry, isContextMenuOpen, filteredItemsByCol, filterFocusCol, columnFilters, enqueue, clearFiltersRightOf]);
const onRowContextMenu = useCallback(
(e: React.MouseEvent, colIndex: number, rowIndex: number, col: Column, item: SubsonicDirectoryEntry) => {
@@ -602,6 +725,55 @@ export default function FolderBrowser() {
className={`folder-col${isColumnCompact(col, colIndex) ? ' folder-col--compact' : ''}`}
data-folder-col-index={colIndex}
>
{(filterFocusCol === colIndex || !!columnFilters[colIndex]) && (
<div className="folder-col-filter">
<input
ref={el => { filterInputRefs.current[colIndex] = el; }}
data-folder-filter-input="true"
className="folder-col-filter-input"
value={columnFilters[colIndex] ?? ''}
placeholder={t('playlists.searchPlaceholder')}
onFocus={() => setFilterFocusCol(colIndex)}
onBlur={() => {
if (!(columnFilters[colIndex] ?? '').trim()) {
setFilterFocusCol(prev => (prev === colIndex ? null : prev));
}
}}
onKeyDown={e => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setColumnFilters(prev => ({ ...prev, [colIndex]: '' }));
setFilterFocusCol(null);
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
return;
}
if (e.key === 'ArrowDown' && !folderBrowserHasKeyModifiers(e)) {
e.preventDefault();
e.stopPropagation();
const rowIndex = preferredRowIndex(colIndex);
if (rowIndex >= 0) {
const nextItem = (filteredItemsByCol[colIndex] ?? [])[rowIndex];
if (nextItem) {
if (nextItem.isDir) handleDirClick(colIndex, nextItem);
else setSelectedInColumn(colIndex, nextItem.id);
}
setKeyboardPos({ colIndex, rowIndex });
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
}
}
}}
onChange={e => {
const value = e.target.value;
setColumnFilters(prev => ({ ...prev, [colIndex]: value }));
setKeyboardPos(prev => {
if (!prev || prev.colIndex !== colIndex) return prev;
return { colIndex, rowIndex: 0 };
});
}}
/>
</div>
)}
{col.loading ? (
<div className="folder-col-status">
<div className="spinner" style={{ width: 20, height: 20 }} />
@@ -610,12 +782,11 @@ export default function FolderBrowser() {
<div className="folder-col-status folder-col-error">
{t('folderBrowser.error')}
</div>
) : col.items.length === 0 ? (
) : (filteredItemsByCol[colIndex]?.length ?? 0) === 0 ? (
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
) : (
col.items.map(item => {
(filteredItemsByCol[colIndex] ?? []).map((item, rowIndex) => {
const isSelected = col.selectedId === item.id;
const rowIndex = col.items.findIndex(it => it.id === item.id);
const isContextRow =
contextAnchorPos?.colIndex === colIndex && contextAnchorPos.rowIndex === rowIndex;
const isKeyboardRow =
@@ -636,6 +807,10 @@ export default function FolderBrowser() {
if (item.isDir) handleDirClick(colIndex, item);
else handleFileClick(colIndex, item);
}}
onKeyDown={e => {
if (!isFolderBrowserArrowKey(e) || folderBrowserHasKeyModifiers(e)) return;
e.preventDefault();
}}
onContextMenu={e => {
setKeyboardPos({ colIndex, rowIndex });
onRowContextMenu(e, colIndex, rowIndex, col, item);
+4 -1
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { usePlayerStore } from '../store/playerStore';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
@@ -50,6 +51,7 @@ export default function NewReleases() {
}, []);
const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
@@ -183,6 +185,7 @@ export default function NewReleases() {
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
+10 -2
View File
@@ -57,6 +57,7 @@ const PL_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
@@ -233,6 +234,8 @@ export default function PlaylistDetail() {
}, [contextMenuOpen]);
// ── Load ─────────────────────────────────────────────────────
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
useEffect(() => {
if (!id) return;
setLoading(true);
@@ -252,7 +255,7 @@ export default function PlaylistDetail() {
})
.catch(() => {})
.finally(() => setLoading(false));
}, [id]);
}, [id, lastModified]);
// ── Suggestions ───────────────────────────────────────────────
const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => {
@@ -1018,7 +1021,7 @@ export default function PlaylistDetail() {
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song', undefined, id, realIdx);
}}
>
{visibleCols.map(colDef => {
@@ -1040,6 +1043,11 @@ export default function PlaylistDetail() {
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
</div>
);
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album}</span>
</div>
);
case 'favorite': return (
<div key="favorite" className="track-star-cell">
<button className="btn btn-ghost track-star-btn" onClick={e => handleToggleStar(song, e)} style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}>
+165 -52
View File
@@ -1,12 +1,13 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { ListMusic, Play, Plus, Trash2, X } from 'lucide-react';
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { ListMusic, Play, Plus, Trash2, X, CheckSquare2, Check } from 'lucide-react';
import { getPlaylists, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore';
import CachedImage from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import { showToast } from '../utils/toast';
function formatDuration(seconds: number): string {
return formatHumanHoursMinutes(seconds);
@@ -16,10 +17,13 @@ export default function Playlists() {
const { t } = useTranslation();
const navigate = useNavigate();
const playTrack = usePlayerStore(s => s.playTrack);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const removeId = usePlaylistStore((s) => s.removeId);
const playlists = usePlaylistStore((s) => s.playlists);
const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);
const playlistsLoading = usePlaylistStore((s) => s.playlistsLoading);
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
@@ -27,24 +31,45 @@ export default function Playlists() {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
getPlaylists()
.then(setPlaylists)
.catch(() => {})
.finally(() => setLoading(false));
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
setSelectedIds(new Set());
};
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const clearSelection = () => {
setSelectionMode(false);
setSelectedIds(new Set());
};
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
useEffect(() => {
fetchPlaylists().finally(() => setLoading(false));
}, [fetchPlaylists]);
useEffect(() => {
if (creating) nameInputRef.current?.focus();
}, [creating]);
const createPlaylist = usePlaylistStore(s => s.createPlaylist);
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
try {
await createPlaylist(name);
const updated = await getPlaylists();
setPlaylists(updated);
} catch {}
await createPlaylist(name);
// Refresh playlists from API to get the new one
await fetchPlaylists();
setCreating(false);
setNewName('');
};
@@ -73,11 +98,67 @@ export default function Playlists() {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
setPlaylists((prev) => prev.filter((p) => p.id !== pl.id));
} catch {}
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => p.id !== pl.id),
}));
showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info');
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
setDeleteConfirmId(null);
};
const handleDeleteSelected = async () => {
if (selectedPlaylists.length === 0) return;
let deleted = 0;
for (const pl of selectedPlaylists) {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
deleted++;
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
}
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => !selectedIds.has(p.id)),
}));
clearSelection();
if (deleted > 0) {
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
}
};
const handleMergeSelected = async (targetPlaylist: SubsonicPlaylist) => {
if (selectedPlaylists.length === 0) return;
try {
const { songs: targetSongs } = await getPlaylist(targetPlaylist.id);
const targetIds = new Set(targetSongs.map(s => s.id));
let totalAdded = 0;
for (const pl of selectedPlaylists) {
if (pl.id === targetPlaylist.id) continue;
const { songs } = await getPlaylist(pl.id);
const newSongs = songs.filter(s => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach(s => targetIds.add(s.id));
totalAdded += newSongs.length;
}
}
if (totalAdded > 0) {
await updatePlaylist(targetPlaylist.id, Array.from(targetIds));
touchPlaylist(targetPlaylist.id);
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetPlaylist.name }), 3000, 'info');
} else {
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
}
clearSelection();
} catch {
showToast(t('playlists.mergeError'), 4000, 'error');
}
};
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
@@ -91,34 +172,51 @@ export default function Playlists() {
{/* ── Header row ── */}
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('playlists.title')}</h1>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('playlists.selectionCount', { count: selectedIds.size })
: t('playlists.title')}
</h1>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{creating ? (
<>
<input
ref={nameInputRef}
className="input"
style={{ width: 220 }}
placeholder={t('playlists.createName')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="btn btn-primary" onClick={handleCreate}>
{t('playlists.create')}
</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
{t('playlists.cancel')}
</button>
{!(selectionMode && selectedIds.size > 0) && (<>
{creating ? (
<>
<input
ref={nameInputRef}
className="input"
style={{ width: 220 }}
placeholder={t('playlists.createName')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="btn btn-primary" onClick={handleCreate}>
{t('playlists.create')}
</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
{t('playlists.cancel')}
</button>
</>
) : (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Plus size={15} /> {t('playlists.newPlaylist')}
</button>
)}
</>
) : (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Plus size={15} /> {t('playlists.newPlaylist')}
</button>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
</button>
</div>
</div>
@@ -130,10 +228,34 @@ export default function Playlists() {
{playlists.map((pl) => (
<div
key={pl.id}
className="album-card"
onClick={() => navigate(`/playlists/${pl.id}`)}
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' selected' : ''}`}
onClick={() => {
if (selectionMode) {
toggleSelect(pl.id);
} else {
navigate(`/playlists/${pl.id}`);
}
}}
onContextMenu={(e) => {
e.preventDefault();
if (selectionMode && selectedIds.size > 0) {
openContextMenu(e.clientX, e.clientY, selectedPlaylists, 'multi-playlist');
} else {
openContextMenu(e.clientX, e.clientY, pl, 'playlist');
}
}}
onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }}
style={selectionMode && selectedIds.has(pl.id) ? {
outline: '2px solid var(--accent)',
outlineOffset: '2px',
borderRadius: 'var(--radius-md)'
} : {}}
>
{selectionMode && (
<div className={`album-card-select-check${selectedIds.has(pl.id) ? ' album-card-select-check--on' : ''}`}>
{selectedIds.has(pl.id) && <Check size={14} strokeWidth={3} />}
</div>
)}
{/* Cover area — server collage or fallback icon */}
<div className="album-card-cover">
{pl.coverArt ? (
@@ -163,15 +285,6 @@ export default function Playlists() {
</button>
</div>
{/* Delete button — top-right corner */}
<button
className={`playlist-card-delete ${deleteConfirmId === pl.id ? 'playlist-card-delete--confirm' : ''}`}
onClick={(e) => handleDelete(e, pl)}
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('playlists.deletePlaylist')}
data-tooltip-pos="bottom"
>
{deleteConfirmId === pl.id ? <Trash2 size={12} /> : <X size={12} />}
</button>
</div>
<div className="album-card-info">
+277 -39
View File
@@ -5,12 +5,13 @@ import { useNavigate, useLocation } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines
} from 'lucide-react';
import i18n from '../i18n';
import { exportBackup, importBackup } from '../utils/backup';
import { showToast } from '../utils/toast';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
import { useOfflineStore } from '../store/offlineStore';
@@ -25,7 +26,7 @@ import { SeekbarPreview } from '../components/WaveformSeek';
import { IS_LINUX } from '../utils/platform';
import { useThemeStore } from '../store/themeStore';
import { useFontStore, FontId } from '../store/fontStore';
import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore';
import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
import { useHomeStore, HomeSectionId } from '../store/homeStore';
@@ -73,6 +74,7 @@ const CONTRIBUTORS = [
'Click-to-seek in synced lyrics (PR #38)',
'Volume scroll wheel on volume slider (PR #38)',
'Lyrics line visual states: active / completed / upcoming (PR #38)',
'Queue auto-scroll to keep upcoming tracks in view; fixed re-renders from currentTime in QueuePanel (PR #115)',
],
},
{
@@ -106,6 +108,12 @@ const CONTRIBUTORS = [
'Hot playback cache — eviction budgeting, grace period, and live Audio settings readout (PR #153)',
'Folder Browser: keyboard navigation, context menus, now-playing path emphasis, and adaptive column layout (PR #158)',
'Infinite queue: artist-driven candidates via Top Songs + Similar Songs with random fallback (PR #163)',
'Folder Browser: per-column filter with keyboard flow and Shift+Enter queue append (PR #165)',
'Keybindings: modifier + key chords for in-app shortcuts; fixed seek ±10s units (PR #167)',
'Statistics: genre insights scoped to music library, cached Subsonic fetches, localized duration formatting (PR #144)',
'Audio output device picker: clearer ALSA labels, duplicate disambiguation, system-default mark, live refresh (PR #173)',
'Folder Browser: arrow navigation blocked when modifier keys are held (PR #174)',
'Linux audio output device picker: stable watcher (disable false enumeration-miss resets), canonicalize ALSA name drift, ghost entry for unlisted device (PR #176)',
],
},
{
@@ -120,6 +128,7 @@ const CONTRIBUTORS = [
'Russian translation refinements (PR #148)',
'Merge Random Mix & Albums into a single Build a Mix hub (PR #155)',
'Fullscreen player: software-rendering performance fixes + portrait toggle & dimming setting (PR #156)',
'Fullscreen player: stop mesh blob and portrait animations in no-compositing mode; remove seekbar box-shadow repaint (PR #175)',
],
},
{
@@ -128,6 +137,7 @@ const CONTRIBUTORS = [
contributions: [
'Spanish (es) translation — 964 strings (PR #159)',
'Column-header sorting for albums & playlists (PR #160)',
'Multi-select for albums, artists & playlists with bulk "Add to Playlist"; collapsible sidebar playlist section; infinite scroll on Artists page; "Remove from Playlist" in context menu (PR #168)',
],
},
{
@@ -167,7 +177,7 @@ function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile
</div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.serverUrl')}</label>
<input className="input" type="text" value={form.url} onChange={update('url')} placeholder="192.168.1.100:4533" autoComplete="off" />
<input className="input" type="text" value={form.url} onChange={update('url')} placeholder={t('settings.serverUrlPlaceholder')} autoComplete="off" />
</div>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<div className="form-group">
@@ -220,6 +230,125 @@ function snapHotCacheMb(v: number): number {
return Math.round((x - 32) / 32) * 32 + 32;
}
/** Makes raw ALSA device names more readable on Linux.
* Values are kept as-is (rodio needs the ALSA name); only the displayed label is cleaned.
* e.g. "sysdefault:CARD=U192k" "U192k"
* "hw:CARD=U192k,DEV=0" "U192k (hw · PCM 0)"
* "hdmi:CARD=NVidia,DEV=1" "NVidia (HDMI · DEV 1)" (same DEV as in ALSA string)
* "iec958:CARD=PCH,DEV=0" "PCH (S/PDIF)"
* Names without ALSA prefix (pipewire, pulse, default) are returned unchanged. */
function formatAudioDeviceLabel(name: string): string {
const cardMatch = name.match(/CARD=([^,]+)/);
if (!cardMatch) return name;
const card = cardMatch[1];
const devM = name.match(/DEV=(\d+)/);
const devNum = devM ? parseInt(devM[1], 10) : null;
const subM = name.match(/SUBDEV=(\d+)/);
const subNum = subM ? parseInt(subM[1], 10) : null;
if (name.startsWith('iec958:')) return `${card} (S/PDIF)`;
if (name.startsWith('hdmi:')) {
const d = devNum !== null ? devNum : 0;
return `${card} (HDMI · DEV ${d})`;
}
if (name.startsWith('sysdefault:')) {
if (devNum !== null && devNum > 0) return `${card} (default · PCM ${devNum})`;
return card;
}
if (name.startsWith('plughw:')) {
if (devNum !== null) {
const sub = subNum !== null ? ` · sub ${subNum}` : '';
return `${card} (plug · PCM ${devNum}${sub})`;
}
return card;
}
if (name.startsWith('hw:')) {
if (devNum !== null) {
const sub = subNum !== null ? ` · sub ${subNum}` : '';
return `${card} (hw · PCM ${devNum}${sub})`;
}
return `${card} (hw)`;
}
if (name.startsWith('front:')) return `${card} (Front)`;
if (name.startsWith('surround')) return `${card} (${name.split(':')[0]})`;
// Other ALSA iface:card,dev — show plugin + PCM so identical cards differ
const iface = name.split(':')[0];
if (iface && !['default', 'pulse', 'pipewire'].includes(iface)) {
if (devNum !== null) return `${card} (${iface} · PCM ${devNum})`;
return `${card} (${iface})`;
}
return card;
}
/** Readable tail when two devices still share the same label (rare after formatAudioDeviceLabel). */
function audioDeviceDuplicateHint(raw: string): string {
const cardM = raw.match(/CARD=([^,]+)/);
const devM = raw.match(/DEV=(\d+)/);
const subM = raw.match(/SUBDEV=(\d+)/);
const iface = raw.split(':')[0] || '';
const parts: string[] = [];
if (iface) parts.push(iface);
if (cardM) parts.push(cardM[1]);
if (devM) parts.push(`PCM ${devM[1]}`);
if (subM) parts.push(`sub ${subM[1]}`);
if (parts.length > 1) return parts.join(' · ');
return raw.length > 56 ? `${raw.slice(-53)}` : raw;
}
/** When several devices share the same display label, append a disambiguator. */
function disambiguatedAudioDeviceLabel(raw: string, baseLabel: string, duplicateBase: boolean): string {
if (!duplicateBase) return baseLabel;
return `${baseLabel} · ${audioDeviceDuplicateHint(raw)}`;
}
/** cpal order is arbitrary; sort by readable label, current OS default first. */
function sortAudioDeviceIds(devices: string[], osDefaultDeviceId: string | null): string[] {
return [...devices].sort((a, b) => {
const aDef = osDefaultDeviceId && a === osDefaultDeviceId;
const bDef = osDefaultDeviceId && b === osDefaultDeviceId;
if (aDef !== bDef) return aDef ? -1 : 1;
const la = formatAudioDeviceLabel(a);
const lb = formatAudioDeviceLabel(b);
const byLabel = la.localeCompare(lb, undefined, { sensitivity: 'base' });
if (byLabel !== 0) return byLabel;
return a.localeCompare(b);
});
}
function buildAudioDeviceSelectOptions(
devices: string[],
defaultLabel: string,
osDefaultDeviceId: string | null,
osDefaultMark: string,
pinnedDevice: string | null,
notInListSuffix: string,
): { value: string; label: string }[] {
const baseLabels = devices.map(formatAudioDeviceLabel);
const countByBase = new Map<string, number>();
for (const b of baseLabels) countByBase.set(b, (countByBase.get(b) ?? 0) + 1);
const pinned = pinnedDevice?.trim() || null;
const pinnedNotListed = !!(pinned && !devices.includes(pinned));
const ghost: { value: string; label: string }[] = pinnedNotListed
? (() => {
const base = formatAudioDeviceLabel(pinned);
let label = `${base} · ${notInListSuffix}`;
if (osDefaultDeviceId && pinned === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
return [{ value: pinned, label }];
})()
: [];
return [
{ value: '', label: defaultLabel },
...ghost,
...devices.map((d, i) => {
const base = baseLabels[i];
const dup = (countByBase.get(base) ?? 0) > 1;
let label = disambiguatedAudioDeviceLabel(d, base, dup);
if (osDefaultDeviceId && d === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
return { value: d, label };
}),
];
}
export default function Settings() {
const auth = useAuthStore();
const theme = useThemeStore();
@@ -259,6 +388,10 @@ export default function Settings() {
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
const [audioDevices, setAudioDevices] = useState<string[]>([]);
const [osDefaultAudioDeviceId, setOsDefaultAudioDeviceId] = useState<string | null>(null);
const [deviceSwitching, setDeviceSwitching] = useState(false);
const [devicesLoading, setDevicesLoading] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearing, setClearing] = useState(false);
const [contributorsOpen, setContributorsOpen] = useState(false);
@@ -275,6 +408,65 @@ export default function Settings() {
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
}, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
const refreshAudioDevices = useCallback((opts?: { silent?: boolean }) => {
const silent = !!opts?.silent;
if (!silent) setDevicesLoading(true);
const listP = invoke<string[]>('audio_list_devices').catch((e) => {
console.error(e);
showToast(t('settings.audioOutputDeviceListError'), 5000, 'error');
return [] as string[];
});
const defP = invoke<string | null>('audio_default_output_device_name').catch(() => null);
Promise.all([listP, defP])
.then(async ([devices, osDefault]) => {
let canon: string | null = null;
try {
canon = await invoke<string | null>('audio_canonicalize_selected_device');
if (canon) useAuthStore.getState().setAudioOutputDevice(canon);
} catch {
/* ignore */
}
const finalList = canon
? await invoke<string[]>('audio_list_devices').catch(() => devices)
: devices;
const defId = osDefault ?? null;
setAudioDevices(sortAudioDeviceIds(finalList, defId));
setOsDefaultAudioDeviceId(defId);
})
.finally(() => {
if (!silent) setDevicesLoading(false);
});
}, [t]);
// Load available audio output devices when Audio tab opens.
useEffect(() => {
if (activeTab !== 'audio') return;
refreshAudioDevices();
}, [activeTab, refreshAudioDevices]);
// Keep device list + "current system output" mark in sync when the backend reopens the stream.
useEffect(() => {
if (activeTab !== 'audio') return;
let cancelled = false;
const unlisteners: Array<() => void> = [];
(async () => {
for (const ev of ['audio:device-changed', 'audio:device-reset'] as const) {
const u = await listen(ev, () => {
if (!cancelled) refreshAudioDevices({ silent: true });
});
if (cancelled) {
u();
return;
}
unlisteners.push(u);
}
})();
return () => {
cancelled = true;
for (const u of unlisteners) u();
};
}, [activeTab, refreshAudioDevices]);
/** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */
useEffect(() => {
if (activeTab !== 'audio') return;
@@ -489,6 +681,76 @@ export default function Settings() {
{/* ── Audio ────────────────────────────────────────────────────────────── */}
{activeTab === 'audio' && (
<>
{/* Audio Output Device */}
<section className="settings-section">
<div className="settings-section-header">
<AudioLines size={18} />
<h2>{t('settings.audioOutputDevice')}</h2>
</div>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.audioOutputDeviceDesc')}
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<CustomSelect
style={{ flex: 1 }}
value={auth.audioOutputDevice ?? ''}
disabled={deviceSwitching || devicesLoading}
onChange={async (val) => {
const device = val || null;
setDeviceSwitching(true);
try {
await invoke('audio_set_device', { deviceName: device });
auth.setAudioOutputDevice(device);
} catch { /* device open failed — don't persist */ }
setDeviceSwitching(false);
}}
options={buildAudioDeviceSelectOptions(
audioDevices,
t('settings.audioOutputDeviceDefault'),
osDefaultAudioDeviceId,
t('settings.audioOutputDeviceOsDefaultNow'),
auth.audioOutputDevice,
t('settings.audioOutputDeviceNotInCurrentList'),
)}
/>
<button
className="icon-btn"
onClick={() => refreshAudioDevices()}
disabled={devicesLoading || deviceSwitching}
data-tooltip={t('settings.audioOutputDeviceRefresh')}
>
<RotateCcw size={15} className={devicesLoading ? 'spin' : ''} />
</button>
</div>
</div>
</section>
{/* Native Hi-Res Playback */}
<section className="settings-section">
<div className="settings-section-header">
<Waves size={18} />
<h2>{t('settings.hiResTitle')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
<input
type="checkbox"
checked={auth.enableHiRes}
onChange={e => auth.setEnableHiRes(e.target.checked)}
id="hires-enabled-toggle"
/>
<span className="toggle-track" />
</label>
</div>
</div>
</section>
{/* Equalizer */}
<section className="settings-section">
<div className="settings-section-header">
@@ -809,31 +1071,6 @@ export default function Settings() {
</div>
</section>
{/* Native Hi-Res Playback */}
<section className="settings-section">
<div className="settings-section-header">
<Waves size={18} />
<h2>{t('settings.hiResTitle')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
<input
type="checkbox"
checked={auth.enableHiRes}
onChange={e => auth.setEnableHiRes(e.target.checked)}
id="hires-enabled-toggle"
/>
<span className="toggle-track" />
</label>
</div>
</div>
</section>
</>
)}
@@ -1576,13 +1813,17 @@ export default function Settings() {
const handler = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.code !== 'Escape') {
// unbind any existing action with this key first
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
.find(([, c]) => c === e.code)?.[0];
if (existing && existing !== action) kb.setBinding(existing, null);
kb.setBinding(action, e.code);
if (e.code === 'Escape') {
setListeningFor(null);
window.removeEventListener('keydown', handler, true);
return;
}
const chord = buildInAppBinding(e);
if (!chord) return;
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
.find(([, c]) => c === chord)?.[0];
if (existing && existing !== action) kb.setBinding(existing, null);
kb.setBinding(action, chord);
setListeningFor(null);
window.removeEventListener('keydown', handler, true);
};
@@ -1598,7 +1839,7 @@ export default function Settings() {
cursor: 'pointer',
}}
>
{isListening ? t('settings.shortcutListening') : bound ? formatKeyCode(bound) : t('settings.shortcutUnbound')}
{isListening ? t('settings.shortcutListening') : bound ? formatBinding(bound) : t('settings.shortcutUnbound')}
</button>
{bound && !isListening && (
<button
@@ -1961,9 +2202,6 @@ export default function Settings() {
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}>
{t('settings.aboutDesc')}
</p>
<p style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0.5rem 0' }}>
{t('settings.aboutFeatures')}
</p>
<div className="divider" style={{ margin: '1rem 0' }} />
@@ -2525,7 +2763,7 @@ function ChangelogSection() {
</label>
</div>
<div className="changelog-list">
{versions.map(({ version, date, body }) => (
{versions.slice(0, 3).map(({ version, date, body }) => (
<details key={version} className="changelog-entry" open={version === appVersion}>
<summary className="changelog-summary">
<span className="changelog-version">v{version}</span>
+5
View File
@@ -77,6 +77,8 @@ interface AuthState {
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
enableHiRes: boolean;
/** Selected audio output device name. null = system default. */
audioOutputDevice: string | null;
/** Alpha: ephemeral queue prefetch cache on disk */
hotCacheEnabled: boolean;
@@ -197,6 +199,7 @@ interface AuthState {
setLastSeenChangelogVersion: (v: string) => void;
setSeekbarStyle: (v: SeekbarStyle) => void;
setEnableHiRes: (v: boolean) => void;
setAudioOutputDevice: (v: string | null) => void;
setHotCacheEnabled: (v: boolean) => void;
setHotCacheMaxMb: (v: number) => void;
setHotCacheDebounceSec: (v: number) => void;
@@ -289,6 +292,7 @@ export const useAuthStore = create<AuthState>()(
lastSeenChangelogVersion: '',
seekbarStyle: 'waveform',
enableHiRes: false,
audioOutputDevice: null,
hotCacheEnabled: false,
hotCacheMaxMb: 256,
hotCacheDebounceSec: 30,
@@ -410,6 +414,7 @@ export const useAuthStore = create<AuthState>()(
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
setEnableHiRes: (v) => set({ enableHiRes: v }),
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
+3 -14
View File
@@ -1,18 +1,13 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { formatKeyCode } from './keybindingsStore';
import { MODIFIER_KEY_CODES, formatBinding } from './keybindingsStore';
export type GlobalAction = 'play-pause' | 'next' | 'prev' | 'volume-up' | 'volume-down';
const MODIFIER_CODES = [
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight',
'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'OSLeft', 'OSRight',
];
/** Build a Tauri-compatible shortcut string from a KeyboardEvent, or null if invalid. */
export function buildGlobalShortcut(e: KeyboardEvent): string | null {
if (MODIFIER_CODES.includes(e.code)) return null;
if ((MODIFIER_KEY_CODES as readonly string[]).includes(e.code)) return null;
// Require at least Ctrl, Alt, or Meta — Shift alone is too invasive
if (!e.ctrlKey && !e.altKey && !e.metaKey) return null;
@@ -27,13 +22,7 @@ export function buildGlobalShortcut(e: KeyboardEvent): string | null {
/** Human-readable label for a stored shortcut string, e.g. "ctrl+alt+ArrowRight" → "Ctrl+Alt+→". */
export function formatGlobalShortcut(shortcut: string): string {
return shortcut.split('+').map(part => {
if (part === 'ctrl') return 'Ctrl';
if (part === 'alt') return 'Alt';
if (part === 'shift') return 'Shift';
if (part === 'super' || part === 'meta') return 'Super';
return formatKeyCode(part);
}).join('+');
return formatBinding(shortcut);
}
// Module-level guard — prevents double-registration from React StrictMode's
+55 -4
View File
@@ -14,7 +14,13 @@ export type KeyAction =
| 'fullscreen-player'
| 'native-fullscreen';
// key = action, value = e.code string (e.g. 'Space', 'KeyN', 'F11') or null for unbound
/** Physical keys only — ignore for binding capture */
export const MODIFIER_KEY_CODES = [
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight',
'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'OSLeft', 'OSRight',
] as const;
// key = action, value = plain e.code ("Space", "KeyN") or chord "ctrl+shift+KeyN", null = unbound
export type Bindings = Record<KeyAction, string | null>;
export const DEFAULT_BINDINGS: Bindings = {
@@ -33,16 +39,49 @@ export const DEFAULT_BINDINGS: Bindings = {
interface KeybindingsState {
bindings: Bindings;
setBinding: (action: KeyAction, code: string | null) => void;
setBinding: (action: KeyAction, binding: string | null) => void;
resetToDefaults: () => void;
}
/** Build persisted binding from a keydown: single key or modifier+key chord. */
export function buildInAppBinding(e: KeyboardEvent): string | null {
if ((MODIFIER_KEY_CODES as readonly string[]).includes(e.code)) return null;
if (!e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) return e.code;
const mods: string[] = [];
if (e.ctrlKey) mods.push('ctrl');
if (e.altKey) mods.push('alt');
if (e.shiftKey) mods.push('shift');
if (e.metaKey) mods.push('super');
return [...mods, e.code].join('+');
}
/** True if the event matches a stored binding (legacy plain codes = no modifiers). */
export function matchInAppBinding(e: KeyboardEvent, binding: string | null): boolean {
if (!binding) return false;
if (!binding.includes('+')) {
return (
e.code === binding &&
!e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey
);
}
const parts = binding.split('+');
const code = parts[parts.length - 1];
if (e.code !== code) return false;
const mods = new Set(parts.slice(0, -1));
return (
e.ctrlKey === mods.has('ctrl') &&
e.altKey === mods.has('alt') &&
e.shiftKey === mods.has('shift') &&
e.metaKey === mods.has('super')
);
}
export const useKeybindingsStore = create<KeybindingsState>()(
persist(
(set) => ({
bindings: { ...DEFAULT_BINDINGS },
setBinding: (action, code) =>
set(s => ({ bindings: { ...s.bindings, [action]: code } })),
setBinding: (action, binding) =>
set(s => ({ bindings: { ...s.bindings, [action]: binding } })),
resetToDefaults: () => set({ bindings: { ...DEFAULT_BINDINGS } }),
}),
{ name: 'psysonic_keybindings' }
@@ -80,3 +119,15 @@ export function formatKeyCode(code: string): string {
if (code === 'Slash') return '/';
return code;
}
/** Label for settings UI: plain key or chord (same string shape as global shortcuts). */
export function formatBinding(binding: string): string {
if (!binding.includes('+')) return formatKeyCode(binding);
return binding.split('+').map(part => {
if (part === 'ctrl') return 'Ctrl';
if (part === 'alt') return 'Alt';
if (part === 'shift') return 'Shift';
if (part === 'super' || part === 'meta') return 'Super';
return formatKeyCode(part);
}).join('+');
}
+10 -5
View File
@@ -174,10 +174,12 @@ interface PlayerState {
x: number;
y: number;
item: any;
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | null;
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist' | null;
queueIndex?: number;
playlistId?: string;
playlistSongIndex?: number;
};
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number, playlistId?: string, playlistSongIndex?: number) => void;
closeContextMenu: () => void;
songInfoModal: { isOpen: boolean; songId: string | null };
@@ -573,9 +575,12 @@ export function initAudioListeners(): () => void {
usePlayerStore.getState().syncLastfmLovedTracks();
// Initial sync of audio settings to Rust engine on startup.
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled } = useAuthStore.getState();
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
if (audioOutputDevice) {
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
}
// Keep audio settings in sync whenever auth store changes.
const unsubAuth = useAuthStore.subscribe((state) => {
@@ -729,8 +734,8 @@ export const usePlayerStore = create<PlayerState>()(
repeatMode: 'off',
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
openContextMenu: (x, y, item, type, queueIndex) => set({
contextMenu: { isOpen: true, x, y, item, type, queueIndex },
openContextMenu: (x, y, item, type, queueIndex, playlistId, playlistSongIndex) => set({
contextMenu: { isOpen: true, x, y, item, type, queueIndex, playlistId, playlistSongIndex },
}),
closeContextMenu: () => set(state => ({
contextMenu: { ...state.contextMenu, isOpen: false },
+38 -1
View File
@@ -1,22 +1,59 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { getPlaylists, createPlaylist as apiCreatePlaylist, SubsonicPlaylist } from '../api/subsonic';
interface PlaylistStore {
recentIds: string[];
playlists: SubsonicPlaylist[];
playlistsLoading: boolean;
lastModified: Record<string, number>;
touchPlaylist: (id: string) => void;
removeId: (id: string) => void;
fetchPlaylists: () => Promise<void>;
createPlaylist: (name: string, songIds?: string[]) => Promise<SubsonicPlaylist | null>;
addPlaylist: (playlist: SubsonicPlaylist) => void;
}
export const usePlaylistStore = create<PlaylistStore>()(
persist(
(set) => ({
(set, get) => ({
recentIds: [],
playlists: [],
playlistsLoading: false,
lastModified: {},
touchPlaylist: (id) =>
set((s) => ({
recentIds: [id, ...s.recentIds.filter((x) => x !== id)].slice(0, 50),
lastModified: { ...s.lastModified, [id]: Date.now() },
})),
removeId: (id) =>
set((s) => ({ recentIds: s.recentIds.filter((x) => x !== id) })),
fetchPlaylists: async () => {
set({ playlistsLoading: true });
try {
const playlists = await getPlaylists();
set({ playlists, playlistsLoading: false });
} catch {
set({ playlistsLoading: false });
}
},
createPlaylist: async (name: string, songIds?: string[]) => {
try {
const playlist = await apiCreatePlaylist(name, songIds);
set((s) => ({
playlists: [...s.playlists, playlist],
recentIds: [playlist.id, ...s.recentIds.filter((x) => x !== playlist.id)].slice(0, 50),
}));
return playlist;
} catch {
return null;
}
},
addPlaylist: (playlist) => {
set((s) => ({
playlists: [...s.playlists, playlist],
}));
},
}),
{ name: 'psysonic_playlists_recent' }
)
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10' | 'north-park' | 'dark-side-of-the-moon' | 'powerslave' | 'nightfox' | 'dayfox' | 'dawnfox' | 'duskfox' | 'nordfox' | 'terafox' | 'carbonfox' | 'dracula';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10' | 'north-park' | 'dark-side-of-the-moon' | 'powerslave' | 'nightfox' | 'dayfox' | 'dawnfox' | 'duskfox' | 'nordfox' | 'terafox' | 'carbonfox' | 'dracula' | 'vision-dark' | 'vision-navy';
interface ThemeState {
theme: Theme;
+74 -1
View File
@@ -198,6 +198,7 @@
/* ─ Album Card ─ */
.album-card {
position: relative;
cursor: pointer;
background: var(--bg-card);
border-radius: var(--radius-lg);
@@ -519,6 +520,33 @@
color: var(--ctp-crust);
}
/* ─── Artist Card Selection Checkbox ─── */
.artist-card {
position: relative;
}
.artist-card-select-check {
position: absolute;
top: 6px;
left: 6px;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
border: 2px solid var(--border-subtle);
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
pointer-events: none;
}
.artist-card-select-check--on {
background: var(--accent);
border-color: var(--accent);
color: var(--ctp-crust);
}
/* ── Albums selection bar (portal) ── */
.albums-selection-bar {
position: fixed;
@@ -3498,10 +3526,11 @@ html.no-compositing .fs-close {
background: rgba(0, 0, 0, 0.45);
}
/* Portrait: remove CSS filter (drop-shadow) and will-change */
/* Portrait: remove CSS filter (drop-shadow), will-change and drift animation */
html.no-compositing .fs-portrait {
filter: none;
will-change: auto;
animation: none;
}
/* Portrait wrap: replace mask-image fade with a pseudo-element overlay gradient.
@@ -3535,6 +3564,20 @@ html.no-compositing .fs-lyrics-rail {
will-change: auto;
}
/* Mesh blobs: stop animations in software mode each frame composites surfaces
larger than the screen (blob-a is 130 × 130 % of the viewport); on high-DPI
displays this saturates the CPU and drops the player to ~10 FPS.
Static gradients are preserved; only the slow pan animation is removed. */
html.no-compositing .fs-mesh-blob {
animation: none;
}
/* Seekbar played bar: remove box-shadow its width changes on every playback
tick; in software rendering each change triggers a box-shadow repaint. */
html.no-compositing .fs-seekbar-played {
box-shadow: none;
}
/* Chat */
.chat-popup {
position: absolute;
@@ -3685,6 +3728,7 @@ html.no-compositing .fs-lyrics-rail {
backdrop-filter: blur(16px);
color: var(--text-primary);
font-size: 13px;
outline: none;
}
.context-menu-item {
@@ -7268,6 +7312,35 @@ html.no-compositing .fs-lyrics-rail {
text-align: center;
}
.folder-col-filter {
position: sticky;
top: 0;
z-index: 2;
padding: 0.35rem 0.45rem;
background: color-mix(in srgb, var(--bg-app) 92%, transparent);
border-bottom: 1px solid var(--border-subtle);
}
.folder-col-filter-input {
width: 100%;
padding: 0.35rem 0.5rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
background: var(--bg-card);
color: var(--text-primary);
font-size: 0.78rem;
}
.folder-col-filter-input:focus {
outline: none;
border-color: color-mix(in srgb, var(--accent) 70%, var(--border-subtle));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
}
.folder-col.folder-col--compact .folder-col-filter {
display: none;
}
.folder-col-error {
color: var(--danger, #f38ba8);
}
+140
View File
@@ -2306,3 +2306,143 @@
color: inherit;
opacity: 0.7;
}
/* ─── Sidebar Expandable Playlists Section ─── */
.sidebar-playlists-wrapper {
display: flex;
flex-direction: column;
}
.sidebar-playlists-header-row {
display: flex;
align-items: center;
gap: var(--space-1);
padding-right: var(--space-2);
}
.sidebar-playlists-main-link {
flex: 1;
min-width: 0;
}
.sidebar-playlists-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border-radius: var(--radius-md);
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.sidebar-playlists-toggle:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar-playlists-toggle.expanded {
transform: rotate(90deg);
color: var(--accent);
}
.sidebar-playlists-section {
display: flex;
flex-direction: column;
}
.sidebar-playlists-header {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
transition: background var(--transition-fast), color var(--transition-fast);
cursor: pointer;
background: transparent;
border: none;
text-align: left;
}
.sidebar-playlists-header:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar-playlists-header.active {
background: var(--accent-dim);
color: var(--accent);
}
.sidebar-playlists-chevron {
margin-left: auto;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.sidebar-playlists-chevron.expanded {
transform: rotate(90deg);
}
.sidebar-playlists-list {
display: flex;
flex-direction: column;
padding-left: var(--space-4);
padding-top: var(--space-1);
padding-bottom: var(--space-1);
gap: var(--space-1);
}
.sidebar-playlist-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 13px;
font-weight: 400;
transition: background var(--transition-fast), color var(--transition-fast);
overflow: hidden;
}
.sidebar-playlist-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.sidebar-playlist-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar-playlist-item.active {
background: var(--accent-dim);
color: var(--accent);
}
.sidebar-playlists-loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2);
}
.sidebar-playlists-empty {
padding: var(--space-2);
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
+106
View File
@@ -14317,3 +14317,109 @@ input[type="range"]:hover::-webkit-slider-thumb {
--warning: #ffb86c;
--danger: #ff5555;
}
/* ─── Vision Dark — Purple & Gold, colorblind-safe (Deuteranopia / Protanopia / Tritanopia) ─── */
/* Gold #FFD700 on #0D0B12 → contrast ~14.7:1 (WCAG AAA) */
[data-theme='vision-dark'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23C8C4D4%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-crust: #030210;
--ctp-mantle: #06050e;
--ctp-base: #0d0b12;
--ctp-surface0: #16131e;
--ctp-surface1: #201c2c;
--ctp-surface2: #2c2838;
--ctp-overlay0: #544e6a;
--ctp-overlay1: #6e6886;
--ctp-overlay2: #8a84a0;
--ctp-text: #f2eef8;
--ctp-subtext1: #c8c4d4;
--ctp-subtext0: #9c98aa;
--ctp-mauve: #8b5cf6; /* Purple — secondary accent */
--ctp-lavender: #a07af8;
--ctp-blue: #60a5fa;
--ctp-sapphire: #38bdf8;
--ctp-sky: #38bdf8;
--ctp-teal: #2dd4bf;
--ctp-green: #009e73; /* Okabe-Ito Bluish Green */
--ctp-yellow: #ffd700; /* Gold */
--ctp-peach: #e69f00;
--ctp-maroon: #d55e00; /* Okabe-Ito Vermillion — danger */
--ctp-red: #d55e00;
--ctp-flamingo: #f472b6;
--ctp-pink: #e879f9;
--ctp-rosewater: #f0abfc;
--bg-app: #0d0b12;
--bg-sidebar: #08060e;
--bg-card: #16131e;
--bg-hover: #201c2c;
--bg-player: #050310;
--bg-glass: rgba(13, 11, 18, 0.84);
--accent: #ffd700;
--accent-dim: rgba(255, 215, 0, 0.14);
--accent-glow: rgba(255, 215, 0, 0.36);
--text-primary: #f2eef8;
--text-secondary: #c8c4d4;
--text-muted: #7e7a8e;
--border: #201c2c;
--border-subtle: #16131e;
--positive: #009e73;
--warning: #e69f00;
--danger: #d55e00;
--volume-accent: #ffd700;
}
/* ─── Vision Navy — Purple & Gold, colorblind-safe (Deuteranopia / Protanopia / Tritanopia) ─── */
/* Gold #FFD700 on #0A1628 → contrast ~14.5:1 (WCAG AAA) */
[data-theme='vision-navy'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23C0C8D8%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-crust: #030a14;
--ctp-mantle: #060e1c;
--ctp-base: #0a1628;
--ctp-surface0: #112038;
--ctp-surface1: #1a2e48;
--ctp-surface2: #243c58;
--ctp-overlay0: #486480;
--ctp-overlay1: #607a98;
--ctp-overlay2: #7890ae;
--ctp-text: #eef2f8;
--ctp-subtext1: #c0c8d8;
--ctp-subtext0: #8898ae;
--ctp-mauve: #8b5cf6; /* Purple — secondary accent */
--ctp-lavender: #a07af8;
--ctp-blue: #3b82f6;
--ctp-sapphire: #38bdf8;
--ctp-sky: #7dd3fc;
--ctp-teal: #2dd4bf;
--ctp-green: #009e73; /* Okabe-Ito Bluish Green */
--ctp-yellow: #ffd700; /* Gold */
--ctp-peach: #e69f00;
--ctp-maroon: #d55e00; /* Okabe-Ito Vermillion */
--ctp-red: #d55e00;
--ctp-flamingo: #f472b6;
--ctp-pink: #e879f9;
--ctp-rosewater: #f0abfc;
--bg-app: #0a1628;
--bg-sidebar: #060e1c;
--bg-card: #112038;
--bg-hover: #1a2e48;
--bg-player: #040c18;
--bg-glass: rgba(10, 22, 40, 0.84);
--accent: #ffd700;
--accent-dim: rgba(255, 215, 0, 0.14);
--accent-glow: rgba(255, 215, 0, 0.36);
--text-primary: #eef2f8;
--text-secondary: #c0c8d8;
--text-muted: #607080;
--border: #1a2e48;
--border-subtle: #112038;
--positive: #009e73;
--warning: #e69f00;
--danger: #d55e00;
--volume-accent: #ffd700;
}