Merge pull request #104 from meowarex/dev

Overhaul Audio Visualizer & RL UI Improvements
This commit is contained in:
Meow Meow
2026-04-03 23:11:39 +11:00
committed by GitHub
14 changed files with 2657 additions and 942 deletions
+458 -321
View File
@@ -3,434 +3,571 @@ import {
LunaSettings, LunaSettings,
LunaNumberSetting, LunaNumberSetting,
LunaSwitchSetting, LunaSwitchSetting,
LunaTextSetting, LunaSelectSetting,
LunaSelectItem,
} from "@luna/ui"; } from "@luna/ui";
import React from "react"; 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( export const settings = await ReactiveStore.getPluginStorage(
"AudioVisualizer", "AudioVisualizer",
{ {
barCount: 32, navLeft1: "none" as VisualizerType,
barColor: "#ffffff", 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, barRounding: true,
lineThickness: 2.0,
fillOpacity: 0.6,
opacityFalloff: 0.5,
lissajous: false,
scrollingOscilloscope: false,
miniSlots: [] as string[],
customColors: [] 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 = () => { export const Settings = () => {
const [barCount, setBarCount] = React.useState(settings.barCount);
const [barColor, setBarColor] = React.useState(settings.barColor); 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 [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 [showColorPicker, setShowColorPicker] = React.useState(false);
const [isAnimatingIn, setIsAnimatingIn] = React.useState(false); const [isColorAnimIn, setIsColorAnimIn] = React.useState(false);
const [shouldRender, setShouldRender] = React.useState(false); const [shouldRenderColor, setShouldRenderColor] = React.useState(false);
const [customInput, setCustomInput] = React.useState(settings.barColor); const [customInput, setCustomInput] = React.useState(settings.barColor);
const [customColors, setCustomColors] = React.useState(settings.customColors); const [customColors, setCustomColors] = React.useState(settings.customColors);
const [hoveredColorIndex, setHoveredColorIndex] = React.useState< const [hoveredColorIndex, setHoveredColorIndex] = React.useState<number | null>(null);
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 = () => { const closeColorPicker = () => {
setIsAnimatingIn(false); setIsColorAnimIn(false);
setTimeout(() => { setTimeout(() => { setShowColorPicker(false); setShouldRenderColor(false); }, 200);
setShowColorPicker(false);
setShouldRender(false);
}, 200); // Wait for animation to complete because i need to
}; };
const openColorPicker = () => { const openColorPicker = () => {
setShowColorPicker(true); setShowColorPicker(true);
setShouldRender(true); setShouldRenderColor(true);
setTimeout(() => setIsAnimatingIn(true), 10); 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(() => { React.useEffect(() => {
if (showColorPicker) { if (showColorPicker) {
setShouldRender(true); setShouldRenderColor(true);
setTimeout(() => setIsAnimatingIn(true), 10); setTimeout(() => setIsColorAnimIn(true), 10);
} }
}, [showColorPicker]); }, [showColorPicker]);
// Common color presets for cool points :D React.useEffect(() => {
if (showSlotConfig) {
setShouldRenderSlot(true);
setTimeout(() => setIsSlotAnimIn(true), 10);
}
}, [showSlotConfig]);
const colorPresets = [ const colorPresets = [
"#ffffff", "#ff69b4", "#ff1493", "#e91e8a", "#c71585",
"#ff0000", "#ff006e", "#ff4da6", "#ff85c8", "#ffb3d9",
"#00ff00", "#ffffff", "#ff0000", "#00ff00", "#0000ff",
"#0000ff", "#ffff00", "#ff00ff", "#00ffff", "#ff8800",
"#ffff00", "#8800ff", "#0088ff", "#1db954", "#444444",
"#ff00ff",
"#00ffff",
"#ff8800",
"#8800ff",
"#0088ff",
"#88ff00",
"#ff0088",
"#00ff88",
"#444444",
"#888888",
"#cccccc",
"#1db954",
"#e22134",
"#1976d2",
]; ];
const updateColor = (color: string) => { const updateColor = (color: string) => {
setBarColor(color); setBarColor(color);
setCustomInput(color); setCustomInput(color);
settings.barColor = color; settings.barColor = color;
(window as any).updateAudioVisualizer?.();
}; };
const addCustomColor = () => { const addCustomColor = () => {
if (customInput) { if (customInput) {
// Trim whitespace and convert to lowercase const trimmed = customInput.trim().toLowerCase();
const trimmedInput = customInput.trim().toLowerCase(); const hexRe = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
if (hexRe.test(trimmed) && !colorPresets.includes(trimmed) && !customColors.includes(trimmed)) {
// Validate hex color format const nc = [...customColors, trimmed];
const hexColorRegex = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i; setCustomColors(nc);
settings.customColors = nc;
if (
hexColorRegex.test(trimmedInput) &&
!colorPresets.includes(trimmedInput) &&
!customColors.includes(trimmedInput)
) {
const newCustomColors = [...customColors, trimmedInput];
setCustomColors(newCustomColors);
settings.customColors = newCustomColors;
} }
} }
}; };
const removeCustomColor = (colorToRemove: string) => { const removeCustomColor = (c: string) => {
const newCustomColors = customColors.filter( const nc = customColors.filter(x => x !== c);
(color) => color !== colorToRemove, setCustomColors(nc);
); settings.customColors = nc;
setCustomColors(newCustomColors); if (barColor === c) updateColor("#ff69b4");
settings.customColors = newCustomColors;
// If the removed color was the selected color (reset to white)
if (barColor === colorToRemove) {
updateColor("#ffffff");
}
}; };
const allColors = [...colorPresets, ...customColors]; 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 ( return (
<LunaSettings> <LunaSettings>
<LunaSwitchSetting {/* Color & Layout */}
title="Bar Roundness" <div style={{
desc="Enable rounded corners on visualizer bars" display: "flex", justifyContent: "space-between", alignItems: "center",
checked={barRounding} padding: "10px 0",
onChange={(_, checked) => { }}>
setBarRounding(checked);
settings.barRounding = checked;
(window as any).updateAudioVisualizer?.();
}}
/>
<LunaNumberSetting
title="Bar Count"
desc="Number of frequency bars to display"
min={8}
max={64}
step={1}
value={barCount}
onNumber={(value: number) => {
setBarCount(value);
settings.barCount = value;
(window as any).updateAudioVisualizer?.();
}}
/>
{/* YUP YOUR EYES WORK... we do be using React code in the settings..*/}
{/* I'm not sure if this is a good idea, but it works & looks amazing */}
{/* Sorry @Inrixia <3 */}
<div
style={{
padding: "16px 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div> <div>
<div <div style={{ fontWeight: 600, fontSize: "14px", color: "#fff" }}>Color & Layout</div>
style={{ <div style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)", marginTop: "2px" }}>
fontWeight: "normal", Visualizer color and slot placement
fontSize: "1.075rem",
marginBottom: "4px",
}}
>
Bar Color
</div>
<div style={{ opacity: 0.7, fontSize: "14px" }}>
Color of the visualizer bars
</div> </div>
</div> </div>
<div <div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
style={{
display: "flex",
gap: "8px",
alignItems: "center",
position: "relative",
}}
>
<button <button
onClick={() => type="button"
showColorPicker ? closeColorPicker() : openColorPicker() onClick={() => showColorPicker ? closeColorPicker() : openColorPicker()}
}
style={{ style={{
width: "32px", width: "28px", height: "28px",
height: "32px",
border: "1px solid rgba(255,255,255,0.15)", border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "6px", borderRadius: "6px", cursor: "pointer", background: barColor,
cursor: "pointer", overflow: "hidden", position: "relative",
background: barColor,
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
position: "relative",
overflow: "hidden",
}} }}
> >
<div <div style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.1)", backdropFilter: "blur(2px)" }} />
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.1)",
backdropFilter: "blur(2px)",
}}
/>
</button> </button>
<button
{/* Custom Color Picker Modal */} type="button"
{shouldRender && ( onClick={() => showSlotConfig ? closeSlotConfig() : openSlotConfig()}
<>
{/* Backdrop */}
<div
style={{ style={{
position: "fixed", padding: "6px 12px", borderRadius: "6px",
top: 0, border: "1px solid rgba(255,255,255,0.2)",
left: 0, background: "rgba(255,255,255,0.1)",
right: 0, color: "#fff", cursor: "pointer", fontSize: "12px",
bottom: 0, fontWeight: 500, transition: "all 0.2s ease",
background: "rgba(0,0,0,0.6)", whiteSpace: "nowrap",
zIndex: 1000,
opacity: isAnimatingIn ? 1 : 0,
transition: "opacity 0.2s ease",
}} }}
onClick={closeColorPicker} 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>
{/* Color Picker Panel */} </div>
<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> </div>
{/* Color Grid */} {/* Color picker modal */}
<div {shouldRenderColor && (
style={{ <>
display: "grid", <button type="button" aria-label="Close color picker" onClick={closeColorPicker} style={backdropStyle(isColorAnimIn)} />
gridTemplateColumns: "repeat(7, 1fr)", <div style={{ ...panelBaseStyle(isColorAnimIn), minWidth: "320px", maxWidth: "90vw" }}>
gap: "8px", <div style={{ marginBottom: "12px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>Choose Color</div>
marginBottom: "16px",
}} <div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "8px", marginBottom: "16px" }}>
>
{allColors.map((color, index) => { {allColors.map((color, index) => {
const isCustomColor = customColors.includes(color); const isCustom = customColors.includes(color);
const isHovered = hoveredColorIndex === index; const isHovered = hoveredColorIndex === index;
return ( return (
// biome-ignore lint/a11y/noStaticElementInteractions: cosmetic hover tracking on wrapper containing interactive buttons
<div <div
key={index} key={color}
style={{ style={{ position: "relative", width: "32px", height: "32px", cursor: "pointer" }}
position: "relative",
width: "32px",
height: "32px",
cursor: "pointer",
}}
className="color-item"
onMouseEnter={() => setHoveredColorIndex(index)} onMouseEnter={() => setHoveredColorIndex(index)}
onMouseLeave={() => setHoveredColorIndex(null)} onMouseLeave={() => setHoveredColorIndex(null)}
> >
<button <button
onClick={() => { type="button"
updateColor(color); onClick={() => { updateColor(color); closeColorPicker(); }}
closeColorPicker();
}}
style={{ style={{
width: "100%", width: "100%", height: "100%", borderRadius: "6px",
height: "100%", border: barColor === color ? "2px solid #fff" : "1px solid rgba(255,255,255,0.2)",
borderRadius: "6px", background: color, cursor: "pointer", transition: "all 0.2s ease",
border:
barColor === color
? "2px solid #fff"
: "1px solid rgba(255,255,255,0.2)",
background: color,
cursor: "pointer",
transition: "all 0.2s ease",
}} }}
/> />
{isCustomColor && ( {isCustom && (
<button <button
onClick={(e) => { type="button"
e.stopPropagation(); onClick={(e) => { e.stopPropagation(); removeCustomColor(color); }}
removeCustomColor(color);
}}
style={{ style={{
position: "absolute", position: "absolute", top: "-4px", right: "-4px",
top: "-4px", width: "16px", height: "16px", borderRadius: "50%",
right: "-4px", border: "1px solid rgba(255,255,255,0.8)", background: "rgba(0,0,0,0.8)",
width: "16px", color: "#fff", cursor: "pointer", fontSize: "10px",
height: "16px", display: "flex", alignItems: "center", justifyContent: "center",
borderRadius: "50%", opacity: isHovered ? 1 : 0, transition: "opacity 0.2s ease", zIndex: 10,
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" >x</button>
>
×
</button>
)} )}
</div> </div>
); );
})} })}
</div> </div>
{/* Custom Hex Input */}
<div style={{ marginBottom: "12px" }}> <div style={{ marginBottom: "12px" }}>
<div <div style={{ color: "rgba(255,255,255,0.7)", fontSize: "12px", marginBottom: "6px" }}>Add Custom Color</div>
style={{ <div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
color: "rgba(255,255,255,0.7)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Add Custom Color
</div>
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
}}
>
<input <input
type="text" type="text"
value={customInput} value={customInput}
onChange={(e) => setCustomInput(e.target.value)} onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => { if (e.key === "Enter") { updateColor(customInput); addCustomColor(); } }}
if (e.key === "Enter") { placeholder="#ff69b4"
updateColor(customInput);
addCustomColor();
}
}}
placeholder="#ffffff"
style={{ style={{
flex: 1, flex: 1, padding: "8px 12px", borderRadius: "6px",
padding: "8px 12px", border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
borderRadius: "6px", color: "#fff", fontSize: "14px", fontFamily: "monospace", boxSizing: "border-box",
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 <button
onClick={() => { type="button"
updateColor(customInput); onClick={() => { updateColor(customInput); addCustomColor(); }}
addCustomColor();
}}
style={{ style={{
width: "32px", width: "32px", height: "32px", borderRadius: "6px",
height: "32px", border: "1px solid rgba(255,255,255,0.3)", background: "rgba(255,255,255,0.15)",
borderRadius: "6px", color: "#fff", cursor: "pointer", fontSize: "16px",
border: "1px solid rgba(255,255,255,0.3)", display: "flex", alignItems: "center", justifyContent: "center",
background: "rgba(255,255,255,0.15)",
color: "#fff",
cursor: "pointer",
fontSize: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease", transition: "all 0.2s ease",
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.25)"; }}
e.currentTarget.style.background = onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.15)"; }}
"rgba(255,255,255,0.25)"; >+</button>
}}
onMouseLeave={(e) => {
e.currentTarget.style.background =
"rgba(255,255,255,0.15)";
}}
>
+
</button>
</div> </div>
</div> </div>
{/* Close Button (Done) - Also runs when color chosen*/}
<button <button
type="button"
onClick={closeColorPicker} onClick={closeColorPicker}
style={{ style={{
width: "100%", width: "100%", padding: "8px", borderRadius: "6px",
padding: "8px", border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
borderRadius: "6px", color: "#fff", cursor: "pointer", fontSize: "12px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
cursor: "pointer",
fontSize: "12px",
}} }}
> >Done</button>
Done
</button>
</div> </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> </div>
);
})}
</div>
<button
type="button"
onClick={closeSlotConfig}
style={{
width: "100%", padding: "8px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
color: "#fff", cursor: "pointer", fontSize: "12px", marginTop: "20px",
}}
>Done</button>
</div>
</>
)}
<LunaNumberSetting
title="Reactivity"
desc="How quickly visualizers respond to audio (5-100)"
min={5}
max={100}
step={5}
value={reactivity}
onNumber={(v: number) => { setReactivity(v); settings.reactivity = v; }}
/>
<LunaNumberSetting
title="Gain"
desc="Amplitude boost for spectrum visualizers (0.5-3.0)"
min={0.5}
max={3.0}
step={0.5}
value={gain}
onNumber={(v: number) => { setGain(v); settings.gain = v; }}
/>
<LunaSelectSetting
title="FFT Size"
desc="Frequency resolution (higher = more detail, more CPU)"
value={fftSize}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const v = Number(e.target.value);
setFftSize(v);
settings.fftSize = v;
}}
>
{[256, 512, 1024, 2048, 4096, 8192, 16384].map(s => (
<LunaSelectItem key={s} value={s}>{s}</LunaSelectItem>
))}
</LunaSelectSetting>
<LunaNumberSetting
title="Bar Count"
desc="Number of frequency bars (Spectrum Bars)"
min={8}
max={128}
step={1}
value={barCount}
onNumber={(v: number) => { setBarCount(v); settings.barCount = v; }}
/>
{hasBars && (
<AnySwitch
title="Bar Rounding"
desc="Round the top corners of spectrum bars"
checked={barRounding}
onChange={(_: unknown, checked: boolean) => {
setBarRounding(checked);
settings.barRounding = checked;
}}
/>
)}
<LunaNumberSetting
title="Line Thickness"
desc="Stroke width for line-based visualizers (0.5-5)"
min={0.5}
max={5}
step={0.5}
value={lineThickness}
onNumber={(v: number) => { setLineThickness(v); settings.lineThickness = v; }}
/>
<LunaNumberSetting
title="Fill Opacity"
desc="Fill below the Spectrum Line curve (0-1)"
min={0}
max={1}
step={0.05}
value={fillOpacity}
onNumber={(v: number) => { setFillOpacity(v); settings.fillOpacity = v; }}
/>
<AnySwitch
title="Scrolling Oscilloscope"
desc="Waveform scrolls right-to-left like a chart recorder"
checked={scrollingOscilloscope}
onChange={(_: unknown, checked: boolean) => {
setScrollingOscilloscope(checked);
settings.scrollingOscilloscope = checked;
}}
/>
<AnySwitch
title="Lissajous Mode"
desc="Rotate the Vectorscope 45° for Lissajous display"
checked={lissajous}
onChange={(_: unknown, checked: boolean) => {
setLissajous(checked);
settings.lissajous = checked;
}}
/>
</LunaSettings> </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;
};
+436 -470
View File
@@ -1,535 +1,501 @@
import { LunaUnload, Tracer } from "@luna/core"; import { type LunaUnload, Tracer } from "@luna/core";
import { StyleTag, PlayState } from "@luna/lib"; import { StyleTag, PlayState, MediaItem, observe } from "@luna/lib";
import { settings, Settings } from "./Settings"; 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 CSS styles for the visualizer
import visualizerStyles from "file://styles.css?minify"; import visualizerStyles from "file://styles.css?minify";
export const { trace } = Tracer("[Audio Visualizer]"); export const { trace } = Tracer("[Audio Visualizer]");
const log = (message: string) => console.log(`[Audio Visualizer] ${message}`);
export { Settings }; export { Settings };
const config = { const log = (msg: string) => console.log(`[Audio Visualizer] ${msg}`);
enabled: true,
width: 200, export const unloads = new Set<LunaUnload>();
height: 40, new StyleTag("AudioVisualizer", unloads, visualizerStyles);
get barCount() {
return settings.barCount; const FACTORIES: Record<Exclude<VisualizerType, "none">, () => Visualizer> = {
}, "spectrum-line": createSpectrumLine,
get color() { "spectrum-bars": createSpectrumBars,
return settings.barColor; oscilloscope: createOscilloscope,
}, vectorscope: createVectorscope,
get barRounding() { "loudness-meter": createLoudnessMeter,
return settings.barRounding;
},
sensitivity: 1.5,
smoothing: 0.8,
}; };
// Clean up resources // Slot Management
export const unloads = new Set<LunaUnload>();
// StyleTag for CSS interface Slot {
const styleTag = new StyleTag("AudioVisualizer", unloads, visualizerStyles);
// Audio context and analyzer
let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let audioSource: MediaElementAudioSourceNode | null = null;
let dataArray: Uint8Array | null = null;
let animationId: number | null = null;
let currentAudioElement: HTMLAudioElement | null = null;
let isSourceConnected: boolean = false;
// Each placement gets its own container/canvas/context
interface VisualizerSlot {
container: HTMLDivElement | null; container: HTMLDivElement | null;
canvas: HTMLCanvasElement | 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 }; interface SlotGroup {
const npSlot: VisualizerSlot = { container: null, canvas: null, ctx: null }; groupContainer: HTMLDivElement;
slots: Slot[];
keys: readonly SlotKey[];
}
// Find the audio element - this is a bit of a hack but it works const groups = new Map<string, SlotGroup>();
const findAudioElement = (): HTMLAudioElement | null => { let navArrowsEl: HTMLElement | null = null;
// Try main selectors first
const selectors = [
"audio",
"video",
"audio[data-test]",
'[data-test="audio-player"] audio',
];
for (const selector of selectors) { const getSlot = (key: SlotKey): VisualizerType =>
const element = document.querySelector(selector) as HTMLAudioElement; (settings as unknown as Record<string, VisualizerType>)[key] ?? "none";
if (
element &&
(element.tagName === "AUDIO" || element.tagName === "VIDEO")
) {
return element;
}
}
// Quick scan for any audio elements const isWebGLViz = (type: VisualizerType): boolean =>
const audioElements = document.querySelectorAll("audio, video"); type === "spectrum-line" || type === "spectrum-bars";
for (const element of audioElements) {
const audioEl = element as HTMLAudioElement;
if (audioEl.src || audioEl.currentSrc) {
return audioEl;
}
}
return null; const isMiniSlot = (key: SlotKey): boolean =>
}; (settings.miniSlots ?? []).includes(key);
// Initialize audio visualization const getSlotDims = (type: VisualizerType, key: SlotKey) =>
const initializeAudioVisualizer = async (): Promise<void> => { isMiniSlot(key) && MINI_DIMENSIONS[type] ? MINI_DIMENSIONS[type] : VISUALIZER_DIMENSIONS[type];
try {
// Find the audio element
const audioElement = findAudioElement();
if (!audioElement) {
return;
}
// create audio context
if (!audioContext) {
audioContext = new AudioContext();
log("Created AudioContext");
}
// create analyser
if (!analyser) {
analyser = audioContext.createAnalyser();
analyser.fftSize = 512; // Fixed power of 2 that provides enough frequency bins
analyser.smoothingTimeConstant = config.smoothing;
dataArray = new Uint8Array(analyser.frequencyBinCount);
log("Created AnalyserNode");
}
// attempt audio connection if not already connected
if (!isSourceConnected && audioElement !== currentAudioElement) {
try {
// Create audio source - this might fail if already connected elsewhere
audioSource = audioContext.createMediaElementSource(audioElement);
audioSource.connect(analyser);
// CRITICAL: connect back to destination for audio output (otherwise no sound)
analyser.connect(audioContext.destination);
currentAudioElement = audioElement;
isSourceConnected = true;
log("Connected to audio stream with output");
} catch (error) {
// Audio is connected elsewhere - that's fine, we just can't visualize
if (
error instanceof Error &&
error.message.includes("already connected")
) {
log("Audio already connected elsewhere - skipping visualization");
}
return;
}
}
// Resume context only if needed and don't wait for it
// (otherwise it will wait for the audio to start playing)
if (audioContext.state === "suspended") {
audioContext.resume().catch(() => {}); // Fire and forget
}
createVisualizerUI();
// Start animation only if not already running
if (!animationId) {
animate();
}
} catch (err) {
// log errors
console.error(err);
}
};
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 createSlotCanvas = (dims: { width: number; height: number }): HTMLCanvasElement => {
const cvs = document.createElement("canvas"); const cvs = document.createElement("canvas");
cvs.width = config.width; cvs.width = dims.width;
cvs.height = config.height; cvs.height = dims.height;
cvs.style.cssText = ` cvs.style.cssText = `width:${dims.width}px;height:${dims.height}px;border-radius:4px;display:block;`;
width: ${config.width}px; return cvs;
height: ${config.height}px;
border-radius: 4px;
`;
container.appendChild(cvs);
const ctx = cvs.getContext("2d");
if (!ctx) return null;
return { container, canvas: cvs, ctx };
}; };
const clearSlot = (slot: VisualizerSlot): void => { const applySlotSize = (slot: Slot, dims: { width: number; height: number }): void => {
slot.container?.remove(); if (!slot.container || !slot.canvas) return;
slot.container = null; slot.canvas.width = dims.width;
slot.canvas = null; slot.canvas.height = dims.height;
slot.ctx = null; 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);
}; };
const ensureNavSlot = (): void => { const switchVisualizer = (slot: Slot, type: VisualizerType, key: SlotKey): void => {
if (navSlot.container?.isConnected) return; if (slot.currentType === type) return;
clearSlot(navSlot);
const searchField = document.querySelector('input[class*="_searchField"]') as HTMLInputElement; slot.visualizer?.dispose();
if (!searchField) return; slot.visualizer = null;
const searchContainer = searchField.parentElement;
if (!searchContainer?.parentElement) return;
const els = makeSlotElements(); if (type === "none") {
if (!els) return; if (slot.container) slot.container.style.display = "none";
els.container.style.marginRight = "12px"; slot.currentType = "none";
Object.assign(navSlot, els);
searchContainer.parentElement.insertBefore(els.container, searchContainer);
};
const ensureNpSlot = (): void => {
if (npSlot.container?.isConnected) return;
clearSlot(npSlot);
const artistInfo = document.querySelector('[data-test="artist-info"]');
if (!artistInfo) return;
const leftContent = artistInfo.parentElement;
if (!leftContent) return;
const els = makeSlotElements();
if (!els) return;
els.container.style.marginLeft = "12px";
Object.assign(npSlot, els);
leftContent.insertBefore(els.container, artistInfo.nextSibling);
};
const createVisualizerUI = (): void => {
if (!config.enabled) return;
ensureNavSlot();
ensureNpSlot();
};
const removeVisualizerUI = (): void => {
clearSlot(navSlot);
clearSlot(npSlot);
};
// Animation loop for rendering visualizer
const animate = (): void => {
// Re-attach slots that got disconnected from the DOM
createVisualizerUI();
const slots = [navSlot, npSlot].filter(s => s.ctx && s.canvas);
if (slots.length === 0) {
animationId = requestAnimationFrame(animate);
return; return;
} }
let hasRealAudio = false; const dims = getSlotDims(type, key);
if (analyser && dataArray) { if (slot.container) {
analyser.getByteFrequencyData(dataArray); slot.canvas?.remove();
const avgVolume = const cvs = createSlotCanvas(dims);
dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length; slot.container.appendChild(cvs);
hasRealAudio = avgVolume > 5; 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`;
} }
for (const slot of slots) { const factory = FACTORIES[type];
const ctx = slot.ctx!; const viz = factory();
const cvs = slot.canvas!; if (slot.canvas) {
ctx.fillStyle = config.color; viz.init(slot.canvas, settings.barColor);
ctx.strokeStyle = config.color; }
ctx.clearRect(0, 0, cvs.width, cvs.height); slot.visualizer = viz;
slot.currentType = type;
};
if (hasRealAudio && analyser && dataArray) { const syncGroupHeights = (group: SlotGroup): void => {
drawBars(ctx, cvs); let maxH = 0;
for (let i = 0; i < group.keys.length; i++) {
const slot = group.slots[i];
if (slot.currentType === "none") continue;
const dims = getSlotDims(slot.currentType, group.keys[i]);
if (dims.height > maxH) maxH = dims.height;
}
if (maxH === 0) return;
for (let i = 0; i < group.keys.length; i++) {
const slot = group.slots[i];
if (!slot.container || !slot.canvas || slot.currentType === "none") continue;
const dims = getSlotDims(slot.currentType, group.keys[i]);
const targetH = Math.max(dims.height, maxH);
applySlotSize(slot, { width: dims.width, height: targetH });
}
};
const updateGroupVisibility = (group: SlotGroup): void => {
const allNone = group.slots.every(s => s.currentType === "none");
group.groupContainer.style.display = allNone ? "none" : "flex";
if (!allNone) syncGroupHeights(group);
if (group === groups.get("topNav-left") && navArrowsEl) {
navArrowsEl.style.marginRight = allNone ? "" : "0";
}
};
const createGroup = (keys: readonly SlotKey[], zone: string, position: string): SlotGroup => {
const groupContainer = document.createElement("div");
groupContainer.className = "av-slot-group";
groupContainer.dataset.zone = zone;
groupContainer.dataset.position = position;
const slots: Slot[] = [];
for (const _key of keys) {
const slotContainer = document.createElement("div");
slotContainer.className = "audio-visualizer-container";
slotContainer.style.display = "none";
groupContainer.appendChild(slotContainer);
slots.push({
container: slotContainer,
canvas: null,
visualizer: null,
currentType: "none",
contextType: null,
});
}
return { groupContainer, slots, keys };
};
const initGroupVisualizers = (group: SlotGroup): void => {
for (let i = 0; i < group.keys.length; i++) {
const key = group.keys[i];
const type = getSlot(key);
if (type !== "none") {
switchVisualizer(group.slots[i], type, key);
}
}
updateGroupVisibility(group);
};
const initAllGroups = (): void => {
for (const [zoneId, positions] of Object.entries(ZONE_SLOTS)) {
for (const [posId, keys] of Object.entries(positions)) {
if (!keys) continue;
const groupId = `${zoneId}-${posId}`;
const group = createGroup(keys, zoneId, posId);
groups.set(groupId, group);
}
}
};
// UI Attachment
const attachNavGroups = (anchor: Element): void => {
const parent = anchor.parentElement;
if (!parent) return;
const 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 { } else {
drawScrollingWave(ctx, cvs); parent.prepend(navLeft.groupContainer);
} }
navLeft.groupContainer.style.marginRight = "auto";
initGroupVisualizers(navLeft);
} }
animationId = requestAnimationFrame(animate); const navRight = groups.get("topNav-right");
if (navRight && !navRight.groupContainer.isConnected) {
parent.insertBefore(navRight.groupContainer, anchor);
initGroupVisualizers(navRight);
}
}; };
// Global wave animation state const attachNpGroups = (anchor: Element): void => {
const leftContent = anchor.parentElement;
if (!leftContent) return;
const header = leftContent.parentElement as HTMLElement | null;
if (!header) return;
const npLeft = groups.get("nowPlaying-left");
if (npLeft && !npLeft.groupContainer.isConnected) {
leftContent.insertBefore(npLeft.groupContainer, anchor.nextSibling);
initGroupVisualizers(npLeft);
}
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);
}
};
const attachPbGroups = (anchor: Element): void => {
const trackInfo = anchor.querySelector('[data-test="track-info"]');
const utilityContainer = anchor.querySelector('[class*="utilityContainer"]');
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; 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);
// Helper function to draw rounded rectangles const generateIdleData = (): AudioData => {
const drawRoundedRect = ( for (let i = 0; i < IDLE_SIZE; i++) {
ctx: CanvasRenderingContext2D, const x = i / IDLE_SIZE;
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 => {
waveTime += 0.05 / [navSlot, npSlot].filter(s => s.ctx).length;
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 wave1 = Math.sin(x * Math.PI * 2 + waveTime) * 0.3; 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 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 wave3 = Math.sin(x * Math.PI * 6 + waveTime * 0.7) * 0.1;
const combinedWave = (wave1 + wave2 + wave3 + 1) / 2; const combined = (wave1 + wave2 + wave3 + 1) / 2;
const travelWave = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5; const travel = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5;
const barHeight = maxHeight * combinedWave * travelWave * 0.8 + 2;
const xPos = i * barWidth; const byteVal = Math.floor(combined * travel * 140 + 20);
const yPos = (cvs.height - barHeight) / 2; idleByteFreq[i] = byteVal;
idleFloatFreq[i] = -40 + byteVal * 0.3;
if (config.barRounding) { const timeSample = Math.sin(x * Math.PI * 8 + waveTime * 3) * 0.15;
drawRoundedRect(ctx, xPos, yPos, barWidth - 1, barHeight, 2); idleByteTime[i] = 128 + Math.floor(timeSample * 127);
} else { idleFloatTime[i] = timeSample;
ctx.fillRect(xPos, yPos, barWidth - 1, barHeight); 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);
}
}
};
// Draw waveform visualization - NOT IMPLEMENTED YET
// const drawWaveform = (): void => {
// if (!canvasContext || !dataArray || !canvas) return;
// const centerY = canvas.height / 2;
// const amplitudeScale = canvas.height / 512;
// canvasContext.strokeStyle = config.color;
// canvasContext.lineWidth = 2;
// canvasContext.beginPath();
// for (let i = 0; i < config.barCount; i++) {
// const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
// const amplitude = (dataArray[dataIndex] - 128) * config.sensitivity * amplitudeScale;
// const x = (i / config.barCount) * canvas.width;
// const y = centerY + amplitude;
// if (i === 0) {
// canvasContext.moveTo(x, y);
// } else {
// canvasContext.lineTo(x, y);
// }
// }
// canvasContext.stroke();
// };
// Draw circular visualization - NOT IMPLEMENTED YET
// const drawCircular = (): void => {
// if (!canvasContext || !dataArray || !canvas) return;
// const centerX = canvas.width / 2;
// const centerY = canvas.height / 2;
// const radius = Math.min(centerX, centerY) - 10;
// canvasContext.strokeStyle = config.color;
// canvasContext.lineWidth = 2;
// for (let i = 0; i < config.barCount; i++) {
// const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
// const amplitude = (dataArray[dataIndex] * config.sensitivity) / 255;
// const angle = (i / config.barCount) * Math.PI * 2;
// const startX = centerX + Math.cos(angle) * radius * 0.7;
// const startY = centerY + Math.sin(angle) * radius * 0.7;
// const endX = centerX + Math.cos(angle) * radius * (0.7 + amplitude * 0.3);
// const endY = centerY + Math.sin(angle) * radius * (0.7 + amplitude * 0.3);
// canvasContext.beginPath();
// canvasContext.moveTo(startX, startY);
// canvasContext.lineTo(endX, endY);
// canvasContext.stroke();
// }
// };
const updateAudioVisualizer = (): void => {
if (analyser) {
analyser.fftSize = 512;
analyser.smoothingTimeConstant = config.smoothing;
dataArray = new Uint8Array(analyser.frequencyBinCount);
} }
for (const slot of [navSlot, npSlot]) { return {
if (slot.canvas) { byteFrequency: idleByteFreq,
slot.canvas.width = config.width; byteTimeDomain: idleByteTime,
slot.canvas.height = config.height; floatFrequency: idleFloatFreq,
slot.canvas.style.width = `${config.width}px`; floatTimeDomain: idleFloatTime,
slot.canvas.style.height = `${config.height}px`; leftTimeDomain: idleLeftTime,
} rightTimeDomain: idleRightTime,
} sampleRate: 44100,
fftSize: IDLE_SIZE * 2,
removeVisualizerUI(); binCount: IDLE_SIZE,
createVisualizerUI();
};
// Make updateAudioVisualizer available globally for settings
(window as any).updateAudioVisualizer = updateAudioVisualizer;
// Clean up function
const cleanupAudioVisualizer = (): void => {
// stop animation and hide UI - don't touch audio connections (otherwise it will reconnect)
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
removeVisualizerUI();
// i was killing audio connections - But it was reconnecting and being a pain
// so i just left it alone - it works fine
};
// Initialize when DOM is ready and track is playing
const observePlayState = (): void => {
let hasTriedInitialization = false;
let checkCount = 0;
const checkAndInitialize = () => {
checkCount++;
// Only try to initialize once when music starts playing
if (PlayState.playing && !hasTriedInitialization) {
hasTriedInitialization = true;
log("Initializing audio visualizer...");
// Initialize immediately - no delay (after audio starts playing ofc)
initializeAudioVisualizer().then(() => {
if (audioContext && analyser) {
log("Audio visualizer ready!");
} else {
hasTriedInitialization = false; // Allow retry if failed
}
});
} else if (!PlayState.playing && hasTriedInitialization) {
// Reset try flag when music stops so it can try again next time (otherwise it explode)
hasTriedInitialization = false;
}
// Keep animation running regardless of play state
if (!animationId) {
animate();
}
}; };
};
// Start with fast checking, then slow down // Animation Loop
const fastInterval = setInterval(() => {
checkAndInitialize(); let animationId: number | null = null;
if (checkCount > 10) { const lastSlotTypes = new Map<SlotKey, VisualizerType>();
// After 10 quick checks, switch to slower const lastMiniState = new Map<SlotKey, boolean>();
clearInterval(fastInterval);
const slowInterval = setInterval(checkAndInitialize, 2000); for (const key of ALL_SLOT_KEYS) {
unloads.add(() => clearInterval(slowInterval)); lastSlotTypes.set(key, getSlot(key));
lastMiniState.set(key, isMiniSlot(key));
}
const animate = (): void => {
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);
} }
}, 200); // Check every 200ms initially
unloads.add(() => clearInterval(fastInterval)); const currentReactivity = settings.reactivity ?? 30;
if (currentReactivity !== lastReactivity) {
audio.setSmoothing(reactivityToSmoothing(currentReactivity));
lastReactivity = currentReactivity;
}
// Immediate first check waveTime += 0.05;
checkAndInitialize(); 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);
}; };
// Initialize the plugin // Init
const initialize = (): void => {
log("Audio Visualizer plugin initializing...");
// Start immediately - DOM should be ready by plugin load log("Initializing...");
setTimeout(() => {
log("Starting visualizer...");
// Create UI immediately so wave effect shows
createVisualizerUI();
// Start animation loop immediately
animate();
// Also observe play state for audio detection
observePlayState();
}, 100); // Minimal delay to ensure DOM is ready
};
// Complete cleanup function for plugin unload if (PlayState.playing) {
const completeCleanup = (): void => { if (!tryConnect()) scheduleRetry();
log("Complete cleanup - plugin unloading"); }
animationId = requestAnimationFrame(animate);
// Cleanup
unloads.add(() => {
log("Plugin unloading");
clearRetry();
if (navArrowsEl) {
navArrowsEl.style.marginRight = "";
navArrowsEl = null;
}
if (animationId) { if (animationId) {
cancelAnimationFrame(animationId); cancelAnimationFrame(animationId);
animationId = null; animationId = null;
} }
removeVisualizerUI(); for (const group of groups.values()) {
for (const slot of group.slots) {
// Fully disconnect and reset everything slot.visualizer?.dispose();
if (audioSource) {
try {
audioSource.disconnect();
log("Disconnected audio source completely");
} catch (e) {
log("Audio source already disconnected");
} }
group.groupContainer.remove();
} }
groups.clear();
// Close audio context completely on plugin unload audio.dispose();
if (audioContext && audioContext.state !== "closed") { });
audioContext.close();
log("Closed AudioContext");
}
// Reset all references
audioContext = null;
analyser = null;
audioSource = null;
dataArray = null;
currentAudioElement = null;
isSourceConnected = false;
};
// Register cleanup
unloads.add(completeCleanup);
// Start initialization
initialize();
+39 -19
View File
@@ -1,37 +1,31 @@
/* Audio Visualizer CSS */
.audio-visualizer-container { .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; transition: all 0.3s ease-in-out;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 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; animation: av-fadeIn 0.5s ease-out;
} }
.audio-visualizer-container:hover { .audio-visualizer-container:hover {
transform: scale(1.02); 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 { .audio-visualizer-container canvas {
display: block; display: block;
transition: all 0.3s ease-in-out; border-radius: 4px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.audio-visualizer-container {
margin: 4px;
padding: 2px;
}
.audio-visualizer-container canvas {
max-width: 150px;
max-height: 30px;
}
} }
.audio-visualizer-container.active { .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 { @keyframes av-fadeIn {
@@ -48,3 +42,29 @@
[data-type="search-field"] { [data-type="search-field"] {
min-width: 220px !important; 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];
};
+159 -14
View File
@@ -12,6 +12,7 @@ declare global {
updateRadiantLyricsGlobalBackground?: () => void; updateRadiantLyricsGlobalBackground?: () => void;
updateRadiantLyricsNowPlayingBackground?: () => void; updateRadiantLyricsNowPlayingBackground?: () => void;
updateQualityProgressColor?: () => void; updateQualityProgressColor?: () => void;
updateIntegratedSeekBar?: () => void;
updateLyricsStyle?: () => void; updateLyricsStyle?: () => void;
updateLyricsStyleSetting?: (value: number) => void; updateLyricsStyleSetting?: (value: number) => void;
updateRomanizeLyrics?: () => void; updateRomanizeLyrics?: () => void;
@@ -21,19 +22,32 @@ declare global {
export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", { export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
lyricsGlowEnabled: true, 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, hideUIEnabled: true,
playerBarVisible: false, playerBarVisible: false,
qualityProgressColor: true, qualityProgressColor: true,
integratedSeekBar: true,
floatingPlayerBar: true, floatingPlayerBar: true,
playerBarRadius: 5,
playerBarSpacing: 10,
playerBarBlur: true,
playerBarBlurAmount: 15,
playerBarTintEnabled: true,
playerBarTint: 5, playerBarTint: 5,
playerBarTintColor: "#000000" as string, playerBarTintColor: "#000000" as string,
playerBarTintCustomColors: [] as string[], playerBarTintCustomColors: [] as string[],
playerBarRadius: 5,
playerBarSpacing: 10,
CoverEverywhere: true, CoverEverywhere: true,
performanceMode: false, performanceMode: false,
spinningArt: true, spinningArt: true,
textGlow: 20,
backgroundScale: 15, backgroundScale: 15,
backgroundRadius: 25, backgroundRadius: 25,
backgroundContrast: 120, backgroundContrast: 120,
@@ -41,16 +55,6 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
backgroundBrightness: 40, backgroundBrightness: 40,
spinSpeed: 45, spinSpeed: 45,
settingsAffectNowPlaying: true, 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 = () => { export const Settings = () => {
@@ -92,12 +96,21 @@ export const Settings = () => {
const [floatingPlayerBar, setFloatingPlayerBar] = React.useState( const [floatingPlayerBar, setFloatingPlayerBar] = React.useState(
settings.floatingPlayerBar, settings.floatingPlayerBar,
); );
const [playerBarTintEnabled, setPlayerBarTintEnabled] = React.useState(
settings.playerBarTintEnabled,
);
const [playerBarTint, setPlayerBarTint] = React.useState( const [playerBarTint, setPlayerBarTint] = React.useState(
settings.playerBarTint, settings.playerBarTint,
); );
const [playerBarTintColor, setPlayerBarTintColor] = React.useState( const [playerBarTintColor, setPlayerBarTintColor] = React.useState(
settings.playerBarTintColor, settings.playerBarTintColor,
); );
const [playerBarBlur, setPlayerBarBlur] = React.useState(
settings.playerBarBlur,
);
const [playerBarBlurAmount, setPlayerBarBlurAmount] = React.useState(
settings.playerBarBlurAmount,
);
const [playerBarRadius, setPlayerBarRadius] = React.useState( const [playerBarRadius, setPlayerBarRadius] = React.useState(
settings.playerBarRadius, settings.playerBarRadius,
); );
@@ -145,6 +158,9 @@ export const Settings = () => {
const [qualityProgressColor, setQualityProgressColor] = React.useState( const [qualityProgressColor, setQualityProgressColor] = React.useState(
settings.qualityProgressColor, settings.qualityProgressColor,
); );
const [integratedSeekBar, setIntegratedSeekBar] = React.useState(
settings.integratedSeekBar,
);
const [romanizeLyrics, setRomanizeLyrics] = React.useState( const [romanizeLyrics, setRomanizeLyrics] = React.useState(
settings.romanizeLyrics, 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 <AnySwitch
title="Floating Player Bar" title="Floating Player Bar"
desc="When disabled, the player bar becomes a square edge-to-edge 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 = () => { const closeTintColorPicker = () => {
setIsTintAnimatingIn(false); setIsTintAnimatingIn(false);
@@ -548,12 +601,96 @@ export const Settings = () => {
Choose Tint Color 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={{
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 <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "repeat(7, 1fr)", gridTemplateColumns: "repeat(7, 1fr)",
gap: "8px", gap: "8px",
marginBottom: "16px", 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) => { {allTintColors.map((color, index) => {
@@ -626,7 +763,15 @@ export const Settings = () => {
})} })}
</div> </div>
<div style={{ marginBottom: "12px" }}> <div
style={{
marginBottom: "12px",
opacity: playerBarTintEnabled ? 1 : 0.3,
pointerEvents: playerBarTintEnabled ? "auto" : "none",
filter: playerBarTintEnabled ? "none" : "grayscale(1)",
transition: "all 0.25s ease",
}}
>
<div <div
style={{ style={{
color: "rgba(255,255,255,0.7)", color: "rgba(255,255,255,0.7)",
+238 -31
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) // Apply inline styles to the player bar (tint + optional radius/spacing customisation)
const applyPlayerBarTintToElement = (): void => { const applyPlayerBarTintToElement = (): void => {
const footerPlayer = document.querySelector( const footerPlayer = document.querySelector(
'[data-test="footer-player"]', '[data-test="footer-player"]',
) as HTMLElement; ) as HTMLElement;
if (!footerPlayer) return; if (!footerPlayer) return;
if (settings.playerBarTintEnabled) {
const alpha = settings.playerBarTint / 10; const alpha = settings.playerBarTint / 10;
const { r, g, b } = hexToRgb(settings.playerBarTintColor); const { r, g, b } = hexToRgb(settings.playerBarTintColor);
footerPlayer.style.removeProperty("background");
footerPlayer.style.setProperty( footerPlayer.style.setProperty(
"background-color", "background-color",
`rgba(${r}, ${g}, ${b}, ${alpha})`, `rgba(${r}, ${g}, ${b}, ${alpha})`,
"important", "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) { if (settings.floatingPlayerBar) {
footerPlayer.style.setProperty( footerPlayer.style.setProperty(
"border-radius", "border-radius",
@@ -151,6 +190,7 @@ const applyPlayerBarTintToElement = (): void => {
footerPlayer.style.removeProperty("left"); footerPlayer.style.removeProperty("left");
footerPlayer.style.removeProperty("width"); footerPlayer.style.removeProperty("width");
} }
applyIntegratedSeekbarChrome();
}; };
// When floating is disabled, inject square-bar CSS to override Tidal's native floating styles // When floating is disabled, inject square-bar CSS to override Tidal's native floating styles
@@ -214,6 +254,182 @@ if (settings.qualityProgressColor) {
applyQualityProgressColor(); 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) // Apply base styles always (I kinda dont really remember what this does but it's important i guess)
baseStyleTag.css = baseStyles; baseStyleTag.css = baseStyles;
@@ -923,6 +1139,7 @@ const updateRadiantLyricsNowPlayingBackground = function (): void {
(window as any).updateRadiantLyricsPlayerBarTint = (window as any).updateRadiantLyricsPlayerBarTint =
updateRadiantLyricsPlayerBarTint; updateRadiantLyricsPlayerBarTint;
(window as any).updateQualityProgressColor = applyQualityProgressColor; (window as any).updateQualityProgressColor = applyQualityProgressColor;
(window as any).updateIntegratedSeekBar = applyIntegratedSeekBar;
const cleanUpDynamicArt = function (): void { const cleanUpDynamicArt = function (): void {
// Clean up cached Now Playing elements // Clean up cached Now Playing elements
@@ -1003,6 +1220,23 @@ unloads.add(() => {
footerPlayer.style.removeProperty("bottom"); footerPlayer.style.removeProperty("bottom");
footerPlayer.style.removeProperty("left"); footerPlayer.style.removeProperty("left");
footerPlayer.style.removeProperty("width"); 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 // Clean up action buttons
@@ -1030,45 +1264,18 @@ unloads.add(() => {
// MARKER: Sticky Lyrics Feature // MARKER: Sticky Lyrics Feature
const STICKY_ICONS: Record<string, string> = { const STICKY_ICON =
chevron: '<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>';
'<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 applyStickyIcon = (): void => { const applyStickyIcon = (): void => {
const trigger = document.querySelector( const trigger = document.querySelector(
".sticky-lyrics-trigger", ".sticky-lyrics-trigger",
) as HTMLElement; ) as HTMLElement;
if (!trigger) return; if (!trigger) return;
trigger.innerHTML = getStickyIcon(); trigger.innerHTML = STICKY_ICON;
trigger.style.paddingLeft = "5px"; 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 // Console: Syllables.log = true/false
// Verbose logging for word/syllable lyrics (hidden setting) // Verbose logging for word/syllable lyrics (hidden setting)
const sylLog = (...args: unknown[]) => { const sylLog = (...args: unknown[]) => {
@@ -1251,7 +1458,7 @@ const createStickyLyricsDropdown = (): void => {
const trigger = document.createElement("div"); const trigger = document.createElement("div");
trigger.className = "sticky-lyrics-trigger"; trigger.className = "sticky-lyrics-trigger";
trigger.setAttribute("title", "Sticky Lyrics"); trigger.setAttribute("title", "Sticky Lyrics");
trigger.innerHTML = getStickyIcon(); trigger.innerHTML = STICKY_ICON;
for (const evtName of [ for (const evtName of [
"pointerdown", "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) */ /* MARKER: PATCHES (Random Fixes for Tidals Changes) */
/* These change allot so i gave them their own section */ /* These change allot so i gave them their own section */
@@ -267,3 +351,69 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
[data-test="new-now-playing-expand"] { [data-test="new-now-playing-expand"] {
display: none !important; 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;
}