Improve Audio Visualizer

This commit is contained in:
2026-04-03 17:07:35 +11:00
parent 548e4bcaf0
commit 59af461ea1
2 changed files with 91 additions and 322 deletions
@@ -3,7 +3,6 @@ import {
LunaSettings, LunaSettings,
LunaNumberSetting, LunaNumberSetting,
LunaSwitchSetting, LunaSwitchSetting,
LunaTextSetting,
} from "@luna/ui"; } from "@luna/ui";
import React from "react"; import React from "react";
@@ -78,7 +77,6 @@ export const Settings = () => {
setBarColor(color); setBarColor(color);
setCustomInput(color); setCustomInput(color);
settings.barColor = color; settings.barColor = color;
(window as any).updateAudioVisualizer?.();
}; };
const addCustomColor = () => { const addCustomColor = () => {
@@ -125,7 +123,6 @@ export const Settings = () => {
onChange={(_, checked) => { onChange={(_, checked) => {
setBarRounding(checked); setBarRounding(checked);
settings.barRounding = checked; settings.barRounding = checked;
(window as any).updateAudioVisualizer?.();
}} }}
/> />
@@ -139,7 +136,6 @@ export const Settings = () => {
onNumber={(value: number) => { onNumber={(value: number) => {
setBarCount(value); setBarCount(value);
settings.barCount = value; settings.barCount = value;
(window as any).updateAudioVisualizer?.();
}} }}
/> />
+97 -324
View File
@@ -1,17 +1,15 @@
import { LunaUnload, Tracer } from "@luna/core"; import { type LunaUnload, Tracer } from "@luna/core";
import { StyleTag, PlayState } from "@luna/lib"; import { StyleTag, PlayState, MediaItem, observe } from "@luna/lib";
import { settings, Settings } from "./Settings"; import { settings, Settings } from "./Settings";
// Import CSS styles for the visualizer
import visualizerStyles from "file://styles.css?minify"; import visualizerStyles from "file://styles.css?minify";
export const { trace } = Tracer("[Audio Visualizer]"); export const { trace } = Tracer("[Audio Visualizer]");
const log = (message: string) => console.log(`[Audio Visualizer] ${message}`);
export { Settings }; export { Settings };
const log = (message: string) => console.log(`[Audio Visualizer] ${message}`);
const config = { const config = {
enabled: true,
width: 200, width: 200,
height: 40, height: 40,
get barCount() { get barCount() {
@@ -27,22 +25,19 @@ const config = {
smoothing: 0.8, smoothing: 0.8,
}; };
// Clean up resources
export const unloads = new Set<LunaUnload>(); export const unloads = new Set<LunaUnload>();
// StyleTag for CSS new StyleTag("AudioVisualizer", unloads, visualizerStyles);
const styleTag = new StyleTag("AudioVisualizer", unloads, visualizerStyles);
// Audio context and analyzer
let audioContext: AudioContext | null = null; let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null; let analyser: AnalyserNode | null = null;
let audioSource: MediaElementAudioSourceNode | null = null; let audioSource: MediaStreamAudioSourceNode | null = null;
let dataArray: Uint8Array | null = null; let dataArray: Uint8Array<ArrayBuffer> | null = null;
let animationId: number | null = null; let animationId: number | null = null;
let currentAudioElement: HTMLAudioElement | null = null; let isSourceConnected = false;
let isSourceConnected: boolean = false;
// Each placement gets its own container/canvas/context
interface VisualizerSlot { interface VisualizerSlot {
container: HTMLDivElement | null; container: HTMLDivElement | null;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
@@ -52,104 +47,48 @@ interface VisualizerSlot {
const navSlot: VisualizerSlot = { container: null, canvas: null, ctx: null }; const navSlot: VisualizerSlot = { container: null, canvas: null, ctx: null };
const npSlot: 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 => {
// Try main selectors first
const selectors = [
"audio",
"video",
"audio[data-test]",
'[data-test="audio-player"] audio',
];
for (const selector of selectors) { const connectAudio = (): boolean => {
const element = document.querySelector(selector) as HTMLAudioElement; const video = document.getElementById("video-one") as HTMLVideoElement | null;
if ( const capture = (video as unknown as { captureStream?: () => MediaStream })?.captureStream;
element && if (!video || typeof capture !== "function") return false;
(element.tagName === "AUDIO" || element.tagName === "VIDEO")
) {
return element;
}
}
// Quick scan for any audio elements
const audioElements = document.querySelectorAll("audio, video");
for (const element of audioElements) {
const audioEl = element as HTMLAudioElement;
if (audioEl.src || audioEl.currentSrc) {
return audioEl;
}
}
return null;
};
// Initialize audio visualization
const initializeAudioVisualizer = async (): Promise<void> => {
try { try {
// Find the audio element if (!audioContext) audioContext = new AudioContext();
const audioElement = findAudioElement();
if (!audioElement) {
return;
}
// create audio context
if (!audioContext) {
audioContext = new AudioContext();
log("Created AudioContext");
}
// create analyser
if (!analyser) { if (!analyser) {
analyser = audioContext.createAnalyser(); analyser = audioContext.createAnalyser();
analyser.fftSize = 512; // Fixed power of 2 that provides enough frequency bins analyser.fftSize = 512;
analyser.smoothingTimeConstant = config.smoothing; analyser.smoothingTimeConstant = config.smoothing;
dataArray = new Uint8Array(analyser.frequencyBinCount); dataArray = new Uint8Array(analyser.frequencyBinCount) as Uint8Array<ArrayBuffer>;
log("Created AnalyserNode");
} }
// attempt audio connection if not already connected audioSource?.disconnect();
if (!isSourceConnected && audioElement !== currentAudioElement) {
try { const stream = capture.call(video);
// Create audio source - this might fail if already connected elsewhere const audioTracks = stream.getAudioTracks();
audioSource = audioContext.createMediaElementSource(audioElement); if (audioTracks.length === 0) {
log("No audio tracks in captured stream");
return false;
}
audioSource = audioContext.createMediaStreamSource(stream);
audioSource.connect(analyser); audioSource.connect(analyser);
// CRITICAL: connect back to destination for audio output (otherwise no sound)
analyser.connect(audioContext.destination);
currentAudioElement = audioElement;
isSourceConnected = true;
log("Connected to audio stream with output");
} catch (error) {
// Audio is connected elsewhere - that's fine, we just can't visualize
if (
error instanceof Error &&
error.message.includes("already connected")
) {
log("Audio already connected elsewhere - skipping visualization");
}
return;
}
}
// Resume context only if needed and don't wait for it
// (otherwise it will wait for the audio to start playing)
if (audioContext.state === "suspended") { if (audioContext.state === "suspended") {
audioContext.resume().catch(() => {}); // Fire and forget audioContext.resume().catch(() => {});
} }
createVisualizerUI(); log("Connected via captureStream()");
return true;
// Start animation only if not already running
if (!animationId) {
animate();
}
} catch (err) { } catch (err) {
// log errors log(`Audio connection failed: ${err}`);
console.error(err); return false;
} }
}; };
// Canvas things
const makeSlotElements = (): { container: HTMLDivElement; canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null => { const makeSlotElements = (): { container: HTMLDivElement; canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null => {
const container = document.createElement("div"); const container = document.createElement("div");
container.className = "audio-visualizer-container"; container.className = "audio-visualizer-container";
@@ -186,89 +125,43 @@ const clearSlot = (slot: VisualizerSlot): void => {
slot.ctx = null; slot.ctx = null;
}; };
const ensureNavSlot = (): void => { // UI Placement with Luna Observer
const attachNavSlot = (anchor: Element): void => {
if (navSlot.container?.isConnected) return; if (navSlot.container?.isConnected) return;
clearSlot(navSlot); clearSlot(navSlot);
const searchField = document.querySelector('input[class*="_searchField"]') as HTMLInputElement; const parent = anchor.parentElement;
if (!searchField) return; if (!parent) return;
const searchContainer = searchField.parentElement;
if (!searchContainer?.parentElement) return;
const els = makeSlotElements(); const els = makeSlotElements();
if (!els) return; if (!els) return;
els.container.style.marginRight = "12px"; els.container.style.marginRight = "12px";
Object.assign(navSlot, els); Object.assign(navSlot, els);
searchContainer.parentElement.insertBefore(els.container, searchContainer); parent.insertBefore(els.container, anchor);
}; };
const ensureNpSlot = (): void => { const attachNpSlot = (anchor: Element): void => {
if (npSlot.container?.isConnected) return; if (npSlot.container?.isConnected) return;
clearSlot(npSlot); clearSlot(npSlot);
const artistInfo = document.querySelector('[data-test="artist-info"]'); const parent = anchor.parentElement;
if (!artistInfo) return; if (!parent) return;
const leftContent = artistInfo.parentElement;
if (!leftContent) return;
const els = makeSlotElements(); const els = makeSlotElements();
if (!els) return; if (!els) return;
els.container.style.marginLeft = "12px"; els.container.style.marginLeft = "12px";
Object.assign(npSlot, els); Object.assign(npSlot, els);
leftContent.insertBefore(els.container, artistInfo.nextSibling); parent.insertBefore(els.container, anchor.nextSibling);
}; };
const createVisualizerUI = (): void => { observe(unloads, '[data-test="search-popover-container"]', attachNavSlot);
if (!config.enabled) return; observe(unloads, '[data-test="artist-info"]', attachNpSlot);
ensureNavSlot();
ensureNpSlot();
};
const removeVisualizerUI = (): void => { // Rendering things
clearSlot(navSlot);
clearSlot(npSlot);
};
// Animation loop for rendering visualizer
const animate = (): void => {
// 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;
}
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 = 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) {
drawBars(ctx, cvs);
} else {
drawScrollingWave(ctx, cvs);
}
}
animationId = requestAnimationFrame(animate);
};
// Global wave animation state
let waveTime = 0; let waveTime = 0;
// Helper function to draw rounded rectangles
const drawRoundedRect = ( const drawRoundedRect = (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
x: number, x: number,
@@ -283,8 +176,6 @@ const drawRoundedRect = (
}; };
const drawScrollingWave = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => { const drawScrollingWave = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => {
waveTime += 0.05 / [navSlot, npSlot].filter(s => s.ctx).length;
const barCount = config.barCount; const barCount = config.barCount;
const barWidth = cvs.width / barCount; const barWidth = cvs.width / barCount;
const maxHeight = cvs.height * 0.6; const maxHeight = cvs.height * 0.6;
@@ -334,202 +225,84 @@ const drawBars = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void =
} }
}; };
// Draw waveform visualization - NOT IMPLEMENTED YET // Animation Loop
// const drawWaveform = (): void => {
// if (!canvasContext || !dataArray || !canvas) return;
// const centerY = canvas.height / 2; const animate = (): void => {
// const amplitudeScale = canvas.height / 512; const slots = [navSlot, npSlot].filter(s => s.ctx && s.canvas);
// canvasContext.strokeStyle = config.color; if (slots.length > 0) {
// canvasContext.lineWidth = 2; waveTime += 0.05;
// canvasContext.beginPath();
// for (let i = 0; i < config.barCount; i++) { let hasRealAudio = false;
// const dataIndex = Math.floor(i * (dataArray.length / config.barCount)); if (analyser && dataArray) {
// const amplitude = (dataArray[dataIndex] - 128) * config.sensitivity * amplitudeScale; analyser.getByteFrequencyData(dataArray);
const avgVolume = dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length;
// const x = (i / config.barCount) * canvas.width; hasRealAudio = avgVolume > 5;
// const y = centerY + amplitude;
// if (i === 0) {
// canvasContext.moveTo(x, y);
// } else {
// canvasContext.lineTo(x, y);
// }
// }
// canvasContext.stroke();
// };
// Draw circular visualization - NOT IMPLEMENTED YET
// const drawCircular = (): void => {
// if (!canvasContext || !dataArray || !canvas) return;
// const centerX = canvas.width / 2;
// const centerY = canvas.height / 2;
// const radius = Math.min(centerX, centerY) - 10;
// canvasContext.strokeStyle = config.color;
// canvasContext.lineWidth = 2;
// for (let i = 0; i < config.barCount; i++) {
// const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
// const amplitude = (dataArray[dataIndex] * config.sensitivity) / 255;
// const angle = (i / config.barCount) * Math.PI * 2;
// const startX = centerX + Math.cos(angle) * radius * 0.7;
// const startY = centerY + Math.sin(angle) * radius * 0.7;
// const endX = centerX + Math.cos(angle) * radius * (0.7 + amplitude * 0.3);
// const endY = centerY + Math.sin(angle) * radius * (0.7 + amplitude * 0.3);
// canvasContext.beginPath();
// canvasContext.moveTo(startX, startY);
// canvasContext.lineTo(endX, endY);
// canvasContext.stroke();
// }
// };
const updateAudioVisualizer = (): void => {
if (analyser) {
analyser.fftSize = 512;
analyser.smoothingTimeConstant = config.smoothing;
dataArray = new Uint8Array(analyser.frequencyBinCount);
} }
for (const slot of [navSlot, npSlot]) { for (const slot of slots) {
if (slot.canvas) { const { ctx, canvas: cvs } = slot;
slot.canvas.width = config.width; if (!ctx || !cvs) continue;
slot.canvas.height = config.height; ctx.clearRect(0, 0, cvs.width, cvs.height);
slot.canvas.style.width = `${config.width}px`;
slot.canvas.style.height = `${config.height}px`;
}
}
removeVisualizerUI(); if (hasRealAudio) {
createVisualizerUI(); drawBars(ctx, cvs);
};
// Make updateAudioVisualizer available globally for settings
(window as any).updateAudioVisualizer = updateAudioVisualizer;
// Clean up function
const cleanupAudioVisualizer = (): void => {
// stop animation and hide UI - don't touch audio connections (otherwise it will reconnect)
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
removeVisualizerUI();
// i was killing audio connections - But it was reconnecting and being a pain
// so i just left it alone - it works fine
};
// Initialize when DOM is ready and track is playing
const observePlayState = (): void => {
let hasTriedInitialization = false;
let checkCount = 0;
const checkAndInitialize = () => {
checkCount++;
// Only try to initialize once when music starts playing
if (PlayState.playing && !hasTriedInitialization) {
hasTriedInitialization = true;
log("Initializing audio visualizer...");
// Initialize immediately - no delay (after audio starts playing ofc)
initializeAudioVisualizer().then(() => {
if (audioContext && analyser) {
log("Audio visualizer ready!");
} else { } else {
hasTriedInitialization = false; // Allow retry if failed drawScrollingWave(ctx, cvs);
}
} }
});
} else if (!PlayState.playing && hasTriedInitialization) {
// Reset try flag when music stops so it can try again next time (otherwise it explode)
hasTriedInitialization = false;
} }
// Keep animation running regardless of play state animationId = requestAnimationFrame(animate);
if (!animationId) {
animate();
}
};
// Start with fast checking, then slow down
const fastInterval = setInterval(() => {
checkAndInitialize();
if (checkCount > 10) {
// After 10 quick checks, switch to slower
clearInterval(fastInterval);
const slowInterval = setInterval(checkAndInitialize, 2000);
unloads.add(() => clearInterval(slowInterval));
}
}, 200); // Check every 200ms initially
unloads.add(() => clearInterval(fastInterval));
// Immediate first check
checkAndInitialize();
}; };
// Initialize the plugin // Initialization (events)
const initialize = (): void => {
log("Audio Visualizer plugin initializing...");
// Start immediately - DOM should be ready by plugin load PlayState.onState(unloads, (state) => {
setTimeout(() => { if (state === "PLAYING" && !isSourceConnected) {
log("Starting visualizer..."); isSourceConnected = connectAudio();
// Create UI immediately so wave effect shows }
createVisualizerUI(); });
// Start animation loop immediately
animate();
// Also observe play state for audio detection
observePlayState();
}, 100); // Minimal delay to ensure DOM is ready
};
// Complete cleanup function for plugin unload MediaItem.onMediaTransition(unloads, () => {
const completeCleanup = (): void => { isSourceConnected = false;
log("Complete cleanup - plugin unloading"); if (PlayState.playing) {
isSourceConnected = connectAudio();
}
});
// Initialization (startup)
log("Initializing...");
if (PlayState.playing) {
isSourceConnected = connectAudio();
}
animationId = requestAnimationFrame(animate);
// Cleanup
unloads.add(() => {
log("Plugin unloading");
if (animationId) { if (animationId) {
cancelAnimationFrame(animationId); cancelAnimationFrame(animationId);
animationId = null; animationId = null;
} }
removeVisualizerUI(); clearSlot(navSlot);
clearSlot(npSlot);
// Fully disconnect and reset everything try { audioSource?.disconnect(); } catch {}
if (audioSource) {
try {
audioSource.disconnect();
log("Disconnected audio source completely");
} catch (e) {
log("Audio source already disconnected");
}
}
// Close audio context completely on plugin unload
if (audioContext && audioContext.state !== "closed") { if (audioContext && audioContext.state !== "closed") {
audioContext.close(); audioContext.close();
log("Closed AudioContext");
} }
// Reset all references
audioContext = null; audioContext = null;
analyser = null; analyser = null;
audioSource = null; audioSource = null;
dataArray = null; dataArray = null;
currentAudioElement = null;
isSourceConnected = false; isSourceConnected = false;
}; });
// Register cleanup
unloads.add(completeCleanup);
// Start initialization
initialize();