mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-24 16:25:46 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67f31b0700 | |||
| c873880a26 | |||
| 463b7483fd | |||
| 3d11ef91a1 | |||
| c365140870 | |||
| 651b3cb050 | |||
| e2ee9247ad | |||
| 74df7b6b88 | |||
| 27a6693c8c |
+61
-2
@@ -5,12 +5,71 @@ 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.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
|
||||
|
||||
- **Bulk offline download — Playlists & Artist discographies** *(requested by [@Apollosport](https://github.com/Apollosport), [#54](https://github.com/Psychotoxical/psysonic/issues/54))*: Download an entire playlist or a full artist discography for offline use in one click. Progress is tracked per album on the Artist page ("Caching… 2/5 albums").
|
||||
- **Offline Library filter tabs**: The Offline Library now has four filter tabs — All, Albums, Playlists, and Discographies. The Discographies tab groups albums under their respective artist with section headings.
|
||||
- **Discord Rich Presence** *(requested by [@Bewenben](https://github.com/Bewenben), [#49](https://github.com/Psychotoxical/psysonic/issues/49))* (opt-in): Psysonic can now update your Discord status with the currently playing track, artist, and a live elapsed timer. Toggle in Settings → General → "Discord Rich Presence".
|
||||
- **Artist images on Artists page** *(reported by [@Apollosport](https://github.com/Apollosport), [#53](https://github.com/Psychotoxical/psysonic/issues/53))* (opt-in): Artist avatars on the Artists overview can now show the actual artist image from the server instead of the coloured initial. Toggle in Settings → General → "Show artist images". Off by default to preserve performance on large libraries.
|
||||
- **Image lazy loading**: Cover art and artist images across all pages now load lazily via `IntersectionObserver` (300 px pre-fetch margin), significantly reducing initial page render time on large libraries.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Crossfade triggers on manual track skip** *(reported by [@netherguy4](https://github.com/netherguy4), [#35](https://github.com/Psychotoxical/psysonic/issues/35))*: Manually clicking Next/Prev or selecting a track from the queue no longer triggers the crossfade transition. Crossfade now only fires on natural track end.
|
||||
- **Playlist offline cache showing individual album cards**: Caching a playlist offline previously created one card per album group in the Offline Library. The playlist is now stored as a single cohesive entry.
|
||||
- **Image cache abort handling**: Aborted image fetches no longer prevented the cached result from being written to IndexedDB, causing covers to reload on every page visit.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Queue tech strip**: Removed genre from the codec/bitrate overlay strip in the Queue panel — genre strings frequently caused layout overflow.
|
||||
- **"Save discography offline" label**: The Artist page offline button now reads "Save discography offline" instead of "Download discography" to avoid confusion with a ZIP export.
|
||||
- **Update toast (Win/Mac)**: The update notification now includes a disclaimer that auto-update is still in development, and always shows a direct GitHub Releases download link alongside the install button as a fallback.
|
||||
- **Facebook theme overhaul**: Improved grey text contrast, opaque album chip and back button, readable Queue/Lyrics tab labels.
|
||||
|
||||
---
|
||||
|
||||
## [1.29.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Radio: instant start + background enrichment** *(requested by [@netherguy4](https://github.com/netherguy4))*: Artist Radio now starts immediately from fast local `getTopSongs` results. `getSimilarSongs2` (Last.fm-dependent, slow) continues in the background and silently enriches the queue once it resolves — no waiting before the first song.
|
||||
- **Proactive Radio queue loading** *(requested by [@netherguy4](https://github.com/netherguy4))*: When ≤2 Radio tracks remain, Psysonic automatically fetches more similar songs and appends them — independent of the Infinite Queue setting. Radio sessions now continue indefinitely.
|
||||
- **OGG/Vorbis playback** *(contributed by [@JulianNymark](https://github.com/JulianNymark), [PR #42](https://github.com/Psychotoxical/psysonic/pull/42))*: Added `symphonia-format-ogg` — `.ogg` files now play natively without server-side transcoding.
|
||||
- **Click-to-seek in synced lyrics** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Clicking any line in the synced lyrics pane seeks directly to that timestamp.
|
||||
- **Volume scroll wheel** *(contributed by [@nisarg-78](https://github.com/nisarg-78), [PR #38](https://github.com/Psychotoxical/psysonic/pull/38))*: Scrolling the mouse wheel over the volume slider adjusts volume in ±5 % steps.
|
||||
@@ -19,7 +78,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Auto-updater endless loop on macOS / Windows**: The single-instance plugin was killing the relaunching process before it could start. Fixed by exiting the old process first (releasing the lock) and spawning the new process via a shell-based delayed restart.
|
||||
- **Auto-updater endless loop on macOS / Windows**: The single-instance plugin was killing the relaunching process before it could start. Hopefully fixed by exiting the old process first (releasing the lock) and spawning the new process via a shell-based delayed restart.
|
||||
- **Radio queue stacking**: Clicking "Start Radio" multiple times no longer appends unlimited duplicate batches — each click replaces the pending Radio section cleanly.
|
||||
- **Start Radio keeps current song playing**: Triggering Radio while a song is playing no longer stops and restarts the current track.
|
||||
- **Radio proactive loading with songs missing `artistId`**: `getSimilarSongs2` results frequently lack `artistId`. A `currentRadioArtistId` module variable now persists the original artist ID as fallback, so proactive loading always fires correctly.
|
||||
|
||||
@@ -84,9 +84,18 @@ Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) p
|
||||
- **macOS**: `.dmg` (Universal or Apple Silicon)
|
||||
- **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases
|
||||
- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases
|
||||
- **Linux (Arch/CachyOS)**: AUR — `yay -S psysonic` or `paru -S psysonic`
|
||||
|
||||
> The AUR package builds from source using your system's own WebKitGTK — no bundled libs, no EGL/Mesa compatibility issues.
|
||||
## 📦 Installation (Arch Linux / AUR)
|
||||
|
||||
Psysonic is available in the **AUR** in two versions. Choose the one that best fits your needs:
|
||||
|
||||
| Package | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| [**psysonic**](https://aur.archlinux.org/packages/psysonic) | **Source** | Builds from source using your system's native **WebKitGTK** (no bundled libs, no EGL/Mesa compatibility issues). |
|
||||
| [**psysonic-bin**](https://aur.archlinux.org/packages/psysonic-bin) | **Binary** | Pre-compiled version for faster installation. |
|
||||
|
||||
> [!TIP]
|
||||
> The AUR binary package is kindly provided and maintained by [**kilyabin**](https://github.com/kilyabin).
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
|
||||
Generated
+30
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.29.0",
|
||||
"version": "1.31.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,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.29.0
|
||||
pkgver=1.31.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+30
-6
@@ -565,7 +565,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -986,6 +986,19 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "discord-rich-presence"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"uuid 0.8.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -3470,9 +3483,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.28.0"
|
||||
version = "1.31.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
"md5",
|
||||
"reqwest 0.12.28",
|
||||
"rodio",
|
||||
@@ -3996,7 +4011,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4906,7 +4921,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -5191,7 +5206,7 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -5704,6 +5719,15 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.22.0"
|
||||
@@ -6898,7 +6922,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"uuid 1.22.0",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.15",
|
||||
"zbus_macros 5.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.28.0"
|
||||
version = "1.31.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -33,6 +33,7 @@ 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"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
biquad = "0.4"
|
||||
@@ -40,3 +41,4 @@ tauri-plugin-window-state = "2.4.1"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
discord-rich-presence = "0.2"
|
||||
|
||||
+449
-10
@@ -1,5 +1,6 @@
|
||||
use std::io::{Cursor, Read, Seek};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -16,6 +17,7 @@ use symphonia::core::{
|
||||
probe::Hint,
|
||||
units::{self, Time},
|
||||
};
|
||||
use futures_util::StreamExt;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
|
||||
@@ -30,6 +32,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 +40,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 +61,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 +109,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);
|
||||
}
|
||||
@@ -411,6 +416,120 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
|
||||
|
||||
// ─── SizedCursorSource — MediaSource with correct byte_len ────────────────────
|
||||
//
|
||||
// ─── RadioBuffer — streaming MediaSource for live HTTP radio ─────────────────
|
||||
//
|
||||
// Bridges an async reqwest byte-stream (download task) into a synchronous
|
||||
// Read+Seek interface that symphonia / rodio can consume.
|
||||
//
|
||||
// Back-pressure: the download task pauses when the ring buffer exceeds 4 MB
|
||||
// (~4 min at 128 kbps). Read() blocks (via Condvar) until data arrives so
|
||||
// rodio's audio thread can decode in real time. is_seekable() = false so
|
||||
// symphonia never tries to seek backward into consumed data.
|
||||
|
||||
const RADIO_BUF_MAX: usize = 256 * 1024;
|
||||
|
||||
pub(crate) struct RadioInner {
|
||||
data: VecDeque<u8>,
|
||||
eof: bool,
|
||||
pos: u64,
|
||||
}
|
||||
|
||||
// The read-side: given to symphonia / rodio.
|
||||
struct RadioBuffer {
|
||||
inner: Arc<(Mutex<RadioInner>, Condvar)>,
|
||||
}
|
||||
|
||||
// The write-side: held by the async download task.
|
||||
pub struct RadioFeed {
|
||||
pub inner: Arc<(Mutex<RadioInner>, Condvar)>,
|
||||
}
|
||||
|
||||
impl RadioBuffer {
|
||||
fn new() -> (RadioBuffer, RadioFeed) {
|
||||
let arc = Arc::new((
|
||||
Mutex::new(RadioInner { data: VecDeque::new(), eof: false, pos: 0 }),
|
||||
Condvar::new(),
|
||||
));
|
||||
(RadioBuffer { inner: arc.clone() }, RadioFeed { inner: arc })
|
||||
}
|
||||
}
|
||||
|
||||
impl RadioFeed {
|
||||
pub fn push(&self, chunk: &[u8]) {
|
||||
let (lock, cvar) = &*self.inner;
|
||||
let mut g = lock.lock().unwrap();
|
||||
g.data.extend(chunk.iter().copied());
|
||||
cvar.notify_one();
|
||||
}
|
||||
|
||||
pub fn is_full(&self) -> bool {
|
||||
let (lock, _) = &*self.inner;
|
||||
lock.lock().unwrap().data.len() >= RADIO_BUF_MAX
|
||||
}
|
||||
|
||||
pub fn flush(&self) {
|
||||
let (lock, _) = &*self.inner;
|
||||
lock.lock().unwrap().data.clear();
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let (lock, cvar) = &*self.inner;
|
||||
let mut g = lock.lock().unwrap();
|
||||
g.eof = true;
|
||||
cvar.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for RadioBuffer {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let (lock, cvar) = &*self.inner;
|
||||
let mut g = lock.lock().unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while g.data.is_empty() && !g.eof {
|
||||
let rem = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if rem.is_zero() {
|
||||
eprintln!("[radio] RadioBuffer::read() timed out — no data for 10 s");
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "radio: no data after 10 s"));
|
||||
}
|
||||
let (new_g, _) = cvar.wait_timeout(g, rem).unwrap();
|
||||
g = new_g;
|
||||
}
|
||||
if g.data.is_empty() {
|
||||
eprintln!("[radio] RadioBuffer::read() → EOF (eof flag set, buffer empty)");
|
||||
return Ok(0); // EOF
|
||||
}
|
||||
let n = buf.len().min(g.data.len());
|
||||
for (i, b) in g.data.drain(..n).enumerate() {
|
||||
buf[i] = b;
|
||||
}
|
||||
g.pos += n as u64;
|
||||
// Notify downloader that buffer has drained below the cap.
|
||||
cvar.notify_one();
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for RadioBuffer {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
// Live streams cannot seek. Symphonia will not try because
|
||||
// is_seekable() = false; the only call it makes is SeekFrom::Current(0)
|
||||
// (tell) which we handle.
|
||||
let (lock, _) = &*self.inner;
|
||||
let g = lock.lock().unwrap();
|
||||
match pos {
|
||||
SeekFrom::Current(0) => Ok(g.pos),
|
||||
_ => Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "radio stream: not seekable")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for RadioBuffer {
|
||||
fn is_seekable(&self) -> bool { false }
|
||||
fn byte_len(&self) -> Option<u64> { 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
|
||||
// prevents the FLAC demuxer from seeking (it validates seek offsets against
|
||||
@@ -552,6 +671,50 @@ 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 = symphonia::default::get_codecs()
|
||||
.make(&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 +928,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 +988,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 +1034,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>,
|
||||
@@ -979,6 +1144,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())),
|
||||
@@ -1041,7 +1207,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))
|
||||
}
|
||||
|
||||
@@ -1074,6 +1250,7 @@ pub async fn audio_play(
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -1137,7 +1314,8 @@ pub async fn audio_play(
|
||||
|
||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
||||
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed);
|
||||
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
|
||||
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
|
||||
|
||||
// Measure how much audio Track A actually has left right now.
|
||||
@@ -1179,6 +1357,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(),
|
||||
@@ -1348,7 +1527,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
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1374,6 +1562,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(),
|
||||
@@ -1459,6 +1648,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.
|
||||
@@ -1661,9 +1859,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 GraphicEQ profile for a specific headphone from GitHub raw content.
|
||||
///
|
||||
/// Directory layout in the AutoEQ repo:
|
||||
/// results/{source}/{form}/{name}/{name} GraphicEQ.txt (most sources)
|
||||
/// results/{source}/{rig} {form}/{name}/{name} GraphicEQ.txt (crinacle — rig-prefixed dir)
|
||||
///
|
||||
/// 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!("{} GraphicEQ.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!("GraphicEQ 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);
|
||||
}
|
||||
@@ -1695,6 +1940,200 @@ pub async fn audio_preload(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Play a live internet radio stream.
|
||||
///
|
||||
/// Unlike `audio_play`, the stream URL is infinite so bytes cannot be
|
||||
/// downloaded upfront. A `RadioBuffer` bridges the async HTTP download into
|
||||
/// the synchronous Read interface that symphonia/rodio expect. Emits
|
||||
/// `audio:playing` with `duration = 0.0` to signal "unknown duration" to JS.
|
||||
#[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;
|
||||
|
||||
// Cancel pending chain and fading-out sink immediately so audio stops.
|
||||
*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 the HTTP stream.
|
||||
let response = state.http_client
|
||||
.get(&url)
|
||||
.header("Icy-MetaData", "0") // opt-out of Shoutcast inline metadata
|
||||
.send().await
|
||||
.map_err(|e| { let m = format!("radio: connection failed: {e}"); app.emit("audio:error", &m).ok(); m })?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let m = format!("radio: HTTP {}", response.status());
|
||||
app.emit("audio:error", &m).ok();
|
||||
return Err(m);
|
||||
}
|
||||
|
||||
// Derive a format hint from Content-Type so symphonia probes faster.
|
||||
let ct = response.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let fmt_hint: Option<String> = if ct.contains("mpeg") || ct.contains("mp3") {
|
||||
Some("mp3".into())
|
||||
} else if ct.contains("aac") || ct.contains("aacp") {
|
||||
Some("aac".into())
|
||||
} else if ct.contains("ogg") {
|
||||
Some("ogg".into())
|
||||
} else if ct.contains("flac") {
|
||||
Some("flac".into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (radio_buf, feed) = RadioBuffer::new();
|
||||
let feed = Arc::new(feed);
|
||||
|
||||
// Background task: stream HTTP chunks into RadioBuffer with auto-reconnect.
|
||||
// Many radio CDNs (Shoutcast/Icecast) close the TCP connection periodically.
|
||||
// Rather than stopping, we reconnect to the same URL and keep pushing into
|
||||
// the same RadioBuffer so the decoder recovers with only a brief glitch.
|
||||
{
|
||||
let gen_arc = state.generation.clone();
|
||||
let feed2 = feed.clone();
|
||||
let http_client2 = state.http_client.clone();
|
||||
let url2 = url.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut bytes_total: u64 = 0;
|
||||
let mut reconnects: u32 = 0;
|
||||
// Use the already-open response for the first connection; reconnect as needed.
|
||||
let mut response_opt: Option<reqwest::Response> = Some(response);
|
||||
|
||||
'outer: loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
eprintln!("[radio] download: gen mismatch → exit ({bytes_total} bytes, {reconnects} reconnects)");
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
let resp = match response_opt.take() {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { break 'outer; }
|
||||
match http_client2.get(&url2).header("Icy-MetaData", "0").send().await {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
reconnects += 1;
|
||||
eprintln!("[radio] reconnected #{reconnects} ({bytes_total} bytes so far)");
|
||||
feed2.flush(); // clear stale buffer so decoder gets a clean stream start
|
||||
r
|
||||
},
|
||||
Ok(r) => { eprintln!("[radio] reconnect failed: HTTP {} — giving up", r.status()); break 'outer; },
|
||||
Err(e) => { eprintln!("[radio] reconnect error: {e} — giving up"); break 'outer; },
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { break 'outer; }
|
||||
if feed2.is_full() {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
continue;
|
||||
}
|
||||
match stream.next().await {
|
||||
Some(Ok(chunk)) => {
|
||||
bytes_total += chunk.len() as u64;
|
||||
feed2.push(&chunk);
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
eprintln!("[radio] stream error: {e} → reconnecting (attempt {})", reconnects + 1);
|
||||
break; // triggers outer reconnect
|
||||
},
|
||||
None => {
|
||||
eprintln!("[radio] stream ended cleanly → reconnecting (attempt {})", reconnects + 1);
|
||||
break;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[radio] download task exiting, closing feed");
|
||||
feed2.close();
|
||||
});
|
||||
}
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
// Build symphonia decoder in a blocking thread (RadioBuffer::read blocks).
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(radio_buf), fmt_hint.as_deref())
|
||||
}).await.map_err(|e| e.to_string())??;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
state.samples_played.store(0, Ordering::Relaxed);
|
||||
|
||||
// Radio: no gapless trim, no ReplayGain, 5 ms micro-fade to suppress click.
|
||||
let dyn_src = DynSource::new(decoder.convert_samples::<f32>());
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(), state.eq_enabled.clone(), state.eq_pre_gain.clone());
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
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())?;
|
||||
let effective_vol = (volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_vol);
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/// Discord Rich Presence integration.
|
||||
///
|
||||
/// To enable this feature:
|
||||
/// 1. Go to https://discord.com/developers/applications and create an application.
|
||||
/// 2. Copy the Application ID and replace DISCORD_APP_ID below.
|
||||
/// 3. In the "Rich Presence → Art Assets" tab, upload a PNG named "psysonic"
|
||||
/// (use the app icon from public/logo.png).
|
||||
///
|
||||
/// The commands silently no-op when Discord is not running or the App ID is wrong,
|
||||
/// so the app always starts cleanly regardless of Discord availability.
|
||||
|
||||
use discord_rich_presence::{
|
||||
activity::{Activity, ActivityType, Assets, Timestamps},
|
||||
DiscordIpc, DiscordIpcClient,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const DISCORD_APP_ID: &str = "1489544859718258779";
|
||||
|
||||
pub struct DiscordState(pub Mutex<Option<DiscordIpcClient>>);
|
||||
|
||||
impl DiscordState {
|
||||
pub fn new() -> Self {
|
||||
DiscordState(Mutex::new(None))
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to create and connect a fresh IPC client. Returns None silently on failure.
|
||||
fn try_connect() -> Option<DiscordIpcClient> {
|
||||
let mut client = DiscordIpcClient::new(DISCORD_APP_ID).ok()?;
|
||||
client.connect().ok()?;
|
||||
Some(client)
|
||||
}
|
||||
|
||||
/// Update the Discord Rich Presence activity.
|
||||
///
|
||||
/// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows
|
||||
/// the song/artist without a running timer.
|
||||
#[tauri::command]
|
||||
pub fn discord_update_presence(
|
||||
state: tauri::State<DiscordState>,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: Option<String>,
|
||||
elapsed_secs: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
|
||||
// (Re)connect lazily — handles the case where Discord starts after the app.
|
||||
if guard.is_none() {
|
||||
match try_connect() {
|
||||
Some(client) => *guard = Some(client),
|
||||
None => return Ok(()), // Discord not running — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
let client = guard.as_mut().unwrap();
|
||||
|
||||
// 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()
|
||||
.large_image("psysonic")
|
||||
.large_text(large_text);
|
||||
|
||||
let mut activity = Activity::new()
|
||||
.activity_type(ActivityType::Listening)
|
||||
.details(&title)
|
||||
.state(&artist)
|
||||
.assets(assets);
|
||||
|
||||
// Start timestamp: Discord auto-counts up from this point. We back-calculate
|
||||
// it so the displayed elapsed time matches the actual playback position.
|
||||
if let Some(elapsed) = elapsed_secs {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let start = now - elapsed.floor() as i64;
|
||||
activity = activity.timestamps(Timestamps::new().start(start));
|
||||
}
|
||||
|
||||
if client.set_activity(activity).is_err() {
|
||||
// IPC pipe broke (Discord restarted etc.) — drop the client so the next
|
||||
// call re-connects.
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
|
||||
#[tauri::command]
|
||||
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(client) = guard.as_mut() {
|
||||
if client.clear_activity().is_err() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod audio;
|
||||
mod discord;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
@@ -340,6 +341,7 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(audio_engine)
|
||||
.manage(ShortcutMap::default())
|
||||
.manage(discord::DiscordState::new())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
@@ -534,10 +536,15 @@ 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,
|
||||
download_track_offline,
|
||||
delete_offline_track,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.29.0",
|
||||
"version": "1.31.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+25
-1
@@ -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>
|
||||
|
||||
+53
-4
@@ -88,6 +88,14 @@ export interface SubsonicSong {
|
||||
};
|
||||
}
|
||||
|
||||
export interface InternetRadioStation {
|
||||
id: string;
|
||||
name: string;
|
||||
streamUrl: string;
|
||||
homepageUrl?: string;
|
||||
coverArt?: string; // Navidrome v0.61.0+
|
||||
}
|
||||
|
||||
export interface SubsonicPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -337,7 +345,6 @@ export function buildStreamUrl(id: string): string {
|
||||
u: server?.username ?? '',
|
||||
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
|
||||
});
|
||||
|
||||
return `${baseUrl}/rest/stream.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
@@ -396,9 +403,18 @@ export async function createPlaylist(name: string, songIds?: string[]): Promise<
|
||||
return data.playlist;
|
||||
}
|
||||
|
||||
export async function updatePlaylist(id: string, songIds: string[]): Promise<void> {
|
||||
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
|
||||
await api('createPlaylist.view', { playlistId: id, songId: songIds });
|
||||
export async function updatePlaylist(id: string, songIds: string[], prevCount = 0): Promise<void> {
|
||||
if (songIds.length > 0) {
|
||||
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
|
||||
await api('createPlaylist.view', { playlistId: id, songId: songIds });
|
||||
} else if (prevCount > 0) {
|
||||
// Axios serialises empty arrays as no params — createPlaylist.view would leave songs unchanged.
|
||||
// Use updatePlaylist.view with explicit index removal to clear the list instead.
|
||||
await api('updatePlaylist.view', {
|
||||
playlistId: id,
|
||||
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePlaylist(id: string): Promise<void> {
|
||||
@@ -434,3 +450,36 @@ 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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
@@ -143,8 +145,8 @@ export default function AlbumHeader({
|
||||
<button
|
||||
className="album-detail-cover-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
data-tooltip="Vergrößern"
|
||||
aria-label={`${info.name} Cover vergrößern`}
|
||||
data-tooltip={t('albumDetail.enlargeCover')}
|
||||
aria-label={`${info.name} ${t('albumDetail.enlargeCover')}`}
|
||||
>
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
</button>
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Play, Heart, ListPlus, X } from 'lucide-react';
|
||||
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
|
||||
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 +45,89 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true, fixed: true },
|
||||
{ key: 'title', i18nKey: 'trackTitle', minWidth: 100, defaultWidth: 220, required: true, fixed: false },
|
||||
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 160, required: false, fixed: false },
|
||||
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false, fixed: false },
|
||||
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 100, required: false, fixed: false },
|
||||
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 60, required: false, fixed: false },
|
||||
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false, fixed: false },
|
||||
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 80, required: false, fixed: false },
|
||||
] as const;
|
||||
|
||||
type ColKey = (typeof COLUMNS)[number]['key'];
|
||||
|
||||
const DEFAULT_WIDTHS: Record<ColKey, number> = Object.fromEntries(
|
||||
COLUMNS.map(c => [c.key, c.defaultWidth])
|
||||
) as Record<ColKey, number>;
|
||||
|
||||
const DEFAULT_VISIBLE = new Set<ColKey>([
|
||||
'num', 'title', 'artist', 'favorite', 'rating', 'duration', 'format', 'genre',
|
||||
]);
|
||||
|
||||
function loadColPrefs(): { widths: Record<ColKey, number>; visible: Set<ColKey> } {
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic_tracklist_columns');
|
||||
if (!raw) return { widths: { ...DEFAULT_WIDTHS }, visible: new Set(DEFAULT_VISIBLE) };
|
||||
const parsed = JSON.parse(raw);
|
||||
const visible = new Set<ColKey>((parsed.visible as ColKey[]) ?? [...DEFAULT_VISIBLE]);
|
||||
COLUMNS.filter(c => c.required).forEach(c => visible.add(c.key as ColKey));
|
||||
return {
|
||||
widths: { ...DEFAULT_WIDTHS, ...(parsed.widths ?? {}) },
|
||||
visible,
|
||||
};
|
||||
} catch {
|
||||
return { widths: { ...DEFAULT_WIDTHS }, visible: new Set(DEFAULT_VISIBLE) };
|
||||
}
|
||||
}
|
||||
|
||||
function saveColPrefs(widths: Record<ColKey, number>, visible: Set<ColKey>) {
|
||||
localStorage.setItem('psysonic_tracklist_columns', JSON.stringify({
|
||||
widths,
|
||||
visible: [...visible],
|
||||
}));
|
||||
}
|
||||
|
||||
/** Scale flexible (non-fixed) visible columns proportionally to fill `targetW` exactly.
|
||||
* Fixed columns (e.g. 'num') keep their width unchanged.
|
||||
* Each flexible column is clamped to its minWidth; rounding error is absorbed by 'title'. */
|
||||
function fitColumnsToWidth(
|
||||
widths: Record<ColKey, number>,
|
||||
vCols: readonly { readonly key: string; readonly minWidth: number; readonly fixed: boolean }[],
|
||||
targetW: number,
|
||||
gapPx: number
|
||||
): Record<ColKey, number> {
|
||||
if (vCols.length === 0 || targetW <= 0) return widths;
|
||||
const next = { ...widths };
|
||||
const fixedCols = vCols.filter(c => c.fixed);
|
||||
const flexCols = vCols.filter(c => !c.fixed);
|
||||
if (flexCols.length === 0) return next;
|
||||
const totalGaps = Math.max(0, vCols.length - 1) * gapPx;
|
||||
const fixedTotal = fixedCols.reduce((s, c) => s + (next[c.key as ColKey] ?? c.minWidth), 0);
|
||||
const available = targetW - totalGaps - fixedTotal;
|
||||
if (available <= 0) return next;
|
||||
const currentFlexTotal = flexCols.reduce((s, c) => s + (next[c.key as ColKey] ?? c.minWidth), 0);
|
||||
if (currentFlexTotal === 0) return next;
|
||||
const ratio = available / currentFlexTotal;
|
||||
flexCols.forEach(c => {
|
||||
const key = c.key as ColKey;
|
||||
next[key] = Math.max(c.minWidth, Math.round((next[key] ?? c.minWidth) * ratio));
|
||||
});
|
||||
// Correct rounding drift in 'title' column
|
||||
const newFlexTotal = flexCols.reduce((s, c) => s + next[c.key as ColKey], 0);
|
||||
const diff = available - newFlexTotal;
|
||||
if (flexCols.some(c => c.key === 'title') && diff !== 0) {
|
||||
const titleDef = COLUMNS.find(c => c.key === 'title')!;
|
||||
next['title'] = Math.max(titleDef.minWidth, next['title'] + diff);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AlbumTrackListProps {
|
||||
songs: SubsonicSong[];
|
||||
hasVariousArtists: boolean;
|
||||
@@ -68,15 +154,153 @@ 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 ──────────────────────────────────────────────────────────
|
||||
const [colWidths, setColWidths] = useState<Record<ColKey, number>>(() => loadColPrefs().widths);
|
||||
const [colVisible, setColVisible] = useState<Set<ColKey>>(() => loadColPrefs().visible);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const tracklistRef = useRef<HTMLDivElement>(null);
|
||||
const prevContainerW = useRef(0);
|
||||
const colVisibleRef = useRef(colVisible);
|
||||
useEffect(() => { colVisibleRef.current = colVisible; }, [colVisible]);
|
||||
|
||||
// Stores the user's last intentional column widths + the container W they match.
|
||||
// ResizeObserver always scales FROM this base — never from intermediate scaled values.
|
||||
// This prevents drift when the window is shrunk and enlarged again.
|
||||
const baseWidthsRef = useRef<{ widths: Record<ColKey, number>; containerW: number } | null>(null);
|
||||
// Tracks current colWidths without a useEffect dependency in callbacks
|
||||
const colWidthsRef = useRef(colWidths);
|
||||
useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
|
||||
|
||||
// On mount: fit saved (or default) widths to current container; establish base.
|
||||
useLayoutEffect(() => {
|
||||
const el = tracklistRef.current;
|
||||
if (!el) return;
|
||||
const style = getComputedStyle(el);
|
||||
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const containerW = el.clientWidth - paddingH;
|
||||
prevContainerW.current = containerW;
|
||||
const vCols = COLUMNS.filter(c => colVisibleRef.current.has(c.key));
|
||||
setColWidths(prev => {
|
||||
const fitted = fitColumnsToWidth(prev, vCols, containerW, 12);
|
||||
baseWidthsRef.current = { widths: fitted, containerW };
|
||||
return fitted;
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// When the container resizes, scale all columns proportionally FROM the base.
|
||||
// Using the base (not prev) means shrink → grow always returns to exact original widths.
|
||||
useEffect(() => {
|
||||
const el = tracklistRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const style = getComputedStyle(el);
|
||||
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const newW = el.clientWidth - paddingH;
|
||||
if (Math.abs(newW - prevContainerW.current) < 2) return;
|
||||
prevContainerW.current = newW;
|
||||
const base = baseWidthsRef.current;
|
||||
if (!base) return;
|
||||
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
|
||||
const gapPx = headerEl ? (parseFloat(getComputedStyle(headerEl).columnGap) || 12) : 12;
|
||||
const vCols = COLUMNS.filter(c => colVisibleRef.current.has(c.key));
|
||||
// Always scale from base.widths, never from current state → no drift
|
||||
setColWidths(() => fitColumnsToWidth(base.widths, vCols, newW, gapPx));
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// All visible columns in order
|
||||
const visibleCols = useMemo(
|
||||
() => COLUMNS.filter(c => colVisible.has(c.key)),
|
||||
[colVisible]
|
||||
);
|
||||
|
||||
// Grid template: all fixed px — bidirectional resize works correctly
|
||||
const gridTemplate = useMemo(
|
||||
() => visibleCols.map(c => `${colWidths[c.key]}px`).join(' '),
|
||||
[colWidths, visibleCols]
|
||||
);
|
||||
|
||||
const colStyle = { gridTemplateColumns: gridTemplate };
|
||||
|
||||
|
||||
// ── Bidirectional resize ─────────────────────────────────────────────────
|
||||
// Dragging the divider between col[colIndex] and col[colIndex+1]:
|
||||
// → right: colA grows, colB shrinks (clamped to minWidth)
|
||||
// → left: colA shrinks, colB grows (clamped to minWidth)
|
||||
// Excel-style resize: only the dragged column changes width.
|
||||
// Clamped so total never exceeds container width — no overflow, no scrollbar.
|
||||
const startResize = (e: React.MouseEvent, colIndex: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const colA = visibleCols[colIndex];
|
||||
const defA = COLUMNS.find(c => c.key === colA.key)!;
|
||||
const startX = e.clientX;
|
||||
const startW = colWidths[colA.key as ColKey];
|
||||
const snapshotVisible = colVisible;
|
||||
|
||||
// Measure container once at drag start
|
||||
let maxW = Infinity;
|
||||
const el = tracklistRef.current;
|
||||
if (el) {
|
||||
const style = getComputedStyle(el);
|
||||
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const containerW = el.clientWidth - paddingH;
|
||||
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
|
||||
const gapPx = headerEl ? (parseFloat(getComputedStyle(headerEl).columnGap) || 0) : 12;
|
||||
const sumOthers = visibleCols
|
||||
.filter((_, i) => i !== colIndex)
|
||||
.reduce((s, c) => s + colWidths[c.key as ColKey], 0);
|
||||
maxW = Math.max(defA.minWidth, containerW - sumOthers - (visibleCols.length - 1) * gapPx);
|
||||
}
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
const newW = Math.min(Math.max(defA.minWidth, startW + me.clientX - startX), maxW);
|
||||
setColWidths(prev => ({ ...prev, [colA.key]: newW }));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
// Save final state and update base so future window resizes scale from here
|
||||
const finalWidths = colWidthsRef.current;
|
||||
baseWidthsRef.current = { widths: { ...finalWidths }, containerW: prevContainerW.current };
|
||||
saveColPrefs(finalWidths, snapshotVisible);
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
const toggleColumn = (key: ColKey) => {
|
||||
const def = COLUMNS.find(c => c.key === key)!;
|
||||
if (def.required) return;
|
||||
setColVisible(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
saveColPrefs(colWidths, next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -99,7 +323,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,6 +333,15 @@ export default function AlbumTrackList({
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showPlPicker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [pickerOpen]);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
|
||||
const discs = new Map<number, SubsonicSong[]>();
|
||||
@@ -123,8 +355,137 @@ export default function AlbumTrackList({
|
||||
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
|
||||
// ── Header cell renderer ──────────────────────────────────────────────────
|
||||
const renderHeaderCell = (colDef: (typeof COLUMNS)[number], colIndex: number) => {
|
||||
const key = colDef.key as ColKey;
|
||||
const isLastCol = colIndex === visibleCols.length - 1;
|
||||
const isCentered = key === 'favorite' || key === 'rating' || key === 'duration';
|
||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
|
||||
|
||||
// 'num' header mirrors the row-cell layout exactly so checkbox + # stay aligned
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={isCentered ? 'col-center' : undefined}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</span>
|
||||
{/* Resize handle on all non-fixed columns except the last */}
|
||||
{!isLastCol && !colDef.fixed && (
|
||||
<div
|
||||
className="col-resize-handle"
|
||||
onMouseDown={e => startResize(e, colIndex)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 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 +519,47 @@ 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={colStyle}>
|
||||
{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 key = c.key as ColKey;
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : key;
|
||||
const isOn = colVisible.has(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
|
||||
onClick={() => toggleColumn(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 +574,7 @@ export default function AlbumTrackList({
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
style={colStyle}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (inSelectMode) {
|
||||
@@ -216,59 +605,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,9 +123,15 @@ export default function AppUpdater() {
|
||||
{state.phase === 'available' && (
|
||||
<div className="app-updater-actions">
|
||||
{canInstall && (
|
||||
<button className="app-updater-btn-primary" onClick={handleInstall}>
|
||||
<Download size={12} /> {t('common.updaterInstall')}
|
||||
</button>
|
||||
<>
|
||||
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
|
||||
<button className="app-updater-btn-primary" onClick={handleInstall}>
|
||||
<Download size={12} /> {t('common.updaterInstall')}
|
||||
</button>
|
||||
<button className="app-updater-btn-secondary" onClick={handleDownload}>
|
||||
<Download size={12} /> {t('common.updaterDownload')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isLinuxFallback && (
|
||||
<button className="app-updater-btn-primary" onClick={handleDownload}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { getCachedUrl } from '../utils/imageCache';
|
||||
|
||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
@@ -16,16 +16,33 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
||||
const [resolved, setResolved] = useState('');
|
||||
useEffect(() => {
|
||||
if (!fetchUrl) { setResolved(''); return; }
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setResolved('');
|
||||
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
|
||||
return () => { cancelled = true; };
|
||||
getCachedUrl(fetchUrl, cacheKey, controller.signal).then(url => {
|
||||
if (!controller.signal.aborted) setResolved(url);
|
||||
});
|
||||
return () => { controller.abort(); };
|
||||
}, [fetchUrl, cacheKey]);
|
||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||
}
|
||||
|
||||
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
|
||||
const resolvedSrc = useCachedUrl(src, cacheKey);
|
||||
const [inView, setInView] = useState(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = imgRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } },
|
||||
{ rootMargin: '300px' }, // start fetching 300px before entering viewport
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Pass empty string when not yet in view so useCachedUrl skips the fetch entirely.
|
||||
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
|
||||
@@ -36,7 +53,8 @@ export default function CachedImage({ src, cacheKey, style, onLoad, ...props }:
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
ref={imgRef}
|
||||
src={resolvedSrc || undefined}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
|
||||
onLoad={e => { setLoaded(true); onLoad?.(e); }}
|
||||
{...props}
|
||||
|
||||
@@ -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 = Math.round(pctToGain(pct) / 0.1) * 0.1; // snap to 0.1 dB
|
||||
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
|
||||
}, [onChange]);
|
||||
|
||||
@@ -182,20 +183,63 @@ 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; }
|
||||
|
||||
|
||||
function parseGraphicEqString(graphicEqStr: string): number[] {
|
||||
const line = graphicEqStr.replace(/^GraphicEQ:\s*/i, '');
|
||||
const points: [number, number][] = line
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.map(s => { const [f, g] = s.split(/\s+/).map(Number); return [f, g] as [number, number]; })
|
||||
.filter(([f, g]) => !isNaN(f) && !isNaN(g));
|
||||
|
||||
if (points.length === 0) return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
||||
return [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].map(targetFreq => {
|
||||
if (targetFreq <= points[0][0]) return points[0][1];
|
||||
if (targetFreq >= points[points.length - 1][0]) return points[points.length - 1][1];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
if (points[i][0] <= targetFreq && points[i + 1][0] >= targetFreq) {
|
||||
const lo = points[i], hi = points[i + 1];
|
||||
if (lo[0] === hi[0]) return lo[1];
|
||||
const t = (Math.log10(targetFreq) - Math.log10(lo[0])) / (Math.log10(hi[0]) - Math.log10(lo[0]));
|
||||
return Math.round((lo[1] + t * (hi[1] - lo[1])) / 0.1) * 0.1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 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 +260,63 @@ 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 newGains = parseGraphicEqString(text);
|
||||
// autoeq.app normalizes gains (preamp baked in) — apply with 0 pre-gain
|
||||
applyAutoEq(result.name, newGains, 0);
|
||||
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 +380,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 +482,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>
|
||||
);
|
||||
|
||||
@@ -245,11 +245,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop">
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
|
||||
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
|
||||
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -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,13 @@ export default function PlayerBar() {
|
||||
}, [currentTrack, isStarred, setStarredOverride]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// Cover art: prefer radio station art, fall back to track art.
|
||||
const radioCoverSrc = useMemo(
|
||||
() => currentRadio?.coverArt ? buildCoverArtUrl(currentRadio.coverArt, 128) : '',
|
||||
[currentRadio?.coverArt]
|
||||
);
|
||||
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(currentRadio.coverArt, 128) : '';
|
||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
|
||||
|
||||
@@ -80,11 +89,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 +118,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 +126,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 +149,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 +167,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 +178,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 +192,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 */}
|
||||
|
||||
@@ -371,10 +371,9 @@ export default function QueuePanel() {
|
||||
|
||||
{currentTrack && (
|
||||
<div className="queue-current-track">
|
||||
{(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate || currentTrack.samplingRate || currentTrack.bitDepth) && (
|
||||
{(currentTrack.suffix || currentTrack.bitRate || currentTrack.samplingRate || currentTrack.bitDepth) && (
|
||||
<div className="queue-current-tech">
|
||||
{[
|
||||
currentTrack.genre,
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
|
||||
(() => {
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
+231
@@ -26,6 +26,7 @@ const enTranslation = {
|
||||
offlineLibrary: 'Offline Library',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
radio: 'Internet Radio',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
@@ -131,15 +132,18 @@ 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',
|
||||
ratingLabel: 'Rating',
|
||||
enlargeCover: 'Enlarge',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Back',
|
||||
@@ -165,6 +169,9 @@ const enTranslation = {
|
||||
openedInBrowser: 'Opened in browser',
|
||||
featuredOn: 'Also Featured On',
|
||||
similarArtists: 'Similar Artists',
|
||||
cacheOffline: 'Save discography offline',
|
||||
offlineCached: 'Discography cached',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorites',
|
||||
@@ -237,6 +244,8 @@ const enTranslation = {
|
||||
all: 'All',
|
||||
gridView: 'Grid view',
|
||||
listView: 'List view',
|
||||
imagesOn: 'Artist images on — may increase network and system load',
|
||||
imagesOff: 'Artist images off — showing initials only',
|
||||
loadMore: 'Load more',
|
||||
notFound: 'No artists found.',
|
||||
albumCount_one: '{{count}} Album',
|
||||
@@ -275,6 +284,10 @@ const enTranslation = {
|
||||
offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'All',
|
||||
offlineFilterAlbums: 'Albums',
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Retry',
|
||||
lastfmConnected: 'Last.fm connected as @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalid — click to re-connect',
|
||||
@@ -313,6 +326,7 @@ const enTranslation = {
|
||||
updaterDownloading: 'Downloading…',
|
||||
updaterInstalling: 'Installing…',
|
||||
updaterDownload: 'Download from GitHub',
|
||||
updaterExperimentalHint: 'Auto-update is still in development',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -342,6 +356,7 @@ const enTranslation = {
|
||||
serverFailed: 'Connection failed.',
|
||||
testBtn: 'Test Connection',
|
||||
testingBtn: 'Testing…',
|
||||
serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Connected',
|
||||
failed: 'Failed',
|
||||
eqTitle: 'Equalizer',
|
||||
@@ -354,6 +369,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…',
|
||||
@@ -374,8 +398,12 @@ const enTranslation = {
|
||||
cacheClearWarning: 'This will also remove all offline albums from the library.',
|
||||
cacheClearConfirm: 'Clear Everything',
|
||||
cacheClearCancel: 'Cancel',
|
||||
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',
|
||||
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
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',
|
||||
@@ -672,6 +700,25 @@ const enTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'New suggestions',
|
||||
addSong: 'Add to playlist',
|
||||
cacheOffline: 'Cache playlist offline',
|
||||
offlineCached: 'Playlist cached',
|
||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
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',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -700,6 +747,7 @@ const deTranslation = {
|
||||
offlineLibrary: 'Offline-Bibliothek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
radio: 'Internetradio',
|
||||
},
|
||||
home: {
|
||||
hero: 'Featured',
|
||||
@@ -805,15 +853,18 @@ 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',
|
||||
ratingLabel: 'Bewertung',
|
||||
enlargeCover: 'Vergrößern',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Zurück',
|
||||
@@ -839,6 +890,9 @@ const deTranslation = {
|
||||
openedInBrowser: 'Im Browser geöffnet',
|
||||
featuredOn: 'Auch enthalten auf',
|
||||
similarArtists: 'Ähnliche Künstler',
|
||||
cacheOffline: 'Diskografie offline speichern',
|
||||
offlineCached: 'Diskografie gecacht',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoriten',
|
||||
@@ -911,6 +965,8 @@ const deTranslation = {
|
||||
all: 'Alle',
|
||||
gridView: 'Gitteransicht',
|
||||
listView: 'Listenansicht',
|
||||
imagesOn: 'Künstlerbilder aktiv — kann Netzwerk- und Systemlast erhöhen',
|
||||
imagesOff: 'Künstlerbilder deaktiviert — zeigt nur Initialen',
|
||||
loadMore: 'Mehr laden',
|
||||
notFound: 'Keine Künstler gefunden.',
|
||||
albumCount_one: '{{count}} Album',
|
||||
@@ -949,6 +1005,10 @@ const deTranslation = {
|
||||
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
|
||||
offlineAlbumCount: '{{n}} Album',
|
||||
offlineAlbumCount_plural: '{{n}} Alben',
|
||||
offlineFilterAll: 'Alle',
|
||||
offlineFilterAlbums: 'Alben',
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Diskografien',
|
||||
retry: 'Erneut versuchen',
|
||||
lastfmConnected: 'Last.fm verbunden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
|
||||
@@ -987,6 +1047,7 @@ const deTranslation = {
|
||||
updaterDownloading: 'Wird geladen…',
|
||||
updaterInstalling: 'Wird installiert…',
|
||||
updaterDownload: 'Von GitHub herunterladen',
|
||||
updaterExperimentalHint: 'Automatische Updates sind noch in Entwicklung',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
@@ -1016,6 +1077,7 @@ const deTranslation = {
|
||||
serverFailed: 'Verbindung fehlgeschlagen.',
|
||||
testBtn: 'Verbindung testen',
|
||||
testingBtn: 'Teste…',
|
||||
serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Verbunden',
|
||||
failed: 'Fehlgeschlagen',
|
||||
eqTitle: 'Equalizer',
|
||||
@@ -1028,6 +1090,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…',
|
||||
@@ -1048,8 +1119,12 @@ const deTranslation = {
|
||||
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
|
||||
cacheClearConfirm: 'Alles löschen',
|
||||
cacheClearCancel: 'Abbrechen',
|
||||
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',
|
||||
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
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',
|
||||
@@ -1346,6 +1421,25 @@ const deTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Neue Vorschläge',
|
||||
addSong: 'Zur Playlist hinzufügen',
|
||||
cacheOffline: 'Playlist offline speichern',
|
||||
offlineCached: 'Playlist gecacht',
|
||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||
},
|
||||
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',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1374,6 +1468,7 @@ const frTranslation = {
|
||||
offlineLibrary: 'Bibliothèque hors ligne',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
radio: 'Radio Internet',
|
||||
},
|
||||
home: {
|
||||
hero: 'En vedette',
|
||||
@@ -1479,15 +1574,18 @@ 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',
|
||||
ratingLabel: 'Note',
|
||||
enlargeCover: 'Agrandir',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Retour',
|
||||
@@ -1513,6 +1611,9 @@ const frTranslation = {
|
||||
openedInBrowser: 'Ouvert dans le navigateur',
|
||||
featuredOn: 'Également sur',
|
||||
similarArtists: 'Artistes similaires',
|
||||
cacheOffline: 'Enregistrer la discographie hors ligne',
|
||||
offlineCached: 'Discographie en cache',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoris',
|
||||
@@ -1585,6 +1686,8 @@ const frTranslation = {
|
||||
all: 'Tous',
|
||||
gridView: 'Vue en grille',
|
||||
listView: 'Vue en liste',
|
||||
imagesOn: 'Images d\'artistes activées — peut augmenter la charge réseau et système',
|
||||
imagesOff: 'Images d\'artistes désactivées — affichage des initiales uniquement',
|
||||
loadMore: 'Charger plus',
|
||||
notFound: 'Aucun artiste trouvé.',
|
||||
albumCount_one: '{{count}} album',
|
||||
@@ -1623,6 +1726,10 @@ const frTranslation = {
|
||||
offlineLibraryEmpty: 'Aucun album en cache. Connectez-vous, ouvrez un album et cliquez sur "Rendre disponible hors ligne".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'Tout',
|
||||
offlineFilterAlbums: 'Albums',
|
||||
offlineFilterPlaylists: 'Playlists',
|
||||
offlineFilterArtists: 'Discographies',
|
||||
retry: 'Réessayer',
|
||||
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
|
||||
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
|
||||
@@ -1661,6 +1768,7 @@ const frTranslation = {
|
||||
updaterDownloading: 'Téléchargement…',
|
||||
updaterInstalling: 'Installation…',
|
||||
updaterDownload: 'Télécharger depuis GitHub',
|
||||
updaterExperimentalHint: 'La mise à jour automatique est encore en développement',
|
||||
},
|
||||
settings: {
|
||||
title: 'Paramètres',
|
||||
@@ -1690,6 +1798,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',
|
||||
@@ -1702,6 +1811,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…',
|
||||
@@ -1722,8 +1840,12 @@ const frTranslation = {
|
||||
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
|
||||
cacheClearConfirm: 'Tout supprimer',
|
||||
cacheClearCancel: 'Annuler',
|
||||
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',
|
||||
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
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',
|
||||
@@ -2020,6 +2142,25 @@ const frTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nouvelles suggestions',
|
||||
addSong: 'Ajouter à la playlist',
|
||||
cacheOffline: 'Mettre la playlist hors ligne',
|
||||
offlineCached: 'Playlist en cache',
|
||||
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
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',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2048,6 +2189,7 @@ const nlTranslation = {
|
||||
offlineLibrary: 'Offline bibliotheek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
radio: 'Internetradio',
|
||||
},
|
||||
home: {
|
||||
hero: 'Uitgelicht',
|
||||
@@ -2153,15 +2295,18 @@ 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',
|
||||
ratingLabel: 'Beoordeling',
|
||||
enlargeCover: 'Vergroten',
|
||||
},
|
||||
artistDetail: {
|
||||
back: 'Terug',
|
||||
@@ -2187,6 +2332,9 @@ const nlTranslation = {
|
||||
openedInBrowser: 'Geopend in browser',
|
||||
featuredOn: 'Ook te vinden op',
|
||||
similarArtists: 'Vergelijkbare artiesten',
|
||||
cacheOffline: 'Discografie offline opslaan',
|
||||
offlineCached: 'Discografie gecached',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favorieten',
|
||||
@@ -2259,6 +2407,8 @@ const nlTranslation = {
|
||||
all: 'Alle',
|
||||
gridView: 'Rasterweergave',
|
||||
listView: 'Lijstweergave',
|
||||
imagesOn: 'Artiestafbeeldingen aan — kan netwerk- en systeembelasting verhogen',
|
||||
imagesOff: 'Artiestafbeeldingen uit — toont alleen initialen',
|
||||
loadMore: 'Meer laden',
|
||||
notFound: 'Geen artiesten gevonden.',
|
||||
albumCount_one: '{{count}} album',
|
||||
@@ -2297,6 +2447,10 @@ const nlTranslation = {
|
||||
offlineLibraryEmpty: 'Nog geen albums gecached. Ga online, open een album en klik op "Offline beschikbaar maken".',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'Alles',
|
||||
offlineFilterAlbums: 'Albums',
|
||||
offlineFilterPlaylists: 'Afspeellijsten',
|
||||
offlineFilterArtists: 'Discografieën',
|
||||
retry: 'Opnieuw proberen',
|
||||
lastfmConnected: 'Last.fm verbonden als @{{user}}',
|
||||
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
|
||||
@@ -2335,6 +2489,7 @@ const nlTranslation = {
|
||||
updaterDownloading: 'Downloaden…',
|
||||
updaterInstalling: 'Installeren…',
|
||||
updaterDownload: 'Downloaden van GitHub',
|
||||
updaterExperimentalHint: 'Automatisch bijwerken is nog in ontwikkeling',
|
||||
},
|
||||
settings: {
|
||||
title: 'Instellingen',
|
||||
@@ -2364,6 +2519,7 @@ const nlTranslation = {
|
||||
serverFailed: 'Verbinding mislukt.',
|
||||
testBtn: 'Verbinding testen',
|
||||
testingBtn: 'Testen…',
|
||||
serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: 'Verbonden',
|
||||
failed: 'Mislukt',
|
||||
eqTitle: 'Equalizer',
|
||||
@@ -2376,6 +2532,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…',
|
||||
@@ -2396,8 +2561,12 @@ const nlTranslation = {
|
||||
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
|
||||
cacheClearConfirm: 'Alles wissen',
|
||||
cacheClearCancel: 'Annuleren',
|
||||
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',
|
||||
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
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',
|
||||
@@ -2694,6 +2863,25 @@ const nlTranslation = {
|
||||
titleBadge: 'Playlist',
|
||||
refreshSuggestions: 'Nieuwe suggesties',
|
||||
addSong: 'Toevoegen aan playlist',
|
||||
cacheOffline: 'Playlist offline opslaan',
|
||||
offlineCached: 'Playlist gecached',
|
||||
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
|
||||
},
|
||||
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',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2722,6 +2910,7 @@ const zhTranslation = {
|
||||
offlineLibrary: '离线音乐库',
|
||||
genres: '流派',
|
||||
playlists: '播放列表',
|
||||
radio: '网络电台',
|
||||
},
|
||||
home: {
|
||||
hero: '精选',
|
||||
@@ -2827,15 +3016,18 @@ const zhTranslation = {
|
||||
moreLabelAlbums: '{{label}} 的更多专辑',
|
||||
trackTitle: '标题',
|
||||
trackArtist: '艺术家',
|
||||
trackGenre: '流派',
|
||||
trackFormat: '格式',
|
||||
trackFavorite: '收藏',
|
||||
trackRating: '评分',
|
||||
trackDuration: '时长',
|
||||
trackTotal: '总计',
|
||||
columns: '列',
|
||||
notFound: '未找到专辑。',
|
||||
bioModal: '艺术家简介',
|
||||
bioClose: '关闭',
|
||||
ratingLabel: '评分',
|
||||
enlargeCover: '放大',
|
||||
},
|
||||
artistDetail: {
|
||||
back: '返回',
|
||||
@@ -2861,6 +3053,9 @@ const zhTranslation = {
|
||||
openedInBrowser: '已在浏览器中打开',
|
||||
featuredOn: '还出现在',
|
||||
similarArtists: '相似艺术家',
|
||||
cacheOffline: '离线保存全部专辑',
|
||||
offlineCached: '全部专辑已缓存',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
},
|
||||
favorites: {
|
||||
title: '收藏夹',
|
||||
@@ -2933,6 +3128,8 @@ const zhTranslation = {
|
||||
all: '全部',
|
||||
gridView: '网格视图',
|
||||
listView: '列表视图',
|
||||
imagesOn: '艺术家图片已开启 — 可能增加网络和系统负载',
|
||||
imagesOff: '艺术家图片已关闭 — 仅显示首字母',
|
||||
loadMore: '加载更多',
|
||||
notFound: '未找到艺术家。',
|
||||
albumCount_one: '{{count}} 张专辑',
|
||||
@@ -3009,6 +3206,7 @@ const zhTranslation = {
|
||||
updaterDownloading: '下载中…',
|
||||
updaterInstalling: '安装中…',
|
||||
updaterDownload: '从 GitHub 下载',
|
||||
updaterExperimentalHint: '自动更新功能仍在开发中',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
@@ -3038,6 +3236,7 @@ const zhTranslation = {
|
||||
serverFailed: '连接失败。',
|
||||
testBtn: '测试连接',
|
||||
testingBtn: '正在测试…',
|
||||
serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic',
|
||||
connected: '已连接',
|
||||
failed: '失败',
|
||||
eqTitle: '均衡器',
|
||||
@@ -3050,6 +3249,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: '等待授权…',
|
||||
@@ -3070,8 +3278,12 @@ const zhTranslation = {
|
||||
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
|
||||
cacheClearConfirm: '全部清除',
|
||||
cacheClearCancel: '取消',
|
||||
showArtistImages: '显示艺术家图片',
|
||||
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
|
||||
minimizeToTray: '最小化到托盘',
|
||||
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
||||
discordRichPresence: 'Discord Rich Presence',
|
||||
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
downloadsTitle: '下载文件夹',
|
||||
@@ -3368,6 +3580,25 @@ const zhTranslation = {
|
||||
titleBadge: '播放列表',
|
||||
refreshSuggestions: '新建议',
|
||||
addSong: '添加到播放列表',
|
||||
cacheOffline: '离线缓存播放列表',
|
||||
offlineCached: '播放列表已缓存',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
},
|
||||
radio: {
|
||||
title: '网络电台',
|
||||
empty: '未配置任何电台。',
|
||||
addStation: '添加电台',
|
||||
editStation: '编辑',
|
||||
deleteStation: '删除电台',
|
||||
confirmDelete: '再次点击确认',
|
||||
stationName: '电台名称…',
|
||||
streamUrl: '流地址…',
|
||||
homepageUrl: '主页地址(可选)',
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
live: '直播',
|
||||
liveStream: '网络电台',
|
||||
openHomepage: '打开主页',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)}
|
||||
>
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function AlbumDetail() {
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
@@ -201,12 +202,14 @@ const handleEnqueueAll = () => {
|
||||
const next = new Set(starredSongs);
|
||||
if (wasStarred) next.delete(song.id); else next.add(song.id);
|
||||
setStarredSongs(next);
|
||||
setStarredOverride(song.id, !wasStarred);
|
||||
try {
|
||||
if (wasStarred) await unstar(song.id, 'song');
|
||||
else await star(song.id, 'song');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
setStarredSongs(new Set(starredSongs));
|
||||
setStarredOverride(song.id, wasStarred);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, sear
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
@@ -60,6 +62,8 @@ export default function ArtistDetail() {
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const currentTrack = usePlayerStore(state => state.currentTrack);
|
||||
const isPlaying = usePlayerStore(state => state.isPlaying);
|
||||
const { downloadArtist, bulkProgress } = useOfflineStore();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -341,6 +345,28 @@ export default function ArtistDetail() {
|
||||
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
|
||||
{radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}
|
||||
</button>
|
||||
{albums.length > 0 && (() => {
|
||||
const progress = id ? bulkProgress[id] : undefined;
|
||||
const isDone = progress && progress.done === progress.total;
|
||||
const isDownloading = progress && !isDone;
|
||||
return (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={!!isDownloading}
|
||||
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
|
||||
data-tooltip={isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
||||
{isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -384,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 && (
|
||||
|
||||
+98
-55
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getArtists, SubsonicArtist } from '../api/subsonic';
|
||||
import { LayoutGrid, List } from 'lucide-react';
|
||||
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { LayoutGrid, List, Images } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ALL_SENTINEL = 'ALL';
|
||||
@@ -23,12 +25,52 @@ function nameColor(name: string): string {
|
||||
}
|
||||
|
||||
function nameInitial(name: string): string {
|
||||
// Skip leading non-letter chars (punctuation, numbers, brackets, …)
|
||||
const letter = name.match(/[a-zA-ZÀ-ÖØ-öø-ÿ]/)?.[0];
|
||||
// \p{L} matches any Unicode letter — covers cyrillic, arabic, CJK, etc.
|
||||
const letter = name.match(/\p{L}/u)?.[0];
|
||||
if (letter) return letter.toUpperCase();
|
||||
// Fallback: first alphanumeric (e.g. "1349")
|
||||
const alnum = name.match(/[a-zA-Z0-9]/)?.[0];
|
||||
return alnum?.toUpperCase() ?? '?';
|
||||
const alnum = name.match(/[0-9]/)?.[0];
|
||||
return alnum ?? '?';
|
||||
}
|
||||
|
||||
function ArtistCardAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
|
||||
const color = nameColor(artist.name);
|
||||
if (showImages && artist.coverArt) {
|
||||
return (
|
||||
<div className="artist-card-avatar">
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(artist.coverArt, 300)}
|
||||
cacheKey={coverArtCacheKey(artist.coverArt, 300)}
|
||||
alt={artist.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
|
||||
const color = nameColor(artist.name);
|
||||
if (showImages && artist.coverArt) {
|
||||
return (
|
||||
<div className="artist-avatar">
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(artist.coverArt, 64)}
|
||||
cacheKey={coverArtCacheKey(artist.coverArt, 64)}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Artists() {
|
||||
@@ -42,6 +84,8 @@ export default function Artists() {
|
||||
const [visibleCount, setVisibleCount] = useState(50);
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||
|
||||
useEffect(() => {
|
||||
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
|
||||
@@ -101,6 +145,15 @@ export default function Artists() {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
className={`btn btn-surface`}
|
||||
onClick={() => setShowArtistImages(!showArtistImages)}
|
||||
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
|
||||
data-tooltip-wrap
|
||||
>
|
||||
<Images size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
@@ -146,30 +199,25 @@ export default function Artists() {
|
||||
|
||||
{!loading && viewMode === 'grid' && (
|
||||
<div className="album-grid-wrap">
|
||||
{visible.map(artist => {
|
||||
const color = nameColor(artist.name);
|
||||
return (
|
||||
<div
|
||||
key={artist.id}
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
>
|
||||
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
{visible.map(artist => (
|
||||
<div
|
||||
key={artist.id}
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
>
|
||||
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -179,31 +227,26 @@ export default function Artists() {
|
||||
<div key={letter} style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 className="letter-heading">{letter}</h3>
|
||||
<div className="artist-list">
|
||||
{groups[letter].map(artist => {
|
||||
const color = nameColor(artist.name);
|
||||
return (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
id={`artist-${artist.id}`}
|
||||
>
|
||||
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div className="artist-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{groups[letter].map(artist => (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
id={`artist-${artist.id}`}
|
||||
>
|
||||
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div className="artist-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+15
-13
@@ -19,10 +19,13 @@ export default function Favorites() {
|
||||
const { playTrack, enqueue } = usePlayerStore();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const psyDrag = useDragDrop();
|
||||
|
||||
function removeSong(id: string) {
|
||||
unstar(id, 'song').catch(() => {});
|
||||
setStarredOverride(id, false);
|
||||
setSongs(prev => prev.filter(s => s.id !== id));
|
||||
}
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
@@ -47,7 +50,8 @@ export default function Favorites() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || songs.length > 0;
|
||||
const visibleSongs = songs.filter(s => starredOverrides[s.id] !== false);
|
||||
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0;
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
@@ -67,14 +71,14 @@ export default function Favorites() {
|
||||
<AlbumRow title={t('favorites.albums')} albums={albums} />
|
||||
)}
|
||||
|
||||
{songs.length > 0 && (
|
||||
{visibleSongs.length > 0 && (
|
||||
<section className="album-row-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
const tracks = songs.map(songToTrack);
|
||||
const tracks = visibleSongs.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
}}
|
||||
>
|
||||
@@ -84,7 +88,7 @@ export default function Favorites() {
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={() => {
|
||||
const tracks = songs.map(songToTrack);
|
||||
const tracks = visibleSongs.map(songToTrack);
|
||||
enqueue(tracks);
|
||||
}}
|
||||
>
|
||||
@@ -100,7 +104,7 @@ export default function Favorites() {
|
||||
<div className="col-center">{t('albumDetail.trackDuration')}</div>
|
||||
<div />
|
||||
</div>
|
||||
{songs.map((song, i) => {
|
||||
{visibleSongs.map((song, i) => {
|
||||
const track = songToTrack(song);
|
||||
return (
|
||||
<div
|
||||
@@ -109,7 +113,7 @@ export default function Favorites() {
|
||||
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
playTrack(track, songs.map(songToTrack));
|
||||
playTrack(track, visibleSongs.map(songToTrack));
|
||||
}}
|
||||
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||
role="row"
|
||||
@@ -129,19 +133,17 @@ 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 className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}>
|
||||
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{i + 1}</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span
|
||||
className="track-artist"
|
||||
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
|
||||
>{song.artist}</span>
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Cast, Plus, Trash2, X, Pencil, Check, Globe } from 'lucide-react';
|
||||
import {
|
||||
getInternetRadioStations, createInternetRadioStation,
|
||||
updateInternetRadioStation, deleteInternetRadioStation,
|
||||
InternetRadioStation, buildCoverArtUrl, coverArtCacheKey,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
|
||||
export default function InternetRadio() {
|
||||
const { t } = useTranslation();
|
||||
const { playRadio, stop, currentRadio, isPlaying } = usePlayerStore();
|
||||
|
||||
const [stations, setStations] = useState<InternetRadioStation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Add form
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addName, setAddName] = useState('');
|
||||
const [addUrl, setAddUrl] = useState('');
|
||||
const [addHomepage, setAddHomepage] = useState('');
|
||||
const [addSaving, setAddSaving] = useState(false);
|
||||
const addNameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Edit inline
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editUrl, setEditUrl] = useState('');
|
||||
const [editHomepage, setEditHomepage] = useState('');
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
|
||||
// Delete confirm
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getInternetRadioStations()
|
||||
.then(setStations)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (adding) addNameRef.current?.focus();
|
||||
}, [adding]);
|
||||
|
||||
const reload = async () => {
|
||||
const list = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]);
|
||||
setStations(list);
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!addName.trim() || !addUrl.trim()) return;
|
||||
setAddSaving(true);
|
||||
try {
|
||||
await createInternetRadioStation(addName.trim(), addUrl.trim(), addHomepage.trim() || undefined);
|
||||
await reload();
|
||||
} catch {}
|
||||
setAddSaving(false);
|
||||
setAdding(false);
|
||||
setAddName(''); setAddUrl(''); setAddHomepage('');
|
||||
};
|
||||
|
||||
const handleEditStart = (s: InternetRadioStation) => {
|
||||
setEditId(s.id);
|
||||
setEditName(s.name);
|
||||
setEditUrl(s.streamUrl);
|
||||
setEditHomepage(s.homepageUrl ?? '');
|
||||
setDeleteConfirmId(null);
|
||||
};
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!editId || !editName.trim() || !editUrl.trim()) return;
|
||||
setEditSaving(true);
|
||||
try {
|
||||
await updateInternetRadioStation(editId, editName.trim(), editUrl.trim(), editHomepage.trim() || undefined);
|
||||
await reload();
|
||||
} catch {}
|
||||
setEditSaving(false);
|
||||
setEditId(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: '0.5rem', alignItems: 'center' }}>
|
||||
{adding ? (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
ref={addNameRef}
|
||||
className="input"
|
||||
style={{ width: 160 }}
|
||||
placeholder={t('radio.stationName')}
|
||||
value={addName}
|
||||
onChange={e => setAddName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') { setAdding(false); setAddName(''); setAddUrl(''); setAddHomepage(''); } }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 220 }}
|
||||
placeholder={t('radio.streamUrl')}
|
||||
value={addUrl}
|
||||
onChange={e => setAddUrl(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') { setAdding(false); setAddName(''); setAddUrl(''); setAddHomepage(''); } }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 160 }}
|
||||
placeholder={t('radio.homepageUrl')}
|
||||
value={addHomepage}
|
||||
onChange={e => setAddHomepage(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') { setAdding(false); setAddName(''); setAddUrl(''); setAddHomepage(''); } }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={addSaving || !addName.trim() || !addUrl.trim()}>
|
||||
{addSaving ? <span className="spinner" style={{ width: 12, height: 12 }} /> : t('radio.save')}
|
||||
</button>
|
||||
<button className="btn btn-surface" onClick={() => { setAdding(false); setAddName(''); setAddUrl(''); setAddHomepage(''); }}>
|
||||
{t('radio.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => setAdding(true)}>
|
||||
<Plus size={15} /> {t('radio.addStation')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Grid ── */}
|
||||
{stations.length === 0 ? (
|
||||
<div className="empty-state">{t('radio.empty')}</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap">
|
||||
{stations.map(s => {
|
||||
const isActive = currentRadio?.id === s.id;
|
||||
const isEditingThis = editId === s.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`album-card${isActive ? ' radio-card-active' : ''}`}
|
||||
onMouseLeave={() => { if (deleteConfirmId === s.id) setDeleteConfirmId(null); }}
|
||||
>
|
||||
{/* Cover area */}
|
||||
<div className="album-card-cover">
|
||||
{s.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(s.coverArt, 256)}
|
||||
cacheKey={coverArtCacheKey(s.coverArt, 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>
|
||||
)}
|
||||
|
||||
{/* LIVE badge on active station */}
|
||||
{isActive && isPlaying && (
|
||||
<div className="radio-live-overlay">
|
||||
<span className="radio-live-badge">{t('radio.live')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play overlay */}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={e => handlePlay(e, s)}
|
||||
>
|
||||
{isActive && isPlaying
|
||||
? <X size={15} />
|
||||
: <Cast size={14} />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
className={`playlist-card-delete ${deleteConfirmId === s.id ? 'playlist-card-delete--confirm' : ''}`}
|
||||
onClick={e => handleDelete(e, s)}
|
||||
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 / inline edit */}
|
||||
<div className="album-card-info" style={{ padding: '0.5rem 0.6rem 0.4rem' }}>
|
||||
{isEditingThis ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ fontSize: '0.78rem', padding: '0.25rem 0.4rem' }}
|
||||
value={editName}
|
||||
placeholder={t('radio.stationName')}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleEditSave(); if (e.key === 'Escape') setEditId(null); }}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
style={{ fontSize: '0.78rem', padding: '0.25rem 0.4rem' }}
|
||||
value={editUrl}
|
||||
placeholder={t('radio.streamUrl')}
|
||||
onChange={e => setEditUrl(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleEditSave(); if (e.key === 'Escape') setEditId(null); }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
style={{ fontSize: '0.78rem', padding: '0.25rem 0.4rem' }}
|
||||
value={editHomepage}
|
||||
placeholder={t('radio.homepageUrl')}
|
||||
onChange={e => setEditHomepage(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleEditSave(); if (e.key === 'Escape') setEditId(null); }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '0.3rem', marginTop: '0.1rem' }}>
|
||||
<button className="btn btn-primary" style={{ flex: 1, padding: '0.2rem 0' }} onClick={handleEditSave} disabled={editSaving}>
|
||||
{editSaving ? <span className="spinner" style={{ width: 10, height: 10 }} /> : <Check size={12} />}
|
||||
</button>
|
||||
<button className="btn btn-surface" style={{ flex: 1, padding: '0.2rem 0' }} onClick={() => setEditId(null)}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="album-card-title" style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.name}</span>
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
style={{ flexShrink: 0, opacity: 0.6 }}
|
||||
onClick={() => handleEditStart(s)}
|
||||
data-tooltip={t('radio.editStation')}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
{s.homepageUrl && (
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
style={{ flexShrink: 0, opacity: 0.6 }}
|
||||
onClick={() => open(s.homepageUrl!)}
|
||||
data-tooltip={t('radio.openHomepage')}
|
||||
>
|
||||
<Globe size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="album-card-artist" style={{ fontSize: '0.7rem', opacity: 0.55, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{s.streamUrl}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+129
-69
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Play, HardDriveDownload, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
@@ -7,6 +7,8 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
|
||||
type FilterType = 'all' | 'album' | 'playlist' | 'artist';
|
||||
|
||||
export default function OfflineLibrary() {
|
||||
const { t } = useTranslation();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
@@ -15,26 +17,36 @@ export default function OfflineLibrary() {
|
||||
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const [filter, setFilter] = useState<FilterType>('all');
|
||||
|
||||
const albums = Object.values(offlineAlbums).filter(a => a.serverId === serverId);
|
||||
|
||||
const buildTracks = (albumId: string) => {
|
||||
const meta = offlineAlbums[`${serverId}:${albumId}`];
|
||||
if (!meta) return [];
|
||||
return meta.trackIds.flatMap(tid => {
|
||||
const t = offlineTracks[`${serverId}:${tid}`];
|
||||
if (!t) return [];
|
||||
return [{
|
||||
id: t.id, title: t.title, artist: t.artist, album: t.album,
|
||||
albumId: t.albumId, artistId: t.artistId, duration: t.duration,
|
||||
coverArt: t.coverArt, track: undefined, year: t.year,
|
||||
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
|
||||
replayGainTrackDb: t.replayGainTrackDb,
|
||||
replayGainAlbumDb: t.replayGainAlbumDb,
|
||||
replayGainPeak: t.replayGainPeak,
|
||||
}];
|
||||
});
|
||||
};
|
||||
const countByType = (type: FilterType) => {
|
||||
if (type === 'all') return albums.length;
|
||||
return albums.filter(a => (a.type ?? 'album') === type).length;
|
||||
};
|
||||
|
||||
const filtered = filter === 'all'
|
||||
? albums
|
||||
: albums.filter(a => (a.type ?? 'album') === filter);
|
||||
|
||||
const buildTracks = (albumId: string) => {
|
||||
const meta = offlineAlbums[`${serverId}:${albumId}`];
|
||||
if (!meta) return [];
|
||||
return meta.trackIds.flatMap(tid => {
|
||||
const t = offlineTracks[`${serverId}:${tid}`];
|
||||
if (!t) return [];
|
||||
return [{
|
||||
id: t.id, title: t.title, artist: t.artist, album: t.album,
|
||||
albumId: t.albumId, artistId: t.artistId, duration: t.duration,
|
||||
coverArt: t.coverArt, track: undefined, year: t.year,
|
||||
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
|
||||
replayGainTrackDb: t.replayGainTrackDb,
|
||||
replayGainAlbumDb: t.replayGainAlbumDb,
|
||||
replayGainPeak: t.replayGainPeak,
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlay = (albumId: string) => {
|
||||
const tracks = buildTracks(albumId);
|
||||
@@ -45,6 +57,84 @@ const buildTracks = (albumId: string) => {
|
||||
enqueue(buildTracks(albumId));
|
||||
};
|
||||
|
||||
const renderCard = (album: typeof albums[0]) => {
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
|
||||
return (
|
||||
<div key={`${album.serverId}:${album.id}`} className="album-card card offline-library-card">
|
||||
<div className="album-card-cover">
|
||||
{coverUrl ? (
|
||||
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
<HardDriveDownload size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={() => handlePlay(album.id)}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate">{album.name}</p>
|
||||
<p className="album-card-artist truncate">{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
<div className="offline-library-card-meta">
|
||||
<button
|
||||
className="offline-library-enqueue"
|
||||
onClick={() => handleEnqueue(album.id)}
|
||||
data-tooltip={t('queue.addToQueue')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
+ Queue
|
||||
</button>
|
||||
<span className="offline-library-tracks">{trackCount} tracks</span>
|
||||
<button
|
||||
className="offline-library-delete"
|
||||
onClick={() => deleteAlbum(album.id, serverId)}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// For artist filter: group by artist name
|
||||
const renderArtistGroups = () => {
|
||||
const groups: Record<string, typeof albums> = {};
|
||||
for (const album of filtered) {
|
||||
const key = album.artist || '—';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(album);
|
||||
}
|
||||
const sortedArtists = Object.keys(groups).sort((a, b) => a.localeCompare(b));
|
||||
return sortedArtists.map(artistName => (
|
||||
<div key={artistName} className="offline-artist-group">
|
||||
<h2 className="offline-artist-group-heading">{artistName}</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{groups[artistName].map(renderCard)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
const TABS: { id: FilterType; labelKey: string }[] = [
|
||||
{ id: 'all', labelKey: 'connection.offlineFilterAll' },
|
||||
{ id: 'album', labelKey: 'connection.offlineFilterAlbums' },
|
||||
{ id: 'playlist', labelKey: 'connection.offlineFilterPlaylists' },
|
||||
{ id: 'artist', labelKey: 'connection.offlineFilterArtists' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="offline-library animate-fade-in">
|
||||
<div className="offline-library-header">
|
||||
@@ -57,60 +147,30 @@ const buildTracks = (albumId: string) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{albums.length === 0 ? (
|
||||
<div className="offline-filter-tabs">
|
||||
{TABS.map(tab => {
|
||||
const count = countByType(tab.id);
|
||||
if (tab.id !== 'all' && count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`offline-filter-tab${filter === tab.id ? ' active' : ''}`}
|
||||
onClick={() => setFilter(tab.id)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
<span className="offline-filter-tab-count">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">{t('connection.offlineLibraryEmpty')}</div>
|
||||
) : filter === 'artist' ? (
|
||||
renderArtistGroups()
|
||||
) : (
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map(album => {
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
|
||||
return (
|
||||
<div key={album.id} className="album-card card offline-library-card">
|
||||
<div className="album-card-cover">
|
||||
{coverUrl ? (
|
||||
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
<HardDriveDownload size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={() => handlePlay(album.id)}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate">{album.name}</p>
|
||||
<p className="album-card-artist truncate">{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
<div className="offline-library-card-meta">
|
||||
<button
|
||||
className="offline-library-enqueue"
|
||||
onClick={() => handleEnqueue(album.id)}
|
||||
title="Zur Warteschlange hinzufügen"
|
||||
>
|
||||
+ Queue
|
||||
</button>
|
||||
<span className="offline-library-tracks">{trackCount} tracks</span>
|
||||
<button
|
||||
className="offline-library-delete"
|
||||
onClick={() => deleteAlbum(album.id, serverId)}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filtered.map(renderCard)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
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 } from 'lucide-react';
|
||||
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
import {
|
||||
getPlaylist, updatePlaylist, 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';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
||||
@@ -54,9 +57,21 @@ export default function PlaylistDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying } = 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();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
|
||||
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
|
||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||
@@ -92,9 +107,10 @@ export default function PlaylistDetail() {
|
||||
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
|
||||
|
||||
const bulkRemove = () => {
|
||||
const prevCount = songs.length;
|
||||
const next = songs.filter(s => !selectedIds.has(s.id));
|
||||
setSongs(next);
|
||||
savePlaylist(next);
|
||||
savePlaylist(next, prevCount);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
@@ -204,11 +220,11 @@ export default function PlaylistDetail() {
|
||||
}, [playlist?.id]);
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[]) => {
|
||||
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[], prevCount = 0) => {
|
||||
if (!id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updatePlaylist(id, updatedSongs.map(s => s.id));
|
||||
await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount);
|
||||
if (id) touchPlaylist(id);
|
||||
} catch {}
|
||||
setSaving(false);
|
||||
@@ -216,9 +232,10 @@ export default function PlaylistDetail() {
|
||||
|
||||
// ── Remove ────────────────────────────────────────────────────
|
||||
const removeSong = (idx: number) => {
|
||||
const prevCount = songs.length;
|
||||
const next = songs.filter((_, i) => i !== idx);
|
||||
setSongs(next);
|
||||
savePlaylist(next);
|
||||
savePlaylist(next, prevCount);
|
||||
};
|
||||
|
||||
// ── Add ───────────────────────────────────────────────────────
|
||||
@@ -239,12 +256,13 @@ export default function PlaylistDetail() {
|
||||
|
||||
const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const isStarred = starredSongs.has(song.id);
|
||||
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
|
||||
setStarredSongs(prev => {
|
||||
const next = new Set(prev);
|
||||
isStarred ? next.delete(song.id) : next.add(song.id);
|
||||
return next;
|
||||
});
|
||||
setStarredOverride(song.id, !isStarred);
|
||||
(isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -424,6 +442,25 @@ export default function PlaylistDetail() {
|
||||
>
|
||||
<Search size={16} /> {t('playlists.addSongs')}
|
||||
</button>
|
||||
{songs.length > 0 && id && (() => {
|
||||
const isDownloading = isAlbumDownloading(id);
|
||||
const isCached = isAlbumDownloaded(id, activeServerId);
|
||||
const progress = isDownloading ? getAlbumProgress(id) : null;
|
||||
return (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={isDownloading}
|
||||
onClick={() => { if (playlist) downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId); }}
|
||||
data-tooltip={isDownloading
|
||||
? t('albumDetail.offlineDownloading', { n: progress?.done ?? 0, total: progress?.total ?? 0 })
|
||||
: isCached ? t('playlists.offlineCached') : t('playlists.cacheOffline')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
||||
: isCached ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -535,11 +572,9 @@ 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' : ''}`}
|
||||
@@ -564,7 +599,7 @@ export default function PlaylistDetail() {
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
return (
|
||||
<div
|
||||
className="track-num"
|
||||
className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
@@ -575,11 +610,9 @@ export default function PlaylistDetail() {
|
||||
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>
|
||||
{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>
|
||||
);
|
||||
})()}
|
||||
@@ -591,7 +624,11 @@ export default function PlaylistDetail() {
|
||||
|
||||
{/* Artist */}
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
<span
|
||||
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
|
||||
>{song.artist}</span>
|
||||
</div>
|
||||
|
||||
{/* Favorite */}
|
||||
@@ -599,9 +636,9 @@ export default function PlaylistDetail() {
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => handleToggleStar(song, e)}
|
||||
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
|
||||
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={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
|
||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -628,8 +665,6 @@ export default function PlaylistDetail() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drop indicator below last row or between rows */}
|
||||
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
|
||||
<div className="playlist-drop-indicator" />
|
||||
)}
|
||||
@@ -700,7 +735,11 @@ export default function PlaylistDetail() {
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
<span
|
||||
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
|
||||
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
|
||||
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
|
||||
>{song.artist}</span>
|
||||
</div>
|
||||
{/* no star/rating for suggestions */}
|
||||
<div />
|
||||
|
||||
+143
-142
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { Play, Star, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
|
||||
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
|
||||
@@ -28,6 +28,10 @@ export default function RandomMix() {
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const psyDrag = useDragDrop();
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
@@ -105,11 +109,12 @@ export default function RandomMix() {
|
||||
|
||||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const currentlyStarred = starredSongs.has(song.id);
|
||||
const currentlyStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
|
||||
const nextStarred = new Set(starredSongs);
|
||||
if (currentlyStarred) nextStarred.delete(song.id);
|
||||
else nextStarred.add(song.id);
|
||||
setStarredSongs(nextStarred);
|
||||
setStarredOverride(song.id, !currentlyStarred);
|
||||
|
||||
try {
|
||||
if (currentlyStarred) await unstar(song.id, 'song');
|
||||
@@ -117,6 +122,7 @@ export default function RandomMix() {
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle song star', err);
|
||||
setStarredSongs(new Set(starredSongs));
|
||||
setStarredOverride(song.id, currentlyStarred);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,19 +325,28 @@ 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: '36px 1fr 1fr 1fr 120px 80px' }}>
|
||||
<span></span>
|
||||
<span>{t('randomMix.trackTitle')}</span>
|
||||
<span>{t('randomMix.trackArtist')}</span>
|
||||
<span>{t('randomMix.trackAlbum')}</span>
|
||||
<span>{t('randomMix.trackGenre')}</span>
|
||||
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
<div>{t('randomMix.trackArtist')}</div>
|
||||
<div>{t('randomMix.trackAlbum')}</div>
|
||||
<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;
|
||||
const artist = song.artist;
|
||||
const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||
const isArtistJustAdded = addedArtist === artist;
|
||||
return (
|
||||
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
|
||||
onDoubleClick={() => playTrack(track, genreMixSongs.map(songToTrack))} role="row"
|
||||
<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' }}
|
||||
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'); }}
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -349,21 +364,49 @@ export default function RandomMix() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(track, genreMixSongs.map(songToTrack)); }}>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<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-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>{formatDuration(song.duration)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
{artist ? (
|
||||
<button
|
||||
className={`rm-artist-btn${isArtistBlocked ? ' is-blocked' : isArtistJustAdded ? ' just-added' : ''}`}
|
||||
onClick={() => {
|
||||
if (isArtistBlocked) return;
|
||||
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
|
||||
setAddedArtist(artist);
|
||||
setTimeout(() => setAddedArtist(null), 1500);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isArtistBlocked ? t('randomMix.artistBlocked') : isArtistJustAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
|
||||
>{artist}</button>
|
||||
) : <span className="track-artist">—</span>}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{song.album ?? '—'}</span>
|
||||
</div>
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => toggleSongStar(song, e)}
|
||||
data-tooltip={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
|
||||
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>
|
||||
<div className="track-duration">{formatDuration(song.duration)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedGenre && (loading && songs.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
@@ -371,26 +414,37 @@ export default function RandomMix() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}>
|
||||
<span></span>
|
||||
<span>{t('randomMix.trackTitle')}</span>
|
||||
<span>{t('randomMix.trackArtist')}</span>
|
||||
<span>{t('randomMix.trackAlbum')}</span>
|
||||
<span data-tooltip={t('randomMix.genreClickHint')} data-tooltip-wrap style={{ cursor: 'help' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
<div>{t('randomMix.trackArtist')}</div>
|
||||
<div>{t('randomMix.trackAlbum')}</div>
|
||||
<div data-tooltip={t('randomMix.genreClickHint')} data-tooltip-wrap style={{ cursor: 'help' }}>
|
||||
{t('randomMix.trackGenre')} <span style={{ color: 'var(--accent)', fontWeight: 700, fontSize: 13 }}>ⓘ</span>
|
||||
</span>
|
||||
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
|
||||
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||
</div>
|
||||
<div className="col-center">{t('randomMix.trackFavorite')}</div>
|
||||
<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;
|
||||
const artist = song.artist;
|
||||
const genre = song.genre;
|
||||
const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||
const isArtistJustAdded = addedArtist === artist;
|
||||
const isGenreBlocked = !!genre && (
|
||||
AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
|
||||
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))
|
||||
);
|
||||
const isGenreJustAdded = addedGenre === genre;
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
|
||||
onDoubleClick={() => playTrack(track, filteredSongs.map(songToTrack))}
|
||||
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' }}
|
||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
|
||||
role="row"
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
@@ -413,124 +467,71 @@ export default function RandomMix() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: 4 }}
|
||||
onClick={(e) => { e.stopPropagation(); playTrack(songToTrack(song), filteredSongs.map(songToTrack)); }}
|
||||
data-tooltip={t('randomMix.play')}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<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-info">
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="track-artist-cell">
|
||||
{(() => {
|
||||
const artist = song.artist;
|
||||
if (!artist) return <span className="track-artist">—</span>;
|
||||
const isBlocked = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||
const justAdded = addedArtist === artist;
|
||||
return (
|
||||
<div className="track-artist-cell">
|
||||
{artist ? (
|
||||
<button
|
||||
className="btn btn-ghost track-artist"
|
||||
style={{
|
||||
fontSize: 'inherit',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'transparent',
|
||||
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
border: 'none',
|
||||
cursor: isBlocked ? 'default' : 'pointer',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
height: 'auto',
|
||||
minHeight: 'unset',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
className={`rm-artist-btn${isArtistBlocked ? ' is-blocked' : isArtistJustAdded ? ' just-added' : ''}`}
|
||||
onClick={() => {
|
||||
if (isBlocked) return;
|
||||
const already = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
|
||||
if (!already) {
|
||||
if (isArtistBlocked) return;
|
||||
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
|
||||
setAddedArtist(artist);
|
||||
setTimeout(() => setAddedArtist(null), 1500);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isBlocked ? t('randomMix.artistBlocked') : justAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
|
||||
>
|
||||
{artist}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
data-tooltip={isArtistBlocked ? t('randomMix.artistBlocked') : isArtistJustAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
|
||||
>{artist}</button>
|
||||
) : <span className="track-artist">—</span>}
|
||||
</div>
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span>
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{song.album ?? '—'}</span>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const genre = song.genre;
|
||||
if (!genre) return <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>—</div>;
|
||||
const isBlocked = AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
|
||||
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
|
||||
const justAdded = addedGenre === genre;
|
||||
return (
|
||||
<div>
|
||||
{genre ? (
|
||||
<button
|
||||
className={`rm-genre-chip${isGenreBlocked ? ' is-blocked' : isGenreJustAdded ? ' just-added' : ''}`}
|
||||
onClick={() => {
|
||||
if (isGenreBlocked) return;
|
||||
if (!customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
|
||||
setAddedGenre(genre);
|
||||
setTimeout(() => setAddedGenre(null), 1500);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isGenreBlocked ? t('randomMix.genreBlocked') : isGenreJustAdded ? t('randomMix.genreAddedToBlacklist') : t('randomMix.genreClickHint')}
|
||||
>{genre}</button>
|
||||
) : <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
|
||||
<div className="track-star-cell">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'var(--bg-hover)',
|
||||
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-muted)',
|
||||
border: 'none',
|
||||
cursor: isBlocked ? 'default' : 'pointer',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
height: 'auto',
|
||||
minHeight: 'unset',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isBlocked) return;
|
||||
const already = customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
|
||||
if (!already) {
|
||||
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
|
||||
setAddedGenre(genre);
|
||||
setTimeout(() => setAddedGenre(null), 1500);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isBlocked ? t('randomMix.genreBlocked') : justAdded ? t('randomMix.genreAddedToBlacklist') : genre}
|
||||
className="btn btn-ghost track-star-btn"
|
||||
onClick={e => toggleSongStar(song, e)}
|
||||
data-tooltip={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
|
||||
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))' }}
|
||||
>
|
||||
{genre}
|
||||
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={(e) => toggleSongStar(song, e)}
|
||||
data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
|
||||
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
>
|
||||
<Heart size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
|
||||
</button>
|
||||
<div className="track-duration">{formatDuration(song.duration)}</div>
|
||||
</div>
|
||||
|
||||
<span className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
+31
-6
@@ -5,7 +5,7 @@ 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
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
@@ -453,7 +453,7 @@ export default function Settings() {
|
||||
{/* Cache */}
|
||||
<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">
|
||||
@@ -847,6 +847,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 +879,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 +918,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>
|
||||
)}
|
||||
@@ -993,7 +996,7 @@ export default function Settings() {
|
||||
{/* Downloads + Tray */}
|
||||
<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">
|
||||
@@ -1008,6 +1011,28 @@ export default function Settings() {
|
||||
</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>
|
||||
@@ -1038,7 +1063,7 @@ export default function Settings() {
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-ghost" onClick={pickDownloadFolder} id="settings-download-folder-btn">
|
||||
<button className="btn btn-surface" onClick={pickDownloadFolder} id="settings-download-folder-btn">
|
||||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,9 @@ interface AuthState {
|
||||
crossfadeSecs: number;
|
||||
gaplessEnabled: boolean;
|
||||
infiniteQueueEnabled: boolean;
|
||||
showArtistImages: boolean;
|
||||
minimizeToTray: boolean;
|
||||
discordRichPresence: boolean;
|
||||
nowPlayingEnabled: boolean;
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
@@ -68,7 +70,9 @@ interface AuthState {
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||
setShowArtistImages: (v: boolean) => void;
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
setDiscordRichPresence: (v: boolean) => void;
|
||||
setNowPlayingEnabled: (v: boolean) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
@@ -103,7 +107,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
infiniteQueueEnabled: false,
|
||||
showArtistImages: false,
|
||||
minimizeToTray: false,
|
||||
discordRichPresence: false,
|
||||
nowPlayingEnabled: false,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
@@ -170,7 +176,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
|
||||
setShowArtistImages: (v) => set({ showArtistImages: v }),
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
|
||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
|
||||
+25
-7
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildStreamUrl } from '../api/subsonic';
|
||||
import { buildStreamUrl, getArtist, getAlbum } from '../api/subsonic';
|
||||
import type { SubsonicSong } from '../api/subsonic';
|
||||
|
||||
export interface OfflineTrackMeta {
|
||||
@@ -33,6 +33,7 @@ export interface OfflineAlbumMeta {
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
trackIds: string[];
|
||||
type?: 'album' | 'playlist' | 'artist';
|
||||
}
|
||||
|
||||
export interface DownloadJob {
|
||||
@@ -49,6 +50,8 @@ interface OfflineState {
|
||||
tracks: Record<string, OfflineTrackMeta>; // key: `${serverId}:${trackId}`
|
||||
albums: Record<string, OfflineAlbumMeta>; // key: `${serverId}:${albumId}`
|
||||
jobs: DownloadJob[];
|
||||
/** Progress for bulk (playlist / artist) downloads. Key = playlistId or artistId. */
|
||||
bulkProgress: Record<string, { done: number; total: number }>;
|
||||
|
||||
isDownloaded: (trackId: string, serverId: string) => boolean;
|
||||
isAlbumDownloaded: (albumId: string, serverId: string) => boolean;
|
||||
@@ -62,7 +65,10 @@ interface OfflineState {
|
||||
year: number | undefined,
|
||||
songs: SubsonicSong[],
|
||||
serverId: string,
|
||||
type?: 'album' | 'playlist' | 'artist',
|
||||
) => Promise<void>;
|
||||
downloadPlaylist: (playlistId: string, playlistName: string, coverArt: string | undefined, songs: SubsonicSong[], serverId: string) => Promise<void>;
|
||||
downloadArtist: (artistId: string, artistName: string, serverId: string) => Promise<void>;
|
||||
deleteAlbum: (albumId: string, serverId: string) => Promise<void>;
|
||||
clearAll: (serverId: string) => Promise<void>;
|
||||
getAlbumProgress: (albumId: string) => { done: number; total: number } | null;
|
||||
@@ -74,6 +80,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
tracks: {},
|
||||
albums: {},
|
||||
jobs: [],
|
||||
bulkProgress: {},
|
||||
|
||||
isDownloaded: (trackId, serverId) =>
|
||||
!!get().tracks[`${serverId}:${trackId}`],
|
||||
@@ -110,7 +117,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
return { done, total: albumJobs.length };
|
||||
},
|
||||
|
||||
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId) => {
|
||||
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId, type = 'album') => {
|
||||
const CONCURRENCY = 2;
|
||||
const trackIds = songs.map(s => s.id);
|
||||
|
||||
@@ -118,7 +125,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
set(state => ({
|
||||
albums: {
|
||||
...state.albums,
|
||||
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds },
|
||||
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds, type },
|
||||
},
|
||||
jobs: [
|
||||
...state.jobs.filter(j => j.albumId !== albumId),
|
||||
@@ -211,6 +218,42 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
}, 2500);
|
||||
},
|
||||
|
||||
downloadPlaylist: async (playlistId, playlistName, coverArt, songs, serverId) => {
|
||||
// Deduplicate songs (a track can appear multiple times in a playlist).
|
||||
const seen = new Set<string>();
|
||||
const unique = songs.filter(s => { if (seen.has(s.id)) return false; seen.add(s.id); return true; });
|
||||
// Store the entire playlist as one virtual album entry so the Offline Library
|
||||
// shows a single card for the playlist rather than one card per album.
|
||||
await get().downloadAlbum(playlistId, playlistName, '', coverArt, undefined, unique, serverId, 'playlist');
|
||||
},
|
||||
|
||||
downloadArtist: async (artistId, artistName, serverId) => {
|
||||
let albums: { id: string; name: string; artist: string; coverArt?: string; year?: number }[] = [];
|
||||
try {
|
||||
const res = await getArtist(artistId);
|
||||
albums = res.albums;
|
||||
} catch { return; }
|
||||
set(state => ({
|
||||
bulkProgress: { ...state.bulkProgress, [artistId]: { done: 0, total: albums.length } },
|
||||
}));
|
||||
for (let i = 0; i < albums.length; i++) {
|
||||
const album = albums[i];
|
||||
try {
|
||||
const { songs } = await getAlbum(album.id);
|
||||
await get().downloadAlbum(album.id, album.name, album.artist || artistName, album.coverArt, album.year, songs, serverId, 'artist');
|
||||
} catch { /* skip failed album */ }
|
||||
set(state => ({
|
||||
bulkProgress: { ...state.bulkProgress, [artistId]: { done: i + 1, total: albums.length } },
|
||||
}));
|
||||
}
|
||||
setTimeout(() => {
|
||||
set(state => {
|
||||
const { [artistId]: _removed, ...rest } = state.bulkProgress;
|
||||
return { bulkProgress: rest };
|
||||
});
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
deleteAlbum: async (albumId, serverId) => {
|
||||
const album = get().albums[`${serverId}:${albumId}`];
|
||||
if (!album) return;
|
||||
|
||||
+92
-14
@@ -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,12 +74,13 @@ interface PlayerState {
|
||||
starredOverrides: Record<string, boolean>;
|
||||
setStarredOverride: (id: string, starred: boolean) => void;
|
||||
|
||||
playTrack: (track: Track, queue?: Track[]) => void;
|
||||
playRadio: (station: InternetRadioStation) => void;
|
||||
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
stop: () => void;
|
||||
togglePlay: () => void;
|
||||
next: () => void;
|
||||
next: (manual?: boolean) => void;
|
||||
previous: () => void;
|
||||
seek: (progress: number) => void;
|
||||
setVolume: (v: number) => void;
|
||||
@@ -262,14 +264,21 @@ 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 });
|
||||
setTimeout(() => {
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
usePlayerStore.getState().playTrack(currentTrack, queue);
|
||||
usePlayerStore.getState().playTrack(currentTrack, queue, false);
|
||||
} else {
|
||||
usePlayerStore.getState().next();
|
||||
usePlayerStore.getState().next(false);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
@@ -341,7 +350,7 @@ function handleAudioError(message: string) {
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
usePlayerStore.getState().next();
|
||||
usePlayerStore.getState().next(false);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
@@ -427,9 +436,50 @@ export function initAudioListeners(): () => void {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Discord Rich Presence sync ────────────────────────────────────────────
|
||||
// Updates on track change or play/pause toggle. No per-tick updates needed —
|
||||
// Discord auto-counts up the elapsed timer from the start_timestamp we set.
|
||||
let discordPrevTrackId: string | null = null;
|
||||
let discordPrevIsPlaying: boolean | null = null;
|
||||
|
||||
function syncDiscord() {
|
||||
const { currentTrack, isPlaying, currentTime } = usePlayerStore.getState();
|
||||
const { discordRichPresence } = useAuthStore.getState();
|
||||
|
||||
if (!discordRichPresence || !currentTrack) {
|
||||
if (discordPrevTrackId !== null) {
|
||||
discordPrevTrackId = null;
|
||||
discordPrevIsPlaying = null;
|
||||
invoke('discord_clear_presence').catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const trackChanged = currentTrack.id !== discordPrevTrackId;
|
||||
const playingChanged = isPlaying !== discordPrevIsPlaying;
|
||||
if (!trackChanged && !playingChanged) return;
|
||||
|
||||
discordPrevTrackId = currentTrack.id;
|
||||
discordPrevIsPlaying = isPlaying;
|
||||
|
||||
invoke('discord_update_presence', {
|
||||
title: currentTrack.title,
|
||||
artist: currentTrack.artist ?? 'Unknown Artist',
|
||||
album: currentTrack.album ?? null,
|
||||
// Pass elapsed when playing so Discord shows a live running timer.
|
||||
// Pass null when paused — Discord shows the song/artist without a timer.
|
||||
elapsedSecs: isPlaying ? currentTime : null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);
|
||||
const unsubDiscordAuth = useAuthStore.subscribe(syncDiscord);
|
||||
|
||||
return () => {
|
||||
unsubAuth();
|
||||
unsubMpris();
|
||||
unsubDiscordPlayer();
|
||||
unsubDiscordAuth();
|
||||
pending.forEach(p => p.then(unlisten => unlisten()));
|
||||
};
|
||||
}
|
||||
@@ -440,6 +490,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
currentTrack: null,
|
||||
currentRadio: null,
|
||||
queue: [],
|
||||
queueIndex: 0,
|
||||
isPlaying: false,
|
||||
@@ -531,11 +582,35 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
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;
|
||||
invoke('audio_play_radio', { url: station.streamUrl, volume }).catch((err: unknown) => {
|
||||
console.error('[psysonic] audio_play_radio 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 ────────────────────────────────────────────────────────────
|
||||
playTrack: (track, queue) => {
|
||||
playTrack: (track, queue, manual = true) => {
|
||||
// Ghost-command guard: if a gapless switch happened within 500 ms,
|
||||
// this playTrack call is likely a stale IPC echo — suppress it.
|
||||
if (Date.now() - lastGaplessSwitchTime < 500) {
|
||||
@@ -576,13 +651,14 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
manual,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
console.error('[psysonic] audio_play failed:', err);
|
||||
set({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
get().next();
|
||||
get().next(false);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
@@ -641,6 +717,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
volume: vol,
|
||||
durationHint: trackToPlay.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
manual: false,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
}).then(() => {
|
||||
if (playGeneration === gen && currentTime > 1) {
|
||||
@@ -668,6 +745,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: currentTrack.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
manual: false,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
||||
@@ -687,11 +765,11 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
},
|
||||
|
||||
// ── next / previous ──────────────────────────────────────────────────────
|
||||
next: () => {
|
||||
next: (manual = true) => {
|
||||
const { queue, queueIndex, repeatMode, currentTrack } = get();
|
||||
const nextIdx = queueIndex + 1;
|
||||
if (nextIdx < queue.length) {
|
||||
get().playTrack(queue[nextIdx], queue);
|
||||
get().playTrack(queue[nextIdx], queue, manual);
|
||||
// Proactively top up auto-added tracks when ≤ 2 remain ahead,
|
||||
// so the queue never runs dry without a visible loading pause.
|
||||
const { infiniteQueueEnabled } = useAuthStore.getState();
|
||||
@@ -735,7 +813,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
}
|
||||
}
|
||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||
get().playTrack(queue[0], queue);
|
||||
get().playTrack(queue[0], queue, manual);
|
||||
} else {
|
||||
// Queue exhausted. Check radio first (independent of infinite queue setting),
|
||||
// then infinite queue, then stop.
|
||||
@@ -755,7 +833,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
if (fresh.length > 0) {
|
||||
const currentQueue = get().queue;
|
||||
const newQueue = [...currentQueue, ...fresh];
|
||||
get().playTrack(fresh[0], newQueue);
|
||||
get().playTrack(fresh[0], newQueue, false);
|
||||
} else {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
@@ -786,7 +864,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
|
||||
const currentQueue = get().queue;
|
||||
const newQueue = [...currentQueue, ...newTracks];
|
||||
get().playTrack(newTracks[0], newQueue);
|
||||
get().playTrack(newTracks[0], newQueue, false);
|
||||
}).catch(() => {
|
||||
infiniteQueueFetching = false;
|
||||
invoke('audio_stop').catch(console.error);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
+460
-43
@@ -723,6 +723,63 @@
|
||||
color: var(--color-error, #e05050);
|
||||
}
|
||||
|
||||
/* Filter tabs */
|
||||
.offline-filter-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.offline-filter-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.offline-filter-tab:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.offline-filter-tab.active {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-base);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.offline-filter-tab-count {
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.offline-filter-tab.active .offline-filter-tab-count {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Artist discography groups */
|
||||
.offline-artist-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.offline-artist-group-heading {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.album-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1160,6 +1217,7 @@
|
||||
/* ─ Tracklist ─ */
|
||||
.tracklist {
|
||||
padding: 0 var(--space-6) var(--space-6);
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.col-center {
|
||||
@@ -1168,7 +1226,6 @@
|
||||
|
||||
.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);
|
||||
@@ -1181,13 +1238,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);
|
||||
@@ -1195,65 +1247,156 @@
|
||||
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 ── */
|
||||
/* Sits at the right edge of each header cell, fully inside the cell bounds */
|
||||
.col-resize-handle {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
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: 3px;
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--ctp-surface1);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.col-resize-handle:hover::after,
|
||||
.col-resize-handle:active::after {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Column visibility picker ── */
|
||||
.tracklist-col-picker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
.tracklist-total.tracklist-playlist .tracklist-total-label {
|
||||
grid-column: 1 / 6;
|
||||
}
|
||||
|
||||
.tracklist-total.tracklist-playlist .tracklist-total-value {
|
||||
grid-column: 6 / 7;
|
||||
.track-row.tracklist-playlist {
|
||||
grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 80px 80px 36px;
|
||||
}
|
||||
|
||||
/* Delete button in playlist row */
|
||||
@@ -1335,7 +1478,7 @@
|
||||
}
|
||||
.track-num .bulk-check {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.1s;
|
||||
@@ -1349,6 +1492,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;
|
||||
@@ -1421,11 +1602,6 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.track-num {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
min-width: 0;
|
||||
@@ -1453,6 +1629,11 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-artist-link:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.track-codec {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
@@ -1485,6 +1666,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;
|
||||
@@ -1496,6 +1685,70 @@
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
/* ── Random Mix — clickable artist name ── */
|
||||
.rm-artist-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-decoration-color: transparent;
|
||||
transition: color 0.15s, text-decoration-color 0.15s;
|
||||
}
|
||||
.rm-artist-btn:hover {
|
||||
color: var(--accent);
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
.rm-artist-btn.is-blocked {
|
||||
color: var(--danger);
|
||||
text-decoration-color: var(--danger);
|
||||
cursor: default;
|
||||
}
|
||||
.rm-artist-btn.just-added {
|
||||
color: var(--accent);
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Random Mix — genre pill chip ── */
|
||||
.rm-genre-chip {
|
||||
display: inline-block;
|
||||
background: var(--bg-hover);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.rm-genre-chip:hover {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.rm-genre-chip.is-blocked {
|
||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||
color: var(--danger);
|
||||
cursor: default;
|
||||
}
|
||||
.rm-genre-chip.just-added {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─ Modal ─ */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
@@ -2160,10 +2413,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);
|
||||
}
|
||||
@@ -2386,10 +2647,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;
|
||||
@@ -2440,6 +2701,7 @@
|
||||
gap: 18px;
|
||||
width: min(440px, 88vw);
|
||||
padding: 16px 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Artist name — above cover */
|
||||
@@ -2463,6 +2725,7 @@
|
||||
box-shadow: none;
|
||||
flex-shrink: 0;
|
||||
animation: cover-breathe 9s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.fs-cover {
|
||||
@@ -4233,6 +4496,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 {
|
||||
@@ -4547,6 +4938,32 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* ─ Bulk Select ─ */
|
||||
.bulk-action-bar {
|
||||
display: flex;
|
||||
|
||||
+36
-4
@@ -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 {
|
||||
@@ -293,6 +301,15 @@
|
||||
.app-updater-actions {
|
||||
padding-left: 19px;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.app-updater-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
.app-updater-btn-primary {
|
||||
display: inline-flex;
|
||||
@@ -309,6 +326,21 @@
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.app-updater-btn-primary:hover { opacity: 1; }
|
||||
.app-updater-btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.75;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.app-updater-btn-secondary:hover { opacity: 1; color: var(--text-primary); }
|
||||
.app-updater-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+37
-6
@@ -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 ─── */
|
||||
@@ -8384,11 +8386,11 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
--ctp-surface1: #D8DADF;
|
||||
--ctp-surface2: #CED0D4;
|
||||
--ctp-overlay0: #B0B3B8;
|
||||
--ctp-overlay1: #65676B;
|
||||
--ctp-overlay2: #4B4C4F;
|
||||
--ctp-overlay1: #4B4C4F;
|
||||
--ctp-overlay2: #3A3B3D;
|
||||
--ctp-text: #050505;
|
||||
--ctp-subtext1: #65676B;
|
||||
--ctp-subtext0: #8A8D91;
|
||||
--ctp-subtext1: #44464A;
|
||||
--ctp-subtext0: #65676B;
|
||||
|
||||
--ctp-mauve: #1877F2;
|
||||
--ctp-lavender: #2D88FF;
|
||||
@@ -8418,8 +8420,8 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
--volume-accent: #1877F2;
|
||||
|
||||
--text-primary: #050505;
|
||||
--text-secondary: #65676B;
|
||||
--text-muted: #606369;
|
||||
--text-secondary: #44464A;
|
||||
--text-muted: #65676B;
|
||||
--border: #CED0D4;
|
||||
--border-subtle: #E4E6EB;
|
||||
|
||||
@@ -8533,6 +8535,35 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Album chip — opaque Facebook blue pill */
|
||||
[data-theme='the-book'] .badge,
|
||||
[data-theme='the-book'] .album-detail-badge {
|
||||
background: #1877F2;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Back button — opaque Facebook-style pill */
|
||||
[data-theme='the-book'] .album-detail-back {
|
||||
background: #E7F3FF;
|
||||
color: #1877F2;
|
||||
}
|
||||
[data-theme='the-book'] .album-detail-back:hover {
|
||||
background: #D8EAFD;
|
||||
color: #0d6ae0;
|
||||
}
|
||||
|
||||
/* Queue/Lyrics tab bar — white text on blue sidebar */
|
||||
[data-theme='the-book'] .queue-tab-btn {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
[data-theme='the-book'] .queue-tab-btn:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
[data-theme='the-book'] .queue-tab-btn.active {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ── ReadIt (Social Media) ──────────────────────────────────── */
|
||||
[data-theme='readit'] {
|
||||
color-scheme: dark;
|
||||
|
||||
+40
-10
@@ -9,16 +9,36 @@ const MAX_CONCURRENT_FETCHES = 5;
|
||||
// In-memory map: cacheKey → object URL (insertion-order = LRU approximation)
|
||||
const objectUrlCache = new Map<string, string>();
|
||||
|
||||
// Concurrency limiter for network fetches
|
||||
// Concurrency limiter for network fetches.
|
||||
// Each queue entry is a resolver that signals "slot acquired".
|
||||
let activeFetches = 0;
|
||||
const fetchQueue: Array<() => void> = [];
|
||||
|
||||
function acquireFetchSlot(): Promise<void> {
|
||||
/**
|
||||
* Acquires a fetch slot. Returns true if a slot was granted, false if the
|
||||
* provided AbortSignal fired while the call was waiting in the queue (in that
|
||||
* case no slot is held and the caller must NOT call releaseFetchSlot).
|
||||
*/
|
||||
function acquireFetchSlot(signal?: AbortSignal): Promise<boolean> {
|
||||
if (signal?.aborted) return Promise.resolve(false);
|
||||
if (activeFetches < MAX_CONCURRENT_FETCHES) {
|
||||
activeFetches++;
|
||||
return Promise.resolve();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise(resolve => fetchQueue.push(resolve));
|
||||
return new Promise<boolean>(resolve => {
|
||||
const onGrant = () => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve(true);
|
||||
};
|
||||
const onAbort = () => {
|
||||
// Remove from queue without consuming a slot — no releaseFetchSlot needed.
|
||||
const idx = fetchQueue.indexOf(onGrant);
|
||||
if (idx !== -1) fetchQueue.splice(idx, 1);
|
||||
resolve(false);
|
||||
};
|
||||
fetchQueue.push(onGrant);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function releaseFetchSlot(): void {
|
||||
@@ -174,9 +194,11 @@ export async function clearImageCache(): Promise<void> {
|
||||
* Returns a cached object URL for an image.
|
||||
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
|
||||
* @param cacheKey A stable key that identifies the image across sessions.
|
||||
* @param signal Optional AbortSignal — aborts queue-waiting and in-flight fetches
|
||||
* so navigating away does not leave zombie fetches draining I/O.
|
||||
*/
|
||||
export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<string> {
|
||||
if (!fetchUrl) return '';
|
||||
export async function getCachedUrl(fetchUrl: string, cacheKey: string, signal?: AbortSignal): Promise<string> {
|
||||
if (!fetchUrl || signal?.aborted) return '';
|
||||
|
||||
// 1. In-memory hit (same session)
|
||||
const existing = objectUrlCache.get(cacheKey);
|
||||
@@ -184,6 +206,7 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
|
||||
|
||||
// 2. IndexedDB hit (persisted from previous session)
|
||||
const blob = await getBlob(cacheKey);
|
||||
if (signal?.aborted) return '';
|
||||
if (blob) {
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
@@ -191,19 +214,26 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
|
||||
return objUrl;
|
||||
}
|
||||
|
||||
// 3. Network fetch with concurrency limit → store in IDB → return object URL
|
||||
await acquireFetchSlot();
|
||||
// 3. Network fetch with concurrency limit → store in IDB → return object URL.
|
||||
// acquireFetchSlot returns false (without holding a slot) when aborted in queue.
|
||||
const acquired = await acquireFetchSlot(signal);
|
||||
if (!acquired || signal?.aborted) {
|
||||
if (acquired) releaseFetchSlot();
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(fetchUrl);
|
||||
if (!resp.ok) return fetchUrl;
|
||||
const newBlob = await resp.blob();
|
||||
if (signal?.aborted) return '';
|
||||
putBlob(cacheKey, newBlob); // fire-and-forget (includes disk eviction)
|
||||
const objUrl = URL.createObjectURL(newBlob);
|
||||
objectUrlCache.set(cacheKey, objUrl);
|
||||
evictMemoryIfNeeded();
|
||||
return objUrl;
|
||||
} catch {
|
||||
return fetchUrl;
|
||||
} catch (e) {
|
||||
// AbortError → return '' (component is gone). Other errors → return raw URL.
|
||||
return e instanceof DOMException && e.name === 'AbortError' ? '' : fetchUrl;
|
||||
} finally {
|
||||
releaseFetchSlot();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user