diff --git a/plugins/colorama-lyrics-luna/src/Settings.tsx b/plugins/colorama-lyrics-luna/src/Settings.tsx index 330b71f..eb01980 100644 --- a/plugins/colorama-lyrics-luna/src/Settings.tsx +++ b/plugins/colorama-lyrics-luna/src/Settings.tsx @@ -1,16 +1,19 @@ import { ReactiveStore } from "@luna/core"; -import { LunaSettings, LunaNumberSetting, LunaSwitchSetting, LunaTextSetting } from "@luna/ui"; +import { LunaSettings, LunaSwitchSetting } from "@luna/ui"; 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", { enabled: true, mode: "single" as ColoramaMode, - // Store colors as ARGB hex (#AARRGGBB) - singleColor: "#FFFFFFFF", - gradientStart: "#FFFFFFFF", - gradientEnd: "#88AAFFFF", + // 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 @@ -20,8 +23,11 @@ export const Settings = () => { const [enabled, setEnabled] = React.useState(settings.enabled); const [mode, setMode] = React.useState(settings.mode); const [singleColor, setSingleColor] = React.useState(settings.singleColor); + const [singleAlpha, setSingleAlpha] = React.useState(settings.singleAlpha ?? 100); const [gradientStart, setGradientStart] = React.useState(settings.gradientStart); + const [gradientStartAlpha, setGradientStartAlpha] = React.useState(settings.gradientStartAlpha ?? 100); const [gradientEnd, setGradientEnd] = React.useState(settings.gradientEnd); + const [gradientEndAlpha, setGradientEndAlpha] = React.useState(settings.gradientEndAlpha ?? 100); const [gradientAngle, setGradientAngle] = React.useState(settings.gradientAngle); const [customInput, setCustomInput] = React.useState(settings.singleColor); const [customColors, setCustomColors] = React.useState(settings.customColors); @@ -29,50 +35,40 @@ export const Settings = () => { 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; - // 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 => { + // 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 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 + // 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)) { - 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(); + if (/^#([0-9a-f]{6})$/.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" + "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", + "#FF8800", "#8800FF", "#0088FF", "#88FF00", "#FF0088", "#00FF88", + "#444444", "#888888", "#CCCCCC", "#1DB954", "#E22134", "#1976D2" ]; - const openPicker = () => { + const openPicker = (endpoint: 'single' | 'start' | 'end' = 'single') => { + setActiveEndpoint(endpoint); setShowPicker(true); setShouldRender(true); setTimeout(() => setIsAnimatingIn(true), 10); @@ -85,16 +81,35 @@ export const Settings = () => { }, 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 trimmed = customInput.trim(); if ( - argbColorRegex.test(trimmed) && + hexColorRegex.test(trimmed) && !colorPresets.includes(trimmed) && - !customColors.includes(normalizeToARGB(trimmed)) + !customColors.includes(normalizeToRGB(trimmed)) ) { - const updated = [...customColors, normalizeToARGB(trimmed)]; + const updated = [...customColors, normalizeToRGB(trimmed)]; setCustomColors(updated); settings.customColors = updated; } @@ -115,10 +130,10 @@ export const Settings = () => { return ( - {/* Mode selection via dropdown */} -
-
-
Mode
+ {/* Mode selection via dropdown (aligned right) */} +
+
+
Mode
Choose how lyrics are colored
@@ -148,99 +165,64 @@ export const Settings = () => {
Lyrics Color
-
Solid color (HEX/ARGB HEX)
+
Set lyrics color
-
- { - const next = setAlphaOnARGB(normalizeToARGB(singleColor), value / 100); - setSingleColor((settings.singleColor = next)); - if (customInput) setCustomInput(next); - requestApply(); + + + {/* Gradient controls (open picker) */} +
+
+
Gradient (Experimental)
+
Set colors & angle
+
+
- {/* Gradient controls */} -
-
Gradient
-
Pick start/end and angle
-
-
{/* Modal for picking and managing colors (reused) */} @@ -281,112 +263,225 @@ export const Settings = () => { transition: "all 0.2s ease" }} > -
Choose Color (ARGB HEX)
-
- {allColors.map((color, index) => ( - +
-
+ )} + {mode !== 'cover-gradient' && ( +
+ {allColors.map((color, index) => ( +
+ )} + {mode !== 'cover-gradient' && ( +
+
Custom Hex (#RRGGBB)
+
+ 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" + }} + /> + +
+
+ )} + {/* Sliders inside picker based on mode */} + {mode === 'single' && ( +
+
Alpha
+ { + const value = Number(e.target.value); + setSingleAlpha((settings.singleAlpha = value)); + requestApply(); + }} + style={{ width: '100%' }} + /> +
+ )} + + {mode === 'gradient-experimental' && ( +
+
+
+
+
Start Alpha
+
+ { + const value = Number(e.target.value); + setGradientStartAlpha((settings.gradientStartAlpha = value)); + requestApply(); + }} + style={{ width: '100%' }} + /> +
+
+
+
+
End Alpha
+
+ { + const value = Number(e.target.value); + setGradientEndAlpha((settings.gradientEndAlpha = value)); + requestApply(); + }} + style={{ width: '100%' }} + /> +
+
+
+
Angle
+
{gradientAngle}°
+
+ { + const value = Number(e.target.value); + setGradientAngle((settings.gradientAngle = value)); + requestApply(); + }} + style={{ width: '100%' }} + /> +
+
+ )} + + {mode === 'cover-gradient' && ( +
+
+
Angle
+
{gradientAngle}°
+
+ { + const value = Number(e.target.value); + setGradientAngle((settings.gradientAngle = value)); + requestApply(); + }} + style={{ width: '100%' }} + /> +
+ )} +
)} - { diff --git a/plugins/colorama-lyrics-luna/src/index.ts b/plugins/colorama-lyrics-luna/src/index.ts index 80bb2ee..b7c83d1 100644 --- a/plugins/colorama-lyrics-luna/src/index.ts +++ b/plugins/colorama-lyrics-luna/src/index.ts @@ -1,5 +1,5 @@ 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 styles from "file://styles.css?minify"; @@ -9,7 +9,7 @@ export { Settings }; export const unloads = new Set(); -const styleTag = new StyleTag("ColoramaLyrics", unloads, styles); +new StyleTag("ColoramaLyrics", unloads, styles); // Simple dominant color extraction from current cover art async function getCoverArtElement(): Promise { @@ -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) { - document.documentElement.style.setProperty('--cl-lyrics-color', color); - document.documentElement.style.setProperty('--cl-glow1', color); - document.documentElement.style.setProperty('--cl-glow2', color); + const alpha = (settings as any).singleAlpha ?? 100; + const rgba = rgbaFromHexAndAlpha(color, alpha); + 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-end'); document.documentElement.style.removeProperty('--cl-grad-angle'); @@ -75,16 +111,24 @@ function applySingleColor(color: string) { } function applyGradient(start: string, end: string, angle: number) { - document.documentElement.style.setProperty('--cl-grad-start', start); - document.documentElement.style.setProperty('--cl-grad-end', end); + const startAlpha = (settings as any).gradientStartAlpha ?? 100; + 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-glow1', start); - document.documentElement.style.setProperty('--cl-glow2', end); + document.documentElement.style.setProperty('--cl-glow1', startRgba); + document.documentElement.style.setProperty('--cl-glow2', endRgba); document.body.classList.remove('colorama-single'); 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(); if (!img) return; const colors = getDominantColorsFromImage(img, gradient ? 2 : 1); @@ -105,23 +149,24 @@ function applyColoramaLyrics(): void { } // Toggle only-active-line mode class - if (settings.onlyActiveLine) { + if (settings.excludeInactive) { document.body.classList.add('colorama-only-active'); } else { document.body.classList.remove('colorama-only-active'); } + resetModeClasses(); switch (settings.mode) { case "single": applySingleColor(settings.singleColor); break; - case "gradient": + case "gradient-experimental": applyGradient(settings.gradientStart, settings.gradientEnd, settings.gradientAngle); break; - case "auto-single": - applyAutoColors(false); + case "cover": + applyCoverColors(false); break; - case "auto-gradient": - applyAutoColors(true); + case "cover-gradient": + applyCoverColors(true); break; } } @@ -135,7 +180,7 @@ function observeTrackChanges(): void { const currentTrackId = PlayState.playbackContext?.actualProductId; if (currentTrackId && currentTrackId !== lastTrackId) { lastTrackId = currentTrackId; - if (settings.mode.startsWith("auto")) { + if (settings.mode === 'cover' || settings.mode === 'cover-gradient') { setTimeout(() => applyColoramaLyrics(), 200); } } @@ -149,7 +194,7 @@ function observeTrackChanges(): void { setTimeout(() => applyColoramaLyrics(), 200); 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 { const w = window as any; const wrap = (name: string) => { diff --git a/plugins/colorama-lyrics-luna/src/styles.css b/plugins/colorama-lyrics-luna/src/styles.css index 999bf25..494acf1 100644 --- a/plugins/colorama-lyrics-luna/src/styles.css +++ b/plugins/colorama-lyrics-luna/src/styles.css @@ -32,6 +32,8 @@ -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) */ .colorama-gradient [class*="_lyricsText"] > div > span[data-current="true"], .colorama-gradient [class^="_lyricsContainer"] > div > div > span[data-current="true"] { @@ -69,12 +71,24 @@ /* 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; + /* Match Radiant inactive styling */ + color: rgba(128, 128, 128, 0.4) !important; background: none !important; -webkit-background-clip: initial !important; background-clip: 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; }