diff --git a/plugins/colorama-lyrics-luna/package.json b/plugins/colorama-lyrics-luna/package.json new file mode 100644 index 0000000..122a98e --- /dev/null +++ b/plugins/colorama-lyrics-luna/package.json @@ -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" +} + diff --git a/plugins/colorama-lyrics-luna/src/Settings.tsx b/plugins/colorama-lyrics-luna/src/Settings.tsx new file mode 100644 index 0000000..330b71f --- /dev/null +++ b/plugins/colorama-lyrics-luna/src/Settings.tsx @@ -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(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 ( + + + {/* Mode selection via dropdown */} +
+
+
Mode
+
Choose how lyrics are colored
+
+ +
+ + {/* Single color */} +
+
+
Lyrics Color
+
Solid color (HEX/ARGB HEX)
+
+
+
+
+
+ { + const next = setAlphaOnARGB(normalizeToARGB(singleColor), value / 100); + setSingleColor((settings.singleColor = next)); + if (customInput) setCustomInput(next); + requestApply(); + }} + /> +
+ + {/* Gradient controls */} +
+
Gradient
+
Pick start/end and angle
+
+
+ { + const next = setAlphaOnARGB(normalizeToARGB(gradientStart), value / 100); + setGradientStart((settings.gradientStart = next)); + requestApply(); + }} + /> + { + const next = setAlphaOnARGB(normalizeToARGB(gradientEnd), value / 100); + setGradientEnd((settings.gradientEnd = next)); + requestApply(); + }} + /> + { + setGradientAngle((settings.gradientAngle = value)); + requestApply(); + }} + /> +
+ + {/* Modal for picking and managing colors (reused) */} + {shouldRender && ( + <> +
+
+
Choose Color (ARGB HEX)
+
+ {allColors.map((color, index) => ( +
+
+
Custom ARGB Hex (#AARRGGBB)
+
+ 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" + }} + /> + +
+
+ +
+ + )} + { + setExcludeInactive((settings.excludeInactive = checked)); + requestApply(); + }} + /> + + ); +}; + + diff --git a/plugins/colorama-lyrics-luna/src/index.ts b/plugins/colorama-lyrics-luna/src/index.ts new file mode 100644 index 0000000..80bb2ee --- /dev/null +++ b/plugins/colorama-lyrics-luna/src/index.ts @@ -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(); + +const styleTag = new StyleTag("ColoramaLyrics", unloads, styles); + +// Simple dominant color extraction from current cover art +async function getCoverArtElement(): Promise { + 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((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(); + 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); + + diff --git a/plugins/colorama-lyrics-luna/src/styles.css b/plugins/colorama-lyrics-luna/src/styles.css new file mode 100644 index 0000000..999bf25 --- /dev/null +++ b/plugins/colorama-lyrics-luna/src/styles.css @@ -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; +} + + diff --git a/plugins/radiant-lyrics-luna/src/Settings.tsx b/plugins/radiant-lyrics-luna/src/Settings.tsx index 87b22de..9bda75f 100644 --- a/plugins/radiant-lyrics-luna/src/Settings.tsx +++ b/plugins/radiant-lyrics-luna/src/Settings.tsx @@ -4,6 +4,7 @@ import React from "react"; export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", { hideUIEnabled: true, + trackTitleGlow: false, playerBarVisible: false, lyricsGlowEnabled: true, textGlow: 20, @@ -14,7 +15,7 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", { backgroundBlur: 80, backgroundBrightness: 40, spinSpeed: 45, - settingsAffectNowPlaying: true, + settingsAffectNowPlaying: true }); export const Settings = () => { @@ -30,6 +31,7 @@ export const Settings = () => { const [backgroundBrightness, setBackgroundBrightness] = React.useState(settings.backgroundBrightness); const [spinSpeed, setSpinSpeed] = React.useState(settings.spinSpeed); const [settingsAffectNowPlaying, setSettingsAffectNowPlaying] = React.useState(settings.settingsAffectNowPlaying); + const [trackTitleGlow, setTrackTitleGlow] = React.useState(settings.trackTitleGlow); return ( @@ -45,6 +47,17 @@ export const Settings = () => { } }} /> + { + setTrackTitleGlow((settings.trackTitleGlow = checked)); + if ((window as any).updateRadiantLyricsStyles) { + (window as any).updateRadiantLyricsStyles(); + } + }} + /> {}); } + + // 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'); + } + } }; diff --git a/plugins/radiant-lyrics-luna/src/lyrics-glow.css b/plugins/radiant-lyrics-luna/src/lyrics-glow.css index 87fb4d7..a552cf1 100644 --- a/plugins/radiant-lyrics-luna/src/lyrics-glow.css +++ b/plugins/radiant-lyrics-luna/src/lyrics-glow.css @@ -25,7 +25,7 @@ /* Enhanced lyrics styling with glow effects */ [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; transition-duration: 0.7s; font-size: 55px; @@ -52,7 +52,17 @@ /* Track title glow */ [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 */