Compare commits

..

9 Commits

Author SHA1 Message Date
Psychotoxical d6f6e6466c feat: v1.22.0 — Queue Management, DnD Overhaul, Seek & Waveform Fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:14:59 +02:00
Psychotoxical 42863877f6 fix: close seek-flash race window after debounce expires
PR #7 blocked stale Rust progress ticks during the 100 ms debounce
window but lifted the guard exactly when invoke('audio_seek') went out.
The engine can still emit 1–2 ticks with the old position before the
seek takes effect.  Add seekTarget: once the debounce fires, record the
requested time and keep ignoring progress until current_time is within
2 s of that target, then clear it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:28:48 +02:00
Psychotoxical 7ed0fa4914 Merge branch 'pr-8' into test/pr-7-8 2026-03-30 17:24:18 +02:00
Joshua Bassett 4f8e7d7bc7 fix: stabilize waveform seekbar width when player time changes
Add min-width to .player-time elements so they don't resize when the
displayed time string changes length (e.g. "9:59" → "10:00"). Not all
fonts support the tabular-nums font-variant, so without a min-width the
time elements can change size and cause the waveform seekbar to
continuously resize as playback progresses.

Also align the elapsed time to the right and duration to the left so
they sit flush against the waveform edges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:17:50 +11:00
Joshua Bassett bb56269cd1 fix: prevent waveform seekbar flashing back to old position on seek
When clicking/dragging the waveform seekbar, the playback position would
briefly flash back to the previous position before snapping to the new
one.

The seek function optimistically updates the store with the target
position immediately but debounces the actual audio_seek IPC call by
100ms. During that window, audio:progress events from the Rust backend
continue to arrive carrying the old playback position, overwriting the
optimistic update and causing the visual flash.

The fix skips incoming audio:progress events while a seek debounce is
pending, since the store already holds the correct target position. Once
the debounce fires and audio_seek is sent to the backend, progress
events resume normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:59:55 +11:00
Psychotoxical 29a4363dca feat: v1.21.0 — What's New Modal, 3 New Themes (Beta), Artist Column, Powerslave Blue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:48:40 +02:00
Psychotoxical e1d27798eb feat: v1.20.0 — FLAC Seek Fix, Genres, Genre Filter, Chinese, W10 Theme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:04:01 +02:00
Psychotoxical b35539d3cf Merge pull request #3 from jiezhuo/feature/chinese_translations
Add Chinese language
2026-03-29 16:32:46 +02:00
jiezhuo a1b3022140 Add Chinese language 2026-03-28 22:04:13 +08:00
40 changed files with 4260 additions and 493 deletions
+73
View File
@@ -5,6 +5,79 @@ 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.22.0] - 2026-03-30
### Added
- **Queue — Active Playlist Tracking** *(Beta)* ⚠️: The queue now remembers which playlist was last loaded or saved. The playlist name appears as a subtitle below the queue title. The save button smart-saves: if an active playlist is set, it updates that playlist directly without opening a modal. If no playlist is active, the save modal opens as before.
- **Queue — Themed Delete Confirmation** *(Beta)* ⚠️: Deleting a playlist now shows a styled in-app confirmation dialog matching the current theme, replacing the unstyled native browser `confirm()` dialog.
- **Queue — Load Modal Live Filter** *(Beta)* ⚠️: The playlist load modal now has a live filter input at the top — typing narrows the playlist list in real time.
- **Drag & Drop — Precise Insertion** *(Beta)* ⚠️: Songs and albums dragged into the queue can now be dropped at any position between existing items. A blue insertion line shows exactly where the track will land. Previously all drops appended to the end of the queue.
- **Drag & Drop — Slim Ghost** *(Beta)* ⚠️: The drag ghost is now a compact single-line chip (cover thumbnail + title) instead of the full album card or track row. Consistent for both song and album drags.
### Fixed
- **Seek flash after debounce** *(contributed by [@nullobject](https://github.com/nullobject))*: After a seek the waveform briefly flashed back to the pre-seek position when the Rust `audio:progress` event arrived before the seek completed. A `seekTarget` guard now blocks stale progress ticks until the engine catches up.
- **Waveform seekbar jitter** *(contributed by [@nullobject](https://github.com/nullobject))*: The seekbar width changed on every progress tick because player time updates caused the waveform canvas container to reflow. The canvas now has an explicit stable width so time label changes no longer affect its layout.
- **Drag & Drop — text selection and grid auto-scroll during drag**: Dragging album cards or track rows caused the browser to begin a text selection and auto-scroll grid rows horizontally. All drag `onMouseDown` handlers now call `preventDefault()` and the DragDropContext uses `{ passive: false }` to suppress selection during mouse moves.
- **Drag & Drop — forbidden cursor on KDE Plasma**: Replaced the HTML5 `dragstart`/`dragend` system with a pure mouse-event DnD pipeline (`DragDropContext`). The WebKitGTK forbidden-cursor artefact on KDE Plasma no longer appears during drags.
### Changed
- **Settings — Contributors**: [@nullobject](https://github.com/nullobject) added for seek & waveform fixes.
### Theme Fixes
- **Powerslave**: Connection indicators (Last.fm / Server name) dimmed to match sidebar tone. Back button in album details now white on dark overlay. Tech strip (codec/bitrate) in queue uses dark Nile-blue background instead of sandstone. Artist name in album hero changed to Nile-blue `#050E19`.
- **North Park**: Back button in album details now visible (was dark brown on dark overlay).
- **Dark Side of the Moon**: Album detail year/genre/info brightened from `#555555` to `#888888`. Connection indicators brightened for legibility on near-black sidebar.
---
## [1.21.0] - 2026-03-29
### Added
- **What's New modal**: On first launch after an update, a changelog popup appears showing the current version's release notes. Can be permanently dismissed via checkbox, or re-enabled in Settings → About.
- **New theme category — Famous Albums**: A dedicated group for album-art-inspired themes.
- **Theme — Dark Side of the Moon (inspired)** *(Famous Albums)* ⚠️ **Beta**: Void-black everywhere, the iconic prism spectrum rainbow as a 2 px top border on the player bar, spectrum-violet accent `#9B30FF`, white track name (the input light beam).
- **Theme — Powerslave (inspired)** *(Famous Albums)* ⚠️ **Beta**: Sun-bleached sandstone main area, deep Nile-sky blue sidebar and player bar, pharaoh gold accent `#C8960C`. Bluegold duality mirrors the album artwork's vivid azure sky against the Egyptian temple gold.
- **Theme — North Park** *(Series)* ⚠️ **Beta**: South Park-inspired. Construction-paper cream main area, Colorado mountain-blue `#1B3D6E` sidebar, Kenny orange `#FF8C00` accent, flat no-gradient buttons.
### Changed
- **AlbumTrackList — artist column always visible**: The artist column is now shown on all albums, not only Various Artists compilations. Useful for albums with guest artists or featuring credits where track-level artist differs from the album artist.
- **Tracklist column widths — more flexible**: Title and artist columns now use `minmax` fr units (`1.5fr` / `1fr`) instead of fixed sizes, so the artist column moves naturally closer to the title on wide viewports and never clips on narrow ones.
### Fixed
- **Settings — changelog toggle alignment**: The "Show What's New on update" toggle was rendering below its label instead of beside it.
---
## [1.20.0] - 2026-03-29
### Added
- **Chinese language (zh)**: Full UI translation contributed by [@jiezhuo](https://github.com/jiezhuo). Language can be selected in Settings → General.
- **Genres page** *(requested by [@grillonbleu](https://github.com/grillonbleu))*: New page (sidebar: Tags icon) showing all server genres as coloured cards — icon watermark, genre name, album count. Cards are sorted by album count descending and deterministically colour-coded from the Catppuccin palette. Clicking a card opens the album list for that genre. Navigating back restores the previous scroll position.
- **Genre filter on Albums, New Releases, Random Albums** *(requested by [@grillonbleu](https://github.com/grillonbleu))*: A multi-select genre combobox in the page header lets you filter any of these views to one or more genres. Chips show selected genres; backspace removes the last one; clicking outside collapses the filter automatically when nothing is selected. In filter mode, results are fetched in parallel across all selected genres and deduped client-side.
- **Settings — Contributors**: A new "Contributors" row in the About section credits community translators.
### Changed
- **Theme — W10** *(Operating Systems)*: New Windows 10 Fluent Design light theme. Clean white content area, flat light-grey `#F3F3F3` navigation pane, near-black `#1C1C1C` taskbar player bar with a Windows-blue `#0078D4` accent stripe, flat buttons without gradients (4 px radius). Sharp, unmistakably W10 — distinct from the glass-era W7/Vista and the rounded-corner W11.
- **ThemePicker — Windows themes sorted by release year**: W3.1 → W98 → WXP → Wista → W7 → W10 → W11.
- **Playlists page — removed**: The dedicated Playlists page has been removed. Playlists remain fully accessible via the Queue panel (Save / Load buttons in the toolbar).
### Fixed
- **FLAC seeking** *(Rust audio engine)*: `rodio`'s internal `ReadSeekSource` hardcodes `byte_len() → None`, which caused the symphonia FLAC demuxer to reject all seek attempts (it validates seek byte offsets against the total stream length). Replaced `rodio::Decoder` with a direct symphonia pipeline (`SizedDecoder`) that wraps the audio bytes in a `SizedCursorSource` providing the correct `byte_len()`. FLAC seeking now works regardless of whether the file has an embedded SEEKTABLE.
- **Genre missing in Queue meta box when playing from album card**: `playAlbum()` (used by the play button on all album cards) mapped song-level genre only — which Navidrome does not always return per song. Now falls back to the album-level genre from `getAlbum`. Same fallback applied to all three play/enqueue handlers in `AlbumDetail`.
- **Logo gradient CSS variables**: Sidebar logo gradient now uses `--logo-color-start` / `--logo-color-end` with fallbacks, allowing themes with dark sidebars to override the gradient colours.
---
## [1.19.0] - 2026-03-27
### Added
+3 -3
View File
@@ -24,7 +24,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
-**Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, and Dutch.
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, and Chinese.
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll and line highlighting, plain-text fallback.
@@ -36,6 +36,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
- 🔤 **Font Picker**: 10 UI fonts to choose from in Settings → Appearance.
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
- 🏷️ **Genres**: Browse your entire library by genre — coloured cards sorted by album count with a dedicated album view per genre. Multi-select genre filter available on Albums, New Releases, and Random Albums pages.
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
@@ -56,7 +57,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
- [x] IndexedDB image caching
- [x] Random Mix with keyword filter & Super Genre mix
- [x] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
- [x] Internationalization (English, German, French, Dutch)
- [x] Internationalization (English, German, French, Dutch, Chinese)
- [x] AUR package (Arch / CachyOS)
- [x] Configurable keybindings
- [x] Font picker (10 UI fonts)
@@ -71,7 +72,6 @@ Designed specifically for users hosting their own music via Navidrome or other S
## ● Known Limitations
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
- **FLAC seeking**: Seeking in FLAC files requires an embedded SEEKTABLE metadata block. Files encoded without one cannot be seeked — clicking the waveform has no effect. Most modern encoders include a SEEKTABLE by default. You can add one retroactively with `metaflac --add-seekpoint=10s *.flac`.
## 📥 Installation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.19.0",
"version": "1.22.0",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.19.0
pkgver=1.21.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+2 -1
View File
@@ -3361,7 +3361,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.19.0"
version = "1.21.0"
dependencies = [
"biquad",
"md5",
@@ -3370,6 +3370,7 @@ dependencies = [
"serde",
"serde_json",
"souvlaki",
"symphonia",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.19.0"
version = "1.21.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -30,6 +30,7 @@ tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "wav", "adpcm"] }
reqwest = { version = "0.12", features = ["stream", "json"] }
md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] }
+297 -10
View File
@@ -1,12 +1,21 @@
use std::io::Cursor;
use std::io::{Cursor, Read, Seek};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
use rodio::{Decoder, Sink, Source};
use rodio::{Sink, Source};
use rodio::source::UniformSourceIterator;
use serde::Serialize;
use symphonia::core::{
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
codecs::{DecoderOptions, CODEC_TYPE_NULL},
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
io::{MediaSource, MediaSourceStream},
meta::MetadataOptions,
probe::Hint,
units::{self, Time},
};
use tauri::{AppHandle, Emitter, State};
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
@@ -394,6 +403,264 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
}
}
// ─── SizedCursorSource — MediaSource with correct byte_len ────────────────────
//
// 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
// the total stream length from byte_len). MP3 is unaffected because its
// demuxer uses Xing/LAME headers instead.
//
// This wrapper provides the actual byte length, fixing seek for all formats.
struct SizedCursorSource {
inner: Cursor<Vec<u8>>,
len: u64,
}
impl Read for SizedCursorSource {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
}
impl Seek for SizedCursorSource {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
impl MediaSource for SizedCursorSource {
fn is_seekable(&self) -> bool { true }
fn byte_len(&self) -> Option<u64> { Some(self.len) }
}
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
//
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
// (byte_len = None). This constructs the symphonia pipeline directly,
// providing the correct byte_len via SizedCursorSource.
//
// Implements Iterator<Item = i16> + Source — identical interface to
// rodio::Decoder, so the rest of the source chain is unchanged.
const DECODE_MAX_RETRIES: usize = 3;
struct SizedDecoder {
decoder: Box<dyn symphonia::core::codecs::Decoder>,
current_frame_offset: usize,
format: Box<dyn FormatReader>,
total_duration: Option<Time>,
buffer: SampleBuffer<i16>,
spec: SignalSpec,
}
impl SizedDecoder {
fn new(data: Vec<u8>, format_hint: Option<&str>) -> Result<Self, String> {
let data_len = data.len() as u64;
let source = SizedCursorSource {
inner: Cursor::new(data),
len: data_len,
};
let mss = MediaSourceStream::new(
Box::new(source) as Box<dyn MediaSource>,
Default::default(),
);
let mut hint = Hint::new();
if let Some(ext) = format_hint {
hint.with_extension(ext);
}
let format_opts = FormatOptions {
enable_gapless: true,
..Default::default()
};
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &MetadataOptions::default())
.map_err(|e| format!("probe failed: {e}"))?;
let track = probed.format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or_else(|| "no supported audio track".to_string())?;
let track_id = track.id;
let total_duration = track.codec_params.time_base
.zip(track.codec_params.n_frames)
.map(|(base, frames)| base.calc_time(frames));
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| format!("codec init failed: {e}"))?;
let mut format = probed.format;
// Decode the first packet to initialise spec + buffer.
let mut decode_errors: usize = 0;
let decoded = loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(symphonia::core::errors::Error::IoError(_)) => {
break decoder.last_decoded();
}
Err(e) => return Err(format!("first packet: {e}")),
};
if packet.track_id() != track_id { continue; }
match decoder.decode(&packet) {
Ok(decoded) => break decoded,
Err(symphonia::core::errors::Error::DecodeError(_)) => {
decode_errors += 1;
if decode_errors > DECODE_MAX_RETRIES {
return Err("too many decode errors".into());
}
}
Err(e) => return Err(format!("decode: {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);
let mut buffer = SampleBuffer::<i16>::new(duration, *spec);
buffer.copy_interleaved_ref(decoded);
buffer
}
/// Refine position after a coarse seek — decode packets until we reach the
/// exact requested timestamp.
fn refine_position(
&mut self,
seek_res: symphonia::core::formats::SeekedTo,
) -> Result<(), String> {
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
let packet = loop {
let candidate = self.format.next_packet()
.map_err(|e| format!("refine seek: {e}"))?;
if candidate.dur() > samples_to_pass {
break candidate;
}
samples_to_pass -= candidate.dur();
};
let mut decoded = self.decoder.decode(&packet);
for _ in 0..DECODE_MAX_RETRIES {
if decoded.is_err() {
let p = self.format.next_packet()
.map_err(|e| format!("refine retry: {e}"))?;
decoded = self.decoder.decode(&p);
}
}
let decoded = decoded.map_err(|e| format!("refine decode: {e}"))?;
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.current_frame_offset = samples_to_pass as usize * self.spec.channels.count();
Ok(())
}
}
impl Iterator for SizedDecoder {
type Item = i16;
#[inline]
fn next(&mut self) -> Option<i16> {
if self.current_frame_offset >= self.buffer.len() {
let packet = self.format.next_packet().ok()?;
let mut decoded = self.decoder.decode(&packet);
for _ in 0..DECODE_MAX_RETRIES {
if decoded.is_err() {
let p = self.format.next_packet().ok()?;
decoded = self.decoder.decode(&p);
}
}
let decoded = decoded.ok()?;
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.current_frame_offset = 0;
}
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
self.current_frame_offset += 1;
Some(sample)
}
}
impl Source for SizedDecoder {
#[inline]
fn current_frame_len(&self) -> Option<usize> {
Some(self.buffer.samples().len())
}
#[inline]
fn channels(&self) -> u16 {
self.spec.channels.count() as u16
}
#[inline]
fn sample_rate(&self) -> u32 {
self.spec.rate
}
#[inline]
fn total_duration(&self) -> Option<Duration> {
self.total_duration.map(|Time { seconds, frac }| {
Duration::new(seconds, (frac * 1_000_000_000.0) as u32)
})
}
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
let seek_beyond_end = self
.total_duration()
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);
let time: Time = if seek_beyond_end {
let t = self.total_duration.unwrap_or(pos.as_secs_f64().into());
// Step back a tiny bit — some demuxers can't seek to the exact end.
let mut secs = t.seconds;
let mut frac = t.frac - 0.0001;
if frac < 0.0 {
secs = secs.saturating_sub(1);
frac = 1.0 - frac;
}
Time { seconds: secs, frac }
} else {
pos.as_secs_f64().into()
};
let to_skip = self.current_frame_offset % self.channels() as usize;
let seek_res = self
.format
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
.map_err(|e| rodio::source::SeekError::Other(
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
))?;
self.refine_position(seek_res)
.map_err(|e| rodio::source::SeekError::Other(
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e))
))?;
self.current_frame_offset += to_skip;
Ok(())
}
}
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
//
// MP3/AAC encoders prepend an "encoder delay" (typically 5762112 silent
@@ -473,6 +740,7 @@ struct BuiltSource {
///
/// `sample_counter`: atomic counter incremented per sample for drift-free position.
/// `target_rate`: canonical output sample rate for resampling (0 = no resampling).
/// `format_hint`: optional file extension (e.g. "flac", "mp3") to help symphonia probe.
fn build_source(
data: Vec<u8>,
duration_hint: f64,
@@ -482,11 +750,11 @@ fn build_source(
fade_in_dur: Duration,
sample_counter: Arc<AtomicU64>,
target_rate: u32,
format_hint: Option<&str>,
) -> Result<BuiltSource, String> {
let gapless = parse_gapless_info(&data);
let cursor = Cursor::new(data);
let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?;
let decoder = SizedDecoder::new(data, format_hint)?;
let sample_rate = decoder.sample_rate();
let channels = decoder.channels();
@@ -881,6 +1149,10 @@ pub async fn audio_play(
// Reset sample counter for the new track.
state.samples_played.store(0, Ordering::Relaxed);
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
// Extract format hint from URL for better symphonia probing.
let format_hint = url.rsplit('.').next()
.and_then(|ext| ext.split('?').next())
.map(|s| s.to_lowercase());
let built = build_source(
data,
duration_hint,
@@ -890,6 +1162,7 @@ pub async fn audio_play(
fade_in_dur,
state.samples_played.clone(),
target_rate,
format_hint.as_deref(),
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
let source = built.source;
let duration_secs = built.duration_secs;
@@ -1071,6 +1344,9 @@ pub async fn audio_chain_preload(
// samples_played when the chained track becomes active.
let chain_counter = Arc::new(AtomicU64::new(0));
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
let format_hint = url.rsplit('.').next()
.and_then(|ext| ext.split('?').next())
.map(|s| s.to_lowercase());
let built = build_source(
data,
duration_hint,
@@ -1080,6 +1356,7 @@ pub async fn audio_chain_preload(
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
chain_counter.clone(),
target_rate,
format_hint.as_deref(),
).map_err(|e| e.to_string())?;
let source = built.source;
let duration_secs = built.duration_secs;
@@ -1144,6 +1421,8 @@ fn spawn_progress_task(
let mut near_end_ticks: u32 = 0;
// Local done-flag reference; swapped on gapless transition.
let mut current_done = initial_done;
// Local sample counter; swapped to chained source's counter on transition.
let mut samples_played = samples_played;
loop {
// 100 ms tick — tight enough for responsive UI, low enough CPU cost.
@@ -1163,12 +1442,11 @@ fn spawn_progress_task(
// Swap to the chained source's done flag.
current_done = info.source_done;
// Swap the sample counter: the chained source's counter
// is already being incremented by CountingSource. Copy its
// current value into the shared samples_played so the
// progress calculation stays accurate.
let chained_samples = info.sample_counter.load(Ordering::Relaxed);
samples_played.store(chained_samples, Ordering::Relaxed);
// Swap to the chained source's sample counter.
// The chained CountingSource increments its own Arc,
// so we must rebind our local reference to it —
// a one-time value copy would go stale immediately.
samples_played = info.sample_counter;
// Update tracking state.
{
@@ -1234,6 +1512,15 @@ fn spawn_progress_task(
near_end_ticks += 1;
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
if near_end_ticks >= 10 {
// If a gapless chain is pending, the source hasn't
// exhausted yet — duration_hint (integer seconds from
// Subsonic) is shorter than the actual audio content.
// Don't emit audio:ended; let the gapless transition
// handle it when current_done fires.
let has_chain = chained_arc.lock().unwrap().is_some();
if has_chain {
continue;
}
gen_counter.fetch_add(1, Ordering::SeqCst);
app.emit("audio:ended", ()).ok();
break;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.19.0",
"version": "1.22.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+47 -3
View File
@@ -22,7 +22,6 @@ import Login from './pages/Login';
import AlbumDetail from './pages/AlbumDetail';
import LabelAlbums from './pages/LabelAlbums';
import Statistics from './pages/Statistics';
import Playlists from './pages/Playlists';
import Help from './pages/Help';
import RandomAlbums from './pages/RandomAlbums';
import SearchResults from './pages/SearchResults';
@@ -30,13 +29,18 @@ import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import DownloadFolderModal from './components/DownloadFolderModal';
import { DragDropProvider } from './contexts/DragDropContext';
import TooltipPortal from './components/TooltipPortal';
import ConnectionIndicator from './components/ConnectionIndicator';
import LastfmIndicator from './components/LastfmIndicator';
import OfflineOverlay from './components/OfflineOverlay';
import OfflineBanner from './components/OfflineBanner';
import OfflineLibrary from './pages/OfflineLibrary';
import Genres from './pages/Genres';
import GenreDetail from './pages/GenreDetail';
import ExportPickerModal from './components/ExportPickerModal';
import ChangelogModal from './components/ChangelogModal';
import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore';
import { useOfflineStore } from './store/offlineStore';
@@ -110,6 +114,15 @@ function AppShell() {
fn();
}, [currentTrack, isPlaying]);
const [changelogModalOpen, setChangelogModalOpen] = useState(false);
useEffect(() => {
const { showChangelogOnUpdate, lastSeenChangelogVersion } = useAuthStore.getState();
if (showChangelogOnUpdate && lastSeenChangelogVersion !== version) {
setChangelogModalOpen(true);
}
}, []);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
});
@@ -150,6 +163,33 @@ function AppShell() {
};
}, [isDraggingQueue, handleMouseMove, handleMouseUp]);
// ── Global DnD fix for Linux/WebKitGTK ──────────────────────────
// WebKitGTK (used by Tauri on Linux) requires the document itself to
// accept drags via preventDefault() on dragover/dragenter. Without
// this, the webview shows a "forbidden" cursor for all in-app HTML5
// drag-and-drop because it never sees a valid drop target at the
// document level. This is harmless on Windows/macOS where DnD already
// works correctly.
useEffect(() => {
const allow = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
};
// Prevent the webview from navigating when something (e.g. a file
// from the OS file manager) is dropped on the document body.
const blockDrop = (e: DragEvent) => { e.preventDefault(); };
document.addEventListener('dragover', allow);
document.addEventListener('dragenter', allow);
document.addEventListener('drop', blockDrop);
return () => {
document.removeEventListener('dragover', allow);
document.removeEventListener('dragenter', allow);
document.removeEventListener('drop', blockDrop);
};
}, []);
return (
<div
className="app-shell"
@@ -200,7 +240,6 @@ function AppShell() {
<Route path="/new-releases" element={<NewReleases />} />
<Route path="/favorites" element={<Favorites />} />
<Route path="/random-mix" element={<RandomMix />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/statistics" element={<Statistics />} />
@@ -208,6 +247,8 @@ function AppShell() {
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<Help />} />
<Route path="/offline" element={<OfflineLibrary />} />
<Route path="/genres" element={<Genres />} />
<Route path="/genres/:name" element={<GenreDetail />} />
</Routes>
</div>
</main>
@@ -227,6 +268,7 @@ function AppShell() {
<ContextMenu />
<DownloadFolderModal />
<TooltipPortal />
{changelogModalOpen && <ChangelogModal onClose={() => setChangelogModalOpen(false)} />}
</div>
);
}
@@ -431,7 +473,9 @@ export default function App() {
path="/*"
element={
<RequireAuth>
<AppShell />
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
+18 -2
View File
@@ -230,8 +230,19 @@ export async function getSimilarSongs2(id: string, count = 50): Promise<Subsonic
}
export async function getGenres(): Promise<SubsonicGenre[]> {
const data = await api<{ genres: { genre: SubsonicGenre[] } }>('getGenres.view');
return data.genres?.genre ?? [];
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view');
const raw = data.genres?.genre;
if (!raw) return [];
return Array.isArray(raw) ? raw : [raw];
}
export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'byGenre', genre, size, offset, _t: Date.now(),
});
const raw = data.albumList2?.album;
if (!raw) return [];
return Array.isArray(raw) ? raw : [raw];
}
export interface SearchResults {
@@ -381,6 +392,11 @@ export async function createPlaylist(name: string, songIds?: string[]): Promise<
await api('createPlaylist.view', params);
}
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 deletePlaylist(id: string): Promise<void> {
await api('deletePlaylist.view', { id });
}
+16 -8
View File
@@ -7,6 +7,7 @@ import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from './CachedImage';
import { playAlbum } from '../utils/playAlbum';
import { useDragDrop } from '../contexts/DragDropContext';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -22,6 +23,7 @@ function AlbumCard({ album }: AlbumCardProps) {
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const psyDrag = useDragDrop();
return (
<div
@@ -35,14 +37,20 @@ function AlbumCard({ album }: AlbumCardProps) {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album');
}}
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'album',
id: album.id,
name: album.name,
}));
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl: coverUrl || undefined }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="album-card-cover">
+23 -13
View File
@@ -3,6 +3,7 @@ import { Play, Star } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import { Track, usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -69,6 +70,7 @@ export default function AlbumTrackList({
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const psyDrag = useDragDrop();
useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
@@ -93,10 +95,10 @@ export default function AlbumTrackList({
return (
<div className="tracklist">
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
<div className={`tracklist-header${' tracklist-va'}`}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</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>
@@ -114,7 +116,7 @@ export default function AlbumTrackList({
{discs.get(discNum)!.map((song, i) => (
<div
key={song.id}
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
onMouseEnter={() => setHoveredSongId(song.id)}
onMouseLeave={() => setHoveredSongId(null)}
onDoubleClick={() => onPlaySong(song)}
@@ -124,10 +126,20 @@ export default function AlbumTrackList({
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song');
}}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: makeTrack(song) }));
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: makeTrack(song) }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div
@@ -149,11 +161,9 @@ export default function AlbumTrackList({
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
{hasVariousArtists && (
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</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"
@@ -181,7 +191,7 @@ export default function AlbumTrackList({
</div>
))}
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
<div className={`tracklist-total${' tracklist-va'}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</div>
+105
View File
@@ -0,0 +1,105 @@
import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Sparkles } from 'lucide-react';
import { version } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw';
import { useAuthStore } from '../store/authStore';
function renderInline(text: string): React.ReactNode[] {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**'))
return <strong key={i}>{part.slice(2, -2)}</strong>;
if (part.startsWith('*') && part.endsWith('*'))
return <em key={i}>{part.slice(1, -1)}</em>;
if (part.startsWith('`') && part.endsWith('`'))
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
return part;
});
}
interface Props {
onClose: () => void;
}
export default function ChangelogModal({ onClose }: Props) {
const { t } = useTranslation();
const [dontShow, setDontShow] = useState(false);
const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate);
const setLastSeenChangelogVersion = useAuthStore(s => s.setLastSeenChangelogVersion);
const currentVersionData = useMemo(() => {
const blocks = changelogRaw.split(/\n(?=## \[)/).filter((b: string) => b.startsWith('## ['));
const block = blocks.find((b: string) => b.startsWith(`## [${version}]`));
if (!block) return null;
const lines = block.split('\n');
const match = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
const body = lines.slice(1).join('\n').trim();
return { version: match?.[1] ?? version, date: match?.[2] ?? '', body };
}, []);
const handleClose = () => {
if (dontShow) setShowChangelogOnUpdate(false);
setLastSeenChangelogVersion(version);
onClose();
};
if (!currentVersionData) return null;
return createPortal(
<>
<div className="eq-popup-backdrop" onClick={handleClose} style={{ zIndex: 300 }} />
<div
className="eq-popup"
style={{ zIndex: 301, width: 'min(580px, 92vw)', gap: 0, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
>
<div className="eq-popup-header" style={{ flexShrink: 0 }}>
<span className="eq-popup-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
{t('changelog.modalTitle')} v{version}
</span>
{currentVersionData.date && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
{currentVersionData.date}
</span>
)}
</div>
<div style={{ overflowY: 'auto', padding: '12px 20px', flex: 1 }}>
{currentVersionData.body.split('\n').map((line, i) => {
if (line.startsWith('### '))
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
if (line.startsWith('#### '))
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
if (line.startsWith('- '))
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
if (line.trim() === '') return null;
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
})}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px',
borderTop: '1px solid var(--border-subtle)',
flexShrink: 0,
gap: '1rem',
}}
>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, color: 'var(--text-secondary)', cursor: 'pointer', userSelect: 'none' }}>
<input type="checkbox" checked={dontShow} onChange={e => setDontShow(e.target.checked)} />
{t('changelog.dontShowAgain')}
</label>
<button className="btn btn-primary" onClick={handleClose}>
{t('changelog.close')}
</button>
</div>
</div>
</>,
document.body
);
}
+146
View File
@@ -0,0 +1,146 @@
import React, { useEffect, useRef, useState } from 'react';
import { Filter, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getGenres } from '../api/subsonic';
interface GenreFilterBarProps {
selected: string[];
onSelectionChange: (selected: string[]) => void;
}
export default function GenreFilterBar({ selected, onSelectionChange }: GenreFilterBarProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [genres, setGenres] = useState<string[]>([]);
const [search, setSearch] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
getGenres().then(data =>
setGenres(data.map(g => g.value).sort((a, b) => a.localeCompare(b)))
);
}, []);
// close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
// sync open state with selection
useEffect(() => {
if (selected.length > 0) setOpen(true);
}, [selected]);
const filteredOptions = genres.filter(
g => !selected.includes(g) && g.toLowerCase().includes(search.toLowerCase())
);
const add = (genre: string) => {
onSelectionChange([...selected, genre]);
setSearch('');
inputRef.current?.focus();
};
const remove = (genre: string) => {
onSelectionChange(selected.filter(s => s !== genre));
};
const clear = () => {
onSelectionChange([]);
setSearch('');
setOpen(false);
setDropdownOpen(false);
};
const openFilter = () => {
setOpen(true);
setTimeout(() => { inputRef.current?.focus(); setDropdownOpen(true); }, 30);
};
if (!open) {
return (
<button className="btn btn-surface" onClick={openFilter} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<Filter size={14} />
{t('common.filterGenre')}
</button>
);
}
const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
// relatedTarget is the next focused element; if it's outside our container, handle close
const next = e.relatedTarget as Node | null;
if (containerRef.current && next && containerRef.current.contains(next)) return;
setTimeout(() => {
if (selected.length === 0) {
setOpen(false);
setSearch('');
setDropdownOpen(false);
} else {
setDropdownOpen(false);
}
}, 150);
};
return (
<div ref={containerRef} onBlur={handleBlur} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<Filter size={14} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<div className="genre-filter-tagbox">
{selected.map(g => (
<span key={g} className="genre-filter-chip">
{g}
<button onClick={() => remove(g)} aria-label={`Remove ${g}`}>
<X size={11} />
</button>
</span>
))}
<input
ref={inputRef}
className="genre-filter-input"
placeholder={selected.length === 0 ? t('common.filterSearchGenres') : ''}
value={search}
onChange={e => { setSearch(e.target.value); setDropdownOpen(true); }}
onFocus={() => setDropdownOpen(true)}
onKeyDown={e => {
if (e.key === 'Escape') { setDropdownOpen(false); e.currentTarget.blur(); }
if (e.key === 'Backspace' && search === '' && selected.length > 0) {
remove(selected[selected.length - 1]);
}
}}
/>
{dropdownOpen && filteredOptions.length > 0 && (
<div className="genre-filter-dropdown">
{filteredOptions.slice(0, 60).map(g => (
<div key={g} className="genre-filter-option" onMouseDown={() => add(g)}>
{g}
</div>
))}
</div>
)}
{dropdownOpen && filteredOptions.length === 0 && search.length > 0 && (
<div className="genre-filter-dropdown">
<div className="genre-filter-empty">{t('common.filterNoGenres')}</div>
</div>
)}
</div>
{selected.length > 0 && (
<button className="btn btn-ghost" onClick={clear} style={{ padding: '0.35rem 0.6rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem' }}>
<X size={13} />
{t('common.filterClear')}
</button>
)}
</div>
);
}
+2 -2
View File
@@ -9,8 +9,8 @@ export default function PsysonicLogo({ className, style }: Props) {
return (
<svg viewBox="0 0 594.45007 134.93138" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
<linearGradient id="psysonicGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="var(--accent)" />
<stop offset="100%" stopColor="var(--ctp-blue)" />
<stop offset="0%" stopColor="var(--logo-color-start, var(--accent))" />
<stop offset="100%" stopColor="var(--logo-color-end, var(--ctp-blue))" />
</linearGradient>
</defs><g
id="layer1"
+218 -27
View File
@@ -1,12 +1,13 @@
import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane';
function formatTime(seconds: number): string {
@@ -59,10 +60,12 @@ function SavePlaylistModal({ onClose, onSave }: { onClose: () => void, onSave: (
);
}
function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (id: string) => void }) {
function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (id: string, name: string) => void }) {
const { t } = useTranslation();
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string } | null>(null);
const fetchPlaylists = () => {
setLoading(true);
@@ -80,28 +83,44 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
}, []);
const handleDelete = async (id: string, name: string) => {
if (confirm(t('queue.deleteConfirm', { name }))) {
await deletePlaylist(id);
fetchPlaylists();
}
setConfirmDelete({ id, name });
};
const confirmDeletePlaylist = async () => {
if (!confirmDelete) return;
await deletePlaylist(confirmDelete.id);
setConfirmDelete(null);
fetchPlaylists();
};
return (
<>
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<button className="modal-close" onClick={onClose}><X size={18} /></button>
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.loadPlaylist')}</h3>
{!loading && playlists.length > 0 && (
<input
type="text"
className="live-search-field"
placeholder={t('queue.filterPlaylists')}
value={filter}
onChange={e => setFilter(e.target.value)}
autoFocus
style={{ width: '100%', marginBottom: '0.75rem', padding: '8px 14px' }}
/>
)}
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>{t('queue.loading')}</p>
) : playlists.length === 0 ? (
<p style={{ color: 'var(--text-muted)' }}>{t('queue.noPlaylists')}</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '300px', overflowY: 'auto' }}>
{playlists.map(p => (
{playlists.filter(p => p.name.toLowerCase().includes(filter.toLowerCase())).map(p => (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: 'var(--ctp-surface1)', borderRadius: 'var(--radius-md)' }}>
<span style={{ fontWeight: 500 }} className="truncate" data-tooltip={p.name}>{p.name}</span>
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
<button className="nav-btn" onClick={() => onLoad(p.id)} data-tooltip={t('queue.load')} style={{ width: '28px', height: '28px', background: 'transparent' }}><Play size={14} /></button>
<button className="nav-btn" onClick={() => onLoad(p.id, p.name)} data-tooltip={t('queue.load')} style={{ width: '28px', height: '28px', background: 'transparent' }}><Play size={14} /></button>
<button className="nav-btn" onClick={() => handleDelete(p.id, p.name)} data-tooltip={t('queue.delete')} style={{ width: '28px', height: '28px', background: 'transparent', color: 'var(--ctp-red)' }}><Trash2 size={14} /></button>
</div>
</div>
@@ -110,6 +129,25 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
)}
</div>
</div>
{confirmDelete && (
<div className="modal-overlay" onClick={() => setConfirmDelete(null)} role="dialog" aria-modal="true">
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '360px' }}>
<button className="modal-close" onClick={() => setConfirmDelete(null)}><X size={18} /></button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>{t('queue.delete')}</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
{t('queue.deleteConfirm', { name: confirmDelete.name })}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>{t('queue.cancel')}</button>
<button className="btn btn-primary" style={{ background: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={confirmDeletePlaylist}>
{t('queue.delete')}
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -132,6 +170,7 @@ export default function QueuePanel() {
const reorderQueue = usePlayerStore(s => s.reorderQueue);
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
const enqueue = usePlayerStore(s => s.enqueue);
const enqueueAt = usePlayerStore(s => s.enqueueAt);
const contextMenu = usePlayerStore(s => s.contextMenu);
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
@@ -171,13 +210,78 @@ export default function QueuePanel() {
const dragOverIdxRef = useRef<number | null>(null);
const queueListRef = useRef<HTMLDivElement>(null);
const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging } = useDragDrop();
useEffect(() => {
if (!isPsyDragging) {
externalDropTargetRef.current = null;
setExternalDropTarget(null);
}
}, [isPsyDragging]);
const [externalDragOver, setExternalDragOver] = useState(false);
const externalDragCounterRef = useRef(0);
const [externalDropTarget, setExternalDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
const externalDropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
// ── Mouse-event DnD: listen for psy-drop custom events ─────────
useEffect(() => {
const aside = asideRef.current;
if (!aside) return;
const onPsyDrop = async (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsedData: any = null;
try { parsedData = JSON.parse(detail.data); } catch { return; }
const dropTarget = externalDropTargetRef.current;
const insertIdx = dropTarget
? (dropTarget.before ? dropTarget.idx : dropTarget.idx + 1)
: usePlayerStore.getState().queue.length;
externalDropTargetRef.current = null;
setExternalDropTarget(null);
if (parsedData.type === 'song') {
enqueueAt([parsedData.track], insertIdx);
} else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id);
const tracks: Track[] = albumData.songs.map((s: any) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
enqueueAt(tracks, insertIdx);
}
};
aside.addEventListener('psy-drop', onPsyDrop);
return () => aside.removeEventListener('psy-drop', onPsyDrop);
}, [enqueueAt]);
const [activePlaylist, setActivePlaylist] = useState<{ id: string; name: string } | null>(null);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
const handleSave = () => {
const handleSave = async () => {
if (queue.length === 0) return;
setSaveModalOpen(true);
if (activePlaylist) {
setSaveState('saving');
try {
await updatePlaylist(activePlaylist.id, queue.map(t => t.id));
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 1500);
} catch (e) {
console.error('Failed to update playlist', e);
setSaveState('idle');
}
} else {
setSaveModalOpen(true);
}
};
const handleLoad = () => {
@@ -186,6 +290,7 @@ export default function QueuePanel() {
const handleClear = () => {
clearQueue();
setActivePlaylist(null);
};
const onDragStart = (e: React.DragEvent, index: number) => {
@@ -207,11 +312,20 @@ export default function QueuePanel() {
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
dragOverIdxRef.current = index;
setDragOverIdx(index);
if (!isDraggingInternalRef.current) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
const target = { idx: index, before };
externalDropTargetRef.current = target;
setExternalDropTarget(target);
}
};
const onDragEnd = () => {
setDraggedIdx(null);
setDragOverIdx(null);
setExternalDropTarget(null);
externalDropTargetRef.current = null;
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
@@ -223,12 +337,19 @@ export default function QueuePanel() {
const onDropQueue = async (e: React.DragEvent) => {
e.preventDefault();
// Snapshot drop target before clearing visual state
const droppedTarget = externalDropTargetRef.current;
// Clear visual state immediately
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
setDraggedIdx(null);
setDragOverIdx(null);
externalDragCounterRef.current = 0;
setExternalDragOver(false);
externalDropTargetRef.current = null;
setExternalDropTarget(null);
let parsedData: any = null;
try {
@@ -263,8 +384,13 @@ export default function QueuePanel() {
// External drop (song / album dragged from elsewhere in the app)
_dragFromIdx = null;
if (!parsedData) return;
const insertIdx = droppedTarget
? (droppedTarget.before ? droppedTarget.idx : droppedTarget.idx + 1)
: usePlayerStore.getState().queue.length;
if (parsedData.type === 'song') {
enqueue([parsedData.track]);
enqueueAt([parsedData.track], insertIdx);
} else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id);
const tracks: Track[] = albumData.songs.map(s => ({
@@ -272,21 +398,61 @@ export default function QueuePanel() {
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
enqueue(tracks);
enqueueAt(tracks, insertIdx);
}
};
return (
<aside
className="queue-panel"
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
ref={asideRef}
className={`queue-panel${(externalDragOver || isPsyDragging) ? ' queue-drop-active' : ''}`}
onDragEnter={e => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
if (!isDraggingInternalRef.current) {
externalDragCounterRef.current++;
setExternalDragOver(true);
}
}}
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onMouseMove={e => {
if (!isPsyDragging || !queueListRef.current) return;
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
let found = false;
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
const before = e.clientY < rect.top + rect.height / 2;
const idx = parseInt(items[i].dataset.queueIdx!);
const target = { idx, before };
externalDropTargetRef.current = target;
setExternalDropTarget(target);
found = true;
break;
}
}
if (!found) {
externalDropTargetRef.current = null;
setExternalDropTarget(null);
}
}}
onDragLeave={e => {
if (!isDraggingInternalRef.current) {
externalDragCounterRef.current--;
if (externalDragCounterRef.current === 0) {
setExternalDragOver(false);
externalDropTargetRef.current = null;
setExternalDropTarget(null);
}
}
}}
onDrop={onDropQueue}
style={{
borderLeftWidth: isQueueVisible ? 1 : 0
}}
>
<div className="queue-header">
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', minWidth: 0 }}>
<h2 style={{ fontSize: '16px', fontWeight: 700, margin: 0, flexShrink: 0 }}>{t('queue.title')}</h2>
{queue.length > 0 && (() => {
@@ -315,6 +481,13 @@ export default function QueuePanel() {
);
})()}
</div>
{activePlaylist && (
<div className="truncate" style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<ListMusic size={10} style={{ flexShrink: 0 }} />
<span className="truncate">{activePlaylist.name}</span>
</div>
)}
</div>
</div>
{currentTrack && (
@@ -362,8 +535,14 @@ export default function QueuePanel() {
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
<Shuffle size={13} />
</button>
<button className="queue-round-btn" onClick={handleSave} data-tooltip={t('queue.savePlaylist')} aria-label={t('queue.savePlaylist')}>
<Save size={13} />
<button
className={`queue-round-btn${saveState === 'saved' ? ' active' : ''}`}
onClick={handleSave}
disabled={saveState === 'saving'}
data-tooltip={activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
aria-label={t('queue.savePlaylist')}
>
{saveState === 'saved' ? <Check size={13} /> : <Save size={13} />}
</button>
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
<FolderOpen size={13} />
@@ -444,11 +623,19 @@ export default function QueuePanel() {
if (isDragging) {
dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' };
} else if (isDragOver && draggedIdx !== null) {
// Internal reorder indicator
if (draggedIdx > idx) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else {
dragStyle = { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
}
} else if (externalDropTarget?.idx === idx && draggedIdx === null) {
// External drop insertion indicator
if (externalDropTarget.before) {
dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' };
} else {
dragStyle = { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' };
}
}
return (
@@ -507,23 +694,26 @@ export default function QueuePanel() {
</div>
{saveModalOpen && (
<SavePlaylistModal
onClose={() => setSaveModalOpen(false)}
onSave={async (name) => {
<SavePlaylistModal
onClose={() => setSaveModalOpen(false)}
onSave={async (name) => {
try {
await createPlaylist(name, queue.map(t => t.id));
setSaveModalOpen(false);
const playlists = await getPlaylists();
const created = playlists.find(p => p.name === name);
if (created) setActivePlaylist({ id: created.id, name: created.name });
setSaveModalOpen(false);
} catch (e) {
console.error('Failed to save playlist', e);
}
}}
}}
/>
)}
{loadModalOpen && (
<LoadPlaylistModal
onClose={() => setLoadModalOpen(false)}
onLoad={async (id) => {
<LoadPlaylistModal
onClose={() => setLoadModalOpen(false)}
onLoad={async (id, name) => {
try {
const data = await getPlaylist(id);
const tracks: Track[] = data.songs.map(s => ({
@@ -535,11 +725,12 @@ export default function QueuePanel() {
clearQueue();
playTrack(tracks[0], tracks);
}
setLoadModalOpen(false);
setActivePlaylist({ id, name });
setLoadModalOpen(false);
} catch (e) {
console.error('Failed to load playlist', e);
}
}}
}}
/>
)}
</aside>
+3 -3
View File
@@ -7,8 +7,8 @@ import { version as appVersion } from '../../package.json';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle, AudioLines, HardDriveDownload, Tags
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
@@ -19,7 +19,7 @@ const navItems = [
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
{ icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums' },
{ icon: Users, labelKey: 'sidebar.artists', to: '/artists' },
{ icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists' },
{ icon: Tags, labelKey: 'sidebar.genres', to: '/genres' },
{ icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' },
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
];
+13 -2
View File
@@ -60,15 +60,18 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Operating Systems',
themes: [
{ id: 'ubuntu-ambiance', label: 'Ubuntu', bg: '#f4efea', card: '#3d1f3d', accent: '#e95420' },
{ id: 'aqua-quartz', label: 'Aqua Quartz', bg: '#f6f6f6', card: '#ffffff', accent: '#3876f7' },
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'dos', label: 'DOS', bg: '#0000AA', card: '#000080', accent: '#FFFF55' },
{ id: 'unix', label: 'Unix', bg: '#000000', card: '#111111', accent: '#22C55E' },
{ id: 'w3-1', label: 'W3.1', bg: '#c0c0c0', card: '#ffffff', accent: '#000080' },
{ id: 'aero-glass', label: 'W7', bg: '#b8cfe8', card: '#05080f', accent: '#1878e8' },
{ id: 'w98', label: 'W98', bg: '#008080', card: '#d4d0c8', accent: '#000080' },
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#1248b8', accent: '#3c9d29' },
{ id: 'wista', label: 'Wista', bg: '#eef3fc', card: '#0e1e3e', accent: '#1565c8' },
{ id: 'aero-glass', label: 'W7', bg: '#b8cfe8', card: '#05080f', accent: '#1878e8' },
{ id: 'w10', label: 'W10', bg: '#f3f3f3', card: '#ffffff', accent: '#0078d4' },
{ id: 'w11', label: 'W11', bg: '#202020', card: '#2c2c2c', accent: '#0078d4' },
],
},
@@ -102,6 +105,14 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
{ id: 'heisenberg', label: 'Heisenberg', bg: '#0b0e12', card: '#141a22', accent: '#35d4f8' },
{ id: 'turtle-power', label: 'Turtle Power', bg: '#1a1a1a', card: '#0a0a0a', accent: '#33cc33' },
{ id: 'north-park', label: 'North Park', bg: '#F5F1E8', card: '#FFFFFF', accent: '#FF8C00' },
],
},
{
group: 'Famous Albums',
themes: [
{ id: 'dark-side-of-the-moon', label: 'Dark Side of the Moon (inspired)', bg: '#050505', card: '#0D0D0D', accent: '#9B30FF' },
{ id: 'powerslave', label: 'Powerslave (inspired)', bg: '#F0DFB0', card: '#2A1808', accent: '#C8960C' },
],
},
{
+233
View File
@@ -0,0 +1,233 @@
/**
* Mouse-event-based Drag & Drop system.
*
* Replaces the HTML5 Drag & Drop API for cross-component drags (song queue,
* album queue) because WebKitGTK on Linux always shows a "forbidden" cursor
* during native HTML5 DnD and there is no way to fix it at the GTK level
* without breaking DnD entirely.
*
* This system uses mousedown / mousemove / mouseup which keeps cursor control
* in CSS and avoids the native DnD subsystem completely.
*/
import React, {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
// ── Types ─────────────────────────────────────────────────────────
export interface DragPayload {
/** Serialised JSON identical to what was previously in dataTransfer */
data: string;
/** Label shown on the ghost element */
label: string;
/** Optional cover URL for the ghost */
coverUrl?: string;
}
interface DragState {
payload: DragPayload | null;
position: { x: number; y: number };
}
interface DragDropContextValue {
/** Begin a drag. Called from mousedown (after threshold). */
startDrag: (payload: DragPayload, x: number, y: number) => void;
/** Current drag payload (null when idle). */
payload: DragPayload | null;
/** Whether a drag is in progress. */
isDragging: boolean;
}
const Ctx = createContext<DragDropContextValue>({
startDrag: () => {},
payload: null,
isDragging: false,
});
export const useDragDrop = () => useContext(Ctx);
// ── Ghost overlay ─────────────────────────────────────────────────
function DragGhost({ state }: { state: DragState }) {
if (!state.payload) return null;
const { label, coverUrl } = state.payload;
return createPortal(
<div
style={{
position: 'fixed',
left: state.position.x + 12,
top: state.position.y - 20,
pointerEvents: 'none',
zIndex: 99999,
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'var(--bg-card, #1e1e2e)',
border: '1px solid var(--border, rgba(255,255,255,0.1))',
borderRadius: 8,
padding: '6px 12px',
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
color: 'var(--text-primary, #fff)',
fontSize: 13,
fontWeight: 500,
maxWidth: 280,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
opacity: 0.95,
userSelect: 'none',
}}
>
{coverUrl && (
<img
src={coverUrl}
alt=""
style={{
width: 28,
height: 28,
borderRadius: 4,
objectFit: 'cover',
flexShrink: 0,
}}
/>
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{label}
</span>
</div>,
document.body,
);
}
// ── Provider ──────────────────────────────────────────────────────
export function DragDropProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<DragState>({
payload: null,
position: { x: 0, y: 0 },
});
const stateRef = useRef(state);
stateRef.current = state;
const startDrag = useCallback(
(payload: DragPayload, x: number, y: number) => {
// Clear any text selection the browser may have started during the
// threshold detection phase (mousedown → mousemove before startDrag).
window.getSelection()?.removeAllRanges();
setState({ payload, position: { x, y } });
},
[],
);
// Global mousemove + mouseup listeners (only while dragging)
useEffect(() => {
if (!state.payload) return;
const onMove = (e: MouseEvent) => {
// preventDefault stops the browser from treating the mouse movement as
// a text-selection drag, which causes element highlighting and
// horizontal auto-scroll in grid containers.
e.preventDefault();
setState((prev) => ({ ...prev, position: { x: e.clientX, y: e.clientY } }));
};
const onUp = () => {
// Clear any residual selection (from the pre-threshold phase).
window.getSelection()?.removeAllRanges();
// Dispatch a custom event so drop targets can react.
// The payload is in `detail`.
const evt = new CustomEvent('psy-drop', {
bubbles: true,
detail: stateRef.current.payload,
});
// Find element under cursor
const el = document.elementFromPoint(
stateRef.current.position.x,
stateRef.current.position.y,
);
if (el) el.dispatchEvent(evt);
setState({ payload: null, position: { x: 0, y: 0 } });
};
document.addEventListener('mousemove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
// Add a class so CSS can show grab cursor and suppress selection
document.body.classList.add('psy-dragging');
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.classList.remove('psy-dragging');
};
}, [state.payload !== null]); // eslint-disable-line react-hooks/exhaustive-deps
const ctxValue: DragDropContextValue = {
startDrag,
payload: state.payload,
isDragging: state.payload !== null,
};
return (
<Ctx.Provider value={ctxValue}>
{children}
<DragGhost state={state} />
</Ctx.Provider>
);
}
// ── useDragSource hook ────────────────────────────────────────────
const DRAG_THRESHOLD = 5; // px before drag starts
/**
* Returns an onMouseDown handler for a draggable element.
* Usage: <div {...useDragSource(payload)} />
*/
export function useDragSource(getPayload: () => DragPayload) {
const { startDrag } = useDragDrop();
const startPosRef = useRef<{ x: number; y: number } | null>(null);
const payloadRef = useRef(getPayload);
payloadRef.current = getPayload;
const onMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only left-click
if (e.button !== 0) return;
const startX = e.clientX;
const startY = e.clientY;
startPosRef.current = { x: startX, y: startY };
const onMove = (me: MouseEvent) => {
if (!startPosRef.current) return;
const dx = me.clientX - startX;
const dy = me.clientY - startY;
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
startPosRef.current = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
startDrag(payloadRef.current(), me.clientX, me.clientY);
}
};
const onUp = () => {
startPosRef.current = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
[startDrag],
);
return { onMouseDown };
}
+711 -93
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -91,32 +91,35 @@ export default function AlbumDetail() {
const handlePlayAll = () => {
if (!album) return;
const albumGenre = album.album.genre;
const tracks = album.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
starred: s.starred, genre: s.genre,
starred: s.starred, genre: s.genre ?? albumGenre,
}));
if (tracks[0]) playTrack(tracks[0], tracks);
};
const handleEnqueueAll = () => {
if (!album) return;
const albumGenre = album.album.genre;
const tracks = album.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
starred: s.starred, genre: s.genre,
starred: s.starred, genre: s.genre ?? albumGenre,
}));
enqueue(tracks);
};
const handlePlaySong = (song: SubsonicSong) => {
const albumGenre = album?.album.genre;
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
starred: song.starred, genre: song.genre,
starred: song.starred, genre: song.genre ?? albumGenre,
};
playTrack(track, [track]);
};
+45 -21
View File
@@ -1,10 +1,19 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
const PAGE_SIZE = 30;
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>();
return results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
}
export default function Albums() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
@@ -12,9 +21,9 @@ export default function Albums() {
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const PAGE_SIZE = 30;
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const observerTarget = useRef<HTMLDivElement>(null);
const filtered = selectedGenres.length > 0;
const load = useCallback(async (sortType: SortType, offset: number, append = false) => {
setLoading(true);
@@ -28,27 +37,40 @@ export default function Albums() {
}
}, []);
useEffect(() => { setPage(0); load(sort, 0); }, [sort, load]);
const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => {
setLoading(true);
try {
const data = await fetchByGenres(genres);
const sorted = [...data].sort((a, b) =>
sortType === 'alphabeticalByArtist'
? a.artist.localeCompare(b.artist)
: a.name.localeCompare(b.name)
);
setAlbums(sorted);
setHasMore(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (filtered) loadFiltered(selectedGenres, sort);
else { setPage(0); load(sort, 0); }
}, [sort, filtered, selectedGenres, load, loadFiltered]);
const loadMore = useCallback(() => {
if (loading || !hasMore) return;
if (loading || !hasMore || filtered) return;
const next = page + 1;
setPage(next);
load(sort, next * PAGE_SIZE, true);
}, [loading, hasMore, page, sort, load]);
}, [loading, hasMore, page, sort, load, filtered]);
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
loadMore();
}
},
entries => { if (entries[0].isIntersecting) loadMore(); },
{ rootMargin: '200px' }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
if (observerTarget.current) observer.observe(observerTarget.current);
return () => observer.disconnect();
}, [loadMore]);
@@ -59,9 +81,9 @@ export default function Albums() {
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h1 className="page-title">{t('albums.title')}</h1>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('albums.title')}</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{sortOptions.map(o => (
<button
key={o.value}
@@ -72,6 +94,7 @@ export default function Albums() {
{o.label}
</button>
))}
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
</div>
</div>
@@ -84,10 +107,11 @@ export default function Albums() {
<div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
{!filtered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
</>
)}
</div>
+17 -4
View File
@@ -7,6 +7,7 @@ import { ListPlus, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
import { useDragDrop } from '../contexts/DragDropContext';
export default function Favorites() {
const { t } = useTranslation();
@@ -16,6 +17,7 @@ export default function Favorites() {
const [loading, setLoading] = useState(true);
const { playTrack, enqueue } = usePlayerStore();
const psyDrag = useDragDrop();
function removeSong(id: string) {
unstar(id, 'song').catch(() => {});
@@ -104,10 +106,21 @@ export default function Favorites() {
onDoubleClick={() => playTrack(song, songs)}
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
draggable={false}
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="track-num col-center" onClick={() => playTrack(song, songs)} style={{ cursor: 'pointer' }}>
+86
View File
@@ -0,0 +1,86 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Disc3 } from 'lucide-react';
import { getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
const PAGE_SIZE = 50;
export default function GenreDetail() {
const { name } = useParams<{ name: string }>();
const genre = decodeURIComponent(name ?? '');
const { t } = useTranslation();
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [offset, setOffset] = useState(0);
useEffect(() => {
setAlbums([]);
setOffset(0);
setHasMore(true);
setLoading(true);
getAlbumsByGenre(genre, PAGE_SIZE, 0)
.then(data => {
setAlbums(data);
setHasMore(data.length === PAGE_SIZE);
setOffset(PAGE_SIZE);
})
.finally(() => setLoading(false));
}, [genre]);
const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
getAlbumsByGenre(genre, PAGE_SIZE, offset)
.then(data => {
setAlbums(prev => [...prev, ...data]);
setHasMore(data.length === PAGE_SIZE);
setOffset(prev => prev + PAGE_SIZE);
})
.finally(() => setLoadingMore(false));
}, [genre, offset, loadingMore, hasMore]);
return (
<div className="content-body animate-fade-in">
<button
className="btn btn-ghost"
onClick={() => navigate(-1)}
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
<ArrowLeft size={16} />
<span>{t('genres.back')}</span>
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{genre}</h1>
{!loading && albums.length > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
<Disc3 size={14} style={{ color: 'var(--accent)' }} />
{t('genres.albumCount', { count: albums.length })}{hasMore ? '+' : ''}
</span>
)}
</div>
{loading && <p className="loading-text">{t('genres.albumsLoading')}</p>}
{!loading && albums.length === 0 && <p className="loading-text">{t('genres.albumsEmpty')}</p>}
{albums.length > 0 && (
<div className="album-grid-wrap">
{albums.map(album => <AlbumCard key={album.id} album={album} />)}
</div>
)}
{hasMore && !loading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
<button className="btn btn-surface" onClick={loadMore} disabled={loadingMore}>
{loadingMore ? t('common.loadingMore') : t('genres.loadMore')}
</button>
</div>
)}
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Headphones, Zap, Music2, Music, Cpu, Mic, Radio, Cloud,
Leaf, Heart, Sun, Flame, Film, Globe, BookOpen, Podcast, Star,
Tags, type LucideIcon,
} from 'lucide-react';
import { getGenres, SubsonicGenre } from '../api/subsonic';
function getGenreIcon(name: string): LucideIcon {
const n = name.toLowerCase();
if (/ambient|drone|new age/.test(n)) return Cloud;
if (/metal|hardcore|thrash|death|grind|doom/.test(n)) return Zap;
if (/rock/.test(n)) return Radio;
if (/jazz/.test(n)) return Music2;
if (/classical|orchestra|chamber|baroque|opera|symphon/.test(n)) return Music;
if (/electronic|techno|edm|house|trance|electro|synth/.test(n)) return Cpu;
if (/hip.?hop|rap/.test(n)) return Mic;
if (/pop/.test(n)) return Star;
if (/folk|country|bluegrass|americana/.test(n)) return Leaf;
if (/blues/.test(n)) return Music2;
if (/soul|r.?b|funk|gospel/.test(n)) return Heart;
if (/reggae|ska|dub/.test(n)) return Sun;
if (/punk/.test(n)) return Flame;
if (/soundtrack|score|ost|film|movie|cinema/.test(n)) return Film;
if (/world|latin|afro|celtic|tribal|traditional/.test(n)) return Globe;
if (/audiobook|spoken|hörbuch|speech|comedy/.test(n)) return BookOpen;
if (/podcast/.test(n)) return Podcast;
return Headphones;
}
const CTP_COLORS = [
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
'var(--ctp-blue)', 'var(--ctp-lavender)',
];
function genreColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return CTP_COLORS[h % CTP_COLORS.length];
}
const SCROLL_KEY = 'genres-scroll';
export default function Genres() {
const { t } = useTranslation();
const navigate = useNavigate();
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [loading, setLoading] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
getGenres()
.then(data => {
const sorted = [...data].sort((a, b) => b.albumCount - a.albumCount);
setGenres(sorted);
})
.finally(() => setLoading(false));
}, []);
// Restore scroll position after genres are rendered
useEffect(() => {
if (loading || genres.length === 0) return;
const saved = sessionStorage.getItem(SCROLL_KEY);
if (!saved) return;
const pos = parseInt(saved, 10);
sessionStorage.removeItem(SCROLL_KEY);
requestAnimationFrame(() => {
if (containerRef.current) containerRef.current.scrollTop = pos;
});
}, [loading, genres.length]);
const handleGenreClick = (genreValue: string) => {
if (containerRef.current) {
sessionStorage.setItem(SCROLL_KEY, String(containerRef.current.scrollTop));
}
navigate(`/genres/${encodeURIComponent(genreValue)}`);
};
return (
<div ref={containerRef} className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('genres.title')}</h1>
{!loading && genres.length > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
<Tags size={14} style={{ color: 'var(--accent)' }} />
{genres.length} {t('genres.genreCount')}
</span>
)}
</div>
{loading && <p className="loading-text">{t('genres.loading')}</p>}
{!loading && genres.length === 0 && <p className="loading-text">{t('genres.empty')}</p>}
{!loading && genres.length > 0 && (
<div className="album-grid-wrap">
{genres.map(genre => {
const Icon = getGenreIcon(genre.value);
const color = genreColor(genre.value);
return (
<div
key={genre.value}
className="genre-card"
style={{ '--genre-color': color } as React.CSSProperties}
onClick={() => handleGenreClick(genre.value)}
role="button"
tabIndex={0}
onKeyDown={e => e.key === 'Enter' && handleGenreClick(genre.value)}
data-tooltip={genre.value}
>
<div className="genre-card-watermark">
<Icon size={80} strokeWidth={1.2} />
</div>
<p className="genre-card-name">{genre.value}</p>
<p className="genre-card-count">
{t('genres.albumCount', { count: genre.albumCount })}
</p>
</div>
);
})}
</div>
)}
</div>
);
}
-1
View File
@@ -46,7 +46,6 @@ export default function Help() {
{ q: t('help.q24'), a: t('help.a24') },
{ q: t('help.q29'), a: t('help.a29') },
{ q: t('help.q30'), a: t('help.a30') },
{ q: t('help.q33'), a: t('help.a33') },
],
},
{
+41 -20
View File
@@ -1,17 +1,27 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
const PAGE_SIZE = 30;
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>();
const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
return union.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
}
export default function NewReleases() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const PAGE_SIZE = 30;
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const observerTarget = useRef<HTMLDivElement>(null);
const filtered = selectedGenres.length > 0;
const load = useCallback(async (offset: number, append = false) => {
setLoading(true);
@@ -25,34 +35,44 @@ export default function NewReleases() {
}
}, []);
useEffect(() => { setPage(0); load(0); }, [load]);
const loadFiltered = useCallback(async (genres: string[]) => {
setLoading(true);
try {
setAlbums(await fetchByGenres(genres));
setHasMore(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (filtered) loadFiltered(selectedGenres);
else { setPage(0); load(0); }
}, [filtered, selectedGenres, load, loadFiltered]);
const loadMore = useCallback(() => {
if (loading || !hasMore) return;
if (loading || !hasMore || filtered) return;
const next = page + 1;
setPage(next);
load(next * PAGE_SIZE, true);
}, [loading, hasMore, page, load]);
}, [loading, hasMore, page, load, filtered]);
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
loadMore();
}
},
entries => { if (entries[0].isIntersecting) loadMore(); },
{ rootMargin: '200px' }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
if (observerTarget.current) observer.observe(observerTarget.current);
return () => observer.disconnect();
}, [loadMore]);
return (
<div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('sidebar.newReleases')}</h1>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('sidebar.newReleases')}</h1>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
</div>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
@@ -62,10 +82,11 @@ export default function NewReleases() {
<div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
{!filtered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
</>
)}
</div>
-141
View File
@@ -1,141 +0,0 @@
import React, { useEffect, useState, useMemo } from 'react';
import { SubsonicPlaylist, getPlaylists, getPlaylist, deletePlaylist } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { Play, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type SortKey = 'name' | 'songCount' | 'duration';
type SortDir = 'asc' | 'desc';
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function SortHeader({
label, sortKey, current, dir, onSort
}: {
label: string;
sortKey: SortKey;
current: SortKey;
dir: SortDir;
onSort: (k: SortKey) => void;
}) {
const active = current === sortKey;
return (
<button className={`playlist-sort-btn${active ? ' active' : ''}`} onClick={() => onSort(sortKey)}>
{label}
{active ? (dir === 'asc' ? <ChevronUp size={13} /> : <ChevronDown size={13} />) : null}
</button>
);
}
export default function Playlists() {
const { t } = useTranslation();
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('name');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const playTrack = usePlayerStore(s => s.playTrack);
const clearQueue = usePlayerStore(s => s.clearQueue);
const fetchPlaylists = () => {
setLoading(true);
getPlaylists()
.then(data => { setPlaylists(data); setLoading(false); })
.catch(err => { console.error('Failed to load playlists', err); setLoading(false); });
};
useEffect(() => { fetchPlaylists(); }, []);
const handleSort = (key: SortKey) => {
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
};
const handlePlay = async (id: string) => {
try {
const data = await getPlaylist(id);
const tracks = data.songs.map((s: any) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, track: s.track, year: s.year,
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
}));
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
} catch (e) { console.error('Failed to play playlist', e); }
};
const handleDelete = async (id: string, name: string) => {
if (confirm(t('playlists.confirmDelete', { name }))) {
try { await deletePlaylist(id); fetchPlaylists(); }
catch (e) { console.error('Failed to delete playlist', e); }
}
};
const visible = useMemo(() => {
const q = filter.toLowerCase();
const filtered = q ? playlists.filter(p => p.name.toLowerCase().includes(q)) : playlists;
return [...filtered].sort((a, b) => {
let cmp = 0;
if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
else if (sortKey === 'songCount') cmp = a.songCount - b.songCount;
else cmp = a.duration - b.duration;
return sortDir === 'asc' ? cmp : -cmp;
});
}, [playlists, filter, sortKey, sortDir]);
return (
<div className="content-body animate-fade-in">
<div className="playlist-page-header">
<h1 className="page-title">{t('playlists.title')}</h1>
<input
className="playlist-filter-input"
type="search"
placeholder={t('playlists.filterPlaceholder')}
value={filter}
onChange={e => setFilter(e.target.value)}
/>
</div>
{loading ? (
<div className="loading-center"><div className="spinner" /></div>
) : playlists.length === 0 ? (
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>{t('playlists.empty')}</div>
) : (
<div className="playlist-list">
<div className="playlist-list-header">
<div />
<SortHeader label={t('playlists.colName')} sortKey="name" current={sortKey} dir={sortDir} onSort={handleSort} />
<SortHeader label={t('playlists.colTracks')} sortKey="songCount" current={sortKey} dir={sortDir} onSort={handleSort} />
<SortHeader label={t('playlists.colDuration')} sortKey="duration" current={sortKey} dir={sortDir} onSort={handleSort} />
<div />
</div>
{visible.length === 0 ? (
<div className="empty-state">{t('playlists.noResults')}</div>
) : visible.map(p => (
<div key={p.id} className="playlist-row">
<button className="playlist-play-icon" onClick={() => handlePlay(p.id)} data-tooltip={t('playlists.play')}>
<Play size={14} fill="currentColor" />
</button>
<span className="playlist-name truncate">{p.name}</span>
<span className="playlist-meta">{t('playlists.track', { count: p.songCount })}</span>
<span className="playlist-meta">{formatDuration(p.duration)}</span>
<button
className="btn btn-ghost playlist-delete-btn"
onClick={() => handleDelete(p.id, p.name)}
data-tooltip={t('playlists.deleteTooltip')}
>
<Trash2 size={15} />
</button>
</div>
))}
</div>
)}
</div>
);
}
+34 -14
View File
@@ -1,23 +1,40 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw } from 'lucide-react';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next';
const ALBUM_COUNT = 30;
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>();
const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
// Fisher-Yates shuffle
for (let i = union.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[union[i], union[j]] = [union[j], union[i]];
}
return union.slice(0, ALBUM_COUNT);
}
export default function RandomAlbums() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const loadingRef = useRef(false);
const filtered = selectedGenres.length > 0;
const load = useCallback(async () => {
const load = useCallback(async (genres: string[]) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const data = await getAlbumList('random', ALBUM_COUNT);
const data = genres.length > 0
? await fetchByGenres(genres)
: await getAlbumList('random', ALBUM_COUNT);
setAlbums(data);
} catch (e) {
console.error(e);
@@ -27,21 +44,24 @@ export default function RandomAlbums() {
}
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
<button
className="btn btn-ghost"
onClick={load}
disabled={loading}
data-tooltip={t('randomAlbums.refresh')}
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{t('randomAlbums.refresh')}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<button
className="btn btn-ghost"
onClick={() => load(selectedGenres)}
disabled={loading}
data-tooltip={t('randomAlbums.refresh')}
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{t('randomAlbums.refresh')}
</button>
</div>
</div>
{loading && albums.length === 0 ? (
+31 -12
View File
@@ -4,6 +4,7 @@ import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { Play, Star, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
const AUDIOBOOK_GENRES = [
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
@@ -45,6 +46,7 @@ export default function RandomMix() {
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const psyDrag = useDragDrop();
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const [addedGenre, setAddedGenre] = useState<string | null>(null);
@@ -343,11 +345,22 @@ export default function RandomMix() {
</div>
{genreMixSongs.map(song => (
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row"
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre }, 'song'); }}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre } }));
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre } }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
@@ -390,21 +403,27 @@ export default function RandomMix() {
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
onDoubleClick={() => playTrack(song, filteredSongs)}
role="row"
draggable
onContextMenu={e => {
e.preventDefault();
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre };
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre,
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre };
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<button
+17 -9
View File
@@ -6,6 +6,7 @@ import { usePlayerStore } from '../store/playerStore';
import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
function formatDuration(s: number) {
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
@@ -19,6 +20,7 @@ export default function SearchResults() {
const [loading, setLoading] = useState(false);
const playTrack = usePlayerStore(s => s.playTrack);
const currentTrack = usePlayerStore(s => s.currentTrack);
const psyDrag = useDragDrop();
useEffect(() => {
if (!query.trim()) { setResults(null); return; }
@@ -92,16 +94,22 @@ export default function SearchResults() {
style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}
onDoubleClick={() => playSong(song, results.songs)}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating, genre: song.genre,
draggable={false}
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre };
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<button
+17
View File
@@ -552,6 +552,7 @@ export default function Settings() {
{ value: 'en', label: t('settings.languageEn') },
{ value: 'fr', label: t('settings.languageFr') },
{ value: 'de', label: t('settings.languageDe') },
{ value: 'zh', label: t('settings.languageZh') },
]}
/>
</div>
@@ -1016,6 +1017,10 @@ export default function Settings() {
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutContributorsLabel')}</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutContributors')}</span>
</div>
</div>
<button
@@ -1060,6 +1065,8 @@ function renderInline(text: string): React.ReactNode[] {
function ChangelogSection() {
const { t } = useTranslation();
const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate);
const versions = useMemo(() => {
const blocks = changelogRaw.split(/\n(?=## \[)/).filter(b => b.startsWith('## ['));
@@ -1083,6 +1090,16 @@ function ChangelogSection() {
<Info size={18} />
<h2>{t('settings.changelog')}</h2>
</div>
<div className="settings-toggle-row" style={{ marginBottom: '1rem' }}>
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showChangelogOnUpdate')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showChangelogOnUpdateDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showChangelogOnUpdate')}>
<input type="checkbox" checked={showChangelogOnUpdate} onChange={e => setShowChangelogOnUpdate(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="changelog-list">
{versions.map(({ version, date, body }) => (
<details key={version} className="changelog-entry" open={version === appVersion}>
+8
View File
@@ -31,6 +31,8 @@ interface AuthState {
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
// Status
isLoggedIn: boolean;
@@ -60,6 +62,8 @@ interface AuthState {
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
logout: () => void;
// Derived
@@ -90,6 +94,8 @@ export const useAuthStore = create<AuthState>()(
crossfadeEnabled: false,
crossfadeSecs: 3,
gaplessEnabled: false,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
isLoggedIn: false,
isConnecting: false,
connectionError: null,
@@ -146,6 +152,8 @@ export const useAuthStore = create<AuthState>()(
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
logout: () => set({ isLoggedIn: false }),
+36 -3
View File
@@ -77,6 +77,7 @@ interface PlayerState {
setVolume: (v: number) => void;
setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[]) => void;
enqueueAt: (tracks: Track[], insertIndex: number) => void;
clearQueue: () => void;
isQueueVisible: boolean;
@@ -125,6 +126,9 @@ let playGeneration = 0;
// Debounce timer for seek slider drags.
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// Target time of the last seek — blocks stale Rust progress ticks until the
// engine has actually caught up to the new position.
let seekTarget: number | null = null;
// Guard against rapid double-click play/pause sending two state transitions
// to the Rust backend before it has finished the previous one.
@@ -159,6 +163,18 @@ function handleAudioPlaying(_duration: number) {
}
function handleAudioProgress(current_time: number, duration: number) {
// While a seek is pending, the store already holds the optimistic target
// position. Accepting stale progress from the Rust engine would briefly
// snap the waveform back to the old position before the seek completes.
if (seekDebounce) return;
// After the debounce fires, Rust may still emit 12 ticks with the old
// position before the seek takes effect. Block until current_time is
// within 2 s of the requested target, then clear the guard.
if (seekTarget !== null) {
if (Math.abs(current_time - seekTarget) > 2.0) return;
seekTarget = null;
}
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (!track) return;
@@ -484,7 +500,7 @@ export const usePlayerStore = create<PlayerState>()(
stop: () => {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
},
@@ -499,7 +515,7 @@ export const usePlayerStore = create<PlayerState>()(
const gen = ++playGeneration;
isAudioPaused = false;
gaplessPreloadingId = null; // new track — allow fresh preload for next
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
const state = get();
const newQueue = queue ?? state.queue;
@@ -650,6 +666,7 @@ export const usePlayerStore = create<PlayerState>()(
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
seekDebounce = null;
seekTarget = time;
invoke('audio_seek', { seconds: time }).catch(console.error);
}, 100);
},
@@ -674,10 +691,26 @@ export const usePlayerStore = create<PlayerState>()(
});
},
enqueueAt: (tracks, insertIndex) => {
set(state => {
const idx = Math.max(0, Math.min(insertIndex, state.queue.length));
const newQueue = [
...state.queue.slice(0, idx),
...tracks,
...state.queue.slice(idx),
];
const newQueueIndex = idx <= state.queueIndex
? state.queueIndex + tracks.length
: state.queueIndex;
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue, queueIndex: newQueueIndex };
});
},
clearQueue: () => {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
syncQueueToServer([], null, 0);
},
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'muma-jukebox' | 'winmedplayer' | 'p-dvd' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'gw1' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'stark-hud' | 'blade' | 'heisenberg' | 'ice-and-fire' | 'doh-matic' | 't-800' | 'dune' | 'tetrastack' | 'the-book' | 'readit' | 'insta' | 'hill-valley-85' | 'turtle-power' | 'w3-1' | 'aqua-quartz' | 'spider-tech' | 'dos' | 'unix' | 'jayfin' | 'horde' | 'alliance' | 'w11' | 'w10' | 'north-park' | 'dark-side-of-the-moon' | 'powerslave';
interface ThemeState {
theme: Theme;
+147 -3
View File
@@ -1036,7 +1036,7 @@
}
.tracklist-header.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
}
.track-row {
@@ -1052,7 +1052,7 @@
}
.track-row.track-row-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
}
.tracklist-total {
@@ -1065,7 +1065,7 @@
}
.tracklist-total.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px;
grid-template-columns: 36px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px;
}
.tracklist-total-label {
@@ -4055,3 +4055,147 @@
padding: 1px 4px;
border-radius: 3px;
}
/* ─ Genre Filter Bar ─ */
.genre-filter-tagbox {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--bg-card);
min-width: 220px;
cursor: text;
transition: border-color 0.15s;
}
.genre-filter-tagbox:focus-within {
border-color: var(--accent);
}
.genre-filter-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.15rem 0.3rem 0.15rem 0.5rem;
background: var(--accent);
color: var(--ctp-crust);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.genre-filter-chip button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
opacity: 0.75;
line-height: 1;
}
.genre-filter-chip button:hover { opacity: 1; }
.genre-filter-input {
border: none;
background: none;
outline: none;
color: var(--text-primary);
font-size: 0.85rem;
min-width: 100px;
flex: 1;
padding: 0.1rem 0;
}
.genre-filter-input::placeholder { color: var(--text-muted); }
.genre-filter-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
max-height: 220px;
overflow-y: auto;
z-index: 500;
}
.genre-filter-option {
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
color: var(--text-primary);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.genre-filter-option:hover {
background: var(--bg-hover);
color: var(--accent);
}
.genre-filter-empty {
padding: 0.6rem 0.75rem;
font-size: 0.82rem;
color: var(--text-muted);
}
/* ─ Genre Cards ─ */
.genre-card {
aspect-ratio: 1;
border-radius: 12px;
padding: 0.9rem;
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
background:
linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 45%, transparent 70%),
var(--genre-color, var(--accent));
transition: transform 0.15s ease, box-shadow 0.15s ease;
user-select: none;
}
.genre-card:hover {
transform: translateY(-3px) scale(1.02);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);
}
.genre-card-watermark {
position: absolute;
top: 50%;
right: -10px;
transform: translateY(-55%);
opacity: 0.22;
color: #fff;
pointer-events: none;
}
.genre-card-name {
font-size: 0.86rem;
font-weight: 700;
color: #fff;
margin: 0;
line-height: 1.25;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.genre-card-count {
font-size: 0.72rem;
color: rgba(255, 255, 255, 0.85);
margin: 0.2rem 0 0;
}
+14
View File
@@ -632,6 +632,15 @@
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
min-width: 2.5rem;
}
.player-time:first-child {
text-align: right;
}
.player-time:last-child {
text-align: left;
}
/* Volume section */
@@ -750,6 +759,11 @@
min-height: 0; /* allow 1fr row to shrink freely */
}
.queue-panel.queue-drop-active {
box-shadow: inset 0 0 0 2px var(--accent);
background: linear-gradient(var(--accent-dim), var(--accent-dim)), var(--bg-sidebar);
}
.queue-header {
height: 64px;
box-sizing: border-box;
+1635 -89
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
/**
* Creates a slim, themed drag ghost and registers it via setDragImage.
* The element is cleaned up when the drag completes (dragend).
*
* On Linux (WebKitGTK), the GTK drag subsystem captures the drag image
* asynchronously removing the element in the same tick or via
* setTimeout(, 0) causes the image to be gone before GTK reads it,
* which makes GTK treat the entire drag as invalid (forbidden cursor).
* Using a dragend listener ensures the element persists for the full
* duration of the drag operation on all platforms.
*
* @param dataTransfer - the event's dataTransfer object
* @param label - primary text (track/album title)
* @param opts.coverUrl - optional thumbnail URL (shown as 24×24 image)
*/
export function setDragGhost(
dataTransfer: DataTransfer,
label: string,
opts: { coverUrl?: string } = {},
): void {
const el = document.createElement('div');
el.style.cssText = [
'position:fixed',
'left:-9999px',
'top:-9999px',
'display:flex',
'align-items:center',
'gap:8px',
`padding:0 14px 0 ${opts.coverUrl ? '6px' : '10px'}`,
'height:34px',
'max-width:240px',
'border-radius:17px',
'background:var(--bg-card,#fff)',
'border:1px solid var(--border,rgba(0,0,0,.12))',
'border-left:3px solid var(--accent,#888)',
'box-shadow:0 4px 20px rgba(0,0,0,.22)',
'font-family:var(--font-ui,sans-serif)',
'font-size:13px',
'font-weight:500',
'color:var(--text-primary,#222)',
'pointer-events:none',
'white-space:nowrap',
'overflow:hidden',
'z-index:99999',
].join(';');
if (opts.coverUrl) {
const img = document.createElement('img');
img.src = opts.coverUrl;
img.style.cssText = 'width:24px;height:24px;border-radius:4px;object-fit:cover;flex-shrink:0;';
el.appendChild(img);
} else {
const dot = document.createElement('span');
dot.style.cssText = 'width:6px;height:6px;border-radius:50%;background:var(--accent,#888);flex-shrink:0;';
el.appendChild(dot);
}
const text = document.createElement('span');
text.textContent = label;
text.style.cssText = 'overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0;';
el.appendChild(text);
document.body.appendChild(el);
dataTransfer.setDragImage(el, 20, 17);
// Clean up the ghost element when the drag ends, not immediately.
// This is critical for Linux/WebKitGTK where the drag image is
// captured asynchronously by GTK — removing it sooner causes
// the "forbidden" cursor and blocks the entire drop operation.
const cleanup = () => {
el.remove();
document.removeEventListener('dragend', cleanup);
};
document.addEventListener('dragend', cleanup, { once: true });
}
+6 -1
View File
@@ -20,7 +20,12 @@ function fadeOut(setVolume: (v: number) => void, from: number, durationMs: numbe
export async function playAlbum(albumId: string): Promise<void> {
const albumData = await getAlbum(albumId);
const tracks = albumData.songs.map(songToTrack);
const albumGenre = albumData.album.genre;
const tracks = albumData.songs.map(s => {
const track = songToTrack(s);
if (!track.genre && albumGenre) track.genre = albumGenre;
return track;
});
if (!tracks.length) return;
const store = usePlayerStore.getState();