feat(browse,cover,perf): lazy catalogs, cover pipeline, and Performance Probe (#890)

* fix(cover): per-server cache stats and cover pipeline perf probe

Stop count_cached_cover_ids from borrowing sibling bucket counts so
Settings progress no longer attributes one server's disk cache to another.

Add cover pipeline queue stats (ui ensure queue, ui vs lib HTTP/WebP
semaphores) to Performance Probe overlay, with clearer ui/lib labels.

* fix(browse): stabilize in-page infinite scroll and cap cover memory caches

Extract useInpageScrollSentinel for album grids and song lists so sentinel
reconnects do not spam loadMore during scroll. Harden useAlbumBrowseData with
sync loading refs, tighter root margin, and hasMore termination when dedupe
adds nothing. Pause middle-priority cover work during SQL pagination and bound
diskSrc/resolve/ensure tail maps on long cold-cache sessions.

* refactor(browse): unify in-page infinite scroll hooks and sentinel UI

Extract shared transport (viewport ref, async pagination guards, client slice)
and InpageScrollSentinel so Albums, New Releases, Artists, and song lists
use one pagination pattern instead of duplicated IntersectionObserver wiring.

* fix(browse): prioritize album SQL pagination over cover ensures

Pause the entire webview ensure pump during grid page fetches, resume after
SQL settles, add cover-queue backpressure before load-more, and re-probe the
sentinel when pagination finishes so cold-cache scroll does not stall.

* fix(browse): unblock covers, SQL spawn_blocking, and pagination retry

Pair grid-pagination hold begin/end on stale fetches, resume the ensure pump
after SQL, retry load-more when the cover backlog drains while the sentinel
stays visible, and run album browse SQL on spawn_blocking so Tokio stays
responsive during library_advanced_search.

* feat(browse): All Albums client-slice scroll on local index (Artists-style)

Load the filtered catalog once from SQLite when the library index is ready,
then grow the visible grid with useClientSliceInfiniteScroll instead of
offset SQL pagination per scroll. Network-only servers keep page mode.

* fix(browse): lazy local catalog chunks instead of full 50k SQL fetch

All Albums slice mode now loads 200 albums first, shows the grid immediately,
then appends catalog chunks in the background as the user scrolls. Avoids the
blocking library_advanced_search that hung the app on large libraries.

* fix(browse): keep album covers loading during active grid scroll

Pass high ensure priority and the in-page scroll root to AlbumCard on All
Albums, stop pausing cover traffic for background catalog chunks, and never
trim high-priority ensure jobs from the queue during scroll bursts.

* fix(cover): viewport priority tiers and unstick ensure invoke pump

All Albums uses IO-driven high/middle instead of blanket high; release
only on unmount so scroll-ahead jobs are not dropped on reprioritize.
Ensure queue shares one Rust flight per cover id, attaches duplicate
waiters without consuming invoke slots, and times out wedged calls.
Warm the first viewport slice on large grids; acquire CPU permits before
spawn_blocking in cover_cache to avoid blocking-thread deadlocks.

* fix(cover): wire in-page scroll root on New Releases and Lossless grids

AlbumCard IO uses the same viewport id as VirtualCardGrid so cover
ensure priority tracks visible in-page rows like All Albums.

* fix(browse): lazy local artist catalog in 200-row chunks

Replace runLocalBrowseAllArtists bulk fetch with paginated local-index
chunks so large libraries do not hang on open; preserve text search,
starred, letter filter, and client-slice scroll behavior.

* feat(perf): add RSS and thread CPU groups to Performance Probe

Extend performance_cpu_snapshot with process RSS (psysonic + WebKit
children) and in-process thread CPU breakdown. Classify tokio-rt-worker
and tokio-* workers separately from glib, audio/pipewire, reqwest, and
other misc threads (Linux /proc only).

* feat(perf): redesign Performance Probe with tabs, pins, and overlay layout

Split the probe into Monitor (live metric cards, per-metric overlay pins,
corner and opacity controls) and Toggles (diagnostic tree). Share live
polling via perfLiveStore; label analysis/cover pipeline blocks in the HUD.

* feat(perf): overlay sparklines, macOS CPU/memory, and sync fixes

Add 1-minute pinned-metric sparklines with right-aligned growth and a shared
poll clock. Enable macOS performance snapshots via sysinfo. Fix overlay infinite
loop from unstable history snapshots, bar/sparkline tick jitter, and probe bar
rescale flicker.

* docs: CHANGELOG and credits for PR #890

* perf(probe): scoped CPU poll, adjustable interval, lazy thread groups

Read only psysonic + WebKit children instead of the full process table;
macOS uses sysctl host CPU and refreshes cached child PIDs. Add 0.5–10s
poll slider (default 2s). Collect /proc thread groups only when the
Monitor section is open or a thread metric is pinned.

* feat(perf): three-way overlay mode switch (off / FPS / pinned)

Add Monitor control for overlay visibility: hidden, FPS-only, or pinned
metrics from Monitor. Live CPU poll runs only in pinned mode with live pins.
This commit is contained in:
cucadmuh
2026-05-29 04:40:31 +03:00
committed by GitHub
parent 839c438a6d
commit 9925771a86
67 changed files with 5298 additions and 1043 deletions
+31
View File
@@ -151,6 +151,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Performance Probe — monitor UI, overlay pins, and live metrics
**By [@cucadmuh](https://github.com/cucadmuh), PR [#890](https://github.com/Psychotoxical/psysonic/pull/890)**
* **Ctrl+Shift+D** modal: **Monitor** tab (metric cards with pin-to-overlay) and **Toggles** tab (tree of probe flags/phases).
* Live CPU/memory polling: process CPU, RSS by group, thread CPU groups (Linux `/proc`); **macOS** process CPU + RSS via `sysinfo`.
* HUD overlay: FPS always on top; pinned live metrics with **1-minute sparklines**; Analysis/Cover pipeline blocks; corner + opacity controls.
* Cover pipeline stats in the probe (per-server cache, ensure/peek queues).
## Changed
### Linux — session GDK, WebKitGTK mitigations, and Wayland text
@@ -276,6 +287,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Library browse — chunked local catalogs and unified in-page scroll
**By [@cucadmuh](https://github.com/cucadmuh), PR [#890](https://github.com/Psychotoxical/psysonic/pull/890)**
* **Albums / Artists**: local index loaded in **200-row SQL chunks** instead of a single ~50k-row fetch; filters preserved.
* **All Albums**: client-slice infinite scroll on the local index (Artists-style).
* Shared in-page scroll hooks and sentinel UI across browse routes; album SQL pagination prioritized over cover ensures.
## Fixed
### Analytics — Opus waveform and loudness analysis
@@ -494,6 +515,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Library browse & covers — scroll stability and cover loading
**By [@cucadmuh](https://github.com/cucadmuh), PR [#890](https://github.com/Psychotoxical/psysonic/pull/890)**
* In-page infinite scroll stabilized; cover memory caches capped; covers keep loading during active grid scroll.
* **New Releases** and **Lossless** grids use the correct in-page scroll root; cover ensure invoke pump no longer sticks; viewport priority tiers for ensure/peek.
* Performance Probe overlay: fixed blank page from unstable sparkline history; synchronized poll clock and bar/sparkline tick jitter.
## [1.46.0] - 2026-05-18
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
+11
View File
@@ -3103,6 +3103,15 @@ dependencies = [
"memoffset 0.7.1",
]
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -5607,6 +5616,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows 0.62.2",
+1 -1
View File
@@ -67,7 +67,7 @@ discord-rich-presence = "1.1"
url = "2"
thread-priority = "3"
lofty = "0.24"
sysinfo = { version = "0.38", default-features = false, features = ["disk"] }
sysinfo = { version = "0.38", default-features = false, features = ["disk", "system"] }
id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.9"
rusqlite = { version = "0.39", features = ["bundled"] }
@@ -41,6 +41,17 @@ use crate::sync::initial::InitialSyncRunner;
use crate::sync::progress::{ChannelProgress, Progress, ProgressEvent};
use crate::sync::tombstone::should_auto_reconcile;
/// Run synchronous SQLite / library read work off the async runtime worker.
async fn library_spawn_blocking<F, R>(f: F) -> Result<R, String>
where
F: FnOnce() -> Result<R, String> + Send + 'static,
R: Send + 'static,
{
tauri::async_runtime::spawn_blocking(f)
.await
.map_err(|e| format!("library blocking worker failed: {e}"))?
}
/// Cap for `library_get_tracks_batch` per spec §7.1 ("max 100 refs/call").
const TRACKS_BATCH_LIMIT: usize = 100;
const ANALYSIS_PROGRESS_CACHE_TTL: Duration = Duration::from_secs(30);
@@ -458,7 +469,8 @@ pub async fn library_advanced_search(
runtime: State<'_, LibraryRuntime>,
request: LibraryAdvancedSearchRequest,
) -> Result<LibraryAdvancedSearchResponse, String> {
advanced_search::run_advanced_search(&runtime.store, &request)
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || advanced_search::run_advanced_search(&store, &request)).await
}
#[tauri::command]
@@ -466,7 +478,8 @@ pub async fn library_list_lossless_albums(
runtime: State<'_, LibraryRuntime>,
request: crate::dto::LibraryLosslessAlbumsRequest,
) -> Result<crate::dto::LibraryLosslessAlbumsResponse, String> {
crate::lossless_albums::list_lossless_albums(&runtime.store, &request)
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::lossless_albums::list_lossless_albums(&store, &request)).await
}
#[tauri::command]
@@ -94,6 +94,14 @@ impl CoverBackfillWorker {
pub async fn reset_cursor(&self) {
*self.cursor.lock().await = String::new();
}
/// Semaphore-backed library backfill HTTP slots (perf probe).
pub fn pipeline_http_stats(&self) -> (u32, u32, bool) {
let max = LIBRARY_BACKFILL_PARALLEL as u32;
let active = max.saturating_sub(self.backfill_http.available_permits() as u32);
let pass_running = self.pass_running.load(Ordering::Relaxed);
(max, active, pass_running)
}
}
fn sync_allows_cover_backfill(store: &psysonic_library::store::LibraryStore, server_id: &str) -> bool {
+85 -26
View File
@@ -47,6 +47,49 @@ pub struct CoverCacheStatsDto {
pub entry_count: u64,
}
/// Live cover HTTP / WebP-encode slots — mirrors analysis pipeline probe shape.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CoverPipelineQueueStatsDto {
pub http_max: u32,
pub http_active: u32,
pub cpu_ui_max: u32,
pub cpu_ui_active: u32,
pub cpu_backfill_max: u32,
pub cpu_backfill_active: u32,
pub library_backfill_http_max: u32,
pub library_backfill_http_active: u32,
pub library_backfill_pass_running: bool,
}
fn sem_active(sem: &Semaphore, max: u32) -> u32 {
max.saturating_sub(sem.available_permits() as u32)
}
pub(crate) fn cover_pipeline_queue_stats(
cache: &CoverCacheState,
backfill: Option<&backfill_worker::CoverBackfillWorker>,
) -> CoverPipelineQueueStatsDto {
let (library_backfill_http_max, library_backfill_http_active, library_backfill_pass_running) =
backfill
.map(backfill_worker::CoverBackfillWorker::pipeline_http_stats)
.unwrap_or((0, 0, false));
CoverPipelineQueueStatsDto {
http_max: COVER_HTTP_CONCURRENCY as u32,
http_active: sem_active(&cache.http_sem, COVER_HTTP_CONCURRENCY as u32),
cpu_ui_max: COVER_CPU_UI_CONCURRENCY as u32,
cpu_ui_active: sem_active(&cache.cover_cpu_ui_sem, COVER_CPU_UI_CONCURRENCY as u32),
cpu_backfill_max: COVER_CPU_BACKFILL_CONCURRENCY as u32,
cpu_backfill_active: sem_active(
&cache.cover_cpu_backfill_sem,
COVER_CPU_BACKFILL_CONCURRENCY as u32,
),
library_backfill_http_max,
library_backfill_http_active,
library_backfill_pass_running,
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CoverCacheEnsureArgs {
@@ -204,14 +247,15 @@ impl CoverCacheState {
};
let dir_bg = dir.clone();
let cover_cpu_sem_bg = cover_cpu_sem.clone();
let tiers_bg = tiers_now.clone();
let cpu_permit = cover_cpu_sem
.clone()
.acquire_owned()
.await
.map_err(|e| e.to_string())?;
let (mut wrote_requested, fresh_tiers) = tauri::async_runtime::spawn_blocking(
move || -> Result<(bool, Vec<(u32, PathBuf)>), String> {
let rt = tokio::runtime::Handle::current();
let _permit = rt
.block_on(cover_cpu_sem_bg.acquire())
.map_err(|e| e.to_string())?;
let _cpu_permit = cpu_permit;
let img = match source {
CoverSource::Image(i) => i,
CoverSource::Bytes(b) => decode_image_bytes(&b)?,
@@ -374,11 +418,11 @@ fn spawn_derive_remaining_tiers(
guard.cpu_sem_for(args.library_bulk),
)
};
let written = tauri::async_runtime::spawn_blocking(move || -> Vec<(u32, PathBuf)> {
let rt = tokio::runtime::Handle::current();
let Ok(_permit) = rt.block_on(cover_cpu_sem.acquire()) else {
return Vec::new();
let Ok(cpu_permit) = cover_cpu_sem.clone().acquire_owned().await else {
return;
};
let written = tauri::async_runtime::spawn_blocking(move || -> Vec<(u32, PathBuf)> {
let _cpu_permit = cpu_permit;
let mut fresh = Vec::new();
for tier in tiers_bg {
if tier_exists(&dir, tier).is_some() {
@@ -400,23 +444,9 @@ fn spawn_derive_remaining_tiers(
}
/// Entity dirs with canonical `800.webp` under `album/` and `artist/` (segment layout).
/// Per-server only — must not borrow counts from sibling buckets (multi-server UI stats).
pub(crate) fn count_cached_cover_ids(root: &Path, server_index_key: &str) -> i64 {
let keyed = count_entities_with_canonical_tier(&cover_server_dir(root, server_index_key));
if keyed > 0 {
return keyed;
}
// Host alias / legacy bucket name — pick the best segment count among siblings.
let Ok(entries) = std::fs::read_dir(root) else {
return 0;
};
entries
.flatten()
.filter(|e| {
e.path().is_dir() && e.file_name().to_string_lossy() != ".storage-layout"
})
.map(|e| count_entities_with_canonical_tier(&e.path()))
.max()
.unwrap_or(0)
count_entities_with_canonical_tier(&cover_server_dir(root, server_index_key))
}
pub(crate) fn dir_usage_for_server(root: &Path, server_index_key: &str) -> (u64, u64) {
@@ -687,6 +717,19 @@ pub async fn cover_cache_stats_server(
})
}
#[tauri::command]
pub async fn cover_cache_get_pipeline_queue_stats(
app: AppHandle,
) -> Result<CoverPipelineQueueStatsDto, String> {
let st = state(&app)?;
let guard = st.lock().await;
let backfill = app.try_state::<Arc<backfill_worker::CoverBackfillWorker>>();
Ok(cover_pipeline_queue_stats(
&guard,
backfill.as_ref().map(|w| w.as_ref()),
))
}
#[tauri::command]
pub async fn cover_cache_clear_server(
app: AppHandle,
@@ -979,10 +1022,26 @@ mod tests {
use super::decode_image_bytes;
use super::disk::{cover_dir, tier_path};
use super::{is_safe_index_key, merge_cover_bucket, rename_bucket_inner};
use super::{count_cached_cover_ids, is_safe_index_key, merge_cover_bucket, rename_bucket_inner};
use psysonic_core::cover_cache_layout::CANONICAL_PROGRESS_TIER;
use std::fs;
use std::path::PathBuf;
#[test]
fn count_cached_cover_ids_is_per_server_bucket() {
let root = fresh_tmpdir("count-per-server");
let home = cover_dir(&root, "music.home.example", "album", "al-home");
fs::create_dir_all(&home).unwrap();
fs::write(
home.join(format!("{CANONICAL_PROGRESS_TIER}.webp")),
b"x",
)
.unwrap();
assert_eq!(count_cached_cover_ids(&root, "music.home.example"), 1);
assert_eq!(count_cached_cover_ids(&root, "music.other.example"), 0);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn disk_layout_paths() {
let root = std::path::Path::new("/tmp/cover-test");
+1
View File
@@ -751,6 +751,7 @@ pub fn run() {
cover_cache::cover_cache_clear_server,
cover_cache::cover_cache_rename_server_bucket,
cover_cache::cover_cache_stats_server,
cover_cache::cover_cache_get_pipeline_queue_stats,
cover_cache::library_cover_backfill_batch,
cover_cache::library_cover_progress,
cover_cache::library_cover_catalog_size,
+484 -23
View File
@@ -1,12 +1,31 @@
//! Performance telemetry: process-level CPU snapshot for the Linux /proc
//! parser. Other platforms return `supported: false` — the frontend treats
//! that as "perf overlay not available" and hides itself.
//! Performance telemetry: process-level CPU + RSS and in-process thread CPU
//! groups. Linux uses `/proc`; macOS uses `sysinfo`. Other platforms return
//! `supported: false`.
use serde::Serialize;
#[cfg(target_os = "linux")]
use std::collections::HashMap;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::sync::Mutex;
#[cfg(target_os = "linux")]
use std::fs;
const CHILD_RESCAN_EVERY: u8 = 8;
#[derive(Debug, Clone, Serialize)]
pub(crate) struct PerfProcessMemory {
pub label: String,
pub rss_kb: u64,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct PerfThreadCpuGroup {
pub label: String,
pub thread_count: u32,
pub jiffies: u64,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct PerformanceCpuSnapshot {
pub supported: bool,
@@ -14,6 +33,8 @@ pub(crate) struct PerformanceCpuSnapshot {
pub app_jiffies: u64,
pub webkit_jiffies: u64,
pub logical_cpus: u32,
pub memory: Vec<PerfProcessMemory>,
pub thread_cpu_groups: Vec<PerfThreadCpuGroup>,
}
#[cfg(target_os = "linux")]
@@ -48,32 +69,423 @@ fn read_total_jiffies() -> Option<u64> {
}
#[cfg(target_os = "linux")]
fn collect_proc_stats() -> Vec<(i32, String, i32, u64)> {
let mut rows = Vec::new();
fn read_status_rss_kb(path: &str) -> Option<u64> {
let content = fs::read_to_string(path).ok()?;
for line in content.lines() {
if let Some(kb) = line.strip_prefix("VmRSS:") {
return kb.split_whitespace().next()?.parse().ok();
}
}
None
}
#[cfg(target_os = "linux")]
fn proc_exists(pid: i32) -> bool {
fs::metadata(format!("/proc/{pid}")).is_ok()
}
#[cfg(target_os = "linux")]
fn read_proc_stat_row(pid: i32) -> Option<(i32, String, i32, u64)> {
let stat_line = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let (comm, ppid, utime, stime) = parse_proc_stat_line(stat_line.trim())?;
Some((pid, comm, ppid, utime.saturating_add(stime)))
}
#[cfg(target_os = "linux")]
fn scan_child_pids(self_pid: i32) -> Vec<i32> {
let mut out = Vec::new();
let entries = match fs::read_dir("/proc") {
Ok(v) => v,
Err(_) => return rows,
Err(_) => return out,
};
for entry in entries.flatten() {
let name = entry.file_name();
let pid = match name.to_string_lossy().parse::<i32>() {
let pid = match entry.file_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 pid == self_pid {
continue;
}
let Some((_, _, ppid, _)) = read_proc_stat_row(pid) else {
continue;
};
if let Some((comm, ppid, utime, stime)) = parse_proc_stat_line(stat_line.trim()) {
rows.push((pid, comm, ppid, utime.saturating_add(stime)));
if ppid == self_pid {
out.push(pid);
}
}
out
}
#[cfg(target_os = "linux")]
struct ChildPidCache {
child_pids: Vec<i32>,
ticks_until_rescan: u8,
}
#[cfg(target_os = "linux")]
impl ChildPidCache {
fn refresh(&mut self, self_pid: i32) {
let stale = self
.child_pids
.iter()
.any(|pid| !proc_exists(*pid));
if self.ticks_until_rescan == 0 || stale {
self.child_pids = scan_child_pids(self_pid);
self.ticks_until_rescan = CHILD_RESCAN_EVERY;
} else {
self.ticks_until_rescan -= 1;
}
}
}
#[cfg(target_os = "linux")]
fn linux_child_cache() -> std::sync::MutexGuard<'static, ChildPidCache> {
static CACHE: Mutex<ChildPidCache> = Mutex::new(ChildPidCache {
child_pids: Vec::new(),
ticks_until_rescan: 0,
});
CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(target_os = "linux")]
fn collect_relevant_proc_stats(self_pid: i32) -> Vec<(i32, String, i32, u64)> {
let mut rows = Vec::new();
if let Some(row) = read_proc_stat_row(self_pid) {
rows.push(row);
}
let child_pids = {
let mut cache = linux_child_cache();
cache.refresh(self_pid);
cache.child_pids.clone()
};
for child_pid in child_pids {
let Some(row) = read_proc_stat_row(child_pid) else {
continue;
};
if row.2 == self_pid {
rows.push(row);
}
}
rows
}
/// Map a child process name to a stable perf-probe label (Linux `comm` or macOS name).
#[cfg(any(test, target_os = "linux", target_os = "macos"))]
fn child_process_memory_label(name: &str) -> &'static str {
let lower = name.to_ascii_lowercase();
if lower.contains("webkitwebproces") || lower.contains("web content") || lower.contains("webcontent") {
"WebKit web"
} else if lower.contains("webkitnetwork") || (lower.contains("webkit") && lower.contains("network")) {
"WebKit network"
} else if lower.contains("webkitwebgp") || lower.contains("webkitgpuproc") || (lower.contains("webkit") && lower.contains("gpu")) {
"WebKit GPU"
} else if lower.contains("webkit") {
"WebKit other"
} else {
"other child"
}
}
/// Group in-process thread names for CPU attribution (`feat/rust-thread-names` uses `psy-*`).
#[cfg(any(test, target_os = "linux"))]
fn thread_cpu_group_label(comm: &str) -> String {
// Tauri default: `tokio-rt-worker`; `feat/rust-thread-names`: `psy-tokio-N`.
if comm.starts_with("tokio-") || comm.starts_with("psy-tokio") {
return "tokio".to_string();
}
if comm.starts_with("psy-") {
return comm.to_string();
}
if comm.starts_with("psysonic-") {
return comm.to_string();
}
if comm == "psysonic" {
return "psysonic".to_string();
}
if comm.starts_with("pool") {
return "blocking-pool".to_string();
}
if matches!(comm, "gmain" | "gdbus" | "dconf worker") {
return "glib".to_string();
}
if comm.starts_with("cpal_")
|| comm.starts_with("alsa-")
|| comm == "module-rt"
|| comm.starts_with("data-loop")
{
return "audio/pipewire".to_string();
}
if comm.starts_with("reqwest-") {
return "reqwest".to_string();
}
if comm.starts_with("async-io") || comm.starts_with("zbus::") {
return "async-io".to_string();
}
"other".to_string()
}
#[cfg(target_os = "linux")]
fn collect_task_cpu_groups(pid: i32) -> Vec<PerfThreadCpuGroup> {
let task_root = format!("/proc/{pid}/task");
let entries = match fs::read_dir(&task_root) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let mut groups: HashMap<String, (u32, u64)> = HashMap::new();
for entry in entries.flatten() {
let tid = entry.file_name();
let stat_path = task_root.clone() + "/" + &tid.to_string_lossy() + "/stat";
let stat_line = match fs::read_to_string(&stat_path) {
Ok(v) => v,
Err(_) => continue,
};
let Some((comm, _, utime, stime)) = parse_proc_stat_line(stat_line.trim()) else {
continue;
};
let label = thread_cpu_group_label(&comm);
let entry = groups.entry(label).or_insert((0, 0));
entry.0 += 1;
entry.1 = entry.1.saturating_add(utime.saturating_add(stime));
}
let mut out: Vec<PerfThreadCpuGroup> = groups
.into_iter()
.map(|(label, (thread_count, jiffies))| PerfThreadCpuGroup {
label,
thread_count,
jiffies,
})
.collect();
out.sort_by(|a, b| b.jiffies.cmp(&a.jiffies).then_with(|| a.label.cmp(&b.label)));
out
}
#[cfg(target_os = "linux")]
fn collect_process_memory(pid: i32, rows: &[(i32, String, i32, u64)], self_pid: i32) -> Vec<PerfProcessMemory> {
let mut groups: HashMap<&'static str, u64> = HashMap::new();
if let Some(rss) = read_status_rss_kb(&format!("/proc/{pid}/status")) {
groups.insert("psysonic", rss);
}
for (child_pid, comm, ppid, _) in rows {
if *ppid != self_pid || *child_pid == self_pid {
continue;
}
let Some(rss) = read_status_rss_kb(&format!("/proc/{child_pid}/status")) else {
continue;
};
let label = child_process_memory_label(comm);
let entry = groups.entry(label).or_insert(0);
*entry = entry.saturating_add(rss);
}
let order = [
"psysonic",
"WebKit web",
"WebKit network",
"WebKit GPU",
"WebKit other",
"other child",
];
let mut out: Vec<PerfProcessMemory> = groups
.into_iter()
.map(|(label, rss_kb)| PerfProcessMemory {
label: label.to_string(),
rss_kb,
})
.collect();
out.sort_by(|a, b| {
let ai = order.iter().position(|&x| x == a.label).unwrap_or(order.len());
let bi = order.iter().position(|&x| x == b.label).unwrap_or(order.len());
ai.cmp(&bi).then_with(|| b.rss_kb.cmp(&a.rss_kb))
});
out
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn empty_snapshot() -> PerformanceCpuSnapshot {
PerformanceCpuSnapshot {
supported: false,
total_jiffies: 0,
app_jiffies: 0,
webkit_jiffies: 0,
logical_cpus: 1,
memory: Vec::new(),
thread_cpu_groups: Vec::new(),
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::{
child_process_memory_label, PerformanceCpuSnapshot, PerfProcessMemory, CHILD_RESCAN_EVERY,
};
use std::collections::HashMap;
use std::mem;
use std::sync::Mutex;
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
struct ChildPidCache {
child_pids: Vec<Pid>,
ticks_until_rescan: u8,
}
impl ChildPidCache {
fn refresh(&mut self, sys: &mut System, self_pid: Pid) {
let stale = self
.child_pids
.iter()
.any(|pid| sys.process(*pid).is_none());
if self.ticks_until_rescan == 0 || stale {
sys.refresh_processes_specifics(
ProcessesToUpdate::All,
false,
ProcessRefreshKind::nothing().with_cpu().with_memory(),
);
self.child_pids = sys
.processes()
.iter()
.filter_map(|(pid, process)| {
if process.parent() == Some(self_pid) {
Some(*pid)
} else {
None
}
})
.collect();
self.ticks_until_rescan = CHILD_RESCAN_EVERY;
} else {
self.ticks_until_rescan -= 1;
}
}
}
static SYSTEM: Mutex<Option<System>> = Mutex::new(None);
fn child_cache() -> std::sync::MutexGuard<'static, ChildPidCache> {
static CACHE: Mutex<ChildPidCache> = Mutex::new(ChildPidCache {
child_pids: Vec::new(),
ticks_until_rescan: 0,
});
CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn read_host_total_cpu_ticks() -> u64 {
let mut mib = [libc::CTL_KERN, libc::KERN_CP_TIME];
let mut cp_time = [0_u64; libc::CPUSTATES as usize];
let mut size = mem::size_of_val(&cp_time);
let ok = unsafe {
libc::sysctl(
mib.as_mut_ptr(),
mib.len() as _,
cp_time.as_mut_ptr() as *mut _,
&mut size,
std::ptr::null_mut(),
0,
) == 0
};
if ok {
cp_time.iter().sum()
} else {
0
}
}
fn refresh_target_processes(sys: &mut System, self_pid: Pid) -> Vec<Pid> {
let child_pids = {
let mut cache = child_cache();
cache.refresh(sys, self_pid);
cache.child_pids.clone()
};
let mut target = Vec::with_capacity(1 + child_pids.len());
target.push(self_pid);
target.extend(child_pids);
sys.refresh_processes_specifics(
ProcessesToUpdate::Some(&target),
false,
ProcessRefreshKind::nothing().with_cpu().with_memory(),
);
target
}
fn is_webkit_web_cpu_process(name: &str) -> bool {
child_process_memory_label(name) == "WebKit web"
}
fn collect_process_memory(sys: &System, self_pid: Pid, child_pids: &[Pid]) -> Vec<PerfProcessMemory> {
let mut groups: HashMap<&'static str, u64> = HashMap::new();
if let Some(process) = sys.process(self_pid) {
groups.insert("psysonic", process.memory() / 1024);
}
for child_pid in child_pids {
if *child_pid == self_pid {
continue;
}
let Some(process) = sys.process(*child_pid) else {
continue;
};
let name = process.name().to_string_lossy();
let label = child_process_memory_label(&name);
let entry = groups.entry(label).or_insert(0);
*entry = entry.saturating_add(process.memory() / 1024);
}
let order = [
"psysonic",
"WebKit web",
"WebKit network",
"WebKit GPU",
"WebKit other",
"other child",
];
let mut out: Vec<PerfProcessMemory> = groups
.into_iter()
.map(|(label, rss_kb)| PerfProcessMemory {
label: label.to_string(),
rss_kb,
})
.collect();
out.sort_by(|a, b| {
let ai = order.iter().position(|&x| x == a.label).unwrap_or(order.len());
let bi = order.iter().position(|&x| x == b.label).unwrap_or(order.len());
ai.cmp(&bi).then_with(|| b.rss_kb.cmp(&a.rss_kb))
});
out
}
pub(super) fn performance_cpu_snapshot(_include_thread_groups: bool) -> PerformanceCpuSnapshot {
let mut guard = SYSTEM.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if guard.is_none() {
*guard = Some(System::new());
}
let sys = guard.as_mut().unwrap();
let self_pid = Pid::from_u32(std::process::id());
let child_pids = refresh_target_processes(sys, self_pid);
let logical_cpus = std::thread::available_parallelism()
.map(|n| n.get() as u32)
.unwrap_or(1);
let total_jiffies = read_host_total_cpu_ticks();
let app_jiffies = sys
.process(self_pid)
.map(|process| process.accumulated_cpu_time())
.unwrap_or(0);
let webkit_jiffies: u64 = child_pids
.iter()
.filter_map(|pid| sys.process(*pid))
.filter(|process| is_webkit_web_cpu_process(&process.name().to_string_lossy()))
.map(|process| process.accumulated_cpu_time())
.sum();
PerformanceCpuSnapshot {
supported: true,
total_jiffies,
app_jiffies,
webkit_jiffies,
logical_cpus,
memory: collect_process_memory(sys, self_pid, &child_pids),
thread_cpu_groups: Vec::new(),
}
}
}
#[tauri::command]
pub(crate) fn performance_cpu_snapshot() -> PerformanceCpuSnapshot {
pub(crate) fn performance_cpu_snapshot(include_thread_groups: Option<bool>) -> PerformanceCpuSnapshot {
let include_thread_groups = include_thread_groups.unwrap_or(false);
#[cfg(target_os = "linux")]
{
let total_jiffies = read_total_jiffies().unwrap_or(0);
@@ -81,7 +493,7 @@ pub(crate) fn performance_cpu_snapshot() -> PerformanceCpuSnapshot {
.map(|n| n.get() as u32)
.unwrap_or(1);
let self_pid = std::process::id() as i32;
let rows = collect_proc_stats();
let rows = collect_relevant_proc_stats(self_pid);
let app_jiffies = rows
.iter()
.find(|(pid, _, _, _)| *pid == self_pid)
@@ -100,16 +512,65 @@ pub(crate) fn performance_cpu_snapshot() -> PerformanceCpuSnapshot {
app_jiffies,
webkit_jiffies,
logical_cpus,
memory: collect_process_memory(self_pid, &rows, self_pid),
thread_cpu_groups: if include_thread_groups {
collect_task_cpu_groups(self_pid)
} else {
Vec::new()
},
}
}
#[cfg(not(target_os = "linux"))]
#[cfg(target_os = "macos")]
{
PerformanceCpuSnapshot {
supported: false,
total_jiffies: 0,
app_jiffies: 0,
webkit_jiffies: 0,
logical_cpus: 1,
macos::performance_cpu_snapshot(include_thread_groups)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = include_thread_groups;
empty_snapshot()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thread_cpu_group_label_tokio_and_named() {
assert_eq!(thread_cpu_group_label("psy-tokio-3"), "tokio");
assert_eq!(thread_cpu_group_label("tokio-runtime-w"), "tokio");
assert_eq!(thread_cpu_group_label("tokio-rt-worker"), "tokio");
assert_eq!(thread_cpu_group_label("psy-audio-out"), "psy-audio-out");
assert_eq!(thread_cpu_group_label("psy-decode"), "psy-decode");
assert_eq!(
thread_cpu_group_label("psysonic-audio-"),
"psysonic-audio-"
);
assert_eq!(thread_cpu_group_label("pool-1"), "blocking-pool");
assert_eq!(thread_cpu_group_label("gmain"), "glib");
assert_eq!(thread_cpu_group_label("cpal_alsa_out"), "audio/pipewire");
assert_eq!(thread_cpu_group_label("reqwest-interna"), "reqwest");
assert_eq!(thread_cpu_group_label("rustc"), "other");
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn child_process_memory_label_webkit_names() {
assert_eq!(
child_process_memory_label("WebKitWebProces"),
"WebKit web"
);
assert_eq!(
child_process_memory_label("WebKitNetworkP"),
"WebKit network"
);
assert_eq!(
child_process_memory_label("com.apple.WebKit.WebContent.xpc"),
"WebKit web"
);
assert_eq!(
child_process_memory_label("WebKit Networking"),
"WebKit network"
);
}
}
+16
View File
@@ -33,6 +33,18 @@ export type CoverCacheStats = {
entryCount: number;
};
export type CoverPipelineQueueStatsDto = {
httpMax: number;
httpActive: number;
cpuUiMax: number;
cpuUiActive: number;
cpuBackfillMax: number;
cpuBackfillActive: number;
libraryBackfillHttpMax: number;
libraryBackfillHttpActive: number;
libraryBackfillPassRunning: boolean;
};
let coverAutoDownloadEnabled = true;
export function setCoverCacheAutoDownloadEnabled(enabled: boolean): void {
@@ -150,6 +162,10 @@ export async function coverCacheStatsServer(
return { bytes: stats.bytes, entryCount: stats.entryCount };
}
export function coverGetPipelineQueueStats(): Promise<CoverPipelineQueueStatsDto> {
return invoke<CoverPipelineQueueStatsDto>('cover_cache_get_pipeline_queue_stats');
}
export async function libraryCoverBackfillBatch(
serverIndexKey: string,
libraryServerId: string,
+152 -12
View File
@@ -1,29 +1,111 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { analysisGetPipelineQueueStats, type AnalysisPipelineQueueStatsDto } from '../api/analysis';
import { usePerfProbeFlag } from '../utils/perf/perfFlags';
import { coverGetPipelineQueueStats, type CoverPipelineQueueStatsDto } from '../api/coverCache';
import { coverEnsureQueueStats } from '../cover/ensureQueue';
import { coverPeekQueueStats } from '../cover/peekQueue';
import PerfOverlaySparkline from './perf/PerfOverlaySparkline';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import {
formatPerfMs,
getAnalysisTracksPerMinute,
useAnalysisPerfLast,
} from '../utils/perf/analysisPerfStore';
import { formatAnalysisPipelineQueueOverlay } from '../utils/perf/formatAnalysisQueueStats';
import { formatCoverPipelineQueueOverlay } from '../utils/perf/formatCoverPipelineQueueOverlay';
import {
buildLiveOverlayItems,
type LiveOverlayItem,
} from '../utils/perf/formatLiveOverlayItems';
import {
getPerfLiveHistoryClock,
syncPerfLiveHistoryFromPoll,
usePerfLiveHistorySamples,
} from '../utils/perf/perfLiveHistory';
import { acquirePerfLivePoll, usePerfLiveSnapshot } from '../utils/perf/perfLiveStore';
import { hasAnyLiveMetricPollNeed, usePerfLiveOverlayPins } from '../utils/perf/perfOverlayPins';
import {
perfOverlayCornerClass,
usePerfOverlayAppearance,
} from '../utils/perf/perfOverlayAppearance';
import {
resolveOverlayVisibility,
usePerfOverlayMode,
} from '../utils/perf/perfOverlayMode';
import { useAnalysisPerfListener } from '../hooks/useAnalysisPerfListener';
const SAMPLE_MS = 500;
const TPM_REFRESH_MS = 500;
const QUEUE_STATS_MS = 750;
/** FPS + analysis throughput overlay (Performance Probe). */
function LiveOverlayPinnedMetric({
item,
now,
}: {
item: LiveOverlayItem;
now: number;
}) {
const history = usePerfLiveHistorySamples(item.id);
const sparklineKind = item.kind === 'memory' ? 'memory' : 'cpu';
return (
<div className="fps-overlay__live-metric">
<div className="fps-overlay__row fps-overlay__row--live">{item.line}</div>
{item.sparkline && (
<PerfOverlaySparkline samples={history} kind={sparklineKind} now={now} />
)}
</div>
);
}
/** FPS + pipeline + pinned live metrics overlay (Performance Probe). */
export default function FpsOverlay() {
const showFpsOverlay = usePerfProbeFlag('showFpsOverlay');
const showAnalysisPerfOverlay = usePerfProbeFlag('showAnalysisPerfOverlay');
const overlayMode = usePerfOverlayMode();
const perfFlags = usePerfProbeFlags();
const livePins = usePerfLiveOverlayPins();
const live = usePerfLiveSnapshot();
const overlayAppearance = usePerfOverlayAppearance();
const [fps, setFps] = useState(0);
const [tpm, setTpm] = useState(0);
const [queueStats, setQueueStats] = useState<AnalysisPipelineQueueStatsDto | null>(null);
const [coverQueueLines, setCoverQueueLines] = useState<string[]>([]);
const last = useAnalysisPerfLast();
const lastHistoryAt = useRef(0);
useAnalysisPerfListener(showAnalysisPerfOverlay);
const liveOverlayItems = useMemo(
() => buildLiveOverlayItems(livePins, live),
[livePins, live],
);
const visibility = useMemo(
() => resolveOverlayVisibility(overlayMode, perfFlags, liveOverlayItems.length),
[overlayMode, perfFlags, liveOverlayItems.length],
);
const {
showFps: showFpsOverlay,
showAnalysis: showAnalysisPerfOverlay,
showCover: showCoverPerfOverlay,
showLive,
} = visibility;
lastHistoryAt.current = overlayMode === 'pinned'
? syncPerfLiveHistoryFromPoll(livePins, live, lastHistoryAt.current)
: lastHistoryAt.current;
const sparklineNow = useMemo(() => {
const clock = getPerfLiveHistoryClock(
liveOverlayItems.filter(item => item.sparkline).map(item => item.id),
);
return clock > 0 ? clock : Date.now();
}, [liveOverlayItems, live.updatedAt]);
useAnalysisPerfListener(showAnalysisPerfOverlay || livePins.has('analysis:tpm') || livePins.has('analysis:last'));
useEffect(() => {
if (overlayMode !== 'pinned' || !hasAnyLiveMetricPollNeed()) return;
return acquirePerfLivePoll('overlay-pins');
}, [overlayMode, livePins.size]);
useEffect(() => {
if (!showAnalysisPerfOverlay) {
@@ -59,6 +141,36 @@ export default function FpsOverlay() {
};
}, [showAnalysisPerfOverlay]);
useEffect(() => {
if (!showCoverPerfOverlay) {
setCoverQueueLines([]);
return;
}
let cancelled = false;
const refresh = () => {
void coverGetPipelineQueueStats()
.then((rust: CoverPipelineQueueStatsDto) => {
if (cancelled) return;
setCoverQueueLines(
formatCoverPipelineQueueOverlay({
rust,
ensure: coverEnsureQueueStats(),
peek: coverPeekQueueStats(),
}),
);
})
.catch(() => {
if (!cancelled) setCoverQueueLines([]);
});
};
refresh();
const id = window.setInterval(refresh, QUEUE_STATS_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [showCoverPerfOverlay]);
useEffect(() => {
if (!showFpsOverlay) {
setFps(0);
@@ -85,19 +197,35 @@ export default function FpsOverlay() {
return () => cancelAnimationFrame(rafId);
}, [showFpsOverlay]);
if (!showFpsOverlay && !showAnalysisPerfOverlay) return null;
if (overlayMode === 'off') return null;
if (!showFpsOverlay && !showAnalysisPerfOverlay && !showCoverPerfOverlay && !showLive) return null;
const analysisQueueLines = queueStats ? formatAnalysisPipelineQueueOverlay(queueStats) : [];
return createPortal(
<div className="fps-overlay" aria-hidden="true">
<div
className={`fps-overlay ${perfOverlayCornerClass(overlayAppearance.corner)}`}
style={{ '--fps-overlay-opacity': overlayAppearance.opacity } as CSSProperties}
aria-hidden="true"
>
{showFpsOverlay && (
<div className="fps-overlay__row">
<div className="fps-overlay__row fps-overlay__row--fps">
{fps}
{' '}
<span className="fps-overlay__unit">FPS</span>
</div>
)}
{showLive && (
<div className="fps-overlay__block">
<div className="fps-overlay__block-title">Live</div>
{liveOverlayItems.map(item => (
<LiveOverlayPinnedMetric key={item.id} item={item} now={sparklineNow} />
))}
</div>
)}
{showAnalysisPerfOverlay && (
<>
<div className="fps-overlay__block">
<div className="fps-overlay__block-title">Analysis pipeline</div>
<div className="fps-overlay__row">
{tpm.toFixed(1)}
{' '}
@@ -122,12 +250,24 @@ export default function FpsOverlay() {
</div>
</>
)}
{queueStats && formatAnalysisPipelineQueueOverlay(queueStats).map(line => (
{analysisQueueLines.map(line => (
<div key={line} className="fps-overlay__row fps-overlay__row--steps">
{line}
</div>
))}
</>
</div>
)}
{showCoverPerfOverlay && (
<div className="fps-overlay__block">
<div className="fps-overlay__block-title">Cover pipeline</div>
{coverQueueLines.length > 0 ? coverQueueLines.map(line => (
<div key={line} className="fps-overlay__row fps-overlay__row--steps">
{line}
</div>
)) : (
<div className="fps-overlay__row fps-overlay__row--steps">collecting</div>
)}
</div>
)}
</div>,
document.body,
+27
View File
@@ -0,0 +1,27 @@
import type { CSSProperties, RefCallback } from 'react';
const DEFAULT_STYLE: CSSProperties = {
height: '20px',
margin: '2rem 0',
display: 'flex',
justifyContent: 'center',
};
type InpageScrollSentinelProps = {
bindSentinel: RefCallback<HTMLDivElement | null>;
loading?: boolean;
style?: CSSProperties;
};
/** Bottom-of-grid load-more sentinel + optional spinner (in-page scroll areas). */
export default function InpageScrollSentinel({
bindSentinel,
loading = false,
style,
}: InpageScrollSentinelProps) {
return (
<div ref={bindSentinel} style={{ ...DEFAULT_STYLE, ...style }}>
{loading && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
);
}
+15 -16
View File
@@ -1,6 +1,8 @@
import type { SubsonicSong } from '../api/subsonicTypes';
import React, { useEffect, useRef } from 'react';
import React, { useRef } from 'react';
import SongRow, { SongListHeader } from './SongRow';
import { useInpageScrollSentinel } from '../hooks/useInpageScrollSentinel';
import InpageScrollSentinel from './InpageScrollSentinel';
interface Props {
songs: SubsonicSong[];
@@ -22,19 +24,14 @@ interface Props {
* is never painted over issue #841).
*/
export default function PagedSongList({ songs, hasMore, loadingMore, onLoadMore, showBpm }: Props) {
const sentinelRef = useRef<HTMLDivElement>(null);
const onLoadMoreRef = useRef(onLoadMore);
onLoadMoreRef.current = onLoadMore;
// Re-observe whenever `onLoadMore` changes identity (callers rebuild it after
// each page), so a sentinel still in view keeps loading the next page.
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const obs = new IntersectionObserver(entries => {
if (entries[0]?.isIntersecting) onLoadMore();
}, { rootMargin: '600px' });
obs.observe(el);
return () => obs.disconnect();
}, [onLoadMore]);
const bindSentinel = useInpageScrollSentinel({
active: hasMore,
onIntersect: () => onLoadMoreRef.current(),
rootMargin: '600px',
});
return (
<>
@@ -43,9 +40,11 @@ export default function PagedSongList({ songs, hasMore, loadingMore, onLoadMore,
<SongRow key={song.id} song={song} showBpm={showBpm} />
))}
{hasMore && (
<div ref={sentinelRef} style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
<InpageScrollSentinel
bindSentinel={bindSentinel}
loading={loadingMore}
style={{ padding: '1rem', height: 'auto', margin: 0 }}
/>
)}
</>
);
+1 -4
View File
@@ -132,7 +132,7 @@ export default function Sidebar({
isLoggedIn,
pathname: location.pathname,
});
const { perfProbeOpen, setPerfProbeOpen, perfCpu, perfDiagRates, analysisPerf } = useSidebarPerfProbe();
const { perfProbeOpen, setPerfProbeOpen } = useSidebarPerfProbe();
const perfFlags = usePerfProbeFlags();
@@ -258,9 +258,6 @@ export default function Sidebar({
open={perfProbeOpen}
onClose={() => setPerfProbeOpen(false)}
perfFlags={perfFlags}
perfCpu={perfCpu}
perfDiagRates={perfDiagRates}
analysisPerf={analysisPerf}
hotCacheEnabled={hotCacheEnabled}
setHotCacheEnabled={setHotCacheEnabled}
normalizationEngine={normalizationEngine}
@@ -0,0 +1,130 @@
import { useMemo, useRef } from 'react';
import type { PerfLiveSample } from '../../utils/perf/perfLiveHistory';
import { PERF_LIVE_HISTORY_MS } from '../../utils/perf/perfLiveHistory';
type SparklineKind = 'cpu' | 'memory';
interface Props {
samples: readonly PerfLiveSample[];
kind: SparklineKind;
now: number;
windowMs?: number;
width?: number;
height?: number;
}
function sampleX(at: number, now: number, windowMs: number, width: number): number {
const age = now - at;
const ratio = 1 - age / windowMs;
return Math.max(0, Math.min(width, ratio * width));
}
function sparklinePaths(
samples: readonly PerfLiveSample[],
now: number,
windowMs: number,
width: number,
height: number,
min: number,
max: number,
): { line: string; area: string } {
if (samples.length === 0) return { line: '', area: '' };
const range = Math.max(max - min, 1e-6);
const toY = (value: number) => height - ((value - min) / range) * (height - 2) - 1;
const sorted = [...samples].sort((a, b) => a.at - b.at);
if (sorted.length === 1) {
const x = sampleX(sorted[0].at, now, windowMs, width);
const y = toY(sorted[0].value);
const x0 = Math.max(0, x - 6);
const line = `M${x0.toFixed(2)},${y.toFixed(2)} L${x.toFixed(2)},${y.toFixed(2)}`;
const area = `${line} L${x.toFixed(2)},${height} L${x0.toFixed(2)},${height} Z`;
return { line, area };
}
const points = sorted.map(sample => ({
x: sampleX(sample.at, now, windowMs, width),
y: toY(sample.value),
}));
const line = points
.map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x.toFixed(2)},${point.y.toFixed(2)}`)
.join(' ');
const first = points[0];
const last = points[points.length - 1];
const area = `${line} L${last.x.toFixed(2)},${height} L${first.x.toFixed(2)},${height} Z`;
return { line, area };
}
function scale(samples: readonly PerfLiveSample[], kind: SparklineKind): { min: number; max: number } {
if (samples.length === 0) return { min: 0, max: 1 };
const values = samples.map(sample => sample.value);
const minVal = Math.min(...values);
const maxVal = Math.max(...values);
if (kind === 'cpu') {
return { min: 0, max: Math.max(100, maxVal * 1.1) };
}
const pad = Math.max((maxVal - minVal) * 0.08, maxVal * 0.02, 1);
return { min: Math.max(0, minVal - pad), max: maxVal + pad };
}
function stableScale(
samples: readonly PerfLiveSample[],
kind: SparklineKind,
peakRef: { current: number },
): { min: number; max: number } {
const raw = scale(samples, kind);
if (kind === 'cpu') return raw;
if (raw.max > peakRef.current) peakRef.current = raw.max;
return { min: 0, max: Math.max(peakRef.current, raw.max) };
}
/** Compact 1-minute sparkline for perf overlay CPU / memory pins. */
export default function PerfOverlaySparkline({
samples,
kind,
now,
windowMs = PERF_LIVE_HISTORY_MS,
width = 132,
height = 22,
}: Props) {
const peakRef = useRef(1);
const { path, areaPath } = useMemo(() => {
const bounds = stableScale(samples, kind, peakRef);
const paths = sparklinePaths(samples, now, windowMs, width, height, bounds.min, bounds.max);
return { path: paths.line, areaPath: paths.area };
}, [samples, kind, now, windowMs, width, height]);
const latest = samples.length > 0 ? samples[samples.length - 1]?.value : null;
return (
<svg
className={`perf-overlay-sparkline perf-overlay-sparkline--${kind}`}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
aria-hidden="true"
>
<rect
className="perf-overlay-sparkline__track"
x={0}
y={0}
width={width}
height={height}
rx={3}
/>
{areaPath && <path className="perf-overlay-sparkline__fill" d={areaPath} />}
{path && (
<path className="perf-overlay-sparkline__line" d={path} vectorEffect="non-scaling-stroke" />
)}
<title>
{latest == null
? `${kind === 'cpu' ? 'CPU' : 'Memory'} (1 min)`
: kind === 'cpu'
? `CPU ${latest.toFixed(1)}% (1 min)`
: `Memory ${latest.toFixed(1)} MB (1 min)`}
</title>
</svg>
);
}
+81 -248
View File
@@ -1,35 +1,19 @@
import { useState } from 'react';
import { Activity, SlidersHorizontal, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import SidebarPerfProbePhase2 from './SidebarPerfProbePhase2';
import { resetPerfProbeFlags, setPerfProbeFlag, type PerfProbeFlags } from '../../utils/perf/perfFlags';
import SidebarPerfProbeMonitorTab from './perfProbe/SidebarPerfProbeMonitorTab';
import SidebarPerfProbeTogglesTab from './perfProbe/SidebarPerfProbeTogglesTab';
import { resetPerfProbeFlags, type PerfProbeFlags } from '../../utils/perf/perfFlags';
import { clearPerfLiveOverlayPins } from '../../utils/perf/perfOverlayPins';
import { resetPerfOverlayAppearance } from '../../utils/perf/perfOverlayAppearance';
import { resetPerfOverlayMode } from '../../utils/perf/perfOverlayMode';
interface PerfCpu {
app: number;
webkit: number;
supported: boolean;
}
interface PerfDiagRates {
progress: number;
waveform: number;
home: number;
}
interface AnalysisPerfDiag {
tracksPerMinute: number;
lastTotalMs: number | null;
lastFetchMs: number | null;
lastSeedMs: number | null;
lastBpmMs: number | null;
}
type TabId = 'monitor' | 'toggles';
interface Props {
open: boolean;
onClose: () => void;
perfFlags: PerfProbeFlags;
perfCpu: PerfCpu | null;
perfDiagRates: PerfDiagRates | null;
analysisPerf: AnalysisPerfDiag | null;
hotCacheEnabled: boolean;
setHotCacheEnabled: (v: boolean) => void;
normalizationEngine: string;
@@ -39,243 +23,92 @@ interface Props {
}
export default function SidebarPerfProbeModal({
open, onClose, perfFlags, perfCpu, perfDiagRates, analysisPerf,
hotCacheEnabled, setHotCacheEnabled,
normalizationEngine, setNormalizationEngine,
loggingMode, setLoggingMode,
open,
onClose,
perfFlags,
hotCacheEnabled,
setHotCacheEnabled,
normalizationEngine,
setNormalizationEngine,
loggingMode,
setLoggingMode,
}: Props) {
const [tab, setTab] = useState<TabId>('monitor');
if (!open) return null;
const resetAll = () => {
resetPerfProbeFlags();
clearPerfLiveOverlayPins();
resetPerfOverlayAppearance();
resetPerfOverlayMode();
};
return createPortal(
<div className="modal-overlay modal-overlay--perf-probe" onClick={() => onClose()} role="dialog" aria-modal="true">
<div
className="modal-overlay modal-overlay--perf-probe"
onClick={() => onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="perf-probe-title"
>
<div
className="modal-content sidebar-perf-modal"
onClick={e => e.stopPropagation()}
style={{ maxWidth: 560 }}
>
<button className="modal-close" onClick={() => onClose()}><X size={18} /></button>
<h3 className="modal-title">Performance Probe</h3>
<button type="button" className="modal-close" onClick={() => onClose()} aria-label="Close">
<X size={18} />
</button>
<header className="sidebar-perf-modal__header">
<h3 id="perf-probe-title" className="modal-title">Performance Probe</h3>
<p className="sidebar-perf-modal__hint">
Temporary runtime switches to estimate UI effect cost.
Live metrics with optional on-screen overlays, plus diagnostic disable toggles.
</p>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.showFpsOverlay}
onChange={e => setPerfProbeFlag('showFpsOverlay', e.target.checked)}
/>
<span>Show FPS overlay (requestAnimationFrame rate)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.showAnalysisPerfOverlay}
onChange={e => setPerfProbeFlag('showAnalysisPerfOverlay', e.target.checked)}
/>
<span>Show analysis throughput overlay (tpm + last track timings)</span>
</label>
<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>
</>
)}
{analysisPerf && (
<>
<div className="sidebar-perf-modal__cpu-row">analysis tpm (1m avg): {analysisPerf.tracksPerMinute.toFixed(1)}</div>
{analysisPerf.lastTotalMs != null && (
<div className="sidebar-perf-modal__cpu-row">
last track: {(analysisPerf.lastTotalMs / 1000).toFixed(1)}s
{' '}
(fetch {(analysisPerf.lastFetchMs ?? 0) / 1000}s · seed {(analysisPerf.lastSeedMs ?? 0) / 1000}s · bpm {(analysisPerf.lastBpmMs ?? 0) / 1000}s)
</header>
<div className="sidebar-perf-modal__tabs" role="tablist" aria-label="Performance probe sections">
<button
type="button"
role="tab"
aria-selected={tab === 'monitor'}
className={`sidebar-perf-modal__tab${tab === 'monitor' ? ' sidebar-perf-modal__tab--active' : ''}`}
onClick={() => setTab('monitor')}
>
<Activity size={15} />
Monitor
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'toggles'}
className={`sidebar-perf-modal__tab${tab === 'toggles' ? ' sidebar-perf-modal__tab--active' : ''}`}
onClick={() => setTab('toggles')}
>
<SlidersHorizontal size={15} />
Toggles
</button>
</div>
)}
</>
)}
</>
<div className="sidebar-perf-modal__body">
{tab === 'monitor' ? (
<SidebarPerfProbeMonitorTab />
) : (
<div className="sidebar-perf-modal__cpu-row">Unavailable on this platform/build.</div>
<SidebarPerfProbeTogglesTab
perfFlags={perfFlags}
hotCacheEnabled={hotCacheEnabled}
setHotCacheEnabled={setHotCacheEnabled}
normalizationEngine={normalizationEngine}
setNormalizationEngine={setNormalizationEngine}
loggingMode={loggingMode}
setLoggingMode={setLoggingMode}
/>
)}
</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>
<SidebarPerfProbePhase2 perfFlags={perfFlags} />
<details className="sidebar-perf-modal__phase">
<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 type="button" className="btn btn-ghost" onClick={resetAll}>
Reset all
</button>
<button type="button" className="btn btn-primary" onClick={() => onClose()}>
Close
@@ -1,176 +0,0 @@
import { setPerfProbeFlag, type PerfProbeFlags } from '../../utils/perf/perfFlags';
export default function SidebarPerfProbePhase2({ perfFlags }: { perfFlags: PerfProbeFlags }) {
return (
<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">
<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">
<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">
<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">
<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>
);
}
@@ -0,0 +1,33 @@
import {
PERF_LIVE_POLL_MS_MAX,
PERF_LIVE_POLL_MS_MIN,
PERF_LIVE_POLL_MS_STEP,
setPerfLivePollIntervalMs,
usePerfLivePollIntervalMs,
} from '../../../utils/perf/perfLivePollSettings';
export default function PerfLivePollControls() {
const pollMs = usePerfLivePollIntervalMs();
const pollSec = (pollMs / 1000).toFixed(1);
return (
<section className="perf-live-poll" aria-label="Live poll interval">
<div className="perf-live-poll__title">Live sampling</div>
<label className="perf-live-poll__row">
<span className="perf-live-poll__label">
Poll interval
{' '}
<span className="perf-live-poll__value">{pollSec}s</span>
</span>
<input
type="range"
min={PERF_LIVE_POLL_MS_MIN}
max={PERF_LIVE_POLL_MS_MAX}
step={PERF_LIVE_POLL_MS_STEP}
value={pollMs}
onChange={e => setPerfLivePollIntervalMs(Number(e.target.value))}
/>
</label>
</section>
);
}
@@ -0,0 +1,48 @@
import {
PERF_OVERLAY_CORNER_OPTIONS,
setPerfOverlayCorner,
setPerfOverlayOpacity,
usePerfOverlayAppearance,
} from '../../../utils/perf/perfOverlayAppearance';
export default function PerfOverlayAppearanceControls() {
const { corner, opacity } = usePerfOverlayAppearance();
return (
<section className="perf-overlay-appearance" aria-label="Overlay layout">
<div className="perf-overlay-appearance__title">Layout</div>
<div className="perf-overlay-appearance__row">
<span className="perf-overlay-appearance__label">Corner</span>
<div className="perf-overlay-appearance__corners" role="group" aria-label="Overlay corner">
{PERF_OVERLAY_CORNER_OPTIONS.map(opt => (
<button
key={opt.id}
type="button"
className={`perf-overlay-appearance__corner${corner === opt.id ? ' perf-overlay-appearance__corner--active' : ''}`}
aria-pressed={corner === opt.id}
onClick={() => setPerfOverlayCorner(opt.id)}
title={opt.label}
>
<span className={`perf-overlay-appearance__corner-mark perf-overlay-appearance__corner-mark--${opt.id}`} />
</button>
))}
</div>
</div>
<label className="perf-overlay-appearance__row perf-overlay-appearance__opacity">
<span className="perf-overlay-appearance__label">
Opacity
{' '}
<span className="perf-overlay-appearance__value">{Math.round(opacity * 100)}%</span>
</span>
<input
type="range"
min={25}
max={100}
step={5}
value={Math.round(opacity * 100)}
onChange={e => setPerfOverlayOpacity(Number(e.target.value) / 100)}
/>
</label>
</section>
);
}
@@ -0,0 +1,33 @@
import {
PERF_OVERLAY_MODE_OPTIONS,
setPerfOverlayMode,
usePerfOverlayMode,
} from '../../../utils/perf/perfOverlayMode';
export default function PerfOverlayModeControls() {
const mode = usePerfOverlayMode();
return (
<section className="perf-overlay-mode" aria-label="Overlay mode">
<div className="perf-overlay-mode__title">On-screen overlay</div>
<div className="perf-overlay-mode__segments" role="group" aria-label="Overlay mode">
{PERF_OVERLAY_MODE_OPTIONS.map(option => (
<button
key={option.id}
type="button"
className={`perf-overlay-mode__segment${mode === option.id ? ' perf-overlay-mode__segment--active' : ''}`}
aria-pressed={mode === option.id}
onClick={() => setPerfOverlayMode(option.id)}
>
{option.label}
</button>
))}
</div>
<p className="perf-overlay-mode__hint">
{mode === 'off' && 'Overlay hidden.'}
{mode === 'fps' && 'Shows only the FPS counter.'}
{mode === 'pinned' && 'Shows metrics pinned in Monitor (pipeline + live).'}
</p>
</section>
);
}
@@ -0,0 +1,98 @@
import type { ReactNode } from 'react';
import { ChevronRight, Pin, PinOff } from 'lucide-react';
type PinKind = 'live' | 'pipeline';
interface Props {
label: string;
value: string;
unit?: string;
detail?: string;
barPct?: number;
barTone?: 'cpu' | 'memory' | 'rate' | 'neutral';
pinned?: boolean;
pinKind?: PinKind;
onTogglePin?: () => void;
disabled?: boolean;
}
export default function PerfProbeMetricCard({
label,
value,
unit,
detail,
barPct,
barTone = 'neutral',
pinned = false,
pinKind,
onTogglePin,
disabled = false,
}: Props) {
const showBar = barPct != null && Number.isFinite(barPct) && barPct > 0;
return (
<div className={`perf-metric-card${disabled ? ' perf-metric-card--disabled' : ''}`}>
<div className="perf-metric-card__head">
<div className="perf-metric-card__label">{label}</div>
{onTogglePin && (
<button
type="button"
className={`perf-metric-card__pin${pinned ? ' perf-metric-card__pin--active' : ''}`}
onClick={onTogglePin}
aria-pressed={pinned}
title={pinned ? 'Remove from overlay' : 'Show in overlay'}
>
{pinned ? <PinOff size={14} /> : <Pin size={14} />}
</button>
)}
</div>
<div className="perf-metric-card__value-row">
<span className="perf-metric-card__value">{value}</span>
{unit && <span className="perf-metric-card__unit">{unit}</span>}
</div>
{showBar && (
<div className="perf-metric-card__bar-track" aria-hidden="true">
<div
className={`perf-metric-card__bar-fill perf-metric-card__bar-fill--${barTone}`}
style={{ width: `${Math.min(100, barPct)}%` }}
/>
</div>
)}
{detail && <div className="perf-metric-card__detail">{detail}</div>}
{pinKind === 'pipeline' && pinned && (
<div className="perf-metric-card__badge">Pipeline overlay</div>
)}
</div>
);
}
interface SectionProps {
title: string;
hint?: string;
children: ReactNode;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function PerfProbeMetricSection({
title,
hint,
children,
defaultOpen = true,
onOpenChange,
}: SectionProps) {
return (
<details
className="perf-metric-section"
open={defaultOpen}
onToggle={e => onOpenChange?.((e.currentTarget as HTMLDetailsElement).open)}
>
<summary className="perf-metric-section__title">
<ChevronRight size={14} className="perf-metric-section__chevron" />
<span>{title}</span>
{hint && <span className="perf-metric-section__hint">{hint}</span>}
</summary>
<div className="perf-metric-section__grid">{children}</div>
</details>
);
}
@@ -0,0 +1,230 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { usePerfLiveSnapshot } from '../../../utils/perf/perfLiveStore';
import {
syncPerfLiveThreadGroupsNeed,
} from '../../../utils/perf/perfLivePollSettings';
import {
togglePerfLiveOverlayPin,
togglePipelineOverlayPin,
usePerfLiveOverlayPins,
usePipelineOverlayPinned,
type PerfLiveOverlayPinId,
} from '../../../utils/perf/perfOverlayPins';
import PerfProbeMetricCard, { PerfProbeMetricSection } from './PerfProbeMetricCard';
import PerfOverlayAppearanceControls from './PerfOverlayAppearanceControls';
import PerfOverlayModeControls from './PerfOverlayModeControls';
import PerfLivePollControls from './PerfLivePollControls';
function memoryBarPct(rssKb: number, maxKb: number): number {
if (maxKb <= 0) return 0;
return (rssKb / maxKb) * 100;
}
export default function SidebarPerfProbeMonitorTab() {
const live = usePerfLiveSnapshot();
const livePins = usePerfLiveOverlayPins();
const fpsPinned = usePipelineOverlayPinned('pipeline:fps');
const analysisPinned = usePipelineOverlayPinned('pipeline:analysis');
const coverPinned = usePipelineOverlayPinned('pipeline:cover');
const cpu = live.cpu;
const collecting = live.collecting && cpu == null;
const peakMemoryKbRef = useRef(1);
const peakThreadCpuRef = useRef(1);
const [threadSectionOpen, setThreadSectionOpen] = useState(false);
useEffect(() => {
syncPerfLiveThreadGroupsNeed(threadSectionOpen, livePins);
}, [threadSectionOpen, livePins]);
const maxMemoryKb = useMemo(() => {
const current = Math.max(1, ...(cpu?.memory.map(m => m.rss_kb) ?? [1]));
if (current > peakMemoryKbRef.current) peakMemoryKbRef.current = current;
return peakMemoryKbRef.current;
}, [cpu?.memory]);
const maxThreadCpu = useMemo(() => {
const current = Math.max(1, ...(cpu?.threadCpu.map(t => t.pct) ?? [1]));
if (current > peakThreadCpuRef.current) peakThreadCpuRef.current = current;
return peakThreadCpuRef.current;
}, [cpu?.threadCpu]);
if (collecting) {
return (
<div className="perf-monitor-empty">
<div className="spinner" style={{ width: 22, height: 22 }} />
<span>Collecting live samples</span>
</div>
);
}
if (cpu && !cpu.supported) {
return (
<div className="perf-monitor-empty">
Live CPU/memory monitoring is unavailable on this platform or build.
</div>
);
}
const toggleLive = (id: PerfLiveOverlayPinId) => () => togglePerfLiveOverlayPin(id);
const livePinned = (id: PerfLiveOverlayPinId) => livePins.has(id);
return (
<div className="perf-monitor">
<PerfOverlayModeControls />
<PerfOverlayAppearanceControls />
<PerfLivePollControls />
<PerfProbeMetricSection title="Pipeline overlays" hint="Rust / UI queues">
<PerfProbeMetricCard
label="FPS"
value="—"
detail="requestAnimationFrame rate"
pinned={fpsPinned}
pinKind="pipeline"
onTogglePin={() => togglePipelineOverlayPin('pipeline:fps')}
/>
<PerfProbeMetricCard
label="Analysis"
value="—"
detail="Throughput + last track timings"
pinned={analysisPinned}
pinKind="pipeline"
onTogglePin={() => togglePipelineOverlayPin('pipeline:analysis')}
/>
<PerfProbeMetricCard
label="Cover pipeline"
value="—"
detail="Ensure / HTTP / encode queues"
pinned={coverPinned}
pinKind="pipeline"
onTogglePin={() => togglePipelineOverlayPin('pipeline:cover')}
/>
</PerfProbeMetricSection>
{cpu && (
<>
<PerfProbeMetricSection title="CPU — processes">
<PerfProbeMetricCard
label="psysonic"
value={cpu.app.toFixed(1)}
unit="%"
barPct={cpu.app}
barTone="cpu"
pinned={livePinned('cpu:app')}
onTogglePin={toggleLive('cpu:app')}
/>
<PerfProbeMetricCard
label="WebKit web"
value={cpu.webkit.toFixed(1)}
unit="%"
barPct={cpu.webkit}
barTone="cpu"
pinned={livePinned('cpu:webkit')}
onTogglePin={toggleLive('cpu:webkit')}
/>
</PerfProbeMetricSection>
{(cpu.threadCpu.length > 0 || threadSectionOpen) && (
<PerfProbeMetricSection
title="CPU — psysonic threads"
defaultOpen={false}
onOpenChange={setThreadSectionOpen}
>
{cpu.threadCpu.length > 0 ? cpu.threadCpu.map(row => {
const pinId = `cpu:thread:${row.label}` as PerfLiveOverlayPinId;
return (
<PerfProbeMetricCard
key={row.label}
label={row.label}
value={row.pct.toFixed(1)}
unit="%"
detail={row.threadCount > 1 ? `${row.threadCount} threads` : undefined}
barPct={(row.pct / maxThreadCpu) * 100}
barTone="cpu"
pinned={livePinned(pinId)}
onTogglePin={toggleLive(pinId)}
/>
);
}) : (
<div className="perf-monitor-empty perf-monitor-empty--inline">Collecting thread samples</div>
)}
</PerfProbeMetricSection>
)}
{cpu.memory.length > 0 && (
<PerfProbeMetricSection title="Memory — RSS">
{cpu.memory.map(row => {
const pinId = `mem:${row.label}` as PerfLiveOverlayPinId;
return (
<PerfProbeMetricCard
key={row.label}
label={row.label}
value={(row.rss_kb / 1024).toFixed(1)}
unit="MB"
barPct={memoryBarPct(row.rss_kb, maxMemoryKb)}
barTone="memory"
pinned={livePinned(pinId)}
onTogglePin={toggleLive(pinId)}
/>
);
})}
</PerfProbeMetricSection>
)}
</>
)}
{live.diagRates && (
<PerfProbeMetricSection title="UI event rates" defaultOpen={false}>
<PerfProbeMetricCard
label="audio:progress"
value={live.diagRates.progress.toFixed(1)}
unit="/s"
barPct={Math.min(100, live.diagRates.progress * 2)}
barTone="rate"
pinned={livePinned('rate:progress')}
onTogglePin={toggleLive('rate:progress')}
/>
<PerfProbeMetricCard
label="waveform draws"
value={live.diagRates.waveform.toFixed(1)}
unit="/s"
barPct={Math.min(100, live.diagRates.waveform * 2)}
barTone="rate"
pinned={livePinned('rate:waveform')}
onTogglePin={toggleLive('rate:waveform')}
/>
<PerfProbeMetricCard
label="Home commits"
value={live.diagRates.home.toFixed(1)}
unit="/s"
barPct={Math.min(100, live.diagRates.home * 5)}
barTone="rate"
pinned={livePinned('rate:home')}
onTogglePin={toggleLive('rate:home')}
/>
</PerfProbeMetricSection>
)}
{live.analysis && (
<PerfProbeMetricSection title="Analysis" defaultOpen={false}>
<PerfProbeMetricCard
label="Throughput"
value={live.analysis.tracksPerMinute.toFixed(1)}
unit="tpm"
pinned={livePinned('analysis:tpm')}
onTogglePin={toggleLive('analysis:tpm')}
/>
{live.analysis.lastTotalMs != null && (
<PerfProbeMetricCard
label="Last track"
value={(live.analysis.lastTotalMs / 1000).toFixed(1)}
unit="s"
detail={`fetch ${((live.analysis.lastFetchMs ?? 0) / 1000).toFixed(1)}s · seed ${((live.analysis.lastSeedMs ?? 0) / 1000).toFixed(1)}s · bpm ${((live.analysis.lastBpmMs ?? 0) / 1000).toFixed(1)}s`}
pinned={livePinned('analysis:last')}
onTogglePin={toggleLive('analysis:last')}
/>
)}
</PerfProbeMetricSection>
)}
</div>
);
}
@@ -0,0 +1,165 @@
import { useState, type CSSProperties } from 'react';
import { ChevronRight } from 'lucide-react';
import { setPerfProbeFlag, type PerfProbeFlags } from '../../../utils/perf/perfFlags';
import type { PerfToggleEngineLeaf, PerfToggleLeaf, PerfToggleNode } from '../../../utils/perf/perfProbeToggleTree';
import {
isPerfToggleEngineLeaf,
isPerfToggleGroup,
PERF_PROBE_TOGGLE_TREE,
} from '../../../utils/perf/perfProbeToggleTree';
interface EngineProps {
hotCacheEnabled: boolean;
setHotCacheEnabled: (v: boolean) => void;
normalizationEngine: string;
setNormalizationEngine: (v: 'off' | 'loudness') => void;
loggingMode: string;
setLoggingMode: (v: 'off' | 'normal') => void;
}
interface Props extends EngineProps {
perfFlags: PerfProbeFlags;
}
function ToggleLeaf({
node,
perfFlags,
engineProps,
}: {
node: PerfToggleLeaf | PerfToggleEngineLeaf;
perfFlags: PerfProbeFlags;
engineProps: EngineProps;
}) {
if (isPerfToggleEngineLeaf(node)) {
const checked = node.engine === 'hotCache'
? !engineProps.hotCacheEnabled
: node.engine === 'normalization'
? engineProps.normalizationEngine === 'off'
: engineProps.loggingMode === 'off';
return (
<label className="perf-tree-leaf">
<input
type="checkbox"
checked={checked}
onChange={e => {
if (node.engine === 'hotCache') engineProps.setHotCacheEnabled(!e.target.checked);
else if (node.engine === 'normalization') {
engineProps.setNormalizationEngine(e.target.checked ? 'off' : 'loudness');
} else engineProps.setLoggingMode(e.target.checked ? 'off' : 'normal');
}}
/>
<span className="perf-tree-leaf__text">
<span className="perf-tree-leaf__label">{node.label}</span>
{node.description && (
<span className="perf-tree-leaf__desc">{node.description}</span>
)}
</span>
</label>
);
}
if ('flag' in node) {
return (
<label className="perf-tree-leaf">
<input
type="checkbox"
checked={perfFlags[node.flag]}
onChange={e => setPerfProbeFlag(node.flag, e.target.checked)}
/>
<span className="perf-tree-leaf__text">
<span className="perf-tree-leaf__label">{node.label}</span>
{node.description && (
<span className="perf-tree-leaf__desc">{node.description}</span>
)}
</span>
</label>
);
}
return null;
}
function ToggleTreeNode({
node,
depth,
perfFlags,
engineProps,
}: {
node: PerfToggleNode;
depth: number;
perfFlags: PerfProbeFlags;
engineProps: EngineProps;
}) {
const [open, setOpen] = useState(depth < 1);
if (isPerfToggleGroup(node)) {
return (
<div className="perf-tree-group" style={{ '--perf-tree-depth': depth } as CSSProperties}>
<button
type="button"
className="perf-tree-group__head"
onClick={() => setOpen(v => !v)}
aria-expanded={open}
>
<ChevronRight size={14} className={`perf-tree-group__chevron${open ? ' perf-tree-group__chevron--open' : ''}`} />
<span>{node.label}</span>
</button>
{open && (
<div className="perf-tree-group__body">
{node.children.map(child => (
<ToggleTreeNode
key={child.id}
node={child}
depth={depth + 1}
perfFlags={perfFlags}
engineProps={engineProps}
/>
))}
</div>
)}
</div>
);
}
return (
<div className="perf-tree-leaf-wrap" style={{ '--perf-tree-depth': depth } as CSSProperties}>
<ToggleLeaf node={node} perfFlags={perfFlags} engineProps={engineProps} />
</div>
);
}
export default function SidebarPerfProbeTogglesTab({
perfFlags,
hotCacheEnabled,
setHotCacheEnabled,
normalizationEngine,
setNormalizationEngine,
loggingMode,
setLoggingMode,
}: Props) {
const engineProps: EngineProps = {
hotCacheEnabled,
setHotCacheEnabled,
normalizationEngine,
setNormalizationEngine,
loggingMode,
setLoggingMode,
};
return (
<div className="perf-toggle-tree">
<p className="sidebar-perf-modal__hint perf-toggle-tree__hint">
Disable subsystems one at a time to estimate their UI cost. Changes apply immediately.
</p>
{PERF_PROBE_TOGGLE_TREE.map(group => (
<ToggleTreeNode
key={group.id}
node={group}
depth={0}
perfFlags={perfFlags}
engineProps={engineProps}
/>
))}
</div>
);
}
+2
View File
@@ -339,6 +339,8 @@ const CONTRIBUTOR_ENTRIES = [
'Server index-key rebuild: safe dual-DB migration flow, per-server analysis strategy controls, and playback/index scope hardening (PR #864)',
'Settings: opt-in Linux input-focus repaint — workaround for the WebKitGTK 2.50.x text-field freeze (PR #884)',
'Discord Rich Presence: configurable activity-name template — member list shows the playing track instead of "Psysonic" (PR #885)',
'Library browse: lazy 200-row local catalog chunks, unified in-page scroll, cover priority during scroll (PR #890)',
'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)',
],
},
{
+71 -12
View File
@@ -1,10 +1,11 @@
import type { ImgHTMLAttributes } from 'react';
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { DEFAULT_CACHED_IMAGE_PREPARE_MARGIN } from '../components/CachedImage';
import { resolveIntersectionScrollRoot } from '../utils/ui/resolveIntersectionScrollRoot';
import { coverEnsureBump } from './ensureQueue';
import { coverEnsureQueued, coverEnsureReprioritize } from './ensureQueue';
import { coverPrefetchBumpPriority } from './prefetchRegistry';
import { coverServerReachable } from './reachability';
import { coverStorageKeyFromRef } from './storageKeys';
import { resolveCoverDisplayTier } from './tiers';
import { coverImgSrc } from './imgSrc';
@@ -38,9 +39,12 @@ export function CoverArtImage({
onError: restOnError,
...rest
}: CoverArtImageProps) {
const pinnedHigh = ensurePriorityProp === 'high';
const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>(
ensurePriorityProp ?? 'middle',
);
const [seenViewport, setSeenViewport] = useState(false);
const seenViewportRef = useRef(false);
const imgRef = useRef<HTMLImageElement>(null);
const [imgLoadFailed, setImgLoadFailed] = useState(false);
@@ -48,11 +52,15 @@ export function CoverArtImage({
if (ensurePriorityProp) setEnsurePriority(ensurePriorityProp);
}, [ensurePriorityProp]);
useEffect(() => {
seenViewportRef.current = seenViewport;
}, [seenViewport]);
useEffect(() => {
setImgLoadFailed(false);
}, [coverRef.cacheEntityId, coverRef.cacheKind, coverRef.fetchCoverArtId, displayCssPx, surface, fullRes]);
useEffect(() => {
useLayoutEffect(() => {
const el = imgRef.current;
if (!el) return;
@@ -63,15 +71,37 @@ export function CoverArtImage({
const tier = resolveCoverDisplayTier(displayCssPx, { surface, fullRes });
const storageKey = coverStorageKeyFromRef(coverRef, tier);
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
const reachable = coverServerReachable(coverRef.serverScope);
const queueEnsure = (priority: CoverPrefetchPriority) => {
if (!reachable) return;
void coverEnsureQueued(storageKey, coverRef, tier, priority);
};
const applyIntersecting = () => {
seenViewportRef.current = true;
setSeenViewport(true);
setEnsurePriority('high');
coverPrefetchBumpPriority(coverRef, 'high');
coverEnsureBump(storageKey, 'high');
}
}
coverEnsureReprioritize(storageKey, 'high');
queueEnsure('high');
};
const applyLeftViewport = () => {
if (!seenViewportRef.current || pinnedHigh) return;
setEnsurePriority('middle');
coverEnsureReprioritize(storageKey, 'middle');
queueEnsure('middle');
};
const applyEntry = (entry: IntersectionObserverEntry) => {
if (entry.isIntersecting) applyIntersecting();
else applyLeftViewport();
};
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) applyEntry(entry);
},
{
root: root ?? undefined,
@@ -80,13 +110,42 @@ export function CoverArtImage({
},
);
observer.observe(el);
return () => observer.disconnect();
}, [coverRef, displayCssPx, surface, fullRes, observeRootMargin, observeScrollRootId]);
const drainRecords = () => {
for (const entry of observer.takeRecords()) applyEntry(entry);
};
drainRecords();
let cancelled = false;
const raf1 = requestAnimationFrame(() => {
if (cancelled) return;
drainRecords();
requestAnimationFrame(() => {
if (cancelled) return;
drainRecords();
});
});
return () => {
cancelled = true;
cancelAnimationFrame(raf1);
observer.disconnect();
};
}, [
coverRef,
displayCssPx,
surface,
fullRes,
observeRootMargin,
observeScrollRootId,
pinnedHigh,
]);
const { src, provisional, onImgError } = useCoverArt(coverRef, displayCssPx, {
surface,
fullRes,
ensurePriority,
seenViewport,
alt,
});
+27
View File
@@ -9,8 +9,11 @@ import { libraryCoverBackfillSetUiPriority } from '../api/coverCache';
import {
__test_resetCoverTraffic,
coverTrafficBackgroundPaused,
coverTrafficBeginGridPagination,
coverTrafficBeginNavigation,
coverTrafficEndGridPagination,
coverTrafficEndNavigation,
coverTrafficGridPaginationDepth,
} from './coverTraffic';
describe('coverTraffic navigation hold', () => {
@@ -40,3 +43,27 @@ describe('coverTraffic navigation hold', () => {
expect(coverTrafficBackgroundPaused()).toBe(false);
});
});
describe('coverTraffic grid pagination hold', () => {
beforeEach(() => {
__test_resetCoverTraffic();
});
it('pauses middle/low cover work while album pages fetch', () => {
coverTrafficBeginGridPagination();
expect(coverTrafficBackgroundPaused()).toBe(true);
expect(coverTrafficGridPaginationDepth()).toBe(1);
coverTrafficEndGridPagination();
expect(coverTrafficBackgroundPaused()).toBe(false);
expect(coverTrafficGridPaginationDepth()).toBe(0);
});
it('does not leak hold depth when overlapping browse fetches end out of order', () => {
coverTrafficBeginGridPagination();
coverTrafficBeginGridPagination();
coverTrafficEndGridPagination();
expect(coverTrafficGridPaginationDepth()).toBe(1);
coverTrafficEndGridPagination();
expect(coverTrafficGridPaginationDepth()).toBe(0);
});
});
+18 -1
View File
@@ -13,6 +13,8 @@ function cancelVisibleCoverWork(): void {
}
let navigationHoldDepth = 0;
/** Album grid SQL page fetch — pause middle/low cover work until rows settle. */
let gridPaginationHoldDepth = 0;
let serverSwitchHold = false;
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
let serverSwitchEndTimer: ReturnType<typeof setTimeout> | null = null;
@@ -48,6 +50,15 @@ export function coverTrafficEndNavigation(): void {
scheduleNavigationResume();
}
/** Local album browse page fetch — avoid middle-priority cover storms during SQL pagination. */
export function coverTrafficBeginGridPagination(): void {
gridPaginationHoldDepth += 1;
}
export function coverTrafficEndGridPagination(): void {
gridPaginationHoldDepth = Math.max(0, gridPaginationHoldDepth - 1);
}
/** Active server change — stop all cover IPC so ping + menu stay responsive. */
export function coverTrafficBeginServerSwitch(): void {
serverSwitchHold = true;
@@ -70,7 +81,12 @@ export function coverTrafficEndServerSwitch(): void {
}
export function coverTrafficBackgroundPaused(): boolean {
return navigationHoldDepth > 0 || serverSwitchHold;
return navigationHoldDepth > 0 || gridPaginationHoldDepth > 0 || serverSwitchHold;
}
/** @internal Diagnostics / tests — album grid SQL hold depth. */
export function coverTrafficGridPaginationDepth(): number {
return gridPaginationHoldDepth;
}
/** Hard stop for ensure/peek pumps (includes visible `high` grid jobs). */
@@ -89,6 +105,7 @@ function scheduleNavigationResume(): void {
/** Test-only — reset module hold state between cases. */
export function __test_resetCoverTraffic(): void {
navigationHoldDepth = 0;
gridPaginationHoldDepth = 0;
serverSwitchHold = false;
if (resumeTimer) clearTimeout(resumeTimer);
if (serverSwitchEndTimer) clearTimeout(serverSwitchEndTimer);
+12 -1
View File
@@ -4,6 +4,8 @@ import type { CoverServerScope } from './types';
/** Stable asset URLs for disk `.webp` tiers — survives route unmount. */
const diskSrcByStorageKey = new Map<string, string>();
/** Bound webview memory when scrolling large grids with a cold cover cache. */
const MAX_DISK_SRC_CACHE_ENTRIES = 4096;
let cacheGeneration = 0;
const cacheListeners = new Set<() => void>();
@@ -93,13 +95,22 @@ export function rememberDiskSrc(storageKey: string, fsPath: string): string {
if (!src) return '';
const prev = diskSrcByStorageKey.get(storageKey);
if (prev === src) return src;
if (diskSrcByStorageKey.size >= MAX_DISK_SRC_CACHE_ENTRIES) {
const oldest = diskSrcByStorageKey.keys().next().value;
if (oldest !== undefined) diskSrcByStorageKey.delete(oldest);
}
diskSrcByStorageKey.set(storageKey, src);
bumpDiskSrcCache();
return src;
}
export function getDiskSrc(storageKey: string): string {
return diskSrcByStorageKey.get(storageKey) ?? '';
const src = diskSrcByStorageKey.get(storageKey) ?? '';
if (src && diskSrcByStorageKey.has(storageKey)) {
diskSrcByStorageKey.delete(storageKey);
diskSrcByStorageKey.set(storageKey, src);
}
return src;
}
export function forgetDiskSrc(storageKey: string): void {
+53 -1
View File
@@ -16,19 +16,32 @@ vi.mock('../api/coverCache', () => ({
}));
import { coverArtRef } from './ref';
import { coverTrafficBeginServerSwitch, coverTrafficEndServerSwitch } from './coverTraffic';
import {
__test_resetCoverTraffic,
coverTrafficBeginServerSwitch,
coverTrafficEndServerSwitch,
} from './coverTraffic';
import {
__test_queuedCoverIds,
__test_resetCoverEnsureQueue,
coverEnsureBump,
coverEnsureQueued,
coverEnsureRelease,
coverEnsureReprioritize,
coverEnsureQueueStats,
} from './ensureQueue';
describe('coverEnsureQueued', () => {
beforeEach(() => {
__test_resetCoverEnsureQueue();
__test_resetCoverTraffic();
ensureImpl.mockClear();
ensureImpl.mockImplementation(
async (ref: { fetchCoverArtId: string }, _tier: number, _priority: string) => {
await new Promise(r => setTimeout(r, 2));
return { hit: true, path: `/tmp/${ref.fetchCoverArtId}.webp`, tier: 128 };
},
);
});
it('dedupes concurrent ensures for the same storage key', async () => {
@@ -57,6 +70,45 @@ describe('coverEnsureQueued', () => {
coverTrafficEndServerSwitch();
});
it('reprioritize downgrades viewport-leavers to middle with LIFO order', () => {
coverTrafficBeginServerSwitch();
const refNear = coverArtRef('al-near');
const refFar = coverArtRef('al-far');
void coverEnsureQueued('s:cover:al-near:128', refNear, 128, 'high');
void coverEnsureQueued('s:cover:al-far:128', refFar, 128, 'high');
coverEnsureReprioritize('s:cover:al-near:128', 'middle');
coverEnsureReprioritize('s:cover:al-far:128', 'middle');
const stats = coverEnsureQueueStats();
expect(stats.queuedHigh).toBe(0);
expect(stats.queuedMiddle).toBe(2);
expect(__test_queuedCoverIds()[0]).toBe('al-far');
coverTrafficEndServerSwitch();
});
it('shares one invoke slot per cover art id while duplicate jobs wait', async () => {
let release!: () => void;
const gate = new Promise<void>(resolve => {
release = resolve;
});
ensureImpl.mockImplementationOnce(async () => {
await gate;
return { hit: true, path: '/tmp/al-shared.webp', tier: 128 };
});
const ref = coverArtRef('al-shared');
void coverEnsureQueued('s:cover:al-shared:128', ref, 128, 'high');
void coverEnsureQueued('s:cover:al-shared:256', ref, 256, 'high');
await new Promise(r => setTimeout(r, 10));
expect(ensureImpl).toHaveBeenCalledTimes(1);
expect(coverEnsureQueueStats().inflight).toBe(1);
release();
await new Promise(r => setTimeout(r, 10));
});
it('release drops a pending job so a remount can re-queue', async () => {
coverTrafficBeginServerSwitch();
const ref = coverArtRef('al-drop');
+162 -33
View File
@@ -24,11 +24,87 @@ export const COVER_ENSURE_MAX_INFLIGHT = 10;
const MAX_INFLIGHT = COVER_ENSURE_MAX_INFLIGHT;
/** Drop stale scroll-ahead work so the queue cannot grow without bound. */
const MAX_QUEUE = 96;
/** Abort wedged Rust ensures so invoke slots cannot stall the grid forever. */
const ENSURE_INVOKE_TIMEOUT_MS = 45_000;
let inflight = 0;
let queue: EnsureJob[] = [];
let nextOrderKey = 0;
const inflightStorageKeys = new Set<string>();
const backlogDrainListeners = new Set<() => void>();
type EnsureInvokeResult = { hit: boolean; path: string };
function coverInflightKey(ref: CoverArtRef): string {
return `${coverIndexKeyFromRef(ref)}:${ref.cacheKind}:${ref.cacheEntityId}`;
}
/** One active Rust ensure per cover art id — waiters attach without consuming invoke slots. */
const coverArtInFlight = new Map<string, Promise<EnsureInvokeResult>>();
function withEnsureTimeout(
promise: Promise<EnsureInvokeResult>,
): Promise<EnsureInvokeResult> {
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
reject(new Error('cover ensure invoke timeout'));
}, ENSURE_INVOKE_TIMEOUT_MS);
void promise.then(
value => {
window.clearTimeout(timer);
resolve(value);
},
error => {
window.clearTimeout(timer);
reject(error);
},
);
});
}
function invokeEnsureForCover(
ref: CoverArtRef,
tier: CoverArtTier,
priority: CoverPrefetchPriority,
): Promise<EnsureInvokeResult> {
const key = coverInflightKey(ref);
const existing = coverArtInFlight.get(key);
if (existing) return existing;
const flight = withEnsureTimeout(
coverCacheEnsure(ref, tier, priority).then(r => ({ hit: r.hit, path: r.path })),
).finally(() => {
if (coverArtInFlight.get(key) === flight) coverArtInFlight.delete(key);
});
coverArtInFlight.set(key, flight);
return flight;
}
function settleJob(job: EnsureJob, result: EnsureInvokeResult): void {
job.resolve(result);
}
function attachQueuedJobsToActiveFlights(): void {
let i = 0;
while (i < queue.length) {
const job = queue[i]!;
const flight = coverArtInFlight.get(coverInflightKey(job.ref));
if (!flight) {
i += 1;
continue;
}
queue.splice(i, 1);
void flight
.then(r => settleJob(job, r))
.catch(() => settleJob(job, { hit: false, path: '' }));
}
}
function notifyBacklogDrain(): void {
for (const listener of backlogDrainListeners) {
listener();
}
}
function priorityRank(p: CoverPrefetchPriority): number {
return p === 'high' ? 0 : p === 'middle' ? 1 : 2;
@@ -44,41 +120,28 @@ function sortQueue(): void {
function trimQueue(): void {
while (queue.length > MAX_QUEUE) {
let worstIdx = 0;
for (let i = 1; i < queue.length; i += 1) {
const a = queue[worstIdx]!;
const b = queue[i]!;
const rankA = priorityRank(a.priority);
const rankB = priorityRank(b.priority);
if (rankB > rankA || (rankB === rankA && b.orderKey < a.orderKey)) {
let worstIdx = -1;
for (let i = 0; i < queue.length; i += 1) {
const job = queue[i]!;
if (job.priority === 'high') continue;
if (worstIdx < 0) {
worstIdx = i;
continue;
}
const worst = queue[worstIdx]!;
const rank = priorityRank(job.priority);
const worstRank = priorityRank(worst.priority);
if (rank > worstRank || (rank === worstRank && job.orderKey < worst.orderKey)) {
worstIdx = i;
}
}
if (worstIdx < 0) break;
const [job] = queue.splice(worstIdx, 1);
job.resolve({ hit: false, path: '' });
ensureInflight.delete(job.storageKey);
}
}
function coverInflightKey(ref: CoverArtRef): string {
return `${coverIndexKeyFromRef(ref)}:${ref.cacheKind}:${ref.cacheEntityId}`;
}
/** Serialize ensures per cover ID so we do not re-download for every tier. */
const coverDownloadTail = new Map<string, Promise<unknown>>();
function ensureForCover(
ref: CoverArtRef,
tier: CoverArtTier,
priority: CoverPrefetchPriority,
) {
const key = coverInflightKey(ref);
const tail = coverDownloadTail.get(key) ?? Promise.resolve();
const run = tail.then(() => coverCacheEnsure(ref, tier, priority));
coverDownloadTail.set(key, run.catch(() => {}));
return run;
}
function findQueuedJob(storageKey: string): EnsureJob | undefined {
return queue.find(j => j.storageKey === storageKey);
}
@@ -93,21 +156,32 @@ function bumpJob(job: EnsureJob, priority?: CoverPrefetchPriority): void {
function pump(): void {
if (coverTrafficServerSwitchPaused()) return;
attachQueuedJobsToActiveFlights();
while (inflight < MAX_INFLIGHT && queue.length > 0) {
const next = queue[0]!;
if (coverTrafficBackgroundPaused() && next.priority !== 'high') {
let pickIdx = -1;
for (let i = 0; i < queue.length; i += 1) {
const candidate = queue[i]!;
if (coverTrafficBackgroundPaused() && candidate.priority !== 'high') {
break;
}
const job = queue.shift()!;
if (coverArtInFlight.has(coverInflightKey(candidate.ref))) continue;
pickIdx = i;
break;
}
if (pickIdx < 0) break;
const job = queue.splice(pickIdx, 1)[0]!;
inflight += 1;
inflightStorageKeys.add(job.storageKey);
void ensureForCover(job.ref, job.tier, job.priority)
.then(r => job.resolve({ hit: r.hit, path: r.path }))
.catch(() => job.resolve({ hit: false, path: '' }))
void invokeEnsureForCover(job.ref, job.tier, job.priority)
.then(r => settleJob(job, r))
.catch(() => settleJob(job, { hit: false, path: '' }))
.finally(() => {
inflight -= 1;
inflightStorageKeys.delete(job.storageKey);
pump();
notifyBacklogDrain();
});
}
}
@@ -125,6 +199,19 @@ export function coverEnsureBump(
pump();
}
/** Set queued priority (upgrade or downgrade) and bump order for LIFO within the tier. */
export function coverEnsureReprioritize(
storageKey: string,
priority: CoverPrefetchPriority,
): void {
const job = findQueuedJob(storageKey);
if (!job) return;
job.priority = priority;
job.orderKey = ++nextOrderKey;
sortQueue();
pump();
}
/** Drop queued ensures (route/server change) — in-flight jobs finish on their own. */
export function coverEnsureCancelPending(): void {
const dropped = queue;
@@ -147,11 +234,52 @@ export function coverEnsureRelease(storageKey: string): void {
}
}
/** Resume ensure pump after a grid-pagination hold ends. */
export function coverEnsureResumePump(): void {
pump();
notifyBacklogDrain();
}
/** Retry pagination / prefetch when the ensure backlog drops (sentinel may still be visible). */
export function coverEnsureSubscribeBacklogDrain(listener: () => void): () => void {
backlogDrainListeners.add(listener);
return () => {
backlogDrainListeners.delete(listener);
};
}
/** Queued + active ensure jobs (for library backfill watermark). */
export function coverEnsureQueueBacklog(): number {
return queue.length + inflight;
}
export type CoverEnsureQueueStats = {
queuedHigh: number;
queuedMiddle: number;
queuedLow: number;
inflight: number;
maxInflight: number;
};
/** Webview ensure queue — tier breakdown for perf probe overlay. */
export function coverEnsureQueueStats(): CoverEnsureQueueStats {
let queuedHigh = 0;
let queuedMiddle = 0;
let queuedLow = 0;
for (const job of queue) {
if (job.priority === 'high') queuedHigh += 1;
else if (job.priority === 'middle') queuedMiddle += 1;
else queuedLow += 1;
}
return {
queuedHigh,
queuedMiddle,
queuedLow,
inflight,
maxInflight: MAX_INFLIGHT,
};
}
/** @internal Vitest-only — module singleton queue. */
export function __test_resetCoverEnsureQueue(): void {
queue = [];
@@ -159,7 +287,8 @@ export function __test_resetCoverEnsureQueue(): void {
nextOrderKey = 0;
inflightStorageKeys.clear();
ensureInflight.clear();
coverDownloadTail.clear();
coverArtInFlight.clear();
backlogDrainListeners.clear();
}
/** @internal Vitest-only — queued cover art IDs front-to-back. */
+10
View File
@@ -114,3 +114,13 @@ export function coverPeekCancelPending(): void {
inflight.delete(job.storageKey);
}
}
export type CoverPeekQueueStats = {
pending: number;
inflight: number;
};
/** Batched disk peek backlog — perf probe overlay. */
export function coverPeekQueueStats(): CoverPeekQueueStats {
return { pending: pending.size, inflight: inflight.size };
}
+11
View File
@@ -54,6 +54,15 @@ function libraryResolveCacheKey(
const resolvedEntryCache = new Map<string, CoverEntry | null>();
const inflightResolves = new Map<string, Promise<CoverEntry | null>>();
const MAX_RESOLVED_ENTRY_CACHE = 4096;
function trimResolvedEntryCache(): void {
while (resolvedEntryCache.size > MAX_RESOLVED_ENTRY_CACHE) {
const oldest = resolvedEntryCache.keys().next().value;
if (oldest === undefined) break;
resolvedEntryCache.delete(oldest);
}
}
const LIBRARY_RESOLVE_MAX_INFLIGHT = 4;
let libraryResolveActive = 0;
@@ -99,9 +108,11 @@ export async function libraryResolveCoverEntry(
});
const entry = dto ? dtoToEntry(dto) : null;
resolvedEntryCache.set(key, entry);
trimResolvedEntryCache();
return entry;
} catch {
resolvedEntryCache.set(key, null);
trimResolvedEntryCache();
return null;
} finally {
inflightResolves.delete(key);
+12 -4
View File
@@ -31,6 +31,8 @@ export function useCoverArt(
observeRootMargin?: string;
alt?: string;
ensurePriority?: CoverPrefetchPriority;
/** Dense grid: true after first viewport intersection — allows middle-tier scroll-ahead. */
seenViewport?: boolean;
},
): CoverArtHandle {
const ref = coverRef ?? null;
@@ -55,7 +57,9 @@ export function useCoverArt(
const ensurePriority: CoverPrefetchPriority = opts?.ensurePriority ?? 'middle';
const deferEnsureUntilVisible = surface === 'dense' && ensurePriority !== 'high';
const seenViewport = opts?.seenViewport ?? false;
const deferEnsureUntilVisible =
surface === 'dense' && !seenViewport && ensurePriority !== 'high';
const readCachedSrc = useCallback(() => {
if (!ref) return '';
@@ -83,9 +87,9 @@ export function useCoverArt(
let cancelled = false;
void (async () => {
const peekHit = await coverPeekQueued(storageKey, ref, tier);
await coverPeekQueued(storageKey, ref, tier);
if (cancelled) return;
if (peekHit || readCachedSrc()) return;
if (readCachedSrc()) return;
if (reachable && !deferEnsureUntilVisible) {
const result = await coverEnsureQueued(storageKey, ref, tier, ensurePriority);
@@ -103,7 +107,6 @@ export function useCoverArt(
return () => {
cancelled = true;
unsubDisk();
coverEnsureRelease(storageKey);
};
}, [
ref,
@@ -116,6 +119,11 @@ export function useCoverArt(
readCachedSrc,
]);
useEffect(() => {
if (!storageKey) return;
return () => coverEnsureRelease(storageKey);
}, [storageKey]);
const src = cachedSrc;
const provisional = Boolean(ref && storageKey && !src);
+6 -5
View File
@@ -110,13 +110,14 @@ export async function ensureAlbumCoverMisses(
const tier = resolveCoverDisplayTier(displayCssPx, { surface });
const slice = albums.slice(0, limit);
const needEnsure: Array<{ entityId: string; coverArt: string; ref: CoverArtRef }> = [];
const needEnsure: Array<{ ref: CoverArtRef }> = [];
for (const album of slice) {
const entityId = album.id ?? album.coverArt;
if (!entityId || !album.coverArt) continue;
const ref = albumCoverRef(entityId, album.coverArt);
if (!entityId) continue;
const coverArt = album.coverArt ?? entityId;
const ref = albumCoverRef(entityId, coverArt);
if (!getDiskSrcForGrid(ref, tier)) {
needEnsure.push({ entityId, coverArt: album.coverArt, ref });
needEnsure.push({ ref });
}
}
if (needEnsure.length === 0) return;
@@ -127,7 +128,7 @@ export async function ensureAlbumCoverMisses(
await Promise.all(
chunk.map(async ({ ref }) => {
const key = coverStorageKeyFromRef(ref, tier);
const result = await coverEnsureQueued(key, ref, tier, 'high');
const result = await coverEnsureQueued(key, ref, tier, 'middle');
if (result.hit && result.path) {
rememberGridDiskSrc(ref, tier, result.path);
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import type { SubsonicAlbum } from '../api/subsonicTypes';
// Keep pagination termination rules aligned with the hook implementation.
function resolveHasMoreAfterPage(
page: { albums: SubsonicAlbum[]; hasMore: boolean },
append: boolean,
prevCount: number,
mergedCount: number,
): boolean {
if (page.albums.length === 0) return false;
if (append && mergedCount === prevCount) return false;
return page.hasMore;
}
describe('resolveHasMoreAfterPage', () => {
it('stops when the server returns an empty page', () => {
expect(resolveHasMoreAfterPage({ albums: [], hasMore: true }, true, 30, 30)).toBe(false);
});
it('stops when dedupe adds no new albums', () => {
expect(resolveHasMoreAfterPage({ albums: [{ id: 'a1' } as SubsonicAlbum], hasMore: true }, true, 1, 1)).toBe(false);
});
it('continues while the page grows and the server reports more', () => {
expect(resolveHasMoreAfterPage({ albums: [{ id: 'a2' } as SubsonicAlbum], hasMore: true }, true, 1, 2)).toBe(true);
});
});
+282 -22
View File
@@ -1,5 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import {
coverTrafficBeginGridPagination,
coverTrafficEndGridPagination,
coverTrafficGridPaginationDepth,
} from '../cover/coverTraffic';
import { coverEnsureQueueBacklog, coverEnsureResumePump, coverEnsureSubscribeBacklogDrain } from '../cover/ensureQueue';
import { dedupeById } from '../utils/dedupeById';
import { albumBrowseCompScanComplete } from '../utils/library/albumCompilation';
import type { AlbumCompFilter } from '../utils/library/albumCompilation';
@@ -8,6 +20,7 @@ import {
albumBrowseHasServerFilters,
fetchAlbumBrowseGenreOptions,
fetchAlbumBrowsePage,
fetchLocalAlbumCatalogChunk,
filterAlbumsByCompilation,
filterAlbumsByStarred,
type AlbumBrowseQuery,
@@ -17,9 +30,18 @@ import {
ALBUM_YEAR_FILTER_DEBOUNCE_MS,
resolveAlbumYearBounds,
} from '../utils/library/albumYearFilter';
import { useClientSliceInfiniteScroll } from './useClientSliceInfiniteScroll';
import { useDebouncedValue } from './useDebouncedValue';
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
const PAGE_SIZE = 30;
const CLIENT_SLICE_PAGE_SIZE = 60;
/** Local-index catalog buffer grows by this many albums per background SQL chunk. */
const CATALOG_CHUNK_SIZE = 200;
/** Wait for visible-row cover ensures to drain before fetching the next SQL page (network mode). */
const LOAD_MORE_COVER_BACKLOG_MAX = 12;
type AlbumBrowseMode = 'slice' | 'page';
export type UseAlbumBrowseDataArgs = {
serverId: string;
@@ -33,8 +55,23 @@ export type UseAlbumBrowseDataArgs = {
starredOnly: boolean;
compFilter: AlbumCompFilter;
starredOverrides: Record<string, boolean>;
/** IntersectionObserver scroll root (Albums in-page viewport). */
getScrollRoot?: () => HTMLElement | null;
/** Bumps when the scroll root mounts so the sentinel observer can rebind. */
scrollRootEl?: HTMLElement | null;
};
function resolveHasMoreAfterPage(
page: { albums: SubsonicAlbum[]; hasMore: boolean },
append: boolean,
prevCount: number,
mergedCount: number,
): boolean {
if (page.albums.length === 0) return false;
if (append && mergedCount === prevCount) return false;
return page.hasMore;
}
export function useAlbumBrowseData({
serverId,
indexEnabled,
@@ -47,11 +84,17 @@ export function useAlbumBrowseData({
starredOnly,
compFilter,
starredOverrides,
getScrollRoot,
scrollRootEl,
}: UseAlbumBrowseDataArgs) {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
const [catalogHasMore, setCatalogHasMore] = useState(false);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [browseMode, setBrowseMode] = useState<AlbumBrowseMode>('page');
const [genreCatalogOptions, setGenreCatalogOptions] = useState<GenreFilterOption[] | null>(null);
const yearFields = useMemo(() => ({ from: yearFrom, to: yearTo }), [yearFrom, yearTo]);
@@ -91,6 +134,33 @@ export function useAlbumBrowseData({
return out;
}, [albums, compFilter, compFilterActive, starredOnly, starredOverrides]);
const {
visibleCount,
loadingMore: sliceLoadingMore,
loadMore: sliceLoadMore,
} = useClientSliceInfiniteScroll({
pageSize: CLIENT_SLICE_PAGE_SIZE,
resetDeps: [
browseMode,
sort,
selectedGenres,
yearFilterActive,
yearFilterBounds,
losslessOnly,
starredOnly,
compFilter,
musicLibraryFilterVersion,
serverId,
],
getScrollRoot,
scrollRootEl,
});
const displayAlbums = useMemo(() => {
if (browseMode !== 'slice') return visibleAlbums;
return visibleAlbums.slice(0, visibleCount);
}, [browseMode, visibleAlbums, visibleCount]);
const genreFiltered = albumBrowseHasGenreFilter(browseQuery);
const serverFilterActive = albumBrowseHasServerFilters(browseQuery);
const narrowGenreList = yearFilterActive || losslessOnly || starredOnly || compFilterActive;
@@ -104,7 +174,76 @@ export function useAlbumBrowseData({
const pendingClientFilterMatch =
compFilterClientOnly && visibleAlbums.length === 0 && hasMore && !genreFiltered && !compScanExhausted;
const gridHasMore = browseMode === 'slice'
? visibleCount < visibleAlbums.length || catalogHasMore
: hasMore && !genreFiltered;
const gridLoadingMore = browseMode === 'slice'
? sliceLoadingMore || catalogLoadingMore
: loadingMore;
const loadGenerationRef = useRef(0);
const pageRef = useRef(0);
const catalogOffsetRef = useRef(0);
const catalogLoadingRef = useRef(false);
const loadingRef = useRef(false);
const loadPendingRef = useRef(false);
const loadMoreRef = useRef<() => void>(() => {});
const sentinelIntersectingRef = useRef(false);
const browseModeRef = useRef(browseMode);
browseModeRef.current = browseMode;
useEffect(() => {
while (coverTrafficGridPaginationDepth() > 0) {
coverTrafficEndGridPagination();
}
coverEnsureResumePump();
}, []);
useEffect(() => {
return coverEnsureSubscribeBacklogDrain(() => {
if (browseModeRef.current !== 'page') return;
if (!sentinelIntersectingRef.current) return;
if (loadingRef.current || loadPendingRef.current) return;
if (coverEnsureQueueBacklog() > LOAD_MORE_COVER_BACKLOG_MAX) return;
loadMoreRef.current();
});
}, []);
useEffect(() => {
pageRef.current = page;
}, [page]);
const loadCatalogChunk = useCallback(async (
query: AlbumBrowseQuery,
offset: number,
append: boolean,
) => {
if (catalogLoadingRef.current) return;
const generation = loadGenerationRef.current;
catalogLoadingRef.current = true;
setCatalogLoadingMore(true);
try {
const chunk = await fetchLocalAlbumCatalogChunk(serverId, query, offset, CATALOG_CHUNK_SIZE);
if (generation !== loadGenerationRef.current || chunk == null) return;
if (append) {
setAlbums(prev => {
const merged = dedupeById([...prev, ...chunk.albums]);
catalogOffsetRef.current = merged.length;
return merged;
});
} else {
setAlbums(chunk.albums);
catalogOffsetRef.current = chunk.albums.length;
}
setCatalogHasMore(chunk.hasMore);
} finally {
catalogLoadingRef.current = false;
if (generation === loadGenerationRef.current) {
setCatalogLoadingMore(false);
}
}
}, [serverId]);
const loadBrowse = useCallback(async (
query: AlbumBrowseQuery,
@@ -112,15 +251,26 @@ export function useAlbumBrowseData({
append = false,
) => {
const generation = ++loadGenerationRef.current;
setLoading(true);
const applyPage = (page: { albums: SubsonicAlbum[]; hasMore: boolean }) => {
loadingRef.current = true;
loadPendingRef.current = true;
coverTrafficBeginGridPagination();
if (append) setLoadingMore(true);
else setLoading(true);
const applyPage = (pageResult: { albums: SubsonicAlbum[]; hasMore: boolean }) => {
if (generation !== loadGenerationRef.current) return;
if (append) setAlbums(prev => dedupeById([...prev, ...page.albums]));
else setAlbums(page.albums);
setHasMore(page.hasMore);
if (append) {
setAlbums(prev => {
const merged = dedupeById([...prev, ...pageResult.albums]);
setHasMore(resolveHasMoreAfterPage(pageResult, true, prev.length, merged.length));
return merged;
});
} else {
setAlbums(pageResult.albums);
setHasMore(resolveHasMoreAfterPage(pageResult, false, 0, pageResult.albums.length));
}
};
try {
const page = await fetchAlbumBrowsePage(
const pageResult = await fetchAlbumBrowsePage(
serverId,
indexEnabled,
query,
@@ -130,20 +280,72 @@ export function useAlbumBrowseData({
onPartial: partial => {
if (generation !== loadGenerationRef.current) return;
applyPage(partial);
setLoading(false);
loadingRef.current = false;
if (append) setLoadingMore(false);
else setLoading(false);
},
},
);
applyPage(page);
applyPage(pageResult);
} finally {
if (generation === loadGenerationRef.current) setLoading(false);
coverTrafficEndGridPagination();
coverEnsureResumePump();
if (generation === loadGenerationRef.current) {
loadingRef.current = false;
loadPendingRef.current = false;
if (append) setLoadingMore(false);
else setLoading(false);
}
}
}, [indexEnabled, serverId]);
useEffect(() => {
let cancelled = false;
pageRef.current = 0;
catalogOffsetRef.current = 0;
loadPendingRef.current = false;
catalogLoadingRef.current = false;
setPage(0);
loadBrowse(browseQuery, 0, false);
}, [browseQuery, loadBrowse, musicLibraryFilterVersion]);
setAlbums([]);
setHasMore(true);
setCatalogHasMore(false);
setCatalogLoadingMore(false);
setLoading(true);
void (async () => {
if (indexEnabled && serverId) {
const generation = ++loadGenerationRef.current;
coverTrafficBeginGridPagination();
try {
const first = await fetchLocalAlbumCatalogChunk(
serverId,
browseQuery,
0,
CATALOG_CHUNK_SIZE,
);
if (cancelled || generation !== loadGenerationRef.current) return;
if (first != null) {
setBrowseMode('slice');
setAlbums(first.albums);
catalogOffsetRef.current = first.albums.length;
setCatalogHasMore(first.hasMore);
setLoading(false);
return;
}
} finally {
coverTrafficEndGridPagination();
coverEnsureResumePump();
}
}
if (cancelled) return;
setBrowseMode('page');
await loadBrowse(browseQuery, 0, false);
})();
return () => {
cancelled = true;
};
}, [browseQuery, indexEnabled, serverId, loadBrowse, musicLibraryFilterVersion]);
useEffect(() => {
if (!narrowGenreList) {
@@ -165,19 +367,19 @@ export function useAlbumBrowseData({
musicLibraryFilterVersion,
]);
const loadMore = useCallback(() => {
if (loading || !hasMore || genreFiltered) return;
const loadMorePage = useCallback(() => {
if (loadingRef.current || loadPendingRef.current || !hasMore || genreFiltered) return;
if (coverEnsureQueueBacklog() > LOAD_MORE_COVER_BACKLOG_MAX) return;
if (compFilterClientOnly && visibleAlbums.length === 0
&& albumBrowseCompScanComplete(albums, compFilter, hasMore)) {
return;
}
const next = page + 1;
const next = pageRef.current + 1;
pageRef.current = next;
setPage(next);
loadBrowse(browseQuery, next * PAGE_SIZE, true);
void loadBrowse(browseQuery, next * PAGE_SIZE, true);
}, [
loading,
hasMore,
page,
browseQuery,
loadBrowse,
genreFiltered,
@@ -187,15 +389,72 @@ export function useAlbumBrowseData({
compFilter,
]);
const loadMoreGrid = useCallback(() => {
if (visibleCount < visibleAlbums.length) {
sliceLoadMore();
return;
}
if (catalogHasMore && !catalogLoadingRef.current) {
void loadCatalogChunk(browseQuery, catalogOffsetRef.current, true);
}
}, [
visibleCount,
visibleAlbums.length,
catalogHasMore,
sliceLoadMore,
loadCatalogChunk,
browseQuery,
]);
const loadMore = useCallback(() => {
if (browseMode === 'slice') {
loadMoreGrid();
return;
}
loadMorePage();
}, [browseMode, loadMoreGrid, loadMorePage]);
loadMoreRef.current = loadMore;
useEffect(() => {
if (!pendingClientFilterMatch || loading) return;
loadMore();
}, [pendingClientFilterMatch, loading, loadMore]);
if (browseMode !== 'page') return;
if (!pendingClientFilterMatch || loadingRef.current || loadPendingRef.current) return;
loadMorePage();
}, [browseMode, pendingClientFilterMatch, loading, loadMorePage]);
useEffect(() => {
if (browseMode !== 'slice') return;
if (!sentinelIntersectingRef.current) return;
if (visibleCount >= visibleAlbums.length - CLIENT_SLICE_PAGE_SIZE
&& catalogHasMore
&& !catalogLoadingRef.current) {
void loadCatalogChunk(browseQuery, catalogOffsetRef.current, true);
}
}, [
browseMode,
visibleCount,
visibleAlbums.length,
catalogHasMore,
loadCatalogChunk,
browseQuery,
]);
const bindLoadMoreSentinel = useInpageScrollSentinel({
active: gridHasMore,
getScrollRoot,
scrollRootEl,
onIntersect: () => loadMoreRef.current(),
drainSignal: gridLoadingMore,
intersectingRef: sentinelIntersectingRef,
});
return {
albums,
loading,
hasMore,
loadingMore: gridLoadingMore,
hasMore: gridHasMore,
displayAlbums,
browseMode,
PAGE_SIZE,
browseQuery,
browseQueryWithoutGenre,
@@ -211,5 +470,6 @@ export function useAlbumBrowseData({
compScanExhausted,
pendingClientFilterMatch,
loadMore,
bindLoadMoreSentinel,
};
}
+130
View File
@@ -0,0 +1,130 @@
import { getArtists } from '../api/subsonicArtists';
import type { SubsonicArtist } from '../api/subsonicTypes';
import { useCallback, useEffect, useRef, useState } from 'react';
import { dedupeById } from '../utils/dedupeById';
import {
fetchLocalArtistCatalogChunk,
fetchNetworkStarredArtists,
} from '../utils/library/browseTextSearch';
/** Local-index artist catalog buffer grows by this many rows per background SQL chunk. */
export const ARTIST_CATALOG_CHUNK_SIZE = 200;
export type ArtistsBrowseMode = 'slice' | 'network';
export type UseArtistsBrowseCatalogArgs = {
serverId: string | null | undefined;
indexEnabled: boolean;
starredOnly: boolean;
musicLibraryFilterVersion: number;
};
export function useArtistsBrowseCatalog({
serverId,
indexEnabled,
starredOnly,
musicLibraryFilterVersion,
}: UseArtistsBrowseCatalogArgs) {
const [catalogArtists, setCatalogArtists] = useState<SubsonicArtist[]>([]);
const [loading, setLoading] = useState(true);
const [catalogHasMore, setCatalogHasMore] = useState(false);
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
const [browseMode, setBrowseMode] = useState<ArtistsBrowseMode>('network');
const loadGenerationRef = useRef(0);
const catalogOffsetRef = useRef(0);
const catalogLoadingRef = useRef(false);
const loadCatalogChunk = useCallback(async (append: boolean) => {
if (!serverId || catalogLoadingRef.current) return;
const generation = loadGenerationRef.current;
catalogLoadingRef.current = true;
setCatalogLoadingMore(true);
try {
const chunk = await fetchLocalArtistCatalogChunk(
serverId,
catalogOffsetRef.current,
ARTIST_CATALOG_CHUNK_SIZE,
);
if (generation !== loadGenerationRef.current || chunk == null) return;
if (append) {
setCatalogArtists(prev => {
const merged = dedupeById([...prev, ...chunk.artists]);
catalogOffsetRef.current = merged.length;
return merged;
});
} else {
setCatalogArtists(chunk.artists);
catalogOffsetRef.current = chunk.artists.length;
}
setCatalogHasMore(chunk.hasMore);
setBrowseMode('slice');
} finally {
catalogLoadingRef.current = false;
if (generation === loadGenerationRef.current) {
setCatalogLoadingMore(false);
}
}
}, [serverId]);
useEffect(() => {
let cancelled = false;
const generation = ++loadGenerationRef.current;
catalogOffsetRef.current = 0;
catalogLoadingRef.current = false;
setCatalogArtists([]);
setCatalogHasMore(false);
setCatalogLoadingMore(false);
setBrowseMode('network');
setLoading(true);
void (async () => {
try {
if (starredOnly) {
if (!cancelled && generation === loadGenerationRef.current) {
setCatalogArtists(await fetchNetworkStarredArtists());
}
return;
}
if (indexEnabled && serverId) {
const first = await fetchLocalArtistCatalogChunk(
serverId,
0,
ARTIST_CATALOG_CHUNK_SIZE,
);
if (cancelled || generation !== loadGenerationRef.current) return;
if (first != null) {
setBrowseMode('slice');
setCatalogArtists(first.artists);
catalogOffsetRef.current = first.artists.length;
setCatalogHasMore(first.hasMore);
return;
}
}
if (!cancelled && generation === loadGenerationRef.current) {
setCatalogArtists(await getArtists());
}
} catch {
/* ignore */
} finally {
if (!cancelled && generation === loadGenerationRef.current) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [musicLibraryFilterVersion, indexEnabled, serverId, starredOnly]);
return {
catalogArtists,
loading,
catalogHasMore,
catalogLoadingMore,
browseMode,
loadCatalogChunk,
catalogLoadingRef,
};
}
+7 -89
View File
@@ -1,91 +1,9 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
interface UseArtistsInfiniteScrollArgs {
pageSize: number;
resetDeps: ReadonlyArray<unknown>;
/** IntersectionObserver root (e.g. Artists in-page overlay viewport). */
getScrollRoot?: () => HTMLElement | null;
}
interface UseArtistsInfiniteScrollResult {
visibleCount: number;
loadingMore: boolean;
/** Callback ref — attaches IntersectionObserver when the sentinel mounts (fixes first paint behind `loading`). */
observerTarget: React.RefCallback<HTMLDivElement | null>;
loadMore: () => void;
}
/**
* Page through the artists list with a sentinel-driven
* IntersectionObserver. `pageSize` is dynamic because artist-images
* mode wants smaller batches to keep disk I/O sane on big libraries
* (5000+ artists).
*
* `resetDeps` is the list of values that should snap `visibleCount`
* back to one page filter text, letter pick, starred-only,
* view-mode, page-size itself.
*
* The observer doesn't take a `hasMore` flag the page only renders
* the sentinel `<div ref={observerTarget}>` while there is more data,
* so the observer naturally disconnects when the last page is reached
* (callback ref runs with `node === null` as the sentinel unmounts).
* @deprecated Import {@link useClientSliceInfiniteScroll} instead.
* Kept as a thin alias for existing call sites.
*/
export function useArtistsInfiniteScroll({
pageSize,
resetDeps,
getScrollRoot,
}: UseArtistsInfiniteScrollArgs): UseArtistsInfiniteScrollResult {
const [visibleCount, setVisibleCount] = useState(pageSize);
const [loadingMore, setLoadingMore] = useState(false);
const loadMoreRef = useRef<() => void>(() => {});
const observerInst = useRef<IntersectionObserver | null>(null);
/** Blocks overlapping sentinel callbacks until `visibleCount` commits. */
const loadPendingRef = useRef(false);
const loadMore = useCallback(() => {
if (loadPendingRef.current) return;
loadPendingRef.current = true;
setLoadingMore(true);
setVisibleCount(prev => prev + pageSize);
}, [pageSize]);
loadMoreRef.current = loadMore;
useEffect(() => {
loadPendingRef.current = false;
setLoadingMore(false);
}, [visibleCount]);
useEffect(() => {
setVisibleCount(pageSize);
// resetDeps is intentionally spread into the dep array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, ...resetDeps]);
const observerTarget = useCallback((node: HTMLDivElement | null) => {
observerInst.current?.disconnect();
observerInst.current = null;
if (!node) return;
const rootEl = getScrollRoot?.() ?? document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
const observer = new IntersectionObserver(
entries => {
if (entries[0]?.isIntersecting) loadMoreRef.current();
},
{
root: rootEl instanceof HTMLElement ? rootEl : null,
rootMargin: '200px',
},
);
observer.observe(node);
observerInst.current = observer;
}, [getScrollRoot]);
useEffect(() => () => {
observerInst.current?.disconnect();
observerInst.current = null;
}, []);
return { visibleCount, loadingMore, observerTarget, loadMore };
}
export {
useClientSliceInfiniteScroll as useArtistsInfiniteScroll,
type UseClientSliceInfiniteScrollArgs as UseArtistsInfiniteScrollArgs,
type UseClientSliceInfiniteScrollResult as UseArtistsInfiniteScrollResult,
} from './useClientSliceInfiniteScroll';
@@ -0,0 +1,47 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useAsyncInpagePagination } from './useAsyncInpagePagination';
describe('useAsyncInpagePagination', () => {
it('blocks concurrent page requests', async () => {
let resolveLoad: (() => void) | undefined;
const onPage = vi.fn(
() =>
new Promise<void>(resolve => {
resolveLoad = resolve;
}),
);
const { result } = renderHook(() => useAsyncInpagePagination(30));
act(() => {
expect(result.current.requestNextPage(onPage)).toBe(true);
expect(result.current.requestNextPage(onPage)).toBe(false);
});
expect(onPage).toHaveBeenCalledTimes(1);
expect(onPage).toHaveBeenCalledWith(30);
await act(async () => {
resolveLoad?.();
});
expect(result.current.loading).toBe(false);
expect(result.current.page).toBe(1);
});
it('resetPage clears guards and page index', () => {
const { result } = renderHook(() => useAsyncInpagePagination(30));
act(() => {
result.current.pageRef.current = 3;
result.current.loadPendingRef.current = true;
result.current.setPage(3);
result.current.resetPage();
});
expect(result.current.page).toBe(0);
expect(result.current.pageRef.current).toBe(0);
expect(result.current.loadPendingRef.current).toBe(false);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef, useState } from 'react';
type UseAsyncInpagePaginationOptions = {
/** Initial `loading` state (e.g. true when the first fetch runs on mount). */
initialLoading?: boolean;
};
/** Sync guards for offset-based server pagination inside in-page scroll areas. */
export function useAsyncInpagePagination(
pageSize: number,
options?: UseAsyncInpagePaginationOptions,
) {
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(options?.initialLoading ?? false);
const pageRef = useRef(0);
const loadingRef = useRef(false);
const loadPendingRef = useRef(false);
useEffect(() => {
pageRef.current = page;
}, [page]);
const isBlocked = useCallback(
() => loadingRef.current || loadPendingRef.current,
[],
);
const resetPage = useCallback(() => {
pageRef.current = 0;
loadPendingRef.current = false;
setPage(0);
}, []);
const runLoad = useCallback(async (fn: () => Promise<void>) => {
loadingRef.current = true;
loadPendingRef.current = true;
setLoading(true);
try {
await fn();
} finally {
loadingRef.current = false;
loadPendingRef.current = false;
setLoading(false);
}
}, []);
const requestNextPage = useCallback(
(onPage: (offset: number) => void | Promise<void>) => {
if (isBlocked()) return false;
loadPendingRef.current = true;
const next = pageRef.current + 1;
pageRef.current = next;
setPage(next);
void onPage(next * pageSize);
return true;
},
[isBlocked, pageSize],
);
return {
page,
setPage,
loading,
setLoading,
pageRef,
loadingRef,
loadPendingRef,
isBlocked,
resetPage,
runLoad,
requestNextPage,
};
}
+47
View File
@@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { useAsyncInpagePagination } from './useAsyncInpagePagination';
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
export type UseAsyncInpageScrollArgs = {
pageSize: number;
active: boolean;
hasMore: boolean;
getScrollRoot?: () => HTMLElement | null;
scrollRootEl?: HTMLElement | null;
rootMargin?: string;
onLoadOffset: (offset: number, append: boolean) => void | Promise<void>;
};
/**
* Offset-based server pagination with stable in-page sentinel wiring.
*/
export function useAsyncInpageScroll({
pageSize,
active,
hasMore,
getScrollRoot,
scrollRootEl,
rootMargin,
onLoadOffset,
}: UseAsyncInpageScrollArgs) {
const paging = useAsyncInpagePagination(pageSize);
const loadMore = useCallback(() => {
if (!active || !hasMore || paging.isBlocked()) return;
paging.requestNextPage(offset => onLoadOffset(offset, true));
}, [active, hasMore, onLoadOffset, paging]);
const bindSentinel = useInpageScrollSentinel({
active: active && hasMore,
getScrollRoot,
scrollRootEl,
rootMargin,
onIntersect: loadMore,
});
return {
...paging,
bindSentinel,
loadMore,
};
}
@@ -0,0 +1,31 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useClientSliceInfiniteScroll } from './useClientSliceInfiniteScroll';
vi.mock('./useInpageScrollSentinel', () => ({
useInpageScrollSentinel: () => vi.fn(),
}));
describe('useClientSliceInfiniteScroll', () => {
it('grows visibleCount by pageSize and clears loadingMore', () => {
const { result, rerender } = renderHook(
({ filter }: { filter: string }) =>
useClientSliceInfiniteScroll({
pageSize: 50,
resetDeps: [filter],
}),
{ initialProps: { filter: '' } },
);
expect(result.current.visibleCount).toBe(50);
act(() => {
result.current.loadMore();
});
expect(result.current.visibleCount).toBe(100);
rerender({ filter: 'a' });
expect(result.current.visibleCount).toBe(50);
expect(result.current.loadingMore).toBe(false);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
export type UseClientSliceInfiniteScrollArgs = {
pageSize: number;
resetDeps: ReadonlyArray<unknown>;
getScrollRoot?: () => HTMLElement | null;
scrollRootEl?: HTMLElement | null;
rootMargin?: string;
};
export type UseClientSliceInfiniteScrollResult = {
visibleCount: number;
loadingMore: boolean;
bindSentinel: ReturnType<typeof useInpageScrollSentinel>;
/** @deprecated Use `bindSentinel`. */
observerTarget: ReturnType<typeof useInpageScrollSentinel>;
loadMore: () => void;
};
/**
* Client-side infinite scroll: grow a visible slice from an in-memory list.
* Used by Artists and Composers browse grids.
*/
export function useClientSliceInfiniteScroll({
pageSize,
resetDeps,
getScrollRoot,
scrollRootEl,
rootMargin = '200px',
}: UseClientSliceInfiniteScrollArgs): UseClientSliceInfiniteScrollResult {
const [visibleCount, setVisibleCount] = useState(pageSize);
const [loadingMore, setLoadingMore] = useState(false);
const loadPendingRef = useRef(false);
const loadMore = useCallback(() => {
if (loadPendingRef.current) return;
loadPendingRef.current = true;
setLoadingMore(true);
setVisibleCount(prev => prev + pageSize);
}, [pageSize]);
useEffect(() => {
loadPendingRef.current = false;
setLoadingMore(false);
}, [visibleCount]);
useEffect(() => {
setVisibleCount(pageSize);
// resetDeps is intentionally spread into the dep array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, ...resetDeps]);
const bindSentinel = useInpageScrollSentinel({
active: true,
getScrollRoot: () =>
getScrollRoot?.() ?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null),
scrollRootEl,
rootMargin,
onIntersect: loadMore,
});
return {
visibleCount,
loadingMore,
bindSentinel,
observerTarget: bindSentinel,
loadMore,
};
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
describe('useInpageScrollSentinel', () => {
it('returns a callback ref function', () => {
const onIntersect = vi.fn();
const { result } = renderHook(() =>
useInpageScrollSentinel({
active: true,
onIntersect,
}),
);
expect(typeof result.current).toBe('function');
});
});
+86
View File
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, type MutableRefObject, type RefCallback } from 'react';
const DEFAULT_ROOT_MARGIN = '400px';
export type UseInpageScrollSentinelArgs = {
/** When false, disconnect and ignore the sentinel. */
active: boolean;
getScrollRoot?: () => HTMLElement | null;
/** Rebind when the in-page scroll viewport mounts (callback-ref body). */
scrollRootEl?: HTMLElement | null;
onIntersect: () => void;
rootMargin?: string;
/** Re-fire `onIntersect` when this changes and the sentinel is still visible. */
drainSignal?: unknown;
/** Updated when the sentinel enters/leaves the scroll root viewport. */
intersectingRef?: MutableRefObject<boolean>;
};
/**
* Stable IntersectionObserver callback ref for in-page infinite scroll.
* Matches {@link useClientSliceInfiniteScroll} avoids reconnect storms when
* `onIntersect` / `loadMore` identities change every render.
*/
export function useInpageScrollSentinel({
active,
getScrollRoot,
scrollRootEl,
onIntersect,
rootMargin = DEFAULT_ROOT_MARGIN,
drainSignal,
intersectingRef,
}: UseInpageScrollSentinelArgs): RefCallback<HTMLDivElement | null> {
const onIntersectRef = useRef(onIntersect);
onIntersectRef.current = onIntersect;
const setIntersecting = useCallback((hit: boolean) => {
if (intersectingRef) intersectingRef.current = hit;
}, [intersectingRef]);
const observerInst = useRef<IntersectionObserver | null>(null);
const bindSentinel = useCallback((node: HTMLDivElement | null) => {
observerInst.current?.disconnect();
observerInst.current = null;
if (!node) {
setIntersecting(false);
return;
}
if (!active) {
setIntersecting(false);
return;
}
const rootEl = getScrollRoot?.() ?? null;
const observer = new IntersectionObserver(
entries => {
const hit = Boolean(entries[0]?.isIntersecting);
setIntersecting(hit);
if (hit) onIntersectRef.current();
},
{
root: rootEl instanceof HTMLElement ? rootEl : null,
rootMargin,
},
);
observer.observe(node);
observerInst.current = observer;
}, [active, getScrollRoot, scrollRootEl, rootMargin, setIntersecting]);
useEffect(() => {
const observer = observerInst.current;
if (!observer || !active) return;
for (const entry of observer.takeRecords()) {
const hit = entry.isIntersecting;
setIntersecting(hit);
if (hit) onIntersectRef.current();
}
}, [active, drainSignal, setIntersecting]);
useEffect(() => () => {
observerInst.current?.disconnect();
observerInst.current = null;
}, []);
return bindSentinel;
}
+13
View File
@@ -0,0 +1,13 @@
import { useCallback, useRef, useState } from 'react';
/** In-page overlay scroll viewport ref + state for IntersectionObserver roots. */
export function useInpageScrollViewport() {
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindScrollBody = useCallback((el: HTMLDivElement | null) => {
scrollBodyRef.current = el;
setScrollBodyEl(el);
}, []);
const getScrollRoot = useCallback(() => scrollBodyRef.current, []);
return { scrollBodyRef, scrollBodyEl, bindScrollBody, getScrollRoot };
}
+57 -130
View File
@@ -1,50 +1,50 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { acquirePerfLivePoll, patchPerfLiveAnalysis } from '../utils/perf/perfLiveStore';
import { setPerfProbeTelemetryActive } from '../utils/perf/perfTelemetry';
import {
getAnalysisTracksPerMinute,
useAnalysisPerfLast,
} from '../utils/perf/analysisPerfStore';
import { useAnalysisPerfLast } from '../utils/perf/analysisPerfStore';
import { useAnalysisPerfListener } from './useAnalysisPerfListener';
interface PerfCpu {
app: number;
webkit: number;
supported: boolean;
}
interface PerfDiagRates {
progress: number;
waveform: number;
home: number;
}
interface AnalysisPerfDiag {
tracksPerMinute: number;
lastTotalMs: number | null;
lastFetchMs: number | null;
lastSeedMs: number | null;
lastBpmMs: number | null;
}
import {
getPerfProbeFlags,
subscribePerfProbeFlags,
} from '../utils/perf/perfFlags';
import { hasAnyLiveMetricPollNeed, usePerfLiveOverlayPins } from '../utils/perf/perfOverlayPins';
import { syncPerfLiveThreadGroupsNeed } from '../utils/perf/perfLivePollSettings';
import { useSyncExternalStore } from 'react';
interface Result {
perfProbeOpen: boolean;
setPerfProbeOpen: (open: boolean) => void;
perfCpu: PerfCpu | null;
perfDiagRates: PerfDiagRates | null;
analysisPerf: AnalysisPerfDiag | null;
}
/** Wires up Ctrl+Shift+D to open the perf probe; polls CPU + diag-rate counters
* every 2s while it is open. */
function useNeedAnalysisTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet<string>): boolean {
return useSyncExternalStore(
subscribePerfProbeFlags,
() => {
const flags = getPerfProbeFlags();
return (
perfProbeOpen
|| flags.showAnalysisPerfOverlay
|| livePins.has('analysis:tpm')
|| livePins.has('analysis:last')
);
},
() => perfProbeOpen,
);
}
/** Wires Ctrl+Shift+D probe modal and shared live metric polling. */
export function useSidebarPerfProbe(): Result {
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
const [perfCpu, setPerfCpu] = useState<PerfCpu | null>(null);
const [perfDiagRates, setPerfDiagRates] = useState<PerfDiagRates | null>(null);
const [analysisTpm, setAnalysisTpm] = useState(0);
const livePins = usePerfLiveOverlayPins();
const analysisLast = useAnalysisPerfLast();
const needAnalysis = useNeedAnalysisTelemetry(perfProbeOpen, livePins);
useAnalysisPerfListener(perfProbeOpen);
useAnalysisPerfListener(needAnalysis);
useEffect(() => {
if (perfProbeOpen) return;
syncPerfLiveThreadGroupsNeed(false, livePins);
}, [perfProbeOpen, livePins]);
useEffect(() => {
setPerfProbeTelemetryActive(perfProbeOpen);
@@ -61,89 +61,27 @@ export function useSidebarPerfProbe(): Result {
}, [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]);
const releases: Array<() => void> = [];
if (perfProbeOpen) releases.push(acquirePerfLivePoll('modal'));
if (hasAnyLiveMetricPollNeed()) releases.push(acquirePerfLivePoll('overlay-pins'));
if (releases.length === 0) return;
return () => releases.forEach(release => release());
}, [perfProbeOpen, livePins.size]);
useEffect(() => {
if (!perfProbeOpen) return;
const refresh = () => setAnalysisTpm(getAnalysisTracksPerMinute());
refresh();
const id = window.setInterval(refresh, 2000);
return () => window.clearInterval(id);
}, [perfProbeOpen, analysisLast?.at]);
useEffect(() => {
if (!perfProbeOpen) {
setPerfCpu(null);
setPerfDiagRates(null);
setAnalysisTpm(0);
}
}, [perfProbeOpen]);
patchPerfLiveAnalysis({
lastTotalMs: analysisLast?.totalMs ?? null,
lastFetchMs: analysisLast?.fetchMs ?? null,
lastSeedMs: analysisLast?.seedMs ?? null,
lastBpmMs: analysisLast?.bpmMs ?? null,
});
}, [
analysisLast?.at,
analysisLast?.totalMs,
analysisLast?.fetchMs,
analysisLast?.seedMs,
analysisLast?.bpmMs,
]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -151,10 +89,10 @@ export function useSidebarPerfProbe(): Result {
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
target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.tagName === 'SELECT'
|| target.isContentEditable
)) return;
e.preventDefault();
setPerfProbeOpen(true);
@@ -166,16 +104,5 @@ export function useSidebarPerfProbe(): Result {
return {
perfProbeOpen,
setPerfProbeOpen,
perfCpu,
perfDiagRates,
analysisPerf: perfProbeOpen
? {
tracksPerMinute: analysisTpm,
lastTotalMs: analysisLast?.totalMs ?? null,
lastFetchMs: analysisLast?.fetchMs ?? null,
lastSeedMs: analysisLast?.seedMs ?? null,
lastBpmMs: analysisLast?.bpmMs ?? null,
}
: null,
};
}
+3 -7
View File
@@ -1,5 +1,4 @@
import { useEffect, useMemo } from 'react';
import { GRID_COVER_PRIME_ALL_MAX } from '../cover/layoutSizes';
import {
collectAlbumCoverWarmItems,
ensureAlbumCoverMisses,
@@ -10,7 +9,7 @@ import type { CoverSurfaceKind } from '../cover/types';
const DEFAULT_LIMIT = 120;
/**
* Peek after mount (non-blocking); for small grids (48) queue ensures only for disk misses.
* Peek after mount (non-blocking); ensure disk misses for the warmed viewport slice.
*/
export function useWarmGridCovers(
items: ReadonlyArray<{ coverArt?: string | null }>,
@@ -35,8 +34,6 @@ export function useWarmGridCovers(
return `${displayCssPx}:${slice.map(a => a.coverArt ?? '').join('\u0001')}`;
}, [items, displayCssPx, limit, opts?.warmKey]);
const primeAllMisses = items.length > 0 && items.length <= GRID_COVER_PRIME_ALL_MAX;
useEffect(() => {
if (!enabled || displayCssPx <= 0) return;
@@ -46,13 +43,12 @@ export function useWarmGridCovers(
if (cancelled || batch.length === 0) return;
await warmCoverDiskSrcBatch(batch);
if (cancelled) return;
if (primeAllMisses) {
// Prime disk misses for the warmed viewport slice (not only tiny grids).
await ensureAlbumCoverMisses(items, displayCssPx, { surface, limit });
}
})();
return () => {
cancelled = true;
};
}, [enabled, warmKey, items, displayCssPx, limit, surface, primeAllMisses]);
}, [enabled, warmKey, items, displayCssPx, limit, surface]);
}
+36 -38
View File
@@ -26,6 +26,8 @@ import FilterQuickClear from '../components/FilterQuickClear';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { useRangeSelection } from '../hooks/useRangeSelection';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
import InpageScrollSentinel from '../components/InpageScrollSentinel';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
@@ -70,11 +72,19 @@ export default function Albums() {
setLosslessOnly,
} = useAlbumBrowseFilters(serverId);
const {
scrollBodyEl,
bindScrollBody: bindAlbumsScrollBody,
getScrollRoot,
} = useInpageScrollViewport();
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const {
albums,
loading,
loadingMore,
hasMore,
displayAlbums,
visibleAlbums,
genreFiltered,
serverFilterActive,
@@ -84,7 +94,7 @@ export default function Albums() {
debouncedYearFields,
compFilterActive,
pendingClientFilterMatch,
loadMore,
bindLoadMoreSentinel,
} = useAlbumBrowseData({
serverId,
indexEnabled,
@@ -97,26 +107,20 @@ export default function Albums() {
starredOnly,
compFilter,
starredOverrides,
getScrollRoot,
scrollRootEl: scrollBodyEl,
});
const observerTarget = useRef<HTMLDivElement>(null);
const gridMeasureRef = useRef<HTMLDivElement>(null);
const maxGridCols = useAuthStore(s => clampLibraryGridMaxColumns(s.libraryGridMaxColumns));
const [albumCellDisplayCssPx, setAlbumCellDisplayCssPx] = useState(140);
const [albumGridCols, setAlbumGridCols] = useState(4);
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindAlbumsScrollBody = useCallback((el: HTMLDivElement | null) => {
scrollBodyRef.current = el;
setScrollBodyEl(el);
}, []);
// ── Multi-selection ──────────────────────────────────────────────────────
// selectedIds + toggleSelect come from useRangeSelection (declared after
// `visibleAlbums` so Shift-click range expansion follows the visible order).
// `displayAlbums` — visible grid slice (local index) or loaded SQL pages (network).
const [selectionMode, setSelectionMode] = useState(false);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(visibleAlbums);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums);
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
@@ -128,7 +132,7 @@ export default function Albums() {
resetSelection();
};
const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id));
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id));
const enqueue = usePlayerStore(state => state.enqueue);
const handleEnqueueSelected = async () => {
@@ -208,16 +212,26 @@ export default function Albums() {
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [maxGridCols, visibleAlbums.length]);
}, [maxGridCols, displayAlbums.length]);
const prefetchLimit = Math.max(albumGridCols * 3, albumGridCols);
const prefetchKey = useMemo(
() => displayAlbums.slice(0, prefetchLimit).map(a => a.id).join('\u0001'),
[displayAlbums, prefetchLimit],
);
const prefetchAlbums = useMemo(
() => displayAlbums.slice(0, prefetchLimit),
[displayAlbums, prefetchLimit],
);
useLibraryCoverPrefetch(
[
{
albums: visibleAlbums.slice(0, Math.max(albumGridCols * 3, albumGridCols)),
albums: prefetchAlbums,
priority: 'high',
},
],
[visibleAlbums, albumGridCols],
[prefetchKey, albumGridCols],
);
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
@@ -237,21 +251,6 @@ export default function Albums() {
if (!indexEnabled && losslessOnly) setLosslessOnly(false);
}, [indexEnabled, losslessOnly]);
useEffect(() => {
const node = observerTarget.current;
if (!node) return;
const root = scrollBodyRef.current;
const observer = new IntersectionObserver(
entries => { if (entries[0]?.isIntersecting) loadMore(); },
{
root: root instanceof HTMLElement ? root : null,
rootMargin: '1500px',
},
);
observer.observe(node);
return () => observer.disconnect();
}, [loadMore, scrollBodyEl]);
const sortOptions: { value: SortType; label: string }[] = [
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
@@ -359,7 +358,7 @@ export default function Albums() {
railInset="panel"
measureDeps={[
loading,
visibleAlbums.length,
displayAlbums.length,
genreFiltered,
hasMore,
selectionMode,
@@ -393,20 +392,21 @@ export default function Albums() {
{!perfFlags.disableMainstageGridCards && (
<div ref={gridMeasureRef}>
<VirtualCardGrid
items={visibleAlbums}
items={displayAlbums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={visibleAlbums.length}
layoutSignal={displayAlbums.length}
scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
warmGridCovers={albumGridWarmCovers(
albumCellDisplayCssPx,
Math.min(visibleAlbums.length, Math.max(albumGridCols * 6, 48)),
Math.min(displayAlbums.length, Math.max(albumGridCols * 6, 48)),
)}
renderItem={a => (
<AlbumCard
album={a}
displayCssPx={albumCellDisplayCssPx}
observeScrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
@@ -417,10 +417,8 @@ export default function Albums() {
/>
</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 }} />}
</div>
{hasMore && (
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingMore} />
)}
</>
)}
+123 -60
View File
@@ -1,5 +1,4 @@
import { getArtists } from '../api/subsonicArtists';
import type { SubsonicArtist } from '../api/subsonicTypes';
import { useEffect, useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { LayoutGrid, List, Images, CheckSquare2 } from 'lucide-react';
@@ -23,55 +22,74 @@ import {
ARTIST_LIST_ROW_EST,
} from '../utils/componentHelpers/artistsHelpers';
import { useArtistsFiltering } from '../hooks/useArtistsFiltering';
import { useArtistsBrowseCatalog } from '../hooks/useArtistsBrowseCatalog';
import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
import { useArtistsInfiniteScroll } from '../hooks/useArtistsInfiniteScroll';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { fetchNetworkStarredArtists, runLocalBrowseAllArtists } from '../utils/library/browseTextSearch';
import { useClientSliceInfiniteScroll } from '../hooks/useClientSliceInfiniteScroll';
import { useInpageScrollSentinel } from '../hooks/useInpageScrollSentinel';
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
import { ArtistsGridView } from '../components/artists/ArtistsGridView';
import { ArtistsListView } from '../components/artists/ArtistsListView';
import InpageScrollSentinel from '../components/InpageScrollSentinel';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
export default function Artists() {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const [catalogArtists, setCatalogArtists] = useState<SubsonicArtist[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
const [starredOnly, setStarredOnly] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const artistsScrollBodyRef = useRef<HTMLDivElement | null>(null);
const [artistsScrollBodyEl, setArtistsScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindArtistsScrollBody = useCallback((el: HTMLDivElement | null) => {
artistsScrollBodyRef.current = el;
setArtistsScrollBodyEl(el);
}, []);
const getArtistsScrollRoot = useCallback(() => artistsScrollBodyRef.current, []);
const {
scrollBodyEl: artistsScrollBodyEl,
bindScrollBody: bindArtistsScrollBody,
getScrollRoot: getArtistsScrollRoot,
} = useInpageScrollViewport();
const showArtistImages = useAuthStore(s => s.showArtistImages);
const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O
const {
visibleCount,
loadingMore,
observerTarget,
} = useArtistsInfiniteScroll({
pageSize: PAGE_SIZE,
resetDeps: [filter, letterFilter, starredOnly, viewMode],
getScrollRoot: getArtistsScrollRoot,
});
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const serverId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const {
catalogArtists,
loading: catalogLoading,
catalogHasMore,
catalogLoadingMore,
browseMode,
loadCatalogChunk,
catalogLoadingRef,
} = useArtistsBrowseCatalog({
serverId,
indexEnabled,
starredOnly,
musicLibraryFilterVersion,
});
const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch(
filter,
indexEnabled,
serverId,
);
const artists = textSearchArtists ?? catalogArtists;
const loading = catalogLoading || textSearchLoading;
const textSearchActive = textSearchArtists != null;
const {
visibleCount,
loadingMore: sliceLoadingMore,
loadMore: sliceLoadMore,
} = useClientSliceInfiniteScroll({
pageSize: PAGE_SIZE,
resetDeps: [filter, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId],
getScrollRoot: getArtistsScrollRoot,
scrollRootEl: artistsScrollBodyEl,
});
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
@@ -92,38 +110,79 @@ export default function Artists() {
const selectedArtists = artists.filter(a => selectedIds.has(a.id));
useEffect(() => {
let cancelled = false;
setLoading(true);
void (async () => {
try {
if (starredOnly) {
if (!cancelled) setCatalogArtists(await fetchNetworkStarredArtists());
return;
}
if (indexEnabled && serverId) {
const local = await runLocalBrowseAllArtists(serverId);
if (!cancelled && local != null) {
setCatalogArtists(local);
return;
}
}
if (!cancelled) setCatalogArtists(await getArtists());
} catch {
/* ignore */
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [musicLibraryFilterVersion, indexEnabled, serverId, starredOnly]);
const {
filtered, visible, hasMore, groups, letters, artistListFlatRows,
} = useArtistsFiltering({ artists, filter: effectiveFilter, letterFilter, starredOnly, visibleCount, viewMode });
const pendingLetterMatch =
browseMode === 'slice'
&& !textSearchActive
&& !starredOnly
&& letterFilter !== ALL_SENTINEL
&& filtered.length === 0
&& catalogHasMore;
const gridHasMore =
hasMore
|| (browseMode === 'slice' && !textSearchActive && !starredOnly && catalogHasMore);
const gridLoadingMore = sliceLoadingMore || catalogLoadingMore;
const loadMoreRef = useRef<() => void>(() => {});
const sentinelIntersectingRef = useRef(false);
const loadMoreGrid = useCallback(() => {
if (hasMore) {
sliceLoadMore();
return;
}
if (browseMode === 'slice' && !textSearchActive && !starredOnly && catalogHasMore && !catalogLoadingRef.current) {
void loadCatalogChunk(true);
}
}, [
hasMore,
sliceLoadMore,
browseMode,
textSearchActive,
starredOnly,
catalogHasMore,
loadCatalogChunk,
catalogLoadingRef,
]);
loadMoreRef.current = loadMoreGrid;
useEffect(() => {
if (!pendingLetterMatch || catalogLoadingRef.current) return;
void loadCatalogChunk(true);
}, [pendingLetterMatch, loadCatalogChunk, catalogLoadingRef]);
useEffect(() => {
if (browseMode !== 'slice' || textSearchActive || starredOnly) return;
if (!sentinelIntersectingRef.current) return;
if (visibleCount < filtered.length - PAGE_SIZE) return;
if (!catalogHasMore || catalogLoadingRef.current) return;
void loadCatalogChunk(true);
}, [
browseMode,
textSearchActive,
starredOnly,
visibleCount,
filtered.length,
catalogHasMore,
loadCatalogChunk,
catalogLoadingRef,
PAGE_SIZE,
]);
const bindLoadMoreSentinel = useInpageScrollSentinel({
active: gridHasMore,
getScrollRoot: getArtistsScrollRoot,
scrollRootEl: artistsScrollBodyEl,
onIntersect: () => loadMoreRef.current(),
drainSignal: gridLoadingMore,
intersectingRef: sentinelIntersectingRef,
});
const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [
filter,
letterFilter,
@@ -139,9 +198,9 @@ export default function Artists() {
const getInpageScrollElement = useCallback(
() =>
artistsScrollBodyRef.current
getArtistsScrollRoot()
?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null),
[],
[getArtistsScrollRoot],
);
const artistGridMeasureRef = useRef<HTMLDivElement>(null);
@@ -316,13 +375,19 @@ export default function Artists() {
visible.length,
artistListFlatRows.length,
filtered.length,
hasMore,
gridHasMore,
selectionMode,
]}
>
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
{!loading && viewMode === 'grid' && (
{!loading && pendingLetterMatch && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
)}
{!loading && !pendingLetterMatch && viewMode === 'grid' && (
<ArtistsGridView
visible={visible}
gridCols={artistGridCols}
@@ -343,7 +408,7 @@ export default function Artists() {
/>
)}
{!loading && viewMode === 'list' && (
{!loading && !pendingLetterMatch && viewMode === 'list' && (
<ArtistsListView
virtualized={!perfFlags.disableMainstageVirtualLists}
groups={groups}
@@ -363,13 +428,11 @@ export default function Artists() {
/>
)}
{!loading && hasMore && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
{!loading && gridHasMore && (
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={gridLoadingMore} />
)}
{!loading && filtered.length === 0 && (
{!loading && !pendingLetterMatch && filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
{t('artists.notFound')}
</div>
+21 -38
View File
@@ -17,6 +17,9 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
import { useClientSliceInfiniteScroll } from '../hooks/useClientSliceInfiniteScroll';
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
import InpageScrollSentinel from '../components/InpageScrollSentinel';
const ALL_SENTINEL = 'ALL';
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
@@ -80,15 +83,12 @@ export default function Composers() {
// Compact tiles + initial-letter only → 200 per page is comfortable.
const PAGE_SIZE = 200;
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [loadingMore, setLoadingMore] = useState(false);
const observerTarget = useRef<HTMLDivElement>(null);
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindComposersScrollBody = useCallback((el: HTMLDivElement | null) => {
scrollBodyRef.current = el;
setScrollBodyEl(el);
}, []);
const {
scrollBodyRef,
scrollBodyEl,
bindScrollBody: bindComposersScrollBody,
getScrollRoot,
} = useInpageScrollViewport();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
@@ -102,6 +102,17 @@ export default function Composers() {
);
const composerSource = textSearchArtists ?? composers;
const {
visibleCount,
loadingMore,
bindSentinel,
} = useClientSliceInfiniteScroll({
pageSize: PAGE_SIZE,
resetDeps: [letterFilter, effectiveFilter, starredOnly, viewMode, composerSource],
getScrollRoot,
scrollRootEl: scrollBodyEl,
});
useEffect(() => {
let cancelled = false;
setLoading(true);
@@ -130,17 +141,6 @@ export default function Composers() {
return () => { cancelled = true; };
}, [musicLibraryFilterVersion, reloadTick]);
const loadMore = useCallback(() => {
if (loadingMore) return;
setLoadingMore(true);
setVisibleCount(prev => prev + PAGE_SIZE);
setTimeout(() => setLoadingMore(false), 100);
}, [loadingMore, PAGE_SIZE]);
useEffect(() => {
setVisibleCount(PAGE_SIZE);
}, [filter, letterFilter, starredOnly, viewMode, PAGE_SIZE]);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const filtered = useMemo(() => {
let out = composerSource;
@@ -165,21 +165,6 @@ export default function Composers() {
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
const hasMore = visibleCount < filtered.length;
useEffect(() => {
const node = observerTarget.current;
if (!node) return;
const root = scrollBodyRef.current;
const observer = new IntersectionObserver(
entries => { if (entries[0]?.isIntersecting) loadMore(); },
{
root: root instanceof HTMLElement ? root : null,
rootMargin: '200px',
},
);
observer.observe(node);
return () => observer.disconnect();
}, [loadMore, hasMore, scrollBodyEl]);
const { groups, letters } = useMemo(() => {
if (viewMode !== 'list') return { groups: {} as Record<string, SubsonicArtist[]>, letters: [] as string[] };
const g: Record<string, SubsonicArtist[]> = {};
@@ -481,9 +466,7 @@ export default function Composers() {
)}
{!loading && hasMore && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
<InpageScrollSentinel bindSentinel={bindSentinel} loading={loadingMore} />
)}
{!loading && filtered.length === 0 && (
+18 -24
View File
@@ -23,6 +23,9 @@ import { albumGridWarmCovers } from '../cover/layoutSizes';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useInpageScrollSentinel } from '../hooks/useInpageScrollSentinel';
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
import InpageScrollSentinel from '../components/InpageScrollSentinel';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import SortDropdown from '../components/SortDropdown';
import {
@@ -84,13 +87,11 @@ export default function LosslessAlbums() {
const seenIds = useRef<Set<string>>(new Set());
const localOffset = useRef(0);
const inFlight = useRef(false);
const observerTarget = useRef<HTMLDivElement>(null);
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindLosslessScrollBody = useCallback((el: HTMLDivElement | null) => {
scrollBodyRef.current = el;
setScrollBodyEl(el);
}, []);
const {
scrollBodyEl,
bindScrollBody: bindLosslessScrollBody,
getScrollRoot,
} = useInpageScrollViewport();
const sortOptions: { value: AlbumBrowseSort; label: string }[] = [
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
@@ -220,21 +221,13 @@ export default function LosslessAlbums() {
return () => { cancelled = true; };
}, [activeServerId, indexEnabled, loadMoreNetwork, serverId, sort]);
useEffect(() => {
if (!hasMore || useLocalIndex === null) return;
const node = observerTarget.current;
if (!node) return;
const root = scrollBodyRef.current;
const obs = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) loadMore(); },
{
root: root instanceof HTMLElement ? root : null,
const bindLoadMoreSentinel = useInpageScrollSentinel({
active: hasMore && useLocalIndex !== null,
getScrollRoot,
scrollRootEl: scrollBodyEl,
onIntersect: () => { void loadMore(); },
rootMargin: '200px',
},
);
obs.observe(node);
return () => obs.disconnect();
}, [hasMore, loadMore, loading, albums.length, scrollBodyEl, useLocalIndex]);
});
const handleEnqueueSelected = async () => {
if (selectedAlbums.length === 0) return;
@@ -387,6 +380,7 @@ export default function LosslessAlbums() {
renderItem={a => (
<AlbumCard
album={a}
observeScrollRootId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
linkQuery={LOSSLESS_MODE_QUERY}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
@@ -395,9 +389,9 @@ export default function LosslessAlbums() {
/>
)}
/>
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
{hasMore && useLocalIndex !== null && (
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loading} />
)}
</>
)}
</OverlayScrollArea>
+38 -41
View File
@@ -3,7 +3,7 @@ import { getAlbumsByGenre } from '../api/subsonicGenres';
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById';
import { useEffect, useState, useCallback, useRef } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
@@ -22,6 +22,10 @@ import { albumGridWarmCovers } from '../cover/layoutSizes';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useAsyncInpagePagination } from '../hooks/useAsyncInpagePagination';
import { useInpageScrollSentinel } from '../hooks/useInpageScrollSentinel';
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
import InpageScrollSentinel from '../components/InpageScrollSentinel';
const PAGE_SIZE = 30;
@@ -44,17 +48,21 @@ export default function NewReleases() {
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const observerTarget = useRef<HTMLDivElement>(null);
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindNewReleasesScrollBody = useCallback((el: HTMLDivElement | null) => {
scrollBodyRef.current = el;
setScrollBodyEl(el);
}, []);
const {
scrollBodyEl,
bindScrollBody: bindNewReleasesScrollBody,
getScrollRoot,
} = useInpageScrollViewport();
const {
loading,
setLoading,
resetPage,
runLoad,
requestNextPage,
isBlocked,
} = useAsyncInpagePagination(PAGE_SIZE, { initialLoading: true });
const [selectionMode, setSelectionMode] = useState(false);
const filtered = selectedGenres.length > 0;
@@ -110,16 +118,13 @@ export default function NewReleases() {
};
const load = useCallback(async (offset: number, append = false) => {
setLoading(true);
try {
await runLoad(async () => {
const data = await getAlbumList('newest', PAGE_SIZE, offset);
if (append) setAlbums(prev => [...prev, ...data]);
else setAlbums(data);
setHasMore(data.length === PAGE_SIZE);
} finally {
setLoading(false);
}
}, []);
});
}, [runLoad]);
const loadFiltered = useCallback(async (genres: string[]) => {
setLoading(true);
@@ -133,30 +138,23 @@ export default function NewReleases() {
useEffect(() => {
if (filtered) loadFiltered(selectedGenres);
else { setPage(0); load(0); }
}, [filtered, selectedGenres, load, loadFiltered]);
else {
resetPage();
void load(0);
}
}, [filtered, selectedGenres, load, loadFiltered, resetPage]);
const loadMore = useCallback(() => {
if (loading || !hasMore || filtered) return;
const next = page + 1;
setPage(next);
load(next * PAGE_SIZE, true);
}, [loading, hasMore, page, load, filtered]);
if (!hasMore || filtered || isBlocked()) return;
requestNextPage(offset => load(offset, true));
}, [hasMore, filtered, isBlocked, requestNextPage, load]);
useEffect(() => {
const node = observerTarget.current;
if (!node) return;
const root = scrollBodyRef.current;
const observer = new IntersectionObserver(
entries => { if (entries[0]?.isIntersecting) loadMore(); },
{
root: root instanceof HTMLElement ? root : null,
rootMargin: '200px',
},
);
observer.observe(node);
return () => observer.disconnect();
}, [loadMore, scrollBodyEl]);
const bindLoadMoreSentinel = useInpageScrollSentinel({
active: !filtered && hasMore,
getScrollRoot,
scrollRootEl: scrollBodyEl,
onIntersect: loadMore,
});
return (
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
@@ -232,6 +230,7 @@ export default function NewReleases() {
renderItem={a => (
<AlbumCard
album={a}
observeScrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
@@ -239,10 +238,8 @@ export default function NewReleases() {
/>
)}
/>
{!filtered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
{!filtered && hasMore && (
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loading} />
)}
</>
)}
+547 -1
View File
@@ -76,7 +76,548 @@
}
.sidebar-perf-modal {
max-width: 560px;
max-width: 680px;
display: flex;
flex-direction: column;
max-height: min(88vh, 860px);
overflow: hidden;
padding-bottom: var(--space-6);
}
.sidebar-perf-modal__header {
flex-shrink: 0;
padding-right: 2rem;
}
.sidebar-perf-modal__header .modal-title {
margin-bottom: 0.35rem;
}
.sidebar-perf-modal__tabs {
display: flex;
gap: 6px;
margin: 12px 0 10px;
padding: 4px;
border-radius: 12px;
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 88%, transparent);
border: 1px solid color-mix(in srgb, var(--text-muted) 18%, transparent);
}
.sidebar-perf-modal__tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
border: none;
border-radius: 9px;
background: transparent;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.sidebar-perf-modal__tab:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--bg-hover, var(--ctp-surface1)) 70%, transparent);
}
.sidebar-perf-modal__tab--active {
color: var(--text-primary);
background: color-mix(in srgb, var(--accent) 16%, var(--bg-card, var(--ctp-base)));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent);
}
.sidebar-perf-modal__body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: 2px;
}
.perf-monitor-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 2.5rem 1rem;
color: var(--text-muted);
font-size: 13px;
text-align: center;
}
.perf-monitor-empty--inline {
padding: 0.75rem 0.5rem;
font-size: 11px;
}
.perf-live-poll {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--text-muted) 18%, transparent);
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 82%, transparent);
}
.perf-live-poll__title {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--text-muted) 92%, transparent);
margin-bottom: 8px;
}
.perf-live-poll__row {
display: flex;
flex-direction: column;
gap: 6px;
}
.perf-live-poll__label {
font-size: 11px;
color: var(--text-muted);
}
.perf-live-poll__value {
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.perf-live-poll__row input[type='range'] {
width: 100%;
}
.perf-metric-section {
margin-bottom: 10px;
border: 1px solid color-mix(in srgb, var(--text-muted) 18%, transparent);
border-radius: 12px;
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 82%, transparent);
overflow: hidden;
}
.perf-metric-section__title {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
cursor: pointer;
list-style: none;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
user-select: none;
}
.perf-metric-section__title::-webkit-details-marker {
display: none;
}
.perf-metric-section[open] .perf-metric-section__chevron {
transform: rotate(90deg);
}
.perf-metric-section__chevron {
flex-shrink: 0;
transition: transform 140ms ease;
}
.perf-metric-section__hint {
margin-left: auto;
font-size: 10px;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
color: color-mix(in srgb, var(--text-muted) 88%, transparent);
}
.perf-metric-section__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
padding: 0 10px 10px;
}
.perf-metric-card {
padding: 10px 10px 9px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--text-muted) 14%, transparent);
background: color-mix(in srgb, var(--ctp-mantle, var(--ctp-base)) 72%, transparent);
min-height: 84px;
display: flex;
flex-direction: column;
gap: 4px;
}
.perf-metric-card--disabled {
opacity: 0.55;
}
.perf-metric-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
}
.perf-metric-card__label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
line-height: 1.25;
}
.perf-metric-card__pin {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.perf-metric-card__pin:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--bg-hover) 80%, transparent);
}
.perf-metric-card__pin--active {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.perf-metric-card__value-row {
display: flex;
align-items: baseline;
gap: 4px;
}
.perf-metric-card__value {
font-size: 22px;
line-height: 1;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.perf-metric-card__unit {
font-size: 11px;
color: var(--text-muted);
}
.perf-metric-card__bar-track {
height: 4px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-muted) 16%, transparent);
overflow: hidden;
}
.perf-metric-card__bar-fill {
height: 100%;
border-radius: inherit;
}
.perf-metric-card__bar-fill--cpu {
background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 70%, #6cf), var(--accent));
}
.perf-metric-card__bar-fill--memory {
background: linear-gradient(90deg, #7aa2f7, #9ece6a);
}
.perf-metric-card__bar-fill--rate {
background: linear-gradient(90deg, #fab387, #f9e2af);
}
.perf-metric-card__bar-fill--neutral {
background: var(--text-muted);
}
.perf-metric-card__detail {
font-size: 10px;
line-height: 1.35;
color: color-mix(in srgb, var(--text-muted) 92%, transparent);
}
.perf-metric-card__badge {
margin-top: auto;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
}
.perf-toggle-tree__hint {
margin-bottom: 10px;
}
.perf-toggle-tree {
display: flex;
flex-direction: column;
gap: 8px;
}
.perf-tree-group {
border: 1px solid color-mix(in srgb, var(--text-muted) 16%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 78%, transparent);
overflow: hidden;
}
.perf-tree-group__head {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
padding: 9px 10px;
padding-left: calc(10px + (var(--perf-tree-depth, 0) * 12px));
border: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.perf-tree-group__head:hover {
background: color-mix(in srgb, var(--bg-hover) 65%, transparent);
}
.perf-tree-group__chevron {
flex-shrink: 0;
transition: transform 140ms ease;
}
.perf-tree-group__chevron--open {
transform: rotate(90deg);
}
.perf-tree-group__body {
padding-bottom: 4px;
}
.perf-tree-leaf-wrap {
padding-left: calc(18px + (var(--perf-tree-depth, 0) * 12px));
padding-right: 8px;
}
.perf-tree-leaf {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 7px 8px;
border-radius: 8px;
cursor: pointer;
}
.perf-tree-leaf:hover {
background: color-mix(in srgb, var(--bg-hover) 55%, transparent);
}
.perf-tree-leaf input[type="checkbox"] {
margin-top: 2px;
accent-color: var(--accent);
width: 15px;
height: 15px;
flex-shrink: 0;
}
.perf-tree-leaf__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.perf-tree-leaf__label {
font-size: 13px;
color: var(--text-primary);
line-height: 1.3;
}
.perf-tree-leaf__desc {
font-size: 11px;
color: var(--text-muted);
line-height: 1.35;
}
.sidebar-perf-modal__actions {
flex-shrink: 0;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid color-mix(in srgb, var(--text-muted) 16%, transparent);
}
.perf-overlay-mode {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--text-muted) 18%, transparent);
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 82%, transparent);
}
.perf-overlay-mode__title {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--text-muted) 92%, transparent);
margin-bottom: 8px;
}
.perf-overlay-mode__segments {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.perf-overlay-mode__segment {
padding: 6px 8px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 70%, transparent);
color: var(--text-muted);
font-size: 11px;
line-height: 1.2;
cursor: pointer;
}
.perf-overlay-mode__segment:hover {
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
color: var(--text-primary);
}
.perf-overlay-mode__segment--active {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
background: color-mix(in srgb, var(--accent) 14%, var(--bg-card, var(--ctp-base)));
color: var(--text-primary);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 24%, transparent);
}
.perf-overlay-mode__hint {
margin: 8px 0 0;
font-size: 10px;
line-height: 1.35;
color: color-mix(in srgb, var(--text-muted) 92%, transparent);
}
.perf-overlay-appearance {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--text-muted) 18%, transparent);
background: color-mix(in srgb, var(--bg-card, var(--ctp-base)) 82%, transparent);
}
.perf-overlay-appearance__title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 10px;
}
.perf-overlay-appearance__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.perf-overlay-appearance__row:last-child {
margin-bottom: 0;
}
.perf-overlay-appearance__label {
font-size: 12px;
color: var(--text-primary);
flex-shrink: 0;
}
.perf-overlay-appearance__value {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.perf-overlay-appearance__corners {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.perf-overlay-appearance__corner {
position: relative;
width: 52px;
height: 36px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
background: color-mix(in srgb, var(--ctp-mantle, var(--ctp-base)) 70%, transparent);
cursor: pointer;
padding: 0;
}
.perf-overlay-appearance__corner:hover {
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
}
.perf-overlay-appearance__corner--active {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
}
.perf-overlay-appearance__corner-mark {
position: absolute;
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--accent);
opacity: 0.85;
}
.perf-overlay-appearance__corner-mark--top-right {
top: 6px;
right: 6px;
}
.perf-overlay-appearance__corner-mark--top-left {
top: 6px;
left: 6px;
}
.perf-overlay-appearance__corner-mark--bottom-right {
bottom: 6px;
right: 6px;
}
.perf-overlay-appearance__corner-mark--bottom-left {
bottom: 6px;
left: 6px;
}
.perf-overlay-appearance__opacity {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.perf-overlay-appearance__opacity input[type='range'] {
width: 100%;
accent-color: var(--accent);
}
.sidebar-perf-modal__hint {
@@ -151,6 +692,11 @@
line-height: 1.45;
}
.sidebar-perf-modal__cpu-row--indent {
padding-left: 0.75rem;
color: var(--text-muted);
}
.sidebar-perf-modal__subhead {
margin-top: 10px;
margin-bottom: 2px;
@@ -1511,23 +1511,127 @@
/* FPS overlay (Performance Probe → Show FPS overlay) */
.fps-overlay {
position: fixed;
top: 10px;
right: 10px;
z-index: 100003;
pointer-events: none;
padding: 6px 10px;
border-radius: 6px;
padding: 8px 10px;
border-radius: 8px;
font-family: var(--font-mono, ui-monospace, 'Courier New', monospace);
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--text-primary, #fff);
background: color-mix(in srgb, var(--ctp-crust, #111) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--border-subtle, #444) 70%, transparent);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
--fps-overlay-opacity: 0.82;
background: color-mix(
in srgb,
var(--ctp-crust, #111) calc(var(--fps-overlay-opacity) * 100%),
transparent
);
border: 1px solid color-mix(
in srgb,
var(--border-subtle, #444) calc(var(--fps-overlay-opacity) * 70%),
transparent
);
box-shadow: 0 2px 10px color-mix(in srgb, #000 calc(var(--fps-overlay-opacity) * 35%), transparent);
display: flex;
flex-direction: column;
gap: 6px;
line-height: 1.25;
max-width: min(92vw, 320px);
}
.fps-overlay--top-right {
top: 10px;
right: 10px;
}
.fps-overlay--top-left {
top: 10px;
left: 10px;
}
.fps-overlay--bottom-right {
bottom: 10px;
right: 10px;
}
.fps-overlay--bottom-left {
bottom: 10px;
left: 10px;
}
.fps-overlay__block {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.25;
}
.fps-overlay__block + .fps-overlay__block {
padding-top: 6px;
border-top: 1px solid color-mix(in srgb, var(--border-subtle, #444) 55%, transparent);
}
.fps-overlay__row--fps + .fps-overlay__block {
padding-top: 6px;
border-top: 1px solid color-mix(in srgb, var(--border-subtle, #444) 55%, transparent);
}
.fps-overlay__block-title {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--text-muted, #888) 92%, transparent);
margin-bottom: 2px;
}
.fps-overlay__row--live {
font-size: 11px;
color: color-mix(in srgb, var(--accent) 78%, var(--text-primary));
}
.fps-overlay__live-metric {
display: flex;
flex-direction: column;
gap: 3px;
}
.fps-overlay__live-metric + .fps-overlay__live-metric {
margin-top: 4px;
}
.perf-overlay-sparkline {
display: block;
overflow: visible;
}
.perf-overlay-sparkline__track {
fill: color-mix(in srgb, var(--text-primary, #fff) 5%, transparent);
stroke: color-mix(in srgb, var(--border-subtle, #444) 45%, transparent);
stroke-width: 0.75;
}
.perf-overlay-sparkline__fill {
opacity: 0.22;
}
.perf-overlay-sparkline__line {
fill: none;
stroke-width: 1.25;
}
.perf-overlay-sparkline--cpu .perf-overlay-sparkline__fill {
fill: color-mix(in srgb, var(--accent) 55%, transparent);
}
.perf-overlay-sparkline--cpu .perf-overlay-sparkline__line {
stroke: color-mix(in srgb, var(--accent) 88%, white);
}
.perf-overlay-sparkline--memory .perf-overlay-sparkline__fill {
fill: color-mix(in srgb, #9ece6a 45%, transparent);
}
.perf-overlay-sparkline--memory .perf-overlay-sparkline__line {
stroke: color-mix(in srgb, #9ece6a 92%, white);
}
.fps-overlay__row--detail {
+14
View File
@@ -29,6 +29,20 @@ import type {
} from './albumBrowseTypes';
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
/** One local-index chunk for lazy catalog loading (All Albums slice mode). */
export async function fetchLocalAlbumCatalogChunk(
serverId: string,
query: AlbumBrowseQuery,
offset: number,
chunkSize: number,
): Promise<AlbumBrowsePageResult | null> {
const limit = query.genres.length > 0 && offset === 0 ? GENRE_ALBUM_FETCH_LIMIT : chunkSize;
if (query.genres.length > 0 && offset > 0) {
return { albums: [], hasMore: false };
}
return runLocalAlbumBrowse(serverId, query, offset, limit);
}
/** Genres in albums matching all filters except genre (for combined-filter UI). */
export async function fetchAlbumBrowseGenreOptions(
serverId: string,
+30
View File
@@ -499,6 +499,36 @@ export async function runLocalBrowseAllArtists(
}
}
export type ArtistCatalogChunkResult = {
artists: SubsonicArtist[];
hasMore: boolean;
};
/** One local-index chunk for lazy artist catalog loading (Artists browse slice mode). */
export async function fetchLocalArtistCatalogChunk(
serverId: string,
offset: number,
chunkSize: number,
): Promise<ArtistCatalogChunkResult | null> {
if (!serverId || !(await libraryIsReady(serverId))) return null;
try {
const resp = await libraryAdvancedSearch({
serverId,
libraryScope: libraryScopeForServer(serverId) ?? undefined,
entityTypes: ['artist'],
sort: [{ field: 'name', dir: 'asc' }],
limit: chunkSize,
offset,
skipTotals: true,
});
if (resp.source !== 'local') return null;
const artists = resp.artists.map(artistToArtist);
return { artists, hasMore: artists.length === chunkSize };
} catch {
return null;
}
}
/** Starred artists from `getStarred2` (artist-level only; server is source of truth). */
export async function fetchNetworkStarredArtists(): Promise<SubsonicArtist[]> {
const { artists } = await getStarred();
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { formatCoverPipelineQueueOverlay } from './formatCoverPipelineQueueOverlay';
describe('formatCoverPipelineQueueOverlay', () => {
it('formats ensure tiers, rust pools, and optional peek backlog', () => {
expect(
formatCoverPipelineQueueOverlay({
rust: {
httpMax: 16,
httpActive: 8,
cpuUiMax: 2,
cpuUiActive: 2,
cpuBackfillMax: 2,
cpuBackfillActive: 1,
libraryBackfillHttpMax: 2,
libraryBackfillHttpActive: 1,
libraryBackfillPassRunning: true,
},
ensure: {
queuedHigh: 2,
queuedMiddle: 4,
queuedLow: 6,
inflight: 10,
maxInflight: 10,
},
peek: { pending: 24, inflight: 1 },
}),
).toEqual([
'ui ensure 12(2,4,6) · invoke 10/10',
'http ui 8/16 · lib 1/2 · pass',
'enc ui 2/2 · lib 1/2',
'disk peek 24 pending · 1 inflight',
]);
});
it('omits peek line when idle', () => {
expect(
formatCoverPipelineQueueOverlay({
rust: {
httpMax: 16,
httpActive: 0,
cpuUiMax: 2,
cpuUiActive: 0,
cpuBackfillMax: 2,
cpuBackfillActive: 0,
libraryBackfillHttpMax: 2,
libraryBackfillHttpActive: 0,
libraryBackfillPassRunning: false,
},
ensure: {
queuedHigh: 0,
queuedMiddle: 0,
queuedLow: 0,
inflight: 0,
maxInflight: 10,
},
peek: { pending: 0, inflight: 0 },
}),
).toEqual([
'ui ensure 0(0,0,0) · invoke 0/10',
'http ui 0/16 · lib 0/2',
'enc ui 0/2 · lib 0/2',
]);
});
});
@@ -0,0 +1,32 @@
import type { CoverPipelineQueueStatsDto } from '../../api/coverCache';
import type { CoverEnsureQueueStats } from '../../cover/ensureQueue';
import type { CoverPeekQueueStats } from '../../cover/peekQueue';
import { formatAnalysisTierQueue } from './formatAnalysisQueueStats';
export type CoverPipelineOverlayInput = {
rust: CoverPipelineQueueStatsDto;
ensure: CoverEnsureQueueStats;
peek: CoverPeekQueueStats;
};
export function formatCoverPipelineQueueOverlay(input: CoverPipelineOverlayInput): string[] {
const { rust, ensure, peek } = input;
const ensureQueued = ensure.queuedHigh + ensure.queuedMiddle + ensure.queuedLow;
const ensureLine = `ui ensure ${formatAnalysisTierQueue(
ensureQueued,
ensure.queuedHigh,
ensure.queuedMiddle,
ensure.queuedLow,
)} · invoke ${ensure.inflight}/${ensure.maxInflight}`;
const libPass = rust.libraryBackfillPassRunning ? ' · pass' : '';
const httpLine = `http ui ${rust.httpActive}/${rust.httpMax} · lib ${rust.libraryBackfillHttpActive}/${rust.libraryBackfillHttpMax}${libPass}`;
const cpuLine = `enc ui ${rust.cpuUiActive}/${rust.cpuUiMax} · lib ${rust.cpuBackfillActive}/${rust.cpuBackfillMax}`;
const lines = [ensureLine, httpLine, cpuLine];
if (peek.pending > 0 || peek.inflight > 0) {
lines.push(`disk peek ${peek.pending} pending · ${peek.inflight} inflight`);
}
return lines;
}
+125
View File
@@ -0,0 +1,125 @@
import type { PerfLiveSnapshot } from './perfLiveStore';
export type LiveOverlayItemKind = 'cpu' | 'memory' | 'rate' | 'analysis';
export type LiveOverlayItem = {
id: string;
line: string;
kind: LiveOverlayItemKind;
sparkline: boolean;
};
export function buildLiveOverlayItems(
pins: ReadonlySet<string>,
live: PerfLiveSnapshot,
): LiveOverlayItem[] {
const items: LiveOverlayItem[] = [];
const cpu = live.cpu;
for (const pin of pins) {
if (pin === 'cpu:app' && cpu?.supported) {
items.push({
id: pin,
line: `cpu psysonic ${cpu.app.toFixed(1)}%`,
kind: 'cpu',
sparkline: true,
});
} else if (pin === 'cpu:webkit' && cpu?.supported) {
items.push({
id: pin,
line: `cpu webkit ${cpu.webkit.toFixed(1)}%`,
kind: 'cpu',
sparkline: true,
});
} else if (pin.startsWith('cpu:thread:') && cpu?.supported) {
const label = pin.slice('cpu:thread:'.length);
const row = cpu.threadCpu.find(t => t.label === label);
if (row) {
const suffix = row.threadCount > 1 ? ` (${row.threadCount})` : '';
items.push({
id: pin,
line: `cpu ${label}${suffix} ${row.pct.toFixed(1)}%`,
kind: 'cpu',
sparkline: true,
});
}
} else if (pin.startsWith('mem:') && cpu?.supported) {
const label = pin.slice('mem:'.length);
const row = cpu.memory.find(m => m.label === label);
if (row) {
items.push({
id: pin,
line: `mem ${label} ${(row.rss_kb / 1024).toFixed(1)} MB`,
kind: 'memory',
sparkline: true,
});
}
} else if (pin === 'rate:progress' && live.diagRates) {
items.push({
id: pin,
line: `progress ${live.diagRates.progress.toFixed(1)}/s`,
kind: 'rate',
sparkline: false,
});
} else if (pin === 'rate:waveform' && live.diagRates) {
items.push({
id: pin,
line: `waveform ${live.diagRates.waveform.toFixed(1)}/s`,
kind: 'rate',
sparkline: false,
});
} else if (pin === 'rate:home' && live.diagRates) {
items.push({
id: pin,
line: `home ${live.diagRates.home.toFixed(1)}/s`,
kind: 'rate',
sparkline: false,
});
} else if (pin === 'analysis:tpm' && live.analysis) {
items.push({
id: pin,
line: `analysis ${live.analysis.tracksPerMinute.toFixed(1)} tpm`,
kind: 'analysis',
sparkline: false,
});
} else if (pin === 'analysis:last' && live.analysis?.lastTotalMs != null) {
items.push({
id: pin,
line: `last track ${(live.analysis.lastTotalMs / 1000).toFixed(1)}s`,
kind: 'analysis',
sparkline: false,
});
}
}
return items;
}
/** Numeric sample for history recording (cpu % or memory MB). */
export function liveOverlayItemValue(
pin: string,
live: PerfLiveSnapshot,
): number | null {
const cpu = live.cpu;
if (!cpu?.supported) return null;
if (pin === 'cpu:app') return cpu.app;
if (pin === 'cpu:webkit') return cpu.webkit;
if (pin.startsWith('cpu:thread:')) {
const label = pin.slice('cpu:thread:'.length);
return cpu.threadCpu.find(t => t.label === label)?.pct ?? null;
}
if (pin.startsWith('mem:')) {
const label = pin.slice('mem:'.length);
const row = cpu.memory.find(m => m.label === label);
return row ? row.rss_kb / 1024 : null;
}
return null;
}
export function isLiveHistoryPin(pin: string): boolean {
return pin === 'cpu:app'
|| pin === 'cpu:webkit'
|| pin.startsWith('cpu:thread:')
|| pin.startsWith('mem:');
}
+3
View File
@@ -5,6 +5,8 @@ export type PerfProbeFlags = {
showFpsOverlay: boolean;
/** On-screen analysis throughput + last-track timing (Performance Probe). */
showAnalysisPerfOverlay: boolean;
/** On-screen cover ensure / HTTP / WebP encode pipeline (Performance Probe). */
showCoverPerfOverlay: boolean;
disableWaveformCanvas: boolean;
disablePlayerProgressUi: boolean;
disableMarqueeScroll: boolean;
@@ -35,6 +37,7 @@ const STORAGE_KEY = 'psysonic_perf_probe_flags_v1';
const DEFAULT_FLAGS: PerfProbeFlags = {
showFpsOverlay: false,
showAnalysisPerfOverlay: false,
showCoverPerfOverlay: false,
disableWaveformCanvas: false,
disablePlayerProgressUi: false,
disableMarqueeScroll: false,
+143
View File
@@ -0,0 +1,143 @@
import { useSyncExternalStore } from 'react';
import {
isLiveHistoryPin,
liveOverlayItemValue,
} from './formatLiveOverlayItems';
import type { PerfLiveSnapshot } from './perfLiveStore';
const HISTORY_MS = 60_000;
const EMPTY_VALUES: readonly number[] = [];
const EMPTY_SAMPLES: readonly Sample[] = [];
export type PerfLiveSample = {
readonly at: number;
readonly value: number;
};
type Sample = PerfLiveSample;
const series = new Map<string, Sample[]>();
const valueCache = new Map<string, { source: Sample[]; values: readonly number[] }>();
const listeners = new Set<() => void>();
function emit(): void {
listeners.forEach(fn => fn());
}
function trim(samples: Sample[], now: number): Sample[] {
const cutoff = now - HISTORY_MS;
let start = 0;
while (start < samples.length && samples[start].at < cutoff) start += 1;
return start > 0 ? samples.slice(start) : samples;
}
function appendSample(id: string, value: number, at: number): boolean {
if (!Number.isFinite(value)) return false;
const existing = series.get(id) ?? [];
const last = existing[existing.length - 1];
if (last && last.at === at && last.value === value) return false;
const appended =
last && last.at === at
? [...existing.slice(0, -1), { at, value }]
: [...existing, { at, value }];
const next = trim(appended, at);
series.set(id, next);
valueCache.delete(id);
return true;
}
export function recordPerfLiveHistory(id: string, value: number, at = Date.now()): void {
if (appendSample(id, value, at)) emit();
}
/** Record pinned live samples for one poll tick; returns the new last-recorded timestamp. */
export function syncPerfLiveHistoryFromPoll(
pins: Iterable<string>,
live: PerfLiveSnapshot,
lastRecordedAt: number,
): number {
if (!live.cpu?.supported || live.updatedAt <= 0 || live.updatedAt === lastRecordedAt) {
return lastRecordedAt;
}
let changed = false;
for (const pin of pins) {
if (!isLiveHistoryPin(pin)) continue;
const value = liveOverlayItemValue(pin, live);
if (value != null && appendSample(pin, value, live.updatedAt)) changed = true;
}
if (changed) emit();
return live.updatedAt;
}
export function getPerfLiveHistoryClock(ids: Iterable<string>): number {
let latest = 0;
for (const id of ids) {
const samples = series.get(id);
const last = samples?.[samples.length - 1];
if (last && last.at > latest) latest = last.at;
}
return latest;
}
export function getPerfLiveHistorySamples(id: string): readonly PerfLiveSample[] {
const now = Date.now();
let samples = series.get(id) ?? [];
const trimmed = trim(samples, now);
if (trimmed.length !== samples.length) {
samples = trimmed;
series.set(id, samples);
}
return samples.length === 0 ? EMPTY_SAMPLES : samples;
}
export function getPerfLiveHistory(id: string): readonly number[] {
const now = Date.now();
let samples = series.get(id) ?? [];
const trimmed = trim(samples, now);
if (trimmed.length !== samples.length) {
samples = trimmed;
series.set(id, samples);
}
if (samples.length === 0) return EMPTY_VALUES;
const cached = valueCache.get(id);
if (cached && cached.source === samples) return cached.values;
const values: readonly number[] = samples.map(s => s.value);
valueCache.set(id, { source: samples, values });
return values;
}
export function clearPerfLiveHistory(id?: string): void {
if (id) {
series.delete(id);
valueCache.delete(id);
} else {
series.clear();
valueCache.clear();
}
emit();
}
export function subscribePerfLiveHistory(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function usePerfLiveHistorySamples(id: string): readonly PerfLiveSample[] {
return useSyncExternalStore(
subscribePerfLiveHistory,
() => getPerfLiveHistorySamples(id),
() => EMPTY_SAMPLES,
);
}
export function usePerfLiveHistory(id: string): readonly number[] {
return useSyncExternalStore(
subscribePerfLiveHistory,
() => getPerfLiveHistory(id),
() => EMPTY_VALUES,
);
}
export const PERF_LIVE_HISTORY_MS = HISTORY_MS;
+99
View File
@@ -0,0 +1,99 @@
import { useSyncExternalStore } from 'react';
export const PERF_LIVE_POLL_MS_DEFAULT = 2000;
export const PERF_LIVE_POLL_MS_MIN = 500;
export const PERF_LIVE_POLL_MS_MAX = 10_000;
export const PERF_LIVE_POLL_MS_STEP = 500;
const STORAGE_KEY = 'psysonic_perf_live_poll_ms_v1';
const listeners = new Set<() => void>();
let pollIntervalMs = PERF_LIVE_POLL_MS_DEFAULT;
let includeThreadGroups = false;
let scheduleBump: (() => void) | null = null;
function requestScheduleBump(): void {
scheduleBump?.();
}
function emit(): void {
listeners.forEach(fn => fn());
}
function clampPollMs(value: number): number {
if (!Number.isFinite(value)) return PERF_LIVE_POLL_MS_DEFAULT;
const stepped = Math.round(value / PERF_LIVE_POLL_MS_STEP) * PERF_LIVE_POLL_MS_STEP;
return Math.min(PERF_LIVE_POLL_MS_MAX, Math.max(PERF_LIVE_POLL_MS_MIN, stepped));
}
function initPollInterval(): void {
if (typeof window === 'undefined') return;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw == null) return;
pollIntervalMs = clampPollMs(Number(raw));
} catch {
/* ignore */
}
}
initPollInterval();
export function getPerfLivePollIntervalMs(): number {
return pollIntervalMs;
}
export function setPerfLivePollIntervalMs(ms: number): void {
const next = clampPollMs(ms);
if (next === pollIntervalMs) return;
pollIntervalMs = next;
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(STORAGE_KEY, String(next));
} catch {
/* ignore */
}
}
emit();
requestScheduleBump();
}
export function registerPerfLivePollScheduleBump(fn: () => void): void {
scheduleBump = fn;
}
export function subscribePerfLivePollInterval(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function usePerfLivePollIntervalMs(): number {
return useSyncExternalStore(subscribePerfLivePollInterval, getPerfLivePollIntervalMs, () => PERF_LIVE_POLL_MS_DEFAULT);
}
export function getPerfLiveIncludeThreadGroups(): boolean {
return includeThreadGroups;
}
export function setPerfLiveIncludeThreadGroups(next: boolean): void {
if (next === includeThreadGroups) return;
includeThreadGroups = next;
requestScheduleBump();
}
/** Thread groups when the Monitor section is open or a thread metric is pinned. */
export function syncPerfLiveThreadGroupsNeed(
sectionOpen: boolean,
pins: ReadonlySet<string>,
): void {
const pinnedThread = [...pins].some(pin => pin.startsWith('cpu:thread:'));
setPerfLiveIncludeThreadGroups(sectionOpen || pinnedThread);
}
export type PerfCpuSnapshotRequest = {
include_thread_groups: boolean;
};
export function buildPerfCpuSnapshotRequest(): PerfCpuSnapshotRequest {
return { include_thread_groups: includeThreadGroups };
}
+286
View File
@@ -0,0 +1,286 @@
import { useSyncExternalStore } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { clearPerfLiveHistory } from './perfLiveHistory';
import { getAnalysisTracksPerMinute } from './analysisPerfStore';
import {
buildPerfCpuSnapshotRequest,
getPerfLivePollIntervalMs,
registerPerfLivePollScheduleBump,
} from './perfLivePollSettings';
export type PerfProcessMemory = {
label: string;
rss_kb: number;
};
export type PerfThreadCpu = {
label: string;
threadCount: number;
pct: number;
};
export type PerfLiveCpu = {
app: number;
webkit: number;
supported: boolean;
memory: PerfProcessMemory[];
threadCpu: PerfThreadCpu[];
};
export type PerfDiagRates = {
progress: number;
waveform: number;
home: number;
};
export type PerfAnalysisDiag = {
tracksPerMinute: number;
lastTotalMs: number | null;
lastFetchMs: number | null;
lastSeedMs: number | null;
lastBpmMs: number | null;
};
export type PerfLiveSnapshot = {
cpu: PerfLiveCpu | null;
diagRates: PerfDiagRates | null;
analysis: PerfAnalysisDiag | null;
collecting: boolean;
/** Wall time of the last CPU poll; shared clock for overlay sparklines. */
updatedAt: number;
};
type ProcSnapshot = {
supported: boolean;
total_jiffies: number;
app_jiffies: number;
webkit_jiffies: number;
logical_cpus: number;
memory: PerfProcessMemory[];
thread_cpu_groups: Array<{ label: string; thread_count: number; jiffies: number }>;
};
const EMPTY: PerfLiveSnapshot = {
cpu: null,
diagRates: null,
analysis: null,
collecting: false,
updatedAt: 0,
};
let snapshot: PerfLiveSnapshot = { ...EMPTY };
let pollRefCount = 0;
const listeners = new Set<() => void>();
let pollTimer: number | null = null;
let prevProc: ProcSnapshot | null = null;
let prevCounters: { progress: number; waveform: number; home: number } | null = null;
let prevCountersAt = 0;
let pollGeneration = 0;
function emit(): void {
listeners.forEach(fn => fn());
}
function setSnapshot(next: PerfLiveSnapshot): void {
snapshot = next;
emit();
}
function readUiCounters(): { progress: number; waveform: number; home: number } {
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
const counters = root.__psyPerfCounters ?? {};
return {
progress: counters.audioProgressEvents ?? 0,
waveform: counters.waveformDraws ?? 0,
home: counters.homeCommits ?? 0,
};
}
async function pollOnce(): Promise<void> {
const generation = pollGeneration;
const now = Date.now();
try {
const snap = await invoke<ProcSnapshot>('performance_cpu_snapshot', buildPerfCpuSnapshotRequest());
if (generation !== pollGeneration) return;
if (!snap.supported) {
setSnapshot({
cpu: { app: 0, webkit: 0, supported: false, memory: [], threadCpu: [] },
diagRates: snapshot.diagRates,
analysis: {
tracksPerMinute: getAnalysisTracksPerMinute(),
lastTotalMs: snapshot.analysis?.lastTotalMs ?? null,
lastFetchMs: snapshot.analysis?.lastFetchMs ?? null,
lastSeedMs: snapshot.analysis?.lastSeedMs ?? null,
lastBpmMs: snapshot.analysis?.lastBpmMs ?? null,
},
collecting: false,
updatedAt: now,
});
return;
}
const memory = snap.memory;
let cpu: PerfLiveCpu = {
app: snapshot.cpu?.app ?? 0,
webkit: snapshot.cpu?.webkit ?? 0,
supported: true,
memory,
threadCpu: snap.thread_cpu_groups.map(g => ({
label: g.label,
threadCount: g.thread_count,
pct: snapshot.cpu?.threadCpu.find(t => t.label === g.label)?.pct ?? 0,
})),
};
if (prevProc) {
const totalDelta = snap.total_jiffies - prevProc.total_jiffies;
const appDelta = snap.app_jiffies - prevProc.app_jiffies;
const webkitDelta = snap.webkit_jiffies - prevProc.webkit_jiffies;
if (totalDelta > 0) {
const cpuScale = Math.max(1, snap.logical_cpus || 1) * 100;
const prevThreadByLabel = new Map(
prevProc.thread_cpu_groups.map(g => [g.label, g.jiffies]),
);
cpu = {
app: clampPct((appDelta / totalDelta) * cpuScale),
webkit: clampPct((webkitDelta / totalDelta) * cpuScale),
supported: true,
memory,
threadCpu: snap.thread_cpu_groups.map(g => {
const prevJiffies = prevThreadByLabel.get(g.label) ?? g.jiffies;
const delta = g.jiffies - prevJiffies;
return {
label: g.label,
threadCount: g.thread_count,
pct: clampPct((delta / totalDelta) * cpuScale),
};
}),
};
}
}
const nextCounters = readUiCounters();
let diagRates = snapshot.diagRates;
if (prevCounters && prevCountersAt > 0) {
const dt = Math.max(0.25, (now - prevCountersAt) / 1000);
diagRates = {
progress: (nextCounters.progress - prevCounters.progress) / dt,
waveform: (nextCounters.waveform - prevCounters.waveform) / dt,
home: (nextCounters.home - prevCounters.home) / dt,
};
}
prevCounters = nextCounters;
prevCountersAt = now;
prevProc = snap;
setSnapshot({
cpu,
diagRates,
analysis: {
tracksPerMinute: getAnalysisTracksPerMinute(),
lastTotalMs: snapshot.analysis?.lastTotalMs ?? null,
lastFetchMs: snapshot.analysis?.lastFetchMs ?? null,
lastSeedMs: snapshot.analysis?.lastSeedMs ?? null,
lastBpmMs: snapshot.analysis?.lastBpmMs ?? null,
},
collecting: false,
updatedAt: now,
});
} catch {
if (generation !== pollGeneration) return;
setSnapshot({
...snapshot,
cpu: { app: 0, webkit: 0, supported: false, memory: [], threadCpu: [] },
collecting: false,
updatedAt: Date.now(),
});
}
}
function clampPct(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(1000, value));
}
function schedulePoll(): void {
if (pollTimer != null) return;
setSnapshot({ ...snapshot, collecting: snapshot.cpu == null });
const intervalMs = getPerfLivePollIntervalMs();
const tick = () => {
pollTimer = null;
if (pollRefCount === 0) return;
void pollOnce().finally(() => {
if (pollRefCount > 0) {
pollTimer = window.setTimeout(tick, getPerfLivePollIntervalMs());
}
});
};
void pollOnce().finally(() => {
if (pollRefCount > 0) {
pollTimer = window.setTimeout(tick, intervalMs);
}
});
}
/** Restart the poll loop after interval / snapshot options change. */
export function bumpPerfLivePollSchedule(): void {
if (pollRefCount === 0) return;
pollGeneration += 1;
if (pollTimer != null) {
window.clearTimeout(pollTimer);
pollTimer = null;
}
schedulePoll();
}
registerPerfLivePollScheduleBump(bumpPerfLivePollSchedule);
function stopPoll(): void {
pollGeneration += 1;
if (pollTimer != null) {
window.clearTimeout(pollTimer);
pollTimer = null;
}
prevProc = null;
prevCounters = null;
prevCountersAt = 0;
clearPerfLiveHistory();
setSnapshot({ ...EMPTY });
}
export function acquirePerfLivePoll(_reason: string): () => void {
const start = pollRefCount === 0;
pollRefCount += 1;
if (start) schedulePoll();
return () => {
pollRefCount = Math.max(0, pollRefCount - 1);
if (pollRefCount === 0) stopPoll();
};
}
export function patchPerfLiveAnalysis(partial: Partial<PerfAnalysisDiag>): void {
setSnapshot({
...snapshot,
analysis: {
tracksPerMinute: partial.tracksPerMinute ?? snapshot.analysis?.tracksPerMinute ?? 0,
lastTotalMs: partial.lastTotalMs ?? snapshot.analysis?.lastTotalMs ?? null,
lastFetchMs: partial.lastFetchMs ?? snapshot.analysis?.lastFetchMs ?? null,
lastSeedMs: partial.lastSeedMs ?? snapshot.analysis?.lastSeedMs ?? null,
lastBpmMs: partial.lastBpmMs ?? snapshot.analysis?.lastBpmMs ?? null,
},
});
}
export function getPerfLiveSnapshot(): PerfLiveSnapshot {
return snapshot;
}
export function subscribePerfLiveSnapshot(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function usePerfLiveSnapshot(): PerfLiveSnapshot {
return useSyncExternalStore(subscribePerfLiveSnapshot, getPerfLiveSnapshot, () => EMPTY);
}
+107
View File
@@ -0,0 +1,107 @@
import { useSyncExternalStore } from 'react';
export type PerfOverlayCorner = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
export type PerfOverlayAppearance = {
corner: PerfOverlayCorner;
/** Background fill strength 0.251 (higher = less transparent). */
opacity: number;
};
const STORAGE_KEY = 'psysonic_perf_overlay_appearance_v1';
export const PERF_OVERLAY_CORNER_OPTIONS: Array<{ id: PerfOverlayCorner; label: string }> = [
{ id: 'top-right', label: 'Top right' },
{ id: 'top-left', label: 'Top left' },
{ id: 'bottom-right', label: 'Bottom right' },
{ id: 'bottom-left', label: 'Bottom left' },
];
const DEFAULT_APPEARANCE: PerfOverlayAppearance = {
corner: 'top-right',
opacity: 0.82,
};
let appearance: PerfOverlayAppearance = { ...DEFAULT_APPEARANCE };
const listeners = new Set<() => void>();
function clampOpacity(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_APPEARANCE.opacity;
return Math.min(1, Math.max(0.25, value));
}
function safeParseAppearance(raw: string | null): Partial<PerfOverlayAppearance> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as Partial<PerfOverlayAppearance>;
return parsed ?? {};
} catch {
return {};
}
}
function isCorner(value: unknown): value is PerfOverlayCorner {
return value === 'top-right'
|| value === 'top-left'
|| value === 'bottom-right'
|| value === 'bottom-left';
}
function persist(): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(appearance));
} catch {
/* ignore */
}
}
function emit(): void {
listeners.forEach(fn => fn());
}
function setAppearance(next: PerfOverlayAppearance): void {
appearance = next;
persist();
emit();
}
function init(): void {
if (typeof window === 'undefined') return;
const fromStorage = safeParseAppearance(window.localStorage.getItem(STORAGE_KEY));
appearance = {
corner: isCorner(fromStorage.corner) ? fromStorage.corner : DEFAULT_APPEARANCE.corner,
opacity: clampOpacity(fromStorage.opacity ?? DEFAULT_APPEARANCE.opacity),
};
}
init();
export function getPerfOverlayAppearance(): PerfOverlayAppearance {
return appearance;
}
export function subscribePerfOverlayAppearance(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function usePerfOverlayAppearance(): PerfOverlayAppearance {
return useSyncExternalStore(subscribePerfOverlayAppearance, getPerfOverlayAppearance, () => DEFAULT_APPEARANCE);
}
export function setPerfOverlayCorner(corner: PerfOverlayCorner): void {
setAppearance({ ...appearance, corner });
}
export function setPerfOverlayOpacity(opacity: number): void {
setAppearance({ ...appearance, opacity: clampOpacity(opacity) });
}
export function resetPerfOverlayAppearance(): void {
setAppearance({ ...DEFAULT_APPEARANCE });
}
export function perfOverlayCornerClass(corner: PerfOverlayCorner): string {
return `fps-overlay--${corner}`;
}
+114
View File
@@ -0,0 +1,114 @@
import { useSyncExternalStore } from 'react';
import type { PerfProbeFlags } from './perfFlags';
export type PerfOverlayMode = 'off' | 'fps' | 'pinned';
export type ResolvedOverlayVisibility = {
showFps: boolean;
showAnalysis: boolean;
showCover: boolean;
showLive: boolean;
};
const STORAGE_KEY = 'psysonic_perf_overlay_mode_v1';
export const PERF_OVERLAY_MODE_OPTIONS: Array<{ id: PerfOverlayMode; label: string }> = [
{ id: 'off', label: 'Off' },
{ id: 'fps', label: 'FPS only' },
{ id: 'pinned', label: 'Pinned' },
];
const DEFAULT_MODE: PerfOverlayMode = 'off';
let mode: PerfOverlayMode = DEFAULT_MODE;
const listeners = new Set<() => void>();
function emit(): void {
listeners.forEach(fn => fn());
}
function isPerfOverlayMode(value: unknown): value is PerfOverlayMode {
return value === 'off' || value === 'fps' || value === 'pinned';
}
function inferLegacyMode(): PerfOverlayMode {
if (typeof window === 'undefined') return DEFAULT_MODE;
try {
const flagsRaw = window.localStorage.getItem('psysonic_perf_probe_flags_v1');
const pinsRaw = window.localStorage.getItem('psysonic_perf_overlay_pins_v1');
const flags = flagsRaw ? JSON.parse(flagsRaw) as Partial<PerfProbeFlags> : {};
const pins = pinsRaw ? JSON.parse(pinsRaw) as unknown : [];
const anyFlag = Boolean(
flags.showFpsOverlay || flags.showAnalysisPerfOverlay || flags.showCoverPerfOverlay,
);
const anyPin = Array.isArray(pins) && pins.length > 0;
return anyFlag || anyPin ? 'pinned' : DEFAULT_MODE;
} catch {
return DEFAULT_MODE;
}
}
function initMode(): void {
if (typeof window === 'undefined') return;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (isPerfOverlayMode(raw)) {
mode = raw;
return;
}
mode = inferLegacyMode();
} catch {
mode = DEFAULT_MODE;
}
}
initMode();
export function getPerfOverlayMode(): PerfOverlayMode {
return mode;
}
export function setPerfOverlayMode(next: PerfOverlayMode): void {
if (next === mode) return;
mode = next;
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
/* ignore */
}
}
emit();
}
export function resetPerfOverlayMode(): void {
setPerfOverlayMode(DEFAULT_MODE);
}
export function subscribePerfOverlayMode(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function usePerfOverlayMode(): PerfOverlayMode {
return useSyncExternalStore(subscribePerfOverlayMode, getPerfOverlayMode, () => DEFAULT_MODE);
}
export function resolveOverlayVisibility(
overlayMode: PerfOverlayMode,
flags: Pick<PerfProbeFlags, 'showFpsOverlay' | 'showAnalysisPerfOverlay' | 'showCoverPerfOverlay'>,
liveOverlayItemCount: number,
): ResolvedOverlayVisibility {
if (overlayMode === 'off') {
return { showFps: false, showAnalysis: false, showCover: false, showLive: false };
}
if (overlayMode === 'fps') {
return { showFps: true, showAnalysis: false, showCover: false, showLive: false };
}
return {
showFps: flags.showFpsOverlay,
showAnalysis: flags.showAnalysisPerfOverlay,
showCover: flags.showCoverPerfOverlay,
showLive: liveOverlayItemCount > 0,
};
}
+132
View File
@@ -0,0 +1,132 @@
import { useSyncExternalStore } from 'react';
import { clearPerfLiveHistory } from './perfLiveHistory';
import { getPerfOverlayMode } from './perfOverlayMode';
import { getPerfProbeFlags, setPerfProbeFlag, subscribePerfProbeFlags } from './perfFlags';
const STORAGE_KEY = 'psysonic_perf_overlay_pins_v1';
/** Overlay pin ids for live monitor metrics (CPU, memory, UI rates). */
export type PerfLiveOverlayPinId =
| 'cpu:app'
| 'cpu:webkit'
| `cpu:thread:${string}`
| `mem:${string}`
| 'rate:progress'
| 'rate:waveform'
| 'rate:home'
| 'analysis:tpm'
| 'analysis:last';
const PIPELINE_PIN_TO_FLAG = {
'pipeline:fps': 'showFpsOverlay',
'pipeline:analysis': 'showAnalysisPerfOverlay',
'pipeline:cover': 'showCoverPerfOverlay',
} as const;
export type PerfPipelineOverlayPinId = keyof typeof PIPELINE_PIN_TO_FLAG;
export type PerfOverlayPinId = PerfLiveOverlayPinId | PerfPipelineOverlayPinId;
let livePins = new Set<string>();
const liveListeners = new Set<() => void>();
function safeParsePins(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
} catch {
return [];
}
}
function persistLivePins(): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...livePins]));
} catch {
/* ignore */
}
}
function emitLive(): void {
liveListeners.forEach(fn => fn());
}
function initLivePins(): void {
if (typeof window === 'undefined') return;
livePins = new Set(safeParsePins(window.localStorage.getItem(STORAGE_KEY)));
}
initLivePins();
export function getPerfLiveOverlayPins(): ReadonlySet<string> {
return livePins;
}
export function subscribePerfLiveOverlayPins(cb: () => void): () => void {
liveListeners.add(cb);
return () => liveListeners.delete(cb);
}
export function usePerfLiveOverlayPins(): ReadonlySet<string> {
return useSyncExternalStore(subscribePerfLiveOverlayPins, getPerfLiveOverlayPins, () => new Set());
}
export function isPerfLiveOverlayPinned(id: string): boolean {
return livePins.has(id);
}
export function togglePerfLiveOverlayPin(id: PerfLiveOverlayPinId): void {
if (livePins.has(id)) {
livePins.delete(id);
clearPerfLiveHistory(id);
} else {
livePins.add(id);
}
persistLivePins();
emitLive();
}
export function clearPerfLiveOverlayPins(): void {
livePins.clear();
clearPerfLiveHistory();
persistLivePins();
emitLive();
}
export function isPipelineOverlayPinned(id: PerfPipelineOverlayPinId): boolean {
const flag = PIPELINE_PIN_TO_FLAG[id];
return getPerfProbeFlags()[flag];
}
export function togglePipelineOverlayPin(id: PerfPipelineOverlayPinId): void {
const flag = PIPELINE_PIN_TO_FLAG[id];
setPerfProbeFlag(flag, !getPerfProbeFlags()[flag]);
}
export function usePipelineOverlayPinned(id: PerfPipelineOverlayPinId): boolean {
return useSyncExternalStore(
subscribePerfProbeFlags,
() => getPerfProbeFlags()[PIPELINE_PIN_TO_FLAG[id]],
() => false,
);
}
export function hasAnyPerfOverlayVisible(): boolean {
const overlayMode = getPerfOverlayMode();
if (overlayMode === 'off') return false;
if (overlayMode === 'fps') return true;
const flags = getPerfProbeFlags();
return (
flags.showFpsOverlay
|| flags.showAnalysisPerfOverlay
|| flags.showCoverPerfOverlay
|| livePins.size > 0
);
}
export function hasAnyLiveMetricPollNeed(): boolean {
if (getPerfOverlayMode() !== 'pinned') return false;
return livePins.size > 0;
}
+293
View File
@@ -0,0 +1,293 @@
import type { PerfProbeFlags } from './perfFlags';
export type PerfToggleLeaf = {
id: string;
label: string;
description?: string;
flag: keyof PerfProbeFlags;
};
export type PerfToggleEngineLeaf = {
id: string;
label: string;
description?: string;
engine: 'hotCache' | 'normalization' | 'logging';
};
export type PerfToggleGroup = {
id: string;
label: string;
children: PerfToggleNode[];
};
export type PerfToggleNode = PerfToggleLeaf | PerfToggleEngineLeaf | PerfToggleGroup;
export function isPerfToggleGroup(node: PerfToggleNode): node is PerfToggleGroup {
return 'children' in node;
}
export function isPerfToggleEngineLeaf(node: PerfToggleNode): node is PerfToggleEngineLeaf {
return 'engine' in node;
}
/** Diagnostic disable toggles as a navigable tree (replaces flat “phases”). */
export const PERF_PROBE_TOGGLE_TREE: PerfToggleGroup[] = [
{
id: 'shell',
label: 'Shell & chrome',
children: [
{
id: 'shell-player',
label: 'Player bar',
children: [
{
id: 'disableWaveformCanvas',
label: 'Waveform canvas',
description: 'Disable only PlayerBar waveform (`WaveformSeek`)',
flag: 'disableWaveformCanvas',
},
{
id: 'disablePlayerProgressUi',
label: 'Live progress UI',
description: 'Disable player time + seek/progress bindings',
flag: 'disablePlayerProgressUi',
},
{
id: 'disableMarqueeScroll',
label: 'Marquee scroll',
description: 'Disable scrolling marquee text',
flag: 'disableMarqueeScroll',
},
],
},
{
id: 'shell-visual',
label: 'Visual effects',
children: [
{
id: 'disableBackdropBlur',
label: 'Backdrop blur',
flag: 'disableBackdropBlur',
},
{
id: 'disableCssAnimations',
label: 'CSS animations',
description: 'Disable CSS animations and transitions',
flag: 'disableCssAnimations',
},
],
},
{
id: 'shell-layout',
label: 'Layout & portals',
children: [
{
id: 'disableOverlayScrollbars',
label: 'Overlay scrollbars',
description: 'Disable overlay scrollbar engine (JS + rail)',
flag: 'disableOverlayScrollbars',
},
{
id: 'disableTooltipPortal',
label: 'Tooltip portal',
flag: 'disableTooltipPortal',
},
{
id: 'disableQueuePanelMount',
label: 'Queue panel',
description: 'Disable QueuePanel mount (desktop right column)',
flag: 'disableQueuePanelMount',
},
],
},
{
id: 'shell-network',
label: 'Network & engine',
children: [
{
id: 'disableBackgroundPolling',
label: 'Background polling',
description: 'Connection status + radio metadata',
flag: 'disableBackgroundPolling',
},
{
id: 'hotCache',
label: 'Hot-cache prefetch',
description: 'Disable hot-cache prefetch downloads',
engine: 'hotCache',
},
{
id: 'normalization',
label: 'Normalization engine',
description: 'Set normalization to Off',
engine: 'normalization',
},
{
id: 'logging',
label: 'Runtime logging',
description: 'Set runtime logging mode to Off',
engine: 'logging',
},
],
},
],
},
{
id: 'mainstage',
label: 'Mainstage (center content)',
children: [
{
id: 'disableMainRouteContentMount',
label: 'Route content mount',
description: 'Disable central route content mount',
flag: 'disableMainRouteContentMount',
},
{
id: 'mainstage-shared',
label: 'Shared layers',
children: [
{
id: 'disableMainstageStickyHeader',
label: 'Sticky headers',
description: 'Tracks + Albums sticky toolbars',
flag: 'disableMainstageStickyHeader',
},
],
},
{
id: 'mainstage-home',
label: 'Home (`/`)',
children: [
{
id: 'home-hero',
label: 'Hero',
children: [
{
id: 'disableMainstageHero',
label: 'Hero block',
flag: 'disableMainstageHero',
},
{
id: 'disableMainstageHeroBackdrop',
label: 'Hero backdrop',
description: 'Disable backdrop/crossfade only',
flag: 'disableMainstageHeroBackdrop',
},
],
},
{
id: 'home-rails',
label: 'Rails',
children: [
{
id: 'disableMainstageRails',
label: 'All rails',
description: '`AlbumRow` + `SongRail`',
flag: 'disableMainstageRails',
},
{
id: 'disableHomeAlbumRows',
label: 'Album rows only',
flag: 'disableHomeAlbumRows',
},
{
id: 'disableHomeSongRails',
label: 'Song rails only',
flag: 'disableHomeSongRails',
},
{
id: 'disableMainstageRailInteractivity',
label: 'Rail interactivity',
description: 'Scroll/nav handlers',
flag: 'disableMainstageRailInteractivity',
},
],
},
{
id: 'home-artwork',
label: 'Artwork',
children: [
{
id: 'disableMainstageRailArtwork',
label: 'Rail artwork (all pages)',
flag: 'disableMainstageRailArtwork',
},
{
id: 'disableHomeRailArtwork',
label: 'Rail artwork (Home only)',
flag: 'disableHomeRailArtwork',
},
{
id: 'disableHomeArtworkFx',
label: 'Card visual effects',
description: 'Keep artwork; disable hover/overlay/shadows',
flag: 'disableHomeArtworkFx',
},
{
id: 'disableHomeArtworkClip',
label: 'Flatten clipping',
description: 'No rounded corners/masks on Home cards',
flag: 'disableHomeArtworkClip',
},
],
},
{
id: 'home-discover',
label: 'Discover grid',
children: [
{
id: 'disableMainstageGridCards-home',
label: 'Artist chip grid',
flag: 'disableMainstageGridCards',
},
],
},
],
},
{
id: 'mainstage-tracks',
label: 'Tracks (`/tracks`)',
children: [
{
id: 'tracks-hero',
label: 'Hero block',
flag: 'disableMainstageHero',
},
{
id: 'tracks-rails',
label: 'Rails',
flag: 'disableMainstageRails',
},
{
id: 'tracks-rail-art',
label: 'Rail artwork',
flag: 'disableMainstageRailArtwork',
},
{
id: 'tracks-rail-interact',
label: 'Rail interactivity',
flag: 'disableMainstageRailInteractivity',
},
{
id: 'disableMainstageVirtualLists',
label: 'Virtual browse list',
description: 'Disable `VirtualSongList`',
flag: 'disableMainstageVirtualLists',
},
],
},
{
id: 'mainstage-albums',
label: 'Albums (`/albums`)',
children: [
{
id: 'disableMainstageGridCards-albums',
label: 'Album card grid',
description: 'Disable `AlbumCard` list',
flag: 'disableMainstageGridCards',
},
],
},
],
},
];