From 5f0795919d1b2f8156480b254cc259dd5d8fb332 Mon Sep 17 00:00:00 2001 From: meowarex Date: Fri, 3 Apr 2026 23:10:58 +1100 Subject: [PATCH] Overhaul Audio Visualizer & RL UI Improvements --- .../audio-visualizer-luna/src/Settings.tsx | 867 ++++++++++-------- plugins/audio-visualizer-luna/src/audio.ts | 209 +++++ plugins/audio-visualizer-luna/src/index.ts | 629 ++++++++----- plugins/audio-visualizer-luna/src/styles.css | 58 +- .../src/visualizers/loudness-meter.ts | 201 ++++ .../src/visualizers/oscilloscope.ts | 96 ++ .../src/visualizers/spectrum-bars.ts | 136 +++ .../src/visualizers/spectrum-line.ts | 105 +++ .../src/visualizers/types.ts | 87 ++ .../src/visualizers/vectorscope.ts | 105 +++ plugins/audio-visualizer-luna/src/webgl.ts | 151 +++ plugins/radiant-lyrics-luna/src/Settings.tsx | 221 ++++- plugins/radiant-lyrics-luna/src/index.ts | 283 +++++- plugins/radiant-lyrics-luna/src/styles.css | 150 +++ 14 files changed, 2622 insertions(+), 676 deletions(-) create mode 100644 plugins/audio-visualizer-luna/src/audio.ts create mode 100644 plugins/audio-visualizer-luna/src/visualizers/loudness-meter.ts create mode 100644 plugins/audio-visualizer-luna/src/visualizers/oscilloscope.ts create mode 100644 plugins/audio-visualizer-luna/src/visualizers/spectrum-bars.ts create mode 100644 plugins/audio-visualizer-luna/src/visualizers/spectrum-line.ts create mode 100644 plugins/audio-visualizer-luna/src/visualizers/types.ts create mode 100644 plugins/audio-visualizer-luna/src/visualizers/vectorscope.ts create mode 100644 plugins/audio-visualizer-luna/src/webgl.ts diff --git a/plugins/audio-visualizer-luna/src/Settings.tsx b/plugins/audio-visualizer-luna/src/Settings.tsx index ad0b3d0..cea0ebb 100644 --- a/plugins/audio-visualizer-luna/src/Settings.tsx +++ b/plugins/audio-visualizer-luna/src/Settings.tsx @@ -3,74 +3,146 @@ import { LunaSettings, LunaNumberSetting, LunaSwitchSetting, + LunaSelectSetting, + LunaSelectItem, } from "@luna/ui"; import React from "react"; +import { + VISUALIZER_LABELS, + type VisualizerType, + ALL_SLOT_KEYS, + ZONE_SLOTS, + ZONE_LABELS, + POSITION_LABELS, + type ZoneId, + type PositionId, + type SlotKey, + MINI_SUPPORTED, +} from "./visualizers/types"; export const settings = await ReactiveStore.getPluginStorage( "AudioVisualizer", { - barCount: 32, - barColor: "#ffffff", + navLeft1: "none" as VisualizerType, + navLeft2: "none" as VisualizerType, + navLeft3: "none" as VisualizerType, + navRight1: "spectrum-bars" as VisualizerType, + navRight2: "none" as VisualizerType, + navRight3: "none" as VisualizerType, + npLeft1: "none" as VisualizerType, + npLeft2: "none" as VisualizerType, + npLeft3: "none" as VisualizerType, + npRight1: "oscilloscope" as VisualizerType, + npRight2: "none" as VisualizerType, + npRight3: "none" as VisualizerType, + pbLeft1: "none" as VisualizerType, + pbLeft2: "none" as VisualizerType, + pbLeft3: "none" as VisualizerType, + pbRight1: "none" as VisualizerType, + pbRight2: "none" as VisualizerType, + pbRight3: "none" as VisualizerType, + barColor: "#ff69b4", + barCount: 64, + fftSize: 2048, + reactivity: 30, + gain: 1.5, barRounding: true, + lineThickness: 2.0, + fillOpacity: 0.6, + opacityFalloff: 0.5, + lissajous: false, + scrollingOscilloscope: false, + miniSlots: [] as string[], customColors: [] as string[], }, ); +const VIZ_TYPES: VisualizerType[] = [ + "none", + "spectrum-bars", + "spectrum-line", + "oscilloscope", + "vectorscope", + "loudness-meter", +]; + +const getSlot = (key: SlotKey): VisualizerType => + (settings as unknown as Record)[key] ?? "none"; + +const setSlot = (key: SlotKey, value: VisualizerType): void => { + (settings as unknown as Record)[key] = value; +}; + export const Settings = () => { - const [barCount, setBarCount] = React.useState(settings.barCount); const [barColor, setBarColor] = React.useState(settings.barColor); + const [barCount, setBarCount] = React.useState(settings.barCount); + const [fftSize, setFftSize] = React.useState(settings.fftSize); + const [reactivity, setReactivity] = React.useState(settings.reactivity); + const [gain, setGain] = React.useState(settings.gain); const [barRounding, setBarRounding] = React.useState(settings.barRounding); + const [lineThickness, setLineThickness] = React.useState(settings.lineThickness); + const [fillOpacity, setFillOpacity] = React.useState(settings.fillOpacity); + const [lissajous, setLissajous] = React.useState(settings.lissajous); + const [scrollingOscilloscope, setScrollingOscilloscope] = React.useState(settings.scrollingOscilloscope); + + const [showColorPicker, setShowColorPicker] = React.useState(false); - const [isAnimatingIn, setIsAnimatingIn] = React.useState(false); - const [shouldRender, setShouldRender] = React.useState(false); + const [isColorAnimIn, setIsColorAnimIn] = React.useState(false); + const [shouldRenderColor, setShouldRenderColor] = React.useState(false); const [customInput, setCustomInput] = React.useState(settings.barColor); const [customColors, setCustomColors] = React.useState(settings.customColors); - const [hoveredColorIndex, setHoveredColorIndex] = React.useState< - number | null - >(null); + const [hoveredColorIndex, setHoveredColorIndex] = React.useState(null); + + const [showSlotConfig, setShowSlotConfig] = React.useState(false); + const [isSlotAnimIn, setIsSlotAnimIn] = React.useState(false); + const [shouldRenderSlot, setShouldRenderSlot] = React.useState(false); + const [activeZone, setActiveZone] = React.useState("nowPlaying"); + const [slots, setSlots] = React.useState>(() => { + const vals = {} as Record; + for (const key of ALL_SLOT_KEYS) vals[key] = getSlot(key); + return vals; + }); + const [miniSlots, setMiniSlots] = React.useState>(new Set(settings.miniSlots)); const closeColorPicker = () => { - setIsAnimatingIn(false); - setTimeout(() => { - setShowColorPicker(false); - setShouldRender(false); - }, 200); // Wait for animation to complete because i need to + setIsColorAnimIn(false); + setTimeout(() => { setShowColorPicker(false); setShouldRenderColor(false); }, 200); }; - const openColorPicker = () => { setShowColorPicker(true); - setShouldRender(true); - setTimeout(() => setIsAnimatingIn(true), 10); + setShouldRenderColor(true); + setTimeout(() => setIsColorAnimIn(true), 10); + }; + const closeSlotConfig = () => { + setIsSlotAnimIn(false); + setTimeout(() => { setShowSlotConfig(false); setShouldRenderSlot(false); }, 200); + }; + const openSlotConfig = () => { + setShowSlotConfig(true); + setShouldRenderSlot(true); + setTimeout(() => setIsSlotAnimIn(true), 10); }; React.useEffect(() => { if (showColorPicker) { - setShouldRender(true); - setTimeout(() => setIsAnimatingIn(true), 10); + setShouldRenderColor(true); + setTimeout(() => setIsColorAnimIn(true), 10); } }, [showColorPicker]); - // Common color presets for cool points :D + React.useEffect(() => { + if (showSlotConfig) { + setShouldRenderSlot(true); + setTimeout(() => setIsSlotAnimIn(true), 10); + } + }, [showSlotConfig]); + const colorPresets = [ - "#ffffff", - "#ff0000", - "#00ff00", - "#0000ff", - "#ffff00", - "#ff00ff", - "#00ffff", - "#ff8800", - "#8800ff", - "#0088ff", - "#88ff00", - "#ff0088", - "#00ff88", - "#444444", - "#888888", - "#cccccc", - "#1db954", - "#e22134", - "#1976d2", + "#ff69b4", "#ff1493", "#e91e8a", "#c71585", + "#ff006e", "#ff4da6", "#ff85c8", "#ffb3d9", + "#ffffff", "#ff0000", "#00ff00", "#0000ff", + "#ffff00", "#ff00ff", "#00ffff", "#ff8800", + "#8800ff", "#0088ff", "#1db954", "#444444", ]; const updateColor = (color: string) => { @@ -81,352 +153,421 @@ export const Settings = () => { const addCustomColor = () => { if (customInput) { - // Trim whitespace and convert to lowercase - const trimmedInput = customInput.trim().toLowerCase(); - - // Validate hex color format - const hexColorRegex = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i; - - if ( - hexColorRegex.test(trimmedInput) && - !colorPresets.includes(trimmedInput) && - !customColors.includes(trimmedInput) - ) { - const newCustomColors = [...customColors, trimmedInput]; - setCustomColors(newCustomColors); - settings.customColors = newCustomColors; + const trimmed = customInput.trim().toLowerCase(); + const hexRe = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i; + if (hexRe.test(trimmed) && !colorPresets.includes(trimmed) && !customColors.includes(trimmed)) { + const nc = [...customColors, trimmed]; + setCustomColors(nc); + settings.customColors = nc; } } }; - const removeCustomColor = (colorToRemove: string) => { - const newCustomColors = customColors.filter( - (color) => color !== colorToRemove, - ); - setCustomColors(newCustomColors); - settings.customColors = newCustomColors; - - // If the removed color was the selected color (reset to white) - if (barColor === colorToRemove) { - updateColor("#ffffff"); - } + const removeCustomColor = (c: string) => { + const nc = customColors.filter(x => x !== c); + setCustomColors(nc); + settings.customColors = nc; + if (barColor === c) updateColor("#ff69b4"); }; const allColors = [...colorPresets, ...customColors]; + const updateSlot = (key: SlotKey, value: VisualizerType) => { + setSlots(prev => ({ ...prev, [key]: value })); + setSlot(key, value); + if (!MINI_SUPPORTED.has(value)) { + setMiniSlots(prev => { + const next = new Set(prev); + if (next.delete(key)) settings.miniSlots = [...next]; + return next; + }); + } + }; + + const toggleMini = (key: SlotKey) => { + setMiniSlots(prev => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + settings.miniSlots = [...next]; + return next; + }); + }; + + type BaseSwitchProps = React.ComponentProps; + type AnySwitchProps = Omit & { + onChange: (_: unknown, checked: boolean) => void; + checked: boolean; + }; + const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType; + + const hasBars = ALL_SLOT_KEYS.some(key => slots[key] === "spectrum-bars"); + + const zones: ZoneId[] = ["nowPlaying", "topNav", "playerBar"]; + const zonePositions = (zone: ZoneId) => + Object.keys(ZONE_SLOTS[zone]) as PositionId[]; + + const backdropStyle = (animIn: boolean): React.CSSProperties => ({ + position: "fixed", top: 0, left: 0, right: 0, bottom: 0, + background: "rgba(0,0,0,0.6)", zIndex: 1000, + opacity: animIn ? 1 : 0, transition: "opacity 0.2s ease", + border: "none", padding: 0, cursor: "default", width: "100%", + }); + + const panelBaseStyle = (animIn: boolean): React.CSSProperties => ({ + 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: "16px", + padding: "20px", maxHeight: "90vh", overflowY: "auto", + zIndex: 1001, boxShadow: "0 20px 40px rgba(0,0,0,0.7)", + opacity: animIn ? 1 : 0, + transform: animIn ? "translate(-50%, -50%) scale(1)" : "translate(-50%, -50%) scale(0.9)", + transition: "all 0.2s ease", + }); + + const selectStyle: React.CSSProperties = { + width: "100%", + padding: "6px 8px", + borderRadius: "6px", + border: "1px solid rgba(255,255,255,0.2)", + background: "rgba(255,255,255,0.08)", + color: "#fff", + fontSize: "12px", + cursor: "pointer", + outline: "none", + }; + + const optionStyle: React.CSSProperties = { + background: "#1a1a1a", + color: "#fff", + }; + return ( - { - setBarRounding(checked); - settings.barRounding = checked; - }} + {/* Color & Layout */} +
+
+
Color & Layout
+
+ Visualizer color and slot placement +
+
+
+ + +
+
+ + {/* Color picker modal */} + {shouldRenderColor && ( + <> + + )} + + ); + })} + + +
+
Add Custom Color
+
+ setCustomInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { updateColor(customInput); addCustomColor(); } }} + placeholder="#ff69b4" + style={{ + flex: 1, padding: "8px 12px", borderRadius: "6px", + border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)", + color: "#fff", fontSize: "14px", fontFamily: "monospace", boxSizing: "border-box", + }} + /> + +
+
+ + + + + )} + + {/* Slot configuration modal */} + {shouldRenderSlot && ( + <> + + ))} + + + {/* Slot grid */} +
+ {zonePositions(activeZone).map(pos => { + const slotKeys = ZONE_SLOTS[activeZone][pos]; + if (!slotKeys) return null; + return ( +
+
{POSITION_LABELS[pos]}
+
+ {slotKeys.map((key, i) => ( +
+ + {MINI_SUPPORTED.has(slots[key]) && ( + + )} +
+ ))} +
+
+ ); + })} +
+ + + + + )} + + { setReactivity(v); settings.reactivity = v; }} /> + { setGain(v); settings.gain = v; }} + /> + + ) => { + const v = Number(e.target.value); + setFftSize(v); + settings.fftSize = v; + }} + > + {[256, 512, 1024, 2048, 4096, 8192, 16384].map(s => ( + {s} + ))} + + { - setBarCount(value); - settings.barCount = value; + onNumber={(v: number) => { setBarCount(v); settings.barCount = v; }} + /> + + {hasBars && ( + { + setBarRounding(checked); + settings.barRounding = checked; + }} + /> + )} + + { setLineThickness(v); settings.lineThickness = v; }} + /> + + { setFillOpacity(v); settings.fillOpacity = v; }} + /> + + { + setScrollingOscilloscope(checked); + settings.scrollingOscilloscope = checked; }} /> - {/* YUP YOUR EYES WORK... we do be using React code in the settings..*/} - {/* I'm not sure if this is a good idea, but it works & looks amazing */} - {/* Sorry @Inrixia <3 */} - -
{ + setLissajous(checked); + settings.lissajous = checked; }} - > -
-
- Bar Color -
-
- Color of the visualizer bars -
-
-
- - - {/* Custom Color Picker Modal */} - {shouldRender && ( - <> - {/* Backdrop */} -
- - {/* Color Picker Panel */} -
-
- Choose Color -
- - {/* Color Grid */} -
- {allColors.map((color, index) => { - const isCustomColor = customColors.includes(color); - const isHovered = hoveredColorIndex === index; - return ( -
setHoveredColorIndex(index)} - onMouseLeave={() => setHoveredColorIndex(null)} - > - - )} -
- ); - })} -
- - {/* Custom Hex Input */} -
-
- Add Custom Color -
-
- setCustomInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - updateColor(customInput); - addCustomColor(); - } - }} - placeholder="#ffffff" - style={{ - flex: 1, - padding: "8px 12px", - borderRadius: "6px", - border: "1px solid rgba(255,255,255,0.2)", - background: "rgba(255,255,255,0.1)", - color: "#fff", - fontSize: "14px", - fontFamily: "monospace", - boxSizing: "border-box", - }} - /> - -
-
- - {/* Close Button (Done) - Also runs when color chosen*/} - -
- - )} -
-
+ /> ); }; diff --git a/plugins/audio-visualizer-luna/src/audio.ts b/plugins/audio-visualizer-luna/src/audio.ts new file mode 100644 index 0000000..6d29e3c --- /dev/null +++ b/plugins/audio-visualizer-luna/src/audio.ts @@ -0,0 +1,209 @@ +const log = (message: string) => console.log(`[Audio Visualizer] ${message}`); + +let audioContext: AudioContext | null = null; +let monoAnalyser: AnalyserNode | null = null; +let leftAnalyser: AnalyserNode | null = null; +let rightAnalyser: AnalyserNode | null = null; +let splitter: ChannelSplitterNode | null = null; +let audioSource: MediaStreamAudioSourceNode | null = null; +let trackedVideo: HTMLVideoElement | null = null; +let connected = false; + +let monoByteFreq: Uint8Array | null = null; +let monoByteTime: Uint8Array | null = null; +let monoFloatFreq: Float32Array | null = null; +let monoFloatTime: Float32Array | null = null; +let leftFloatTime: Float32Array | null = null; +let rightFloatTime: Float32Array | null = null; + +export interface AudioData { + byteFrequency: Uint8Array; + byteTimeDomain: Uint8Array; + floatFrequency: Float32Array; + floatTimeDomain: Float32Array; + leftTimeDomain: Float32Array; + rightTimeDomain: Float32Array; + sampleRate: number; + fftSize: number; + binCount: number; +} + +export const setFFTSize = (size: number): void => { + if (monoAnalyser) monoAnalyser.fftSize = size; + if (leftAnalyser) leftAnalyser.fftSize = size; + if (rightAnalyser) rightAnalyser.fftSize = size; + allocateBuffers(); +}; + +export const setSmoothing = (value: number): void => { + if (monoAnalyser) monoAnalyser.smoothingTimeConstant = value; + if (leftAnalyser) leftAnalyser.smoothingTimeConstant = value; + if (rightAnalyser) rightAnalyser.smoothingTimeConstant = value; +}; + +const allocateBuffers = (): void => { + if (!monoAnalyser) return; + const bc = monoAnalyser.frequencyBinCount; + monoByteFreq = new Uint8Array(bc); + monoByteTime = new Uint8Array(bc); + monoFloatFreq = new Float32Array(bc); + monoFloatTime = new Float32Array(monoAnalyser.fftSize); + + if (leftAnalyser && rightAnalyser) { + leftFloatTime = new Float32Array(leftAnalyser.fftSize); + rightFloatTime = new Float32Array(rightAnalyser.fftSize); + } +}; + +const createAnalyser = (ctx: AudioContext, fftSize: number, smoothing: number): AnalyserNode => { + const a = ctx.createAnalyser(); + a.fftSize = fftSize; + a.smoothingTimeConstant = smoothing; + a.minDecibels = -100; + a.maxDecibels = -10; + return a; +}; + +const ensureContext = (fftSize: number, smoothing: number): boolean => { + try { + if (!audioContext || audioContext.state === "closed") { + audioContext = new AudioContext(); + } + + if (!monoAnalyser) { + monoAnalyser = createAnalyser(audioContext, fftSize, smoothing); + leftAnalyser = createAnalyser(audioContext, fftSize, smoothing); + rightAnalyser = createAnalyser(audioContext, fftSize, smoothing); + splitter = audioContext.createChannelSplitter(2); + splitter.connect(leftAnalyser, 0); + splitter.connect(rightAnalyser, 1); + allocateBuffers(); + } + + if (audioContext.state === "suspended") { + audioContext.resume().catch(() => {}); + } + + return true; + } catch (err) { + log(`Failed to create audio context: ${err}`); + return false; + } +}; + +const disconnectSource = (): void => { + if (audioSource) { + try { audioSource.disconnect(); } catch {} + audioSource = null; + } + connected = false; +}; + +const captureFromVideo = (video: HTMLVideoElement): boolean => { + const capture = (video as unknown as { captureStream?: () => MediaStream }).captureStream; + if (typeof capture !== "function") { + log("captureStream() not available on video element"); + return false; + } + + try { + disconnectSource(); + + const stream = capture.call(video); + const tracks = stream.getAudioTracks(); + if (tracks.length === 0) { + log("No audio tracks in captured stream"); + return false; + } + + audioSource = audioContext!.createMediaStreamSource(stream); + audioSource.connect(monoAnalyser!); + audioSource.connect(splitter!); + + trackedVideo = video; + connected = true; + log("Audio connected via captureStream()"); + return true; + } catch (err) { + log(`captureStream() failed: ${err}`); + return false; + } +}; + +export const connect = (fftSize = 2048, smoothing = 0.8): boolean => { + if (!ensureContext(fftSize, smoothing)) return false; + + const video = document.getElementById("video-one") as HTMLVideoElement | null; + if (!video) { + log("video-one element not found"); + return false; + } + + return captureFromVideo(video); +}; + +export const reconnect = (fftSize = 2048, smoothing = 0.8): boolean => { + disconnectSource(); + trackedVideo = null; + return connect(fftSize, smoothing); +}; + +export const isConnected = (): boolean => connected; + +export const videoChanged = (): boolean => { + const video = document.getElementById("video-one") as HTMLVideoElement | null; + if (!video) return false; + return video !== trackedVideo; +}; + +export const sample = (): AudioData | null => { + if (!monoAnalyser || !monoByteFreq || !monoByteTime || !monoFloatFreq || !monoFloatTime || !leftFloatTime || !rightFloatTime) return null; + + // Recover from suspended context (can happen after tab becomes inactive) + if (audioContext?.state === "suspended") { + audioContext.resume().catch(() => {}); + } + + monoAnalyser.getByteFrequencyData(monoByteFreq); + monoAnalyser.getByteTimeDomainData(monoByteTime); + monoAnalyser.getFloatFrequencyData(monoFloatFreq); + monoAnalyser.getFloatTimeDomainData(monoFloatTime); + leftAnalyser!.getFloatTimeDomainData(leftFloatTime); + rightAnalyser!.getFloatTimeDomainData(rightFloatTime); + + return { + byteFrequency: monoByteFreq, + byteTimeDomain: monoByteTime, + floatFrequency: monoFloatFreq, + floatTimeDomain: monoFloatTime, + leftTimeDomain: leftFloatTime, + rightTimeDomain: rightFloatTime, + sampleRate: audioContext!.sampleRate, + fftSize: monoAnalyser.fftSize, + binCount: monoAnalyser.frequencyBinCount, + }; +}; + +export const hasSignal = (data: AudioData): boolean => { + const avg = data.byteFrequency.reduce((s, v) => s + v, 0) / data.byteFrequency.length; + return avg > 5; +}; + +export const dispose = (): void => { + disconnectSource(); + if (audioContext && audioContext.state !== "closed") { + audioContext.close().catch(() => {}); + } + audioContext = null; + monoAnalyser = null; + leftAnalyser = null; + rightAnalyser = null; + splitter = null; + trackedVideo = null; + monoByteFreq = null; + monoByteTime = null; + monoFloatFreq = null; + monoFloatTime = null; + leftFloatTime = null; + rightFloatTime = null; +}; diff --git a/plugins/audio-visualizer-luna/src/index.ts b/plugins/audio-visualizer-luna/src/index.ts index 9049d9f..67dc004 100644 --- a/plugins/audio-visualizer-luna/src/index.ts +++ b/plugins/audio-visualizer-luna/src/index.ts @@ -1,282 +1,475 @@ import { type LunaUnload, Tracer } from "@luna/core"; import { StyleTag, PlayState, MediaItem, observe } from "@luna/lib"; import { settings, Settings } from "./Settings"; +import * as audio from "./audio"; +import type { AudioData } from "./audio"; +import { type Visualizer, type VisualizerType, VISUALIZER_DIMENSIONS, MINI_DIMENSIONS, ALL_SLOT_KEYS, ZONE_SLOTS, type SlotKey } from "./visualizers/types"; +import { createSpectrumLine } from "./visualizers/spectrum-line"; +import { createSpectrumBars } from "./visualizers/spectrum-bars"; +import { createOscilloscope } from "./visualizers/oscilloscope"; +import { createVectorscope } from "./visualizers/vectorscope"; +import { createLoudnessMeter } from "./visualizers/loudness-meter"; import visualizerStyles from "file://styles.css?minify"; export const { trace } = Tracer("[Audio Visualizer]"); export { Settings }; -const log = (message: string) => console.log(`[Audio Visualizer] ${message}`); - -const config = { - width: 200, - height: 40, - get barCount() { - return settings.barCount; - }, - get color() { - return settings.barColor; - }, - get barRounding() { - return settings.barRounding; - }, - sensitivity: 1.5, - smoothing: 0.8, -}; +const log = (msg: string) => console.log(`[Audio Visualizer] ${msg}`); export const unloads = new Set(); - new StyleTag("AudioVisualizer", unloads, visualizerStyles); +const FACTORIES: Record, () => Visualizer> = { + "spectrum-line": createSpectrumLine, + "spectrum-bars": createSpectrumBars, + oscilloscope: createOscilloscope, + vectorscope: createVectorscope, + "loudness-meter": createLoudnessMeter, +}; -let audioContext: AudioContext | null = null; -let analyser: AnalyserNode | null = null; -let audioSource: MediaStreamAudioSourceNode | null = null; -let dataArray: Uint8Array | null = null; -let animationId: number | null = null; -let isSourceConnected = false; +// Slot Management - -interface VisualizerSlot { +interface Slot { container: HTMLDivElement | null; canvas: HTMLCanvasElement | null; - ctx: CanvasRenderingContext2D | null; + visualizer: Visualizer | null; + currentType: VisualizerType; + contextType: "webgl" | "canvas2d" | null; } -const navSlot: VisualizerSlot = { container: null, canvas: null, ctx: null }; -const npSlot: VisualizerSlot = { container: null, canvas: null, ctx: null }; +interface SlotGroup { + groupContainer: HTMLDivElement; + slots: Slot[]; + keys: readonly SlotKey[]; +} +const groups = new Map(); +let navArrowsEl: HTMLElement | null = null; -const connectAudio = (): boolean => { - const video = document.getElementById("video-one") as HTMLVideoElement | null; - const capture = (video as unknown as { captureStream?: () => MediaStream })?.captureStream; - if (!video || typeof capture !== "function") return false; +const getSlot = (key: SlotKey): VisualizerType => + (settings as unknown as Record)[key] ?? "none"; - try { - if (!audioContext) audioContext = new AudioContext(); +const isWebGLViz = (type: VisualizerType): boolean => + type === "spectrum-line" || type === "spectrum-bars"; - if (!analyser) { - analyser = audioContext.createAnalyser(); - analyser.fftSize = 512; - analyser.smoothingTimeConstant = config.smoothing; - dataArray = new Uint8Array(analyser.frequencyBinCount) as Uint8Array; - } +const isMiniSlot = (key: SlotKey): boolean => + (settings.miniSlots ?? []).includes(key); - audioSource?.disconnect(); +const getSlotDims = (type: VisualizerType, key: SlotKey) => + isMiniSlot(key) && MINI_DIMENSIONS[type] ? MINI_DIMENSIONS[type] : VISUALIZER_DIMENSIONS[type]; - const stream = capture.call(video); - const audioTracks = stream.getAudioTracks(); - if (audioTracks.length === 0) { - log("No audio tracks in captured stream"); - return false; - } +const createSlotCanvas = (dims: { width: number; height: number }): HTMLCanvasElement => { + const cvs = document.createElement("canvas"); + cvs.width = dims.width; + cvs.height = dims.height; + cvs.style.cssText = `width:${dims.width}px;height:${dims.height}px;border-radius:4px;display:block;`; + return cvs; +}; - audioSource = audioContext.createMediaStreamSource(stream); - audioSource.connect(analyser); +const applySlotSize = (slot: Slot, dims: { width: number; height: number }): void => { + if (!slot.container || !slot.canvas) return; + slot.canvas.width = dims.width; + slot.canvas.height = dims.height; + slot.canvas.style.width = `${dims.width}px`; + slot.canvas.style.height = `${dims.height}px`; + slot.container.style.width = `${dims.width + 8}px`; + slot.container.style.height = `${dims.height + 8}px`; + slot.visualizer?.resize(dims.width, dims.height); +}; - if (audioContext.state === "suspended") { - audioContext.resume().catch(() => {}); - } +const switchVisualizer = (slot: Slot, type: VisualizerType, key: SlotKey): void => { + if (slot.currentType === type) return; - log("Connected via captureStream()"); - return true; - } catch (err) { - log(`Audio connection failed: ${err}`); - return false; + slot.visualizer?.dispose(); + slot.visualizer = null; + + if (type === "none") { + if (slot.container) slot.container.style.display = "none"; + slot.currentType = "none"; + return; + } + + const dims = getSlotDims(type, key); + if (slot.container) { + slot.canvas?.remove(); + const cvs = createSlotCanvas(dims); + slot.container.appendChild(cvs); + slot.canvas = cvs; + slot.contextType = isWebGLViz(type) ? "webgl" : "canvas2d"; + + slot.container.style.display = "flex"; + slot.container.style.width = `${dims.width + 8}px`; + slot.container.style.height = `${dims.height + 8}px`; + } + + const factory = FACTORIES[type]; + const viz = factory(); + if (slot.canvas) { + viz.init(slot.canvas, settings.barColor); + } + slot.visualizer = viz; + slot.currentType = type; +}; + +const syncGroupHeights = (group: SlotGroup): void => { + let maxH = 0; + for (let i = 0; i < group.keys.length; i++) { + const slot = group.slots[i]; + if (slot.currentType === "none") continue; + const dims = getSlotDims(slot.currentType, group.keys[i]); + if (dims.height > maxH) maxH = dims.height; + } + if (maxH === 0) return; + + for (let i = 0; i < group.keys.length; i++) { + const slot = group.slots[i]; + if (!slot.container || !slot.canvas || slot.currentType === "none") continue; + const dims = getSlotDims(slot.currentType, group.keys[i]); + const targetH = Math.max(dims.height, maxH); + applySlotSize(slot, { width: dims.width, height: targetH }); } }; -// Canvas things +const updateGroupVisibility = (group: SlotGroup): void => { + const allNone = group.slots.every(s => s.currentType === "none"); + group.groupContainer.style.display = allNone ? "none" : "flex"; + if (!allNone) syncGroupHeights(group); -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); - `; - - 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; - `; - - container.appendChild(cvs); - const ctx = cvs.getContext("2d"); - if (!ctx) return null; - return { container, canvas: cvs, ctx }; + if (group === groups.get("topNav-left") && navArrowsEl) { + navArrowsEl.style.marginRight = allNone ? "" : "0"; + } }; -const clearSlot = (slot: VisualizerSlot): void => { - slot.container?.remove(); - slot.container = null; - slot.canvas = null; - slot.ctx = null; +const createGroup = (keys: readonly SlotKey[], zone: string, position: string): SlotGroup => { + const groupContainer = document.createElement("div"); + groupContainer.className = "av-slot-group"; + groupContainer.dataset.zone = zone; + groupContainer.dataset.position = position; + + const slots: Slot[] = []; + for (const _key of keys) { + const slotContainer = document.createElement("div"); + slotContainer.className = "audio-visualizer-container"; + slotContainer.style.display = "none"; + groupContainer.appendChild(slotContainer); + slots.push({ + container: slotContainer, + canvas: null, + visualizer: null, + currentType: "none", + contextType: null, + }); + } + + return { groupContainer, slots, keys }; }; -// UI Placement with Luna Observer +const initGroupVisualizers = (group: SlotGroup): void => { + for (let i = 0; i < group.keys.length; i++) { + const key = group.keys[i]; + const type = getSlot(key); + if (type !== "none") { + switchVisualizer(group.slots[i], type, key); + } + } + updateGroupVisibility(group); +}; -const attachNavSlot = (anchor: Element): void => { - if (navSlot.container?.isConnected) return; - clearSlot(navSlot); +const initAllGroups = (): void => { + for (const [zoneId, positions] of Object.entries(ZONE_SLOTS)) { + for (const [posId, keys] of Object.entries(positions)) { + if (!keys) continue; + const groupId = `${zoneId}-${posId}`; + const group = createGroup(keys, zoneId, posId); + groups.set(groupId, group); + } + } +}; +// UI Attachment + +const attachNavGroups = (anchor: Element): void => { const parent = anchor.parentElement; if (!parent) return; - const els = makeSlotElements(); - if (!els) return; - els.container.style.marginRight = "12px"; - Object.assign(navSlot, els); - parent.insertBefore(els.container, anchor); + const navLeft = groups.get("topNav-left"); + if (navLeft && !navLeft.groupContainer.isConnected) { + const navArrows = parent.querySelector('[data-test="navigation-arrows"]') as HTMLElement | null; + if (navArrows) { + navArrowsEl = navArrows; + navArrows.after(navLeft.groupContainer); + } else { + parent.prepend(navLeft.groupContainer); + } + navLeft.groupContainer.style.marginRight = "auto"; + initGroupVisualizers(navLeft); + } + + const navRight = groups.get("topNav-right"); + if (navRight && !navRight.groupContainer.isConnected) { + parent.insertBefore(navRight.groupContainer, anchor); + initGroupVisualizers(navRight); + } }; -const attachNpSlot = (anchor: Element): void => { - if (npSlot.container?.isConnected) return; - clearSlot(npSlot); +const attachNpGroups = (anchor: Element): void => { + const leftContent = anchor.parentElement; + if (!leftContent) return; + const header = leftContent.parentElement as HTMLElement | null; + if (!header) return; - const parent = anchor.parentElement; - if (!parent) return; + const npLeft = groups.get("nowPlaying-left"); + if (npLeft && !npLeft.groupContainer.isConnected) { + leftContent.insertBefore(npLeft.groupContainer, anchor.nextSibling); + initGroupVisualizers(npLeft); + } - const els = makeSlotElements(); - if (!els) return; - els.container.style.marginLeft = "12px"; - Object.assign(npSlot, els); - parent.insertBefore(els.container, anchor.nextSibling); + const buttonsDiv = header.querySelector(':scope > [class*="buttons"]') as HTMLElement | null; + const npRight = groups.get("nowPlaying-right"); + if (npRight && !npRight.groupContainer.isConnected) { + if (buttonsDiv) { + header.insertBefore(npRight.groupContainer, buttonsDiv); + } else { + header.appendChild(npRight.groupContainer); + } + npRight.groupContainer.style.marginLeft = "auto"; + initGroupVisualizers(npRight); + } }; -observe(unloads, '[data-test="search-popover-container"]', attachNavSlot); -observe(unloads, '[data-test="artist-info"]', attachNpSlot); +const attachPbGroups = (anchor: Element): void => { + const trackInfo = anchor.querySelector('[data-test="track-info"]'); + const utilityContainer = anchor.querySelector('[class*="utilityContainer"]'); -// Rendering things + const pbLeft = groups.get("playerBar-left"); + if (pbLeft && !pbLeft.groupContainer.isConnected && trackInfo) { + trackInfo.appendChild(pbLeft.groupContainer); + initGroupVisualizers(pbLeft); + } + + const pbRight = groups.get("playerBar-right"); + if (pbRight && !pbRight.groupContainer.isConnected && utilityContainer) { + utilityContainer.prepend(pbRight.groupContainer); + initGroupVisualizers(pbRight); + } +}; + +initAllGroups(); + +observe(unloads, '[data-test="search-popover-container"]', attachNavGroups); +observe(unloads, '[data-test="artist-info"]', attachNpGroups); +observe(unloads, '[data-test="footer-player"]', attachPbGroups); + +const existingSearch = document.querySelector('[data-test="search-popover-container"]'); +if (existingSearch) attachNavGroups(existingSearch); +const existingArtist = document.querySelector('[data-test="artist-info"]'); +if (existingArtist) attachNpGroups(existingArtist); +const existingFooter = document.querySelector('[data-test="footer-player"]'); +if (existingFooter) attachPbGroups(existingFooter); + +// Audio Connection stuff + +const fft = () => settings.fftSize ?? 2048; +const reactivityToSmoothing = (r: number) => Math.max(0, Math.min(0.95, (100 - r) / 100)); +const smooth = () => reactivityToSmoothing(settings.reactivity ?? 30); + +let lastReactivity = settings.reactivity ?? 30; +let retryTimer: ReturnType | null = null; +let retryDelay = 500; +const MAX_RETRY_DELAY = 5000; +let silentFrames = 0; +const SILENT_THRESHOLD = 120; + +const clearRetry = (): void => { + if (retryTimer !== null) { + clearTimeout(retryTimer); + retryTimer = null; + } + retryDelay = 500; +}; + +const tryConnect = (): boolean => { + const ok = audio.connect(fft(), smooth()); + if (ok) { + clearRetry(); + silentFrames = 0; + } + return ok; +}; + +const tryReconnect = (): boolean => { + const ok = audio.reconnect(fft(), smooth()); + if (ok) { + clearRetry(); + silentFrames = 0; + } + return ok; +}; + +const scheduleRetry = (): void => { + if (retryTimer !== null) return; + retryTimer = setTimeout(() => { + retryTimer = null; + if (!PlayState.playing) return; + if (!tryConnect()) { + retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY); + scheduleRetry(); + } + }, retryDelay); +}; + +observe(unloads, "#video-one", () => { + log("video-one element observed in DOM"); + silentFrames = 0; + if (PlayState.playing) { + if (!tryReconnect()) scheduleRetry(); + } +}); + +PlayState.onState(unloads, (state) => { + if (state === "PLAYING") { + silentFrames = 0; + if (!audio.isConnected() || audio.videoChanged()) { + if (!tryReconnect()) scheduleRetry(); + } + } else { + clearRetry(); + } +}); + +MediaItem.onMediaTransition(unloads, () => { + log("Media transition"); + silentFrames = 0; + setTimeout(() => { + if (PlayState.playing) { + if (!tryReconnect()) scheduleRetry(); + } + }, 300); +}); + +// Idle Animation Synthetic Data let waveTime = 0; +const IDLE_SIZE = 1024; +const idleByteFreq = new Uint8Array(IDLE_SIZE); +const idleByteTime = new Uint8Array(IDLE_SIZE); +const idleFloatFreq = new Float32Array(IDLE_SIZE); +const idleFloatTime = new Float32Array(IDLE_SIZE); +const idleLeftTime = new Float32Array(IDLE_SIZE); +const idleRightTime = new Float32Array(IDLE_SIZE); -const drawRoundedRect = ( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number, -): void => { - ctx.beginPath(); - ctx.roundRect(x, y, width, height, radius); - ctx.fill(); -}; - -const drawScrollingWave = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => { - const barCount = config.barCount; - const barWidth = cvs.width / barCount; - const maxHeight = cvs.height * 0.6; - - ctx.fillStyle = config.color; - - for (let i = 0; i < barCount; i++) { - const x = i / barCount; +const generateIdleData = (): AudioData => { + for (let i = 0; i < IDLE_SIZE; i++) { + const x = i / IDLE_SIZE; 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; - const combinedWave = (wave1 + wave2 + wave3 + 1) / 2; - const travelWave = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5; - const barHeight = maxHeight * combinedWave * travelWave * 0.8 + 2; + const combined = (wave1 + wave2 + wave3 + 1) / 2; + const travel = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5; - const xPos = i * barWidth; - const yPos = (cvs.height - barHeight) / 2; + const byteVal = Math.floor(combined * travel * 140 + 20); + idleByteFreq[i] = byteVal; + idleFloatFreq[i] = -40 + byteVal * 0.3; - if (config.barRounding) { - drawRoundedRect(ctx, xPos, yPos, barWidth - 1, barHeight, 2); - } else { - ctx.fillRect(xPos, yPos, barWidth - 1, barHeight); - } + const timeSample = Math.sin(x * Math.PI * 8 + waveTime * 3) * 0.15; + idleByteTime[i] = 128 + Math.floor(timeSample * 127); + idleFloatTime[i] = timeSample; + idleLeftTime[i] = timeSample; + idleRightTime[i] = Math.sin(x * Math.PI * 8 + waveTime * 3 + 0.3) * 0.15; } -}; -const drawBars = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => { - if (!dataArray) return; - - const barWidth = cvs.width / config.barCount; - const heightScale = cvs.height / 255; - - 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 = cvs.height - barHeight; - - if (config.barRounding) { - drawRoundedRect(ctx, x, y, barWidth - 1, barHeight, 2); - } else { - ctx.fillRect(x, y, barWidth - 1, barHeight); - } - } + return { + byteFrequency: idleByteFreq, + byteTimeDomain: idleByteTime, + floatFrequency: idleFloatFreq, + floatTimeDomain: idleFloatTime, + leftTimeDomain: idleLeftTime, + rightTimeDomain: idleRightTime, + sampleRate: 44100, + fftSize: IDLE_SIZE * 2, + binCount: IDLE_SIZE, + }; }; // Animation Loop +let animationId: number | null = null; +const lastSlotTypes = new Map(); +const lastMiniState = new Map(); + +for (const key of ALL_SLOT_KEYS) { + lastSlotTypes.set(key, getSlot(key)); + lastMiniState.set(key, isMiniSlot(key)); +} + const animate = (): void => { - const slots = [navSlot, npSlot].filter(s => s.ctx && s.canvas); - - if (slots.length > 0) { - waveTime += 0.05; - - let hasRealAudio = false; - if (analyser && dataArray) { - analyser.getByteFrequencyData(dataArray); - const avgVolume = dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length; - hasRealAudio = avgVolume > 5; - } - - for (const slot of slots) { - const { ctx, canvas: cvs } = slot; - if (!ctx || !cvs) continue; - ctx.clearRect(0, 0, cvs.width, cvs.height); - - if (hasRealAudio) { - drawBars(ctx, cvs); - } else { - drawScrollingWave(ctx, cvs); + for (const group of groups.values()) { + let changed = false; + for (let i = 0; i < group.keys.length; i++) { + const key = group.keys[i]; + const currentType = getSlot(key); + const lastType = lastSlotTypes.get(key) ?? "none"; + const mini = isMiniSlot(key); + const wasMini = lastMiniState.get(key) ?? false; + if (currentType !== lastType) { + switchVisualizer(group.slots[i], currentType, key); + lastSlotTypes.set(key, currentType); + lastMiniState.set(key, mini); + changed = true; + } else if (mini !== wasMini && currentType !== "none") { + const dims = getSlotDims(currentType, key); + applySlotSize(group.slots[i], dims); + lastMiniState.set(key, mini); + changed = true; } } + if (changed) updateGroupVisibility(group); + } + + const currentReactivity = settings.reactivity ?? 30; + if (currentReactivity !== lastReactivity) { + audio.setSmoothing(reactivityToSmoothing(currentReactivity)); + lastReactivity = currentReactivity; + } + + waveTime += 0.05; + const data = audio.sample(); + const hasSignal = data && audio.hasSignal(data); + + if (PlayState.playing && audio.isConnected()) { + if (!hasSignal) { + silentFrames++; + if (silentFrames >= SILENT_THRESHOLD) { + log("Silent for too long, reconnecting..."); + silentFrames = 0; + if (!tryReconnect()) scheduleRetry(); + } + } else { + silentFrames = 0; + } + } else if (PlayState.playing && !audio.isConnected() && retryTimer === null) { + scheduleRetry(); + } + + const renderData = hasSignal ? data : generateIdleData(); + + for (const group of groups.values()) { + for (const slot of group.slots) { + if (!slot.canvas || slot.currentType === "none" || !slot.visualizer) continue; + slot.visualizer.render(renderData, settings.barColor); + } } animationId = requestAnimationFrame(animate); }; -// Initialization (events) - -PlayState.onState(unloads, (state) => { - if (state === "PLAYING" && !isSourceConnected) { - isSourceConnected = connectAudio(); - } -}); - -MediaItem.onMediaTransition(unloads, () => { - isSourceConnected = false; - if (PlayState.playing) { - isSourceConnected = connectAudio(); - } -}); - -// Initialization (startup) +// Init log("Initializing..."); if (PlayState.playing) { - isSourceConnected = connectAudio(); + if (!tryConnect()) scheduleRetry(); } animationId = requestAnimationFrame(animate); @@ -285,24 +478,24 @@ animationId = requestAnimationFrame(animate); unloads.add(() => { log("Plugin unloading"); + clearRetry(); + + if (navArrowsEl) { + navArrowsEl.style.marginRight = ""; + navArrowsEl = null; + } if (animationId) { cancelAnimationFrame(animationId); animationId = null; } - clearSlot(navSlot); - clearSlot(npSlot); - - try { audioSource?.disconnect(); } catch {} - - if (audioContext && audioContext.state !== "closed") { - audioContext.close(); + for (const group of groups.values()) { + for (const slot of group.slots) { + slot.visualizer?.dispose(); + } + group.groupContainer.remove(); } - - audioContext = null; - analyser = null; - audioSource = null; - dataArray = null; - isSourceConnected = false; + groups.clear(); + audio.dispose(); }); diff --git a/plugins/audio-visualizer-luna/src/styles.css b/plugins/audio-visualizer-luna/src/styles.css index e185565..1ac97e4 100644 --- a/plugins/audio-visualizer-luna/src/styles.css +++ b/plugins/audio-visualizer-luna/src/styles.css @@ -1,37 +1,31 @@ -/* Audio Visualizer CSS */ - .audio-visualizer-container { + 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); 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); + border: 1px solid rgba(255, 105, 180, 0.15); animation: av-fadeIn 0.5s ease-out; } .audio-visualizer-container:hover { transform: scale(1.02); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + box-shadow: 0 4px 12px rgba(255, 105, 180, 0.2); + border-color: rgba(255, 105, 180, 0.3); } .audio-visualizer-container canvas { display: block; - transition: all 0.3s ease-in-out; -} - -/* Responsive adjustments */ -@media (max-width: 768px) { - .audio-visualizer-container { - margin: 4px; - padding: 2px; - } - - .audio-visualizer-container canvas { - max-width: 150px; - max-height: 30px; - } + border-radius: 4px; } .audio-visualizer-container.active { - box-shadow: 0 0 20px rgba(255, 255, 255, 0.3); + box-shadow: 0 0 20px rgba(255, 105, 180, 0.3); } @keyframes av-fadeIn { @@ -48,3 +42,29 @@ [data-type="search-field"] { min-width: 220px !important; } + +/* Slot group layout */ +.av-slot-group { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +/* Left/Right group spacing */ +.av-slot-group[data-position="left"] { + margin-right: 12px; +} + +.av-slot-group[data-position="right"] { + margin-left: 12px; +} + +/* Player Bar: LEFT inside trackInfo, RIGHT inside utilityContainer */ +.av-slot-group[data-zone="playerBar"][data-position="left"] { + margin-left: 8px; +} +.av-slot-group[data-zone="playerBar"][data-position="right"] { + margin-right: 8px; +} + diff --git a/plugins/audio-visualizer-luna/src/visualizers/loudness-meter.ts b/plugins/audio-visualizer-luna/src/visualizers/loudness-meter.ts new file mode 100644 index 0000000..1b8da67 --- /dev/null +++ b/plugins/audio-visualizer-luna/src/visualizers/loudness-meter.ts @@ -0,0 +1,201 @@ +import type { AudioData } from "../audio"; +import type { Visualizer } from "./types"; +import { hexToRGB } from "../webgl"; + +const GATE_ABSOLUTE = -70; +const GATE_RELATIVE_OFFSET = -10; +const GAINS = [1.0, 1.0]; + +interface LUFSState { + momentaryBlocks: number[]; + shortTermBlocks: number[]; + integratedPowers: number[]; + momentary: number; + shortTerm: number; + integrated: number; + blockBuffer: Float32Array[]; + blockPos: number; + blockSize: number; + hopSize: number; + hopPos: number; + displayMomentary: number; + displayShortTerm: number; + displayIntegrated: number; +} + +const createLUFSState = (sampleRate: number): LUFSState => { + const blockSize = Math.floor(sampleRate * 0.4); + const hopSize = Math.floor(sampleRate * 0.1); + return { + momentaryBlocks: [], + shortTermBlocks: [], + integratedPowers: [], + momentary: -Infinity, + shortTerm: -Infinity, + integrated: -Infinity, + blockBuffer: [new Float32Array(blockSize), new Float32Array(blockSize)], + blockPos: 0, + blockSize, + hopSize, + hopPos: 0, + displayMomentary: -60, + displayShortTerm: -60, + displayIntegrated: -60, + }; +}; + +const computeBlockLoudness = (left: Float32Array, right: Float32Array, len: number): number => { + let sumL = 0, sumR = 0; + for (let i = 0; i < len; i++) { + sumL += left[i] * left[i]; + sumR += right[i] * right[i]; + } + const powerL = sumL / len; + const powerR = sumR / len; + const weighted = GAINS[0] * powerL + GAINS[1] * powerR; + if (weighted <= 0) return -Infinity; + return -0.691 + 10 * Math.log10(weighted); +}; + +const computeGatedIntegrated = (powers: number[]): number => { + if (powers.length === 0) return -Infinity; + + const aboveAbsolute = powers.filter(p => p > GATE_ABSOLUTE); + if (aboveAbsolute.length === 0) return -Infinity; + + const meanAbsolute = aboveAbsolute.reduce((s, v) => s + Math.pow(10, v / 10), 0) / aboveAbsolute.length; + const relativeThreshold = 10 * Math.log10(meanAbsolute) + GATE_RELATIVE_OFFSET; + const aboveRelative = aboveAbsolute.filter(p => p > relativeThreshold); + if (aboveRelative.length === 0) return -Infinity; + + const meanRelative = aboveRelative.reduce((s, v) => s + Math.pow(10, v / 10), 0) / aboveRelative.length; + return 10 * Math.log10(meanRelative); +}; + +const lerp = (a: number, b: number, t: number): number => a + (b - a) * t; + +export const createLoudnessMeter = (): Visualizer => { + let ctx: CanvasRenderingContext2D | null = null; + let w = 0, h = 0; + let state: LUFSState | null = null; + let lastSampleRate = 0; + + const SMOOTHING_FAST = 0.25; + const SMOOTHING_SLOW = 0.08; + + return { + name: "Loudness (LUFS)", + id: "loudness-meter", + + init(canvas, _color) { + ctx = canvas.getContext("2d")!; + w = canvas.width; + h = canvas.height; + state = null; + lastSampleRate = 0; + }, + + render(data: AudioData, color: string) { + if (!ctx) return; + + if (!state || data.sampleRate !== lastSampleRate) { + state = createLUFSState(data.sampleRate); + lastSampleRate = data.sampleRate; + } + + const left = data.leftTimeDomain; + const right = data.rightTimeDomain; + const len = Math.min(left.length, right.length); + + for (let i = 0; i < len; i++) { + state.blockBuffer[0][state.blockPos] = left[i]; + state.blockBuffer[1][state.blockPos] = right[i]; + state.blockPos++; + state.hopPos++; + + if (state.blockPos >= state.blockSize) { + const loudness = computeBlockLoudness(state.blockBuffer[0], state.blockBuffer[1], state.blockSize); + + state.momentaryBlocks.push(loudness); + if (state.momentaryBlocks.length > 4) state.momentaryBlocks.shift(); + state.momentary = Math.max(...state.momentaryBlocks); + + state.shortTermBlocks.push(loudness); + if (state.shortTermBlocks.length > 30) state.shortTermBlocks.shift(); + const stPowers = state.shortTermBlocks.filter(v => v > -Infinity); + if (stPowers.length > 0) { + const stMean = stPowers.reduce((s, v) => s + Math.pow(10, v / 10), 0) / stPowers.length; + state.shortTerm = 10 * Math.log10(stMean); + } + + state.integratedPowers.push(loudness); + if (state.integratedPowers.length > 3000) state.integratedPowers.shift(); + state.integrated = computeGatedIntegrated(state.integratedPowers); + + const keep = state.blockSize - state.hopSize; + state.blockBuffer[0].copyWithin(0, state.hopSize); + state.blockBuffer[1].copyWithin(0, state.hopSize); + state.blockPos = keep; + state.hopPos = 0; + } + } + + const clamp = (v: number) => (v === -Infinity ? -60 : Math.max(-60, Math.min(0, v))); + state.displayMomentary = lerp(state.displayMomentary, clamp(state.momentary), SMOOTHING_FAST); + state.displayShortTerm = lerp(state.displayShortTerm, clamp(state.shortTerm), SMOOTHING_FAST); + state.displayIntegrated = lerp(state.displayIntegrated, clamp(state.integrated), SMOOTHING_SLOW); + + ctx.clearRect(0, 0, w, h); + const [cr, cg, cb] = hexToRGB(color); + + const minLUFS = -60; + const maxLUFS = 0; + const range = maxLUFS - minLUFS; + const norm = (v: number) => Math.max(0, Math.min(1, (v - minLUFS) / range)); + + const labels = ["M", "S", "I"]; + const rawValues = [state.momentary, state.shortTerm, state.integrated]; + const displayValues = [state.displayMomentary, state.displayShortTerm, state.displayIntegrated]; + const barH = (h - 4) / 3; + const labelW = 12; + const valueW = 36; + const barX = labelW; + const barW = w - labelW - valueW; + + ctx.font = `bold ${Math.min(9, barH - 1)}px monospace`; + ctx.textBaseline = "middle"; + + for (let i = 0; i < 3; i++) { + const y = 1 + i * (barH + 1); + const n = norm(displayValues[i]); + + ctx.fillStyle = color; + ctx.textAlign = "left"; + ctx.fillText(labels[i], 1, y + barH / 2); + + ctx.fillStyle = `rgba(${cr * 255}, ${cg * 255}, ${cb * 255}, 0.15)`; + ctx.fillRect(barX, y, barW, barH); + + ctx.fillStyle = `rgba(${cr * 255}, ${cg * 255}, ${cb * 255}, 0.7)`; + ctx.fillRect(barX, y, barW * n, barH); + + ctx.fillStyle = "rgba(255,255,255,0.8)"; + ctx.textAlign = "right"; + const raw = rawValues[i]; + const txt = raw > -Infinity ? raw.toFixed(1) : "-inf"; + ctx.fillText(txt, w - 1, y + barH / 2); + } + }, + + resize(width, height) { + w = width; + h = height; + }, + + dispose() { + ctx = null; + state = null; + lastSampleRate = 0; + }, + }; +}; diff --git a/plugins/audio-visualizer-luna/src/visualizers/oscilloscope.ts b/plugins/audio-visualizer-luna/src/visualizers/oscilloscope.ts new file mode 100644 index 0000000..cd0a767 --- /dev/null +++ b/plugins/audio-visualizer-luna/src/visualizers/oscilloscope.ts @@ -0,0 +1,96 @@ +import type { AudioData } from "../audio"; +import type { Visualizer } from "./types"; +import { settings } from "../Settings"; + +export const createOscilloscope = (): Visualizer => { + let ctx: CanvasRenderingContext2D | null = null; + let w = 0, h = 0; + let scrollBuffer: Float32Array | null = null; + let scrollPos = 0; + + const ensureScrollBuffer = () => { + if (!scrollBuffer || scrollBuffer.length !== w) { + scrollBuffer = new Float32Array(w); + scrollPos = 0; + } + }; + + return { + name: "Oscilloscope", + id: "oscilloscope", + + init(canvas, _color) { + ctx = canvas.getContext("2d")!; + w = canvas.width; + h = canvas.height; + scrollBuffer = null; + scrollPos = 0; + }, + + render(data: AudioData, color: string) { + if (!ctx) return; + ctx.clearRect(0, 0, w, h); + + const lineWidth = settings.lineThickness ?? 1.5; + ctx.lineWidth = lineWidth; + ctx.strokeStyle = color; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + + if (settings.scrollingOscilloscope) { + ensureScrollBuffer(); + if (!scrollBuffer) return; + + const timeDomain = data.floatTimeDomain; + const samplesPerPixel = Math.max(1, Math.floor(timeDomain.length / w)); + const pixelsToAdd = Math.max(1, Math.ceil(timeDomain.length / samplesPerPixel)); + + for (let p = 0; p < pixelsToAdd; p++) { + const sampleIdx = Math.floor(p * samplesPerPixel); + let peak = 0; + for (let s = sampleIdx; s < Math.min(sampleIdx + samplesPerPixel, timeDomain.length); s++) { + if (Math.abs(timeDomain[s]) > Math.abs(peak)) peak = timeDomain[s]; + } + scrollBuffer[scrollPos % w] = peak; + scrollPos++; + } + + ctx.beginPath(); + for (let x = 0; x < w; x++) { + const idx = (scrollPos - w + x + w * 2) % w; + const sample = scrollBuffer[idx]; + const y = (1 - sample) * h / 2; + if (x === 0) ctx.moveTo(0, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } else { + const buffer = data.byteTimeDomain; + const len = buffer.length; + const segmentWidth = w / len; + + ctx.beginPath(); + for (let i = 0; i < len; i++) { + const v = buffer[i] / 128.0; + const y = (v * h) / 2; + if (i === 0) ctx.moveTo(0, y); + else ctx.lineTo(i * segmentWidth, y); + } + ctx.stroke(); + } + }, + + resize(width, height) { + w = width; + h = height; + scrollBuffer = null; + scrollPos = 0; + }, + + dispose() { + ctx = null; + scrollBuffer = null; + scrollPos = 0; + }, + }; +}; diff --git a/plugins/audio-visualizer-luna/src/visualizers/spectrum-bars.ts b/plugins/audio-visualizer-luna/src/visualizers/spectrum-bars.ts new file mode 100644 index 0000000..3b51282 --- /dev/null +++ b/plugins/audio-visualizer-luna/src/visualizers/spectrum-bars.ts @@ -0,0 +1,136 @@ +import type { AudioData } from "../audio"; +import type { Visualizer } from "./types"; +import { createProgram, drawQuad, setUniform1f, setUniform1fv, setUniform2f, setUniform3f, hexToRGB } from "../webgl"; +import { settings } from "../Settings"; + +const MAX_BARS = 128; + +const FRAG = `#version 300 es +precision highp float; + +uniform vec2 u_resolution; +uniform float u_amplitudes[${MAX_BARS}]; +uniform int u_bar_count; +uniform vec3 u_color; +uniform float u_gap; +uniform float u_gain; +uniform float u_rounding; + +out vec4 fragColor; + +void main() { + vec2 uv = gl_FragCoord.xy / u_resolution; + + float cellFloat = uv.x * float(u_bar_count); + int barIdx = clamp(int(cellFloat), 0, u_bar_count - 1); + float cellPos = fract(cellFloat); + + float amp = clamp(u_amplitudes[barIdx] * u_gain, 0.0, 1.0); + + if (amp < 0.005) { + fragColor = vec4(0.0); + return; + } + + // Bar shape with anti-aliased edges and configurable gap + float barMask = smoothstep(0.0, u_gap, cellPos) + * smoothstep(0.0, u_gap, 1.0 - cellPos); + + // Hard cut at bottom, soft feather only at the top edge + float feather = 1.5 / u_resolution.y; + float heightMask = 1.0 - smoothstep(amp - feather, amp + feather, uv.y); + + float a = barMask * heightMask; + + // Rounded top corners in pixel space + if (u_rounding > 0.5 && a > 0.0) { + float cellPx = u_resolution.x / float(u_bar_count); + float barPx = cellPx * (1.0 - 2.0 * u_gap); + float fromLeft = (cellPos - u_gap) * cellPx; + float fromRight = barPx - fromLeft; + float fromTop = (amp - uv.y) * u_resolution.y; + float r = clamp(barPx * 0.3, 1.0, 3.0); + float edgeX = min(fromLeft, fromRight); + if (edgeX < r && fromTop < r && fromTop >= 0.0) { + float d = length(vec2(r - edgeX, r - fromTop)) - r; + a *= 1.0 - smoothstep(-0.5, 0.5, d); + } + } + + fragColor = vec4(u_color * a, a); +} +`; + +const amplitudes = new Float32Array(MAX_BARS); + +export const createSpectrumBars = (): Visualizer => { + let gl: WebGL2RenderingContext | null = null; + let program: WebGLProgram | null = null; + let w = 0, h = 0; + + return { + name: "Spectrum (Bars)", + id: "spectrum-bars", + + init(canvas, _color) { + gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true })!; + if (!gl) throw new Error("WebGL2 not available"); + program = createProgram(gl, FRAG); + w = canvas.width; + h = canvas.height; + gl.viewport(0, 0, w, h); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + }, + + render(data: AudioData, color: string) { + if (!gl || !program) return; + const barCount = Math.min(settings.barCount ?? 64, MAX_BARS); + const gain = settings.gain ?? 1.5; + + // Use byteFrequency (0-255 normalized across full analyser range) + const binStep = data.byteFrequency.length / barCount; + for (let i = 0; i < barCount; i++) { + let maxVal = 0; + const start = Math.floor(i * binStep); + const end = Math.floor((i + 1) * binStep); + for (let j = start; j < end; j++) { + if (data.byteFrequency[j] > maxVal) maxVal = data.byteFrequency[j]; + } + amplitudes[i] = Math.min(1, (maxVal / 255) * gain); + } + for (let i = barCount; i < MAX_BARS; i++) amplitudes[i] = 0; + + gl.viewport(0, 0, w, h); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.useProgram(program); + + setUniform2f(gl, program, "u_resolution", w, h); + setUniform1fv(gl, program, "u_amplitudes", amplitudes); + const loc = gl.getUniformLocation(program, "u_bar_count"); + gl.uniform1i(loc, barCount); + const [r, g, b] = hexToRGB(color); + setUniform3f(gl, program, "u_color", r, g, b); + const cellPx = w / barCount; + const gap = Math.min(0.15, 1.5 / cellPx); + setUniform1f(gl, program, "u_gap", gap); + setUniform1f(gl, program, "u_gain", 1.0); + setUniform1f(gl, program, "u_rounding", settings.barRounding ? 1.0 : 0.0); + + drawQuad(gl, program); + }, + + resize(width, height) { + w = width; + h = height; + if (gl) gl.viewport(0, 0, w, h); + }, + + dispose() { + if (gl && program) gl.deleteProgram(program); + program = null; + gl = null; + }, + }; +}; diff --git a/plugins/audio-visualizer-luna/src/visualizers/spectrum-line.ts b/plugins/audio-visualizer-luna/src/visualizers/spectrum-line.ts new file mode 100644 index 0000000..32b4019 --- /dev/null +++ b/plugins/audio-visualizer-luna/src/visualizers/spectrum-line.ts @@ -0,0 +1,105 @@ +import type { AudioData } from "../audio"; +import type { Visualizer } from "./types"; +import { createProgram, drawQuad, setUniform1f, setUniform1fv, setUniform2f, setUniform3f, hexToRGB } from "../webgl"; +import { settings } from "../Settings"; + +const BIN_COUNT = 256; + +const FRAG = `#version 300 es +precision highp float; + +uniform vec2 u_resolution; +uniform float u_amplitudes[${BIN_COUNT}]; +uniform vec3 u_color; +uniform float u_fill_opacity; +uniform float u_line_thickness; +uniform float u_opacity_falloff; + +out vec4 fragColor; + +float interpolate(float a, float b, float t) { + return (1.0 - t) * a + t * b; +} + +void main() { + vec2 uv = gl_FragCoord.xy / u_resolution; + int idx = int(uv.x * float(${BIN_COUNT})); + int idxL = int((uv.x - 1.0 / u_resolution.x) * float(${BIN_COUNT})); + int idxR = int((uv.x + 1.0 / u_resolution.x) * float(${BIN_COUNT})); + idx = clamp(idx, 0, ${BIN_COUNT - 1}); + idxL = clamp(idxL, 0, ${BIN_COUNT - 1}); + idxR = clamp(idxR, 0, ${BIN_COUNT - 1}); + + float amplitude = u_amplitudes[idx]; + float left = u_amplitudes[idxL]; + float right = u_amplitudes[idxR]; + float lowest = min(left, right); + float dist = (amplitude - uv.y) * u_resolution.y; + + float a = 0.0; + a += float(abs(dist) <= u_resolution.x * 0.005 * u_line_thickness || (uv.y >= lowest && uv.y <= amplitude)) * clamp(sign(dist), 0.0, 1.0); + a += clamp(sign(amplitude - uv.y), 0.0, 1.0) * interpolate(1.0, u_fill_opacity, pow(1.0 - uv.y, 1.0 - u_opacity_falloff)); + a = clamp(a, 0.0, 1.0); + fragColor = vec4(u_color * a, a); +} +`; + +const amplitudes = new Float32Array(BIN_COUNT); + +export const createSpectrumLine = (): Visualizer => { + let gl: WebGL2RenderingContext | null = null; + let program: WebGLProgram | null = null; + let w = 0, h = 0; + + return { + name: "Spectrum (Line)", + id: "spectrum-line", + + init(canvas, _color) { + gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true })!; + if (!gl) throw new Error("WebGL2 not available"); + program = createProgram(gl, FRAG); + w = canvas.width; + h = canvas.height; + gl.viewport(0, 0, w, h); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + }, + + render(data: AudioData, color: string) { + if (!gl || !program) return; + const gain = settings.gain ?? 1.5; + const binStep = data.byteFrequency.length / BIN_COUNT; + for (let i = 0; i < BIN_COUNT; i++) { + amplitudes[i] = Math.min(1, (data.byteFrequency[Math.floor(i * binStep)] / 255) * gain); + } + + gl.viewport(0, 0, w, h); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.useProgram(program); + + setUniform2f(gl, program, "u_resolution", w, h); + setUniform1fv(gl, program, "u_amplitudes", amplitudes); + const [r, g, b] = hexToRGB(color); + setUniform3f(gl, program, "u_color", r, g, b); + setUniform1f(gl, program, "u_fill_opacity", settings.fillOpacity ?? 0.3); + setUniform1f(gl, program, "u_line_thickness", settings.lineThickness ?? 1.5); + setUniform1f(gl, program, "u_opacity_falloff", settings.opacityFalloff ?? 0.5); + + drawQuad(gl, program); + }, + + resize(width, height) { + w = width; + h = height; + if (gl) gl.viewport(0, 0, w, h); + }, + + dispose() { + if (gl && program) gl.deleteProgram(program); + program = null; + gl = null; + }, + }; +}; diff --git a/plugins/audio-visualizer-luna/src/visualizers/types.ts b/plugins/audio-visualizer-luna/src/visualizers/types.ts new file mode 100644 index 0000000..4081906 --- /dev/null +++ b/plugins/audio-visualizer-luna/src/visualizers/types.ts @@ -0,0 +1,87 @@ +import type { AudioData } from "../audio"; + +export interface Visualizer { + readonly name: string; + readonly id: VisualizerType; + init(canvas: HTMLCanvasElement, color: string): void; + render(data: AudioData, color: string): void; + resize(width: number, height: number): void; + dispose(): void; +} + +export type VisualizerType = + | "spectrum-line" + | "spectrum-bars" + | "oscilloscope" + | "vectorscope" + | "loudness-meter" + | "none"; + +export interface VisualizerDimensions { + width: number; + height: number; +} + +export const VISUALIZER_DIMENSIONS: Record = { + "spectrum-line": { width: 200, height: 40 }, + "spectrum-bars": { width: 200, height: 40 }, + oscilloscope: { width: 200, height: 40 }, + vectorscope: { width: 60, height: 60 }, + "loudness-meter": { width: 160, height: 40 }, + none: { width: 0, height: 0 }, +}; + +export const VISUALIZER_LABELS: Record = { + "spectrum-line": "Spectrum (Line)", + "spectrum-bars": "Spectrum (Bars)", + oscilloscope: "Oscilloscope", + vectorscope: "Vectorscope", + "loudness-meter": "Loudness (LUFS)", + none: "None", +}; + +export type ZoneId = "topNav" | "nowPlaying" | "playerBar"; +export type PositionId = "left" | "right"; + +export const ALL_SLOT_KEYS = [ + "navLeft1", "navLeft2", "navLeft3", + "navRight1", "navRight2", "navRight3", + "npLeft1", "npLeft2", "npLeft3", + "npRight1", "npRight2", "npRight3", + "pbLeft1", "pbLeft2", "pbLeft3", + "pbRight1", "pbRight2", "pbRight3", +] as const; + +export type SlotKey = (typeof ALL_SLOT_KEYS)[number]; + +export const ZONE_SLOTS: Record> = { + topNav: { + left: ["navLeft1", "navLeft2", "navLeft3"], + right: ["navRight1", "navRight2", "navRight3"], + }, + nowPlaying: { + left: ["npLeft1", "npLeft2", "npLeft3"], + right: ["npRight1", "npRight2", "npRight3"], + }, + playerBar: { + left: ["pbLeft1", "pbLeft2", "pbLeft3"], + right: ["pbRight1", "pbRight2", "pbRight3"], + }, +}; + +export const ZONE_LABELS: Record = { + nowPlaying: "Now Playing View", + topNav: "Top Nav", + playerBar: "Player Bar", +}; + +export const POSITION_LABELS: Record = { + left: "Left", + right: "Right", +}; + +export const MINI_SUPPORTED = new Set(["oscilloscope"]); + +export const MINI_DIMENSIONS: Partial> = { + oscilloscope: { width: 80, height: 60 }, +}; diff --git a/plugins/audio-visualizer-luna/src/visualizers/vectorscope.ts b/plugins/audio-visualizer-luna/src/visualizers/vectorscope.ts new file mode 100644 index 0000000..b111341 --- /dev/null +++ b/plugins/audio-visualizer-luna/src/visualizers/vectorscope.ts @@ -0,0 +1,105 @@ +import type { AudioData } from "../audio"; +import type { Visualizer } from "./types"; +import { settings } from "../Settings"; + +export const createVectorscope = (): Visualizer => { + let ctx: CanvasRenderingContext2D | null = null; + let canvas: HTMLCanvasElement | null = null; + let trailCanvas: HTMLCanvasElement | null = null; + let trailCtx: CanvasRenderingContext2D | null = null; + let w = 0, h = 0; + let lastX = 0, lastY = 0; + let hasLast = false; + let lastLissajous = false; + + return { + name: "Vectorscope", + id: "vectorscope", + + init(cvs, _color) { + canvas = cvs; + ctx = cvs.getContext("2d")!; + w = cvs.width; + h = cvs.height; + hasLast = false; + + trailCanvas = document.createElement("canvas"); + trailCanvas.width = w; + trailCanvas.height = h; + trailCtx = trailCanvas.getContext("2d")!; + + lastLissajous = !!settings.lissajous; + cvs.style.transform = lastLissajous ? "rotate(45deg) scale(0.707)" : ""; + }, + + render(data: AudioData, color: string) { + if (!ctx || !trailCtx || !trailCanvas || !canvas) return; + + const wantLissajous = !!settings.lissajous; + if (wantLissajous !== lastLissajous) { + lastLissajous = wantLissajous; + canvas.style.transform = wantLissajous ? "rotate(45deg) scale(0.707)" : ""; + } + + // Fade the trail buffer by drawing it at reduced opacity onto itself + trailCtx.save(); + trailCtx.globalCompositeOperation = "destination-in"; + trailCtx.fillStyle = "rgba(0, 0, 0, 0.82)"; + trailCtx.fillRect(0, 0, w, h); + trailCtx.restore(); + + const left = data.leftTimeDomain; + const right = data.rightTimeDomain; + const len = Math.min(left.length, right.length); + const lineWidth = Math.max(0.5, (settings.lineThickness ?? 1.0) * 0.5); + const scale = 2.25; + + trailCtx.strokeStyle = color; + trailCtx.lineWidth = lineWidth; + trailCtx.lineJoin = "round"; + trailCtx.lineCap = "round"; + trailCtx.globalAlpha = 0.9; + + trailCtx.beginPath(); + for (let i = 0; i < len; i++) { + const x = left[i] * (w / scale) + w / 2; + const y = right[i] * (h / scale) + h / 2; + + if (!hasLast) { + trailCtx.moveTo(x, y); + hasLast = true; + } else { + trailCtx.moveTo(lastX, lastY); + trailCtx.lineTo(x, y); + } + lastX = x; + lastY = y; + } + trailCtx.stroke(); + trailCtx.globalAlpha = 1.0; + + // Composite trail onto visible canvas (fully transparent background) + ctx.clearRect(0, 0, w, h); + ctx.drawImage(trailCanvas, 0, 0); + }, + + resize(width, height) { + w = width; + h = height; + hasLast = false; + if (trailCanvas && trailCtx) { + trailCanvas.width = w; + trailCanvas.height = h; + } + }, + + dispose() { + if (canvas) canvas.style.transform = ""; + ctx = null; + canvas = null; + trailCtx = null; + trailCanvas = null; + hasLast = false; + }, + }; +}; diff --git a/plugins/audio-visualizer-luna/src/webgl.ts b/plugins/audio-visualizer-luna/src/webgl.ts new file mode 100644 index 0000000..9a69441 --- /dev/null +++ b/plugins/audio-visualizer-luna/src/webgl.ts @@ -0,0 +1,151 @@ +const VERTEX_SHADER = `#version 300 es +in vec2 a_position; +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); +} +`; + +export const compileShader = (gl: WebGL2RenderingContext, type: number, source: string): WebGLShader => { + const shader = gl.createShader(type)!; + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new Error(`Shader compile error: ${info}`); + } + return shader; +}; + +export const createProgram = (gl: WebGL2RenderingContext, fragSource: string): WebGLProgram => { + const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); + const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource); + const program = gl.createProgram()!; + gl.attachShader(program, vert); + gl.attachShader(program, frag); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const info = gl.getProgramInfoLog(program); + gl.deleteProgram(program); + throw new Error(`Program link error: ${info}`); + } + gl.deleteShader(vert); + gl.deleteShader(frag); + return program; +}; + +interface QuadResources { + vao: WebGLVertexArrayObject; + vbo: WebGLBuffer; +} +const quadMap = new WeakMap(); + +const ensureQuad = (gl: WebGL2RenderingContext, program: WebGLProgram): QuadResources => { + let res = quadMap.get(gl); + if (res) return res; + const verts = new Float32Array([-1, -1, 3, -1, -1, 3]); + const vao = gl.createVertexArray()!; + const vbo = gl.createBuffer()!; + gl.bindVertexArray(vao); + gl.bindBuffer(gl.ARRAY_BUFFER, vbo); + gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW); + const loc = gl.getAttribLocation(program, "a_position"); + gl.enableVertexAttribArray(loc); + gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(null); + res = { vao, vbo }; + quadMap.set(gl, res); + return res; +}; + +export const drawQuad = (gl: WebGL2RenderingContext, program: WebGLProgram): void => { + const res = ensureQuad(gl, program); + gl.useProgram(program); + gl.bindVertexArray(res.vao); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.bindVertexArray(null); +}; + +export interface PingPongBuffers { + fbos: [WebGLFramebuffer, WebGLFramebuffer]; + textures: [WebGLTexture, WebGLTexture]; + current: 0 | 1; +} + +const createFBOTexture = (gl: WebGL2RenderingContext, w: number, h: number): { fbo: WebGLFramebuffer; texture: WebGLTexture } => { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + const fbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.bindTexture(gl.TEXTURE_2D, null); + + return { fbo, texture: tex }; +}; + +export const createPingPong = (gl: WebGL2RenderingContext, w: number, h: number): PingPongBuffers => { + const a = createFBOTexture(gl, w, h); + const b = createFBOTexture(gl, w, h); + return { + fbos: [a.fbo, b.fbo], + textures: [a.texture, b.texture], + current: 0, + }; +}; + +export const resizePingPong = (gl: WebGL2RenderingContext, pp: PingPongBuffers, w: number, h: number): void => { + for (const tex of pp.textures) { + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + } + gl.bindTexture(gl.TEXTURE_2D, null); +}; + +export const setUniform1f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: number): void => { + gl.uniform1f(gl.getUniformLocation(program, name), v); +}; + +export const setUniform2f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, x: number, y: number): void => { + gl.uniform2f(gl.getUniformLocation(program, name), x, y); +}; + +export const setUniform3f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, x: number, y: number, z: number): void => { + gl.uniform3f(gl.getUniformLocation(program, name), x, y, z); +}; + +export const setUniform1fv = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: Float32Array): void => { + gl.uniform1fv(gl.getUniformLocation(program, name), v); +}; + +export const setUniform1i = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: number): void => { + gl.uniform1i(gl.getUniformLocation(program, name), v); +}; + +export const disposeQuad = (gl: WebGL2RenderingContext): void => { + const res = quadMap.get(gl); + if (res) { + gl.deleteVertexArray(res.vao); + gl.deleteBuffer(res.vbo); + quadMap.delete(gl); + } +}; + +export const disposePingPong = (gl: WebGL2RenderingContext, pp: PingPongBuffers): void => { + for (const fbo of pp.fbos) gl.deleteFramebuffer(fbo); + for (const tex of pp.textures) gl.deleteTexture(tex); +}; + +export const hexToRGB = (hex: string): [number, number, number] => { + const c = hex.replace("#", ""); + const r = parseInt(c.substring(0, 2), 16) / 255; + const g = parseInt(c.substring(2, 4), 16) / 255; + const b = parseInt(c.substring(4, 6), 16) / 255; + return [r, g, b]; +}; diff --git a/plugins/radiant-lyrics-luna/src/Settings.tsx b/plugins/radiant-lyrics-luna/src/Settings.tsx index ac33033..88bd7e5 100644 --- a/plugins/radiant-lyrics-luna/src/Settings.tsx +++ b/plugins/radiant-lyrics-luna/src/Settings.tsx @@ -12,6 +12,7 @@ declare global { updateRadiantLyricsGlobalBackground?: () => void; updateRadiantLyricsNowPlayingBackground?: () => void; updateQualityProgressColor?: () => void; + updateIntegratedSeekBar?: () => void; updateLyricsStyle?: () => void; updateLyricsStyleSetting?: (value: number) => void; updateRomanizeLyrics?: () => void; @@ -21,19 +22,32 @@ declare global { export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", { lyricsGlowEnabled: true, + textGlow: 20, + lyricsStyle: 2, + lyricsFontSize: 100, + blurInactive: true, + contextAwareLyrics: true, + bubbledLyrics: true, + romanizeLyrics: false, + stickyLyrics: false, + syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon) + syllableLogging: false, hideUIEnabled: true, playerBarVisible: false, qualityProgressColor: true, + integratedSeekBar: true, floatingPlayerBar: true, + playerBarRadius: 5, + playerBarSpacing: 10, + playerBarBlur: true, + playerBarBlurAmount: 15, + playerBarTintEnabled: true, playerBarTint: 5, playerBarTintColor: "#000000" as string, playerBarTintCustomColors: [] as string[], - playerBarRadius: 5, - playerBarSpacing: 10, CoverEverywhere: true, performanceMode: false, spinningArt: true, - textGlow: 20, backgroundScale: 15, backgroundRadius: 25, backgroundContrast: 120, @@ -41,16 +55,6 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", { backgroundBrightness: 40, spinSpeed: 45, settingsAffectNowPlaying: true, - stickyLyrics: false, - stickyLyricsIcon: "sparkle" as string, - lyricsStyle: 2, - syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon) - contextAwareLyrics: true, - blurInactive: true, - bubbledLyrics: true, - syllableLogging: false, - lyricsFontSize: 100, - romanizeLyrics: false, }); export const Settings = () => { @@ -92,12 +96,21 @@ export const Settings = () => { const [floatingPlayerBar, setFloatingPlayerBar] = React.useState( settings.floatingPlayerBar, ); + const [playerBarTintEnabled, setPlayerBarTintEnabled] = React.useState( + settings.playerBarTintEnabled, + ); const [playerBarTint, setPlayerBarTint] = React.useState( settings.playerBarTint, ); const [playerBarTintColor, setPlayerBarTintColor] = React.useState( settings.playerBarTintColor, ); + const [playerBarBlur, setPlayerBarBlur] = React.useState( + settings.playerBarBlur, + ); + const [playerBarBlurAmount, setPlayerBarBlurAmount] = React.useState( + settings.playerBarBlurAmount, + ); const [playerBarRadius, setPlayerBarRadius] = React.useState( settings.playerBarRadius, ); @@ -145,6 +158,9 @@ export const Settings = () => { const [qualityProgressColor, setQualityProgressColor] = React.useState( settings.qualityProgressColor, ); + const [integratedSeekBar, setIntegratedSeekBar] = React.useState( + settings.integratedSeekBar, + ); const [romanizeLyrics, setRomanizeLyrics] = React.useState( settings.romanizeLyrics, ); @@ -328,6 +344,18 @@ export const Settings = () => { } }} /> + { + settings.integratedSeekBar = checked; + setIntegratedSeekBar(checked); + if (window.updateIntegratedSeekBar) { + window.updateIntegratedSeekBar(); + } + }} + /> { /> )} + { + settings.playerBarBlur = checked; + setPlayerBarBlur(checked); + window.updateRadiantLyricsPlayerBarTint?.(); + }} + /> + {playerBarBlur && ( + { + settings.playerBarBlurAmount = value; + setPlayerBarBlurAmount(value); + window.updateRadiantLyricsPlayerBarTint?.(); + }} + /> + )} {(() => { const closeTintColorPicker = () => { setIsTintAnimatingIn(false); @@ -537,25 +590,109 @@ export const Settings = () => { transition: "all 0.2s ease", }} > -
- Choose Tint Color -
+
+ Choose Tint Color +
-
+ + Enable Player Bar Tint + + +
+ +
{allTintColors.map((color, index) => { const isCustomColor = tintCustomColors.includes(color); const isHovered = tintHoveredColorIndex === index; @@ -626,16 +763,24 @@ export const Settings = () => { })}
-
-
- Add Custom Color -
+
+
+ Add Custom Color +
{ }; }; +/** CSS var for integrated seekbar top corners — matches Floating Bar Corner Radius when floating is on */ +const applyIntegratedSeekbarChrome = (): void => { + const footer = document.querySelector( + '[data-test="footer-player"]', + ) as HTMLElement | null; + if (!footer) return; + if (!settings.integratedSeekBar) { + footer.style.removeProperty("--rl-integrated-seekbar-top-radius"); + return; + } + const topR = settings.floatingPlayerBar ? settings.playerBarRadius : 0; + footer.style.setProperty("--rl-integrated-seekbar-top-radius", `${topR}px`); +}; + // Apply inline styles to the player bar (tint + optional radius/spacing customisation) const applyPlayerBarTintToElement = (): void => { const footerPlayer = document.querySelector( '[data-test="footer-player"]', ) as HTMLElement; if (!footerPlayer) return; - const alpha = settings.playerBarTint / 10; - const { r, g, b } = hexToRgb(settings.playerBarTintColor); - footerPlayer.style.setProperty( - "background-color", - `rgba(${r}, ${g}, ${b}, ${alpha})`, - "important", - ); + if (settings.playerBarTintEnabled) { + const alpha = settings.playerBarTint / 10; + const { r, g, b } = hexToRgb(settings.playerBarTintColor); + footerPlayer.style.removeProperty("background"); + footerPlayer.style.setProperty( + "background-color", + `rgba(${r}, ${g}, ${b}, ${alpha})`, + "important", + ); + } else { + footerPlayer.style.removeProperty("background-color"); + footerPlayer.style.setProperty( + "background", + "linear-gradient(rgba(60,60,60,0.35) 0%, rgba(60,60,60,0.35) 27%, rgba(61,61,61,0.35) 35%, rgba(62,62,62,0.35) 43.5%, rgba(63,63,63,0.35) 53%, rgba(65,65,65,0.35) 66%, rgba(67,67,67,0.35) 81%, rgba(70,70,70,0.35) 100%)", + "important", + ); + } + if (settings.playerBarBlur) { + footerPlayer.style.setProperty( + "backdrop-filter", + `blur(${settings.playerBarBlurAmount}px)`, + "important", + ); + footerPlayer.style.setProperty( + "-webkit-backdrop-filter", + `blur(${settings.playerBarBlurAmount}px)`, + "important", + ); + } else { + footerPlayer.style.setProperty("backdrop-filter", "none", "important"); + footerPlayer.style.setProperty("-webkit-backdrop-filter", "none", "important"); + } if (settings.floatingPlayerBar) { footerPlayer.style.setProperty( "border-radius", @@ -151,6 +190,7 @@ const applyPlayerBarTintToElement = (): void => { footerPlayer.style.removeProperty("left"); footerPlayer.style.removeProperty("width"); } + applyIntegratedSeekbarChrome(); }; // When floating is disabled, inject square-bar CSS to override Tidal's native floating styles @@ -214,6 +254,182 @@ if (settings.qualityProgressColor) { applyQualityProgressColor(); } +// MARKER: Integrated Seek Bar +// Moves the seekbar to the top border of the player bar | Inspired by Tokyo Tidal-HiFi theme (ages ago) + +let integratedSeekbarIntervalId: ReturnType | null = null; + +const clearIntegratedSeekbarInterval = (): void => { + if (integratedSeekbarIntervalId !== null) { + clearInterval(integratedSeekbarIntervalId); + integratedSeekbarIntervalId = null; + } +}; + +// Restore Original DOM Structure (cleanup) +const unwrapIntegratedSeekbarTimes = (footerPlayer: HTMLElement): void => { + const wrap = footerPlayer.querySelector(".rl-seekbar-times-wrap"); + if (!wrap?.parentElement) return; + const row = wrap.parentElement; + const bar = + (row.querySelector(".rl-seekbar-bar") as HTMLElement | null) ?? + (row.querySelector('[data-test="progress-bar"]')?.parentElement as + | HTMLElement + | null); + const p1 = wrap.querySelector('[data-test="current-time"]') + ?.parentElement as HTMLElement | undefined; + const p2 = wrap.querySelector('[data-test="duration"]') + ?.parentElement as HTMLElement | undefined; + wrap.querySelector(".rl-seekbar-time-sep")?.remove(); + if (p1 && p2 && bar) { + row.insertBefore(p1, wrap); + row.insertBefore(p2, bar.nextSibling); + } else if (p1 && p2) { + row.insertBefore(p1, wrap); + row.appendChild(p2); + } + wrap.remove(); +}; + +const syncIntegratedSeekbarCombinedTime = (footerPlayer: HTMLElement): void => { + const el = footerPlayer.querySelector( + ".rl-seekbar-combined-time", + ) as HTMLElement | null; + if (!el) return; + const row = footerPlayer.querySelector(".rl-seekbar-container"); + if (!row) return; + const cur = + row.querySelector('[data-test="current-time"]')?.textContent?.trim() ?? ""; + const dur = + row.querySelector('[data-test="duration"]')?.textContent?.trim() ?? ""; + el.textContent = `${cur} | ${dur}`; +}; + +// Finds the Seekbar Row +const getScrubberRowFromFooter = ( + footerPlayer: HTMLElement, +): HTMLElement | null => { + const pb = footerPlayer.querySelector('[data-test="progress-bar"]'); + const bar = pb?.parentElement; + return bar?.parentElement ?? null; +}; + +const clearNativeScrubberTimeDisplay = (footerPlayer: HTMLElement): void => { + const row = getScrubberRowFromFooter(footerPlayer); + if (!row) return; + for (const p of row.querySelectorAll("p")) { + if ( + p.querySelector('[data-test="current-time"]') || + p.querySelector('[data-test="duration"]') + ) { + p.style.removeProperty("display"); + } + } +}; + +const cleanupIntegratedSeekbarDisplay = (footerPlayer: HTMLElement): void => { + clearIntegratedSeekbarInterval(); + clearNativeScrubberTimeDisplay(footerPlayer); + unwrapIntegratedSeekbarTimes(footerPlayer); + footerPlayer.querySelectorAll(".rl-seekbar-combined-time").forEach((n) => { + n.remove(); + }); + for (const el of footerPlayer.querySelectorAll(".rl-seekbar-native-time")) { + el.classList.remove("rl-seekbar-native-time"); + } + footerPlayer.style.removeProperty("--rl-integrated-seekbar-top-radius"); +}; + +const applyIntegratedSeekBar = (): void => { + const footerPlayer = document.querySelector( + '[data-test="footer-player"]', + ) as HTMLElement | null; + if (!footerPlayer) return; + + clearIntegratedSeekbarInterval(); + + // Cleanup + for (const cls of ["rl-seekbar-container", "rl-seekbar-bar", "rl-seekbar-native-time"]) { + footerPlayer.querySelectorAll(`.${cls}`).forEach((el) => { + el.classList.remove(cls); + }); + } + + if (!settings.integratedSeekBar) { + document.body.classList.remove("rl-integrated-seekbar"); + cleanupIntegratedSeekbarDisplay(footerPlayer); + return; + } + + document.body.classList.add("rl-integrated-seekbar"); + applyIntegratedSeekbarChrome(); + + const progressBar = footerPlayer.querySelector( + '[data-test="progress-bar"]', + ) as HTMLElement | null; + if (!progressBar) return; + + const scrubberBar = progressBar.parentElement as HTMLElement | null; + const scrubberRow = scrubberBar?.parentElement as HTMLElement | null; + if (!scrubberRow) return; + + scrubberRow.classList.add("rl-seekbar-container"); + + // Mark the Seekbar (so it's moved in a sec) + if (scrubberBar) { + scrubberBar.classList.add("rl-seekbar-bar"); + } + + const currentTime = scrubberRow.querySelector( + '[data-test="current-time"]', + ) as HTMLElement | null; + const duration = scrubberRow.querySelector( + '[data-test="duration"]', + ) as HTMLElement | null; + const p1 = currentTime?.parentElement as HTMLElement | null; + const p2 = duration?.parentElement as HTMLElement | null; + if (!p1 || !p2) return; + + unwrapIntegratedSeekbarTimes(footerPlayer); + + p1.classList.add("rl-seekbar-native-time"); + p2.classList.add("rl-seekbar-native-time"); + p1.style.setProperty("display", "none", "important"); + p2.style.setProperty("display", "none", "important"); + + const combinedNodes = scrubberRow.querySelectorAll(".rl-seekbar-combined-time"); + for (let i = 1; i < combinedNodes.length; i++) { + combinedNodes[i]?.remove(); + } + let combined = scrubberRow.querySelector( + ".rl-seekbar-combined-time", + ) as HTMLElement | null; + if (!combined) { + combined = document.createElement("span"); + combined.className = "rl-seekbar-combined-time"; + combined.setAttribute("aria-live", "polite"); + scrubberRow.insertBefore(combined, p1); + } + + syncIntegratedSeekbarCombinedTime(footerPlayer); + integratedSeekbarIntervalId = setInterval(() => { + const fp = document.querySelector( + '[data-test="footer-player"]', + ) as HTMLElement | null; + if (!fp || !settings.integratedSeekBar) { + clearIntegratedSeekbarInterval(); + return; + } + syncIntegratedSeekbarCombinedTime(fp); + }, 250); +}; + +// Apply on load +applyIntegratedSeekBar(); +observe(unloads, '[data-test="footer-player"]', () => { + applyIntegratedSeekBar(); +}); + // Apply base styles always (I kinda dont really remember what this does but it's important i guess) baseStyleTag.css = baseStyles; @@ -923,6 +1139,7 @@ const updateRadiantLyricsNowPlayingBackground = function (): void { (window as any).updateRadiantLyricsPlayerBarTint = updateRadiantLyricsPlayerBarTint; (window as any).updateQualityProgressColor = applyQualityProgressColor; +(window as any).updateIntegratedSeekBar = applyIntegratedSeekBar; const cleanUpDynamicArt = function (): void { // Clean up cached Now Playing elements @@ -1003,6 +1220,23 @@ unloads.add(() => { footerPlayer.style.removeProperty("bottom"); footerPlayer.style.removeProperty("left"); footerPlayer.style.removeProperty("width"); + footerPlayer.style.removeProperty("--rl-integrated-seekbar-top-radius"); + } + + // Clean up integrated seekbar + document.body.classList.remove("rl-integrated-seekbar"); + clearIntegratedSeekbarInterval(); + document + .querySelectorAll(".rl-seekbar-container, .rl-seekbar-bar, .rl-seekbar-native-time") + .forEach((el) => { + el.classList.remove( + "rl-seekbar-container", + "rl-seekbar-bar", + "rl-seekbar-native-time", + ); + }); + if (footerPlayer) { + cleanupIntegratedSeekbarDisplay(footerPlayer); } // Clean up action buttons @@ -1030,45 +1264,18 @@ unloads.add(() => { // MARKER: Sticky Lyrics Feature -const STICKY_ICONS: Record = { - chevron: - '', - sparkle: - '', -}; - -const getStickyIcon = (): string => - STICKY_ICONS[settings.stickyLyricsIcon] ?? STICKY_ICONS.chevron; +const STICKY_ICON = + ''; const applyStickyIcon = (): void => { const trigger = document.querySelector( ".sticky-lyrics-trigger", ) as HTMLElement; if (!trigger) return; - trigger.innerHTML = getStickyIcon(); + trigger.innerHTML = STICKY_ICON; trigger.style.paddingLeft = "5px"; }; -// Console: StickyLyrics.icon = "sparkle" or "chevron" -// I'm picky and prefer the Sparkle.. shhh -(window as any).StickyLyrics = { - get icon() { - return settings.stickyLyricsIcon; - }, - set icon(value: string) { - const key = value.toLowerCase(); - if (!STICKY_ICONS[key]) { - console.log( - `[Radiant Lyrics] Unknown icon "${value}". Available: ${Object.keys(STICKY_ICONS).join(", ")}`, - ); - return; - } - settings.stickyLyricsIcon = key; - applyStickyIcon(); - console.log(`[Radiant Lyrics] Sticky Lyrics icon set to "${key}"`); - }, -}; - // Console: Syllables.log = true/false // Verbose logging for word/syllable lyrics (hidden setting) const sylLog = (...args: unknown[]) => { @@ -1251,7 +1458,7 @@ const createStickyLyricsDropdown = (): void => { const trigger = document.createElement("div"); trigger.className = "sticky-lyrics-trigger"; trigger.setAttribute("title", "Sticky Lyrics"); - trigger.innerHTML = getStickyIcon(); + trigger.innerHTML = STICKY_ICON; for (const evtName of [ "pointerdown", diff --git a/plugins/radiant-lyrics-luna/src/styles.css b/plugins/radiant-lyrics-luna/src/styles.css index caaad0b..3f7aa90 100644 --- a/plugins/radiant-lyrics-luna/src/styles.css +++ b/plugins/radiant-lyrics-luna/src/styles.css @@ -239,6 +239,90 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] { } +/* MARKER: Integrated Seek Bar */ +/* Moves the seekbar to the top border of the player bar (inspired by Amethyst) */ + +/* Scrubber row stays in flow — centers the time block as one unit */ +body.rl-integrated-seekbar .rl-seekbar-container { + justify-content: center !important; + align-items: center !important; + gap: 0 !important; +} + +/* Single string: "current | duration" — synced from native