Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274import type { SwatchMap } from "colorthief";import { MediaQuery } from "svelte/reactivity";import { api } from "../api/client.svelte.js";import { offlineImages } from "../offline/offlineManager.svelte.js";import { player } from "../playback/player.svelte.js";import { songArtId } from "../ui/artwork.svelte.js";import { customColorSchemeFields, settings } from "./settings.svelte.js";
type ThemeBg = { bg: string; text: string; dark: boolean };export type ArtworkTheme = ThemeBg & { highlight: string; darkBg: ThemeBg | null; lightBg: ThemeBg | null;};
// eslint-disable-next-line svelte/prefer-svelte-reactivityconst cache = new Map<string, ArtworkTheme>();
let colorThiefPromise: Promise<typeof import("colorthief")> | null = null;const darkMQ = new MediaQuery("(prefers-color-scheme: dark)");let themeMeta: HTMLMetaElement | null = null;
const loadColorThief = () => { if (!colorThiefPromise) { colorThiefPromise = import("colorthief").catch((err: unknown) => { colorThiefPromise = null; throw err; }); } return colorThiefPromise;};
const lightness = (r: number, g: number, b: number): number => (Math.max(r, g, b) + Math.min(r, g, b)) / 510;
const parseHex = (hex: string): [number, number, number] => [ Number.parseInt(hex.slice(1, 3), 16), Number.parseInt(hex.slice(3, 5), 16), Number.parseInt(hex.slice(5, 7), 16),];
const clampBrightness = (css: string, min: number, max: number): string => { const match = /^rgb\((\d+),\s*(\d+),\s*(\d+)\s*\)$/.exec(css); if (!match) return css; const [r, g, b] = [Number(match[1]), Number(match[2]), Number(match[3])]; const l = lightness(r, g, b); if (l >= min && l <= max) return css; const target = Math.min(max, Math.max(min, l)); if (l === 0) { const v = Math.round(target * 255); return `rgb(${v}, ${v}, ${v})`; } const factor = target / l; const clamp = (c: number) => Math.min(255, Math.round(c * factor)); return `rgb(${clamp(r)}, ${clamp(g)}, ${clamp(b)})`;};
const pickHighlight = (swatches: SwatchMap): string | null => ( swatches.Vibrant ?? swatches.DarkVibrant ?? swatches.LightVibrant ?? swatches.Muted ?? swatches.DarkMuted ?? swatches.LightMuted )?.color.css() ?? null;
const pickAdaptiveBg = (swatches: SwatchMap, dark: boolean): ThemeBg | null => { const ordered = dark ? [ swatches.DarkMuted, swatches.DarkVibrant, swatches.Muted, swatches.Vibrant, swatches.LightMuted, swatches.LightVibrant, ] : [ swatches.LightMuted, swatches.LightVibrant, swatches.Muted, swatches.Vibrant, swatches.DarkMuted, swatches.DarkVibrant, ]; const color = ordered.find(Boolean)?.color; return color ? { bg: color.css(), text: color.textColor, dark: color.isDark } : null;};
export const getArtworkTheme = async (image: HTMLImageElement): Promise<ArtworkTheme> => { const thief = await loadColorThief().catch(() => null); const [dominantColor, swatchMap] = thief ? await Promise.all([ thief.getColor(image, { ignoreWhite: false, quality: 1 }).catch(() => null), thief.getSwatches(image, { quality: 1 }).catch(() => null), ]) : [null, null]; return { bg: dominantColor?.css() ?? "", text: dominantColor?.textColor ?? "", dark: dominantColor?.isDark ?? false, highlight: (swatchMap ? pickHighlight(swatchMap) : null) ?? "", darkBg: swatchMap ? pickAdaptiveBg(swatchMap, true) : null, lightBg: swatchMap ? pickAdaptiveBg(swatchMap, false) : null, };};
const clearCustomProps = () => { const style = document.documentElement.style; for (const { variable } of customColorSchemeFields) style.removeProperty(variable);};
const explicitColorScheme = (): string => settings.colorScheme === "light" || settings.colorScheme === "dark" ? `only ${settings.colorScheme}` : "";
const resolveCanvasColor = (): string => { const temp = document.createElement("div"); temp.style.display = "none"; temp.style.colorScheme = explicitColorScheme() || (darkMQ.current ? "dark" : "light"); temp.style.color = "Canvas"; document.documentElement.appendChild(temp); const color = getComputedStyle(temp).color; temp.remove(); return color;};
const updateMeta = (color?: string) => { if (!themeMeta) { themeMeta = document.createElement("meta"); themeMeta.name = "theme-color"; document.head.appendChild(themeMeta); } themeMeta.content = color ?? resolveCanvasColor();};
const applyTheme = (bg: string, text: string, dark: boolean) => { const style = document.documentElement.style; clearCustomProps(); style.setProperty("--background", bg); style.setProperty("--text", text); style.colorScheme = `only ${dark ? "dark" : "light"}`; updateMeta(bg);};
const isDarkScheme = (): boolean => { if (settings.colorScheme === "custom") { const [r, g, b] = parseHex(settings.customColorScheme.background); return lightness(r, g, b) < 0.5; } return settings.colorScheme === "dark" || (settings.colorScheme === "auto" && darkMQ.current);};
export const resolveBackground = (theme: ArtworkTheme): ThemeBg => { if (!settings.dynamicColorsAdaptive) return theme; return (isDarkScheme() ? theme.darkBg : theme.lightBg) ?? theme;};
const applyArtworkTheme = (theme: ArtworkTheme, colors: boolean, highlights: boolean) => { const style = document.documentElement.style; const resolved = colors ? resolveBackground(theme) : null; if (resolved?.bg) { applyTheme(resolved.bg, resolved.text, resolved.dark); } else { applySelectedTheme(); } if (highlights && theme.highlight) { for (const { key, variable } of customColorSchemeFields) { if (key === "background" || key === "text") continue; style.removeProperty(variable); } const dark = resolved?.bg ? resolved.dark : isDarkScheme(); style.setProperty( "--accent", clampBrightness(theme.highlight, dark ? 0.5 : 0.2, dark ? 1 : 0.6), ); } else { style.removeProperty("--accent"); }};
const applyCustomTheme = () => { const style = document.documentElement.style; const background = settings.customColorScheme.background; for (const { key, variable } of customColorSchemeFields) style.setProperty(variable, settings.customColorScheme[key]); updateMeta(background); void loadColorThief().then( ({ createColor }) => { if ( settings.colorScheme !== "custom" || (settings.dynamicColors && player.track && songArtId(player.track)) || settings.customColorScheme.background !== background ) return; const [r, g, b] = parseHex(background); style.colorScheme = createColor(r, g, b, 0).isDark ? "only dark" : "only light"; }, () => {}, );};
const clearTheme = () => { const style = document.documentElement.style; clearCustomProps(); style.colorScheme = explicitColorScheme(); updateMeta();};
const applySelectedTheme = () => { document.documentElement.style.removeProperty("--accent"); return settings.colorScheme === "custom" ? applyCustomTheme() : clearTheme();};
export const initTheme = () => { $effect.root(() => { $effect(() => { const track = player.track; const dynamicColors = settings.dynamicColors; const dynamicHighlights = settings.dynamicHighlights; void settings.colorScheme; void settings.dynamicColorsAdaptive; void darkMQ.current; for (const { key } of customColorSchemeFields) void settings.customColorScheme[key]; if ((!dynamicColors && !dynamicHighlights) || !track) return applySelectedTheme();
const id = songArtId(track); const cached = cache.get(id); if (cached) return applyArtworkTheme(cached, dynamicColors, dynamicHighlights);
const coverUrl = api.getCoverArt(id, 128); if (!coverUrl) { if (offlineImages.has(id)) return; return applySelectedTheme(); }
const img = new Image(); img.fetchPriority = "low"; img.decoding = "async"; if (!coverUrl.startsWith("blob:")) img.crossOrigin = "anonymous"; img.src = coverUrl;
img.onload = async () => { img.onload = null; img.onerror = null; if ( (!settings.dynamicColors && !settings.dynamicHighlights) || player.track?.id !== track.id ) return; const theme = await getArtworkTheme(img); cache.set(id, theme); if (player.track?.id === track.id) applyArtworkTheme(theme, settings.dynamicColors, settings.dynamicHighlights); };
img.onerror = () => { img.onload = null; img.onerror = null; if (player.track?.id === track.id) { applySelectedTheme(); } };
return () => { img.onload = null; img.onerror = null; img.src = ""; }; }); });};