mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(lib): fold utils/perf into lib/perf + utils/ui (DOM utilities) into lib/dom
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Creates a slim, themed drag ghost and registers it via setDragImage.
|
||||
* The element is cleaned up when the drag completes (dragend).
|
||||
*
|
||||
* On Linux (WebKitGTK), the GTK drag subsystem captures the drag image
|
||||
* asynchronously — removing the element in the same tick or via
|
||||
* setTimeout(…, 0) causes the image to be gone before GTK reads it,
|
||||
* which makes GTK treat the entire drag as invalid (forbidden cursor).
|
||||
* Using a dragend listener ensures the element persists for the full
|
||||
* duration of the drag operation on all platforms.
|
||||
*
|
||||
* @param dataTransfer - the event's dataTransfer object
|
||||
* @param label - primary text (track/album title)
|
||||
* @param opts.coverUrl - optional thumbnail URL (shown as 24×24 image)
|
||||
*/
|
||||
export function setDragGhost(
|
||||
dataTransfer: DataTransfer,
|
||||
label: string,
|
||||
opts: { coverUrl?: string } = {},
|
||||
): void {
|
||||
const el = document.createElement('div');
|
||||
|
||||
el.style.cssText = [
|
||||
'position:fixed',
|
||||
'left:-9999px',
|
||||
'top:-9999px',
|
||||
'display:flex',
|
||||
'align-items:center',
|
||||
'gap:8px',
|
||||
`padding:0 14px 0 ${opts.coverUrl ? '6px' : '10px'}`,
|
||||
'height:34px',
|
||||
'max-width:240px',
|
||||
'border-radius:17px',
|
||||
'background:var(--bg-card,#fff)',
|
||||
'border:1px solid var(--border,rgba(0,0,0,.12))',
|
||||
'border-left:3px solid var(--accent,#888)',
|
||||
'box-shadow:0 4px 20px rgba(0,0,0,.22)',
|
||||
'font-family:var(--font-ui,sans-serif)',
|
||||
'font-size:13px',
|
||||
'font-weight:500',
|
||||
'color:var(--text-primary,#222)',
|
||||
'pointer-events:none',
|
||||
'white-space:nowrap',
|
||||
'overflow:hidden',
|
||||
'z-index:99999',
|
||||
].join(';');
|
||||
|
||||
if (opts.coverUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = opts.coverUrl;
|
||||
img.style.cssText = 'width:24px;height:24px;border-radius:4px;object-fit:cover;flex-shrink:0;';
|
||||
el.appendChild(img);
|
||||
} else {
|
||||
const dot = document.createElement('span');
|
||||
dot.style.cssText = 'width:6px;height:6px;border-radius:50%;background:var(--accent,#888);flex-shrink:0;';
|
||||
el.appendChild(dot);
|
||||
}
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = label;
|
||||
text.style.cssText = 'overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0;';
|
||||
el.appendChild(text);
|
||||
|
||||
document.body.appendChild(el);
|
||||
dataTransfer.setDragImage(el, 20, 17);
|
||||
|
||||
// Clean up the ghost element when the drag ends, not immediately.
|
||||
// This is critical for Linux/WebKitGTK where the drag image is
|
||||
// captured asynchronously by GTK — removing it sooner causes
|
||||
// the "forbidden" cursor and blocks the entire drop operation.
|
||||
const cleanup = () => {
|
||||
el.remove();
|
||||
document.removeEventListener('dragend', cleanup);
|
||||
};
|
||||
document.addEventListener('dragend', cleanup, { once: true });
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* DOM-touching tests for `dynamicColors.ts` — orchestrator paths in
|
||||
* `extractCoverColors` that exercise the Image / canvas / fetch surfaces.
|
||||
*
|
||||
* jsdom ships HTMLImageElement but does not fire onload/onerror; canvas
|
||||
* `getContext('2d')` returns null. We swap in lightweight mocks so the
|
||||
* orchestrator's branch logic gets covered without a real browser.
|
||||
*
|
||||
* Pure-math helpers live in `dynamicColors.test.ts`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { extractCoverColors } from '@/lib/dom/dynamicColors';
|
||||
|
||||
type ImageBehavior = 'load' | 'error';
|
||||
|
||||
interface MockImageHandle {
|
||||
setBehavior(b: ImageBehavior): void;
|
||||
}
|
||||
|
||||
function installImageMock(): MockImageHandle {
|
||||
let behavior: ImageBehavior = 'load';
|
||||
const original = globalThis.Image;
|
||||
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
crossOrigin = '';
|
||||
private _src = '';
|
||||
get src() { return this._src; }
|
||||
set src(v: string) {
|
||||
this._src = v;
|
||||
queueMicrotask(() => {
|
||||
if (behavior === 'load') this.onload?.();
|
||||
else this.onerror?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.Image = MockImage as unknown as typeof Image;
|
||||
return {
|
||||
setBehavior(b: ImageBehavior) { behavior = b; },
|
||||
[Symbol.dispose]: () => { globalThis.Image = original; },
|
||||
} as MockImageHandle;
|
||||
}
|
||||
|
||||
function installCanvasContextMock(opts: { tainted?: boolean } = {}): () => void {
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement, _id: string) {
|
||||
if (opts.tainted) {
|
||||
return {
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => {
|
||||
throw new Error('tainted canvas');
|
||||
}),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
// Return a context with a fixed 8x8 image where every pixel is a vibrant
|
||||
// orange — the saturation pick will land on it, exercising the
|
||||
// sampleImageToAccent loop.
|
||||
const data = new Uint8ClampedArray(8 * 8 * 4);
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = 230; // R
|
||||
data[i + 1] = 110; // G
|
||||
data[i + 2] = 30; // B
|
||||
data[i + 3] = 255; // A
|
||||
}
|
||||
return {
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({ data, width: 8, height: 8 })),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
} as typeof HTMLCanvasElement.prototype.getContext;
|
||||
return () => {
|
||||
HTMLCanvasElement.prototype.getContext = originalGetContext;
|
||||
};
|
||||
}
|
||||
|
||||
describe('extractCoverColors — early returns', () => {
|
||||
it('returns empty for an empty URL', async () => {
|
||||
expect(await extractCoverColors('')).toEqual({ accent: '' });
|
||||
});
|
||||
|
||||
it('returns empty for the bundled logo (avoids self-tinting)', async () => {
|
||||
expect(await extractCoverColors('/assets/logo-psysonic-256.png')).toEqual({ accent: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCoverColors — blob: URL', () => {
|
||||
let imageMock: MockImageHandle;
|
||||
let restoreCanvas: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
imageMock = installImageMock();
|
||||
restoreCanvas = installCanvasContextMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(imageMock as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.();
|
||||
restoreCanvas();
|
||||
});
|
||||
|
||||
it('returns an accent on a successful sample', async () => {
|
||||
imageMock.setBehavior('load');
|
||||
const result = await extractCoverColors('blob:fake/abc-123');
|
||||
expect(result.accent).toMatch(/^rgb\(\d+,\d+,\d+\)$/);
|
||||
});
|
||||
|
||||
it('returns empty when image load fails', async () => {
|
||||
imageMock.setBehavior('error');
|
||||
const result = await extractCoverColors('blob:fake/oops');
|
||||
expect(result).toEqual({ accent: '' });
|
||||
});
|
||||
|
||||
it('returns empty when the canvas is tainted', async () => {
|
||||
imageMock.setBehavior('load');
|
||||
restoreCanvas(); // swap to tainted canvas
|
||||
restoreCanvas = installCanvasContextMock({ tainted: true });
|
||||
const result = await extractCoverColors('blob:fake/tainted');
|
||||
expect(result).toEqual({ accent: '' });
|
||||
});
|
||||
|
||||
it('returns empty when getContext returns null', async () => {
|
||||
imageMock.setBehavior('load');
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = (() => null) as typeof HTMLCanvasElement.prototype.getContext;
|
||||
try {
|
||||
const result = await extractCoverColors('blob:fake/no-ctx');
|
||||
expect(result).toEqual({ accent: '' });
|
||||
} finally {
|
||||
HTMLCanvasElement.prototype.getContext = originalGetContext;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCoverColors — remote http(s) URL', () => {
|
||||
let imageMock: MockImageHandle;
|
||||
let restoreCanvas: () => void;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
imageMock = installImageMock();
|
||||
restoreCanvas = installCanvasContextMock();
|
||||
fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(imageMock as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.();
|
||||
restoreCanvas();
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('fetches the blob, samples, and revokes the object URL', async () => {
|
||||
imageMock.setBehavior('load');
|
||||
const blob = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' });
|
||||
fetchMock.mockResolvedValue({ ok: true, blob: async () => blob } as Response);
|
||||
|
||||
const result = await extractCoverColors('https://music.example.com/cover.png');
|
||||
|
||||
expect(result.accent).toMatch(/^rgb\(\d+,\d+,\d+\)$/);
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://music.example.com/cover.png');
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to crossOrigin=anonymous when fetch fails', async () => {
|
||||
imageMock.setBehavior('load');
|
||||
fetchMock.mockRejectedValue(new Error('CORS blocked'));
|
||||
|
||||
const result = await extractCoverColors('https://music.example.com/cover.png');
|
||||
// Anonymous-CORS image load succeeds in the mock → still samples.
|
||||
expect(result.accent).toMatch(/^rgb\(\d+,\d+,\d+\)$/);
|
||||
});
|
||||
|
||||
it('falls back to crossOrigin=anonymous when fetch returns !ok', async () => {
|
||||
imageMock.setBehavior('load');
|
||||
fetchMock.mockResolvedValue({ ok: false, blob: async () => new Blob() } as Response);
|
||||
|
||||
const result = await extractCoverColors('https://music.example.com/cover.png');
|
||||
expect(result.accent).toMatch(/^rgb\(\d+,\d+,\d+\)$/);
|
||||
});
|
||||
|
||||
it('returns empty when both fetch and the anonymous fallback fail', async () => {
|
||||
imageMock.setBehavior('error');
|
||||
fetchMock.mockRejectedValue(new Error('network down'));
|
||||
|
||||
const result = await extractCoverColors('https://music.example.com/cover.png');
|
||||
expect(result).toEqual({ accent: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCoverColors — other URL shapes', () => {
|
||||
let imageMock: MockImageHandle;
|
||||
let restoreCanvas: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
imageMock = installImageMock();
|
||||
restoreCanvas = installCanvasContextMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(imageMock as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.();
|
||||
restoreCanvas();
|
||||
});
|
||||
|
||||
it('handles a data: URL through the same blob-or-data path', async () => {
|
||||
imageMock.setBehavior('load');
|
||||
const result = await extractCoverColors('data:image/png;base64,iVBORw0KGgo=');
|
||||
expect(result.accent).toMatch(/^rgb\(\d+,\d+,\d+\)$/);
|
||||
});
|
||||
|
||||
it('handles a relative path as a direct image load', async () => {
|
||||
imageMock.setBehavior('load');
|
||||
const result = await extractCoverColors('/local/cover.jpg');
|
||||
expect(result.accent).toMatch(/^rgb\(\d+,\d+,\d+\)$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
srgbToLinear,
|
||||
relativeLuminance,
|
||||
contrastRatio,
|
||||
rgbToHsl,
|
||||
hslToRgb,
|
||||
ensureContrast,
|
||||
} from '@/lib/dom/dynamicColors';
|
||||
|
||||
// ─── srgbToLinear ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('srgbToLinear', () => {
|
||||
it('maps 0 to 0', () => expect(srgbToLinear(0)).toBe(0));
|
||||
it('maps 1 to 1', () => expect(srgbToLinear(1)).toBeCloseTo(1));
|
||||
it('uses the low-end linear formula below 0.04045', () => {
|
||||
expect(srgbToLinear(0.04)).toBeCloseTo(0.04 / 12.92, 6);
|
||||
});
|
||||
it('uses the gamma formula above 0.04045', () => {
|
||||
expect(srgbToLinear(0.5)).toBeCloseTo(Math.pow((0.5 + 0.055) / 1.055, 2.4), 6);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── relativeLuminance ────────────────────────────────────────────────────────
|
||||
|
||||
describe('relativeLuminance', () => {
|
||||
it('returns 0 for black (0,0,0)', () => expect(relativeLuminance(0, 0, 0)).toBe(0));
|
||||
it('returns 1 for white (1,1,1)', () => expect(relativeLuminance(1, 1, 1)).toBeCloseTo(1, 5));
|
||||
it('weights green highest (0.7152)', () => {
|
||||
const greenOnly = relativeLuminance(0, 1, 0);
|
||||
const redOnly = relativeLuminance(1, 0, 0);
|
||||
const blueOnly = relativeLuminance(0, 0, 1);
|
||||
expect(greenOnly).toBeGreaterThan(redOnly);
|
||||
expect(redOnly).toBeGreaterThan(blueOnly);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── contrastRatio ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('contrastRatio', () => {
|
||||
it('returns 21 for pure black on white', () => {
|
||||
expect(contrastRatio(0, 1)).toBeCloseTo(21, 1);
|
||||
});
|
||||
it('returns 1 when both luminances are equal', () => {
|
||||
expect(contrastRatio(0.2, 0.2)).toBeCloseTo(1, 5);
|
||||
});
|
||||
it('is symmetric', () => {
|
||||
expect(contrastRatio(0.1, 0.5)).toBeCloseTo(contrastRatio(0.5, 0.1), 10);
|
||||
});
|
||||
it('returns ≥ 4.5 for a light accent on near-black (FS bg)', () => {
|
||||
const accent = relativeLuminance(200 / 255, 160 / 255, 60 / 255);
|
||||
expect(contrastRatio(accent, 0.01)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── rgbToHsl / hslToRgb round-trip ──────────────────────────────────────────
|
||||
|
||||
describe('rgbToHsl / hslToRgb', () => {
|
||||
const cases: Array<[number, number, number]> = [
|
||||
[255, 0, 0],
|
||||
[0, 255, 0],
|
||||
[0, 0, 255],
|
||||
[128, 128, 128],
|
||||
[255, 255, 255],
|
||||
[0, 0, 0],
|
||||
[200, 100, 50],
|
||||
];
|
||||
it.each(cases)('round-trips rgb(%i,%i,%i)', (r, g, b) => {
|
||||
const [h, s, l] = rgbToHsl(r, g, b);
|
||||
const [rr, gg, bb] = hslToRgb(h, s, l);
|
||||
expect(rr).toBeCloseTo(r, -1); // within ±1 due to rounding
|
||||
expect(gg).toBeCloseTo(g, -1);
|
||||
expect(bb).toBeCloseTo(b, -1);
|
||||
});
|
||||
|
||||
it('pure grey has saturation 0', () => {
|
||||
const [, s] = rgbToHsl(128, 128, 128);
|
||||
expect(s).toBe(0);
|
||||
});
|
||||
it('pure red has hue 0°', () => {
|
||||
const [h] = rgbToHsl(255, 0, 0);
|
||||
expect(h).toBeCloseTo(0, 0);
|
||||
});
|
||||
it('pure green has hue 120°', () => {
|
||||
const [h] = rgbToHsl(0, 255, 0);
|
||||
expect(h).toBeCloseTo(120, 0);
|
||||
});
|
||||
it('pure blue has hue 240°', () => {
|
||||
const [h] = rgbToHsl(0, 0, 255);
|
||||
expect(h).toBeCloseTo(240, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ensureContrast ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('ensureContrast', () => {
|
||||
const BG_LUMINANCE = 0.010; // near-black FS player background
|
||||
const MIN_RATIO = 4.5;
|
||||
|
||||
function meetsContrast(rgb: [number, number, number]): boolean {
|
||||
const l = relativeLuminance(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255);
|
||||
return contrastRatio(l, BG_LUMINANCE) >= MIN_RATIO;
|
||||
}
|
||||
|
||||
it('returns input unchanged if already sufficient', () => {
|
||||
// White is always sufficient
|
||||
const result = ensureContrast([255, 255, 255], BG_LUMINANCE, MIN_RATIO);
|
||||
expect(result).toEqual([255, 255, 255]);
|
||||
});
|
||||
|
||||
it('lightens a very dark color until contrast is met', () => {
|
||||
const result = ensureContrast([10, 10, 10], BG_LUMINANCE, MIN_RATIO);
|
||||
expect(meetsContrast(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('lightens a dark saturated color until contrast is met', () => {
|
||||
// Dark blue — typical for dark album covers
|
||||
const result = ensureContrast([20, 20, 80], BG_LUMINANCE, MIN_RATIO);
|
||||
expect(meetsContrast(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves a mid-tone color that already meets the ratio', () => {
|
||||
// Yellow-ish, high luminance — should already pass
|
||||
const input: [number, number, number] = [255, 230, 50];
|
||||
const result = ensureContrast(input, BG_LUMINANCE, MIN_RATIO);
|
||||
expect(meetsContrast(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('always produces a result with contrast ≥ MIN_RATIO', () => {
|
||||
// Stress-test with a range of dark, saturated colors
|
||||
const darkColors: Array<[number, number, number]> = [
|
||||
[5, 30, 60],
|
||||
[60, 5, 30],
|
||||
[30, 60, 5],
|
||||
[80, 0, 120],
|
||||
[0, 0, 0],
|
||||
];
|
||||
for (const c of darkColors) {
|
||||
const result = ensureContrast(c, BG_LUMINANCE, MIN_RATIO);
|
||||
expect(meetsContrast(result)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles a very demanding minRatio gracefully (returns white)', () => {
|
||||
// 21:1 is the theoretical maximum — only black-on-white achieves it.
|
||||
// Any input should at least not throw and produce some output.
|
||||
const result = ensureContrast([10, 10, 10], BG_LUMINANCE, 21);
|
||||
expect(result.length).toBe(3);
|
||||
result.forEach(c => { expect(c).toBeGreaterThanOrEqual(0); expect(c).toBeLessThanOrEqual(255); });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
// ─── Dynamic Cover Colors ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Extracts a vibrant accent color from an album cover URL using the Canvas API.
|
||||
// Designed exclusively for the Fullscreen Player — applied as a CSS custom
|
||||
// property on the .fs-player root, inheriting down to all children.
|
||||
//
|
||||
// Guarantees WCAG 4.5:1 contrast against the FS player's near-black background
|
||||
// by progressively lightening the extracted color in HSL space until the ratio
|
||||
// is met. Falls back to an empty string (→ CSS falls back to var(--accent)).
|
||||
|
||||
export interface CoverColors {
|
||||
/** CSS color string, e.g. "rgb(200,120,60)". Empty string = use fallback. */
|
||||
accent: string;
|
||||
}
|
||||
|
||||
// ─── WCAG math (pure — unit-testable) ─────────────────────────────────────────
|
||||
|
||||
/** Convert a sRGB channel [0, 1] to linear light. */
|
||||
export function srgbToLinear(c: number): number {
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
/** WCAG relative luminance for linear-light RGB channels [0, 1]. */
|
||||
export function relativeLuminance(r: number, g: number, b: number): number {
|
||||
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
|
||||
}
|
||||
|
||||
/** WCAG contrast ratio given two relative luminances. Always ≥ 1. */
|
||||
export function contrastRatio(l1: number, l2: number): number {
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
// ─── HSL helpers (pure) ───────────────────────────────────────────────────────
|
||||
|
||||
/** RGB [0–255] → HSL [0–360, 0–1, 0–1]. */
|
||||
export function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
const rn = r / 255, gn = g / 255, bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return [0, 0, l];
|
||||
const d = max - min;
|
||||
const s = l < 0.5 ? d / (max + min) : d / (2 - max - min);
|
||||
let h: number;
|
||||
switch (max) {
|
||||
case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
|
||||
case gn: h = ((bn - rn) / d + 2) / 6; break;
|
||||
default: h = ((rn - gn) / d + 4) / 6;
|
||||
}
|
||||
return [h * 360, s, l];
|
||||
}
|
||||
|
||||
function hue2rgb(p: number, q: number, t: number): number {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** HSL [0–360, 0–1, 0–1] → RGB [0–255]. */
|
||||
export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
h = h / 360;
|
||||
if (s === 0) {
|
||||
const v = Math.round(l * 255);
|
||||
return [v, v, v];
|
||||
}
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
return [
|
||||
Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
||||
Math.round(hue2rgb(p, q, h) * 255),
|
||||
Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Contrast enforcement (pure — unit-testable) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Given an RGB color [0–255] and the luminance of the background it will sit
|
||||
* on, progressively increases HSL lightness in 0.04 steps until the contrast
|
||||
* ratio reaches `minRatio`. Returns white [255,255,255] as the ultimate
|
||||
* fallback if even L=1 doesn't suffice (can only happen at extreme minRatio
|
||||
* values, e.g. 21:1).
|
||||
*/
|
||||
export function ensureContrast(
|
||||
rgb: [number, number, number],
|
||||
bgLuminance: number,
|
||||
minRatio: number,
|
||||
): [number, number, number] {
|
||||
const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
|
||||
|
||||
// Already meeting the requirement?
|
||||
const initialLum = relativeLuminance(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255);
|
||||
if (contrastRatio(initialLum, bgLuminance) >= minRatio) return [...rgb];
|
||||
|
||||
// Lighten in steps of 4 % (25 iterations max → terminates)
|
||||
for (let step = 1; step <= 25; step++) {
|
||||
const newL = Math.min(1, l + step * 0.04);
|
||||
const newRgb = hslToRgb(h, s, newL);
|
||||
const newLum = relativeLuminance(newRgb[0] / 255, newRgb[1] / 255, newRgb[2] / 255);
|
||||
if (contrastRatio(newLum, bgLuminance) >= minRatio) return newRgb;
|
||||
}
|
||||
return [255, 255, 255];
|
||||
}
|
||||
|
||||
// ─── Canvas extraction (requires DOM) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The FS player mesh background is a very dark near-black.
|
||||
* luminance ≈ 0.010 is a conservative upper bound — the color contrast will be
|
||||
* at least this good.
|
||||
*/
|
||||
const FS_BG_LUMINANCE = 0.010;
|
||||
const MIN_CONTRAST = 4.5;
|
||||
|
||||
function isRemoteHttpUrl(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url);
|
||||
}
|
||||
|
||||
function isBlobOrDataUrl(url: string): boolean {
|
||||
return url.startsWith('blob:') || url.startsWith('data:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples decoded pixels from an HTMLImageElement (already loaded).
|
||||
* Throws if the canvas is tainted (caller should catch).
|
||||
*/
|
||||
function sampleImageToAccent(img: HTMLImageElement): CoverColors {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 8;
|
||||
canvas.height = 8;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return { accent: '' };
|
||||
|
||||
ctx.drawImage(img, 0, 0, 8, 8);
|
||||
const { data } = ctx.getImageData(0, 0, 8, 8);
|
||||
|
||||
let bestSat = -1;
|
||||
let bestR = 180;
|
||||
let bestG = 100;
|
||||
let bestB = 50;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const [, s] = rgbToHsl(r, g, b);
|
||||
if (s > bestSat) {
|
||||
bestSat = s;
|
||||
bestR = r;
|
||||
bestG = g;
|
||||
bestB = b;
|
||||
}
|
||||
}
|
||||
|
||||
const [fr, fg, fb] = ensureContrast([bestR, bestG, bestB], FS_BG_LUMINANCE, MIN_CONTRAST);
|
||||
return { accent: `rgb(${fr},${fg},${fb})` };
|
||||
}
|
||||
|
||||
function loadImage(url: string, crossOrigin: '' | 'anonymous'): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('image load failed'));
|
||||
if (crossOrigin) img.crossOrigin = crossOrigin;
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads `imageUrl` into an 8×8 canvas and finds the most vibrant pixel
|
||||
* (highest HSL saturation). Applies `ensureContrast` to guarantee
|
||||
* WCAG AA readability against the FS player background.
|
||||
*
|
||||
* Remote `https?://` URLs would taint a canvas if drawn from a plain `<img>`
|
||||
* without CORS — we prefer `fetch` → `blob:` for sampling, then fall back to
|
||||
* `crossOrigin = "anonymous"` when the server allows it.
|
||||
*
|
||||
* Resolves with `{ accent: '' }` on any error — the caller's CSS
|
||||
* `var(--dynamic-fs-accent, var(--accent))` then falls back to the theme accent.
|
||||
*/
|
||||
export async function extractCoverColors(imageUrl: string): Promise<CoverColors> {
|
||||
if (!imageUrl) return { accent: '' };
|
||||
if (imageUrl.includes('logo-psysonic')) return { accent: '' };
|
||||
|
||||
const safeSample = async (url: string, co: '' | 'anonymous'): Promise<CoverColors> => {
|
||||
try {
|
||||
const img = await loadImage(url, co);
|
||||
try {
|
||||
return sampleImageToAccent(img);
|
||||
} catch {
|
||||
return { accent: '' };
|
||||
}
|
||||
} catch {
|
||||
return { accent: '' };
|
||||
}
|
||||
};
|
||||
|
||||
if (isBlobOrDataUrl(imageUrl)) {
|
||||
return safeSample(imageUrl, '');
|
||||
}
|
||||
|
||||
if (isRemoteHttpUrl(imageUrl)) {
|
||||
try {
|
||||
const resp = await fetch(imageUrl);
|
||||
if (resp.ok) {
|
||||
const blob = await resp.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
return await safeSample(objectUrl, '');
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// CORS / network — try credentialed image load if server sends ACAO for art
|
||||
}
|
||||
return safeSample(imageUrl, 'anonymous');
|
||||
}
|
||||
|
||||
return safeSample(imageUrl, '');
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Duration-based ease-out scroll animator.
|
||||
*
|
||||
* Animates scrollTop from the current position to the target over a fixed
|
||||
* duration using a cubic ease-out curve. Calling scrollTo() mid-flight
|
||||
* restarts cleanly from wherever the container currently sits, so fast
|
||||
* line changes never look jerky or skip.
|
||||
*/
|
||||
export class EaseScroller {
|
||||
private container : HTMLElement;
|
||||
private startY = 0;
|
||||
private targetY = 0;
|
||||
private startTime = 0;
|
||||
private rafId: number | null = null;
|
||||
|
||||
private readonly duration: number;
|
||||
|
||||
constructor(container: HTMLElement, duration = 650) {
|
||||
this.container = container;
|
||||
this.targetY = container.scrollTop;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
scrollTo(y: number) {
|
||||
this.startY = this.container.scrollTop;
|
||||
this.targetY = Math.max(0, y);
|
||||
this.startTime = performance.now();
|
||||
if (this.rafId === null) this.rafId = requestAnimationFrame(this.tick);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
jump(y: number) {
|
||||
this.stop();
|
||||
this.container.scrollTop = y;
|
||||
this.targetY = y;
|
||||
}
|
||||
|
||||
private tick = (now: number) => {
|
||||
const t = Math.min((now - this.startTime) / this.duration, 1);
|
||||
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
|
||||
this.container.scrollTop = this.startY + (this.targetY - this.startY) * ease;
|
||||
if (t < 1) {
|
||||
this.rafId = requestAnimationFrame(this.tick);
|
||||
} else {
|
||||
this.container.scrollTop = this.targetY;
|
||||
this.rafId = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the scroll position that places `el` at `fraction` from the top
|
||||
* of `container` (0 = top edge, 0.35 = Apple Music-style, 0.5 = centre).
|
||||
*/
|
||||
export function targetForFraction(
|
||||
container: HTMLElement,
|
||||
el : HTMLElement,
|
||||
fraction = 0.35,
|
||||
): number {
|
||||
const cRect = container.getBoundingClientRect();
|
||||
const eRect = el.getBoundingClientRect();
|
||||
return container.scrollTop + (eRect.top - cRect.top) - cRect.height * fraction + eRect.height / 2;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export type OverlayScrollbarThumbMeta = {
|
||||
thumbH: number;
|
||||
thumbT: number;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param trackHeight — pixel height of the overlay rail (inset top/bottom).
|
||||
* When shorter than the viewport, thumb size/position must use this or the
|
||||
* thumb’s bottom extends past the visible rail at max scroll.
|
||||
*/
|
||||
function overflowYAllowsUserScroll(overflowY: string): boolean {
|
||||
return overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay';
|
||||
}
|
||||
|
||||
export function computeOverlayScrollbarThumbMeta(
|
||||
el: HTMLElement | null,
|
||||
trackHeight?: number,
|
||||
): OverlayScrollbarThumbMeta {
|
||||
if (!el) return { thumbH: 0, thumbT: 0, visible: false };
|
||||
const { overflowY } = getComputedStyle(el);
|
||||
if (!overflowYAllowsUserScroll(overflowY)) {
|
||||
return { thumbH: 0, thumbT: 0, visible: false };
|
||||
}
|
||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||
if (scrollHeight <= clientHeight + 1) {
|
||||
return { thumbH: 0, thumbT: 0, visible: false };
|
||||
}
|
||||
const th =
|
||||
trackHeight != null && trackHeight > 0 ? Math.min(trackHeight, clientHeight) : clientHeight;
|
||||
const ratio = clientHeight / scrollHeight;
|
||||
const rawH = Math.round(ratio * th);
|
||||
const thumbH = Math.min(th, Math.max(24, rawH));
|
||||
const range = Math.max(0, th - thumbH);
|
||||
const scrollRange = scrollHeight - clientHeight;
|
||||
const thumbT =
|
||||
scrollRange > 0
|
||||
? Math.min(range, Math.max(0, Math.round((scrollTop / scrollRange) * range)))
|
||||
: 0;
|
||||
return { thumbH, thumbT, visible: true };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { computeOverlayScrollbarThumbMeta } from '@/lib/dom/overlayScrollbarMetrics';
|
||||
|
||||
/**
|
||||
* Drag the overlay scrollbar thumb (native bar is hidden). Maps pointer delta
|
||||
* to scrollTop using the same thumb/range geometry as the visual thumb.
|
||||
*/
|
||||
export function bindOverlayScrollbarThumbDrag(
|
||||
e: ReactPointerEvent<HTMLElement>,
|
||||
scrollEl: HTMLDivElement | null,
|
||||
): void {
|
||||
if (e.button !== 0 || !scrollEl) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const thumb = e.currentTarget;
|
||||
const rail = thumb.parentElement;
|
||||
const trackH =
|
||||
rail instanceof HTMLElement && rail.clientHeight > 0 ? rail.clientHeight : scrollEl.clientHeight;
|
||||
const meta = computeOverlayScrollbarThumbMeta(scrollEl, trackH);
|
||||
if (!meta.visible) return;
|
||||
|
||||
const { scrollHeight, clientHeight } = scrollEl;
|
||||
const scrollRange = scrollHeight - clientHeight;
|
||||
const range = trackH - meta.thumbH;
|
||||
if (range <= 1) return;
|
||||
|
||||
const startScroll = scrollEl.scrollTop;
|
||||
const startY = e.clientY;
|
||||
const pointerId = e.pointerId;
|
||||
|
||||
thumb.classList.add('is-thumb-dragging');
|
||||
document.body.classList.add('is-overlay-scrollbar-thumb-drag');
|
||||
try {
|
||||
thumb.setPointerCapture(pointerId);
|
||||
} catch {
|
||||
thumb.classList.remove('is-thumb-dragging');
|
||||
document.body.classList.remove('is-overlay-scrollbar-thumb-drag');
|
||||
return;
|
||||
}
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== pointerId) return;
|
||||
const dy = ev.clientY - startY;
|
||||
const next = startScroll + (dy / range) * scrollRange;
|
||||
scrollEl.scrollTop = Math.max(0, Math.min(scrollRange, next));
|
||||
};
|
||||
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
if (ev.pointerId !== pointerId) return;
|
||||
thumb.classList.remove('is-thumb-dragging');
|
||||
document.body.classList.remove('is-overlay-scrollbar-thumb-drag');
|
||||
try {
|
||||
thumb.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
window.removeEventListener('pointercancel', onUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
window.addEventListener('pointercancel', onUp);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
|
||||
import { resolveIntersectionScrollRoot } from '@/lib/dom/resolveIntersectionScrollRoot';
|
||||
|
||||
describe('resolveIntersectionScrollRoot', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('prefers a horizontal scroll ancestor (mainstage album rails)', () => {
|
||||
const rail = document.createElement('div');
|
||||
const img = document.createElement('img');
|
||||
Object.defineProperty(rail, 'scrollWidth', { value: 4000, configurable: true });
|
||||
Object.defineProperty(rail, 'clientWidth', { value: 800, configurable: true });
|
||||
rail.style.overflowX = 'auto';
|
||||
rail.appendChild(img);
|
||||
document.body.appendChild(rail);
|
||||
|
||||
expect(resolveIntersectionScrollRoot(img)).toBe(rail);
|
||||
});
|
||||
|
||||
it('prefers the nearest scrolling ancestor', () => {
|
||||
const outer = document.createElement('div');
|
||||
const scroller = document.createElement('div');
|
||||
const img = document.createElement('img');
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 2000, configurable: true });
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 400, configurable: true });
|
||||
scroller.style.overflowY = 'auto';
|
||||
outer.appendChild(scroller);
|
||||
scroller.appendChild(img);
|
||||
document.body.appendChild(outer);
|
||||
|
||||
expect(resolveIntersectionScrollRoot(img)).toBe(scroller);
|
||||
});
|
||||
|
||||
it('falls back to mainstage in-page viewport class', () => {
|
||||
const inpage = document.createElement('div');
|
||||
inpage.className = 'mainstage-inpage-scroll__viewport';
|
||||
const img = document.createElement('img');
|
||||
inpage.appendChild(img);
|
||||
document.body.appendChild(inpage);
|
||||
|
||||
expect(resolveIntersectionScrollRoot(img)).toBe(inpage);
|
||||
});
|
||||
|
||||
it('falls back to app main scroll viewport id', () => {
|
||||
const main = document.createElement('div');
|
||||
main.id = APP_MAIN_SCROLL_VIEWPORT_ID;
|
||||
const img = document.createElement('img');
|
||||
main.appendChild(img);
|
||||
document.body.appendChild(main);
|
||||
|
||||
expect(resolveIntersectionScrollRoot(img)).toBe(main);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
|
||||
|
||||
/**
|
||||
* Scroll container for `IntersectionObserver` priority scoring on cover art.
|
||||
* Prefer the nearest scrolling ancestor (in-page browse, queue, route viewport);
|
||||
* fall back to known viewport class hooks, then the main route scroll element.
|
||||
*/
|
||||
export function resolveIntersectionScrollRoot(node: HTMLElement): Element | null {
|
||||
let parent = node.parentElement;
|
||||
while (parent) {
|
||||
const { overflowX, overflowY } = window.getComputedStyle(parent);
|
||||
const scrollableY =
|
||||
(overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay')
|
||||
&& parent.scrollHeight > parent.clientHeight + 2;
|
||||
const scrollableX =
|
||||
(overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'overlay')
|
||||
&& parent.scrollWidth > parent.clientWidth + 2;
|
||||
if (scrollableY || scrollableX) {
|
||||
return parent;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return (
|
||||
(node.closest('.mainstage-inpage-scroll__viewport') as HTMLElement | null)
|
||||
?? (node.closest('.app-shell-route-scroll__viewport') as HTMLElement | null)
|
||||
?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Lightweight DOM-based toast notification.
|
||||
* Uses the app's CSS custom properties so it respects the active theme.
|
||||
* Multiple toasts stack vertically above each other.
|
||||
*/
|
||||
|
||||
const TOAST_GAP = 8;
|
||||
const TOAST_BOTTOM_ANCHOR = 100;
|
||||
|
||||
function getActiveToasts(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('.psysonic-toast'));
|
||||
}
|
||||
|
||||
function reflow(): void {
|
||||
const toasts = getActiveToasts();
|
||||
let bottom = TOAST_BOTTOM_ANCHOR;
|
||||
for (let i = toasts.length - 1; i >= 0; i--) {
|
||||
toasts[i].style.bottom = `${bottom}px`;
|
||||
bottom += toasts[i].offsetHeight + TOAST_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
export type ToastVariant = 'error' | 'info' | 'warning' | 'success';
|
||||
|
||||
export function showToast(text: string, durationMs = 4000, variant: ToastVariant = 'info'): void {
|
||||
const isError = variant === 'error';
|
||||
const isWarning = variant === 'warning';
|
||||
const isSuccess = variant === 'success';
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'psysonic-toast';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.textContent = isError ? '✕' : isWarning ? '!' : isSuccess ? '✓' : 'ℹ';
|
||||
icon.style.cssText = `
|
||||
flex-shrink: 0;
|
||||
font-size: ${isSuccess ? '10px' : '11px'};
|
||||
font-weight: 700;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${isError ? 'var(--danger)' : isWarning ? 'var(--warning, #f59e0b)' : isSuccess ? 'var(--positive, #10b981)' : 'var(--accent)'};
|
||||
color: var(--bg-app);
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
const msg = document.createElement('span');
|
||||
msg.textContent = text;
|
||||
msg.style.cssText = `flex: 1; min-width: 0;`;
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (isError) return 'var(--danger)';
|
||||
if (isWarning) return 'var(--warning, #f59e0b)';
|
||||
if (isSuccess) return 'var(--positive, #10b981)';
|
||||
return 'var(--accent)';
|
||||
};
|
||||
|
||||
const getBoxShadow = () => {
|
||||
const base = '0 4px 24px rgba(0,0,0,0.45)';
|
||||
if (isError) return `${base}, 0 0 0 1px color-mix(in srgb, var(--danger) 20%, transparent)`;
|
||||
if (isWarning) return `${base}, 0 0 0 1px color-mix(in srgb, var(--warning, #f59e0b) 20%, transparent)`;
|
||||
return base;
|
||||
};
|
||||
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: ${TOAST_BOTTOM_ANCHOR}px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid ${getBorderColor()};
|
||||
border-left: 3px solid ${getBorderColor()};
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
z-index: 999999;
|
||||
pointer-events: none;
|
||||
box-shadow: ${getBoxShadow()};
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
transition: bottom 150ms ease;
|
||||
max-width: 480px;
|
||||
width: max-content;
|
||||
`;
|
||||
|
||||
toast.appendChild(icon);
|
||||
toast.appendChild(msg);
|
||||
document.body.appendChild(toast);
|
||||
reflow();
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
reflow();
|
||||
}, durationMs);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
import { resolveIndexKey, serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
|
||||
import {
|
||||
libraryGetStatus,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
getAnalysisTracksPerMinute,
|
||||
recordAnalysisTrackPerf,
|
||||
resetAnalysisPerfStateForTest,
|
||||
} from '@/lib/perf/analysisPerfStore';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAnalysisPerfStateForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('analysisPerfStore', () => {
|
||||
const record = (trackId: string): void =>
|
||||
recordAnalysisTrackPerf({ trackId, fetchMs: 1, seedMs: 1, bpmMs: 1, totalMs: 3 });
|
||||
|
||||
it('records last track timings and trailing-window tpm', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000_000);
|
||||
record('t1');
|
||||
vi.advanceTimersByTime(100);
|
||||
// 1 completion in the trailing 5s → 1 / 5s × 60 = 12 tpm.
|
||||
expect(getAnalysisTracksPerMinute()).toBeCloseTo(12, 0);
|
||||
});
|
||||
|
||||
it('extrapolates a burst over the trailing 5s window', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(2_000_000);
|
||||
record('a');
|
||||
vi.advanceTimersByTime(1_000);
|
||||
record('b');
|
||||
vi.advanceTimersByTime(1_000);
|
||||
record('c');
|
||||
// 3 completions within the last 5s → 3 / 5s × 60 = 36 tpm.
|
||||
expect(getAnalysisTracksPerMinute()).toBeCloseTo(36, 0);
|
||||
});
|
||||
|
||||
it('decays to 0 once completions fall outside the trailing window', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(3_000_000);
|
||||
record('old');
|
||||
expect(getAnalysisTracksPerMinute()).toBeGreaterThan(0);
|
||||
vi.advanceTimersByTime(6_000);
|
||||
expect(getAnalysisTracksPerMinute()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export type AnalysisTrackPerfSample = {
|
||||
trackId: string;
|
||||
fetchMs: number;
|
||||
seedMs: number;
|
||||
bpmMs: number;
|
||||
totalMs: number;
|
||||
at: number;
|
||||
};
|
||||
|
||||
type AnalysisPerfState = {
|
||||
last: AnalysisTrackPerfSample | null;
|
||||
completedAt: number[];
|
||||
};
|
||||
|
||||
/** Completion-timestamp retention (kept generous; the live rate uses a shorter window). */
|
||||
const WINDOW_MS = 60_000;
|
||||
/**
|
||||
* Throughput is measured over the trailing few seconds only — a full-minute
|
||||
* average has too much inertia and flattens real bursts/stalls. The count in
|
||||
* this window is extrapolated to a per-minute figure for display.
|
||||
*/
|
||||
const RATE_WINDOW_MS = 5_000;
|
||||
|
||||
let state: AnalysisPerfState = { last: null, completedAt: [] };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
function pruneCompletedAt(now: number): number[] {
|
||||
const cutoff = now - WINDOW_MS;
|
||||
return state.completedAt.filter(t => t >= cutoff);
|
||||
}
|
||||
|
||||
export function recordAnalysisTrackPerf(payload: {
|
||||
trackId: string;
|
||||
fetchMs: number;
|
||||
seedMs: number;
|
||||
bpmMs: number;
|
||||
totalMs: number;
|
||||
}): void {
|
||||
const now = Date.now();
|
||||
const completedAt = [...pruneCompletedAt(now), now];
|
||||
state = {
|
||||
completedAt,
|
||||
last: {
|
||||
trackId: payload.trackId,
|
||||
fetchMs: payload.fetchMs,
|
||||
seedMs: payload.seedMs,
|
||||
bpmMs: payload.bpmMs,
|
||||
totalMs: payload.totalMs,
|
||||
at: now,
|
||||
},
|
||||
};
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Tracks analyzed per minute, measured over the trailing few seconds (0 when idle). */
|
||||
export function getAnalysisTracksPerMinute(now = Date.now()): number {
|
||||
const cutoff = now - RATE_WINDOW_MS;
|
||||
const count = state.completedAt.filter(t => t >= cutoff).length;
|
||||
if (count === 0) return 0;
|
||||
return (count / RATE_WINDOW_MS) * 60_000;
|
||||
}
|
||||
|
||||
export function getAnalysisPerfState(): AnalysisPerfState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function subscribeAnalysisPerf(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
|
||||
/** Last completed track sample — stable reference until the next completion. */
|
||||
export function useAnalysisPerfLast(): AnalysisTrackPerfSample | null {
|
||||
return useSyncExternalStore(
|
||||
subscribeAnalysisPerf,
|
||||
() => state.last,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
/** Test-only reset. */
|
||||
export function resetAnalysisPerfStateForTest(): void {
|
||||
state = { last: null, completedAt: [] };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function formatPerfMs(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '0';
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.round(ms)}ms`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
getCoverCachedPerMinute,
|
||||
getCoverPerfState,
|
||||
getCoverUiPerMinute,
|
||||
recordCoverProgress,
|
||||
recordCoverUiTotal,
|
||||
resetCoverPerfStateForTest,
|
||||
} from '@/lib/perf/coverPerfStore';
|
||||
|
||||
beforeEach(() => {
|
||||
resetCoverPerfStateForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('coverPerfStore', () => {
|
||||
it('derives covers-per-minute from done deltas over the trailing window', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000_000);
|
||||
recordCoverProgress({ done: 100, total: 1000, pending: 900 });
|
||||
vi.advanceTimersByTime(1_000);
|
||||
recordCoverProgress({ done: 110, total: 1000, pending: 890 });
|
||||
vi.advanceTimersByTime(1_000);
|
||||
recordCoverProgress({ done: 120, total: 1000, pending: 880 });
|
||||
// +20 covers over the last 2s ≈ 600 cpm (no minute-long inertia).
|
||||
expect(getCoverCachedPerMinute()).toBeCloseTo(600, 0);
|
||||
expect(getCoverPerfState().done).toBe(120);
|
||||
});
|
||||
|
||||
it('returns 0 with a single sample and decays once the trailing window empties', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(2_000_000);
|
||||
recordCoverProgress({ done: 10 });
|
||||
expect(getCoverCachedPerMinute()).toBe(0);
|
||||
vi.advanceTimersByTime(1_000);
|
||||
recordCoverProgress({ done: 20 });
|
||||
expect(getCoverCachedPerMinute()).toBeGreaterThan(0);
|
||||
// No fresh samples for >5s → trailing window empties → back to 0.
|
||||
vi.advanceTimersByTime(6_000);
|
||||
expect(getCoverCachedPerMinute()).toBe(0);
|
||||
});
|
||||
|
||||
it('resets the window on a backwards jump (server switch / cache clear)', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(3_000_000);
|
||||
recordCoverProgress({ done: 500 });
|
||||
vi.advanceTimersByTime(5_000);
|
||||
recordCoverProgress({ done: 5 });
|
||||
// Only the new baseline remains → no rate yet.
|
||||
expect(getCoverCachedPerMinute()).toBe(0);
|
||||
expect(getCoverPerfState().done).toBe(5);
|
||||
});
|
||||
|
||||
it('derives UI covers-per-minute from backend total deltas over the trailing window', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(4_000_000);
|
||||
expect(getCoverUiPerMinute()).toBe(0);
|
||||
recordCoverUiTotal(100);
|
||||
// A single sample has no delta yet.
|
||||
expect(getCoverUiPerMinute()).toBe(0);
|
||||
vi.advanceTimersByTime(2_000);
|
||||
recordCoverUiTotal(130);
|
||||
// +30 produced over the last 2s ≈ 900 cpm; lib series stays untouched.
|
||||
expect(getCoverUiPerMinute()).toBeCloseTo(900, 0);
|
||||
expect(getCoverCachedPerMinute()).toBe(0);
|
||||
// Idle poll keeps reporting the same total → delta 0 → rate decays to 0.
|
||||
vi.advanceTimersByTime(6_000);
|
||||
recordCoverUiTotal(130);
|
||||
expect(getCoverUiPerMinute()).toBe(0);
|
||||
});
|
||||
|
||||
it('resets the UI window on a backwards jump (process restart)', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(5_000_000);
|
||||
recordCoverUiTotal(500);
|
||||
vi.advanceTimersByTime(5_000);
|
||||
recordCoverUiTotal(3);
|
||||
expect(getCoverUiPerMinute()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* Cover-pipeline throughput store — the cover analogue of `analysisPerfStore`.
|
||||
*
|
||||
* Two independent throughput series share the one-minute rolling window:
|
||||
* - **lib**: the native backfill worker emits cumulative `done` (covers
|
||||
* cached) on `cover:library-progress`; we sample it and derive the delta
|
||||
* rate, mirroring analysis tpm.
|
||||
* - **ui**: on-demand cover ensures (grid/now-playing) are counted natively —
|
||||
* the backend exposes a cumulative `uiEnsuredTotal` in the pipeline stats;
|
||||
* we sample it and derive the delta rate, exactly like lib. Sourcing the
|
||||
* count in Rust avoids the webview ensure-queue dedup/HMR pitfalls that made
|
||||
* a JS-side counter unreliable.
|
||||
*/
|
||||
export type CoverProgressSample = {
|
||||
at: number;
|
||||
done: number;
|
||||
};
|
||||
|
||||
type CoverTotalSample = {
|
||||
at: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
type CoverPerfState = {
|
||||
samples: CoverProgressSample[];
|
||||
done: number;
|
||||
total: number;
|
||||
pending: number;
|
||||
/** Cumulative on-demand (UI) ensure totals sampled from the backend (rolling window). */
|
||||
uiSamples: CoverTotalSample[];
|
||||
};
|
||||
|
||||
/** Sample-retention window (kept generous so backwards-jump detection is robust). */
|
||||
const WINDOW_MS = 60_000;
|
||||
/**
|
||||
* Rate is measured over the trailing few seconds only — a full-minute average
|
||||
* has too much inertia and flattens real bursts/stalls. We still extrapolate to
|
||||
* a per-minute figure for display.
|
||||
*/
|
||||
const RATE_WINDOW_MS = 5_000;
|
||||
|
||||
let state: CoverPerfState = { samples: [], done: 0, total: 0, pending: 0, uiSamples: [] };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
function pruneSamples(now: number, samples: readonly CoverProgressSample[]): CoverProgressSample[] {
|
||||
const cutoff = now - WINDOW_MS;
|
||||
return samples.filter(s => s.at >= cutoff);
|
||||
}
|
||||
|
||||
function pruneTotals(now: number, samples: readonly CoverTotalSample[]): CoverTotalSample[] {
|
||||
const cutoff = now - WINDOW_MS;
|
||||
return samples.filter(s => s.at >= cutoff);
|
||||
}
|
||||
|
||||
export function recordCoverProgress(payload: {
|
||||
done: number;
|
||||
total?: number;
|
||||
pending?: number;
|
||||
}): void {
|
||||
const now = Date.now();
|
||||
const done = Math.max(0, Math.floor(payload.done));
|
||||
let samples = pruneSamples(now, state.samples);
|
||||
// A backwards jump means a different pass (server switch / cache clear) — start
|
||||
// a fresh window so the old baseline doesn't inflate or zero out the rate.
|
||||
if (samples.length > 0 && done < samples[samples.length - 1].done) {
|
||||
samples = [];
|
||||
}
|
||||
samples = [...samples, { at: now, done }];
|
||||
state = {
|
||||
...state,
|
||||
samples,
|
||||
done,
|
||||
total: payload.total ?? state.total,
|
||||
pending: payload.pending ?? state.pending,
|
||||
};
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Sample the backend's cumulative on-demand (UI) ensure total. */
|
||||
export function recordCoverUiTotal(total: number): void {
|
||||
const now = Date.now();
|
||||
const next = Math.max(0, Math.floor(total));
|
||||
let uiSamples = pruneTotals(now, state.uiSamples);
|
||||
// A backwards jump means the process restarted — drop the stale baseline.
|
||||
if (uiSamples.length > 0 && next < uiSamples[uiSamples.length - 1].total) {
|
||||
uiSamples = [];
|
||||
}
|
||||
uiSamples = [...uiSamples, { at: now, total: next }];
|
||||
state = { ...state, uiSamples };
|
||||
emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-minute rate from a cumulative-counter series, measured over the trailing
|
||||
* `RATE_WINDOW_MS`. Returns 0 when fewer than two recent samples are available
|
||||
* (so a stalled pipeline drops to 0 within the window instead of coasting).
|
||||
*/
|
||||
function recentRatePerMinute<T extends { at: number }>(
|
||||
now: number,
|
||||
samples: readonly T[],
|
||||
valueOf: (sample: T) => number,
|
||||
): number {
|
||||
const cutoff = now - RATE_WINDOW_MS;
|
||||
const recent = samples.filter(s => s.at >= cutoff);
|
||||
if (recent.length < 2) return 0;
|
||||
const first = recent[0];
|
||||
const last = recent[recent.length - 1];
|
||||
const delta = Math.max(0, valueOf(last) - valueOf(first));
|
||||
if (delta === 0) return 0;
|
||||
const spanMs = Math.max(1, last.at - first.at);
|
||||
return (delta / spanMs) * 60_000;
|
||||
}
|
||||
|
||||
/** Covers cached per minute, averaged over the trailing few seconds (0 when idle). */
|
||||
export function getCoverCachedPerMinute(now = Date.now()): number {
|
||||
return recentRatePerMinute(now, state.samples, s => s.done);
|
||||
}
|
||||
|
||||
/** On-demand UI covers produced per minute, averaged over the trailing few seconds. */
|
||||
export function getCoverUiPerMinute(now = Date.now()): number {
|
||||
return recentRatePerMinute(now, state.uiSamples, s => s.total);
|
||||
}
|
||||
|
||||
export function getCoverPerfState(): CoverPerfState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function subscribeCoverPerf(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
|
||||
export function useCoverPerfState(): CoverPerfState {
|
||||
return useSyncExternalStore(subscribeCoverPerf, getCoverPerfState, () => state);
|
||||
}
|
||||
|
||||
/** Test-only reset. */
|
||||
export function resetCoverPerfStateForTest(): void {
|
||||
state = { samples: [], done: 0, total: 0, pending: 0, uiSamples: [] };
|
||||
emit();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterLogLines, parseLogFilter } from '@/lib/perf/filterLogLines';
|
||||
|
||||
const L = (text: string) => ({ text });
|
||||
const lines = [
|
||||
L('[10:00] cover error: timeout'),
|
||||
L('[10:01] cover ok album=Discovery'),
|
||||
L('[10:02] analysis warn: slow'),
|
||||
L('[10:03] cover error spam noise'),
|
||||
];
|
||||
|
||||
const texts = (rows: { text: string }[]) => rows.map(r => r.text);
|
||||
|
||||
describe('parseLogFilter', () => {
|
||||
it('classifies include and exclude tokens, trims, drops empties', () => {
|
||||
expect(parseLogFilter(' cover , -spam ,, - , error')).toEqual([
|
||||
{ kind: 'include', word: 'cover' },
|
||||
{ kind: 'exclude', word: 'spam' },
|
||||
{ kind: 'include', word: 'error' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterLogLines', () => {
|
||||
it('returns all lines when filter is empty', () => {
|
||||
expect(filterLogLines(lines, ' ')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('include-only narrows to matching lines (union of includes)', () => {
|
||||
expect(texts(filterLogLines(lines, 'error, warn'))).toEqual([
|
||||
'[10:00] cover error: timeout',
|
||||
'[10:02] analysis warn: slow',
|
||||
'[10:03] cover error spam noise',
|
||||
]);
|
||||
});
|
||||
|
||||
it('exclude-first starts from all and removes matches', () => {
|
||||
expect(texts(filterLogLines(lines, '-cover'))).toEqual([
|
||||
'[10:02] analysis warn: slow',
|
||||
]);
|
||||
});
|
||||
|
||||
it('respects sequence: include then exclude', () => {
|
||||
expect(texts(filterLogLines(lines, 'cover, -spam'))).toEqual([
|
||||
'[10:00] cover error: timeout',
|
||||
'[10:01] cover ok album=Discovery',
|
||||
]);
|
||||
});
|
||||
|
||||
it('layering order matters: later layer overrides earlier', () => {
|
||||
expect(filterLogLines(lines, 'error, -error')).toHaveLength(0);
|
||||
expect(filterLogLines(lines, '-error, error')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(texts(filterLogLines(lines, 'DISCOVERY'))).toEqual([
|
||||
'[10:01] cover ok album=Discovery',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Ordered include/exclude log filter.
|
||||
*
|
||||
* The filter string is a comma-separated list of tokens applied left to right
|
||||
* as layers — sequence matters:
|
||||
* - a plain word `foo` → INCLUDE: lines containing `foo` are shown.
|
||||
* - a word with a leading `-` (`-foo`) → EXCLUDE: lines containing `foo`
|
||||
* are hidden.
|
||||
*
|
||||
* Layering model (paint order):
|
||||
* - If the first token is an exclude, the baseline is "all lines visible";
|
||||
* otherwise the baseline is "nothing visible" (include-only narrows down).
|
||||
* - Each include unions in the matching lines; each exclude removes matching
|
||||
* lines from what is currently visible. A later layer overrides an earlier
|
||||
* one, so `error, -error` shows nothing while `-error, error` shows all.
|
||||
*
|
||||
* Matching is case-insensitive and substring-based.
|
||||
*/
|
||||
export type LogFilterToken = {
|
||||
kind: 'include' | 'exclude';
|
||||
word: string;
|
||||
};
|
||||
|
||||
export function parseLogFilter(filter: string): LogFilterToken[] {
|
||||
return filter
|
||||
.split(',')
|
||||
.map(raw => raw.trim())
|
||||
.filter(raw => raw.length > 0)
|
||||
.map<LogFilterToken | null>(raw => {
|
||||
if (raw.startsWith('-')) {
|
||||
const word = raw.slice(1).trim().toLowerCase();
|
||||
return word.length > 0 ? { kind: 'exclude', word } : null;
|
||||
}
|
||||
return { kind: 'include', word: raw.toLowerCase() };
|
||||
})
|
||||
.filter((t): t is LogFilterToken => t !== null);
|
||||
}
|
||||
|
||||
export function filterLogLines<T extends { text: string }>(
|
||||
lines: readonly T[],
|
||||
filter: string,
|
||||
): T[] {
|
||||
const tokens = parseLogFilter(filter);
|
||||
if (tokens.length === 0) return [...lines];
|
||||
|
||||
const haystacks = lines.map(l => l.text.toLowerCase());
|
||||
const visible = new Array<boolean>(lines.length);
|
||||
|
||||
// Baseline: include-first starts hidden; exclude-first starts visible.
|
||||
const startVisible = tokens[0].kind === 'exclude';
|
||||
visible.fill(startVisible);
|
||||
|
||||
for (const token of tokens) {
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const matches = haystacks[i].includes(token.word);
|
||||
if (!matches) continue;
|
||||
visible[i] = token.kind === 'include';
|
||||
}
|
||||
}
|
||||
|
||||
return lines.filter((_, i) => visible[i]);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatAnalysisPipelineQueueOverlay,
|
||||
formatAnalysisTierQueue,
|
||||
} from '@/lib/perf/formatAnalysisQueueStats';
|
||||
|
||||
describe('formatAnalysisQueueStats', () => {
|
||||
it('formats tier queue as total(high,middle,low)', () => {
|
||||
expect(formatAnalysisTierQueue(20, 1, 5, 14)).toBe('20(1,5,14)');
|
||||
});
|
||||
|
||||
it('formats pipeline overlay lines with queued + active tiers', () => {
|
||||
expect(
|
||||
formatAnalysisPipelineQueueOverlay({
|
||||
pipelineWorkers: 8,
|
||||
httpQueued: 20,
|
||||
httpQueuedHigh: 1,
|
||||
httpQueuedMiddle: 5,
|
||||
httpQueuedLow: 14,
|
||||
httpDownloadActive: 2,
|
||||
httpDownloadActiveHigh: 0,
|
||||
httpDownloadActiveMiddle: 1,
|
||||
httpDownloadActiveLow: 1,
|
||||
cpuQueued: 12,
|
||||
cpuQueuedHigh: 0,
|
||||
cpuQueuedMiddle: 2,
|
||||
cpuQueuedLow: 10,
|
||||
cpuDecodeActive: 3,
|
||||
cpuDecodeActiveHigh: 0,
|
||||
cpuDecodeActiveMiddle: 0,
|
||||
cpuDecodeActiveLow: 3,
|
||||
}),
|
||||
).toEqual([
|
||||
'http 22(1,6,15) · dl 2/8',
|
||||
'cpu 15(0,2,13) · run 3/8',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows active-only backlog when nothing is waiting in deque', () => {
|
||||
expect(
|
||||
formatAnalysisPipelineQueueOverlay({
|
||||
pipelineWorkers: 20,
|
||||
httpQueued: 0,
|
||||
httpQueuedHigh: 0,
|
||||
httpQueuedMiddle: 0,
|
||||
httpQueuedLow: 0,
|
||||
httpDownloadActive: 15,
|
||||
httpDownloadActiveHigh: 0,
|
||||
httpDownloadActiveMiddle: 0,
|
||||
httpDownloadActiveLow: 15,
|
||||
cpuQueued: 0,
|
||||
cpuQueuedHigh: 0,
|
||||
cpuQueuedMiddle: 0,
|
||||
cpuQueuedLow: 0,
|
||||
cpuDecodeActive: 1,
|
||||
cpuDecodeActiveHigh: 0,
|
||||
cpuDecodeActiveMiddle: 0,
|
||||
cpuDecodeActiveLow: 1,
|
||||
}),
|
||||
).toEqual([
|
||||
'http 15(0,0,15) · dl 15/20',
|
||||
'cpu 1(0,0,1) · run 1/20',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AnalysisPipelineQueueStatsDto } from '@/api/analysis';
|
||||
|
||||
export function formatAnalysisTierQueue(
|
||||
total: number,
|
||||
high: number,
|
||||
middle: number,
|
||||
low: number,
|
||||
): string {
|
||||
return `${total}(${high},${middle},${low})`;
|
||||
}
|
||||
|
||||
function combineTierCounts(
|
||||
queuedHigh: number,
|
||||
queuedMiddle: number,
|
||||
queuedLow: number,
|
||||
activeHigh: number,
|
||||
activeMiddle: number,
|
||||
activeLow: number,
|
||||
): { total: number; high: number; middle: number; low: number } {
|
||||
const high = queuedHigh + activeHigh;
|
||||
const middle = queuedMiddle + activeMiddle;
|
||||
const low = queuedLow + activeLow;
|
||||
return { total: high + middle + low, high, middle, low };
|
||||
}
|
||||
|
||||
export function formatAnalysisPipelineQueueOverlay(
|
||||
stats: AnalysisPipelineQueueStatsDto,
|
||||
): string[] {
|
||||
const http = combineTierCounts(
|
||||
stats.httpQueuedHigh,
|
||||
stats.httpQueuedMiddle,
|
||||
stats.httpQueuedLow,
|
||||
stats.httpDownloadActiveHigh,
|
||||
stats.httpDownloadActiveMiddle,
|
||||
stats.httpDownloadActiveLow,
|
||||
);
|
||||
const cpu = combineTierCounts(
|
||||
stats.cpuQueuedHigh,
|
||||
stats.cpuQueuedMiddle,
|
||||
stats.cpuQueuedLow,
|
||||
stats.cpuDecodeActiveHigh,
|
||||
stats.cpuDecodeActiveMiddle,
|
||||
stats.cpuDecodeActiveLow,
|
||||
);
|
||||
const w = stats.pipelineWorkers;
|
||||
const httpLine = `http ${formatAnalysisTierQueue(http.total, http.high, http.middle, http.low)} · dl ${stats.httpDownloadActive}/${w}`;
|
||||
const cpuLine = `cpu ${formatAnalysisTierQueue(cpu.total, cpu.high, cpu.middle, cpu.low)} · run ${stats.cpuDecodeActive}/${w}`;
|
||||
return [httpLine, cpuLine];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCoverPipelineQueueOverlay } from '@/lib/perf/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,
|
||||
uiEnsuredTotal: 0,
|
||||
},
|
||||
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,
|
||||
uiEnsuredTotal: 0,
|
||||
},
|
||||
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 '@/lib/perf/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,139 @@
|
||||
import type { PerfLiveSnapshot } from '@/lib/perf/perfLiveStore';
|
||||
|
||||
export type LiveOverlayItemKind = 'cpu' | 'memory' | 'rate' | 'analysis' | 'cover';
|
||||
|
||||
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,
|
||||
});
|
||||
} else if (pin === 'cover:cpm' && live.cover) {
|
||||
items.push({
|
||||
id: pin,
|
||||
line: `cover lib ${live.cover.cachedPerMinute.toFixed(1)} cpm`,
|
||||
kind: 'cover',
|
||||
sparkline: false,
|
||||
});
|
||||
} else if (pin === 'cover:cpm:ui' && live.cover) {
|
||||
items.push({
|
||||
id: pin,
|
||||
line: `cover ui ${live.cover.uiPerMinute.toFixed(1)} cpm`,
|
||||
kind: 'cover',
|
||||
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:');
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export type PerfProbeFlags = {
|
||||
/** On-screen rAF-based FPS counter (Performance Probe). */
|
||||
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;
|
||||
disableBackdropBlur: boolean;
|
||||
disableCssAnimations: boolean;
|
||||
disableOverlayScrollbars: boolean;
|
||||
disableTooltipPortal: boolean;
|
||||
disableQueuePanelMount: boolean;
|
||||
disableBackgroundPolling: boolean;
|
||||
disableMainRouteContentMount: boolean;
|
||||
disableMainstageHero: boolean;
|
||||
disableMainstageRails: boolean;
|
||||
disableMainstageGridCards: boolean;
|
||||
disableMainstageVirtualLists: boolean;
|
||||
disableMainstageStickyHeader: boolean;
|
||||
disableMainstageHeroBackdrop: boolean;
|
||||
disableMainstageRailArtwork: boolean;
|
||||
disableMainstageRailInteractivity: boolean;
|
||||
disableHomeAlbumRows: boolean;
|
||||
disableHomeSongRails: boolean;
|
||||
disableHomeRailArtwork: boolean;
|
||||
disableHomeArtworkFx: boolean;
|
||||
disableHomeArtworkClip: boolean;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'psysonic_perf_probe_flags_v1';
|
||||
|
||||
const DEFAULT_FLAGS: PerfProbeFlags = {
|
||||
showFpsOverlay: false,
|
||||
showAnalysisPerfOverlay: false,
|
||||
showCoverPerfOverlay: false,
|
||||
disableWaveformCanvas: false,
|
||||
disablePlayerProgressUi: false,
|
||||
disableMarqueeScroll: false,
|
||||
disableBackdropBlur: false,
|
||||
disableCssAnimations: false,
|
||||
disableOverlayScrollbars: false,
|
||||
disableTooltipPortal: false,
|
||||
disableQueuePanelMount: false,
|
||||
disableBackgroundPolling: false,
|
||||
disableMainRouteContentMount: false,
|
||||
disableMainstageHero: false,
|
||||
disableMainstageRails: false,
|
||||
disableMainstageGridCards: false,
|
||||
disableMainstageVirtualLists: false,
|
||||
disableMainstageStickyHeader: false,
|
||||
disableMainstageHeroBackdrop: false,
|
||||
disableMainstageRailArtwork: false,
|
||||
disableMainstageRailInteractivity: false,
|
||||
disableHomeAlbumRows: false,
|
||||
disableHomeSongRails: false,
|
||||
disableHomeRailArtwork: false,
|
||||
disableHomeArtworkFx: false,
|
||||
disableHomeArtworkClip: false,
|
||||
};
|
||||
|
||||
let flags: PerfProbeFlags = { ...DEFAULT_FLAGS };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function safeParseFlags(raw: string | null): Partial<PerfProbeFlags> {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<PerfProbeFlags>;
|
||||
return parsed ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function applyFlagsToDom(next: PerfProbeFlags): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
root.dataset.perfDisableWaveform = next.disableWaveformCanvas ? 'true' : 'false';
|
||||
root.dataset.perfDisablePlayerProgressUi = next.disablePlayerProgressUi ? 'true' : 'false';
|
||||
root.dataset.perfDisableMarquee = next.disableMarqueeScroll ? 'true' : 'false';
|
||||
root.dataset.perfDisableBlur = next.disableBackdropBlur ? 'true' : 'false';
|
||||
root.dataset.perfDisableAnimations = next.disableCssAnimations ? 'true' : 'false';
|
||||
root.dataset.perfDisableOverlayScroll = next.disableOverlayScrollbars ? 'true' : 'false';
|
||||
root.dataset.perfDisableTooltipPortal = next.disableTooltipPortal ? 'true' : 'false';
|
||||
root.dataset.perfDisableQueuePanel = next.disableQueuePanelMount ? 'true' : 'false';
|
||||
root.dataset.perfDisableBackgroundPolling = next.disableBackgroundPolling ? 'true' : 'false';
|
||||
root.dataset.perfDisableMainRoute = next.disableMainRouteContentMount ? 'true' : 'false';
|
||||
root.dataset.perfDisableMainstageHero = next.disableMainstageHero ? 'true' : 'false';
|
||||
root.dataset.perfDisableMainstageRails = next.disableMainstageRails ? 'true' : 'false';
|
||||
root.dataset.perfDisableMainstageGrid = next.disableMainstageGridCards ? 'true' : 'false';
|
||||
root.dataset.perfDisableMainstageVirtual = next.disableMainstageVirtualLists ? 'true' : 'false';
|
||||
root.dataset.perfDisableMainstageHeader = next.disableMainstageStickyHeader ? 'true' : 'false';
|
||||
root.dataset.perfDisableMainstageHeroBackdrop = next.disableMainstageHeroBackdrop ? 'true' : 'false';
|
||||
root.dataset.perfDisableMainstageRailArtwork = next.disableMainstageRailArtwork ? 'true' : 'false';
|
||||
root.dataset.perfDisableMainstageRailInteractivity = next.disableMainstageRailInteractivity ? 'true' : 'false';
|
||||
root.dataset.perfDisableHomeAlbumRows = next.disableHomeAlbumRows ? 'true' : 'false';
|
||||
root.dataset.perfDisableHomeSongRails = next.disableHomeSongRails ? 'true' : 'false';
|
||||
root.dataset.perfDisableHomeRailArtwork = next.disableHomeRailArtwork ? 'true' : 'false';
|
||||
root.dataset.perfDisableHomeArtworkFx = next.disableHomeArtworkFx ? 'true' : 'false';
|
||||
root.dataset.perfDisableHomeArtworkClip = next.disableHomeArtworkClip ? 'true' : 'false';
|
||||
}
|
||||
|
||||
function persistFlags(next: PerfProbeFlags): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// Ignore storage errors; runtime state still works.
|
||||
}
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
function setFlags(next: PerfProbeFlags): void {
|
||||
flags = next;
|
||||
applyFlagsToDom(flags);
|
||||
persistFlags(flags);
|
||||
emit();
|
||||
}
|
||||
|
||||
function initFlags(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const fromStorage = safeParseFlags(window.localStorage.getItem(STORAGE_KEY));
|
||||
flags = {
|
||||
...DEFAULT_FLAGS,
|
||||
...fromStorage,
|
||||
};
|
||||
applyFlagsToDom(flags);
|
||||
}
|
||||
|
||||
initFlags();
|
||||
|
||||
export function getPerfProbeFlags(): PerfProbeFlags {
|
||||
return flags;
|
||||
}
|
||||
|
||||
export function subscribePerfProbeFlags(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
|
||||
export function setPerfProbeFlag<K extends keyof PerfProbeFlags>(key: K, value: PerfProbeFlags[K]): void {
|
||||
setFlags({ ...flags, [key]: value });
|
||||
}
|
||||
|
||||
export function resetPerfProbeFlags(): void {
|
||||
setFlags({ ...DEFAULT_FLAGS });
|
||||
}
|
||||
|
||||
export function usePerfProbeFlags(): PerfProbeFlags {
|
||||
return useSyncExternalStore(subscribePerfProbeFlags, getPerfProbeFlags, () => DEFAULT_FLAGS);
|
||||
}
|
||||
|
||||
/** Subscribe to a single probe flag so unrelated toggles do not re-render the consumer. */
|
||||
export function usePerfProbeFlag<K extends keyof PerfProbeFlags>(key: K): PerfProbeFlags[K] {
|
||||
return useSyncExternalStore(
|
||||
subscribePerfProbeFlags,
|
||||
() => getPerfProbeFlags()[key],
|
||||
() => DEFAULT_FLAGS[key],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IS_LINUX, IS_MACOS } from '@/lib/util/platform';
|
||||
|
||||
/** Matches Rust `performance_cpu_snapshot` (Linux `/proc`, macOS sysinfo). */
|
||||
export function perfLiveCpuSnapshotSupported(): boolean {
|
||||
return IS_LINUX || IS_MACOS;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import {
|
||||
isLiveHistoryPin,
|
||||
liveOverlayItemValue,
|
||||
} from '@/lib/perf/formatLiveOverlayItems';
|
||||
import type { PerfLiveSnapshot } from '@/lib/perf/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.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. */
|
||||
export function syncPerfLiveHistoryFromPoll(
|
||||
pins: Iterable<string>,
|
||||
live: PerfLiveSnapshot,
|
||||
options?: { emit?: boolean },
|
||||
): void {
|
||||
if (!live.cpu?.supported || live.sampleAt <= 0) return;
|
||||
let changed = false;
|
||||
for (const pin of pins) {
|
||||
if (!isLiveHistoryPin(pin)) continue;
|
||||
const value = liveOverlayItemValue(pin, live);
|
||||
if (value != null && appendSample(pin, value, live.sampleAt)) changed = true;
|
||||
}
|
||||
if (changed && options?.emit !== false) emit();
|
||||
}
|
||||
|
||||
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,129 @@
|
||||
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 THREAD_GROUPS_STORAGE_KEY = 'psysonic_perf_live_thread_groups_v1';
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
const threadGroupListeners = 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 */
|
||||
}
|
||||
}
|
||||
|
||||
function initThreadGroups(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(THREAD_GROUPS_STORAGE_KEY);
|
||||
if (raw == null) return;
|
||||
includeThreadGroups = raw === '1' || raw === 'true';
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
initPollInterval();
|
||||
initThreadGroups();
|
||||
|
||||
function emitThreadGroups(): void {
|
||||
threadGroupListeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
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;
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(THREAD_GROUPS_STORAGE_KEY, next ? '1' : '0');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
emitThreadGroups();
|
||||
requestScheduleBump();
|
||||
}
|
||||
|
||||
export function subscribePerfLiveIncludeThreadGroups(cb: () => void): () => void {
|
||||
threadGroupListeners.add(cb);
|
||||
return () => threadGroupListeners.delete(cb);
|
||||
}
|
||||
|
||||
export function usePerfLiveIncludeThreadGroups(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribePerfLiveIncludeThreadGroups,
|
||||
getPerfLiveIncludeThreadGroups,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export type PerfCpuSnapshotRequest = {
|
||||
includeThreadGroups: boolean;
|
||||
};
|
||||
|
||||
export function buildPerfCpuSnapshotRequest(): PerfCpuSnapshotRequest {
|
||||
return { includeThreadGroups };
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { clearPerfLiveHistory, syncPerfLiveHistoryFromPoll } from '@/lib/perf/perfLiveHistory';
|
||||
import { getAnalysisTracksPerMinute } from '@/lib/perf/analysisPerfStore';
|
||||
import { getCoverCachedPerMinute, getCoverUiPerMinute, getCoverPerfState } from '@/lib/perf/coverPerfStore';
|
||||
import { perfLiveCpuSnapshotSupported } from '@/lib/perf/perfLiveCpuSnapshot';
|
||||
import { getPerfLiveOverlayPins } from '@/lib/perf/perfOverlayPins';
|
||||
import {
|
||||
buildPerfCpuSnapshotRequest,
|
||||
getPerfLivePollIntervalMs,
|
||||
registerPerfLivePollScheduleBump,
|
||||
} from '@/lib/perf/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 PerfCoverDiag = {
|
||||
cachedPerMinute: number;
|
||||
uiPerMinute: number;
|
||||
done: number;
|
||||
total: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export type PerfLiveSnapshot = {
|
||||
cpu: PerfLiveCpu | null;
|
||||
diagRates: PerfDiagRates | null;
|
||||
analysis: PerfAnalysisDiag | null;
|
||||
cover: PerfCoverDiag | null;
|
||||
collecting: boolean;
|
||||
/** Wall time of the last displayed sample change (memory / diag / rates). */
|
||||
updatedAt: number;
|
||||
/** Wall time of the last CPU rate sample; stable sparkline clock between polls. */
|
||||
sampleAt: 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,
|
||||
cover: null,
|
||||
collecting: false,
|
||||
updatedAt: 0,
|
||||
sampleAt: 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 memoryRowsEqual(a: readonly PerfProcessMemory[], b: readonly PerfProcessMemory[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((row, index) => row.label === b[index].label && row.rss_kb === b[index].rss_kb);
|
||||
}
|
||||
|
||||
function threadCpuEqual(a: readonly PerfThreadCpu[], b: readonly PerfThreadCpu[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((row, index) => (
|
||||
row.label === b[index].label
|
||||
&& row.pct === b[index].pct
|
||||
&& row.threadCount === b[index].threadCount
|
||||
));
|
||||
}
|
||||
|
||||
function diagRatesEqual(a: PerfDiagRates | null, b: PerfDiagRates | null): boolean {
|
||||
if (a == null || b == null) return a === b;
|
||||
return a.progress === b.progress && a.waveform === b.waveform && a.home === b.home;
|
||||
}
|
||||
|
||||
function analysisEqual(a: PerfAnalysisDiag | null, b: PerfAnalysisDiag | null): boolean {
|
||||
if (a == null || b == null) return a === b;
|
||||
return a.tracksPerMinute === b.tracksPerMinute
|
||||
&& a.lastTotalMs === b.lastTotalMs
|
||||
&& a.lastFetchMs === b.lastFetchMs
|
||||
&& a.lastSeedMs === b.lastSeedMs
|
||||
&& a.lastBpmMs === b.lastBpmMs;
|
||||
}
|
||||
|
||||
function coverEqual(a: PerfCoverDiag | null, b: PerfCoverDiag | null): boolean {
|
||||
if (a == null || b == null) return a === b;
|
||||
return a.cachedPerMinute === b.cachedPerMinute
|
||||
&& a.uiPerMinute === b.uiPerMinute
|
||||
&& a.done === b.done
|
||||
&& a.total === b.total
|
||||
&& a.pending === b.pending;
|
||||
}
|
||||
|
||||
function cpuEqual(a: PerfLiveCpu | null, b: PerfLiveCpu | null): boolean {
|
||||
if (a == null || b == null) return a === b;
|
||||
return a.app === b.app
|
||||
&& a.webkit === b.webkit
|
||||
&& a.supported === b.supported
|
||||
&& memoryRowsEqual(a.memory, b.memory)
|
||||
&& threadCpuEqual(a.threadCpu, b.threadCpu);
|
||||
}
|
||||
|
||||
function publishLiveSnapshot(next: PerfLiveSnapshot): void {
|
||||
const cpuChanged = !cpuEqual(snapshot.cpu, next.cpu);
|
||||
const diagChanged = !diagRatesEqual(snapshot.diagRates, next.diagRates);
|
||||
const analysisChanged = !analysisEqual(snapshot.analysis, next.analysis);
|
||||
const coverChanged = !coverEqual(snapshot.cover, next.cover);
|
||||
if (!cpuChanged && !diagChanged && !analysisChanged && !coverChanged && next.updatedAt === snapshot.updatedAt) {
|
||||
return;
|
||||
}
|
||||
if (next.sampleAt > snapshot.sampleAt && next.cpu?.supported) {
|
||||
syncPerfLiveHistoryFromPoll(getPerfLiveOverlayPins(), next, { emit: false });
|
||||
}
|
||||
setSnapshot(next);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnalysisDiag(): PerfAnalysisDiag {
|
||||
return {
|
||||
tracksPerMinute: getAnalysisTracksPerMinute(),
|
||||
lastTotalMs: snapshot.analysis?.lastTotalMs ?? null,
|
||||
lastFetchMs: snapshot.analysis?.lastFetchMs ?? null,
|
||||
lastSeedMs: snapshot.analysis?.lastSeedMs ?? null,
|
||||
lastBpmMs: snapshot.analysis?.lastBpmMs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCoverDiag(): PerfCoverDiag {
|
||||
const cover = getCoverPerfState();
|
||||
return {
|
||||
cachedPerMinute: getCoverCachedPerMinute(),
|
||||
uiPerMinute: getCoverUiPerMinute(),
|
||||
done: cover.done,
|
||||
total: cover.total,
|
||||
pending: cover.pending,
|
||||
};
|
||||
}
|
||||
|
||||
function nextDiagRates(
|
||||
nextCounters: { progress: number; waveform: number; home: number },
|
||||
now: number,
|
||||
): PerfDiagRates | null {
|
||||
if (!prevCounters || prevCountersAt <= 0) return snapshot.diagRates;
|
||||
const dt = Math.max(0.25, (now - prevCountersAt) / 1000);
|
||||
return {
|
||||
progress: (nextCounters.progress - prevCounters.progress) / dt,
|
||||
waveform: (nextCounters.waveform - prevCounters.waveform) / dt,
|
||||
home: (nextCounters.home - prevCounters.home) / dt,
|
||||
};
|
||||
}
|
||||
|
||||
const UNSUPPORTED_CPU: PerfLiveCpu = {
|
||||
app: 0,
|
||||
webkit: 0,
|
||||
supported: false,
|
||||
memory: [],
|
||||
threadCpu: [],
|
||||
};
|
||||
|
||||
function applyJsMetricsSnapshot(now: number): void {
|
||||
const nextCounters = readUiCounters();
|
||||
const diagRates = nextDiagRates(nextCounters, now);
|
||||
prevCounters = nextCounters;
|
||||
prevCountersAt = now;
|
||||
publishLiveSnapshot({
|
||||
cpu: snapshot.cpu ?? UNSUPPORTED_CPU,
|
||||
diagRates,
|
||||
analysis: buildAnalysisDiag(),
|
||||
cover: buildCoverDiag(),
|
||||
collecting: false,
|
||||
updatedAt: now,
|
||||
sampleAt: snapshot.sampleAt,
|
||||
});
|
||||
}
|
||||
|
||||
async function pollOnce(): Promise<void> {
|
||||
const generation = pollGeneration;
|
||||
|
||||
if (!perfLiveCpuSnapshotSupported()) {
|
||||
if (generation !== pollGeneration) return;
|
||||
applyJsMetricsSnapshot(Date.now());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const snap = await invoke<ProcSnapshot>('performance_cpu_snapshot', buildPerfCpuSnapshotRequest());
|
||||
if (generation !== pollGeneration) return;
|
||||
|
||||
const completedAt = Date.now();
|
||||
const nextCounters = readUiCounters();
|
||||
const diagRates = nextDiagRates(nextCounters, completedAt);
|
||||
prevCounters = nextCounters;
|
||||
prevCountersAt = completedAt;
|
||||
|
||||
if (!snap.supported) {
|
||||
publishLiveSnapshot({
|
||||
cpu: UNSUPPORTED_CPU,
|
||||
diagRates,
|
||||
analysis: buildAnalysisDiag(),
|
||||
cover: buildCoverDiag(),
|
||||
collecting: false,
|
||||
updatedAt: completedAt,
|
||||
sampleAt: snapshot.sampleAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const memory = snap.memory;
|
||||
const baselineProc = prevProc;
|
||||
const prevCpu = snapshot.cpu;
|
||||
let app = prevCpu?.app ?? 0;
|
||||
let webkit = prevCpu?.webkit ?? 0;
|
||||
let threadCpu: PerfThreadCpu[] = prevCpu?.threadCpu ?? snap.thread_cpu_groups.map(g => ({
|
||||
label: g.label,
|
||||
threadCount: g.thread_count,
|
||||
pct: 0,
|
||||
}));
|
||||
let rateSampleReady = false;
|
||||
|
||||
if (baselineProc) {
|
||||
const totalDelta = snap.total_jiffies - baselineProc.total_jiffies;
|
||||
const appDelta = snap.app_jiffies - baselineProc.app_jiffies;
|
||||
const webkitDelta = snap.webkit_jiffies - baselineProc.webkit_jiffies;
|
||||
if (totalDelta > 0) {
|
||||
rateSampleReady = true;
|
||||
const cpuScale = Math.max(1, snap.logical_cpus || 1) * 100;
|
||||
const prevThreadByLabel = new Map(
|
||||
baselineProc.thread_cpu_groups.map(g => [g.label, g.jiffies]),
|
||||
);
|
||||
app = clampPct((appDelta / totalDelta) * cpuScale);
|
||||
webkit = clampPct((webkitDelta / totalDelta) * cpuScale);
|
||||
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 memoryChanged = !memoryRowsEqual(prevCpu?.memory ?? [], memory);
|
||||
const diagChanged = !diagRatesEqual(snapshot.diagRates, diagRates);
|
||||
const ratesChanged = rateSampleReady && (
|
||||
app !== (prevCpu?.app ?? 0)
|
||||
|| webkit !== (prevCpu?.webkit ?? 0)
|
||||
|| !threadCpuEqual(prevCpu?.threadCpu ?? [], threadCpu)
|
||||
);
|
||||
|
||||
prevProc = snap;
|
||||
|
||||
if (!rateSampleReady) {
|
||||
if (baselineProc == null) return;
|
||||
if (!memoryChanged && !diagChanged) return;
|
||||
}
|
||||
|
||||
const nextUpdatedAt = (ratesChanged || memoryChanged || diagChanged)
|
||||
? completedAt
|
||||
: snapshot.updatedAt;
|
||||
|
||||
const nextSampleAt = ratesChanged ? completedAt : snapshot.sampleAt;
|
||||
|
||||
publishLiveSnapshot({
|
||||
cpu: {
|
||||
app,
|
||||
webkit,
|
||||
supported: true,
|
||||
memory,
|
||||
threadCpu,
|
||||
},
|
||||
diagRates,
|
||||
analysis: buildAnalysisDiag(),
|
||||
cover: buildCoverDiag(),
|
||||
collecting: false,
|
||||
updatedAt: nextUpdatedAt,
|
||||
sampleAt: nextSampleAt,
|
||||
});
|
||||
} catch {
|
||||
if (generation !== pollGeneration) return;
|
||||
publishLiveSnapshot({
|
||||
...snapshot,
|
||||
cpu: { app: 0, webkit: 0, supported: false, memory: [], threadCpu: [] },
|
||||
collecting: false,
|
||||
updatedAt: Date.now(),
|
||||
sampleAt: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
// Fresh baseline after interval / thread-group option changes.
|
||||
prevProc = 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();
|
||||
};
|
||||
}
|
||||
|
||||
/** True while the first CPU baseline sample is still pending. */
|
||||
export function isPerfLivePollWaitingForCpu(): boolean {
|
||||
return pollRefCount > 0 && snapshot.cpu == null && perfLiveCpuSnapshotSupported();
|
||||
}
|
||||
|
||||
export function patchPerfLiveAnalysis(partial: Partial<PerfAnalysisDiag>): void {
|
||||
const nextAnalysis: PerfAnalysisDiag = {
|
||||
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,
|
||||
};
|
||||
if (analysisEqual(snapshot.analysis, nextAnalysis)) return;
|
||||
publishLiveSnapshot({
|
||||
...snapshot,
|
||||
analysis: nextAnalysis,
|
||||
});
|
||||
}
|
||||
|
||||
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 '@/lib/perf/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,144 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { clearPerfLiveHistory } from '@/lib/perf/perfLiveHistory';
|
||||
import { perfLiveCpuSnapshotSupported } from '@/lib/perf/perfLiveCpuSnapshot';
|
||||
import { getPerfOverlayMode } from '@/lib/perf/perfOverlayMode';
|
||||
import { getPerfProbeFlags, setPerfProbeFlag, subscribePerfProbeFlags } from '@/lib/perf/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'
|
||||
| 'cover:cpm'
|
||||
| 'cover:cpm:ui';
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
function livePinsNeedJsPoll(pins: ReadonlySet<string>): boolean {
|
||||
for (const id of pins) {
|
||||
if (id.startsWith('rate:') || id.startsWith('analysis:') || id.startsWith('cover:')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasAnyLiveMetricPollNeed(): boolean {
|
||||
if (getPerfOverlayMode() !== 'pinned') return false;
|
||||
if (livePins.size === 0) return false;
|
||||
if (perfLiveCpuSnapshotSupported()) return true;
|
||||
return livePinsNeedJsPoll(livePins);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { PerfProbeFlags } from '@/lib/perf/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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Hot-path counters for the Performance Probe modal. In production builds these
|
||||
* no-op unless the probe is open (`data-psy-perf-probe-open` on the root), so
|
||||
* normal playback does not pay for object churn. Dev builds always record.
|
||||
*/
|
||||
const ROOT_FLAG = 'psyPerfProbeOpen';
|
||||
|
||||
export function setPerfProbeTelemetryActive(active: boolean): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (active) document.documentElement.dataset[ROOT_FLAG] = 'true';
|
||||
else delete document.documentElement.dataset[ROOT_FLAG];
|
||||
}
|
||||
|
||||
function shouldRecordPerfCounters(): boolean {
|
||||
if (import.meta.env.DEV) return true;
|
||||
if (typeof document === 'undefined') return false;
|
||||
return document.documentElement.dataset[ROOT_FLAG] === 'true';
|
||||
}
|
||||
|
||||
export function bumpPerfCounter(name: string): void {
|
||||
if (!shouldRecordPerfCounters()) return;
|
||||
const w = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
|
||||
const c = w.__psyPerfCounters ?? (w.__psyPerfCounters = Object.create(null) as Record<string, number>);
|
||||
c[name] = (c[name] ?? 0) + 1;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sanitizeLogLine } from '@/lib/perf/sanitizeLogLine';
|
||||
|
||||
describe('sanitizeLogLine', () => {
|
||||
it('redacts Subsonic wire-auth query params', () => {
|
||||
const line = 'GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi';
|
||||
const out = sanitizeLogLine(line);
|
||||
expect(out).toContain('t=REDACTED');
|
||||
expect(out).toContain('s=REDACTED');
|
||||
expect(out).toContain('p=REDACTED');
|
||||
expect(out).not.toContain('abc');
|
||||
});
|
||||
|
||||
it('masks remote hostnames but keeps LAN IPs', () => {
|
||||
const remote = sanitizeLogLine('connect https://my-server.example.com:4533/rest/ping');
|
||||
expect(remote).toContain('my****.example.com');
|
||||
expect(remote).not.toContain('my-server.example.com');
|
||||
|
||||
const lan = sanitizeLogLine('connect http://192.168.1.42:4533/rest/ping');
|
||||
expect(lan).toContain('192.168.1.42');
|
||||
});
|
||||
|
||||
it('handles stream logs with em dashes (UTF-8 safe)', () => {
|
||||
const line = '[stream] RangedHttpSource selected — total=15666KB, hint=Some("mp3")';
|
||||
expect(() => sanitizeLogLine(line)).not.toThrow();
|
||||
expect(sanitizeLogLine(line)).toContain('—');
|
||||
});
|
||||
|
||||
it('redacts bearer tokens and password key/value pairs', () => {
|
||||
const line = 'auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit';
|
||||
const out = sanitizeLogLine(line);
|
||||
expect(out).toContain('Bearer REDACTED');
|
||||
expect(out).not.toContain('eyJhbGci');
|
||||
expect(out).toContain('password=REDACTED');
|
||||
expect(out).not.toContain('sekrit');
|
||||
});
|
||||
|
||||
it('redacts reverse-proxy gate header values', () => {
|
||||
const line = 'req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key';
|
||||
const out = sanitizeLogLine(line);
|
||||
expect(out).toContain('CF-Access-Client-Secret: REDACTED');
|
||||
expect(out).not.toContain('gate-secret');
|
||||
expect(out).not.toContain('tok123');
|
||||
expect(out).toContain('x-pangolin-auth: REDACTED');
|
||||
expect(out).not.toContain('pangolin-key');
|
||||
expect(out).not.toMatch(/Authorization:\s*Bearer\s+\S/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Redact secrets and partially mask remote server hostnames in PsyLab log lines.
|
||||
* Mirrors `psysonic_core::log_sanitize` (defense in depth for lines already buffered).
|
||||
*/
|
||||
|
||||
const SENSITIVE_QUERY_KEYS = new Set([
|
||||
't', 's', 'p', 'token', 'password', 'passwd', 'secret', 'api_key', 'apikey',
|
||||
'access_token', 'refresh_token', 'auth',
|
||||
]);
|
||||
|
||||
const SENSITIVE_KV_KEYS = [
|
||||
'password', 'passwd', 'token', 'secret', 'api_key', 'apikey',
|
||||
'access_token', 'refresh_token', 'authorization', 'auth',
|
||||
'cookie', 'x-api-key', 'cf-access-client-secret', 'cf-access-client-id', 'x-auth-token',
|
||||
];
|
||||
|
||||
/** Gate / reverse-proxy header names — redact any `x-pangolin-*` prefix. */
|
||||
const PANGOLIN_HEADER_RE = /(\bx-pangolin-[a-z0-9-]+\s*[:=]\s*)(\S+)/gi;
|
||||
|
||||
function isIpv4LanLiteral(ip: string): boolean {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
const a = Number(parts[0]);
|
||||
const b = Number(parts[1]);
|
||||
if (!Number.isInteger(a) || !Number.isInteger(b)) return false;
|
||||
return (
|
||||
a === 127 ||
|
||||
a === 10 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168)
|
||||
);
|
||||
}
|
||||
|
||||
function isIpv6LanHostname(hostname: string): boolean {
|
||||
const h = hostname.toLowerCase();
|
||||
if (h === '::1') return true;
|
||||
if (/^fe[89ab][0-9a-f]:/.test(h)) return true;
|
||||
if (/^f[cd][0-9a-f]{2}:/.test(h)) return true;
|
||||
const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(h);
|
||||
if (dotted) return isIpv4LanLiteral(dotted[1]!);
|
||||
const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h);
|
||||
if (hexMapped) {
|
||||
const v1 = parseInt(hexMapped[1]!, 16);
|
||||
const v2 = parseInt(hexMapped[2]!, 16);
|
||||
const ipv4 = `${(v1 >> 8) & 0xff}.${v1 & 0xff}.${(v2 >> 8) & 0xff}.${v2 & 0xff}`;
|
||||
return isIpv4LanLiteral(ipv4);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLanHost(host: string): boolean {
|
||||
const stripped = host.replace(/^\[|\]$/g, '').trim().toLowerCase();
|
||||
if (!stripped || stripped === 'localhost' || stripped.endsWith('.local')) return true;
|
||||
if (stripped.includes(':')) return isIpv6LanHostname(stripped);
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(stripped)) return isIpv4LanLiteral(stripped);
|
||||
return false;
|
||||
}
|
||||
|
||||
function maskPublicIpv4(ip: string): string {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return '***';
|
||||
return `${parts[0]}.*.*.${parts[3]}`;
|
||||
}
|
||||
|
||||
function maskHostname(host: string): string {
|
||||
const stripped = host.replace(/^\[|\]$/g, '');
|
||||
if (isLanHost(stripped)) return host;
|
||||
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(stripped)) return maskPublicIpv4(stripped);
|
||||
if (stripped.includes(':')) return '[ipv6-redacted]';
|
||||
|
||||
const parts = stripped.split('.');
|
||||
if (parts.length === 0) return '***';
|
||||
|
||||
const first = parts[0]!;
|
||||
const maskedFirst = first.length <= 2
|
||||
? '*'.repeat(Math.max(1, first.length))
|
||||
: `${first.slice(0, 2)}${'*'.repeat(Math.min(4, Math.max(1, first.length - 2)))}`;
|
||||
|
||||
return parts.length === 1 ? maskedFirst : `${maskedFirst}.${parts.slice(1).join('.')}`;
|
||||
}
|
||||
|
||||
function splitHostPort(hostport: string): [string, string | null] {
|
||||
if (hostport.startsWith('[')) {
|
||||
const end = hostport.indexOf(']:');
|
||||
if (end !== -1) return [hostport.slice(0, end + 1), hostport.slice(end + 2)];
|
||||
return [hostport, null];
|
||||
}
|
||||
const colon = hostport.lastIndexOf(':');
|
||||
if (colon > 0) {
|
||||
const host = hostport.slice(0, colon);
|
||||
const port = hostport.slice(colon + 1);
|
||||
if (/^\d+$/.test(port) && !host.includes(':')) return [host, port];
|
||||
}
|
||||
return [hostport, null];
|
||||
}
|
||||
|
||||
function splitHostPath(rest: string): [string, string] {
|
||||
if (rest.startsWith('[')) {
|
||||
const end = rest.indexOf(']');
|
||||
if (end !== -1) return [rest.slice(0, end + 1), rest.slice(end + 1)];
|
||||
}
|
||||
const slash = rest.indexOf('/');
|
||||
if (slash === -1) return [rest, ''];
|
||||
return [rest.slice(0, slash), rest.slice(slash)];
|
||||
}
|
||||
|
||||
function redactQueryString(query: string): string {
|
||||
return query.split('&').map(pair => {
|
||||
const eq = pair.indexOf('=');
|
||||
const key = (eq === -1 ? pair : pair.slice(0, eq)).trim().toLowerCase();
|
||||
if (SENSITIVE_QUERY_KEYS.has(key)) {
|
||||
const rawKey = eq === -1 ? pair : pair.slice(0, eq);
|
||||
return `${rawKey}=REDACTED`;
|
||||
}
|
||||
return pair;
|
||||
}).join('&');
|
||||
}
|
||||
|
||||
function splitTrailingPunct(raw: string): [string, string] {
|
||||
let end = raw.length;
|
||||
while (end > 0) {
|
||||
const ch = raw[end - 1]!;
|
||||
if (ch === ')' || ch === ']' || ch === ',') {
|
||||
end -= 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return [raw.slice(0, end), raw.slice(end)];
|
||||
}
|
||||
|
||||
function redactUrl(raw: string): string {
|
||||
const [url, suffix] = splitTrailingPunct(raw);
|
||||
const schemeEnd = url.indexOf('://');
|
||||
if (schemeEnd === -1) return raw;
|
||||
|
||||
let out = url.slice(0, schemeEnd + 3);
|
||||
let rest = url.slice(schemeEnd + 3);
|
||||
|
||||
const at = rest.lastIndexOf('@');
|
||||
if (at !== -1) {
|
||||
out += '***@';
|
||||
rest = rest.slice(at + 1);
|
||||
}
|
||||
|
||||
const [hostport, path] = splitHostPath(rest);
|
||||
const [host, port] = splitHostPort(hostport);
|
||||
out += maskHostname(host);
|
||||
if (port) out += `:${port}`;
|
||||
|
||||
const q = path.indexOf('?');
|
||||
if (q === -1) {
|
||||
out += path;
|
||||
} else {
|
||||
out += path.slice(0, q + 1);
|
||||
out += redactQueryString(path.slice(q + 1));
|
||||
}
|
||||
|
||||
return out + suffix;
|
||||
}
|
||||
|
||||
function redactBearerTokens(line: string): string {
|
||||
const marker = 'Bearer ';
|
||||
let s = line;
|
||||
let searchFrom = 0;
|
||||
while (true) {
|
||||
const idx = s.indexOf(marker, searchFrom);
|
||||
if (idx === -1) break;
|
||||
const start = idx + marker.length;
|
||||
const tail = s.slice(start);
|
||||
const endRel = tail.search(/[\s"')\]]/);
|
||||
const end = endRel === -1 ? s.length : start + endRel;
|
||||
if (end > start) {
|
||||
s = `${s.slice(0, start)}REDACTED${s.slice(end)}`;
|
||||
}
|
||||
searchFrom = start + 'REDACTED'.length;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function redactPangolinHeaders(line: string): string {
|
||||
return line.replace(PANGOLIN_HEADER_RE, '$1REDACTED');
|
||||
}
|
||||
|
||||
function redactSensitiveKeyValues(line: string): string {
|
||||
let out = line;
|
||||
for (const key of SENSITIVE_KV_KEYS) {
|
||||
for (const sep of [':', '='] as const) {
|
||||
const needle = `${key}${sep}`;
|
||||
const lower = out.toLowerCase();
|
||||
let searchFrom = 0;
|
||||
while (true) {
|
||||
const rel = lower.indexOf(needle, searchFrom);
|
||||
if (rel === -1) break;
|
||||
const idx = rel;
|
||||
let valStart = idx + needle.length;
|
||||
while (out[valStart] === ' ') valStart += 1;
|
||||
const tail = out.slice(valStart);
|
||||
const endRel = tail.search(/[\s&,;)]/);
|
||||
const end = endRel === -1 ? out.length : valStart + endRel;
|
||||
if (end > valStart) {
|
||||
out = `${out.slice(0, valStart)}REDACTED${out.slice(end)}`;
|
||||
}
|
||||
searchFrom = valStart + 'REDACTED'.length;
|
||||
if (searchFrom >= out.length) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function redactUrlsInText(line: string): string {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const http = line.startsWith('http://', i);
|
||||
const https = line.startsWith('https://', i);
|
||||
const schemeLen = http ? 7 : https ? 8 : 0;
|
||||
if (schemeLen > 0) {
|
||||
const start = i;
|
||||
i += schemeLen;
|
||||
while (i < line.length) {
|
||||
const c = line[i]!;
|
||||
if (/\s/.test(c) || c === '"' || c === "'" || c === '>') break;
|
||||
if ((c === ')' || c === ']' || c === ',') && i + 1 < line.length) {
|
||||
const next = line[i + 1]!;
|
||||
if (/\s/.test(next) || next === '"' || next === "'") break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out += redactUrl(line.slice(start, i));
|
||||
} else {
|
||||
out += line[i];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sanitizeLogLine(line: string): string {
|
||||
return redactUrlsInText(redactSensitiveKeyValues(redactPangolinHeaders(redactBearerTokens(line))));
|
||||
}
|
||||
Reference in New Issue
Block a user