Improved Settings

This commit is contained in:
2025-08-13 20:25:20 +10:00
parent 1fda054d2a
commit c0255acb4c
3 changed files with 407 additions and 217 deletions
+330 -205
View File
@@ -2,16 +2,20 @@ import { ReactiveStore } from "@luna/core";
import { LunaSettings, LunaNumberSetting, LunaSwitchSetting, LunaTextSetting } from "@luna/ui"; import { LunaSettings, LunaNumberSetting, LunaSwitchSetting, LunaTextSetting } from "@luna/ui";
import React from "react"; import React from "react";
export type ColoramaMode = "single" | "gradient" | "auto-single" | "auto-gradient"; export type ColoramaMode = "single" | "gradient" | "auto-single" | "auto-gradient" | "rainbow";
export const settings = await ReactiveStore.getPluginStorage("ColoramaLyrics", { export const settings = await ReactiveStore.getPluginStorage("ColoramaLyrics", {
enabled: true, enabled: true,
mode: "single" as ColoramaMode, mode: "single" as ColoramaMode,
// Store colors as ARGB hex (#AARRGGBB) // Store colors as RGB hex (#RRGGBB) and opacity separately (0-100)
singleColor: "#FFFFFFFF", singleColor: "#FFFFFF",
gradientStart: "#FFFFFFFF", singleAlpha: 100,
gradientEnd: "#88AAFFFF", gradientStart: "#FFFFFF",
gradientStartAlpha: 100,
gradientEnd: "#AAFFFF",
gradientEndAlpha: 100,
gradientAngle: 0, gradientAngle: 0,
rainbowSpeed: 8,
customColors: [] as string[], customColors: [] as string[],
excludeInactive: false excludeInactive: false
}); });
@@ -20,59 +24,67 @@ export const Settings = () => {
const [enabled, setEnabled] = React.useState(settings.enabled); const [enabled, setEnabled] = React.useState(settings.enabled);
const [mode, setMode] = React.useState<ColoramaMode>(settings.mode); const [mode, setMode] = React.useState<ColoramaMode>(settings.mode);
const [singleColor, setSingleColor] = React.useState(settings.singleColor); const [singleColor, setSingleColor] = React.useState(settings.singleColor);
const [singleAlpha, setSingleAlpha] = React.useState<number>(settings.singleAlpha ?? 100);
const [gradientStart, setGradientStart] = React.useState(settings.gradientStart); const [gradientStart, setGradientStart] = React.useState(settings.gradientStart);
const [gradientStartAlpha, setGradientStartAlpha] = React.useState<number>(settings.gradientStartAlpha ?? 100);
const [gradientEnd, setGradientEnd] = React.useState(settings.gradientEnd); const [gradientEnd, setGradientEnd] = React.useState(settings.gradientEnd);
const [gradientEndAlpha, setGradientEndAlpha] = React.useState<number>(settings.gradientEndAlpha ?? 100);
const [gradientAngle, setGradientAngle] = React.useState(settings.gradientAngle); const [gradientAngle, setGradientAngle] = React.useState(settings.gradientAngle);
const [rainbowSpeed, setRainbowSpeed] = React.useState<number>(settings.rainbowSpeed ?? 8);
const [customInput, setCustomInput] = React.useState(settings.singleColor); const [customInput, setCustomInput] = React.useState(settings.singleColor);
const [customColors, setCustomColors] = React.useState(settings.customColors); const [customColors, setCustomColors] = React.useState(settings.customColors);
const [showPicker, setShowPicker] = React.useState(false); const [showPicker, setShowPicker] = React.useState(false);
const [isAnimatingIn, setIsAnimatingIn] = React.useState(false); const [isAnimatingIn, setIsAnimatingIn] = React.useState(false);
const [shouldRender, setShouldRender] = React.useState(false); const [shouldRender, setShouldRender] = React.useState(false);
const [excludeInactive, setExcludeInactive] = React.useState(settings.excludeInactive); const [excludeInactive, setExcludeInactive] = React.useState(settings.excludeInactive);
const [activeEndpoint, setActiveEndpoint] = React.useState<'single' | 'start' | 'end'>('single');
const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType<any>;
// Helpers for ARGB <-> components // Helpers for HEX parsing and alpha extraction
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
const normalizeToARGB = (hex: string, fallback: string = "#FFFFFFFF"): string => { const normalizeToRGB = (hex: string, fallback: string = "#FFFFFF"): string => {
let v = hex.trim().toLowerCase(); let v = hex.trim().toLowerCase();
if (!v.startsWith('#')) v = `#${v}`; if (!v.startsWith('#')) v = `#${v}`;
// #rgb or #rgba -> expand // #rgb or #rgba -> expand
if (/^#([0-9a-f]{3,4})$/.test(v)) { if (/^#([0-9a-f]{3,4})$/.test(v)) {
const m = v.slice(1); const m = v.slice(1);
const a = m.length === 4 ? m[3] : 'f';
const r = m[0]; const r = m[0];
const g = m[1]; const g = m[1];
const b = m[2]; const b = m[2];
v = `#${a}${r}${g}${b}${r}${g}${b}${a}`; // temporary, will reformat below // ignore alpha if provided (#rgba)
return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
}
// #aarrggbb -> strip alpha
if (/^#([0-9a-f]{8})$/.test(v)) {
const rrggbb = v.slice(3);
return `#${rrggbb}`.toUpperCase();
} }
// #rrggbb // #rrggbb
if (/^#([0-9a-f]{6})$/.test(v)) { if (/^#([0-9a-f]{6})$/.test(v)) return v.toUpperCase();
const m = v.slice(1);
const rrggbb = m;
const aa = 'ff';
return `#${aa}${rrggbb}`.toUpperCase();
}
// #aarrggbb
if (/^#([0-9a-f]{8})$/.test(v)) return v.toUpperCase();
return fallback; return fallback;
}; };
const setAlphaOnARGB = (argb: string, alpha01: number): string => { const extractAlphaPercent = (hex: string, fallbackPercent: number = 100): number => {
const a = clamp(Math.round(alpha01 * 255), 0, 255).toString(16).padStart(2, '0'); let v = hex.trim().toLowerCase();
const body = argb.replace('#', '').slice(2); if (!v.startsWith('#')) v = `#${v}`;
return (`#${a}${body}`).toUpperCase(); if (/^#([0-9a-f]{4})$/.test(v)) {
}; const a = v[4];
const getAlpha01 = (argb: string): number => { return Math.round((parseInt(a + a, 16) / 255) * 100);
const v = normalizeToARGB(argb); }
const a = parseInt(v.slice(1, 3), 16); if (/^#([0-9a-f]{8})$/.test(v)) {
return clamp(a / 255, 0, 1); const a = v.slice(1, 3);
return Math.round((parseInt(a, 16) / 255) * 100);
}
return fallbackPercent;
}; };
const colorPresets = [ const colorPresets = [
"#FFFFFFFF", "#FF0000FF", "#00FF00FF", "#0000FFFF", "#FFFF00FF", "#FF00FFFF", "#00FFFFFF", "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF",
"#FF8800FF", "#8800FFFF", "#0088FFFF", "#88FF00FF", "#FF0088FF", "#00FF88FF", "#FF8800", "#8800FF", "#0088FF", "#88FF00", "#FF0088", "#00FF88",
"#444444FF", "#888888FF", "#CCCCCCFF", "#1DB954FF", "#E22134FF", "#1976D2FF" "#444444", "#888888", "#CCCCCC", "#1DB954", "#E22134", "#1976D2"
]; ];
const openPicker = () => { const openPicker = (endpoint: 'single' | 'start' | 'end' = 'single') => {
setActiveEndpoint(endpoint);
setShowPicker(true); setShowPicker(true);
setShouldRender(true); setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10); setTimeout(() => setIsAnimatingIn(true), 10);
@@ -85,16 +97,16 @@ export const Settings = () => {
}, 200); }, 200);
}; };
const argbColorRegex = /^#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})$/i; const hexColorRegex = /^#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})$/i;
const addCustomColor = () => { const addCustomColor = () => {
const trimmed = customInput.trim(); const trimmed = customInput.trim();
if ( if (
argbColorRegex.test(trimmed) && hexColorRegex.test(trimmed) &&
!colorPresets.includes(trimmed) && !colorPresets.includes(trimmed) &&
!customColors.includes(normalizeToARGB(trimmed)) !customColors.includes(normalizeToRGB(trimmed))
) { ) {
const updated = [...customColors, normalizeToARGB(trimmed)]; const updated = [...customColors, normalizeToRGB(trimmed)];
setCustomColors(updated); setCustomColors(updated);
settings.customColors = updated; settings.customColors = updated;
} }
@@ -115,12 +127,10 @@ export const Settings = () => {
return ( return (
<LunaSettings> <LunaSettings>
{/* Mode selection via dropdown */} {/* Mode selection via dropdown (aligned right) */}
<div style={{ padding: "8px 0", display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}> <div style={{ padding: "8px 0", display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ minWidth: 160 }}> <div style={{ fontWeight: "normal", fontSize: "1.075rem" }}>Mode</div>
<div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: 4 }}>Mode</div> <div style={{ opacity: 0.7, fontSize: 14 }}>Choose how lyrics are colored</div>
<div style={{ opacity: 0.7, fontSize: 14 }}>Choose how lyrics are colored</div>
</div>
<select <select
value={mode} value={mode}
onChange={(e) => { onChange={(e) => {
@@ -132,15 +142,18 @@ export const Settings = () => {
padding: "6px 10px", padding: "6px 10px",
borderRadius: 6, borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)", border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.05)", background: "rgba(255,255,255,0.08)",
color: "#fff", color: "#fff",
cursor: "pointer" cursor: "pointer",
marginLeft: "auto",
minWidth: 180
}} }}
> >
<option value="single">Single</option> <option value="single" style={{ color: '#000', background: '#fff' }}>Single</option>
<option value="gradient">Gradient</option> <option value="gradient" style={{ color: '#000', background: '#fff' }}>Gradient</option>
<option value="auto-single">Auto (Cover)</option> <option value="auto-single" style={{ color: '#000', background: '#fff' }}>Auto (Cover)</option>
<option value="auto-gradient">Auto Gradient</option> <option value="auto-gradient" style={{ color: '#000', background: '#fff' }}>Auto Gradient</option>
<option value="rainbow" style={{ color: '#000', background: '#fff' }}>Rainbow</option>
</select> </select>
</div> </div>
@@ -148,101 +161,71 @@ export const Settings = () => {
<div style={{ padding: "8px 0", display: mode === "single" ? "flex" : "none", justifyContent: "space-between", alignItems: "center" }}> <div style={{ padding: "8px 0", display: mode === "single" ? "flex" : "none", justifyContent: "space-between", alignItems: "center" }}>
<div> <div>
<div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: 4 }}>Lyrics Color</div> <div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: 4 }}>Lyrics Color</div>
<div style={{ opacity: 0.7, fontSize: 14 }}>Solid color (HEX/ARGB HEX)</div> <div style={{ opacity: 0.7, fontSize: 14 }}>Solid color (configure inside picker)</div>
</div> </div>
<div style={{ display: "flex", gap: 8, alignItems: "center", position: "relative" }}> <div style={{ display: "flex", gap: 8, alignItems: "center", position: "relative" }}>
<button <button
onClick={() => (showPicker ? closePicker() : openPicker())} onClick={() => (showPicker ? closePicker() : openPicker('single'))}
style={{ style={{
width: 32, width: 32,
height: 32, height: 32,
border: "1px solid rgba(255,255,255,0.15)", border: "1px solid rgba(255,255,255,0.15)",
borderRadius: 6, borderRadius: 6,
cursor: "pointer", cursor: "pointer",
background: normalizeToARGB(singleColor) background: normalizeToRGB(singleColor)
}} }}
/> />
</div> </div>
</div> </div>
<div style={{ display: mode === "single" ? 'block' : 'none' }}>
<LunaNumberSetting
title="Single Alpha"
desc="Opacity of the single color (0-100%)"
min={0}
max={100}
step={1}
value={Math.round(getAlpha01(singleColor) * 100)}
onNumber={(value: number) => {
const next = setAlphaOnARGB(normalizeToARGB(singleColor), value / 100);
setSingleColor((settings.singleColor = next));
if (customInput) setCustomInput(next);
requestApply();
}}
/>
</div>
{/* Gradient controls */}
{/* Gradient controls (triggers only) */}
<div style={{ padding: "8px 0", display: mode === "gradient" ? "block" : "none" }}> <div style={{ padding: "8px 0", display: mode === "gradient" ? "block" : "none" }}>
<div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: 4 }}>Gradient</div> <div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: 4 }}>Gradient</div>
<div style={{ opacity: 0.7, fontSize: 14, marginBottom: 8 }}>Pick start/end and angle</div> <div style={{ opacity: 0.7, fontSize: 14, marginBottom: 8 }}>Pick start/end and angle (inside picker)</div>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}> <div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<button <button
onClick={() => { onClick={() => {
setCustomInput(gradientStart); setCustomInput(gradientStart);
openPicker(); openPicker('start');
}} }}
title="Start Color" title="Start Color"
style={{ width: 32, height: 32, border: "1px solid rgba(255,255,255,0.15)", borderRadius: 6, background: normalizeToARGB(gradientStart) }} style={{ width: 32, height: 32, border: "1px solid rgba(255,255,255,0.15)", borderRadius: 6, background: normalizeToRGB(gradientStart) }}
/> />
<button <button
onClick={() => { onClick={() => {
setCustomInput(gradientEnd); setCustomInput(gradientEnd);
openPicker(); openPicker('end');
}} }}
title="End Color" title="End Color"
style={{ width: 32, height: 32, border: "1px solid rgba(255,255,255,0.15)", borderRadius: 6, background: normalizeToARGB(gradientEnd) }} style={{ width: 32, height: 32, border: "1px solid rgba(255,255,255,0.15)", borderRadius: 6, background: normalizeToRGB(gradientEnd) }}
/> />
</div> </div>
<LunaNumberSetting
title="Start Alpha"
desc="Opacity of the gradient start (0-100%)"
min={0}
max={100}
step={1}
value={Math.round(getAlpha01(gradientStart) * 100)}
onNumber={(value: number) => {
const next = setAlphaOnARGB(normalizeToARGB(gradientStart), value / 100);
setGradientStart((settings.gradientStart = next));
requestApply();
}}
/>
<LunaNumberSetting
title="End Alpha"
desc="Opacity of the gradient end (0-100%)"
min={0}
max={100}
step={1}
value={Math.round(getAlpha01(gradientEnd) * 100)}
onNumber={(value: number) => {
const next = setAlphaOnARGB(normalizeToARGB(gradientEnd), value / 100);
setGradientEnd((settings.gradientEnd = next));
requestApply();
}}
/>
<LunaNumberSetting
title="Gradient Angle"
desc="Angle in degrees (0-360)"
min={0}
max={360}
step={1}
value={gradientAngle}
onNumber={(value: number) => {
setGradientAngle((settings.gradientAngle = value));
requestApply();
}}
/>
</div> </div>
{/* Auto gradient controls (open picker for angle) */}
<div style={{ padding: "8px 0", display: mode === "auto-gradient" ? "flex" : "none", justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: 4 }}>Auto Gradient</div>
<div style={{ opacity: 0.7, fontSize: 14 }}>Configure angle inside the picker</div>
</div>
<button
onClick={() => openPicker('start')}
style={{
padding: '8px 12px',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.08)',
color: '#fff',
cursor: 'pointer'
}}
>
Configure
</button>
</div>
{/* Rainbow controls removed: mode exists but has no UI */}
{/* Modal for picking and managing colors (reused) */} {/* Modal for picking and managing colors (reused) */}
{shouldRender && ( {shouldRender && (
<> <>
@@ -281,112 +264,254 @@ export const Settings = () => {
transition: "all 0.2s ease" transition: "all 0.2s ease"
}} }}
> >
<div style={{ marginBottom: 12, color: "#fff", fontWeight: "bold", fontSize: 14 }}>Choose Color (ARGB HEX)</div> <div style={{ marginBottom: 12, color: "#fff", fontWeight: "bold", fontSize: 14 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 8, marginBottom: 16 }}> {mode === 'single' ? 'Single Color' : 'Gradient Colors'}
{allColors.map((color, index) => (
<button
key={index}
onClick={() => {
if (mode === "single") {
const next = normalizeToARGB(color);
setSingleColor((settings.singleColor = next));
} else if (mode === "gradient") {
// Toggle which endpoint to update based on last edited input
if (customInput.toLowerCase() === gradientEnd.toLowerCase()) {
setGradientEnd((settings.gradientEnd = normalizeToARGB(color)));
} else {
setGradientStart((settings.gradientStart = normalizeToARGB(color)));
}
}
setCustomInput(normalizeToARGB(color));
requestApply();
closePicker();
}}
style={{
width: 32,
height: 32,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: normalizeToARGB(color),
cursor: "pointer"
}}
/>
))}
</div> </div>
<div style={{ marginBottom: 12 }}> {mode === 'gradient' && (
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: 12, marginBottom: 6 }}>Custom ARGB Hex (#AARRGGBB)</div> <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}> <div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12 }}>Editing</div>
<input <button
type="text" onClick={() => { setActiveEndpoint('start'); setCustomInput(gradientStart); }}
value={customInput} style={{
onChange={(e) => setCustomInput(e.target.value)} display: 'flex', alignItems: 'center', gap: 8,
onKeyDown={(e) => { padding: '6px 10px', borderRadius: 8,
if (e.key === 'Enter') { border: activeEndpoint === 'start' ? '1px solid rgba(255,255,255,0.5)' : '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)', color: '#fff', cursor: 'pointer'
}}
>
<span style={{ width: 14, height: 14, borderRadius: 3, background: normalizeToRGB(gradientStart), border: '1px solid rgba(255,255,255,0.3)' }} />
<span style={{ fontSize: 12 }}>Start</span>
</button>
<button
onClick={() => { setActiveEndpoint('end'); setCustomInput(gradientEnd); }}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 8,
border: activeEndpoint === 'end' ? '1px solid rgba(255,255,255,0.5)' : '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)', color: '#fff', cursor: 'pointer'
}}
>
<span style={{ width: 14, height: 14, borderRadius: 3, background: normalizeToRGB(gradientEnd), border: '1px solid rgba(255,255,255,0.3)' }} />
<span style={{ fontSize: 12 }}>End</span>
</button>
</div>
)}
{mode !== 'auto-gradient' && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 8, marginBottom: 16 }}>
{allColors.map((color, index) => (
<button
key={index}
onClick={() => {
if (mode === "single") {
const next = normalizeToRGB(color);
setSingleColor((settings.singleColor = next));
} else if (mode === "gradient") {
if (activeEndpoint === 'end') {
setGradientEnd((settings.gradientEnd = normalizeToRGB(color)));
} else {
setGradientStart((settings.gradientStart = normalizeToRGB(color)));
}
}
setCustomInput(normalizeToRGB(color));
requestApply();
}}
style={{
width: 32,
height: 32,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: normalizeToRGB(color),
cursor: "pointer"
}}
/>
))}
</div>
)}
{mode !== 'auto-gradient' && (
<div style={{ marginBottom: 12 }}>
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: 12, marginBottom: 6 }}>Custom Hex (#RRGGBB)</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
type="text"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const trimmed = customInput.trim();
if (hexColorRegex.test(trimmed)) {
if (mode === "single") {
const next = normalizeToRGB(trimmed);
setSingleColor((settings.singleColor = next));
setCustomInput(next);
} else if (mode === "gradient") {
const norm = normalizeToRGB(trimmed);
if (activeEndpoint === 'end') {
setGradientEnd((settings.gradientEnd = norm));
} else {
setGradientStart((settings.gradientStart = norm));
}
setCustomInput(norm);
}
requestApply();
}
addCustomColor();
}
}}
placeholder="#RRGGBB"
style={{
flex: 1,
padding: "8px 12px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
fontSize: 14,
fontFamily: "monospace",
boxSizing: "border-box"
}}
/>
<button
onClick={() => {
const trimmed = customInput.trim(); const trimmed = customInput.trim();
if (argbColorRegex.test(trimmed)) { if (hexColorRegex.test(trimmed)) {
if (mode === "single") { if (mode === "single") {
setSingleColor((settings.singleColor = normalizeToARGB(trimmed))); setSingleColor((settings.singleColor = normalizeToRGB(trimmed)));
} else if (mode === "gradient") { } else if (mode === "gradient") {
if (customInput.toLowerCase() === gradientEnd.toLowerCase()) { const norm = normalizeToRGB(trimmed);
setGradientEnd((settings.gradientEnd = normalizeToARGB(trimmed))); if (activeEndpoint === 'end') {
setGradientEnd((settings.gradientEnd = norm));
} else { } else {
setGradientStart((settings.gradientStart = normalizeToARGB(trimmed))); setGradientStart((settings.gradientStart = norm));
} }
} }
requestApply(); requestApply();
} }
addCustomColor(); addCustomColor();
} }}
}} style={{
placeholder="#AARRGGBB" width: 32,
style={{ height: 32,
flex: 1, borderRadius: 6,
padding: "8px 12px", border: "1px solid rgba(255,255,255,0.3)",
borderRadius: 6, background: "rgba(255,255,255,0.15)",
border: "1px solid rgba(255,255,255,0.2)", color: "#fff",
background: "rgba(255,255,255,0.1)", cursor: "pointer",
color: "#fff", fontSize: 16,
fontSize: 14, display: "flex",
fontFamily: "monospace", alignItems: "center",
boxSizing: "border-box" justifyContent: "center",
}} transition: "all 0.2s ease"
/> }}
<button >
onClick={() => { +
const trimmed = customInput.trim(); </button>
if (argbColorRegex.test(trimmed)) { </div>
if (mode === "single") {
setSingleColor((settings.singleColor = normalizeToARGB(trimmed)));
} else if (mode === "gradient") {
if (customInput.toLowerCase() === gradientEnd.toLowerCase()) {
setGradientEnd((settings.gradientEnd = normalizeToARGB(trimmed)));
} else {
setGradientStart((settings.gradientStart = normalizeToARGB(trimmed)));
}
}
requestApply();
}
addCustomColor();
}}
style={{
width: 32,
height: 32,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.3)",
background: "rgba(255,255,255,0.15)",
color: "#fff",
cursor: "pointer",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease"
}}
>
+
</button>
</div> </div>
</div> )}
{/* Sliders inside picker based on mode */}
{mode === 'single' && (
<div style={{ marginBottom: 16 }}>
<div style={{ color: "rgba(255,255,255,0.8)", fontSize: 12, marginBottom: 6 }}>Alpha</div>
<input
type="range"
min={0}
max={100}
step={1}
value={singleAlpha}
onChange={(e) => {
const value = Number(e.target.value);
setSingleAlpha((settings.singleAlpha = value));
requestApply();
}}
style={{ width: '100%' }}
/>
</div>
)}
{mode === 'gradient' && (
<div style={{ marginBottom: 16, display: 'grid', gap: 16 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div style={{ width: 12, height: 12, borderRadius: 3, background: normalizeToRGB(gradientStart), border: '1px solid rgba(255,255,255,0.3)' }} />
<div style={{ color: 'rgba(255,255,255,0.8)', fontSize: 12 }}>Start Alpha</div>
</div>
<input
type="range"
min={0}
max={100}
step={1}
value={gradientStartAlpha}
onChange={(e) => {
const value = Number(e.target.value);
setGradientStartAlpha((settings.gradientStartAlpha = value));
requestApply();
}}
style={{ width: '100%' }}
/>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div style={{ width: 12, height: 12, borderRadius: 3, background: normalizeToRGB(gradientEnd), border: '1px solid rgba(255,255,255,0.3)' }} />
<div style={{ color: 'rgba(255,255,255,0.8)', fontSize: 12 }}>End Alpha</div>
</div>
<input
type="range"
min={0}
max={100}
step={1}
value={gradientEndAlpha}
onChange={(e) => {
const value = Number(e.target.value);
setGradientEndAlpha((settings.gradientEndAlpha = value));
requestApply();
}}
style={{ width: '100%' }}
/>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<div style={{ color: 'rgba(255,255,255,0.8)', fontSize: 12 }}>Angle</div>
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 12 }}>{gradientAngle}°</div>
</div>
<input
type="range"
min={0}
max={360}
step={1}
value={gradientAngle}
onChange={(e) => {
const value = Number(e.target.value);
setGradientAngle((settings.gradientAngle = value));
requestApply();
}}
style={{ width: '100%' }}
/>
</div>
</div>
)}
{mode === 'auto-gradient' && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<div style={{ color: 'rgba(255,255,255,0.8)', fontSize: 12 }}>Angle</div>
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 12 }}>{gradientAngle}°</div>
</div>
<input
type="range"
min={0}
max={360}
step={1}
value={gradientAngle}
onChange={(e) => {
const value = Number(e.target.value);
setGradientAngle((settings.gradientAngle = value));
requestApply();
}}
style={{ width: '100%' }}
/>
</div>
)}
<button <button
onClick={closePicker} onClick={closePicker}
style={{ style={{
@@ -405,7 +530,7 @@ export const Settings = () => {
</div> </div>
</> </>
)} )}
<LunaSwitchSetting <AnySwitch
title="Exclude Inactive | Experimental" title="Exclude Inactive | Experimental"
desc="Apply color/gradient only to the currently active lyric line" desc="Apply color/gradient only to the currently active lyric line"
checked={excludeInactive} checked={excludeInactive}
+60 -10
View File
@@ -1,5 +1,6 @@
// NOTE: definition duplicated earlier accidentally; keep this single definition below
import { LunaUnload, Tracer } from "@luna/core"; import { LunaUnload, Tracer } from "@luna/core";
import { StyleTag, observe, observePromise, PlayState } from "@luna/lib"; import { StyleTag, PlayState } from "@luna/lib";
import { settings, Settings } from "./Settings"; import { settings, Settings } from "./Settings";
import styles from "file://styles.css?minify"; import styles from "file://styles.css?minify";
@@ -63,10 +64,44 @@ function getDominantColorsFromImage(img: HTMLImageElement, count: number = 2): s
} }
} }
// Utilities to build rgba() from hex + alpha percentage
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
let v = hex.trim();
if (!v.startsWith('#')) v = `#${v}`;
if (/^#([0-9a-fA-F]{3})$/.test(v)) {
const r = parseInt(v[1] + v[1], 16);
const g = parseInt(v[2] + v[2], 16);
const b = parseInt(v[3] + v[3], 16);
return { r, g, b };
}
if (/^#([0-9a-fA-F]{6})$/.test(v)) {
const r = parseInt(v.slice(1, 3), 16);
const g = parseInt(v.slice(3, 5), 16);
const b = parseInt(v.slice(5, 7), 16);
return { r, g, b };
}
if (/^#([0-9a-fA-F]{8})$/.test(v)) {
const r = parseInt(v.slice(3, 5), 16);
const g = parseInt(v.slice(5, 7), 16);
const b = parseInt(v.slice(7, 9), 16);
return { r, g, b };
}
return null;
}
function rgbaFromHexAndAlpha(hex: string, alphaPercent: number | undefined): string {
const rgb = hexToRgb(hex);
const a = Math.max(0.05, Math.min(100, alphaPercent ?? 100)) / 100;
if (!rgb) return `rgba(255,255,255,${a})`;
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${a})`;
}
function applySingleColor(color: string) { function applySingleColor(color: string) {
document.documentElement.style.setProperty('--cl-lyrics-color', color); const alpha = (settings as any).singleAlpha ?? 100;
document.documentElement.style.setProperty('--cl-glow1', color); const rgba = rgbaFromHexAndAlpha(color, alpha);
document.documentElement.style.setProperty('--cl-glow2', color); document.documentElement.style.setProperty('--cl-lyrics-color', rgba);
document.documentElement.style.setProperty('--cl-glow1', rgba);
document.documentElement.style.setProperty('--cl-glow2', rgba);
document.documentElement.style.removeProperty('--cl-grad-start'); document.documentElement.style.removeProperty('--cl-grad-start');
document.documentElement.style.removeProperty('--cl-grad-end'); document.documentElement.style.removeProperty('--cl-grad-end');
document.documentElement.style.removeProperty('--cl-grad-angle'); document.documentElement.style.removeProperty('--cl-grad-angle');
@@ -75,15 +110,23 @@ function applySingleColor(color: string) {
} }
function applyGradient(start: string, end: string, angle: number) { function applyGradient(start: string, end: string, angle: number) {
document.documentElement.style.setProperty('--cl-grad-start', start); const startAlpha = (settings as any).gradientStartAlpha ?? 100;
document.documentElement.style.setProperty('--cl-grad-end', end); const endAlpha = (settings as any).gradientEndAlpha ?? 100;
const startRgba = rgbaFromHexAndAlpha(start, startAlpha);
const endRgba = rgbaFromHexAndAlpha(end, endAlpha);
document.documentElement.style.setProperty('--cl-grad-start', startRgba);
document.documentElement.style.setProperty('--cl-grad-end', endRgba);
document.documentElement.style.setProperty('--cl-grad-angle', `${angle}deg`); document.documentElement.style.setProperty('--cl-grad-angle', `${angle}deg`);
document.documentElement.style.setProperty('--cl-glow1', start); document.documentElement.style.setProperty('--cl-glow1', startRgba);
document.documentElement.style.setProperty('--cl-glow2', end); document.documentElement.style.setProperty('--cl-glow2', endRgba);
document.body.classList.remove('colorama-single'); document.body.classList.remove('colorama-single');
document.body.classList.add('colorama-gradient'); document.body.classList.add('colorama-gradient');
} }
function resetModeClasses(): void {
document.body.classList.remove('colorama-single', 'colorama-gradient', 'colorama-rainbow');
}
async function applyAutoColors(gradient: boolean) { async function applyAutoColors(gradient: boolean) {
const img = await getCoverArtElement(); const img = await getCoverArtElement();
if (!img) return; if (!img) return;
@@ -100,16 +143,17 @@ async function applyAutoColors(gradient: boolean) {
function applyColoramaLyrics(): void { function applyColoramaLyrics(): void {
if (!settings.enabled) { if (!settings.enabled) {
document.body.classList.remove('colorama-single', 'colorama-gradient'); document.body.classList.remove('colorama-single', 'colorama-gradient', 'colorama-rainbow');
return; return;
} }
// Toggle only-active-line mode class // Toggle only-active-line mode class
if (settings.onlyActiveLine) { if (settings.excludeInactive) {
document.body.classList.add('colorama-only-active'); document.body.classList.add('colorama-only-active');
} else { } else {
document.body.classList.remove('colorama-only-active'); document.body.classList.remove('colorama-only-active');
} }
resetModeClasses();
switch (settings.mode) { switch (settings.mode) {
case "single": case "single":
applySingleColor(settings.singleColor); applySingleColor(settings.singleColor);
@@ -117,6 +161,9 @@ function applyColoramaLyrics(): void {
case "gradient": case "gradient":
applyGradient(settings.gradientStart, settings.gradientEnd, settings.gradientAngle); applyGradient(settings.gradientStart, settings.gradientEnd, settings.gradientAngle);
break; break;
case "rainbow":
// no-op: rainbow mode disabled
break;
case "auto-single": case "auto-single":
applyAutoColors(false); applyAutoColors(false);
break; break;
@@ -173,4 +220,7 @@ function hookRadiantUpdates(): void {
setTimeout(() => hookRadiantUpdates(), 0); setTimeout(() => hookRadiantUpdates(), 0);
// Observe active lyric span changes and restart rainbow animation to avoid freezes
// Rainbow mode disabled: no lyrics observer needed
+17 -2
View File
@@ -32,6 +32,9 @@
-webkit-text-fill-color: transparent !important; -webkit-text-fill-color: transparent !important;
} }
/* Ensure active line keeps rainbow in only-active mode */
/* Only-active: apply container class only on the active line via JS */
/* Slight emphasis on current line (uniform to single mode) */ /* Slight emphasis on current line (uniform to single mode) */
.colorama-gradient [class*="_lyricsText"] > div > span[data-current="true"], .colorama-gradient [class*="_lyricsText"] > div > span[data-current="true"],
.colorama-gradient [class^="_lyricsContainer"] > div > div > span[data-current="true"] { .colorama-gradient [class^="_lyricsContainer"] > div > div > span[data-current="true"] {
@@ -69,12 +72,24 @@
/* Only color active line mode */ /* Only color active line mode */
body.colorama-only-active.colorama-single [class*="_lyricsText"] > div > span:not([data-current="true"]), body.colorama-only-active.colorama-single [class*="_lyricsText"] > div > span:not([data-current="true"]),
body.colorama-only-active.colorama-gradient [class*="_lyricsText"] > div > span:not([data-current="true"]) { body.colorama-only-active.colorama-gradient [class*="_lyricsText"] > div > span:not([data-current="true"]) {
/* Reset non-active lines to default */ /* Match Radiant inactive styling */
color: inherit !important; color: rgba(128, 128, 128, 0.4) !important;
background: none !important; background: none !important;
-webkit-background-clip: initial !important; -webkit-background-clip: initial !important;
background-clip: initial !important; background-clip: initial !important;
-webkit-text-fill-color: initial !important; -webkit-text-fill-color: initial !important;
text-shadow: initial !important;
}
/* In only-active mode, keep TIDAL defaults even on hover for inactive lines */
body.colorama-only-active.colorama-single [class*="_lyricsText"] > div > span:not([data-current="true"]):hover,
body.colorama-only-active.colorama-gradient [class*="_lyricsText"] > div > span:not([data-current="true"]):hover {
color: lightgray !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
text-shadow: initial !important;
} }