refactor(lib): fold utils/perf into lib/perf + utils/ui (DOM utilities) into lib/dom

This commit is contained in:
Psychotoxical
2026-06-30 20:36:23 +02:00
parent 6becd57798
commit 390fdfbcca
144 changed files with 183 additions and 183 deletions
+76
View File
@@ -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 });
}
+216
View File
@@ -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+\)$/);
});
});
+151
View File
@@ -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); });
});
});
+225
View File
@@ -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 [0255] → HSL [0360, 01, 01]. */
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 [0360, 01, 01] → RGB [0255]. */
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 [0255] 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, '');
}
+69
View File
@@ -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;
}
+41
View File
@@ -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
* thumbs 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 };
}
+66
View File
@@ -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)
);
}
+103
View File
@@ -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);
}
+1 -1
View File
@@ -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,
+49
View File
@@ -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);
});
});
+97
View File
@@ -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`;
}
+83
View File
@@ -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);
});
});
+147
View File
@@ -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();
}
+60
View File
@@ -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',
]);
});
});
+62
View File
@@ -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',
]);
});
});
+49
View File
@@ -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;
}
+139
View File
@@ -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:');
}
+167
View File
@@ -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],
);
}
+6
View File
@@ -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;
}
+140
View File
@@ -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;
+129
View File
@@ -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 };
}
+442
View File
@@ -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);
}
+107
View File
@@ -0,0 +1,107 @@
import { useSyncExternalStore } from 'react';
export type PerfOverlayCorner = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
export type PerfOverlayAppearance = {
corner: PerfOverlayCorner;
/** Background fill strength 0.251 (higher = less transparent). */
opacity: number;
};
const STORAGE_KEY = 'psysonic_perf_overlay_appearance_v1';
export const PERF_OVERLAY_CORNER_OPTIONS: Array<{ id: PerfOverlayCorner; label: string }> = [
{ id: 'top-right', label: 'Top right' },
{ id: 'top-left', label: 'Top left' },
{ id: 'bottom-right', label: 'Bottom right' },
{ id: 'bottom-left', label: 'Bottom left' },
];
const DEFAULT_APPEARANCE: PerfOverlayAppearance = {
corner: 'top-right',
opacity: 0.82,
};
let appearance: PerfOverlayAppearance = { ...DEFAULT_APPEARANCE };
const listeners = new Set<() => void>();
function clampOpacity(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_APPEARANCE.opacity;
return Math.min(1, Math.max(0.25, value));
}
function safeParseAppearance(raw: string | null): Partial<PerfOverlayAppearance> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as Partial<PerfOverlayAppearance>;
return parsed ?? {};
} catch {
return {};
}
}
function isCorner(value: unknown): value is PerfOverlayCorner {
return value === 'top-right'
|| value === 'top-left'
|| value === 'bottom-right'
|| value === 'bottom-left';
}
function persist(): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(appearance));
} catch {
/* ignore */
}
}
function emit(): void {
listeners.forEach(fn => fn());
}
function setAppearance(next: PerfOverlayAppearance): void {
appearance = next;
persist();
emit();
}
function init(): void {
if (typeof window === 'undefined') return;
const fromStorage = safeParseAppearance(window.localStorage.getItem(STORAGE_KEY));
appearance = {
corner: isCorner(fromStorage.corner) ? fromStorage.corner : DEFAULT_APPEARANCE.corner,
opacity: clampOpacity(fromStorage.opacity ?? DEFAULT_APPEARANCE.opacity),
};
}
init();
export function getPerfOverlayAppearance(): PerfOverlayAppearance {
return appearance;
}
export function subscribePerfOverlayAppearance(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function usePerfOverlayAppearance(): PerfOverlayAppearance {
return useSyncExternalStore(subscribePerfOverlayAppearance, getPerfOverlayAppearance, () => DEFAULT_APPEARANCE);
}
export function setPerfOverlayCorner(corner: PerfOverlayCorner): void {
setAppearance({ ...appearance, corner });
}
export function setPerfOverlayOpacity(opacity: number): void {
setAppearance({ ...appearance, opacity: clampOpacity(opacity) });
}
export function resetPerfOverlayAppearance(): void {
setAppearance({ ...DEFAULT_APPEARANCE });
}
export function perfOverlayCornerClass(corner: PerfOverlayCorner): string {
return `fps-overlay--${corner}`;
}
+114
View File
@@ -0,0 +1,114 @@
import { useSyncExternalStore } from 'react';
import type { PerfProbeFlags } from '@/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,
};
}
+144
View File
@@ -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);
}
+293
View File
@@ -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',
},
],
},
],
},
];
+25
View File
@@ -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;
}
+48
View File
@@ -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);
});
});
+243
View File
@@ -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))));
}