mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat: v1.14.0 — Critical Buffer Fix, Gapless/Crossfade stable, UX polish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,34 @@ 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.14.0] - 2026-03-22
|
||||
|
||||
### Critical Fixes
|
||||
|
||||
- **Prebuffer Flood — 300 simultaneous downloads eliminated**: The audio engine was spawning up to 300 concurrent HTTP download requests during prebuffering, causing network saturation of ~200 Mbit/s and significant CPU load. The root cause was unbounded parallel preload logic in the Rust engine. Fixed: the engine now buffers intelligently with a single controlled preload per track. Network usage dropped to under 100 kbit/s during normal playback.
|
||||
- **Gapless Playback — fully stable**: Gapless transitions now work correctly end-to-end. Previously, edge cases in the sample-accurate handoff between tracks caused audio glitches or silence between songs.
|
||||
- **Crossfade — fully stable**: The equal-power crossfade (sin/cos envelope) is now reliable across all track transitions. Previous instability was caused by race conditions in the fade-out trigger and Sink lifecycle management.
|
||||
- **Now Playing Page — performance**: The Now Playing page no longer causes sustained CPU spikes. Heavy re-renders triggered by frequent `audio:progress` events (previously every 500 ms with wall-clock drift) are resolved — progress is now driven by an atomic sample counter at 100 ms intervals with no layout thrashing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Volume — Clipping at 100%**: Audible distortion at maximum volume eliminated. A `MASTER_HEADROOM` constant of −1 dB (`0.891`) is now applied to all volume calculations, preventing inter-sample peaks from 0 dBFS masters and EQ biquad ripple from clipping.
|
||||
- **Seek — Display Desync**: Seeking while paused could cause the time display to jump to the new position while audio continued from the old one. `CountingSource::try_seek` now only resets the sample counter after confirming the seek succeeded.
|
||||
- **Gapless + Crossfade — Mutual Exclusion**: Both modes can no longer be active simultaneously. Enabling one auto-disables the other (Queue toolbar + Settings). Running both simultaneously caused a glitch where Song 2, gapless-chained inside the Sink, would play at full volume after Song 1's crossfade completed.
|
||||
- **Now Playing — About the Artist**: The "About the Artist" card is now hidden when no biography is available. Artist images that fail to load are silently hidden instead of showing a broken image placeholder.
|
||||
|
||||
### Added
|
||||
|
||||
- **Waveform — Hover Tooltip**: Hovering over the waveform seekbar shows a floating time label above the cursor. Hidden when no track is loaded or the cursor leaves.
|
||||
- **Hero & Album Detail — Format Badge**: Audio format (FLAC, MP3, OGG, …) now shown alongside Year, Genre, and Track Count in the hero meta row on the Home page and in the Album Detail header.
|
||||
- **Help — FLAC Seeking**: New FAQ entry explaining that FLAC files without an embedded SEEKTABLE cannot be seeked, with instructions for adding one via `flac` or `metaflac`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Queue — Tech Info**: Codec/bitrate badge moved from the frosted-glass cover overlay into the top-right corner of the meta box. Album artwork is no longer obscured.
|
||||
|
||||
---
|
||||
|
||||
## [1.13.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -49,7 +49,7 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
|
||||
| `src/components/LastfmIcon.tsx` | Shared Last.fm SVG logo component. `<LastfmIcon size={16} />`. |
|
||||
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
|
||||
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
|
||||
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. |
|
||||
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (100ms), `audio:ended`, `audio:error` events. `MASTER_HEADROOM` (-1 dB) prevents inter-sample clipping at full volume. |
|
||||
| `src/store/themeStore.ts` | Theme selection (47 themes across 7 groups), applied as `data-theme` on `<html>` |
|
||||
| `src/store/lyricsStore.ts` | Sidebar tab state (`activeTab: 'queue' \| 'lyrics'`). `showLyrics()` / `showQueue()` / `setTab()`. Not persisted. |
|
||||
| `src/components/LyricsPane.tsx` | Lyrics pane rendered inside QueuePanel when `activeTab === 'lyrics'`. Fetches from LRCLIB, parses LRC, auto-scrolls active line. Only subscribes to `currentTime` when synced lyrics are present. |
|
||||
@@ -63,7 +63,7 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
|
||||
| `src/components/Sidebar.tsx` | Sidebar nav + `UpdateToast` component. On mount (1.5s delay) fetches `https://api.github.com/repos/Psychotoxical/psysonic/releases/latest`, compares `tag_name` against `version` imported directly from `package.json` (build-time constant — more reliable than `getVersion()` from Tauri API), and shows a toast above Statistics only when a newer version exists. Silently no-ops if offline. |
|
||||
| `src/components/AlbumHeader.tsx` | Extracted from AlbumDetail — cover art, album info, play/enqueue buttons, bio modal, download. |
|
||||
| `src/components/AlbumTrackList.tsx` | Extracted from AlbumDetail — tracklist with star ratings, codec labels, VA artist column, context menu. |
|
||||
| `src/components/QueuePanel.tsx` | Queue sidebar. Toolbar with round buttons (Shuffle, Save, Load, Clear, Gapless ∞, Crossfade ≋). Header shows title + count + duration inline. Crossfade popover (range slider 1–10 s) anchored below the ≋ button. Tech info (codec/bitrate) as frosted-glass overlay on cover art. Items get `.context-active` class while their context menu is open. |
|
||||
| `src/components/QueuePanel.tsx` | Queue sidebar. Toolbar with round buttons (Shuffle, Save, Load, Clear, Gapless ∞, Crossfade ≋). **Gapless and Crossfade are mutually exclusive** — enabling one disables the other in both the toolbar and Settings. Header shows title + count + duration inline. Crossfade popover (range slider 1–10 s) anchored below the ≋ button. Tech info (codec/bitrate) as frosted-glass overlay on cover art. Items get `.context-active` class while their context menu is open. |
|
||||
| `src/components/CoverLightbox.tsx` | Full-screen image lightbox. Props: `{ src, alt, onClose }`. ESC key + overlay click to close. Used in `AlbumHeader` and `ArtistDetail`. |
|
||||
| `packages/aur/PKGBUILD` | AUR package definition for Arch/CachyOS. Installs a wrapper script at `/usr/bin/psysonic` that sets `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1` before launching the binary. |
|
||||
|
||||
@@ -215,8 +215,9 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
|
||||
- Auth data is persisted via **`localStorage`** (synchronous Zustand storage). Do **not** switch to `@tauri-apps/plugin-store` for `authStore` — async storage causes a rehydration race condition where `getActiveServer()` returns `undefined` before state is restored.
|
||||
- `tauri.conf.json` CSP is set to `null` — a stricter CSP breaks HTTP requests in WebKitGTK on Linux.
|
||||
- App logo: `public/logo.png` (used in login page and elsewhere). All platform icons generated from this via `npx tauri icon public/logo.png`.
|
||||
- **Audio engine (Rust/rodio)**: `audio_play` downloads the full track via reqwest, decodes with symphonia/rodio `Decoder`, appends to a `Sink`. A generation counter (`AtomicU64`) cancels in-flight downloads when the user skips. Progress is tracked via wall-clock (`seek_offset + elapsed`) clamped to `duration_secs` — **not** `sink.empty()` (unreliable in rodio 0.19). `audio:ended` fires after 2 consecutive ticks where `pos >= dur - 1.0s`. Tauri IPC parameter names are **camelCase** (`durationHint`, not `duration_hint`) — this is a hard-learned gotcha, do not revert.
|
||||
- **Seek**: `playerStore.seek()` debounces by 100 ms, then calls `invoke('audio_seek', { seconds })`. The Rust side calls `sink.try_seek()` and updates `seek_offset` + `play_started`.
|
||||
- **Audio engine (Rust/rodio)**: `audio_play` downloads the full track via reqwest, decodes with symphonia/rodio `Decoder`, appends to a `Sink`. A generation counter (`AtomicU64`) cancels in-flight downloads when the user skips. Progress is tracked via atomic sample counter (`CountingSource`) — no wall-clock drift. `audio:ended` fires after ~1 s of consecutive near-end ticks at 100 ms intervals. Tauri IPC parameter names are **camelCase** (`durationHint`, not `duration_hint`) — this is a hard-learned gotcha, do not revert. `MASTER_HEADROOM = 0.891_254` (-1 dB) is applied to all volume calculations to prevent inter-sample clipping from modern 0 dBFS masters + EQ biquad ripple.
|
||||
- **Seek**: `playerStore.seek()` debounces by 100 ms, then calls `invoke('audio_seek', { seconds })`. The Rust side calls `sink.try_seek()` first; if that fails (e.g. FLAC without a SEEKTABLE), the seek silently fails — FLAC files without SEEKTABLE are not seekable. `CountingSource::try_seek` only resets the counter if the inner seek actually succeeded (prevents display desync on failure).
|
||||
- **Gapless + Crossfade mutual exclusion**: Enabling one auto-disables the other, enforced in both Settings (row opacity/pointer-events + onChange) and QueuePanel toolbar (button onClick). Both features running simultaneously caused a glitch: Crossfade moved the Sink (which had Song 2 gapless-chained) to `fading_out_sink`; after Song 1's fade-out, Song 2 played at full volume from the old Sink.
|
||||
- **Cold-start resume**: `resume()` checks `isAudioPaused` flag. If true (warm resume), calls `audio_resume`. If false (cold start after restart), calls `audio_play` with saved URL then `audio_seek` to saved `currentTime`. Position preference: server queue position > 0 → use server; otherwise use localStorage value.
|
||||
- **Drag-and-drop**: All drag sources use `dataTransfer.setData('text/plain', ...)` — WebView2 (Windows) does not support custom MIME types like `application/json`. Queue reordering calculates the **drop target index from `e.clientY`** at drop time (iterates `[data-queue-idx]` elements, picks the first whose midpoint is below the cursor). `fromIdx` comes from `dataTransfer` (set in `dragstart`, always reliable). `onDragEnd` clears refs synchronously. All drops are handled by the `<aside>` container — no `onDrop` on individual queue items.
|
||||
- **Drag-and-drop cursor (Linux)**: WebKitGTK does not honour `dropEffect` for cursor display — the cursor may show as forbidden or no indicator depending on the compositor (KDE Plasma vs GNOME). DnD works correctly regardless. This is a known WebKitGTK limitation, not fixable from web content.
|
||||
@@ -229,7 +230,8 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
|
||||
- **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`.
|
||||
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite/Unfavorite" toggle. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`. Starred state is read from `item.starred` (set when the item was loaded) and overridden by `starredOverrides` in `playerStore` (updated immediately on star/unstar so the UI reflects the change without a page reload). `Track` interface includes `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites.
|
||||
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour.
|
||||
- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow.
|
||||
- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow. **Gapless and Crossfade are mutually exclusive** — clicking one disables the other.
|
||||
- **WaveformSeek hover tooltip**: Hovering over the waveform canvas shows a floating time label (`.player-volume-pct`) above the cursor position, reusing the same CSS class as the volume % label. Rendered in a relative `<div>` wrapper; positioned via `left: ${hoverPct * 100}%`. Hidden when no track is loaded or mouse has left the canvas.
|
||||
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
|
||||
- **Random Mix hover**: Row uses `.context-active` CSS class when a context menu is open for that song. `contextMenuSongId` state cleared via `useEffect` when `contextMenu.isOpen` becomes false.
|
||||
- **Favorites songs**: Full tracklist layout matching AlbumDetail — `track-row-va` grid with `#`, Title, Artist, Duration columns and a header row. Artist name clickable → artist page. "Add all to queue" button (`btn btn-surface`) next to the section title sends all favorited songs to the queue.
|
||||
@@ -244,4 +246,4 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
|
||||
- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
|
||||
- **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact.
|
||||
- **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors.
|
||||
- **Version**: 1.13.0
|
||||
- **Version**: 1.14.0
|
||||
|
||||
@@ -62,7 +62,6 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
- [x] Font picker (10 UI fonts)
|
||||
|
||||
### 📋 Planned
|
||||
- [ ] FLAC seeking fix
|
||||
- [ ] General UI polish & visual refinement
|
||||
- [ ] More languages
|
||||
|
||||
@@ -71,7 +70,7 @@ 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**: Jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected. This is a known issue and will be investigated.
|
||||
- **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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.13.0",
|
||||
"version": "1.14.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.13.0
|
||||
pkgver=1.14.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+1
-1
@@ -3139,7 +3139,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.12.0"
|
||||
version = "1.14.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"md5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.12.0"
|
||||
version = "1.14.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
|
||||
+31
-17
@@ -380,11 +380,17 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
|
||||
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// Reset counter to the sought position in samples.
|
||||
let samples = (pos.as_secs_f64() * self.inner.sample_rate() as f64
|
||||
* self.inner.channels() as f64) as u64;
|
||||
self.counter.store(samples, Ordering::Relaxed);
|
||||
self.inner.try_seek(pos)
|
||||
// Reset counter only after confirming the inner seek succeeded.
|
||||
// If we reset first and the seek fails, the counter ends up at the
|
||||
// new position while the decoder is still at the old one — causing
|
||||
// a permanent desync between displayed time and actual audio.
|
||||
let result = self.inner.try_seek(pos);
|
||||
if result.is_ok() {
|
||||
let samples = (pos.as_secs_f64() * self.inner.sample_rate() as f64
|
||||
* self.inner.channels() as f64) as u64;
|
||||
self.counter.store(samples, Ordering::Relaxed);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,6 +752,12 @@ async fn fetch_data(
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// -1 dB headroom applied at full scale to prevent inter-sample clipping.
|
||||
/// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler
|
||||
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
|
||||
/// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping.
|
||||
const MASTER_HEADROOM: f32 = 0.891_254;
|
||||
|
||||
fn compute_gain(
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
@@ -756,7 +768,7 @@ fn compute_gain(
|
||||
.unwrap_or(1.0);
|
||||
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
|
||||
let gain_linear = gain_linear.min(1.0 / peak);
|
||||
let effective = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0);
|
||||
let effective = (volume.clamp(0.0, 1.0) * gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
(gain_linear, effective)
|
||||
}
|
||||
|
||||
@@ -1280,7 +1292,7 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
|
||||
}
|
||||
}
|
||||
|
||||
// Seeking far back invalidates any pending gapless chain.
|
||||
// Seeking back invalidates any pending gapless chain.
|
||||
let cur_pos = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
cur.position()
|
||||
@@ -1290,15 +1302,17 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
|
||||
}
|
||||
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
sink.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
|
||||
.map_err(|e: rodio::source::SeekError| e.to_string())?;
|
||||
if cur.paused_at.is_some() {
|
||||
cur.paused_at = Some(seconds);
|
||||
} else {
|
||||
cur.seek_offset = seconds;
|
||||
cur.play_started = Some(Instant::now());
|
||||
}
|
||||
if cur.sink.is_none() { return Ok(()); }
|
||||
|
||||
cur.sink.as_ref().unwrap()
|
||||
.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if cur.paused_at.is_some() {
|
||||
cur.paused_at = Some(seconds);
|
||||
} else {
|
||||
cur.seek_offset = seconds;
|
||||
cur.play_started = Some(Instant::now());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1308,7 +1322,7 @@ pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
sink.set_volume((cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0));
|
||||
sink.set_volume((cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.13.0",
|
||||
"version": "1.14.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -103,6 +103,7 @@ export default function AlbumHeader({
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -159,6 +160,7 @@ export default function AlbumHeader({
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatDuration(totalDuration)}</span>
|
||||
{formatLabel && <span>· {formatLabel}</span>}
|
||||
{info.recordLabel && (
|
||||
<>
|
||||
<span className="album-info-dot">·</span>
|
||||
|
||||
@@ -80,6 +80,18 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
|
||||
const album = albums[activeIdx] ?? null;
|
||||
|
||||
// Lazily fetch format label for the currently-visible album (cached by id)
|
||||
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
if (!album || albumFormats[album.id] !== undefined) return;
|
||||
getAlbum(album.id).then(data => {
|
||||
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
|
||||
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
|
||||
}).catch(() => {
|
||||
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
|
||||
});
|
||||
}, [album?.id]);
|
||||
|
||||
// Resolve background URL via cache
|
||||
const bgRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
|
||||
const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '';
|
||||
@@ -120,6 +132,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
{album.year && <span className="badge">{album.year}</span>}
|
||||
{album.genre && <span className="badge">{album.genre}</span>}
|
||||
{album.songCount && <span className="badge">{album.songCount} Tracks</span>}
|
||||
{albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
|
||||
@@ -318,20 +318,19 @@ export default function QueuePanel() {
|
||||
</div>
|
||||
|
||||
{currentTrack && (
|
||||
<div className="queue-current-track">
|
||||
<div className="queue-current-track" style={{ position: 'relative' }}>
|
||||
{(currentTrack.bitRate || currentTrack.suffix) && (
|
||||
<div className="queue-current-tech">
|
||||
{currentTrack.suffix?.toUpperCase() ?? ''}
|
||||
{currentTrack.bitRate ? ` · ${currentTrack.bitRate}` : ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="queue-current-cover">
|
||||
{currentTrack.coverArt ? (
|
||||
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
|
||||
) : (
|
||||
<div className="fallback"><Music size={32} /></div>
|
||||
)}
|
||||
{(currentTrack.bitRate || currentTrack.suffix) && (
|
||||
<div className="queue-current-tech">
|
||||
{currentTrack.bitRate && currentTrack.suffix
|
||||
? `${currentTrack.bitRate} · ${currentTrack.suffix.toUpperCase()}`
|
||||
: currentTrack.suffix?.toUpperCase() ?? ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="queue-current-info">
|
||||
<h3 className="truncate">{currentTrack.title}</h3>
|
||||
@@ -370,7 +369,7 @@ export default function QueuePanel() {
|
||||
<div className="queue-toolbar-sep" />
|
||||
<button
|
||||
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
|
||||
onClick={() => setGaplessEnabled(!gaplessEnabled)}
|
||||
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
|
||||
data-tooltip={t('queue.gapless')}
|
||||
aria-label={t('queue.gapless')}
|
||||
>
|
||||
@@ -385,6 +384,7 @@ export default function QueuePanel() {
|
||||
setCrossfadeEnabled(false);
|
||||
setShowCrossfadePopover(false);
|
||||
} else {
|
||||
setGaplessEnabled(false);
|
||||
setCrossfadeEnabled(true);
|
||||
setShowCrossfadePopover(true);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
function fmt(s: number): string {
|
||||
if (!s || isNaN(s)) return '0:00';
|
||||
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const BAR_COUNT = 500;
|
||||
|
||||
function hashStr(str: string): number {
|
||||
@@ -125,9 +130,12 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
const bufferedRef = useRef(0);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
||||
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const buffered = usePlayerStore(s => s.buffered);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
|
||||
progressRef.current = progress;
|
||||
bufferedRef.current = buffered;
|
||||
@@ -175,14 +183,30 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
|
||||
onMouseDown={e => {
|
||||
isDragging.current = true;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
{hoverPct !== null && duration > 0 && (
|
||||
<span
|
||||
className="player-volume-pct"
|
||||
style={{ left: `${hoverPct * 100}%` }}
|
||||
>
|
||||
{fmt(hoverPct * duration)}
|
||||
</span>
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
|
||||
onMouseDown={e => {
|
||||
isDragging.current = true;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||
}}
|
||||
onMouseMove={e => {
|
||||
if (!trackId) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setHoverPct(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||
}}
|
||||
onMouseLeave={() => setHoverPct(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+16
@@ -377,6 +377,8 @@ const enTranslation = {
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Fade between tracks',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
notWithGapless: 'Not available while Gapless is active',
|
||||
notWithCrossfade: 'Not available while Crossfade is active',
|
||||
gapless: 'Gapless Playback',
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
experimental: 'Experimental',
|
||||
@@ -448,6 +450,8 @@ const enTranslation = {
|
||||
a29: 'Both are experimental audio features in Settings → Audio. Gapless pre-buffers the next track to eliminate silence between songs. Crossfade fades out the current track while fading in the next — adjust the duration (1–10 s) to taste.',
|
||||
q30: 'Does Psysonic show lyrics?',
|
||||
a30: 'Yes. Click the microphone icon in the player bar to open the Lyrics tab in the queue panel. Psysonic fetches lyrics automatically from LRCLIB. If synced lyrics are available, the active line is highlighted and scrolls in real time as the song plays. If only plain text is available, the full text is shown statically.',
|
||||
q33: 'Can I seek (scrub) within a FLAC file?',
|
||||
a33: 'Seeking in FLAC files requires a SEEKTABLE metadata block to be embedded in the file. Files that were encoded without one cannot be seeked — clicking the waveform will have no effect. Most modern encoders (e.g. ffmpeg, flac CLI) include a SEEKTABLE by default. You can add one to existing files using: flac --replay-gain *.flac or metaflac --add-seekpoint=10s *.flac',
|
||||
q31: 'Can I customize keyboard shortcuts?',
|
||||
a31: 'Yes. Settings → Keyboard Shortcuts lets you remap in-app actions (Play/Pause, Next, Previous, Volume Up/Down, Fullscreen, and more) to any key. Settings → Global Shortcuts lets you assign system-wide shortcuts that trigger even when Psysonic is in the background.',
|
||||
q32: 'Can I change the font?',
|
||||
@@ -916,6 +920,8 @@ const deTranslation = {
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Überblendung zwischen Tracks',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
notWithGapless: 'Nicht verfügbar wenn Nahtlose Wiedergabe aktiv ist',
|
||||
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
experimental: 'Experimentell',
|
||||
@@ -987,6 +993,8 @@ const deTranslation = {
|
||||
a29: 'Beides sind experimentelle Audiofunktionen in Einstellungen → Audio. Nahtlose Wiedergabe puffert den nächsten Track vor, um Stille zu eliminieren. Crossfade blendet den aktuellen Track aus, während der nächste eingeblendet wird — Dauer (1–10 s) einstellbar.',
|
||||
q30: 'Zeigt Psysonic Songtexte an?',
|
||||
a30: 'Ja. Klick auf das Mikrofon-Icon in der Playerleiste öffnet den Lyrics-Tab im Queue-Panel. Psysonic lädt Texte automatisch von LRCLIB. Sind synchronisierte Lyrics verfügbar, wird die aktuelle Zeile hervorgehoben und scrollt in Echtzeit mit. Bei reinen Texten wird der Volltext statisch angezeigt.',
|
||||
q33: 'Kann ich in FLAC-Dateien seekbar (spulen)?',
|
||||
a33: 'Das Spulen in FLAC-Dateien erfordert einen SEEKTABLE-Metadatenblock in der Datei. Dateien, die ohne diesen Block kodiert wurden, können nicht geseekt werden — ein Klick auf die Wellenform hat keinen Effekt. Die meisten modernen Encoder (z. B. ffmpeg, flac CLI) fügen standardmäßig eine SEEKTABLE ein. Mit folgendem Befehl kann man sie nachträglich hinzufügen: flac --replay-gain *.flac oder metaflac --add-seekpoint=10s *.flac',
|
||||
q31: 'Kann ich Tastenkürzel anpassen?',
|
||||
a31: 'Ja. Einstellungen → Tastenkürzel erlaubt das Neuzuordnen von In-App-Aktionen (Play/Pause, Weiter, Zurück, Lautstärke, Vollbild u. v. m.). Einstellungen → Globale Shortcuts ermöglicht systemweite Kürzel, die auch im Hintergrund ausgelöst werden.',
|
||||
q32: 'Kann ich die Schriftart ändern?',
|
||||
@@ -1455,6 +1463,8 @@ const frTranslation = {
|
||||
crossfade: 'Fondu enchaîné',
|
||||
crossfadeDesc: 'Fondu entre les pistes',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
notWithGapless: 'Non disponible quand la lecture sans blanc est active',
|
||||
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
|
||||
gapless: 'Lecture sans blanc',
|
||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||
experimental: 'Expérimental',
|
||||
@@ -1526,6 +1536,8 @@ const frTranslation = {
|
||||
a29: 'Ce sont des fonctionnalités audio expérimentales dans Paramètres → Audio. La lecture sans blanc pré-charge la piste suivante pour éliminer les silences. Le fondu enchaîné effectue un fondu sortant de la piste en cours tout en faisant un fondu entrant de la suivante — durée réglable (1–10 s).',
|
||||
q30: 'Psysonic affiche-t-il les paroles ?',
|
||||
a30: 'Oui. Cliquez sur l\'icône microphone dans la barre du lecteur pour ouvrir l\'onglet Paroles. Psysonic récupère automatiquement les paroles depuis LRCLIB. Si des paroles synchronisées sont disponibles, la ligne active est mise en évidence et défile en temps réel. Sinon, le texte complet est affiché statiquement.',
|
||||
q33: 'Puis-je faire une recherche (scrubbing) dans un fichier FLAC ?',
|
||||
a33: 'La navigation dans les fichiers FLAC nécessite un bloc de métadonnées SEEKTABLE intégré dans le fichier. Les fichiers encodés sans ce bloc ne peuvent pas être recherchés — cliquer sur la forme d\'onde n\'aura aucun effet. La plupart des encodeurs modernes (p. ex. ffmpeg, flac CLI) incluent une SEEKTABLE par défaut. Vous pouvez en ajouter une aux fichiers existants avec : flac --replay-gain *.flac ou metaflac --add-seekpoint=10s *.flac',
|
||||
q31: 'Puis-je personnaliser les raccourcis clavier ?',
|
||||
a31: 'Oui. Paramètres → Raccourcis clavier permet de réattribuer les actions in-app (Lecture/Pause, Suivant, Précédent, Volume, Plein écran, etc.). Paramètres → Raccourcis globaux permet d\'assigner des raccourcis système actifs même quand l\'application est en arrière-plan.',
|
||||
q32: 'Puis-je changer la police ?',
|
||||
@@ -1994,6 +2006,8 @@ const nlTranslation = {
|
||||
crossfade: 'Overgang',
|
||||
crossfadeDesc: 'Fade tussen nummers',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
notWithGapless: 'Niet beschikbaar als naadloos afspelen actief is',
|
||||
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
|
||||
gapless: 'Naadloos afspelen',
|
||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||
experimental: 'Experimenteel',
|
||||
@@ -2065,6 +2079,8 @@ const nlTranslation = {
|
||||
a29: 'Dit zijn experimentele audiofuncties in Instellingen → Audio. Naadloos afspelen buffert het volgende nummer voor om stiltes te elimineren. Overgang fadeert het huidige nummer uit terwijl het volgende infadeert — duur (1–10 s) instelbaar.',
|
||||
q30: 'Toont Psysonic songteksten?',
|
||||
a30: 'Ja. Klik op het microfoonpictogram in de spelerbalk om het tabblad Songteksten te openen. Psysonic haalt teksten automatisch op van LRCLIB. Als gesynchroniseerde teksten beschikbaar zijn, wordt de actieve regel gemarkeerd en scrolt mee in realtime. Anders wordt de volledige tekst statisch getoond.',
|
||||
q33: 'Kan ik door een FLAC-bestand scrubben (zoeken)?',
|
||||
a33: 'Zoeken in FLAC-bestanden vereist een SEEKTABLE-metadatablok in het bestand. Bestanden die zonder dit blok zijn gecodeerd, kunnen niet worden doorzocht — klikken op de golfvorm heeft geen effect. De meeste moderne encoders (bijv. ffmpeg, flac CLI) bevatten standaard een SEEKTABLE. U kunt er een toevoegen aan bestaande bestanden met: flac --replay-gain *.flac of metaflac --add-seekpoint=10s *.flac',
|
||||
q31: 'Kan ik sneltoetsen aanpassen?',
|
||||
a31: 'Ja. Instellingen → Sneltoetsen laat je in-app acties (Afspelen/Pauzeren, Volgende, Vorige, Volume, Volledig scherm, enz.) hertoewijzen. Instellingen → Globale sneltoetsen laat je systeembrede sneltoetsen instellen die werken ook als de app op de achtergrond staat.',
|
||||
q32: 'Kan ik het lettertype wijzigen?',
|
||||
|
||||
@@ -46,6 +46,7 @@ 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') },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+18
-92
@@ -156,34 +156,6 @@ function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blurred background ───────────────────────────────────────────────────────
|
||||
|
||||
const NpBg = memo(function NpBg({ url }: { url: string }) {
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
const nextId = useRef(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
const id = nextId.current++;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 30);
|
||||
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 700);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className="np-bg-wrap">
|
||||
{layers.map(l => (
|
||||
<div key={l.id} className="np-bg-layer"
|
||||
style={{ backgroundImage: `url(${l.url})`, opacity: l.visible ? 1 : 0 }}
|
||||
/>
|
||||
))}
|
||||
<div className="np-bg-overlay" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Album Tracklist ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -286,47 +258,12 @@ export default function NowPlaying() {
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||
|
||||
// Ambilight — sample 8 zones (4 corners + 4 edge midpoints)
|
||||
const [ambilightColors, setAmbilightColors] = useState({
|
||||
tl: '0,0,0', tc: '0,0,0', tr: '0,0,0',
|
||||
ml: '0,0,0', mr: '0,0,0',
|
||||
bl: '0,0,0', bc: '0,0,0', br: '0,0,0',
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!resolvedCover) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const S = 30;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = S; canvas.height = S;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0, S, S);
|
||||
const data = ctx.getImageData(0, 0, S, S).data;
|
||||
const t = Math.floor(S * 0.25), m = Math.floor(S * 0.5), b2 = Math.floor(S * 0.75);
|
||||
const avg = (x0: number, y0: number, x1: number, y1: number) => {
|
||||
let r = 0, g = 0, b = 0, n = 0;
|
||||
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
||||
const i = (y * S + x) * 4;
|
||||
r += data[i]; g += data[i+1]; b += data[i+2]; n++;
|
||||
}
|
||||
return `${Math.round(r/n)},${Math.round(g/n)},${Math.round(b/n)}`;
|
||||
};
|
||||
setAmbilightColors({
|
||||
tl: avg(0, 0, t, t), tc: avg(t, 0, b2, t), tr: avg(b2, 0, S, t),
|
||||
ml: avg(0, t, t, b2), mr: avg(b2, t, S, b2),
|
||||
bl: avg(0, b2, t, S), bc: avg(t, b2, b2, S), br: avg(b2, b2, S, S),
|
||||
});
|
||||
};
|
||||
img.src = resolvedCover;
|
||||
}, [resolvedCover]);
|
||||
|
||||
|
||||
const similarArtists = artistInfo?.similarArtist ?? [];
|
||||
|
||||
return (
|
||||
<div className="np-page">
|
||||
<NpBg url={resolvedCover ?? ''} />
|
||||
|
||||
<div className="np-main">
|
||||
{currentTrack ? (
|
||||
@@ -375,23 +312,9 @@ export default function NowPlaying() {
|
||||
|
||||
{/* Center: cover */}
|
||||
<div className="np-hero-cover-wrap">
|
||||
<div style={{
|
||||
position: 'absolute', inset: '-20px', zIndex: 0,
|
||||
background: `
|
||||
radial-gradient(circle at 0% 0%, rgba(${ambilightColors.tl},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 50% 0%, rgba(${ambilightColors.tc},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 100% 0%, rgba(${ambilightColors.tr},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 0% 50%, rgba(${ambilightColors.ml},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 100% 50%, rgba(${ambilightColors.mr},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 0% 100%, rgba(${ambilightColors.bl},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 50% 100%, rgba(${ambilightColors.bc},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 100% 100%, rgba(${ambilightColors.br},0.85) 0%, transparent 55%)
|
||||
`,
|
||||
filter: 'blur(28px)',
|
||||
}} />
|
||||
{resolvedCover
|
||||
? <img src={resolvedCover} alt="" className="np-cover" style={{ position: 'relative', zIndex: 1 }} />
|
||||
: <div className="np-cover np-cover-fallback" style={{ position: 'relative', zIndex: 1 }}><Music size={52} /></div>
|
||||
? <img src={resolvedCover} alt="" className="np-cover" />
|
||||
: <div className="np-cover np-cover-fallback"><Music size={52} /></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -404,7 +327,7 @@ export default function NowPlaying() {
|
||||
</div>
|
||||
|
||||
{/* ── About the Artist ── */}
|
||||
{(artistInfo?.biography || artistInfo?.largeImageUrl) && (
|
||||
{artistInfo?.biography && (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
|
||||
@@ -416,19 +339,22 @@ export default function NowPlaying() {
|
||||
</div>
|
||||
<div className="np-artist-bio-row">
|
||||
{artistInfo.largeImageUrl && (
|
||||
<img src={artistInfo.largeImageUrl} alt={currentTrack.artist} className="np-artist-thumb" />
|
||||
)}
|
||||
{artistInfo.biography && (
|
||||
<div className="np-bio-wrap">
|
||||
<div
|
||||
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(artistInfo.biography) }}
|
||||
/>
|
||||
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
|
||||
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
|
||||
</button>
|
||||
</div>
|
||||
<img
|
||||
src={artistInfo.largeImageUrl}
|
||||
alt={currentTrack.artist}
|
||||
className="np-artist-thumb"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div className="np-bio-wrap">
|
||||
<div
|
||||
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(artistInfo.biography) }}
|
||||
/>
|
||||
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
|
||||
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+13
-7
@@ -296,19 +296,22 @@ export default function Settings() {
|
||||
<div className="divider" />
|
||||
|
||||
{/* Crossfade */}
|
||||
<div className="settings-toggle-row">
|
||||
<div className="settings-toggle-row" style={auth.gaplessEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.crossfade')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.crossfadeDesc')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{auth.gaplessEnabled ? t('settings.notWithGapless') : t('settings.crossfadeDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
|
||||
<input type="checkbox" checked={auth.crossfadeEnabled} onChange={e => auth.setCrossfadeEnabled(e.target.checked)} id="crossfade-toggle" />
|
||||
<input type="checkbox" checked={auth.crossfadeEnabled} disabled={auth.gaplessEnabled}
|
||||
onChange={e => { auth.setGaplessEnabled(false); auth.setCrossfadeEnabled(e.target.checked); }} id="crossfade-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.crossfadeEnabled && (
|
||||
{auth.crossfadeEnabled && !auth.gaplessEnabled && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
@@ -329,15 +332,18 @@ export default function Settings() {
|
||||
<div className="divider" />
|
||||
|
||||
{/* Gapless */}
|
||||
<div className="settings-toggle-row">
|
||||
<div className="settings-toggle-row" style={auth.crossfadeEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.gapless')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.gapless')}>
|
||||
<input type="checkbox" checked={auth.gaplessEnabled} onChange={e => auth.setGaplessEnabled(e.target.checked)} id="gapless-toggle" />
|
||||
<input type="checkbox" checked={auth.gaplessEnabled} disabled={auth.crossfadeEnabled}
|
||||
onChange={e => { auth.setCrossfadeEnabled(false); auth.setGaplessEnabled(e.target.checked); }} id="gapless-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -131,6 +131,11 @@ let togglePlayLock = false;
|
||||
// Used to suppress ghost-commands from stale IPC arriving after the switch.
|
||||
let lastGaplessSwitchTime = 0;
|
||||
|
||||
// Track ID that has already been sent to audio_chain_preload / audio_preload.
|
||||
// Prevents the 100ms progress ticker from firing 300 identical IPC calls over
|
||||
// the last 30 seconds of a track, each spawning its own HTTP download.
|
||||
let gaplessPreloadingId: string | null = null;
|
||||
|
||||
// ─── Server queue sync ─────────────────────────────────────────────────────────
|
||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
|
||||
@@ -177,7 +182,8 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
const nextTrack = repeatMode === 'one'
|
||||
? track
|
||||
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
||||
if (nextTrack && nextTrack.id !== track.id) {
|
||||
if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) {
|
||||
gaplessPreloadingId = nextTrack.id;
|
||||
const nextUrl = buildStreamUrl(nextTrack.id);
|
||||
if (gaplessEnabled) {
|
||||
// Gapless ON: decode + chain directly into the Sink now, 30 s in
|
||||
@@ -233,6 +239,7 @@ function handleAudioEnded() {
|
||||
*/
|
||||
function handleAudioTrackSwitched(duration: number) {
|
||||
lastGaplessSwitchTime = Date.now();
|
||||
gaplessPreloadingId = null; // allow preloading for the track after this one
|
||||
isAudioPaused = false;
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
@@ -438,6 +445,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; }
|
||||
|
||||
const state = get();
|
||||
|
||||
@@ -2801,6 +2801,10 @@
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 0%, color-mix(in srgb, var(--accent) 10%, var(--bg-main)) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 100%, color-mix(in srgb, var(--ctp-blue) 8%, var(--bg-main)) 0%, transparent 60%),
|
||||
var(--bg-main);
|
||||
}
|
||||
|
||||
/* Main scrollable content */
|
||||
@@ -3175,38 +3179,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.np-bg-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
contain: paint;
|
||||
}
|
||||
|
||||
.np-bg-layer {
|
||||
position: absolute;
|
||||
inset: -15%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(70px) brightness(0.55) saturate(1.6);
|
||||
transition: opacity 0.6s ease;
|
||||
animation: np-ken-burns 40s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes np-ken-burns {
|
||||
0% { transform: scale(1.00) translate(0%, 0%); }
|
||||
25% { transform: scale(1.04) translate(-2%, -1.5%); }
|
||||
50% { transform: scale(1.06) translate(1.5%, 2%); }
|
||||
75% { transform: scale(1.03) translate(-1%, 1.5%); }
|
||||
100% { transform: scale(1.05) translate(2%, -1%); }
|
||||
}
|
||||
|
||||
.np-bg-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.np-hero-content {
|
||||
position: relative;
|
||||
|
||||
@@ -804,20 +804,16 @@
|
||||
|
||||
.queue-current-tech {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 9px;
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.05em;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(4px);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
padding: 3px 6px;
|
||||
text-align: center;
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--accent);
|
||||
padding: 2px 6px;
|
||||
border-radius: 0 0 0 var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-divider {
|
||||
|
||||
Reference in New Issue
Block a user