refactor: rename frontend/ to web-player/ and format with prettier

Updates the compose build context, the Dockerfile header, the package
name and the docs. The compose service and container keep the name
frontend/musify-frontend, since nginx routes to http://frontend:3000.

The source files also carry the first prettier pass, so the rename and
the reformat land together for them.
This commit is contained in:
2026-07-20 07:35:01 +02:00
parent 05514a1c6e
commit 97daa13ebd
104 changed files with 134 additions and 2457 deletions
+37
View File
@@ -0,0 +1,37 @@
export const HUES = [145, 330, 40, 152, 300, 200, 10, 120, 280];
export const ACCENT_LIGHTNESS = 60;
export const ACCENT_CHROMA = 0.17;
export const ACCENT_HUE = 145;
export function hueFor(id: string | number): number {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
export function accentForHue(hue: number): string {
return `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${hue})`;
}
export function accentSoft(hue: number, pct: number): string {
return `color-mix(in srgb, ${accentForHue(hue)} ${pct}%, transparent)`;
}
export function gradientForHue(hue: number): string {
return `linear-gradient(135deg, oklch(70% 0.15 ${hue}), oklch(36% 0.13 ${hue + 28}))`;
}
export function fmtTime(seconds: number): string {
const total = Math.max(0, Math.floor(seconds || 0));
const minutes = Math.floor(total / 60);
const rest = total % 60;
return `${minutes}:${rest < 10 ? '0' : ''}${rest}`;
}
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
export function fmtDate(value: string | number | Date): string {
return dateFormatter.format(new Date(value));
}
+114
View File
@@ -0,0 +1,114 @@
import { browser } from '$app/environment';
import { ACCENT_LIGHTNESS } from './color';
export interface Accent {
accent: string;
gradient: string;
}
function srgbToLinear(channel: number): number {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function rgbToOklch(r: number, g: number, b: number): [number, number, number] {
const lr = srgbToLinear(r);
const lg = srgbToLinear(g);
const lb = srgbToLinear(b);
const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
const l_ = Math.cbrt(l);
const m_ = Math.cbrt(m);
const s_ = Math.cbrt(s);
const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
const C = Math.sqrt(a * a + bb * bb);
const H = (Math.atan2(bb, a) * 180) / Math.PI;
return [L, C, (H + 360) % 360];
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
const BUCKETS = 36;
export async function extractAccent(url: string): Promise<Accent | null> {
if (!browser) return null;
let img: HTMLImageElement;
try {
img = await loadImage(url);
} catch {
return null;
}
const size = 40;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(img, 0, 0, size, size);
let pixels: Uint8ClampedArray;
try {
pixels = ctx.getImageData(0, 0, size, size).data;
} catch {
return null;
}
const weight = new Array(BUCKETS).fill(0);
const hueSin = new Array(BUCKETS).fill(0);
const hueCos = new Array(BUCKETS).fill(0);
const chromaSum = new Array(BUCKETS).fill(0);
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i + 3] < 128) continue;
const [L, C, H] = rgbToOklch(pixels[i], pixels[i + 1], pixels[i + 2]);
if (L < 0.2 || L > 0.92 || C < 0.03) continue;
const bucket = Math.floor((H / 360) * BUCKETS) % BUCKETS;
const w = C * C;
const rad = (H * Math.PI) / 180;
weight[bucket] += w;
hueSin[bucket] += Math.sin(rad) * w;
hueCos[bucket] += Math.cos(rad) * w;
chromaSum[bucket] += C * w;
}
let best = -1;
let bestWeight = 0;
for (let b = 0; b < BUCKETS; b++) {
if (weight[b] > bestWeight) {
bestWeight = weight[b];
best = b;
}
}
if (best < 0) return null;
const hue = ((Math.atan2(hueSin[best], hueCos[best]) * 180) / Math.PI + 360) % 360;
const chroma = Math.min(0.19, Math.max(0.1, chromaSum[best] / weight[best]));
const h = hue.toFixed(1);
const c = chroma.toFixed(3);
const c2 = (chroma * 0.85).toFixed(3);
const h2 = ((hue + 25) % 360).toFixed(1);
return {
accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`,
gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))`
};
}
+22
View File
@@ -0,0 +1,22 @@
:root {
--mf-bg: rgb(9, 9, 9);
--mf-elevated: #0b0b0d;
--mf-text: #c7c6c5;
--mf-text-2: #a5a4a3;
--mf-text-3: #8c8b8a;
--mf-text-4: #7f7e7d;
--mf-surface: rgb(18, 18, 18);
--mf-surface-2: rgba(255, 255, 255, 0.06);
--mf-surface-hover: rgba(255, 255, 255, 0.09);
--mf-border: rgba(255, 255, 255, 0.08);
--mf-border-strong: rgba(255, 255, 255, 0.12);
--mf-track: rgba(255, 255, 255, 0.12);
--mf-accent: oklch(60% 0.17 145);
--mf-accent-60: color-mix(in oklch, var(--mf-accent) 80%, transparent);
--mf-radius: 12px;
--mf-sidebar-w: 264px;
}