mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc40a23d25 | |||
| d5dd237c82 | |||
| dc630cb916 | |||
| fc6ad8c802 | |||
| f7304dc71d | |||
| 0f64b92ad5 | |||
| 4ab3a26dab | |||
| 564e70f4a9 | |||
| 78fa567a5e | |||
| 2eb535484f | |||
| f20030e919 | |||
| 6aa3038fed | |||
| 84d3d973cc |
@@ -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
@@ -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,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-7mSqNSw7KApMERXzhW/Ta7j1wLf55mxBEf3pWs2aaic="
|
||||
"npmDepsHash": "sha256-dyjbLqDNOL+FVM9ExgM90fnlKYp9GhiNqc42YSKfJ7c="
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.45.0-dev"
|
||||
version = "1.45.0-rc.4"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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%' }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user