mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-18 03:43:10 +10:00
Overhaul Audio Visualizer & RL UI Improvements
This commit is contained in:
@@ -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<string, VisualizerType>)[key] ?? "none";
|
||||
|
||||
const setSlot = (key: SlotKey, value: VisualizerType): void => {
|
||||
(settings as unknown as Record<string, VisualizerType>)[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<number | null>(null);
|
||||
|
||||
const [showSlotConfig, setShowSlotConfig] = React.useState(false);
|
||||
const [isSlotAnimIn, setIsSlotAnimIn] = React.useState(false);
|
||||
const [shouldRenderSlot, setShouldRenderSlot] = React.useState(false);
|
||||
const [activeZone, setActiveZone] = React.useState<ZoneId>("nowPlaying");
|
||||
const [slots, setSlots] = React.useState<Record<SlotKey, VisualizerType>>(() => {
|
||||
const vals = {} as Record<SlotKey, VisualizerType>;
|
||||
for (const key of ALL_SLOT_KEYS) vals[key] = getSlot(key);
|
||||
return vals;
|
||||
});
|
||||
const [miniSlots, setMiniSlots] = React.useState<Set<string>>(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<typeof LunaSwitchSetting>;
|
||||
type AnySwitchProps = Omit<BaseSwitchProps, "onChange"> & {
|
||||
onChange: (_: unknown, checked: boolean) => void;
|
||||
checked: boolean;
|
||||
};
|
||||
const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType<AnySwitchProps>;
|
||||
|
||||
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 (
|
||||
<LunaSettings>
|
||||
<LunaSwitchSetting
|
||||
title="Bar Roundness"
|
||||
desc="Enable rounded corners on visualizer bars"
|
||||
checked={barRounding}
|
||||
onChange={(_, checked) => {
|
||||
setBarRounding(checked);
|
||||
settings.barRounding = checked;
|
||||
}}
|
||||
{/* Color & Layout */}
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "10px 0",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: "14px", color: "#fff" }}>Color & Layout</div>
|
||||
<div style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)", marginTop: "2px" }}>
|
||||
Visualizer color and slot placement
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showColorPicker ? closeColorPicker() : openColorPicker()}
|
||||
style={{
|
||||
width: "28px", height: "28px",
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: "6px", cursor: "pointer", background: barColor,
|
||||
overflow: "hidden", position: "relative",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.1)", backdropFilter: "blur(2px)" }} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showSlotConfig ? closeSlotConfig() : openSlotConfig()}
|
||||
style={{
|
||||
padding: "6px 12px", borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.2)",
|
||||
background: "rgba(255,255,255,0.1)",
|
||||
color: "#fff", cursor: "pointer", fontSize: "12px",
|
||||
fontWeight: 500, transition: "all 0.2s ease",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.2)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.1)"; }}
|
||||
>Configure Slots</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color picker modal */}
|
||||
{shouldRenderColor && (
|
||||
<>
|
||||
<button type="button" aria-label="Close color picker" onClick={closeColorPicker} style={backdropStyle(isColorAnimIn)} />
|
||||
<div style={{ ...panelBaseStyle(isColorAnimIn), minWidth: "320px", maxWidth: "90vw" }}>
|
||||
<div style={{ marginBottom: "12px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>Choose Color</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "8px", marginBottom: "16px" }}>
|
||||
{allColors.map((color, index) => {
|
||||
const isCustom = customColors.includes(color);
|
||||
const isHovered = hoveredColorIndex === index;
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: cosmetic hover tracking on wrapper containing interactive buttons
|
||||
<div
|
||||
key={color}
|
||||
style={{ position: "relative", width: "32px", height: "32px", cursor: "pointer" }}
|
||||
onMouseEnter={() => setHoveredColorIndex(index)}
|
||||
onMouseLeave={() => setHoveredColorIndex(null)}
|
||||
>
|
||||
<button
|
||||
type="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",
|
||||
}}
|
||||
/>
|
||||
{isCustom && (
|
||||
<button
|
||||
type="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,
|
||||
}}
|
||||
>x</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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="#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",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="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>
|
||||
|
||||
<button
|
||||
type="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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Slot configuration modal */}
|
||||
{shouldRenderSlot && (
|
||||
<>
|
||||
<button type="button" aria-label="Close slot config" onClick={closeSlotConfig} style={backdropStyle(isSlotAnimIn)} />
|
||||
<div style={{ ...panelBaseStyle(isSlotAnimIn), minWidth: "520px", maxWidth: "90vw", width: "600px" }}>
|
||||
<div style={{ marginBottom: "16px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>
|
||||
Configure Visualizer Slots
|
||||
</div>
|
||||
|
||||
{/* Segment control */}
|
||||
<div style={{
|
||||
display: "flex", background: "rgba(255,255,255,0.08)",
|
||||
borderRadius: "10px", padding: "2px", gap: "2px", marginBottom: "20px",
|
||||
}}>
|
||||
{zones.map(zone => (
|
||||
<button
|
||||
key={zone}
|
||||
type="button"
|
||||
onClick={() => setActiveZone(zone)}
|
||||
style={{
|
||||
flex: 1, border: "none",
|
||||
background: activeZone === zone ? "rgba(255,255,255,0.15)" : "transparent",
|
||||
color: activeZone === zone ? "#fff" : "rgba(255,255,255,0.4)",
|
||||
fontSize: "12px", fontWeight: 600,
|
||||
padding: "7px 0", borderRadius: "8px",
|
||||
cursor: "pointer", transition: "all 0.2s ease",
|
||||
...(activeZone === zone ? { boxShadow: "0 1px 3px rgba(0,0,0,0.3)" } : {}),
|
||||
}}
|
||||
>{ZONE_LABELS[zone]}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Slot grid */}
|
||||
<div style={{ display: "flex", gap: "16px", justifyContent: "center" }}>
|
||||
{zonePositions(activeZone).map(pos => {
|
||||
const slotKeys = ZONE_SLOTS[activeZone][pos];
|
||||
if (!slotKeys) return null;
|
||||
return (
|
||||
<div key={pos} style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
color: "rgba(255,255,255,0.6)", fontSize: "11px",
|
||||
fontWeight: 600, textTransform: "uppercase",
|
||||
letterSpacing: "0.5px", marginBottom: "8px",
|
||||
textAlign: "center",
|
||||
}}>{POSITION_LABELS[pos]}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{slotKeys.map((key, i) => (
|
||||
<div key={key} style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
||||
<select
|
||||
value={slots[key]}
|
||||
onChange={(e) => updateSlot(key, e.target.value as VisualizerType)}
|
||||
style={{ ...selectStyle, flex: 1 }}
|
||||
title={`Slot ${i + 1}`}
|
||||
>
|
||||
{VIZ_TYPES.map(t => (
|
||||
<option key={t} value={t} style={optionStyle}>{VISUALIZER_LABELS[t]}</option>
|
||||
))}
|
||||
</select>
|
||||
{MINI_SUPPORTED.has(slots[key]) && (
|
||||
<button
|
||||
type="button"
|
||||
title="Mini"
|
||||
onClick={() => toggleMini(key)}
|
||||
style={{
|
||||
width: "28px", height: "28px", flexShrink: 0,
|
||||
borderRadius: "6px", border: "1px solid rgba(255,255,255,0.2)",
|
||||
background: miniSlots.has(key) ? "rgba(255,105,180,0.4)" : "rgba(255,255,255,0.08)",
|
||||
color: miniSlots.has(key) ? "#fff" : "rgba(255,255,255,0.4)",
|
||||
cursor: "pointer", fontSize: "9px", fontWeight: 700,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>M</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeSlotConfig}
|
||||
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", marginTop: "20px",
|
||||
}}
|
||||
>Done</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<LunaNumberSetting
|
||||
title="Reactivity"
|
||||
desc="How quickly visualizers respond to audio (5-100)"
|
||||
min={5}
|
||||
max={100}
|
||||
step={5}
|
||||
value={reactivity}
|
||||
onNumber={(v: number) => { setReactivity(v); settings.reactivity = v; }}
|
||||
/>
|
||||
|
||||
<LunaNumberSetting
|
||||
title="Gain"
|
||||
desc="Amplitude boost for spectrum visualizers (0.5-3.0)"
|
||||
min={0.5}
|
||||
max={3.0}
|
||||
step={0.5}
|
||||
value={gain}
|
||||
onNumber={(v: number) => { setGain(v); settings.gain = v; }}
|
||||
/>
|
||||
|
||||
<LunaSelectSetting
|
||||
title="FFT Size"
|
||||
desc="Frequency resolution (higher = more detail, more CPU)"
|
||||
value={fftSize}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const v = Number(e.target.value);
|
||||
setFftSize(v);
|
||||
settings.fftSize = v;
|
||||
}}
|
||||
>
|
||||
{[256, 512, 1024, 2048, 4096, 8192, 16384].map(s => (
|
||||
<LunaSelectItem key={s} value={s}>{s}</LunaSelectItem>
|
||||
))}
|
||||
</LunaSelectSetting>
|
||||
|
||||
<LunaNumberSetting
|
||||
title="Bar Count"
|
||||
desc="Number of frequency bars to display"
|
||||
desc="Number of frequency bars (Spectrum Bars)"
|
||||
min={8}
|
||||
max={64}
|
||||
max={128}
|
||||
step={1}
|
||||
value={barCount}
|
||||
onNumber={(value: number) => {
|
||||
setBarCount(value);
|
||||
settings.barCount = value;
|
||||
onNumber={(v: number) => { setBarCount(v); settings.barCount = v; }}
|
||||
/>
|
||||
|
||||
{hasBars && (
|
||||
<AnySwitch
|
||||
title="Bar Rounding"
|
||||
desc="Round the top corners of spectrum bars"
|
||||
checked={barRounding}
|
||||
onChange={(_: unknown, checked: boolean) => {
|
||||
setBarRounding(checked);
|
||||
settings.barRounding = checked;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LunaNumberSetting
|
||||
title="Line Thickness"
|
||||
desc="Stroke width for line-based visualizers (0.5-5)"
|
||||
min={0.5}
|
||||
max={5}
|
||||
step={0.5}
|
||||
value={lineThickness}
|
||||
onNumber={(v: number) => { setLineThickness(v); settings.lineThickness = v; }}
|
||||
/>
|
||||
|
||||
<LunaNumberSetting
|
||||
title="Fill Opacity"
|
||||
desc="Fill below the Spectrum Line curve (0-1)"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={fillOpacity}
|
||||
onNumber={(v: number) => { setFillOpacity(v); settings.fillOpacity = v; }}
|
||||
/>
|
||||
|
||||
<AnySwitch
|
||||
title="Scrolling Oscilloscope"
|
||||
desc="Waveform scrolls right-to-left like a chart recorder"
|
||||
checked={scrollingOscilloscope}
|
||||
onChange={(_: unknown, checked: boolean) => {
|
||||
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 */}
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 0",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
<AnySwitch
|
||||
title="Lissajous Mode"
|
||||
desc="Rotate the Vectorscope 45° for Lissajous display"
|
||||
checked={lissajous}
|
||||
onChange={(_: unknown, checked: boolean) => {
|
||||
setLissajous(checked);
|
||||
settings.lissajous = checked;
|
||||
}}
|
||||
>
|
||||
<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,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;
|
||||
};
|
||||
@@ -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<LunaUnload>();
|
||||
|
||||
new StyleTag("AudioVisualizer", unloads, visualizerStyles);
|
||||
|
||||
const FACTORIES: Record<Exclude<VisualizerType, "none">, () => 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<ArrayBuffer> | 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<string, SlotGroup>();
|
||||
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<string, VisualizerType>)[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<ArrayBuffer>;
|
||||
}
|
||||
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<typeof setTimeout> | 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<SlotKey, VisualizerType>();
|
||||
const lastMiniState = new Map<SlotKey, boolean>();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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<VisualizerType, VisualizerDimensions> = {
|
||||
"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<VisualizerType, string> = {
|
||||
"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<ZoneId, Record<PositionId, readonly SlotKey[]>> = {
|
||||
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<ZoneId, string> = {
|
||||
nowPlaying: "Now Playing View",
|
||||
topNav: "Top Nav",
|
||||
playerBar: "Player Bar",
|
||||
};
|
||||
|
||||
export const POSITION_LABELS: Record<PositionId, string> = {
|
||||
left: "Left",
|
||||
right: "Right",
|
||||
};
|
||||
|
||||
export const MINI_SUPPORTED = new Set<VisualizerType>(["oscilloscope"]);
|
||||
|
||||
export const MINI_DIMENSIONS: Partial<Record<VisualizerType, VisualizerDimensions>> = {
|
||||
oscilloscope: { width: 80, height: 60 },
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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<WebGL2RenderingContext, QuadResources>();
|
||||
|
||||
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];
|
||||
};
|
||||
Reference in New Issue
Block a user