mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat: v1.32.0 — The Big Easter Update
Internet Radio full release (HTML5 engine, card UI, RadioDirectoryModal, cover upload), Backup/Restore, Albums year filter, Statistics Library Insights (playtime/genres/formats), Playlist cover upload, resizable tracklist columns for Playlists & Favorites, crossfade fine control, Settings Storage tab redesign, various fixes and UI polish. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,9 @@ jobs:
|
||||
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
tagName: app-v${{ needs.create-release.outputs.package_version }}
|
||||
releaseName: Psysonic v${{ needs.create-release.outputs.package_version }}
|
||||
releaseDraft: true
|
||||
args: ${{ matrix.settings.args }}
|
||||
|
||||
- name: sign and upload Windows NSIS updater bundle
|
||||
|
||||
@@ -5,6 +5,40 @@ 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.32.0] - 2026-04-05 — *The Big Easter Update* 🐣
|
||||
|
||||
### Added
|
||||
|
||||
- **Internet Radio — full release**: The Radio page is now accessible from the sidebar. Complete UI rewrite to a card-based layout (cover art, name, edit/homepage buttons) consistent with the Playlists look. Covers can be uploaded or removed via a hover menu directly on the card.
|
||||
- **Internet Radio — Edit Modal**: A dedicated modal lets you change station name, stream URL, and homepage URL, and upload or remove cover art.
|
||||
- **Internet Radio — Radio Browser directory** *(via [radio-browser.info](https://www.radio-browser.info))*: Discover new stations directly inside Psysonic. Top stations by vote are shown as suggestions; a debounced search finds stations by name. Favicon images can be imported as cover art in one click.
|
||||
- **Settings — Backup & Restore**: Export all your settings (servers, theme, font, keybindings, EQ preset, sidebar order) to a single JSON file and import them on another machine or after a reinstall. Available in Settings → Storage.
|
||||
- **Albums — Year Range Filter**: A From/To year input now appears in the Albums toolbar alongside the existing genre filter. Filtering by year and by genre can be combined; clearing both inputs returns to the default view.
|
||||
- **Statistics — Library Insights** *(requested via [#88](https://github.com/Psychotoxical/psysonic/issues/88))*:
|
||||
- **Total Playtime** card: computed in the background by paginating your full album list (up to 5 000 albums). Shows `≥ Xh Ym` if the library is larger.
|
||||
- **Genre Insights**: Top 10 genres ranked by song count with proportional progress bars.
|
||||
- **Format Distribution**: Codec breakdown from a random 500-track sample — shows format name and percentage.
|
||||
- **Playlist Detail — Cover Upload**: Change or remove a playlist's cover image via the hover menu that appears on the hero artwork — no external tool needed.
|
||||
- **Tracklist columns — Playlists & Favorites** *(work in progress)*: PlaylistDetail and Favorites now support the same resizable, configurable column system introduced in v1.31.0 for Album tracklists. Column widths and visibility are persisted independently per page. The feature is still being refined.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Crossfade — fine-grained control**: The crossfade duration slider now ranges from 0.1 s to 10 s in 0.1 s steps (previously 1 s minimum, 0.5 s steps). The current value is shown with one decimal place.
|
||||
- **Settings — Storage tab redesign**: The "Offline Library" section now has a short description and includes Cache settings. The "Downloads" section is now labelled "ZIP Export & Archiving". Both sections have been visually consolidated.
|
||||
- **Artists page — Load More button** *(reported via [#90](https://github.com/Psychotoxical/psysonic/issues/90))*: The button is now styled as `btn-primary` with a `ChevronDown` icon and proper spacing. Previously it was an unstyled ghost button with no visual affordance.
|
||||
- **Tracklist layout consistency**: The Play-button column is now uniformly 60 px and the title column uses `minmax(150px, 1fr)` across all list views — Search Results, Artist Detail, Random Mix, and Advanced Search now match the Album tracklist layout.
|
||||
- **Internet Radio — HTML5 playback**: Radio now streams via the browser's native `<audio>` element instead of a custom Rust pipeline. This improves compatibility with AAC/MP3/HLS streams.
|
||||
- **AppUpdater — error visibility** *(experimental, still in progress)*: Update failures are now shown inside the update card rather than silently logged. Auto-update remains experimental — a direct GitHub Releases link is always shown as a fallback.
|
||||
- **Queue panel — radio drag**: Dragging a radio station card onto the queue is now silently rejected instead of causing an error.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PlayerBar stuck on Radio info**: Switching from an Internet Radio station to a regular track no longer leaves the station name and cover in the player bar. `playTrack` now clears `currentRadio` state and stops the audio element immediately.
|
||||
- **Radio favourite icon**: The heart icon is now correctly used for favourite radio stations on both the Internet Radio page and the Favourites page. It was incorrectly showing a star.
|
||||
- **Offline track deletion — orphaned directories**: Deleting a cached track now removes empty parent directories up to the configured base directory. Uses `std::fs::remove_dir` (safe — only removes empty directories) to avoid accidental data loss.
|
||||
|
||||
---
|
||||
|
||||
## [1.31.0] - 2026-04-04
|
||||
|
||||
> **Note:** This is likely the last update for the coming week — taking a short break. See you on the other side. ☀️
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+28
-1
@@ -2577,6 +2577,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -3483,13 +3493,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.31.0"
|
||||
version = "1.32.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
"md5",
|
||||
"reqwest 0.12.28",
|
||||
"ringbuf",
|
||||
"rodio",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3732,6 +3743,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -3830,6 +3842,15 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ringbuf"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79abed428d1fd2a128201cec72c5f6938e2da607c6f3745f769fabea399d950a"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.19.0"
|
||||
@@ -5658,6 +5679,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.31.0"
|
||||
version = "1.32.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -32,11 +32,12 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "multipart"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
biquad = "0.4"
|
||||
ringbuf = "0.3"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
|
||||
@@ -17,10 +17,13 @@
|
||||
"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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"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","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
|
||||
{"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-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
|
||||
+583
-237
@@ -1,9 +1,10 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ringbuf::{HeapConsumer, HeapProducer, HeapRb};
|
||||
|
||||
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
|
||||
use rodio::{Sink, Source};
|
||||
use rodio::source::UniformSourceIterator;
|
||||
@@ -414,120 +415,440 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SizedCursorSource — MediaSource with correct byte_len ────────────────────
|
||||
// ─── Internet Radio v2 — Lock-Free SPSC + ICY Metadata + Hybrid Pause ────────
|
||||
//
|
||||
// ─── RadioBuffer — streaming MediaSource for live HTTP radio ─────────────────
|
||||
// HTTP task (tokio)
|
||||
// └─[IcyInterceptor]─► HeapProducer<u8>
|
||||
// │ (4 MB HeapRb, lock-free)
|
||||
// HeapConsumer<u8>
|
||||
// │
|
||||
// AudioStreamReader (Read + Seek + MediaSource)
|
||||
// │
|
||||
// SizedDecoder (symphonia)
|
||||
// │
|
||||
// rodio Sink
|
||||
//
|
||||
// Bridges an async reqwest byte-stream (download task) into a synchronous
|
||||
// Read+Seek interface that symphonia / rodio can consume.
|
||||
// Pause modes:
|
||||
// Logical pause — sink.pause(); download task keeps filling (time-shift).
|
||||
// Hard pause — buffer ≥ RADIO_HARD_PAUSE_THRESH full + paused ≥ 5 s
|
||||
// → TCP disconnect, is_hard_paused = true.
|
||||
// Resume (warm) — sink.play(); buffer drains seamlessly.
|
||||
// Resume (cold) — new HeapRb + new GET; consumer swapped in AudioStreamReader.
|
||||
//
|
||||
// Back-pressure: the download task pauses when the ring buffer exceeds 4 MB
|
||||
// (~4 min at 128 kbps). Read() blocks (via Condvar) until data arrives so
|
||||
// rodio's audio thread can decode in real time. is_seekable() = false so
|
||||
// symphonia never tries to seek backward into consumed data.
|
||||
// New Tauri event: "radio:metadata" → String (ICY StreamTitle)
|
||||
|
||||
const RADIO_BUF_MAX: usize = 256 * 1024;
|
||||
/// 256 KB on the heap — ≈16 s at 128 kbps, ≈6 s at 320 kbps.
|
||||
/// Small enough that stale audio drains within a few seconds on reconnect;
|
||||
/// large enough to absorb brief network hiccups without stuttering.
|
||||
const RADIO_BUF_CAPACITY: usize = 256 * 1024;
|
||||
/// Seconds at stall threshold while paused before hard-disconnect.
|
||||
const RADIO_HARD_PAUSE_SECS: u64 = 5;
|
||||
/// AudioStreamReader timeout: if no audio bytes arrive for this long → EOF.
|
||||
const RADIO_READ_TIMEOUT_SECS: u64 = 15;
|
||||
/// Sleep interval when ring buffer is empty (prevents CPU spin).
|
||||
const RADIO_YIELD_MS: u64 = 2;
|
||||
|
||||
pub(crate) struct RadioInner {
|
||||
data: VecDeque<u8>,
|
||||
eof: bool,
|
||||
// ── ICY Metadata State Machine ────────────────────────────────────────────────
|
||||
//
|
||||
// Shoutcast/Icecast embed metadata every `metaint` audio bytes:
|
||||
//
|
||||
// ┌──────────────────────┬───┬─────────────┐
|
||||
// │ audio × metaint │ N │ meta × N×16 │ (repeating)
|
||||
// └──────────────────────┴───┴─────────────┘
|
||||
//
|
||||
// N = 0 → no metadata this block. Metadata bytes are stripped so only
|
||||
// pure audio reaches the ring buffer and Symphonia never sees text bytes.
|
||||
|
||||
enum IcyState {
|
||||
/// Forwarding audio bytes; `remaining` counts down to the next boundary.
|
||||
ReadingAudio { remaining: usize },
|
||||
/// Next byte is the metadata length multiplier N.
|
||||
ReadingLengthByte,
|
||||
/// Accumulating N×16 metadata bytes.
|
||||
ReadingMetadata { remaining: usize, buf: Vec<u8> },
|
||||
}
|
||||
|
||||
struct IcyInterceptor {
|
||||
state: IcyState,
|
||||
metaint: usize,
|
||||
}
|
||||
|
||||
impl IcyInterceptor {
|
||||
fn new(metaint: usize) -> Self {
|
||||
Self { metaint, state: IcyState::ReadingAudio { remaining: metaint } }
|
||||
}
|
||||
|
||||
/// Feed a raw HTTP chunk.
|
||||
/// Appends only audio bytes to `audio_out`.
|
||||
/// Returns `Some(IcyMeta)` when a StreamTitle is extracted.
|
||||
fn process(&mut self, input: &[u8], audio_out: &mut Vec<u8>) -> Option<IcyMeta> {
|
||||
let mut extracted: Option<IcyMeta> = None;
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
match &mut self.state {
|
||||
IcyState::ReadingAudio { remaining } => {
|
||||
let n = (input.len() - i).min(*remaining);
|
||||
audio_out.extend_from_slice(&input[i..i + n]);
|
||||
i += n;
|
||||
*remaining -= n;
|
||||
if *remaining == 0 {
|
||||
self.state = IcyState::ReadingLengthByte;
|
||||
}
|
||||
}
|
||||
IcyState::ReadingLengthByte => {
|
||||
let len_n = input[i] as usize;
|
||||
i += 1;
|
||||
self.state = if len_n == 0 {
|
||||
IcyState::ReadingAudio { remaining: self.metaint }
|
||||
} else {
|
||||
IcyState::ReadingMetadata {
|
||||
remaining: len_n * 16,
|
||||
buf: Vec::with_capacity(len_n * 16),
|
||||
}
|
||||
};
|
||||
}
|
||||
IcyState::ReadingMetadata { remaining, buf } => {
|
||||
let n = (input.len() - i).min(*remaining);
|
||||
buf.extend_from_slice(&input[i..i + n]);
|
||||
i += n;
|
||||
*remaining -= n;
|
||||
if *remaining == 0 {
|
||||
let bytes = std::mem::take(buf);
|
||||
extracted = parse_icy_meta(&bytes);
|
||||
self.state = IcyState::ReadingAudio { remaining: self.metaint };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
extracted
|
||||
}
|
||||
}
|
||||
|
||||
/// ICY metadata parsed from a raw metadata block.
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub(crate) struct IcyMeta {
|
||||
pub title: String,
|
||||
/// `true` when `StreamUrl='0'` — indicates a CDN-injected ad/promo.
|
||||
pub is_ad: bool,
|
||||
}
|
||||
|
||||
/// Extract `StreamTitle` and `StreamUrl` from a raw ICY metadata block.
|
||||
/// Tolerates null padding and non-UTF-8 bytes (lossy conversion).
|
||||
fn parse_icy_meta(raw: &[u8]) -> Option<IcyMeta> {
|
||||
let s = String::from_utf8_lossy(raw);
|
||||
let s = s.trim_end_matches('\0');
|
||||
|
||||
const TITLE_TAG: &str = "StreamTitle='";
|
||||
let title_start = s.find(TITLE_TAG)? + TITLE_TAG.len();
|
||||
let title_rest = &s[title_start..];
|
||||
// find (not rfind) — rfind would skip past StreamUrl and corrupt the title
|
||||
let title_end = title_rest.find("';")?;
|
||||
let title = title_rest[..title_end].trim().to_string();
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
const URL_TAG: &str = "StreamUrl='";
|
||||
let stream_url = s.find(URL_TAG).map(|pos| {
|
||||
let rest = &s[pos + URL_TAG.len()..];
|
||||
let end = rest.find("';").unwrap_or(rest.len());
|
||||
rest[..end].trim().to_string()
|
||||
}).unwrap_or_default();
|
||||
|
||||
Some(IcyMeta { title, is_ad: stream_url == "0" })
|
||||
}
|
||||
|
||||
// ── AudioStreamReader — SPSC consumer → std::io::Read ────────────────────────
|
||||
//
|
||||
// Bridges HeapConsumer<u8> (non-blocking) into the synchronous Read interface
|
||||
// that Symphonia requires. Designed to run inside tokio::task::spawn_blocking.
|
||||
//
|
||||
// Empty buffer: sleeps RADIO_YIELD_MS ms, retries. Never busy-spins.
|
||||
// Timeout: after RADIO_READ_TIMEOUT_SECS with no data → TimedOut.
|
||||
// Generation: if gen_arc != self.gen → Ok(0) (EOF; new track started).
|
||||
// Reconnect: audio_resume sends a fresh HeapConsumer via new_cons_rx.
|
||||
// On the next read() we drain the channel (keep latest) and swap.
|
||||
|
||||
struct AudioStreamReader {
|
||||
cons: HeapConsumer<u8>,
|
||||
/// Delivers fresh consumers on hard-pause reconnect (unbounded; drain to latest).
|
||||
/// Wrapped in Mutex so AudioStreamReader is Sync (required by symphonia::MediaSource).
|
||||
/// No real contention: only the audio thread ever calls read().
|
||||
new_cons_rx: Mutex<std::sync::mpsc::Receiver<HeapConsumer<u8>>>,
|
||||
deadline: std::time::Instant,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
gen: u64,
|
||||
/// Monotonic byte offset for SeekFrom::Current(0) "tell" (Symphonia probe).
|
||||
pos: u64,
|
||||
}
|
||||
|
||||
// The read-side: given to symphonia / rodio.
|
||||
struct RadioBuffer {
|
||||
inner: Arc<(Mutex<RadioInner>, Condvar)>,
|
||||
}
|
||||
|
||||
// The write-side: held by the async download task.
|
||||
pub struct RadioFeed {
|
||||
pub inner: Arc<(Mutex<RadioInner>, Condvar)>,
|
||||
}
|
||||
|
||||
impl RadioBuffer {
|
||||
fn new() -> (RadioBuffer, RadioFeed) {
|
||||
let arc = Arc::new((
|
||||
Mutex::new(RadioInner { data: VecDeque::new(), eof: false, pos: 0 }),
|
||||
Condvar::new(),
|
||||
));
|
||||
(RadioBuffer { inner: arc.clone() }, RadioFeed { inner: arc })
|
||||
}
|
||||
}
|
||||
|
||||
impl RadioFeed {
|
||||
pub fn push(&self, chunk: &[u8]) {
|
||||
let (lock, cvar) = &*self.inner;
|
||||
let mut g = lock.lock().unwrap();
|
||||
g.data.extend(chunk.iter().copied());
|
||||
cvar.notify_one();
|
||||
}
|
||||
|
||||
pub fn is_full(&self) -> bool {
|
||||
let (lock, _) = &*self.inner;
|
||||
lock.lock().unwrap().data.len() >= RADIO_BUF_MAX
|
||||
}
|
||||
|
||||
pub fn flush(&self) {
|
||||
let (lock, _) = &*self.inner;
|
||||
lock.lock().unwrap().data.clear();
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let (lock, cvar) = &*self.inner;
|
||||
let mut g = lock.lock().unwrap();
|
||||
g.eof = true;
|
||||
cvar.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for RadioBuffer {
|
||||
impl Read for AudioStreamReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let (lock, cvar) = &*self.inner;
|
||||
let mut g = lock.lock().unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while g.data.is_empty() && !g.eof {
|
||||
let rem = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if rem.is_zero() {
|
||||
eprintln!("[radio] RadioBuffer::read() timed out — no data for 10 s");
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "radio: no data after 10 s"));
|
||||
// EOF guard: new track started.
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
return Ok(0);
|
||||
}
|
||||
// Drain reconnect channel; keep only the most recently delivered consumer
|
||||
// so a double-tap of resume doesn't leave stale data in place.
|
||||
let mut newest: Option<HeapConsumer<u8>> = None;
|
||||
while let Ok(c) = self.new_cons_rx.lock().unwrap().try_recv() {
|
||||
newest = Some(c);
|
||||
}
|
||||
if let Some(c) = newest {
|
||||
self.cons = c;
|
||||
self.deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
||||
}
|
||||
loop {
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
return Ok(0);
|
||||
}
|
||||
let (new_g, _) = cvar.wait_timeout(g, rem).unwrap();
|
||||
g = new_g;
|
||||
let available = self.cons.len();
|
||||
if available > 0 {
|
||||
let n = buf.len().min(available);
|
||||
let read = self.cons.pop_slice(&mut buf[..n]);
|
||||
self.pos += read as u64;
|
||||
// Reset deadline: data arrived, so connection is alive.
|
||||
self.deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
||||
return Ok(read);
|
||||
}
|
||||
if std::time::Instant::now() >= self.deadline {
|
||||
eprintln!(
|
||||
"[radio] AudioStreamReader: {}s without data → EOF",
|
||||
RADIO_READ_TIMEOUT_SECS
|
||||
);
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"radio: no data received",
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(RADIO_YIELD_MS));
|
||||
}
|
||||
if g.data.is_empty() {
|
||||
eprintln!("[radio] RadioBuffer::read() → EOF (eof flag set, buffer empty)");
|
||||
return Ok(0); // EOF
|
||||
}
|
||||
let n = buf.len().min(g.data.len());
|
||||
for (i, b) in g.data.drain(..n).enumerate() {
|
||||
buf[i] = b;
|
||||
}
|
||||
g.pos += n as u64;
|
||||
// Notify downloader that buffer has drained below the cap.
|
||||
cvar.notify_one();
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for RadioBuffer {
|
||||
impl Seek for AudioStreamReader {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
// Live streams cannot seek. Symphonia will not try because
|
||||
// is_seekable() = false; the only call it makes is SeekFrom::Current(0)
|
||||
// (tell) which we handle.
|
||||
let (lock, _) = &*self.inner;
|
||||
let g = lock.lock().unwrap();
|
||||
match pos {
|
||||
SeekFrom::Current(0) => Ok(g.pos),
|
||||
_ => Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "radio stream: not seekable")),
|
||||
SeekFrom::Current(0) => Ok(self.pos),
|
||||
_ => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"radio stream is not seekable",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for RadioBuffer {
|
||||
impl MediaSource for AudioStreamReader {
|
||||
fn is_seekable(&self) -> bool { false }
|
||||
fn byte_len(&self) -> Option<u64> { None }
|
||||
}
|
||||
|
||||
// ── Pause / Reconnect Coordination ───────────────────────────────────────────
|
||||
|
||||
pub(crate) struct RadioSharedFlags {
|
||||
/// Set by audio_pause; cleared by audio_resume.
|
||||
is_paused: AtomicBool,
|
||||
/// Set by download task on hard disconnect; cleared on resume-reconnect.
|
||||
is_hard_paused: AtomicBool,
|
||||
/// Delivers a fresh HeapConsumer<u8> to AudioStreamReader on reconnect.
|
||||
new_cons_tx: Mutex<std::sync::mpsc::Sender<HeapConsumer<u8>>>,
|
||||
}
|
||||
|
||||
/// Live state for the current radio session, stored in AudioEngine.
|
||||
/// Dropping this struct aborts the HTTP download task immediately.
|
||||
pub(crate) struct RadioLiveState {
|
||||
pub url: String,
|
||||
pub gen: u64,
|
||||
pub task: tokio::task::JoinHandle<()>,
|
||||
pub flags: Arc<RadioSharedFlags>,
|
||||
}
|
||||
|
||||
impl Drop for RadioLiveState {
|
||||
fn drop(&mut self) { self.task.abort(); }
|
||||
}
|
||||
|
||||
// ── HE-AAC / FDK-AAC Fallback ────────────────────────────────────────────────
|
||||
//
|
||||
// Symphonia 0.5.x: AAC-LC only. HE-AAC (AAC+) and HE-AACv2 lack SBR/PS →
|
||||
// streams play at half speed with muffled audio.
|
||||
//
|
||||
// With Cargo feature "fdk-aac": FdkAacDecoder is tried first for CODEC_TYPE_AAC.
|
||||
// Enable in Cargo.toml:
|
||||
// symphonia-adapter-fdk-aac = { version = "0.1", optional = true }
|
||||
// [features]
|
||||
// fdk-aac = ["dep:symphonia-adapter-fdk-aac"]
|
||||
|
||||
fn try_make_radio_decoder(
|
||||
params: &symphonia::core::codecs::CodecParameters,
|
||||
opts: &DecoderOptions,
|
||||
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
|
||||
symphonia::default::get_codecs().make(params, opts)
|
||||
}
|
||||
|
||||
// ── Async HTTP Download Task ──────────────────────────────────────────────────
|
||||
//
|
||||
// Lifecycle:
|
||||
// 'outer loop — reconnect on TCP drop (up to MAX_RECONNECTS)
|
||||
// 'inner loop — read HTTP chunks → ICY interceptor → push audio to ring buffer
|
||||
//
|
||||
// Hard-pause detection: if push_slice() returns 0 (buffer full) AND sink is
|
||||
// paused AND that condition persists for RADIO_HARD_PAUSE_SECS → disconnect.
|
||||
// Sets is_hard_paused = true so audio_resume knows it must reconnect.
|
||||
|
||||
async fn radio_download_task(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
mut initial_response: Option<reqwest::Response>,
|
||||
http_client: reqwest::Client,
|
||||
url: String,
|
||||
mut prod: HeapProducer<u8>,
|
||||
flags: Arc<RadioSharedFlags>,
|
||||
app: AppHandle,
|
||||
) {
|
||||
let mut bytes_total: u64 = 0;
|
||||
// Counts consecutive failures (reset on each successful chunk).
|
||||
// laut.fm and similar CDNs force-reconnect every ~700 KB; this is normal.
|
||||
let mut reconnect_count: u32 = 0;
|
||||
const MAX_CONSECUTIVE_FAILURES: u32 = 5;
|
||||
let mut audio_scratch: Vec<u8> = Vec::with_capacity(65_536);
|
||||
|
||||
'outer: loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
|
||||
// ── Obtain response (initial or reconnect) ────────────────────────────
|
||||
let response = match initial_response.take() {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
if reconnect_count >= MAX_CONSECUTIVE_FAILURES {
|
||||
eprintln!("[radio] {MAX_CONSECUTIVE_FAILURES} consecutive failures — giving up");
|
||||
break 'outer;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
match http_client
|
||||
.get(&url)
|
||||
.header("Icy-MetaData", "1")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
eprintln!("[radio] reconnected ({bytes_total} B so far)");
|
||||
r
|
||||
}
|
||||
Ok(r) => {
|
||||
eprintln!("[radio] reconnect: HTTP {} — giving up", r.status());
|
||||
break 'outer;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[radio] reconnect error: {e} — giving up");
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse ICY metaint from each response (consistent across reconnects).
|
||||
let metaint: Option<usize> = response
|
||||
.headers()
|
||||
.get("icy-metaint")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse().ok());
|
||||
let mut icy = metaint.map(IcyInterceptor::new);
|
||||
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
// Stall timer: tracks how long push_slice() returns 0 while paused.
|
||||
let mut stall_since: Option<std::time::Instant> = None;
|
||||
|
||||
'inner: loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
|
||||
// ── Back-pressure + hard-pause detection ──────────────────────────
|
||||
if prod.is_full() {
|
||||
if flags.is_paused.load(Ordering::Relaxed) {
|
||||
let since = stall_since.get_or_insert(std::time::Instant::now());
|
||||
if since.elapsed() >= Duration::from_secs(RADIO_HARD_PAUSE_SECS) {
|
||||
let fill_pct = ((1.0
|
||||
- prod.free_len() as f32 / RADIO_BUF_CAPACITY as f32)
|
||||
* 100.0) as u32;
|
||||
eprintln!(
|
||||
"[radio] hard pause: {fill_pct}% full, \
|
||||
paused >{RADIO_HARD_PAUSE_SECS}s → disconnecting"
|
||||
);
|
||||
flags.is_hard_paused.store(true, Ordering::Release);
|
||||
return; // Drop HeapProducer → TCP connection released.
|
||||
}
|
||||
} else {
|
||||
stall_since = None;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
continue 'inner;
|
||||
}
|
||||
stall_since = None;
|
||||
|
||||
// ── Read HTTP chunk ───────────────────────────────────────────────
|
||||
match byte_stream.next().await {
|
||||
Some(Ok(chunk)) => {
|
||||
bytes_total += chunk.len() as u64;
|
||||
// Successful data → reset consecutive-failure counter.
|
||||
reconnect_count = 0;
|
||||
audio_scratch.clear();
|
||||
|
||||
if let Some(ref mut interceptor) = icy {
|
||||
if let Some(meta) = interceptor.process(&chunk, &mut audio_scratch) {
|
||||
let label = if meta.is_ad { "[Ad]" } else { "" };
|
||||
eprintln!("[radio] ICY StreamTitle: {}{}", label, meta.title);
|
||||
let _ = app.emit("radio:metadata", &meta);
|
||||
}
|
||||
} else {
|
||||
audio_scratch.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
// Push with per-chunk back-pressure: yield 5 ms if full mid-chunk.
|
||||
let mut offset = 0;
|
||||
while offset < audio_scratch.len() {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
let pushed = prod.push_slice(&audio_scratch[offset..]);
|
||||
if pushed == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
} else {
|
||||
offset += pushed;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
reconnect_count += 1;
|
||||
eprintln!("[radio] stream error: {e} → reconnecting (consecutive #{reconnect_count})");
|
||||
break 'inner;
|
||||
}
|
||||
None => {
|
||||
reconnect_count += 1;
|
||||
eprintln!("[radio] stream ended cleanly → reconnecting (consecutive #{reconnect_count})");
|
||||
break 'inner;
|
||||
}
|
||||
}
|
||||
} // 'inner
|
||||
|
||||
// Do NOT swap the ring buffer here. The remaining bytes in the buffer
|
||||
// are still valid audio and will drain naturally during reconnect.
|
||||
// Clearing it would cause an immediate underrun/glitch.
|
||||
// The buffer is kept small (RADIO_BUF_CAPACITY) so stale audio drains
|
||||
// within a few seconds rather than minutes.
|
||||
} // 'outer
|
||||
|
||||
eprintln!("[radio] download task done ({bytes_total} B total)");
|
||||
}
|
||||
|
||||
fn content_type_to_hint(ct: &str) -> Option<String> {
|
||||
let ct = ct.to_ascii_lowercase();
|
||||
if ct.contains("mpeg") || ct.contains("mp3") { Some("mp3".into()) }
|
||||
else if ct.contains("aac") || ct.contains("aacp") { Some("aac".into()) }
|
||||
else if ct.contains("ogg") { Some("ogg".into()) }
|
||||
else if ct.contains("flac") { Some("flac".into()) }
|
||||
else { None }
|
||||
}
|
||||
|
||||
// ─── SizedCursorSource — correct byte_len for seekable in-memory sources ──────
|
||||
//
|
||||
// rodio's internal ReadSeekSource wraps Cursor<Vec<u8>> but hardcodes
|
||||
@@ -689,8 +1010,7 @@ impl SizedDecoder {
|
||||
let track_id = track.id;
|
||||
// Live streams have no known total frame count → total_duration = None.
|
||||
let total_duration = None;
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| format!("radio: codec init failed: {e}"))?;
|
||||
let mut format = probed.format;
|
||||
|
||||
@@ -1055,6 +1375,9 @@ pub struct AudioEngine {
|
||||
/// Instant (as nanos since UNIX epoch via Instant hack) of the last gapless
|
||||
/// auto-advance. Commands arriving within 500 ms are rejected as ghost commands.
|
||||
pub gapless_switch_at: Arc<AtomicU64>,
|
||||
/// Active radio session state. None for regular (non-radio) tracks.
|
||||
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
|
||||
pub radio_state: Mutex<Option<RadioLiveState>>,
|
||||
}
|
||||
|
||||
pub struct AudioCurrent {
|
||||
@@ -1155,6 +1478,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
current_sample_rate: Arc::new(AtomicU32::new(0)),
|
||||
current_channels: Arc::new(AtomicU32::new(2)),
|
||||
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
||||
radio_state: Mutex::new(None),
|
||||
};
|
||||
|
||||
(engine, thread)
|
||||
@@ -1759,38 +2083,95 @@ pub fn audio_pause(state: State<'_, AudioEngine>) {
|
||||
if !sink.is_paused() {
|
||||
let pos = cur.position();
|
||||
sink.pause();
|
||||
cur.paused_at = Some(pos);
|
||||
cur.paused_at = Some(pos);
|
||||
cur.play_started = None;
|
||||
}
|
||||
}
|
||||
// Notify the download task so it can start measuring the hard-pause stall timer.
|
||||
if let Some(rs) = state.radio_state.lock().unwrap().as_ref() {
|
||||
rs.flags.is_paused.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume playback.
|
||||
///
|
||||
/// **Warm resume** (`is_hard_paused = false`): download task is still running,
|
||||
/// buffer has buffered audio. `sink.play()` suffices.
|
||||
///
|
||||
/// **Cold resume** (`is_hard_paused = true`): TCP was dropped. A fresh 4 MB
|
||||
/// ring buffer is created, its consumer is sent to `AudioStreamReader` (which
|
||||
/// swaps it in on the next `read()`), and a new download task is spawned.
|
||||
#[tauri::command]
|
||||
pub fn audio_resume(state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if sink.is_paused() {
|
||||
let pos = cur.paused_at.unwrap_or(cur.seek_offset);
|
||||
sink.play();
|
||||
cur.seek_offset = pos;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Result<(), String> {
|
||||
// Detect radio hard-disconnect.
|
||||
let reconnect_info = {
|
||||
let guard = state.radio_state.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|rs| rs.flags.is_hard_paused.load(Ordering::Acquire))
|
||||
.map(|rs| (rs.url.clone(), rs.gen, rs.flags.clone()))
|
||||
};
|
||||
|
||||
if let Some((url, gen, flags)) = reconnect_info {
|
||||
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
|
||||
let (new_prod, new_cons) = rb.split();
|
||||
|
||||
// Send new consumer to AudioStreamReader (non-blocking; unbounded channel).
|
||||
let ok = flags.new_cons_tx.lock().unwrap().send(new_cons).is_ok();
|
||||
|
||||
if ok {
|
||||
let new_task = tokio::spawn(radio_download_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
None, // task performs its own fresh GET
|
||||
state.http_client.clone(),
|
||||
url,
|
||||
new_prod,
|
||||
flags.clone(),
|
||||
app,
|
||||
));
|
||||
if let Some(rs) = state.radio_state.lock().unwrap().as_mut() {
|
||||
let old = std::mem::replace(&mut rs.task, new_task);
|
||||
old.abort(); // ensure any lingering old task is gone
|
||||
rs.flags.is_hard_paused.store(false, Ordering::Release);
|
||||
rs.flags.is_paused.store(false, Ordering::Release);
|
||||
}
|
||||
} else {
|
||||
eprintln!("[radio] resume: AudioStreamReader gone — skipping reconnect");
|
||||
}
|
||||
}
|
||||
|
||||
// Resume the rodio Sink (works for both warm and cold resume).
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if sink.is_paused() {
|
||||
let pos = cur.paused_at.unwrap_or(cur.seek_offset);
|
||||
sink.play();
|
||||
cur.seek_offset = pos;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(rs) = state.radio_state.lock().unwrap().as_ref() {
|
||||
rs.flags.is_paused.store(false, Ordering::Release);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
|
||||
drop(state.radio_state.lock().unwrap().take());
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = cur.sink.take() {
|
||||
sink.stop();
|
||||
}
|
||||
if let Some(sink) = cur.sink.take() { sink.stop(); }
|
||||
cur.duration_secs = 0.0;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = None;
|
||||
cur.paused_at = None;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = None;
|
||||
cur.paused_at = None;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1868,11 +2249,11 @@ pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, Str
|
||||
.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetches the AutoEQ GraphicEQ profile for a specific headphone from GitHub raw content.
|
||||
/// Fetches the AutoEQ FixedBandEQ profile for a specific headphone from GitHub raw content.
|
||||
///
|
||||
/// Directory layout in the AutoEQ repo:
|
||||
/// results/{source}/{form}/{name}/{name} GraphicEQ.txt (most sources)
|
||||
/// results/{source}/{rig} {form}/{name}/{name} GraphicEQ.txt (crinacle — rig-prefixed dir)
|
||||
/// results/{source}/{form}/{name}/{name} FixedBandEQ.txt (most sources)
|
||||
/// results/{source}/{rig} {form}/{name}/{name} FixedBandEQ.txt (crinacle — rig-prefixed dir)
|
||||
///
|
||||
/// We try the rig-prefixed path first (when rig is present), then fall back to form-only.
|
||||
#[tauri::command]
|
||||
@@ -1884,7 +2265,7 @@ pub async fn autoeq_fetch_profile(
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<String, String> {
|
||||
let base = "https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results";
|
||||
let filename = format!("{} GraphicEQ.txt", name);
|
||||
let filename = format!("{} FixedBandEQ.txt", name);
|
||||
|
||||
let candidates: Vec<String> = if let Some(ref r) = rig {
|
||||
vec![
|
||||
@@ -1902,7 +2283,7 @@ pub async fn autoeq_fetch_profile(
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("GraphicEQ profile not found for '{}'", name))
|
||||
Err(format!("FixedBandEQ profile not found for '{}'", name))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1942,10 +2323,9 @@ pub async fn audio_preload(
|
||||
|
||||
/// Play a live internet radio stream.
|
||||
///
|
||||
/// Unlike `audio_play`, the stream URL is infinite so bytes cannot be
|
||||
/// downloaded upfront. A `RadioBuffer` bridges the async HTTP download into
|
||||
/// the synchronous Read interface that symphonia/rodio expect. Emits
|
||||
/// `audio:playing` with `duration = 0.0` to signal "unknown duration" to JS.
|
||||
/// Sends `Icy-MetaData: 1` to request inline ICY metadata.
|
||||
/// Emits `audio:playing` with `duration = 0.0` (sentinel for live stream)
|
||||
/// and `radio:metadata` whenever the StreamTitle changes.
|
||||
#[tauri::command]
|
||||
pub async fn audio_play_radio(
|
||||
url: String,
|
||||
@@ -1955,7 +2335,9 @@ pub async fn audio_play_radio(
|
||||
) -> Result<(), String> {
|
||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
// Cancel pending chain and fading-out sink immediately so audio stops.
|
||||
// Abort any previous radio task before stopping the sink.
|
||||
drop(state.radio_state.lock().unwrap().take());
|
||||
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
@@ -1963,12 +2345,17 @@ pub async fn audio_play_radio(
|
||||
}
|
||||
if let Some(old) = state.fading_out_sink.lock().unwrap().take() { old.stop(); }
|
||||
|
||||
// Open the HTTP stream.
|
||||
// ── Open initial HTTP connection ──────────────────────────────────────────
|
||||
let response = state.http_client
|
||||
.get(&url)
|
||||
.header("Icy-MetaData", "0") // opt-out of Shoutcast inline metadata
|
||||
.send().await
|
||||
.map_err(|e| { let m = format!("radio: connection failed: {e}"); app.emit("audio:error", &m).ok(); m })?;
|
||||
.header("Icy-MetaData", "1")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let m = format!("radio: connection failed: {e}");
|
||||
app.emit("audio:error", &m).ok();
|
||||
m
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let m = format!("radio: HTTP {}", response.status());
|
||||
@@ -1976,139 +2363,98 @@ pub async fn audio_play_radio(
|
||||
return Err(m);
|
||||
}
|
||||
|
||||
// Derive a format hint from Content-Type so symphonia probes faster.
|
||||
let ct = response.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let fmt_hint: Option<String> = if ct.contains("mpeg") || ct.contains("mp3") {
|
||||
Some("mp3".into())
|
||||
} else if ct.contains("aac") || ct.contains("aacp") {
|
||||
Some("aac".into())
|
||||
} else if ct.contains("ogg") {
|
||||
Some("ogg".into())
|
||||
} else if ct.contains("flac") {
|
||||
Some("flac".into())
|
||||
} else {
|
||||
None
|
||||
let fmt_hint = content_type_to_hint(
|
||||
response.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
);
|
||||
|
||||
// ── Build 4 MB lock-free SPSC ring buffer ─────────────────────────────────
|
||||
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
|
||||
let (prod, cons) = rb.split();
|
||||
|
||||
let (new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
|
||||
let flags = Arc::new(RadioSharedFlags {
|
||||
is_paused: AtomicBool::new(false),
|
||||
is_hard_paused: AtomicBool::new(false),
|
||||
new_cons_tx: Mutex::new(new_cons_tx),
|
||||
});
|
||||
|
||||
// ── Spawn download task ───────────────────────────────────────────────────
|
||||
let task = tokio::spawn(radio_download_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
Some(response),
|
||||
state.http_client.clone(),
|
||||
url.clone(),
|
||||
prod,
|
||||
flags.clone(),
|
||||
app.clone(),
|
||||
));
|
||||
|
||||
*state.radio_state.lock().unwrap() = Some(RadioLiveState {
|
||||
url: url.clone(),
|
||||
gen,
|
||||
task,
|
||||
flags: flags.clone(),
|
||||
});
|
||||
|
||||
// ── Build Symphonia decoder in a blocking thread ──────────────────────────
|
||||
let reader = AudioStreamReader {
|
||||
cons,
|
||||
new_cons_rx: Mutex::new(new_cons_rx),
|
||||
deadline: std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
|
||||
gen_arc: state.generation.clone(),
|
||||
gen,
|
||||
pos: 0,
|
||||
};
|
||||
|
||||
let (radio_buf, feed) = RadioBuffer::new();
|
||||
let feed = Arc::new(feed);
|
||||
|
||||
// Background task: stream HTTP chunks into RadioBuffer with auto-reconnect.
|
||||
// Many radio CDNs (Shoutcast/Icecast) close the TCP connection periodically.
|
||||
// Rather than stopping, we reconnect to the same URL and keep pushing into
|
||||
// the same RadioBuffer so the decoder recovers with only a brief glitch.
|
||||
{
|
||||
let gen_arc = state.generation.clone();
|
||||
let feed2 = feed.clone();
|
||||
let http_client2 = state.http_client.clone();
|
||||
let url2 = url.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut bytes_total: u64 = 0;
|
||||
let mut reconnects: u32 = 0;
|
||||
// Use the already-open response for the first connection; reconnect as needed.
|
||||
let mut response_opt: Option<reqwest::Response> = Some(response);
|
||||
|
||||
'outer: loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
eprintln!("[radio] download: gen mismatch → exit ({bytes_total} bytes, {reconnects} reconnects)");
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
let resp = match response_opt.take() {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { break 'outer; }
|
||||
match http_client2.get(&url2).header("Icy-MetaData", "0").send().await {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
reconnects += 1;
|
||||
eprintln!("[radio] reconnected #{reconnects} ({bytes_total} bytes so far)");
|
||||
feed2.flush(); // clear stale buffer so decoder gets a clean stream start
|
||||
r
|
||||
},
|
||||
Ok(r) => { eprintln!("[radio] reconnect failed: HTTP {} — giving up", r.status()); break 'outer; },
|
||||
Err(e) => { eprintln!("[radio] reconnect error: {e} — giving up"); break 'outer; },
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { break 'outer; }
|
||||
if feed2.is_full() {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
continue;
|
||||
}
|
||||
match stream.next().await {
|
||||
Some(Ok(chunk)) => {
|
||||
bytes_total += chunk.len() as u64;
|
||||
feed2.push(&chunk);
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
eprintln!("[radio] stream error: {e} → reconnecting (attempt {})", reconnects + 1);
|
||||
break; // triggers outer reconnect
|
||||
},
|
||||
None => {
|
||||
eprintln!("[radio] stream ended cleanly → reconnecting (attempt {})", reconnects + 1);
|
||||
break;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[radio] download task exiting, closing feed");
|
||||
feed2.close();
|
||||
});
|
||||
}
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
// Build symphonia decoder in a blocking thread (RadioBuffer::read blocks).
|
||||
let hint_clone = fmt_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(radio_buf), fmt_hint.as_deref())
|
||||
}).await.map_err(|e| e.to_string())??;
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
state.samples_played.store(0, Ordering::Relaxed);
|
||||
|
||||
// Radio: no gapless trim, no ReplayGain, 5 ms micro-fade to suppress click.
|
||||
let dyn_src = DynSource::new(decoder.convert_samples::<f32>());
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(), state.eq_enabled.clone(), state.eq_pre_gain.clone());
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
state.samples_played.store(0, Ordering::Relaxed);
|
||||
|
||||
// Radio: no gapless trim, no ReplayGain, 5 ms fade-in to suppress click.
|
||||
let dyn_src = DynSource::new(decoder.convert_samples::<f32>());
|
||||
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(), state.eq_pre_gain.clone());
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag.clone());
|
||||
let counting = CountingSource::new(notifying, state.samples_played.clone());
|
||||
let counting = CountingSource::new(notifying, state.samples_played.clone());
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
|
||||
let effective_vol = (volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_vol);
|
||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
sink.append(counting);
|
||||
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(old) = cur.sink.take() { old.stop(); }
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = 0.0; // sentinel: live stream
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = 0.0; // sentinel: live stream
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = 1.0;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(fadeout_trigger);
|
||||
cur.fadeout_samples = Some(fadeout_samples);
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(fadeout_trigger);
|
||||
cur.fadeout_samples = Some(fadeout_samples);
|
||||
}
|
||||
|
||||
state.current_sample_rate.store(sample_rate, Ordering::Relaxed);
|
||||
@@ -2137,7 +2483,7 @@ pub async fn audio_play_radio(
|
||||
#[tauri::command]
|
||||
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
|
||||
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
|
||||
state.crossfade_secs.store(secs.clamp(0.5, 12.0).to_bits(), Ordering::Relaxed);
|
||||
state.crossfade_secs.store(secs.clamp(0.1, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+255
-38
@@ -75,6 +75,170 @@ fn relaunch_after_update(app: tauri::AppHandle) {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
/// Authenticate with Navidrome's own REST API and return a Bearer token.
|
||||
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/auth/login", server_url))
|
||||
.json(&serde_json::json!({ "username": username, "password": password }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
data["token"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn upload_playlist_cover(
|
||||
server_url: String,
|
||||
playlist_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn upload_radio_cover(
|
||||
server_url: String,
|
||||
radio_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_radio_cover(
|
||||
server_url: String,
|
||||
radio_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 404/503 = no image existed — treat as success
|
||||
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
|
||||
resp.error_for_status().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const RADIO_PAGE_SIZE: u32 = 25;
|
||||
|
||||
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
|
||||
#[tauri::command]
|
||||
async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let limit_s = RADIO_PAGE_SIZE.to_string();
|
||||
let offset_s = offset.to_string();
|
||||
let resp = client
|
||||
.get("https://de1.api.radio-browser.info/json/stations/search")
|
||||
.header("User-Agent", "psysonic/1.0")
|
||||
.query(&[
|
||||
("name", query.as_str()),
|
||||
("hidebroken", "true"),
|
||||
("limit", limit_s.as_str()),
|
||||
("offset", offset_s.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch top-voted stations from radio-browser.info for initial suggestions.
|
||||
#[tauri::command]
|
||||
async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let limit_s = RADIO_PAGE_SIZE.to_string();
|
||||
let offset_s = offset.to_string();
|
||||
let resp = client
|
||||
.get("https://de1.api.radio-browser.info/json/stations/topvote")
|
||||
.header("User-Agent", "psysonic/1.0")
|
||||
.query(&[("limit", limit_s.as_str()), ("offset", offset_s.as_str())])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS.
|
||||
/// Returns (bytes, content_type).
|
||||
#[tauri::command]
|
||||
async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "psysonic/1.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("image/jpeg")
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("image/jpeg")
|
||||
.trim()
|
||||
.to_string();
|
||||
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
|
||||
Ok((bytes.to_vec(), content_type))
|
||||
}
|
||||
|
||||
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
|
||||
/// `params` is a list of [key, value] pairs (method must be included).
|
||||
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
|
||||
@@ -244,14 +408,24 @@ async fn download_track_offline(
|
||||
server_id: String,
|
||||
url: String,
|
||||
suffix: String,
|
||||
custom_dir: Option<String>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let cache_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("psysonic-offline")
|
||||
.join(&server_id);
|
||||
// Determine base cache directory.
|
||||
let cache_dir = if let Some(ref cd) = custom_dir {
|
||||
let base = std::path::PathBuf::from(cd);
|
||||
// Check that the volume/directory is still accessible.
|
||||
if !base.exists() {
|
||||
return Err("VOLUME_NOT_FOUND".to_string());
|
||||
}
|
||||
base.join(&server_id)
|
||||
} else {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("psysonic-offline")
|
||||
.join(&server_id)
|
||||
};
|
||||
|
||||
tokio::fs::create_dir_all(&cache_dir)
|
||||
.await
|
||||
@@ -282,56 +456,93 @@ async fn download_track_offline(
|
||||
Ok(path_str)
|
||||
}
|
||||
|
||||
/// Returns the total size in bytes of all files in the offline cache directory.
|
||||
/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir).
|
||||
#[tauri::command]
|
||||
async fn get_offline_cache_size(app: tauri::AppHandle) -> u64 {
|
||||
let offline_dir = match app.path().app_data_dir() {
|
||||
async fn get_offline_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
|
||||
fn dir_size(root: std::path::PathBuf) -> u64 {
|
||||
if !root.exists() { return 0; }
|
||||
let mut total: u64 = 0;
|
||||
let mut stack = vec![root];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let rd = match std::fs::read_dir(&dir) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Ok(meta) = std::fs::metadata(&path) {
|
||||
total += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
let default_dir = match app.path().app_data_dir() {
|
||||
Ok(d) => d.join("psysonic-offline"),
|
||||
Err(_) => return 0,
|
||||
};
|
||||
if !offline_dir.exists() {
|
||||
return 0;
|
||||
}
|
||||
let mut total: u64 = 0;
|
||||
let mut stack = vec![offline_dir];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let rd = match std::fs::read_dir(&dir) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Ok(meta) = std::fs::metadata(&path) {
|
||||
total += meta.len();
|
||||
}
|
||||
let mut total = dir_size(default_dir);
|
||||
|
||||
if let Some(cd) = custom_dir {
|
||||
let custom = std::path::PathBuf::from(cd);
|
||||
if custom != std::path::PathBuf::from("") {
|
||||
total += dir_size(custom);
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Removes a cached track from the offline cache directory.
|
||||
/// Removes a cached track from the offline cache. Accepts the full local path
|
||||
/// (stored in OfflineTrackMeta) so it works regardless of which directory was used.
|
||||
/// After deleting the file, empty parent directories up to (but not including)
|
||||
/// `base_dir` are pruned using `remove_dir` (never `remove_dir_all`).
|
||||
#[tauri::command]
|
||||
async fn delete_offline_track(
|
||||
track_id: String,
|
||||
server_id: String,
|
||||
suffix: String,
|
||||
local_path: String,
|
||||
base_dir: Option<String>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let file_path = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("psysonic-offline")
|
||||
.join(&server_id)
|
||||
.join(format!("{}.{}", track_id, suffix));
|
||||
|
||||
let file_path = std::path::PathBuf::from(&local_path);
|
||||
if file_path.exists() {
|
||||
tokio::fs::remove_file(&file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Determine the safe boundary — never delete at or above this directory.
|
||||
let boundary = if let Some(bd) = base_dir.filter(|s| !s.is_empty()) {
|
||||
std::path::PathBuf::from(bd)
|
||||
} else {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("psysonic-offline")
|
||||
};
|
||||
|
||||
// Walk upward, pruning directories that have become empty.
|
||||
// Stops as soon as a non-empty directory or the boundary is reached.
|
||||
let mut current = file_path.parent().map(|p| p.to_path_buf());
|
||||
while let Some(dir) = current {
|
||||
if dir == boundary || !dir.starts_with(&boundary) {
|
||||
break;
|
||||
}
|
||||
match std::fs::read_dir(&dir) {
|
||||
Ok(mut entries) => {
|
||||
if entries.next().is_some() {
|
||||
break; // Directory still has contents — stop pruning.
|
||||
}
|
||||
if std::fs::remove_dir(&dir).is_err() {
|
||||
break; // Could not remove (e.g. permissions) — stop.
|
||||
}
|
||||
current = dir.parent().map(|p| p.to_path_buf());
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -546,6 +757,12 @@ pub fn run() {
|
||||
discord::discord_update_presence,
|
||||
discord::discord_clear_presence,
|
||||
lastfm_request,
|
||||
upload_playlist_cover,
|
||||
upload_radio_cover,
|
||||
delete_radio_cover,
|
||||
search_radio_browser,
|
||||
get_top_radio_stations,
|
||||
fetch_url_bytes,
|
||||
download_track_offline,
|
||||
delete_offline_track,
|
||||
get_offline_cache_size,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -96,6 +96,14 @@ export interface InternetRadioStation {
|
||||
coverArt?: string; // Navidrome v0.61.0+
|
||||
}
|
||||
|
||||
export interface RadioBrowserStation {
|
||||
stationuuid: string;
|
||||
name: string;
|
||||
url: string;
|
||||
favicon: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
export interface SubsonicPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -105,6 +113,7 @@ export interface SubsonicPlaylist {
|
||||
changed: string;
|
||||
owner?: string;
|
||||
public?: boolean;
|
||||
comment?: string;
|
||||
coverArt?: string;
|
||||
}
|
||||
|
||||
@@ -417,6 +426,33 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount =
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePlaylistMeta(
|
||||
id: string,
|
||||
name: string,
|
||||
comment: string,
|
||||
isPublic: boolean,
|
||||
): Promise<void> {
|
||||
await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic });
|
||||
}
|
||||
|
||||
export async function uploadPlaylistCoverArt(id: string, file: File): Promise<void> {
|
||||
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const fileBytes = Array.from(new Uint8Array(buffer));
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('upload_playlist_cover', {
|
||||
serverUrl: baseUrl,
|
||||
playlistId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
fileBytes,
|
||||
mimeType: file.type || 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePlaylist(id: string): Promise<void> {
|
||||
await api('deletePlaylist.view', { id });
|
||||
}
|
||||
@@ -483,3 +519,79 @@ export async function updateInternetRadioStation(
|
||||
export async function deleteInternetRadioStation(id: string): Promise<void> {
|
||||
await api('deleteInternetRadioStation.view', { id });
|
||||
}
|
||||
|
||||
export async function uploadRadioCoverArt(id: string, file: File): Promise<void> {
|
||||
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const fileBytes = Array.from(new Uint8Array(buffer));
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('upload_radio_cover', {
|
||||
serverUrl: baseUrl,
|
||||
radioId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
fileBytes,
|
||||
mimeType: file.type || 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteRadioCoverArt(id: string): Promise<void> {
|
||||
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('delete_radio_cover', {
|
||||
serverUrl: baseUrl,
|
||||
radioId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], mimeType: string): Promise<void> {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('upload_radio_cover', {
|
||||
serverUrl: baseUrl,
|
||||
radioId: id,
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
fileBytes,
|
||||
mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
function parseRadioBrowserStations(raw: Array<Record<string, string>>): RadioBrowserStation[] {
|
||||
return raw.map(s => ({
|
||||
stationuuid: s.stationuuid ?? '',
|
||||
name: s.name ?? '',
|
||||
url: s.url ?? '',
|
||||
favicon: s.favicon ?? '',
|
||||
tags: s.tags ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
export const RADIO_PAGE_SIZE = 25;
|
||||
|
||||
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
|
||||
return parseRadioBrowserStations(raw);
|
||||
}
|
||||
|
||||
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const raw = await invoke<Array<Record<string, string>>>('get_top_radio_stations', { offset });
|
||||
return parseRadioBrowserStations(raw);
|
||||
}
|
||||
|
||||
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
return invoke<[number[], string]>('fetch_url_bytes', { url });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -46,85 +47,25 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
// 'num' → always 60 px fixed, no resize handle
|
||||
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
|
||||
// rest → persistent px values from useTracklistColumns hook
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true, fixed: true },
|
||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 100, defaultWidth: 220, required: true, fixed: false },
|
||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 160, required: false, fixed: false },
|
||||
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false, fixed: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 100, required: false, fixed: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 60, required: false, fixed: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false, fixed: false },
|
||||
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 80, required: false, fixed: false },
|
||||
] as const;
|
||||
const 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: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
||||
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
|
||||
];
|
||||
|
||||
type ColKey = (typeof COLUMNS)[number]['key'];
|
||||
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
|
||||
|
||||
const DEFAULT_WIDTHS: Record<ColKey, number> = Object.fromEntries(
|
||||
COLUMNS.map(c => [c.key, c.defaultWidth])
|
||||
) as Record<ColKey, number>;
|
||||
|
||||
const DEFAULT_VISIBLE = new Set<ColKey>([
|
||||
'num', 'title', 'artist', 'favorite', 'rating', 'duration', 'format', 'genre',
|
||||
]);
|
||||
|
||||
function loadColPrefs(): { widths: Record<ColKey, number>; visible: Set<ColKey> } {
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic_tracklist_columns');
|
||||
if (!raw) return { widths: { ...DEFAULT_WIDTHS }, visible: new Set(DEFAULT_VISIBLE) };
|
||||
const parsed = JSON.parse(raw);
|
||||
const visible = new Set<ColKey>((parsed.visible as ColKey[]) ?? [...DEFAULT_VISIBLE]);
|
||||
COLUMNS.filter(c => c.required).forEach(c => visible.add(c.key as ColKey));
|
||||
return {
|
||||
widths: { ...DEFAULT_WIDTHS, ...(parsed.widths ?? {}) },
|
||||
visible,
|
||||
};
|
||||
} catch {
|
||||
return { widths: { ...DEFAULT_WIDTHS }, visible: new Set(DEFAULT_VISIBLE) };
|
||||
}
|
||||
}
|
||||
|
||||
function saveColPrefs(widths: Record<ColKey, number>, visible: Set<ColKey>) {
|
||||
localStorage.setItem('psysonic_tracklist_columns', JSON.stringify({
|
||||
widths,
|
||||
visible: [...visible],
|
||||
}));
|
||||
}
|
||||
|
||||
/** Scale flexible (non-fixed) visible columns proportionally to fill `targetW` exactly.
|
||||
* Fixed columns (e.g. 'num') keep their width unchanged.
|
||||
* Each flexible column is clamped to its minWidth; rounding error is absorbed by 'title'. */
|
||||
function fitColumnsToWidth(
|
||||
widths: Record<ColKey, number>,
|
||||
vCols: readonly { readonly key: string; readonly minWidth: number; readonly fixed: boolean }[],
|
||||
targetW: number,
|
||||
gapPx: number
|
||||
): Record<ColKey, number> {
|
||||
if (vCols.length === 0 || targetW <= 0) return widths;
|
||||
const next = { ...widths };
|
||||
const fixedCols = vCols.filter(c => c.fixed);
|
||||
const flexCols = vCols.filter(c => !c.fixed);
|
||||
if (flexCols.length === 0) return next;
|
||||
const totalGaps = Math.max(0, vCols.length - 1) * gapPx;
|
||||
const fixedTotal = fixedCols.reduce((s, c) => s + (next[c.key as ColKey] ?? c.minWidth), 0);
|
||||
const available = targetW - totalGaps - fixedTotal;
|
||||
if (available <= 0) return next;
|
||||
const currentFlexTotal = flexCols.reduce((s, c) => s + (next[c.key as ColKey] ?? c.minWidth), 0);
|
||||
if (currentFlexTotal === 0) return next;
|
||||
const ratio = available / currentFlexTotal;
|
||||
flexCols.forEach(c => {
|
||||
const key = c.key as ColKey;
|
||||
next[key] = Math.max(c.minWidth, Math.round((next[key] ?? c.minWidth) * ratio));
|
||||
});
|
||||
// Correct rounding drift in 'title' column
|
||||
const newFlexTotal = flexCols.reduce((s, c) => s + next[c.key as ColKey], 0);
|
||||
const diff = available - newFlexTotal;
|
||||
if (flexCols.some(c => c.key === 'title') && diff !== 0) {
|
||||
const titleDef = COLUMNS.find(c => c.key === 'title')!;
|
||||
next['title'] = Math.max(titleDef.minWidth, next['title'] + diff);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
// Columns where cell content should be centred (both header and rows)
|
||||
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -164,142 +105,12 @@ export default function AlbumTrackList({
|
||||
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
|
||||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||||
|
||||
// ── Column state ──────────────────────────────────────────────────────────
|
||||
const [colWidths, setColWidths] = useState<Record<ColKey, number>>(() => loadColPrefs().widths);
|
||||
const [colVisible, setColVisible] = useState<Set<ColKey>>(() => loadColPrefs().visible);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const tracklistRef = useRef<HTMLDivElement>(null);
|
||||
const prevContainerW = useRef(0);
|
||||
const colVisibleRef = useRef(colVisible);
|
||||
useEffect(() => { colVisibleRef.current = colVisible; }, [colVisible]);
|
||||
|
||||
// Stores the user's last intentional column widths + the container W they match.
|
||||
// ResizeObserver always scales FROM this base — never from intermediate scaled values.
|
||||
// This prevents drift when the window is shrunk and enlarged again.
|
||||
const baseWidthsRef = useRef<{ widths: Record<ColKey, number>; containerW: number } | null>(null);
|
||||
// Tracks current colWidths without a useEffect dependency in callbacks
|
||||
const colWidthsRef = useRef(colWidths);
|
||||
useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
|
||||
|
||||
// On mount: fit saved (or default) widths to current container; establish base.
|
||||
useLayoutEffect(() => {
|
||||
const el = tracklistRef.current;
|
||||
if (!el) return;
|
||||
const style = getComputedStyle(el);
|
||||
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const containerW = el.clientWidth - paddingH;
|
||||
prevContainerW.current = containerW;
|
||||
const vCols = COLUMNS.filter(c => colVisibleRef.current.has(c.key));
|
||||
setColWidths(prev => {
|
||||
const fitted = fitColumnsToWidth(prev, vCols, containerW, 12);
|
||||
baseWidthsRef.current = { widths: fitted, containerW };
|
||||
return fitted;
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// When the container resizes, scale all columns proportionally FROM the base.
|
||||
// Using the base (not prev) means shrink → grow always returns to exact original widths.
|
||||
useEffect(() => {
|
||||
const el = tracklistRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const style = getComputedStyle(el);
|
||||
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const newW = el.clientWidth - paddingH;
|
||||
if (Math.abs(newW - prevContainerW.current) < 2) return;
|
||||
prevContainerW.current = newW;
|
||||
const base = baseWidthsRef.current;
|
||||
if (!base) return;
|
||||
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
|
||||
const gapPx = headerEl ? (parseFloat(getComputedStyle(headerEl).columnGap) || 12) : 12;
|
||||
const vCols = COLUMNS.filter(c => colVisibleRef.current.has(c.key));
|
||||
// Always scale from base.widths, never from current state → no drift
|
||||
setColWidths(() => fitColumnsToWidth(base.widths, vCols, newW, gapPx));
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// All visible columns in order
|
||||
const visibleCols = useMemo(
|
||||
() => COLUMNS.filter(c => colVisible.has(c.key)),
|
||||
[colVisible]
|
||||
);
|
||||
|
||||
// Grid template: all fixed px — bidirectional resize works correctly
|
||||
const gridTemplate = useMemo(
|
||||
() => visibleCols.map(c => `${colWidths[c.key]}px`).join(' '),
|
||||
[colWidths, visibleCols]
|
||||
);
|
||||
|
||||
const colStyle = { gridTemplateColumns: gridTemplate };
|
||||
|
||||
|
||||
// ── Bidirectional resize ─────────────────────────────────────────────────
|
||||
// Dragging the divider between col[colIndex] and col[colIndex+1]:
|
||||
// → right: colA grows, colB shrinks (clamped to minWidth)
|
||||
// → left: colA shrinks, colB grows (clamped to minWidth)
|
||||
// Excel-style resize: only the dragged column changes width.
|
||||
// Clamped so total never exceeds container width — no overflow, no scrollbar.
|
||||
const startResize = (e: React.MouseEvent, colIndex: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const colA = visibleCols[colIndex];
|
||||
const defA = COLUMNS.find(c => c.key === colA.key)!;
|
||||
const startX = e.clientX;
|
||||
const startW = colWidths[colA.key as ColKey];
|
||||
const snapshotVisible = colVisible;
|
||||
|
||||
// Measure container once at drag start
|
||||
let maxW = Infinity;
|
||||
const el = tracklistRef.current;
|
||||
if (el) {
|
||||
const style = getComputedStyle(el);
|
||||
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const containerW = el.clientWidth - paddingH;
|
||||
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
|
||||
const gapPx = headerEl ? (parseFloat(getComputedStyle(headerEl).columnGap) || 0) : 12;
|
||||
const sumOthers = visibleCols
|
||||
.filter((_, i) => i !== colIndex)
|
||||
.reduce((s, c) => s + colWidths[c.key as ColKey], 0);
|
||||
maxW = Math.max(defA.minWidth, containerW - sumOthers - (visibleCols.length - 1) * gapPx);
|
||||
}
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
const newW = Math.min(Math.max(defA.minWidth, startW + me.clientX - startX), maxW);
|
||||
setColWidths(prev => ({ ...prev, [colA.key]: newW }));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
// Save final state and update base so future window resizes scale from here
|
||||
const finalWidths = colWidthsRef.current;
|
||||
baseWidthsRef.current = { widths: { ...finalWidths }, containerW: prevContainerW.current };
|
||||
saveColPrefs(finalWidths, snapshotVisible);
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
const toggleColumn = (key: ColKey) => {
|
||||
const def = COLUMNS.find(c => c.key === key)!;
|
||||
if (def.required) return;
|
||||
setColVisible(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
saveColPrefs(colWidths, next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
// ── Column state (resize, visibility, picker) via shared hook ────────────
|
||||
const {
|
||||
colWidths, colVisible, visibleCols, gridStyle,
|
||||
startResize, toggleColumn,
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
|
||||
|
||||
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
@@ -333,17 +144,6 @@ export default function AlbumTrackList({
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showPlPicker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [pickerOpen]);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
songs.forEach(song => {
|
||||
const disc = song.discNumber ?? 1;
|
||||
@@ -356,13 +156,13 @@ export default function AlbumTrackList({
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
|
||||
// ── Header cell renderer ──────────────────────────────────────────────────
|
||||
const renderHeaderCell = (colDef: (typeof COLUMNS)[number], colIndex: number) => {
|
||||
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
|
||||
const key = colDef.key as ColKey;
|
||||
const isLastCol = colIndex === visibleCols.length - 1;
|
||||
const isCentered = key === 'favorite' || key === 'rating' || key === 'duration';
|
||||
const isCentered = CENTERED_COLS.has(key);
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
|
||||
|
||||
// 'num' header mirrors the row-cell layout exactly so checkbox + # stay aligned
|
||||
// num header: checkbox + # label, mirrors row-cell layout exactly
|
||||
if (key === 'num') {
|
||||
return (
|
||||
<div key={key} className="track-num">
|
||||
@@ -376,21 +176,31 @@ export default function AlbumTrackList({
|
||||
);
|
||||
}
|
||||
|
||||
// title (1fr): label + divider on RIGHT edge that controls the NEXT px column (drag→shrinks it)
|
||||
if (key === 'title') {
|
||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{hasNextCol && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// px-width columns: centred or left-aligned label + right-edge divider (except last col)
|
||||
// direction=1: drag right → this column grows, title (1fr) shrinks
|
||||
const isResizable = !isLastCol;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={isCentered ? 'col-center' : undefined}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</span>
|
||||
{/* Resize handle on all non-fixed columns except the last */}
|
||||
{!isLastCol && !colDef.fixed && (
|
||||
<div
|
||||
className="col-resize-handle"
|
||||
onMouseDown={e => startResize(e, colIndex)}
|
||||
/>
|
||||
<div key={key} data-align={isCentered ? 'center' : 'start'} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{isResizable && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -521,7 +331,7 @@ export default function AlbumTrackList({
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="tracklist-header" style={colStyle}>
|
||||
<div className="tracklist-header" style={gridStyle}>
|
||||
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
|
||||
</div>
|
||||
|
||||
@@ -538,14 +348,13 @@ export default function AlbumTrackList({
|
||||
<div className="tracklist-col-picker-menu">
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{COLUMNS.filter(c => !c.required).map(c => {
|
||||
const key = c.key as ColKey;
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : key;
|
||||
const isOn = colVisible.has(key);
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key;
|
||||
const isOn = colVisible.has(c.key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
key={c.key}
|
||||
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
|
||||
onClick={() => toggleColumn(key)}
|
||||
onClick={() => toggleColumn(c.key)}
|
||||
>
|
||||
<span className="tracklist-col-picker-check">
|
||||
{isOn && <Check size={13} />}
|
||||
@@ -574,7 +383,7 @@ export default function AlbumTrackList({
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
style={colStyle}
|
||||
style={gridStyle}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (inSelectMode) {
|
||||
|
||||
@@ -20,7 +20,7 @@ function isNewer(a: string, b: string): boolean {
|
||||
|
||||
type State =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'available'; version: string; update: Update | null }
|
||||
| { phase: 'available'; version: string; update: Update | null; error?: string }
|
||||
| { phase: 'downloading'; pct: number }
|
||||
| { phase: 'installing' }
|
||||
| { phase: 'done' };
|
||||
@@ -82,9 +82,11 @@ export default function AppUpdater() {
|
||||
}
|
||||
});
|
||||
await invoke('relaunch_after_update');
|
||||
} catch (e) {
|
||||
console.error('Update failed', e);
|
||||
setState({ phase: 'available', version: savedVersion, update });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error('Update failed:', msg);
|
||||
// Surface the error so the user (and developer) can see what went wrong
|
||||
setState({ phase: 'available', version: savedVersion, update, error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,6 +124,9 @@ export default function AppUpdater() {
|
||||
|
||||
{state.phase === 'available' && (
|
||||
<div className="app-updater-actions">
|
||||
{state.error && (
|
||||
<div className="app-updater-error">{state.error}</div>
|
||||
)}
|
||||
{canInstall && (
|
||||
<>
|
||||
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
|
||||
|
||||
@@ -148,7 +148,7 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) {
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||
const gain = Math.round(pctToGain(pct) / 0.1) * 0.1; // snap to 0.1 dB
|
||||
const gain = parseFloat((Math.round(pctToGain(pct) / 0.1) * 0.1).toFixed(1)); // snap to 0.1 dB
|
||||
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
|
||||
}, [onChange]);
|
||||
|
||||
@@ -189,30 +189,24 @@ interface AutoEqVariant { form: string; rig: string | null; source: string; }
|
||||
interface AutoEqResult { name: string; source: string; rig: string | null; form: string; }
|
||||
|
||||
|
||||
function parseGraphicEqString(graphicEqStr: string): number[] {
|
||||
const line = graphicEqStr.replace(/^GraphicEQ:\s*/i, '');
|
||||
const points: [number, number][] = line
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.map(s => { const [f, g] = s.split(/\s+/).map(Number); return [f, g] as [number, number]; })
|
||||
.filter(([f, g]) => !isNaN(f) && !isNaN(g));
|
||||
/** Parses AutoEQ FixedBandEQ.txt format.
|
||||
* Expected lines:
|
||||
* Preamp: -5.5 dB
|
||||
* Filter 1: ON PK Fc 31 Hz Gain -0.2 dB Q 1.41
|
||||
* ...
|
||||
* Returns all 10 band gains as exact floats and the preamp value.
|
||||
*/
|
||||
function parseFixedBandEqString(text: string): { gains: number[]; preamp: number } {
|
||||
const preampMatch = text.match(/Preamp:\s*(-?\d+(?:\.\d+)?)\s*dB/i);
|
||||
const preamp = preampMatch ? parseFloat(preampMatch[1]) : 0;
|
||||
|
||||
if (points.length === 0) return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
||||
return [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].map(targetFreq => {
|
||||
if (targetFreq <= points[0][0]) return points[0][1];
|
||||
if (targetFreq >= points[points.length - 1][0]) return points[points.length - 1][1];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
if (points[i][0] <= targetFreq && points[i + 1][0] >= targetFreq) {
|
||||
const lo = points[i], hi = points[i + 1];
|
||||
if (lo[0] === hi[0]) return lo[1];
|
||||
const t = (Math.log10(targetFreq) - Math.log10(lo[0])) / (Math.log10(hi[0]) - Math.log10(lo[0]));
|
||||
return Math.round((lo[1] + t * (hi[1] - lo[1])) / 0.1) * 0.1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
const gains: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
const allFilters = [...text.matchAll(/^Filter\s+\d+:\s+ON\s+PK\s+.*?Gain\s+(-?\d+(?:\.\d+)?)\s+dB/gim)];
|
||||
allFilters.slice(0, 10).forEach((m, i) => {
|
||||
gains[i] = parseFloat(m[1]);
|
||||
});
|
||||
|
||||
return { gains, preamp };
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
@@ -303,9 +297,8 @@ export default function Equalizer() {
|
||||
form: result.form,
|
||||
});
|
||||
if (!text) throw new Error(t('settings.eqAutoEqFetchError'));
|
||||
const newGains = parseGraphicEqString(text);
|
||||
// autoeq.app normalizes gains (preamp baked in) — apply with 0 pre-gain
|
||||
applyAutoEq(result.name, newGains, 0);
|
||||
const { gains: newGains, preamp } = parseFixedBandEqString(text);
|
||||
applyAutoEq(result.name, newGains, preamp);
|
||||
setAutoEqApplied(result.name);
|
||||
setAutoEqQuery('');
|
||||
setAutoEqResults([]);
|
||||
|
||||
@@ -61,11 +61,12 @@ export default function PlayerBar() {
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// Cover art: prefer radio station art, fall back to track art.
|
||||
// Note: getCoverArt.view needs ra-{id}, not the raw coverArt filename Navidrome returns.
|
||||
const radioCoverSrc = useMemo(
|
||||
() => currentRadio?.coverArt ? buildCoverArtUrl(currentRadio.coverArt, 128) : '',
|
||||
[currentRadio?.coverArt]
|
||||
() => currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 128) : '',
|
||||
[currentRadio?.coverArt, currentRadio?.id]
|
||||
);
|
||||
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(currentRadio.coverArt, 128) : '';
|
||||
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 128) : '';
|
||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
|
||||
|
||||
|
||||
@@ -216,7 +216,10 @@ export default function QueuePanel() {
|
||||
const queueListRef = useRef<HTMLDivElement>(null);
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
|
||||
const { isDragging: isPsyDragging, startDrag } = useDragDrop();
|
||||
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
|
||||
const isRadioDrag = isPsyDragging && !!psyPayload && (() => {
|
||||
try { return JSON.parse(psyPayload.data).type === 'radio'; } catch { return false; }
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPsyDragging) {
|
||||
@@ -240,6 +243,9 @@ export default function QueuePanel() {
|
||||
let parsedData: any = null;
|
||||
try { parsedData = JSON.parse(detail.data); } catch { return; }
|
||||
|
||||
// Radio streams are not tracks — reject silently
|
||||
if (parsedData.type === 'radio') return;
|
||||
|
||||
const dropTarget = externalDropTargetRef.current;
|
||||
externalDropTargetRef.current = null;
|
||||
setExternalDropTarget(null);
|
||||
@@ -304,9 +310,9 @@ export default function QueuePanel() {
|
||||
return (
|
||||
<aside
|
||||
ref={asideRef}
|
||||
className={`queue-panel${isPsyDragging ? ' queue-drop-active' : ''}`}
|
||||
className={`queue-panel${isPsyDragging && !isRadioDrag ? ' queue-drop-active' : ''}`}
|
||||
onMouseMove={e => {
|
||||
if (!isPsyDragging || !queueListRef.current) return;
|
||||
if (!isPsyDragging || isRadioDrag || !queueListRef.current) return;
|
||||
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
|
||||
let found = false;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
@@ -469,22 +475,22 @@ export default function QueuePanel() {
|
||||
<div className="crossfade-popover-label">
|
||||
<Waves size={11} />
|
||||
{t('queue.crossfade')}
|
||||
<span className="crossfade-popover-value">{crossfadeSecs}s</span>
|
||||
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
step={0.1}
|
||||
value={crossfadeSecs}
|
||||
onChange={e => {
|
||||
setCrossfadeSecs(Number(e.target.value));
|
||||
setCrossfadeSecs(parseFloat(e.target.value));
|
||||
setCrossfadeEnabled(true);
|
||||
}}
|
||||
className="crossfade-popover-slider"
|
||||
/>
|
||||
<div className="crossfade-popover-range">
|
||||
<span>1s</span><span>10s</span>
|
||||
<span>0.1s</span><span>10s</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
|
||||
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
|
||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||
// radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, // TODO: unhide when radio is ready
|
||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, // TODO: unhide when radio is ready
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
|
||||
+319
-34
@@ -182,6 +182,7 @@ const enTranslation = {
|
||||
enqueueAll: 'Add all to queue',
|
||||
playAll: 'Play all',
|
||||
removeSong: 'Remove from favorites',
|
||||
stations: 'Radio Stations',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Random Albums',
|
||||
@@ -237,6 +238,10 @@ const enTranslation = {
|
||||
sortByArtist: 'A–Z (Artist)',
|
||||
sortNewest: 'Newest first',
|
||||
sortRandom: 'Random',
|
||||
yearFrom: 'From',
|
||||
yearTo: 'To',
|
||||
yearFilterClear: 'Clear year filter',
|
||||
yearFilterLabel: 'Year',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artists',
|
||||
@@ -393,11 +398,19 @@ const enTranslation = {
|
||||
behavior: 'App Behavior',
|
||||
cacheTitle: 'Max. Storage Size',
|
||||
cacheDesc: 'Cover art and artist images. When full, the oldest entries are removed automatically. Offline albums are not auto-removed, but will be deleted when clearing the cache manually.',
|
||||
cacheUsed: 'Used: {{images}} images · {{offline}} offline tracks',
|
||||
cacheUsedImages: 'Images:',
|
||||
cacheUsedOffline: 'Offline tracks:',
|
||||
cacheMaxLabel: 'Max. size',
|
||||
cacheClearBtn: 'Clear Cache',
|
||||
cacheClearWarning: 'This will also remove all offline albums from the library.',
|
||||
cacheClearConfirm: 'Clear Everything',
|
||||
cacheClearCancel: 'Cancel',
|
||||
offlineDirTitle: 'Offline Library (In-App)',
|
||||
offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.',
|
||||
offlineDirDefault: 'Default (App Data)',
|
||||
offlineDirChange: 'Change Directory',
|
||||
offlineDirClear: 'Reset to Default',
|
||||
offlineDirHint: 'New downloads will use this location. Existing downloads remain at their original path.',
|
||||
showArtistImages: 'Show Artist Images',
|
||||
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
|
||||
minimizeToTray: 'Minimize to Tray',
|
||||
@@ -406,7 +419,8 @@ const enTranslation = {
|
||||
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
|
||||
nowPlayingEnabled: 'Show in Now Playing',
|
||||
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
|
||||
downloadsTitle: 'Download Folder',
|
||||
downloadsTitle: 'ZIP Export & Archiving',
|
||||
downloadsFolderDesc: 'Destination folder for albums you download as a ZIP file to your computer.',
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
pickFolder: 'Select',
|
||||
pickFolderTitle: 'Select Download Folder',
|
||||
@@ -433,17 +447,27 @@ const enTranslation = {
|
||||
randomMixBlacklistAdd: 'Add',
|
||||
randomMixBlacklistEmpty: 'No custom keywords added yet.',
|
||||
randomMixHardcodedTitle: 'Built-in keywords (active when checkbox is on)',
|
||||
tabPlayback: 'Playback',
|
||||
tabLibrary: 'Library',
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Storage & Downloads',
|
||||
tabAppearance: 'Appearance',
|
||||
homeCustomizerTitle: 'Home Page',
|
||||
sidebarTitle: 'Sidebar',
|
||||
sidebarReset: 'Reset to default',
|
||||
sidebarDrag: 'Drag to reorder',
|
||||
sidebarFixed: 'Always visible',
|
||||
tabShortcuts: 'Shortcuts',
|
||||
tabInput: 'Input',
|
||||
tabServer: 'Server',
|
||||
tabAbout: 'About',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'General',
|
||||
backupTitle: 'Backup & Restore',
|
||||
backupExport: 'Export settings',
|
||||
backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.',
|
||||
backupImport: 'Import settings',
|
||||
backupImportDesc: 'Restores settings from a .psybkp file. The app will reload after import.',
|
||||
backupImportConfirm: 'This will overwrite all current settings. Continue?',
|
||||
backupSuccess: 'Backup saved',
|
||||
backupImportSuccess: 'Settings restored — reloading…',
|
||||
backupImportError: 'Invalid or corrupted backup file.',
|
||||
shortcutsReset: 'Reset to defaults',
|
||||
shortcutListening: 'Press a key…',
|
||||
shortcutUnbound: '—',
|
||||
@@ -468,7 +492,7 @@ const enTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Fade between tracks',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
notWithGapless: 'Not available while Gapless is active',
|
||||
notWithCrossfade: 'Not available while Crossfade is active',
|
||||
gapless: 'Gapless Playback',
|
||||
@@ -601,6 +625,11 @@ const enTranslation = {
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Songs',
|
||||
statGenres: 'Genres',
|
||||
statPlaytime: 'Total Playtime',
|
||||
genreInsights: 'Genre Insights',
|
||||
formatDistribution: 'Format Distribution',
|
||||
formatSample: 'Sample of {{n}} tracks',
|
||||
computing: 'Computing…',
|
||||
genreSongs: '{{count}} Songs',
|
||||
genreAlbums: '{{count}} Albums',
|
||||
recentlyAdded: 'Recently Added',
|
||||
@@ -703,6 +732,19 @@ const enTranslation = {
|
||||
cacheOffline: 'Cache playlist offline',
|
||||
offlineCached: 'Playlist cached',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
publicLabel: 'Public',
|
||||
privateLabel: 'Private',
|
||||
editMeta: 'Edit playlist',
|
||||
editNamePlaceholder: 'Playlist name…',
|
||||
editCommentPlaceholder: 'Add a description…',
|
||||
editPublic: 'Public playlist',
|
||||
editSave: 'Save',
|
||||
editCancel: 'Cancel',
|
||||
changeCover: 'Change cover image',
|
||||
changeCoverLabel: 'Change photo',
|
||||
removeCover: 'Remove photo',
|
||||
coverUpdated: 'Cover updated',
|
||||
metaSaved: 'Playlist updated',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internet Radio',
|
||||
@@ -719,6 +761,21 @@ const enTranslation = {
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internet Radio',
|
||||
openHomepage: 'Open homepage',
|
||||
changeCoverLabel: 'Change cover',
|
||||
removeCover: 'Remove cover',
|
||||
browseDirectory: 'Search Directory',
|
||||
directoryPlaceholder: 'Search stations…',
|
||||
noResults: 'No stations found.',
|
||||
stationAdded: 'Station added',
|
||||
filterAll: 'All',
|
||||
filterFavorites: 'Favorites',
|
||||
sortManual: 'Manual',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: 'Newest',
|
||||
favorite: 'Add to favorites',
|
||||
unfavorite: 'Remove from favorites',
|
||||
noFavorites: 'No favorite stations.',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -903,6 +960,7 @@ const deTranslation = {
|
||||
enqueueAll: 'Alle in die Warteschlange',
|
||||
playAll: 'Alle abspielen',
|
||||
removeSong: 'Aus Favoriten entfernen',
|
||||
stations: 'Radiosender',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Zufallsalben',
|
||||
@@ -958,6 +1016,10 @@ const deTranslation = {
|
||||
sortByArtist: 'A–Z (Künstler)',
|
||||
sortNewest: 'Neueste zuerst',
|
||||
sortRandom: 'Zufällig',
|
||||
yearFrom: 'Von',
|
||||
yearTo: 'Bis',
|
||||
yearFilterClear: 'Jahresfilter zurücksetzen',
|
||||
yearFilterLabel: 'Jahr',
|
||||
},
|
||||
artists: {
|
||||
title: 'Künstler',
|
||||
@@ -1114,11 +1176,19 @@ const deTranslation = {
|
||||
behavior: 'App-Verhalten',
|
||||
cacheTitle: 'Max. Speichergröße',
|
||||
cacheDesc: 'Cover und Künstlerbilder. Wenn voll, werden die ältesten Einträge automatisch entfernt. Offline-Alben werden nicht automatisch gelöscht, aber beim manuellen Cache leeren entfernt.',
|
||||
cacheUsed: 'Belegt: {{images}} Bilder · {{offline}} Offline-Tracks',
|
||||
cacheUsedImages: 'Bilder:',
|
||||
cacheUsedOffline: 'Offline-Tracks:',
|
||||
cacheMaxLabel: 'Max. Größe',
|
||||
cacheClearBtn: 'Cache leeren',
|
||||
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
|
||||
cacheClearConfirm: 'Alles löschen',
|
||||
cacheClearCancel: 'Abbrechen',
|
||||
offlineDirTitle: 'Offline-Bibliothek (In-App)',
|
||||
offlineDirDesc: 'Speicherort für Titel, die du innerhalb von Psysonic offline verfügbar machst.',
|
||||
offlineDirDefault: 'Standard (App-Daten)',
|
||||
offlineDirChange: 'Verzeichnis ändern',
|
||||
offlineDirClear: 'Auf Standard zurücksetzen',
|
||||
offlineDirHint: 'Neue Downloads werden an diesem Ort gespeichert. Bestehende Downloads verbleiben am ursprünglichen Pfad.',
|
||||
showArtistImages: 'Künstlerbilder anzeigen',
|
||||
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
|
||||
minimizeToTray: 'Im Tray minimieren',
|
||||
@@ -1127,7 +1197,8 @@ const deTranslation = {
|
||||
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
|
||||
nowPlayingEnabled: 'Im Livefenster anzeigen',
|
||||
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
|
||||
downloadsTitle: 'Download-Ordner',
|
||||
downloadsTitle: 'ZIP-Export & Archivierung',
|
||||
downloadsFolderDesc: 'Zielverzeichnis für Alben, die du als ZIP-Datei auf deinen Computer herunterlädst.',
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
pickFolder: 'Auswählen',
|
||||
pickFolderTitle: 'Download-Ordner auswählen',
|
||||
@@ -1154,15 +1225,15 @@ const deTranslation = {
|
||||
randomMixBlacklistAdd: 'Hinzufügen',
|
||||
randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.',
|
||||
randomMixHardcodedTitle: 'Eingebaute Keywords (aktiv wenn Checkbox an)',
|
||||
tabPlayback: 'Wiedergabe',
|
||||
tabLibrary: 'Bibliothek',
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Speicher & Downloads',
|
||||
tabAppearance: 'Darstellung',
|
||||
homeCustomizerTitle: 'Startseite',
|
||||
sidebarTitle: 'Seitenleiste',
|
||||
sidebarReset: 'Zurücksetzen',
|
||||
sidebarDrag: 'Ziehen zum Umsortieren',
|
||||
sidebarFixed: 'Immer sichtbar',
|
||||
tabShortcuts: 'Tastenkürzel',
|
||||
tabInput: 'Eingabe',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Auf Standard zurücksetzen',
|
||||
shortcutListening: 'Taste drücken…',
|
||||
@@ -1180,7 +1251,17 @@ const deTranslation = {
|
||||
shortcutToggleQueue: 'Warteschlange ein-/ausblenden',
|
||||
shortcutFullscreenPlayer: 'Vollbild-Player',
|
||||
shortcutNativeFullscreen: 'Nativer Vollbildmodus',
|
||||
tabAbout: 'Info',
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'Allgemein',
|
||||
backupTitle: 'Backup & Wiederherstellung',
|
||||
backupExport: 'Einstellungen exportieren',
|
||||
backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.',
|
||||
backupImport: 'Einstellungen importieren',
|
||||
backupImportDesc: 'Stellt Einstellungen aus einer .psybkp-Datei wieder her. Die App wird nach dem Import neu geladen.',
|
||||
backupImportConfirm: 'Alle aktuellen Einstellungen werden überschrieben. Fortfahren?',
|
||||
backupSuccess: 'Backup gespeichert',
|
||||
backupImportSuccess: 'Einstellungen wiederhergestellt — wird neu geladen…',
|
||||
backupImportError: 'Ungültige oder beschädigte Backup-Datei.',
|
||||
playbackTitle: 'Wiedergabe',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Lautstärke mit ReplayGain-Metadaten normalisieren',
|
||||
@@ -1189,7 +1270,7 @@ const deTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Überblendung zwischen Tracks',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
notWithGapless: 'Nicht verfügbar wenn Nahtlose Wiedergabe aktiv ist',
|
||||
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
@@ -1322,6 +1403,11 @@ const deTranslation = {
|
||||
statAlbums: 'Alben',
|
||||
statSongs: 'Songs',
|
||||
statGenres: 'Genres',
|
||||
statPlaytime: 'Gesamtspielzeit',
|
||||
genreInsights: 'Genre-Einblicke',
|
||||
formatDistribution: 'Format-Verteilung',
|
||||
formatSample: 'Stichprobe von {{n}} Titeln',
|
||||
computing: 'Wird berechnet…',
|
||||
genreSongs: '{{count}} Songs',
|
||||
genreAlbums: '{{count}} Alben',
|
||||
recentlyAdded: 'Neu hinzugefügt',
|
||||
@@ -1424,6 +1510,19 @@ const deTranslation = {
|
||||
cacheOffline: 'Playlist offline speichern',
|
||||
offlineCached: 'Playlist gecacht',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
publicLabel: 'Öffentlich',
|
||||
privateLabel: 'Privat',
|
||||
editMeta: 'Playlist bearbeiten',
|
||||
editNamePlaceholder: 'Playlist-Name…',
|
||||
editCommentPlaceholder: 'Beschreibung hinzufügen…',
|
||||
editPublic: 'Öffentliche Playlist',
|
||||
editSave: 'Speichern',
|
||||
editCancel: 'Abbrechen',
|
||||
changeCover: 'Coverbild ändern',
|
||||
changeCoverLabel: 'Foto ändern',
|
||||
removeCover: 'Foto entfernen',
|
||||
coverUpdated: 'Cover aktualisiert',
|
||||
metaSaved: 'Playlist aktualisiert',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internetradio',
|
||||
@@ -1440,6 +1539,21 @@ const deTranslation = {
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internetradio',
|
||||
openHomepage: 'Homepage öffnen',
|
||||
changeCoverLabel: 'Cover ändern',
|
||||
removeCover: 'Cover entfernen',
|
||||
browseDirectory: 'Verzeichnis durchsuchen',
|
||||
directoryPlaceholder: 'Sender suchen…',
|
||||
noResults: 'Keine Sender gefunden.',
|
||||
stationAdded: 'Sender hinzugefügt',
|
||||
filterAll: 'Alle',
|
||||
filterFavorites: 'Favoriten',
|
||||
sortManual: 'Manuell',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: 'Neueste',
|
||||
favorite: 'Zu Favoriten hinzufügen',
|
||||
unfavorite: 'Aus Favoriten entfernen',
|
||||
noFavorites: 'Keine Lieblingssender.',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1624,6 +1738,7 @@ const frTranslation = {
|
||||
enqueueAll: 'Tout ajouter à la file',
|
||||
playAll: 'Tout lire',
|
||||
removeSong: 'Retirer des favoris',
|
||||
stations: 'Stations de radio',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Albums aléatoires',
|
||||
@@ -1679,6 +1794,10 @@ const frTranslation = {
|
||||
sortByArtist: 'A–Z (Artiste)',
|
||||
sortNewest: 'Plus récents',
|
||||
sortRandom: 'Aléatoire',
|
||||
yearFrom: 'De',
|
||||
yearTo: 'À',
|
||||
yearFilterClear: 'Effacer le filtre année',
|
||||
yearFilterLabel: 'Année',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artistes',
|
||||
@@ -1835,11 +1954,19 @@ const frTranslation = {
|
||||
behavior: 'Comportement de l\'application',
|
||||
cacheTitle: 'Taille max. du stockage',
|
||||
cacheDesc: 'Pochettes et images d\'artistes. Quand le cache est plein, les entrées les plus anciennes sont supprimées automatiquement. Les albums hors ligne ne sont pas supprimés automatiquement, mais le seront lors d\'un nettoyage manuel.',
|
||||
cacheUsed: 'Utilisé : {{images}} images · {{offline}} pistes hors ligne',
|
||||
cacheUsedImages: 'Images :',
|
||||
cacheUsedOffline: 'Pistes hors ligne :',
|
||||
cacheMaxLabel: 'Taille max.',
|
||||
cacheClearBtn: 'Vider le cache',
|
||||
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
|
||||
cacheClearConfirm: 'Tout supprimer',
|
||||
cacheClearCancel: 'Annuler',
|
||||
offlineDirTitle: 'Bibliothèque hors ligne (intégrée)',
|
||||
offlineDirDesc: 'Emplacement de stockage des titres rendus disponibles hors ligne dans Psysonic.',
|
||||
offlineDirDefault: 'Par défaut (données d\'application)',
|
||||
offlineDirChange: 'Changer le dossier',
|
||||
offlineDirClear: 'Réinitialiser',
|
||||
offlineDirHint: 'Les nouveaux téléchargements utiliseront cet emplacement. Les téléchargements existants restent à leur emplacement d\'origine.',
|
||||
showArtistImages: 'Afficher les images d\'artistes',
|
||||
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
|
||||
minimizeToTray: 'Réduire dans la barre système',
|
||||
@@ -1848,7 +1975,8 @@ const frTranslation = {
|
||||
discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.',
|
||||
nowPlayingEnabled: 'Afficher dans la fenêtre live',
|
||||
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
|
||||
downloadsTitle: 'Dossier de téléchargement',
|
||||
downloadsTitle: 'Export ZIP & Archivage',
|
||||
downloadsFolderDesc: 'Dossier de destination pour les albums téléchargés en tant que fichier ZIP sur votre ordinateur.',
|
||||
downloadsDefault: 'Dossier de téléchargement par défaut',
|
||||
pickFolder: 'Sélectionner',
|
||||
pickFolderTitle: 'Sélectionner le dossier de téléchargement',
|
||||
@@ -1875,15 +2003,15 @@ const frTranslation = {
|
||||
randomMixBlacklistAdd: 'Ajouter',
|
||||
randomMixBlacklistEmpty: 'Aucun mot-clé personnalisé ajouté.',
|
||||
randomMixHardcodedTitle: 'Mots-clés intégrés (actifs quand la case est cochée)',
|
||||
tabPlayback: 'Lecture',
|
||||
tabLibrary: 'Bibliothèque',
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Stockage & Téléchargements',
|
||||
tabAppearance: 'Apparence',
|
||||
homeCustomizerTitle: 'Page d\'accueil',
|
||||
sidebarTitle: 'Barre latérale',
|
||||
sidebarReset: 'Réinitialiser',
|
||||
sidebarDrag: 'Glisser pour réorganiser',
|
||||
sidebarFixed: 'Toujours visible',
|
||||
tabShortcuts: 'Raccourcis',
|
||||
tabInput: 'Entrée',
|
||||
tabServer: 'Serveur',
|
||||
shortcutsReset: 'Réinitialiser',
|
||||
shortcutListening: 'Appuyez sur une touche…',
|
||||
@@ -1901,7 +2029,17 @@ const frTranslation = {
|
||||
shortcutToggleQueue: 'Afficher/masquer la file',
|
||||
shortcutFullscreenPlayer: 'Lecteur plein écran',
|
||||
shortcutNativeFullscreen: 'Plein écran natif',
|
||||
tabAbout: 'À propos',
|
||||
tabSystem: 'Système',
|
||||
tabGeneral: 'Général',
|
||||
backupTitle: 'Sauvegarde & Restauration',
|
||||
backupExport: 'Exporter les paramètres',
|
||||
backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.',
|
||||
backupImport: 'Importer les paramètres',
|
||||
backupImportDesc: 'Restaure les paramètres depuis un fichier .psybkp. L\'application sera rechargée après l\'import.',
|
||||
backupImportConfirm: 'Tous les paramètres actuels seront écrasés. Continuer ?',
|
||||
backupSuccess: 'Sauvegarde enregistrée',
|
||||
backupImportSuccess: 'Paramètres restaurés — rechargement…',
|
||||
backupImportError: 'Fichier de sauvegarde invalide ou corrompu.',
|
||||
playbackTitle: 'Lecture',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Normaliser le volume des pistes avec les métadonnées ReplayGain',
|
||||
@@ -1910,7 +2048,7 @@ const frTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Fondu enchaîné',
|
||||
crossfadeDesc: 'Fondu entre les pistes',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
notWithGapless: 'Non disponible quand la lecture sans blanc est active',
|
||||
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
|
||||
gapless: 'Lecture sans blanc',
|
||||
@@ -2043,6 +2181,11 @@ const frTranslation = {
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Morceaux',
|
||||
statGenres: 'Genres',
|
||||
statPlaytime: 'Durée totale',
|
||||
genreInsights: 'Aperçu des genres',
|
||||
formatDistribution: 'Distribution des formats',
|
||||
formatSample: 'Échantillon de {{n}} pistes',
|
||||
computing: 'Calcul en cours…',
|
||||
genreSongs: '{{count}} morceaux',
|
||||
genreAlbums: '{{count}} albums',
|
||||
recentlyAdded: 'Ajoutés récemment',
|
||||
@@ -2145,6 +2288,19 @@ const frTranslation = {
|
||||
cacheOffline: 'Mettre la playlist hors ligne',
|
||||
offlineCached: 'Playlist en cache',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
publicLabel: 'Publique',
|
||||
privateLabel: 'Privée',
|
||||
editMeta: 'Modifier la playlist',
|
||||
editNamePlaceholder: 'Nom de la playlist…',
|
||||
editCommentPlaceholder: 'Ajouter une description…',
|
||||
editPublic: 'Playlist publique',
|
||||
editSave: 'Enregistrer',
|
||||
editCancel: 'Annuler',
|
||||
changeCover: 'Changer la pochette',
|
||||
changeCoverLabel: 'Changer la photo',
|
||||
removeCover: 'Supprimer la photo',
|
||||
coverUpdated: 'Pochette mise à jour',
|
||||
metaSaved: 'Playlist mise à jour',
|
||||
},
|
||||
radio: {
|
||||
title: 'Radio Internet',
|
||||
@@ -2161,6 +2317,21 @@ const frTranslation = {
|
||||
live: 'LIVE',
|
||||
liveStream: 'Radio Internet',
|
||||
openHomepage: 'Ouvrir la page d\'accueil',
|
||||
changeCoverLabel: 'Changer la pochette',
|
||||
removeCover: 'Supprimer la pochette',
|
||||
browseDirectory: 'Parcourir l\'annuaire',
|
||||
directoryPlaceholder: 'Rechercher des stations…',
|
||||
noResults: 'Aucune station trouvée.',
|
||||
stationAdded: 'Station ajoutée',
|
||||
filterAll: 'Toutes',
|
||||
filterFavorites: 'Favoris',
|
||||
sortManual: 'Manuel',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: 'Plus récentes',
|
||||
favorite: 'Ajouter aux favoris',
|
||||
unfavorite: 'Retirer des favoris',
|
||||
noFavorites: 'Aucune station favorite.',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2345,6 +2516,7 @@ const nlTranslation = {
|
||||
enqueueAll: 'Alles aan wachtrij toevoegen',
|
||||
playAll: 'Alles afspelen',
|
||||
removeSong: 'Verwijderen uit favorieten',
|
||||
stations: 'Radiostations',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: 'Willekeurige albums',
|
||||
@@ -2400,6 +2572,10 @@ const nlTranslation = {
|
||||
sortByArtist: 'A–Z (Artiest)',
|
||||
sortNewest: 'Nieuwste eerst',
|
||||
sortRandom: 'Willekeurig',
|
||||
yearFrom: 'Van',
|
||||
yearTo: 'Tot',
|
||||
yearFilterClear: 'Jaarfilter wissen',
|
||||
yearFilterLabel: 'Jaar',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artiesten',
|
||||
@@ -2556,11 +2732,19 @@ const nlTranslation = {
|
||||
behavior: 'App-gedrag',
|
||||
cacheTitle: 'Max. opslaggrootte',
|
||||
cacheDesc: 'Albumhoezen en artiestafbeeldingen. Als de cache vol is, worden de oudste items automatisch verwijderd. Offline albums worden niet automatisch verwijderd, maar wel bij handmatig leegmaken.',
|
||||
cacheUsed: 'Gebruikt: {{images}} afbeeldingen · {{offline}} offline nummers',
|
||||
cacheUsedImages: 'Afbeeldingen:',
|
||||
cacheUsedOffline: 'Offline nummers:',
|
||||
cacheMaxLabel: 'Max. grootte',
|
||||
cacheClearBtn: 'Cache wissen',
|
||||
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
|
||||
cacheClearConfirm: 'Alles wissen',
|
||||
cacheClearCancel: 'Annuleren',
|
||||
offlineDirTitle: 'Offline-bibliotheek (In-app)',
|
||||
offlineDirDesc: 'Opslaglocatie voor nummers die je offline beschikbaar maakt in Psysonic.',
|
||||
offlineDirDefault: 'Standaard (app-gegevens)',
|
||||
offlineDirChange: 'Map wijzigen',
|
||||
offlineDirClear: 'Terugzetten naar standaard',
|
||||
offlineDirHint: 'Nieuwe downloads worden op deze locatie opgeslagen. Bestaande downloads blijven op hun oorspronkelijke locatie.',
|
||||
showArtistImages: 'Artiestafbeeldingen weergeven',
|
||||
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
|
||||
minimizeToTray: 'Minimaliseren naar systeemvak',
|
||||
@@ -2569,7 +2753,8 @@ const nlTranslation = {
|
||||
discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.',
|
||||
nowPlayingEnabled: 'Weergeven in live-venster',
|
||||
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
|
||||
downloadsTitle: 'Downloadmap',
|
||||
downloadsTitle: 'ZIP-export & Archivering',
|
||||
downloadsFolderDesc: 'Doelmap voor albums die je als ZIP-bestand naar je computer downloadt.',
|
||||
downloadsDefault: 'Standaard downloadmap',
|
||||
pickFolder: 'Selecteren',
|
||||
pickFolderTitle: 'Downloadmap selecteren',
|
||||
@@ -2596,15 +2781,15 @@ const nlTranslation = {
|
||||
randomMixBlacklistAdd: 'Toevoegen',
|
||||
randomMixBlacklistEmpty: 'Nog geen aangepaste trefwoorden toegevoegd.',
|
||||
randomMixHardcodedTitle: 'Ingebouwde trefwoorden (actief wanneer selectievakje is aangevinkt)',
|
||||
tabPlayback: 'Afspelen',
|
||||
tabLibrary: 'Bibliotheek',
|
||||
tabAudio: 'Audio',
|
||||
tabStorage: 'Opslag & Downloads',
|
||||
tabAppearance: 'Weergave',
|
||||
homeCustomizerTitle: 'Startpagina',
|
||||
sidebarTitle: 'Zijbalk',
|
||||
sidebarReset: 'Standaard herstellen',
|
||||
sidebarDrag: 'Slepen om te herordenen',
|
||||
sidebarFixed: 'Altijd zichtbaar',
|
||||
tabShortcuts: 'Sneltoetsen',
|
||||
tabInput: 'Invoer',
|
||||
tabServer: 'Server',
|
||||
shortcutsReset: 'Standaard herstellen',
|
||||
shortcutListening: 'Druk op een toets…',
|
||||
@@ -2622,7 +2807,17 @@ const nlTranslation = {
|
||||
shortcutToggleQueue: 'Wachtrij tonen/verbergen',
|
||||
shortcutFullscreenPlayer: 'Volledigschermspeler',
|
||||
shortcutNativeFullscreen: 'Systeemvolledig scherm',
|
||||
tabAbout: 'Over',
|
||||
tabSystem: 'Systeem',
|
||||
tabGeneral: 'Algemeen',
|
||||
backupTitle: 'Back-up & Herstel',
|
||||
backupExport: 'Instellingen exporteren',
|
||||
backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.',
|
||||
backupImport: 'Instellingen importeren',
|
||||
backupImportDesc: 'Herstelt instellingen vanuit een .psybkp-bestand. De app wordt herladen na het importeren.',
|
||||
backupImportConfirm: 'Alle huidige instellingen worden overschreven. Doorgaan?',
|
||||
backupSuccess: 'Back-up opgeslagen',
|
||||
backupImportSuccess: 'Instellingen hersteld — herladen…',
|
||||
backupImportError: 'Ongeldig of beschadigd back-upbestand.',
|
||||
playbackTitle: 'Afspelen',
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Nummervolume normaliseren met ReplayGain-metadata',
|
||||
@@ -2631,7 +2826,7 @@ const nlTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
crossfade: 'Overgang',
|
||||
crossfadeDesc: 'Fade tussen nummers',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
notWithGapless: 'Niet beschikbaar als naadloos afspelen actief is',
|
||||
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
|
||||
gapless: 'Naadloos afspelen',
|
||||
@@ -2764,6 +2959,11 @@ const nlTranslation = {
|
||||
statAlbums: 'Albums',
|
||||
statSongs: 'Nummers',
|
||||
statGenres: 'Genres',
|
||||
statPlaytime: 'Totale speelduur',
|
||||
genreInsights: 'Genre-inzichten',
|
||||
formatDistribution: 'Formaatdistributie',
|
||||
formatSample: 'Steekproef van {{n}} nummers',
|
||||
computing: 'Berekenen…',
|
||||
genreSongs: '{{count}} nummers',
|
||||
genreAlbums: '{{count}} albums',
|
||||
recentlyAdded: 'Recent toegevoegd',
|
||||
@@ -2866,6 +3066,19 @@ const nlTranslation = {
|
||||
cacheOffline: 'Playlist offline opslaan',
|
||||
offlineCached: 'Playlist gecached',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
publicLabel: 'Openbaar',
|
||||
privateLabel: 'Privé',
|
||||
editMeta: 'Playlist bewerken',
|
||||
editNamePlaceholder: 'Playlistnaam…',
|
||||
editCommentPlaceholder: 'Beschrijving toevoegen…',
|
||||
editPublic: 'Openbare playlist',
|
||||
editSave: 'Opslaan',
|
||||
editCancel: 'Annuleren',
|
||||
changeCover: 'Omslagafbeelding wijzigen',
|
||||
changeCoverLabel: 'Foto wijzigen',
|
||||
removeCover: 'Foto verwijderen',
|
||||
coverUpdated: 'Omslag bijgewerkt',
|
||||
metaSaved: 'Playlist bijgewerkt',
|
||||
},
|
||||
radio: {
|
||||
title: 'Internetradio',
|
||||
@@ -2882,6 +3095,21 @@ const nlTranslation = {
|
||||
live: 'LIVE',
|
||||
liveStream: 'Internetradio',
|
||||
openHomepage: 'Homepage openen',
|
||||
changeCoverLabel: 'Cover wijzigen',
|
||||
removeCover: 'Cover verwijderen',
|
||||
browseDirectory: 'Gids doorzoeken',
|
||||
directoryPlaceholder: 'Stations zoeken…',
|
||||
noResults: 'Geen stations gevonden.',
|
||||
stationAdded: 'Station toegevoegd',
|
||||
filterAll: 'Alle',
|
||||
filterFavorites: 'Favorieten',
|
||||
sortManual: 'Handmatig',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: 'Nieuwste',
|
||||
favorite: 'Toevoegen aan favorieten',
|
||||
unfavorite: 'Verwijderen uit favorieten',
|
||||
noFavorites: 'Geen favoriete stations.',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3066,6 +3294,7 @@ const zhTranslation = {
|
||||
enqueueAll: '全部加入队列',
|
||||
playAll: '全部播放',
|
||||
removeSong: '从收藏中移除',
|
||||
stations: '广播电台',
|
||||
},
|
||||
randomAlbums: {
|
||||
title: '随机专辑',
|
||||
@@ -3121,6 +3350,10 @@ const zhTranslation = {
|
||||
sortByArtist: '按艺术家排序 (A-Z)',
|
||||
sortNewest: '最新优先',
|
||||
sortRandom: '随机排序',
|
||||
yearFrom: '从',
|
||||
yearTo: '到',
|
||||
yearFilterClear: '清除年份筛选',
|
||||
yearFilterLabel: '年份',
|
||||
},
|
||||
artists: {
|
||||
title: '艺术家',
|
||||
@@ -3273,11 +3506,19 @@ const zhTranslation = {
|
||||
behavior: '应用行为',
|
||||
cacheTitle: '最大存储大小',
|
||||
cacheDesc: '封面和艺术家图片。存满时,最旧的条目将自动移除。离线专辑不会自动移除,但手动清除缓存时会被删除。',
|
||||
cacheUsed: '已使用:{{images}} 张图片 · {{offline}} 首离线曲目',
|
||||
cacheUsedImages: '图片:',
|
||||
cacheUsedOffline: '离线曲目:',
|
||||
cacheMaxLabel: '最大容量',
|
||||
cacheClearBtn: '清除缓存',
|
||||
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
|
||||
cacheClearConfirm: '全部清除',
|
||||
cacheClearCancel: '取消',
|
||||
offlineDirTitle: '离线音乐库(应用内)',
|
||||
offlineDirDesc: '用于在 Psysonic 中离线可用的曲目存储位置。',
|
||||
offlineDirDefault: '默认(应用数据)',
|
||||
offlineDirChange: '更改目录',
|
||||
offlineDirClear: '重置为默认',
|
||||
offlineDirHint: '新下载将保存到此位置,现有下载保留在原始路径。',
|
||||
showArtistImages: '显示艺术家图片',
|
||||
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
|
||||
minimizeToTray: '最小化到托盘',
|
||||
@@ -3286,7 +3527,8 @@ const zhTranslation = {
|
||||
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
downloadsTitle: '下载文件夹',
|
||||
downloadsTitle: 'ZIP 导出与归档',
|
||||
downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。',
|
||||
downloadsDefault: '默认下载文件夹',
|
||||
pickFolder: '选择',
|
||||
pickFolderTitle: '选择下载文件夹',
|
||||
@@ -3313,17 +3555,27 @@ const zhTranslation = {
|
||||
randomMixBlacklistAdd: '添加',
|
||||
randomMixBlacklistEmpty: '尚未添加自定义关键词。',
|
||||
randomMixHardcodedTitle: '内置关键词(复选框开启时生效)',
|
||||
tabPlayback: '播放',
|
||||
tabLibrary: '音乐库',
|
||||
tabAudio: '音频',
|
||||
tabStorage: '存储与下载',
|
||||
tabAppearance: '外观',
|
||||
homeCustomizerTitle: '首页',
|
||||
sidebarTitle: '侧边栏',
|
||||
sidebarReset: '重置为默认',
|
||||
sidebarDrag: '拖动以重新排序',
|
||||
sidebarFixed: '始终显示',
|
||||
tabShortcuts: '快捷键',
|
||||
tabInput: '输入',
|
||||
tabServer: '服务器',
|
||||
tabAbout: '关于',
|
||||
tabSystem: '系统',
|
||||
tabGeneral: '通用',
|
||||
backupTitle: '备份与恢复',
|
||||
backupExport: '导出设置',
|
||||
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
|
||||
backupImport: '导入设置',
|
||||
backupImportDesc: '从 .psybkp 文件恢复设置。导入后应用将重新加载。',
|
||||
backupImportConfirm: '所有当前设置将被覆盖。是否继续?',
|
||||
backupSuccess: '备份已保存',
|
||||
backupImportSuccess: '设置已恢复——正在重新加载…',
|
||||
backupImportError: '备份文件无效或已损坏。',
|
||||
shortcutsReset: '恢复默认',
|
||||
shortcutListening: '请按下按键…',
|
||||
shortcutUnbound: '—',
|
||||
@@ -3481,6 +3733,11 @@ const zhTranslation = {
|
||||
statAlbums: '专辑',
|
||||
statSongs: '歌曲',
|
||||
statGenres: '流派',
|
||||
statPlaytime: '总播放时长',
|
||||
genreInsights: '流派洞察',
|
||||
formatDistribution: '格式分布',
|
||||
formatSample: '{{n}} 首曲目的样本',
|
||||
computing: '计算中…',
|
||||
genreSongs: '{{count}} 首歌曲',
|
||||
genreAlbums: '{{count}} 张专辑',
|
||||
recentlyAdded: '最近添加',
|
||||
@@ -3583,6 +3840,19 @@ const zhTranslation = {
|
||||
cacheOffline: '离线缓存播放列表',
|
||||
offlineCached: '播放列表已缓存',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
publicLabel: '公开',
|
||||
privateLabel: '私有',
|
||||
editMeta: '编辑播放列表',
|
||||
editNamePlaceholder: '播放列表名称…',
|
||||
editCommentPlaceholder: '添加描述…',
|
||||
editPublic: '公开播放列表',
|
||||
editSave: '保存',
|
||||
editCancel: '取消',
|
||||
changeCover: '更改封面图片',
|
||||
changeCoverLabel: '更改照片',
|
||||
removeCover: '删除照片',
|
||||
coverUpdated: '封面已更新',
|
||||
metaSaved: '播放列表已更新',
|
||||
},
|
||||
radio: {
|
||||
title: '网络电台',
|
||||
@@ -3599,6 +3869,21 @@ const zhTranslation = {
|
||||
live: '直播',
|
||||
liveStream: '网络电台',
|
||||
openHomepage: '打开主页',
|
||||
changeCoverLabel: '更换封面',
|
||||
removeCover: '移除封面',
|
||||
browseDirectory: '搜索目录',
|
||||
directoryPlaceholder: '搜索电台…',
|
||||
noResults: '未找到电台。',
|
||||
stationAdded: '电台已添加',
|
||||
filterAll: '全部',
|
||||
filterFavorites: '收藏',
|
||||
sortManual: '手动',
|
||||
sortAZ: 'A → Z',
|
||||
sortZA: 'Z → A',
|
||||
sortNewest: '最新',
|
||||
favorite: '添加到收藏',
|
||||
unfavorite: '从收藏移除',
|
||||
noFavorites: '没有收藏的电台。',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ export default function AdvancedSearch() {
|
||||
)}
|
||||
</h2>
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '32px 1fr 1fr 1fr 90px 70px' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 90px 65px' }}>
|
||||
<span />
|
||||
<span>{t('randomMix.trackTitle')}</span>
|
||||
<span>{t('randomMix.trackArtist')}</span>
|
||||
@@ -287,7 +287,7 @@ export default function AdvancedSearch() {
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '32px 1fr 1fr 1fr 90px 70px' }}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 90px 65px' }}
|
||||
onDoubleClick={() => playTrack(track, results.songs.map(songToTrack))}
|
||||
role="row"
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
|
||||
+74
-11
@@ -3,10 +3,12 @@ import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
|
||||
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
||||
@@ -22,13 +24,26 @@ export default function Albums() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
const [yearFrom, setYearFrom] = useState('');
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const filtered = selectedGenres.length > 0;
|
||||
|
||||
const load = useCallback(async (sortType: SortType, offset: number, append = false) => {
|
||||
const genreFiltered = selectedGenres.length > 0;
|
||||
const fromNum = parseInt(yearFrom, 10);
|
||||
const toNum = parseInt(yearTo, 10);
|
||||
const yearActive = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
|
||||
|
||||
const load = useCallback(async (
|
||||
sortType: SortType,
|
||||
offset: number,
|
||||
append = false,
|
||||
yearFilter?: { from: number; to: number },
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAlbumList(sortType, PAGE_SIZE, offset);
|
||||
const extra = yearFilter ? { fromYear: yearFilter.from, toYear: yearFilter.to } : {};
|
||||
const type = yearFilter ? 'byYear' : sortType;
|
||||
const data = await getAlbumList(type, PAGE_SIZE, offset, extra);
|
||||
if (append) setAlbums(prev => [...prev, ...data]);
|
||||
else setAlbums(data);
|
||||
setHasMore(data.length === PAGE_SIZE);
|
||||
@@ -54,16 +69,23 @@ export default function Albums() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (filtered) loadFiltered(selectedGenres, sort);
|
||||
else { setPage(0); load(sort, 0); }
|
||||
}, [sort, filtered, selectedGenres, load, loadFiltered]);
|
||||
setPage(0);
|
||||
if (genreFiltered) {
|
||||
loadFiltered(selectedGenres, sort);
|
||||
} else if (yearActive) {
|
||||
load(sort, 0, false, { from: fromNum, to: toNum });
|
||||
} else {
|
||||
load(sort, 0);
|
||||
}
|
||||
}, [sort, genreFiltered, selectedGenres, yearActive, fromNum, toNum, load, loadFiltered]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore || filtered) return;
|
||||
if (loading || !hasMore || genreFiltered) return;
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
load(sort, next * PAGE_SIZE, true);
|
||||
}, [loading, hasMore, page, sort, load, filtered]);
|
||||
const yf = yearActive ? { from: fromNum, to: toNum } : undefined;
|
||||
load(sort, next * PAGE_SIZE, true, yf);
|
||||
}, [loading, hasMore, page, sort, load, genreFiltered, yearActive, fromNum, toNum]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
@@ -74,6 +96,8 @@ export default function Albums() {
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
const clearYear = () => { setYearFrom(''); setYearTo(''); };
|
||||
|
||||
const sortOptions: { value: SortType; label: string }[] = [
|
||||
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
|
||||
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
|
||||
@@ -84,7 +108,7 @@ export default function Albums() {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('albums.title')}</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{sortOptions.map(o => (
|
||||
{!yearActive && sortOptions.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
|
||||
@@ -94,6 +118,45 @@ export default function Albums() {
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Year range filter */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||
{t('albums.yearFilterLabel')}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={CURRENT_YEAR}
|
||||
placeholder={t('albums.yearFrom')}
|
||||
value={yearFrom}
|
||||
onChange={e => setYearFrom(e.target.value)}
|
||||
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>–</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1900}
|
||||
max={CURRENT_YEAR}
|
||||
placeholder={t('albums.yearTo')}
|
||||
value={yearTo}
|
||||
onChange={e => setYearTo(e.target.value)}
|
||||
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
|
||||
/>
|
||||
{yearActive && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={clearYear}
|
||||
data-tooltip={t('albums.yearFilterClear')}
|
||||
style={{ padding: '4px 6px' }}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,7 +170,7 @@ export default function Albums() {
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
{!filtered && (
|
||||
{!genreFiltered && (
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
|
||||
@@ -388,7 +388,7 @@ export default function ArtistDetail() {
|
||||
{t('artistDetail.topTracks')}
|
||||
</h2>
|
||||
<div className="tracklist" style={{ padding: 0, marginBottom: '2rem' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}>
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>{t('artistDetail.trackTitle')}</div>
|
||||
<div>{t('artistDetail.trackAlbum')}</div>
|
||||
@@ -400,7 +400,7 @@ export default function ArtistDetail() {
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
playTrack(track, topSongs.map(songToTrack));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { LayoutGrid, List, Images } from 'lucide-react';
|
||||
import { LayoutGrid, List, Images, ChevronDown } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
@@ -254,9 +254,9 @@ export default function Artists() {
|
||||
)}
|
||||
|
||||
{!loading && hasMore && (
|
||||
<div style={{ margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
<button className="btn btn-ghost" onClick={loadMore}>
|
||||
{t('artists.loadMore')}
|
||||
<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>
|
||||
)}
|
||||
|
||||
+270
-49
@@ -1,23 +1,46 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import {
|
||||
getStarred, getInternetRadioStations,
|
||||
SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation,
|
||||
buildCoverArtUrl, coverArtCacheKey,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { ListPlus, Play, X } from 'lucide-react';
|
||||
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { unstar } from '../api/subsonic';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
|
||||
const FAV_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: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
|
||||
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
||||
];
|
||||
|
||||
export default function Favorites() {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
const [radioStations, setRadioStations] = useState<InternetRadioStation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { playTrack, enqueue } = usePlayerStore();
|
||||
// ── Column resize/visibility (must be before early return) ───────────────
|
||||
const {
|
||||
colVisible, visibleCols, gridStyle,
|
||||
startResize, toggleColumn,
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
|
||||
|
||||
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
@@ -28,18 +51,42 @@ export default function Favorites() {
|
||||
setStarredOverride(id, false);
|
||||
setSongs(prev => prev.filter(s => s.id !== id));
|
||||
}
|
||||
|
||||
function unfavoriteStation(id: string) {
|
||||
setRadioStations(prev => prev.filter(s => s.id !== id));
|
||||
try {
|
||||
const next = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
|
||||
next.delete(id);
|
||||
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...next]));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
getStarred()
|
||||
.then(res => {
|
||||
setAlbums(res.albums);
|
||||
setArtists(res.artists);
|
||||
setSongs(res.songs);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
const loadAll = async () => {
|
||||
const [starredResult] = await Promise.allSettled([
|
||||
getStarred(),
|
||||
]);
|
||||
if (starredResult.status === 'fulfilled') {
|
||||
setAlbums(starredResult.value.albums);
|
||||
setArtists(starredResult.value.artists);
|
||||
setSongs(starredResult.value.songs);
|
||||
}
|
||||
|
||||
// Radio favorites: read IDs from localStorage, fetch all stations, filter
|
||||
try {
|
||||
const favIds = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
|
||||
if (favIds.size > 0) {
|
||||
const all = await getInternetRadioStations();
|
||||
setRadioStations(all.filter(s => favIds.has(s.id)));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
loadAll();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
@@ -51,7 +98,7 @@ export default function Favorites() {
|
||||
}
|
||||
|
||||
const visibleSongs = songs.filter(s => starredOverrides[s.id] !== false);
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0;
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0 || radioStations.length > 0;
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
@@ -71,6 +118,20 @@ export default function Favorites() {
|
||||
<AlbumRow title={t('favorites.albums')} albums={albums} />
|
||||
)}
|
||||
|
||||
{radioStations.length > 0 && (
|
||||
<RadioStationRow
|
||||
title={t('favorites.stations')}
|
||||
stations={radioStations}
|
||||
currentRadio={currentRadio}
|
||||
isPlaying={isPlaying}
|
||||
onPlay={s => {
|
||||
if (currentRadio?.id === s.id && isPlaying) stop();
|
||||
else playRadio(s);
|
||||
}}
|
||||
onUnfavorite={unfavoriteStation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleSongs.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
|
||||
@@ -96,13 +157,57 @@ export default function Favorites() {
|
||||
{t('favorites.enqueueAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }}>
|
||||
<div className="tracklist-header tracklist-va" style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}>
|
||||
<div className="col-center">#</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div />
|
||||
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="tracklist-header tracklist-va" style={gridStyle}>
|
||||
{visibleCols.map((colDef, colIndex) => {
|
||||
const key = colDef.key;
|
||||
const isLastCol = colIndex === visibleCols.length - 1;
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
|
||||
if (key === 'num') return <div key="num" className="track-num"><span className="track-num-number">#</span></div>;
|
||||
if (key === 'title') {
|
||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||
return (
|
||||
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (key === 'remove') return <div key="remove" />;
|
||||
const isCentered = key === 'duration';
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="tracklist-col-picker" ref={pickerRef}>
|
||||
<button className="tracklist-col-picker-btn" onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }} data-tooltip={t('albumDetail.columns')}>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<div className="tracklist-col-picker-menu">
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{FAV_COLUMNS.filter(c => !c.required).map(c => {
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
|
||||
const isOn = colVisible.has(c.key);
|
||||
return (
|
||||
<button key={c.key} className={`tracklist-col-picker-item${isOn ? ' active' : ''}`} onClick={() => toggleColumn(c.key)}>
|
||||
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{visibleSongs.map((song, i) => {
|
||||
const track = songToTrack(song);
|
||||
@@ -110,7 +215,7 @@ export default function Favorites() {
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row track-row-va"
|
||||
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
|
||||
style={gridStyle}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
playTrack(track, visibleSongs.map(songToTrack));
|
||||
@@ -133,34 +238,36 @@ export default function Favorites() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}>
|
||||
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{i + 1}</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span
|
||||
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
|
||||
>{song.artist}</span>
|
||||
</div>
|
||||
<div className="track-duration">
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="btn-icon fav-remove-btn"
|
||||
data-tooltip={t('favorites.removeSong')}
|
||||
onClick={e => { e.stopPropagation(); removeSong(song.id); }}
|
||||
aria-label={t('favorites.removeSong')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{visibleCols.map(colDef => {
|
||||
switch (colDef.key) {
|
||||
case 'num': return (
|
||||
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}>
|
||||
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{i + 1}</span>
|
||||
</div>
|
||||
);
|
||||
case 'title': return <div key="title" className="track-info"><span className="track-title">{song.title}</span></div>;
|
||||
case 'artist': return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
|
||||
</div>
|
||||
);
|
||||
case 'duration': return (
|
||||
<div key="duration" className="track-duration">
|
||||
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
);
|
||||
case 'remove': return (
|
||||
<div key="remove" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<button className="btn-icon fav-remove-btn" data-tooltip={t('favorites.removeSong')} onClick={e => { e.stopPropagation(); removeSong(song.id); }} aria-label={t('favorites.removeSong')}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
default: return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -172,3 +279,117 @@ export default function Favorites() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Radio Station Row ─────────────────────────────────────────────────────────
|
||||
|
||||
interface RadioStationRowProps {
|
||||
title: string;
|
||||
stations: InternetRadioStation[];
|
||||
currentRadio: InternetRadioStation | null;
|
||||
isPlaying: boolean;
|
||||
onPlay: (s: InternetRadioStation) => void;
|
||||
onUnfavorite: (id: string) => void;
|
||||
}
|
||||
|
||||
function RadioStationRow({ title, stations, currentRadio, isPlaying, onPlay, onUnfavorite }: RadioStationRowProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
const [showRight, setShowRight] = useState(true);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
};
|
||||
|
||||
const scroll = (dir: 'left' | 'right') => {
|
||||
if (!scrollRef.current) return;
|
||||
scrollRef.current.scrollBy({ left: dir === 'left' ? -scrollRef.current.clientWidth * 0.75 : scrollRef.current.clientWidth * 0.75, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
<div className="album-row-nav">
|
||||
<button className={`nav-btn${!showLeft ? ' disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button className={`nav-btn${!showRight ? ' disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{stations.map(s => (
|
||||
<RadioFavCard
|
||||
key={s.id}
|
||||
station={s}
|
||||
isActive={currentRadio?.id === s.id}
|
||||
isPlaying={isPlaying}
|
||||
onPlay={() => onPlay(s)}
|
||||
onUnfavorite={() => onUnfavorite(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Radio Favorite Card ───────────────────────────────────────────────────────
|
||||
|
||||
interface RadioFavCardProps {
|
||||
station: InternetRadioStation;
|
||||
isActive: boolean;
|
||||
isPlaying: boolean;
|
||||
onPlay: () => void;
|
||||
onUnfavorite: () => void;
|
||||
}
|
||||
|
||||
function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }: RadioFavCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`album-card${isActive ? ' radio-card-active' : ''}`}>
|
||||
<div className="album-card-cover">
|
||||
{s.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
|
||||
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
|
||||
alt={s.name}
|
||||
className="album-card-cover-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder playlist-card-icon">
|
||||
<Cast size={48} strokeWidth={1.2} />
|
||||
</div>
|
||||
)}
|
||||
{isActive && isPlaying && (
|
||||
<div className="radio-live-overlay">
|
||||
<span className="radio-live-badge">{t('radio.live')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button className="album-card-details-btn" onClick={onPlay}>
|
||||
{isActive && isPlaying ? <X size={15} /> : <Cast size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<div className="album-card-title">{s.name}</div>
|
||||
<div className="album-card-artist" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<button
|
||||
className="radio-favorite-btn active"
|
||||
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', display: 'flex' }}
|
||||
onClick={onUnfavorite}
|
||||
data-tooltip={t('radio.unfavorite')}
|
||||
>
|
||||
<Heart size={12} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+809
-217
File diff suppressed because it is too large
Load Diff
+434
-136
@@ -1,9 +1,11 @@
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
import {
|
||||
getPlaylist, updatePlaylist, search, setRating, star, unstar,
|
||||
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
|
||||
search, setRating, star, unstar,
|
||||
getRandomSongs, SubsonicPlaylist, SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
@@ -15,6 +17,7 @@ import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -53,6 +56,20 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
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: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
|
||||
{ key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
|
||||
];
|
||||
|
||||
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
|
||||
|
||||
export default function PlaylistDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
@@ -78,6 +95,8 @@ export default function PlaylistDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||
const [editingMeta, setEditingMeta] = useState(false);
|
||||
const [customCoverId, setCustomCoverId] = useState<string | null>(null);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
@@ -149,10 +168,20 @@ export default function PlaylistDetail() {
|
||||
}),
|
||||
[coverQuad]);
|
||||
|
||||
const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]);
|
||||
const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]);
|
||||
const effectiveBgId = customCoverId ?? coverQuad[0] ?? '';
|
||||
const bgFetchUrl = useMemo(() => buildCoverArtUrl(effectiveBgId, 300), [effectiveBgId]);
|
||||
const bgCacheKey = useMemo(() => coverArtCacheKey(effectiveBgId, 300), [effectiveBgId]);
|
||||
const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey);
|
||||
|
||||
const customCoverFetchUrl = useMemo(
|
||||
() => customCoverId ? buildCoverArtUrl(customCoverId, 300) : null,
|
||||
[customCoverId],
|
||||
);
|
||||
const customCoverCacheKey = useMemo(
|
||||
() => customCoverId ? coverArtCacheKey(customCoverId, 300) : null,
|
||||
[customCoverId],
|
||||
);
|
||||
|
||||
// Song search
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -164,8 +193,14 @@ export default function PlaylistDetail() {
|
||||
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
||||
|
||||
// ── Column resize/visibility ──────────────────────────────────────────────
|
||||
const {
|
||||
colVisible, visibleCols, gridStyle,
|
||||
startResize, toggleColumn,
|
||||
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
|
||||
} = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns');
|
||||
|
||||
// DnD
|
||||
const tracklistRef = useRef<HTMLDivElement>(null);
|
||||
const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -180,6 +215,7 @@ export default function PlaylistDetail() {
|
||||
.then(({ playlist, songs }) => {
|
||||
setPlaylist(playlist);
|
||||
setSongs(songs);
|
||||
if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
|
||||
const init: Record<string, number> = {};
|
||||
const starred = new Set<string>();
|
||||
songs.forEach(s => {
|
||||
@@ -230,6 +266,34 @@ export default function PlaylistDetail() {
|
||||
setSaving(false);
|
||||
}, [id, touchPlaylist]);
|
||||
|
||||
// ── Meta edit ─────────────────────────────────────────────────
|
||||
const handleSaveMeta = async (opts: {
|
||||
name: string; comment: string; isPublic: boolean;
|
||||
coverFile: File | null; coverRemoved: boolean;
|
||||
}) => {
|
||||
if (!id || !playlist) return;
|
||||
await updatePlaylistMeta(id, opts.name.trim() || playlist.name, opts.comment, opts.isPublic);
|
||||
setPlaylist(p => p
|
||||
? { ...p, name: opts.name.trim() || p.name, comment: opts.comment, public: opts.isPublic }
|
||||
: p
|
||||
);
|
||||
if (opts.coverFile) {
|
||||
try {
|
||||
await uploadPlaylistCoverArt(id, opts.coverFile);
|
||||
const { playlist: refreshed } = await getPlaylist(id);
|
||||
setPlaylist(prev => prev ? { ...prev, coverArt: refreshed.coverArt } : prev);
|
||||
if (refreshed.coverArt) setCustomCoverId(refreshed.coverArt);
|
||||
showToast(t('playlists.coverUpdated'));
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : t('playlists.coverUpdated'), 3000, 'error');
|
||||
}
|
||||
} else if (opts.coverRemoved) {
|
||||
setCustomCoverId(null);
|
||||
}
|
||||
showToast(t('playlists.metaSaved'));
|
||||
setEditingMeta(false);
|
||||
};
|
||||
|
||||
// ── Remove ────────────────────────────────────────────────────
|
||||
const removeSong = (idx: number) => {
|
||||
const prevCount = songs.length;
|
||||
@@ -390,21 +454,61 @@ export default function PlaylistDetail() {
|
||||
</button>
|
||||
|
||||
<div className="album-detail-hero">
|
||||
{/* 2×2 cover grid */}
|
||||
<div className="playlist-cover-grid">
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
{/* Cover — click to open edit modal */}
|
||||
<div
|
||||
className="playlist-hero-cover"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
>
|
||||
{customCoverId && customCoverFetchUrl && customCoverCacheKey ? (
|
||||
<CachedImage
|
||||
src={customCoverFetchUrl}
|
||||
cacheKey={customCoverCacheKey}
|
||||
alt=""
|
||||
className="playlist-cover-grid"
|
||||
style={{ objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="playlist-cover-grid">
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="playlist-hero-cover-overlay">
|
||||
<Camera size={28} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge album-detail-badge">{t('playlists.titleBadge')}</span>
|
||||
<h1 className="album-detail-title">{playlist.name}</h1>
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h1 className="album-detail-title" style={{ marginBottom: 0 }}>{playlist.name}</h1>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
data-tooltip={t('playlists.editMeta')}
|
||||
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{playlist.comment && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{playlist.comment}</div>
|
||||
)}
|
||||
</>
|
||||
<div className="album-detail-info">
|
||||
<span>{t('playlists.songs', { n: songs.length })}</span>
|
||||
{songs.length > 0 && <span>· {totalDurationLabel(songs)}</span>}
|
||||
{playlist.public !== undefined && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||||
· {playlist.public
|
||||
? <><Globe size={11} /> {t('playlists.publicLabel')}</>
|
||||
: <><Lock size={11} /> {t('playlists.privateLabel')}</>}
|
||||
</span>
|
||||
)}
|
||||
{saving && <Loader2 size={12} className="spin-slow" style={{ display: 'inline', marginLeft: 4 }} />}
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
@@ -545,19 +649,75 @@ export default function PlaylistDetail() {
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="tracklist-header tracklist-va tracklist-playlist">
|
||||
<div className="col-center" style={{ cursor: songs.length > 0 ? 'pointer' : undefined }} onClick={songs.length > 0 ? toggleAll : undefined}>
|
||||
{selectedIds.size > 0
|
||||
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} />
|
||||
: '#'}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="tracklist-header tracklist-va" style={gridStyle}>
|
||||
{visibleCols.map((colDef, colIndex) => {
|
||||
const key = colDef.key;
|
||||
const isLastCol = colIndex === visibleCols.length - 1;
|
||||
const isCentered = PL_CENTERED.has(key);
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
|
||||
if (key === 'num') return (
|
||||
<div key="num" className="track-num">
|
||||
<span
|
||||
className={`bulk-check${allSelected ? ' checked' : ''}${selectedIds.size > 0 ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleAll(); }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<span className="track-num-number">#</span>
|
||||
</div>
|
||||
);
|
||||
if (key === 'title') {
|
||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||
return (
|
||||
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (key === 'delete') return <div key="delete" />;
|
||||
return (
|
||||
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</div>
|
||||
{!isLastCol && key !== 'delete' && (
|
||||
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="tracklist-col-picker" ref={pickerRef}>
|
||||
<button
|
||||
className="tracklist-col-picker-btn"
|
||||
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
|
||||
data-tooltip={t('albumDetail.columns')}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<div className="tracklist-col-picker-menu">
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{PL_COLUMNS.filter(c => !c.required).map(c => {
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
|
||||
const isOn = colVisible.has(c.key);
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
|
||||
onClick={() => toggleColumn(c.key)}
|
||||
>
|
||||
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackRating')}</div>
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
{songs.length === 0 && (
|
||||
@@ -578,6 +738,7 @@ export default function PlaylistDetail() {
|
||||
<div
|
||||
data-track-idx={idx}
|
||||
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
style={gridStyle}
|
||||
onMouseEnter={e => handleRowMouseEnter(idx, e)}
|
||||
onMouseDown={e => handleRowMouseDown(e, idx)}
|
||||
onClick={e => {
|
||||
@@ -594,76 +755,49 @@ export default function PlaylistDetail() {
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
>
|
||||
{/* # — checkbox in select mode, always-visible play button otherwise */}
|
||||
{(() => {
|
||||
{visibleCols.map(colDef => {
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
return (
|
||||
<div
|
||||
className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
playTrack(tracks[idx], tracks);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
|
||||
/>
|
||||
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Title */}
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
|
||||
{/* Artist */}
|
||||
<div className="track-artist-cell">
|
||||
<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>
|
||||
|
||||
{/* Favorite */}
|
||||
<div 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))' }}
|
||||
>
|
||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<StarRating value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />
|
||||
|
||||
{/* Duration */}
|
||||
<div className="track-duration">{formatDuration(song.duration ?? 0)}</div>
|
||||
|
||||
{/* Format */}
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="playlist-row-delete-cell">
|
||||
<button
|
||||
className="playlist-row-delete-btn"
|
||||
onClick={e => { e.stopPropagation(); removeSong(idx); }}
|
||||
data-tooltip={t('playlists.removeSong')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
switch (colDef.key) {
|
||||
case 'num': return (
|
||||
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(tracks[idx], tracks); }}>
|
||||
<span className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }} />
|
||||
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
</div>
|
||||
);
|
||||
case 'title': return (
|
||||
<div key="title" className="track-info"><span className="track-title">{song.title}</span></div>
|
||||
);
|
||||
case 'artist': return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<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 '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))' }}>
|
||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
|
||||
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
||||
case 'format': return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||||
</div>
|
||||
);
|
||||
case 'delete': return (
|
||||
<div key="delete" className="playlist-row-delete-cell">
|
||||
<button className="playlist-row-delete-btn" onClick={e => { e.stopPropagation(); removeSong(idx); }} data-tooltip={t('playlists.removeSong')} data-tooltip-pos="left">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
default: return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
|
||||
<div className="playlist-drop-indicator" />
|
||||
@@ -673,9 +807,12 @@ export default function PlaylistDetail() {
|
||||
|
||||
{/* Total row */}
|
||||
{songs.length > 0 && (
|
||||
<div className="tracklist-total tracklist-va tracklist-playlist">
|
||||
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
|
||||
<span className="tracklist-total-value">{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}</span>
|
||||
<div className="tracklist-total" style={gridStyle}>
|
||||
{visibleCols.map(c => {
|
||||
if (c.key === 'title') return <span key="title" className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>;
|
||||
if (c.key === 'duration') return <span key="duration" className="tracklist-total-value">{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}</span>;
|
||||
return <span key={c.key} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -701,21 +838,24 @@ export default function PlaylistDetail() {
|
||||
|
||||
{suggestions.filter(s => !existingIds.has(s.id)).length > 0 && (
|
||||
<>
|
||||
<div className="tracklist-header tracklist-va tracklist-playlist" style={{ marginTop: 'var(--space-3)' }}>
|
||||
<div className="col-center">#</div>
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
<div />
|
||||
<div />
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div>{t('albumDetail.trackFormat')}</div>
|
||||
<div />
|
||||
<div className="tracklist-header tracklist-va" style={{ ...gridStyle, marginTop: 'var(--space-3)' }}>
|
||||
{visibleCols.map((colDef, colIndex) => {
|
||||
const key = colDef.key;
|
||||
const isCentered = PL_CENTERED.has(key);
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
|
||||
if (key === 'num') return <div key="num" className="col-center">#</div>;
|
||||
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
|
||||
if (key === 'delete') return <div key="delete" />;
|
||||
if (key === 'favorite' || key === 'rating') return <div key={key} />;
|
||||
return <div key={key} className={isCentered ? 'col-center' : ''}>{label}</div>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{suggestions.filter(s => !existingIds.has(s.id)).map((song, idx) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={gridStyle}
|
||||
onMouseEnter={() => setHoveredSuggestionId(song.id)}
|
||||
onMouseLeave={() => setHoveredSuggestionId(null)}
|
||||
onClick={e => {
|
||||
@@ -728,42 +868,200 @@ export default function PlaylistDetail() {
|
||||
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ color: 'var(--text-muted)' }}>
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<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>
|
||||
{/* no star/rating for suggestions */}
|
||||
<div />
|
||||
<div />
|
||||
<div className="track-duration">{formatDuration(song.duration ?? 0)}</div>
|
||||
<div className="track-meta">
|
||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||||
</div>
|
||||
<div className="playlist-row-delete-cell">
|
||||
<button
|
||||
className="playlist-row-delete-btn"
|
||||
style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }}
|
||||
onClick={e => { e.stopPropagation(); addSong(song); }}
|
||||
data-tooltip={t('playlists.addSong')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{visibleCols.map(colDef => {
|
||||
switch (colDef.key) {
|
||||
case 'num': return <div key="num" className="track-num" style={{ color: 'var(--text-muted)' }}>{idx + 1}</div>;
|
||||
case 'title': return <div key="title" className="track-info"><span className="track-title">{song.title}</span></div>;
|
||||
case 'artist': return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<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 'favorite': return <div key="favorite" />;
|
||||
case 'rating': return <div key="rating" />;
|
||||
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
||||
case 'format': return (
|
||||
<div key="format" className="track-meta">
|
||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
||||
</div>
|
||||
);
|
||||
case 'delete': return (
|
||||
<div key="delete" className="playlist-row-delete-cell">
|
||||
<button className="playlist-row-delete-btn" style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }} onClick={e => { e.stopPropagation(); addSong(song); }} data-tooltip={t('playlists.addSong')} data-tooltip-pos="left">
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
default: return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingMeta && playlist && (
|
||||
<PlaylistEditModal
|
||||
playlist={playlist}
|
||||
customCoverId={customCoverId}
|
||||
customCoverFetchUrl={customCoverFetchUrl ?? null}
|
||||
customCoverCacheKey={customCoverCacheKey ?? null}
|
||||
coverQuadUrls={coverQuadUrls}
|
||||
onClose={() => setEditingMeta(false)}
|
||||
onSave={handleSaveMeta}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Playlist Edit Modal ───────────────────────────────────────────────────────
|
||||
|
||||
interface EditModalProps {
|
||||
playlist: SubsonicPlaylist;
|
||||
customCoverId: string | null;
|
||||
customCoverFetchUrl: string | null;
|
||||
customCoverCacheKey: string | null;
|
||||
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
|
||||
onClose: () => void;
|
||||
onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
function PlaylistEditModal({
|
||||
playlist, customCoverId, customCoverFetchUrl, customCoverCacheKey,
|
||||
coverQuadUrls, onClose, onSave,
|
||||
}: EditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(playlist.name);
|
||||
const [comment, setComment] = useState(playlist.comment ?? '');
|
||||
const [isPublic, setIsPublic] = useState(playlist.public ?? false);
|
||||
const [coverFile, setCoverFile] = useState<File | null>(null);
|
||||
const [coverPreview, setCoverPreview] = useState<string | null>(null);
|
||||
const [coverRemoved, setCoverRemoved] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasExistingCover = !coverRemoved && (coverPreview || customCoverId);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setCoverFile(file);
|
||||
setCoverRemoved(false);
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev => setCoverPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleRemoveCover = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCoverFile(null);
|
||||
setCoverPreview(null);
|
||||
setCoverRemoved(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ name, comment, isPublic, coverFile, coverRemoved });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleOverlayClick}>
|
||||
<div className="modal-content playlist-edit-modal" onClick={e => e.stopPropagation()}>
|
||||
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ top: 16, right: 16 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<h2 className="modal-title" style={{ fontSize: 22 }}>{t('playlists.editMeta')}</h2>
|
||||
|
||||
<div className="playlist-edit-body">
|
||||
{/* Left: cover */}
|
||||
<div
|
||||
className="playlist-edit-cover-wrap"
|
||||
onClick={() => coverInputRef.current?.click()}
|
||||
>
|
||||
{coverPreview ? (
|
||||
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? (
|
||||
<CachedImage
|
||||
src={customCoverFetchUrl}
|
||||
cacheKey={customCoverCacheKey}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="playlist-cover-grid" style={{ width: '100%', height: '100%' }}>
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="playlist-edit-cover-overlay">
|
||||
<div className="playlist-edit-cover-menu">
|
||||
<button
|
||||
className="playlist-edit-cover-menu-item"
|
||||
onClick={e => { e.stopPropagation(); coverInputRef.current?.click(); }}
|
||||
>
|
||||
<Camera size={14} />
|
||||
{t('playlists.changeCoverLabel')}
|
||||
</button>
|
||||
{hasExistingCover && (
|
||||
<button
|
||||
className="playlist-edit-cover-menu-item playlist-edit-cover-menu-item--danger"
|
||||
onClick={handleRemoveCover}
|
||||
>
|
||||
{t('playlists.removeCover')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<input ref={coverInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
</div>
|
||||
|
||||
{/* Right: fields */}
|
||||
<div className="playlist-edit-fields">
|
||||
<input
|
||||
className="input playlist-edit-name-input"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('playlists.editNamePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<textarea
|
||||
className="input playlist-edit-desc-input"
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder={t('playlists.editCommentPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="playlist-edit-footer">
|
||||
<label className="toggle-label" style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
|
||||
<label className="toggle-switch" style={{ marginBottom: 0 }}>
|
||||
<input type="checkbox" checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('playlists.editPublic')}</span>
|
||||
</label>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !name.trim()}>
|
||||
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
|
||||
{t('playlists.editSave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ export default function RandomMix() {
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
<div>{t('randomMix.trackArtist')}</div>
|
||||
@@ -344,7 +344,7 @@ export default function RandomMix() {
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}
|
||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
|
||||
role="row"
|
||||
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
@@ -414,7 +414,7 @@ export default function RandomMix() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
<div>{t('randomMix.trackArtist')}</div>
|
||||
@@ -443,7 +443,7 @@ export default function RandomMix() {
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}
|
||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
|
||||
role="row"
|
||||
onContextMenu={e => {
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function SearchResults() {
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('search.songs')}</h2>
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 100px 65px' }}>
|
||||
<div />
|
||||
<div>{t('albumDetail.trackTitle')}</div>
|
||||
<div>{t('albumDetail.trackArtist')}</div>
|
||||
@@ -81,7 +81,7 @@ export default function SearchResults() {
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${currentTrack?.id === song.id ? ' active' : ''}`}
|
||||
style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 100px 65px' }}
|
||||
onDoubleClick={() => playSong(song, results.songs)}
|
||||
role="row"
|
||||
onMouseDown={e => {
|
||||
|
||||
+297
-150
@@ -5,8 +5,10 @@ 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
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download
|
||||
} from 'lucide-react';
|
||||
import { exportBackup, importBackup } from '../utils/backup';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
|
||||
@@ -82,7 +84,7 @@ const SPECIAL_THANKS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about';
|
||||
type Tab = 'general' | 'server' | 'audio' | 'storage' | 'appearance' | 'input' | 'system';
|
||||
|
||||
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -162,7 +164,7 @@ export default function Settings() {
|
||||
const { state: routeState } = useLocation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'server');
|
||||
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'general');
|
||||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
@@ -182,9 +184,9 @@ export default function Settings() {
|
||||
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'library') return;
|
||||
if (activeTab !== 'storage') return;
|
||||
getImageCacheSize().then(setImageCacheBytes);
|
||||
invoke<number>('get_offline_cache_size').then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
||||
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
||||
}, [activeTab]);
|
||||
|
||||
const handleClearCache = useCallback(async () => {
|
||||
@@ -193,7 +195,7 @@ export default function Settings() {
|
||||
await clearAllOffline(serverId);
|
||||
const [imgBytes, offBytes] = await Promise.all([
|
||||
getImageCacheSize(),
|
||||
invoke<number>('get_offline_cache_size').catch(() => 0),
|
||||
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).catch(() => 0),
|
||||
]);
|
||||
setImageCacheBytes(imgBytes);
|
||||
setOfflineCacheBytes(offBytes);
|
||||
@@ -299,6 +301,13 @@ export default function Settings() {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const pickOfflineDir = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') });
|
||||
if (selected && typeof selected === 'string') {
|
||||
auth.setOfflineDownloadDir(selected);
|
||||
}
|
||||
};
|
||||
|
||||
const pickDownloadFolder = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') });
|
||||
if (selected && typeof selected === 'string') {
|
||||
@@ -307,12 +316,13 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||||
{ id: 'playback', label: t('settings.tabPlayback'), icon: <Play size={15} /> },
|
||||
{ id: 'library', label: t('settings.tabLibrary'), icon: <Shuffle size={15} /> },
|
||||
{ id: 'shortcuts', label: t('settings.tabShortcuts'), icon: <Keyboard size={15} /> },
|
||||
{ id: 'about', label: t('settings.tabAbout'), icon: <Info size={15} /> },
|
||||
{ id: 'general', label: t('settings.tabGeneral'), icon: <AppWindow size={15} /> },
|
||||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||||
{ id: 'audio', label: t('settings.tabAudio'), icon: <Music2 size={15} /> },
|
||||
{ id: 'storage', label: t('settings.tabStorage'), icon: <HardDrive size={15} /> },
|
||||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||||
{ id: 'input', label: t('settings.tabInput'), icon: <Keyboard size={15} /> },
|
||||
{ id: 'system', label: t('settings.tabSystem'), icon: <Info size={15} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -333,8 +343,8 @@ export default function Settings() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* ── Playback ─────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'playback' && (
|
||||
{/* ── Audio ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'audio' && (
|
||||
<>
|
||||
{/* Equalizer */}
|
||||
<section className="settings-section">
|
||||
@@ -407,16 +417,16 @@ export default function Settings() {
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
step={0.1}
|
||||
value={auth.crossfadeSecs}
|
||||
onChange={e => auth.setCrossfadeSecs(Number(e.target.value))}
|
||||
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
|
||||
style={{ width: 120 }}
|
||||
id="crossfade-secs-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 28 }}>
|
||||
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs })}
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||||
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -447,63 +457,59 @@ export default function Settings() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Library ──────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'library' && (
|
||||
{/* ── General ──────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'general' && (
|
||||
<>
|
||||
{/* Cache */}
|
||||
{/* App behaviour */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<AppWindow size={18} />
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>{t('settings.cacheTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 10, lineHeight: 1.5 }}>
|
||||
{t('settings.cacheDesc')}
|
||||
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
|
||||
<span style={{ marginLeft: 6, color: 'var(--text-secondary)' }}>
|
||||
— {t('settings.cacheUsed', {
|
||||
images: imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…',
|
||||
offline: offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-toggle-row" style={{ marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{auth.maxCacheMb} MB</span>
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={5000}
|
||||
step={100}
|
||||
value={auth.maxCacheMb}
|
||||
onChange={e => auth.setMaxCacheMb(Number(e.target.value))}
|
||||
style={{ width: 120 }}
|
||||
id="cache-size-slider"
|
||||
/>
|
||||
</div>
|
||||
{showClearConfirm ? (
|
||||
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
|
||||
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
||||
onClick={handleClearCache}
|
||||
disabled={clearing}
|
||||
>
|
||||
{t('settings.cacheClearConfirm')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
|
||||
{t('settings.cacheClearCancel')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
|
||||
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
||||
</button>
|
||||
)}
|
||||
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
|
||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
|
||||
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
|
||||
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
|
||||
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -595,6 +601,143 @@ export default function Settings() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Storage & Downloads ───────────────────────────────────────────────── */}
|
||||
{activeTab === 'storage' && (
|
||||
<>
|
||||
{/* Offline Library (In-App) — includes cache settings */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Download size={18} />
|
||||
<h2>{t('settings.offlineDirTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{t('settings.offlineDirDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.offlineDownloadDir || t('settings.offlineDirDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.offlineDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.offlineDownloadDir && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => auth.setOfflineDownloadDir('')}
|
||||
data-tooltip={t('settings.offlineDirClear')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickOfflineDir} style={{ flexShrink: 0 }} id="settings-offline-dir-btn">
|
||||
<FolderOpen size={16} /> {t('settings.offlineDirChange')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.offlineDownloadDir && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
{t('settings.offlineDirHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
|
||||
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
|
||||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedImages')}</span>
|
||||
{imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedOffline')}</span>
|
||||
{offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.cacheMaxLabel')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={100}
|
||||
max={50000}
|
||||
step={100}
|
||||
value={auth.maxCacheMb}
|
||||
onChange={e => {
|
||||
const v = Number(e.target.value);
|
||||
if (v >= 100) auth.setMaxCacheMb(v);
|
||||
}}
|
||||
style={{ width: 80, padding: '4px 8px', fontSize: 13 }}
|
||||
id="cache-size-input"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span>
|
||||
</div>
|
||||
{showClearConfirm ? (
|
||||
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
|
||||
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
||||
onClick={handleClearCache}
|
||||
disabled={clearing}
|
||||
>
|
||||
{t('settings.cacheClearConfirm')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
|
||||
{t('settings.cacheClearCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
|
||||
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ZIP Export & Archiving */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<FolderOpen size={18} />
|
||||
<h2>{t('settings.downloadsTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{t('settings.downloadsFolderDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.downloadFolder || t('settings.downloadsDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.downloadFolder ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.downloadFolder && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => auth.setDownloadFolder('')}
|
||||
aria-label={t('settings.clearFolder')}
|
||||
data-tooltip={t('settings.clearFolder')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickDownloadFolder} style={{ flexShrink: 0 }} id="settings-download-folder-btn">
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Appearance ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'appearance' && (
|
||||
<>
|
||||
@@ -668,13 +811,13 @@ export default function Settings() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Shortcuts ────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'shortcuts' && (
|
||||
{/* ── Input ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'input' && (
|
||||
<>
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Keyboard size={18} />
|
||||
<h2>{t('settings.tabShortcuts')}</h2>
|
||||
<h2>{t('settings.tabInput')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||
@@ -993,89 +1136,13 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Downloads + Tray */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<AppWindow size={18} />
|
||||
<h2>{t('settings.behavior')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
|
||||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
|
||||
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
|
||||
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
|
||||
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, wordBreak: 'break-all' }}>
|
||||
{auth.downloadFolder || t('settings.downloadsDefault')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{auth.downloadFolder && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => auth.setDownloadFolder('')}
|
||||
aria-label={t('settings.clearFolder')}
|
||||
data-tooltip={t('settings.clearFolder')}
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickDownloadFolder} id="settings-download-folder-btn">
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── About ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'about' && (
|
||||
{/* ── System ───────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'system' && (
|
||||
<>
|
||||
<BackupSection />
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Info size={18} />
|
||||
@@ -1417,6 +1484,86 @@ function SidebarCustomizer() {
|
||||
);
|
||||
}
|
||||
|
||||
function BackupSection() {
|
||||
const { t } = useTranslation();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const path = await exportBackup();
|
||||
if (path) showToast(t('settings.backupSuccess'), 3000, 'info');
|
||||
} catch (e) {
|
||||
console.error('Export failed', e);
|
||||
showToast(t('settings.backupImportError'), 4000, 'error');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!window.confirm(t('settings.backupImportConfirm'))) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
await importBackup();
|
||||
// importBackup reloads the page — this toast will briefly show before reload
|
||||
showToast(t('settings.backupImportSuccess'), 3000, 'info');
|
||||
} catch (e) {
|
||||
console.error('Import failed', e);
|
||||
showToast(t('settings.backupImportError'), 4000, 'error');
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<HardDrive size={18} />
|
||||
<h2>{t('settings.backupTitle')}</h2>
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="settings-card" style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupExport')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupExportDesc')}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Upload size={14} />
|
||||
{exporting ? '…' : t('settings.backupExport')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import */}
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupImport')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupImportDesc')}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleImport}
|
||||
disabled={importing}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Download size={14} />
|
||||
{importing ? '…' : t('settings.backupImport')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangelogSection() {
|
||||
const { t } = useTranslation();
|
||||
const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
|
||||
|
||||
+151
-7
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbumList, getArtists, getGenres, SubsonicAlbum } from '../api/subsonic';
|
||||
import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -14,6 +14,13 @@ function relativeTime(timestamp: number, t: (key: string, opts?: any) => string)
|
||||
return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) });
|
||||
}
|
||||
|
||||
function formatPlaytime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h.toLocaleString()}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
const PERIODS: { key: LastfmPeriod; label: string }[] = [
|
||||
{ key: '7day', label: 'lfmPeriod7day' },
|
||||
{ key: '1month', label: 'lfmPeriod1month' },
|
||||
@@ -32,8 +39,14 @@ export default function Statistics() {
|
||||
const [artistCount, setArtistCount] = useState<number | null>(null);
|
||||
const [totalSongs, setTotalSongs] = useState<number | null>(null);
|
||||
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [totalPlaytime, setTotalPlaytime] = useState<number | null>(null);
|
||||
const [playtimeCapped, setPlaytimeCapped] = useState(false);
|
||||
const [formatData, setFormatData] = useState<{ format: string; count: number }[] | null>(null);
|
||||
const [formatSampleSize, setFormatSampleSize] = useState(0);
|
||||
|
||||
const [lfmPeriod, setLfmPeriod] = useState<LastfmPeriod>('1month');
|
||||
const [lfmTopArtists, setLfmTopArtists] = useState<LastfmTopArtist[]>([]);
|
||||
const [lfmTopAlbums, setLfmTopAlbums] = useState<LastfmTopAlbum[]>([]);
|
||||
@@ -54,12 +67,62 @@ export default function Statistics() {
|
||||
setFrequent(fr);
|
||||
setHighest(hi);
|
||||
setArtistCount(a.length);
|
||||
setTotalSongs(g.reduce((acc: number, genre: any) => acc + genre.songCount, 0));
|
||||
setTotalAlbums(g.reduce((acc: number, genre: any) => acc + genre.albumCount, 0));
|
||||
setTotalSongs(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.songCount, 0));
|
||||
setTotalAlbums(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.albumCount, 0));
|
||||
const sorted = [...g].sort((a, b) => b.songCount - a.songCount);
|
||||
setGenres(sorted);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Background fetch: total playtime (paginate getAlbumList up to 10 pages of 500)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
let total = 0;
|
||||
let offset = 0;
|
||||
const pageSize = 500;
|
||||
const maxPages = 10;
|
||||
let capped = false;
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
try {
|
||||
const albums = await getAlbumList('newest', pageSize, offset);
|
||||
if (cancelled) return;
|
||||
for (const a of albums) total += (a.duration ?? 0);
|
||||
if (albums.length < pageSize) break;
|
||||
if (page === maxPages - 1) capped = true;
|
||||
offset += pageSize;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setTotalPlaytime(total);
|
||||
setPlaytimeCapped(capped);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Background fetch: format distribution (sample of 500 random songs)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getRandomSongs(500).then(songs => {
|
||||
if (cancelled) return;
|
||||
const counts: Record<string, number> = {};
|
||||
for (const song of songs) {
|
||||
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
|
||||
counts[fmt] = (counts[fmt] ?? 0) + 1;
|
||||
}
|
||||
const sorted = Object.entries(counts)
|
||||
.map(([format, count]) => ({ format, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
setFormatData(sorted);
|
||||
setFormatSampleSize(songs.length);
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
|
||||
setLfmRecentLoading(true);
|
||||
@@ -97,12 +160,20 @@ export default function Statistics() {
|
||||
}
|
||||
};
|
||||
|
||||
const playtimeDisplay = totalPlaytime === null
|
||||
? t('statistics.computing')
|
||||
: (playtimeCapped ? '≥ ' : '') + formatPlaytime(totalPlaytime);
|
||||
|
||||
const stats = [
|
||||
{ label: t('statistics.statArtists'), value: artistCount },
|
||||
{ label: t('statistics.statAlbums'), value: totalAlbums },
|
||||
{ label: t('statistics.statSongs'), value: totalSongs },
|
||||
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—' },
|
||||
{ label: t('statistics.statAlbums'), value: totalAlbums?.toLocaleString() ?? '—' },
|
||||
{ label: t('statistics.statSongs'), value: totalSongs?.toLocaleString() ?? '—' },
|
||||
{ label: t('statistics.statPlaytime'), value: playtimeDisplay },
|
||||
];
|
||||
|
||||
const topGenres = genres.slice(0, 10);
|
||||
const maxGenreSongs = topGenres[0]?.songCount ?? 1;
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<h1 className="page-title">{t('statistics.title')}</h1>
|
||||
@@ -115,12 +186,85 @@ export default function Statistics() {
|
||||
<div className="stats-overview">
|
||||
{stats.map(s => (
|
||||
<div key={s.label} className="stats-card">
|
||||
<span className="stats-card-value">{s.value?.toLocaleString() ?? '—'}</span>
|
||||
<span className="stats-card-value">{s.value}</span>
|
||||
<span className="stats-card-label">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Genre Insights + Format Distribution */}
|
||||
{(topGenres.length > 0 || formatData) && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1rem', marginBottom: '0.5rem' }}>
|
||||
|
||||
{topGenres.length > 0 && (
|
||||
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
||||
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
|
||||
{t('statistics.genreInsights')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{topGenres.map(g => (
|
||||
<div key={g.value}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
|
||||
{g.value}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0, marginLeft: '0.5rem' }}>
|
||||
{g.songCount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--glass-border)', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${(g.songCount / maxGenreSongs) * 100}%`,
|
||||
background: 'var(--accent)',
|
||||
opacity: 0.7,
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.4s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formatData && (
|
||||
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
||||
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '0.25rem' }}>
|
||||
{t('statistics.formatDistribution')}
|
||||
</h3>
|
||||
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
{t('statistics.formatSample', { n: formatSampleSize.toLocaleString() })}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{formatData.map(f => {
|
||||
const pct = formatSampleSize > 0 ? Math.round((f.count / formatSampleSize) * 100) : 0;
|
||||
return (
|
||||
<div key={f.format}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 600, fontFamily: 'monospace' }}>{f.format}</span>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{pct}%</span>
|
||||
</div>
|
||||
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--glass-border)', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${pct}%`,
|
||||
background: 'var(--accent)',
|
||||
opacity: 0.6,
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.4s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recent.length > 0 && (
|
||||
<AlbumRow title={t('statistics.recentlyPlayed')} albums={recent} />
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,7 @@ interface AuthState {
|
||||
scrobblingEnabled: boolean;
|
||||
maxCacheMb: number;
|
||||
downloadFolder: string;
|
||||
offlineDownloadDir: string;
|
||||
excludeAudiobooks: boolean;
|
||||
customGenreBlacklist: string[];
|
||||
replayGainEnabled: boolean;
|
||||
@@ -62,6 +63,7 @@ interface AuthState {
|
||||
setScrobblingEnabled: (v: boolean) => void;
|
||||
setMaxCacheMb: (v: number) => void;
|
||||
setDownloadFolder: (v: string) => void;
|
||||
setOfflineDownloadDir: (v: string) => void;
|
||||
setExcludeAudiobooks: (v: boolean) => void;
|
||||
setCustomGenreBlacklist: (v: string[]) => void;
|
||||
setReplayGainEnabled: (v: boolean) => void;
|
||||
@@ -99,6 +101,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
scrobblingEnabled: true,
|
||||
maxCacheMb: 500,
|
||||
downloadFolder: '',
|
||||
offlineDownloadDir: '',
|
||||
excludeAudiobooks: false,
|
||||
customGenreBlacklist: [],
|
||||
replayGainEnabled: false,
|
||||
@@ -162,6 +165,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
|
||||
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
|
||||
setDownloadFolder: (v) => set({ downloadFolder: v }),
|
||||
setOfflineDownloadDir: (v) => set({ offlineDownloadDir: v }),
|
||||
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
|
||||
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
|
||||
setReplayGainEnabled: (v) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildStreamUrl, getArtist, getAlbum } from '../api/subsonic';
|
||||
import type { SubsonicSong } from '../api/subsonic';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
export interface OfflineTrackMeta {
|
||||
id: string;
|
||||
@@ -156,6 +158,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
|
||||
const suffix = song.suffix || 'mp3';
|
||||
const url = buildStreamUrl(song.id);
|
||||
const customDir = useAuthStore.getState().offlineDownloadDir || null;
|
||||
|
||||
try {
|
||||
const localPath = await invoke<string>('download_track_offline', {
|
||||
@@ -163,6 +166,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
serverId,
|
||||
url,
|
||||
suffix,
|
||||
customDir,
|
||||
});
|
||||
|
||||
set(state => ({
|
||||
@@ -195,7 +199,11 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
: j,
|
||||
),
|
||||
}));
|
||||
} catch {
|
||||
} catch (err) {
|
||||
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : '');
|
||||
if (msg === 'VOLUME_NOT_FOUND') {
|
||||
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
|
||||
}
|
||||
set(state => ({
|
||||
jobs: state.jobs.map(j =>
|
||||
j.trackId === song.id && j.albumId === albumId
|
||||
@@ -263,9 +271,8 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
const meta = get().tracks[`${serverId}:${trackId}`];
|
||||
if (!meta) return;
|
||||
await invoke('delete_offline_track', {
|
||||
trackId,
|
||||
serverId,
|
||||
suffix: meta.suffix,
|
||||
localPath: meta.localPath,
|
||||
baseDir: useAuthStore.getState().offlineDownloadDir || null,
|
||||
}).catch(() => {});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -158,6 +158,24 @@ let seekTarget: number | null = null;
|
||||
// to the Rust backend before it has finished the previous one.
|
||||
let togglePlayLock = false;
|
||||
|
||||
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
|
||||
// Internet radio streams are played via a native <audio> element instead of
|
||||
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
|
||||
// codec support (MP3, AAC, HE-AAC, OGG) and stable ICY stream handling for
|
||||
// free, without touching the regular playback pipeline at all.
|
||||
const radioAudio = new Audio();
|
||||
radioAudio.preload = 'none';
|
||||
let radioStopping = false;
|
||||
radioAudio.addEventListener('ended', () => {
|
||||
// Stream disconnected unexpectedly — clear radio state.
|
||||
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
|
||||
});
|
||||
radioAudio.addEventListener('error', () => {
|
||||
if (radioStopping) { radioStopping = false; return; }
|
||||
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
|
||||
showToast('Radio stream error', 3000, 'error');
|
||||
});
|
||||
|
||||
// Timestamp of the last gapless auto-advance (from audio:track_switched).
|
||||
// Used to suppress ghost-commands from stale IPC arriving after the switch.
|
||||
let lastGaplessSwitchTime = 0;
|
||||
@@ -579,7 +597,13 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
// ── stop ────────────────────────────────────────────────────────────────
|
||||
stop: () => {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
if (get().currentRadio) {
|
||||
radioStopping = true;
|
||||
radioAudio.pause();
|
||||
radioAudio.src = '';
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
}
|
||||
isAudioPaused = false;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, currentRadio: null });
|
||||
@@ -592,8 +616,13 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
isAudioPaused = false;
|
||||
gaplessPreloadingId = null;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
invoke('audio_play_radio', { url: station.streamUrl, volume }).catch((err: unknown) => {
|
||||
console.error('[psysonic] audio_play_radio failed:', err);
|
||||
// Stop Rust engine in case a regular track was playing.
|
||||
invoke('audio_stop').catch(() => {});
|
||||
// Play via HTML5 audio — browser handles reconnects, codec negotiation, buffering.
|
||||
radioAudio.src = station.streamUrl;
|
||||
radioAudio.volume = volume;
|
||||
radioAudio.play().catch((err: unknown) => {
|
||||
console.error('[psysonic] radio HTML5 play failed:', err);
|
||||
set({ isPlaying: false, currentRadio: null });
|
||||
});
|
||||
set({
|
||||
@@ -622,13 +651,23 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
gaplessPreloadingId = null; // new track — allow fresh preload for next
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
|
||||
// If a radio stream is active, stop it before the new track starts so
|
||||
// the PlayerBar clears radio mode immediately and the stream is released.
|
||||
if (get().currentRadio) {
|
||||
radioStopping = true;
|
||||
radioAudio.pause();
|
||||
radioAudio.src = '';
|
||||
}
|
||||
|
||||
const state = get();
|
||||
const newQueue = queue ?? state.queue;
|
||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||
|
||||
// Set state immediately so the UI updates before the download completes.
|
||||
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
||||
set({
|
||||
currentTrack: track,
|
||||
currentRadio: null,
|
||||
queue: newQueue,
|
||||
queueIndex: idx >= 0 ? idx : 0,
|
||||
progress: 0,
|
||||
@@ -680,12 +719,21 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
// ── pause / resume / togglePlay ──────────────────────────────────────────
|
||||
pause: () => {
|
||||
invoke('audio_pause').catch(console.error);
|
||||
isAudioPaused = true;
|
||||
if (get().currentRadio) {
|
||||
radioAudio.pause();
|
||||
} else {
|
||||
invoke('audio_pause').catch(console.error);
|
||||
isAudioPaused = true;
|
||||
}
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
|
||||
resume: () => {
|
||||
if (get().currentRadio) {
|
||||
radioAudio.play().catch(console.error);
|
||||
set({ isPlaying: true });
|
||||
return;
|
||||
}
|
||||
const { currentTrack, queue, currentTime } = get();
|
||||
if (!currentTrack) return;
|
||||
|
||||
@@ -912,6 +960,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
setVolume: (v) => {
|
||||
const clamped = Math.max(0, Math.min(1, v));
|
||||
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
|
||||
radioAudio.volume = clamped;
|
||||
set({ volume: clamped });
|
||||
},
|
||||
|
||||
|
||||
+282
-13
@@ -1217,10 +1217,13 @@
|
||||
/* ─ Tracklist ─ */
|
||||
.tracklist {
|
||||
padding: 0 var(--space-6) var(--space-6);
|
||||
contain: layout;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.col-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1277,13 +1280,14 @@
|
||||
}
|
||||
|
||||
/* ── Column resize handle ── */
|
||||
/* Sits at the right edge of each header cell, fully inside the cell bounds */
|
||||
/* Positioned flush with the right edge of its header cell.
|
||||
The ::after pseudo-element renders the visible 1 px divider line. */
|
||||
.col-resize-handle {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -1291,10 +1295,11 @@
|
||||
.col-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 2px;
|
||||
width: 1px;
|
||||
border-radius: 1px;
|
||||
background: var(--ctp-surface1);
|
||||
transition: background 0.15s;
|
||||
@@ -1393,12 +1398,6 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Playlist tracklist variant — adds delete column ── */
|
||||
.tracklist-header.tracklist-playlist,
|
||||
.track-row.tracklist-playlist {
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 80px 80px 36px;
|
||||
}
|
||||
|
||||
/* Delete button in playlist row */
|
||||
.playlist-row-delete-cell {
|
||||
display: flex;
|
||||
@@ -1651,10 +1650,12 @@
|
||||
}
|
||||
|
||||
.track-duration {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.track-meta {
|
||||
@@ -1793,6 +1794,130 @@
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
/* ─ Playlist edit modal ─ */
|
||||
.playlist-edit-modal {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.playlist-edit-body {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Clickable cover wrap in the hero (opens modal) */
|
||||
.playlist-hero-cover {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlist-hero-cover-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.playlist-hero-cover:hover .playlist-hero-cover-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Cover area inside the modal */
|
||||
.playlist-edit-cover-wrap {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.playlist-edit-cover-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.playlist-edit-cover-wrap:hover .playlist-edit-cover-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.playlist-edit-cover-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.playlist-edit-cover-menu-item {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.playlist-edit-cover-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.playlist-edit-cover-menu-item--danger {
|
||||
color: var(--ctp-red, #f38ba8);
|
||||
}
|
||||
|
||||
/* Right side fields */
|
||||
.playlist-edit-fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.playlist-edit-name-input {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.playlist-edit-desc-input {
|
||||
resize: none;
|
||||
min-height: 80px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.playlist-edit-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.artist-bio {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
@@ -4964,6 +5089,150 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ─ Radio Toolbar ─ */
|
||||
.radio-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.radio-toolbar-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.radio-toolbar-chips::-webkit-scrollbar { display: none; }
|
||||
.radio-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.radio-filter-chip:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.radio-filter-chip.active { background: var(--accent); color: var(--ctp-crust); border-color: var(--accent); }
|
||||
|
||||
|
||||
.radio-card-drop-before {
|
||||
box-shadow: -3px 0 0 0 var(--accent);
|
||||
}
|
||||
.radio-card-drop-after {
|
||||
box-shadow: 3px 0 0 0 var(--accent);
|
||||
}
|
||||
|
||||
/* ─ Radio card edit chip ─ */
|
||||
.radio-card-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--text-secondary);
|
||||
color: var(--ctp-surface1);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.radio-card-chip:hover {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
/* ─ Alphabet Filter Bar ─ */
|
||||
.alphabet-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.alphabet-filter-btn {
|
||||
min-width: 26px;
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s, background 0.12s;
|
||||
}
|
||||
.alphabet-filter-btn:hover { background: var(--bg-hover); }
|
||||
.alphabet-filter-btn.active { background: var(--accent); color: var(--ctp-crust); }
|
||||
.alphabet-filter-btn.empty { opacity: 0.28; pointer-events: none; }
|
||||
|
||||
/* ─ Radio favorite star ─ */
|
||||
.radio-favorite-btn { color: rgba(255, 255, 255, 0.35); }
|
||||
.radio-favorite-btn:hover { color: rgba(255, 255, 255, 0.8); }
|
||||
.radio-favorite-btn.active { color: var(--accent); }
|
||||
|
||||
/* ─ Radio Browser Directory ─ */
|
||||
.radio-browser-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 6px;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.12s;
|
||||
cursor: default;
|
||||
}
|
||||
.radio-browser-result.clickable { cursor: pointer; }
|
||||
.radio-browser-result.clickable:hover { background: var(--bg-hover); }
|
||||
.radio-browser-result.added { opacity: 0.6; }
|
||||
.radio-browser-action {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radio-browser-favicon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.radio-browser-favicon--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.radio-browser-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.radio-browser-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.radio-browser-tags {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ─ Bulk Select ─ */
|
||||
.bulk-action-bar {
|
||||
display: flex;
|
||||
|
||||
@@ -311,6 +311,15 @@
|
||||
font-style: italic;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
.app-updater-error {
|
||||
font-size: 10px;
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.app-updater-btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { save, open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { writeFile, readTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
|
||||
const BACKUP_VERSION = 1;
|
||||
|
||||
const BACKUP_KEYS = [
|
||||
'psysonic-auth',
|
||||
'psysonic_theme',
|
||||
'psysonic_font',
|
||||
'psysonic_language',
|
||||
'psysonic_keybindings',
|
||||
'psysonic_sidebar',
|
||||
'psysonic-eq',
|
||||
'psysonic_global_shortcuts',
|
||||
'psysonic-player',
|
||||
];
|
||||
|
||||
export async function exportBackup(): Promise<string | null> {
|
||||
const stores: Record<string, unknown> = {};
|
||||
for (const key of BACKUP_KEYS) {
|
||||
const val = localStorage.getItem(key);
|
||||
if (val !== null) {
|
||||
try {
|
||||
stores[key] = JSON.parse(val);
|
||||
} catch {
|
||||
stores[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: BACKUP_VERSION,
|
||||
app_version: appVersion,
|
||||
created_at: new Date().toISOString(),
|
||||
stores,
|
||||
};
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const path = await save({
|
||||
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp'] }],
|
||||
defaultPath: `psysonic-backup-${today}.psybkp`,
|
||||
});
|
||||
|
||||
if (!path) return null;
|
||||
|
||||
const content = JSON.stringify(manifest, null, 2);
|
||||
await writeFile(path, new TextEncoder().encode(content));
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function importBackup(): Promise<void> {
|
||||
const path = await openDialog({
|
||||
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp'] }],
|
||||
multiple: false,
|
||||
title: 'Import Psysonic Backup',
|
||||
});
|
||||
|
||||
if (!path || typeof path !== 'string') return;
|
||||
|
||||
const raw = await readTextFile(path);
|
||||
const manifest = JSON.parse(raw);
|
||||
|
||||
if (typeof manifest.version !== 'number' || !manifest.stores || typeof manifest.stores !== 'object') {
|
||||
throw new Error('invalid_backup');
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(manifest.stores)) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
|
||||
export interface ColDef {
|
||||
readonly key: string;
|
||||
readonly i18nKey?: string | null;
|
||||
readonly minWidth: number;
|
||||
readonly defaultWidth: number;
|
||||
readonly required: boolean;
|
||||
/** If true the column uses minmax(minWidth, 1fr) instead of a fixed px width. */
|
||||
readonly flex?: boolean;
|
||||
}
|
||||
|
||||
function loadPrefs(
|
||||
storageKey: string,
|
||||
columns: readonly ColDef[],
|
||||
): { widths: Record<string, number>; visible: Set<string> } {
|
||||
const defaultWidths: Record<string, number> = Object.fromEntries(
|
||||
columns.map(c => [c.key, c.defaultWidth]),
|
||||
);
|
||||
const defaultVisible = new Set<string>(columns.map(c => c.key));
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) return { widths: defaultWidths, visible: defaultVisible };
|
||||
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[] };
|
||||
const visible = new Set<string>(parsed.visible ?? [...defaultVisible]);
|
||||
columns.filter(c => c.required).forEach(c => visible.add(c.key));
|
||||
return {
|
||||
widths: { ...defaultWidths, ...(parsed.widths ?? {}) },
|
||||
visible,
|
||||
};
|
||||
} catch {
|
||||
return { widths: defaultWidths, visible: defaultVisible };
|
||||
}
|
||||
}
|
||||
|
||||
function savePrefs(storageKey: string, widths: Record<string, number>, visible: Set<string>) {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ widths, visible: [...visible] }));
|
||||
}
|
||||
|
||||
export function useTracklistColumns(columns: readonly ColDef[], storageKey: string) {
|
||||
const [colWidths, setColWidths] = useState<Record<string, number>>(
|
||||
() => loadPrefs(storageKey, columns).widths,
|
||||
);
|
||||
const [colVisible, setColVisible] = useState<Set<string>>(
|
||||
() => loadPrefs(storageKey, columns).visible,
|
||||
);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const tracklistRef = useRef<HTMLDivElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
// Refs to avoid stale closures in drag/save handlers
|
||||
const colWidthsRef = useRef(colWidths);
|
||||
const colVisibleRef = useRef(colVisible);
|
||||
useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
|
||||
useEffect(() => { colVisibleRef.current = colVisible; }, [colVisible]);
|
||||
|
||||
const visibleCols = useMemo(
|
||||
() => columns.filter(c => colVisible.has(c.key)),
|
||||
[columns, colVisible],
|
||||
);
|
||||
|
||||
const gridTemplate = useMemo(
|
||||
() =>
|
||||
visibleCols
|
||||
.map(c => (c.flex ? `minmax(${c.minWidth}px, 1fr)` : `${colWidths[c.key]}px`))
|
||||
.join(' '),
|
||||
[visibleCols, colWidths],
|
||||
);
|
||||
|
||||
// Minimum total width so the grid never squishes below its current column sizes.
|
||||
// When .tracklist is narrower, overflow-x: auto triggers a scrollbar.
|
||||
// Formula (box-sizing: border-box): colSum + gaps + left/right padding (12px each = 24px)
|
||||
const gridMinWidth = useMemo(() => {
|
||||
const gapPx = 12; // --space-3
|
||||
const boxPaddingH = 24; // var(--space-3) * 2
|
||||
const colSum = visibleCols.reduce<number>(
|
||||
(s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]),
|
||||
0,
|
||||
);
|
||||
const gaps = Math.max(0, visibleCols.length - 1) * gapPx;
|
||||
return colSum + gaps + boxPaddingH;
|
||||
}, [visibleCols, colWidths]);
|
||||
|
||||
const gridStyle = useMemo(
|
||||
() => ({ gridTemplateColumns: gridTemplate, minWidth: `${gridMinWidth}px` }),
|
||||
[gridTemplate, gridMinWidth],
|
||||
);
|
||||
|
||||
// Excel-style column resize:
|
||||
// direction = 1 → right-edge handle: drag right → column grows, 1fr title shrinks
|
||||
// direction = -1 → left-edge handle : drag right → next px col shrinks, 1fr title grows
|
||||
const startResize = useCallback(
|
||||
(e: React.MouseEvent, colIndex: number, direction: 1 | -1 = 1) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const visCols = visibleCols; // stable for the drag duration
|
||||
const colDef = visCols[colIndex];
|
||||
const colKey = colDef.key;
|
||||
const colMin = columns.find(c => c.key === colKey)!.minWidth;
|
||||
const startX = e.clientX;
|
||||
const startW = colWidths[colKey];
|
||||
|
||||
let maxW = Infinity;
|
||||
const el = tracklistRef.current;
|
||||
if (el) {
|
||||
const style = getComputedStyle(el);
|
||||
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const containerW = el.clientWidth - paddingH;
|
||||
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
|
||||
const gapPx = headerEl
|
||||
? parseFloat(getComputedStyle(headerEl).columnGap) || 12
|
||||
: 12;
|
||||
const totalGaps = (visCols.length - 1) * gapPx;
|
||||
const otherFixed = visCols
|
||||
.filter((_, i) => i !== colIndex)
|
||||
.reduce<number>((s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]), 0);
|
||||
maxW = Math.max(colMin, containerW - totalGaps - otherFixed);
|
||||
}
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
const delta = me.clientX - startX;
|
||||
const newW = Math.min(Math.max(colMin, startW + direction * delta), maxW);
|
||||
setColWidths(prev => ({ ...prev, [colKey]: newW }));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
savePrefs(storageKey, colWidthsRef.current, colVisibleRef.current);
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
},
|
||||
[columns, visibleCols, colWidths, storageKey],
|
||||
);
|
||||
|
||||
const toggleColumn = useCallback(
|
||||
(key: string) => {
|
||||
const def = columns.find(c => c.key === key)!;
|
||||
if (def.required) return;
|
||||
setColVisible(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
savePrefs(storageKey, colWidthsRef.current, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[columns, storageKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [pickerOpen]);
|
||||
|
||||
return {
|
||||
colWidths,
|
||||
colVisible,
|
||||
visibleCols,
|
||||
gridStyle,
|
||||
startResize,
|
||||
toggleColumn,
|
||||
pickerOpen,
|
||||
setPickerOpen,
|
||||
pickerRef,
|
||||
tracklistRef,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user