Files
TidaLuna-Plugins/plugins/colorama-lyrics-luna/src/Settings.tsx
T
2025-09-09 19:20:30 +10:00

814 lines
20 KiB
TypeScript

import { ReactiveStore } from "@luna/core";
import { LunaSettings, LunaSwitchSetting } from "@luna/ui";
import React from "react";
declare global {
interface Window {
applyColoramaLyrics?: () => void;
}
}
// Define a typed onChange signature for the switch
type SwitchChangeHandler = (
event: React.ChangeEvent<HTMLInputElement> | null,
checked: boolean,
) => void;
export type ColoramaMode =
| "single"
| "gradient-experimental"
| "cover"
| "cover-gradient";
export const settings = await ReactiveStore.getPluginStorage("ColoramaLyrics", {
enabled: true,
mode: "single" as ColoramaMode,
// Store colors as RGB hex (#RRGGBB) and opacity separately (0-100)
singleColor: "#FFFFFF",
singleAlpha: 100,
gradientStart: "#FFFFFF",
gradientStartAlpha: 100,
gradientEnd: "#AAFFFF",
gradientEndAlpha: 100,
gradientAngle: 0,
customColors: [] as string[],
excludeInactive: false,
});
export const Settings = () => {
// const [enabled, setEnabled] = React.useState(settings.enabled);
const [mode, setMode] = React.useState<ColoramaMode>(settings.mode);
const [singleColor, setSingleColor] = React.useState(settings.singleColor);
const [singleAlpha, setSingleAlpha] = React.useState<number>(
settings.singleAlpha ?? 100,
);
const [gradientStart, setGradientStart] = React.useState(
settings.gradientStart,
);
const [gradientStartAlpha, setGradientStartAlpha] = React.useState<number>(
settings.gradientStartAlpha ?? 100,
);
const [gradientEnd, setGradientEnd] = React.useState(settings.gradientEnd);
const [gradientEndAlpha, setGradientEndAlpha] = React.useState<number>(
settings.gradientEndAlpha ?? 100,
);
const [gradientAngle, setGradientAngle] = React.useState(
settings.gradientAngle,
);
const [customInput, setCustomInput] = React.useState(settings.singleColor);
const [customColors, setCustomColors] = React.useState(settings.customColors);
const [showPicker, setShowPicker] = React.useState(false);
const [isAnimatingIn, setIsAnimatingIn] = React.useState(false);
const [shouldRender, setShouldRender] = React.useState(false);
const [excludeInactive, setExcludeInactive] = React.useState(
settings.excludeInactive,
);
const [activeEndpoint, setActiveEndpoint] = React.useState<
"single" | "start" | "end"
>("single");
const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType<{
title: string;
desc?: string;
checked: boolean;
onChange: SwitchChangeHandler;
}>;
// Helper for HEX normalization
const normalizeToRGB = (
hex: string,
fallback: string = "#FFFFFF",
): string => {
let v = hex.trim().toLowerCase();
if (!v.startsWith("#")) v = `#${v}`;
// #rgb or #rgba -> expand
if (/^#([0-9a-f]{3,4})$/.test(v)) {
const m = v.slice(1);
const r = m[0];
const g = m[1];
const b = m[2];
// 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
if (/^#([0-9a-f]{6})$/.test(v)) return v.toUpperCase();
return fallback;
};
const colorPresets = [
"#FFFFFF",
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#FF00FF",
"#00FFFF",
"#FF8800",
"#8800FF",
"#0088FF",
"#88FF00",
"#FF0088",
"#00FF88",
"#444444",
"#888888",
"#CCCCCC",
"#1DB954",
"#E22134",
"#1976D2",
];
const openPicker = (endpoint: "single" | "start" | "end" = "single") => {
setActiveEndpoint(endpoint);
setShowPicker(true);
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
};
const closePicker = () => {
setIsAnimatingIn(false);
setTimeout(() => {
setShowPicker(false);
setShouldRender(false);
}, 200);
};
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);
settings.singleColor = next;
setSingleColor(next);
if (updateInput) setCustomInput(next);
} else if (mode === "gradient-experimental") {
const next = normalizeToRGB(trimmed);
if (activeEndpoint === "end") {
settings.gradientEnd = next;
setGradientEnd(next);
} else {
settings.gradientStart = next;
setGradientStart(next);
}
if (updateInput) setCustomInput(next);
}
requestApply();
};
const addCustomColor = () => {
const trimmed = customInput.trim();
if (
hexColorRegex.test(trimmed) &&
!colorPresets.includes(trimmed) &&
!customColors.includes(normalizeToRGB(trimmed))
) {
const updated = [...customColors, normalizeToRGB(trimmed)];
setCustomColors(updated);
settings.customColors = updated;
}
};
// const removeCustomColor = (color: string) => {
// const updated = customColors.filter((c) => c !== color);
// setCustomColors(updated);
// settings.customColors = updated;
// };
const allColors = [...colorPresets, ...customColors];
const requestApply = () => {
window.applyColoramaLyrics?.();
};
return (
<LunaSettings>
{/* Mode selection via dropdown (aligned right) */}
<div
style={{
padding: "8px 0",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<div style={{ display: "flex", flexDirection: "column" }}>
<div style={{ fontWeight: "normal", fontSize: "1.075rem" }}>Mode</div>
<div style={{ opacity: 0.7, fontSize: 14 }}>
Choose how lyrics are colored
</div>
</div>
<select
value={mode}
onChange={(e) => {
const next = e.target.value as ColoramaMode;
settings.mode = next;
setMode(next);
requestApply();
}}
style={{
padding: "6px 10px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.08)",
color: "#fff",
cursor: "pointer",
marginLeft: "auto",
minWidth: 180,
}}
>
<option value="single" style={{ color: "#000", background: "#fff" }}>
Single
</option>
<option
value="gradient-experimental"
style={{ color: "#000", background: "#fff" }}
>
Gradient - Experimental
</option>
<option value="cover" style={{ color: "#000", background: "#fff" }}>
Cover - Experimental
</option>
<option
value="cover-gradient"
style={{ color: "#000", background: "#fff" }}
>
Cover (Gradient) - Experimental
</option>
</select>
</div>
{/* Single color */}
<div
style={{
padding: "8px 0",
display: mode === "single" ? "flex" : "none",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{
fontWeight: "normal",
fontSize: "1.075rem",
marginBottom: 4,
}}
>
Lyrics Color
</div>
<div style={{ opacity: 0.7, fontSize: 14 }}>Set lyrics color</div>
</div>
<div
style={{
display: "flex",
gap: 8,
alignItems: "center",
position: "relative",
}}
>
<button
type="button"
onClick={() => (showPicker ? closePicker() : openPicker("single"))}
style={{
width: 32,
height: 32,
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: 6,
cursor: "pointer",
background: normalizeToRGB(singleColor),
}}
/>
</div>
</div>
{/* Gradient controls (open picker) */}
<div
style={{
padding: "8px 0",
display: mode === "gradient-experimental" ? "flex" : "none",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{
fontWeight: "normal",
fontSize: "1.075rem",
marginBottom: 4,
}}
>
Gradient (Experimental)
</div>
<div style={{ opacity: 0.7, fontSize: 14 }}>Set colors & angle</div>
</div>
<button
type="button"
onClick={() => {
setCustomInput(gradientStart);
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>
{/* Cover gradient controls (open picker for angle) */}
<div
style={{
padding: "8px 0",
display: mode === "cover-gradient" ? "flex" : "none",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{
fontWeight: "normal",
fontSize: "1.075rem",
marginBottom: 4,
}}
>
Cover (Gradient) - Experimental
</div>
<div style={{ opacity: 0.7, fontSize: 14 }}>Set angle</div>
</div>
<button
type="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>
{/* Modal for picking and managing colors (reused) */}
{shouldRender && (
<>
<button
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.6)",
zIndex: 1000,
opacity: isAnimatingIn ? 1 : 0,
transition: "opacity 0.2s ease",
}}
type="button"
aria-label="Close color picker"
onClick={closePicker}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "Escape") closePicker();
}}
/>
<div
style={{
position: "fixed",
top: "50%",
left: "50%",
background: "rgba(20,20,20,0.98)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: 16,
padding: 20,
minWidth: 320,
maxWidth: "90vw",
maxHeight: "90vh",
zIndex: 1001,
boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
opacity: isAnimatingIn ? 1 : 0,
transform: isAnimatingIn
? "translate(-50%, -50%) scale(1)"
: "translate(-50%, -50%) scale(0.9)",
transition: "all 0.2s ease",
}}
>
<div
style={{
marginBottom: 12,
color: "#fff",
fontWeight: "bold",
fontSize: 14,
}}
>
{mode === "single" ? "Single Color" : "Gradient Colors"}
</div>
{mode === "gradient-experimental" && (
<div
style={{
display: "flex",
gap: 8,
alignItems: "center",
marginBottom: 12,
}}
>
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: 12 }}>
Editing
</div>
<button
onClick={() => {
setActiveEndpoint("start");
setCustomInput(gradientStart);
}}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 10px",
borderRadius: 8,
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",
}}
type="button"
>
<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
type="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 !== "cover-gradient" && (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 8,
marginBottom: 16,
}}
>
{allColors.map((color) => (
<button
key={color}
type="button"
onClick={() => {
const next = normalizeToRGB(color);
if (mode === "single") {
settings.singleColor = next;
setSingleColor(next);
} else if (mode === "gradient-experimental") {
if (activeEndpoint === "end") {
settings.gradientEnd = next;
setGradientEnd(next);
} else {
settings.gradientStart = next;
setGradientStart(next);
}
}
setCustomInput(next);
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",
}}
type="button"
>
+
</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);
settings.singleAlpha = value;
setSingleAlpha(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);
settings.gradientStartAlpha = value;
setGradientStartAlpha(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);
settings.gradientEndAlpha = value;
setGradientEndAlpha(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);
settings.gradientAngle = value;
setGradientAngle(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);
settings.gradientAngle = value;
setGradientAngle(value);
requestApply();
}}
style={{ width: "100%" }}
/>
</div>
)}
<button
onClick={closePicker}
style={{
width: "100%",
padding: 8,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
cursor: "pointer",
fontSize: 12,
}}
type="button"
>
Done
</button>
</div>
</>
)}
<AnySwitch
title="Exclude Inactive"
desc="Apply color/gradient only to the currently active lyric line"
checked={excludeInactive}
onChange={(_event: React.ChangeEvent<HTMLInputElement> | null, checked: boolean) => {
settings.excludeInactive = checked;
setExcludeInactive(checked);
requestApply();
}}
/>
</LunaSettings>
);
};