mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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:');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.25–1 (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}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user