initial state: muzick music player + recommendation engine

This commit is contained in:
kami
2026-07-14 01:35:52 +04:00
commit 737bf19fd1
196 changed files with 32431 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
/**
* Shared colour utilities.
*
* `hueFromString` is used by `Artwork` (gradient placeholders) and `Genres`
* (genre-card gradients). Previously duplicated in both files — extracted here
* as the single source of truth.
*/
/** Deterministic hash → hue (0..359) from an arbitrary string. */
export function hueFromString(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h) % 360;
}
/**
* Build a two-stop diagonal HSL gradient from a seed string. Used by genre
* cards and artwork placeholders so they get a distinct but deterministic
* colour per name.
*/
export function gradientFromSeed(
seed: string,
sat = 48,
light = 23,
light2 = 11,
hueOffset = 55,
): string {
const hue = hueFromString(seed);
return `linear-gradient(160deg, hsl(${hue},${sat}%,${light}%), hsl(${(hue + hueOffset) % 360},${Math.max(sat - 8, 20)}%,${light2}%))`;
}
+67
View File
@@ -0,0 +1,67 @@
// Parse LRC-format synced lyrics into timestamped lines.
//
// The worker stores lrclib's `syncedLyrics` (a raw LRC string) JSON-encoded in
// the track_lyrics.synced_lyrics JSONB column, so it comes back over the API as
// a JSON string. LRC looks like:
// [ar:Artist] <- metadata tag, ignored
// [00:12.34]First line
// [00:15.80]Second line
// [01:02.5] <- empty line (instrumental gap) kept as ''
// A single text line can carry several timestamps ("[00:01][00:05]chorus").
export interface LyricLine {
/** Start time in seconds. */
time: number;
text: string;
}
const TIMESTAMP_RE = /\[(\d{1,2}):(\d{2}(?:[.:]\d{1,3})?)\]/g;
/**
* Parse an LRC string into time-sorted lines. Returns [] when the input has no
* timestamped lines (e.g. plain-text lyrics or null), so callers can fall back.
*/
export function parseLrc(input: unknown): LyricLine[] {
if (typeof input !== 'string' || input.trim() === '') return [];
const lines: LyricLine[] = [];
for (const raw of input.split(/\r?\n/)) {
TIMESTAMP_RE.lastIndex = 0;
const stamps: number[] = [];
let match: RegExpExecArray | null;
let lastEnd = 0;
while ((match = TIMESTAMP_RE.exec(raw)) !== null) {
const min = parseInt(match[1], 10);
const sec = parseFloat(match[2].replace(':', '.'));
stamps.push(min * 60 + sec);
lastEnd = match.index + match[0].length;
}
if (stamps.length === 0) continue; // metadata tag or untimed line
const text = raw.slice(lastEnd).trim();
for (const time of stamps) lines.push({ time, text });
}
lines.sort((a, b) => a.time - b.time);
return lines;
}
/**
* Index of the active line for a given playback position (the last line whose
* timestamp is <= position). Returns -1 before the first line. Lines must be
* time-sorted (as returned by parseLrc).
*/
export function activeLineIndex(lines: LyricLine[], position: number): number {
let lo = 0;
let hi = lines.length - 1;
let result = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (lines[mid].time <= position) {
result = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Muzick's fixed Ethos fingerprint: honey amber accent on the warm-brown
* neutral ramp. Ethos apps get exactly one accent — no runtime theme
* picker — so these are applied once at boot, not user-selectable.
*/
const ETHOS_VARS: Record<string, string> = {
'--ethos-bg0': '#14110D',
'--ethos-bg1': '#1B1712',
'--ethos-bg2': '#221D17',
'--ethos-surface0': '#2C261D',
'--ethos-surface1': '#372F24',
'--ethos-surface2': '#423927',
'--ethos-border': 'rgba(244,234,220,0.09)',
'--ethos-text': '#F4EEE4',
'--ethos-secondary': '#B4AA98',
'--ethos-muted': '#756C5C',
'--ethos-disabled': '#5a5347',
'--ethos-accent': '#EDA24E',
'--ethos-accent-hover': '#E08F32',
'--ethos-on-accent': '#14110D',
'--ethos-green': '#22c55e',
'--ethos-amber': '#eab308',
'--ethos-red': '#ef4444',
'--ethos-purple': '#a855f7',
'--ethos-cyan': '#22d3ee',
'--ethos-orange': '#f97316',
};
export const STORAGE_KEYS = {
volume: 'muzick.settings.volume',
} as const;
export function initTheme(): void {
const root = document.documentElement;
for (const [key, value] of Object.entries(ETHOS_VARS)) {
root.style.setProperty(key, value);
}
}
export function readStoredVolume(fallback: number): number {
try {
const stored = localStorage.getItem(STORAGE_KEYS.volume);
if (stored !== null) {
const parsed = Number(stored);
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) return parsed;
}
} catch { /* ignore */ }
return fallback;
}