Overhaul Audio Visualizer & RL UI Improvements

This commit is contained in:
2026-04-03 23:10:58 +11:00
parent 59af461ea1
commit 5f0795919d
14 changed files with 2622 additions and 676 deletions
+504 -363
View File
@@ -3,74 +3,146 @@ import {
LunaSettings,
LunaNumberSetting,
LunaSwitchSetting,
LunaSelectSetting,
LunaSelectItem,
} from "@luna/ui";
import React from "react";
import {
VISUALIZER_LABELS,
type VisualizerType,
ALL_SLOT_KEYS,
ZONE_SLOTS,
ZONE_LABELS,
POSITION_LABELS,
type ZoneId,
type PositionId,
type SlotKey,
MINI_SUPPORTED,
} from "./visualizers/types";
export const settings = await ReactiveStore.getPluginStorage(
"AudioVisualizer",
{
barCount: 32,
barColor: "#ffffff",
navLeft1: "none" as VisualizerType,
navLeft2: "none" as VisualizerType,
navLeft3: "none" as VisualizerType,
navRight1: "spectrum-bars" as VisualizerType,
navRight2: "none" as VisualizerType,
navRight3: "none" as VisualizerType,
npLeft1: "none" as VisualizerType,
npLeft2: "none" as VisualizerType,
npLeft3: "none" as VisualizerType,
npRight1: "oscilloscope" as VisualizerType,
npRight2: "none" as VisualizerType,
npRight3: "none" as VisualizerType,
pbLeft1: "none" as VisualizerType,
pbLeft2: "none" as VisualizerType,
pbLeft3: "none" as VisualizerType,
pbRight1: "none" as VisualizerType,
pbRight2: "none" as VisualizerType,
pbRight3: "none" as VisualizerType,
barColor: "#ff69b4",
barCount: 64,
fftSize: 2048,
reactivity: 30,
gain: 1.5,
barRounding: true,
lineThickness: 2.0,
fillOpacity: 0.6,
opacityFalloff: 0.5,
lissajous: false,
scrollingOscilloscope: false,
miniSlots: [] as string[],
customColors: [] as string[],
},
);
const VIZ_TYPES: VisualizerType[] = [
"none",
"spectrum-bars",
"spectrum-line",
"oscilloscope",
"vectorscope",
"loudness-meter",
];
const getSlot = (key: SlotKey): VisualizerType =>
(settings as unknown as Record<string, VisualizerType>)[key] ?? "none";
const setSlot = (key: SlotKey, value: VisualizerType): void => {
(settings as unknown as Record<string, VisualizerType>)[key] = value;
};
export const Settings = () => {
const [barCount, setBarCount] = React.useState(settings.barCount);
const [barColor, setBarColor] = React.useState(settings.barColor);
const [barCount, setBarCount] = React.useState(settings.barCount);
const [fftSize, setFftSize] = React.useState(settings.fftSize);
const [reactivity, setReactivity] = React.useState(settings.reactivity);
const [gain, setGain] = React.useState(settings.gain);
const [barRounding, setBarRounding] = React.useState(settings.barRounding);
const [lineThickness, setLineThickness] = React.useState(settings.lineThickness);
const [fillOpacity, setFillOpacity] = React.useState(settings.fillOpacity);
const [lissajous, setLissajous] = React.useState(settings.lissajous);
const [scrollingOscilloscope, setScrollingOscilloscope] = React.useState(settings.scrollingOscilloscope);
const [showColorPicker, setShowColorPicker] = React.useState(false);
const [isAnimatingIn, setIsAnimatingIn] = React.useState(false);
const [shouldRender, setShouldRender] = React.useState(false);
const [isColorAnimIn, setIsColorAnimIn] = React.useState(false);
const [shouldRenderColor, setShouldRenderColor] = React.useState(false);
const [customInput, setCustomInput] = React.useState(settings.barColor);
const [customColors, setCustomColors] = React.useState(settings.customColors);
const [hoveredColorIndex, setHoveredColorIndex] = React.useState<
number | null
>(null);
const [hoveredColorIndex, setHoveredColorIndex] = React.useState<number | null>(null);
const [showSlotConfig, setShowSlotConfig] = React.useState(false);
const [isSlotAnimIn, setIsSlotAnimIn] = React.useState(false);
const [shouldRenderSlot, setShouldRenderSlot] = React.useState(false);
const [activeZone, setActiveZone] = React.useState<ZoneId>("nowPlaying");
const [slots, setSlots] = React.useState<Record<SlotKey, VisualizerType>>(() => {
const vals = {} as Record<SlotKey, VisualizerType>;
for (const key of ALL_SLOT_KEYS) vals[key] = getSlot(key);
return vals;
});
const [miniSlots, setMiniSlots] = React.useState<Set<string>>(new Set(settings.miniSlots));
const closeColorPicker = () => {
setIsAnimatingIn(false);
setTimeout(() => {
setShowColorPicker(false);
setShouldRender(false);
}, 200); // Wait for animation to complete because i need to
setIsColorAnimIn(false);
setTimeout(() => { setShowColorPicker(false); setShouldRenderColor(false); }, 200);
};
const openColorPicker = () => {
setShowColorPicker(true);
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
setShouldRenderColor(true);
setTimeout(() => setIsColorAnimIn(true), 10);
};
const closeSlotConfig = () => {
setIsSlotAnimIn(false);
setTimeout(() => { setShowSlotConfig(false); setShouldRenderSlot(false); }, 200);
};
const openSlotConfig = () => {
setShowSlotConfig(true);
setShouldRenderSlot(true);
setTimeout(() => setIsSlotAnimIn(true), 10);
};
React.useEffect(() => {
if (showColorPicker) {
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
setShouldRenderColor(true);
setTimeout(() => setIsColorAnimIn(true), 10);
}
}, [showColorPicker]);
// Common color presets for cool points :D
React.useEffect(() => {
if (showSlotConfig) {
setShouldRenderSlot(true);
setTimeout(() => setIsSlotAnimIn(true), 10);
}
}, [showSlotConfig]);
const colorPresets = [
"#ffffff",
"#ff0000",
"#00ff00",
"#0000ff",
"#ffff00",
"#ff00ff",
"#00ffff",
"#ff8800",
"#8800ff",
"#0088ff",
"#88ff00",
"#ff0088",
"#00ff88",
"#444444",
"#888888",
"#cccccc",
"#1db954",
"#e22134",
"#1976d2",
"#ff69b4", "#ff1493", "#e91e8a", "#c71585",
"#ff006e", "#ff4da6", "#ff85c8", "#ffb3d9",
"#ffffff", "#ff0000", "#00ff00", "#0000ff",
"#ffff00", "#ff00ff", "#00ffff", "#ff8800",
"#8800ff", "#0088ff", "#1db954", "#444444",
];
const updateColor = (color: string) => {
@@ -81,352 +153,421 @@ export const Settings = () => {
const addCustomColor = () => {
if (customInput) {
// Trim whitespace and convert to lowercase
const trimmedInput = customInput.trim().toLowerCase();
// Validate hex color format
const hexColorRegex = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
if (
hexColorRegex.test(trimmedInput) &&
!colorPresets.includes(trimmedInput) &&
!customColors.includes(trimmedInput)
) {
const newCustomColors = [...customColors, trimmedInput];
setCustomColors(newCustomColors);
settings.customColors = newCustomColors;
const trimmed = customInput.trim().toLowerCase();
const hexRe = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
if (hexRe.test(trimmed) && !colorPresets.includes(trimmed) && !customColors.includes(trimmed)) {
const nc = [...customColors, trimmed];
setCustomColors(nc);
settings.customColors = nc;
}
}
};
const removeCustomColor = (colorToRemove: string) => {
const newCustomColors = customColors.filter(
(color) => color !== colorToRemove,
);
setCustomColors(newCustomColors);
settings.customColors = newCustomColors;
// If the removed color was the selected color (reset to white)
if (barColor === colorToRemove) {
updateColor("#ffffff");
}
const removeCustomColor = (c: string) => {
const nc = customColors.filter(x => x !== c);
setCustomColors(nc);
settings.customColors = nc;
if (barColor === c) updateColor("#ff69b4");
};
const allColors = [...colorPresets, ...customColors];
const updateSlot = (key: SlotKey, value: VisualizerType) => {
setSlots(prev => ({ ...prev, [key]: value }));
setSlot(key, value);
if (!MINI_SUPPORTED.has(value)) {
setMiniSlots(prev => {
const next = new Set(prev);
if (next.delete(key)) settings.miniSlots = [...next];
return next;
});
}
};
const toggleMini = (key: SlotKey) => {
setMiniSlots(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
settings.miniSlots = [...next];
return next;
});
};
type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>;
type AnySwitchProps = Omit<BaseSwitchProps, "onChange"> & {
onChange: (_: unknown, checked: boolean) => void;
checked: boolean;
};
const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType<AnySwitchProps>;
const hasBars = ALL_SLOT_KEYS.some(key => slots[key] === "spectrum-bars");
const zones: ZoneId[] = ["nowPlaying", "topNav", "playerBar"];
const zonePositions = (zone: ZoneId) =>
Object.keys(ZONE_SLOTS[zone]) as PositionId[];
const backdropStyle = (animIn: boolean): React.CSSProperties => ({
position: "fixed", top: 0, left: 0, right: 0, bottom: 0,
background: "rgba(0,0,0,0.6)", zIndex: 1000,
opacity: animIn ? 1 : 0, transition: "opacity 0.2s ease",
border: "none", padding: 0, cursor: "default", width: "100%",
});
const panelBaseStyle = (animIn: boolean): React.CSSProperties => ({
position: "fixed", top: "50%", left: "50%",
background: "rgba(20,20,20,0.98)",
backdropFilter: "blur(20px)", WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.15)", borderRadius: "16px",
padding: "20px", maxHeight: "90vh", overflowY: "auto",
zIndex: 1001, boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
opacity: animIn ? 1 : 0,
transform: animIn ? "translate(-50%, -50%) scale(1)" : "translate(-50%, -50%) scale(0.9)",
transition: "all 0.2s ease",
});
const selectStyle: React.CSSProperties = {
width: "100%",
padding: "6px 8px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.08)",
color: "#fff",
fontSize: "12px",
cursor: "pointer",
outline: "none",
};
const optionStyle: React.CSSProperties = {
background: "#1a1a1a",
color: "#fff",
};
return (
<LunaSettings>
<LunaSwitchSetting
title="Bar Roundness"
desc="Enable rounded corners on visualizer bars"
checked={barRounding}
onChange={(_, checked) => {
setBarRounding(checked);
settings.barRounding = checked;
}}
{/* Color & Layout */}
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "10px 0",
}}>
<div>
<div style={{ fontWeight: 600, fontSize: "14px", color: "#fff" }}>Color & Layout</div>
<div style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)", marginTop: "2px" }}>
Visualizer color and slot placement
</div>
</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<button
type="button"
onClick={() => showColorPicker ? closeColorPicker() : openColorPicker()}
style={{
width: "28px", height: "28px",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "6px", cursor: "pointer", background: barColor,
overflow: "hidden", position: "relative",
}}
>
<div style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.1)", backdropFilter: "blur(2px)" }} />
</button>
<button
type="button"
onClick={() => showSlotConfig ? closeSlotConfig() : openSlotConfig()}
style={{
padding: "6px 12px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff", cursor: "pointer", fontSize: "12px",
fontWeight: 500, transition: "all 0.2s ease",
whiteSpace: "nowrap",
}}
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.2)"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.1)"; }}
>Configure Slots</button>
</div>
</div>
{/* Color picker modal */}
{shouldRenderColor && (
<>
<button type="button" aria-label="Close color picker" onClick={closeColorPicker} style={backdropStyle(isColorAnimIn)} />
<div style={{ ...panelBaseStyle(isColorAnimIn), minWidth: "320px", maxWidth: "90vw" }}>
<div style={{ marginBottom: "12px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>Choose Color</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "8px", marginBottom: "16px" }}>
{allColors.map((color, index) => {
const isCustom = customColors.includes(color);
const isHovered = hoveredColorIndex === index;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: cosmetic hover tracking on wrapper containing interactive buttons
<div
key={color}
style={{ position: "relative", width: "32px", height: "32px", cursor: "pointer" }}
onMouseEnter={() => setHoveredColorIndex(index)}
onMouseLeave={() => setHoveredColorIndex(null)}
>
<button
type="button"
onClick={() => { updateColor(color); closeColorPicker(); }}
style={{
width: "100%", height: "100%", borderRadius: "6px",
border: barColor === color ? "2px solid #fff" : "1px solid rgba(255,255,255,0.2)",
background: color, cursor: "pointer", transition: "all 0.2s ease",
}}
/>
{isCustom && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeCustomColor(color); }}
style={{
position: "absolute", top: "-4px", right: "-4px",
width: "16px", height: "16px", borderRadius: "50%",
border: "1px solid rgba(255,255,255,0.8)", background: "rgba(0,0,0,0.8)",
color: "#fff", cursor: "pointer", fontSize: "10px",
display: "flex", alignItems: "center", justifyContent: "center",
opacity: isHovered ? 1 : 0, transition: "opacity 0.2s ease", zIndex: 10,
}}
>x</button>
)}
</div>
);
})}
</div>
<div style={{ marginBottom: "12px" }}>
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "12px", marginBottom: "6px" }}>Add Custom Color</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<input
type="text"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { updateColor(customInput); addCustomColor(); } }}
placeholder="#ff69b4"
style={{
flex: 1, padding: "8px 12px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
color: "#fff", fontSize: "14px", fontFamily: "monospace", boxSizing: "border-box",
}}
/>
<button
type="button"
onClick={() => { updateColor(customInput); addCustomColor(); }}
style={{
width: "32px", height: "32px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.3)", background: "rgba(255,255,255,0.15)",
color: "#fff", cursor: "pointer", fontSize: "16px",
display: "flex", alignItems: "center", justifyContent: "center",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.25)"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.15)"; }}
>+</button>
</div>
</div>
<button
type="button"
onClick={closeColorPicker}
style={{
width: "100%", padding: "8px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
color: "#fff", cursor: "pointer", fontSize: "12px",
}}
>Done</button>
</div>
</>
)}
{/* Slot configuration modal */}
{shouldRenderSlot && (
<>
<button type="button" aria-label="Close slot config" onClick={closeSlotConfig} style={backdropStyle(isSlotAnimIn)} />
<div style={{ ...panelBaseStyle(isSlotAnimIn), minWidth: "520px", maxWidth: "90vw", width: "600px" }}>
<div style={{ marginBottom: "16px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>
Configure Visualizer Slots
</div>
{/* Segment control */}
<div style={{
display: "flex", background: "rgba(255,255,255,0.08)",
borderRadius: "10px", padding: "2px", gap: "2px", marginBottom: "20px",
}}>
{zones.map(zone => (
<button
key={zone}
type="button"
onClick={() => setActiveZone(zone)}
style={{
flex: 1, border: "none",
background: activeZone === zone ? "rgba(255,255,255,0.15)" : "transparent",
color: activeZone === zone ? "#fff" : "rgba(255,255,255,0.4)",
fontSize: "12px", fontWeight: 600,
padding: "7px 0", borderRadius: "8px",
cursor: "pointer", transition: "all 0.2s ease",
...(activeZone === zone ? { boxShadow: "0 1px 3px rgba(0,0,0,0.3)" } : {}),
}}
>{ZONE_LABELS[zone]}</button>
))}
</div>
{/* Slot grid */}
<div style={{ display: "flex", gap: "16px", justifyContent: "center" }}>
{zonePositions(activeZone).map(pos => {
const slotKeys = ZONE_SLOTS[activeZone][pos];
if (!slotKeys) return null;
return (
<div key={pos} style={{ flex: 1, minWidth: 0 }}>
<div style={{
color: "rgba(255,255,255,0.6)", fontSize: "11px",
fontWeight: 600, textTransform: "uppercase",
letterSpacing: "0.5px", marginBottom: "8px",
textAlign: "center",
}}>{POSITION_LABELS[pos]}</div>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{slotKeys.map((key, i) => (
<div key={key} style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<select
value={slots[key]}
onChange={(e) => updateSlot(key, e.target.value as VisualizerType)}
style={{ ...selectStyle, flex: 1 }}
title={`Slot ${i + 1}`}
>
{VIZ_TYPES.map(t => (
<option key={t} value={t} style={optionStyle}>{VISUALIZER_LABELS[t]}</option>
))}
</select>
{MINI_SUPPORTED.has(slots[key]) && (
<button
type="button"
title="Mini"
onClick={() => toggleMini(key)}
style={{
width: "28px", height: "28px", flexShrink: 0,
borderRadius: "6px", border: "1px solid rgba(255,255,255,0.2)",
background: miniSlots.has(key) ? "rgba(255,105,180,0.4)" : "rgba(255,255,255,0.08)",
color: miniSlots.has(key) ? "#fff" : "rgba(255,255,255,0.4)",
cursor: "pointer", fontSize: "9px", fontWeight: 700,
display: "flex", alignItems: "center", justifyContent: "center",
transition: "all 0.2s ease",
}}
>M</button>
)}
</div>
))}
</div>
</div>
);
})}
</div>
<button
type="button"
onClick={closeSlotConfig}
style={{
width: "100%", padding: "8px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
color: "#fff", cursor: "pointer", fontSize: "12px", marginTop: "20px",
}}
>Done</button>
</div>
</>
)}
<LunaNumberSetting
title="Reactivity"
desc="How quickly visualizers respond to audio (5-100)"
min={5}
max={100}
step={5}
value={reactivity}
onNumber={(v: number) => { setReactivity(v); settings.reactivity = v; }}
/>
<LunaNumberSetting
title="Gain"
desc="Amplitude boost for spectrum visualizers (0.5-3.0)"
min={0.5}
max={3.0}
step={0.5}
value={gain}
onNumber={(v: number) => { setGain(v); settings.gain = v; }}
/>
<LunaSelectSetting
title="FFT Size"
desc="Frequency resolution (higher = more detail, more CPU)"
value={fftSize}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const v = Number(e.target.value);
setFftSize(v);
settings.fftSize = v;
}}
>
{[256, 512, 1024, 2048, 4096, 8192, 16384].map(s => (
<LunaSelectItem key={s} value={s}>{s}</LunaSelectItem>
))}
</LunaSelectSetting>
<LunaNumberSetting
title="Bar Count"
desc="Number of frequency bars to display"
desc="Number of frequency bars (Spectrum Bars)"
min={8}
max={64}
max={128}
step={1}
value={barCount}
onNumber={(value: number) => {
setBarCount(value);
settings.barCount = value;
onNumber={(v: number) => { setBarCount(v); settings.barCount = v; }}
/>
{hasBars && (
<AnySwitch
title="Bar Rounding"
desc="Round the top corners of spectrum bars"
checked={barRounding}
onChange={(_: unknown, checked: boolean) => {
setBarRounding(checked);
settings.barRounding = checked;
}}
/>
)}
<LunaNumberSetting
title="Line Thickness"
desc="Stroke width for line-based visualizers (0.5-5)"
min={0.5}
max={5}
step={0.5}
value={lineThickness}
onNumber={(v: number) => { setLineThickness(v); settings.lineThickness = v; }}
/>
<LunaNumberSetting
title="Fill Opacity"
desc="Fill below the Spectrum Line curve (0-1)"
min={0}
max={1}
step={0.05}
value={fillOpacity}
onNumber={(v: number) => { setFillOpacity(v); settings.fillOpacity = v; }}
/>
<AnySwitch
title="Scrolling Oscilloscope"
desc="Waveform scrolls right-to-left like a chart recorder"
checked={scrollingOscilloscope}
onChange={(_: unknown, checked: boolean) => {
setScrollingOscilloscope(checked);
settings.scrollingOscilloscope = checked;
}}
/>
{/* YUP YOUR EYES WORK... we do be using React code in the settings..*/}
{/* I'm not sure if this is a good idea, but it works & looks amazing */}
{/* Sorry @Inrixia <3 */}
<div
style={{
padding: "16px 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
<AnySwitch
title="Lissajous Mode"
desc="Rotate the Vectorscope 45° for Lissajous display"
checked={lissajous}
onChange={(_: unknown, checked: boolean) => {
setLissajous(checked);
settings.lissajous = checked;
}}
>
<div>
<div
style={{
fontWeight: "normal",
fontSize: "1.075rem",
marginBottom: "4px",
}}
>
Bar Color
</div>
<div style={{ opacity: 0.7, fontSize: "14px" }}>
Color of the visualizer bars
</div>
</div>
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
position: "relative",
}}
>
<button
onClick={() =>
showColorPicker ? closeColorPicker() : openColorPicker()
}
style={{
width: "32px",
height: "32px",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "6px",
cursor: "pointer",
background: barColor,
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
position: "relative",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.1)",
backdropFilter: "blur(2px)",
}}
/>
</button>
{/* Custom Color Picker Modal */}
{shouldRender && (
<>
{/* Backdrop */}
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.6)",
zIndex: 1000,
opacity: isAnimatingIn ? 1 : 0,
transition: "opacity 0.2s ease",
}}
onClick={closeColorPicker}
/>
{/* Color Picker Panel */}
<div
style={{
position: "fixed",
top: "50%",
left: "50%",
background: "rgba(20,20,20,0.98)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "16px",
padding: "20px",
minWidth: "320px",
maxWidth: "90vw",
maxHeight: "90vh",
zIndex: 1001,
boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
opacity: isAnimatingIn ? 1 : 0,
transform: isAnimatingIn
? "translate(-50%, -50%) scale(1)"
: "translate(-50%, -50%) scale(0.9)",
transition: "all 0.2s ease",
}}
>
<div
style={{
marginBottom: "12px",
color: "#fff",
fontWeight: "bold",
fontSize: "14px",
}}
>
Choose Color
</div>
{/* Color Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "8px",
marginBottom: "16px",
}}
>
{allColors.map((color, index) => {
const isCustomColor = customColors.includes(color);
const isHovered = hoveredColorIndex === index;
return (
<div
key={index}
style={{
position: "relative",
width: "32px",
height: "32px",
cursor: "pointer",
}}
className="color-item"
onMouseEnter={() => setHoveredColorIndex(index)}
onMouseLeave={() => setHoveredColorIndex(null)}
>
<button
onClick={() => {
updateColor(color);
closeColorPicker();
}}
style={{
width: "100%",
height: "100%",
borderRadius: "6px",
border:
barColor === color
? "2px solid #fff"
: "1px solid rgba(255,255,255,0.2)",
background: color,
cursor: "pointer",
transition: "all 0.2s ease",
}}
/>
{isCustomColor && (
<button
onClick={(e) => {
e.stopPropagation();
removeCustomColor(color);
}}
style={{
position: "absolute",
top: "-4px",
right: "-4px",
width: "16px",
height: "16px",
borderRadius: "50%",
border: "1px solid rgba(255,255,255,0.8)",
background: "rgba(0,0,0,0.8)",
color: "#fff",
cursor: "pointer",
fontSize: "10px",
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: isHovered ? 1 : 0,
transition: "opacity 0.2s ease",
zIndex: 10,
}}
className="remove-button"
>
×
</button>
)}
</div>
);
})}
</div>
{/* Custom Hex Input */}
<div style={{ marginBottom: "12px" }}>
<div
style={{
color: "rgba(255,255,255,0.7)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Add Custom Color
</div>
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
}}
>
<input
type="text"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
updateColor(customInput);
addCustomColor();
}
}}
placeholder="#ffffff"
style={{
flex: 1,
padding: "8px 12px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
fontSize: "14px",
fontFamily: "monospace",
boxSizing: "border-box",
}}
/>
<button
onClick={() => {
updateColor(customInput);
addCustomColor();
}}
style={{
width: "32px",
height: "32px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.3)",
background: "rgba(255,255,255,0.15)",
color: "#fff",
cursor: "pointer",
fontSize: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"rgba(255,255,255,0.25)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background =
"rgba(255,255,255,0.15)";
}}
>
+
</button>
</div>
</div>
{/* Close Button (Done) - Also runs when color chosen*/}
<button
onClick={closeColorPicker}
style={{
width: "100%",
padding: "8px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
cursor: "pointer",
fontSize: "12px",
}}
>
Done
</button>
</div>
</>
)}
</div>
</div>
/>
</LunaSettings>
);
};
+209
View File
@@ -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;
};
+411 -218
View File
@@ -1,282 +1,475 @@
import { type LunaUnload, Tracer } from "@luna/core";
import { StyleTag, PlayState, MediaItem, observe } from "@luna/lib";
import { settings, Settings } from "./Settings";
import * as audio from "./audio";
import type { AudioData } from "./audio";
import { type Visualizer, type VisualizerType, VISUALIZER_DIMENSIONS, MINI_DIMENSIONS, ALL_SLOT_KEYS, ZONE_SLOTS, type SlotKey } from "./visualizers/types";
import { createSpectrumLine } from "./visualizers/spectrum-line";
import { createSpectrumBars } from "./visualizers/spectrum-bars";
import { createOscilloscope } from "./visualizers/oscilloscope";
import { createVectorscope } from "./visualizers/vectorscope";
import { createLoudnessMeter } from "./visualizers/loudness-meter";
import visualizerStyles from "file://styles.css?minify";
export const { trace } = Tracer("[Audio Visualizer]");
export { Settings };
const log = (message: string) => console.log(`[Audio Visualizer] ${message}`);
const config = {
width: 200,
height: 40,
get barCount() {
return settings.barCount;
},
get color() {
return settings.barColor;
},
get barRounding() {
return settings.barRounding;
},
sensitivity: 1.5,
smoothing: 0.8,
};
const log = (msg: string) => console.log(`[Audio Visualizer] ${msg}`);
export const unloads = new Set<LunaUnload>();
new StyleTag("AudioVisualizer", unloads, visualizerStyles);
const FACTORIES: Record<Exclude<VisualizerType, "none">, () => Visualizer> = {
"spectrum-line": createSpectrumLine,
"spectrum-bars": createSpectrumBars,
oscilloscope: createOscilloscope,
vectorscope: createVectorscope,
"loudness-meter": createLoudnessMeter,
};
let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let audioSource: MediaStreamAudioSourceNode | null = null;
let dataArray: Uint8Array<ArrayBuffer> | null = null;
let animationId: number | null = null;
let isSourceConnected = false;
// Slot Management
interface VisualizerSlot {
interface Slot {
container: HTMLDivElement | null;
canvas: HTMLCanvasElement | null;
ctx: CanvasRenderingContext2D | null;
visualizer: Visualizer | null;
currentType: VisualizerType;
contextType: "webgl" | "canvas2d" | null;
}
const navSlot: VisualizerSlot = { container: null, canvas: null, ctx: null };
const npSlot: VisualizerSlot = { container: null, canvas: null, ctx: null };
interface SlotGroup {
groupContainer: HTMLDivElement;
slots: Slot[];
keys: readonly SlotKey[];
}
const groups = new Map<string, SlotGroup>();
let navArrowsEl: HTMLElement | null = null;
const connectAudio = (): boolean => {
const video = document.getElementById("video-one") as HTMLVideoElement | null;
const capture = (video as unknown as { captureStream?: () => MediaStream })?.captureStream;
if (!video || typeof capture !== "function") return false;
const getSlot = (key: SlotKey): VisualizerType =>
(settings as unknown as Record<string, VisualizerType>)[key] ?? "none";
try {
if (!audioContext) audioContext = new AudioContext();
const isWebGLViz = (type: VisualizerType): boolean =>
type === "spectrum-line" || type === "spectrum-bars";
if (!analyser) {
analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = config.smoothing;
dataArray = new Uint8Array(analyser.frequencyBinCount) as Uint8Array<ArrayBuffer>;
}
const isMiniSlot = (key: SlotKey): boolean =>
(settings.miniSlots ?? []).includes(key);
audioSource?.disconnect();
const getSlotDims = (type: VisualizerType, key: SlotKey) =>
isMiniSlot(key) && MINI_DIMENSIONS[type] ? MINI_DIMENSIONS[type] : VISUALIZER_DIMENSIONS[type];
const stream = capture.call(video);
const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
log("No audio tracks in captured stream");
return false;
}
const createSlotCanvas = (dims: { width: number; height: number }): HTMLCanvasElement => {
const cvs = document.createElement("canvas");
cvs.width = dims.width;
cvs.height = dims.height;
cvs.style.cssText = `width:${dims.width}px;height:${dims.height}px;border-radius:4px;display:block;`;
return cvs;
};
audioSource = audioContext.createMediaStreamSource(stream);
audioSource.connect(analyser);
const applySlotSize = (slot: Slot, dims: { width: number; height: number }): void => {
if (!slot.container || !slot.canvas) return;
slot.canvas.width = dims.width;
slot.canvas.height = dims.height;
slot.canvas.style.width = `${dims.width}px`;
slot.canvas.style.height = `${dims.height}px`;
slot.container.style.width = `${dims.width + 8}px`;
slot.container.style.height = `${dims.height + 8}px`;
slot.visualizer?.resize(dims.width, dims.height);
};
if (audioContext.state === "suspended") {
audioContext.resume().catch(() => {});
}
const switchVisualizer = (slot: Slot, type: VisualizerType, key: SlotKey): void => {
if (slot.currentType === type) return;
log("Connected via captureStream()");
return true;
} catch (err) {
log(`Audio connection failed: ${err}`);
return false;
slot.visualizer?.dispose();
slot.visualizer = null;
if (type === "none") {
if (slot.container) slot.container.style.display = "none";
slot.currentType = "none";
return;
}
const dims = getSlotDims(type, key);
if (slot.container) {
slot.canvas?.remove();
const cvs = createSlotCanvas(dims);
slot.container.appendChild(cvs);
slot.canvas = cvs;
slot.contextType = isWebGLViz(type) ? "webgl" : "canvas2d";
slot.container.style.display = "flex";
slot.container.style.width = `${dims.width + 8}px`;
slot.container.style.height = `${dims.height + 8}px`;
}
const factory = FACTORIES[type];
const viz = factory();
if (slot.canvas) {
viz.init(slot.canvas, settings.barColor);
}
slot.visualizer = viz;
slot.currentType = type;
};
const syncGroupHeights = (group: SlotGroup): void => {
let maxH = 0;
for (let i = 0; i < group.keys.length; i++) {
const slot = group.slots[i];
if (slot.currentType === "none") continue;
const dims = getSlotDims(slot.currentType, group.keys[i]);
if (dims.height > maxH) maxH = dims.height;
}
if (maxH === 0) return;
for (let i = 0; i < group.keys.length; i++) {
const slot = group.slots[i];
if (!slot.container || !slot.canvas || slot.currentType === "none") continue;
const dims = getSlotDims(slot.currentType, group.keys[i]);
const targetH = Math.max(dims.height, maxH);
applySlotSize(slot, { width: dims.width, height: targetH });
}
};
// Canvas things
const updateGroupVisibility = (group: SlotGroup): void => {
const allNone = group.slots.every(s => s.currentType === "none");
group.groupContainer.style.display = allNone ? "none" : "flex";
if (!allNone) syncGroupHeights(group);
const makeSlotElements = (): { container: HTMLDivElement; canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null => {
const container = document.createElement("div");
container.className = "audio-visualizer-container";
container.style.cssText = `
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 4px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
`;
const cvs = document.createElement("canvas");
cvs.width = config.width;
cvs.height = config.height;
cvs.style.cssText = `
width: ${config.width}px;
height: ${config.height}px;
border-radius: 4px;
`;
container.appendChild(cvs);
const ctx = cvs.getContext("2d");
if (!ctx) return null;
return { container, canvas: cvs, ctx };
if (group === groups.get("topNav-left") && navArrowsEl) {
navArrowsEl.style.marginRight = allNone ? "" : "0";
}
};
const clearSlot = (slot: VisualizerSlot): void => {
slot.container?.remove();
slot.container = null;
slot.canvas = null;
slot.ctx = null;
const createGroup = (keys: readonly SlotKey[], zone: string, position: string): SlotGroup => {
const groupContainer = document.createElement("div");
groupContainer.className = "av-slot-group";
groupContainer.dataset.zone = zone;
groupContainer.dataset.position = position;
const slots: Slot[] = [];
for (const _key of keys) {
const slotContainer = document.createElement("div");
slotContainer.className = "audio-visualizer-container";
slotContainer.style.display = "none";
groupContainer.appendChild(slotContainer);
slots.push({
container: slotContainer,
canvas: null,
visualizer: null,
currentType: "none",
contextType: null,
});
}
return { groupContainer, slots, keys };
};
// UI Placement with Luna Observer
const initGroupVisualizers = (group: SlotGroup): void => {
for (let i = 0; i < group.keys.length; i++) {
const key = group.keys[i];
const type = getSlot(key);
if (type !== "none") {
switchVisualizer(group.slots[i], type, key);
}
}
updateGroupVisibility(group);
};
const attachNavSlot = (anchor: Element): void => {
if (navSlot.container?.isConnected) return;
clearSlot(navSlot);
const initAllGroups = (): void => {
for (const [zoneId, positions] of Object.entries(ZONE_SLOTS)) {
for (const [posId, keys] of Object.entries(positions)) {
if (!keys) continue;
const groupId = `${zoneId}-${posId}`;
const group = createGroup(keys, zoneId, posId);
groups.set(groupId, group);
}
}
};
// UI Attachment
const attachNavGroups = (anchor: Element): void => {
const parent = anchor.parentElement;
if (!parent) return;
const els = makeSlotElements();
if (!els) return;
els.container.style.marginRight = "12px";
Object.assign(navSlot, els);
parent.insertBefore(els.container, anchor);
const navLeft = groups.get("topNav-left");
if (navLeft && !navLeft.groupContainer.isConnected) {
const navArrows = parent.querySelector('[data-test="navigation-arrows"]') as HTMLElement | null;
if (navArrows) {
navArrowsEl = navArrows;
navArrows.after(navLeft.groupContainer);
} else {
parent.prepend(navLeft.groupContainer);
}
navLeft.groupContainer.style.marginRight = "auto";
initGroupVisualizers(navLeft);
}
const navRight = groups.get("topNav-right");
if (navRight && !navRight.groupContainer.isConnected) {
parent.insertBefore(navRight.groupContainer, anchor);
initGroupVisualizers(navRight);
}
};
const attachNpSlot = (anchor: Element): void => {
if (npSlot.container?.isConnected) return;
clearSlot(npSlot);
const attachNpGroups = (anchor: Element): void => {
const leftContent = anchor.parentElement;
if (!leftContent) return;
const header = leftContent.parentElement as HTMLElement | null;
if (!header) return;
const parent = anchor.parentElement;
if (!parent) return;
const npLeft = groups.get("nowPlaying-left");
if (npLeft && !npLeft.groupContainer.isConnected) {
leftContent.insertBefore(npLeft.groupContainer, anchor.nextSibling);
initGroupVisualizers(npLeft);
}
const els = makeSlotElements();
if (!els) return;
els.container.style.marginLeft = "12px";
Object.assign(npSlot, els);
parent.insertBefore(els.container, anchor.nextSibling);
const buttonsDiv = header.querySelector(':scope > [class*="buttons"]') as HTMLElement | null;
const npRight = groups.get("nowPlaying-right");
if (npRight && !npRight.groupContainer.isConnected) {
if (buttonsDiv) {
header.insertBefore(npRight.groupContainer, buttonsDiv);
} else {
header.appendChild(npRight.groupContainer);
}
npRight.groupContainer.style.marginLeft = "auto";
initGroupVisualizers(npRight);
}
};
observe(unloads, '[data-test="search-popover-container"]', attachNavSlot);
observe(unloads, '[data-test="artist-info"]', attachNpSlot);
const attachPbGroups = (anchor: Element): void => {
const trackInfo = anchor.querySelector('[data-test="track-info"]');
const utilityContainer = anchor.querySelector('[class*="utilityContainer"]');
// Rendering things
const pbLeft = groups.get("playerBar-left");
if (pbLeft && !pbLeft.groupContainer.isConnected && trackInfo) {
trackInfo.appendChild(pbLeft.groupContainer);
initGroupVisualizers(pbLeft);
}
const pbRight = groups.get("playerBar-right");
if (pbRight && !pbRight.groupContainer.isConnected && utilityContainer) {
utilityContainer.prepend(pbRight.groupContainer);
initGroupVisualizers(pbRight);
}
};
initAllGroups();
observe(unloads, '[data-test="search-popover-container"]', attachNavGroups);
observe(unloads, '[data-test="artist-info"]', attachNpGroups);
observe(unloads, '[data-test="footer-player"]', attachPbGroups);
const existingSearch = document.querySelector('[data-test="search-popover-container"]');
if (existingSearch) attachNavGroups(existingSearch);
const existingArtist = document.querySelector('[data-test="artist-info"]');
if (existingArtist) attachNpGroups(existingArtist);
const existingFooter = document.querySelector('[data-test="footer-player"]');
if (existingFooter) attachPbGroups(existingFooter);
// Audio Connection stuff
const fft = () => settings.fftSize ?? 2048;
const reactivityToSmoothing = (r: number) => Math.max(0, Math.min(0.95, (100 - r) / 100));
const smooth = () => reactivityToSmoothing(settings.reactivity ?? 30);
let lastReactivity = settings.reactivity ?? 30;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let retryDelay = 500;
const MAX_RETRY_DELAY = 5000;
let silentFrames = 0;
const SILENT_THRESHOLD = 120;
const clearRetry = (): void => {
if (retryTimer !== null) {
clearTimeout(retryTimer);
retryTimer = null;
}
retryDelay = 500;
};
const tryConnect = (): boolean => {
const ok = audio.connect(fft(), smooth());
if (ok) {
clearRetry();
silentFrames = 0;
}
return ok;
};
const tryReconnect = (): boolean => {
const ok = audio.reconnect(fft(), smooth());
if (ok) {
clearRetry();
silentFrames = 0;
}
return ok;
};
const scheduleRetry = (): void => {
if (retryTimer !== null) return;
retryTimer = setTimeout(() => {
retryTimer = null;
if (!PlayState.playing) return;
if (!tryConnect()) {
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
scheduleRetry();
}
}, retryDelay);
};
observe(unloads, "#video-one", () => {
log("video-one element observed in DOM");
silentFrames = 0;
if (PlayState.playing) {
if (!tryReconnect()) scheduleRetry();
}
});
PlayState.onState(unloads, (state) => {
if (state === "PLAYING") {
silentFrames = 0;
if (!audio.isConnected() || audio.videoChanged()) {
if (!tryReconnect()) scheduleRetry();
}
} else {
clearRetry();
}
});
MediaItem.onMediaTransition(unloads, () => {
log("Media transition");
silentFrames = 0;
setTimeout(() => {
if (PlayState.playing) {
if (!tryReconnect()) scheduleRetry();
}
}, 300);
});
// Idle Animation Synthetic Data
let waveTime = 0;
const IDLE_SIZE = 1024;
const idleByteFreq = new Uint8Array(IDLE_SIZE);
const idleByteTime = new Uint8Array(IDLE_SIZE);
const idleFloatFreq = new Float32Array(IDLE_SIZE);
const idleFloatTime = new Float32Array(IDLE_SIZE);
const idleLeftTime = new Float32Array(IDLE_SIZE);
const idleRightTime = new Float32Array(IDLE_SIZE);
const drawRoundedRect = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void => {
ctx.beginPath();
ctx.roundRect(x, y, width, height, radius);
ctx.fill();
};
const drawScrollingWave = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => {
const barCount = config.barCount;
const barWidth = cvs.width / barCount;
const maxHeight = cvs.height * 0.6;
ctx.fillStyle = config.color;
for (let i = 0; i < barCount; i++) {
const x = i / barCount;
const generateIdleData = (): AudioData => {
for (let i = 0; i < IDLE_SIZE; i++) {
const x = i / IDLE_SIZE;
const wave1 = Math.sin(x * Math.PI * 2 + waveTime) * 0.3;
const wave2 = Math.sin(x * Math.PI * 4 + waveTime * 1.3) * 0.2;
const wave3 = Math.sin(x * Math.PI * 6 + waveTime * 0.7) * 0.1;
const combinedWave = (wave1 + wave2 + wave3 + 1) / 2;
const travelWave = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5;
const barHeight = maxHeight * combinedWave * travelWave * 0.8 + 2;
const combined = (wave1 + wave2 + wave3 + 1) / 2;
const travel = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5;
const xPos = i * barWidth;
const yPos = (cvs.height - barHeight) / 2;
const byteVal = Math.floor(combined * travel * 140 + 20);
idleByteFreq[i] = byteVal;
idleFloatFreq[i] = -40 + byteVal * 0.3;
if (config.barRounding) {
drawRoundedRect(ctx, xPos, yPos, barWidth - 1, barHeight, 2);
} else {
ctx.fillRect(xPos, yPos, barWidth - 1, barHeight);
}
const timeSample = Math.sin(x * Math.PI * 8 + waveTime * 3) * 0.15;
idleByteTime[i] = 128 + Math.floor(timeSample * 127);
idleFloatTime[i] = timeSample;
idleLeftTime[i] = timeSample;
idleRightTime[i] = Math.sin(x * Math.PI * 8 + waveTime * 3 + 0.3) * 0.15;
}
};
const drawBars = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => {
if (!dataArray) return;
const barWidth = cvs.width / config.barCount;
const heightScale = cvs.height / 255;
ctx.fillStyle = config.color;
for (let i = 0; i < config.barCount; i++) {
const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
const barHeight = dataArray[dataIndex] * config.sensitivity * heightScale;
const x = i * barWidth;
const y = cvs.height - barHeight;
if (config.barRounding) {
drawRoundedRect(ctx, x, y, barWidth - 1, barHeight, 2);
} else {
ctx.fillRect(x, y, barWidth - 1, barHeight);
}
}
return {
byteFrequency: idleByteFreq,
byteTimeDomain: idleByteTime,
floatFrequency: idleFloatFreq,
floatTimeDomain: idleFloatTime,
leftTimeDomain: idleLeftTime,
rightTimeDomain: idleRightTime,
sampleRate: 44100,
fftSize: IDLE_SIZE * 2,
binCount: IDLE_SIZE,
};
};
// Animation Loop
let animationId: number | null = null;
const lastSlotTypes = new Map<SlotKey, VisualizerType>();
const lastMiniState = new Map<SlotKey, boolean>();
for (const key of ALL_SLOT_KEYS) {
lastSlotTypes.set(key, getSlot(key));
lastMiniState.set(key, isMiniSlot(key));
}
const animate = (): void => {
const slots = [navSlot, npSlot].filter(s => s.ctx && s.canvas);
if (slots.length > 0) {
waveTime += 0.05;
let hasRealAudio = false;
if (analyser && dataArray) {
analyser.getByteFrequencyData(dataArray);
const avgVolume = dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length;
hasRealAudio = avgVolume > 5;
}
for (const slot of slots) {
const { ctx, canvas: cvs } = slot;
if (!ctx || !cvs) continue;
ctx.clearRect(0, 0, cvs.width, cvs.height);
if (hasRealAudio) {
drawBars(ctx, cvs);
} else {
drawScrollingWave(ctx, cvs);
for (const group of groups.values()) {
let changed = false;
for (let i = 0; i < group.keys.length; i++) {
const key = group.keys[i];
const currentType = getSlot(key);
const lastType = lastSlotTypes.get(key) ?? "none";
const mini = isMiniSlot(key);
const wasMini = lastMiniState.get(key) ?? false;
if (currentType !== lastType) {
switchVisualizer(group.slots[i], currentType, key);
lastSlotTypes.set(key, currentType);
lastMiniState.set(key, mini);
changed = true;
} else if (mini !== wasMini && currentType !== "none") {
const dims = getSlotDims(currentType, key);
applySlotSize(group.slots[i], dims);
lastMiniState.set(key, mini);
changed = true;
}
}
if (changed) updateGroupVisibility(group);
}
const currentReactivity = settings.reactivity ?? 30;
if (currentReactivity !== lastReactivity) {
audio.setSmoothing(reactivityToSmoothing(currentReactivity));
lastReactivity = currentReactivity;
}
waveTime += 0.05;
const data = audio.sample();
const hasSignal = data && audio.hasSignal(data);
if (PlayState.playing && audio.isConnected()) {
if (!hasSignal) {
silentFrames++;
if (silentFrames >= SILENT_THRESHOLD) {
log("Silent for too long, reconnecting...");
silentFrames = 0;
if (!tryReconnect()) scheduleRetry();
}
} else {
silentFrames = 0;
}
} else if (PlayState.playing && !audio.isConnected() && retryTimer === null) {
scheduleRetry();
}
const renderData = hasSignal ? data : generateIdleData();
for (const group of groups.values()) {
for (const slot of group.slots) {
if (!slot.canvas || slot.currentType === "none" || !slot.visualizer) continue;
slot.visualizer.render(renderData, settings.barColor);
}
}
animationId = requestAnimationFrame(animate);
};
// Initialization (events)
PlayState.onState(unloads, (state) => {
if (state === "PLAYING" && !isSourceConnected) {
isSourceConnected = connectAudio();
}
});
MediaItem.onMediaTransition(unloads, () => {
isSourceConnected = false;
if (PlayState.playing) {
isSourceConnected = connectAudio();
}
});
// Initialization (startup)
// Init
log("Initializing...");
if (PlayState.playing) {
isSourceConnected = connectAudio();
if (!tryConnect()) scheduleRetry();
}
animationId = requestAnimationFrame(animate);
@@ -285,24 +478,24 @@ animationId = requestAnimationFrame(animate);
unloads.add(() => {
log("Plugin unloading");
clearRetry();
if (navArrowsEl) {
navArrowsEl.style.marginRight = "";
navArrowsEl = null;
}
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
clearSlot(navSlot);
clearSlot(npSlot);
try { audioSource?.disconnect(); } catch {}
if (audioContext && audioContext.state !== "closed") {
audioContext.close();
for (const group of groups.values()) {
for (const slot of group.slots) {
slot.visualizer?.dispose();
}
group.groupContainer.remove();
}
audioContext = null;
analyser = null;
audioSource = null;
dataArray = null;
isSourceConnected = false;
groups.clear();
audio.dispose();
});
+39 -19
View File
@@ -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;
},
};
};
+151
View File
@@ -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];
};
+183 -38
View File
@@ -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);
@@ -537,25 +590,109 @@ export const Settings = () => {
transition: "all 0.2s ease",
}}
>
<div
style={{
marginBottom: "12px",
color: "#fff",
fontWeight: "bold",
fontSize: "14px",
}}
>
Choose Tint Color
</div>
<div
style={{
marginBottom: "12px",
color: "#fff",
fontWeight: "bold",
fontSize: "14px",
}}
>
Choose Tint Color
</div>
<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={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "8px",
marginBottom: "16px",
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) => {
const isCustomColor = tintCustomColors.includes(color);
const isHovered = tintHoveredColorIndex === index;
@@ -626,16 +763,24 @@ export const Settings = () => {
})}
</div>
<div style={{ marginBottom: "12px" }}>
<div
style={{
color: "rgba(255,255,255,0.7)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Add Custom Color
</div>
<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)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Add Custom Color
</div>
<div
style={{
display: "flex",
+245 -38
View File
@@ -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;
const alpha = settings.playerBarTint / 10;
const { r, g, b } = hexToRgb(settings.playerBarTintColor);
footerPlayer.style.setProperty(
"background-color",
`rgba(${r}, ${g}, ${b}, ${alpha})`,
"important",
);
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",
+150
View File
@@ -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 */
@@ -266,4 +350,70 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
/* Hide fullscreen button — breaks Radiant 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;
}