mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-18 03:43:10 +10:00
WIP | Audio Visualizer Plugin
This commit is contained in:
@@ -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,342 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustomColor = () => {
|
||||||
|
if (customInput && !colorPresets.includes(customInput) && !customColors.includes(customInput)) {
|
||||||
|
const newCustomColors = [...customColors, customInput];
|
||||||
|
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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
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;
|
||||||
|
if (dataArray) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user