Merge pull request #37 from meowarex/dev

WIP | Audio Visualizer Plugin
This commit is contained in:
Meow Meow
2025-06-11 03:09:12 +10:00
committed by GitHub
5 changed files with 963 additions and 0 deletions
+13
View File
@@ -34,6 +34,19 @@ Allows users to copy song lyrics by selecting them directly in the interface.
- Automatic clipboard copying of selected lyrics - Automatic clipboard copying of selected lyrics
- Smart lyric span detection - Smart lyric span detection
### 🎶 Audio Visualizer
**Location:** `plugins/audio-visualizer-luna/`
⚠️ **Work in Progress** - Audio visualization plugin that displays real-time audio frequency data.
**Features:**
- Real-time audio frequency visualization with bars
- Animated effect when no audio is detected
- Configurable options.. like allot of em
- Theme frienly (Wont clash with your themes style)
**Note:** This plugin is currently in development and may have stability issues.
## Installation ## Installation
### Installing from URL ### Installing from URL
@@ -0,0 +1,11 @@
{
"name": "@meowarex/audio-visualizer",
"description": "VERY BROKEY... PLEASE DONT USE IF YOU LOVE YOURSELF",
"author": {
"name": "meowarex",
"url": "https://github.com/meowarex",
"avatarUrl": "https://avatars.githubusercontent.com/u/90243579"
},
"main": "./src/index.ts",
"type": "module"
}
@@ -0,0 +1,355 @@
import { ReactiveStore } from "@luna/core";
import { LunaSettings, LunaNumberSetting, LunaSwitchSetting, LunaTextSetting } from "@luna/ui";
import React from "react";
export const settings = await ReactiveStore.getPluginStorage("AudioVisualizer", {
barCount: 32,
barColor: "#ffffff",
barRounding: true,
customColors: [] as string[]
});
export const Settings = () => {
const [barCount, setBarCount] = React.useState(settings.barCount);
const [barColor, setBarColor] = React.useState(settings.barColor);
const [barRounding, setBarRounding] = React.useState(settings.barRounding);
const [showColorPicker, setShowColorPicker] = React.useState(false);
const [isAnimatingIn, setIsAnimatingIn] = React.useState(false);
const [shouldRender, setShouldRender] = 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 closeColorPicker = () => {
setIsAnimatingIn(false);
setTimeout(() => {
setShowColorPicker(false);
setShouldRender(false);
}, 200); // Wait for animation to complete because i need to
};
const openColorPicker = () => {
setShowColorPicker(true);
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
};
React.useEffect(() => {
if (showColorPicker) {
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
}
}, [showColorPicker]);
// Common color presets for cool points :D
const colorPresets = [
"#ffffff", "#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
"#ff8800", "#8800ff", "#0088ff", "#88ff00", "#ff0088", "#00ff88",
"#444444", "#888888", "#cccccc", "#1db954", "#e22134", "#1976d2"
];
const updateColor = (color: string) => {
setBarColor(color);
setCustomInput(color);
settings.barColor = color;
(window as any).updateAudioVisualizer?.();
};
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 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 allColors = [...colorPresets, ...customColors];
return (
<LunaSettings>
<LunaSwitchSetting
title="Bar Roundness"
desc="Enable rounded corners on visualizer bars"
checked={barRounding}
onChange={(_, checked) => {
setBarRounding(checked);
settings.barRounding = checked;
(window as any).updateAudioVisualizer?.();
}}
/>
<LunaNumberSetting
title="Bar Count"
desc="Number of frequency bars to display"
min={8}
max={64}
step={1}
value={barCount}
onNumber={(value: number) => {
setBarCount(value);
settings.barCount = value;
(window as any).updateAudioVisualizer?.();
}}
/>
{/* 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 */}
<div style={{
padding: "16px 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}>
<div>
<div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: "4px" }}>Bar Color</div>
<div style={{ opacity: 0.7, fontSize: "14px" }}>Color of the visualizer bars</div>
</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center", position: "relative" }}>
<button
onClick={() => showColorPicker ? closeColorPicker() : openColorPicker()}
style={{
width: "32px",
height: "32px",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "6px",
cursor: "pointer",
background: barColor,
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
position: "relative",
overflow: "hidden"
}}
>
<div style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.1)",
backdropFilter: "blur(2px)"
}} />
</button>
{/* Custom Color Picker Modal */}
{shouldRender && (
<>
{/* Backdrop */}
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.6)",
zIndex: 1000,
opacity: isAnimatingIn ? 1 : 0,
transition: "opacity 0.2s ease"
}}
onClick={closeColorPicker}
/>
{/* Color Picker Panel */}
<div style={{
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",
minWidth: "320px",
maxWidth: "90vw",
maxHeight: "90vh",
zIndex: 1001,
boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
opacity: isAnimatingIn ? 1 : 0,
transform: isAnimatingIn ? "translate(-50%, -50%) scale(1)" : "translate(-50%, -50%) scale(0.9)",
transition: "all 0.2s ease"
}}>
<div style={{ marginBottom: "12px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>
Choose Color
</div>
{/* Color Grid */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "8px",
marginBottom: "16px"
}}>
{allColors.map((color, index) => {
const isCustomColor = customColors.includes(color);
const isHovered = hoveredColorIndex === index;
return (
<div
key={index}
style={{
position: "relative",
width: "32px",
height: "32px",
cursor: "pointer"
}}
className="color-item"
onMouseEnter={() => setHoveredColorIndex(index)}
onMouseLeave={() => setHoveredColorIndex(null)}
>
<button
onClick={() => {
updateColor(color);
closeColorPicker();
}}
style={{
width: "100%",
height: "100%",
borderRadius: "6px",
border: barColor === color ? "2px solid #fff" : "1px solid rgba(255,255,255,0.2)",
background: color,
cursor: "pointer",
transition: "all 0.2s ease"
}}
/>
{isCustomColor && (
<button
onClick={(e) => {
e.stopPropagation();
removeCustomColor(color);
}}
style={{
position: "absolute",
top: "-4px",
right: "-4px",
width: "16px",
height: "16px",
borderRadius: "50%",
border: "1px solid rgba(255,255,255,0.8)",
background: "rgba(0,0,0,0.8)",
color: "#fff",
cursor: "pointer",
fontSize: "10px",
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: isHovered ? 1 : 0,
transition: "opacity 0.2s ease",
zIndex: 10
}}
className="remove-button"
>
×
</button>
)}
</div>
);
})}
</div>
{/* Custom Hex Input */}
<div style={{ marginBottom: "12px" }}>
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "12px", marginBottom: "6px" }}>
Add Custom Color
</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<input
type="text"
value={customInput}
onChange={(e) => 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"
}}
/>
<button
onClick={() => {
updateColor(customInput);
addCustomColor();
}}
style={{
width: "32px",
height: "32px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.3)",
background: "rgba(255,255,255,0.15)",
color: "#fff",
cursor: "pointer",
fontSize: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease"
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)";
}}
>
+
</button>
</div>
</div>
{/* Close Button (Done) - Also runs when color chosen*/}
<button
onClick={closeColorPicker}
style={{
width: "100%",
padding: "8px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
cursor: "pointer",
fontSize: "12px"
}}
>
Done
</button>
</div>
</>
)}
</div>
</div>
</LunaSettings>
);
};
+528
View File
@@ -0,0 +1,528 @@
import { LunaUnload, Tracer } from "@luna/core";
import { StyleTag, PlayState } from "@luna/lib";
import { settings, Settings } from "./Settings";
// Import CSS styles for the visualizer
import visualizerStyles from "file://styles.css?minify";
export const { trace } = Tracer("[Audio Visualizer]");
// Helper function for consistent logging
const log = (message: string) => console.log(`[Audio Visualizer] ${message}`);
const warn = (message: string) => console.warn(`[Audio Visualizer] ${message}`);
const error = (message: string) => console.error(`[Audio Visualizer] ${message}`);
export { Settings };
// Basic config with settings
const config = {
enabled: true,
position: 'left' as 'left' | 'right',
width: 200,
height: 40,
get barCount() { return settings.barCount; },
get color() { return settings.barColor; },
get barRounding() { return settings.barRounding; },
sensitivity: 1.5,
smoothing: 0.8,
visualizerType: 'bars' as 'bars' | 'waveform' | 'circular'
};
// Clean up resources
export const unloads = new Set<LunaUnload>();
// StyleTag for CSS
const styleTag = new StyleTag("AudioVisualizer", unloads, visualizerStyles);
// Audio context and analyzer
let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let audioSource: MediaElementAudioSourceNode | null = null;
let dataArray: Uint8Array | null = null;
let animationId: number | null = null;
let currentAudioElement: HTMLAudioElement | null = null;
let isSourceConnected: boolean = false;
// Canvas and container elements
let visualizerContainer: HTMLDivElement | null = null;
let canvas: HTMLCanvasElement | null = null;
let canvasContext: CanvasRenderingContext2D | null = null;
// 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 element = document.querySelector(selector) as HTMLAudioElement;
if (element && (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 {
// Find the audio element
const audioElement = findAudioElement();
if (!audioElement) {
return;
}
// create audio context
if (!audioContext) {
audioContext = new AudioContext();
log("Created AudioContext");
}
// create analyser
if (!analyser) {
analyser = audioContext.createAnalyser();
analyser.fftSize = 512; // Fixed power of 2 that provides enough frequency bins
analyser.smoothingTimeConstant = config.smoothing;
dataArray = new Uint8Array(analyser.frequencyBinCount);
log("Created AnalyserNode");
}
// attempt audio connection if not already connected
if (!isSourceConnected && audioElement !== currentAudioElement) {
try {
// Create audio source - this might fail if already connected elsewhere
audioSource = audioContext.createMediaElementSource(audioElement);
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') {
audioContext.resume().catch(() => {}); // Fire and forget
}
// Create UI only if it doesn't exist
if (!visualizerContainer) {
createVisualizerUI();
}
// Start animation only if not already running
if (!animationId) {
animate();
}
} catch (err) {
// log errors
console.error(err);
}
};
// Create the visualizer UI container and canvas
const createVisualizerUI = (): void => {
// Remove existing visualizer if it exists
removeVisualizerUI();
if (!config.enabled) return;
// Find the search bar
const searchField = document.querySelector('input[class*="_searchField"]') as HTMLInputElement;
if (!searchField) {
warn("Search field not found");
return;
}
const searchContainer = searchField.parentElement;
if (!searchContainer) {
warn("Search container not found");
return;
}
// Create visualizer container
visualizerContainer = document.createElement('div');
visualizerContainer.id = 'audio-visualizer-container';
visualizerContainer.style.cssText = `
display: flex;
align-items: center;
justify-content: center;
margin-${config.position === 'left' ? 'right' : 'left'}: 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 4px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
`;
// Create canvas
canvas = document.createElement('canvas');
canvas.width = config.width;
canvas.height = config.height;
canvas.style.cssText = `
width: ${config.width}px;
height: ${config.height}px;
border-radius: 4px;
`;
visualizerContainer.appendChild(canvas);
canvasContext = canvas.getContext('2d');
// Insert visualizer next to search bar
if (config.position === 'left') {
searchContainer.parentElement?.insertBefore(visualizerContainer, searchContainer);
} else {
searchContainer.parentElement?.insertBefore(visualizerContainer, searchContainer.nextSibling);
}
};
// Remove visualizer UI
const removeVisualizerUI = (): void => {
if (visualizerContainer) {
visualizerContainer.remove();
visualizerContainer = null;
canvas = null;
canvasContext = null;
}
};
// Animation loop for rendering visualizer
const animate = (): void => {
if (!canvasContext || !canvas) {
animationId = null;
return;
}
// Update canvas color in case it changed
canvasContext.fillStyle = config.color;
canvasContext.strokeStyle = config.color;
// Check if we have real audio data - this might not be needed but its a good idea
let hasRealAudio = false;
if (analyser && dataArray) {
analyser.getByteFrequencyData(dataArray);
// Check if there's actual audio signal (not just silence)
const avgVolume = dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length;
hasRealAudio = avgVolume > 5; // Threshold for detecting actual audio
}
// Clear canvas
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
if (hasRealAudio && analyser && dataArray) {
// Draw real audio visualization
switch (config.visualizerType) {
case 'bars': // Is implemented YAYYY (default)
drawBars();
break;
case 'waveform': // Not implemented yet
drawWaveform();
break;
case 'circular': // Not implemented yet
drawCircular();
break;
}
} else {
// Draw cool scrolling wave effect when no audio
drawScrollingWave();
}
animationId = requestAnimationFrame(animate);
};
// Global wave animation state
let waveTime = 0;
// Helper function to draw rounded rectangles
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();
};
// Draw scrolling wave effect when no audio is detected
const drawScrollingWave = (): void => {
if (!canvasContext || !canvas) return;
waveTime += 0.05; // Speed of wave animation
const barCount = config.barCount;
const barWidth = canvas.width / barCount;
const maxHeight = canvas.height * 0.6;
canvasContext.fillStyle = config.color;
for (let i = 0; i < barCount; i++) {
// Create a sine wave that scrolls back and forth
const x = i / barCount;
const wave1 = Math.sin(x * Math.PI * 2 + waveTime) * 0.3;
const wave2 = Math.sin(x * Math.PI * 4 + waveTime * 1.3) * 0.2;
const wave3 = Math.sin(x * Math.PI * 6 + waveTime * 0.7) * 0.1;
// Combine waves for complex pattern
const combinedWave = (wave1 + wave2 + wave3 + 1) / 2; // Normalize to 0-1
// Add a traveling wave effect
const travelWave = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5;
// Final height calculation
const barHeight = maxHeight * combinedWave * travelWave * 0.8 + 2; // Minimum height of 2px
const xPos = i * barWidth;
const yPos = (canvas.height - barHeight) / 2;
// Draw rounded or square bars based on setting
if (config.barRounding) {
drawRoundedRect(canvasContext, xPos, yPos, barWidth - 1, barHeight, 2);
} else {
canvasContext.fillRect(xPos, yPos, barWidth - 1, barHeight);
}
}
};
// Draw frequency bars - default
const drawBars = (): void => {
if (!canvasContext || !dataArray || !canvas) return;
const barWidth = canvas.width / config.barCount;
const heightScale = canvas.height / 255;
canvasContext.fillStyle = config.color;
for (let i = 0; i < config.barCount; i++) {
const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
const barHeight = (dataArray[dataIndex] * config.sensitivity * heightScale);
const x = i * barWidth;
const y = canvas.height - barHeight;
// Draw rounded or square bars based on setting
if (config.barRounding) {
drawRoundedRect(canvasContext, x, y, barWidth - 1, barHeight, 2);
} else {
canvasContext.fillRect(x, y, barWidth - 1, barHeight);
}
}
};
// Draw waveform visualization - NOT IMPLEMENTED YET
// const drawWaveform = (): void => {
// if (!canvasContext || !dataArray || !canvas) return;
// const centerY = canvas.height / 2;
// const amplitudeScale = canvas.height / 512;
// canvasContext.strokeStyle = config.color;
// canvasContext.lineWidth = 2;
// canvasContext.beginPath();
// for (let i = 0; i < config.barCount; i++) {
// const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
// const amplitude = (dataArray[dataIndex] - 128) * config.sensitivity * amplitudeScale;
// const x = (i / config.barCount) * canvas.width;
// 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();
// }
// };
// Update visualizer settings
const updateAudioVisualizer = (): void => {
if (analyser) {
// use a fixed size that provides enough frequency bins
analyser.fftSize = 512; // Fixed power of 2 - important
analyser.smoothingTimeConstant = config.smoothing;
dataArray = new Uint8Array(analyser.frequencyBinCount);
}
if (canvas) {
canvas.width = config.width;
canvas.height = config.height;
canvas.style.width = `${config.width}px`;
canvas.style.height = `${config.height}px`;
}
// Recreate UI if position changed
createVisualizerUI();
};
// 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 {
hasTriedInitialization = false; // Allow retry if failed
}
});
} 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
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
const initialize = (): void => {
log("Audio Visualizer plugin initializing...");
// Start immediately - DOM should be ready by plugin load
setTimeout(() => {
log("Starting visualizer...");
// 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
const completeCleanup = (): void => {
log("Complete cleanup - plugin unloading");
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
removeVisualizerUI();
// Fully disconnect and reset everything
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') {
audioContext.close();
log("Closed AudioContext");
}
// Reset all references
audioContext = null;
analyser = null;
audioSource = null;
dataArray = null;
currentAudioElement = null;
isSourceConnected = false;
};
// Register cleanup
unloads.add(completeCleanup);
// Start initialization
initialize();
@@ -0,0 +1,56 @@
/* Audio Visualizer CSS - Only applies to the Visualizer */
#audio-visualizer-container {
transition: all 0.3s ease-in-out;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
#audio-visualizer-container:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
#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;
}
}
/* Where to put the thingy */
[class*="_searchField"] {
transition: all 0.3s ease-in-out;
}
/* Shadow when active - doesnt seem to only apply when active but thats better */
#audio-visualizer-container.active {
box-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
}
/* Fade in animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
#audio-visualizer-container {
animation: fadeIn 0.5s ease-out;
}