Merge pull request #49 from meowarex/dev

Improved Settings + Labeling
This commit is contained in:
Meow Meow
2025-08-13 21:33:05 +10:00
committed by GitHub
3 changed files with 397 additions and 243 deletions
+318 -223
View File
@@ -1,16 +1,19 @@
import { ReactiveStore } from "@luna/core"; import { ReactiveStore } from "@luna/core";
import { LunaSettings, LunaNumberSetting, LunaSwitchSetting, LunaTextSetting } from "@luna/ui"; import { LunaSettings, LunaSwitchSetting } 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-experimental" | "cover" | "cover-gradient";
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,
customColors: [] as string[], customColors: [] as string[],
excludeInactive: false excludeInactive: false
@@ -20,8 +23,11 @@ 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 [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);
@@ -29,50 +35,40 @@ export const Settings = () => {
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 // Helper for HEX normalization
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); const normalizeToRGB = (hex: string, fallback: string = "#FFFFFF"): string => {
const normalizeToARGB = (hex: string, fallback: string = "#FFFFFFFF"): 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 a = clamp(Math.round(alpha01 * 255), 0, 255).toString(16).padStart(2, '0');
const body = argb.replace('#', '').slice(2);
return (`#${a}${body}`).toUpperCase();
};
const getAlpha01 = (argb: string): number => {
const v = normalizeToARGB(argb);
const a = parseInt(v.slice(1, 3), 16);
return clamp(a / 255, 0, 1);
};
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 +81,35 @@ 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 applyCustomInputColor = (raw: string, updateInput: boolean): void => {
const trimmed = raw.trim();
if (!hexColorRegex.test(trimmed)) return;
if (mode === "single") {
const next = normalizeToRGB(trimmed);
setSingleColor((settings.singleColor = next));
if (updateInput) setCustomInput(next);
} else if (mode === "gradient-experimental") {
const norm = normalizeToRGB(trimmed);
if (activeEndpoint === 'end') {
setGradientEnd((settings.gradientEnd = norm));
} else {
setGradientStart((settings.gradientStart = norm));
}
if (updateInput) setCustomInput(norm);
}
requestApply();
};
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,10 +130,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={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: 4 }}>Mode</div> <div style={{ fontWeight: "normal", fontSize: "1.075rem" }}>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> </div>
<select <select
@@ -132,15 +147,17 @@ 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-experimental" style={{ color: '#000', background: '#fff' }}>Gradient - Experimental</option>
<option value="auto-single">Auto (Cover)</option> <option value="cover" style={{ color: '#000', background: '#fff' }}>Cover - Experimental</option>
<option value="auto-gradient">Auto Gradient</option> <option value="cover-gradient" style={{ color: '#000', background: '#fff' }}>Cover (Gradient) - Experimental</option>
</select> </select>
</div> </div>
@@ -148,99 +165,64 @@ 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 }}>Set lyrics color</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" {/* Gradient controls (open picker) */}
desc="Opacity of the single color (0-100%)" <div style={{ padding: "8px 0", display: mode === "gradient-experimental" ? "flex" : "none", justifyContent: 'space-between', alignItems: 'center' }}>
min={0} <div>
max={100} <div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: 4 }}>Gradient (Experimental)</div>
step={1} <div style={{ opacity: 0.7, fontSize: 14 }}>Set colors & angle</div>
value={Math.round(getAlpha01(singleColor) * 100)} </div>
onNumber={(value: number) => { <button
const next = setAlphaOnARGB(normalizeToARGB(singleColor), value / 100); onClick={() => { setCustomInput(gradientStart); openPicker('start'); }}
setSingleColor((settings.singleColor = next)); style={{
if (customInput) setCustomInput(next); padding: '8px 12px',
requestApply(); 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> </div>
{/* Gradient controls */} {/* Cover gradient controls (open picker for angle) */}
<div style={{ padding: "8px 0", display: mode === "gradient" ? "block" : "none" }}> <div style={{ padding: "8px 0", display: mode === "cover-gradient" ? "flex" : "none", justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: 4 }}>Gradient</div> <div>
<div style={{ opacity: 0.7, fontSize: 14, marginBottom: 8 }}>Pick start/end and angle</div> <div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: 4 }}>Cover (Gradient) - Experimental</div>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}> <div style={{ opacity: 0.7, fontSize: 14 }}>Set angle</div>
<button
onClick={() => {
setCustomInput(gradientStart);
openPicker();
}}
title="Start Color"
style={{ width: 32, height: 32, border: "1px solid rgba(255,255,255,0.15)", borderRadius: 6, background: normalizeToARGB(gradientStart) }}
/>
<button
onClick={() => {
setCustomInput(gradientEnd);
openPicker();
}}
title="End Color"
style={{ width: 32, height: 32, border: "1px solid rgba(255,255,255,0.15)", borderRadius: 6, background: normalizeToARGB(gradientEnd) }}
/>
</div> </div>
<LunaNumberSetting <button
title="Start Alpha" onClick={() => openPicker('start')}
desc="Opacity of the gradient start (0-100%)" style={{
min={0} padding: '8px 12px',
max={100} borderRadius: 8,
step={1} border: '1px solid rgba(255,255,255,0.2)',
value={Math.round(getAlpha01(gradientStart) * 100)} background: 'rgba(255,255,255,0.08)',
onNumber={(value: number) => { color: '#fff',
const next = setAlphaOnARGB(normalizeToARGB(gradientStart), value / 100); cursor: 'pointer'
setGradientStart((settings.gradientStart = next));
requestApply();
}} }}
/> >
<LunaNumberSetting Configure
title="End Alpha" </button>
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>
{/* Modal for picking and managing colors (reused) */} {/* Modal for picking and managing colors (reused) */}
@@ -281,112 +263,225 @@ 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-experimental' && (
<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
type="text"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const trimmed = customInput.trim();
if (argbColorRegex.test(trimmed)) {
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();
}
}}
placeholder="#AARRGGBB"
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 <button
onClick={() => { onClick={() => { setActiveEndpoint('start'); setCustomInput(gradientStart); }}
const trimmed = customInput.trim();
if (argbColorRegex.test(trimmed)) {
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={{ style={{
width: 32, display: 'flex', alignItems: 'center', gap: 8,
height: 32, padding: '6px 10px', borderRadius: 8,
borderRadius: 6, border: activeEndpoint === 'start' ? '1px solid rgba(255,255,255,0.5)' : '1px solid rgba(255,255,255,0.2)',
border: "1px solid rgba(255,255,255,0.3)", background: 'rgba(255,255,255,0.05)', color: '#fff', cursor: 'pointer'
background: "rgba(255,255,255,0.15)",
color: "#fff",
cursor: "pointer",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease"
}} }}
> >
+ <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> </button>
</div> </div>
</div> )}
{mode !== 'cover-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-experimental") {
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 !== 'cover-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') {
applyCustomInputColor(customInput, true);
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={() => {
applyCustomInputColor(customInput, false);
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>
)}
{/* 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-experimental' && (
<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 === 'cover-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,8 +500,8 @@ export const Settings = () => {
</div> </div>
</> </>
)} )}
<LunaSwitchSetting <AnySwitch
title="Exclude Inactive | Experimental" title="Exclude Inactive"
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}
onChange={(_: unknown, checked: boolean) => { onChange={(_: unknown, checked: boolean) => {
+63 -18
View File
@@ -1,5 +1,5 @@
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";
@@ -9,7 +9,7 @@ export { Settings };
export const unloads = new Set<LunaUnload>(); export const unloads = new Set<LunaUnload>();
const styleTag = new StyleTag("ColoramaLyrics", unloads, styles); new StyleTag("ColoramaLyrics", unloads, styles);
// Simple dominant color extraction from current cover art // Simple dominant color extraction from current cover art
async function getCoverArtElement(): Promise<HTMLImageElement | null> { async function getCoverArtElement(): Promise<HTMLImageElement | null> {
@@ -63,10 +63,46 @@ function getDominantColorsFromImage(img: HTMLImageElement, count: number = 2): s
} }
} }
// 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 };
}
// 8-digit hex expects #AARRGGBB. Indices 1-3 are the alpha byte (ignored here),
// so r/g/b are extracted from v.slice(3,5), v.slice(5,7), v.slice(7,9) respectively.
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,16 +111,24 @@ 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');
} }
async function applyAutoColors(gradient: boolean) { function resetModeClasses(): void {
document.body.classList.remove('colorama-single', 'colorama-gradient');
}
async function applyCoverColors(gradient: boolean) {
const img = await getCoverArtElement(); const img = await getCoverArtElement();
if (!img) return; if (!img) return;
const colors = getDominantColorsFromImage(img, gradient ? 2 : 1); const colors = getDominantColorsFromImage(img, gradient ? 2 : 1);
@@ -105,23 +149,24 @@ function applyColoramaLyrics(): void {
} }
// 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);
break; break;
case "gradient": case "gradient-experimental":
applyGradient(settings.gradientStart, settings.gradientEnd, settings.gradientAngle); applyGradient(settings.gradientStart, settings.gradientEnd, settings.gradientAngle);
break; break;
case "auto-single": case "cover":
applyAutoColors(false); applyCoverColors(false);
break; break;
case "auto-gradient": case "cover-gradient":
applyAutoColors(true); applyCoverColors(true);
break; break;
} }
} }
@@ -135,7 +180,7 @@ function observeTrackChanges(): void {
const currentTrackId = PlayState.playbackContext?.actualProductId; const currentTrackId = PlayState.playbackContext?.actualProductId;
if (currentTrackId && currentTrackId !== lastTrackId) { if (currentTrackId && currentTrackId !== lastTrackId) {
lastTrackId = currentTrackId; lastTrackId = currentTrackId;
if (settings.mode.startsWith("auto")) { if (settings.mode === 'cover' || settings.mode === 'cover-gradient') {
setTimeout(() => applyColoramaLyrics(), 200); setTimeout(() => applyColoramaLyrics(), 200);
} }
} }
@@ -149,7 +194,7 @@ function observeTrackChanges(): void {
setTimeout(() => applyColoramaLyrics(), 200); setTimeout(() => applyColoramaLyrics(), 200);
observeTrackChanges(); observeTrackChanges();
// Ensure compatibility: re-apply after Radiant updates its styles/backgrounds // for some reason, re-apply after Radiant updates its styles/backgrounds
function hookRadiantUpdates(): void { function hookRadiantUpdates(): void {
const w = window as any; const w = window as any;
const wrap = (name: string) => { const wrap = (name: string) => {
+16 -2
View File
@@ -32,6 +32,8 @@
-webkit-text-fill-color: transparent !important; -webkit-text-fill-color: transparent !important;
} }
/* 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 +71,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;
} }