mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-17 19:33: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;
|
||||
}}
|
||||
/>
|
||||
|
||||
<LunaNumberSetting
|
||||
title="Bar Count"
|
||||
desc="Number of frequency bars to display"
|
||||
min={8}
|
||||
max={64}
|
||||
step={1}
|
||||
value={barCount}
|
||||
onNumber={(value: number) => {
|
||||
setBarCount(value);
|
||||
settings.barCount = value;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* YUP YOUR EYES WORK... we do be using React code in the settings..*/}
|
||||
{/* I'm not sure if this is a good idea, but it works & looks amazing */}
|
||||
{/* Sorry @Inrixia <3 */}
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 0",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Color & Layout */}
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "10px 0",
|
||||
}}>
|
||||
<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 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",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<button
|
||||
onClick={() =>
|
||||
showColorPicker ? closeColorPicker() : openColorPicker()
|
||||
}
|
||||
type="button"
|
||||
onClick={() => showColorPicker ? closeColorPicker() : openColorPicker()}
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
width: "28px", height: "28px",
|
||||
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",
|
||||
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)",
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.1)", backdropFilter: "blur(2px)" }} />
|
||||
</button>
|
||||
|
||||
{/* Custom Color Picker Modal */}
|
||||
{shouldRender && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showSlotConfig ? closeSlotConfig() : openSlotConfig()}
|
||||
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",
|
||||
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",
|
||||
}}
|
||||
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
|
||||
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 Grid */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "8px",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
{/* 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 isCustomColor = customColors.includes(color);
|
||||
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={index}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className="color-item"
|
||||
key={color}
|
||||
style={{ position: "relative", width: "32px", height: "32px", cursor: "pointer" }}
|
||||
onMouseEnter={() => setHoveredColorIndex(index)}
|
||||
onMouseLeave={() => setHoveredColorIndex(null)}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateColor(color);
|
||||
closeColorPicker();
|
||||
}}
|
||||
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",
|
||||
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 && (
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeCustomColor(color);
|
||||
}}
|
||||
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,
|
||||
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>
|
||||
>x</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",
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
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",
|
||||
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();
|
||||
}}
|
||||
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",
|
||||
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>
|
||||
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
|
||||
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",
|
||||
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>
|
||||
>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 (Spectrum Bars)"
|
||||
min={8}
|
||||
max={128}
|
||||
step={1}
|
||||
value={barCount}
|
||||
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;
|
||||
}}
|
||||
/>
|
||||
|
||||
<AnySwitch
|
||||
title="Lissajous Mode"
|
||||
desc="Rotate the Vectorscope 45° for Lissajous display"
|
||||
checked={lissajous}
|
||||
onChange={(_: unknown, checked: boolean) => {
|
||||
setLissajous(checked);
|
||||
settings.lissajous = checked;
|
||||
}}
|
||||
/>
|
||||
</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 stream = capture.call(video);
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
if (audioTracks.length === 0) {
|
||||
log("No audio tracks in captured stream");
|
||||
return false;
|
||||
}
|
||||
|
||||
audioSource = audioContext.createMediaStreamSource(stream);
|
||||
audioSource.connect(analyser);
|
||||
|
||||
if (audioContext.state === "suspended") {
|
||||
audioContext.resume().catch(() => {});
|
||||
}
|
||||
|
||||
log("Connected via captureStream()");
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`Audio connection failed: ${err}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Canvas things
|
||||
|
||||
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 getSlotDims = (type: VisualizerType, key: SlotKey) =>
|
||||
isMiniSlot(key) && MINI_DIMENSIONS[type] ? MINI_DIMENSIONS[type] : VISUALIZER_DIMENSIONS[type];
|
||||
|
||||
const createSlotCanvas = (dims: { width: number; height: number }): HTMLCanvasElement => {
|
||||
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 };
|
||||
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;
|
||||
};
|
||||
|
||||
const clearSlot = (slot: VisualizerSlot): void => {
|
||||
slot.container?.remove();
|
||||
slot.container = null;
|
||||
slot.canvas = null;
|
||||
slot.ctx = null;
|
||||
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);
|
||||
};
|
||||
|
||||
// UI Placement with Luna Observer
|
||||
const switchVisualizer = (slot: Slot, type: VisualizerType, key: SlotKey): void => {
|
||||
if (slot.currentType === type) return;
|
||||
|
||||
const attachNavSlot = (anchor: Element): void => {
|
||||
if (navSlot.container?.isConnected) return;
|
||||
clearSlot(navSlot);
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
if (group === groups.get("topNav-left") && navArrowsEl) {
|
||||
navArrowsEl.style.marginRight = allNone ? "" : "0";
|
||||
}
|
||||
};
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
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 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);
|
||||
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;
|
||||
}
|
||||
|
||||
if (slots.length > 0) {
|
||||
waveTime += 0.05;
|
||||
const data = audio.sample();
|
||||
const hasSignal = data && audio.hasSignal(data);
|
||||
|
||||
let hasRealAudio = false;
|
||||
if (analyser && dataArray) {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
const avgVolume = dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length;
|
||||
hasRealAudio = avgVolume > 5;
|
||||
if (PlayState.playing && audio.isConnected()) {
|
||||
if (!hasSignal) {
|
||||
silentFrames++;
|
||||
if (silentFrames >= SILENT_THRESHOLD) {
|
||||
log("Silent for too long, reconnecting...");
|
||||
silentFrames = 0;
|
||||
if (!tryReconnect()) scheduleRetry();
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
}
|
||||
|
||||
audioContext = null;
|
||||
analyser = null;
|
||||
audioSource = null;
|
||||
dataArray = null;
|
||||
isSourceConnected = false;
|
||||
group.groupContainer.remove();
|
||||
}
|
||||
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];
|
||||
};
|
||||
@@ -12,6 +12,7 @@ declare global {
|
||||
updateRadiantLyricsGlobalBackground?: () => void;
|
||||
updateRadiantLyricsNowPlayingBackground?: () => void;
|
||||
updateQualityProgressColor?: () => void;
|
||||
updateIntegratedSeekBar?: () => void;
|
||||
updateLyricsStyle?: () => void;
|
||||
updateLyricsStyleSetting?: (value: number) => void;
|
||||
updateRomanizeLyrics?: () => void;
|
||||
@@ -21,19 +22,32 @@ declare global {
|
||||
|
||||
export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
|
||||
lyricsGlowEnabled: true,
|
||||
textGlow: 20,
|
||||
lyricsStyle: 2,
|
||||
lyricsFontSize: 100,
|
||||
blurInactive: true,
|
||||
contextAwareLyrics: true,
|
||||
bubbledLyrics: true,
|
||||
romanizeLyrics: false,
|
||||
stickyLyrics: false,
|
||||
syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon)
|
||||
syllableLogging: false,
|
||||
hideUIEnabled: true,
|
||||
playerBarVisible: false,
|
||||
qualityProgressColor: true,
|
||||
integratedSeekBar: true,
|
||||
floatingPlayerBar: true,
|
||||
playerBarRadius: 5,
|
||||
playerBarSpacing: 10,
|
||||
playerBarBlur: true,
|
||||
playerBarBlurAmount: 15,
|
||||
playerBarTintEnabled: true,
|
||||
playerBarTint: 5,
|
||||
playerBarTintColor: "#000000" as string,
|
||||
playerBarTintCustomColors: [] as string[],
|
||||
playerBarRadius: 5,
|
||||
playerBarSpacing: 10,
|
||||
CoverEverywhere: true,
|
||||
performanceMode: false,
|
||||
spinningArt: true,
|
||||
textGlow: 20,
|
||||
backgroundScale: 15,
|
||||
backgroundRadius: 25,
|
||||
backgroundContrast: 120,
|
||||
@@ -41,16 +55,6 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
|
||||
backgroundBrightness: 40,
|
||||
spinSpeed: 45,
|
||||
settingsAffectNowPlaying: true,
|
||||
stickyLyrics: false,
|
||||
stickyLyricsIcon: "sparkle" as string,
|
||||
lyricsStyle: 2,
|
||||
syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon)
|
||||
contextAwareLyrics: true,
|
||||
blurInactive: true,
|
||||
bubbledLyrics: true,
|
||||
syllableLogging: false,
|
||||
lyricsFontSize: 100,
|
||||
romanizeLyrics: false,
|
||||
});
|
||||
|
||||
export const Settings = () => {
|
||||
@@ -92,12 +96,21 @@ export const Settings = () => {
|
||||
const [floatingPlayerBar, setFloatingPlayerBar] = React.useState(
|
||||
settings.floatingPlayerBar,
|
||||
);
|
||||
const [playerBarTintEnabled, setPlayerBarTintEnabled] = React.useState(
|
||||
settings.playerBarTintEnabled,
|
||||
);
|
||||
const [playerBarTint, setPlayerBarTint] = React.useState(
|
||||
settings.playerBarTint,
|
||||
);
|
||||
const [playerBarTintColor, setPlayerBarTintColor] = React.useState(
|
||||
settings.playerBarTintColor,
|
||||
);
|
||||
const [playerBarBlur, setPlayerBarBlur] = React.useState(
|
||||
settings.playerBarBlur,
|
||||
);
|
||||
const [playerBarBlurAmount, setPlayerBarBlurAmount] = React.useState(
|
||||
settings.playerBarBlurAmount,
|
||||
);
|
||||
const [playerBarRadius, setPlayerBarRadius] = React.useState(
|
||||
settings.playerBarRadius,
|
||||
);
|
||||
@@ -145,6 +158,9 @@ export const Settings = () => {
|
||||
const [qualityProgressColor, setQualityProgressColor] = React.useState(
|
||||
settings.qualityProgressColor,
|
||||
);
|
||||
const [integratedSeekBar, setIntegratedSeekBar] = React.useState(
|
||||
settings.integratedSeekBar,
|
||||
);
|
||||
const [romanizeLyrics, setRomanizeLyrics] = React.useState(
|
||||
settings.romanizeLyrics,
|
||||
);
|
||||
@@ -328,6 +344,18 @@ export const Settings = () => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AnySwitch
|
||||
title="Integrated Seek Bar"
|
||||
desc="Move the seekbar to the top border of the player bar (inspired by Amethyst)"
|
||||
checked={integratedSeekBar}
|
||||
onChange={(_: unknown, checked: boolean) => {
|
||||
settings.integratedSeekBar = checked;
|
||||
setIntegratedSeekBar(checked);
|
||||
if (window.updateIntegratedSeekBar) {
|
||||
window.updateIntegratedSeekBar();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AnySwitch
|
||||
title="Floating Player Bar"
|
||||
desc="When disabled, the player bar becomes a square edge-to-edge bar"
|
||||
@@ -370,6 +398,31 @@ export const Settings = () => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<AnySwitch
|
||||
title="Player Bar Blur"
|
||||
desc="Enable backdrop blur effect on the player bar"
|
||||
checked={playerBarBlur}
|
||||
onChange={(_: unknown, checked: boolean) => {
|
||||
settings.playerBarBlur = checked;
|
||||
setPlayerBarBlur(checked);
|
||||
window.updateRadiantLyricsPlayerBarTint?.();
|
||||
}}
|
||||
/>
|
||||
{playerBarBlur && (
|
||||
<LunaNumberSetting
|
||||
title="Player Bar Blur Amount"
|
||||
desc="Adjust the backdrop blur intensity (0-100, default: 26)"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={playerBarBlurAmount}
|
||||
onNumber={(value: number) => {
|
||||
settings.playerBarBlurAmount = value;
|
||||
setPlayerBarBlurAmount(value);
|
||||
window.updateRadiantLyricsPlayerBarTint?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
const closeTintColorPicker = () => {
|
||||
setIsTintAnimatingIn(false);
|
||||
@@ -548,12 +601,96 @@ export const Settings = () => {
|
||||
Choose Tint Color
|
||||
</div>
|
||||
|
||||
{/* Enable/Disable tint toggle */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "14px",
|
||||
padding: "8px 10px",
|
||||
borderRadius: "8px",
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Enable Player Bar Tint
|
||||
</span>
|
||||
<label
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-block",
|
||||
width: "36px",
|
||||
height: "20px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={playerBarTintEnabled}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
settings.playerBarTintEnabled = checked;
|
||||
setPlayerBarTintEnabled(checked);
|
||||
window.updateRadiantLyricsPlayerBarTint?.();
|
||||
}}
|
||||
style={{
|
||||
opacity: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
position: "absolute",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
cursor: "pointer",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: playerBarTintEnabled
|
||||
? "rgba(255,255,255,0.8)"
|
||||
: "rgba(255,255,255,0.15)",
|
||||
transition: "0.25s",
|
||||
borderRadius: "20px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
content: '""',
|
||||
height: "16px",
|
||||
width: "16px",
|
||||
left: playerBarTintEnabled ? "18px" : "2px",
|
||||
bottom: "2px",
|
||||
backgroundColor: playerBarTintEnabled
|
||||
? "rgb(20,20,20)"
|
||||
: "rgba(255,255,255,0.5)",
|
||||
transition: "0.25s",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "8px",
|
||||
marginBottom: "16px",
|
||||
opacity: playerBarTintEnabled ? 1 : 0.3,
|
||||
pointerEvents: playerBarTintEnabled ? "auto" : "none",
|
||||
filter: playerBarTintEnabled ? "none" : "grayscale(1)",
|
||||
transition: "all 0.25s ease",
|
||||
}}
|
||||
>
|
||||
{allTintColors.map((color, index) => {
|
||||
@@ -626,7 +763,15 @@ export const Settings = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "12px",
|
||||
opacity: playerBarTintEnabled ? 1 : 0.3,
|
||||
pointerEvents: playerBarTintEnabled ? "auto" : "none",
|
||||
filter: playerBarTintEnabled ? "none" : "grayscale(1)",
|
||||
transition: "all 0.25s ease",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
|
||||
@@ -118,19 +118,58 @@ const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
|
||||
};
|
||||
};
|
||||
|
||||
/** CSS var for integrated seekbar top corners — matches Floating Bar Corner Radius when floating is on */
|
||||
const applyIntegratedSeekbarChrome = (): void => {
|
||||
const footer = document.querySelector(
|
||||
'[data-test="footer-player"]',
|
||||
) as HTMLElement | null;
|
||||
if (!footer) return;
|
||||
if (!settings.integratedSeekBar) {
|
||||
footer.style.removeProperty("--rl-integrated-seekbar-top-radius");
|
||||
return;
|
||||
}
|
||||
const topR = settings.floatingPlayerBar ? settings.playerBarRadius : 0;
|
||||
footer.style.setProperty("--rl-integrated-seekbar-top-radius", `${topR}px`);
|
||||
};
|
||||
|
||||
// Apply inline styles to the player bar (tint + optional radius/spacing customisation)
|
||||
const applyPlayerBarTintToElement = (): void => {
|
||||
const footerPlayer = document.querySelector(
|
||||
'[data-test="footer-player"]',
|
||||
) as HTMLElement;
|
||||
if (!footerPlayer) return;
|
||||
if (settings.playerBarTintEnabled) {
|
||||
const alpha = settings.playerBarTint / 10;
|
||||
const { r, g, b } = hexToRgb(settings.playerBarTintColor);
|
||||
footerPlayer.style.removeProperty("background");
|
||||
footerPlayer.style.setProperty(
|
||||
"background-color",
|
||||
`rgba(${r}, ${g}, ${b}, ${alpha})`,
|
||||
"important",
|
||||
);
|
||||
} else {
|
||||
footerPlayer.style.removeProperty("background-color");
|
||||
footerPlayer.style.setProperty(
|
||||
"background",
|
||||
"linear-gradient(rgba(60,60,60,0.35) 0%, rgba(60,60,60,0.35) 27%, rgba(61,61,61,0.35) 35%, rgba(62,62,62,0.35) 43.5%, rgba(63,63,63,0.35) 53%, rgba(65,65,65,0.35) 66%, rgba(67,67,67,0.35) 81%, rgba(70,70,70,0.35) 100%)",
|
||||
"important",
|
||||
);
|
||||
}
|
||||
if (settings.playerBarBlur) {
|
||||
footerPlayer.style.setProperty(
|
||||
"backdrop-filter",
|
||||
`blur(${settings.playerBarBlurAmount}px)`,
|
||||
"important",
|
||||
);
|
||||
footerPlayer.style.setProperty(
|
||||
"-webkit-backdrop-filter",
|
||||
`blur(${settings.playerBarBlurAmount}px)`,
|
||||
"important",
|
||||
);
|
||||
} else {
|
||||
footerPlayer.style.setProperty("backdrop-filter", "none", "important");
|
||||
footerPlayer.style.setProperty("-webkit-backdrop-filter", "none", "important");
|
||||
}
|
||||
if (settings.floatingPlayerBar) {
|
||||
footerPlayer.style.setProperty(
|
||||
"border-radius",
|
||||
@@ -151,6 +190,7 @@ const applyPlayerBarTintToElement = (): void => {
|
||||
footerPlayer.style.removeProperty("left");
|
||||
footerPlayer.style.removeProperty("width");
|
||||
}
|
||||
applyIntegratedSeekbarChrome();
|
||||
};
|
||||
|
||||
// When floating is disabled, inject square-bar CSS to override Tidal's native floating styles
|
||||
@@ -214,6 +254,182 @@ if (settings.qualityProgressColor) {
|
||||
applyQualityProgressColor();
|
||||
}
|
||||
|
||||
// MARKER: Integrated Seek Bar
|
||||
// Moves the seekbar to the top border of the player bar | Inspired by Tokyo Tidal-HiFi theme (ages ago)
|
||||
|
||||
let integratedSeekbarIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const clearIntegratedSeekbarInterval = (): void => {
|
||||
if (integratedSeekbarIntervalId !== null) {
|
||||
clearInterval(integratedSeekbarIntervalId);
|
||||
integratedSeekbarIntervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Restore Original DOM Structure (cleanup)
|
||||
const unwrapIntegratedSeekbarTimes = (footerPlayer: HTMLElement): void => {
|
||||
const wrap = footerPlayer.querySelector(".rl-seekbar-times-wrap");
|
||||
if (!wrap?.parentElement) return;
|
||||
const row = wrap.parentElement;
|
||||
const bar =
|
||||
(row.querySelector(".rl-seekbar-bar") as HTMLElement | null) ??
|
||||
(row.querySelector('[data-test="progress-bar"]')?.parentElement as
|
||||
| HTMLElement
|
||||
| null);
|
||||
const p1 = wrap.querySelector('[data-test="current-time"]')
|
||||
?.parentElement as HTMLElement | undefined;
|
||||
const p2 = wrap.querySelector('[data-test="duration"]')
|
||||
?.parentElement as HTMLElement | undefined;
|
||||
wrap.querySelector(".rl-seekbar-time-sep")?.remove();
|
||||
if (p1 && p2 && bar) {
|
||||
row.insertBefore(p1, wrap);
|
||||
row.insertBefore(p2, bar.nextSibling);
|
||||
} else if (p1 && p2) {
|
||||
row.insertBefore(p1, wrap);
|
||||
row.appendChild(p2);
|
||||
}
|
||||
wrap.remove();
|
||||
};
|
||||
|
||||
const syncIntegratedSeekbarCombinedTime = (footerPlayer: HTMLElement): void => {
|
||||
const el = footerPlayer.querySelector(
|
||||
".rl-seekbar-combined-time",
|
||||
) as HTMLElement | null;
|
||||
if (!el) return;
|
||||
const row = footerPlayer.querySelector(".rl-seekbar-container");
|
||||
if (!row) return;
|
||||
const cur =
|
||||
row.querySelector('[data-test="current-time"]')?.textContent?.trim() ?? "";
|
||||
const dur =
|
||||
row.querySelector('[data-test="duration"]')?.textContent?.trim() ?? "";
|
||||
el.textContent = `${cur} | ${dur}`;
|
||||
};
|
||||
|
||||
// Finds the Seekbar Row
|
||||
const getScrubberRowFromFooter = (
|
||||
footerPlayer: HTMLElement,
|
||||
): HTMLElement | null => {
|
||||
const pb = footerPlayer.querySelector('[data-test="progress-bar"]');
|
||||
const bar = pb?.parentElement;
|
||||
return bar?.parentElement ?? null;
|
||||
};
|
||||
|
||||
const clearNativeScrubberTimeDisplay = (footerPlayer: HTMLElement): void => {
|
||||
const row = getScrubberRowFromFooter(footerPlayer);
|
||||
if (!row) return;
|
||||
for (const p of row.querySelectorAll("p")) {
|
||||
if (
|
||||
p.querySelector('[data-test="current-time"]') ||
|
||||
p.querySelector('[data-test="duration"]')
|
||||
) {
|
||||
p.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupIntegratedSeekbarDisplay = (footerPlayer: HTMLElement): void => {
|
||||
clearIntegratedSeekbarInterval();
|
||||
clearNativeScrubberTimeDisplay(footerPlayer);
|
||||
unwrapIntegratedSeekbarTimes(footerPlayer);
|
||||
footerPlayer.querySelectorAll(".rl-seekbar-combined-time").forEach((n) => {
|
||||
n.remove();
|
||||
});
|
||||
for (const el of footerPlayer.querySelectorAll(".rl-seekbar-native-time")) {
|
||||
el.classList.remove("rl-seekbar-native-time");
|
||||
}
|
||||
footerPlayer.style.removeProperty("--rl-integrated-seekbar-top-radius");
|
||||
};
|
||||
|
||||
const applyIntegratedSeekBar = (): void => {
|
||||
const footerPlayer = document.querySelector(
|
||||
'[data-test="footer-player"]',
|
||||
) as HTMLElement | null;
|
||||
if (!footerPlayer) return;
|
||||
|
||||
clearIntegratedSeekbarInterval();
|
||||
|
||||
// Cleanup
|
||||
for (const cls of ["rl-seekbar-container", "rl-seekbar-bar", "rl-seekbar-native-time"]) {
|
||||
footerPlayer.querySelectorAll(`.${cls}`).forEach((el) => {
|
||||
el.classList.remove(cls);
|
||||
});
|
||||
}
|
||||
|
||||
if (!settings.integratedSeekBar) {
|
||||
document.body.classList.remove("rl-integrated-seekbar");
|
||||
cleanupIntegratedSeekbarDisplay(footerPlayer);
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.classList.add("rl-integrated-seekbar");
|
||||
applyIntegratedSeekbarChrome();
|
||||
|
||||
const progressBar = footerPlayer.querySelector(
|
||||
'[data-test="progress-bar"]',
|
||||
) as HTMLElement | null;
|
||||
if (!progressBar) return;
|
||||
|
||||
const scrubberBar = progressBar.parentElement as HTMLElement | null;
|
||||
const scrubberRow = scrubberBar?.parentElement as HTMLElement | null;
|
||||
if (!scrubberRow) return;
|
||||
|
||||
scrubberRow.classList.add("rl-seekbar-container");
|
||||
|
||||
// Mark the Seekbar (so it's moved in a sec)
|
||||
if (scrubberBar) {
|
||||
scrubberBar.classList.add("rl-seekbar-bar");
|
||||
}
|
||||
|
||||
const currentTime = scrubberRow.querySelector(
|
||||
'[data-test="current-time"]',
|
||||
) as HTMLElement | null;
|
||||
const duration = scrubberRow.querySelector(
|
||||
'[data-test="duration"]',
|
||||
) as HTMLElement | null;
|
||||
const p1 = currentTime?.parentElement as HTMLElement | null;
|
||||
const p2 = duration?.parentElement as HTMLElement | null;
|
||||
if (!p1 || !p2) return;
|
||||
|
||||
unwrapIntegratedSeekbarTimes(footerPlayer);
|
||||
|
||||
p1.classList.add("rl-seekbar-native-time");
|
||||
p2.classList.add("rl-seekbar-native-time");
|
||||
p1.style.setProperty("display", "none", "important");
|
||||
p2.style.setProperty("display", "none", "important");
|
||||
|
||||
const combinedNodes = scrubberRow.querySelectorAll(".rl-seekbar-combined-time");
|
||||
for (let i = 1; i < combinedNodes.length; i++) {
|
||||
combinedNodes[i]?.remove();
|
||||
}
|
||||
let combined = scrubberRow.querySelector(
|
||||
".rl-seekbar-combined-time",
|
||||
) as HTMLElement | null;
|
||||
if (!combined) {
|
||||
combined = document.createElement("span");
|
||||
combined.className = "rl-seekbar-combined-time";
|
||||
combined.setAttribute("aria-live", "polite");
|
||||
scrubberRow.insertBefore(combined, p1);
|
||||
}
|
||||
|
||||
syncIntegratedSeekbarCombinedTime(footerPlayer);
|
||||
integratedSeekbarIntervalId = setInterval(() => {
|
||||
const fp = document.querySelector(
|
||||
'[data-test="footer-player"]',
|
||||
) as HTMLElement | null;
|
||||
if (!fp || !settings.integratedSeekBar) {
|
||||
clearIntegratedSeekbarInterval();
|
||||
return;
|
||||
}
|
||||
syncIntegratedSeekbarCombinedTime(fp);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
// Apply on load
|
||||
applyIntegratedSeekBar();
|
||||
observe<HTMLElement>(unloads, '[data-test="footer-player"]', () => {
|
||||
applyIntegratedSeekBar();
|
||||
});
|
||||
|
||||
// Apply base styles always (I kinda dont really remember what this does but it's important i guess)
|
||||
baseStyleTag.css = baseStyles;
|
||||
|
||||
@@ -923,6 +1139,7 @@ const updateRadiantLyricsNowPlayingBackground = function (): void {
|
||||
(window as any).updateRadiantLyricsPlayerBarTint =
|
||||
updateRadiantLyricsPlayerBarTint;
|
||||
(window as any).updateQualityProgressColor = applyQualityProgressColor;
|
||||
(window as any).updateIntegratedSeekBar = applyIntegratedSeekBar;
|
||||
|
||||
const cleanUpDynamicArt = function (): void {
|
||||
// Clean up cached Now Playing elements
|
||||
@@ -1003,6 +1220,23 @@ unloads.add(() => {
|
||||
footerPlayer.style.removeProperty("bottom");
|
||||
footerPlayer.style.removeProperty("left");
|
||||
footerPlayer.style.removeProperty("width");
|
||||
footerPlayer.style.removeProperty("--rl-integrated-seekbar-top-radius");
|
||||
}
|
||||
|
||||
// Clean up integrated seekbar
|
||||
document.body.classList.remove("rl-integrated-seekbar");
|
||||
clearIntegratedSeekbarInterval();
|
||||
document
|
||||
.querySelectorAll(".rl-seekbar-container, .rl-seekbar-bar, .rl-seekbar-native-time")
|
||||
.forEach((el) => {
|
||||
el.classList.remove(
|
||||
"rl-seekbar-container",
|
||||
"rl-seekbar-bar",
|
||||
"rl-seekbar-native-time",
|
||||
);
|
||||
});
|
||||
if (footerPlayer) {
|
||||
cleanupIntegratedSeekbarDisplay(footerPlayer);
|
||||
}
|
||||
|
||||
// Clean up action buttons
|
||||
@@ -1030,45 +1264,18 @@ unloads.add(() => {
|
||||
|
||||
// MARKER: Sticky Lyrics Feature
|
||||
|
||||
const STICKY_ICONS: Record<string, string> = {
|
||||
chevron:
|
||||
'<svg viewBox="0 0 24 24" width="10" height="10" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M4.29289 8.29289C4.68342 7.90237 5.31658 7.90237 5.70711 8.29289L12 14.5858L18.2929 8.29289C18.6834 7.90237 19.3166 7.90237 19.7071 8.29289C20.0976 8.68342 20.0976 9.31658 19.7071 9.70711L12.7071 16.7071C12.3166 17.0976 11.6834 17.0976 11.2929 16.7071L4.29289 9.70711C3.90237 9.31658 3.90237 8.68342 4.29289 8.29289Z" fill="currentColor"/></svg>',
|
||||
sparkle:
|
||||
'<svg viewBox="0 0 512 512" width="16" height="16"><path fill="currentColor" d="M208,512a24.84,24.84,0,0,1-23.34-16l-39.84-103.6a16.06,16.06,0,0,0-9.19-9.19L32,343.34a25,25,0,0,1,0-46.68l103.6-39.84a16.06,16.06,0,0,0,9.19-9.19L184.66,144a25,25,0,0,1,46.68,0l39.84,103.6a16.06,16.06,0,0,0,9.19,9.19l103,39.63A25.49,25.49,0,0,1,400,320.52a24.82,24.82,0,0,1-16,22.82l-103.6,39.84a16.06,16.06,0,0,0-9.19,9.19L231.34,496A24.84,24.84,0,0,1,208,512Zm66.85-254.84h0Z"/><path fill="currentColor" d="M88,176a14.67,14.67,0,0,1-13.69-9.4L57.45,122.76a7.28,7.28,0,0,0-4.21-4.21L9.4,101.69a14.67,14.67,0,0,1,0-27.38L53.24,57.45a7.31,7.31,0,0,0,4.21-4.21L74.16,9.79A15,15,0,0,1,86.23.11,14.67,14.67,0,0,1,101.69,9.4l16.86,43.84a7.31,7.31,0,0,0,4.21,4.21L166.6,74.31a14.67,14.67,0,0,1,0,27.38l-43.84,16.86a7.28,7.28,0,0,0-4.21,4.21L101.69,166.6A14.67,14.67,0,0,1,88,176Z"/><path fill="currentColor" d="M400,256a16,16,0,0,1-14.93-10.26l-22.84-59.37a8,8,0,0,0-4.6-4.6l-59.37-22.84a16,16,0,0,1,0-29.86l59.37-22.84a8,8,0,0,0,4.6-4.6L384.9,42.68a16.45,16.45,0,0,1,13.17-10.57,16,16,0,0,1,16.86,10.15l22.84,59.37a8,8,0,0,0,4.6,4.6l59.37,22.84a16,16,0,0,1,0,29.86l-59.37,22.84a8,8,0,0,0-4.6,4.6l-22.84,59.37A16,16,0,0,1,400,256Z"/></svg>',
|
||||
};
|
||||
|
||||
const getStickyIcon = (): string =>
|
||||
STICKY_ICONS[settings.stickyLyricsIcon] ?? STICKY_ICONS.chevron;
|
||||
const STICKY_ICON =
|
||||
'<svg viewBox="0 0 512 512" width="16" height="16"><path fill="currentColor" d="M208,512a24.84,24.84,0,0,1-23.34-16l-39.84-103.6a16.06,16.06,0,0,0-9.19-9.19L32,343.34a25,25,0,0,1,0-46.68l103.6-39.84a16.06,16.06,0,0,0,9.19-9.19L184.66,144a25,25,0,0,1,46.68,0l39.84,103.6a16.06,16.06,0,0,0,9.19,9.19l103,39.63A25.49,25.49,0,0,1,400,320.52a24.82,24.82,0,0,1-16,22.82l-103.6,39.84a16.06,16.06,0,0,0-9.19,9.19L231.34,496A24.84,24.84,0,0,1,208,512Zm66.85-254.84h0Z"/><path fill="currentColor" d="M88,176a14.67,14.67,0,0,1-13.69-9.4L57.45,122.76a7.28,7.28,0,0,0-4.21-4.21L9.4,101.69a14.67,14.67,0,0,1,0-27.38L53.24,57.45a7.31,7.31,0,0,0,4.21-4.21L74.16,9.79A15,15,0,0,1,86.23.11,14.67,14.67,0,0,1,101.69,9.4l16.86,43.84a7.31,7.31,0,0,0,4.21,4.21L166.6,74.31a14.67,14.67,0,0,1,0,27.38l-43.84,16.86a7.28,7.28,0,0,0-4.21,4.21L101.69,166.6A14.67,14.67,0,0,1,88,176Z"/><path fill="currentColor" d="M400,256a16,16,0,0,1-14.93-10.26l-22.84-59.37a8,8,0,0,0-4.6-4.6l-59.37-22.84a16,16,0,0,1,0-29.86l59.37-22.84a8,8,0,0,0,4.6-4.6L384.9,42.68a16.45,16.45,0,0,1,13.17-10.57,16,16,0,0,1,16.86,10.15l22.84,59.37a8,8,0,0,0,4.6,4.6l59.37,22.84a16,16,0,0,1,0,29.86l-59.37,22.84a8,8,0,0,0-4.6,4.6l-22.84,59.37A16,16,0,0,1,400,256Z"/></svg>';
|
||||
|
||||
const applyStickyIcon = (): void => {
|
||||
const trigger = document.querySelector(
|
||||
".sticky-lyrics-trigger",
|
||||
) as HTMLElement;
|
||||
if (!trigger) return;
|
||||
trigger.innerHTML = getStickyIcon();
|
||||
trigger.innerHTML = STICKY_ICON;
|
||||
trigger.style.paddingLeft = "5px";
|
||||
};
|
||||
|
||||
// Console: StickyLyrics.icon = "sparkle" or "chevron"
|
||||
// I'm picky and prefer the Sparkle.. shhh
|
||||
(window as any).StickyLyrics = {
|
||||
get icon() {
|
||||
return settings.stickyLyricsIcon;
|
||||
},
|
||||
set icon(value: string) {
|
||||
const key = value.toLowerCase();
|
||||
if (!STICKY_ICONS[key]) {
|
||||
console.log(
|
||||
`[Radiant Lyrics] Unknown icon "${value}". Available: ${Object.keys(STICKY_ICONS).join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
settings.stickyLyricsIcon = key;
|
||||
applyStickyIcon();
|
||||
console.log(`[Radiant Lyrics] Sticky Lyrics icon set to "${key}"`);
|
||||
},
|
||||
};
|
||||
|
||||
// Console: Syllables.log = true/false
|
||||
// Verbose logging for word/syllable lyrics (hidden setting)
|
||||
const sylLog = (...args: unknown[]) => {
|
||||
@@ -1251,7 +1458,7 @@ const createStickyLyricsDropdown = (): void => {
|
||||
const trigger = document.createElement("div");
|
||||
trigger.className = "sticky-lyrics-trigger";
|
||||
trigger.setAttribute("title", "Sticky Lyrics");
|
||||
trigger.innerHTML = getStickyIcon();
|
||||
trigger.innerHTML = STICKY_ICON;
|
||||
|
||||
for (const evtName of [
|
||||
"pointerdown",
|
||||
|
||||
@@ -239,6 +239,90 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
|
||||
}
|
||||
|
||||
|
||||
/* MARKER: Integrated Seek Bar */
|
||||
/* Moves the seekbar to the top border of the player bar (inspired by Amethyst) */
|
||||
|
||||
/* Scrubber row stays in flow — centers the time block as one unit */
|
||||
body.rl-integrated-seekbar .rl-seekbar-container {
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
/* Single string: "current | duration" — synced from native <time> nodes */
|
||||
body.rl-integrated-seekbar .rl-seekbar-combined-time {
|
||||
opacity: 0.5 !important;
|
||||
white-space: nowrap !important;
|
||||
line-height: 1.2 !important;
|
||||
flex: 0 0 auto !important;
|
||||
font-variant-numeric: tabular-nums !important;
|
||||
}
|
||||
|
||||
/* Hide Tidal's two time <p> cells (class + structural — survives React re-renders) */
|
||||
body.rl-integrated-seekbar .rl-seekbar-native-time {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.rl-integrated-seekbar [data-test="footer-player"] .rl-seekbar-container > p:has([data-test="current-time"]),
|
||||
body.rl-integrated-seekbar [data-test="footer-player"] .rl-seekbar-container > p:has([data-test="duration"]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Scrubber bar — top strip: same width as footer player, top corners = Floating Bar Corner Radius */
|
||||
body.rl-integrated-seekbar .rl-seekbar-bar {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
z-index: 100 !important;
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
flex: none !important;
|
||||
border-top-left-radius: var(--rl-integrated-seekbar-top-radius, 0px) !important;
|
||||
border-top-right-radius: var(--rl-integrated-seekbar-top-radius, 0px) !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Knob — pill shape, hidden until hover */
|
||||
body.rl-integrated-seekbar .rl-seekbar-bar [class*="knob"] {
|
||||
width: 7px !important;
|
||||
height: 14px !important;
|
||||
border-radius: 3px !important;
|
||||
background: #fff !important;
|
||||
opacity: 0 !important;
|
||||
transition: opacity 0.15s ease !important;
|
||||
}
|
||||
|
||||
body.rl-integrated-seekbar .rl-seekbar-bar:hover [class*="knob"] {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Track elements only: top corners follow player bar radius, bottom square */
|
||||
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"],
|
||||
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"] [class*="_wrapper"],
|
||||
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"] [class*="_range"],
|
||||
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"] [data-test="progress-indicator"] {
|
||||
height: 3px !important;
|
||||
border-top-left-radius: var(--rl-integrated-seekbar-top-radius, 0px) !important;
|
||||
border-top-right-radius: var(--rl-integrated-seekbar-top-radius, 0px) !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
transition: height 0.15s ease !important;
|
||||
}
|
||||
|
||||
/* Expand on hover */
|
||||
body.rl-integrated-seekbar .rl-seekbar-bar:hover [data-test="progress-bar"],
|
||||
body.rl-integrated-seekbar .rl-seekbar-bar:hover [data-test="progress-bar"] [class*="_wrapper"],
|
||||
body.rl-integrated-seekbar .rl-seekbar-bar:hover [data-test="progress-bar"] [class*="_range"],
|
||||
body.rl-integrated-seekbar .rl-seekbar-bar:hover [data-test="progress-bar"] [data-test="progress-indicator"] {
|
||||
height: 5px !important;
|
||||
}
|
||||
|
||||
|
||||
/* MARKER: PATCHES (Random Fixes for Tidals Changes) */
|
||||
/* These change allot so i gave them their own section */
|
||||
|
||||
@@ -267,3 +351,69 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
|
||||
[data-test="new-now-playing-expand"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* Restore the Old Quality Tag style | thx Aya <3 */
|
||||
|
||||
._gradientMax_9111fba {
|
||||
background-color: #ffd4321a !important;
|
||||
box-shadow: none;
|
||||
border-style: none;
|
||||
border-radius: 0.75em;
|
||||
}
|
||||
|
||||
._max_894bc7c ._badgeText_1c9dd30 {
|
||||
color: #ffd432 !important;
|
||||
text-shadow: 0 0 10px #0000 !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 90% !important;
|
||||
}
|
||||
|
||||
._gradientHigh_87f2c3b {
|
||||
background-color: #073430 !important;
|
||||
box-shadow: none;
|
||||
border-style: none;
|
||||
border-radius: 0.75em;
|
||||
}
|
||||
|
||||
._high_4b5525b ._badgeText_1c9dd30 {
|
||||
color: #33ffee !important;
|
||||
text-shadow: none !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 90% !important;
|
||||
}
|
||||
|
||||
._gradientLow_3f9bc0d {
|
||||
background-color: #ffffff1a !important;
|
||||
box-shadow: none;
|
||||
border-style: none;
|
||||
border-radius: 0.75em;
|
||||
}
|
||||
|
||||
._badgeText_1c9dd30 {
|
||||
color: #e4e4e7 !important;
|
||||
text-shadow: none;
|
||||
font-weight: 600 !important;
|
||||
font-size: 90% !important;
|
||||
}
|
||||
|
||||
._badge_7b2911e {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: none;
|
||||
height: 33px;
|
||||
padding: 0 !important;
|
||||
width: 111px;
|
||||
min-width: 111px;
|
||||
border-radius: 0.75em;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
._badge_7b2911e:hover {
|
||||
transition: 100ms;
|
||||
filter: saturate(1.25) brightness(1.1);
|
||||
}
|
||||
|
||||
._glowEffect_74c5e85 {
|
||||
display: none !important;
|
||||
}
|
||||
Reference in New Issue
Block a user