Compare commits

..

3 Commits

Author SHA1 Message Date
Psychotoxical 9be0d8dfa9 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>
2026-04-05 19:34:26 +02:00
Psychotoxical 67f31b0700 fix: include InternetRadio.tsx (referenced in App.tsx router) 2026-04-04 02:09:19 +02:00
Psychotoxical c873880a26 feat: v1.31.0 — AutoEQ, resizable tracklist columns, Discord Listening type, layout fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 02:04:14 +02:00
45 changed files with 5548 additions and 680 deletions
+3
View File
@@ -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
+69
View File
@@ -5,6 +5,75 @@ 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. ☀️
### Added
- **AutoEQ — 10-Band Parametric Equalizer**: Full parametric EQ with 10 adjustable bands, bypass toggle, and pre-gain control. AutoEQ presets are loaded directly from the AutoEQ GitHub repository — search for your headphone model and apply a community-measured correction curve with one click.
- **Internet Radio — infrastructure** *(work in progress, not yet released)*: The full backend for Internet Radio playback is in place — a dedicated Rust `RadioBuffer` streaming pipeline in the audio engine, Subsonic API integration (`getInternetRadioStations`, create/update/delete), and a `playRadio` action in the player store. The UI page exists but the feature is **not yet accessible** from the sidebar — it will be enabled once the experience is polished.
- **Tracklist columns — resizable & configurable** *(experimental)*: Album tracklist columns can now be resized by dragging the dividers between header cells, similar to a spreadsheet. A column visibility picker (chevron button at the top right) lets you show or hide individual columns. The `#` column is fixed-width. Column widths and visibility are persisted in localStorage. The feature works but is still being refined.
- **Genre column in album tracklist**: Albums that have genre tags per track now show a Genre column in the tracklist.
- **Sidebar auto-migration**: New sidebar items (e.g. Internet Radio) are automatically appended to existing persisted sidebar configurations on first launch — no more missing entries after updates.
### Changed
- **Discord Rich Presence**: Activity type is now `Listening` instead of the default `Playing`. The artist field no longer has the "by " prefix — Discord's layout makes the context clear without it. Album name is shown as a tooltip on the cover icon.
- **Clickable artist names everywhere**: Artist names in Album Cards, Favorites, Random Mix, Playlist Detail, and Artist Detail tracklists are now clickable links that navigate to the artist page.
- **Duration format supports hours**: Tracks and albums longer than 60 minutes are now displayed as `H:MM:SS` instead of overflowing minutes (e.g. `75:03``1:15:03`).
- **Format column**: Codec label no longer includes the "kbps" suffix or the `·` separator — cleaner and fits the narrower column better (e.g. `FLAC 1411` instead of `FLAC · 1411 kbps`).
- **Now Playing sidebar link**: No longer permanently styled as an active menu item. It now only shows the accent background when you are actually on the Now Playing page; at all other times it is distinguished only by its accent text colour.
- **Paused-state indicator in tracklist**: When the currently active track is paused, a dimmed play icon is shown in the `#` column instead of a blank space — making it clear which track is loaded even when playback is stopped.
- **Text selection disabled**: Text can no longer be accidentally selected anywhere in the player by click-dragging or pressing Ctrl+A. Standard input fields are unaffected.
- **Settings — button styles**: "Test connection", "Add server", and "Pick download folder" buttons are now `btn-surface` (with a subtle border) instead of the borderless `btn-ghost` — clearer affordance.
- **Settings — Behavior section icon**: Replaced the generic `Sliders` icon with `AppWindow` for the Behavior section header.
- **`btn-surface` border**: The surface button variant now has a 1 px border that brightens on hover — consistent with the card and input visual language.
- **Queue panel minimum width**: Increased from 250 px to 310 px to prevent layout overflow when the codec/bitrate overlay is visible.
- **Server compatibility hint**: A short note below the Servers section header in Settings clarifies which Subsonic-compatible servers are supported.
### Fixed
- **Tracklist `#` column header alignment**: The "Select all" checkbox and the `#` symbol in the header now use the same internal layout as the row cells — ensuring alignment with individual checkboxes and track numbers at all window sizes.
- **Column resize dividers**: The visible 2 px divider line is now placed in the gap between columns rather than inside the cell, so header labels appear visually centred between their dividers.
- **Internet Radio sidebar link hidden**: The navigation entry is temporarily removed until the feature is ready for release. The underlying code remains in place and will be re-enabled without any migration required.
---
## [1.30.0] - 2026-04-03
### Added
+30 -2
View File
@@ -1,13 +1,14 @@
{
"name": "psysonic",
"version": "1.26.1",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.26.1",
"version": "1.30.0",
"dependencies": {
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
@@ -1226,6 +1227,33 @@
"win32"
]
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.23"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.30.0",
"version": "1.32.0",
"private": true,
"scripts": {
"dev": "vite",
@@ -11,6 +11,7 @@
"tauri:build": "tauri build"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.30.0
pkgver=1.31.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+29 -1
View File
@@ -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,12 +3493,14 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.30.0"
version = "1.32.0"
dependencies = [
"biquad",
"discord-rich-presence",
"futures-util",
"md5",
"reqwest 0.12.28",
"ringbuf",
"rodio",
"serde",
"serde_json",
@@ -3731,6 +3743,7 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -3829,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"
@@ -5657,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"
+4 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.30.0"
version = "1.32.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -32,10 +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"
+3
View File
@@ -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
View File
@@ -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"]}}
+809 -26
View File
@@ -1,8 +1,10 @@
use std::io::{Cursor, Read, Seek};
use std::io::{Cursor, Read, Seek, SeekFrom};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use ringbuf::{HeapConsumer, HeapProducer, HeapRb};
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
use rodio::{Sink, Source};
use rodio::source::UniformSourceIterator;
@@ -16,6 +18,7 @@ use symphonia::core::{
probe::Hint,
units::{self, Time},
};
use futures_util::StreamExt;
use tauri::{AppHandle, Emitter, State};
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
@@ -30,6 +33,7 @@ struct EqSource<S: Source<Item = f32>> {
channels: u16,
gains: Arc<[AtomicU32; 10]>,
enabled: Arc<AtomicBool>,
pre_gain: Arc<AtomicU32>,
filters: [[DirectForm2Transposed<f32>; 2]; 10],
current_gains: [f32; 10],
sample_counter: usize,
@@ -37,7 +41,7 @@ struct EqSource<S: Source<Item = f32>> {
}
impl<S: Source<Item = f32>> EqSource<S> {
fn new(inner: S, gains: Arc<[AtomicU32; 10]>, enabled: Arc<AtomicBool>) -> Self {
fn new(inner: S, gains: Arc<[AtomicU32; 10]>, enabled: Arc<AtomicBool>, pre_gain: Arc<AtomicU32>) -> Self {
let sample_rate = inner.sample_rate();
let channels = inner.channels();
let filters = std::array::from_fn(|band| {
@@ -58,7 +62,7 @@ impl<S: Source<Item = f32>> EqSource<S> {
})
});
Self {
inner, sample_rate, channels, gains, enabled,
inner, sample_rate, channels, gains, enabled, pre_gain,
filters,
current_gains: [0.0; 10],
sample_counter: 0,
@@ -106,7 +110,9 @@ impl<S: Source<Item = f32>> Iterator for EqSource<S> {
let ch = self.channel_idx.min(1);
self.channel_idx = (self.channel_idx + 1) % self.channels as usize;
let mut s = sample;
let pre_gain_db = f32::from_bits(self.pre_gain.load(Ordering::Relaxed));
let pre_gain_factor = 10_f32.powf(pre_gain_db / 20.0);
let mut s = sample * pre_gain_factor;
for band in 0..10 {
s = self.filters[band][ch].run(s);
}
@@ -409,7 +415,441 @@ 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 ────────
//
// HTTP task (tokio)
// └─[IcyInterceptor]─► HeapProducer<u8>
// │ (4 MB HeapRb, lock-free)
// HeapConsumer<u8>
// │
// AudioStreamReader (Read + Seek + MediaSource)
// │
// SizedDecoder (symphonia)
// │
// rodio Sink
//
// 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.
//
// New Tauri event: "radio:metadata" → String (ICY StreamTitle)
/// 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;
// ── 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,
}
impl Read for AudioStreamReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
// 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 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));
}
}
}
impl Seek for AudioStreamReader {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
match pos {
SeekFrom::Current(0) => Ok(self.pos),
_ => Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"radio stream is not seekable",
)),
}
}
}
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
// byte_len() → None. This tells symphonia "stream length unknown", which
@@ -552,6 +992,49 @@ impl SizedDecoder {
})
}
/// Build a decoder from any `MediaSource` (e.g. `RadioBuffer`).
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
fn new_streaming(media: Box<dyn MediaSource>, format_hint: Option<&str>) -> Result<Self, String> {
let mss = MediaSourceStream::new(media, Default::default());
let mut hint = Hint::new();
if let Some(ext) = format_hint { hint.with_extension(ext); }
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &MetadataOptions::default())
.map_err(|e| format!("radio: format probe failed: {e}"))?;
let track = probed.format.tracks().iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or_else(|| "radio: no audio track found".to_string())?;
let track_id = track.id;
// Live streams have no known total frame count → total_duration = None.
let total_duration = None;
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;
let mut errors = 0usize;
let decoded = loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(_) => break decoder.last_decoded(),
};
if packet.track_id() != track_id { continue; }
match decoder.decode(&packet) {
Ok(d) => break d,
Err(symphonia::core::errors::Error::DecodeError(_)) => {
errors += 1;
if errors > DECODE_MAX_RETRIES { return Err("radio: too many decode errors".into()); }
}
Err(e) => return Err(format!("radio: decode error: {e}")),
}
};
let spec = decoded.spec().to_owned();
let buffer = Self::make_buffer(decoded, &spec);
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec })
}
#[inline]
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<i16> {
let duration = units::Duration::from(decoded.capacity() as u64);
@@ -765,6 +1248,7 @@ fn build_source(
duration_hint: f64,
eq_gains: Arc<[AtomicU32; 10]>,
eq_enabled: Arc<AtomicBool>,
eq_pre_gain: Arc<AtomicU32>,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
sample_counter: Arc<AtomicU64>,
@@ -824,7 +1308,7 @@ fn build_source(
let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0));
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled);
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag);
@@ -870,6 +1354,7 @@ pub struct AudioEngine {
pub http_client: reqwest::Client,
pub eq_gains: Arc<[AtomicU32; 10]>,
pub eq_enabled: Arc<AtomicBool>,
pub eq_pre_gain: Arc<AtomicU32>,
pub preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
pub crossfade_enabled: Arc<AtomicBool>,
pub crossfade_secs: Arc<AtomicU32>,
@@ -890,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 {
@@ -979,6 +1467,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
.unwrap_or_default(),
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
eq_enabled: Arc::new(AtomicBool::new(false)),
eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())),
preloaded: Arc::new(Mutex::new(None)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
@@ -989,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)
@@ -1041,7 +1531,17 @@ async fn fetch_data(
app.emit("audio:error", &msg).ok();
return Err(msg);
}
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
// Stream the body, checking gen between chunks so a rapid manual skip can
// abort a superseded download mid-flight and free bandwidth for the new one.
let hint = response.content_length().unwrap_or(0) as usize;
let mut stream = response.bytes_stream();
let mut data = Vec::with_capacity(hint);
while let Some(chunk) = stream.next().await {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(None); // superseded — abort
}
data.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
}
Ok(Some(data))
}
@@ -1181,6 +1681,7 @@ pub async fn audio_play(
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
done_flag.clone(),
fade_in_dur,
state.samples_played.clone(),
@@ -1350,7 +1851,16 @@ pub async fn audio_chain_preload(
if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry
}
resp.bytes().await.map_err(|e| e.to_string())?.into()
let hint = resp.content_length().unwrap_or(0) as usize;
let mut stream = resp.bytes_stream();
let mut buf = Vec::with_capacity(hint);
while let Some(chunk) = stream.next().await {
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
return Ok(()); // superseded by manual skip — abort download
}
buf.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
}
buf
}
}
};
@@ -1376,6 +1886,7 @@ pub async fn audio_chain_preload(
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
done_next.clone(),
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
chain_counter.clone(),
@@ -1461,6 +1972,15 @@ fn spawn_progress_task(
// ready, transition seamlessly: swap tracking state, emit
// audio:track_switched for the new track, and continue the loop.
if current_done.load(Ordering::SeqCst) {
// Radio (dur == 0): stream exhausted / connection dropped → stop.
let cur_dur = current_arc.lock().unwrap().duration_secs;
if cur_dur <= 0.0 {
eprintln!("[radio] current_done fired → emitting audio:ended (dur=0)");
gen_counter.fetch_add(1, Ordering::SeqCst);
app.emit("audio:ended", ()).ok();
break;
}
let chained = chained_arc.lock().unwrap().take();
if let Some(info) = chained {
// Swap to the chained source's done flag.
@@ -1563,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]
@@ -1663,9 +2240,56 @@ pub fn audio_update_replay_gain(
}
}
/// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions.
#[tauri::command]
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, state: State<'_, AudioEngine>) {
pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, String> {
state.http_client
.get("https://autoeq.app/entries")
.send().await.map_err(|e| e.to_string())?
.text().await.map_err(|e| e.to_string())
}
/// Fetches the AutoEQ FixedBandEQ profile for a specific headphone from GitHub raw content.
///
/// Directory layout in the AutoEQ repo:
/// 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]
pub async fn autoeq_fetch_profile(
name: String,
source: String,
rig: Option<String>,
form: String,
state: State<'_, AudioEngine>,
) -> Result<String, String> {
let base = "https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results";
let filename = format!("{} FixedBandEQ.txt", name);
let candidates: Vec<String> = if let Some(ref r) = rig {
vec![
format!("{}/{}/{} {}/{}/{}", base, source, r, form, name, filename),
format!("{}/{}/{}/{}/{}", base, source, form, name, filename),
]
} else {
vec![format!("{}/{}/{}/{}/{}", base, source, form, name, filename)]
};
for url in &candidates {
let resp = state.http_client.get(url).send().await.map_err(|e| e.to_string())?;
if resp.status().is_success() {
return resp.text().await.map_err(|e| e.to_string());
}
}
Err(format!("FixedBandEQ profile not found for '{}'", name))
}
#[tauri::command]
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State<'_, AudioEngine>) {
state.eq_enabled.store(enabled, Ordering::Relaxed);
state.eq_pre_gain.store(pre_gain.clamp(-30.0, 6.0).to_bits(), Ordering::Relaxed);
for (i, &gain) in gains.iter().enumerate() {
state.eq_gains[i].store(gain.clamp(-12.0, 12.0).to_bits(), Ordering::Relaxed);
}
@@ -1697,10 +2321,169 @@ pub async fn audio_preload(
Ok(())
}
/// Play a live internet radio stream.
///
/// 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,
volume: f32,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
// 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();
if let Some(old) = cur.sink.take() { old.stop(); }
}
if let Some(old) = state.fading_out_sink.lock().unwrap().take() { old.stop(); }
// ── Open initial HTTP connection ──────────────────────────────────────────
let response = state.http_client
.get(&url)
.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());
app.emit("audio:error", &m).ok();
return Err(m);
}
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,
};
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
let hint_clone = fmt_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
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));
let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0));
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());
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
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.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);
}
state.current_sample_rate.store(sample_rate, Ordering::Relaxed);
state.current_channels.store(channels as u32, Ordering::Relaxed);
app.emit("audio:playing", 0.0f64).ok();
spawn_progress_task(
gen,
state.generation.clone(),
state.current.clone(),
state.chained_info.clone(),
state.crossfade_enabled.clone(),
state.crossfade_secs.clone(),
done_flag,
app,
state.samples_played.clone(),
state.current_sample_rate.clone(),
state.current_channels.clone(),
state.gapless_switch_at.clone(),
);
Ok(())
}
#[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]
+7 -3
View File
@@ -10,7 +10,7 @@
/// so the app always starts cleanly regardless of Discord availability.
use discord_rich_presence::{
activity::{Activity, Assets, Timestamps},
activity::{Activity, ActivityType, Assets, Timestamps},
DiscordIpc, DiscordIpcClient,
};
use std::sync::Mutex;
@@ -56,7 +56,10 @@ pub fn discord_update_presence(
let client = guard.as_mut().unwrap();
let state_text = format!("by {artist}");
// Discord RPC only exposes two visible text rows (details + state).
// The application name "Psysonic" is shown automatically by Discord as the
// header line. Album goes into large_text — visible as a hover tooltip on
// the cover art icon.
let large_text = album.as_deref().unwrap_or("Psysonic");
let assets = Assets::new()
@@ -64,8 +67,9 @@ pub fn discord_update_presence(
.large_text(large_text);
let mut activity = Activity::new()
.activity_type(ActivityType::Listening)
.details(&title)
.state(&state_text)
.state(&artist)
.assets(assets);
// Start timestamp: Discord auto-counts up from this point. We back-calculate
+258 -38
View File
@@ -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(())
}
@@ -536,13 +747,22 @@ pub fn run() {
audio::audio_set_volume,
audio::audio_update_replay_gain,
audio::audio_set_eq,
audio::autoeq_entries,
audio::autoeq_fetch_profile,
audio::audio_preload,
audio::audio_play_radio,
audio::audio_set_crossfade,
audio::audio_set_gapless,
audio::audio_chain_preload,
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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.30.0",
"version": "1.32.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+25 -1
View File
@@ -29,6 +29,7 @@ import SearchResults from './pages/SearchResults';
import AdvancedSearch from './pages/AdvancedSearch';
import Playlists from './pages/Playlists';
import PlaylistDetail from './pages/PlaylistDetail';
import InternetRadio from './pages/InternetRadio';
import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
@@ -141,7 +142,7 @@ function AppShell() {
const handleMouseMove = useCallback((e: MouseEvent) => {
if (isDraggingQueue) {
const newWidth = Math.max(250, Math.min(window.innerWidth - e.clientX, 500));
const newWidth = Math.max(310, Math.min(window.innerWidth - e.clientX, 500));
setQueueWidth(newWidth);
}
}, [isDraggingQueue]);
@@ -185,14 +186,36 @@ function AppShell() {
// from the OS file manager) is dropped on the document body.
const blockDrop = (e: DragEvent) => { e.preventDefault(); };
// Block Ctrl+A / Cmd+A "select all" — WebKit ignores user-select:none for keyboard shortcuts
const blockSelectAll = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
const target = e.target as HTMLElement;
// Allow Ctrl+A inside actual text inputs and textareas
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
e.preventDefault();
}
};
// Block mouse drag selection — WebKitGTK ignores user-select:none on * for drag selection
const blockSelectStart = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
if ((target as HTMLElement).closest('[data-selectable]')) return;
e.preventDefault();
};
document.addEventListener('dragover', allow);
document.addEventListener('dragenter', allow);
document.addEventListener('drop', blockDrop);
document.addEventListener('keydown', blockSelectAll, true);
document.addEventListener('selectstart', blockSelectStart);
return () => {
document.removeEventListener('dragover', allow);
document.removeEventListener('dragenter', allow);
document.removeEventListener('drop', blockDrop);
document.removeEventListener('keydown', blockSelectAll, true);
document.removeEventListener('selectstart', blockSelectStart);
};
}, []);
@@ -258,6 +281,7 @@ function AppShell() {
<Route path="/genres/:name" element={<GenreDetail />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/playlists/:id" element={<PlaylistDetail />} />
<Route path="/radio" element={<InternetRadio />} />
</Routes>
</div>
</main>
+153
View File
@@ -88,6 +88,22 @@ export interface SubsonicSong {
};
}
export interface InternetRadioStation {
id: string;
name: string;
streamUrl: string;
homepageUrl?: string;
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;
@@ -97,6 +113,7 @@ export interface SubsonicPlaylist {
changed: string;
owner?: string;
public?: boolean;
comment?: string;
coverArt?: string;
}
@@ -409,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 });
}
@@ -442,3 +486,112 @@ export async function getNowPlaying(): Promise<SubsonicNowPlaying[]> {
return [];
}
}
// ─── Internet Radio ───────────────────────────────────────────
export async function getInternetRadioStations(): Promise<InternetRadioStation[]> {
try {
const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>(
'getInternetRadioStations.view'
);
return data.internetRadioStations?.internetRadioStation ?? [];
} catch {
return [];
}
}
export async function createInternetRadioStation(
name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('createInternetRadioStation.view', params);
}
export async function updateInternetRadioStation(
id: string, name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { id, name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('updateInternetRadioStation.view', params);
}
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 });
}
+5 -1
View File
@@ -81,7 +81,11 @@ function AlbumCard({ album }: AlbumCardProps) {
</div>
<div className="album-card-info">
<p className="album-card-title truncate">{album.name}</p>
<p className="album-card-artist truncate">{album.artist}</p>
<p
className={`album-card-artist truncate${album.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: album.artistId ? 'pointer' : 'default' }}
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
>{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>}
</div>
</div>
+4 -2
View File
@@ -7,8 +7,10 @@ import CoverLightbox from './CoverLightbox';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
+221 -69
View File
@@ -1,22 +1,26 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Play, Heart, ListPlus, X } from 'lucide-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';
import { useNavigate } from 'react-router-dom';
import { useDragDrop } from '../contexts/DragDropContext';
import { AddToPlaylistSubmenu } from './ContextMenu';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
return parts.join(' · ');
if (song.bitRate) parts.push(`${song.bitRate}`);
return parts.join(' ');
}
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
@@ -42,6 +46,29 @@ 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: 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 = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
// Columns where cell content should be centred (both header and rows)
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
// ── Props ─────────────────────────────────────────────────────────────────────
interface AlbumTrackListProps {
songs: SubsonicSong[];
hasVariousArtists: boolean;
@@ -68,15 +95,23 @@ export default function AlbumTrackList({
onContextMenu,
}: AlbumTrackListProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop();
// ── Bulk select ───────────────────────────────────────────────────
// ── Bulk select ───────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
// ── 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 => {
const next = new Set(prev);
@@ -99,7 +134,6 @@ export default function AlbumTrackList({
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
// Close playlist picker on outside click
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
@@ -110,8 +144,6 @@ export default function AlbumTrackList({
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const discs = new Map<number, SubsonicSong[]>();
songs.forEach(song => {
const disc = song.discNumber ?? 1;
@@ -123,8 +155,147 @@ export default function AlbumTrackList({
const inSelectMode = selectedIds.size > 0;
// ── Header cell renderer ──────────────────────────────────────────────────
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
const key = colDef.key as ColKey;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = CENTERED_COLS.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
// num header: checkbox + # label, mirrors row-cell layout exactly
if (key === 'num') {
return (
<div key={key} className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
}
// 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} 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>
);
};
// ── Row cell renderer ─────────────────────────────────────────────────────
const renderRowCell = (key: ColKey, song: SubsonicSong, globalIdx: number) => {
switch (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(); onPlaySong(song); }}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, 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">{song.track ?? '—'}</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 => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating':
return (
<StarRating
key="rating"
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
);
case 'duration':
return (
<div key="duration" className="track-duration">
{formatDuration(song.duration)}
</div>
);
case 'format':
return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">{codecLabel(song)}</span>
)}
</div>
);
case 'genre':
return (
<div key="genre" className="track-genre">
{song.genre ?? '—'}
</div>
);
default:
return null;
}
};
return (
<div className="tracklist">
<div className="tracklist" ref={tracklistRef}>
{/* ── Bulk action bar ── */}
{inSelectMode && (
@@ -158,20 +329,46 @@ export default function AlbumTrackList({
</div>
)}
<div className={`tracklist-header${' tracklist-va'}`}>
<div className="col-center">
{inSelectMode
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} onClick={toggleAll} style={{ cursor: 'pointer' }} />
: '#'}
{/* ── Header ── */}
<div style={{ position: 'relative' }}>
<div className="tracklist-header" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
</div>
{/* Column visibility picker */}
<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>
{COLUMNS.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : 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>
{/* ── Tracks ── */}
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
@@ -186,6 +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={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (inSelectMode) {
@@ -216,59 +414,13 @@ export default function AlbumTrackList({
document.addEventListener('mouseup', onUp);
}}
>
<div
className="track-num"
style={{ cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }}
/>
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
{currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
<StarRating
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
<div className="track-duration">
{formatDuration(song.duration)}
</div>
<div className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">{codecLabel(song)}</span>
)}
</div>
{visibleCols.map(colDef => renderRowCell(colDef.key as ColKey, song, globalIdx))}
</div>
);
})}
</div>
))}
<div className={`tracklist-total${' tracklist-va'}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</div>
</div>
);
}
+9 -4
View File
@@ -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>
+186 -3
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Save, Trash2, RotateCcw } from 'lucide-react';
import { Save, Trash2, RotateCcw, Search, X, ChevronDown, ChevronUp } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import CustomSelect from './CustomSelect';
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore';
import { useThemeStore } from '../store/themeStore';
@@ -147,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.5) * 0.5; // snap to 0.5 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]);
@@ -182,20 +183,57 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) {
);
}
// ─── AutoEQ helpers ───────────────────────────────────────────────────────────
interface AutoEqVariant { form: string; rig: string | null; source: string; }
interface AutoEqResult { name: string; source: string; rig: string | null; form: string; }
/** 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;
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 ───────────────────────────────────────────────────────────
export default function Equalizer() {
const { t } = useTranslation();
const gains = useEqStore(s => s.gains);
const enabled = useEqStore(s => s.enabled);
const preGain = useEqStore(s => s.preGain);
const activePreset = useEqStore(s => s.activePreset);
const customPresets = useEqStore(s => s.customPresets);
const { setBandGain, setEnabled, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore();
const { setBandGain, setEnabled, setPreGain, applyPreset, applyAutoEq, saveCustomPreset, deleteCustomPreset } = useEqStore();
const [saveName, setSaveName] = useState('');
const [showSave, setShowSave] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
// AutoEQ state
const [autoEqOpen, setAutoEqOpen] = useState(false);
const [autoEqQuery, setAutoEqQuery] = useState('');
const [autoEqResults, setAutoEqResults] = useState<AutoEqResult[]>([]);
const [autoEqLoading, setAutoEqLoading] = useState(false);
const [autoEqError, setAutoEqError] = useState<string | null>(null);
const [autoEqApplied, setAutoEqApplied] = useState<string | null>(null);
const [entriesLoading, setEntriesLoading] = useState(false);
const entriesCacheRef = useRef<Record<string, AutoEqVariant[]> | null>(null);
const theme = useThemeStore(s => s.theme);
const redraw = useCallback(() => {
@@ -216,6 +254,62 @@ export default function Equalizer() {
return () => ro.disconnect();
}, [redraw]);
// AutoEQ: load entries index lazily when section opens, then filter client-side
async function ensureEntries() {
if (entriesCacheRef.current) return;
setEntriesLoading(true);
setAutoEqError(null);
try {
const json = await invoke<string>('autoeq_entries');
entriesCacheRef.current = JSON.parse(json);
} catch (e: unknown) {
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqError'));
} finally {
setEntriesLoading(false);
}
}
useEffect(() => {
const q = autoEqQuery.trim().toLowerCase();
if (!entriesCacheRef.current || q.length < 1) { setAutoEqResults([]); return; }
const flat: AutoEqResult[] = [];
for (const [name, variants] of Object.entries(entriesCacheRef.current)) {
if (!name.toLowerCase().includes(q)) continue;
for (const v of variants) {
flat.push({ name, source: v.source, rig: v.rig, form: v.form });
if (flat.length >= 20) break;
}
if (flat.length >= 20) break;
}
setAutoEqResults(flat);
// entriesLoading in deps: re-runs after entries finish loading so a query typed
// during loading produces results immediately without needing a re-type.
}, [autoEqQuery, entriesLoading]);
async function applyAutoEqResult(result: AutoEqResult) {
setAutoEqLoading(true);
setAutoEqError(null);
try {
const text = await invoke<string>('autoeq_fetch_profile', {
name: result.name,
source: result.source,
rig: result.rig ?? null,
form: result.form,
});
if (!text) throw new Error(t('settings.eqAutoEqFetchError'));
const { gains: newGains, preamp } = parseFixedBandEqString(text);
applyAutoEq(result.name, newGains, preamp);
setAutoEqApplied(result.name);
setAutoEqQuery('');
setAutoEqResults([]);
setTimeout(() => setAutoEqApplied(null), 3000);
} catch (e: unknown) {
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqFetchError'));
} finally {
setAutoEqLoading(false);
}
}
const allPresets = [...BUILTIN_PRESETS, ...customPresets];
const selectValue = activePreset ?? '__custom__';
const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset);
@@ -279,6 +373,74 @@ export default function Equalizer() {
</div>
)}
{/* AutoEQ section */}
<div className="eq-autoeq-section">
<button
className="eq-autoeq-toggle"
onClick={() => {
const opening = !autoEqOpen;
setAutoEqOpen(opening);
setAutoEqQuery('');
setAutoEqResults([]);
setAutoEqError(null);
if (opening) ensureEntries();
}}
>
<Search size={13} />
<span>{t('settings.eqAutoEqTitle')}</span>
{autoEqOpen ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</button>
{autoEqOpen && (
<div className="eq-autoeq-body">
<div className="eq-autoeq-search-row">
<input
className="input"
placeholder={t('settings.eqAutoEqPlaceholder')}
value={autoEqQuery}
onChange={e => { setAutoEqQuery(e.target.value); setAutoEqError(null); }}
autoFocus
style={{ flex: 1, padding: '6px 12px', fontSize: 13 }}
/>
{autoEqQuery && (
<button className="eq-ctrl-btn" onClick={() => { setAutoEqQuery(''); setAutoEqResults([]); }}>
<X size={13} />
</button>
)}
</div>
{(entriesLoading || autoEqLoading) && (
<div className="eq-autoeq-status">{t('settings.eqAutoEqSearching')}</div>
)}
{autoEqError && (
<div className="eq-autoeq-status eq-autoeq-error">{autoEqError}</div>
)}
{autoEqApplied && (
<div className="eq-autoeq-status eq-autoeq-applied"> {autoEqApplied}</div>
)}
{autoEqResults.length > 0 && (
<div className="eq-autoeq-results">
{autoEqResults.map((r, i) => (
<button
key={`${r.name}|${r.source}|${i}`}
className="eq-autoeq-result-btn"
onClick={() => applyAutoEqResult(r)}
>
<span>{r.name}</span>
<span className="eq-autoeq-result-source">{r.source}</span>
</button>
))}
</div>
)}
{!entriesLoading && !autoEqLoading && !autoEqError && autoEqQuery.length >= 2 && autoEqResults.length === 0 && (
<div className="eq-autoeq-status">{t('settings.eqAutoEqNoResults')}</div>
)}
</div>
)}
</div>
{/* EQ panel */}
<div className={`eq-panel ${!enabled ? 'eq-panel--off' : ''}`}>
{/* Frequency response */}
@@ -313,6 +475,27 @@ export default function Equalizer() {
</div>
))}
</div>
{/* Pre-gain row */}
<div className="eq-pregain-row">
<span className="eq-pregain-label">{t('settings.eqPreGain')}</span>
<input
type="range"
className="eq-pregain-slider"
min={-30} max={6} step={0.1}
value={preGain}
disabled={!enabled}
onChange={e => setPreGain(parseFloat(e.target.value))}
/>
<span className="eq-pregain-val">
{preGain > 0 ? '+' : ''}{preGain.toFixed(1)} dB
</span>
{preGain !== 0 && (
<button className="eq-ctrl-btn" onClick={() => setPreGain(0)} data-tooltip={t('settings.eqResetPreGain')} style={{ marginLeft: 4 }}>
<RotateCcw size={11} />
</button>
)}
</div>
</div>
</div>
);
+58 -23
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal, Cast
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -31,7 +31,7 @@ export default function PlayerBar() {
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const {
currentTrack, isPlaying, currentTime, volume,
currentTrack, currentRadio, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
@@ -40,6 +40,8 @@ export default function PlayerBar() {
} = usePlayerStore();
const { lastfmSessionKey } = useAuthStore();
const isRadio = !!currentRadio;
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
@@ -57,6 +59,14 @@ export default function PlayerBar() {
}, [currentTrack, isStarred, setStarredOverride]);
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(`ra-${currentRadio.id}`, 128) : '',
[currentRadio?.coverArt, currentRadio?.id]
);
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) : '';
@@ -80,11 +90,24 @@ export default function PlayerBar() {
{/* Track Info */}
<div className="player-track-info">
<div
className={`player-album-art-wrap ${currentTrack ? 'clickable' : ''}`}
onClick={() => currentTrack && toggleFullscreen()}
data-tooltip={currentTrack ? t('player.openFullscreen') : undefined}
className={`player-album-art-wrap ${currentTrack && !isRadio ? 'clickable' : ''}`}
onClick={() => !isRadio && currentTrack && toggleFullscreen()}
data-tooltip={!isRadio && currentTrack ? t('player.openFullscreen') : undefined}
>
{currentTrack?.coverArt ? (
{isRadio ? (
currentRadio?.coverArt ? (
<CachedImage
className="player-album-art"
src={radioCoverSrc}
cacheKey={radioCoverKey}
alt={currentRadio.name}
/>
) : (
<div className="player-album-art-placeholder">
<Cast size={20} />
</div>
)
) : currentTrack?.coverArt ? (
<CachedImage
className="player-album-art"
src={coverSrc}
@@ -96,7 +119,7 @@ export default function PlayerBar() {
<Music size={22} />
</div>
)}
{currentTrack && (
{currentTrack && !isRadio && (
<div className="player-art-expand-hint" aria-hidden="true">
<Maximize2 size={16} />
</div>
@@ -104,19 +127,19 @@ export default function PlayerBar() {
</div>
<div className="player-track-meta">
<MarqueeText
text={currentTrack?.title ?? t('player.noTitle')}
text={isRadio ? (currentRadio?.name ?? '—') : (currentTrack?.title ?? t('player.noTitle'))}
className="player-track-name"
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
/>
<MarqueeText
text={currentTrack?.artist ?? '—'}
text={isRadio ? t('radio.liveStream') : (currentTrack?.artist ?? '—')}
className="player-track-artist"
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
/>
</div>
{currentTrack && (
{currentTrack && !isRadio && (
<button
className="player-btn player-btn-sm player-star-btn"
onClick={toggleStar}
@@ -127,7 +150,7 @@ export default function PlayerBar() {
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
{currentTrack && lastfmSessionKey && (
{currentTrack && !isRadio && lastfmSessionKey && (
<button
className="player-btn player-btn-sm player-love-btn"
onClick={toggleLastfmLove}
@@ -145,7 +168,7 @@ export default function PlayerBar() {
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
<Square size={14} fill="currentColor" />
</button>
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
<SkipBack size={19} />
</button>
<button
@@ -156,7 +179,7 @@ export default function PlayerBar() {
>
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
<SkipForward size={19} />
</button>
<button
@@ -170,13 +193,25 @@ export default function PlayerBar() {
</button>
</div>
{/* Waveform Seekbar */}
{/* Waveform Seekbar / Radio live bar */}
<div className="player-waveform-section">
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
<span className="player-time">{formatTime(duration)}</span>
{isRadio ? (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
</>
) : (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
<span className="player-time">{formatTime(duration)}</span>
</>
)}
</div>
{/* Lyrics Button */}
+14 -8
View File
@@ -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>
)}
+2 -1
View File
@@ -7,7 +7,7 @@ import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
@@ -24,6 +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
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
};
+464 -34
View File
@@ -26,6 +26,7 @@ const enTranslation = {
offlineLibrary: 'Offline Library',
genres: 'Genres',
playlists: 'Playlists',
radio: 'Internet Radio',
},
home: {
hero: 'Featured',
@@ -131,11 +132,13 @@ const enTranslation = {
moreLabelAlbums: 'More albums on {{label}}',
trackTitle: 'Title',
trackArtist: 'Artist',
trackGenre: 'Genre',
trackFormat: 'Format',
trackFavorite: 'Favorite',
trackRating: 'Rating',
trackDuration: 'Duration',
trackTotal: 'Total',
columns: 'Columns',
notFound: 'Album not found.',
bioModal: 'Artist Biography',
bioClose: 'Close',
@@ -179,6 +182,7 @@ const enTranslation = {
enqueueAll: 'Add all to queue',
playAll: 'Play all',
removeSong: 'Remove from favorites',
stations: 'Radio Stations',
},
randomAlbums: {
title: 'Random Albums',
@@ -234,6 +238,10 @@ const enTranslation = {
sortByArtist: 'AZ (Artist)',
sortNewest: 'Newest first',
sortRandom: 'Random',
yearFrom: 'From',
yearTo: 'To',
yearFilterClear: 'Clear year filter',
yearFilterLabel: 'Year',
},
artists: {
title: 'Artists',
@@ -353,6 +361,7 @@ const enTranslation = {
serverFailed: 'Connection failed.',
testBtn: 'Test Connection',
testingBtn: 'Testing…',
serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic',
connected: 'Connected',
failed: 'Failed',
eqTitle: 'Equalizer',
@@ -365,6 +374,15 @@ const enTranslation = {
eqPresetName: 'Preset name…',
eqDeletePreset: 'Delete preset',
eqResetBands: 'Reset to Flat',
eqPreGain: 'Pre-gain',
eqResetPreGain: 'Reset pre-gain',
eqAutoEqTitle: 'AutoEQ Headphone Lookup',
eqAutoEqPlaceholder: 'Search headphone / IEM model…',
eqAutoEqSearching: 'Searching…',
eqAutoEqNoResults: 'No results found',
eqAutoEqError: 'Search failed',
eqAutoEqRateLimit: 'GitHub rate limit reached — try again in a minute',
eqAutoEqFetchError: 'Failed to fetch EQ profile',
lfmTitle: 'Last.fm',
lfmConnect: 'Connect with Last.fm',
lfmConnecting: 'Waiting for authorisation…',
@@ -380,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',
@@ -393,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',
@@ -420,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: '—',
@@ -455,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',
@@ -588,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',
@@ -690,6 +732,50 @@ 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',
empty: 'No radio stations configured.',
addStation: 'Add Station',
editStation: 'Edit',
deleteStation: 'Delete station',
confirmDelete: 'Click again to confirm',
stationName: 'Station name…',
streamUrl: 'Stream URL…',
homepageUrl: 'Homepage URL (optional)',
save: 'Save',
cancel: 'Cancel',
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.',
}
};
@@ -718,6 +804,7 @@ const deTranslation = {
offlineLibrary: 'Offline-Bibliothek',
genres: 'Genres',
playlists: 'Playlists',
radio: 'Internetradio',
},
home: {
hero: 'Featured',
@@ -823,11 +910,13 @@ const deTranslation = {
moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen',
trackTitle: 'Titel',
trackArtist: 'Interpret',
trackGenre: 'Genre',
trackFormat: 'Format',
trackFavorite: 'Favorit',
trackRating: 'Bewertung',
trackDuration: 'Dauer',
trackTotal: 'Gesamt',
columns: 'Spalten',
notFound: 'Album nicht gefunden.',
bioModal: 'Künstler-Biografie',
bioClose: 'Schließen',
@@ -871,6 +960,7 @@ const deTranslation = {
enqueueAll: 'Alle in die Warteschlange',
playAll: 'Alle abspielen',
removeSong: 'Aus Favoriten entfernen',
stations: 'Radiosender',
},
randomAlbums: {
title: 'Zufallsalben',
@@ -926,6 +1016,10 @@ const deTranslation = {
sortByArtist: 'AZ (Künstler)',
sortNewest: 'Neueste zuerst',
sortRandom: 'Zufällig',
yearFrom: 'Von',
yearTo: 'Bis',
yearFilterClear: 'Jahresfilter zurücksetzen',
yearFilterLabel: 'Jahr',
},
artists: {
title: 'Künstler',
@@ -1045,6 +1139,7 @@ const deTranslation = {
serverFailed: 'Verbindung fehlgeschlagen.',
testBtn: 'Verbindung testen',
testingBtn: 'Teste…',
serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic',
connected: 'Verbunden',
failed: 'Fehlgeschlagen',
eqTitle: 'Equalizer',
@@ -1057,6 +1152,15 @@ const deTranslation = {
eqPresetName: 'Preset-Name…',
eqDeletePreset: 'Preset löschen',
eqResetBands: 'Auf Flat zurücksetzen',
eqPreGain: 'Vorverstärkung',
eqResetPreGain: 'Vorverstärkung zurücksetzen',
eqAutoEqTitle: 'AutoEQ Kopfhörer-Suche',
eqAutoEqPlaceholder: 'Kopfhörer / IEM Modell suchen…',
eqAutoEqSearching: 'Suche…',
eqAutoEqNoResults: 'Keine Ergebnisse gefunden',
eqAutoEqError: 'Suche fehlgeschlagen',
eqAutoEqRateLimit: 'GitHub Rate-Limit erreicht — in einer Minute erneut versuchen',
eqAutoEqFetchError: 'EQ-Profil konnte nicht geladen werden',
lfmTitle: 'Last.fm',
lfmConnect: 'Mit Last.fm verbinden',
lfmConnecting: 'Warte auf Bestätigung…',
@@ -1072,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',
@@ -1085,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',
@@ -1112,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…',
@@ -1138,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',
@@ -1147,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',
@@ -1280,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',
@@ -1382,6 +1510,50 @@ 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',
empty: 'Keine Radiosender konfiguriert.',
addStation: 'Sender hinzufügen',
editStation: 'Bearbeiten',
deleteStation: 'Sender löschen',
confirmDelete: 'Zum Bestätigen erneut klicken',
stationName: 'Sendername…',
streamUrl: 'Stream-URL…',
homepageUrl: 'Homepage-URL (optional)',
save: 'Speichern',
cancel: 'Abbrechen',
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.',
}
};
@@ -1410,6 +1582,7 @@ const frTranslation = {
offlineLibrary: 'Bibliothèque hors ligne',
genres: 'Genres',
playlists: 'Playlists',
radio: 'Radio Internet',
},
home: {
hero: 'En vedette',
@@ -1515,11 +1688,13 @@ const frTranslation = {
moreLabelAlbums: 'Plus d\'albums sur {{label}}',
trackTitle: 'Titre',
trackArtist: 'Artiste',
trackGenre: 'Genre',
trackFormat: 'Format',
trackFavorite: 'Favori',
trackRating: 'Note',
trackDuration: 'Durée',
trackTotal: 'Total',
columns: 'Colonnes',
notFound: 'Album introuvable.',
bioModal: 'Biographie de l\'artiste',
bioClose: 'Fermer',
@@ -1563,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',
@@ -1618,6 +1794,10 @@ const frTranslation = {
sortByArtist: 'AZ (Artiste)',
sortNewest: 'Plus récents',
sortRandom: 'Aléatoire',
yearFrom: 'De',
yearTo: 'À',
yearFilterClear: 'Effacer le filtre année',
yearFilterLabel: 'Année',
},
artists: {
title: 'Artistes',
@@ -1737,6 +1917,7 @@ const frTranslation = {
serverFailed: 'Connexion échouée.',
testBtn: 'Tester la connexion',
testingBtn: 'Test en cours…',
serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic',
connected: 'Connecté',
failed: 'Échec',
eqTitle: 'Égaliseur',
@@ -1749,6 +1930,15 @@ const frTranslation = {
eqPresetName: 'Nom du préréglage…',
eqDeletePreset: 'Supprimer le préréglage',
eqResetBands: 'Réinitialiser à plat',
eqPreGain: 'Pré-amplification',
eqResetPreGain: 'Réinitialiser la pré-amplification',
eqAutoEqTitle: 'Recherche AutoEQ',
eqAutoEqPlaceholder: 'Rechercher un casque / IEM…',
eqAutoEqSearching: 'Recherche…',
eqAutoEqNoResults: 'Aucun résultat',
eqAutoEqError: 'Échec de la recherche',
eqAutoEqRateLimit: 'Limite GitHub atteinte — réessayez dans une minute',
eqAutoEqFetchError: 'Impossible de charger le profil EQ',
lfmTitle: 'Last.fm',
lfmConnect: 'Connecter avec Last.fm',
lfmConnecting: 'En attente d\'autorisation…',
@@ -1764,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',
@@ -1777,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',
@@ -1804,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…',
@@ -1830,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',
@@ -1839,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',
@@ -1972,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',
@@ -2074,6 +2288,50 @@ 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',
empty: 'Aucune station radio configurée.',
addStation: 'Ajouter une station',
editStation: 'Modifier',
deleteStation: 'Supprimer la station',
confirmDelete: 'Cliquer à nouveau pour confirmer',
stationName: 'Nom de la station…',
streamUrl: 'URL du flux…',
homepageUrl: 'URL de la page d\'accueil (optionnel)',
save: 'Enregistrer',
cancel: 'Annuler',
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.',
}
};
@@ -2102,6 +2360,7 @@ const nlTranslation = {
offlineLibrary: 'Offline bibliotheek',
genres: 'Genres',
playlists: 'Playlists',
radio: 'Internetradio',
},
home: {
hero: 'Uitgelicht',
@@ -2207,11 +2466,13 @@ const nlTranslation = {
moreLabelAlbums: 'Meer albums op {{label}}',
trackTitle: 'Titel',
trackArtist: 'Artiest',
trackGenre: 'Genre',
trackFormat: 'Formaat',
trackFavorite: 'Favoriet',
trackRating: 'Beoordeling',
trackDuration: 'Duur',
trackTotal: 'Totaal',
columns: 'Kolommen',
notFound: 'Album niet gevonden.',
bioModal: 'Artiest biografie',
bioClose: 'Sluiten',
@@ -2255,6 +2516,7 @@ const nlTranslation = {
enqueueAll: 'Alles aan wachtrij toevoegen',
playAll: 'Alles afspelen',
removeSong: 'Verwijderen uit favorieten',
stations: 'Radiostations',
},
randomAlbums: {
title: 'Willekeurige albums',
@@ -2310,6 +2572,10 @@ const nlTranslation = {
sortByArtist: 'AZ (Artiest)',
sortNewest: 'Nieuwste eerst',
sortRandom: 'Willekeurig',
yearFrom: 'Van',
yearTo: 'Tot',
yearFilterClear: 'Jaarfilter wissen',
yearFilterLabel: 'Jaar',
},
artists: {
title: 'Artiesten',
@@ -2429,6 +2695,7 @@ const nlTranslation = {
serverFailed: 'Verbinding mislukt.',
testBtn: 'Verbinding testen',
testingBtn: 'Testen…',
serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic',
connected: 'Verbonden',
failed: 'Mislukt',
eqTitle: 'Equalizer',
@@ -2441,6 +2708,15 @@ const nlTranslation = {
eqPresetName: 'Naam voorinstelling…',
eqDeletePreset: 'Voorinstelling verwijderen',
eqResetBands: 'Terugzetten naar vlak',
eqPreGain: 'Voorversterking',
eqResetPreGain: 'Voorversterking resetten',
eqAutoEqTitle: 'AutoEQ Hoofdtelefoon Zoeken',
eqAutoEqPlaceholder: 'Zoek hoofdtelefoon / IEM model…',
eqAutoEqSearching: 'Zoeken…',
eqAutoEqNoResults: 'Geen resultaten gevonden',
eqAutoEqError: 'Zoeken mislukt',
eqAutoEqRateLimit: 'GitHub limiet bereikt — probeer over een minuut opnieuw',
eqAutoEqFetchError: 'EQ-profiel kon niet worden geladen',
lfmTitle: 'Last.fm',
lfmConnect: 'Verbinden met Last.fm',
lfmConnecting: 'Wachten op autorisatie…',
@@ -2456,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',
@@ -2469,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',
@@ -2496,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…',
@@ -2522,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',
@@ -2531,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',
@@ -2664,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',
@@ -2766,6 +3066,50 @@ 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',
empty: 'Geen radiostations geconfigureerd.',
addStation: 'Station toevoegen',
editStation: 'Bewerken',
deleteStation: 'Station verwijderen',
confirmDelete: 'Klik opnieuw om te bevestigen',
stationName: 'Stationsnaam…',
streamUrl: 'Stream-URL…',
homepageUrl: 'Homepage-URL (optioneel)',
save: 'Opslaan',
cancel: 'Annuleren',
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.',
}
};
@@ -2794,6 +3138,7 @@ const zhTranslation = {
offlineLibrary: '离线音乐库',
genres: '流派',
playlists: '播放列表',
radio: '网络电台',
},
home: {
hero: '精选',
@@ -2899,11 +3244,13 @@ const zhTranslation = {
moreLabelAlbums: '{{label}} 的更多专辑',
trackTitle: '标题',
trackArtist: '艺术家',
trackGenre: '流派',
trackFormat: '格式',
trackFavorite: '收藏',
trackRating: '评分',
trackDuration: '时长',
trackTotal: '总计',
columns: '列',
notFound: '未找到专辑。',
bioModal: '艺术家简介',
bioClose: '关闭',
@@ -2947,6 +3294,7 @@ const zhTranslation = {
enqueueAll: '全部加入队列',
playAll: '全部播放',
removeSong: '从收藏中移除',
stations: '广播电台',
},
randomAlbums: {
title: '随机专辑',
@@ -3002,6 +3350,10 @@ const zhTranslation = {
sortByArtist: '按艺术家排序 (A-Z)',
sortNewest: '最新优先',
sortRandom: '随机排序',
yearFrom: '从',
yearTo: '到',
yearFilterClear: '清除年份筛选',
yearFilterLabel: '年份',
},
artists: {
title: '艺术家',
@@ -3117,6 +3469,7 @@ const zhTranslation = {
serverFailed: '连接失败。',
testBtn: '测试连接',
testingBtn: '正在测试…',
serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic',
connected: '已连接',
failed: '失败',
eqTitle: '均衡器',
@@ -3129,6 +3482,15 @@ const zhTranslation = {
eqPresetName: '预设名称…',
eqDeletePreset: '删除预设',
eqResetBands: '重置为平直',
eqPreGain: '预增益',
eqResetPreGain: '重置预增益',
eqAutoEqTitle: 'AutoEQ 耳机查询',
eqAutoEqPlaceholder: '搜索耳机/入耳式型号…',
eqAutoEqSearching: '搜索中…',
eqAutoEqNoResults: '未找到结果',
eqAutoEqError: '搜索失败',
eqAutoEqRateLimit: 'GitHub 请求限制 — 请一分钟后重试',
eqAutoEqFetchError: '无法获取 EQ 配置',
lfmTitle: 'Last.fm',
lfmConnect: '连接 Last.fm',
lfmConnecting: '等待授权…',
@@ -3144,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: '最小化到托盘',
@@ -3157,7 +3527,8 @@ const zhTranslation = {
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
nowPlayingEnabled: '在实时窗口中显示',
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
downloadsTitle: '下载文件夹',
downloadsTitle: 'ZIP 导出与归档',
downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。',
downloadsDefault: '默认下载文件夹',
pickFolder: '选择',
pickFolderTitle: '选择下载文件夹',
@@ -3184,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: '—',
@@ -3352,6 +3733,11 @@ const zhTranslation = {
statAlbums: '专辑',
statSongs: '歌曲',
statGenres: '流派',
statPlaytime: '总播放时长',
genreInsights: '流派洞察',
formatDistribution: '格式分布',
formatSample: '{{n}} 首曲目的样本',
computing: '计算中…',
genreSongs: '{{count}} 首歌曲',
genreAlbums: '{{count}} 张专辑',
recentlyAdded: '最近添加',
@@ -3454,6 +3840,50 @@ const zhTranslation = {
cacheOffline: '离线缓存播放列表',
offlineCached: '播放列表已缓存',
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
publicLabel: '公开',
privateLabel: '私有',
editMeta: '编辑播放列表',
editNamePlaceholder: '播放列表名称…',
editCommentPlaceholder: '添加描述…',
editPublic: '公开播放列表',
editSave: '保存',
editCancel: '取消',
changeCover: '更改封面图片',
changeCoverLabel: '更改照片',
removeCover: '删除照片',
coverUpdated: '封面已更新',
metaSaved: '播放列表已更新',
},
radio: {
title: '网络电台',
empty: '未配置任何电台。',
addStation: '添加电台',
editStation: '编辑',
deleteStation: '删除电台',
confirmDelete: '再次点击确认',
stationName: '电台名称…',
streamUrl: '流地址…',
homepageUrl: '主页地址(可选)',
save: '保存',
cancel: '取消',
live: '直播',
liveStream: '网络电台',
openHomepage: '打开主页',
changeCoverLabel: '更换封面',
removeCover: '移除封面',
browseDirectory: '搜索目录',
directoryPlaceholder: '搜索电台…',
noResults: '未找到电台。',
stationAdded: '电台已添加',
filterAll: '全部',
filterFavorites: '收藏',
sortManual: '手动',
sortAZ: 'A → Z',
sortZA: 'Z → A',
sortNewest: '最新',
favorite: '添加到收藏',
unfavorite: '从收藏移除',
noFavorites: '没有收藏的电台。',
}
};
+6 -6
View File
@@ -169,7 +169,7 @@ export default function AdvancedSearch() {
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedGenre')}
</span>
<div style={{ minWidth: 180 }}>
<div style={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360 }}>
<CustomSelect
value={genre}
options={genreSelectOptions}
@@ -188,7 +188,7 @@ export default function AdvancedSearch() {
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
placeholder={t('search.advancedYearFrom')}
style={{ width: 76 }}
style={{ width: 96 }}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}></span>
<input
@@ -199,7 +199,7 @@ export default function AdvancedSearch() {
value={yearTo}
onChange={e => setYearTo(e.target.value)}
placeholder={t('search.advancedYearTo')}
style={{ width: 76 }}
style={{ width: 96 }}
/>
</div>
@@ -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'); }}
@@ -319,7 +319,7 @@ export default function AdvancedSearch() {
</div>
<div className="track-artist-cell">
<span
className="track-artist"
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
>
+74 -11
View File
@@ -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>
+6 -8
View File
@@ -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));
@@ -410,12 +410,10 @@ export default function ArtistDetail() {
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
>
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, topSongs.map(songToTrack)); }}>
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
{currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, topSongs.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">{idx + 1}</span>
</div>
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
{song.coverArt && (
+4 -4
View File
@@ -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 -51
View File
@@ -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,36 +238,36 @@ export default function Favorites() {
document.addEventListener('mouseup', onUp);
}}
>
<div className="track-num col-center" style={{ cursor: 'pointer' }}>
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
{currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span
className="track-artist"
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>
);
})}
@@ -174,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>
);
}
+892
View File
@@ -0,0 +1,892 @@
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Cast, Plus, Trash2, X, Globe, Camera, Loader2, Search, Heart, Check } from 'lucide-react';
import { useDragSource, useDragDrop } from '../contexts/DragDropContext';
import {
getInternetRadioStations, createInternetRadioStation,
updateInternetRadioStation, deleteInternetRadioStation,
uploadRadioCoverArt, deleteRadioCoverArt,
uploadRadioCoverArtBytes, searchRadioBrowser, getTopRadioStations, fetchUrlBytes,
InternetRadioStation, RadioBrowserStation, buildCoverArtUrl, coverArtCacheKey, RADIO_PAGE_SIZE,
} from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import CachedImage from '../components/CachedImage';
import CustomSelect from '../components/CustomSelect';
import { useTranslation } from 'react-i18next';
import { open } from '@tauri-apps/plugin-shell';
import { showToast } from '../utils/toast';
export default function InternetRadio() {
const { t } = useTranslation();
const { playRadio, stop, currentRadio, isPlaying } = usePlayerStore();
const [stations, setStations] = useState<InternetRadioStation[]>([]);
const [loading, setLoading] = useState(true);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// null = closed, 'new' = create modal, InternetRadioStation = edit modal
const [modalStation, setModalStation] = useState<InternetRadioStation | 'new' | null>(null);
const [browseOpen, setBrowseOpen] = useState(false);
const [sortBy, setSortBy] = useState<'manual' | 'az' | 'za' | 'newest'>('manual');
const [activeFilter, setActiveFilter] = useState('all');
const [activeLetter, setActiveLetter] = useState<string | null>(null);
const [favorites, setFavorites] = useState<Set<string>>(() => {
try { return new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]')); }
catch { return new Set<string>(); }
});
const [manualOrder, setManualOrder] = useState<string[]>([]);
const [dragOver, setDragOver] = useState<{ id: string; side: 'before' | 'after' } | null>(null);
useEffect(() => {
getInternetRadioStations()
.then(setStations)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const reload = async () => {
const list = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]);
setStations(list);
};
// Merge saved manual order with current stations when stations change
useEffect(() => {
if (!stations.length) return;
const saved: string[] = (() => {
try { return JSON.parse(localStorage.getItem('psysonic_radio_order') ?? '[]'); }
catch { return []; }
})();
const currentIds = new Set(stations.map(s => s.id));
const merged = saved.filter((id: string) => currentIds.has(id));
stations.forEach(s => { if (!merged.includes(s.id)) merged.push(s.id); });
setManualOrder(merged);
}, [stations]);
const toggleFavorite = useCallback((id: string) => {
setFavorites(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...next]));
return next;
});
}, []);
const handleReorder = useCallback((srcId: string, tgtId: string, side: 'before' | 'after') => {
setManualOrder(prev => {
const order = [...prev];
const si = order.indexOf(srcId);
if (si === -1) return prev;
order.splice(si, 1); // remove from original position
const ti = order.indexOf(tgtId); // recalculate after removal
if (ti === -1) return prev;
const insertAt = side === 'before' ? ti : ti + 1;
order.splice(insertAt, 0, srcId);
localStorage.setItem('psysonic_radio_order', JSON.stringify(order));
return order;
});
}, []);
// After chip-filter + sort, but before alphabet filter — used to compute available letters
const sortedFilteredStations = useMemo(() => {
let list = [...stations];
if (activeFilter === 'favorites') list = list.filter(s => favorites.has(s.id));
if (sortBy === 'az') list.sort((a, b) => a.name.localeCompare(b.name));
else if (sortBy === 'za') list.sort((a, b) => b.name.localeCompare(a.name));
else if (sortBy === 'newest') list.reverse();
else {
const orderMap = new Map(manualOrder.map((id, i) => [id, i]));
list.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
}
return list;
}, [stations, activeFilter, favorites, sortBy, manualOrder]);
const availableLetters = useMemo(() => {
const set = new Set<string>();
for (const s of sortedFilteredStations) {
const ch = s.name.trim()[0]?.toUpperCase() ?? '';
if (ch >= 'A' && ch <= 'Z') set.add(ch);
else if (ch) set.add('#');
}
return set;
}, [sortedFilteredStations]);
const displayedStations = useMemo(() => {
if (!activeLetter) return sortedFilteredStations;
return sortedFilteredStations.filter(s => {
const ch = s.name.trim()[0]?.toUpperCase() ?? '';
if (activeLetter === '#') return !(ch >= 'A' && ch <= 'Z');
return ch === activeLetter;
});
}, [sortedFilteredStations, activeLetter]);
const handleSave = async (opts: {
name: string;
streamUrl: string;
homepageUrl: string;
coverFile: File | null;
coverRemoved: boolean;
}) => {
if (modalStation === 'new') {
await createInternetRadioStation(
opts.name.trim(),
opts.streamUrl.trim(),
opts.homepageUrl.trim() || undefined
);
if (opts.coverFile) {
// Reload first to get the new station's ID, then upload cover
const updated = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]);
const created = updated.find(
s => s.name === opts.name.trim() && s.streamUrl === opts.streamUrl.trim()
);
if (created) {
try {
await uploadRadioCoverArt(created.id, opts.coverFile);
} catch (err) {
showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Cover upload failed', 4000, 'error');
}
// Reload again so coverArt field is picked up
await reload();
} else {
setStations(updated);
}
} else {
await reload();
}
} else {
const id = (modalStation as InternetRadioStation).id;
await updateInternetRadioStation(
id,
opts.name.trim(),
opts.streamUrl.trim(),
opts.homepageUrl.trim() || undefined
);
if (opts.coverFile) {
try {
await uploadRadioCoverArt(id, opts.coverFile);
} catch (err) {
showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Cover upload failed', 4000, 'error');
}
} else if (opts.coverRemoved) {
await deleteRadioCoverArt(id).catch(() => {});
}
await reload();
}
setModalStation(null);
};
const handleDelete = async (e: React.MouseEvent, s: InternetRadioStation) => {
e.stopPropagation();
if (deleteConfirmId !== s.id) {
setDeleteConfirmId(s.id);
return;
}
if (currentRadio?.id === s.id) stop();
try {
await deleteInternetRadioStation(s.id);
setStations(prev => prev.filter(st => st.id !== s.id));
} catch {}
setDeleteConfirmId(null);
};
const handlePlay = (e: React.MouseEvent, s: InternetRadioStation) => {
e.stopPropagation();
if (currentRadio?.id === s.id && isPlaying) {
stop();
} else {
playRadio(s);
}
};
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
);
}
return (
<div className="content-body animate-fade-in">
{/* ── Header ── */}
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('radio.title')}</h1>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={() => setBrowseOpen(true)}>
<Search size={14} /> {t('radio.browseDirectory')}
</button>
<button className="btn btn-primary" onClick={() => setModalStation('new')}>
<Plus size={15} /> {t('radio.addStation')}
</button>
</div>
</div>
{/* ── Toolbar + Grid ── */}
{stations.length === 0 ? (
<div className="empty-state">{t('radio.empty')}</div>
) : (
<>
<RadioToolbar
sortBy={sortBy}
activeFilter={activeFilter}
onSortChange={setSortBy}
onFilterChange={f => { setActiveFilter(f); setActiveLetter(null); }}
/>
<AlphabetFilterBar
activeLetter={activeLetter}
availableLetters={availableLetters}
onSelect={l => setActiveLetter(prev => prev === l ? null : l)}
/>
{displayedStations.length === 0 ? (
<div className="empty-state">{t('radio.noFavorites')}</div>
) : (
<div className="album-grid-wrap">
{displayedStations.map(s => (
<RadioCard
key={s.id}
s={s}
isActive={currentRadio?.id === s.id}
isPlaying={isPlaying}
deleteConfirmId={deleteConfirmId}
isFavorite={favorites.has(s.id)}
isManual={sortBy === 'manual'}
dropIndicator={dragOver?.id === s.id ? dragOver.side : null}
onPlay={e => handlePlay(e, s)}
onDelete={e => handleDelete(e, s)}
onEdit={() => setModalStation(s)}
onFavoriteToggle={() => toggleFavorite(s.id)}
onDragEnter={side => setDragOver({ id: s.id, side })}
onDragLeave={() => setDragOver(prev => prev?.id === s.id ? null : prev)}
onDropOnto={(srcId, side) => handleReorder(srcId, s.id, side)}
onCardMouseLeave={() => { if (deleteConfirmId === s.id) setDeleteConfirmId(null); }}
/>
))}
</div>
)}
</>
)}
{/* ── Edit/Create Modal ── */}
{modalStation !== null && (
<RadioEditModal
station={modalStation === 'new' ? null : modalStation}
onClose={() => setModalStation(null)}
onSave={handleSave}
/>
)}
{/* ── Directory Modal ── */}
{browseOpen && (
<RadioDirectoryModal
onClose={() => setBrowseOpen(false)}
onAdded={reload}
/>
)}
</div>
);
}
// ── Toolbar ───────────────────────────────────────────────────────────────────
interface RadioToolbarProps {
sortBy: 'manual' | 'az' | 'za' | 'newest';
activeFilter: string;
onSortChange: (s: 'manual' | 'az' | 'za' | 'newest') => void;
onFilterChange: (f: string) => void;
}
function RadioToolbar({ sortBy, activeFilter, onSortChange, onFilterChange }: RadioToolbarProps) {
const { t } = useTranslation();
const sortOptions = [
{ value: 'manual', label: t('radio.sortManual') },
{ value: 'az', label: t('radio.sortAZ') },
{ value: 'za', label: t('radio.sortZA') },
{ value: 'newest', label: t('radio.sortNewest') },
];
return (
<div className="radio-toolbar">
<div className="radio-toolbar-chips">
{(['all', 'favorites'] as const).map(f => (
<button
key={f}
className={`radio-filter-chip${activeFilter === f ? ' active' : ''}`}
onClick={() => onFilterChange(f)}
>
{f === 'all' ? t('radio.filterAll') : t('radio.filterFavorites')}
</button>
))}
</div>
<CustomSelect
value={sortBy}
options={sortOptions}
onChange={v => onSortChange(v as RadioToolbarProps['sortBy'])}
style={{ width: 'max-content', minWidth: 130, maxWidth: 220, flexShrink: 0 }}
/>
</div>
);
}
// ── Alphabet Filter Bar ────────────────────────────────────────────────────────
const ALPHABET_KEYS = ['#', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))];
interface AlphabetFilterBarProps {
activeLetter: string | null;
availableLetters: Set<string>;
onSelect: (l: string) => void;
}
function AlphabetFilterBar({ activeLetter, availableLetters, onSelect }: AlphabetFilterBarProps) {
return (
<div className="alphabet-filter-bar">
{ALPHABET_KEYS.map(l => {
const available = availableLetters.has(l);
const active = activeLetter === l;
return (
<button
key={l}
className={`alphabet-filter-btn${active ? ' active' : ''}${!available ? ' empty' : ''}`}
onClick={() => { if (available) onSelect(l); }}
tabIndex={available ? 0 : -1}
>
{l}
</button>
);
})}
</div>
);
}
// ── Radio Card ────────────────────────────────────────────────────────────────
interface RadioCardProps {
s: InternetRadioStation;
isActive: boolean;
isPlaying: boolean;
deleteConfirmId: string | null;
isFavorite: boolean;
isManual: boolean;
dropIndicator: 'before' | 'after' | null;
onPlay: (e: React.MouseEvent) => void;
onDelete: (e: React.MouseEvent) => void;
onEdit: () => void;
onFavoriteToggle: () => void;
onDragEnter: (side: 'before' | 'after') => void;
onDragLeave: () => void;
onDropOnto: (srcId: string, side: 'before' | 'after') => void;
onCardMouseLeave: () => void;
}
function RadioCard({
s, isActive, isPlaying, deleteConfirmId, isFavorite, isManual, dropIndicator,
onPlay, onDelete, onEdit, onFavoriteToggle, onDragEnter, onDragLeave,
onDropOnto, onCardMouseLeave,
}: RadioCardProps) {
const { t } = useTranslation();
const cardRef = useRef<HTMLDivElement>(null);
const lastSideRef = useRef<'before' | 'after'>('after');
const { isDragging, payload } = useDragDrop();
const isBeingDragged = isDragging && !!payload && (() => {
try { return JSON.parse(payload.data).id === s.id; } catch { return false; }
})();
const dragHandlers = useDragSource(() => ({
data: JSON.stringify({ type: 'radio', id: s.id }),
label: s.name,
}));
// Calculate which half of the card the cursor is on
const getSide = (e: React.MouseEvent): 'before' | 'after' => {
const rect = cardRef.current?.getBoundingClientRect();
if (!rect) return 'after';
return e.clientX < rect.left + rect.width / 2 ? 'before' : 'after';
};
// psy-drop listener: fires when a drag is released over this card
useEffect(() => {
if (!isManual) return;
const el = cardRef.current;
if (!el) return;
const handler = (e: Event) => {
const data = JSON.parse((e as CustomEvent).detail?.data ?? '{}');
if (data.type === 'radio' && data.id !== s.id) onDropOnto(data.id, lastSideRef.current);
};
el.addEventListener('psy-drop', handler);
return () => el.removeEventListener('psy-drop', handler);
}, [isManual, s.id, onDropOnto]);
return (
<div
ref={cardRef}
className={[
'album-card',
isActive ? 'radio-card-active' : '',
dropIndicator === 'before' ? 'radio-card-drop-before' : '',
dropIndicator === 'after' ? 'radio-card-drop-after' : '',
].filter(Boolean).join(' ')}
style={{ cursor: isManual ? 'grab' : 'default', opacity: isBeingDragged ? 0.4 : 1 }}
{...(isManual ? dragHandlers : {})}
onMouseMove={e => {
if (!isDragging || !isManual) return;
const side = getSide(e);
lastSideRef.current = side;
onDragEnter(side);
}}
onMouseLeave={() => { onDragLeave(); onCardMouseLeave(); }}
>
{/* Cover */}
<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>
<button
className={`playlist-card-delete ${deleteConfirmId === s.id ? 'playlist-card-delete--confirm' : ''}`}
onClick={onDelete}
data-tooltip={deleteConfirmId === s.id ? t('radio.confirmDelete') : t('radio.deleteStation')}
data-tooltip-pos="bottom"
>
{deleteConfirmId === s.id ? <Trash2 size={12} /> : <X size={12} />}
</button>
</div>
{/* Info */}
<div className="album-card-info">
<div className="album-card-title">{s.name}</div>
<div className="album-card-artist" style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
<button className="radio-card-chip" onClick={onEdit}>
{t('radio.editStation')}
</button>
<button
className={`player-btn player-btn-sm radio-favorite-btn${isFavorite ? ' active' : ''}`}
onClick={e => { e.stopPropagation(); onFavoriteToggle(); }}
data-tooltip={t(isFavorite ? 'radio.unfavorite' : 'radio.favorite')}
>
<Heart size={11} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
{s.homepageUrl && (
<button
className="player-btn player-btn-sm"
style={{ opacity: 0.6 }}
onClick={() => open(s.homepageUrl!)}
data-tooltip={t('radio.openHomepage')}
>
<Globe size={11} />
</button>
)}
</div>
</div>
</div>
);
}
// ── Radio Edit Modal ──────────────────────────────────────────────────────────
interface RadioEditModalProps {
station: InternetRadioStation | null; // null = create new
onClose: () => void;
onSave: (opts: {
name: string;
streamUrl: string;
homepageUrl: string;
coverFile: File | null;
coverRemoved: boolean;
}) => Promise<void>;
}
function RadioEditModal({ station, onClose, onSave }: RadioEditModalProps) {
const { t } = useTranslation();
const [name, setName] = useState(station?.name ?? '');
const [streamUrl, setStreamUrl] = useState(station?.streamUrl ?? '');
const [homepageUrl, setHomepageUrl] = useState(station?.homepageUrl ?? '');
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 || station?.coverArt);
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 () => {
if (!name.trim() || !streamUrl.trim()) return;
setSaving(true);
try {
await onSave({ name, streamUrl, homepageUrl, coverFile, coverRemoved });
} finally {
setSaving(false);
}
};
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div className="modal-overlay" style={{ alignItems: 'center', paddingTop: 0, overflowY: 'auto' }} onClick={handleOverlayClick}>
<div
className="modal-content"
style={{ maxWidth: 440, width: '90%', maxHeight: 'none', overflow: 'visible' }}
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: 20 }}>
{station ? t('radio.editStation') : t('radio.addStation')}
</h2>
{/* Cover + fields side by side */}
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', marginBottom: 20 }}>
{/* Cover */}
<div
className="playlist-edit-cover-wrap"
style={{ width: 140, height: 140 }}
onClick={() => coverInputRef.current?.click()}
>
{coverPreview ? (
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && station?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(`ra-${station.id}`, 256)}
cacheKey={coverArtCacheKey(`ra-${station.id}`, 256)}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="album-card-cover-placeholder playlist-card-icon" style={{ width: '100%', height: '100%', borderRadius: 0 }}>
<Cast size={36} strokeWidth={1.2} />
</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={13} />
{t('radio.changeCoverLabel')}
</button>
{hasExistingCover && (
<button
className="playlist-edit-cover-menu-item playlist-edit-cover-menu-item--danger"
onClick={handleRemoveCover}
>
{t('radio.removeCover')}
</button>
)}
</div>
</div>
<input ref={coverInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
</div>
{/* Fields */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
<input
className="input"
style={{ fontSize: 15, fontWeight: 600 }}
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('radio.stationName')}
autoFocus
/>
<input
className="input"
value={streamUrl}
onChange={e => setStreamUrl(e.target.value)}
placeholder={t('radio.streamUrl')}
/>
<input
className="input"
value={homepageUrl}
onChange={e => setHomepageUrl(e.target.value)}
placeholder={t('radio.homepageUrl')}
/>
</div>
</div>
<div className="playlist-edit-footer">
<div />
<button
className="btn btn-primary"
onClick={handleSave}
disabled={saving || !name.trim() || !streamUrl.trim()}
>
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
{t('radio.save')}
</button>
</div>
</div>
</div>
);
}
// ── Radio Directory Modal ─────────────────────────────────────────────────────
interface RadioDirectoryModalProps {
onClose: () => void;
onAdded: () => void;
}
function RadioDirectoryModal({ onClose, onAdded }: RadioDirectoryModalProps) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [results, setResults] = useState<RadioBrowserStation[]>([]);
const [searching, setSearching] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [addingId, setAddingId] = useState<string | null>(null);
const [addedIds, setAddedIds] = useState<Set<string>>(new Set());
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const queryRef = useRef(query);
useEffect(() => { queryRef.current = query; }, [query]);
const fetchPage = useCallback(async (q: string, off: number, append: boolean) => {
if (append) setLoadingMore(true); else setSearching(true);
try {
const page = q.trim()
? await searchRadioBrowser(q.trim(), off)
: await getTopRadioStations(off);
if (append) setResults(prev => [...prev, ...page]);
else setResults(page);
setHasMore(page.length >= RADIO_PAGE_SIZE);
setOffset(off + page.length);
} catch {
if (!append) setResults([]);
setHasMore(false);
} finally {
if (append) setLoadingMore(false); else setSearching(false);
}
}, []);
// Load top stations on open
useEffect(() => {
fetchPage('', 0, false);
}, [fetchPage]);
// Debounced search; reset pagination on new query
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setOffset(0);
setHasMore(true);
fetchPage(query, 0, false);
}, query.trim() ? 400 : 0);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [query, fetchPage]);
// Callback ref: re-creates the IntersectionObserver whenever hasMore/loadingMore/offset change,
// so the closure always captures current state — no stale refs needed.
const sentinelRef = useCallback((node: HTMLDivElement | null) => {
if (observerRef.current) { observerRef.current.disconnect(); observerRef.current = null; }
if (!node) return;
const root = scrollContainerRef.current ?? null;
observerRef.current = new IntersectionObserver(entries => {
const entry = entries[0];
console.log('[RadioDir] Observer fired:', entry.isIntersecting, '| hasMore:', hasMore, '| loading:', loadingMore);
if (entry.isIntersecting && hasMore && !loadingMore) {
fetchPage(queryRef.current, offset, true);
}
}, { root, rootMargin: '200px', threshold: 0 });
observerRef.current.observe(node);
}, [hasMore, loadingMore, offset, fetchPage]);
const handleAdd = async (s: RadioBrowserStation) => {
if (addedIds.has(s.stationuuid) || addingId !== null) return;
setAddingId(s.stationuuid);
try {
await createInternetRadioStation(s.name, s.url);
if (s.favicon) {
const list = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]);
const created = list.find(r => r.streamUrl === s.url);
if (created) {
try {
const [fileBytes, mimeType] = await fetchUrlBytes(s.favicon);
await uploadRadioCoverArtBytes(created.id, fileBytes, mimeType);
} catch { /* favicon optional */ }
}
}
onAdded();
setAddedIds(prev => new Set(prev).add(s.stationuuid));
showToast(`${t('radio.stationAdded')}: ${s.name}`, 3000);
} catch (err) {
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : '');
if (msg.toLowerCase().includes('unique constraint') || msg.toLowerCase().includes('radio.name')) {
showToast('Ein Sender mit diesem Namen existiert bereits.', 4000, 'error');
} else {
showToast(msg || 'Failed', 3000, 'error');
}
} finally {
setAddingId(null);
}
};
return createPortal(
// ── 1. Backdrop ──────────────────────────────────────────────
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(17,17,27,0.85)',
backdropFilter: 'blur(8px)',
}}
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
>
{/* ── 2. Content Box ─────────────────────────────────────── */}
<div
style={{
width: '80vw',
maxWidth: 800,
height: '80vh',
background: 'var(--ctp-surface0)',
border: '1px solid var(--border)',
borderRadius: 12,
boxShadow: 'var(--shadow-lg)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
onClick={e => e.stopPropagation()}
>
{/* ── 3. Header ──────────────────────────────────────────── */}
<div
style={{
flexShrink: 0,
padding: 20,
background: 'var(--ctp-surface0)',
zIndex: 10,
position: 'relative',
borderBottom: '1px solid var(--border)',
}}
>
<button
className="btn btn-ghost"
style={{ position: 'absolute', top: 16, right: 16, color: 'var(--text-muted)' }}
onClick={onClose}
>
<X size={18} />
</button>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 14, color: 'var(--text-primary)', fontFamily: 'var(--font-display)' }}>
{t('radio.browseDirectory')}
</h2>
<input
className="input"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('radio.directoryPlaceholder')}
autoFocus
style={{ width: '100%' }}
/>
</div>
{/* ── 4. Body / Results ──────────────────────────────────── */}
<div ref={scrollContainerRef} style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 20px 20px' }}>
{searching ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<div className="spinner" />
</div>
) : results.length === 0 ? (
<div className="empty-state" style={{ padding: '2rem 0' }}>{t('radio.noResults')}</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingTop: 8 }}>
{results.map(s => {
const isAdded = addedIds.has(s.stationuuid);
const isLoading = addingId === s.stationuuid;
const isDisabled = isAdded || addingId !== null;
return (
<div
key={s.stationuuid}
className={`radio-browser-result${isAdded ? ' added' : ''}${isDisabled ? '' : ' clickable'}`}
onClick={() => handleAdd(s)}
>
{s.favicon ? (
<img
src={s.favicon}
alt=""
className="radio-browser-favicon"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<div className="radio-browser-favicon radio-browser-favicon--placeholder">
<Cast size={16} strokeWidth={1.5} />
</div>
)}
<div className="radio-browser-info">
<div className="radio-browser-name">{s.name}</div>
{s.tags && (
<div className="radio-browser-tags">
{s.tags.split(',').slice(0, 4).map(tag => tag.trim()).filter(Boolean).join(' · ')}
</div>
)}
</div>
<div className="radio-browser-action" aria-hidden>
{isLoading
? <Loader2 size={14} className="spin-slow" style={{ color: 'var(--accent)' }} />
: isAdded
? <Check size={14} style={{ color: 'var(--accent)' }} />
: <Plus size={14} style={{ color: 'var(--text-muted)' }} />}
</div>
</div>
);
})}
{/* Sentinel for IntersectionObserver */}
<div ref={sentinelRef} style={{ height: 20, width: '100%', flexShrink: 0 }} />
{loadingMore && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0' }}>
<div className="spinner" style={{ width: 20, height: 20 }} />
</div>
)}
</div>
)}
</div>
</div>
</div>,
document.body
);
}
+446 -135
View File
@@ -1,12 +1,15 @@
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';
import { useShallow } from 'zustand/react/shallow';
import { usePlaylistStore } from '../store/playlistStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
@@ -14,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);
@@ -52,11 +56,35 @@ 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();
const navigate = useNavigate();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore(
useShallow(s => ({
playTrack: s.playTrack,
enqueue: s.enqueue,
openContextMenu: s.openContextMenu,
currentTrack: s.currentTrack,
isPlaying: s.isPlaying,
starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride,
}))
);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const { startDrag, isDragging } = useDragDrop();
const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
@@ -67,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);
@@ -138,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('');
@@ -153,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(() => {
@@ -169,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 => {
@@ -219,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;
@@ -379,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">
@@ -534,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 && (
@@ -561,14 +732,13 @@ export default function PlaylistDetail() {
{songs.map((song, idx) => (
<React.Fragment key={song.id + idx}>
{/* Drop indicator above row */}
{isDragging && dropTargetIdx?.idx === idx && dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
)}
<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 => {
@@ -585,77 +755,50 @@ 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"
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); }}
/>
<span style={{ color: currentTrack?.id === song.id ? 'var(--accent)' : 'var(--text-muted)' }}>
{currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</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.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>
{/* Drop indicator below last row or between rows */}
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
)}
@@ -664,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>
@@ -692,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 => {
@@ -719,38 +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.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>
);
}
+14 -18
View File
@@ -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>
@@ -333,7 +333,7 @@ export default function RandomMix() {
<div className="col-center">{t('randomMix.trackFavorite')}</div>
<div className="col-center">{t('randomMix.trackDuration')}</div>
</div>
{genreMixSongs.map(song => {
{genreMixSongs.map((song, idx) => {
const track = songToTrack(song);
const queueSongs = genreMixSongs.map(songToTrack);
const isCurrentTrack = currentTrack?.id === song.id;
@@ -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'); }}
@@ -364,12 +364,10 @@ export default function RandomMix() {
document.addEventListener('mouseup', onUp);
}}
>
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
<span style={{ color: isCurrentTrack ? 'var(--accent)' : 'var(--text-muted)' }}>
{isCurrentTrack && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
{isCurrentTrack && 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>
<div className="track-info"><span className="track-title">{song.title}</span></div>
<div className="track-artist-cell">
@@ -416,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>
@@ -428,7 +426,7 @@ export default function RandomMix() {
<div className="col-center">{t('randomMix.trackDuration')}</div>
</div>
{filteredSongs.map((song) => {
{filteredSongs.map((song, idx) => {
const track = songToTrack(song);
const queueSongs = filteredSongs.map(songToTrack);
const isCurrentTrack = currentTrack?.id === song.id;
@@ -445,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 => {
@@ -469,12 +467,10 @@ export default function RandomMix() {
document.addEventListener('mouseup', onUp);
}}
>
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
<span style={{ color: isCurrentTrack ? 'var(--accent)' : 'var(--text-muted)' }}>
{isCurrentTrack && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
{isCurrentTrack && 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>
<div className="track-info">
+2 -2
View File
@@ -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 => {
+303 -153
View File
@@ -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
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">
<Sliders size={18} />
<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' }}>
@@ -847,6 +990,9 @@ export default function Settings() {
<Server size={18} />
<h2>{t('settings.servers')}</h2>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.serverCompatible')}
</div>
{auth.servers.length === 0 && !showAddForm ? (
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
@@ -876,7 +1022,7 @@ export default function Settings() {
{status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />}
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />}
<button
className="btn btn-ghost"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => testConnection(srv)}
disabled={status === 'testing'}
@@ -915,7 +1061,7 @@ export default function Settings() {
{showAddForm ? (
<AddServerForm onSave={handleAddServer} onCancel={() => setShowAddForm(false)} />
) : (
<button className="btn btn-ghost" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
<button className="btn btn-surface" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
<Plus size={16} /> {t('settings.addServer')}
</button>
)}
@@ -990,89 +1136,13 @@ export default function Settings() {
</div>
</section>
{/* Downloads + Tray */}
<section className="settings-section">
<div className="settings-section-header">
<Sliders 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-ghost" 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} />
@@ -1414,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
View File
@@ -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} />
)}
+4
View File
@@ -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) => {
+25 -7
View File
@@ -37,19 +37,22 @@ export const BUILTIN_PRESETS: EqPreset[] = [
interface EqState {
gains: number[]; // 10 values, -12 to +12 dB
enabled: boolean;
preGain: number; // pre-amplification in dB (-30 to +6), applied before bands
activePreset: string | null;
customPresets: EqPreset[];
setBandGain: (index: number, gain: number) => void;
setEnabled: (v: boolean) => void;
setPreGain: (v: number) => void;
applyPreset: (name: string) => void;
applyAutoEq: (name: string, gains: number[], preGain: number) => void;
saveCustomPreset: (name: string) => void;
deleteCustomPreset: (name: string) => void;
syncToRust: () => void;
}
function syncEq(gains: number[], enabled: boolean) {
invoke('audio_set_eq', { gains: gains.map(g => g), enabled }).catch(() => {});
function syncEq(gains: number[], enabled: boolean, preGain: number) {
invoke('audio_set_eq', { gains: gains.map(g => g), enabled, preGain }).catch(() => {});
}
export const useEqStore = create<EqState>()(
@@ -57,6 +60,7 @@ export const useEqStore = create<EqState>()(
(set, get) => ({
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
enabled: false,
preGain: 0,
activePreset: 'Flat',
customPresets: [],
@@ -65,12 +69,25 @@ export const useEqStore = create<EqState>()(
const gains = [...get().gains];
gains[index] = clamped;
set({ gains, activePreset: null });
syncEq(gains, get().enabled);
syncEq(gains, get().enabled, get().preGain);
},
setEnabled: (v) => {
set({ enabled: v });
syncEq(get().gains, v);
syncEq(get().gains, v, get().preGain);
},
setPreGain: (v) => {
const clamped = Math.max(-30, Math.min(6, v));
set({ preGain: clamped, activePreset: null });
syncEq(get().gains, get().enabled, clamped);
},
applyAutoEq: (name, gains, preGain) => {
const clampedPreGain = Math.max(-30, Math.min(6, preGain));
const clampedGains = gains.map(g => Math.max(-12, Math.min(12, g)));
set({ gains: clampedGains, preGain: clampedPreGain, activePreset: name });
syncEq(clampedGains, get().enabled, clampedPreGain);
},
applyPreset: (name) => {
@@ -78,7 +95,7 @@ export const useEqStore = create<EqState>()(
const preset = all.find(p => p.name === name);
if (!preset) return;
set({ gains: [...preset.gains], activePreset: name });
syncEq(preset.gains, get().enabled);
syncEq(preset.gains, get().enabled, get().preGain);
},
saveCustomPreset: (name) => {
@@ -95,8 +112,8 @@ export const useEqStore = create<EqState>()(
},
syncToRust: () => {
const { gains, enabled } = get();
syncEq(gains, enabled);
const { gains, enabled, preGain } = get();
syncEq(gains, enabled, preGain);
},
}),
{
@@ -105,6 +122,7 @@ export const useEqStore = create<EqState>()(
partialize: (s) => ({
gains: s.gains,
enabled: s.enabled,
preGain: s.preGain,
activePreset: s.activePreset,
customPresets: s.customPresets,
}),
+11 -4
View File
@@ -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(() => {});
}),
);
+88 -5
View File
@@ -3,7 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { showToast } from '../utils/toast';
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic';
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore';
@@ -60,6 +60,7 @@ export function songToTrack(song: SubsonicSong): Track {
interface PlayerState {
currentTrack: Track | null;
currentRadio: InternetRadioStation | null;
queue: Track[];
queueIndex: number;
isPlaying: boolean;
@@ -73,6 +74,7 @@ interface PlayerState {
starredOverrides: Record<string, boolean>;
setStarredOverride: (id: string, starred: boolean) => void;
playRadio: (station: InternetRadioStation) => void;
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
pause: () => void;
resume: () => void;
@@ -156,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;
@@ -262,6 +282,13 @@ function handleAudioEnded() {
return;
}
// Radio stream disconnected — just stop; don't advance queue.
if (usePlayerStore.getState().currentRadio) {
isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
return;
}
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
@@ -481,6 +508,7 @@ export const usePlayerStore = create<PlayerState>()(
persist(
(set, get) => ({
currentTrack: null,
currentRadio: null,
queue: [],
queueIndex: 0,
isPlaying: false,
@@ -569,10 +597,45 @@ 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 });
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, currentRadio: null });
},
// ── playRadio ────────────────────────────────────────────────────────────
playRadio: (station) => {
const { volume } = get();
++playGeneration;
isAudioPaused = false;
gaplessPreloadingId = null;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
// 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({
currentRadio: station,
currentTrack: null,
queue: [],
queueIndex: 0,
isPlaying: true,
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: true, // no scrobbling for radio
});
},
// ── playTrack ────────────────────────────────────────────────────────────
@@ -588,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,
@@ -646,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;
@@ -878,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 });
},
+12 -1
View File
@@ -18,6 +18,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'randomMix', visible: true },
{ id: 'favorites', visible: true },
{ id: 'playlists', visible: true },
{ id: 'radio', visible: true },
{ id: 'statistics', visible: true },
{ id: 'help', visible: true },
];
@@ -42,6 +43,16 @@ export const useSidebarStore = create<SidebarStore>()(
reset: () => set({ items: DEFAULT_SIDEBAR_ITEMS }),
}),
{ name: 'psysonic_sidebar' }
{
name: 'psysonic_sidebar',
onRehydrateStorage: () => (state) => {
if (!state) return;
const known = new Set(state.items.map(i => i.id));
const missing = DEFAULT_SIDEBAR_ITEMS.filter(i => !known.has(i.id));
if (missing.length > 0) {
state.items = [...state.items, ...missing];
}
},
}
)
);
+607 -42
View File
@@ -1217,15 +1217,18 @@
/* ─ Tracklist ─ */
.tracklist {
padding: 0 var(--space-6) var(--space-6);
overflow-x: auto;
}
.col-center {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.tracklist-header {
display: grid;
grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) var(--space-3);
@@ -1238,13 +1241,8 @@
margin-bottom: var(--space-2);
}
.tracklist-header.tracklist-va {
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
}
.track-row {
display: grid;
grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) var(--space-3);
@@ -1252,65 +1250,152 @@
cursor: pointer;
transition: background var(--transition-fast);
user-select: none;
}
.track-row.track-row-va {
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
contain: layout style;
overflow: hidden;
}
.tracklist-total {
display: grid;
grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px;
gap: var(--space-3);
align-items: center;
border-top: 1px solid var(--border-subtle);
padding: var(--space-2) var(--space-3);
margin-top: var(--space-1);
}
.tracklist-total.tracklist-va {
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
}
.tracklist-total-label {
grid-column: 1 / 5;
text-align: right;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
text-align: right;
}
.tracklist-total-value {
grid-column: 5 / 6;
text-align: center;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
text-align: center;
}
.tracklist-total.tracklist-va .tracklist-total-label {
grid-column: 1 / 6;
/* ── Column resize handle ── */
/* 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: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
z-index: 2;
}
.tracklist-total.tracklist-va .tracklist-total-value {
grid-column: 6 / 7;
.col-resize-handle::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 20%;
bottom: 20%;
width: 1px;
border-radius: 1px;
background: var(--ctp-surface1);
transition: background 0.15s;
}
/* ── Playlist tracklist variant — adds delete column ── */
.tracklist-header.tracklist-playlist,
.track-row.tracklist-playlist,
.tracklist-total.tracklist-playlist {
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px 36px;
.col-resize-handle:hover::after,
.col-resize-handle:active::after {
background: var(--accent);
}
.tracklist-total.tracklist-playlist .tracklist-total-label {
grid-column: 1 / 6;
/* ── Column visibility picker ── */
.tracklist-col-picker {
position: absolute;
top: 0;
right: 0;
height: 100%;
display: flex;
align-items: center;
padding-right: 4px;
}
.tracklist-total.tracklist-playlist .tracklist-total-value {
grid-column: 6 / 7;
.tracklist-col-picker-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s, background 0.15s;
}
.tracklist-col-picker-btn:hover {
opacity: 1;
background: var(--ctp-surface0);
color: var(--text-primary);
}
.tracklist-col-picker-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
background: var(--bg-card);
border: 1px solid var(--ctp-surface1);
border-radius: var(--radius-md);
padding: 6px;
min-width: 160px;
z-index: 200;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
}
.tracklist-col-picker-label {
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
padding: 4px 8px 6px;
}
.tracklist-col-picker-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 8px;
border-radius: var(--radius-sm);
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
text-align: left;
transition: background 0.1s;
}
.tracklist-col-picker-item:hover {
background: var(--ctp-surface0);
color: var(--text-primary);
}
.tracklist-col-picker-item.active {
color: var(--accent);
}
.tracklist-col-picker-check {
width: 16px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
}
/* Delete button in playlist row */
@@ -1392,7 +1477,7 @@
}
.track-num .bulk-check {
position: absolute;
left: 6px;
left: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.1s;
@@ -1406,6 +1491,44 @@
pointer-events: auto;
}
/* Default: show track number */
.track-num-number {
font-size: 13px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
text-align: center;
}
/* Play icon: hidden by default */
.track-num-play {
display: none;
align-items: center;
color: var(--text-primary);
}
/* Eq-bars wrapper */
.track-num-eq {
display: flex;
align-items: center;
justify-content: flex-end;
}
/* ── Hover (non-active row): hide number, show play icon ── */
.track-row:hover .track-num-number { display: none; }
.track-row:hover .track-num-play { display: flex; }
/* ── Active (currently playing): hide number, show eq-bars ── */
.track-num.track-num-active .track-num-number { display: none; }
.track-num.track-num-active .track-num-number { color: var(--accent); } /* kept for when visible */
/* ── Active + hover: hide eq-bars, show play icon ── */
.track-row:hover .track-num.track-num-active .track-num-eq { display: none; }
.track-row:hover .track-num.track-num-active .track-num-play { display: flex; }
/* Paused state: show play icon statically (no hover needed) */
.track-num.track-num-paused .track-num-play { display: flex; opacity: 0.5; }
.track-row:hover .track-num.track-num-paused .track-num-play { opacity: 1; }
/* Equalizer bars — shown for the currently playing track */
.eq-bars {
display: flex;
@@ -1478,11 +1601,6 @@
font-size: 16px;
}
.track-num {
font-size: 13px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.track-info {
min-width: 0;
@@ -1510,6 +1628,11 @@
text-overflow: ellipsis;
}
.track-artist-link:hover {
color: var(--accent);
text-decoration: underline;
}
.track-codec {
display: block;
font-size: 10px;
@@ -1527,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 {
@@ -1542,6 +1667,14 @@
margin-top: 0;
}
.track-genre {
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-star-cell {
display: flex;
justify-content: center;
@@ -1661,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;
@@ -2281,10 +2538,18 @@
box-shadow: var(--shadow-sm);
}
.toggle-switch:hover .toggle-track {
background: var(--ctp-overlay0);
}
.toggle-switch input:checked+.toggle-track {
background: var(--accent);
}
.toggle-switch:hover input:checked+.toggle-track {
filter: brightness(1.15);
}
.toggle-switch input:checked+.toggle-track::before {
transform: translateX(20px);
}
@@ -2507,10 +2772,10 @@
/* ── Blurred background ── */
.fs-bg {
position: absolute;
inset: -30%;
inset: -20%;
background-size: cover;
background-position: center 20%;
filter: blur(6px) brightness(0.25) saturate(1.6);
filter: blur(4px) brightness(0.25) saturate(1.5);
animation: ken-burns 120s linear infinite;
z-index: 0;
will-change: transform;
@@ -2561,6 +2826,7 @@
gap: 18px;
width: min(440px, 88vw);
padding: 16px 0;
will-change: transform;
}
/* Artist name — above cover */
@@ -2584,6 +2850,7 @@
box-shadow: none;
flex-shrink: 0;
animation: cover-breathe 9s ease-in-out infinite;
will-change: transform;
}
.fs-cover {
@@ -4354,6 +4621,134 @@
color: var(--text-muted);
white-space: nowrap;
}
/* Pre-gain row */
.eq-pregain-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 10px;
border-top: 1px solid var(--border-subtle);
}
.eq-pregain-label {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
min-width: 72px;
}
.eq-pregain-slider {
flex: 1;
accent-color: var(--accent);
height: 4px;
cursor: pointer;
}
.eq-pregain-val {
font-size: 11px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
min-width: 52px;
text-align: right;
}
/* AutoEQ section */
.eq-autoeq-section {
border: 1px solid var(--border-subtle);
border-radius: 8px;
overflow: hidden;
}
.eq-autoeq-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 8px 12px;
background: var(--bg-card);
border: none;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
text-align: left;
}
.eq-autoeq-toggle:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.eq-autoeq-toggle span {
flex: 1;
}
.eq-autoeq-body {
padding: 10px 12px 12px;
background: var(--bg-app);
display: flex;
flex-direction: column;
gap: 8px;
}
.eq-autoeq-search-row {
display: flex;
align-items: center;
gap: 6px;
}
.eq-autoeq-status {
font-size: 11px;
color: var(--text-muted);
padding: 2px 0;
}
.eq-autoeq-error {
color: var(--danger);
}
.eq-autoeq-applied {
color: var(--accent);
}
.eq-autoeq-results {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 180px;
overflow-y: auto;
}
.eq-autoeq-result-btn {
text-align: left;
padding: 5px 10px;
border-radius: 5px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
.eq-autoeq-result-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.eq-autoeq-result-btn {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.eq-autoeq-result-source {
font-size: 10px;
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
}
/* ─── Changelog ────────────────────────────────────────────────────────────── */
.changelog-list {
@@ -4668,6 +5063,176 @@
to { background: #ff2222; }
}
/* ─ Internet Radio ─ */
.radio-card-active {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.radio-live-overlay {
position: absolute;
top: 6px;
left: 6px;
z-index: 3;
pointer-events: none;
}
.radio-live-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
background: #e03030;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.06em;
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;
+21 -4
View File
@@ -1,5 +1,14 @@
/* ─── Psysonic App Shell ─── */
*, *::before, *::after {
user-select: none;
}
/* Allow text selection only where the user needs to copy content */
.artist-bio, [data-selectable] {
user-select: text;
}
.app-shell {
position: relative;
display: grid;
@@ -141,18 +150,17 @@
opacity: 1;
}
/* Now Playing nav link */
/* Now Playing nav link — subtly accented but not permanently "active"-looking */
.nav-link-nowplaying {
color: var(--accent);
background: var(--accent-dim);
font-weight: 600;
}
.nav-link-nowplaying:hover {
background: color-mix(in srgb, var(--accent) 20%, transparent);
background: var(--bg-hover);
color: var(--accent);
}
.nav-link-nowplaying.active {
background: color-mix(in srgb, var(--accent) 22%, transparent);
background: var(--accent-dim);
color: var(--accent);
}
.nav-link-nowplaying svg {
@@ -303,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;
+2
View File
@@ -3670,10 +3670,12 @@ body.psy-dragging * {
.btn-surface {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--ctp-surface1);
}
.btn-surface:hover {
background: var(--bg-hover);
border-color: var(--ctp-overlay0);
}
/* ─── Input ─── */
+73
View File
@@ -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();
}
+179
View File
@@ -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,
};
}