mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-24 00:05:47 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01f71cf50c | |||
| 39966d1c65 | |||
| cbf72c7eba | |||
| 4207455440 | |||
| 9983d13122 | |||
| 1b55e88cd5 | |||
| 2326f1f94b | |||
| 27ea221130 | |||
| 9cd4743d1c | |||
| e75fda168e | |||
| ec021516c7 | |||
| 82e1b458f3 | |||
| fc7a5815ef | |||
| 1cf5faff21 | |||
| 16cb4f5a6f | |||
| 9b22327bb0 | |||
| dba89cc4e4 | |||
| 91d4cb58b8 | |||
| 374ebce9b5 | |||
| 083c5f62cb | |||
| 592a479c30 | |||
| 6439200e95 | |||
| 9cb1471170 | |||
| 21e26e2604 | |||
| 94323e91fa | |||
| bef6941a2b | |||
| 805b6bf163 | |||
| fee454da5d | |||
| a765bcd5d0 | |||
| 7e50c7af40 | |||
| a12d6a4015 |
@@ -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']
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,7 @@ dist-ssr
|
||||
|
||||
# Tauri
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
@@ -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 0–1 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.9",
|
||||
"version": "1.34.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+9
-8
@@ -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",
|
||||
|
||||
@@ -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 +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
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+939
-23
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: '网络电台',
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('+');
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user