mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
0119e27f6d
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>
166 lines
6.6 KiB
TypeScript
166 lines
6.6 KiB
TypeScript
// ─── Dynamic Cover Colors ─────────────────────────────────────────────────────
|
||
//
|
||
// Extracts a vibrant accent color from an album cover URL using the Canvas API.
|
||
// Designed exclusively for the Fullscreen Player — applied as a CSS custom
|
||
// property on the .fs-player root, inheriting down to all children.
|
||
//
|
||
// Guarantees WCAG 4.5:1 contrast against the FS player's near-black background
|
||
// by progressively lightening the extracted color in HSL space until the ratio
|
||
// is met. Falls back to an empty string (→ CSS falls back to var(--accent)).
|
||
|
||
export interface CoverColors {
|
||
/** CSS color string, e.g. "rgb(200,120,60)". Empty string = use fallback. */
|
||
accent: string;
|
||
}
|
||
|
||
// ─── WCAG math (pure — unit-testable) ─────────────────────────────────────────
|
||
|
||
/** Convert a sRGB channel [0, 1] to linear light. */
|
||
export function srgbToLinear(c: number): number {
|
||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||
}
|
||
|
||
/** WCAG relative luminance for linear-light RGB channels [0, 1]. */
|
||
export function relativeLuminance(r: number, g: number, b: number): number {
|
||
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
|
||
}
|
||
|
||
/** WCAG contrast ratio given two relative luminances. Always ≥ 1. */
|
||
export function contrastRatio(l1: number, l2: number): number {
|
||
const lighter = Math.max(l1, l2);
|
||
const darker = Math.min(l1, l2);
|
||
return (lighter + 0.05) / (darker + 0.05);
|
||
}
|
||
|
||
// ─── HSL helpers (pure) ───────────────────────────────────────────────────────
|
||
|
||
/** RGB [0–255] → HSL [0–360, 0–1, 0–1]. */
|
||
export function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||
const rn = r / 255, gn = g / 255, bn = b / 255;
|
||
const max = Math.max(rn, gn, bn);
|
||
const min = Math.min(rn, gn, bn);
|
||
const l = (max + min) / 2;
|
||
if (max === min) return [0, 0, l];
|
||
const d = max - min;
|
||
const s = l < 0.5 ? d / (max + min) : d / (2 - max - min);
|
||
let h: number;
|
||
switch (max) {
|
||
case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
|
||
case gn: h = ((bn - rn) / d + 2) / 6; break;
|
||
default: h = ((rn - gn) / d + 4) / 6;
|
||
}
|
||
return [h * 360, s, l];
|
||
}
|
||
|
||
function hue2rgb(p: number, q: number, t: number): number {
|
||
if (t < 0) t += 1;
|
||
if (t > 1) t -= 1;
|
||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||
if (t < 1 / 2) return q;
|
||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||
return p;
|
||
}
|
||
|
||
/** HSL [0–360, 0–1, 0–1] → RGB [0–255]. */
|
||
export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||
h = h / 360;
|
||
if (s === 0) {
|
||
const v = Math.round(l * 255);
|
||
return [v, v, v];
|
||
}
|
||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||
const p = 2 * l - q;
|
||
return [
|
||
Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
||
Math.round(hue2rgb(p, q, h) * 255),
|
||
Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
||
];
|
||
}
|
||
|
||
// ─── Contrast enforcement (pure — unit-testable) ──────────────────────────────
|
||
|
||
/**
|
||
* Given an RGB color [0–255] and the luminance of the background it will sit
|
||
* on, progressively increases HSL lightness in 0.04 steps until the contrast
|
||
* ratio reaches `minRatio`. Returns white [255,255,255] as the ultimate
|
||
* fallback if even L=1 doesn't suffice (can only happen at extreme minRatio
|
||
* values, e.g. 21:1).
|
||
*/
|
||
export function ensureContrast(
|
||
rgb: [number, number, number],
|
||
bgLuminance: number,
|
||
minRatio: number,
|
||
): [number, number, number] {
|
||
const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
|
||
|
||
// Already meeting the requirement?
|
||
const initialLum = relativeLuminance(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255);
|
||
if (contrastRatio(initialLum, bgLuminance) >= minRatio) return [...rgb];
|
||
|
||
// Lighten in steps of 4 % (25 iterations max → terminates)
|
||
for (let step = 1; step <= 25; step++) {
|
||
const newL = Math.min(1, l + step * 0.04);
|
||
const newRgb = hslToRgb(h, s, newL);
|
||
const newLum = relativeLuminance(newRgb[0] / 255, newRgb[1] / 255, newRgb[2] / 255);
|
||
if (contrastRatio(newLum, bgLuminance) >= minRatio) return newRgb;
|
||
}
|
||
return [255, 255, 255];
|
||
}
|
||
|
||
// ─── Canvas extraction (requires DOM) ─────────────────────────────────────────
|
||
|
||
/**
|
||
* The FS player mesh background is a very dark near-black.
|
||
* luminance ≈ 0.010 is a conservative upper bound — the color contrast will be
|
||
* at least this good.
|
||
*/
|
||
const FS_BG_LUMINANCE = 0.010;
|
||
const MIN_CONTRAST = 4.5;
|
||
|
||
/**
|
||
* 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;
|
||
});
|
||
}
|