mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-18 03:43:10 +10:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50c40e6978 | |||
| ec42f5c287 | |||
| db6310cef4 | |||
| ee2243443e | |||
| 2deda8aed1 | |||
| b403c3a80c | |||
| 48c8738bcd | |||
| 7627bd7051 | |||
| 20a2c2b7f7 | |||
| f0139165a9 | |||
| e4df0a8c64 | |||
| 8ee9717f25 | |||
| 5ead825b3d | |||
| 1a2e25c717 | |||
| a2cb822a2c | |||
| e223f933c6 | |||
| 031bb107f8 | |||
| a6371240ef | |||
| 92697d7396 | |||
| 5e6e897395 | |||
| 4749f50b95 | |||
| b48d248cda | |||
| 4af872133e | |||
| 0f9d5a75d8 | |||
| 764cb1aa96 | |||
| e062b4bd02 | |||
| 9f01ecd1ff | |||
| e59121968d | |||
| 8fbb48f8fe | |||
| b351fa859a | |||
| 353b72e1e1 | |||
| c648f3df95 | |||
| e376fb745b | |||
| ca085ce31b | |||
| 34e0a51bcd | |||
| 8fdfff10e7 | |||
| fbd0c2b696 | |||
| 4ad4b5879c | |||
| 764c71b45f | |||
| 1876a37185 | |||
| 8c27eebd88 | |||
| 9fd8208996 | |||
| a1ddb0ede6 | |||
| 411e20b9f7 | |||
| 50215fa0f5 | |||
| 5761c01973 | |||
| 62e15b0d3d | |||
| 13cbe01bd8 |
@@ -1,7 +0,0 @@
|
|||||||
*.ts text eol=lf
|
|
||||||
*.tsx text eol=lf
|
|
||||||
*.js text eol=lf
|
|
||||||
*.css text eol=lf
|
|
||||||
*.json text eol=lf
|
|
||||||
*.md text eol=lf
|
|
||||||
*.yaml text eol=lf
|
|
||||||
@@ -18,10 +18,5 @@
|
|||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"esbuild"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,615 +3,434 @@ import {
|
|||||||
LunaSettings,
|
LunaSettings,
|
||||||
LunaNumberSetting,
|
LunaNumberSetting,
|
||||||
LunaSwitchSetting,
|
LunaSwitchSetting,
|
||||||
LunaSelectSetting,
|
LunaTextSetting,
|
||||||
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",
|
||||||
{
|
{
|
||||||
navLeft1: "none" as VisualizerType,
|
barCount: 32,
|
||||||
navLeft2: "none" as VisualizerType,
|
barColor: "#ffffff",
|
||||||
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,
|
|
||||||
groupedSlots: false,
|
|
||||||
transparentContainers: false,
|
|
||||||
idleMode: 1,
|
|
||||||
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 [barColor, setBarColor] = React.useState(settings.barColor);
|
|
||||||
const [barCount, setBarCount] = React.useState(settings.barCount);
|
const [barCount, setBarCount] = React.useState(settings.barCount);
|
||||||
const [fftSize, setFftSize] = React.useState(settings.fftSize);
|
const [barColor, setBarColor] = React.useState(settings.barColor);
|
||||||
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 [groupedSlots, setGroupedSlots] = React.useState(settings.groupedSlots);
|
|
||||||
const [transparentContainers, setTransparentContainers] = React.useState(
|
|
||||||
settings.transparentContainers,
|
|
||||||
);
|
|
||||||
const [idleMode, setIdleMode] = React.useState(settings.idleMode);
|
|
||||||
|
|
||||||
const [showColorPicker, setShowColorPicker] = React.useState(false);
|
const [showColorPicker, setShowColorPicker] = React.useState(false);
|
||||||
const [isColorAnimIn, setIsColorAnimIn] = React.useState(false);
|
const [isAnimatingIn, setIsAnimatingIn] = React.useState(false);
|
||||||
const [shouldRenderColor, setShouldRenderColor] = React.useState(false);
|
const [shouldRender, setShouldRender] = 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<number | null>(null);
|
const [hoveredColorIndex, setHoveredColorIndex] = React.useState<
|
||||||
|
number | null
|
||||||
const [showSlotConfig, setShowSlotConfig] = React.useState(false);
|
>(null);
|
||||||
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 = () => {
|
||||||
setIsColorAnimIn(false);
|
setIsAnimatingIn(false);
|
||||||
setTimeout(() => { setShowColorPicker(false); setShouldRenderColor(false); }, 200);
|
setTimeout(() => {
|
||||||
|
setShowColorPicker(false);
|
||||||
|
setShouldRender(false);
|
||||||
|
}, 200); // Wait for animation to complete because i need to
|
||||||
};
|
};
|
||||||
|
|
||||||
const openColorPicker = () => {
|
const openColorPicker = () => {
|
||||||
setShowColorPicker(true);
|
setShowColorPicker(true);
|
||||||
setShouldRenderColor(true);
|
setShouldRender(true);
|
||||||
setTimeout(() => setIsColorAnimIn(true), 10);
|
setTimeout(() => setIsAnimatingIn(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) {
|
||||||
setShouldRenderColor(true);
|
setShouldRender(true);
|
||||||
setTimeout(() => setIsColorAnimIn(true), 10);
|
setTimeout(() => setIsAnimatingIn(true), 10);
|
||||||
}
|
}
|
||||||
}, [showColorPicker]);
|
}, [showColorPicker]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
// Common color presets for cool points :D
|
||||||
if (showSlotConfig) {
|
|
||||||
setShouldRenderSlot(true);
|
|
||||||
setTimeout(() => setIsSlotAnimIn(true), 10);
|
|
||||||
}
|
|
||||||
}, [showSlotConfig]);
|
|
||||||
|
|
||||||
const colorPresets = [
|
const colorPresets = [
|
||||||
"#ff69b4", "#ff1493", "#e91e8a", "#c71585",
|
"#ffffff",
|
||||||
"#ff006e", "#ff4da6", "#ff85c8", "#ffb3d9",
|
"#ff0000",
|
||||||
"#ffffff", "#ff0000", "#00ff00", "#0000ff",
|
"#00ff00",
|
||||||
"#ffff00", "#ff00ff", "#00ffff", "#ff8800",
|
"#0000ff",
|
||||||
"#8800ff", "#0088ff", "#1db954", "#444444",
|
"#ffff00",
|
||||||
|
"#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) {
|
||||||
const trimmed = customInput.trim().toLowerCase();
|
// Trim whitespace and convert to lowercase
|
||||||
const hexRe = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
|
const trimmedInput = customInput.trim().toLowerCase();
|
||||||
if (hexRe.test(trimmed) && !colorPresets.includes(trimmed) && !customColors.includes(trimmed)) {
|
|
||||||
const nc = [...customColors, trimmed];
|
// Validate hex color format
|
||||||
setCustomColors(nc);
|
const hexColorRegex = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
|
||||||
settings.customColors = nc;
|
|
||||||
|
if (
|
||||||
|
hexColorRegex.test(trimmedInput) &&
|
||||||
|
!colorPresets.includes(trimmedInput) &&
|
||||||
|
!customColors.includes(trimmedInput)
|
||||||
|
) {
|
||||||
|
const newCustomColors = [...customColors, trimmedInput];
|
||||||
|
setCustomColors(newCustomColors);
|
||||||
|
settings.customColors = newCustomColors;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeCustomColor = (c: string) => {
|
const removeCustomColor = (colorToRemove: string) => {
|
||||||
const nc = customColors.filter(x => x !== c);
|
const newCustomColors = customColors.filter(
|
||||||
setCustomColors(nc);
|
(color) => color !== colorToRemove,
|
||||||
settings.customColors = nc;
|
);
|
||||||
if (barColor === c) updateColor("#ff69b4");
|
setCustomColors(newCustomColors);
|
||||||
|
settings.customColors = newCustomColors;
|
||||||
|
|
||||||
|
// If the removed color was the selected color (reset to white)
|
||||||
|
if (barColor === colorToRemove) {
|
||||||
|
updateColor("#ffffff");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const allColors = [...colorPresets, ...customColors];
|
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>
|
||||||
{/* Color & Layout */}
|
<LunaSwitchSetting
|
||||||
<div style={{
|
title="Bar Roundness"
|
||||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
desc="Enable rounded corners on visualizer bars"
|
||||||
padding: "10px 0",
|
checked={barRounding}
|
||||||
}}>
|
onChange={(_, checked) => {
|
||||||
<div>
|
setBarRounding(checked);
|
||||||
<div style={{ fontWeight: 600, fontSize: "14px", color: "#fff" }}>Color & Layout</div>
|
settings.barRounding = checked;
|
||||||
<div style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)", marginTop: "2px" }}>
|
(window as any).updateAudioVisualizer?.();
|
||||||
Visualizer color and slot placement
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => showColorPicker ? closeColorPicker() : openColorPicker()}
|
|
||||||
style={{
|
|
||||||
width: "28px", height: "28px",
|
|
||||||
border: "1px solid rgba(255,255,255,0.15)",
|
|
||||||
borderRadius: "6px", cursor: "pointer", background: barColor,
|
|
||||||
overflow: "hidden", position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.1)", backdropFilter: "blur(2px)" }} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => showSlotConfig ? closeSlotConfig() : openSlotConfig()}
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px", borderRadius: "6px",
|
|
||||||
border: "1px solid rgba(255,255,255,0.2)",
|
|
||||||
background: "rgba(255,255,255,0.1)",
|
|
||||||
color: "#fff", cursor: "pointer", fontSize: "12px",
|
|
||||||
fontWeight: 500, transition: "all 0.2s ease",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.2)"; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.1)"; }}
|
|
||||||
>Configure Slots</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnySwitch
|
|
||||||
title="Grouped Slots"
|
|
||||||
desc="Active slots in the same position share a single container"
|
|
||||||
checked={groupedSlots}
|
|
||||||
onChange={(_: unknown, checked: boolean) => {
|
|
||||||
setGroupedSlots(checked);
|
|
||||||
settings.groupedSlots = checked;
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnySwitch
|
|
||||||
title="Transparent containers"
|
|
||||||
desc="Remove panel background, blur and shadow"
|
|
||||||
checked={transparentContainers}
|
|
||||||
onChange={(_: unknown, checked: boolean) => {
|
|
||||||
setTransparentContainers(checked);
|
|
||||||
settings.transparentContainers = checked;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LunaSelectSetting
|
|
||||||
title="Idle Animation"
|
|
||||||
desc="Behaviour when no audio is playing"
|
|
||||||
value={idleMode}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
const v = Number(e.target.value);
|
|
||||||
setIdleMode(v);
|
|
||||||
settings.idleMode = v;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LunaSelectItem value={0}>Enabled</LunaSelectItem>
|
|
||||||
<LunaSelectItem value={1}>Disabled & Hide</LunaSelectItem>
|
|
||||||
<LunaSelectItem value={2}>Disabled & Static</LunaSelectItem>
|
|
||||||
</LunaSelectSetting>
|
|
||||||
|
|
||||||
{/* Color picker modal */}
|
|
||||||
{shouldRenderColor && (
|
|
||||||
<>
|
|
||||||
<button type="button" aria-label="Close color picker" onClick={closeColorPicker} style={backdropStyle(isColorAnimIn)} />
|
|
||||||
<div style={{ ...panelBaseStyle(isColorAnimIn), minWidth: "320px", maxWidth: "90vw" }}>
|
|
||||||
<div style={{ marginBottom: "12px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>Choose Color</div>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "8px", marginBottom: "16px" }}>
|
|
||||||
{allColors.map((color, index) => {
|
|
||||||
const isCustom = customColors.includes(color);
|
|
||||||
const isHovered = hoveredColorIndex === index;
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: cosmetic hover tracking on wrapper containing interactive buttons
|
|
||||||
<div
|
|
||||||
key={color}
|
|
||||||
style={{ position: "relative", width: "32px", height: "32px", cursor: "pointer" }}
|
|
||||||
onMouseEnter={() => setHoveredColorIndex(index)}
|
|
||||||
onMouseLeave={() => setHoveredColorIndex(null)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { updateColor(color); closeColorPicker(); }}
|
|
||||||
style={{
|
|
||||||
width: "100%", height: "100%", borderRadius: "6px",
|
|
||||||
border: barColor === color ? "2px solid #fff" : "1px solid rgba(255,255,255,0.2)",
|
|
||||||
background: color, cursor: "pointer", transition: "all 0.2s ease",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isCustom && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => { e.stopPropagation(); removeCustomColor(color); }}
|
|
||||||
style={{
|
|
||||||
position: "absolute", top: "-4px", right: "-4px",
|
|
||||||
width: "16px", height: "16px", borderRadius: "50%",
|
|
||||||
border: "1px solid rgba(255,255,255,0.8)", background: "rgba(0,0,0,0.8)",
|
|
||||||
color: "#fff", cursor: "pointer", fontSize: "10px",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
opacity: isHovered ? 1 : 0, transition: "opacity 0.2s ease", zIndex: 10,
|
|
||||||
}}
|
|
||||||
>x</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "12px" }}>
|
|
||||||
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "12px", marginBottom: "6px" }}>Add Custom Color</div>
|
|
||||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={customInput}
|
|
||||||
onChange={(e) => setCustomInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { updateColor(customInput); addCustomColor(); } }}
|
|
||||||
placeholder="#ff69b4"
|
|
||||||
style={{
|
|
||||||
flex: 1, padding: "8px 12px", borderRadius: "6px",
|
|
||||||
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
|
|
||||||
color: "#fff", fontSize: "14px", fontFamily: "monospace", boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { updateColor(customInput); addCustomColor(); }}
|
|
||||||
style={{
|
|
||||||
width: "32px", height: "32px", borderRadius: "6px",
|
|
||||||
border: "1px solid rgba(255,255,255,0.3)", background: "rgba(255,255,255,0.15)",
|
|
||||||
color: "#fff", cursor: "pointer", fontSize: "16px",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
transition: "all 0.2s ease",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.25)"; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.15)"; }}
|
|
||||||
>+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeColorPicker}
|
|
||||||
style={{
|
|
||||||
width: "100%", padding: "8px", borderRadius: "6px",
|
|
||||||
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
|
|
||||||
color: "#fff", cursor: "pointer", fontSize: "12px",
|
|
||||||
}}
|
|
||||||
>Done</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Slot configuration modal */}
|
|
||||||
{shouldRenderSlot && (
|
|
||||||
<>
|
|
||||||
<button type="button" aria-label="Close slot config" onClick={closeSlotConfig} style={backdropStyle(isSlotAnimIn)} />
|
|
||||||
<div style={{ ...panelBaseStyle(isSlotAnimIn), minWidth: "520px", maxWidth: "90vw", width: "600px" }}>
|
|
||||||
<div style={{ marginBottom: "16px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>
|
|
||||||
Configure Visualizer Slots
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Segment control */}
|
|
||||||
<div style={{
|
|
||||||
display: "flex", background: "rgba(255,255,255,0.08)",
|
|
||||||
borderRadius: "10px", padding: "2px", gap: "2px", marginBottom: "20px",
|
|
||||||
}}>
|
|
||||||
{zones.map(zone => (
|
|
||||||
<button
|
|
||||||
key={zone}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveZone(zone)}
|
|
||||||
style={{
|
|
||||||
flex: 1, border: "none",
|
|
||||||
background: activeZone === zone ? "rgba(255,255,255,0.15)" : "transparent",
|
|
||||||
color: activeZone === zone ? "#fff" : "rgba(255,255,255,0.4)",
|
|
||||||
fontSize: "12px", fontWeight: 600,
|
|
||||||
padding: "7px 0", borderRadius: "8px",
|
|
||||||
cursor: "pointer", transition: "all 0.2s ease",
|
|
||||||
...(activeZone === zone ? { boxShadow: "0 1px 3px rgba(0,0,0,0.3)" } : {}),
|
|
||||||
}}
|
|
||||||
>{ZONE_LABELS[zone]}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Slot grid */}
|
|
||||||
<div style={{ display: "flex", gap: "16px", justifyContent: "center" }}>
|
|
||||||
{zonePositions(activeZone).map(pos => {
|
|
||||||
const slotKeys = ZONE_SLOTS[activeZone][pos];
|
|
||||||
if (!slotKeys) return null;
|
|
||||||
return (
|
|
||||||
<div key={pos} style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{
|
|
||||||
color: "rgba(255,255,255,0.6)", fontSize: "11px",
|
|
||||||
fontWeight: 600, textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.5px", marginBottom: "8px",
|
|
||||||
textAlign: "center",
|
|
||||||
}}>{POSITION_LABELS[pos]}</div>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
|
||||||
{slotKeys.map((key, i) => (
|
|
||||||
<div key={key} style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
|
||||||
<select
|
|
||||||
value={slots[key]}
|
|
||||||
onChange={(e) => updateSlot(key, e.target.value as VisualizerType)}
|
|
||||||
style={{ ...selectStyle, flex: 1 }}
|
|
||||||
title={`Slot ${i + 1}`}
|
|
||||||
>
|
|
||||||
{VIZ_TYPES.map(t => (
|
|
||||||
<option key={t} value={t} style={optionStyle}>{VISUALIZER_LABELS[t]}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{MINI_SUPPORTED.has(slots[key]) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title="Mini"
|
|
||||||
onClick={() => toggleMini(key)}
|
|
||||||
style={{
|
|
||||||
width: "28px", height: "28px", flexShrink: 0,
|
|
||||||
borderRadius: "6px", border: "1px solid rgba(255,255,255,0.2)",
|
|
||||||
background: miniSlots.has(key) ? "rgba(255,105,180,0.4)" : "rgba(255,255,255,0.08)",
|
|
||||||
color: miniSlots.has(key) ? "#fff" : "rgba(255,255,255,0.4)",
|
|
||||||
cursor: "pointer", fontSize: "9px", fontWeight: 700,
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
transition: "all 0.2s ease",
|
|
||||||
}}
|
|
||||||
>M</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeSlotConfig}
|
|
||||||
style={{
|
|
||||||
width: "100%", padding: "8px", borderRadius: "6px",
|
|
||||||
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
|
|
||||||
color: "#fff", cursor: "pointer", fontSize: "12px", marginTop: "20px",
|
|
||||||
}}
|
|
||||||
>Done</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<LunaNumberSetting
|
|
||||||
title="Reactivity"
|
|
||||||
desc="How quickly visualizers respond to audio (5-100)"
|
|
||||||
min={5}
|
|
||||||
max={100}
|
|
||||||
step={5}
|
|
||||||
value={reactivity}
|
|
||||||
onNumber={(v: number) => { setReactivity(v); settings.reactivity = v; }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LunaNumberSetting
|
|
||||||
title="Gain"
|
|
||||||
desc="Amplitude boost for spectrum visualizers (0.5-3.0)"
|
|
||||||
min={0.5}
|
|
||||||
max={3.0}
|
|
||||||
step={0.5}
|
|
||||||
value={gain}
|
|
||||||
onNumber={(v: number) => { setGain(v); settings.gain = v; }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LunaSelectSetting
|
|
||||||
title="FFT Size"
|
|
||||||
desc="Frequency resolution (higher = more detail, more CPU)"
|
|
||||||
value={fftSize}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
const v = Number(e.target.value);
|
|
||||||
setFftSize(v);
|
|
||||||
settings.fftSize = v;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[256, 512, 1024, 2048, 4096, 8192, 16384].map(s => (
|
|
||||||
<LunaSelectItem key={s} value={s}>{s}</LunaSelectItem>
|
|
||||||
))}
|
|
||||||
</LunaSelectSetting>
|
|
||||||
|
|
||||||
<LunaNumberSetting
|
<LunaNumberSetting
|
||||||
title="Bar Count"
|
title="Bar Count"
|
||||||
desc="Number of frequency bars (Spectrum Bars)"
|
desc="Number of frequency bars to display"
|
||||||
min={8}
|
min={8}
|
||||||
max={128}
|
max={64}
|
||||||
step={1}
|
step={1}
|
||||||
value={barCount}
|
value={barCount}
|
||||||
onNumber={(v: number) => { setBarCount(v); settings.barCount = v; }}
|
onNumber={(value: number) => {
|
||||||
|
setBarCount(value);
|
||||||
|
settings.barCount = value;
|
||||||
|
(window as any).updateAudioVisualizer?.();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasBars && (
|
{/* YUP YOUR EYES WORK... we do be using React code in the settings..*/}
|
||||||
<AnySwitch
|
{/* I'm not sure if this is a good idea, but it works & looks amazing */}
|
||||||
title="Bar Rounding"
|
{/* Sorry @Inrixia <3 */}
|
||||||
desc="Round the top corners of spectrum bars"
|
|
||||||
checked={barRounding}
|
<div
|
||||||
onChange={(_: unknown, checked: boolean) => {
|
style={{
|
||||||
setBarRounding(checked);
|
padding: "16px 0",
|
||||||
settings.barRounding = checked;
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: "normal",
|
||||||
|
fontSize: "1.075rem",
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bar Color
|
||||||
|
</div>
|
||||||
|
<div style={{ opacity: 0.7, fontSize: "14px" }}>
|
||||||
|
Color of the visualizer bars
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "8px",
|
||||||
|
alignItems: "center",
|
||||||
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
)}
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
showColorPicker ? closeColorPicker() : openColorPicker()
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
border: "1px solid rgba(255,255,255,0.15)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: barColor,
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
WebkitBackdropFilter: "blur(10px)",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.1)",
|
||||||
|
backdropFilter: "blur(2px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
<LunaNumberSetting
|
{/* Custom Color Picker Modal */}
|
||||||
title="Line Thickness"
|
{shouldRender && (
|
||||||
desc="Stroke width for line-based visualizers (0.5-5)"
|
<>
|
||||||
min={0.5}
|
{/* Backdrop */}
|
||||||
max={5}
|
<div
|
||||||
step={0.5}
|
style={{
|
||||||
value={lineThickness}
|
position: "fixed",
|
||||||
onNumber={(v: number) => { setLineThickness(v); settings.lineThickness = v; }}
|
top: 0,
|
||||||
/>
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: "rgba(0,0,0,0.6)",
|
||||||
|
zIndex: 1000,
|
||||||
|
opacity: isAnimatingIn ? 1 : 0,
|
||||||
|
transition: "opacity 0.2s ease",
|
||||||
|
}}
|
||||||
|
onClick={closeColorPicker}
|
||||||
|
/>
|
||||||
|
|
||||||
<LunaNumberSetting
|
{/* Color Picker Panel */}
|
||||||
title="Fill Opacity"
|
<div
|
||||||
desc="Fill below the Spectrum Line curve (0-1)"
|
style={{
|
||||||
min={0}
|
position: "fixed",
|
||||||
max={1}
|
top: "50%",
|
||||||
step={0.05}
|
left: "50%",
|
||||||
value={fillOpacity}
|
background: "rgba(20,20,20,0.98)",
|
||||||
onNumber={(v: number) => { setFillOpacity(v); settings.fillOpacity = v; }}
|
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>
|
||||||
|
|
||||||
<AnySwitch
|
{/* Color Grid */}
|
||||||
title="Scrolling Oscilloscope"
|
<div
|
||||||
desc="Waveform scrolls right-to-left like a chart recorder"
|
style={{
|
||||||
checked={scrollingOscilloscope}
|
display: "grid",
|
||||||
onChange={(_: unknown, checked: boolean) => {
|
gridTemplateColumns: "repeat(7, 1fr)",
|
||||||
setScrollingOscilloscope(checked);
|
gap: "8px",
|
||||||
settings.scrollingOscilloscope = checked;
|
marginBottom: "16px",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{allColors.map((color, index) => {
|
||||||
|
const isCustomColor = customColors.includes(color);
|
||||||
|
const isHovered = hoveredColorIndex === index;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
className="color-item"
|
||||||
|
onMouseEnter={() => setHoveredColorIndex(index)}
|
||||||
|
onMouseLeave={() => setHoveredColorIndex(null)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
updateColor(color);
|
||||||
|
closeColorPicker();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border:
|
||||||
|
barColor === color
|
||||||
|
? "2px solid #fff"
|
||||||
|
: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: color,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isCustomColor && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeCustomColor(color);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-4px",
|
||||||
|
right: "-4px",
|
||||||
|
width: "16px",
|
||||||
|
height: "16px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "1px solid rgba(255,255,255,0.8)",
|
||||||
|
background: "rgba(0,0,0,0.8)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "10px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: isHovered ? 1 : 0,
|
||||||
|
transition: "opacity 0.2s ease",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
className="remove-button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<AnySwitch
|
{/* Custom Hex Input */}
|
||||||
title="Lissajous Mode"
|
<div style={{ marginBottom: "12px" }}>
|
||||||
desc="Rotate the Vectorscope 45° for Lissajous display"
|
<div
|
||||||
checked={lissajous}
|
style={{
|
||||||
onChange={(_: unknown, checked: boolean) => {
|
color: "rgba(255,255,255,0.7)",
|
||||||
setLissajous(checked);
|
fontSize: "12px",
|
||||||
settings.lissajous = checked;
|
marginBottom: "6px",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
Add Custom Color
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "8px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customInput}
|
||||||
|
onChange={(e) => setCustomInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
updateColor(customInput);
|
||||||
|
addCustomColor();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="#ffffff"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: "rgba(255,255,255,0.1)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
updateColor(customInput);
|
||||||
|
addCustomColor();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid rgba(255,255,255,0.3)",
|
||||||
|
background: "rgba(255,255,255,0.15)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "16px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background =
|
||||||
|
"rgba(255,255,255,0.25)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background =
|
||||||
|
"rgba(255,255,255,0.15)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close Button (Done) - Also runs when color chosen*/}
|
||||||
|
<button
|
||||||
|
onClick={closeColorPicker}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: "rgba(255,255,255,0.1)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</LunaSettings>
|
</LunaSettings>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
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 => {
|
|
||||||
const ctx = audioContext;
|
|
||||||
if (!ctx || !monoAnalyser || !monoByteFreq || !monoByteTime || !monoFloatFreq || !monoFloatTime || !leftFloatTime || !rightFloatTime || !leftAnalyser || !rightAnalyser) return null;
|
|
||||||
|
|
||||||
if (ctx.state === "suspended") {
|
|
||||||
ctx.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: ctx.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;
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,50 @@
|
|||||||
.audio-visualizer-container {
|
/* Audio Visualizer CSS - Only applies to the Visualizer */
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
#audio-visualizer-container {
|
||||||
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, 105, 180, 0.15);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
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(255, 105, 180, 0.2);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
border-color: rgba(255, 105, 180, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-visualizer-container canvas {
|
#audio-visualizer-container canvas {
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 4px;
|
transition: all 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-visualizer-container.active {
|
/* Responsive adjustments */
|
||||||
box-shadow: 0 0 20px rgba(255, 105, 180, 0.3);
|
@media (max-width: 768px) {
|
||||||
|
#audio-visualizer-container {
|
||||||
|
margin: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#audio-visualizer-container canvas {
|
||||||
|
max-width: 150px;
|
||||||
|
max-height: 30px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes av-fadeIn {
|
/* Where to put the thingy */
|
||||||
|
[class*="_searchField"] {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-type="search-field"] {
|
||||||
|
min-width: 220px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shadow when active - doesnt seem to only apply when active but thats better */
|
||||||
|
#audio-visualizer-container.active {
|
||||||
|
box-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade in animation */
|
||||||
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.8);
|
transform: scale(0.8);
|
||||||
@@ -39,95 +55,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-type="search-field"] {
|
#audio-visualizer-container {
|
||||||
min-width: 220px !important;
|
animation: fadeIn 0.5s ease-out;
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grouped slots: merge active containers into one shared box */
|
|
||||||
.av-slot-group.av-grouped {
|
|
||||||
gap: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
border: 1px solid rgba(255, 105, 180, 0.15);
|
|
||||||
animation: av-fadeIn 0.5s ease-out;
|
|
||||||
transition: all 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
.av-slot-group.av-grouped:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
box-shadow: 0 4px 12px rgba(255, 105, 180, 0.2);
|
|
||||||
border-color: rgba(255, 105, 180, 0.3);
|
|
||||||
}
|
|
||||||
.av-slot-group.av-grouped > .audio-visualizer-container {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
backdrop-filter: none;
|
|
||||||
-webkit-backdrop-filter: none;
|
|
||||||
box-shadow: none;
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
.av-slot-group.av-grouped > .audio-visualizer-container:hover {
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chromeless: no fill, blur, or shadow; border kept */
|
|
||||||
body.av-chromeless .audio-visualizer-container {
|
|
||||||
background: transparent;
|
|
||||||
backdrop-filter: none;
|
|
||||||
-webkit-backdrop-filter: none;
|
|
||||||
box-shadow: none;
|
|
||||||
border: 1px solid rgba(255, 105, 180, 0.15);
|
|
||||||
animation: none;
|
|
||||||
transition: border-color 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
body.av-chromeless .audio-visualizer-container:hover {
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
border-color: rgba(255, 105, 180, 0.3);
|
|
||||||
}
|
|
||||||
body.av-chromeless .audio-visualizer-container.active {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
body.av-chromeless .av-slot-group.av-grouped {
|
|
||||||
background: transparent;
|
|
||||||
backdrop-filter: none;
|
|
||||||
-webkit-backdrop-filter: none;
|
|
||||||
box-shadow: none;
|
|
||||||
border: 1px solid rgba(255, 105, 180, 0.15);
|
|
||||||
animation: none;
|
|
||||||
transition: border-color 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
body.av-chromeless .av-slot-group.av-grouped:hover {
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
border-color: rgba(255, 105, 180, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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: 100, height: 40 },
|
|
||||||
"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", "vectorscope"]);
|
|
||||||
|
|
||||||
export const MINI_DIMENSIONS: Partial<Record<VisualizerType, VisualizerDimensions>> = {
|
|
||||||
oscilloscope: { width: 80, height: 60 },
|
|
||||||
vectorscope: { width: 72, height: 40 },
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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 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;
|
|
||||||
const c = cvs.getContext("2d");
|
|
||||||
if (!c) return;
|
|
||||||
ctx = c;
|
|
||||||
w = cvs.width;
|
|
||||||
h = cvs.height;
|
|
||||||
hasLast = false;
|
|
||||||
|
|
||||||
lastLissajous = !!settings.lissajous;
|
|
||||||
cvs.style.transform = lastLissajous ? "rotate(45deg) scale(0.707)" : "";
|
|
||||||
},
|
|
||||||
|
|
||||||
render(data: AudioData, color: string) {
|
|
||||||
if (!ctx || !canvas) return;
|
|
||||||
|
|
||||||
const wantLissajous = !!settings.lissajous;
|
|
||||||
if (wantLissajous !== lastLissajous) {
|
|
||||||
lastLissajous = wantLissajous;
|
|
||||||
canvas.style.transform = wantLissajous ? "rotate(45deg) scale(0.707)" : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
|
|
||||||
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 inset = lineWidth;
|
|
||||||
const halfW = Math.max(1, w / 2 - inset);
|
|
||||||
const halfH = Math.max(1, h / 2 - inset);
|
|
||||||
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = lineWidth;
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
|
|
||||||
hasLast = false;
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
const x = left[i] * halfW + w / 2;
|
|
||||||
const y = right[i] * halfH + h / 2;
|
|
||||||
|
|
||||||
if (!hasLast) {
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
hasLast = true;
|
|
||||||
} else {
|
|
||||||
ctx.moveTo(lastX, lastY);
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
lastX = x;
|
|
||||||
lastY = y;
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
},
|
|
||||||
|
|
||||||
resize(width, height) {
|
|
||||||
w = width;
|
|
||||||
h = height;
|
|
||||||
hasLast = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
if (canvas) canvas.style.transform = "";
|
|
||||||
ctx = null;
|
|
||||||
canvas = null;
|
|
||||||
hasLast = false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
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];
|
|
||||||
};
|
|
||||||
@@ -8,24 +8,53 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Define a typed onChange signature for the switch
|
||||||
type SwitchChangeHandler = (
|
type SwitchChangeHandler = (
|
||||||
event: React.ChangeEvent<HTMLInputElement> | null,
|
event: React.ChangeEvent<HTMLInputElement> | null,
|
||||||
checked: boolean,
|
checked: boolean,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
|
export type ColoramaMode =
|
||||||
|
| "single"
|
||||||
|
| "gradient-experimental"
|
||||||
|
| "cover"
|
||||||
|
| "cover-gradient";
|
||||||
|
|
||||||
export const settings = await ReactiveStore.getPluginStorage("ColoramaLyrics", {
|
export const settings = await ReactiveStore.getPluginStorage("ColoramaLyrics", {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
mode: "single" as ColoramaMode,
|
||||||
|
// Store colors as RGB hex (#RRGGBB) and opacity separately (0-100)
|
||||||
singleColor: "#FFFFFF",
|
singleColor: "#FFFFFF",
|
||||||
singleAlpha: 100,
|
singleAlpha: 100,
|
||||||
|
gradientStart: "#FFFFFF",
|
||||||
|
gradientStartAlpha: 100,
|
||||||
|
gradientEnd: "#AAFFFF",
|
||||||
|
gradientEndAlpha: 100,
|
||||||
|
gradientAngle: 0,
|
||||||
customColors: [] as string[],
|
customColors: [] as string[],
|
||||||
excludeInactive: false,
|
excludeInactive: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Settings = () => {
|
export const Settings = () => {
|
||||||
|
// const [enabled, setEnabled] = React.useState(settings.enabled);
|
||||||
|
const [mode, setMode] = React.useState<ColoramaMode>(settings.mode);
|
||||||
const [singleColor, setSingleColor] = React.useState(settings.singleColor);
|
const [singleColor, setSingleColor] = React.useState(settings.singleColor);
|
||||||
const [singleAlpha, setSingleAlpha] = React.useState<number>(
|
const [singleAlpha, setSingleAlpha] = React.useState<number>(
|
||||||
settings.singleAlpha ?? 100,
|
settings.singleAlpha ?? 100,
|
||||||
);
|
);
|
||||||
|
const [gradientStart, setGradientStart] = React.useState(
|
||||||
|
settings.gradientStart,
|
||||||
|
);
|
||||||
|
const [gradientStartAlpha, setGradientStartAlpha] = React.useState<number>(
|
||||||
|
settings.gradientStartAlpha ?? 100,
|
||||||
|
);
|
||||||
|
const [gradientEnd, setGradientEnd] = React.useState(settings.gradientEnd);
|
||||||
|
const [gradientEndAlpha, setGradientEndAlpha] = React.useState<number>(
|
||||||
|
settings.gradientEndAlpha ?? 100,
|
||||||
|
);
|
||||||
|
const [gradientAngle, setGradientAngle] = React.useState(
|
||||||
|
settings.gradientAngle,
|
||||||
|
);
|
||||||
const [customInput, setCustomInput] = React.useState(settings.singleColor);
|
const [customInput, setCustomInput] = React.useState(settings.singleColor);
|
||||||
const [customColors, setCustomColors] = React.useState(settings.customColors);
|
const [customColors, setCustomColors] = React.useState(settings.customColors);
|
||||||
const [showPicker, setShowPicker] = React.useState(false);
|
const [showPicker, setShowPicker] = React.useState(false);
|
||||||
@@ -34,6 +63,9 @@ export const Settings = () => {
|
|||||||
const [excludeInactive, setExcludeInactive] = React.useState(
|
const [excludeInactive, setExcludeInactive] = React.useState(
|
||||||
settings.excludeInactive,
|
settings.excludeInactive,
|
||||||
);
|
);
|
||||||
|
const [activeEndpoint, setActiveEndpoint] = React.useState<
|
||||||
|
"single" | "start" | "end"
|
||||||
|
>("single");
|
||||||
const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType<{
|
const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType<{
|
||||||
title: string;
|
title: string;
|
||||||
desc?: string;
|
desc?: string;
|
||||||
@@ -41,23 +73,28 @@ export const Settings = () => {
|
|||||||
onChange: SwitchChangeHandler;
|
onChange: SwitchChangeHandler;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// Helper for HEX normalization
|
||||||
const normalizeToRGB = (
|
const normalizeToRGB = (
|
||||||
hex: string,
|
hex: string,
|
||||||
fallback: string = "#FFFFFF",
|
fallback: string = "#FFFFFF",
|
||||||
): string => {
|
): string => {
|
||||||
let v = hex.trim().toLowerCase();
|
let v = hex.trim().toLowerCase();
|
||||||
if (!v.startsWith("#")) v = `#${v}`;
|
if (!v.startsWith("#")) v = `#${v}`;
|
||||||
|
// #rgb or #rgba -> expand
|
||||||
if (/^#([0-9a-f]{3,4})$/.test(v)) {
|
if (/^#([0-9a-f]{3,4})$/.test(v)) {
|
||||||
const m = v.slice(1);
|
const m = v.slice(1);
|
||||||
const r = m[0];
|
const r = m[0];
|
||||||
const g = m[1];
|
const g = m[1];
|
||||||
const b = m[2];
|
const b = m[2];
|
||||||
|
// ignore alpha if provided (#rgba)
|
||||||
return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
|
return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
// #aarrggbb -> strip alpha
|
||||||
if (/^#([0-9a-f]{8})$/.test(v)) {
|
if (/^#([0-9a-f]{8})$/.test(v)) {
|
||||||
const rrggbb = v.slice(3);
|
const rrggbb = v.slice(3);
|
||||||
return `#${rrggbb}`.toUpperCase();
|
return `#${rrggbb}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
// #rrggbb
|
||||||
if (/^#([0-9a-f]{6})$/.test(v)) return v.toUpperCase();
|
if (/^#([0-9a-f]{6})$/.test(v)) return v.toUpperCase();
|
||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
@@ -84,7 +121,8 @@ export const Settings = () => {
|
|||||||
"#1976D2",
|
"#1976D2",
|
||||||
];
|
];
|
||||||
|
|
||||||
const openPicker = () => {
|
const openPicker = (endpoint: "single" | "start" | "end" = "single") => {
|
||||||
|
setActiveEndpoint(endpoint);
|
||||||
setShowPicker(true);
|
setShowPicker(true);
|
||||||
setShouldRender(true);
|
setShouldRender(true);
|
||||||
setTimeout(() => setIsAnimatingIn(true), 10);
|
setTimeout(() => setIsAnimatingIn(true), 10);
|
||||||
@@ -102,10 +140,22 @@ export const Settings = () => {
|
|||||||
const applyCustomInputColor = (raw: string, updateInput: boolean): void => {
|
const applyCustomInputColor = (raw: string, updateInput: boolean): void => {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!hexColorRegex.test(trimmed)) return;
|
if (!hexColorRegex.test(trimmed)) return;
|
||||||
const next = normalizeToRGB(trimmed);
|
if (mode === "single") {
|
||||||
settings.singleColor = next;
|
const next = normalizeToRGB(trimmed);
|
||||||
setSingleColor(next);
|
settings.singleColor = next;
|
||||||
if (updateInput) setCustomInput(next);
|
setSingleColor(next);
|
||||||
|
if (updateInput) setCustomInput(next);
|
||||||
|
} else if (mode === "gradient-experimental") {
|
||||||
|
const next = normalizeToRGB(trimmed);
|
||||||
|
if (activeEndpoint === "end") {
|
||||||
|
settings.gradientEnd = next;
|
||||||
|
setGradientEnd(next);
|
||||||
|
} else {
|
||||||
|
settings.gradientStart = next;
|
||||||
|
setGradientStart(next);
|
||||||
|
}
|
||||||
|
if (updateInput) setCustomInput(next);
|
||||||
|
}
|
||||||
requestApply();
|
requestApply();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,6 +172,12 @@ export const Settings = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// const removeCustomColor = (color: string) => {
|
||||||
|
// const updated = customColors.filter((c) => c !== color);
|
||||||
|
// setCustomColors(updated);
|
||||||
|
// settings.customColors = updated;
|
||||||
|
// };
|
||||||
|
|
||||||
const allColors = [...colorPresets, ...customColors];
|
const allColors = [...colorPresets, ...customColors];
|
||||||
|
|
||||||
const requestApply = () => {
|
const requestApply = () => {
|
||||||
@@ -130,11 +186,66 @@ export const Settings = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LunaSettings>
|
<LunaSettings>
|
||||||
{/* Single color picker button */}
|
{/* Mode selection via dropdown (aligned right) */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "8px 0",
|
padding: "8px 0",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
<div style={{ fontWeight: "normal", fontSize: "1.075rem" }}>Mode</div>
|
||||||
|
<div style={{ opacity: 0.7, fontSize: 14 }}>
|
||||||
|
Choose how lyrics are colored
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={mode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value as ColoramaMode;
|
||||||
|
settings.mode = next;
|
||||||
|
setMode(next);
|
||||||
|
requestApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: "rgba(255,255,255,0.08)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
marginLeft: "auto",
|
||||||
|
minWidth: 180,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="single" style={{ color: "#000", background: "#fff" }}>
|
||||||
|
Single
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="gradient-experimental"
|
||||||
|
style={{ color: "#000", background: "#fff" }}
|
||||||
|
>
|
||||||
|
Gradient - Experimental
|
||||||
|
</option>
|
||||||
|
<option value="cover" style={{ color: "#000", background: "#fff" }}>
|
||||||
|
Cover - Experimental
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="cover-gradient"
|
||||||
|
style={{ color: "#000", background: "#fff" }}
|
||||||
|
>
|
||||||
|
Cover (Gradient) - Experimental
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Single color */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 0",
|
||||||
|
display: mode === "single" ? "flex" : "none",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
@@ -161,7 +272,7 @@ export const Settings = () => {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => (showPicker ? closePicker() : openPicker())}
|
onClick={() => (showPicker ? closePicker() : openPicker("single"))}
|
||||||
style={{
|
style={{
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
@@ -174,7 +285,84 @@ export const Settings = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Color picker modal */}
|
{/* Gradient controls (open picker) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 0",
|
||||||
|
display: mode === "gradient-experimental" ? "flex" : "none",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: "normal",
|
||||||
|
fontSize: "1.075rem",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Gradient (Experimental)
|
||||||
|
</div>
|
||||||
|
<div style={{ opacity: 0.7, fontSize: 14 }}>Set colors & angle</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setCustomInput(gradientStart);
|
||||||
|
openPicker("start");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: "rgba(255,255,255,0.08)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Configure
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover gradient controls (open picker for angle) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 0",
|
||||||
|
display: mode === "cover-gradient" ? "flex" : "none",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: "normal",
|
||||||
|
fontSize: "1.075rem",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cover (Gradient) - Experimental
|
||||||
|
</div>
|
||||||
|
<div style={{ opacity: 0.7, fontSize: 14 }}>Set angle</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openPicker("start")}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: "rgba(255,255,255,0.08)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Configure
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal for picking and managing colors (reused) */}
|
||||||
{shouldRender && (
|
{shouldRender && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -227,122 +415,369 @@ export const Settings = () => {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Lyrics Color
|
{mode === "single" ? "Single Color" : "Gradient Colors"}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{mode === "gradient-experimental" && (
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(7, 1fr)",
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{allColors.map((color) => (
|
|
||||||
<button
|
|
||||||
key={color}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const next = normalizeToRGB(color);
|
|
||||||
settings.singleColor = next;
|
|
||||||
setSingleColor(next);
|
|
||||||
setCustomInput(next);
|
|
||||||
requestApply();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 6,
|
|
||||||
border: "1px solid rgba(255,255,255,0.2)",
|
|
||||||
background: normalizeToRGB(color),
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
color: "rgba(255,255,255,0.7)",
|
display: "flex",
|
||||||
fontSize: 12,
|
gap: 8,
|
||||||
marginBottom: 6,
|
alignItems: "center",
|
||||||
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Custom Hex (#RRGGBB)
|
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: 12 }}>
|
||||||
</div>
|
Editing
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
</div>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={customInput}
|
|
||||||
onChange={(e) => setCustomInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
applyCustomInputColor(customInput, true);
|
|
||||||
addCustomColor();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="#RRGGBB"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 6,
|
|
||||||
border: "1px solid rgba(255,255,255,0.2)",
|
|
||||||
background: "rgba(255,255,255,0.1)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: "monospace",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
applyCustomInputColor(customInput, false);
|
setActiveEndpoint("start");
|
||||||
addCustomColor();
|
setCustomInput(gradientStart);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 6,
|
|
||||||
border: "1px solid rgba(255,255,255,0.3)",
|
|
||||||
background: "rgba(255,255,255,0.15)",
|
|
||||||
color: "#fff",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 16,
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
gap: 8,
|
||||||
transition: "all 0.2s ease",
|
padding: "6px 10px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border:
|
||||||
|
activeEndpoint === "start"
|
||||||
|
? "1px solid rgba(255,255,255,0.5)"
|
||||||
|
: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
+
|
<span
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: normalizeToRGB(gradientStart),
|
||||||
|
border: "1px solid rgba(255,255,255,0.3)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12 }}>Start</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveEndpoint("end");
|
||||||
|
setCustomInput(gradientEnd);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border:
|
||||||
|
activeEndpoint === "end"
|
||||||
|
? "1px solid rgba(255,255,255,0.5)"
|
||||||
|
: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: normalizeToRGB(gradientEnd),
|
||||||
|
border: "1px solid rgba(255,255,255,0.3)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12 }}>End</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div style={{ marginBottom: 16 }}>
|
{mode !== "cover-gradient" && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
color: "rgba(255,255,255,0.8)",
|
display: "grid",
|
||||||
fontSize: 12,
|
gridTemplateColumns: "repeat(7, 1fr)",
|
||||||
marginBottom: 6,
|
gap: 8,
|
||||||
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Alpha
|
{allColors.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const next = normalizeToRGB(color);
|
||||||
|
if (mode === "single") {
|
||||||
|
settings.singleColor = next;
|
||||||
|
setSingleColor(next);
|
||||||
|
} else if (mode === "gradient-experimental") {
|
||||||
|
if (activeEndpoint === "end") {
|
||||||
|
settings.gradientEnd = next;
|
||||||
|
setGradientEnd(next);
|
||||||
|
} else {
|
||||||
|
settings.gradientStart = next;
|
||||||
|
setGradientStart(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCustomInput(next);
|
||||||
|
requestApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: normalizeToRGB(color),
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<input
|
)}
|
||||||
type="range"
|
{mode !== "cover-gradient" && (
|
||||||
min={5}
|
<div style={{ marginBottom: 12 }}>
|
||||||
max={100}
|
<div
|
||||||
step={1}
|
style={{
|
||||||
value={singleAlpha}
|
color: "rgba(255,255,255,0.7)",
|
||||||
onChange={(e) => {
|
fontSize: 12,
|
||||||
const value = Number(e.target.value);
|
marginBottom: 6,
|
||||||
settings.singleAlpha = value;
|
}}
|
||||||
setSingleAlpha(value);
|
>
|
||||||
requestApply();
|
Custom Hex (#RRGGBB)
|
||||||
}}
|
</div>
|
||||||
style={{ width: "100%" }}
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
/>
|
<input
|
||||||
</div>
|
type="text"
|
||||||
|
value={customInput}
|
||||||
|
onChange={(e) => setCustomInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
applyCustomInputColor(customInput, true);
|
||||||
|
addCustomColor();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="#RRGGBB"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid rgba(255,255,255,0.2)",
|
||||||
|
background: "rgba(255,255,255,0.1)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
applyCustomInputColor(customInput, false);
|
||||||
|
addCustomColor();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid rgba(255,255,255,0.3)",
|
||||||
|
background: "rgba(255,255,255,0.15)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 16,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Sliders inside picker based on mode */}
|
||||||
|
{mode === "single" && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Alpha
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={singleAlpha}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
settings.singleAlpha = value;
|
||||||
|
setSingleAlpha(value);
|
||||||
|
requestApply();
|
||||||
|
}}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "gradient-experimental" && (
|
||||||
|
<div style={{ marginBottom: 16, display: "grid", gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: normalizeToRGB(gradientStart),
|
||||||
|
border: "1px solid rgba(255,255,255,0.3)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Start Alpha
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={gradientStartAlpha}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
settings.gradientStartAlpha = value;
|
||||||
|
setGradientStartAlpha(value);
|
||||||
|
requestApply();
|
||||||
|
}}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: normalizeToRGB(gradientEnd),
|
||||||
|
border: "1px solid rgba(255,255,255,0.3)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}
|
||||||
|
>
|
||||||
|
End Alpha
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={gradientEndAlpha}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
settings.gradientEndAlpha = value;
|
||||||
|
setGradientEndAlpha(value);
|
||||||
|
requestApply();
|
||||||
|
}}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Angle
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ color: "rgba(255,255,255,0.6)", fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{gradientAngle}°
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
step={1}
|
||||||
|
value={gradientAngle}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
settings.gradientAngle = value;
|
||||||
|
setGradientAngle(value);
|
||||||
|
requestApply();
|
||||||
|
}}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "cover-gradient" && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}>
|
||||||
|
Angle
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "rgba(255,255,255,0.6)", fontSize: 12 }}>
|
||||||
|
{gradientAngle}°
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
step={1}
|
||||||
|
value={gradientAngle}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
settings.gradientAngle = value;
|
||||||
|
setGradientAngle(value);
|
||||||
|
requestApply();
|
||||||
|
}}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={closePicker}
|
onClick={closePicker}
|
||||||
@@ -365,7 +800,7 @@ export const Settings = () => {
|
|||||||
)}
|
)}
|
||||||
<AnySwitch
|
<AnySwitch
|
||||||
title="Exclude Inactive"
|
title="Exclude Inactive"
|
||||||
desc="Apply color only to the currently active lyric line"
|
desc="Apply color/gradient only to the currently active lyric line"
|
||||||
checked={excludeInactive}
|
checked={excludeInactive}
|
||||||
onChange={(_event: React.ChangeEvent<HTMLInputElement> | null, checked: boolean) => {
|
onChange={(_event: React.ChangeEvent<HTMLInputElement> | null, checked: boolean) => {
|
||||||
settings.excludeInactive = checked;
|
settings.excludeInactive = checked;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LunaUnload, Tracer } from "@luna/core";
|
import { LunaUnload, Tracer } from "@luna/core";
|
||||||
import { StyleTag } from "@luna/lib";
|
import { StyleTag, PlayState } from "@luna/lib";
|
||||||
import { settings, Settings } from "./Settings";
|
import { settings, Settings } from "./Settings";
|
||||||
|
|
||||||
import styles from "file://styles.css?minify";
|
import styles from "file://styles.css?minify";
|
||||||
@@ -11,6 +11,66 @@ export const unloads = new Set<LunaUnload>();
|
|||||||
|
|
||||||
new StyleTag("ColoramaLyrics", unloads, styles);
|
new StyleTag("ColoramaLyrics", unloads, styles);
|
||||||
|
|
||||||
|
// Simple dominant color extraction from current cover art
|
||||||
|
async function getCoverArtElement(): Promise<HTMLImageElement | null> {
|
||||||
|
const img = document.querySelector(
|
||||||
|
'figure[class*="_albumImage"] > div > div > div > img',
|
||||||
|
) as HTMLImageElement | null;
|
||||||
|
if (img) return img;
|
||||||
|
const video = document.querySelector(
|
||||||
|
'figure[class*="_albumImage"] > div > div > div > video',
|
||||||
|
) as HTMLVideoElement | null;
|
||||||
|
if (video) {
|
||||||
|
const poster = video.getAttribute("poster");
|
||||||
|
if (!poster) return null;
|
||||||
|
const tempImg = new Image();
|
||||||
|
tempImg.crossOrigin = "anonymous";
|
||||||
|
tempImg.src = poster;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
tempImg.onload = () => resolve();
|
||||||
|
tempImg.onerror = () => resolve();
|
||||||
|
});
|
||||||
|
return tempImg as unknown as HTMLImageElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDominantColorsFromImage(
|
||||||
|
img: HTMLImageElement,
|
||||||
|
count: number = 2,
|
||||||
|
): string[] {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return ["#ffffff", "#88aaff"]; // fallback
|
||||||
|
const w = 64;
|
||||||
|
const h = 64;
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
const data = ctx.getImageData(0, 0, w, h).data;
|
||||||
|
|
||||||
|
// Simple k-means-ish binning into 16 buckets per channel
|
||||||
|
const buckets = new Map<string, number>();
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const r = data[i];
|
||||||
|
const g = data[i + 1];
|
||||||
|
const b = data[i + 2];
|
||||||
|
const key = `${Math.round(r / 16)},${Math.round(g / 16)},${Math.round(b / 16)}`;
|
||||||
|
buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const sorted = [...buckets.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const picked = sorted.slice(0, Math.max(1, count)).map(([key]) => {
|
||||||
|
const [r, g, b] = key.split(",").map((v) => parseInt(v, 10) * 16);
|
||||||
|
return `#${[r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
});
|
||||||
|
return picked;
|
||||||
|
} catch {
|
||||||
|
return ["#ffffff", "#88aaff"]; // fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// build rgba() from hex + alpha percentage
|
||||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||||
let v = hex.trim();
|
let v = hex.trim();
|
||||||
if (!v.startsWith("#")) v = `#${v}`;
|
if (!v.startsWith("#")) v = `#${v}`;
|
||||||
@@ -26,6 +86,8 @@ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|||||||
const b = parseInt(v.slice(5, 7), 16);
|
const b = parseInt(v.slice(5, 7), 16);
|
||||||
return { r, g, b };
|
return { r, g, b };
|
||||||
}
|
}
|
||||||
|
// 8-digit hex expects #AARRGGBB. Indices 1-3 are the alpha byte (ignored here),
|
||||||
|
// so r/g/b are extracted from v.slice(3,5), v.slice(5,7), v.slice(7,9) respectively.
|
||||||
if (/^#([0-9a-fA-F]{8})$/.test(v)) {
|
if (/^#([0-9a-fA-F]{8})$/.test(v)) {
|
||||||
const r = parseInt(v.slice(3, 5), 16);
|
const r = parseInt(v.slice(3, 5), 16);
|
||||||
const g = parseInt(v.slice(5, 7), 16);
|
const g = parseInt(v.slice(5, 7), 16);
|
||||||
@@ -51,28 +113,102 @@ function applySingleColor(color: string) {
|
|||||||
document.documentElement.style.setProperty("--cl-lyrics-color", rgba);
|
document.documentElement.style.setProperty("--cl-lyrics-color", rgba);
|
||||||
document.documentElement.style.setProperty("--cl-glow1", rgba);
|
document.documentElement.style.setProperty("--cl-glow1", rgba);
|
||||||
document.documentElement.style.setProperty("--cl-glow2", rgba);
|
document.documentElement.style.setProperty("--cl-glow2", rgba);
|
||||||
|
document.documentElement.style.removeProperty("--cl-grad-start");
|
||||||
|
document.documentElement.style.removeProperty("--cl-grad-end");
|
||||||
|
document.documentElement.style.removeProperty("--cl-grad-angle");
|
||||||
|
document.body.classList.remove("colorama-gradient");
|
||||||
document.body.classList.add("colorama-single");
|
document.body.classList.add("colorama-single");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyGradient(start: string, end: string, angle: number) {
|
||||||
|
const startAlpha = (settings as any).gradientStartAlpha ?? 100;
|
||||||
|
const endAlpha = (settings as any).gradientEndAlpha ?? 100;
|
||||||
|
const startRgba = rgbaFromHexAndAlpha(start, startAlpha);
|
||||||
|
const endRgba = rgbaFromHexAndAlpha(end, endAlpha);
|
||||||
|
document.documentElement.style.setProperty("--cl-grad-start", startRgba);
|
||||||
|
document.documentElement.style.setProperty("--cl-grad-end", endRgba);
|
||||||
|
document.documentElement.style.setProperty("--cl-grad-angle", `${angle}deg`);
|
||||||
|
document.documentElement.style.setProperty("--cl-glow1", startRgba);
|
||||||
|
document.documentElement.style.setProperty("--cl-glow2", endRgba);
|
||||||
|
document.body.classList.remove("colorama-single");
|
||||||
|
document.body.classList.add("colorama-gradient");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetModeClasses(): void {
|
||||||
|
document.body.classList.remove("colorama-single", "colorama-gradient");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCoverColors(gradient: boolean) {
|
||||||
|
const img = await getCoverArtElement();
|
||||||
|
if (!img) return;
|
||||||
|
const colors = getDominantColorsFromImage(img, gradient ? 2 : 1);
|
||||||
|
if (gradient) {
|
||||||
|
const start = colors[0] ?? settings.gradientStart;
|
||||||
|
const end = colors[1] ?? settings.gradientEnd;
|
||||||
|
applyGradient(start, end, settings.gradientAngle);
|
||||||
|
} else {
|
||||||
|
const color = colors[0] ?? settings.singleColor;
|
||||||
|
applySingleColor(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyColoramaLyrics(): void {
|
function applyColoramaLyrics(): void {
|
||||||
if (!settings.enabled) {
|
if (!settings.enabled) {
|
||||||
document.body.classList.remove("colorama-single");
|
document.body.classList.remove("colorama-single", "colorama-gradient");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle only-active-line mode class
|
||||||
if (settings.excludeInactive) {
|
if (settings.excludeInactive) {
|
||||||
document.body.classList.add("colorama-only-active");
|
document.body.classList.add("colorama-only-active");
|
||||||
} else {
|
} else {
|
||||||
document.body.classList.remove("colorama-only-active");
|
document.body.classList.remove("colorama-only-active");
|
||||||
}
|
}
|
||||||
|
resetModeClasses();
|
||||||
applySingleColor(settings.singleColor);
|
switch (settings.mode) {
|
||||||
|
case "single":
|
||||||
|
applySingleColor(settings.singleColor);
|
||||||
|
break;
|
||||||
|
case "gradient-experimental":
|
||||||
|
applyGradient(
|
||||||
|
settings.gradientStart,
|
||||||
|
settings.gradientEnd,
|
||||||
|
settings.gradientAngle,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "cover":
|
||||||
|
applyCoverColors(false);
|
||||||
|
break;
|
||||||
|
case "cover-gradient":
|
||||||
|
applyCoverColors(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(window as any).applyColoramaLyrics = applyColoramaLyrics;
|
(window as any).applyColoramaLyrics = applyColoramaLyrics;
|
||||||
|
|
||||||
setTimeout(() => applyColoramaLyrics(), 200);
|
// Re-apply on track changes (for auto modes)
|
||||||
|
function observeTrackChanges(): void {
|
||||||
|
let lastTrackId: string | null = null;
|
||||||
|
const check = () => {
|
||||||
|
const currentTrackId = PlayState.playbackContext?.actualProductId;
|
||||||
|
if (currentTrackId && currentTrackId !== lastTrackId) {
|
||||||
|
lastTrackId = currentTrackId;
|
||||||
|
if (settings.mode === "cover" || settings.mode === "cover-gradient") {
|
||||||
|
setTimeout(() => applyColoramaLyrics(), 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const interval = setInterval(check, 500);
|
||||||
|
unloads.add(() => clearInterval(interval));
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial apply and observers
|
||||||
|
setTimeout(() => applyColoramaLyrics(), 200);
|
||||||
|
observeTrackChanges();
|
||||||
|
|
||||||
|
// for some reason, re-apply after Radiant updates its styles/backgrounds
|
||||||
function hookRadiantUpdates(): void {
|
function hookRadiantUpdates(): void {
|
||||||
const w = window as any;
|
const w = window as any;
|
||||||
const wrap = (name: string) => {
|
const wrap = (name: string) => {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/* Variables used by Colorama Lyrics */
|
/* Variables used by Colorama Lyrics */
|
||||||
:root {
|
:root {
|
||||||
--cl-lyrics-color: #ffffff;
|
--cl-lyrics-color: #ffffff;
|
||||||
|
--cl-grad-start: #ffffff;
|
||||||
|
--cl-grad-end: #88aaff;
|
||||||
|
--cl-grad-angle: 0deg;
|
||||||
--cl-glow1: #ffffff;
|
--cl-glow1: #ffffff;
|
||||||
--cl-glow2: #ffffff;
|
--cl-glow2: #ffffff;
|
||||||
}
|
}
|
||||||
@@ -21,9 +24,54 @@
|
|||||||
-webkit-text-fill-color: initial !important;
|
-webkit-text-fill-color: initial !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Apply gradient to lyrics text */
|
||||||
|
.colorama-gradient [class*="_lyricsText"] > div > span,
|
||||||
|
.colorama-gradient [class*="_lyricsText"] > div > span[data-current="true"],
|
||||||
|
.colorama-gradient [class^="_lyricsContainer"] > div > div > span,
|
||||||
|
.colorama-gradient
|
||||||
|
[class^="_lyricsContainer"]
|
||||||
|
> div
|
||||||
|
> div
|
||||||
|
> span[data-current="true"] {
|
||||||
|
background: linear-gradient(
|
||||||
|
var(--cl-grad-angle),
|
||||||
|
var(--cl-grad-start),
|
||||||
|
var(--cl-grad-end)
|
||||||
|
) !important;
|
||||||
|
-webkit-background-clip: text !important;
|
||||||
|
background-clip: text !important;
|
||||||
|
color: transparent !important;
|
||||||
|
-webkit-text-fill-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only-active: apply container class only on the active line via JS */
|
||||||
|
|
||||||
|
/* Slight emphasis on current line (uniform to single mode) */
|
||||||
|
.colorama-gradient [class*="_lyricsText"] > div > span[data-current="true"],
|
||||||
|
.colorama-gradient
|
||||||
|
[class^="_lyricsContainer"]
|
||||||
|
> div
|
||||||
|
> div
|
||||||
|
> span[data-current="true"] {
|
||||||
|
filter: brightness(1.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep song title color unchanged; its glow is controlled in Radiant CSS */
|
||||||
|
|
||||||
/* Color Radiant glow shadows using Colorama colors (respect RL sizes) */
|
/* Color Radiant glow shadows using Colorama colors (respect RL sizes) */
|
||||||
.colorama-single [class*="_lyricsText"] > div > span[data-current="true"],
|
.colorama-single [class*="_lyricsText"] > div > span[data-current="true"],
|
||||||
.colorama-single
|
.colorama-single
|
||||||
|
[class^="_lyricsContainer"]
|
||||||
|
> div
|
||||||
|
> div
|
||||||
|
> span[data-current="true"],
|
||||||
|
.colorama-gradient [class*="_lyricsText"] > div > span[data-current="true"],
|
||||||
|
.colorama-gradient
|
||||||
|
[class^="_lyricsContainer"]
|
||||||
|
> div
|
||||||
|
> div
|
||||||
|
> span[data-current="true"],
|
||||||
|
.colorama-gradient
|
||||||
[class^="_lyricsContainer"]
|
[class^="_lyricsContainer"]
|
||||||
> div
|
> div
|
||||||
> div
|
> div
|
||||||
@@ -42,6 +90,20 @@
|
|||||||
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
|
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.colorama-gradient [class*="_lyricsText"] > div > span:hover,
|
||||||
|
.colorama-gradient [class^="_lyricsContainer"] > div > div > span:hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
var(--cl-grad-angle),
|
||||||
|
var(--cl-grad-start),
|
||||||
|
var(--cl-grad-end)
|
||||||
|
) !important;
|
||||||
|
-webkit-background-clip: text !important;
|
||||||
|
background-clip: text !important;
|
||||||
|
color: transparent !important;
|
||||||
|
-webkit-text-fill-color: transparent !important;
|
||||||
|
/* Do not increase glow strength on hover for gradients */
|
||||||
|
}
|
||||||
|
|
||||||
/* MARKER: Radiant WBW Lyrics Support */
|
/* MARKER: Radiant WBW Lyrics Support */
|
||||||
|
|
||||||
/* Single color: active wbw words & syllable finished */
|
/* Single color: active wbw words & syllable finished */
|
||||||
@@ -61,6 +123,31 @@
|
|||||||
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
|
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gradient: active wbw words */
|
||||||
|
.colorama-gradient .rl-wbw-word.rl-wbw-active {
|
||||||
|
background: linear-gradient(
|
||||||
|
var(--cl-grad-angle),
|
||||||
|
var(--cl-grad-start),
|
||||||
|
var(--cl-grad-end)
|
||||||
|
) !important;
|
||||||
|
-webkit-background-clip: text !important;
|
||||||
|
background-clip: text !important;
|
||||||
|
color: transparent !important;
|
||||||
|
-webkit-text-fill-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient: syllable finished (solid color — gradient conflicts with sweep animation) */
|
||||||
|
.colorama-gradient .rl-wbw-word.rl-syl-finished {
|
||||||
|
color: var(--cl-glow1, #ffffff) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient: active wbw word glow */
|
||||||
|
.colorama-gradient .rl-wbw-word.rl-wbw-active {
|
||||||
|
text-shadow:
|
||||||
|
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #ffffff),
|
||||||
|
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hover: wbw words pick up Colorama colors */
|
/* Hover: wbw words pick up Colorama colors */
|
||||||
.colorama-single .rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word:hover,
|
.colorama-single .rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word:hover,
|
||||||
.colorama-single .rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word.rl-wbw-word-hover {
|
.colorama-single .rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word.rl-wbw-word-hover {
|
||||||
@@ -70,8 +157,23 @@
|
|||||||
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
|
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.colorama-gradient .rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word:hover,
|
||||||
|
.colorama-gradient .rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word.rl-wbw-word-hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
var(--cl-grad-angle),
|
||||||
|
var(--cl-grad-start),
|
||||||
|
var(--cl-grad-end)
|
||||||
|
) !important;
|
||||||
|
-webkit-background-clip: text !important;
|
||||||
|
background-clip: text !important;
|
||||||
|
color: transparent !important;
|
||||||
|
-webkit-text-fill-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Only-active: wbw words on inactive lines stay default */
|
/* Only-active: wbw words on inactive lines stay default */
|
||||||
body.colorama-only-active.colorama-single
|
body.colorama-only-active.colorama-single
|
||||||
|
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word,
|
||||||
|
body.colorama-only-active.colorama-gradient
|
||||||
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word {
|
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word {
|
||||||
color: rgba(128, 128, 128, 0.4) !important;
|
color: rgba(128, 128, 128, 0.4) !important;
|
||||||
background: none !important;
|
background: none !important;
|
||||||
@@ -83,6 +185,8 @@ body.colorama-only-active.colorama-single
|
|||||||
|
|
||||||
/* Only-active: hover on inactive wbw lines keeps default */
|
/* Only-active: hover on inactive wbw lines keeps default */
|
||||||
body.colorama-only-active.colorama-single
|
body.colorama-only-active.colorama-single
|
||||||
|
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word:hover,
|
||||||
|
body.colorama-only-active.colorama-gradient
|
||||||
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word:hover {
|
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word:hover {
|
||||||
color: lightgray !important;
|
color: lightgray !important;
|
||||||
background: none !important;
|
background: none !important;
|
||||||
@@ -94,8 +198,13 @@ body.colorama-only-active.colorama-single
|
|||||||
|
|
||||||
/* Only color active line mode */
|
/* Only color active line mode */
|
||||||
body.colorama-only-active.colorama-single [class*="_lyricsText"]
|
body.colorama-only-active.colorama-single [class*="_lyricsText"]
|
||||||
|
> div
|
||||||
|
> span:not([data-current="true"]),
|
||||||
|
body.colorama-only-active.colorama-gradient
|
||||||
|
[class*="_lyricsText"]
|
||||||
> div
|
> div
|
||||||
> span:not([data-current="true"]) {
|
> span:not([data-current="true"]) {
|
||||||
|
/* Match Radiant inactive styling */
|
||||||
color: rgba(128, 128, 128, 0.4) !important;
|
color: rgba(128, 128, 128, 0.4) !important;
|
||||||
background: none !important;
|
background: none !important;
|
||||||
-webkit-background-clip: initial !important;
|
-webkit-background-clip: initial !important;
|
||||||
@@ -106,6 +215,10 @@ body.colorama-only-active.colorama-single [class*="_lyricsText"]
|
|||||||
|
|
||||||
/* In only-active mode, keep TIDAL defaults even on hover for inactive lines */
|
/* In only-active mode, keep TIDAL defaults even on hover for inactive lines */
|
||||||
body.colorama-only-active.colorama-single [class*="_lyricsText"]
|
body.colorama-only-active.colorama-single [class*="_lyricsText"]
|
||||||
|
> div
|
||||||
|
> span:not([data-current="true"]):hover,
|
||||||
|
body.colorama-only-active.colorama-gradient
|
||||||
|
[class*="_lyricsText"]
|
||||||
> div
|
> div
|
||||||
> span:not([data-current="true"]):hover {
|
> span:not([data-current="true"]):hover {
|
||||||
color: lightgray !important;
|
color: lightgray !important;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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;
|
||||||
@@ -22,32 +21,20 @@ declare global {
|
|||||||
|
|
||||||
export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
|
export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
|
||||||
lyricsGlowEnabled: true,
|
lyricsGlowEnabled: true,
|
||||||
textGlow: 20,
|
trackTitleGlow: false,
|
||||||
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: false,
|
|
||||||
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,
|
||||||
@@ -55,6 +42,16 @@ 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 = () => {
|
||||||
@@ -87,6 +84,9 @@ export const Settings = () => {
|
|||||||
const [spinSpeed, setSpinSpeed] = React.useState(settings.spinSpeed);
|
const [spinSpeed, setSpinSpeed] = React.useState(settings.spinSpeed);
|
||||||
const [settingsAffectNowPlaying, setSettingsAffectNowPlaying] =
|
const [settingsAffectNowPlaying, setSettingsAffectNowPlaying] =
|
||||||
React.useState(settings.settingsAffectNowPlaying);
|
React.useState(settings.settingsAffectNowPlaying);
|
||||||
|
const [trackTitleGlow, setTrackTitleGlow] = React.useState(
|
||||||
|
settings.trackTitleGlow,
|
||||||
|
);
|
||||||
const [backgroundScale, setBackgroundScale] = React.useState(
|
const [backgroundScale, setBackgroundScale] = React.useState(
|
||||||
settings.backgroundScale,
|
settings.backgroundScale,
|
||||||
);
|
);
|
||||||
@@ -96,21 +96,12 @@ 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,
|
||||||
);
|
);
|
||||||
@@ -158,9 +149,6 @@ 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,
|
||||||
);
|
);
|
||||||
@@ -196,7 +184,19 @@ export const Settings = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{lyricsGlowEnabled && (
|
<AnySwitch
|
||||||
|
title="Track Title Glow"
|
||||||
|
desc="Apply glow to the track title"
|
||||||
|
checked={trackTitleGlow}
|
||||||
|
onChange={(_: unknown, checked: boolean) => {
|
||||||
|
settings.trackTitleGlow = checked;
|
||||||
|
setTrackTitleGlow(checked);
|
||||||
|
if (window.updateRadiantLyricsStyles) {
|
||||||
|
window.updateRadiantLyricsStyles();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{(lyricsGlowEnabled || trackTitleGlow) && (
|
||||||
<LunaNumberSetting
|
<LunaNumberSetting
|
||||||
title="Text Glow"
|
title="Text Glow"
|
||||||
desc="Adjust the glow size of lyrics (0-100, default: 20)"
|
desc="Adjust the glow size of lyrics (0-100, default: 20)"
|
||||||
@@ -344,21 +344,9 @@ export const Settings = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<AnySwitch
|
|
||||||
title="Integrated Seek Bar"
|
|
||||||
desc="Move the seekbar to the top border of the player bar"
|
|
||||||
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="Floating rounded player bar with backdrop blur"
|
||||||
checked={floatingPlayerBar}
|
checked={floatingPlayerBar}
|
||||||
onChange={(_: unknown, checked: boolean) => {
|
onChange={(_: unknown, checked: boolean) => {
|
||||||
settings.floatingPlayerBar = checked;
|
settings.floatingPlayerBar = checked;
|
||||||
@@ -398,31 +386,6 @@ 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: 15)"
|
|
||||||
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);
|
||||||
@@ -590,109 +553,25 @@ export const Settings = () => {
|
|||||||
transition: "all 0.2s ease",
|
transition: "all 0.2s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
|
||||||
marginBottom: "12px",
|
|
||||||
color: "#fff",
|
|
||||||
fontWeight: "bold",
|
|
||||||
fontSize: "14px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Choose Tint Color
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Enable/Disable tint toggle */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginBottom: "14px",
|
|
||||||
padding: "8px 10px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
background: "rgba(255,255,255,0.06)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
color: "rgba(255,255,255,0.8)",
|
marginBottom: "12px",
|
||||||
fontSize: "12px",
|
color: "#fff",
|
||||||
fontWeight: 600,
|
fontWeight: "bold",
|
||||||
|
fontSize: "14px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Enable Player Bar Tint
|
Choose Tint Color
|
||||||
</span>
|
</div>
|
||||||
<label
|
|
||||||
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
display: "grid",
|
||||||
display: "inline-block",
|
gridTemplateColumns: "repeat(7, 1fr)",
|
||||||
width: "36px",
|
gap: "8px",
|
||||||
height: "20px",
|
marginBottom: "16px",
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={playerBarTintEnabled}
|
|
||||||
onChange={(e) => {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
settings.playerBarTintEnabled = checked;
|
|
||||||
setPlayerBarTintEnabled(checked);
|
|
||||||
window.updateRadiantLyricsPlayerBarTint?.();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
opacity: 0,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
position: "absolute",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
cursor: "pointer",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: playerBarTintEnabled
|
|
||||||
? "rgba(255,255,255,0.8)"
|
|
||||||
: "rgba(255,255,255,0.15)",
|
|
||||||
transition: "0.25s",
|
|
||||||
borderRadius: "20px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
content: '""',
|
|
||||||
height: "16px",
|
|
||||||
width: "16px",
|
|
||||||
left: playerBarTintEnabled ? "18px" : "2px",
|
|
||||||
bottom: "2px",
|
|
||||||
backgroundColor: playerBarTintEnabled
|
|
||||||
? "rgb(20,20,20)"
|
|
||||||
: "rgba(255,255,255,0.5)",
|
|
||||||
transition: "0.25s",
|
|
||||||
borderRadius: "50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(7, 1fr)",
|
|
||||||
gap: "8px",
|
|
||||||
marginBottom: "16px",
|
|
||||||
opacity: playerBarTintEnabled ? 1 : 0.3,
|
|
||||||
pointerEvents: playerBarTintEnabled ? "auto" : "none",
|
|
||||||
filter: playerBarTintEnabled ? "none" : "grayscale(1)",
|
|
||||||
transition: "all 0.25s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{allTintColors.map((color, index) => {
|
{allTintColors.map((color, index) => {
|
||||||
const isCustomColor = tintCustomColors.includes(color);
|
const isCustomColor = tintCustomColors.includes(color);
|
||||||
const isHovered = tintHoveredColorIndex === index;
|
const isHovered = tintHoveredColorIndex === index;
|
||||||
@@ -763,24 +642,16 @@ export const Settings = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div style={{ marginBottom: "12px" }}>
|
||||||
style={{
|
<div
|
||||||
marginBottom: "12px",
|
style={{
|
||||||
opacity: playerBarTintEnabled ? 1 : 0.3,
|
color: "rgba(255,255,255,0.7)",
|
||||||
pointerEvents: playerBarTintEnabled ? "auto" : "none",
|
fontSize: "12px",
|
||||||
filter: playerBarTintEnabled ? "none" : "grayscale(1)",
|
marginBottom: "6px",
|
||||||
transition: "all 0.25s ease",
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Add Custom Color
|
||||||
<div
|
</div>
|
||||||
style={{
|
|
||||||
color: "rgba(255,255,255,0.7)",
|
|
||||||
fontSize: "12px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Custom Color
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
import { Tracer } from "@luna/core";
|
|
||||||
|
|
||||||
import { settings } from "./Settings";
|
|
||||||
|
|
||||||
const { trace } = Tracer("[Radiant Lyrics]");
|
|
||||||
|
|
||||||
const sylTrace = (...args: unknown[]) => {
|
|
||||||
if (settings.syllableLogging) trace.log(...args);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RL_PLATFORM = "Radiant Lyrics";
|
|
||||||
|
|
||||||
const RL_ACCESS_TOKEN_ID = "58hy4s86";
|
|
||||||
const RL_ACCESS_TOKEN = "xjehy2lfg5h5mjwotoxrcqugam";
|
|
||||||
// Yup that's right, plaintext token in a public Repo!!
|
|
||||||
// The API does not return sensitive data & won't be plain text like this in the future <3
|
|
||||||
|
|
||||||
let cachedPublicIP: string | undefined;
|
|
||||||
|
|
||||||
export async function ip(): Promise<string | undefined> {
|
|
||||||
if (cachedPublicIP) return cachedPublicIP;
|
|
||||||
try {
|
|
||||||
const res = await fetch("https://api.ipify.org?format=text");
|
|
||||||
if (res.ok) cachedPublicIP = (await res.text()).trim();
|
|
||||||
} catch {}
|
|
||||||
return cachedPublicIP;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function auth(): Promise<Record<string, string>> {
|
|
||||||
const clientIP = await ip();
|
|
||||||
return {
|
|
||||||
"P-Access-Token-Id": RL_ACCESS_TOKEN_ID,
|
|
||||||
"P-Access-Token": RL_ACCESS_TOKEN,
|
|
||||||
"x-client-ip": clientIP ?? "null",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platform param (just for DX logging)
|
|
||||||
const platformQs = `platform=${encodeURIComponent(RL_PLATFORM)}`;
|
|
||||||
|
|
||||||
// Query string & params
|
|
||||||
function query(
|
|
||||||
title: string,
|
|
||||||
artist: string,
|
|
||||||
isrc: string | undefined,
|
|
||||||
options?: { romanize?: boolean; flush?: boolean },
|
|
||||||
): string {
|
|
||||||
let q = `?title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`;
|
|
||||||
if (isrc) q += `&isrc=${encodeURIComponent(isrc)}`;
|
|
||||||
if (options?.romanize) q += "&romanize=true";
|
|
||||||
if (options?.flush) q += "&flush=true";
|
|
||||||
q += `&${platformQs}`;
|
|
||||||
return q;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response types
|
|
||||||
|
|
||||||
export interface WordTiming {
|
|
||||||
text: string;
|
|
||||||
time: number;
|
|
||||||
duration: number;
|
|
||||||
isBackground: boolean;
|
|
||||||
romanized?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WordLine {
|
|
||||||
text: string;
|
|
||||||
startTime: number;
|
|
||||||
duration: number;
|
|
||||||
endTime: number;
|
|
||||||
syllabus: WordTiming[];
|
|
||||||
element: {
|
|
||||||
key: string;
|
|
||||||
songPart?: string;
|
|
||||||
songPartIndex?: number;
|
|
||||||
singer: string;
|
|
||||||
};
|
|
||||||
translation: string | null;
|
|
||||||
romanized?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiLine {
|
|
||||||
text: string;
|
|
||||||
startTime: number;
|
|
||||||
duration: number;
|
|
||||||
endTime: number;
|
|
||||||
syllabus?: WordTiming[];
|
|
||||||
element?: {
|
|
||||||
key: string;
|
|
||||||
songPart?: string;
|
|
||||||
songPartIndex?: number;
|
|
||||||
singer?: string;
|
|
||||||
};
|
|
||||||
translation?: string | null;
|
|
||||||
romanized?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WordLyricsResponse {
|
|
||||||
type: "Word";
|
|
||||||
data: WordLine[];
|
|
||||||
metadata: {
|
|
||||||
source: string;
|
|
||||||
title: string;
|
|
||||||
language: string;
|
|
||||||
totalDuration: string;
|
|
||||||
agents?: Record<string, { type: string; name: string; alias: string }>;
|
|
||||||
songParts?: Array<{ name: string; time: number; duration: number }>;
|
|
||||||
};
|
|
||||||
_cached?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LineLyricsResponse {
|
|
||||||
type: "Line";
|
|
||||||
data: ApiLine[];
|
|
||||||
metadata: {
|
|
||||||
source: string;
|
|
||||||
title: string;
|
|
||||||
language: string;
|
|
||||||
totalDuration: string;
|
|
||||||
agents?: Record<string, { type: string; name: string; alias: string }>;
|
|
||||||
songParts?: Array<{ name: string; time: number; duration: number }>;
|
|
||||||
};
|
|
||||||
_cached?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LyricsApiResponse = WordLyricsResponse | LineLyricsResponse;
|
|
||||||
|
|
||||||
type FetchOutcome =
|
|
||||||
| { status: "ok"; data: LyricsApiResponse | null }
|
|
||||||
| { status: "404" }
|
|
||||||
| { status: "500" }
|
|
||||||
| { status: "err" };
|
|
||||||
|
|
||||||
// Lyrics lookup (network)
|
|
||||||
export async function fetchLyrics(
|
|
||||||
title: string,
|
|
||||||
artist: string,
|
|
||||||
isrc: string | undefined,
|
|
||||||
romanize: boolean,
|
|
||||||
): Promise<LyricsApiResponse | null> {
|
|
||||||
const params = query(title, artist, isrc, { romanize });
|
|
||||||
const atomixUrl = `https://api.atomix.one/rl-api${params}`;
|
|
||||||
const fallbackUrl = `https://rl-api.kineticsand.net/lyrics${params}`;
|
|
||||||
|
|
||||||
const rlApiHeaders = await auth();
|
|
||||||
|
|
||||||
const tryFetch = async (url: string, useAtomixAuth: boolean): Promise<FetchOutcome> => {
|
|
||||||
try {
|
|
||||||
sylTrace(`RL API: Fetching lyrics: ${url}`);
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: useAtomixAuth ? rlApiHeaders : undefined,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
trace.log(`RL API: fetch failed: ${res.status} from ${url}`);
|
|
||||||
if (res.status === 404) return { status: "404" };
|
|
||||||
return res.status === 500 ? { status: "500" } : { status: "err" };
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as LyricsApiResponse;
|
|
||||||
if (!data?.data || !Array.isArray(data.data)) {
|
|
||||||
trace.log("Lyrics API returned invalid payload");
|
|
||||||
return { status: "ok", data: null };
|
|
||||||
}
|
|
||||||
if (data.type !== "Word" && data.type !== "Line") {
|
|
||||||
trace.log("Lyrics not available in supported format");
|
|
||||||
return { status: "ok", data: null };
|
|
||||||
}
|
|
||||||
return { status: "ok", data };
|
|
||||||
} catch (err) {
|
|
||||||
trace.log(`RL API: fetch error from ${url}: ${err}`);
|
|
||||||
return { status: "err" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const primary = await tryFetch(atomixUrl, true);
|
|
||||||
if (primary.status === "ok") return primary.data;
|
|
||||||
if (primary.status === "404") {
|
|
||||||
trace.log("RL API: 404 — no API lyrics exist for this track");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (primary.status === "500") {
|
|
||||||
trace.log("RL API: 500 (Execution Timeout) — fallback");
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallback = await tryFetch(fallbackUrl, false);
|
|
||||||
if (fallback.status === "ok") return fallback.data;
|
|
||||||
if (fallback.status === "404") {
|
|
||||||
trace.log("RL API: 404 from fallback — no API lyrics exist for this track");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (fallback.status === "500") {
|
|
||||||
trace.log("RL API: 500 from fallback — API IS ACTUALLY BORKED!");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
trace.log("RL API: All Endpoints Failed");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function flushLyrics(track: {
|
|
||||||
title: string;
|
|
||||||
artist: string;
|
|
||||||
isrc?: string;
|
|
||||||
}): Promise<
|
|
||||||
| { ok: true; data: LyricsApiResponse & { _flush?: string } }
|
|
||||||
| { ok: false; status: number; notFound: boolean }
|
|
||||||
> {
|
|
||||||
const q = query(track.title, track.artist, track.isrc, {
|
|
||||||
flush: true,
|
|
||||||
});
|
|
||||||
const url = `https://api.atomix.one/rl-api${q}`;
|
|
||||||
const headers = await auth();
|
|
||||||
const res = await fetch(url, { headers });
|
|
||||||
if (res.status === 404) {
|
|
||||||
return { ok: false, status: 404, notFound: true };
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
return { ok: false, status: res.status, notFound: false };
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as LyricsApiResponse & { _flush?: string };
|
|
||||||
return { ok: true, data };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Romanize
|
|
||||||
export async function romanizeLyrics(
|
|
||||||
lineTexts: string[],
|
|
||||||
): Promise<string[] | null> {
|
|
||||||
if (lineTexts.length === 0) return null;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
type: "Line" as const,
|
|
||||||
data: lineTexts.map((text, idx) => ({
|
|
||||||
text,
|
|
||||||
startTime: idx,
|
|
||||||
duration: 0,
|
|
||||||
endTime: idx,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const romanizeQuery = `?${platformQs}`;
|
|
||||||
const urls: { url: string; useAtomixAuth: boolean }[] = [
|
|
||||||
{
|
|
||||||
url: `https://api.atomix.one/rl-api/romanize${romanizeQuery}`,
|
|
||||||
useAtomixAuth: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `https://rl-api.kineticsand.net/romanize${romanizeQuery}`,
|
|
||||||
useAtomixAuth: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { url, useAtomixAuth } of urls) {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
||||||
try {
|
|
||||||
const romanizeHeaders: Record<string, string> = {
|
|
||||||
"content-type": "application/json",
|
|
||||||
};
|
|
||||||
if (useAtomixAuth) {
|
|
||||||
Object.assign(romanizeHeaders, await auth());
|
|
||||||
}
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: romanizeHeaders,
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
clearTimeout(timeout);
|
|
||||||
if (!res.ok) {
|
|
||||||
trace.log(`Romanize: request failed ${res.status} | ${url}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
type?: string;
|
|
||||||
data?: Array<{ text?: string; romanized?: string }>;
|
|
||||||
};
|
|
||||||
if (!Array.isArray(data?.data)) continue;
|
|
||||||
|
|
||||||
return lineTexts.map((original, idx) => {
|
|
||||||
const item = data.data?.[idx];
|
|
||||||
return item?.romanized ?? item?.text ?? original;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
if (err instanceof DOMException && err.name === "AbortError") {
|
|
||||||
trace.log(`Romanize: request timed out | ${url}`);
|
|
||||||
} else {
|
|
||||||
trace.log(`Romanize: request error | ${url} | ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -43,18 +43,6 @@
|
|||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide Tidal's native now-playing background color overlay */
|
|
||||||
[data-test="new-now-playing"] > [class*="_background_"] {
|
|
||||||
/* biome-ignore lint: Must override native album-art-derived background */
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure the now-playing container itself is transparent */
|
|
||||||
[class*="_nowPlayingContainer"] {
|
|
||||||
/* biome-ignore lint: Must override any inline background styles */
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Now Playing Background Container Optimization */
|
/* Now Playing Background Container Optimization */
|
||||||
.now-playing-background-container {
|
.now-playing-background-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -62,7 +50,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 0;
|
z-index: -3;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Hardware acceleration */
|
/* Hardware acceleration */
|
||||||
@@ -70,13 +58,6 @@
|
|||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure now-playing content renders above the dynamic background */
|
|
||||||
[data-test="new-now-playing"] > header,
|
|
||||||
[data-test="new-now-playing"] > [class*="_content_"] {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optimized keyframe animations with GPU acceleration */
|
/* Optimized keyframe animations with GPU acceleration */
|
||||||
@keyframes spinGlobal {
|
@keyframes spinGlobal {
|
||||||
from {
|
from {
|
||||||
@@ -108,27 +89,34 @@
|
|||||||
filter: blur(10px) brightness(0.4) contrast(1.1) !important;
|
filter: blur(10px) brightness(0.4) contrast(1.1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make app chrome transparent for cover-everywhere background */
|
/* Make Notification Feed sidebar transparent */
|
||||||
body,
|
body,
|
||||||
#wimp,
|
#wimp,
|
||||||
main,
|
main,
|
||||||
[class^="_sidebarWrapper"],
|
[class^="_sidebarWrapper"],
|
||||||
[class^="_mainContainer"],
|
[class^="_mainContainer"],
|
||||||
|
[class*="smallHeader"],
|
||||||
[data-test="main-layout-sidebar-wrapper"],
|
[data-test="main-layout-sidebar-wrapper"],
|
||||||
[data-test="main-layout-header"],
|
[data-test="main-layout-header"],
|
||||||
[data-test="feed-sidebar"],
|
[data-test="feed-sidebar"],
|
||||||
|
[data-test="stream-metadata"],
|
||||||
[data-test="footer-player"],
|
[data-test="footer-player"],
|
||||||
[class^="_feedSidebarVStack"],
|
/* Notification Feed sidebar specific container */
|
||||||
|
[class^="_feedSidebarVStack"],
|
||||||
[class^="_feedSidebarSpacer"],
|
[class^="_feedSidebarSpacer"],
|
||||||
[class^="_feedSidebarItem"],
|
[class^="_feedSidebarItem"],
|
||||||
[class^="_feedSidebarItemDiv"],
|
[class^="_feedSidebarItemDiv"],
|
||||||
[class^="_cellContainer"] {
|
[class^="_cellContainer"],
|
||||||
|
[class^="_cellTextContainer"] {
|
||||||
/* biome-ignore lint: Ensure background is fully cleared under theme CSS */
|
/* biome-ignore lint: Ensure background is fully cleared under theme CSS */
|
||||||
background: unset !important;
|
background: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make sidebar semi-transparent with optimized backdrop-filter */
|
/* Make sidebar and player bar semi-transparent with optimized backdrop-filter */
|
||||||
[data-test="main-layout-sidebar-wrapper"] {
|
[data-test="footer-player"],
|
||||||
|
[data-test="main-layout-sidebar-wrapper"],
|
||||||
|
[class^="_bar"],
|
||||||
|
[class^="_sidebarItem"]:hover {
|
||||||
/* biome-ignore lint: Must beat app inline styles for translucency */
|
/* biome-ignore lint: Must beat app inline styles for translucency */
|
||||||
background-color: rgba(0, 0, 0, 0.3) !important;
|
background-color: rgba(0, 0, 0, 0.3) !important;
|
||||||
/* biome-ignore lint: Must beat app inline styles for translucency */
|
/* biome-ignore lint: Must beat app inline styles for translucency */
|
||||||
@@ -138,7 +126,10 @@ main,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Performance mode: reduce backdrop blur */
|
/* Performance mode: reduce backdrop blur */
|
||||||
.performance-mode [data-test="main-layout-sidebar-wrapper"] {
|
.performance-mode [data-test="footer-player"],
|
||||||
|
.performance-mode [data-test="main-layout-sidebar-wrapper"],
|
||||||
|
.performance-mode [class^="_bar"],
|
||||||
|
.performance-mode [class^="_sidebarItem"]:hover {
|
||||||
/* biome-ignore lint: Performance mode style requires priority */
|
/* biome-ignore lint: Performance mode style requires priority */
|
||||||
backdrop-filter: blur(5px) !important;
|
backdrop-filter: blur(5px) !important;
|
||||||
/* biome-ignore lint: Performance mode style requires priority */
|
/* biome-ignore lint: Performance mode style requires priority */
|
||||||
@@ -172,3 +163,9 @@ main,
|
|||||||
/* biome-ignore lint: Match theme transparency */
|
/* biome-ignore lint: Match theme transparency */
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Remove bottom gradient */
|
||||||
|
[class^="_bottomGradient"] {
|
||||||
|
/* biome-ignore lint: Explicitly remove conflicting gradient */
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,9 @@
|
|||||||
/* Square Player Bar override — injected when floating is disabled */
|
/* Floating Rounded Player Bar from Obsidian <3 */
|
||||||
|
|
||||||
/* MARKER: Floating Player Bar CSS */
|
/* MARKER: Floating Player Bar CSS */
|
||||||
|
|
||||||
[data-test="footer-player"] {
|
[data-test="footer-player"] {
|
||||||
/* biome-ignore lint: Override native floating position */
|
position: absolute !important;
|
||||||
bottom: 0 !important;
|
backdrop-filter: blur(10px);
|
||||||
/* biome-ignore lint: Override native floating position */
|
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin, rgba(255, 255, 255, 0.1)) !important;
|
||||||
left: 0 !important;
|
|
||||||
/* biome-ignore lint: Override native floating position */
|
|
||||||
right: 0 !important;
|
|
||||||
/* biome-ignore lint: Override native floating position */
|
|
||||||
width: 100% !important;
|
|
||||||
/* biome-ignore lint: Override native floating position */
|
|
||||||
margin: 0 !important;
|
|
||||||
/* biome-ignore lint: Force square corners */
|
|
||||||
border-radius: 0 !important;
|
|
||||||
/* biome-ignore lint: Remove floating border */
|
|
||||||
border: none !important;
|
|
||||||
/* biome-ignore lint: Remove floating shadow */
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1218
-1631
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,237 @@
|
|||||||
/* Radiant Lyrics — text glow only (injected when Lyrics Glow is enabled) */
|
/* Font imports for lyrics */
|
||||||
|
@font-face {
|
||||||
|
font-family: "AbyssFont";
|
||||||
|
font-weight: 400;
|
||||||
|
src: url("https://excel.lexploits.top/extra/tidal/LyricsRegular.woff2")
|
||||||
|
format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
/* MARKER: Lyrics glow CSS */
|
@font-face {
|
||||||
|
font-family: "AbyssFont";
|
||||||
|
font-weight: 500;
|
||||||
|
src: url("https://excel.lexploits.top/extra/tidal/LyricsMedium.woff2")
|
||||||
|
format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"][class*="_current_"] {
|
@font-face {
|
||||||
|
font-family: "AbyssFont";
|
||||||
|
font-weight: 600;
|
||||||
|
src: url("https://excel.lexploits.top/extra/tidal/LyricsSemibold.woff2")
|
||||||
|
format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "AbyssFont";
|
||||||
|
font-weight: 700;
|
||||||
|
src: url("https://excel.lexploits.top/extra/tidal/LyricsBold.woff2")
|
||||||
|
format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced lyrics styling with glow effects */
|
||||||
|
[class*="_lyricsText"] > div > span[data-current="true"] {
|
||||||
text-shadow:
|
text-shadow:
|
||||||
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #fff),
|
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #fff),
|
||||||
/* biome-ignore lint: Required to override app glow strength */
|
/* biome-ignore lint: Required to override app glow strength */
|
||||||
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #fff) !important;
|
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #fff) !important;
|
||||||
|
padding-left: 20px;
|
||||||
|
transition-duration: 0.7s;
|
||||||
|
font-size: calc(55px * var(--rl-font-scale, 1));
|
||||||
|
/* biome-ignore lint: Active lyric uses Colorama color */
|
||||||
|
color: var(--cl-glow1, #fff) !important;
|
||||||
|
font-family:
|
||||||
|
"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"]:hover {
|
[class*="_lyricsText"] > div > span {
|
||||||
|
text-shadow:
|
||||||
|
0 0 0px transparent,
|
||||||
|
0 0 0px transparent;
|
||||||
|
transition-duration: 0.25s;
|
||||||
|
color: rgba(128, 128, 128, 0.4);
|
||||||
|
font-size: calc(40px * var(--rl-font-scale, 1));
|
||||||
|
font-family:
|
||||||
|
"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*="_lyricsText"] > div > span:hover {
|
||||||
text-shadow:
|
text-shadow:
|
||||||
0 0 var(--rl-glow-inner, 2px) lightgray,
|
0 0 var(--rl-glow-inner, 2px) lightgray,
|
||||||
/* biome-ignore lint: Hover glow should override defaults */
|
/* biome-ignore lint: Hover glow should override defaults */
|
||||||
0 0 var(--rl-glow-outer, 20px) lightgray !important;
|
0 0 var(--rl-glow-outer, 20px) lightgray !important;
|
||||||
|
/* biome-ignore lint: Hover color override */
|
||||||
|
color: lightgray !important;
|
||||||
|
padding-left: 20px;
|
||||||
|
transition-duration: 0.7s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Track title glow */
|
||||||
|
[data-test="now-playing-track-title"] {
|
||||||
|
/* Title text color/gradient is left to default app styling; only glow is customized. */
|
||||||
|
text-shadow:
|
||||||
|
0 0 var(--rl-glow-inner, 1px) var(--cl-glow1, #fff),
|
||||||
|
/* biome-ignore lint: Title glow needs priority */
|
||||||
|
0 0 var(--rl-glow-outer, 30px) #fff !important;
|
||||||
|
/* biome-ignore lint: Reset vendor background clip */
|
||||||
|
-webkit-background-clip: initial !important;
|
||||||
|
/* biome-ignore lint: Reset background clip */
|
||||||
|
background-clip: initial !important;
|
||||||
|
/* biome-ignore lint: Reset vendor text fill */
|
||||||
|
-webkit-text-fill-color: initial !important;
|
||||||
|
/* biome-ignore lint: Ensure inherited color takes precedence */
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When track title glow setting is disabled, remove glow regardless of Colorama */
|
||||||
|
.rl-title-glow-disabled[data-test="now-playing-track-title"] {
|
||||||
|
/* biome-ignore lint: Full reset required */
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current line transitions */
|
||||||
|
[class*="_lyricsText"] > div > span {
|
||||||
|
transition:
|
||||||
|
text-shadow 0.7s ease-in-out,
|
||||||
|
color 0.7s ease-in-out,
|
||||||
|
/* biome-ignore lint: Transition priority needed */
|
||||||
|
padding 0.7s ease-in-out !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow-aware left padding so the glow doesn't clip | Thanks Aya <3*/
|
||||||
|
.rl-wbw-active {
|
||||||
|
padding-left: var(--rl-glow-outer) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-rl-injected][role="tabpanel"] {
|
||||||
|
transform: translateX(calc(var(--rl-glow-outer) * -1)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lyrics container styling */
|
||||||
|
[class^="_lyricsContainer"] > div > div > span {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
opacity: 1;
|
||||||
|
font-family:
|
||||||
|
"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
/* biome-ignore lint: Typography override for readability */
|
||||||
|
font-size: calc(38px * var(--rl-font-scale, 1)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MARKER: WBW lyrics CSS */
|
||||||
|
|
||||||
|
/* hide tidal spans for wbw */
|
||||||
|
.rl-wbw-active span[data-test="lyrics-line"] {
|
||||||
|
/* biome-ignore lint: Must hide original lines when word-by-word is on */
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active line slide */
|
||||||
|
.rl-wbw-line {
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
filter: none;
|
||||||
|
transform: translateZ(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition:
|
||||||
|
filter 0.4s ease,
|
||||||
|
padding-left 0.7s ease-in-out,
|
||||||
|
padding-right 0.7s ease-in-out;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-wbw-line.rl-wbw-spacer {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blur Inactive (opt-in via .rl-blur-active on container) */
|
||||||
|
.rl-blur-active .rl-wbw-line {
|
||||||
|
filter: blur(0.07em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-blur-active .rl-wbw-line.rl-pos-1 {
|
||||||
|
filter: blur(0.035em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-blur-active .rl-wbw-line.rl-pos-2 {
|
||||||
|
filter: blur(0.05em);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-blur-active .rl-wbw-line.rl-pos-3 {
|
||||||
|
filter: blur(0.06em);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active line overrides (MUST come after blur rules to win on equal specificity) */
|
||||||
|
.rl-wbw-line.rl-wbw-line-active,
|
||||||
|
.rl-blur-active .rl-wbw-line.rl-wbw-line-active {
|
||||||
|
padding-left: 20px;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep last-active line unblurred during instrumental gaps */
|
||||||
|
.rl-blur-active .rl-wbw-line.rl-gap-hold {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bubbled Lyrics scale (opt-in via .rl-bubbled on container) */
|
||||||
|
.rl-bubbled .rl-wbw-line {
|
||||||
|
scale: 0.93 0.93 0.95;
|
||||||
|
transition:
|
||||||
|
scale 0.7s ease,
|
||||||
|
filter 0.4s ease,
|
||||||
|
padding-left 0.7s ease-in-out,
|
||||||
|
padding-right 0.7s ease-in-out;
|
||||||
|
will-change: scale, translate, filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-bubbled .rl-wbw-line.rl-wbw-spacer {
|
||||||
|
scale: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-bubbled .rl-wbw-line.rl-wbw-line-active {
|
||||||
|
scale: 1;
|
||||||
|
transition:
|
||||||
|
scale 0.5s ease,
|
||||||
|
filter 0.4s ease,
|
||||||
|
padding-left 0.7s ease-in-out,
|
||||||
|
padding-right 0.7s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Staggered scroll bounce animation (part of Bubbled Lyrics) */
|
||||||
|
@keyframes rl-scroll-bounce {
|
||||||
|
from {
|
||||||
|
translate: 0 var(--rl-scroll-delta);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
translate: 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-wbw-line:not(.rl-scroll-animate) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-scroll-animate {
|
||||||
|
animation: rl-scroll-bounce 400ms cubic-bezier(0.41, 0, 0.12, 0.99) both;
|
||||||
|
animation-delay: var(--rl-line-delay, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Word span */
|
||||||
|
.rl-wbw-word {
|
||||||
|
text-shadow:
|
||||||
|
0 0 0px transparent,
|
||||||
|
0 0 0px transparent;
|
||||||
|
color: rgba(128, 128, 128, 0.4);
|
||||||
|
transition:
|
||||||
|
text-shadow 0.15s ease-out,
|
||||||
|
color 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover word (Grouped Syllables) */
|
||||||
.rl-wbw-line:not(.rl-wbw-line-active) > .rl-wbw-word:hover,
|
.rl-wbw-line:not(.rl-wbw-line-active) > .rl-wbw-word:hover,
|
||||||
.rl-wbw-line:not(.rl-wbw-line-active) > .rl-wbw-word.rl-wbw-word-hover,
|
.rl-wbw-line:not(.rl-wbw-line-active) > .rl-wbw-word.rl-wbw-word-hover,
|
||||||
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-main .rl-wbw-word:hover,
|
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-main .rl-wbw-word:hover,
|
||||||
@@ -26,11 +242,224 @@
|
|||||||
0 0 var(--rl-glow-inner, 2px) lightgray,
|
0 0 var(--rl-glow-inner, 2px) lightgray,
|
||||||
/* biome-ignore lint: Hover glow should override defaults */
|
/* biome-ignore lint: Hover glow should override defaults */
|
||||||
0 0 var(--rl-glow-outer, 20px) lightgray !important;
|
0 0 var(--rl-glow-outer, 20px) lightgray !important;
|
||||||
|
/* biome-ignore lint: Hover color override */
|
||||||
|
color: lightgray !important;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Active word */
|
||||||
.rl-wbw-word.rl-wbw-active {
|
.rl-wbw-word.rl-wbw-active {
|
||||||
text-shadow:
|
text-shadow:
|
||||||
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #fff),
|
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #fff),
|
||||||
/* biome-ignore lint: Glow priority for active word */
|
/* biome-ignore lint: Glow priority for active word */
|
||||||
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #fff) !important;
|
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #fff) !important;
|
||||||
|
/* biome-ignore lint: Active word uses Colorama color */
|
||||||
|
color: var(--cl-glow1, #fff) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* MARKER: Syllable sweep animation CSS */
|
||||||
|
|
||||||
|
@keyframes rl-wipe {
|
||||||
|
from {
|
||||||
|
background-size:
|
||||||
|
0.75em 100%,
|
||||||
|
0% 100%,
|
||||||
|
100% 100%;
|
||||||
|
background-position:
|
||||||
|
-0.375em 0%,
|
||||||
|
left,
|
||||||
|
left;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-size:
|
||||||
|
0.75em 100%,
|
||||||
|
100% 100%,
|
||||||
|
100% 100%;
|
||||||
|
background-position:
|
||||||
|
calc(100% + 0.375em) 0%,
|
||||||
|
left,
|
||||||
|
left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syllable active: gradient sweep L-to-R via background-clip */
|
||||||
|
.rl-wbw-word.rl-syl-active {
|
||||||
|
/* biome-ignore lint: Kill base transitions so class swaps are instant */
|
||||||
|
transition: none !important;
|
||||||
|
/* biome-ignore lint: Transparent fill so gradient paints the text */
|
||||||
|
color: transparent !important;
|
||||||
|
/* biome-ignore lint: Clip gradient to text glyphs */
|
||||||
|
-webkit-background-clip: text !important;
|
||||||
|
/* biome-ignore lint: Clip gradient to text glyphs */
|
||||||
|
background-clip: text !important;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--cl-glow1, #fff) 50%,
|
||||||
|
transparent 100%
|
||||||
|
),
|
||||||
|
linear-gradient(90deg, var(--cl-glow1, #fff) 100%, transparent 100%),
|
||||||
|
linear-gradient(90deg, rgba(128, 128, 128, 0.4), rgba(128, 128, 128, 0.4));
|
||||||
|
background-size:
|
||||||
|
0.75em 100%,
|
||||||
|
0% 100%,
|
||||||
|
100% 100%;
|
||||||
|
background-position:
|
||||||
|
-0.375em 0%,
|
||||||
|
left,
|
||||||
|
left;
|
||||||
|
/* biome-ignore lint: No glow for syllable mode */
|
||||||
|
text-shadow: none !important;
|
||||||
|
/* biome-ignore lint: No glow for syllable mode */
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syllable finished: word stays Colorama color, no glow */
|
||||||
|
.rl-wbw-word.rl-syl-finished {
|
||||||
|
/* biome-ignore lint: Kill base transitions so class swaps are instant */
|
||||||
|
transition: none !important;
|
||||||
|
/* biome-ignore lint: Finished syllable uses Colorama color */
|
||||||
|
color: var(--cl-glow1, #fff) !important;
|
||||||
|
/* biome-ignore lint: No glow for syllable mode */
|
||||||
|
text-shadow: none !important;
|
||||||
|
/* biome-ignore lint: No glow for syllable mode */
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MARKER: Syllable animations CSS (WIP coming soon) */
|
||||||
|
/* syllableStyle: 0 = none, 1 = Pop!, 2 = Jump */
|
||||||
|
|
||||||
|
@keyframes rl-pop {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
25%,
|
||||||
|
35% {
|
||||||
|
transform: scale(1.03) translateY(-0.5%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rl-jump {
|
||||||
|
0% {
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pop! for word mode */
|
||||||
|
.rl-syl-pop .rl-wbw-word.rl-wbw-active {
|
||||||
|
transform-origin: center bottom;
|
||||||
|
animation: rl-pop 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pop! for syllable mode */
|
||||||
|
.rl-syl-pop .rl-wbw-word.rl-syl-active {
|
||||||
|
transform-origin: center bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Jump for word mode */
|
||||||
|
.rl-syl-jump .rl-wbw-word.rl-wbw-active {
|
||||||
|
animation: rl-jump 0.35s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tidals "..." at the top of the container */
|
||||||
|
.rl-wbw-active > span:not([data-test="lyrics-line"]) {
|
||||||
|
display: block;
|
||||||
|
font-size: calc(40px * var(--rl-font-scale, 1));
|
||||||
|
font-family:
|
||||||
|
"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(128, 128, 128, 0.4);
|
||||||
|
text-shadow: 0 0 0px transparent;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MARKER: Context Aware Lyrics CSS */
|
||||||
|
|
||||||
|
/* Background vocal sub-container */
|
||||||
|
.rl-wbw-bg-container {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: visible;
|
||||||
|
opacity: 0;
|
||||||
|
font-size: 0.55em;
|
||||||
|
padding-top: 0.15em;
|
||||||
|
transition:
|
||||||
|
max-height 0.3s ease,
|
||||||
|
opacity 0.5s ease;
|
||||||
|
color: rgba(128, 128, 128, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-wbw-line.rl-wbw-line-active .rl-wbw-bg-container {
|
||||||
|
max-height: 3em;
|
||||||
|
opacity: 1;
|
||||||
|
transition:
|
||||||
|
max-height 0.5s ease,
|
||||||
|
opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Singer duet positioning */
|
||||||
|
.rl-wbw-line.rl-singer-right {
|
||||||
|
text-align: end;
|
||||||
|
transform-origin: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-dual-side .rl-wbw-line.rl-singer-left {
|
||||||
|
padding-right: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-dual-side .rl-wbw-line.rl-singer-right {
|
||||||
|
padding-left: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-dual-side .rl-wbw-line.rl-singer-right .rl-wbw-bg-container {
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-dual-side .rl-wbw-line.rl-singer-right.rl-wbw-line-active {
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rl-dual-side .rl-wbw-line.rl-singer-left.rl-wbw-line-active {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset glow when disabled */
|
||||||
|
.lyrics-glow-disabled [class*="_lyricsText"] > div > span[data-current="true"],
|
||||||
|
.lyrics-glow-disabled [class*="_lyricsText"] > div > span:hover {
|
||||||
|
/* biome-ignore lint: Kill glow on active/hover lines */
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* kill glow on active word */
|
||||||
|
.lyrics-glow-disabled .rl-wbw-word.rl-wbw-active {
|
||||||
|
/* biome-ignore lint: Kill glow on active word */
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* kill glow on hovered word */
|
||||||
|
.lyrics-glow-disabled .rl-wbw-line:not(.rl-wbw-line-active)
|
||||||
|
> .rl-wbw-word:hover,
|
||||||
|
.lyrics-glow-disabled
|
||||||
|
.rl-wbw-line:not(.rl-wbw-line-active)
|
||||||
|
> .rl-wbw-word.rl-wbw-word-hover,
|
||||||
|
.lyrics-glow-disabled
|
||||||
|
.rl-wbw-line:not(.rl-wbw-line-active)
|
||||||
|
.rl-wbw-main
|
||||||
|
.rl-wbw-word:hover,
|
||||||
|
.lyrics-glow-disabled
|
||||||
|
.rl-wbw-line:not(.rl-wbw-line-active)
|
||||||
|
.rl-wbw-main
|
||||||
|
.rl-wbw-word.rl-wbw-word-hover {
|
||||||
|
/* biome-ignore lint: Kill glow on hovered word */
|
||||||
|
text-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
/* Rounded corners */
|
/* Rounded corners */
|
||||||
[class*="_thumbnail_"],
|
[class*="_thumbnail_"],
|
||||||
[class*="_imageWrapper_"],
|
[class*="_imageWrapper_"],
|
||||||
|
[class*="_coverImage_"],
|
||||||
|
[class*="_overlayIconWrapperAlbum_"],
|
||||||
[class*="_playButton_"] {
|
[class*="_playButton_"] {
|
||||||
border-radius: 5px !important;
|
border-radius: 5px !important;
|
||||||
}
|
}
|
||||||
@@ -18,38 +20,67 @@
|
|||||||
|
|
||||||
/* MARKER: HideUI CSS*/
|
/* MARKER: HideUI CSS*/
|
||||||
|
|
||||||
/* Only apply styles when UI is hidden — hide toggle buttons */
|
/* Only apply styles when UI is hidden */
|
||||||
.radiant-lyrics-ui-hidden [data-test="toggle-lyrics"],
|
.radiant-lyrics-ui-hidden [class*="tabItems"] {
|
||||||
.radiant-lyrics-ui-hidden [data-test="toggle-credits"],
|
|
||||||
.radiant-lyrics-ui-hidden [data-test="toggle-similar-tracks"] {
|
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
transition: opacity 0.4s ease-in-out !important;
|
transition: opacity 0.4s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radiant-lyrics-ui-hidden [data-test="toggle-lyrics"]:hover,
|
.radiant-lyrics-ui-hidden [class*="tabItems"]:hover {
|
||||||
.radiant-lyrics-ui-hidden [data-test="toggle-credits"]:hover,
|
|
||||||
.radiant-lyrics-ui-hidden [data-test="toggle-similar-tracks"]:hover,
|
|
||||||
.radiant-lyrics-ui-hidden.rl-dropdown-open [data-test="toggle-lyrics"] {
|
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
transition: opacity 0.4s ease-in-out !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide header, artist info, and visualizer when UI is hidden */
|
/* Hide header container (search, minimize, fullscreen) when UI is hidden */
|
||||||
.radiant-lyrics-ui-hidden [data-test="header"],
|
.radiant-lyrics-ui-hidden [data-test="header-container"] {
|
||||||
.radiant-lyrics-ui-hidden [data-test="artist-info"],
|
|
||||||
.radiant-lyrics-ui-hidden .audio-visualizer-container {
|
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
visibility: hidden !important;
|
visibility: hidden !important;
|
||||||
transition: opacity 0.4s ease-in-out !important, visibility 0s linear 0.4s;
|
transition: opacity 0.4s ease-in-out, visibility 0s linear 0.4s;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Immediate hide class for unhide button */
|
||||||
|
.hide-immediately {
|
||||||
|
opacity: 0 !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto-fade styling for unhide button */
|
||||||
|
.unhide-ui-button.auto-faded {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
color: rgba(255, 255, 255, 0.4) !important;
|
||||||
|
transition:
|
||||||
|
background-color 0.8s ease-in-out,
|
||||||
|
border-color 0.8s ease-in-out,
|
||||||
|
box-shadow 0.8s ease-in-out,
|
||||||
|
backdrop-filter 0.8s ease-in-out,
|
||||||
|
color 0.8s ease-in-out !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unhide-ui-button.auto-faded:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.3) !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(10px) !important;
|
||||||
|
color: white !important;
|
||||||
|
transition:
|
||||||
|
background-color 0.3s ease-in-out,
|
||||||
|
border-color 0.3s ease-in-out,
|
||||||
|
box-shadow 0.3s ease-in-out,
|
||||||
|
backdrop-filter 0.3s ease-in-out,
|
||||||
|
color 0.3s ease-in-out !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* MARKER: Sticky Lyrics CSS */
|
/* MARKER: Sticky Lyrics CSS */
|
||||||
|
|
||||||
/* Lyrics toggle button */
|
/* Lyrics tab */
|
||||||
[data-test="toggle-lyrics"]:has(.sticky-lyrics-trigger) {
|
[data-test="tabs-lyrics"]:has(.sticky-lyrics-trigger) {
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
padding-right: 38px !important;
|
padding-right: 38px !important;
|
||||||
}
|
}
|
||||||
@@ -84,41 +115,35 @@
|
|||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When Lyrics toggle is pressed — show divider & adjust icon */
|
/* When Lyrics tab is active — show divider & make icon black*/
|
||||||
[data-test="toggle-lyrics"][aria-pressed="true"] .sticky-lyrics-trigger {
|
[data-test="tabs-lyrics"][aria-selected="true"] .sticky-lyrics-trigger {
|
||||||
color: rgb(30, 30, 30);
|
color: black;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-test="toggle-lyrics"][aria-pressed="true"] .sticky-lyrics-trigger::before {
|
[data-test="tabs-lyrics"][aria-selected="true"] .sticky-lyrics-trigger::before {
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-test="toggle-lyrics"][aria-pressed="true"] .sticky-lyrics-trigger:hover {
|
[data-test="tabs-lyrics"][aria-selected="true"] .sticky-lyrics-trigger:hover {
|
||||||
color: rgba(0, 0, 0, 0.5);
|
color: rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animate widening when dropdown opens */
|
/* Square the Lyrics button bottom corners when dropdown is open */
|
||||||
[data-test="toggle-lyrics"]:has(.sticky-lyrics-trigger) {
|
[data-test="tabs-lyrics"].sticky-lyrics-open {
|
||||||
transition: min-width 0.12s ease-out;
|
border-bottom-left-radius: 0 !important;
|
||||||
|
border-bottom-right-radius: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduce top rounding, square bottom, and kill hover tint when dropdown is open */
|
/* Dropdown */
|
||||||
body.rl-dropdown-open [data-test="toggle-lyrics"] {
|
|
||||||
border-radius: 12px 12px 0 0 !important;
|
|
||||||
background-color: rgb(255, 255, 255) !important;
|
|
||||||
min-width: 150px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdown — right-aligned under the Lyrics button */
|
|
||||||
.sticky-lyrics-dropdown {
|
.sticky-lyrics-dropdown {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background: rgb(255, 255, 255);
|
background: white;
|
||||||
border-radius: 0 0 12px 12px;
|
border-radius: 0 0 16px 16px;
|
||||||
padding: 8px 12px 10px;
|
padding: 8px 12px 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
|
||||||
clip-path: inset(0 -20px -20px -20px);
|
clip-path: inset(0 -20px -20px -20px);
|
||||||
animation: stickyLyricsDropdownIn 0.12s ease-out;
|
animation: stickyLyricsDropdownIn 0.12s ease-out;
|
||||||
}
|
}
|
||||||
@@ -126,11 +151,11 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
|
|||||||
@keyframes stickyLyricsDropdownIn {
|
@keyframes stickyLyricsDropdownIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-4px);
|
clip-path: inset(0 0 100% 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
clip-path: inset(0 0 0 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +170,7 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
|
|||||||
.sticky-lyrics-label {
|
.sticky-lyrics-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(0, 0, 0, 0.8);
|
color: rgba(0, 0, 0, 1);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +196,7 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
transition: 0.3s;
|
transition: 0.3s;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
@@ -186,16 +211,15 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
|
|||||||
background-color: white;
|
background-color: white;
|
||||||
transition: 0.3s;
|
transition: 0.3s;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sticky-lyrics-switch input:checked + .sticky-lyrics-slider {
|
.sticky-lyrics-switch input:checked + .sticky-lyrics-slider {
|
||||||
background-color: rgb(30, 30, 30);
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sticky-lyrics-switch input:checked + .sticky-lyrics-slider::before {
|
.sticky-lyrics-switch input:checked + .sticky-lyrics-slider::before {
|
||||||
transform: translateX(16px);
|
transform: translateX(16px);
|
||||||
background-color: rgb(255, 255, 255);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Segmented control (Line | Word | Syllable) */
|
/* Segmented control (Line | Word | Syllable) */
|
||||||
@@ -206,7 +230,7 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
|
|||||||
|
|
||||||
.rl-seg-control {
|
.rl-seg-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: rgba(0, 0, 0, 0.06);
|
background: rgba(0, 0, 0, 0.08);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -217,7 +241,7 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(0, 0, 0, 0.4);
|
color: rgba(0, 0, 0, 0.5);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
@@ -229,661 +253,99 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
|
|||||||
|
|
||||||
.rl-seg-btn:hover {
|
.rl-seg-btn:hover {
|
||||||
color: rgba(0, 0, 0, 0.7);
|
color: rgba(0, 0, 0, 0.7);
|
||||||
background: rgba(0, 0, 0, 0.06);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rl-seg-btn.rl-seg-active {
|
.rl-seg-btn.rl-seg-active {
|
||||||
background: rgb(30, 30, 30);
|
background: white;
|
||||||
color: rgb(255, 255, 255);
|
color: black;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Z-Index Fix for Integrated Seekbar (Volume slider) */
|
|
||||||
body.rl-integrated-seekbar [data-test="footer-player"] [class*="playerContent"] > [class*="utilityContainer"] {
|
|
||||||
position: relative;
|
|
||||||
z-index: 110;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* MARKER: Lyrics core CSS (always loaded) */
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "AbyssFont";
|
|
||||||
font-weight: 400;
|
|
||||||
src: url("https://excel.lexploits.top/extra/tidal/LyricsRegular.woff2")
|
|
||||||
format("woff2");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "AbyssFont";
|
|
||||||
font-weight: 500;
|
|
||||||
src: url("https://excel.lexploits.top/extra/tidal/LyricsMedium.woff2")
|
|
||||||
format("woff2");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "AbyssFont";
|
|
||||||
font-weight: 600;
|
|
||||||
src: url("https://excel.lexploits.top/extra/tidal/LyricsSemibold.woff2")
|
|
||||||
format("woff2");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "AbyssFont";
|
|
||||||
font-weight: 700;
|
|
||||||
src: url("https://excel.lexploits.top/extra/tidal/LyricsBold.woff2")
|
|
||||||
format("woff2");
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-test="now-playing-lyrics"] {
|
|
||||||
--rl-line-shift: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"][class*="_current_"] {
|
|
||||||
text-shadow: none;
|
|
||||||
padding-left: 0;
|
|
||||||
transition-duration: 0.7s;
|
|
||||||
font-size: calc(55px * var(--rl-font-scale, 1));
|
|
||||||
/* biome-ignore lint: Active lyric uses Colorama color */
|
|
||||||
color: var(--cl-glow1, #fff) !important;
|
|
||||||
font-family:
|
|
||||||
"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
||||||
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"] {
|
|
||||||
text-shadow:
|
|
||||||
0 0 0px transparent,
|
|
||||||
0 0 0px transparent;
|
|
||||||
transition-duration: 0.25s;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
font-size: calc(40px * var(--rl-font-scale, 1));
|
|
||||||
font-family:
|
|
||||||
"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
||||||
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"]:hover {
|
|
||||||
text-shadow: none;
|
|
||||||
/* biome-ignore lint: Hover color override */
|
|
||||||
color: lightgray !important;
|
|
||||||
padding-left: 0;
|
|
||||||
transition-duration: 0.7s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Current line transitions */
|
|
||||||
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"] {
|
|
||||||
transition:
|
|
||||||
text-shadow 0.7s ease-in-out,
|
|
||||||
color 0.7s ease-in-out,
|
|
||||||
/* biome-ignore lint: Transition priority needed */
|
|
||||||
padding 0.7s ease-in-out !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Honestly forgot exactly why i wrote this... i just know it's smthn to do with the fact that .rl-wbw-line already does the shift & pre-wrap*/
|
|
||||||
.rl-wbw-active {
|
|
||||||
padding-left: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* WBW parent only (adapt to vanilla padding [stops the sudden snap in position]) */
|
|
||||||
[data-test="now-playing-lyrics"] div.rl-wbw-active:not(.rl-wbw-word) {
|
|
||||||
margin-left: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lyrics container styling */
|
|
||||||
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"] {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
/* biome-ignore lint: Must beat Tidal _hasCues_ opacity */
|
|
||||||
opacity: 1 !important;
|
|
||||||
font-family:
|
|
||||||
"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
||||||
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
|
||||||
font-weight: 700;
|
|
||||||
/* biome-ignore lint: Typography override for readability */
|
|
||||||
font-size: calc(38px * var(--rl-font-scale, 1)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide the Musixmatch attribution footer in the lyrics panel */
|
|
||||||
[data-test="now-playing-lyrics"] [class*="_footer_"] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* MARKER: WBW lyrics CSS */
|
|
||||||
|
|
||||||
/* hide tidal spans for wbw */
|
|
||||||
.rl-wbw-active span[data-test="lyrics-line"] {
|
|
||||||
/* biome-ignore lint: hide original lines when word-by-word is on */
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active line slide & Pre Word Wrap */
|
|
||||||
.rl-wbw-line {
|
|
||||||
--rl-line-shift: 20px;
|
|
||||||
text-align: left;
|
|
||||||
padding-left: 0;
|
|
||||||
padding-right: var(--rl-line-shift);
|
|
||||||
filter: none;
|
|
||||||
transform: translateZ(0);
|
|
||||||
transform-origin: left;
|
|
||||||
transition:
|
|
||||||
filter 0.4s ease,
|
|
||||||
padding 0.7s ease-in-out;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-wbw-line.rl-wbw-spacer {
|
|
||||||
filter: none;
|
|
||||||
padding-left: 0;
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Blur Inactive */
|
|
||||||
.rl-blur-active .rl-wbw-line {
|
|
||||||
filter: blur(0.07em);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-blur-active .rl-wbw-line.rl-pos-1 {
|
|
||||||
filter: blur(0.035em);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-blur-active .rl-wbw-line.rl-pos-2 {
|
|
||||||
filter: blur(0.05em);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-blur-active .rl-wbw-line.rl-pos-3 {
|
|
||||||
filter: blur(0.06em);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active line overrides */
|
|
||||||
.rl-wbw-line.rl-wbw-line-active,
|
|
||||||
.rl-blur-active .rl-wbw-line.rl-wbw-line-active {
|
|
||||||
padding-left: var(--rl-line-shift, 20px);
|
|
||||||
padding-right: 0;
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right singer: mirror shift + pre-wrap (inactive pl = shift, active pr = shift) */
|
|
||||||
.rl-wbw-line.rl-singer-right:not(.rl-wbw-line-active) {
|
|
||||||
padding-left: var(--rl-line-shift, 20px);
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-wbw-line.rl-singer-right.rl-wbw-line-active,
|
|
||||||
.rl-blur-active .rl-wbw-line.rl-singer-right.rl-wbw-line-active {
|
|
||||||
padding-left: 0;
|
|
||||||
padding-right: var(--rl-line-shift, 20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep last-active line unblurred during instrumental gaps */
|
|
||||||
.rl-blur-active .rl-wbw-line.rl-gap-hold {
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bubbled Lyrics scale */
|
|
||||||
.rl-bubbled .rl-wbw-line {
|
|
||||||
scale: 0.93 0.93 0.95;
|
|
||||||
transition:
|
|
||||||
scale 0.7s ease,
|
|
||||||
filter 0.4s ease,
|
|
||||||
padding 0.7s ease-in-out;
|
|
||||||
will-change: scale, translate, filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-bubbled .rl-wbw-line.rl-wbw-spacer {
|
|
||||||
scale: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-bubbled .rl-wbw-line.rl-wbw-line-active {
|
|
||||||
scale: 1;
|
|
||||||
transition:
|
|
||||||
scale 0.5s ease,
|
|
||||||
filter 0.4s ease,
|
|
||||||
padding 0.7s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Staggered scroll bounce animation (Bubbled Lyrics WIP) */
|
|
||||||
@keyframes rl-scroll-bounce {
|
|
||||||
from {
|
|
||||||
translate: 0 var(--rl-scroll-delta);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
translate: 0 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-wbw-line:not(.rl-scroll-animate) {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-scroll-animate {
|
|
||||||
animation: rl-scroll-bounce 400ms cubic-bezier(0.41, 0, 0.12, 0.99) both;
|
|
||||||
animation-delay: var(--rl-line-delay, 0ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Word span */
|
|
||||||
.rl-wbw-word {
|
|
||||||
text-shadow: none;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
/* biome-ignore lint: Must beat Tidal _hasCues_ opacity */
|
|
||||||
opacity: 1 !important;
|
|
||||||
line-height: 1.15;
|
|
||||||
transition:
|
|
||||||
text-shadow 0.15s ease-out,
|
|
||||||
color 0.15s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover word (Grouped Syllables) */
|
|
||||||
.rl-wbw-line:not(.rl-wbw-line-active) > .rl-wbw-word:hover,
|
|
||||||
.rl-wbw-line:not(.rl-wbw-line-active) > .rl-wbw-word.rl-wbw-word-hover,
|
|
||||||
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-main .rl-wbw-word:hover,
|
|
||||||
.rl-wbw-line:not(.rl-wbw-line-active)
|
|
||||||
.rl-wbw-main
|
|
||||||
.rl-wbw-word.rl-wbw-word-hover {
|
|
||||||
text-shadow: none;
|
|
||||||
/* biome-ignore lint: Hover color override */
|
|
||||||
color: lightgray !important;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active word */
|
|
||||||
.rl-wbw-word.rl-wbw-active {
|
|
||||||
/* biome-ignore lint: Active word uses Colorama color */
|
|
||||||
color: var(--cl-glow1, #fff) !important;
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* MARKER: Syllable sweep/wipe animation CSS */
|
|
||||||
|
|
||||||
@keyframes rl-wipe {
|
|
||||||
from {
|
|
||||||
background-size:
|
|
||||||
0.75em 100%,
|
|
||||||
0% 100%,
|
|
||||||
100% 100%;
|
|
||||||
background-position:
|
|
||||||
-0.375em 0%,
|
|
||||||
left,
|
|
||||||
left;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
background-size:
|
|
||||||
0.75em 100%,
|
|
||||||
100% 100%,
|
|
||||||
100% 100%;
|
|
||||||
background-position:
|
|
||||||
calc(100% + 0.375em) 0%,
|
|
||||||
left,
|
|
||||||
left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Syllable active: gradient sweep/wipe via background-clip */
|
|
||||||
.rl-wbw-word.rl-syl-active {
|
|
||||||
/* biome-ignore lint: Kill base transitions so class swaps are instant */
|
|
||||||
transition: none !important;
|
|
||||||
/* biome-ignore lint: Transparent fill so gradient paints the text */
|
|
||||||
color: transparent !important;
|
|
||||||
/* biome-ignore lint: Clip gradient to text glyphs */
|
|
||||||
-webkit-background-clip: text !important;
|
|
||||||
/* biome-ignore lint: Clip gradient to text glyphs */
|
|
||||||
background-clip: text !important;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent 0%,
|
|
||||||
var(--cl-glow1, #fff) 50%,
|
|
||||||
transparent 100%
|
|
||||||
),
|
|
||||||
linear-gradient(90deg, var(--cl-glow1, #fff) 100%, transparent 100%),
|
|
||||||
linear-gradient(90deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4));
|
|
||||||
background-size:
|
|
||||||
0.75em 100%,
|
|
||||||
0% 100%,
|
|
||||||
100% 100%;
|
|
||||||
background-position:
|
|
||||||
-0.375em 0%,
|
|
||||||
left,
|
|
||||||
left;
|
|
||||||
/* biome-ignore lint: No glow for syllable mode */
|
|
||||||
text-shadow: none !important;
|
|
||||||
/* biome-ignore lint: No glow for syllable mode */
|
|
||||||
filter: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Syllable finished: word stays Colorama color */
|
|
||||||
.rl-wbw-word.rl-syl-finished {
|
|
||||||
/* biome-ignore lint: Kill base transitions so class swaps are instant */
|
|
||||||
transition: none !important;
|
|
||||||
/* biome-ignore lint: Finished syllable uses Colorama color */
|
|
||||||
color: var(--cl-glow1, #fff) !important;
|
|
||||||
/* biome-ignore lint: No glow for syllable mode */
|
|
||||||
text-shadow: none !important;
|
|
||||||
/* biome-ignore lint: No glow for syllable mode */
|
|
||||||
filter: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* MARKER: Syllable animations CSS (WIP coming soon) */
|
|
||||||
/* syllableStyle: 0 = none, 1 = Pop!, 2 = Jump */
|
|
||||||
|
|
||||||
@keyframes rl-pop {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
25%,
|
|
||||||
35% {
|
|
||||||
transform: scale(1.03) translateY(-0.5%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rl-jump {
|
|
||||||
0% {
|
|
||||||
transform: translateY(8px);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pop! for word mode */
|
|
||||||
.rl-syl-pop .rl-wbw-word.rl-wbw-active {
|
|
||||||
transform-origin: center bottom;
|
|
||||||
animation: rl-pop 0.6s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pop! for syllable mode */
|
|
||||||
.rl-syl-pop .rl-wbw-word.rl-syl-active {
|
|
||||||
transform-origin: center bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Jump for word mode */
|
|
||||||
.rl-syl-jump .rl-wbw-word.rl-wbw-active {
|
|
||||||
animation: rl-jump 0.35s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tidals "..." at the top of the container */
|
|
||||||
.rl-wbw-active > span:not([data-test="lyrics-line"]) {
|
|
||||||
display: block;
|
|
||||||
font-size: calc(40px * var(--rl-font-scale, 1));
|
|
||||||
font-family:
|
|
||||||
"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
||||||
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
|
||||||
font-weight: 700;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
/* biome-ignore lint: Must beat Tidal _hasCues_ opacity */
|
|
||||||
opacity: 1 !important;
|
|
||||||
text-shadow: none;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* MARKER: Context Aware Lyrics CSS */
|
|
||||||
|
|
||||||
/* Background vocal sub-container */
|
|
||||||
.rl-wbw-bg-container {
|
|
||||||
max-height: 0;
|
|
||||||
overflow: visible;
|
|
||||||
opacity: 0;
|
|
||||||
font-size: 0.55em;
|
|
||||||
padding-top: 0.15em;
|
|
||||||
transition:
|
|
||||||
max-height 0.3s ease,
|
|
||||||
opacity 0.5s ease;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-wbw-line.rl-wbw-line-active .rl-wbw-bg-container {
|
|
||||||
max-height: 3em;
|
|
||||||
opacity: 1;
|
|
||||||
transition:
|
|
||||||
max-height 0.5s ease,
|
|
||||||
opacity 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Singer duet positioning */
|
|
||||||
.rl-wbw-line.rl-singer-right {
|
|
||||||
text-align: end;
|
|
||||||
transform-origin: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Duet: 20% column inset + same shift / pre-wrap as single (inactive reserves along outer edge) */
|
|
||||||
.rl-dual-side .rl-wbw-line.rl-singer-left:not(.rl-wbw-line-active) {
|
|
||||||
padding-left: 0;
|
|
||||||
padding-right: calc(20% + var(--rl-line-shift, 20px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-dual-side .rl-wbw-line.rl-singer-left.rl-wbw-line-active {
|
|
||||||
padding-left: var(--rl-line-shift, 20px);
|
|
||||||
padding-right: 20%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-dual-side .rl-wbw-line.rl-singer-right:not(.rl-wbw-line-active) {
|
|
||||||
padding-left: calc(20% + var(--rl-line-shift, 20px));
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-dual-side .rl-wbw-line.rl-singer-right.rl-wbw-line-active {
|
|
||||||
padding-left: 20%;
|
|
||||||
padding-right: var(--rl-line-shift, 20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rl-dual-side .rl-wbw-line.rl-singer-right .rl-wbw-bg-container {
|
|
||||||
text-align: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* MARKER: Lyrics panel vertical fade (Tidal’s mask clips sides) */
|
|
||||||
[data-test="now-playing-lyrics"] {
|
|
||||||
/* biome-ignore lint: Override Tidal mask with vertical fade */
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0%,
|
|
||||||
#fff 10%,
|
|
||||||
#fff 95%,
|
|
||||||
transparent 100%
|
|
||||||
) !important;
|
|
||||||
-webkit-mask-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0%,
|
|
||||||
#fff 10%,
|
|
||||||
#fff 95%,
|
|
||||||
transparent 100%
|
|
||||||
) !important;
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
-webkit-mask-repeat: no-repeat;
|
|
||||||
mask-size: 100% 100%;
|
|
||||||
-webkit-mask-size: 100% 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lyrics Cutoff Padding (Glow radius + 4px extra) */
|
|
||||||
[data-test="now-playing-lyrics"] > div:has(.rl-wbw-active),
|
|
||||||
[data-test="now-playing-lyrics"] > div:has([data-test="lyrics-line"]) {
|
|
||||||
/* biome-ignore lint: Override Tidal inline padding on lyrics scrollport */
|
|
||||||
padding-left: calc(var(--rl-glow-outer, 20px) + 0px) !important; /* 4px cushion (not needed atm) */
|
|
||||||
padding-right: calc(var(--rl-glow-outer, 20px) + 0px) !important; /* 4px cushion (not needed atm) */
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* MARKER: PATCHES (Random Fixes for Tidals Changes) */
|
/* MARKER: PATCHES (Random Fixes for Tidals Changes) */
|
||||||
/* These change a lot so I gave them their own section */
|
/* These change allot so i gave them their own section */
|
||||||
|
|
||||||
/* Remove max-width cap on now-playing content */
|
/* Fixes the new Sticky Header Tidal added.. in the shittest jankiest way possible */
|
||||||
[class*="_contentInner"] {
|
/* [class*="_stickyHeader"] {
|
||||||
max-width: none !important;
|
background: transparent !important;
|
||||||
|
backdrop-filter: blur(50px);
|
||||||
|
background-color: transparent !important;
|
||||||
|
width: fit-content !important;
|
||||||
|
padding-right: 3.5% !important;
|
||||||
|
-webkit-mask-image:
|
||||||
|
linear-gradient(to bottom, black 60%, transparent),
|
||||||
|
linear-gradient(to right, black 85%, transparent) !important;
|
||||||
|
mask-image:
|
||||||
|
linear-gradient(to bottom, black 60%, transparent),
|
||||||
|
linear-gradient(to right, black 85%, transparent) !important;
|
||||||
|
-webkit-mask-composite: source-in !important;
|
||||||
|
mask-composite: intersect !important;
|
||||||
|
padding-bottom: 5px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Round now-playing artwork corners */
|
[class*="_playQueueItems"]{
|
||||||
[data-test="now-playing-artwork"] {
|
border-radius: 2.5px 0 0 0 !important;
|
||||||
/* biome-ignore lint: Override flat corners */
|
|
||||||
border-radius: 10px !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide the Overlay Scrollbar (people just use mouse scroll) */
|
[data-test="playqueue-sticky-clear-active-items"] {
|
||||||
.os-scrollbar {
|
visibility: collapse !important;
|
||||||
display: none !important;
|
width: 0px !important;
|
||||||
pointer-events: none !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
._artworkTilt_1c6d5cc {
|
[data-test="playqueue-sticky-clear-source-items"] {
|
||||||
border: none !important;
|
visibility: collapse !important;
|
||||||
|
width: 0px !important;
|
||||||
|
} */
|
||||||
|
|
||||||
|
|
||||||
|
/* Remove the background color from the small header */
|
||||||
|
[class*="_smallHeader"]::before {
|
||||||
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide fullscreen button — breaks Radiant Lyrics */
|
/* fixes Tidals broken mini cover art padding | Cheers Aya <3*/
|
||||||
[data-test="new-now-playing-expand"] {
|
._imageBorder_110890a {
|
||||||
display: none !important;
|
filter: opacity(0);
|
||||||
|
}
|
||||||
|
._container_14bcbd4._playingFrom_79b167e {
|
||||||
|
transform: scale(1.01) translatex(.1em);
|
||||||
|
}
|
||||||
|
._leftColumn_aaf28de {
|
||||||
|
min-height: 110%;
|
||||||
|
transform: translatey(-.23em);
|
||||||
|
}
|
||||||
|
._imageryContainer_f99fc07.image {
|
||||||
|
transform: scale(1.03) translatey(.2em) translatex(.1em);
|
||||||
|
background-color: #00000000;
|
||||||
|
padding: 0em !important;
|
||||||
|
}
|
||||||
|
._image_145331a._cellImage_0ef8dd3 {
|
||||||
|
border-radius: .7em !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-test="footer-player"] {
|
||||||
/* Restore the Old Quality Tag style | thx Aya <3 */
|
._container_14bcbd4._playingFrom_79b167e > ._text_15008b2._medium20_1lyag_192._marketText_1lyag_1 {
|
||||||
|
transform: translatey(-.2em);
|
||||||
._gradientMax_9111fba {
|
}
|
||||||
background-color: #ffd4321a !important;
|
[class="image _imageryContainer_f99fc07"] {
|
||||||
box-shadow: none;
|
transform: translatey(.3em) !important;
|
||||||
border-style: none;
|
}
|
||||||
border-radius: 0.75em;
|
._image_145331a._cellImage_0ef8dd3 {
|
||||||
}
|
border-radius: .25em !important;
|
||||||
|
}
|
||||||
._max_894bc7c ._badgeText_1c9dd30 {
|
._toggleButton_809eee8 {
|
||||||
color: #ffd432 !important;
|
transform: translateY(-.22em);
|
||||||
text-shadow: 0 0 10px #0000 !important;
|
}
|
||||||
font-weight: 600 !important;
|
[class="image _imageryContainer_f99fc07"]:hover {
|
||||||
font-size: 90% !important;
|
[class="_cellImage_0ef8dd3 _image_145331a"] {
|
||||||
}
|
filter: brightness(.3);
|
||||||
|
}
|
||||||
._gradientHigh_87f2c3b {
|
}
|
||||||
background-color: #073430 !important;
|
._notFullscreenOverlay_1442d60 {
|
||||||
box-shadow: none;
|
background: none !important;
|
||||||
border-style: none;
|
transition: 0ms;
|
||||||
border-radius: 0.75em;
|
}
|
||||||
}
|
._notFullscreenOverlay_1442d60 ._nowPlayingButton_c1a86fa {
|
||||||
|
background-color: rgba(245, 245, 220, 0);
|
||||||
._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;
|
|
||||||
}
|
}
|
||||||
Generated
+1
-5
@@ -39,13 +39,9 @@ importers:
|
|||||||
specifier: ^5.8.3
|
specifier: ^5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
|
|
||||||
plugins/audio-visualizer-luna: {}
|
|
||||||
|
|
||||||
plugins/colorama-lyrics-luna: {}
|
|
||||||
|
|
||||||
plugins/copy-lyrics-luna: {}
|
plugins/copy-lyrics-luna: {}
|
||||||
|
|
||||||
plugins/element-hider-luna: {}
|
plugins/oled-theme-luna: {}
|
||||||
|
|
||||||
plugins/radiant-lyrics-luna: {}
|
plugins/radiant-lyrics-luna: {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,2 @@
|
|||||||
packages:
|
packages:
|
||||||
- "plugins/*"
|
- "plugins/*"
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
|
||||||
- esbuild
|
|
||||||
|
|||||||
Reference in New Issue
Block a user