Files
Psychotoxical-psysonic/src/utils/dynamicColors.ts
T
Psychotoxical 0119e27f6d feat(covers): logo fallback for broken cover art images
CachedImage now shows /logo-psysonic.png when an image fails to load
instead of a broken icon. The fallback switches to object-fit: contain
with a card background so the logo stays proportional inside the container.

If the caller provides a custom onError (e.g. to hide the element), that
handler is used instead. A separate fallbackSrc state prevents React from
re-writing the broken URL on re-renders. The DOM-level onerror = null
guard prevents any infinite error loop.

extractCoverColors skips color extraction when the logo fallback is
active, falling back to the theme accent color instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:25:25 +02:00

166 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─── 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;
/**
* 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.
*
* Resolves with `{ accent: '' }` on any error — the caller's CSS
* `var(--dynamic-fs-accent, var(--accent))` then falls back to the theme accent.
*/
export function extractCoverColors(imageUrl: string): Promise<CoverColors> {
if (!imageUrl) return Promise.resolve({ accent: '' });
// Logo fallback has no meaningful color — skip extraction and use theme accent
if (imageUrl.includes('logo-psysonic')) return Promise.resolve({ accent: '' });
return new Promise(resolve => {
const img = new Image();
// Blob URLs are same-origin in Tauri WebKit — no crossOrigin needed.
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = 8;
canvas.height = 8;
const ctx = canvas.getContext('2d');
if (!ctx) { resolve({ accent: '' }); return; }
ctx.drawImage(img, 0, 0, 8, 8);
const { data } = ctx.getImageData(0, 0, 8, 8);
// Pick pixel with highest HSL saturation (most vibrant).
let bestSat = -1;
let bestR = 180, bestG = 100, bestB = 50; // warm orange fallback
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], 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);
resolve({ accent: `rgb(${fr},${fg},${fb})` });
} catch {
resolve({ accent: '' });
}
};
img.onerror = () => resolve({ accent: '' });
img.src = imageUrl;
});
}