Compare commits

...

13 Commits

Author SHA1 Message Date
github-actions[bot] dc40a23d25 chore(nix): sync npmDepsHash for v1.45.0-rc.4 (retroactive tag fix) 2026-05-30 14:32:50 +03:00
github-actions[bot] d5dd237c82 chore(release): bump next channel to 1.45.0-rc.4 2026-05-04 15:20:26 +00:00
Maxim Isaev dc630cb916 docs(changelog): document experiment/performance probe and playback work
Add an [Unreleased] section for the performance probe, throttled audio
progress IPC, snapshot-based live UI updates, WaveformSeek scheduling
over the same canvas bar renderer, Hero/Home rail fixes, and Linux/Nix
GDK dev ergonomics.
2026-05-04 18:18:52 +03:00
Maxim Isaev fc6ad8c802 Merge branch 'main' into experiment/performance 2026-05-04 18:18:03 +03:00
Maxim Isaev f7304dc71d Merge branch 'main' into experiment/performance 2026-05-04 17:58:29 +03:00
Maxim Isaev 0f64b92ad5 fix(debug): open performance probe with Ctrl+Shift+D
Replace logo-triggered opening with a keyboard shortcut and keep logo purely decorative to avoid accidental probe activation.
2026-05-04 16:57:09 +03:00
Maxim Isaev 4ab3a26dab fix(perf): decouple playback progress from mainstage compositing pressure
Throttle audio progress delivery and route live seekbar timing through a lightweight progress channel to cut focus-time WebKit CPU spikes.
Add focused diagnostics in Performance Probe and restore hero/waveform behavior so visuals remain stable while profiling.
2026-05-04 16:54:17 +03:00
Maxim Isaev 564e70f4a9 fix(hero): resume background and autoplay after viewport return
Re-check hero visibility on focus/visibility changes and add a short recovery poll while off-screen so missed scroll/RAF events cannot leave hero animation paused.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 15:45:28 +03:00
Maxim Isaev 78fa567a5e fix(perf): isolate player progress updates from mainstage diagnostics
Add probe toggles for PlayerBar waveform and live progress UI updates to confirm playback progress churn as the main CPU driver.
Restore Home artwork quality defaults and keep visual-degradation modes opt-in via debug flags only.
2026-05-04 15:42:32 +03:00
Maxim Isaev 2eb535484f fix(perf): stop hero rotation when section is off-screen
Gate hero auto-rotation and backdrop crossfade by real viewport visibility using the actual scrolling ancestor. This prevents periodic 10-second CPU spikes from hidden hero updates while preserving normal behavior when the hero is visible.
2026-05-04 14:58:21 +03:00
Maxim Isaev f20030e919 feat(perf): add mainstage probe controls and cut WebKit repaint load
Add a dedicated performance probe surface for mainstage/home toggles and wire Linux CPU diagnostics to isolate expensive UI paths. Tune waveform drawing and Home artwork clipping/windowing so visible content loads immediately while reducing WebKit compositor pressure during playback.
2026-05-04 14:49:02 +03:00
Maxim Isaev 6aa3038fed fix(ui): portal server switch menu above sidebar
Main column stacks below the sidebar (layout z-index), so an in-tree dropdown
could never win over the left nav. Render the menu via createPortal to
document.body with fixed coordinates, matching the library scope picker.
2026-05-04 12:25:54 +03:00
Maxim Isaev 84d3d973cc feat(linux): optional native GDK for Nix gdk-session
Introduce PSYSONIC_ALLOW_NATIVE_GDK so main skips the default GDK_BACKEND=x11
pin when the Nix gdk-session wrapper sets the flag. Remove GDK_BACKEND from
the npm tauri:dev script so it does not override nix develop defaults.
2026-05-04 12:18:44 +03:00
35 changed files with 2163 additions and 363 deletions
+24
View File
@@ -5,6 +5,30 @@ 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).
## [Unreleased]
Summarizes work on branch `experiment/performance` until it is folded into a versioned release.
### Added
- **Performance Probe (diagnostics)** — Modal at **Ctrl+Shift+D** (sidebar logo is decorative only). Collapsible Phase 1/2 and an open-by-default Phase 3 for the toggles used most in profiling. Flags persist in `localStorage`, map to `data-perf-*` on the document root, and can disable targeted subsystems (shell/network hooks, mainstage sections, PlayerBar waveform only, live progress UI, rail artwork, and similar) to isolate WebKit/WebProcess CPU on Linux.
- **Approximate live CPU (Linux)** — Tauri command reading `/proc` (including WebKit helper process names) for rough host CPU share during tuning.
- **Playback progress snapshot API**`getPlaybackProgressSnapshot` / `subscribePlaybackProgress` so time, seekbars, and lyrics can update without writing every tick into the persisted player store.
### Changed
- **`audio:progress` (Rust)** — Throttled by minimum interval and position delta, with immediate emit on pause transitions, to reduce IPC during playback.
- **Player store timeline**`currentTime` / `progress` / `buffered` updates to the persisted store are coarse-grained; live UI reads the snapshot channel instead.
- **`WaveformSeek`** — Still one **`<canvas>`** with **2D `fillRect`** bar drawing (same style functions as before; not pre-rendered image layers or `drawImage` compositing). Progress is fed from the snapshot channel; sparse backend ticks are bridged with prediction/smoothing and capped repaint cadence; animated preview ticks no longer stop solely because the window lost focus while still visible.
- **MPRIS position** — Driven from the progress snapshot with conservative timing.
### Fixed
- **Hero** — Auto-advance respects visibility in the app scroll viewport, pauses when the window is blurred, and recovers after returning on-screen or on focus/visibility changes.
- **Home `AlbumRow` / `SongRail`** — Artwork visibility budget uses real card geometry so covers fill the viewport without requiring an initial horizontal scroll nudge.
- **Server switch menu** — Portaled with fixed coordinates so it stacks above the sidebar.
- **Linux / Nix**`PSYSONIC_ALLOW_NATIVE_GDK` skips the default `GDK_BACKEND=x11` pin when using the `gdk-session` wrapper; `tauri:dev` no longer forces `GDK_BACKEND` over `nix develop` defaults.
> **🛡️ A note on safety investments:** Making sure Psysonic is trusted on every OS takes real money out of my pocket — an Apple Developer Account (now active, which is why macOS builds are signed + notarized for everyone starting with this release) and a Windows code-signing certificate (ordered, currently in validation). If you'd like to help cover those costs, you can chip in at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all. Every bit helps keep Psysonic free and safe across Windows, macOS and Linux.
>
> **⚠️ Windows users:** This is one of the last releases with an unsigned Windows installer. Until the certificate clears validation, any SmartScreen or antivirus warning on the installer is a false positive — the binary itself is safe.
+4 -1
View File
@@ -165,13 +165,16 @@ stdenv.mkDerivation (finalAttrs: {
gdkX11Wrap = lib.optionalString forceGdkX11 ''
--set GDK_BACKEND x11 \
'';
allowNativeGdkWrap = lib.optionalString (!forceGdkX11) ''
--set PSYSONIC_ALLOW_NATIVE_GDK 1 \
'';
in
''
wrapProgram $out/bin/psysonic \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \
--prefix GST_PLUGIN_PATH : "${gstPluginPath}" \
--prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \
${gdkX11Wrap}--set WEBKIT_DISABLE_COMPOSITING_MODE 1 \
${gdkX11Wrap}${allowNativeGdkWrap}--set WEBKIT_DISABLE_COMPOSITING_MODE 1 \
--set WEBKIT_DISABLE_DMABUF_RENDERER 1
'';
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-7mSqNSw7KApMERXzhW/Ta7j1wLf55mxBEf3pWs2aaic="
"npmDepsHash": "sha256-dyjbLqDNOL+FVM9ExgM90fnlKYp9GhiNqc42YSKfJ7c="
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.45.0-dev",
"version": "1.45.0-rc.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.45.0-dev",
"version": "1.45.0-rc.4",
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
+2 -2
View File
@@ -1,13 +1,13 @@
{
"name": "psysonic",
"version": "1.45.0-dev",
"version": "1.45.0-rc.4",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"test": "vitest run"
},
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.45.0-dev"
version = "1.45.0-rc.4"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+21 -1
View File
@@ -963,12 +963,19 @@ fn spawn_progress_task(
0.0
}
// Keep near-end detection at 100 ms, but throttle progress IPC to webview.
const PROGRESS_EMIT_MIN_MS: u64 = 1500;
const PROGRESS_EMIT_MIN_DELTA_SECS: f64 = 0.9;
tokio::spawn(async move {
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;
let mut last_progress_emit_at = Instant::now() - Duration::from_millis(PROGRESS_EMIT_MIN_MS);
let mut last_progress_emit_pos = -1.0f64;
let mut last_progress_emit_paused = false;
loop {
// 100 ms tick keeps near-end detection timely for crossfade/gapless
@@ -1070,7 +1077,20 @@ fn spawn_progress_task(
};
let pos = (pos_raw - progress_latency).max(0.0);
app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok();
let now = Instant::now();
let should_emit_progress = if is_paused != last_progress_emit_paused {
true
} else if now.duration_since(last_progress_emit_at) >= Duration::from_millis(PROGRESS_EMIT_MIN_MS) {
true
} else {
(pos - last_progress_emit_pos).abs() >= PROGRESS_EMIT_MIN_DELTA_SECS
};
if should_emit_progress {
app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok();
last_progress_emit_at = now;
last_progress_emit_pos = pos;
last_progress_emit_paused = is_paused;
}
if is_paused {
continue;
+1
View File
@@ -849,6 +849,7 @@ pub fn run() {
set_logging_mode,
export_runtime_logs,
frontend_debug_log,
performance_cpu_snapshot,
set_subsonic_wire_user_agent,
no_compositing_mode,
is_tiling_wm_cmd,
+109
View File
@@ -1,4 +1,15 @@
use super::*;
use serde::Serialize;
use std::fs;
#[derive(Debug, Clone, Serialize)]
pub(crate) struct PerformanceCpuSnapshot {
pub supported: bool,
pub total_jiffies: u64,
pub app_jiffies: u64,
pub webkit_jiffies: u64,
pub logical_cpus: u32,
}
#[tauri::command]
pub(crate) fn greet(name: &str) -> String {
@@ -121,4 +132,102 @@ pub(crate) fn set_subsonic_wire_user_agent(
Ok(())
}
#[cfg(target_os = "linux")]
fn parse_proc_stat_line(stat_line: &str) -> Option<(String, i32, u64, u64)> {
let close_idx = stat_line.rfind(')')?;
let open_idx = stat_line.find('(')?;
if open_idx + 1 >= close_idx {
return None;
}
let comm = stat_line.get(open_idx + 1..close_idx)?.to_string();
let after = stat_line.get(close_idx + 2..)?;
let mut parts = after.split_whitespace();
let _state = parts.next()?;
let ppid = parts.next()?.parse::<i32>().ok()?;
let rest: Vec<&str> = parts.collect();
// After `state` and `ppid`, remaining fields start at `pgrp` (field #5).
// `utime` = field #14 => rest[9], `stime` = field #15 => rest[10].
let utime = rest.get(9)?.parse::<u64>().ok()?;
let stime = rest.get(10)?.parse::<u64>().ok()?;
Some((comm, ppid, utime, stime))
}
#[cfg(target_os = "linux")]
fn read_total_jiffies() -> Option<u64> {
let content = fs::read_to_string("/proc/stat").ok()?;
let line = content.lines().next()?;
let mut it = line.split_whitespace();
if it.next()? != "cpu" {
return None;
}
Some(it.filter_map(|n| n.parse::<u64>().ok()).sum())
}
#[cfg(target_os = "linux")]
fn collect_proc_stats() -> Vec<(i32, String, i32, u64)> {
let mut rows = Vec::new();
let entries = match fs::read_dir("/proc") {
Ok(v) => v,
Err(_) => return rows,
};
for entry in entries.flatten() {
let name = entry.file_name();
let pid = match name.to_string_lossy().parse::<i32>() {
Ok(v) => v,
Err(_) => continue,
};
let stat_path = format!("/proc/{pid}/stat");
let stat_line = match fs::read_to_string(stat_path) {
Ok(v) => v,
Err(_) => continue,
};
if let Some((comm, ppid, utime, stime)) = parse_proc_stat_line(stat_line.trim()) {
rows.push((pid, comm, ppid, utime.saturating_add(stime)));
}
}
rows
}
#[tauri::command]
pub(crate) fn performance_cpu_snapshot() -> PerformanceCpuSnapshot {
#[cfg(target_os = "linux")]
{
let total_jiffies = read_total_jiffies().unwrap_or(0);
let logical_cpus = std::thread::available_parallelism()
.map(|n| n.get() as u32)
.unwrap_or(1);
let self_pid = std::process::id() as i32;
let rows = collect_proc_stats();
let app_jiffies = rows
.iter()
.find(|(pid, _, _, _)| *pid == self_pid)
.map(|(_, _, _, ticks)| *ticks)
.unwrap_or(0);
let webkit_jiffies = rows
.iter()
// Linux `/proc/*/stat` `comm` is capped to 15 chars, so
// "WebKitWebProcess" appears as "WebKitWebProces".
.filter(|(_, comm, ppid, _)| comm.starts_with("WebKitWebProces") && *ppid == self_pid)
.map(|(_, _, _, ticks)| *ticks)
.sum::<u64>();
return PerformanceCpuSnapshot {
supported: true,
total_jiffies,
app_jiffies,
webkit_jiffies,
logical_cpus,
};
}
#[cfg(not(target_os = "linux"))]
{
PerformanceCpuSnapshot {
supported: false,
total_jiffies: 0,
app_jiffies: 0,
webkit_jiffies: 0,
logical_cpus: 1,
}
}
}
+7 -3
View File
@@ -41,8 +41,9 @@ fn detect_gpu_vendor() -> Option<GpuVendor> {
}
fn main() {
// WebKitGTK on Wayland is unstable — force X11/XWayland on all Linux packages.
// Users can still override by setting these vars before launch.
// WebKitGTK on Wayland can be unstable — default to X11 when GDK_BACKEND is unset,
// except when PSYSONIC_ALLOW_NATIVE_GDK is set (e.g. Nix psysonic-gdk-session wrapper).
// Users can still override by setting GDK_BACKEND before launch.
//
// Safety: set_var modifies global process state. These calls are safe here
// because we're in main() before the Tauri runtime starts — no other threads
@@ -50,7 +51,10 @@ fn main() {
// need synchronization or marking as unsafe (Rust 2024+).
#[cfg(target_os = "linux")]
{
if std::env::var("GDK_BACKEND").is_err() {
// Nix `psysonic-gdk-session` sets this so we do not pin X11 when the packager asked for
// session-native GDK (e.g. Wayland). Local dev can export the same var before `tauri dev`.
let allow_native_gdk = std::env::var("PSYSONIC_ALLOW_NATIVE_GDK").is_ok();
if std::env::var("GDK_BACKEND").is_err() && !allow_native_gdk {
std::env::set_var("GDK_BACKEND", "x11");
}
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.45.0-dev",
"version": "1.45.0-rc.4",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+72 -37
View File
@@ -90,6 +90,7 @@ import i18n from './i18n';
import { switchActiveServer } from './utils/switchActiveServer';
import {
usePlayerStore,
getPlaybackProgressSnapshot,
initAudioListeners,
songToTrack,
shuffleArray,
@@ -107,6 +108,7 @@ import { DEFAULT_IN_APP_BINDINGS, canRunShortcutActionInMiniWindow, executeCliPl
import { matchInAppShortcutAction } from './shortcuts/runtime';
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
import PasteClipboardHandler from './components/PasteClipboardHandler';
import { usePerfProbeFlags } from './utils/perfFlags';
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed';
@@ -233,6 +235,7 @@ function AppShell() {
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const perfFlags = usePerfProbeFlags();
// Mini player → main: route requests dispatched as `psy:navigate`
// CustomEvents from the bridge land here so React Router can take over.
@@ -657,37 +660,41 @@ function AppShell() {
railInset="panel"
>
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} />
<Route path="/tracks" element={<Tracks />} />
<Route path="/random" element={<RandomLanding />} />
<Route path="/random/albums" element={<RandomAlbums />} />
<Route path="/album/:id" element={<AlbumDetail />} />
<Route path="/artists" element={<Artists />} />
<Route path="/artist/:id" element={<ArtistDetail />} />
<Route path="/new-releases" element={<NewReleases />} />
<Route path="/favorites" element={<Favorites />} />
<Route path="/random/mix" element={<RandomMix />} />
<Route path="/lucky-mix" element={<LuckyMixPage />} />
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/most-played" element={<MostPlayed />} />
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/whats-new" element={<WhatsNew />} />
<Route path="/help" element={<Help />} />
<Route path="/offline" element={<OfflineLibrary />} />
<Route path="/genres" element={<Genres />} />
<Route path="/genres/:name" element={<GenreDetail />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/playlists/:id" element={<PlaylistDetail />} />
<Route path="/radio" element={<InternetRadio />} />
<Route path="/folders" element={<FolderBrowser />} />
<Route path="/device-sync" element={<DeviceSync />} />
</Routes>
{perfFlags.disableMainRouteContentMount ? (
<div style={{ minHeight: '60vh' }} />
) : (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} />
<Route path="/tracks" element={<Tracks />} />
<Route path="/random" element={<RandomLanding />} />
<Route path="/random/albums" element={<RandomAlbums />} />
<Route path="/album/:id" element={<AlbumDetail />} />
<Route path="/artists" element={<Artists />} />
<Route path="/artist/:id" element={<ArtistDetail />} />
<Route path="/new-releases" element={<NewReleases />} />
<Route path="/favorites" element={<Favorites />} />
<Route path="/random/mix" element={<RandomMix />} />
<Route path="/lucky-mix" element={<LuckyMixPage />} />
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/most-played" element={<MostPlayed />} />
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/whats-new" element={<WhatsNew />} />
<Route path="/help" element={<Help />} />
<Route path="/offline" element={<OfflineLibrary />} />
<Route path="/genres" element={<Genres />} />
<Route path="/genres/:name" element={<GenreDetail />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/playlists/:id" element={<PlaylistDetail />} />
<Route path="/radio" element={<InternetRadio />} />
<Route path="/folders" element={<FolderBrowser />} />
<Route path="/device-sync" element={<DeviceSync />} />
</Routes>
)}
</Suspense>
</OverlayScrollArea>
</div>
@@ -738,7 +745,7 @@ function AppShell() {
{isQueueVisible ? <PanelRightClose size={14} /> : <PanelRight size={14} />}
</button>
)}
{!isMobile && <QueuePanel />}
{!isMobile && !perfFlags.disableQueuePanelMount && <QueuePanel />}
{isMobile && !isMobilePlayer && <BottomNav />}
{!isMobilePlayer && <PlayerBar />}
{isFullscreenOpen && (
@@ -750,7 +757,7 @@ function AppShell() {
<GlobalConfirmModal />
<OrbitAccountPicker />
<OrbitHelpModal />
<TooltipPortal />
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
<AppUpdater />
</div>
);
@@ -1075,9 +1082,10 @@ function TauriEventBridge() {
{
const u = await listen<number>('media:seek-relative', e => {
const s = usePlayerStore.getState();
const p = getPlaybackProgressSnapshot();
const dur = s.currentTrack?.duration;
if (!dur) return;
s.seek(Math.max(0, s.currentTime + e.payload) / dur);
s.seek(Math.max(0, p.currentTime + e.payload) / dur);
});
if (cancelled) { u(); return; }
unlisten.push(u);
@@ -1152,6 +1160,11 @@ function TauriEventBridge() {
// `psysonic --info`: JSON snapshot under XDG_RUNTIME_DIR (Rust writes atomically).
useEffect(() => {
let tid: ReturnType<typeof setTimeout> | undefined;
let lastPublishAt = 0;
let lastStableKey = '';
let lastPlaying = false;
const SNAPSHOT_PLAYING_HEARTBEAT_MS = 4000;
const SNAPSHOT_IDLE_HEARTBEAT_MS = 15000;
const publish = () => {
const s = usePlayerStore.getState();
const auth = useAuthStore.getState();
@@ -1171,7 +1184,7 @@ function TauriEventBridge() {
queue_index: s.queueIndex,
queue_length: s.queue.length,
is_playing: s.isPlaying,
current_time: s.currentTime,
current_time: getPlaybackProgressSnapshot().currentTime,
volume: s.volume,
repeat_mode: s.repeatMode,
current_track_user_rating: currentTrackUserRating,
@@ -1183,11 +1196,32 @@ function TauriEventBridge() {
folders: auth.musicFolders.map(f => ({ id: f.id, name: f.name })),
},
};
const stableKey = JSON.stringify({
trackId: s.currentTrack?.id ?? null,
radioId: s.currentRadio?.id ?? null,
queueIndex: s.queueIndex,
queueLength: s.queue.length,
isPlaying: s.isPlaying,
volume: Math.round(s.volume * 100),
repeatMode: s.repeatMode,
serverId: sid ?? null,
selected,
currentTrackUserRating,
currentTrackStarred,
});
const now = Date.now();
const heartbeatMs = s.isPlaying ? SNAPSHOT_PLAYING_HEARTBEAT_MS : SNAPSHOT_IDLE_HEARTBEAT_MS;
const stableChanged = stableKey !== lastStableKey;
const playingEdge = s.isPlaying !== lastPlaying;
if (!stableChanged && !playingEdge && now - lastPublishAt < heartbeatMs) return;
lastStableKey = stableKey;
lastPlaying = s.isPlaying;
lastPublishAt = now;
invoke('cli_publish_player_snapshot', { snapshot }).catch(() => {});
};
publish();
const schedule = () => {
if (tid !== undefined) clearTimeout(tid);
if (tid !== undefined) return;
tid = setTimeout(() => {
tid = undefined;
publish();
@@ -1210,6 +1244,7 @@ export default function App() {
const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font);
const [exportPickerOpen, setExportPickerOpen] = useState(false);
const perfFlags = usePerfProbeFlags();
// Mini Player window: detected via Tauri window label. Rendered without
// router / sidebar / full audio listeners — it just listens for state + sends
@@ -1293,7 +1328,7 @@ export default function App() {
<DragDropProvider>
<MiniPlayer />
<GlobalConfirmModal />
<TooltipPortal />
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
</DragDropProvider>
);
}
+42 -5
View File
@@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -18,9 +18,22 @@ interface AlbumCardProps {
onToggleSelect?: (id: string) => void;
showRating?: boolean;
selectedAlbums?: SubsonicAlbum[];
disableArtwork?: boolean;
artworkSize?: number;
directImageSrc?: boolean;
}
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) {
function AlbumCard({
album,
selected,
selectionMode,
onToggleSelect,
showRating = false,
selectedAlbums = [],
disableArtwork = false,
artworkSize = 300,
directImageSrc = false,
}: AlbumCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
@@ -31,7 +44,15 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
// buildCoverArtUrl emits a salted URL; memoize to avoid churn on rerenders.
const coverUrl = useMemo(
() => (album.coverArt ? buildCoverArtUrl(album.coverArt, artworkSize) : ''),
[album.coverArt, artworkSize],
);
const coverCacheKey = useMemo(
() => (album.coverArt ? coverArtCacheKey(album.coverArt, artworkSize) : ''),
[album.coverArt, artworkSize],
);
const psyDrag = useDragDrop();
const isNewAlbum = isAlbumRecentlyAdded(album.created);
@@ -73,8 +94,24 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
}}
>
<div className="album-card-cover">
{coverUrl ? (
<CachedImage src={coverUrl} cacheKey={coverArtCacheKey(album.coverArt!, 300)} alt={`${album.name} Cover`} loading="lazy" />
{!disableArtwork && coverUrl ? (
directImageSrc ? (
<img
src={coverUrl}
alt={`${album.name} Cover`}
loading="lazy"
decoding="async"
fetchPriority="low"
/>
) : (
<CachedImage
src={coverUrl}
cacheKey={coverCacheKey}
alt={`${album.name} Cover`}
loading="lazy"
decoding="async"
/>
)
) : (
<div className="album-card-cover-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
+95 -19
View File
@@ -4,6 +4,7 @@ import AlbumCard from './AlbumCard';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { NavLink, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePerfProbeFlags } from '../utils/perfFlags';
interface Props {
title: string;
@@ -15,23 +16,65 @@ interface Props {
showRating?: boolean;
/** Optional content rendered in the row header, left of the scroll-nav. */
headerExtra?: React.ReactNode;
disableArtwork?: boolean;
disableInteractivity?: boolean;
artworkSize?: number;
directImageSrc?: boolean;
windowArtworkByViewport?: boolean;
initialArtworkBudget?: number;
}
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating, headerExtra }: Props) {
export default function AlbumRow({
title,
titleLink,
albums,
moreLink,
moreText,
onLoadMore,
showRating,
headerExtra,
disableArtwork = false,
disableInteractivity = false,
artworkSize,
directImageSrc = false,
windowArtworkByViewport = false,
initialArtworkBudget = 8,
}: Props) {
const perfFlags = usePerfProbeFlags();
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
const loadingRef = useRef(false);
const recomputeArtworkBudget = () => {
if (!windowArtworkByViewport) return;
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, clientWidth } = el;
const firstCard = el.querySelector<HTMLElement>('.album-card, .artist-card');
const cardW = firstCard?.clientWidth || firstCard?.getBoundingClientRect().width || 170;
const gridStyles = window.getComputedStyle(el);
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '16') || 16;
const step = Math.max(1, cardW + gap);
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 4);
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
};
const handleScroll = () => {
if (interactivityDisabled) return;
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
recomputeArtworkBudget();
// Auto-load trigger
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
@@ -49,10 +92,27 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
};
useEffect(() => {
if (interactivityDisabled) return;
handleScroll();
const raf = window.requestAnimationFrame(() => {
// One post-layout pass ensures we account for final grid/card geometry.
recomputeArtworkBudget();
});
window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll);
}, [albums]);
const ro = new ResizeObserver(() => {
recomputeArtworkBudget();
});
if (scrollRef.current) ro.observe(scrollRef.current);
return () => {
window.cancelAnimationFrame(raf);
window.removeEventListener('resize', handleScroll);
ro.disconnect();
};
}, [albums, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
useEffect(() => {
setArtworkBudget(initialArtworkBudget);
}, [initialArtworkBudget, albums.length]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
@@ -74,26 +134,42 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
)}
<div className="album-row-nav">
{headerExtra}
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
{!interactivityDisabled && (
<>
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
</>
)}
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{albums.map(a => <AlbumCard key={a.id} album={a} showRating={showRating} />)}
<div className="album-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
{albums.map((a, idx) => (
<AlbumCard
key={a.id}
album={a}
showRating={showRating}
disableArtwork={
artworkDisabled ||
(windowArtworkByViewport && idx >= artworkBudget)
}
artworkSize={artworkSize}
directImageSrc={directImageSrc}
/>
))}
{loadingMore && (
<div className="album-card-more" style={{ cursor: 'default' }}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
+90 -47
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown } from 'lucide-react';
@@ -21,14 +22,37 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const activeServerId = useAuthStore(s => s.activeServerId);
const [menuOpen, setMenuOpen] = useState(false);
const [switchingId, setSwitchingId] = useState<string | null>(null);
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
const hostRef = useRef<HTMLDivElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
const multi = servers.length > 1;
const updateMenuPosition = useCallback(() => {
const el = hostRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setMenuFixed({ top: r.bottom + 6, right: window.innerWidth - r.right });
}, []);
useLayoutEffect(() => {
if (!menuOpen) return;
updateMenuPosition();
const onWin = () => updateMenuPosition();
window.addEventListener('resize', onWin);
window.addEventListener('scroll', onWin, true);
return () => {
window.removeEventListener('resize', onWin);
window.removeEventListener('scroll', onWin, true);
};
}, [menuOpen, updateMenuPosition]);
useEffect(() => {
if (!menuOpen) return;
const onDown = (e: MouseEvent) => {
if (hostRef.current?.contains(e.target as Node)) return;
const t = e.target as Node;
if (hostRef.current?.contains(t)) return;
if (menuPanelRef.current?.contains(t)) return;
setMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
@@ -103,55 +127,74 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
</span>
</div>
</div>
{multi && menuOpen && (
<div
className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
role="menu"
aria-label={t('connection.switchServerTitle')}
>
{multi &&
menuOpen &&
typeof document !== 'undefined' &&
createPortal(
<div
ref={menuPanelRef}
className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
role="menu"
aria-label={t('connection.switchServerTitle')}
style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--text-muted)',
padding: '6px 10px 4px',
position: 'fixed',
top: menuFixed.top,
right: menuFixed.right,
minWidth: 220,
maxWidth: 'min(320px, 85vw)',
zIndex: 10050,
}}
>
{t('connection.switchServerTitle')}
</div>
{servers.map(srv => {
const active = srv.id === activeServerId;
const busy = switchingId !== null;
const labelText = serverListDisplayLabel(srv, servers);
return (
<button
key={srv.id}
type="button"
role="menuitem"
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
disabled={busy}
onClick={() => onPickServer(srv)}
>
<span className="nav-library-dropdown-item-label">{labelText}</span>
{switchingId === srv.id ? (
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
) : active ? (
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
) : (
<span className="nav-library-dropdown-check-spacer" aria-hidden />
)}
</button>
);
})}
<div style={{ borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)', marginTop: 2, paddingTop: 2 }} />
<button type="button" className="nav-library-dropdown-item" onClick={goServerSettings}>
<span className="nav-library-dropdown-item-label">{t('connection.manageServers')}</span>
<span className="nav-library-dropdown-check-spacer" aria-hidden />
</button>
</div>
)}
<div
style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--text-muted)',
padding: '6px 10px 4px',
}}
>
{t('connection.switchServerTitle')}
</div>
{servers.map(srv => {
const active = srv.id === activeServerId;
const busy = switchingId !== null;
const labelText = serverListDisplayLabel(srv, servers);
return (
<button
key={srv.id}
type="button"
role="menuitem"
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
disabled={busy}
onClick={() => onPickServer(srv)}
>
<span className="nav-library-dropdown-item-label">{labelText}</span>
{switchingId === srv.id ? (
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
) : active ? (
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
) : (
<span className="nav-library-dropdown-check-spacer" aria-hidden />
)}
</button>
);
})}
<div
style={{
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)',
marginTop: 2,
paddingTop: 2,
}}
/>
<button type="button" className="nav-library-dropdown-item" onClick={goServerSettings}>
<span className="nav-library-dropdown-item-label">{t('connection.manageServers')}</span>
<span className="nav-library-dropdown-check-spacer" aria-hidden />
</button>
</div>,
document.body
)}
</div>
);
}
+9 -9
View File
@@ -4,7 +4,7 @@ import {
ChevronDown, Repeat, Repeat1, Square, Music, Heart, MicVocal,
Moon, Sunrise,
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import { getCachedBlob } from '../utils/imageCache';
@@ -88,8 +88,8 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
setActiveIdx(idx);
}
};
apply(usePlayerStore.getState().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime));
apply(getPlaybackProgressSnapshot().currentTime);
return subscribePlaybackProgress(s => apply(s.currentTime));
}, [hasSynced, currentTrack?.id]);
// Ease-scroll active line to ~35% from the top of the container.
@@ -129,8 +129,8 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
}
prevWord.current = { line: li, word: wi };
};
apply(usePlayerStore.getState().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime));
apply(getPlaybackProgressSnapshot().currentTime);
return subscribePlaybackProgress(s => apply(s.currentTime));
}, [useWords, wordLines]);
const handleUserScroll = useCallback(() => {
@@ -275,8 +275,8 @@ const FsLyricsRail = memo(function FsLyricsRail({ currentTrack }: { currentTrack
}
prevWord.current = { line: li, word: wi };
};
apply(usePlayerStore.getState().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime));
apply(getPlaybackProgressSnapshot().currentTime);
return subscribePlaybackProgress(s => apply(s.currentTime));
}, [useWords, wordLines]);
if (!currentTrack || loading || !hasSynced) return null;
@@ -457,14 +457,14 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
}, [seek]);
useEffect(() => {
const s = usePlayerStore.getState();
const s = getPlaybackProgressSnapshot();
const pct = s.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(s.currentTime);
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
if (inputRef.current) inputRef.current.value = String(s.progress);
return usePlayerStore.subscribe(state => {
return subscribePlaybackProgress(state => {
if (isDraggingRef.current) return;
const p = state.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
+120 -6
View File
@@ -11,6 +11,7 @@ import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
import { usePerfProbeFlags } from '../utils/perfFlags';
const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8;
@@ -55,6 +56,7 @@ interface HeroProps {
}
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
@@ -67,6 +69,102 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const windowHidden = useWindowVisibility();
const [windowBlurred, setWindowBlurred] = useState<boolean>(() => Boolean(window.__psyBlurred));
const heroRef = useRef<HTMLDivElement | null>(null);
const heroScrollRootRef = useRef<HTMLElement | null>(null);
const visibilityRafRef = useRef<number | null>(null);
const [heroInView, setHeroInView] = useState(true);
const heroInViewRef = useRef(true);
heroInViewRef.current = heroInView;
const computeHeroVisibleNow = useCallback((): boolean => {
const node = heroRef.current;
if (!node) return false;
const rect = node.getBoundingClientRect();
if (rect.height <= 0 || rect.width <= 0) {
return false;
}
const root = heroScrollRootRef.current;
const viewportTop = root ? root.getBoundingClientRect().top : 0;
const viewportBottom = root ? root.getBoundingClientRect().bottom : window.innerHeight;
const overlap = Math.max(0, Math.min(rect.bottom, viewportBottom) - Math.max(rect.top, viewportTop));
// Consider hero visible only when at least a meaningful slice is on screen.
const minVisiblePx = Math.min(56, rect.height * 0.2);
return overlap >= minVisiblePx;
}, []);
const updateHeroVisibility = useCallback(() => {
const visible = computeHeroVisibleNow();
setHeroInView(prev => (prev === visible ? prev : visible));
}, [computeHeroVisibleNow]);
useEffect(() => {
const node = heroRef.current;
if (!node) return;
// Prefer the nearest actual scrolling ancestor; class fallback for safety.
let scrollRoot: HTMLElement | null = null;
let parent = node.parentElement;
while (parent) {
const styles = window.getComputedStyle(parent);
const overflowY = styles.overflowY;
if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight + 2) {
scrollRoot = parent;
break;
}
parent = parent.parentElement;
}
heroScrollRootRef.current =
scrollRoot ?? (node.closest('.app-shell-route-scroll__viewport') as HTMLElement | null);
updateHeroVisibility();
const root = heroScrollRootRef.current;
const onScroll = () => {
if (visibilityRafRef.current != null) return;
visibilityRafRef.current = window.requestAnimationFrame(() => {
visibilityRafRef.current = null;
updateHeroVisibility();
});
};
const onResize = () => updateHeroVisibility();
const onFocusLike = () => updateHeroVisibility();
root?.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onResize);
window.addEventListener('focus', onFocusLike);
document.addEventListener('visibilitychange', onFocusLike);
return () => {
root?.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
window.removeEventListener('focus', onFocusLike);
document.removeEventListener('visibilitychange', onFocusLike);
if (visibilityRafRef.current != null) {
window.cancelAnimationFrame(visibilityRafRef.current);
visibilityRafRef.current = null;
}
};
}, [updateHeroVisibility]);
useEffect(() => {
const updateBlurState = () => {
setWindowBlurred(Boolean(window.__psyBlurred));
};
window.addEventListener('focus', updateBlurState);
window.addEventListener('blur', updateBlurState);
updateBlurState();
return () => {
window.removeEventListener('focus', updateBlurState);
window.removeEventListener('blur', updateBlurState);
};
}, []);
useEffect(() => {
if (heroInView || windowHidden) return;
// Recovery guard: if a scroll/RAF event was missed while hero was outside
// viewport, keep checking briefly so autoplay/background resume immediately
// after returning into view.
const id = window.setInterval(() => {
updateHeroVisibility();
}, 220);
return () => window.clearInterval(id);
}, [heroInView, windowHidden, updateHeroVisibility]);
useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; }
@@ -93,12 +191,27 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const startTimer = useCallback((len: number) => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
if (len <= 1 || windowHidden) return;
if (len <= 1 || windowHidden || windowBlurred || !heroInViewRef.current || !computeHeroVisibleNow()) return;
timerRef.current = setInterval(() => {
if (document.hidden || window.__psyHidden) return;
const visibleNow = computeHeroVisibleNow();
if (!visibleNow && heroInViewRef.current) setHeroInView(false);
if (document.hidden || window.__psyHidden || window.__psyBlurred || !heroInViewRef.current || !visibleNow) {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
return;
}
setActiveIdx(prev => (prev + 1) % len);
}, INTERVAL_MS);
}, [windowHidden]);
}, [windowHidden, windowBlurred, computeHeroVisibleNow]);
useEffect(() => {
// Hard-stop timer immediately when hero leaves viewport.
if (heroInView && !windowBlurred) return;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, [heroInView, windowBlurred]);
useEffect(() => {
startTimer(albums.length);
@@ -106,7 +219,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
}, [albums.length, startTimer]);
}, [albums.length, heroInView, startTimer]);
const goTo = useCallback((idx: number) => {
setActiveIdx(idx);
@@ -144,14 +257,15 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
return (
<div
ref={heroRef}
className="hero"
role="banner"
aria-label={t('hero.eyebrow')}
onClick={() => navigate(`/album/${album.id}`)}
style={{ cursor: 'pointer' }}
>
{enableCoverArtBackground && <HeroBg url={stableBgUrl.current} />}
{enableCoverArtBackground && <div className="hero-overlay" aria-hidden="true" />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content animate-fade-in" key={album.id}>
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
import type { LrcLine } from '../api/lrclib';
import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore';
@@ -126,8 +126,8 @@ export default function LyricsPane({ currentTrack }: Props) {
prevActive.current = { line: lineIdx, word: wordIdx };
};
apply(usePlayerStore.getState().currentTime);
return usePlayerStore.subscribe(s => apply(s.currentTime));
apply(getPlaybackProgressSnapshot().currentTime);
return subscribePlaybackProgress(s => apply(s.currentTime));
}, [useWords, hasSynced, wordLines, syncedLines, scrollToLine]);
if (!currentTrack) {
+3 -1
View File
@@ -1,5 +1,6 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { useAuthStore } from '../store/authStore';
import { usePerfProbeFlags } from '../utils/perfFlags';
interface Props {
text: string;
@@ -13,6 +14,7 @@ export default function MarqueeText({ text, className, style, onClick }: Props)
const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0);
const animationMode = useAuthStore(s => s.animationMode);
const perfFlags = usePerfProbeFlags();
const measure = useCallback(() => {
const container = containerRef.current;
@@ -34,7 +36,7 @@ export default function MarqueeText({ text, className, style, onClick }: Props)
// In `static` animation mode the marquee never scrolls — overflowing text
// is truncated with an ellipsis (handled by CSS via data-anim-mode).
const shouldScroll = scrollAmount > 0 && animationMode !== 'static';
const shouldScroll = scrollAmount > 0 && animationMode !== 'static' && !perfFlags.disableMarqueeScroll;
return (
<div
+9 -4
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo, useRef, useEffect, CSSProperties } from 'react';
import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
@@ -6,7 +6,7 @@ import {
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
Moon, Sunrise,
} from 'lucide-react';
import { usePlayerStore, Track } from '../store/playerStore';
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress, Track } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import { useCachedUrl } from './CachedImage';
import LyricsPane from './LyricsPane';
@@ -164,8 +164,13 @@ export default function MobilePlayerView() {
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const progress = usePlayerStore(s => s.progress);
const currentTime = usePlayerStore(s => s.currentTime);
const playbackProgress = useSyncExternalStore(
onStoreChange => subscribePlaybackProgress(() => onStoreChange()),
getPlaybackProgressSnapshot,
getPlaybackProgressSnapshot,
);
const progress = playbackProgress.progress;
const currentTime = playbackProgress.currentTime;
const togglePlay = usePlayerStore(s => s.togglePlay);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const transportAnchorRef = useRef<HTMLDivElement>(null);
+8 -4
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { computeOverlayScrollbarThumbMeta } from '../utils/overlayScrollbarMetrics';
import { bindOverlayScrollbarThumbDrag } from '../utils/overlayScrollbarThumb';
import { usePerfProbeFlags } from '../utils/perfFlags';
export type OverlayScrollRailInset = 'none' | 'mini' | 'panel';
@@ -56,6 +57,7 @@ export default function OverlayScrollArea({
viewportOnWheel,
viewportOnTouchMove,
}: OverlayScrollAreaProps) {
const perfFlags = usePerfProbeFlags();
const wrapRef = useRef<HTMLDivElement | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const [meta, setMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
@@ -72,6 +74,7 @@ export default function OverlayScrollArea({
const measureKey = JSON.stringify(measureDeps ?? []);
useLayoutEffect(() => {
if (perfFlags.disableOverlayScrollbars) return;
if (!meta.visible) return;
const vp = viewportRef.current;
const wrap = wrapRef.current;
@@ -89,9 +92,10 @@ export default function OverlayScrollArea({
}
return next;
});
}, [meta.visible]);
}, [meta.visible, perfFlags.disableOverlayScrollbars]);
useEffect(() => {
if (perfFlags.disableOverlayScrollbars) return;
recompute();
const wrap = wrapRef.current;
const onWinResize = () => recompute();
@@ -105,7 +109,7 @@ export default function OverlayScrollArea({
window.removeEventListener('resize', onWinResize);
ro?.disconnect();
};
}, [recompute, measureKey]);
}, [recompute, measureKey, perfFlags.disableOverlayScrollbars]);
const setViewportNode = (el: HTMLDivElement | null) => {
viewportRef.current = el;
@@ -134,13 +138,13 @@ export default function OverlayScrollArea({
id={viewportId}
ref={setViewportNode}
className={viewportClass}
onScroll={recompute}
onScroll={perfFlags.disableOverlayScrollbars ? undefined : recompute}
onWheel={viewportOnWheel}
onTouchMove={viewportOnTouchMove}
>
{children}
</div>
{meta.visible && (
{!perfFlags.disableOverlayScrollbars && meta.visible && (
<div className="overlay-scroll__rail" aria-hidden>
<div
className="overlay-scroll__thumb"
+10 -6
View File
@@ -6,7 +6,7 @@ import {
PictureInPicture2, ArrowLeftRight, Moon, Sunrise, Ellipsis,
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
@@ -26,6 +26,7 @@ import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
import { usePreviewStore } from '../store/previewStore';
import { usePerfProbeFlags } from '../utils/perfFlags';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -40,9 +41,9 @@ const PlaybackTime = memo(function PlaybackTime({ className }: { className?: str
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (spanRef.current) {
spanRef.current.textContent = formatTime(usePlayerStore.getState().currentTime);
spanRef.current.textContent = formatTime(getPlaybackProgressSnapshot().currentTime);
}
return usePlayerStore.subscribe(state => {
return subscribePlaybackProgress(state => {
if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime);
});
}, []);
@@ -55,12 +56,12 @@ const RemainingTime = memo(function RemainingTime({ duration, className }: { dur
useEffect(() => {
const updateRemaining = () => {
if (spanRef.current) {
const remaining = Math.max(0, duration - usePlayerStore.getState().currentTime);
const remaining = Math.max(0, duration - getPlaybackProgressSnapshot().currentTime);
spanRef.current.textContent = `-${formatTime(remaining)}`;
}
};
updateRemaining();
return usePlayerStore.subscribe(updateRemaining);
return subscribePlaybackProgress(updateRemaining);
}, [duration]);
return <span className={className} ref={spanRef} />;
});
@@ -117,6 +118,7 @@ export default function PlayerBar() {
const [utilityMenuStyle, setUtilityMenuStyle] = useState<React.CSSProperties>({});
const volumeWheelMenuTimerRef = useRef<number | null>(null);
const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false);
const perfFlags = usePerfProbeFlags();
useEffect(() => {
if (!floatingPlayerBar) return;
@@ -530,7 +532,9 @@ export default function PlayerBar() {
<>
<PlaybackTime className="player-time" />
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
{perfFlags.disableWaveformCanvas
? <div className="radio-progress-bar" aria-hidden />
: <WaveformSeek trackId={currentTrack?.id} />}
</div>
<span
className="player-time player-time-toggle"
+498 -1
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
@@ -28,6 +29,7 @@ import {
type SidebarNavDropTarget,
} from '../utils/sidebarNavReorder';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { resetPerfProbeFlags, setPerfProbeFlag, usePerfProbeFlags } from '../utils/perfFlags';
const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
@@ -81,6 +83,12 @@ export default function Sidebar({
const musicFolders = useAuthStore(s => s.musicFolders);
const musicLibraryFilterByServer = useAuthStore(s => s.musicLibraryFilterByServer);
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
const hotCacheEnabled = useAuthStore(s => s.hotCacheEnabled);
const setHotCacheEnabled = useAuthStore(s => s.setHotCacheEnabled);
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
const loggingMode = useAuthStore(s => s.loggingMode);
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems);
@@ -151,6 +159,10 @@ export default function Sidebar({
const newReleasesRefreshSeqRef = useRef(0);
const newReleasesPageEnteredAtRef = useRef<number | null>(null);
const newReleasesResetTimerRef = useRef<number | null>(null);
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
const perfFlags = usePerfProbeFlags();
const [perfCpu, setPerfCpu] = useState<{ app: number; webkit: number; supported: boolean } | null>(null);
const [perfDiagRates, setPerfDiagRates] = useState<{ progress: number; waveform: number; home: number } | null>(null);
const newReleasesSeenStorageKey = useMemo(
() => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:${filterId || 'all'}`,
@@ -572,10 +584,113 @@ export default function Sidebar({
};
}, [location.pathname, refreshNewReleasesUnread]);
useEffect(() => {
if (!perfProbeOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setPerfProbeOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [perfProbeOpen]);
useEffect(() => {
if (!perfProbeOpen) return;
type Snapshot = {
supported: boolean;
total_jiffies: number;
app_jiffies: number;
webkit_jiffies: number;
logical_cpus: number;
};
let cancelled = false;
let prev: Snapshot | null = null;
let prevCounters: { progress: number; waveform: number; home: number } | null = null;
let prevCountersAt = 0;
let timer: number | null = null;
const poll = async () => {
try {
const snap = await invoke<Snapshot>('performance_cpu_snapshot');
if (cancelled) return;
if (!snap.supported) {
setPerfCpu({ app: 0, webkit: 0, supported: false });
return;
}
if (prev) {
const totalDelta = snap.total_jiffies - prev.total_jiffies;
const appDelta = snap.app_jiffies - prev.app_jiffies;
const webkitDelta = snap.webkit_jiffies - prev.webkit_jiffies;
if (totalDelta > 0) {
const cpuScale = Math.max(1, snap.logical_cpus || 1) * 100;
const appPct = Math.max(0, Math.min(1000, (appDelta / totalDelta) * cpuScale));
const webkitPct = Math.max(0, Math.min(1000, (webkitDelta / totalDelta) * cpuScale));
setPerfCpu({
app: Number.isFinite(appPct) ? appPct : 0,
webkit: Number.isFinite(webkitPct) ? webkitPct : 0,
supported: true,
});
}
}
const now = Date.now();
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
const counters = root.__psyPerfCounters ?? {};
const nextCounters = {
progress: counters.audioProgressEvents ?? 0,
waveform: counters.waveformDraws ?? 0,
home: counters.homeCommits ?? 0,
};
if (prevCounters && prevCountersAt > 0) {
const dt = Math.max(0.25, (now - prevCountersAt) / 1000);
setPerfDiagRates({
progress: (nextCounters.progress - prevCounters.progress) / dt,
waveform: (nextCounters.waveform - prevCounters.waveform) / dt,
home: (nextCounters.home - prevCounters.home) / dt,
});
}
prevCounters = nextCounters;
prevCountersAt = now;
prev = snap;
} catch {
if (!cancelled) setPerfCpu({ app: 0, webkit: 0, supported: false });
} finally {
if (!cancelled) timer = window.setTimeout(poll, 2000);
}
};
void poll();
return () => {
cancelled = true;
if (timer != null) window.clearTimeout(timer);
};
}, [perfProbeOpen]);
useEffect(() => {
if (!perfProbeOpen) {
setPerfCpu(null);
setPerfDiagRates(null);
}
}, [perfProbeOpen]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey)) return;
if (e.key.toLowerCase() !== 'd') return;
const target = e.target as HTMLElement | null;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
)) return;
e.preventDefault();
setPerfProbeOpen(true);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return (
<>
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-brand">
<div className="sidebar-brand" aria-hidden>
{isCollapsed
? <PSmallLogo style={{ height: '32px', width: 'auto' }} />
: <PsysonicLogo style={{ height: '28px', width: 'auto' }} />
@@ -953,6 +1068,388 @@ export default function Sidebar({
</div>,
document.body,
)}
{perfProbeOpen &&
createPortal(
<div className="modal-overlay modal-overlay--perf-probe" onClick={() => setPerfProbeOpen(false)} role="dialog" aria-modal="true">
<div
className="modal-content sidebar-perf-modal"
onClick={e => e.stopPropagation()}
style={{ maxWidth: 560 }}
>
<button className="modal-close" onClick={() => setPerfProbeOpen(false)}><X size={18} /></button>
<h3 className="modal-title">Performance Probe</h3>
<p className="sidebar-perf-modal__hint">
Temporary runtime switches to estimate UI effect cost.
</p>
<div className="sidebar-perf-modal__cpu">
<div className="sidebar-perf-modal__cpu-title">Live CPU (approx)</div>
{perfCpu == null ? (
<div className="sidebar-perf-modal__cpu-row">Collecting samples</div>
) : perfCpu.supported ? (
<>
<div className="sidebar-perf-modal__cpu-row">psysonic: {perfCpu.app.toFixed(1)}%</div>
<div className="sidebar-perf-modal__cpu-row">WebKitWebProcess: {perfCpu.webkit.toFixed(1)}%</div>
{perfDiagRates && (
<>
<div className="sidebar-perf-modal__cpu-row">audio:progress rate: {perfDiagRates.progress.toFixed(1)}/s</div>
<div className="sidebar-perf-modal__cpu-row">waveform draws rate: {perfDiagRates.waveform.toFixed(1)}/s</div>
<div className="sidebar-perf-modal__cpu-row">Home commits rate: {perfDiagRates.home.toFixed(1)}/s</div>
</>
)}
</>
) : (
<div className="sidebar-perf-modal__cpu-row">Unavailable on this platform/build.</div>
)}
</div>
<details className="sidebar-perf-modal__phase">
<summary className="sidebar-perf-modal__phase-title">Phase 1 Global / Shell / Network</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableWaveformCanvas}
onChange={e => setPerfProbeFlag('disableWaveformCanvas', e.target.checked)}
/>
<span>Disable only PlayerBar waveform (`WaveformSeek`)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disablePlayerProgressUi}
onChange={e => setPerfProbeFlag('disablePlayerProgressUi', e.target.checked)}
/>
<span>Disable player live progress UI updates (time + seek/progress bindings)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMarqueeScroll}
onChange={e => setPerfProbeFlag('disableMarqueeScroll', e.target.checked)}
/>
<span>Disable marquee text scrolling</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableBackdropBlur}
onChange={e => setPerfProbeFlag('disableBackdropBlur', e.target.checked)}
/>
<span>Disable backdrop blur effects</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableCssAnimations}
onChange={e => setPerfProbeFlag('disableCssAnimations', e.target.checked)}
/>
<span>Disable CSS animations and transitions</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableOverlayScrollbars}
onChange={e => setPerfProbeFlag('disableOverlayScrollbars', e.target.checked)}
/>
<span>Disable overlay scrollbar engine (JS + rail)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableTooltipPortal}
onChange={e => setPerfProbeFlag('disableTooltipPortal', e.target.checked)}
/>
<span>Disable global tooltip portal/listeners</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableQueuePanelMount}
onChange={e => setPerfProbeFlag('disableQueuePanelMount', e.target.checked)}
/>
<span>Disable QueuePanel mount (desktop right column)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableBackgroundPolling}
onChange={e => setPerfProbeFlag('disableBackgroundPolling', e.target.checked)}
/>
<span>Disable background polling (connection + radio metadata)</span>
</label>
<div className="sidebar-perf-modal__subhead">Engine/network toggles</div>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={!hotCacheEnabled}
onChange={e => setHotCacheEnabled(!e.target.checked)}
/>
<span>Disable hot-cache prefetch downloads</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={normalizationEngine === 'off'}
onChange={e => setNormalizationEngine(e.target.checked ? 'off' : 'loudness')}
/>
<span>Disable normalization engine (set to Off)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={loggingMode === 'off'}
onChange={e => setLoggingMode(e.target.checked ? 'off' : 'normal')}
/>
<span>Set runtime logging mode to Off</span>
</label>
</details>
<details className="sidebar-perf-modal__phase">
<summary className="sidebar-perf-modal__phase-title">Phase 2 Mainstage (Center Content)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainRouteContentMount}
onChange={e => setPerfProbeFlag('disableMainRouteContentMount', e.target.checked)}
/>
<span>Disable central route content mount</span>
</label>
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested" open>
<summary className="sidebar-perf-modal__phase-title">Shared mainstage layers (multiple pages)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageStickyHeader}
onChange={e => setPerfProbeFlag('disableMainstageStickyHeader', e.target.checked)}
/>
<span>Disable sticky headers (Tracks + Albums)</span>
</label>
</details>
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested" open>
<summary className="sidebar-perf-modal__phase-title">Home (`/`)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageHero}
onChange={e => setPerfProbeFlag('disableMainstageHero', e.target.checked)}
/>
<span>Disable Home hero block</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageHeroBackdrop}
onChange={e => setPerfProbeFlag('disableMainstageHeroBackdrop', e.target.checked)}
/>
<span>Disable Hero backdrop/crossfade only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRails}
onChange={e => setPerfProbeFlag('disableMainstageRails', e.target.checked)}
/>
<span>Disable Home rows/rails (`AlbumRow` + `SongRail`)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeAlbumRows}
onChange={e => setPerfProbeFlag('disableHomeAlbumRows', e.target.checked)}
/>
<span>Disable Home `AlbumRow` sections only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeSongRails}
onChange={e => setPerfProbeFlag('disableHomeSongRails', e.target.checked)}
/>
<span>Disable Home `SongRail` sections only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailArtwork}
onChange={e => setPerfProbeFlag('disableMainstageRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Home rows/rails</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeRailArtwork}
onChange={e => setPerfProbeFlag('disableHomeRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Home rows/rails only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeArtworkFx}
onChange={e => setPerfProbeFlag('disableHomeArtworkFx', e.target.checked)}
/>
<span>Keep artwork, disable Home card visual effects (hover/overlay/shadows)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeArtworkClip}
onChange={e => setPerfProbeFlag('disableHomeArtworkClip', e.target.checked)}
/>
<span>Diagnostic: flatten Home artwork clipping (no rounded corners/masks)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailInteractivity}
onChange={e => setPerfProbeFlag('disableMainstageRailInteractivity', e.target.checked)}
/>
<span>Disable Home rail scroll/nav handlers</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageGridCards}
onChange={e => setPerfProbeFlag('disableMainstageGridCards', e.target.checked)}
/>
<span>Disable Home discover artists chip-grid</span>
</label>
</details>
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested" open>
<summary className="sidebar-perf-modal__phase-title">Tracks (`/tracks`)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageHero}
onChange={e => setPerfProbeFlag('disableMainstageHero', e.target.checked)}
/>
<span>Disable Tracks hero block</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRails}
onChange={e => setPerfProbeFlag('disableMainstageRails', e.target.checked)}
/>
<span>Disable Tracks rails (Highly Rated + Random)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailArtwork}
onChange={e => setPerfProbeFlag('disableMainstageRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Tracks rails</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailInteractivity}
onChange={e => setPerfProbeFlag('disableMainstageRailInteractivity', e.target.checked)}
/>
<span>Disable Tracks rail scroll/nav handlers</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageVirtualLists}
onChange={e => setPerfProbeFlag('disableMainstageVirtualLists', e.target.checked)}
/>
<span>Disable Tracks virtual browse list (`VirtualSongList`)</span>
</label>
</details>
<details className="sidebar-perf-modal__phase sidebar-perf-modal__phase--nested" open>
<summary className="sidebar-perf-modal__phase-title">Albums (`/albums`)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageGridCards}
onChange={e => setPerfProbeFlag('disableMainstageGridCards', e.target.checked)}
/>
<span>Disable Albums card grid (`AlbumCard` list)</span>
</label>
</details>
</details>
<details className="sidebar-perf-modal__phase" open>
<summary className="sidebar-perf-modal__phase-title">Phase 3 Active diagnostics (quick access)</summary>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disablePlayerProgressUi}
onChange={e => setPerfProbeFlag('disablePlayerProgressUi', e.target.checked)}
/>
<span>Disable player live progress UI updates (time + seek/progress bindings)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableWaveformCanvas}
onChange={e => setPerfProbeFlag('disableWaveformCanvas', e.target.checked)}
/>
<span>Disable only PlayerBar waveform (`WaveformSeek`)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeRailArtwork}
onChange={e => setPerfProbeFlag('disableHomeRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Home rows/rails only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRailArtwork}
onChange={e => setPerfProbeFlag('disableMainstageRailArtwork', e.target.checked)}
/>
<span>Disable artwork inside Home rows/rails</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageRails}
onChange={e => setPerfProbeFlag('disableMainstageRails', e.target.checked)}
/>
<span>Disable Home rows/rails (`AlbumRow` + `SongRail`)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableMainstageHeroBackdrop}
onChange={e => setPerfProbeFlag('disableMainstageHeroBackdrop', e.target.checked)}
/>
<span>Disable Hero backdrop/crossfade only</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeArtworkFx}
onChange={e => setPerfProbeFlag('disableHomeArtworkFx', e.target.checked)}
/>
<span>Keep artwork, disable Home card visual effects (hover/overlay/shadows)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.disableHomeArtworkClip}
onChange={e => setPerfProbeFlag('disableHomeArtworkClip', e.target.checked)}
/>
<span>Diagnostic: flatten Home artwork clipping (no rounded corners/masks)</span>
</label>
</details>
<div className="sidebar-perf-modal__actions">
<button type="button" className="btn btn-ghost" onClick={() => resetPerfProbeFlags()}>
Reset
</button>
<button type="button" className="btn btn-primary" onClick={() => setPerfProbeOpen(false)}>
Close
</button>
</div>
</div>
</div>,
document.body,
)}
</>
);
}
+32 -10
View File
@@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus, Star } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -11,14 +11,25 @@ import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
interface SongCardProps {
song: SubsonicSong;
disableArtwork?: boolean;
artworkSize?: number;
directImageSrc?: boolean;
}
function SongCard({ song }: SongCardProps) {
function SongCard({ song, disableArtwork = false, artworkSize = 200, directImageSrc = false }: SongCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const coverUrl = song.coverArt ? buildCoverArtUrl(song.coverArt, 200) : '';
// buildCoverArtUrl emits a salted URL; memoize to avoid churn on rerenders.
const coverUrl = useMemo(
() => (song.coverArt ? buildCoverArtUrl(song.coverArt, artworkSize) : ''),
[song.coverArt, artworkSize],
);
const coverCacheKey = useMemo(
() => (song.coverArt ? coverArtCacheKey(song.coverArt, artworkSize) : ''),
[song.coverArt, artworkSize],
);
const psyDrag = useDragDrop();
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
@@ -74,13 +85,24 @@ function SongCard({ song }: SongCardProps) {
}}
>
<div className="song-card-cover">
{coverUrl ? (
<CachedImage
src={coverUrl}
cacheKey={coverArtCacheKey(song.coverArt!, 200)}
alt={`${song.album} Cover`}
loading="lazy"
/>
{!disableArtwork && coverUrl ? (
directImageSrc ? (
<img
src={coverUrl}
alt={`${song.album} Cover`}
loading="lazy"
decoding="async"
fetchPriority="low"
/>
) : (
<CachedImage
src={coverUrl}
cacheKey={coverCacheKey}
alt={`${song.album} Cover`}
loading="lazy"
decoding="async"
/>
)
) : (
<div className="song-card-cover-placeholder">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
+90 -20
View File
@@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import SongCard from './SongCard';
import { usePerfProbeFlags } from '../utils/perfFlags';
interface Props {
title: string;
@@ -12,25 +13,81 @@ interface Props {
loading?: boolean;
/** Empty-state copy when songs is empty AND not loading. */
emptyText?: string;
disableArtwork?: boolean;
disableInteractivity?: boolean;
artworkSize?: number;
directImageSrc?: boolean;
windowArtworkByViewport?: boolean;
initialArtworkBudget?: number;
}
export default function SongRail({ title, songs, onReroll, loading, emptyText }: Props) {
export default function SongRail({
title,
songs,
onReroll,
loading,
emptyText,
disableArtwork = false,
disableInteractivity = false,
artworkSize,
directImageSrc = false,
windowArtworkByViewport = false,
initialArtworkBudget = 10,
}: Props) {
const perfFlags = usePerfProbeFlags();
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
const recomputeArtworkBudget = () => {
if (!windowArtworkByViewport) return;
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, clientWidth } = el;
const firstCard = el.querySelector<HTMLElement>('.song-card');
const cardW = firstCard?.clientWidth || firstCard?.getBoundingClientRect().width || 140;
const gridStyles = window.getComputedStyle(el);
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '12') || 12;
const step = Math.max(1, cardW + gap);
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 4);
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
};
const handleScroll = () => {
if (interactivityDisabled) return;
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
recomputeArtworkBudget();
};
useEffect(() => {
if (interactivityDisabled) return;
handleScroll();
const raf = window.requestAnimationFrame(() => {
// One post-layout pass ensures we account for final grid/card geometry.
recomputeArtworkBudget();
});
window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll);
}, [songs]);
const ro = new ResizeObserver(() => {
recomputeArtworkBudget();
});
if (scrollRef.current) ro.observe(scrollRef.current);
return () => {
window.cancelAnimationFrame(raf);
window.removeEventListener('resize', handleScroll);
ro.disconnect();
};
}, [songs, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
useEffect(() => {
setArtworkBudget(initialArtworkBudget);
}, [initialArtworkBudget, songs.length]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
@@ -58,20 +115,24 @@ export default function SongRail({ title, songs, onReroll, loading, emptyText }:
<RefreshCw size={16} className={loading ? 'is-spinning' : ''} />
</button>
)}
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
{!interactivityDisabled && (
<>
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
</>
)}
</div>
</div>
@@ -79,9 +140,18 @@ export default function SongRail({ title, songs, onReroll, loading, emptyText }:
{songs.length === 0 && emptyText ? (
<p className="song-row-empty">{emptyText}</p>
) : (
<div className="song-grid" ref={scrollRef} onScroll={handleScroll}>
{songs.map(s => (
<SongCard key={s.id} song={s} />
<div className="song-grid" ref={scrollRef} onScroll={interactivityDisabled ? undefined : handleScroll}>
{songs.map((s, idx) => (
<SongCard
key={s.id}
song={s}
disableArtwork={
artworkDisabled ||
(windowArtworkByViewport && idx >= artworkBudget)
}
artworkSize={artworkSize}
directImageSrc={directImageSrc}
/>
))}
</div>
)}
+210 -59
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
import { useAuthStore, type SeekbarStyle } from '../store/authStore';
function fmt(s: number): string {
if (!s || isNaN(s)) return '0:00';
@@ -15,6 +15,9 @@ const WAVE_MIX_MAX = 0.3;
const SEG_COUNT = 60;
const FLAT_WAVE_NORM = 0.06;
const WAVE_MORPH_MS = 1000;
const STATIC_REDRAW_MIN_MS = 90;
const STATIC_REDRAW_FORCE_MS = 220;
const INTERPOLATION_PAINT_MIN_MS = 80;
// ── animation state ───────────────────────────────────────────────────────────
@@ -40,13 +43,38 @@ const ANIMATED_STYLES = new Set<SeekbarStyle>(['particletrail', 'pulsewave', 'li
// ── color helper ──────────────────────────────────────────────────────────────
function getColors() {
const s = getComputedStyle(document.documentElement);
return {
played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7',
type SeekbarColors = {
played: string;
buffered: string;
unplayed: string;
};
let cachedColors: SeekbarColors | null = null;
let cachedColorsKey = '';
function invalidateColorCache() {
cachedColors = null;
}
function getColors(): SeekbarColors {
const root = document.documentElement;
const style = root.style;
const key = [
root.getAttribute('data-theme') ?? '',
style.getPropertyValue('--accent'),
style.getPropertyValue('--waveform-played'),
style.getPropertyValue('--waveform-buffered'),
style.getPropertyValue('--waveform-unplayed'),
].join('|');
if (cachedColors && cachedColorsKey === key) return cachedColors;
const s = getComputedStyle(root);
cachedColorsKey = key;
cachedColors = {
played: s.getPropertyValue('--waveform-played').trim() || s.getPropertyValue('--accent').trim() || '#cba6f7',
buffered: s.getPropertyValue('--waveform-buffered').trim() || s.getPropertyValue('--ctp-overlay0').trim() || '#6c7086',
unplayed: s.getPropertyValue('--waveform-unplayed').trim() || s.getPropertyValue('--ctp-surface1').trim() || '#313244',
};
return cachedColors;
}
// ── canvas setup ──────────────────────────────────────────────────────────────
@@ -56,9 +84,8 @@ function setupCanvas(
): { ctx: CanvasRenderingContext2D; w: number; h: number } | null {
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const rect = canvas.getBoundingClientRect();
const w = rect.width || canvas.clientWidth;
const h = rect.height || canvas.clientHeight;
const w = canvas.clientWidth || canvas.getBoundingClientRect().width;
const h = canvas.clientHeight || canvas.getBoundingClientRect().height;
if (w === 0 || h === 0) return null;
const dpr = window.devicePixelRatio || 1;
const pw = Math.round(w * dpr);
@@ -72,6 +99,10 @@ function setupCanvas(
return { ctx, w, h };
}
function setShadowBlur(ctx: CanvasRenderingContext2D, blur: number) {
ctx.shadowBlur = Math.max(0, blur);
}
// ── waveform heights ──────────────────────────────────────────────────────────
function hashStr(str: string): number {
@@ -156,6 +187,15 @@ function waveformBarThickness(logicalH: number, norm: number): number {
return Math.max(1, safeNorm * logicalH);
}
function quantizeProgressByBars(progress: number): number {
const clamped = Math.max(0, Math.min(1, progress));
return Math.max(0, Math.min(1, Math.floor(clamped * BAR_COUNT) / BAR_COUNT));
}
function isBarQuantizedSeekStyle(style: SeekbarStyle): boolean {
return style === 'truewave' || style === 'pseudowave';
}
function drawWaveform(
canvas: HTMLCanvasElement,
heights: Float32Array | null,
@@ -166,6 +206,8 @@ function drawWaveform(
if (!r) return;
const { ctx, w, h } = r;
const { played, buffered: buffCol, unplayed } = getColors();
const pNorm = Math.max(0, Math.min(1, progress));
const bNorm = Math.max(pNorm, Math.min(1, buffered));
if (!heights) {
// No waveform data yet: flat rail like `drawLineDot`, but do not return early
@@ -185,32 +227,30 @@ function drawWaveform(
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 5;
ctx.fillRect(0, cy - lh / 2, Math.min(1, progress) * w, lh);
ctx.shadowBlur = 0;
setShadowBlur(ctx, 5);
ctx.fillRect(0, cy - lh / 2, pNorm * w, lh);
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
if (w > 0) {
const dx = Math.max(dotR, Math.min(w - dotR, Math.min(1, progress) * w));
const dx = Math.max(dotR, Math.min(w - dotR, pNorm * w));
ctx.shadowColor = played;
ctx.shadowBlur = 7;
setShadowBlur(ctx, 7);
ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played;
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
return;
}
const x1Of = (i: number) => (i / BAR_COUNT) * w;
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
ctx.globalAlpha = 0.28;
ctx.fillStyle = unplayed;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT < buffered) continue;
if (i / BAR_COUNT < bNorm) continue;
const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
@@ -220,17 +260,17 @@ function drawWaveform(
ctx.fillStyle = buffCol;
for (let i = 0; i < BAR_COUNT; i++) {
const frac = i / BAR_COUNT;
if (frac < progress || frac >= buffered) continue;
if (frac < pNorm || frac >= bNorm) continue;
const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
}
if (progress > 0) {
if (pNorm > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT >= progress) break;
if (i / BAR_COUNT >= pNorm) break;
const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
@@ -264,12 +304,12 @@ function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: numb
const dx = Math.max(dotR, Math.min(w - dotR, progress * w));
ctx.shadowColor = played;
ctx.shadowBlur = 7;
setShadowBlur(ctx, 7);
ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played;
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
@@ -300,11 +340,11 @@ function drawBar(canvas: HTMLCanvasElement, progress: number, buffered: number)
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 5;
setShadowBlur(ctx, 5);
ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
}
@@ -336,11 +376,11 @@ function drawThick(canvas: HTMLCanvasElement, progress: number, buffered: number
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 10;
setShadowBlur(ctx, 10);
ctx.beginPath();
ctx.roundRect(0, y, progress * w, bh, rad);
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
}
@@ -359,13 +399,13 @@ function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: nu
for (let i = 0; i < SEG_COUNT; i++) {
const frac = i / SEG_COUNT;
const x = i * (segW + gap);
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
if (frac < progress) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
if (i === playedIdx - 1) {
ctx.shadowColor = played;
ctx.shadowBlur = 5;
setShadowBlur(ctx, 5);
}
} else if (frac < buffered) {
ctx.globalAlpha = 0.55;
@@ -378,7 +418,7 @@ function drawSegmented(canvas: HTMLCanvasElement, progress: number, buffered: nu
ctx.roundRect(x, y, Math.max(1, segW), segH, 1);
ctx.fill();
}
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
@@ -410,34 +450,34 @@ function drawNeon(canvas: HTMLCanvasElement, progress: number, buffered: number)
ctx.globalAlpha = 0.18;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 22;
setShadowBlur(ctx, 22);
ctx.fillRect(0, cy - 5, px, 10);
// Mid glow
ctx.globalAlpha = 0.45;
ctx.shadowBlur = 12;
setShadowBlur(ctx, 12);
ctx.fillRect(0, cy - 2.5, px, 5);
// Inner glow
ctx.globalAlpha = 0.85;
ctx.shadowBlur = 5;
setShadowBlur(ctx, 5);
ctx.fillRect(0, cy - 1.5, px, 3);
// Bright white core
ctx.globalAlpha = 1;
ctx.fillStyle = '#ffffff';
ctx.shadowColor = played;
ctx.shadowBlur = 4;
setShadowBlur(ctx, 4);
ctx.fillRect(0, cy - 0.75, px, 1.5);
// End-cap flare
ctx.shadowBlur = 16;
setShadowBlur(ctx, 16);
ctx.beginPath();
ctx.arc(px, cy, 2.5, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
@@ -478,16 +518,16 @@ function drawPulseWave(
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 3;
setShadowBlur(ctx, 3);
ctx.fillRect(0, cy - 1, startX, 2);
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
ctx.strokeStyle = played;
ctx.lineWidth = 1.5;
ctx.shadowColor = played;
ctx.shadowBlur = 7;
setShadowBlur(ctx, 7);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
@@ -499,7 +539,7 @@ function drawPulseWave(
ctx.lineTo(x, cy - wave);
}
ctx.stroke();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
@@ -561,22 +601,22 @@ function drawParticleTrail(
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 4;
setShadowBlur(ctx, 4);
ctx.fillRect(0, cy - 1, px, 2);
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
// Particles
ctx.shadowColor = played;
for (const p of animState.particles) {
ctx.globalAlpha = p.life * 0.85;
ctx.shadowBlur = 5;
setShadowBlur(ctx, 5);
ctx.fillStyle = played;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
// Playhead dot
if (progress > 0) {
@@ -584,11 +624,11 @@ function drawParticleTrail(
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 10;
setShadowBlur(ctx, 10);
ctx.beginPath();
ctx.arc(dx, cy, 4, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
ctx.globalAlpha = 1;
@@ -660,9 +700,9 @@ function drawLiquidFill(
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 9;
setShadowBlur(ctx, 9);
ctx.fill();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
// Glass highlight on top
const hl = ctx.createLinearGradient(0, y0, 0, y0 + tubeH * 0.45);
@@ -720,9 +760,9 @@ function drawRetroTape(
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 4;
setShadowBlur(ctx, 4);
ctx.fillRect(0, cy - 1, px - reelR, 2);
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
}
// Spinning reel at playhead
@@ -730,13 +770,13 @@ function drawRetroTape(
ctx.strokeStyle = played;
ctx.lineWidth = 1;
ctx.shadowColor = played;
ctx.shadowBlur = 7;
setShadowBlur(ctx, 7);
// Outer ring
ctx.beginPath();
ctx.arc(px, cy, reelR, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
// Hub
const hubR = Math.max(1.5, reelR * 0.28);
@@ -758,7 +798,7 @@ function drawRetroTape(
}
}
ctx.shadowBlur = 0;
setShadowBlur(ctx, 0);
ctx.globalAlpha = 1;
}
@@ -772,6 +812,9 @@ export function drawSeekbar(
buffered: number,
animState?: AnimState,
) {
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
const counters = root.__psyPerfCounters ?? (root.__psyPerfCounters = Object.create(null) as Record<string, number>);
counters.waveformDraws = (counters.waveformDraws ?? 0) + 1;
const anim = animState ?? makeAnimState();
switch (style) {
case 'truewave': drawWaveform(canvas, heights, progress, buffered); break;
@@ -829,7 +872,7 @@ export function SeekbarPreview({
}
};
const tick = () => {
if (document.hidden || window.__psyHidden || window.__psyBlurred) {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
@@ -902,17 +945,23 @@ export default function WaveformSeek({ trackId }: Props) {
const SEEK_COMMIT_MIN_HOLD_MS = 320;
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
const WHEEL_SEEK_STEP_SECONDS = 10;
const WHEEL_SEEK_DEBOUNCE_MS = 1000;
const WHEEL_SEEK_DEBOUNCE_MS = 350;
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(usePlayerStore.getState().progress);
const bufferedRef = useRef(usePlayerStore.getState().buffered);
const progressRef = useRef(getPlaybackProgressSnapshot().progress);
const bufferedRef = useRef(getPlaybackProgressSnapshot().buffered);
const visualProgressRef = useRef(progressRef.current);
const visualTargetProgressRef = useRef(progressRef.current);
const isDragging = useRef(false);
const animStateRef = useRef<AnimState>(makeAnimState());
const lastStaticDrawAtRef = useRef(0);
const lastStaticDrawProgressRef = useRef(-1);
const lastStaticDrawBufferedRef = useRef(-1);
const [hoverPct, setHoverPct] = useState<number | null>(null);
const seek = usePlayerStore(s => s.seek);
const isPlaying = usePlayerStore(s => s.isPlaying);
const waveformBins = usePlayerStore(s => s.waveformBins);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
@@ -1009,7 +1058,7 @@ export default function WaveformSeek({ trackId }: Props) {
// Imperative subscription — no React re-renders from progress changes.
// Static styles draw here; animated styles only update refs.
useEffect(() => {
return usePlayerStore.subscribe((state, prev) => {
return subscribePlaybackProgress((state, prev) => {
if (state.progress === prev.progress && state.buffered === prev.buffered) return;
// While user drags, keep the local preview stable. External progress ticks
// during streaming/recovery would otherwise fight the cursor and flicker.
@@ -1031,6 +1080,13 @@ export default function WaveformSeek({ trackId }: Props) {
}
progressRef.current = state.progress;
bufferedRef.current = state.buffered;
progressAnchorRef.current = {
progress: state.progress,
atMs: performance.now(),
};
visualTargetProgressRef.current = isBarQuantizedSeekStyle(styleRef.current)
? quantizeProgressByBars(state.progress)
: state.progress;
// Static styles always redraw on progress; animated styles let the rAF
// loop drive paints. In `static` animation mode we skip the rAF loop
// entirely, so animated styles also need to repaint here on every tick.
@@ -1038,7 +1094,25 @@ export default function WaveformSeek({ trackId }: Props) {
!ANIMATED_STYLES.has(styleRef.current) || animationModeRef.current === 'static';
if (drawNow) {
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, styleRef.current, heightsRef.current, state.progress, state.buffered);
if (!canvas) return;
if (!ANIMATED_STYLES.has(styleRef.current) && !isDragging.current) {
const now = Date.now();
const widthPx = Math.max(1, canvas.clientWidth || canvas.width || 1);
const minVisualDelta = 0.35 / widthPx; // allow smoother progress while still skipping no-op paints
const progressDelta = Math.abs(state.progress - lastStaticDrawProgressRef.current);
const bufferedDelta = Math.abs(state.buffered - lastStaticDrawBufferedRef.current);
const ageMs = now - lastStaticDrawAtRef.current;
const visuallySame = progressDelta < minVisualDelta && bufferedDelta < minVisualDelta;
if (
ageMs < STATIC_REDRAW_MIN_MS &&
visuallySame
) return;
if (visuallySame && ageMs < STATIC_REDRAW_FORCE_MS) return;
lastStaticDrawAtRef.current = now;
lastStaticDrawProgressRef.current = state.progress;
lastStaticDrawBufferedRef.current = state.buffered;
}
drawSeekbar(canvas, styleRef.current, heightsRef.current, visualProgressRef.current, state.buffered);
}
});
}, []);
@@ -1088,7 +1162,7 @@ export default function WaveformSeek({ trackId }: Props) {
}
};
const tick = () => {
if (document.hidden || window.__psyHidden || window.__psyBlurred) {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
@@ -1112,6 +1186,66 @@ export default function WaveformSeek({ trackId }: Props) {
return () => stop();
}, [seekbarStyle, animationMode]);
// Smoothly advance progress between sparse transport ticks.
useEffect(() => {
if (!isPlaying || duration <= 0 || !isFinite(duration)) return;
let rafId: number | null = null;
let lastPaintAt = 0;
const tick = (now: number) => {
if (document.hidden || window.__psyHidden) {
rafId = requestAnimationFrame(tick);
return;
}
if (isDragging.current) {
rafId = requestAnimationFrame(tick);
return;
}
const wheelPreviewFraction = wheelPreviewFractionRef.current;
if (wheelPreviewFraction != null && Date.now() < wheelPreviewUntilRef.current) {
rafId = requestAnimationFrame(tick);
return;
}
if (pendingCommittedSeekRef.current) {
rafId = requestAnimationFrame(tick);
return;
}
const anchor = progressAnchorRef.current;
const elapsedSec = Math.max(0, (now - anchor.atMs) / 1000);
const predicted = Math.max(0, Math.min(1, anchor.progress + elapsedSec / duration));
const nextTargetProgress = isBarQuantizedSeekStyle(styleRef.current)
? quantizeProgressByBars(predicted)
: predicted;
if (Math.abs(nextTargetProgress - visualTargetProgressRef.current) > 0.000001) {
visualTargetProgressRef.current = nextTargetProgress;
}
const currentVisual = visualProgressRef.current;
const targetVisual = visualTargetProgressRef.current;
const delta = targetVisual - currentVisual;
if (Math.abs(delta) > 0.000001) {
const smoothing = isBarQuantizedSeekStyle(styleRef.current) ? 0.22 : 0.28;
const nextVisualProgress = Math.abs(delta) < 0.002
? targetVisual
: currentVisual + delta * smoothing;
visualProgressRef.current = nextVisualProgress;
progressRef.current = nextVisualProgress;
const needsDirectDraw =
!ANIMATED_STYLES.has(styleRef.current) || animationModeRef.current === 'static';
if (needsDirectDraw && now - lastPaintAt >= INTERPOLATION_PAINT_MIN_MS) {
const canvas = canvasRef.current;
if (canvas) {
drawSeekbar(canvas, styleRef.current, heightsRef.current, nextVisualProgress, bufferedRef.current, animStateRef.current);
lastPaintAt = now;
}
}
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
if (rafId != null) cancelAnimationFrame(rafId);
};
}, [duration, isPlaying]);
// Resize observer.
useEffect(() => {
const canvas = canvasRef.current;
@@ -1128,6 +1262,7 @@ export default function WaveformSeek({ trackId }: Props) {
const canvas = canvasRef.current;
if (!canvas) return;
const observer = new MutationObserver(() => {
invalidateColorCache();
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
@@ -1140,6 +1275,10 @@ export default function WaveformSeek({ trackId }: Props) {
seekRef.current = seek;
const pendingSeekRef = useRef<number | null>(null);
const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null);
const progressAnchorRef = useRef<{ progress: number; atMs: number }>({
progress: progressRef.current,
atMs: performance.now(),
});
const wheelSeekTimerRef = useRef<number | null>(null);
const queuedWheelSeekFractionRef = useRef<number | null>(null);
const wheelPreviewFractionRef = useRef<number | null>(null);
@@ -1158,6 +1297,12 @@ export default function WaveformSeek({ trackId }: Props) {
// responsiveness; the actual seek is committed on mouseup.
const previewFraction = (fraction: number) => {
progressRef.current = fraction;
visualProgressRef.current = fraction;
visualTargetProgressRef.current = fraction;
progressAnchorRef.current = {
progress: fraction,
atMs: performance.now(),
};
pendingSeekRef.current = fraction;
const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
@@ -1222,6 +1367,12 @@ export default function WaveformSeek({ trackId }: Props) {
// Preventive UI update: move visual playhead immediately on every wheel event.
progressRef.current = nextFraction;
visualProgressRef.current = nextFraction;
visualTargetProgressRef.current = nextFraction;
progressAnchorRef.current = {
progress: nextFraction,
atMs: performance.now(),
};
wheelPreviewFractionRef.current = nextFraction;
wheelPreviewUntilRef.current = now + WHEEL_SEEK_DEBOUNCE_MS;
const canvas = canvasRef.current;
+11 -1
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useAuthStore } from '../store/authStore';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import { serverListDisplayLabel } from '../utils/serverDisplayName';
import { usePerfProbeFlags } from '../utils/perfFlags';
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
@@ -22,6 +23,7 @@ export function isLanUrl(url: string): boolean {
}
export function useConnectionStatus() {
const perfFlags = usePerfProbeFlags();
const [status, setStatus] = useState<ConnectionStatus>('checking');
const [isRetrying, setIsRetrying] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -61,6 +63,14 @@ export function useConnectionStatus() {
}, [check]);
useEffect(() => {
if (perfFlags.disableBackgroundPolling) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setStatus('connected');
return;
}
check();
intervalRef.current = setInterval(check, 120_000);
@@ -75,7 +85,7 @@ export function useConnectionStatus() {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [check]);
}, [check, perfFlags.disableBackgroundPolling]);
const server = useAuthStore(s => s.getActiveServer());
const servers = useAuthStore(s => s.servers);
+9 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import type { InternetRadioStation } from '../api/subsonic';
import { usePerfProbeFlags } from '../utils/perfFlags';
import {
guessAzuraCastApiUrl,
normaliseAzuraCastHomepageUrl,
@@ -88,6 +89,7 @@ const ICY_POLL_MS = 30_000;
const EMPTY_METADATA: RadioMetadata = { source: 'none', history: [] };
export function useRadioMetadata(station: InternetRadioStation | null): RadioMetadata {
const perfFlags = usePerfProbeFlags();
const [metadata, setMetadata] = useState<RadioMetadata>(EMPTY_METADATA);
// Keep elapsed in sync while AzuraCast is active: advance 1 s/tick while playing.
@@ -124,6 +126,12 @@ export function useRadioMetadata(station: InternetRadioStation | null): RadioMet
}
useEffect(() => {
if (perfFlags.disableBackgroundPolling) {
setMetadata(EMPTY_METADATA);
azuraCastUrlRef.current = null;
stopElapsedTick();
return;
}
if (!station) {
setMetadata(EMPTY_METADATA);
azuraCastUrlRef.current = null;
@@ -202,7 +210,7 @@ export function useRadioMetadata(station: InternetRadioStation | null): RadioMet
stopElapsedTick();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [station?.id, station?.streamUrl, station?.homepageUrl]);
}, [station?.id, station?.streamUrl, station?.homepageUrl, perfFlags.disableBackgroundPolling]);
return metadata;
}
+87 -81
View File
@@ -15,6 +15,7 @@ import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3, ListPlus } from 'lucide-react';
import { usePerfProbeFlags } from '../utils/perfFlags';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
type CompFilter = 'all' | 'only' | 'hide';
@@ -32,6 +33,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
}
export default function Albums() {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
@@ -218,80 +220,82 @@ export default function Albums() {
return (
<div className="content-body animate-fade-in">
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('albums.title')}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{selectionMode && selectedIds.size > 0 ? (
<>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleEnqueueSelected}>
<ListPlus size={15} />
{t('albums.enqueueSelected', { count: selectedIds.size })}
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
<HardDriveDownload size={15} />
{t('albums.addOffline')}
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
<Download size={15} />
{t('albums.downloadZips')}
</button>
</>
) : (
<>
{!yearActive && (
<SortDropdown
value={sort}
options={sortOptions}
onChange={setSort}
{!perfFlags.disableMainstageStickyHeader && (
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('albums.title')}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{selectionMode && selectedIds.size > 0 ? (
<>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleEnqueueSelected}>
<ListPlus size={15} />
{t('albums.enqueueSelected', { count: selectedIds.size })}
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
<HardDriveDownload size={15} />
{t('albums.addOffline')}
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
<Download size={15} />
{t('albums.downloadZips')}
</button>
</>
) : (
<>
{!yearActive && (
<SortDropdown
value={sort}
options={sortOptions}
onChange={setSort}
/>
)}
<YearFilterButton
from={yearFrom}
to={yearTo}
onChange={(from, to) => { setYearFrom(from); setYearTo(to); }}
/>
)}
<YearFilterButton
from={yearFrom}
to={yearTo}
onChange={(from, to) => { setYearFrom(from); setYearTo(to); }}
/>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<button
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
onClick={cycleCompFilter}
data-tooltip={
compFilter === 'all' ? t('albums.compilationTooltipAll')
: compFilter === 'only' ? t('albums.compilationTooltipOnly')
: t('albums.compilationTooltipHide')
}
data-tooltip-pos="bottom"
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
}}
>
<Disc3 size={14} />
{compFilter === 'all' ? t('albums.compilationLabel')
: compFilter === 'only' ? t('albums.compilationOnly')
: t('albums.compilationHide')}
</button>
</>
)}
<button
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
onClick={cycleCompFilter}
data-tooltip={
compFilter === 'all' ? t('albums.compilationTooltipAll')
: compFilter === 'only' ? t('albums.compilationTooltipOnly')
: t('albums.compilationTooltipHide')
}
data-tooltip-pos="bottom"
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
}}
>
<Disc3 size={14} />
{compFilter === 'all' ? t('albums.compilationLabel')
: compFilter === 'only' ? t('albums.compilationOnly')
: t('albums.compilationHide')}
</button>
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
</button>
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
</button>
</div>
</div>
</div>
)}
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
@@ -299,18 +303,20 @@ export default function Albums() {
</div>
) : (
<>
<div className="album-grid-wrap">
{visibleAlbums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
{!perfFlags.disableMainstageGridCards && (
<div className="album-grid-wrap">
{visibleAlbums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
)}
{!genreFiltered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
+102 -9
View File
@@ -9,14 +9,27 @@ import { ChevronRight } from 'lucide-react';
import { useHomeStore } from '../store/homeStore';
import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
import { usePerfProbeFlags } from '../utils/perfFlags';
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
const HOME_RANDOM_FETCH = 100;
const HOME_HERO_COUNT = 8;
const HOME_DISCOVER_SLICE = 20;
const HOME_DISCOVER_SONGS_SIZE = 18;
const HOME_ALBUM_ROW_ARTWORK_SIZE = 300;
const HOME_SONG_RAIL_ARTWORK_SIZE = 200;
const HOME_DIRECT_IMAGE_SRC = false;
const HOME_ARTWORK_WINDOWING = true;
const HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET = 3;
const HOME_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 4;
// Keep artwork enabled across Home rows in normal mode.
const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8;
export default function Home() {
const perfFlags = usePerfProbeFlags();
const homeAlbumRowsDisabled = perfFlags.disableMainstageRails || perfFlags.disableHomeAlbumRows;
const homeSongRailsDisabled = perfFlags.disableMainstageRails || perfFlags.disableHomeSongRails;
const homeRailArtworkDisabled = perfFlags.disableMainstageRailArtwork || perfFlags.disableHomeRailArtwork;
const homeSections = useHomeStore(s => s.sections);
const activeServerId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
@@ -35,6 +48,12 @@ export default function Home() {
const [discoverSongs, setDiscoverSongs] = useState<SubsonicSong[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
const counters = root.__psyPerfCounters ?? (root.__psyPerfCounters = Object.create(null) as Record<string, number>);
counters.homeCommits = (counters.homeCommits ?? 0) + 1;
});
useEffect(() => {
let cancelled = false;
setLoading(true);
@@ -105,10 +124,54 @@ export default function Home() {
const { t } = useTranslation();
const navigate = useNavigate();
let artworkRowsLeft = homeRailArtworkDisabled ? 0 : HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED;
const reserveArtworkRow = () => {
if (artworkRowsLeft <= 0) return false;
artworkRowsLeft -= 1;
return true;
};
const recentArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('recent') &&
recent.length > 0 &&
reserveArtworkRow();
const discoverArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('discover') &&
random.length > 0 &&
reserveArtworkRow();
const discoverSongsArtworkEnabled =
!homeRailArtworkDisabled &&
!homeSongRailsDisabled &&
isVisible('discoverSongs') &&
discoverSongs.length > 0 &&
reserveArtworkRow();
const recentlyPlayedArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('recentlyPlayed') &&
recentlyPlayed.length > 0 &&
reserveArtworkRow();
const starredArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('starred') &&
starred.length > 0 &&
reserveArtworkRow();
const mostPlayedArtworkEnabled =
!homeRailArtworkDisabled &&
!homeAlbumRowsDisabled &&
isVisible('mostPlayed') &&
mostPlayed.length > 0 &&
reserveArtworkRow();
const homeLiteArtworkFx = perfFlags.disableHomeArtworkFx;
const homeFlatArtworkClip = perfFlags.disableHomeArtworkClip;
return (
<div className="animate-fade-in">
{isVisible('hero') && <Hero albums={heroAlbums} />}
<div className={`animate-fade-in${homeLiteArtworkFx ? ' home-lite-artwork' : ''}${homeFlatArtworkClip ? ' home-flat-artwork-clip' : ''}`}>
{!perfFlags.disableMainstageHero && isVisible('hero') && <Hero albums={heroAlbums} />}
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
{loading ? (
@@ -117,31 +180,46 @@ export default function Home() {
</div>
) : (
<>
{isVisible('recent') && (
{!homeAlbumRowsDisabled && isVisible('recent') && (
<AlbumRow
title={t('home.recent')}
titleLink="/new-releases"
albums={recent}
onLoadMore={() => loadMore('newest', recent, setRecent)}
moreText={t('home.loadMore')}
disableArtwork={!recentArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
directImageSrc={HOME_DIRECT_IMAGE_SRC}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/>
)}
{isVisible('discover') && (
{!homeAlbumRowsDisabled && isVisible('discover') && (
<AlbumRow
title={t('home.discover')}
titleLink="/random/albums"
albums={random}
onLoadMore={() => loadMore('random', random, setRandom)}
moreText={t('home.discoverMore')}
disableArtwork={!discoverArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
directImageSrc={HOME_DIRECT_IMAGE_SRC}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/>
)}
{isVisible('discoverSongs') && discoverSongs.length > 0 && (
{!homeSongRailsDisabled && isVisible('discoverSongs') && discoverSongs.length > 0 && (
<SongRail
title={t('home.discoverSongs')}
songs={discoverSongs}
disableArtwork={!discoverSongsArtworkEnabled}
artworkSize={HOME_SONG_RAIL_ARTWORK_SIZE}
directImageSrc={HOME_DIRECT_IMAGE_SRC}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_SONG_RAIL_INITIAL_ARTWORK_BUDGET}
/>
)}
{isVisible('discoverArtists') && randomArtists.length > 0 && (
{!perfFlags.disableMainstageGridCards && isVisible('discoverArtists') && randomArtists.length > 0 && (
<section className="album-row-section">
<div className="album-row-header">
<NavLink to="/artists" className="section-title-link" style={{ marginBottom: 0 }}>
@@ -161,30 +239,45 @@ export default function Home() {
</div>
</section>
)}
{isVisible('recentlyPlayed') && recentlyPlayed.length > 0 && (
{!homeAlbumRowsDisabled && isVisible('recentlyPlayed') && recentlyPlayed.length > 0 && (
<AlbumRow
title={t('home.recentlyPlayed')}
albums={recentlyPlayed}
onLoadMore={() => loadMore('recent', recentlyPlayed, setRecentlyPlayed)}
moreText={t('home.loadMore')}
disableArtwork={!recentlyPlayedArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
directImageSrc={HOME_DIRECT_IMAGE_SRC}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/>
)}
{isVisible('starred') && starred.length > 0 && (
{!homeAlbumRowsDisabled && isVisible('starred') && starred.length > 0 && (
<AlbumRow
title={t('home.starred')}
titleLink="/favorites"
albums={starred}
onLoadMore={() => loadMore('starred', starred, setStarred)}
moreText={t('home.loadMore')}
disableArtwork={!starredArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
directImageSrc={HOME_DIRECT_IMAGE_SRC}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/>
)}
{isVisible('mostPlayed') && (
{!homeAlbumRowsDisabled && isVisible('mostPlayed') && (
<AlbumRow
title={t('home.mostPlayed')}
titleLink="/most-played"
albums={mostPlayed}
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
moreText={t('home.loadMore')}
disableArtwork={!mostPlayedArtworkEnabled}
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
directImageSrc={HOME_DIRECT_IMAGE_SRC}
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
/>
)}
</>
+26 -18
View File
@@ -15,6 +15,7 @@ import SongRail from '../components/SongRail';
import VirtualSongList from '../components/VirtualSongList';
import { playSongNow } from '../utils/playSong';
import { ndListSongs, ndInvalidateSongsCache } from '../api/navidromeBrowse';
import { usePerfProbeFlags } from '../utils/perfFlags';
const RANDOM_RAIL_SIZE = 18;
/** Over-fetch buffer so the client-side `userRating > 0` filter still leaves
@@ -27,6 +28,7 @@ const RATED_RAIL_DISPLAY = 30;
const RATED_RAIL_CACHE_MS = 60_000;
export default function Tracks() {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const navigate = useNavigate();
const activeServerId = useAuthStore(s => s.activeServerId);
@@ -97,14 +99,16 @@ export default function Tracks() {
return (
<div className="content-body animate-fade-in tracks-page">
<header className="tracks-header">
<div className="tracks-header-text">
<h1 className="page-title">{t('tracks.title')}</h1>
<p className="tracks-subtitle">{t('tracks.subtitle')}</p>
</div>
</header>
{!perfFlags.disableMainstageStickyHeader && (
<header className="tracks-header">
<div className="tracks-header-text">
<h1 className="page-title">{t('tracks.title')}</h1>
<p className="tracks-subtitle">{t('tracks.subtitle')}</p>
</div>
</header>
)}
{hero && (
{!perfFlags.disableMainstageHero && hero && (
<section className="tracks-hero">
<div className="tracks-hero-cover">
{heroCoverUrl ? (
@@ -168,7 +172,7 @@ export default function Tracks() {
</section>
)}
{ratedSupported && (ratedLoading || rated.length > 0) && (
{!perfFlags.disableMainstageRails && ratedSupported && (ratedLoading || rated.length > 0) && (
<SongRail
title={t('tracks.railHighlyRated')}
songs={rated}
@@ -177,17 +181,21 @@ export default function Tracks() {
/>
)}
<SongRail
title={t('tracks.railRandom')}
songs={railSongs}
loading={randomLoading}
onReroll={rerollRandom}
/>
{!perfFlags.disableMainstageRails && (
<SongRail
title={t('tracks.railRandom')}
songs={railSongs}
loading={randomLoading}
onReroll={rerollRandom}
/>
)}
<VirtualSongList
title={t('tracks.browseTitle')}
emptyBrowseText={t('tracks.browseUnsupported')}
/>
{!perfFlags.disableMainstageVirtualLists && (
<VirtualSongList
title={t('tracks.browseTitle')}
emptyBrowseText={t('tracks.browseUnsupported')}
/>
)}
</div>
);
}
+129 -10
View File
@@ -22,6 +22,7 @@ import {
getMixMinRatingsConfigFromAuth,
passesMixMinRatings,
} from '../utils/mixRatingFilter';
import { getPerfProbeFlags } from '../utils/perfFlags';
const QUEUE_VISIBILITY_STORAGE_KEY = 'psysonic_queue_visible';
@@ -619,6 +620,60 @@ let seekFallbackVisualTarget: { trackId: string; seconds: number; setAtMs: numbe
const SEEK_FALLBACK_VISUAL_GUARD_MS = 1600;
const SEEK_FALLBACK_RETRY_INTERVAL_MS = 180;
const SEEK_FALLBACK_RETRY_MAX_MS = 6000;
const LIVE_PROGRESS_EMIT_MIN_MS = 1500;
const LIVE_PROGRESS_EMIT_MIN_DELTA_SEC = 0.9;
let lastLiveProgressEmitAt = 0;
const STORE_PROGRESS_COMMIT_MIN_MS = 20_000;
const STORE_PROGRESS_COMMIT_MIN_DELTA_SEC = 5.0;
let lastStoreProgressCommitAt = 0;
export type PlaybackProgressSnapshot = {
currentTime: number;
progress: number;
buffered: number;
};
let playbackProgressSnapshot: PlaybackProgressSnapshot = {
currentTime: 0,
progress: 0,
buffered: 0,
};
const playbackProgressListeners = new Set<(
next: PlaybackProgressSnapshot,
prev: PlaybackProgressSnapshot
) => void>();
function emitPlaybackProgress(next: PlaybackProgressSnapshot): void {
const prev = playbackProgressSnapshot;
if (
Math.abs(prev.currentTime - next.currentTime) < 0.005 &&
Math.abs(prev.progress - next.progress) < 0.0002 &&
Math.abs(prev.buffered - next.buffered) < 0.0002
) {
return;
}
playbackProgressSnapshot = next;
playbackProgressListeners.forEach(cb => cb(next, prev));
}
export function getPlaybackProgressSnapshot(): PlaybackProgressSnapshot {
return playbackProgressSnapshot;
}
export function subscribePlaybackProgress(
cb: (next: PlaybackProgressSnapshot, prev: PlaybackProgressSnapshot) => void,
): () => void {
playbackProgressListeners.add(cb);
return () => {
playbackProgressListeners.delete(cb);
};
}
function bumpUiPerfCounter(key: 'audioProgressEvents'): void {
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
const counters = root.__psyPerfCounters ?? (root.__psyPerfCounters = Object.create(null) as Record<string, number>);
counters[key] = (counters[key] ?? 0) + 1;
}
/** Deferred pause / resume — cleared on stop, new track, manual pause/resume. */
let scheduledPauseTimer: number | null = null;
@@ -1178,17 +1233,22 @@ function flushQueueSyncToServer(queue: Track[], currentTrack: Track | null, curr
export function flushPlayQueuePosition(): Promise<void> {
const s = usePlayerStore.getState();
if (s.currentRadio) return Promise.resolve();
return flushQueueSyncToServer(s.queue, s.currentTrack, s.currentTime);
return flushQueueSyncToServer(s.queue, s.currentTrack, getPlaybackProgressSnapshot().currentTime);
}
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
function handleAudioPlaying(_duration: number) {
setDeferHotCachePrefetch(false);
lastLiveProgressEmitAt = 0;
lastStoreProgressCommitAt = 0;
usePlayerStore.setState({ isPlaying: true });
}
function handleAudioProgress(current_time: number, duration: number) {
bumpUiPerfCounter('audioProgressEvents');
const perfFlags = getPerfProbeFlags();
const progressUiDisabled = perfFlags.disablePlayerProgressUi;
// 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.
@@ -1209,6 +1269,10 @@ function handleAudioProgress(current_time: number, duration: number) {
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (!track) return;
// Some backends can emit stale progress ticks shortly after pause/stop.
// Ignoring them avoids reactivating UI redraw loops while transport is idle.
const transportActive = store.isPlaying || store.currentRadio != null;
if (!transportActive && !seekFallbackVisualTarget) return;
if (seekFallbackVisualTarget && seekFallbackVisualTarget.trackId !== track.id) {
seekFallbackVisualTarget = null;
}
@@ -1230,11 +1294,26 @@ function handleAudioProgress(current_time: number, duration: number) {
const dur = duration > 0 ? duration : track.duration;
if (dur <= 0) return;
const progress = displayTime / dur;
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
// Heartbeat: push current position to the server every 15 s while
// playing so cross-device resume works even on a hard close — pause()
// and the close handler flush on top of this for clean shutdowns.
if (!progressUiDisabled) {
const nowLive = Date.now();
const live = getPlaybackProgressSnapshot();
const liveTimeDelta = Math.abs(live.currentTime - displayTime);
if (
nowLive - lastLiveProgressEmitAt >= LIVE_PROGRESS_EMIT_MIN_MS ||
liveTimeDelta >= LIVE_PROGRESS_EMIT_MIN_DELTA_SEC ||
seekFallbackVisualTarget != null
) {
emitPlaybackProgress({
currentTime: displayTime,
progress,
buffered: 0,
});
lastLiveProgressEmitAt = nowLive;
}
}
// Heartbeat: push current position to the server every 15 s while playing so
// cross-device resume works even on a hard close — pause() and the close
// handler flush on top of this for clean shutdowns.
if (store.isPlaying && !store.currentRadio) {
const now = Date.now();
if (now - lastQueueHeartbeatAt >= 15_000) {
@@ -1251,6 +1330,19 @@ function handleAudioProgress(current_time: number, duration: number) {
lastfmScrobble(track, Date.now(), lastfmSessionKey);
}
}
if (progressUiDisabled) return;
// Critical architectural guard: avoid high-frequency writes to the persisted
// Zustand store (each write serializes queue state). Keep only coarse commits.
const nowCommit = Date.now();
const commitDelta = Math.abs(store.currentTime - displayTime);
const shouldCommitStore =
seekFallbackVisualTarget != null ||
nowCommit - lastStoreProgressCommitAt >= STORE_PROGRESS_COMMIT_MIN_MS ||
commitDelta >= STORE_PROGRESS_COMMIT_MIN_DELTA_SEC;
if (shouldCommitStore) {
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
lastStoreProgressCommitAt = nowCommit;
}
// Pre-buffer / pre-chain next track based on preload mode and crossfade.
const {
@@ -1786,10 +1878,11 @@ export function initAudioListeners(): () => void {
return;
}
// Keep position in sync while playing — update every ~500 ms so Plasma
// Keep position in sync while playing — update at a coarse cadence so UI
// updates do not amplify IPC churn on Linux/WebKit.
// always shows the correct time without interpolation gaps.
// Radio streams have no meaningful position, so skip for radio.
if (!currentRadio && isPlaying && Date.now() - lastMprisPositionUpdate >= 500) {
if (!currentRadio && isPlaying && Date.now() - lastMprisPositionUpdate >= 1500) {
lastMprisPositionUpdate = Date.now();
invoke('mpris_set_playback', {
playing: true,
@@ -1797,6 +1890,16 @@ export function initAudioListeners(): () => void {
}).catch(() => {});
}
});
const unsubMprisProgress = subscribePlaybackProgress(({ currentTime }) => {
const { currentRadio, isPlaying } = usePlayerStore.getState();
if (currentRadio || !isPlaying) return;
if (Date.now() - lastMprisPositionUpdate < 1500) return;
lastMprisPositionUpdate = Date.now();
invoke('mpris_set_playback', {
playing: true,
positionSecs: currentTime,
}).catch(() => {});
});
// ── Radio ICY StreamTitle → MPRIS ─────────────────────────────────────────
// The Rust download task emits "radio:metadata" with { title, is_ad } every
@@ -1833,7 +1936,8 @@ export function initAudioListeners(): () => void {
let discordPrevTemplateLargeText: string | null = null;
function syncDiscord() {
const { currentTrack, isPlaying, currentTime } = usePlayerStore.getState();
const { currentTrack, isPlaying } = usePlayerStore.getState();
const currentTime = getPlaybackProgressSnapshot().currentTime;
const {
discordRichPresence,
enableAppleMusicCoversDiscord,
@@ -1893,6 +1997,7 @@ export function initAudioListeners(): () => void {
unsubAuth();
unsubAnalysisSync();
unsubMpris();
unsubMprisProgress();
unsubDiscordPlayer();
unsubDiscordAuth();
pending.forEach(p => p.then(unlisten => unlisten()));
@@ -2832,7 +2937,8 @@ export const usePlayerStore = create<PlayerState>()(
},
previous: () => {
const { queue, queueIndex, currentTime } = get();
const { queue, queueIndex } = get();
const currentTime = getPlaybackProgressSnapshot().currentTime;
if (currentTime > 3) {
// Restart current track from the beginning.
invoke('audio_seek', { seconds: 0 }).catch(console.error);
@@ -3186,6 +3292,19 @@ export const usePlayerStore = create<PlayerState>()(
)
);
usePlayerStore.subscribe((state, prev) => {
if (
state.currentTime === prev.currentTime &&
state.progress === prev.progress &&
state.buffered === prev.buffered
) return;
emitPlaybackProgress({
currentTime: state.currentTime,
progress: state.progress,
buffered: state.buffered,
});
});
const QUEUE_UNDO_HOTKEY_FLAG = '__psyQueueUndoListenerInstalled';
/** True when the event path includes a real text field — skip queue undo so Ctrl+Z stays native there. */
+180
View File
@@ -414,6 +414,12 @@
scroll-snap-align: start;
}
/* Horizontal rails can hold many off-screen cards; skip their paint/layout until visible. */
.album-grid .album-card {
content-visibility: auto;
contain-intrinsic-size: 0 240px;
}
.album-card-more {
flex: 0 0 clamp(140px, 15vw, 180px);
display: flex;
@@ -2298,6 +2304,14 @@ html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .pl
animation: fadeIn 150ms ease both;
}
/* Perf probe must not blur/composite the whole app while profiling. */
.modal-overlay--perf-probe {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
background: rgba(17, 17, 27, 0.96);
animation: none;
}
.modal-content {
background: var(--ctp-surface0);
border: 1px solid var(--border);
@@ -2338,6 +2352,99 @@ html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .pl
font-family: var(--font-display);
}
.sidebar-perf-modal {
max-width: 560px;
}
.sidebar-perf-modal__hint {
margin: 0 0 12px;
font-size: 13px;
color: var(--text-muted);
}
.sidebar-perf-modal__item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
font-size: 13px;
color: var(--text-primary);
}
.sidebar-perf-modal__item input[type="checkbox"] {
accent-color: var(--accent);
width: 15px;
height: 15px;
}
.sidebar-perf-modal__phase {
margin-top: 10px;
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
border-radius: 10px;
padding: 8px 10px 10px;
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 78%, transparent);
}
.sidebar-perf-modal__phase--nested {
margin-left: 10px;
margin-right: 2px;
padding-top: 6px;
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 68%, transparent);
}
.sidebar-perf-modal__phase-title {
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
user-select: none;
list-style: none;
}
.sidebar-perf-modal__phase-title::-webkit-details-marker {
display: none;
}
.sidebar-perf-modal__cpu {
margin: 0 0 10px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 80%, transparent);
}
.sidebar-perf-modal__cpu-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 6px;
}
.sidebar-perf-modal__cpu-row {
font-size: 13px;
color: var(--text-primary);
line-height: 1.45;
}
.sidebar-perf-modal__subhead {
margin-top: 10px;
margin-bottom: 2px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
color: var(--text-muted);
}
.sidebar-perf-modal__actions {
margin-top: 14px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ─ Playback delay (sleep / delayed start) modal ─ */
.playback-delay-modal {
max-height: min(85vh, 560px);
@@ -6019,6 +6126,7 @@ html.no-compositing .fs-seekbar-played {
flex-shrink: 0;
}
/* Server switch menu is portaled to body (ConnectionIndicator); rules kept for any inline-less use. */
.connection-indicator-dropdown-panel {
position: absolute;
top: calc(100% + 6px);
@@ -11129,6 +11237,78 @@ html[data-app-blurred="true"] .spin {
animation-play-state: paused !important;
}
html[data-perf-disable-blur="true"] *,
html[data-perf-disable-blur="true"] *::before,
html[data-perf-disable-blur="true"] *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
html[data-perf-disable-animations="true"] *,
html[data-perf-disable-animations="true"] *::before,
html[data-perf-disable-animations="true"] *::after {
animation: none !important;
transition: none !important;
}
html[data-perf-disable-overlay-scroll="true"] .overlay-scroll__rail {
display: none !important;
}
html[data-perf-disable-mainstage-header="true"] .page-sticky-header,
html[data-perf-disable-mainstage-header="true"] .tracks-header {
display: none !important;
}
/* Home artwork diagnostics: keep images, strip expensive card compositing effects. */
.home-lite-artwork .album-card,
.home-lite-artwork .song-card {
box-shadow: inset 0 0 0 1px var(--border-subtle) !important;
transition: none !important;
transform: none !important;
contain: layout paint;
}
.home-lite-artwork .album-card:hover,
.home-lite-artwork .song-card:hover {
transform: none !important;
box-shadow: inset 0 0 0 1px var(--border-subtle) !important;
}
.home-lite-artwork .album-card-cover img,
.home-lite-artwork .song-card-cover img {
transition: none !important;
transform: none !important;
}
.home-lite-artwork .album-card-play-overlay,
.home-lite-artwork .song-card-play-overlay {
display: none !important;
}
.home-lite-artwork .album-row-section,
.home-lite-artwork .song-row-section {
content-visibility: auto;
contain-intrinsic-size: 0 340px;
}
/* Home artwork diagnostic mode: keep images, remove rounded clipping/masks. */
html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .album-card,
html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card,
html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .album-card-cover,
html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-cover,
html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .album-card-cover img,
html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-cover img,
.home-flat-artwork-clip.home-lite-artwork .album-card,
.home-flat-artwork-clip.home-lite-artwork .song-card,
.home-flat-artwork-clip.home-lite-artwork .album-card-cover,
.home-flat-artwork-clip.home-lite-artwork .song-card-cover,
.home-flat-artwork-clip.home-lite-artwork .album-card-cover img,
.home-flat-artwork-clip.home-lite-artwork .song-card-cover img {
border-radius: 0 !important;
clip-path: none !important;
}
/* ── Now Playing Info panel ──────────────────────────────────────────────── */
.np-info {
flex: 1;
+6
View File
@@ -186,6 +186,12 @@
scrollbar-width: none;
}
/* Keep off-screen rail cards out of paint/compositing work. */
.song-grid .song-card {
content-visibility: auto;
contain-intrinsic-size: 0 190px;
}
.song-grid::-webkit-scrollbar {
display: none;
}
+149
View File
@@ -0,0 +1,149 @@
import { useSyncExternalStore } from 'react';
export type PerfProbeFlags = {
disableWaveformCanvas: boolean;
disablePlayerProgressUi: boolean;
disableMarqueeScroll: boolean;
disableBackdropBlur: boolean;
disableCssAnimations: boolean;
disableOverlayScrollbars: boolean;
disableTooltipPortal: boolean;
disableQueuePanelMount: boolean;
disableBackgroundPolling: boolean;
disableMainRouteContentMount: boolean;
disableMainstageHero: boolean;
disableMainstageRails: boolean;
disableMainstageGridCards: boolean;
disableMainstageVirtualLists: boolean;
disableMainstageStickyHeader: boolean;
disableMainstageHeroBackdrop: boolean;
disableMainstageRailArtwork: boolean;
disableMainstageRailInteractivity: boolean;
disableHomeAlbumRows: boolean;
disableHomeSongRails: boolean;
disableHomeRailArtwork: boolean;
disableHomeArtworkFx: boolean;
disableHomeArtworkClip: boolean;
};
const STORAGE_KEY = 'psysonic_perf_probe_flags_v1';
const DEFAULT_FLAGS: PerfProbeFlags = {
disableWaveformCanvas: false,
disablePlayerProgressUi: false,
disableMarqueeScroll: false,
disableBackdropBlur: false,
disableCssAnimations: false,
disableOverlayScrollbars: false,
disableTooltipPortal: false,
disableQueuePanelMount: false,
disableBackgroundPolling: false,
disableMainRouteContentMount: false,
disableMainstageHero: false,
disableMainstageRails: false,
disableMainstageGridCards: false,
disableMainstageVirtualLists: false,
disableMainstageStickyHeader: false,
disableMainstageHeroBackdrop: false,
disableMainstageRailArtwork: false,
disableMainstageRailInteractivity: false,
disableHomeAlbumRows: false,
disableHomeSongRails: false,
disableHomeRailArtwork: false,
disableHomeArtworkFx: false,
disableHomeArtworkClip: false,
};
let flags: PerfProbeFlags = { ...DEFAULT_FLAGS };
const listeners = new Set<() => void>();
function safeParseFlags(raw: string | null): Partial<PerfProbeFlags> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as Partial<PerfProbeFlags>;
return parsed ?? {};
} catch {
return {};
}
}
function applyFlagsToDom(next: PerfProbeFlags): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.dataset.perfDisableWaveform = next.disableWaveformCanvas ? 'true' : 'false';
root.dataset.perfDisablePlayerProgressUi = next.disablePlayerProgressUi ? 'true' : 'false';
root.dataset.perfDisableMarquee = next.disableMarqueeScroll ? 'true' : 'false';
root.dataset.perfDisableBlur = next.disableBackdropBlur ? 'true' : 'false';
root.dataset.perfDisableAnimations = next.disableCssAnimations ? 'true' : 'false';
root.dataset.perfDisableOverlayScroll = next.disableOverlayScrollbars ? 'true' : 'false';
root.dataset.perfDisableTooltipPortal = next.disableTooltipPortal ? 'true' : 'false';
root.dataset.perfDisableQueuePanel = next.disableQueuePanelMount ? 'true' : 'false';
root.dataset.perfDisableBackgroundPolling = next.disableBackgroundPolling ? 'true' : 'false';
root.dataset.perfDisableMainRoute = next.disableMainRouteContentMount ? 'true' : 'false';
root.dataset.perfDisableMainstageHero = next.disableMainstageHero ? 'true' : 'false';
root.dataset.perfDisableMainstageRails = next.disableMainstageRails ? 'true' : 'false';
root.dataset.perfDisableMainstageGrid = next.disableMainstageGridCards ? 'true' : 'false';
root.dataset.perfDisableMainstageVirtual = next.disableMainstageVirtualLists ? 'true' : 'false';
root.dataset.perfDisableMainstageHeader = next.disableMainstageStickyHeader ? 'true' : 'false';
root.dataset.perfDisableMainstageHeroBackdrop = next.disableMainstageHeroBackdrop ? 'true' : 'false';
root.dataset.perfDisableMainstageRailArtwork = next.disableMainstageRailArtwork ? 'true' : 'false';
root.dataset.perfDisableMainstageRailInteractivity = next.disableMainstageRailInteractivity ? 'true' : 'false';
root.dataset.perfDisableHomeAlbumRows = next.disableHomeAlbumRows ? 'true' : 'false';
root.dataset.perfDisableHomeSongRails = next.disableHomeSongRails ? 'true' : 'false';
root.dataset.perfDisableHomeRailArtwork = next.disableHomeRailArtwork ? 'true' : 'false';
root.dataset.perfDisableHomeArtworkFx = next.disableHomeArtworkFx ? 'true' : 'false';
root.dataset.perfDisableHomeArtworkClip = next.disableHomeArtworkClip ? 'true' : 'false';
}
function persistFlags(next: PerfProbeFlags): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// Ignore storage errors; runtime state still works.
}
}
function emit(): void {
listeners.forEach(fn => fn());
}
function setFlags(next: PerfProbeFlags): void {
flags = next;
applyFlagsToDom(flags);
persistFlags(flags);
emit();
}
function initFlags(): void {
if (typeof window === 'undefined') return;
const fromStorage = safeParseFlags(window.localStorage.getItem(STORAGE_KEY));
flags = {
...DEFAULT_FLAGS,
...fromStorage,
};
applyFlagsToDom(flags);
}
initFlags();
export function getPerfProbeFlags(): PerfProbeFlags {
return flags;
}
export function subscribePerfProbeFlags(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function setPerfProbeFlag<K extends keyof PerfProbeFlags>(key: K, value: PerfProbeFlags[K]): void {
setFlags({ ...flags, [key]: value });
}
export function resetPerfProbeFlags(): void {
setFlags({ ...DEFAULT_FLAGS });
}
export function usePerfProbeFlags(): PerfProbeFlags {
return useSyncExternalStore(subscribePerfProbeFlags, getPerfProbeFlags, () => DEFAULT_FLAGS);
}