From 74e3c971477f291c946b56353c6527d3f0f638e7 Mon Sep 17 00:00:00 2001 From: meowarex Date: Tue, 31 Mar 2026 20:53:13 +1100 Subject: [PATCH] Add Audio Viz to Now Playing & Remove Lyrics Scrollbar --- plugins/audio-visualizer-luna/src/index.ts | 274 ++++---- plugins/audio-visualizer-luna/src/styles.css | 32 +- plugins/colorama-lyrics-luna/src/Settings.tsx | 639 +++--------------- plugins/colorama-lyrics-luna/src/index.ts | 144 +--- plugins/colorama-lyrics-luna/src/styles.css | 113 ---- plugins/radiant-lyrics-luna/src/styles.css | 7 + 6 files changed, 250 insertions(+), 959 deletions(-) diff --git a/plugins/audio-visualizer-luna/src/index.ts b/plugins/audio-visualizer-luna/src/index.ts index 0a83bb3..2f63385 100644 --- a/plugins/audio-visualizer-luna/src/index.ts +++ b/plugins/audio-visualizer-luna/src/index.ts @@ -7,17 +7,11 @@ import visualizerStyles from "file://styles.css?minify"; export const { trace } = Tracer("[Audio Visualizer]"); -// Helper function for consistent logging const log = (message: string) => console.log(`[Audio Visualizer] ${message}`); -const warn = (message: string) => console.warn(`[Audio Visualizer] ${message}`); -const error = (message: string) => - console.error(`[Audio Visualizer] ${message}`); export { Settings }; -// Basic config with settings const config = { enabled: true, - position: "left" as "left" | "right", width: 200, height: 40, get barCount() { @@ -31,7 +25,6 @@ const config = { }, sensitivity: 1.5, smoothing: 0.8, - visualizerType: "bars" as "bars" | "waveform" | "circular", }; // Clean up resources @@ -49,10 +42,15 @@ let animationId: number | null = null; let currentAudioElement: HTMLAudioElement | null = null; let isSourceConnected: boolean = false; -// Canvas and container elements -let visualizerContainer: HTMLDivElement | null = null; -let canvas: HTMLCanvasElement | null = null; -let canvasContext: CanvasRenderingContext2D | null = null; +// Each placement gets its own container/canvas/context +interface VisualizerSlot { + container: HTMLDivElement | null; + canvas: HTMLCanvasElement | null; + ctx: CanvasRenderingContext2D | null; +} + +const navSlot: VisualizerSlot = { container: null, canvas: null, ctx: null }; +const npSlot: VisualizerSlot = { container: null, canvas: null, ctx: null }; // Find the audio element - this is a bit of a hack but it works const findAudioElement = (): HTMLAudioElement | null => { @@ -140,10 +138,7 @@ const initializeAudioVisualizer = async (): Promise => { audioContext.resume().catch(() => {}); // Fire and forget } - // Create UI only if it doesn't exist - if (!visualizerContainer) { - createVisualizerUI(); - } + createVisualizerUI(); // Start animation only if not already running if (!animationId) { @@ -155,120 +150,116 @@ const initializeAudioVisualizer = async (): Promise => { } }; -// Create the visualizer UI container and canvas -const createVisualizerUI = (): void => { - // Remove existing visualizer if it exists - removeVisualizerUI(); +const makeSlotElements = (): { container: HTMLDivElement; canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null => { + const container = document.createElement("div"); + container.className = "audio-visualizer-container"; + container.style.cssText = ` + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + padding: 4px; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + `; - if (!config.enabled) return; + const cvs = document.createElement("canvas"); + cvs.width = config.width; + cvs.height = config.height; + cvs.style.cssText = ` + width: ${config.width}px; + height: ${config.height}px; + border-radius: 4px; + `; - // Find the search bar - const searchField = document.querySelector( - 'input[class*="_searchField"]', - ) as HTMLInputElement; - if (!searchField) { - warn("Search field not found"); - return; - } - - const searchContainer = searchField.parentElement; - if (!searchContainer) { - warn("Search container not found"); - return; - } - - // Create visualizer container - visualizerContainer = document.createElement("div"); - visualizerContainer.id = "audio-visualizer-container"; - visualizerContainer.style.cssText = ` - display: flex; - align-items: center; - justify-content: center; - margin-${config.position === "left" ? "right" : "left"}: 12px; - background: rgba(0, 0, 0, 0.2); - border-radius: 8px; - padding: 4px; - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - `; - - // Create canvas - canvas = document.createElement("canvas"); - canvas.width = config.width; - canvas.height = config.height; - canvas.style.cssText = ` - width: ${config.width}px; - height: ${config.height}px; - border-radius: 4px; - `; - - visualizerContainer.appendChild(canvas); - canvasContext = canvas.getContext("2d"); - - // Insert visualizer next to search bar - if (config.position === "left") { - searchContainer.parentElement?.insertBefore( - visualizerContainer, - searchContainer, - ); - } else { - searchContainer.parentElement?.insertBefore( - visualizerContainer, - searchContainer.nextSibling, - ); - } + container.appendChild(cvs); + const ctx = cvs.getContext("2d"); + if (!ctx) return null; + return { container, canvas: cvs, ctx }; +}; + +const clearSlot = (slot: VisualizerSlot): void => { + slot.container?.remove(); + slot.container = null; + slot.canvas = null; + slot.ctx = null; +}; + +const ensureNavSlot = (): void => { + if (navSlot.container?.isConnected) return; + clearSlot(navSlot); + + const searchField = document.querySelector('input[class*="_searchField"]') as HTMLInputElement; + if (!searchField) return; + const searchContainer = searchField.parentElement; + if (!searchContainer?.parentElement) return; + + const els = makeSlotElements(); + if (!els) return; + els.container.style.marginRight = "12px"; + Object.assign(navSlot, els); + searchContainer.parentElement.insertBefore(els.container, searchContainer); +}; + +const ensureNpSlot = (): void => { + if (npSlot.container?.isConnected) return; + clearSlot(npSlot); + + const artistInfo = document.querySelector('[data-test="artist-info"]'); + if (!artistInfo) return; + const leftContent = artistInfo.parentElement; + if (!leftContent) return; + + const els = makeSlotElements(); + if (!els) return; + els.container.style.marginLeft = "12px"; + Object.assign(npSlot, els); + leftContent.insertBefore(els.container, artistInfo.nextSibling); +}; + +const createVisualizerUI = (): void => { + if (!config.enabled) return; + ensureNavSlot(); + ensureNpSlot(); }; -// Remove visualizer UI const removeVisualizerUI = (): void => { - if (visualizerContainer) { - visualizerContainer.remove(); - visualizerContainer = null; - canvas = null; - canvasContext = null; - } + clearSlot(navSlot); + clearSlot(npSlot); }; // Animation loop for rendering visualizer const animate = (): void => { - if (!canvasContext || !canvas) { - animationId = null; + // Re-attach slots that got disconnected from the DOM + createVisualizerUI(); + + const slots = [navSlot, npSlot].filter(s => s.ctx && s.canvas); + if (slots.length === 0) { + animationId = requestAnimationFrame(animate); return; } - // Update canvas color in case it changed - canvasContext.fillStyle = config.color; - canvasContext.strokeStyle = config.color; - - // Check if we have real audio data - this might not be needed but its a good idea let hasRealAudio = false; if (analyser && dataArray) { analyser.getByteFrequencyData(dataArray); - // Check if there's actual audio signal (not just silence) const avgVolume = dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length; - hasRealAudio = avgVolume > 5; // Threshold for detecting actual audio + hasRealAudio = avgVolume > 5; } - // Clear canvas - canvasContext.clearRect(0, 0, canvas.width, canvas.height); + for (const slot of slots) { + const ctx = slot.ctx!; + const cvs = slot.canvas!; + ctx.fillStyle = config.color; + ctx.strokeStyle = config.color; + ctx.clearRect(0, 0, cvs.width, cvs.height); - if (hasRealAudio && analyser && dataArray) { - // Draw real audio visualization - switch (config.visualizerType) { - case "bars": // Is implemented YAYYY (default) - drawBars(); - break; - case "waveform": // Not implemented yet - drawWaveform(); - break; - case "circular": // Not implemented yet - drawCircular(); - break; + if (hasRealAudio && analyser && dataArray) { + drawBars(ctx, cvs); + } else { + drawScrollingWave(ctx, cvs); } - } else { - // Draw cool scrolling wave effect when no audio - drawScrollingWave(); } animationId = requestAnimationFrame(animate); @@ -291,67 +282,54 @@ const drawRoundedRect = ( ctx.fill(); }; -// Draw scrolling wave effect when no audio is detected -const drawScrollingWave = (): void => { - if (!canvasContext || !canvas) return; - - waveTime += 0.05; // Speed of wave animation +const drawScrollingWave = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => { + waveTime += 0.05 / [navSlot, npSlot].filter(s => s.ctx).length; const barCount = config.barCount; - const barWidth = canvas.width / barCount; - const maxHeight = canvas.height * 0.6; + const barWidth = cvs.width / barCount; + const maxHeight = cvs.height * 0.6; - canvasContext.fillStyle = config.color; + ctx.fillStyle = config.color; for (let i = 0; i < barCount; i++) { - // Create a sine wave that scrolls back and forth const x = i / barCount; const wave1 = Math.sin(x * Math.PI * 2 + waveTime) * 0.3; const wave2 = Math.sin(x * Math.PI * 4 + waveTime * 1.3) * 0.2; const wave3 = Math.sin(x * Math.PI * 6 + waveTime * 0.7) * 0.1; - - // Combine waves for complex pattern - const combinedWave = (wave1 + wave2 + wave3 + 1) / 2; // Normalize to 0-1 - - // Add a traveling wave effect + const combinedWave = (wave1 + wave2 + wave3 + 1) / 2; const travelWave = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5; - - // Final height calculation - const barHeight = maxHeight * combinedWave * travelWave * 0.8 + 2; // Minimum height of 2px + const barHeight = maxHeight * combinedWave * travelWave * 0.8 + 2; const xPos = i * barWidth; - const yPos = (canvas.height - barHeight) / 2; + const yPos = (cvs.height - barHeight) / 2; - // Draw rounded or square bars based on setting if (config.barRounding) { - drawRoundedRect(canvasContext, xPos, yPos, barWidth - 1, barHeight, 2); + drawRoundedRect(ctx, xPos, yPos, barWidth - 1, barHeight, 2); } else { - canvasContext.fillRect(xPos, yPos, barWidth - 1, barHeight); + ctx.fillRect(xPos, yPos, barWidth - 1, barHeight); } } }; -// Draw frequency bars - default -const drawBars = (): void => { - if (!canvasContext || !dataArray || !canvas) return; +const drawBars = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => { + if (!dataArray) return; - const barWidth = canvas.width / config.barCount; - const heightScale = canvas.height / 255; + const barWidth = cvs.width / config.barCount; + const heightScale = cvs.height / 255; - canvasContext.fillStyle = config.color; + ctx.fillStyle = config.color; for (let i = 0; i < config.barCount; i++) { const dataIndex = Math.floor(i * (dataArray.length / config.barCount)); const barHeight = dataArray[dataIndex] * config.sensitivity * heightScale; const x = i * barWidth; - const y = canvas.height - barHeight; + const y = cvs.height - barHeight; - // Draw rounded or square bars based on setting if (config.barRounding) { - drawRoundedRect(canvasContext, x, y, barWidth - 1, barHeight, 2); + drawRoundedRect(ctx, x, y, barWidth - 1, barHeight, 2); } else { - canvasContext.fillRect(x, y, barWidth - 1, barHeight); + ctx.fillRect(x, y, barWidth - 1, barHeight); } } }; @@ -412,23 +390,23 @@ const drawBars = (): void => { // } // }; -// Update visualizer settings const updateAudioVisualizer = (): void => { if (analyser) { - // use a fixed size that provides enough frequency bins - analyser.fftSize = 512; // Fixed power of 2 - important + analyser.fftSize = 512; analyser.smoothingTimeConstant = config.smoothing; dataArray = new Uint8Array(analyser.frequencyBinCount); } - if (canvas) { - canvas.width = config.width; - canvas.height = config.height; - canvas.style.width = `${config.width}px`; - canvas.style.height = `${config.height}px`; + for (const slot of [navSlot, npSlot]) { + if (slot.canvas) { + slot.canvas.width = config.width; + slot.canvas.height = config.height; + slot.canvas.style.width = `${config.width}px`; + slot.canvas.style.height = `${config.height}px`; + } } - // Recreate UI if position changed + removeVisualizerUI(); createVisualizerUI(); }; diff --git a/plugins/audio-visualizer-luna/src/styles.css b/plugins/audio-visualizer-luna/src/styles.css index 2835ea2..e185565 100644 --- a/plugins/audio-visualizer-luna/src/styles.css +++ b/plugins/audio-visualizer-luna/src/styles.css @@ -1,50 +1,40 @@ -/* Audio Visualizer CSS - Only applies to the Visualizer */ +/* Audio Visualizer CSS */ -#audio-visualizer-container { +.audio-visualizer-container { transition: all 0.3s ease-in-out; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.1); + animation: av-fadeIn 0.5s ease-out; } -#audio-visualizer-container:hover { +.audio-visualizer-container:hover { transform: scale(1.02); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } -#audio-visualizer-container canvas { +.audio-visualizer-container canvas { display: block; transition: all 0.3s ease-in-out; } /* Responsive adjustments */ @media (max-width: 768px) { - #audio-visualizer-container { + .audio-visualizer-container { margin: 4px; padding: 2px; } - #audio-visualizer-container canvas { + .audio-visualizer-container canvas { max-width: 150px; max-height: 30px; } } -/* Where to put the thingy */ -[class*="_searchField"] { - transition: all 0.3s ease-in-out; -} - -[data-type="search-field"] { - min-width: 220px !important; -} - -/* Shadow when active - doesnt seem to only apply when active but thats better */ -#audio-visualizer-container.active { +.audio-visualizer-container.active { box-shadow: 0 0 20px rgba(255, 255, 255, 0.3); } -/* Fade in animation */ -@keyframes fadeIn { +@keyframes av-fadeIn { from { opacity: 0; transform: scale(0.8); @@ -55,6 +45,6 @@ } } -#audio-visualizer-container { - animation: fadeIn 0.5s ease-out; +[data-type="search-field"] { + min-width: 220px !important; } diff --git a/plugins/colorama-lyrics-luna/src/Settings.tsx b/plugins/colorama-lyrics-luna/src/Settings.tsx index 5b8e408..9c5d26a 100644 --- a/plugins/colorama-lyrics-luna/src/Settings.tsx +++ b/plugins/colorama-lyrics-luna/src/Settings.tsx @@ -8,53 +8,24 @@ declare global { } } -// Define a typed onChange signature for the switch type SwitchChangeHandler = ( event: React.ChangeEvent | 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(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); const [showPicker, setShowPicker] = React.useState(false); @@ -63,9 +34,6 @@ export const Settings = () => { 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; @@ -73,28 +41,23 @@ export const Settings = () => { 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; }; @@ -121,8 +84,7 @@ export const Settings = () => { "#1976D2", ]; - const openPicker = (endpoint: "single" | "start" | "end" = "single") => { - setActiveEndpoint(endpoint); + const openPicker = () => { setShowPicker(true); setShouldRender(true); setTimeout(() => setIsAnimatingIn(true), 10); @@ -140,22 +102,10 @@ export const Settings = () => { 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); - } + const next = normalizeToRGB(trimmed); + settings.singleColor = next; + setSingleColor(next); + if (updateInput) setCustomInput(next); requestApply(); }; @@ -172,12 +122,6 @@ export const Settings = () => { } }; - // const removeCustomColor = (color: string) => { - // const updated = customColors.filter((c) => c !== color); - // setCustomColors(updated); - // settings.customColors = updated; - // }; - const allColors = [...colorPresets, ...customColors]; const requestApply = () => { @@ -186,66 +130,11 @@ export const Settings = () => { return ( - {/* Mode selection via dropdown (aligned right) */} + {/* Single color picker button */}
-
-
Mode
-
- Choose how lyrics are colored -
-
- -
- - {/* Single color */} -
{ >
- {/* Gradient controls (open picker) */} -
-
-
- Gradient (Experimental) -
-
Set colors & angle
-
- -
- - {/* Cover gradient controls (open picker for angle) */} -
-
-
- Cover (Gradient) - Experimental -
-
Set angle
-
- -
- - {/* Modal for picking and managing colors (reused) */} + {/* Color picker modal */} {shouldRender && ( <> +
+ {allColors.map((color) => ( -
- )} - {mode !== "cover-gradient" && ( + /> + ))} + +
- {allColors.map((color) => ( -
- )} - {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); - settings.singleAlpha = value; - setSingleAlpha(value); - requestApply(); - }} - style={{ width: "100%" }} /> -
- )} - - {mode === "gradient-experimental" && ( -
-
-
-
-
- Start Alpha -
-
- { - const value = Number(e.target.value); - settings.gradientStartAlpha = value; - setGradientStartAlpha(value); - requestApply(); - }} - style={{ width: "100%" }} - /> -
-
-
-
-
- End Alpha -
-
- { - const value = Number(e.target.value); - settings.gradientEndAlpha = value; - setGradientEndAlpha(value); - requestApply(); - }} - style={{ width: "100%" }} - /> -
-
-
-
- Angle -
-
- {gradientAngle}° -
-
- { - const value = Number(e.target.value); - settings.gradientAngle = value; - setGradientAngle(value); - requestApply(); - }} - style={{ width: "100%" }} - /> -
-
- )} - - {mode === "cover-gradient" && ( -
-
{ + 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: "space-between", - marginBottom: 6, + justifyContent: "center", + transition: "all 0.2s ease", }} + type="button" > -
- Angle -
-
- {gradientAngle}° -
-
- { - const value = Number(e.target.value); - settings.gradientAngle = value; - setGradientAngle(value); - requestApply(); - }} - style={{ width: "100%" }} - /> + + +
- )} +
+
+
+ Alpha +
+ { + const value = Number(e.target.value); + settings.singleAlpha = value; + setSingleAlpha(value); + requestApply(); + }} + style={{ width: "100%" }} + /> +