initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -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}%))`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user