mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-18 03:43:10 +10:00
Merge pull request #48 from meowarex/dev
Colorama-Lyrics Plugin + Title Glow Setting
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@meowarex/colorama-lyrics",
|
||||||
|
"description": "Customize lyrics colors: single, gradient & auto from cover art",
|
||||||
|
"author": {
|
||||||
|
"name": "meowarex",
|
||||||
|
"url": "https://github.com/meowarex",
|
||||||
|
"avatarUrl": "https://avatars.githubusercontent.com/u/90243579"
|
||||||
|
},
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
import { ReactiveStore } from "@luna/core";
|
||||||
|
import { LunaSettings, LunaNumberSetting, LunaSwitchSetting, LunaTextSetting } from "@luna/ui";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export type ColoramaMode = "single" | "gradient" | "auto-single" | "auto-gradient";
|
||||||
|
|
||||||
|
export const settings = await ReactiveStore.getPluginStorage("ColoramaLyrics", {
|
||||||
|
enabled: true,
|
||||||
|
mode: "single" as ColoramaMode,
|
||||||
|
// Store colors as ARGB hex (#AARRGGBB)
|
||||||
|
singleColor: "#FFFFFFFF",
|
||||||
|
gradientStart: "#FFFFFFFF",
|
||||||
|
gradientEnd: "#88AAFFFF",
|
||||||
|
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 [gradientStart, setGradientStart] = React.useState(settings.gradientStart);
|
||||||
|
const [gradientEnd, setGradientEnd] = React.useState(settings.gradientEnd);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Helpers for ARGB <-> components
|
||||||
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
|
||||||
|
const normalizeToARGB = (hex: string, fallback: string = "#FFFFFFFF"): 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 a = m.length === 4 ? m[3] : 'f';
|
||||||
|
const r = m[0];
|
||||||
|
const g = m[1];
|
||||||
|
const b = m[2];
|
||||||
|
v = `#${a}${r}${g}${b}${r}${g}${b}${a}`; // temporary, will reformat below
|
||||||
|
}
|
||||||
|
// #rrggbb
|
||||||
|
if (/^#([0-9a-f]{6})$/.test(v)) {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
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 = [
|
||||||
|
"#FFFFFFFF", "#FF0000FF", "#00FF00FF", "#0000FFFF", "#FFFF00FF", "#FF00FFFF", "#00FFFFFF",
|
||||||
|
"#FF8800FF", "#8800FFFF", "#0088FFFF", "#88FF00FF", "#FF0088FF", "#00FF88FF",
|
||||||
|
"#444444FF", "#888888FF", "#CCCCCCFF", "#1DB954FF", "#E22134FF", "#1976D2FF"
|
||||||
|
];
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
setShowPicker(true);
|
||||||
|
setShouldRender(true);
|
||||||
|
setTimeout(() => setIsAnimatingIn(true), 10);
|
||||||
|
};
|
||||||
|
const closePicker = () => {
|
||||||
|
setIsAnimatingIn(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowPicker(false);
|
||||||
|
setShouldRender(false);
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const argbColorRegex = /^#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})$/i;
|
||||||
|
|
||||||
|
const addCustomColor = () => {
|
||||||
|
const trimmed = customInput.trim();
|
||||||
|
if (
|
||||||
|
argbColorRegex.test(trimmed) &&
|
||||||
|
!colorPresets.includes(trimmed) &&
|
||||||
|
!customColors.includes(normalizeToARGB(trimmed))
|
||||||
|
) {
|
||||||
|
const updated = [...customColors, normalizeToARGB(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 as any).applyColoramaLyrics?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LunaSettings>
|
||||||
|
|
||||||
|
{/* Mode selection via dropdown */}
|
||||||
|
<div style={{ padding: "8px 0", display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<div style={{ minWidth: 160 }}>
|
||||||
|
<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>
|
||||||
|
<select
|
||||||
|
value={mode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value as ColoramaMode;
|
||||||
|
setMode((settings.mode = next));
|
||||||
|
requestApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="single">Single</option>
|
||||||
|
<option value="gradient">Gradient</option>
|
||||||
|
<option value="auto-single">Auto (Cover)</option>
|
||||||
|
<option value="auto-gradient">Auto Gradient</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 }}>Solid color (HEX/ARGB HEX)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center", position: "relative" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => (showPicker ? closePicker() : openPicker())}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: "1px solid rgba(255,255,255,0.15)",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
background: normalizeToARGB(singleColor)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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 */}
|
||||||
|
<div style={{ padding: "8px 0", display: mode === "gradient" ? "block" : "none" }}>
|
||||||
|
<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={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Modal for picking and managing colors (reused) */}
|
||||||
|
{shouldRender && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
}}
|
||||||
|
onClick={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 }}>Choose Color (ARGB HEX)</div>
|
||||||
|
<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 = 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 style={{ marginBottom: 12 }}>
|
||||||
|
<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" }}>
|
||||||
|
<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
|
||||||
|
onClick={() => {
|
||||||
|
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={{
|
||||||
|
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>
|
||||||
|
<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
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<LunaSwitchSetting
|
||||||
|
title="Exclude Inactive | Experimental"
|
||||||
|
desc="Apply color/gradient only to the currently active lyric line"
|
||||||
|
checked={excludeInactive}
|
||||||
|
onChange={(_: unknown, checked: boolean) => {
|
||||||
|
setExcludeInactive((settings.excludeInactive = checked));
|
||||||
|
requestApply();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LunaSettings>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { LunaUnload, Tracer } from "@luna/core";
|
||||||
|
import { StyleTag, observe, observePromise, PlayState } from "@luna/lib";
|
||||||
|
import { settings, Settings } from "./Settings";
|
||||||
|
|
||||||
|
import styles from "file://styles.css?minify";
|
||||||
|
|
||||||
|
export const { trace } = Tracer("[Colorama Lyrics]");
|
||||||
|
export { Settings };
|
||||||
|
|
||||||
|
export const unloads = new Set<LunaUnload>();
|
||||||
|
|
||||||
|
const styleTag = new StyleTag("ColoramaLyrics", unloads, styles);
|
||||||
|
|
||||||
|
// Simple dominant color extraction from current cover art
|
||||||
|
async function getCoverArtElement(): Promise<HTMLImageElement | null> {
|
||||||
|
const img = document.querySelector('figure[class*="_albumImage"] > div > div > div > img') as HTMLImageElement | null;
|
||||||
|
if (img) return img;
|
||||||
|
const video = document.querySelector('figure[class*="_albumImage"] > div > div > div > video') as HTMLVideoElement | null;
|
||||||
|
if (video) {
|
||||||
|
const poster = video.getAttribute("poster");
|
||||||
|
if (!poster) return null;
|
||||||
|
const tempImg = new Image();
|
||||||
|
tempImg.crossOrigin = "anonymous";
|
||||||
|
tempImg.src = poster;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
tempImg.onload = () => resolve();
|
||||||
|
tempImg.onerror = () => resolve();
|
||||||
|
});
|
||||||
|
return tempImg as unknown as HTMLImageElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDominantColorsFromImage(img: HTMLImageElement, count: number = 2): string[] {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return ["#ffffff", "#88aaff"]; // fallback
|
||||||
|
const w = 64;
|
||||||
|
const h = 64;
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
const data = ctx.getImageData(0, 0, w, h).data;
|
||||||
|
|
||||||
|
// Simple k-means-ish binning into 16 buckets per channel
|
||||||
|
const buckets = new Map<string, number>();
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const r = data[i];
|
||||||
|
const g = data[i + 1];
|
||||||
|
const b = data[i + 2];
|
||||||
|
const key = `${Math.round(r/16)},${Math.round(g/16)},${Math.round(b/16)}`;
|
||||||
|
buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const sorted = [...buckets.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const picked = sorted.slice(0, Math.max(1, count)).map(([key]) => {
|
||||||
|
const [r, g, b] = key.split(',').map(v => parseInt(v, 10) * 16);
|
||||||
|
return `#${[r, g, b].map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
});
|
||||||
|
return picked;
|
||||||
|
} catch {
|
||||||
|
return ["#ffffff", "#88aaff"]; // fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySingleColor(color: string) {
|
||||||
|
document.documentElement.style.setProperty('--cl-lyrics-color', color);
|
||||||
|
document.documentElement.style.setProperty('--cl-glow1', color);
|
||||||
|
document.documentElement.style.setProperty('--cl-glow2', color);
|
||||||
|
document.documentElement.style.removeProperty('--cl-grad-start');
|
||||||
|
document.documentElement.style.removeProperty('--cl-grad-end');
|
||||||
|
document.documentElement.style.removeProperty('--cl-grad-angle');
|
||||||
|
document.body.classList.remove('colorama-gradient');
|
||||||
|
document.body.classList.add('colorama-single');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGradient(start: string, end: string, angle: number) {
|
||||||
|
document.documentElement.style.setProperty('--cl-grad-start', start);
|
||||||
|
document.documentElement.style.setProperty('--cl-grad-end', end);
|
||||||
|
document.documentElement.style.setProperty('--cl-grad-angle', `${angle}deg`);
|
||||||
|
document.documentElement.style.setProperty('--cl-glow1', start);
|
||||||
|
document.documentElement.style.setProperty('--cl-glow2', end);
|
||||||
|
document.body.classList.remove('colorama-single');
|
||||||
|
document.body.classList.add('colorama-gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAutoColors(gradient: boolean) {
|
||||||
|
const img = await getCoverArtElement();
|
||||||
|
if (!img) return;
|
||||||
|
const colors = getDominantColorsFromImage(img, gradient ? 2 : 1);
|
||||||
|
if (gradient) {
|
||||||
|
const start = colors[0] ?? settings.gradientStart;
|
||||||
|
const end = colors[1] ?? settings.gradientEnd;
|
||||||
|
applyGradient(start, end, settings.gradientAngle);
|
||||||
|
} else {
|
||||||
|
const color = colors[0] ?? settings.singleColor;
|
||||||
|
applySingleColor(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyColoramaLyrics(): void {
|
||||||
|
if (!settings.enabled) {
|
||||||
|
document.body.classList.remove('colorama-single', 'colorama-gradient');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle only-active-line mode class
|
||||||
|
if (settings.onlyActiveLine) {
|
||||||
|
document.body.classList.add('colorama-only-active');
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('colorama-only-active');
|
||||||
|
}
|
||||||
|
switch (settings.mode) {
|
||||||
|
case "single":
|
||||||
|
applySingleColor(settings.singleColor);
|
||||||
|
break;
|
||||||
|
case "gradient":
|
||||||
|
applyGradient(settings.gradientStart, settings.gradientEnd, settings.gradientAngle);
|
||||||
|
break;
|
||||||
|
case "auto-single":
|
||||||
|
applyAutoColors(false);
|
||||||
|
break;
|
||||||
|
case "auto-gradient":
|
||||||
|
applyAutoColors(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).applyColoramaLyrics = applyColoramaLyrics;
|
||||||
|
|
||||||
|
// Re-apply on track changes (for auto modes)
|
||||||
|
function observeTrackChanges(): void {
|
||||||
|
let lastTrackId: string | null = null;
|
||||||
|
const check = () => {
|
||||||
|
const currentTrackId = PlayState.playbackContext?.actualProductId;
|
||||||
|
if (currentTrackId && currentTrackId !== lastTrackId) {
|
||||||
|
lastTrackId = currentTrackId;
|
||||||
|
if (settings.mode.startsWith("auto")) {
|
||||||
|
setTimeout(() => applyColoramaLyrics(), 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const interval = setInterval(check, 500);
|
||||||
|
unloads.add(() => clearInterval(interval));
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial apply and observers
|
||||||
|
setTimeout(() => applyColoramaLyrics(), 200);
|
||||||
|
observeTrackChanges();
|
||||||
|
|
||||||
|
// Ensure compatibility: re-apply after Radiant updates its styles/backgrounds
|
||||||
|
function hookRadiantUpdates(): void {
|
||||||
|
const w = window as any;
|
||||||
|
const wrap = (name: string) => {
|
||||||
|
const fn = w[name];
|
||||||
|
if (typeof fn === 'function' && !fn.__coloramaPatched) {
|
||||||
|
const orig = fn.bind(w);
|
||||||
|
const patched = (...args: unknown[]) => {
|
||||||
|
const result = orig(...args);
|
||||||
|
try { applyColoramaLyrics(); } catch {}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
(patched as any).__coloramaPatched = true;
|
||||||
|
w[name] = patched;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
wrap('updateRadiantLyricsStyles');
|
||||||
|
wrap('updateRadiantLyricsNowPlayingBackground');
|
||||||
|
wrap('updateRadiantLyricsGlobalBackground');
|
||||||
|
wrap('updateRadiantLyricsTextGlow');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => hookRadiantUpdates(), 0);
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/* Variables used by Colorama Lyrics */
|
||||||
|
:root {
|
||||||
|
--cl-lyrics-color: #ffffff;
|
||||||
|
--cl-grad-start: #ffffff;
|
||||||
|
--cl-grad-end: #88aaff;
|
||||||
|
--cl-grad-angle: 0deg;
|
||||||
|
--cl-glow1: #ffffff;
|
||||||
|
--cl-glow2: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply solid color to lyrics text */
|
||||||
|
.colorama-single [class*="_lyricsText"] > div > span,
|
||||||
|
.colorama-single [class*="_lyricsText"] > div > span[data-current="true"],
|
||||||
|
.colorama-single [class^="_lyricsContainer"] > div > div > span,
|
||||||
|
.colorama-single [class^="_lyricsContainer"] > div > div > span[data-current="true"] {
|
||||||
|
color: var(--cl-lyrics-color) !important;
|
||||||
|
background: none !important;
|
||||||
|
-webkit-background-clip: initial !important;
|
||||||
|
background-clip: initial !important;
|
||||||
|
-webkit-text-fill-color: initial !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply gradient to lyrics text */
|
||||||
|
.colorama-gradient [class*="_lyricsText"] > div > span,
|
||||||
|
.colorama-gradient [class*="_lyricsText"] > div > span[data-current="true"],
|
||||||
|
.colorama-gradient [class^="_lyricsContainer"] > div > div > span,
|
||||||
|
.colorama-gradient [class^="_lyricsContainer"] > div > div > span[data-current="true"] {
|
||||||
|
background: linear-gradient(var(--cl-grad-angle), var(--cl-grad-start), var(--cl-grad-end)) !important;
|
||||||
|
-webkit-background-clip: text !important;
|
||||||
|
background-clip: text !important;
|
||||||
|
color: transparent !important;
|
||||||
|
-webkit-text-fill-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slight emphasis on current line (uniform to single mode) */
|
||||||
|
.colorama-gradient [class*="_lyricsText"] > div > span[data-current="true"],
|
||||||
|
.colorama-gradient [class^="_lyricsContainer"] > div > div > span[data-current="true"] {
|
||||||
|
filter: brightness(1.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep song title color unchanged; its glow is controlled in Radiant CSS */
|
||||||
|
|
||||||
|
/* Color Radiant glow shadows using Colorama colors (respect RL sizes) */
|
||||||
|
.colorama-single [class*="_lyricsText"] > div > span[data-current="true"],
|
||||||
|
.colorama-single [class^="_lyricsContainer"] > div > 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"] {
|
||||||
|
text-shadow: 0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #ffffff), 0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover: force glow color to match Colorama settings for inactive lines */
|
||||||
|
.colorama-single [class*="_lyricsText"] > div > span:hover,
|
||||||
|
.colorama-single [class^="_lyricsContainer"] > div > div > span:hover {
|
||||||
|
color: var(--cl-lyrics-color) !important;
|
||||||
|
text-shadow: 0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #ffffff), 0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorama-gradient [class*="_lyricsText"] > div > span:hover,
|
||||||
|
.colorama-gradient [class^="_lyricsContainer"] > div > div > span:hover {
|
||||||
|
background: linear-gradient(var(--cl-grad-angle), var(--cl-grad-start), var(--cl-grad-end)) !important;
|
||||||
|
-webkit-background-clip: text !important;
|
||||||
|
background-clip: text !important;
|
||||||
|
color: transparent !important;
|
||||||
|
-webkit-text-fill-color: transparent !important;
|
||||||
|
/* Do not increase glow strength on hover for gradients */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only color active line mode */
|
||||||
|
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"]) {
|
||||||
|
/* Reset non-active lines to default */
|
||||||
|
color: inherit !important;
|
||||||
|
background: none !important;
|
||||||
|
-webkit-background-clip: initial !important;
|
||||||
|
background-clip: initial !important;
|
||||||
|
-webkit-text-fill-color: initial !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import React from "react";
|
|||||||
|
|
||||||
export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
|
export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
|
||||||
hideUIEnabled: true,
|
hideUIEnabled: true,
|
||||||
|
trackTitleGlow: false,
|
||||||
playerBarVisible: false,
|
playerBarVisible: false,
|
||||||
lyricsGlowEnabled: true,
|
lyricsGlowEnabled: true,
|
||||||
textGlow: 20,
|
textGlow: 20,
|
||||||
@@ -14,7 +15,7 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
|
|||||||
backgroundBlur: 80,
|
backgroundBlur: 80,
|
||||||
backgroundBrightness: 40,
|
backgroundBrightness: 40,
|
||||||
spinSpeed: 45,
|
spinSpeed: 45,
|
||||||
settingsAffectNowPlaying: true,
|
settingsAffectNowPlaying: true
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Settings = () => {
|
export const Settings = () => {
|
||||||
@@ -30,6 +31,7 @@ export const Settings = () => {
|
|||||||
const [backgroundBrightness, setBackgroundBrightness] = React.useState(settings.backgroundBrightness);
|
const [backgroundBrightness, setBackgroundBrightness] = React.useState(settings.backgroundBrightness);
|
||||||
const [spinSpeed, setSpinSpeed] = React.useState(settings.spinSpeed);
|
const [spinSpeed, setSpinSpeed] = React.useState(settings.spinSpeed);
|
||||||
const [settingsAffectNowPlaying, setSettingsAffectNowPlaying] = React.useState(settings.settingsAffectNowPlaying);
|
const [settingsAffectNowPlaying, setSettingsAffectNowPlaying] = React.useState(settings.settingsAffectNowPlaying);
|
||||||
|
const [trackTitleGlow, setTrackTitleGlow] = React.useState(settings.trackTitleGlow);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LunaSettings>
|
<LunaSettings>
|
||||||
@@ -45,6 +47,17 @@ export const Settings = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<LunaSwitchSetting
|
||||||
|
title="Track Title Glow"
|
||||||
|
desc="Apply glow to the track title"
|
||||||
|
checked={trackTitleGlow}
|
||||||
|
onChange={(_: unknown, checked: boolean) => {
|
||||||
|
setTrackTitleGlow((settings.trackTitleGlow = checked));
|
||||||
|
if ((window as any).updateRadiantLyricsStyles) {
|
||||||
|
(window as any).updateRadiantLyricsStyles();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<LunaSwitchSetting
|
<LunaSwitchSetting
|
||||||
title="Hide UI Feature"
|
title="Hide UI Feature"
|
||||||
desc="Enable hide/unhide UI functionality with toggle buttons"
|
desc="Enable hide/unhide UI functionality with toggle buttons"
|
||||||
|
|||||||
@@ -78,6 +78,16 @@ const updateRadiantLyricsStyles = function(): void {
|
|||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track title glow toggle
|
||||||
|
const trackTitleEl = document.querySelector('[data-test="now-playing-track-title"]') as HTMLElement | null;
|
||||||
|
if (trackTitleEl) {
|
||||||
|
if (settings.trackTitleGlow && settings.lyricsGlowEnabled) {
|
||||||
|
trackTitleEl.classList.remove('rl-title-glow-disabled');
|
||||||
|
} else {
|
||||||
|
trackTitleEl.classList.add('rl-title-glow-disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
/* Enhanced lyrics styling with glow effects */
|
/* Enhanced lyrics styling with glow effects */
|
||||||
[class*="_lyricsText"] > div > span[data-current="true"] {
|
[class*="_lyricsText"] > div > span[data-current="true"] {
|
||||||
text-shadow: 0 0 var(--rl-glow-inner, 2px) #fff, 0 0 var(--rl-glow-outer, 20px) #fff !important;
|
text-shadow: 0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #fff), 0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #fff) !important;
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
transition-duration: 0.7s;
|
transition-duration: 0.7s;
|
||||||
font-size: 55px;
|
font-size: 55px;
|
||||||
@@ -52,7 +52,17 @@
|
|||||||
|
|
||||||
/* Track title glow */
|
/* Track title glow */
|
||||||
[data-test="now-playing-track-title"] {
|
[data-test="now-playing-track-title"] {
|
||||||
text-shadow: 0 0 1px #fff, 0 0 var(--rl-glow-outer, 30px) #fff !important;
|
/* Title text color/gradient is left to default app styling; only glow is customized. */
|
||||||
|
text-shadow: 0 0 var(--rl-glow-inner, 1px) var(--cl-glow1, #fff), 0 0 var(--rl-glow-outer, 30px) #fff !important;
|
||||||
|
-webkit-background-clip: initial !important;
|
||||||
|
background-clip: initial !important;
|
||||||
|
-webkit-text-fill-color: initial !important;
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When track title glow setting is disabled, remove glow regardless of Colorama */
|
||||||
|
.rl-title-glow-disabled[data-test="now-playing-track-title"] {
|
||||||
|
text-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Current line transitions */
|
/* Current line transitions */
|
||||||
|
|||||||
Reference in New Issue
Block a user