32 Commits

Author SHA1 Message Date
meoware.exe 5a7d4f9c07 updated platform for DX 2026-06-15 23:59:35 +10:00
meoware.exe 8a86de1b39 Literally just changed the README 2026-06-01 20:24:32 +10:00
meoware.exe f7fa918473 Fix Audio Visualizer player hook <3 2026-06-01 20:16:11 +10:00
meoware.exe fa273705ad Fix Romanization <3 2026-06-01 19:17:02 +10:00
meoware.exe f069d7eae2 Fix Copy Lyrics <3 2026-05-29 16:22:17 +10:00
meoware.exe 497f3a95b0 Fix Tidals Playlist control header style 2026-05-29 15:57:13 +10:00
meoware.exe 734e0012cc Fixed Element hider counter leak 2026-05-21 00:18:03 +10:00
Meow Meow 3d8a755c0f Merge pull request #121 from meowarex/fix/pnpm-11-allow-builds
fix(ci): use pnpm 11 `allowBuilds` syntax and pin pnpm version
2026-05-17 23:31:11 +10:00
meoware.exe 06c4adf54b fix(ci): use pnpm 11 allowBuilds syntax and pin pnpm version
The previous fix (#119) used `onlyBuiltDependencies` in pnpm-workspace.yaml,
but the CI runner resolved `version: latest` to pnpm 11.1.2, where that key
was removed and replaced by `allowBuilds` (a map of name -> bool). The
`pnpm.onlyBuiltDependencies` block in package.json doesn't apply at the
workspace level either, so esbuild was still being ignored.

Changes:
- pnpm-workspace.yaml: replace `onlyBuiltDependencies: [esbuild]` with
  `allowBuilds: { esbuild: true }` (pnpm 11 syntax).
- package.json: add `packageManager: pnpm@11.1.2` so the version is
  reproducible across CI and local; drop the now-dead `pnpm` block.
- .github/workflows/build.yml: drop `version: latest` from
  pnpm/action-setup; the action reads the version from `packageManager`.
2026-05-17 13:29:38 +00:00
Meow Meow e2614d1b68 Merge pull request #119 from meowarex/fix/pnpm-approve-builds-esbuild
fix(ci): allow esbuild build scripts to fix ERR_PNPM_IGNORED_BUILDS
2026-05-17 23:22:40 +10:00
meoware.exe 4c6ee03df6 fix(ci): allow esbuild build scripts to fix ERR_PNPM_IGNORED_BUILDS
pnpm v10 blocks dependency build/postinstall scripts by default and
requires interactive 'pnpm approve-builds' to whitelist them, which
is not usable in CI. Declare esbuild as an allowed build in both
pnpm-workspace.yaml (canonical location since pnpm 10.7) and
package.json (legacy/parity) so installs run non-interactively.
2026-05-17 13:20:39 +00:00
meoware.exe d860383d65 Idle animation setting (Audio-Viz) 2026-05-17 18:03:47 +10:00
meoware.exe b28e245019 Cleanup & Normalize Endings 2026-04-07 18:02:07 +10:00
meoware.exe 83ef103118 Tidal Connect Support <3 2026-04-07 18:00:51 +10:00
meoware.exe e890c86b85 API Overhaul & Fixed Volume Slider <3 2026-04-06 22:59:26 +10:00
meoware.exe 957285bbbf CSS Overhaul & Glow Clipping Fix! 2026-04-06 17:18:29 +10:00
meoware.exe 35d03dc116 Pre Word Wrap Lyrics <3 2026-04-06 14:46:34 +10:00
meoware.exe 1dc77fc9d8 Grouped Slots & Visual fixes 2026-04-04 15:28:26 +11:00
meoware.exe be61b0bbb5 Fixed Null Assertions & Freed !important 2026-04-03 23:35:55 +11:00
meoware.exe 5f0795919d Overhaul Audio Visualizer & RL UI Improvements 2026-04-03 23:10:58 +11:00
meoware.exe 59af461ea1 Improve Audio Visualizer 2026-04-03 17:07:35 +11:00
meoware.exe 548e4bcaf0 Fixed Descender Character Cutoff @ Larger Font Sizes 2026-04-03 16:25:17 +11:00
meoware.exe 6ea24618d9 Improve HideUI & Flush controls 2026-04-03 16:20:26 +11:00
meoware.exe 20a4f11818 Add Ratelimiting <3 2026-04-03 01:26:44 +11:00
meoware.exe 6603c87eb3 Improved Lyric Injection 2026-04-03 01:09:06 +11:00
meoware.exe f34382aa08 Prevent clicking flush before first Request 2026-04-03 00:59:53 +11:00
meoware.exe ce8b1da26d Lyrics Flush Feature + Redesigned HideUI 2026-04-03 00:49:43 +11:00
meoware.exe daf76c5ffc Remove Artwork Border CSS 2026-04-02 23:19:18 +11:00
meoware.exe 306ab3a862 Fix Scroll break on large lyric count 2026-04-02 23:02:54 +11:00
meoware.exe a45a834580 Fixed Tidal line fallback scroll re/lock 2026-04-02 17:58:53 +11:00
Meow Meow 3f5aaf746b Merge pull request #94 from meowarex/player-market-ui-rewrite
Fixed UI Freeze on Tidal line fallback
2026-04-02 16:34:18 +11:00
Meow Meow 9c62d3e1f2 Merge pull request #91 from meowarex/player-market-ui-rewrite
Player market UI rewrite
2026-04-01 13:24:36 +11:00
26 changed files with 4332 additions and 2044 deletions
+7
View File
@@ -0,0 +1,7 @@
*.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
+1 -2
View File
@@ -13,8 +13,7 @@ jobs:
- name: Install pnpm 📥
uses: pnpm/action-setup@v4
with:
version: latest
# Version is read from `packageManager` in package.json for reproducible builds.
- name: Install Node.js 📥
uses: actions/setup-node@v4
+9 -16
View File
@@ -1,29 +1,23 @@
# Luna Plugins Collection
A collection of Luna plugins for Tidal, ported from Neptune framework.
A collection of [TidaLuna](https://github.com/Inrixia/TidaLuna) plugins that enhance and personalize the TIDAL Desktop experience. Build them yourself or grab them straight from the Plugin Store. Made with <3
## Plugins
### 🎨 Obsidian
**Location:** `plugins/obsidian-theme-luna/`
A dark OLED-friendly theme that transforms Tidal Luna's appearance.
**Features:**
- Applies a dark, OLED-optimized theme
- Reduces battery consumption on OLED displays.. i guess <3
- Modern, sleek dark interface
### 🎵 Radiant Lyrics
### Radiant Lyrics
**Location:** `plugins/radiant-lyrics-luna/`
A radiant and beautiful lyrics view for TIDAL with dynamic visual effects.
**Features:**
- Dynamic cover art backgrounds with blur and rotation effects
- Complete overhaul of tidals UI
- Syllable level lyric highlighting
- Romanization of lyrics
- Fully customizable
- Glowing Animated Lyrics with clean scrolling
### 📋 Copy Lyrics
### Copy Lyrics
**Location:** `plugins/copy-lyrics-luna/`
Allows users to copy song lyrics by selecting them directly in the interface.
@@ -33,7 +27,7 @@ Allows users to copy song lyrics by selecting them directly in the interface.
- Automatic clipboard copying of selected lyrics
- Smart lyric span detection
### 🧽 Element Hider
### Element Hider
**Location:** `plugins/element-hider-luna/`
Allows users to hide/remove UI elements by right clicking on them.
@@ -43,7 +37,7 @@ Allows users to hide/remove UI elements by right clicking on them.
- Automagically saves hidden elements
- Allows for elements to be restored
### 🎶 Audio Visualizer
### Audio Visualizer
**Location:** `plugins/audio-visualizer-luna/`
⚠️ **Work in Progress** - Audio visualization plugin that displays real-time audio frequency data.
@@ -115,4 +109,3 @@ This project is made for:
## Credits
Inrixia | [TidalLuna](https://github.com/Inrixia/TidaLuna) Plugin Framework (The successor Neptune)
ItzzExcel | The Original Neptune version of "Radiant Lyrics" (Which was ported to Luna and Rewritten by me!)
+1 -3
View File
@@ -2,6 +2,7 @@
"name": "@meowarex/TidalLuna-Plugins",
"description": "A Collection of Plugins for TidalLuna",
"type": "module",
"packageManager": "pnpm@11.1.2",
"scripts": {
"watch": "concurrently \"pnpm:build --watch\" pnpm:serve",
"build": "rimraf ./dist && tsx esbuild.config.ts",
@@ -18,8 +19,5 @@
"rimraf": "^6.0.1",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
},
"dependencies": {
"pnpm": "^10.14.0"
}
}
+506 -325
View File
@@ -3,434 +3,615 @@ import {
LunaSettings,
LunaNumberSetting,
LunaSwitchSetting,
LunaTextSetting,
LunaSelectSetting,
LunaSelectItem,
} from "@luna/ui";
import React from "react";
import {
VISUALIZER_LABELS,
type VisualizerType,
ALL_SLOT_KEYS,
ZONE_SLOTS,
ZONE_LABELS,
POSITION_LABELS,
type ZoneId,
type PositionId,
type SlotKey,
MINI_SUPPORTED,
} from "./visualizers/types";
export const settings = await ReactiveStore.getPluginStorage(
"AudioVisualizer",
{
barCount: 32,
barColor: "#ffffff",
navLeft1: "none" as VisualizerType,
navLeft2: "none" as VisualizerType,
navLeft3: "none" as VisualizerType,
navRight1: "spectrum-bars" as VisualizerType,
navRight2: "none" as VisualizerType,
navRight3: "none" as VisualizerType,
npLeft1: "none" as VisualizerType,
npLeft2: "none" as VisualizerType,
npLeft3: "none" as VisualizerType,
npRight1: "oscilloscope" as VisualizerType,
npRight2: "none" as VisualizerType,
npRight3: "none" as VisualizerType,
pbLeft1: "none" as VisualizerType,
pbLeft2: "none" as VisualizerType,
pbLeft3: "none" as VisualizerType,
pbRight1: "none" as VisualizerType,
pbRight2: "none" as VisualizerType,
pbRight3: "none" as VisualizerType,
barColor: "#ff69b4",
barCount: 64,
fftSize: 2048,
reactivity: 30,
gain: 1.5,
barRounding: true,
lineThickness: 2.0,
fillOpacity: 0.6,
opacityFalloff: 0.5,
lissajous: false,
scrollingOscilloscope: false,
groupedSlots: false,
transparentContainers: false,
idleMode: 1,
miniSlots: [] as string[],
customColors: [] as string[],
},
);
export const Settings = () => {
const [barCount, setBarCount] = React.useState(settings.barCount);
const [barColor, setBarColor] = React.useState(settings.barColor);
const [barRounding, setBarRounding] = React.useState(settings.barRounding);
const [showColorPicker, setShowColorPicker] = React.useState(false);
const [isAnimatingIn, setIsAnimatingIn] = React.useState(false);
const [shouldRender, setShouldRender] = React.useState(false);
const [customInput, setCustomInput] = React.useState(settings.barColor);
const [customColors, setCustomColors] = React.useState(settings.customColors);
const [hoveredColorIndex, setHoveredColorIndex] = React.useState<
number | null
>(null);
const VIZ_TYPES: VisualizerType[] = [
"none",
"spectrum-bars",
"spectrum-line",
"oscilloscope",
"vectorscope",
"loudness-meter",
];
const closeColorPicker = () => {
setIsAnimatingIn(false);
setTimeout(() => {
setShowColorPicker(false);
setShouldRender(false);
}, 200); // Wait for animation to complete because i need to
const getSlot = (key: SlotKey): VisualizerType =>
(settings as unknown as Record<string, VisualizerType>)[key] ?? "none";
const setSlot = (key: SlotKey, value: VisualizerType): void => {
(settings as unknown as Record<string, VisualizerType>)[key] = value;
};
export const Settings = () => {
const [barColor, setBarColor] = React.useState(settings.barColor);
const [barCount, setBarCount] = React.useState(settings.barCount);
const [fftSize, setFftSize] = React.useState(settings.fftSize);
const [reactivity, setReactivity] = React.useState(settings.reactivity);
const [gain, setGain] = React.useState(settings.gain);
const [barRounding, setBarRounding] = React.useState(settings.barRounding);
const [lineThickness, setLineThickness] = React.useState(settings.lineThickness);
const [fillOpacity, setFillOpacity] = React.useState(settings.fillOpacity);
const [lissajous, setLissajous] = React.useState(settings.lissajous);
const [scrollingOscilloscope, setScrollingOscilloscope] = React.useState(settings.scrollingOscilloscope);
const [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 [isColorAnimIn, setIsColorAnimIn] = React.useState(false);
const [shouldRenderColor, setShouldRenderColor] = React.useState(false);
const [customInput, setCustomInput] = React.useState(settings.barColor);
const [customColors, setCustomColors] = React.useState(settings.customColors);
const [hoveredColorIndex, setHoveredColorIndex] = React.useState<number | null>(null);
const [showSlotConfig, setShowSlotConfig] = React.useState(false);
const [isSlotAnimIn, setIsSlotAnimIn] = React.useState(false);
const [shouldRenderSlot, setShouldRenderSlot] = React.useState(false);
const [activeZone, setActiveZone] = React.useState<ZoneId>("nowPlaying");
const [slots, setSlots] = React.useState<Record<SlotKey, VisualizerType>>(() => {
const vals = {} as Record<SlotKey, VisualizerType>;
for (const key of ALL_SLOT_KEYS) vals[key] = getSlot(key);
return vals;
});
const [miniSlots, setMiniSlots] = React.useState<Set<string>>(new Set(settings.miniSlots));
const closeColorPicker = () => {
setIsColorAnimIn(false);
setTimeout(() => { setShowColorPicker(false); setShouldRenderColor(false); }, 200);
};
const openColorPicker = () => {
setShowColorPicker(true);
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
setShouldRenderColor(true);
setTimeout(() => setIsColorAnimIn(true), 10);
};
const closeSlotConfig = () => {
setIsSlotAnimIn(false);
setTimeout(() => { setShowSlotConfig(false); setShouldRenderSlot(false); }, 200);
};
const openSlotConfig = () => {
setShowSlotConfig(true);
setShouldRenderSlot(true);
setTimeout(() => setIsSlotAnimIn(true), 10);
};
React.useEffect(() => {
if (showColorPicker) {
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
setShouldRenderColor(true);
setTimeout(() => setIsColorAnimIn(true), 10);
}
}, [showColorPicker]);
// Common color presets for cool points :D
React.useEffect(() => {
if (showSlotConfig) {
setShouldRenderSlot(true);
setTimeout(() => setIsSlotAnimIn(true), 10);
}
}, [showSlotConfig]);
const colorPresets = [
"#ffffff",
"#ff0000",
"#00ff00",
"#0000ff",
"#ffff00",
"#ff00ff",
"#00ffff",
"#ff8800",
"#8800ff",
"#0088ff",
"#88ff00",
"#ff0088",
"#00ff88",
"#444444",
"#888888",
"#cccccc",
"#1db954",
"#e22134",
"#1976d2",
"#ff69b4", "#ff1493", "#e91e8a", "#c71585",
"#ff006e", "#ff4da6", "#ff85c8", "#ffb3d9",
"#ffffff", "#ff0000", "#00ff00", "#0000ff",
"#ffff00", "#ff00ff", "#00ffff", "#ff8800",
"#8800ff", "#0088ff", "#1db954", "#444444",
];
const updateColor = (color: string) => {
setBarColor(color);
setCustomInput(color);
settings.barColor = color;
(window as any).updateAudioVisualizer?.();
};
const addCustomColor = () => {
if (customInput) {
// Trim whitespace and convert to lowercase
const trimmedInput = customInput.trim().toLowerCase();
// Validate hex color format
const hexColorRegex = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
if (
hexColorRegex.test(trimmedInput) &&
!colorPresets.includes(trimmedInput) &&
!customColors.includes(trimmedInput)
) {
const newCustomColors = [...customColors, trimmedInput];
setCustomColors(newCustomColors);
settings.customColors = newCustomColors;
const trimmed = customInput.trim().toLowerCase();
const hexRe = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
if (hexRe.test(trimmed) && !colorPresets.includes(trimmed) && !customColors.includes(trimmed)) {
const nc = [...customColors, trimmed];
setCustomColors(nc);
settings.customColors = nc;
}
}
};
const removeCustomColor = (colorToRemove: string) => {
const newCustomColors = customColors.filter(
(color) => color !== colorToRemove,
);
setCustomColors(newCustomColors);
settings.customColors = newCustomColors;
// If the removed color was the selected color (reset to white)
if (barColor === colorToRemove) {
updateColor("#ffffff");
}
const removeCustomColor = (c: string) => {
const nc = customColors.filter(x => x !== c);
setCustomColors(nc);
settings.customColors = nc;
if (barColor === c) updateColor("#ff69b4");
};
const allColors = [...colorPresets, ...customColors];
const updateSlot = (key: SlotKey, value: VisualizerType) => {
setSlots(prev => ({ ...prev, [key]: value }));
setSlot(key, value);
if (!MINI_SUPPORTED.has(value)) {
setMiniSlots(prev => {
const next = new Set(prev);
if (next.delete(key)) settings.miniSlots = [...next];
return next;
});
}
};
const toggleMini = (key: SlotKey) => {
setMiniSlots(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
settings.miniSlots = [...next];
return next;
});
};
type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>;
type AnySwitchProps = Omit<BaseSwitchProps, "onChange"> & {
onChange: (_: unknown, checked: boolean) => void;
checked: boolean;
};
const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType<AnySwitchProps>;
const hasBars = ALL_SLOT_KEYS.some(key => slots[key] === "spectrum-bars");
const zones: ZoneId[] = ["nowPlaying", "topNav", "playerBar"];
const zonePositions = (zone: ZoneId) =>
Object.keys(ZONE_SLOTS[zone]) as PositionId[];
const backdropStyle = (animIn: boolean): React.CSSProperties => ({
position: "fixed", top: 0, left: 0, right: 0, bottom: 0,
background: "rgba(0,0,0,0.6)", zIndex: 1000,
opacity: animIn ? 1 : 0, transition: "opacity 0.2s ease",
border: "none", padding: 0, cursor: "default", width: "100%",
});
const panelBaseStyle = (animIn: boolean): React.CSSProperties => ({
position: "fixed", top: "50%", left: "50%",
background: "rgba(20,20,20,0.98)",
backdropFilter: "blur(20px)", WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.15)", borderRadius: "16px",
padding: "20px", maxHeight: "90vh", overflowY: "auto",
zIndex: 1001, boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
opacity: animIn ? 1 : 0,
transform: animIn ? "translate(-50%, -50%) scale(1)" : "translate(-50%, -50%) scale(0.9)",
transition: "all 0.2s ease",
});
const selectStyle: React.CSSProperties = {
width: "100%",
padding: "6px 8px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.08)",
color: "#fff",
fontSize: "12px",
cursor: "pointer",
outline: "none",
};
const optionStyle: React.CSSProperties = {
background: "#1a1a1a",
color: "#fff",
};
return (
<LunaSettings>
<LunaSwitchSetting
title="Bar Roundness"
desc="Enable rounded corners on visualizer bars"
checked={barRounding}
onChange={(_, checked) => {
setBarRounding(checked);
settings.barRounding = checked;
(window as any).updateAudioVisualizer?.();
}}
/>
<LunaNumberSetting
title="Bar Count"
desc="Number of frequency bars to display"
min={8}
max={64}
step={1}
value={barCount}
onNumber={(value: number) => {
setBarCount(value);
settings.barCount = value;
(window as any).updateAudioVisualizer?.();
}}
/>
{/* YUP YOUR EYES WORK... we do be using React code in the settings..*/}
{/* I'm not sure if this is a good idea, but it works & looks amazing */}
{/* Sorry @Inrixia <3 */}
<div
style={{
padding: "16px 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
{/* Color & Layout */}
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "10px 0",
}}>
<div>
<div
style={{
fontWeight: "normal",
fontSize: "1.075rem",
marginBottom: "4px",
}}
>
Bar Color
</div>
<div style={{ opacity: 0.7, fontSize: "14px" }}>
Color of the visualizer bars
<div style={{ fontWeight: 600, fontSize: "14px", color: "#fff" }}>Color & Layout</div>
<div style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)", marginTop: "2px" }}>
Visualizer color and slot placement
</div>
</div>
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
position: "relative",
}}
>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<button
onClick={() =>
showColorPicker ? closeColorPicker() : openColorPicker()
}
type="button"
onClick={() => showColorPicker ? closeColorPicker() : openColorPicker()}
style={{
width: "32px",
height: "32px",
width: "28px", height: "28px",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "6px",
cursor: "pointer",
background: barColor,
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
position: "relative",
overflow: "hidden",
borderRadius: "6px", cursor: "pointer", background: barColor,
overflow: "hidden", position: "relative",
}}
>
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.1)",
backdropFilter: "blur(2px)",
}}
/>
<div style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.1)", backdropFilter: "blur(2px)" }} />
</button>
{/* Custom Color Picker Modal */}
{shouldRender && (
<>
{/* Backdrop */}
<div
<button
type="button"
onClick={() => showSlotConfig ? closeSlotConfig() : openSlotConfig()}
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.6)",
zIndex: 1000,
opacity: isAnimatingIn ? 1 : 0,
transition: "opacity 0.2s ease",
padding: "6px 12px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff", cursor: "pointer", fontSize: "12px",
fontWeight: 500, transition: "all 0.2s ease",
whiteSpace: "nowrap",
}}
onClick={closeColorPicker}
/>
{/* Color Picker Panel */}
<div
style={{
position: "fixed",
top: "50%",
left: "50%",
background: "rgba(20,20,20,0.98)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "16px",
padding: "20px",
minWidth: "320px",
maxWidth: "90vw",
maxHeight: "90vh",
zIndex: 1001,
boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
opacity: isAnimatingIn ? 1 : 0,
transform: isAnimatingIn
? "translate(-50%, -50%) scale(1)"
: "translate(-50%, -50%) scale(0.9)",
transition: "all 0.2s ease",
}}
>
<div
style={{
marginBottom: "12px",
color: "#fff",
fontWeight: "bold",
fontSize: "14px",
}}
>
Choose Color
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.2)"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.1)"; }}
>Configure Slots</button>
</div>
</div>
{/* Color Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "8px",
marginBottom: "16px",
<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 &amp; Hide</LunaSelectItem>
<LunaSelectItem value={2}>Disabled &amp; 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 isCustomColor = customColors.includes(color);
const isCustom = customColors.includes(color);
const isHovered = hoveredColorIndex === index;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: cosmetic hover tracking on wrapper containing interactive buttons
<div
key={index}
style={{
position: "relative",
width: "32px",
height: "32px",
cursor: "pointer",
}}
className="color-item"
key={color}
style={{ position: "relative", width: "32px", height: "32px", cursor: "pointer" }}
onMouseEnter={() => setHoveredColorIndex(index)}
onMouseLeave={() => setHoveredColorIndex(null)}
>
<button
onClick={() => {
updateColor(color);
closeColorPicker();
}}
type="button"
onClick={() => { updateColor(color); closeColorPicker(); }}
style={{
width: "100%",
height: "100%",
borderRadius: "6px",
border:
barColor === color
? "2px solid #fff"
: "1px solid rgba(255,255,255,0.2)",
background: color,
cursor: "pointer",
transition: "all 0.2s ease",
width: "100%", height: "100%", borderRadius: "6px",
border: barColor === color ? "2px solid #fff" : "1px solid rgba(255,255,255,0.2)",
background: color, cursor: "pointer", transition: "all 0.2s ease",
}}
/>
{isCustomColor && (
{isCustom && (
<button
onClick={(e) => {
e.stopPropagation();
removeCustomColor(color);
}}
type="button"
onClick={(e) => { e.stopPropagation(); removeCustomColor(color); }}
style={{
position: "absolute",
top: "-4px",
right: "-4px",
width: "16px",
height: "16px",
borderRadius: "50%",
border: "1px solid rgba(255,255,255,0.8)",
background: "rgba(0,0,0,0.8)",
color: "#fff",
cursor: "pointer",
fontSize: "10px",
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: isHovered ? 1 : 0,
transition: "opacity 0.2s ease",
zIndex: 10,
position: "absolute", top: "-4px", right: "-4px",
width: "16px", height: "16px", borderRadius: "50%",
border: "1px solid rgba(255,255,255,0.8)", background: "rgba(0,0,0,0.8)",
color: "#fff", cursor: "pointer", fontSize: "10px",
display: "flex", alignItems: "center", justifyContent: "center",
opacity: isHovered ? 1 : 0, transition: "opacity 0.2s ease", zIndex: 10,
}}
className="remove-button"
>
×
</button>
>x</button>
)}
</div>
);
})}
</div>
{/* Custom Hex Input */}
<div style={{ marginBottom: "12px" }}>
<div
style={{
color: "rgba(255,255,255,0.7)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Add Custom Color
</div>
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
}}
>
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "12px", marginBottom: "6px" }}>Add Custom Color</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<input
type="text"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
updateColor(customInput);
addCustomColor();
}
}}
placeholder="#ffffff"
onKeyDown={(e) => { if (e.key === "Enter") { updateColor(customInput); addCustomColor(); } }}
placeholder="#ff69b4"
style={{
flex: 1,
padding: "8px 12px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
fontSize: "14px",
fontFamily: "monospace",
boxSizing: "border-box",
flex: 1, padding: "8px 12px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
color: "#fff", fontSize: "14px", fontFamily: "monospace", boxSizing: "border-box",
}}
/>
<button
onClick={() => {
updateColor(customInput);
addCustomColor();
}}
type="button"
onClick={() => { updateColor(customInput); addCustomColor(); }}
style={{
width: "32px",
height: "32px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.3)",
background: "rgba(255,255,255,0.15)",
color: "#fff",
cursor: "pointer",
fontSize: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "32px", height: "32px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.3)", background: "rgba(255,255,255,0.15)",
color: "#fff", cursor: "pointer", fontSize: "16px",
display: "flex", alignItems: "center", justifyContent: "center",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"rgba(255,255,255,0.25)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background =
"rgba(255,255,255,0.15)";
}}
>
+
</button>
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.25)"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.15)"; }}
>+</button>
</div>
</div>
{/* Close Button (Done) - Also runs when color chosen*/}
<button
type="button"
onClick={closeColorPicker}
style={{
width: "100%",
padding: "8px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
cursor: "pointer",
fontSize: "12px",
width: "100%", padding: "8px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
color: "#fff", cursor: "pointer", fontSize: "12px",
}}
>
Done
</button>
>Done</button>
</div>
</>
)}
{/* Slot configuration modal */}
{shouldRenderSlot && (
<>
<button type="button" aria-label="Close slot config" onClick={closeSlotConfig} style={backdropStyle(isSlotAnimIn)} />
<div style={{ ...panelBaseStyle(isSlotAnimIn), minWidth: "520px", maxWidth: "90vw", width: "600px" }}>
<div style={{ marginBottom: "16px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>
Configure Visualizer Slots
</div>
{/* Segment control */}
<div style={{
display: "flex", background: "rgba(255,255,255,0.08)",
borderRadius: "10px", padding: "2px", gap: "2px", marginBottom: "20px",
}}>
{zones.map(zone => (
<button
key={zone}
type="button"
onClick={() => setActiveZone(zone)}
style={{
flex: 1, border: "none",
background: activeZone === zone ? "rgba(255,255,255,0.15)" : "transparent",
color: activeZone === zone ? "#fff" : "rgba(255,255,255,0.4)",
fontSize: "12px", fontWeight: 600,
padding: "7px 0", borderRadius: "8px",
cursor: "pointer", transition: "all 0.2s ease",
...(activeZone === zone ? { boxShadow: "0 1px 3px rgba(0,0,0,0.3)" } : {}),
}}
>{ZONE_LABELS[zone]}</button>
))}
</div>
{/* Slot grid */}
<div style={{ display: "flex", gap: "16px", justifyContent: "center" }}>
{zonePositions(activeZone).map(pos => {
const slotKeys = ZONE_SLOTS[activeZone][pos];
if (!slotKeys) return null;
return (
<div key={pos} style={{ flex: 1, minWidth: 0 }}>
<div style={{
color: "rgba(255,255,255,0.6)", fontSize: "11px",
fontWeight: 600, textTransform: "uppercase",
letterSpacing: "0.5px", marginBottom: "8px",
textAlign: "center",
}}>{POSITION_LABELS[pos]}</div>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{slotKeys.map((key, i) => (
<div key={key} style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<select
value={slots[key]}
onChange={(e) => updateSlot(key, e.target.value as VisualizerType)}
style={{ ...selectStyle, flex: 1 }}
title={`Slot ${i + 1}`}
>
{VIZ_TYPES.map(t => (
<option key={t} value={t} style={optionStyle}>{VISUALIZER_LABELS[t]}</option>
))}
</select>
{MINI_SUPPORTED.has(slots[key]) && (
<button
type="button"
title="Mini"
onClick={() => toggleMini(key)}
style={{
width: "28px", height: "28px", flexShrink: 0,
borderRadius: "6px", border: "1px solid rgba(255,255,255,0.2)",
background: miniSlots.has(key) ? "rgba(255,105,180,0.4)" : "rgba(255,255,255,0.08)",
color: miniSlots.has(key) ? "#fff" : "rgba(255,255,255,0.4)",
cursor: "pointer", fontSize: "9px", fontWeight: 700,
display: "flex", alignItems: "center", justifyContent: "center",
transition: "all 0.2s ease",
}}
>M</button>
)}
</div>
))}
</div>
</div>
);
})}
</div>
<button
type="button"
onClick={closeSlotConfig}
style={{
width: "100%", padding: "8px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
color: "#fff", cursor: "pointer", fontSize: "12px", marginTop: "20px",
}}
>Done</button>
</div>
</>
)}
<LunaNumberSetting
title="Reactivity"
desc="How quickly visualizers respond to audio (5-100)"
min={5}
max={100}
step={5}
value={reactivity}
onNumber={(v: number) => { setReactivity(v); settings.reactivity = v; }}
/>
<LunaNumberSetting
title="Gain"
desc="Amplitude boost for spectrum visualizers (0.5-3.0)"
min={0.5}
max={3.0}
step={0.5}
value={gain}
onNumber={(v: number) => { setGain(v); settings.gain = v; }}
/>
<LunaSelectSetting
title="FFT Size"
desc="Frequency resolution (higher = more detail, more CPU)"
value={fftSize}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const v = Number(e.target.value);
setFftSize(v);
settings.fftSize = v;
}}
>
{[256, 512, 1024, 2048, 4096, 8192, 16384].map(s => (
<LunaSelectItem key={s} value={s}>{s}</LunaSelectItem>
))}
</LunaSelectSetting>
<LunaNumberSetting
title="Bar Count"
desc="Number of frequency bars (Spectrum Bars)"
min={8}
max={128}
step={1}
value={barCount}
onNumber={(v: number) => { setBarCount(v); settings.barCount = v; }}
/>
{hasBars && (
<AnySwitch
title="Bar Rounding"
desc="Round the top corners of spectrum bars"
checked={barRounding}
onChange={(_: unknown, checked: boolean) => {
setBarRounding(checked);
settings.barRounding = checked;
}}
/>
)}
<LunaNumberSetting
title="Line Thickness"
desc="Stroke width for line-based visualizers (0.5-5)"
min={0.5}
max={5}
step={0.5}
value={lineThickness}
onNumber={(v: number) => { setLineThickness(v); settings.lineThickness = v; }}
/>
<LunaNumberSetting
title="Fill Opacity"
desc="Fill below the Spectrum Line curve (0-1)"
min={0}
max={1}
step={0.05}
value={fillOpacity}
onNumber={(v: number) => { setFillOpacity(v); settings.fillOpacity = v; }}
/>
<AnySwitch
title="Scrolling Oscilloscope"
desc="Waveform scrolls right-to-left like a chart recorder"
checked={scrollingOscilloscope}
onChange={(_: unknown, checked: boolean) => {
setScrollingOscilloscope(checked);
settings.scrollingOscilloscope = checked;
}}
/>
<AnySwitch
title="Lissajous Mode"
desc="Rotate the Vectorscope 45° for Lissajous display"
checked={lissajous}
onChange={(_: unknown, checked: boolean) => {
setLissajous(checked);
settings.lissajous = checked;
}}
/>
</LunaSettings>
);
};
+320
View File
@@ -0,0 +1,320 @@
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 trackedEl: HTMLMediaElement | null = null;
let capturedTrack: MediaStreamTrack | null = null;
let trackCleanup: (() => void) | null = null;
let docCleanup: (() => void) | null = null;
let desiredFFT = 2048;
let desiredSmoothing = 0.8;
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;
}
// Connection states
// disconnected - no audio source
// pending - wired but audio isn't detected (track muted / loading)
// live - audio is detected
export type ConnectionState = "disconnected" | "pending" | "live";
let state: ConnectionState = "disconnected";
let onStateChange: ((state: ConnectionState) => void) | null = null;
export const setOnStateChange = (cb: ((state: ConnectionState) => void) | null): void => {
onStateChange = cb;
};
export const getState = (): ConnectionState => state;
export const isLive = (): boolean => state === "live";
const setState = (next: ConnectionState): void => {
if (next === state) return;
state = next;
try {
onStateChange?.(next);
} catch {}
};
// Analyser / buffer setup
export const setFFTSize = (size: number): void => {
desiredFFT = size;
if (monoAnalyser) monoAnalyser.fftSize = size;
if (leftAnalyser) leftAnalyser.fftSize = size;
if (rightAnalyser) rightAnalyser.fftSize = size;
allocateBuffers();
};
export const setSmoothing = (value: number): void => {
desiredSmoothing = value;
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 = (): boolean => {
try {
if (!audioContext || audioContext.state === "closed") {
audioContext = new AudioContext();
}
if (!monoAnalyser) {
monoAnalyser = createAnalyser(audioContext, desiredFFT, desiredSmoothing);
leftAnalyser = createAnalyser(audioContext, desiredFFT, desiredSmoothing);
rightAnalyser = createAnalyser(audioContext, desiredFFT, desiredSmoothing);
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;
}
};
// Source / track wiring
const clearTrackListeners = (): void => {
trackCleanup?.();
trackCleanup = null;
};
const detachSource = (): void => {
clearTrackListeners();
if (audioSource) {
try {
audioSource.disconnect();
} catch {}
audioSource = null;
}
capturedTrack = null;
trackedEl = null;
};
// captureFromEl() is retried after log spam to stop it <3
let loggedCaptureFailure = false;
const logCaptureFailureOnce = (message: string): void => {
if (loggedCaptureFailure) return;
loggedCaptureFailure = true;
log(message);
};
const captureFromEl = (el: HTMLMediaElement): boolean => {
const capture = (el as unknown as { captureStream?: () => MediaStream }).captureStream;
if (typeof capture !== "function") {
logCaptureFailureOnce("captureStream() not available on media element");
return false;
}
let stream: MediaStream;
try {
stream = capture.call(el);
} catch (err) {
logCaptureFailureOnce(`captureStream() failed: ${err}`);
return false;
}
const tracks = stream.getAudioTracks();
// No audio track yet
if (tracks.length === 0) return false;
if (!ensureContext()) return false;
detachSource();
const track = tracks[0];
try {
audioSource = audioContext!.createMediaStreamSource(stream);
audioSource.connect(monoAnalyser!);
audioSource.connect(splitter!);
} catch (err) {
log(`Failed to connect captured stream: ${err}`);
audioSource = null;
return false;
}
trackedEl = el;
capturedTrack = track;
const onUnmute = () => setState("live");
const onMute = () => {
if (state === "live") setState("pending");
};
const onEnded = () => {
detachSource();
setState("pending");
};
track.addEventListener("unmute", onUnmute);
track.addEventListener("mute", onMute);
track.addEventListener("ended", onEnded);
trackCleanup = () => {
track.removeEventListener("unmute", onUnmute);
track.removeEventListener("mute", onMute);
track.removeEventListener("ended", onEnded);
};
// Captured tracks start muted until samples flow; wait for "unmute" instead of guessing.
loggedCaptureFailure = false;
setState(track.muted ? "pending" : "live");
return true;
};
// An element is worth capturing from when it's actually advancing audio.
const isPlayingAudio = (el: HTMLMediaElement): boolean => !el.paused && !el.ended && el.readyState >= 2;
const captureFrom = (el: HTMLMediaElement): void => {
if (el === trackedEl) {
if (state === "live") return;
if (capturedTrack && capturedTrack.readyState === "live" && !capturedTrack.muted) {
setState("live"); // healthy track, we just missed its unmute event
return;
}
}
captureFromEl(el);
};
// Capture media events (timeupdate & playing etc..)
const MEDIA_ACTIVE_EVENTS = ["playing", "timeupdate"] as const;
const MEDIA_RESET_EVENTS = ["pause", "ended", "emptied", "abort"] as const;
const onMediaActive = (e: Event): void => {
const el = e.target;
if (el instanceof HTMLMediaElement && isPlayingAudio(el)) captureFrom(el);
};
const onMediaReset = (e: Event): void => {
if (e.target === trackedEl) {
detachSource();
setState("pending");
}
};
/** global media listeners */
export const init = (): void => {
ensureContext();
if (docCleanup) return;
for (const ev of MEDIA_ACTIVE_EVENTS) document.addEventListener(ev, onMediaActive, true);
for (const ev of MEDIA_RESET_EVENTS) document.addEventListener(ev, onMediaReset, true);
docCleanup = () => {
for (const ev of MEDIA_ACTIVE_EVENTS) document.removeEventListener(ev, onMediaActive, true);
for (const ev of MEDIA_RESET_EVENTS) document.removeEventListener(ev, onMediaReset, true);
};
};
/** capture from whatever is already playing (plugin loaded mid-playback) */
export const scan = (): void => {
if (!ensureContext()) return;
for (const el of document.querySelectorAll<HTMLMediaElement>(
"video, audio",
)) {
if (isPlayingAudio(el)) {
captureFrom(el);
if (state === "live") return;
}
}
};
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 => {
docCleanup?.();
docCleanup = null;
detachSource();
setState("disconnected");
onStateChange = null;
if (audioContext && audioContext.state !== "closed") {
audioContext.close().catch(() => {});
}
audioContext = null;
monoAnalyser = null;
leftAnalyser = null;
rightAnalyser = null;
splitter = null;
monoByteFreq = null;
monoByteTime = null;
monoFloatFreq = null;
monoFloatTime = null;
leftFloatTime = null;
rightFloatTime = null;
};
+415 -471
View File
@@ -1,535 +1,479 @@
import { LunaUnload, Tracer } from "@luna/core";
import { StyleTag, PlayState } from "@luna/lib";
import { type LunaUnload, Tracer } from "@luna/core";
import { StyleTag, PlayState, MediaItem, observe } from "@luna/lib";
import { settings, Settings } from "./Settings";
import * as audio from "./audio";
import type { AudioData } from "./audio";
import { type Visualizer, type VisualizerType, VISUALIZER_DIMENSIONS, MINI_DIMENSIONS, ALL_SLOT_KEYS, ZONE_SLOTS, type SlotKey } from "./visualizers/types";
import { createSpectrumLine } from "./visualizers/spectrum-line";
import { createSpectrumBars } from "./visualizers/spectrum-bars";
import { createOscilloscope } from "./visualizers/oscilloscope";
import { createVectorscope } from "./visualizers/vectorscope";
import { createLoudnessMeter } from "./visualizers/loudness-meter";
// Import CSS styles for the visualizer
import visualizerStyles from "file://styles.css?minify";
export const { trace } = Tracer("[Audio Visualizer]");
const log = (message: string) => console.log(`[Audio Visualizer] ${message}`);
export { Settings };
const config = {
enabled: true,
width: 200,
height: 40,
get barCount() {
return settings.barCount;
},
get color() {
return settings.barColor;
},
get barRounding() {
return settings.barRounding;
},
sensitivity: 1.5,
smoothing: 0.8,
const log = (msg: string) => console.log(`[Audio Visualizer] ${msg}`);
export const unloads = new Set<LunaUnload>();
new StyleTag("AudioVisualizer", unloads, visualizerStyles);
const FACTORIES: Record<Exclude<VisualizerType, "none">, () => Visualizer> = {
"spectrum-line": createSpectrumLine,
"spectrum-bars": createSpectrumBars,
oscilloscope: createOscilloscope,
vectorscope: createVectorscope,
"loudness-meter": createLoudnessMeter,
};
// Clean up resources
export const unloads = new Set<LunaUnload>();
// Slot Management
// StyleTag for CSS
const styleTag = new StyleTag("AudioVisualizer", unloads, visualizerStyles);
// Audio context and analyzer
let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let audioSource: MediaElementAudioSourceNode | null = null;
let dataArray: Uint8Array | null = null;
let animationId: number | null = null;
let currentAudioElement: HTMLAudioElement | null = null;
let isSourceConnected: boolean = false;
// Each placement gets its own container/canvas/context
interface VisualizerSlot {
interface Slot {
container: HTMLDivElement | null;
canvas: HTMLCanvasElement | null;
ctx: CanvasRenderingContext2D | null;
visualizer: Visualizer | null;
currentType: VisualizerType;
contextType: "webgl" | "canvas2d" | null;
}
const navSlot: VisualizerSlot = { container: null, canvas: null, ctx: null };
const npSlot: VisualizerSlot = { container: null, canvas: null, ctx: null };
// Find the audio element - this is a bit of a hack but it works
const findAudioElement = (): HTMLAudioElement | null => {
// Try main selectors first
const selectors = [
"audio",
"video",
"audio[data-test]",
'[data-test="audio-player"] audio',
];
for (const selector of selectors) {
const element = document.querySelector(selector) as HTMLAudioElement;
if (
element &&
(element.tagName === "AUDIO" || element.tagName === "VIDEO")
) {
return element;
}
interface SlotGroup {
groupContainer: HTMLDivElement;
slots: Slot[];
keys: readonly SlotKey[];
}
// Quick scan for any audio elements
const audioElements = document.querySelectorAll("audio, video");
for (const element of audioElements) {
const audioEl = element as HTMLAudioElement;
if (audioEl.src || audioEl.currentSrc) {
return audioEl;
}
}
const groups = new Map<string, SlotGroup>();
let navArrowsEl: HTMLElement | null = null;
let idleHidden = false;
return null;
};
const getSlot = (key: SlotKey): VisualizerType =>
(settings as unknown as Record<string, VisualizerType>)[key] ?? "none";
// Initialize audio visualization
const initializeAudioVisualizer = async (): Promise<void> => {
try {
// Find the audio element
const audioElement = findAudioElement();
if (!audioElement) {
return;
}
const isWebGLViz = (type: VisualizerType): boolean =>
type === "spectrum-line" || type === "spectrum-bars";
// create audio context
if (!audioContext) {
audioContext = new AudioContext();
log("Created AudioContext");
}
const isMiniSlot = (key: SlotKey): boolean =>
(settings.miniSlots ?? []).includes(key);
// create analyser
if (!analyser) {
analyser = audioContext.createAnalyser();
analyser.fftSize = 512; // Fixed power of 2 that provides enough frequency bins
analyser.smoothingTimeConstant = config.smoothing;
dataArray = new Uint8Array(analyser.frequencyBinCount);
log("Created AnalyserNode");
}
// attempt audio connection if not already connected
if (!isSourceConnected && audioElement !== currentAudioElement) {
try {
// Create audio source - this might fail if already connected elsewhere
audioSource = audioContext.createMediaElementSource(audioElement);
audioSource.connect(analyser);
// CRITICAL: connect back to destination for audio output (otherwise no sound)
analyser.connect(audioContext.destination);
currentAudioElement = audioElement;
isSourceConnected = true;
log("Connected to audio stream with output");
} catch (error) {
// Audio is connected elsewhere - that's fine, we just can't visualize
if (
error instanceof Error &&
error.message.includes("already connected")
) {
log("Audio already connected elsewhere - skipping visualization");
}
return;
}
}
// Resume context only if needed and don't wait for it
// (otherwise it will wait for the audio to start playing)
if (audioContext.state === "suspended") {
audioContext.resume().catch(() => {}); // Fire and forget
}
createVisualizerUI();
// Start animation only if not already running
if (!animationId) {
animate();
}
} catch (err) {
// log errors
console.error(err);
}
};
const makeSlotElements = (): { container: HTMLDivElement; canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null => {
const container = document.createElement("div");
container.className = "audio-visualizer-container";
container.style.cssText = `
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 4px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
`;
const getSlotDims = (type: VisualizerType, key: SlotKey) =>
isMiniSlot(key) && MINI_DIMENSIONS[type] ? MINI_DIMENSIONS[type] : VISUALIZER_DIMENSIONS[type];
const createSlotCanvas = (dims: { width: number; height: number }): HTMLCanvasElement => {
const cvs = document.createElement("canvas");
cvs.width = config.width;
cvs.height = config.height;
cvs.style.cssText = `
width: ${config.width}px;
height: ${config.height}px;
border-radius: 4px;
`;
container.appendChild(cvs);
const ctx = cvs.getContext("2d");
if (!ctx) return null;
return { container, canvas: cvs, ctx };
cvs.width = dims.width;
cvs.height = dims.height;
cvs.style.cssText = `width:${dims.width}px;height:${dims.height}px;border-radius:4px;display:block;`;
return cvs;
};
const clearSlot = (slot: VisualizerSlot): void => {
slot.container?.remove();
slot.container = null;
slot.canvas = null;
slot.ctx = null;
const applySlotSize = (slot: Slot, dims: { width: number; height: number }): void => {
if (!slot.container || !slot.canvas) return;
slot.canvas.width = dims.width;
slot.canvas.height = dims.height;
slot.canvas.style.width = `${dims.width}px`;
slot.canvas.style.height = `${dims.height}px`;
slot.container.style.width = `${dims.width + 8}px`;
slot.container.style.height = `${dims.height + 8}px`;
slot.visualizer?.resize(dims.width, dims.height);
};
const ensureNavSlot = (): void => {
if (navSlot.container?.isConnected) return;
clearSlot(navSlot);
const switchVisualizer = (slot: Slot, type: VisualizerType, key: SlotKey): void => {
if (slot.currentType === type) return;
const searchField = document.querySelector('input[class*="_searchField"]') as HTMLInputElement;
if (!searchField) return;
const searchContainer = searchField.parentElement;
if (!searchContainer?.parentElement) return;
slot.visualizer?.dispose();
slot.visualizer = null;
const els = makeSlotElements();
if (!els) return;
els.container.style.marginRight = "12px";
Object.assign(navSlot, els);
searchContainer.parentElement.insertBefore(els.container, searchContainer);
};
const ensureNpSlot = (): void => {
if (npSlot.container?.isConnected) return;
clearSlot(npSlot);
const artistInfo = document.querySelector('[data-test="artist-info"]');
if (!artistInfo) return;
const leftContent = artistInfo.parentElement;
if (!leftContent) return;
const els = makeSlotElements();
if (!els) return;
els.container.style.marginLeft = "12px";
Object.assign(npSlot, els);
leftContent.insertBefore(els.container, artistInfo.nextSibling);
};
const createVisualizerUI = (): void => {
if (!config.enabled) return;
ensureNavSlot();
ensureNpSlot();
};
const removeVisualizerUI = (): void => {
clearSlot(navSlot);
clearSlot(npSlot);
};
// Animation loop for rendering visualizer
const animate = (): void => {
// Re-attach slots that got disconnected from the DOM
createVisualizerUI();
const slots = [navSlot, npSlot].filter(s => s.ctx && s.canvas);
if (slots.length === 0) {
animationId = requestAnimationFrame(animate);
if (type === "none") {
if (slot.container) slot.container.style.display = "none";
slot.currentType = "none";
return;
}
let hasRealAudio = false;
if (analyser && dataArray) {
analyser.getByteFrequencyData(dataArray);
const avgVolume =
dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length;
hasRealAudio = avgVolume > 5;
const dims = getSlotDims(type, key);
if (slot.container) {
slot.canvas?.remove();
const cvs = createSlotCanvas(dims);
slot.container.appendChild(cvs);
slot.canvas = cvs;
slot.contextType = isWebGLViz(type) ? "webgl" : "canvas2d";
slot.container.style.display = "flex";
slot.container.style.width = `${dims.width + 8}px`;
slot.container.style.height = `${dims.height + 8}px`;
}
for (const slot of slots) {
const ctx = slot.ctx!;
const cvs = slot.canvas!;
ctx.fillStyle = config.color;
ctx.strokeStyle = config.color;
ctx.clearRect(0, 0, cvs.width, cvs.height);
const factory = FACTORIES[type];
const viz = factory();
if (slot.canvas) {
viz.init(slot.canvas, settings.barColor);
}
slot.visualizer = viz;
slot.currentType = type;
};
if (hasRealAudio && analyser && dataArray) {
drawBars(ctx, cvs);
const syncGroupHeights = (group: SlotGroup): void => {
let maxH = 0;
for (let i = 0; i < group.keys.length; i++) {
const slot = group.slots[i];
if (slot.currentType === "none") continue;
const dims = getSlotDims(slot.currentType, group.keys[i]);
if (dims.height > maxH) maxH = dims.height;
}
if (maxH === 0) return;
for (let i = 0; i < group.keys.length; i++) {
const slot = group.slots[i];
if (!slot.container || !slot.canvas || slot.currentType === "none") continue;
const dims = getSlotDims(slot.currentType, group.keys[i]);
const targetH = Math.max(dims.height, maxH);
applySlotSize(slot, { width: dims.width, height: targetH });
}
};
const updateGroupVisibility = (group: SlotGroup): void => {
const activeCount = group.slots.filter(s => s.currentType !== "none").length;
const allNone = activeCount === 0;
const hidden = allNone || idleHidden;
group.groupContainer.style.display = hidden ? "none" : "flex";
if (!hidden) syncGroupHeights(group);
group.groupContainer.classList.toggle(
"av-grouped",
settings.groupedSlots && activeCount >= 2,
);
if (group === groups.get("topNav-left") && navArrowsEl) {
navArrowsEl.style.marginRight = hidden ? "" : "0";
}
};
const createGroup = (keys: readonly SlotKey[], zone: string, position: string): SlotGroup => {
const groupContainer = document.createElement("div");
groupContainer.className = "av-slot-group";
groupContainer.dataset.zone = zone;
groupContainer.dataset.position = position;
const slots: Slot[] = [];
for (const _key of keys) {
const slotContainer = document.createElement("div");
slotContainer.className = "audio-visualizer-container";
slotContainer.style.display = "none";
groupContainer.appendChild(slotContainer);
slots.push({
container: slotContainer,
canvas: null,
visualizer: null,
currentType: "none",
contextType: null,
});
}
return { groupContainer, slots, keys };
};
const initGroupVisualizers = (group: SlotGroup): void => {
for (let i = 0; i < group.keys.length; i++) {
const key = group.keys[i];
const type = getSlot(key);
if (type !== "none") {
switchVisualizer(group.slots[i], type, key);
}
}
updateGroupVisibility(group);
};
const initAllGroups = (): void => {
for (const [zoneId, positions] of Object.entries(ZONE_SLOTS)) {
for (const [posId, keys] of Object.entries(positions)) {
if (!keys) continue;
const groupId = `${zoneId}-${posId}`;
const group = createGroup(keys, zoneId, posId);
groups.set(groupId, group);
}
}
};
// UI Attachment
const attachNavGroups = (anchor: Element): void => {
const parent = anchor.parentElement;
if (!parent) return;
const navLeft = groups.get("topNav-left");
if (navLeft && !navLeft.groupContainer.isConnected) {
const navArrows = parent.querySelector('[data-test="navigation-arrows"]') as HTMLElement | null;
if (navArrows) {
navArrowsEl = navArrows;
navArrows.after(navLeft.groupContainer);
} else {
drawScrollingWave(ctx, cvs);
parent.prepend(navLeft.groupContainer);
}
navLeft.groupContainer.style.marginRight = "auto";
initGroupVisualizers(navLeft);
}
animationId = requestAnimationFrame(animate);
const navRight = groups.get("topNav-right");
if (navRight && !navRight.groupContainer.isConnected) {
parent.insertBefore(navRight.groupContainer, anchor);
initGroupVisualizers(navRight);
}
};
// Global wave animation state
const attachNpGroups = (anchor: Element): void => {
const leftContent = anchor.parentElement;
if (!leftContent) return;
const header = leftContent.parentElement as HTMLElement | null;
if (!header) return;
const npLeft = groups.get("nowPlaying-left");
if (npLeft && !npLeft.groupContainer.isConnected) {
leftContent.insertBefore(npLeft.groupContainer, anchor.nextSibling);
initGroupVisualizers(npLeft);
}
const buttonsDiv = header.querySelector(':scope > [class*="buttons"]') as HTMLElement | null;
const npRight = groups.get("nowPlaying-right");
if (npRight && !npRight.groupContainer.isConnected) {
if (buttonsDiv) {
header.insertBefore(npRight.groupContainer, buttonsDiv);
} else {
header.appendChild(npRight.groupContainer);
}
npRight.groupContainer.style.marginLeft = "auto";
initGroupVisualizers(npRight);
}
};
const attachPbGroups = (anchor: Element): void => {
const trackInfo = anchor.querySelector('[data-test="track-info"]');
const utilityContainer = anchor.querySelector('[class*="utilityContainer"]');
const pbLeft = groups.get("playerBar-left");
if (pbLeft && !pbLeft.groupContainer.isConnected && trackInfo) {
trackInfo.appendChild(pbLeft.groupContainer);
initGroupVisualizers(pbLeft);
}
const pbRight = groups.get("playerBar-right");
if (pbRight && !pbRight.groupContainer.isConnected && utilityContainer) {
utilityContainer.prepend(pbRight.groupContainer);
initGroupVisualizers(pbRight);
}
};
initAllGroups();
observe(unloads, '[data-test="search-popover-container"]', attachNavGroups);
observe(unloads, '[data-test="artist-info"]', attachNpGroups);
observe(unloads, '[data-test="footer-player"]', attachPbGroups);
const existingSearch = document.querySelector('[data-test="search-popover-container"]');
if (existingSearch) attachNavGroups(existingSearch);
const existingArtist = document.querySelector('[data-test="artist-info"]');
if (existingArtist) attachNpGroups(existingArtist);
const existingFooter = document.querySelector('[data-test="footer-player"]');
if (existingFooter) attachPbGroups(existingFooter);
// Audio Connection
const fft = () => settings.fftSize ?? 2048;
const reactivityToSmoothing = (r: number) => Math.max(0, Math.min(0.95, (100 - r) / 100));
const smooth = () => reactivityToSmoothing(settings.reactivity ?? 30);
let lastReactivity = settings.reactivity ?? 30;
audio.setOnStateChange((state) => {
if (state === "live") log("Audio connected");
});
PlayState.onState(unloads, (state) => {
if (state === "PLAYING") audio.scan();
});
MediaItem.onMediaTransition(unloads, () => audio.scan());
// Idle Animation Synthetic Data
let waveTime = 0;
const IDLE_SIZE = 1024;
const idleByteFreq = new Uint8Array(IDLE_SIZE);
const idleByteTime = new Uint8Array(IDLE_SIZE);
const idleFloatFreq = new Float32Array(IDLE_SIZE);
const idleFloatTime = new Float32Array(IDLE_SIZE);
const idleLeftTime = new Float32Array(IDLE_SIZE);
const idleRightTime = new Float32Array(IDLE_SIZE);
// Helper function to draw rounded rectangles
const drawRoundedRect = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void => {
ctx.beginPath();
ctx.roundRect(x, y, width, height, radius);
ctx.fill();
};
const drawScrollingWave = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => {
waveTime += 0.05 / [navSlot, npSlot].filter(s => s.ctx).length;
const barCount = config.barCount;
const barWidth = cvs.width / barCount;
const maxHeight = cvs.height * 0.6;
ctx.fillStyle = config.color;
for (let i = 0; i < barCount; i++) {
const x = i / barCount;
const generateIdleData = (): AudioData => {
for (let i = 0; i < IDLE_SIZE; i++) {
const x = i / IDLE_SIZE;
const wave1 = Math.sin(x * Math.PI * 2 + waveTime) * 0.3;
const wave2 = Math.sin(x * Math.PI * 4 + waveTime * 1.3) * 0.2;
const wave3 = Math.sin(x * Math.PI * 6 + waveTime * 0.7) * 0.1;
const combinedWave = (wave1 + wave2 + wave3 + 1) / 2;
const travelWave = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5;
const barHeight = maxHeight * combinedWave * travelWave * 0.8 + 2;
const combined = (wave1 + wave2 + wave3 + 1) / 2;
const travel = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5;
const xPos = i * barWidth;
const yPos = (cvs.height - barHeight) / 2;
const byteVal = Math.floor(combined * travel * 140 + 20);
idleByteFreq[i] = byteVal;
idleFloatFreq[i] = -40 + byteVal * 0.3;
if (config.barRounding) {
drawRoundedRect(ctx, xPos, yPos, barWidth - 1, barHeight, 2);
} else {
ctx.fillRect(xPos, yPos, barWidth - 1, barHeight);
}
const timeSample = Math.sin(x * Math.PI * 8 + waveTime * 3) * 0.15;
idleByteTime[i] = 128 + Math.floor(timeSample * 127);
idleFloatTime[i] = timeSample;
idleLeftTime[i] = timeSample;
idleRightTime[i] = Math.sin(x * Math.PI * 8 + waveTime * 3 + 0.3) * 0.15;
}
return {
byteFrequency: idleByteFreq,
byteTimeDomain: idleByteTime,
floatFrequency: idleFloatFreq,
floatTimeDomain: idleFloatTime,
leftTimeDomain: idleLeftTime,
rightTimeDomain: idleRightTime,
sampleRate: 44100,
fftSize: IDLE_SIZE * 2,
binCount: IDLE_SIZE,
};
};
const drawBars = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => {
if (!dataArray) return;
const barWidth = cvs.width / config.barCount;
const heightScale = cvs.height / 255;
ctx.fillStyle = config.color;
for (let i = 0; i < config.barCount; i++) {
const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
const barHeight = dataArray[dataIndex] * config.sensitivity * heightScale;
const x = i * barWidth;
const y = cvs.height - barHeight;
if (config.barRounding) {
drawRoundedRect(ctx, x, y, barWidth - 1, barHeight, 2);
} else {
ctx.fillRect(x, y, barWidth - 1, barHeight);
}
}
// Static idle data — flat, silent, no movement
const STATIC_IDLE_DATA: AudioData = {
byteFrequency: new Uint8Array(IDLE_SIZE),
byteTimeDomain: new Uint8Array(IDLE_SIZE).fill(128),
floatFrequency: new Float32Array(IDLE_SIZE).fill(-100),
floatTimeDomain: new Float32Array(IDLE_SIZE),
leftTimeDomain: new Float32Array(IDLE_SIZE),
rightTimeDomain: new Float32Array(IDLE_SIZE),
sampleRate: 44100,
fftSize: IDLE_SIZE * 2,
binCount: IDLE_SIZE,
};
// Draw waveform visualization - NOT IMPLEMENTED YET
// const drawWaveform = (): void => {
// if (!canvasContext || !dataArray || !canvas) return;
// Animation Loop
// const centerY = canvas.height / 2;
// const amplitudeScale = canvas.height / 512;
let animationId: number | null = null;
const lastSlotTypes = new Map<SlotKey, VisualizerType>();
const lastMiniState = new Map<SlotKey, boolean>();
let lastGrouped = settings.groupedSlots;
let lastChromeless = settings.transparentContainers;
// canvasContext.strokeStyle = config.color;
// canvasContext.lineWidth = 2;
// canvasContext.beginPath();
// for (let i = 0; i < config.barCount; i++) {
// const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
// const amplitude = (dataArray[dataIndex] - 128) * config.sensitivity * amplitudeScale;
// const x = (i / config.barCount) * canvas.width;
// const y = centerY + amplitude;
// if (i === 0) {
// canvasContext.moveTo(x, y);
// } else {
// canvasContext.lineTo(x, y);
// }
// }
// canvasContext.stroke();
// };
// Draw circular visualization - NOT IMPLEMENTED YET
// const drawCircular = (): void => {
// if (!canvasContext || !dataArray || !canvas) return;
// const centerX = canvas.width / 2;
// const centerY = canvas.height / 2;
// const radius = Math.min(centerX, centerY) - 10;
// canvasContext.strokeStyle = config.color;
// canvasContext.lineWidth = 2;
// for (let i = 0; i < config.barCount; i++) {
// const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
// const amplitude = (dataArray[dataIndex] * config.sensitivity) / 255;
// const angle = (i / config.barCount) * Math.PI * 2;
// const startX = centerX + Math.cos(angle) * radius * 0.7;
// const startY = centerY + Math.sin(angle) * radius * 0.7;
// const endX = centerX + Math.cos(angle) * radius * (0.7 + amplitude * 0.3);
// const endY = centerY + Math.sin(angle) * radius * (0.7 + amplitude * 0.3);
// canvasContext.beginPath();
// canvasContext.moveTo(startX, startY);
// canvasContext.lineTo(endX, endY);
// canvasContext.stroke();
// }
// };
const updateAudioVisualizer = (): void => {
if (analyser) {
analyser.fftSize = 512;
analyser.smoothingTimeConstant = config.smoothing;
dataArray = new Uint8Array(analyser.frequencyBinCount);
}
for (const slot of [navSlot, npSlot]) {
if (slot.canvas) {
slot.canvas.width = config.width;
slot.canvas.height = config.height;
slot.canvas.style.width = `${config.width}px`;
slot.canvas.style.height = `${config.height}px`;
}
}
removeVisualizerUI();
createVisualizerUI();
const syncChromelessClass = (): void => {
document.body.classList.toggle("av-chromeless", !!settings.transparentContainers);
};
// Make updateAudioVisualizer available globally for settings
(window as any).updateAudioVisualizer = updateAudioVisualizer;
syncChromelessClass();
for (const key of ALL_SLOT_KEYS) {
lastSlotTypes.set(key, getSlot(key));
lastMiniState.set(key, isMiniSlot(key));
}
const animate = (): void => {
for (const group of groups.values()) {
let changed = false;
for (let i = 0; i < group.keys.length; i++) {
const key = group.keys[i];
const currentType = getSlot(key);
const lastType = lastSlotTypes.get(key) ?? "none";
const mini = isMiniSlot(key);
const wasMini = lastMiniState.get(key) ?? false;
if (currentType !== lastType) {
switchVisualizer(group.slots[i], currentType, key);
lastSlotTypes.set(key, currentType);
lastMiniState.set(key, mini);
changed = true;
} else if (mini !== wasMini && currentType !== "none") {
const dims = getSlotDims(currentType, key);
applySlotSize(group.slots[i], dims);
lastMiniState.set(key, mini);
changed = true;
}
}
if (changed) updateGroupVisibility(group);
}
const grouped = settings.groupedSlots;
if (grouped !== lastGrouped) {
for (const group of groups.values()) updateGroupVisibility(group);
lastGrouped = grouped;
}
const chromeless = !!settings.transparentContainers;
if (chromeless !== lastChromeless) {
syncChromelessClass();
lastChromeless = chromeless;
}
const currentReactivity = settings.reactivity ?? 30;
if (currentReactivity !== lastReactivity) {
audio.setSmoothing(reactivityToSmoothing(currentReactivity));
lastReactivity = currentReactivity;
}
waveTime += 0.05;
const data = audio.sample();
const hasSignal = data && audio.hasSignal(data);
// idleMode: 0 = animated, 1 = hide when idle, 2 = static when idle
const idleMode = settings.idleMode ?? 0;
const newIdleHidden = !hasSignal && idleMode === 1;
if (newIdleHidden !== idleHidden) {
idleHidden = newIdleHidden;
for (const group of groups.values()) updateGroupVisibility(group);
}
if (!idleHidden) {
let renderData: AudioData;
if (hasSignal) renderData = data as AudioData;
else if (idleMode === 2) renderData = STATIC_IDLE_DATA;
else renderData = generateIdleData();
for (const group of groups.values()) {
for (const slot of group.slots) {
if (!slot.canvas || slot.currentType === "none" || !slot.visualizer) continue;
slot.visualizer.render(renderData, settings.barColor);
}
}
}
animationId = requestAnimationFrame(animate);
};
// Init
log("Initializing...");
audio.setFFTSize(fft());
audio.setSmoothing(smooth());
audio.init();
audio.scan();
animationId = requestAnimationFrame(animate);
// Cleanup
unloads.add(() => {
log("Plugin unloading");
document.body.classList.remove("av-chromeless");
if (navArrowsEl) {
navArrowsEl.style.marginRight = "";
navArrowsEl = null;
}
// Clean up function
const cleanupAudioVisualizer = (): void => {
// stop animation and hide UI - don't touch audio connections (otherwise it will reconnect)
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
removeVisualizerUI();
// i was killing audio connections - But it was reconnecting and being a pain
// so i just left it alone - it works fine
};
// Initialize when DOM is ready and track is playing
const observePlayState = (): void => {
let hasTriedInitialization = false;
let checkCount = 0;
const checkAndInitialize = () => {
checkCount++;
// Only try to initialize once when music starts playing
if (PlayState.playing && !hasTriedInitialization) {
hasTriedInitialization = true;
log("Initializing audio visualizer...");
// Initialize immediately - no delay (after audio starts playing ofc)
initializeAudioVisualizer().then(() => {
if (audioContext && analyser) {
log("Audio visualizer ready!");
} else {
hasTriedInitialization = false; // Allow retry if failed
for (const group of groups.values()) {
for (const slot of group.slots) {
slot.visualizer?.dispose();
}
group.groupContainer.remove();
}
groups.clear();
audio.dispose();
});
} else if (!PlayState.playing && hasTriedInitialization) {
// Reset try flag when music stops so it can try again next time (otherwise it explode)
hasTriedInitialization = false;
}
// Keep animation running regardless of play state
if (!animationId) {
animate();
}
};
// Start with fast checking, then slow down
const fastInterval = setInterval(() => {
checkAndInitialize();
if (checkCount > 10) {
// After 10 quick checks, switch to slower
clearInterval(fastInterval);
const slowInterval = setInterval(checkAndInitialize, 2000);
unloads.add(() => clearInterval(slowInterval));
}
}, 200); // Check every 200ms initially
unloads.add(() => clearInterval(fastInterval));
// Immediate first check
checkAndInitialize();
};
// Initialize the plugin
const initialize = (): void => {
log("Audio Visualizer plugin initializing...");
// Start immediately - DOM should be ready by plugin load
setTimeout(() => {
log("Starting visualizer...");
// Create UI immediately so wave effect shows
createVisualizerUI();
// Start animation loop immediately
animate();
// Also observe play state for audio detection
observePlayState();
}, 100); // Minimal delay to ensure DOM is ready
};
// Complete cleanup function for plugin unload
const completeCleanup = (): void => {
log("Complete cleanup - plugin unloading");
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
removeVisualizerUI();
// Fully disconnect and reset everything
if (audioSource) {
try {
audioSource.disconnect();
log("Disconnected audio source completely");
} catch (e) {
log("Audio source already disconnected");
}
}
// Close audio context completely on plugin unload
if (audioContext && audioContext.state !== "closed") {
audioContext.close();
log("Closed AudioContext");
}
// Reset all references
audioContext = null;
analyser = null;
audioSource = null;
dataArray = null;
currentAudioElement = null;
isSourceConnected = false;
};
// Register cleanup
unloads.add(completeCleanup);
// Start initialization
initialize();
+102 -19
View File
@@ -1,37 +1,31 @@
/* Audio Visualizer CSS */
.audio-visualizer-container {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 4px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: all 0.3s ease-in-out;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 105, 180, 0.15);
animation: av-fadeIn 0.5s ease-out;
}
.audio-visualizer-container:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
box-shadow: 0 4px 12px rgba(255, 105, 180, 0.2);
border-color: rgba(255, 105, 180, 0.3);
}
.audio-visualizer-container canvas {
display: block;
transition: all 0.3s ease-in-out;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.audio-visualizer-container {
margin: 4px;
padding: 2px;
}
.audio-visualizer-container canvas {
max-width: 150px;
max-height: 30px;
}
border-radius: 4px;
}
.audio-visualizer-container.active {
box-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
box-shadow: 0 0 20px rgba(255, 105, 180, 0.3);
}
@keyframes av-fadeIn {
@@ -48,3 +42,92 @@
[data-type="search-field"] {
min-width: 220px !important;
}
/* Slot group layout */
.av-slot-group {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Left/Right group spacing */
.av-slot-group[data-position="left"] {
margin-right: 12px;
}
.av-slot-group[data-position="right"] {
margin-left: 12px;
}
/* Player Bar: LEFT inside trackInfo, RIGHT inside utilityContainer */
.av-slot-group[data-zone="playerBar"][data-position="left"] {
margin-left: 8px;
}
.av-slot-group[data-zone="playerBar"][data-position="right"] {
margin-right: 8px;
}
/* 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);
}
@@ -0,0 +1,201 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { hexToRGB } from "../webgl";
const GATE_ABSOLUTE = -70;
const GATE_RELATIVE_OFFSET = -10;
const GAINS = [1.0, 1.0];
interface LUFSState {
momentaryBlocks: number[];
shortTermBlocks: number[];
integratedPowers: number[];
momentary: number;
shortTerm: number;
integrated: number;
blockBuffer: Float32Array[];
blockPos: number;
blockSize: number;
hopSize: number;
hopPos: number;
displayMomentary: number;
displayShortTerm: number;
displayIntegrated: number;
}
const createLUFSState = (sampleRate: number): LUFSState => {
const blockSize = Math.floor(sampleRate * 0.4);
const hopSize = Math.floor(sampleRate * 0.1);
return {
momentaryBlocks: [],
shortTermBlocks: [],
integratedPowers: [],
momentary: -Infinity,
shortTerm: -Infinity,
integrated: -Infinity,
blockBuffer: [new Float32Array(blockSize), new Float32Array(blockSize)],
blockPos: 0,
blockSize,
hopSize,
hopPos: 0,
displayMomentary: -60,
displayShortTerm: -60,
displayIntegrated: -60,
};
};
const computeBlockLoudness = (left: Float32Array, right: Float32Array, len: number): number => {
let sumL = 0, sumR = 0;
for (let i = 0; i < len; i++) {
sumL += left[i] * left[i];
sumR += right[i] * right[i];
}
const powerL = sumL / len;
const powerR = sumR / len;
const weighted = GAINS[0] * powerL + GAINS[1] * powerR;
if (weighted <= 0) return -Infinity;
return -0.691 + 10 * Math.log10(weighted);
};
const computeGatedIntegrated = (powers: number[]): number => {
if (powers.length === 0) return -Infinity;
const aboveAbsolute = powers.filter(p => p > GATE_ABSOLUTE);
if (aboveAbsolute.length === 0) return -Infinity;
const meanAbsolute = aboveAbsolute.reduce((s, v) => s + Math.pow(10, v / 10), 0) / aboveAbsolute.length;
const relativeThreshold = 10 * Math.log10(meanAbsolute) + GATE_RELATIVE_OFFSET;
const aboveRelative = aboveAbsolute.filter(p => p > relativeThreshold);
if (aboveRelative.length === 0) return -Infinity;
const meanRelative = aboveRelative.reduce((s, v) => s + Math.pow(10, v / 10), 0) / aboveRelative.length;
return 10 * Math.log10(meanRelative);
};
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
export const createLoudnessMeter = (): Visualizer => {
let ctx: CanvasRenderingContext2D | null = null;
let w = 0, h = 0;
let state: LUFSState | null = null;
let lastSampleRate = 0;
const SMOOTHING_FAST = 0.25;
const SMOOTHING_SLOW = 0.08;
return {
name: "Loudness (LUFS)",
id: "loudness-meter",
init(canvas, _color) {
ctx = canvas.getContext("2d")!;
w = canvas.width;
h = canvas.height;
state = null;
lastSampleRate = 0;
},
render(data: AudioData, color: string) {
if (!ctx) return;
if (!state || data.sampleRate !== lastSampleRate) {
state = createLUFSState(data.sampleRate);
lastSampleRate = data.sampleRate;
}
const left = data.leftTimeDomain;
const right = data.rightTimeDomain;
const len = Math.min(left.length, right.length);
for (let i = 0; i < len; i++) {
state.blockBuffer[0][state.blockPos] = left[i];
state.blockBuffer[1][state.blockPos] = right[i];
state.blockPos++;
state.hopPos++;
if (state.blockPos >= state.blockSize) {
const loudness = computeBlockLoudness(state.blockBuffer[0], state.blockBuffer[1], state.blockSize);
state.momentaryBlocks.push(loudness);
if (state.momentaryBlocks.length > 4) state.momentaryBlocks.shift();
state.momentary = Math.max(...state.momentaryBlocks);
state.shortTermBlocks.push(loudness);
if (state.shortTermBlocks.length > 30) state.shortTermBlocks.shift();
const stPowers = state.shortTermBlocks.filter(v => v > -Infinity);
if (stPowers.length > 0) {
const stMean = stPowers.reduce((s, v) => s + Math.pow(10, v / 10), 0) / stPowers.length;
state.shortTerm = 10 * Math.log10(stMean);
}
state.integratedPowers.push(loudness);
if (state.integratedPowers.length > 3000) state.integratedPowers.shift();
state.integrated = computeGatedIntegrated(state.integratedPowers);
const keep = state.blockSize - state.hopSize;
state.blockBuffer[0].copyWithin(0, state.hopSize);
state.blockBuffer[1].copyWithin(0, state.hopSize);
state.blockPos = keep;
state.hopPos = 0;
}
}
const clamp = (v: number) => (v === -Infinity ? -60 : Math.max(-60, Math.min(0, v)));
state.displayMomentary = lerp(state.displayMomentary, clamp(state.momentary), SMOOTHING_FAST);
state.displayShortTerm = lerp(state.displayShortTerm, clamp(state.shortTerm), SMOOTHING_FAST);
state.displayIntegrated = lerp(state.displayIntegrated, clamp(state.integrated), SMOOTHING_SLOW);
ctx.clearRect(0, 0, w, h);
const [cr, cg, cb] = hexToRGB(color);
const minLUFS = -60;
const maxLUFS = 0;
const range = maxLUFS - minLUFS;
const norm = (v: number) => Math.max(0, Math.min(1, (v - minLUFS) / range));
const labels = ["M", "S", "I"];
const rawValues = [state.momentary, state.shortTerm, state.integrated];
const displayValues = [state.displayMomentary, state.displayShortTerm, state.displayIntegrated];
const barH = (h - 4) / 3;
const labelW = 12;
const valueW = 36;
const barX = labelW;
const barW = w - labelW - valueW;
ctx.font = `bold ${Math.min(9, barH - 1)}px monospace`;
ctx.textBaseline = "middle";
for (let i = 0; i < 3; i++) {
const y = 1 + i * (barH + 1);
const n = norm(displayValues[i]);
ctx.fillStyle = color;
ctx.textAlign = "left";
ctx.fillText(labels[i], 1, y + barH / 2);
ctx.fillStyle = `rgba(${cr * 255}, ${cg * 255}, ${cb * 255}, 0.15)`;
ctx.fillRect(barX, y, barW, barH);
ctx.fillStyle = `rgba(${cr * 255}, ${cg * 255}, ${cb * 255}, 0.7)`;
ctx.fillRect(barX, y, barW * n, barH);
ctx.fillStyle = "rgba(255,255,255,0.8)";
ctx.textAlign = "right";
const raw = rawValues[i];
const txt = raw > -Infinity ? raw.toFixed(1) : "-inf";
ctx.fillText(txt, w - 1, y + barH / 2);
}
},
resize(width, height) {
w = width;
h = height;
},
dispose() {
ctx = null;
state = null;
lastSampleRate = 0;
},
};
};
@@ -0,0 +1,96 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { settings } from "../Settings";
export const createOscilloscope = (): Visualizer => {
let ctx: CanvasRenderingContext2D | null = null;
let w = 0, h = 0;
let scrollBuffer: Float32Array | null = null;
let scrollPos = 0;
const ensureScrollBuffer = () => {
if (!scrollBuffer || scrollBuffer.length !== w) {
scrollBuffer = new Float32Array(w);
scrollPos = 0;
}
};
return {
name: "Oscilloscope",
id: "oscilloscope",
init(canvas, _color) {
ctx = canvas.getContext("2d")!;
w = canvas.width;
h = canvas.height;
scrollBuffer = null;
scrollPos = 0;
},
render(data: AudioData, color: string) {
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
const lineWidth = settings.lineThickness ?? 1.5;
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
ctx.lineJoin = "round";
ctx.lineCap = "round";
if (settings.scrollingOscilloscope) {
ensureScrollBuffer();
if (!scrollBuffer) return;
const timeDomain = data.floatTimeDomain;
const samplesPerPixel = Math.max(1, Math.floor(timeDomain.length / w));
const pixelsToAdd = Math.max(1, Math.ceil(timeDomain.length / samplesPerPixel));
for (let p = 0; p < pixelsToAdd; p++) {
const sampleIdx = Math.floor(p * samplesPerPixel);
let peak = 0;
for (let s = sampleIdx; s < Math.min(sampleIdx + samplesPerPixel, timeDomain.length); s++) {
if (Math.abs(timeDomain[s]) > Math.abs(peak)) peak = timeDomain[s];
}
scrollBuffer[scrollPos % w] = peak;
scrollPos++;
}
ctx.beginPath();
for (let x = 0; x < w; x++) {
const idx = (scrollPos - w + x + w * 2) % w;
const sample = scrollBuffer[idx];
const y = (1 - sample) * h / 2;
if (x === 0) ctx.moveTo(0, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
} else {
const buffer = data.byteTimeDomain;
const len = buffer.length;
const segmentWidth = w / len;
ctx.beginPath();
for (let i = 0; i < len; i++) {
const v = buffer[i] / 128.0;
const y = (v * h) / 2;
if (i === 0) ctx.moveTo(0, y);
else ctx.lineTo(i * segmentWidth, y);
}
ctx.stroke();
}
},
resize(width, height) {
w = width;
h = height;
scrollBuffer = null;
scrollPos = 0;
},
dispose() {
ctx = null;
scrollBuffer = null;
scrollPos = 0;
},
};
};
@@ -0,0 +1,136 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { createProgram, drawQuad, setUniform1f, setUniform1fv, setUniform2f, setUniform3f, hexToRGB } from "../webgl";
import { settings } from "../Settings";
const MAX_BARS = 128;
const FRAG = `#version 300 es
precision highp float;
uniform vec2 u_resolution;
uniform float u_amplitudes[${MAX_BARS}];
uniform int u_bar_count;
uniform vec3 u_color;
uniform float u_gap;
uniform float u_gain;
uniform float u_rounding;
out vec4 fragColor;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float cellFloat = uv.x * float(u_bar_count);
int barIdx = clamp(int(cellFloat), 0, u_bar_count - 1);
float cellPos = fract(cellFloat);
float amp = clamp(u_amplitudes[barIdx] * u_gain, 0.0, 1.0);
if (amp < 0.005) {
fragColor = vec4(0.0);
return;
}
// Bar shape with anti-aliased edges and configurable gap
float barMask = smoothstep(0.0, u_gap, cellPos)
* smoothstep(0.0, u_gap, 1.0 - cellPos);
// Hard cut at bottom, soft feather only at the top edge
float feather = 1.5 / u_resolution.y;
float heightMask = 1.0 - smoothstep(amp - feather, amp + feather, uv.y);
float a = barMask * heightMask;
// Rounded top corners in pixel space
if (u_rounding > 0.5 && a > 0.0) {
float cellPx = u_resolution.x / float(u_bar_count);
float barPx = cellPx * (1.0 - 2.0 * u_gap);
float fromLeft = (cellPos - u_gap) * cellPx;
float fromRight = barPx - fromLeft;
float fromTop = (amp - uv.y) * u_resolution.y;
float r = clamp(barPx * 0.3, 1.0, 3.0);
float edgeX = min(fromLeft, fromRight);
if (edgeX < r && fromTop < r && fromTop >= 0.0) {
float d = length(vec2(r - edgeX, r - fromTop)) - r;
a *= 1.0 - smoothstep(-0.5, 0.5, d);
}
}
fragColor = vec4(u_color * a, a);
}
`;
const amplitudes = new Float32Array(MAX_BARS);
export const createSpectrumBars = (): Visualizer => {
let gl: WebGL2RenderingContext | null = null;
let program: WebGLProgram | null = null;
let w = 0, h = 0;
return {
name: "Spectrum (Bars)",
id: "spectrum-bars",
init(canvas, _color) {
gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true })!;
if (!gl) throw new Error("WebGL2 not available");
program = createProgram(gl, FRAG);
w = canvas.width;
h = canvas.height;
gl.viewport(0, 0, w, h);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
},
render(data: AudioData, color: string) {
if (!gl || !program) return;
const barCount = Math.min(settings.barCount ?? 64, MAX_BARS);
const gain = settings.gain ?? 1.5;
// Use byteFrequency (0-255 normalized across full analyser range)
const binStep = data.byteFrequency.length / barCount;
for (let i = 0; i < barCount; i++) {
let maxVal = 0;
const start = Math.floor(i * binStep);
const end = Math.floor((i + 1) * binStep);
for (let j = start; j < end; j++) {
if (data.byteFrequency[j] > maxVal) maxVal = data.byteFrequency[j];
}
amplitudes[i] = Math.min(1, (maxVal / 255) * gain);
}
for (let i = barCount; i < MAX_BARS; i++) amplitudes[i] = 0;
gl.viewport(0, 0, w, h);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
setUniform2f(gl, program, "u_resolution", w, h);
setUniform1fv(gl, program, "u_amplitudes", amplitudes);
const loc = gl.getUniformLocation(program, "u_bar_count");
gl.uniform1i(loc, barCount);
const [r, g, b] = hexToRGB(color);
setUniform3f(gl, program, "u_color", r, g, b);
const cellPx = w / barCount;
const gap = Math.min(0.15, 1.5 / cellPx);
setUniform1f(gl, program, "u_gap", gap);
setUniform1f(gl, program, "u_gain", 1.0);
setUniform1f(gl, program, "u_rounding", settings.barRounding ? 1.0 : 0.0);
drawQuad(gl, program);
},
resize(width, height) {
w = width;
h = height;
if (gl) gl.viewport(0, 0, w, h);
},
dispose() {
if (gl && program) gl.deleteProgram(program);
program = null;
gl = null;
},
};
};
@@ -0,0 +1,105 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { createProgram, drawQuad, setUniform1f, setUniform1fv, setUniform2f, setUniform3f, hexToRGB } from "../webgl";
import { settings } from "../Settings";
const BIN_COUNT = 256;
const FRAG = `#version 300 es
precision highp float;
uniform vec2 u_resolution;
uniform float u_amplitudes[${BIN_COUNT}];
uniform vec3 u_color;
uniform float u_fill_opacity;
uniform float u_line_thickness;
uniform float u_opacity_falloff;
out vec4 fragColor;
float interpolate(float a, float b, float t) {
return (1.0 - t) * a + t * b;
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
int idx = int(uv.x * float(${BIN_COUNT}));
int idxL = int((uv.x - 1.0 / u_resolution.x) * float(${BIN_COUNT}));
int idxR = int((uv.x + 1.0 / u_resolution.x) * float(${BIN_COUNT}));
idx = clamp(idx, 0, ${BIN_COUNT - 1});
idxL = clamp(idxL, 0, ${BIN_COUNT - 1});
idxR = clamp(idxR, 0, ${BIN_COUNT - 1});
float amplitude = u_amplitudes[idx];
float left = u_amplitudes[idxL];
float right = u_amplitudes[idxR];
float lowest = min(left, right);
float dist = (amplitude - uv.y) * u_resolution.y;
float a = 0.0;
a += float(abs(dist) <= u_resolution.x * 0.005 * u_line_thickness || (uv.y >= lowest && uv.y <= amplitude)) * clamp(sign(dist), 0.0, 1.0);
a += clamp(sign(amplitude - uv.y), 0.0, 1.0) * interpolate(1.0, u_fill_opacity, pow(1.0 - uv.y, 1.0 - u_opacity_falloff));
a = clamp(a, 0.0, 1.0);
fragColor = vec4(u_color * a, a);
}
`;
const amplitudes = new Float32Array(BIN_COUNT);
export const createSpectrumLine = (): Visualizer => {
let gl: WebGL2RenderingContext | null = null;
let program: WebGLProgram | null = null;
let w = 0, h = 0;
return {
name: "Spectrum (Line)",
id: "spectrum-line",
init(canvas, _color) {
gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true })!;
if (!gl) throw new Error("WebGL2 not available");
program = createProgram(gl, FRAG);
w = canvas.width;
h = canvas.height;
gl.viewport(0, 0, w, h);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
},
render(data: AudioData, color: string) {
if (!gl || !program) return;
const gain = settings.gain ?? 1.5;
const binStep = data.byteFrequency.length / BIN_COUNT;
for (let i = 0; i < BIN_COUNT; i++) {
amplitudes[i] = Math.min(1, (data.byteFrequency[Math.floor(i * binStep)] / 255) * gain);
}
gl.viewport(0, 0, w, h);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
setUniform2f(gl, program, "u_resolution", w, h);
setUniform1fv(gl, program, "u_amplitudes", amplitudes);
const [r, g, b] = hexToRGB(color);
setUniform3f(gl, program, "u_color", r, g, b);
setUniform1f(gl, program, "u_fill_opacity", settings.fillOpacity ?? 0.3);
setUniform1f(gl, program, "u_line_thickness", settings.lineThickness ?? 1.5);
setUniform1f(gl, program, "u_opacity_falloff", settings.opacityFalloff ?? 0.5);
drawQuad(gl, program);
},
resize(width, height) {
w = width;
h = height;
if (gl) gl.viewport(0, 0, w, h);
},
dispose() {
if (gl && program) gl.deleteProgram(program);
program = null;
gl = null;
},
};
};
@@ -0,0 +1,88 @@
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 },
};
@@ -0,0 +1,86 @@
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;
},
};
};
+151
View File
@@ -0,0 +1,151 @@
const VERTEX_SHADER = `#version 300 es
in vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
export const compileShader = (gl: WebGL2RenderingContext, type: number, source: string): WebGLShader => {
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`Shader compile error: ${info}`);
}
return shader;
};
export const createProgram = (gl: WebGL2RenderingContext, fragSource: string): WebGLProgram => {
const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource);
const program = gl.createProgram()!;
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const info = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`Program link error: ${info}`);
}
gl.deleteShader(vert);
gl.deleteShader(frag);
return program;
};
interface QuadResources {
vao: WebGLVertexArrayObject;
vbo: WebGLBuffer;
}
const quadMap = new WeakMap<WebGL2RenderingContext, QuadResources>();
const ensureQuad = (gl: WebGL2RenderingContext, program: WebGLProgram): QuadResources => {
let res = quadMap.get(gl);
if (res) return res;
const verts = new Float32Array([-1, -1, 3, -1, -1, 3]);
const vao = gl.createVertexArray()!;
const vbo = gl.createBuffer()!;
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
const loc = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
res = { vao, vbo };
quadMap.set(gl, res);
return res;
};
export const drawQuad = (gl: WebGL2RenderingContext, program: WebGLProgram): void => {
const res = ensureQuad(gl, program);
gl.useProgram(program);
gl.bindVertexArray(res.vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
export interface PingPongBuffers {
fbos: [WebGLFramebuffer, WebGLFramebuffer];
textures: [WebGLTexture, WebGLTexture];
current: 0 | 1;
}
const createFBOTexture = (gl: WebGL2RenderingContext, w: number, h: number): { fbo: WebGLFramebuffer; texture: WebGLTexture } => {
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const fbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.bindTexture(gl.TEXTURE_2D, null);
return { fbo, texture: tex };
};
export const createPingPong = (gl: WebGL2RenderingContext, w: number, h: number): PingPongBuffers => {
const a = createFBOTexture(gl, w, h);
const b = createFBOTexture(gl, w, h);
return {
fbos: [a.fbo, b.fbo],
textures: [a.texture, b.texture],
current: 0,
};
};
export const resizePingPong = (gl: WebGL2RenderingContext, pp: PingPongBuffers, w: number, h: number): void => {
for (const tex of pp.textures) {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
}
gl.bindTexture(gl.TEXTURE_2D, null);
};
export const setUniform1f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: number): void => {
gl.uniform1f(gl.getUniformLocation(program, name), v);
};
export const setUniform2f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, x: number, y: number): void => {
gl.uniform2f(gl.getUniformLocation(program, name), x, y);
};
export const setUniform3f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, x: number, y: number, z: number): void => {
gl.uniform3f(gl.getUniformLocation(program, name), x, y, z);
};
export const setUniform1fv = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: Float32Array): void => {
gl.uniform1fv(gl.getUniformLocation(program, name), v);
};
export const setUniform1i = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: number): void => {
gl.uniform1i(gl.getUniformLocation(program, name), v);
};
export const disposeQuad = (gl: WebGL2RenderingContext): void => {
const res = quadMap.get(gl);
if (res) {
gl.deleteVertexArray(res.vao);
gl.deleteBuffer(res.vbo);
quadMap.delete(gl);
}
};
export const disposePingPong = (gl: WebGL2RenderingContext, pp: PingPongBuffers): void => {
for (const fbo of pp.fbos) gl.deleteFramebuffer(fbo);
for (const tex of pp.textures) gl.deleteTexture(tex);
};
export const hexToRGB = (hex: string): [number, number, number] => {
const c = hex.replace("#", "");
const r = parseInt(c.substring(0, 2), 16) / 255;
const g = parseInt(c.substring(2, 4), 16) / 255;
const b = parseInt(c.substring(4, 6), 16) / 255;
return [r, g, b];
};
+136 -77
View File
@@ -15,7 +15,11 @@ new StyleTag("Copy-Lyrics", unloads, unlockSelection);
function SetClipboard(text: string): void {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed"; // Avoid scrolling to bottom
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
@@ -29,101 +33,152 @@ function SetClipboard(text: string): void {
}
}
let isSelecting = false;
const LINE_SELECTORS = [
".rl-wbw-container .rl-wbw-line",
'[data-test="now-playing-lyrics"] span[data-test="lyrics-line"]',
'[class*="_lyricsText"] > div > span',
].join(",");
const onMouseDown = (): void => {
isSelecting = true;
const OVERLAY_LINE_SELECTOR = ".rl-wbw-container .rl-wbw-line";
const LYRICS_ROOT_SELECTOR = [
'[data-test="now-playing-lyrics"]',
'[class*="_lyricsText"]',
".rl-wbw-container",
].join(",");
let isPointerDownInLyrics = false;
let suppressNextClick = false;
let suppressClickResetTimer: number | null = null;
const isElement = (node: Node | null): node is Element =>
Boolean(node && node.nodeType === Node.ELEMENT_NODE);
const getElementFromNode = (node: Node | null): Element | null => {
if (!node) return null;
return isElement(node) ? node : node.parentElement;
};
const isInLyrics = (node: Node | null): boolean =>
Boolean(getElementFromNode(node)?.closest(LYRICS_ROOT_SELECTOR));
const rangeIntersectsNode = (range: Range, node: Node): boolean => {
try {
return range.intersectsNode(node);
} catch {
return false;
}
};
const normalizeLineText = (text: string): string =>
text
.replace(/\u00a0/g, " ")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n[ \t]+/g, "\n")
.replace(/[ \t]{2,}/g, " ")
.trim();
const getTextInsideRange = (line: HTMLElement, range: Range): string => {
if (
!line.contains(range.startContainer) &&
!line.contains(range.endContainer)
) {
return normalizeLineText(line.textContent ?? "");
}
const selected = document.createRange();
selected.selectNodeContents(line);
if (line.contains(range.startContainer)) {
selected.setStart(range.startContainer, range.startOffset);
}
if (line.contains(range.endContainer)) {
selected.setEnd(range.endContainer, range.endOffset);
}
return normalizeLineText(selected.toString());
};
const getSelectedLines = (range: Range, selector: string): HTMLElement[] =>
Array.from(document.querySelectorAll(selector)).filter(
(node): node is HTMLElement =>
node instanceof HTMLElement && rangeIntersectsNode(range, node),
);
const getLyricsTextFromRange = (range: Range): string => {
const overlayLines = getSelectedLines(range, OVERLAY_LINE_SELECTOR);
const lines =
overlayLines.length > 0
? overlayLines
: getSelectedLines(range, LINE_SELECTORS);
if (lines.length === 0) {
return isInLyrics(range.commonAncestorContainer)
? normalizeLineText(range.toString())
: "";
}
return lines
.map((line) =>
line.classList.contains("rl-wbw-spacer")
? ""
: getTextInsideRange(line, range),
)
.join("\n")
.trim();
};
const getSelectedLyricsText = (selection: Selection): string => {
const chunks: string[] = [];
for (let i = 0; i < selection.rangeCount; i++) {
const text = getLyricsTextFromRange(selection.getRangeAt(i));
if (text.length > 0) chunks.push(text);
}
return chunks.join("\n").trim();
};
const suppressUpcomingClick = (): void => {
suppressNextClick = true;
if (suppressClickResetTimer !== null) {
window.clearTimeout(suppressClickResetTimer);
}
suppressClickResetTimer = window.setTimeout(() => {
suppressNextClick = false;
suppressClickResetTimer = null;
}, 250);
};
const onMouseDown = (event: MouseEvent): void => {
isPointerDownInLyrics = isInLyrics(event.target as Node | null);
};
const onMouseUp = (): void => {
if (isSelecting) {
if (!isPointerDownInLyrics) return;
const selection = window.getSelection();
if (selection?.toString().length > 0) {
const selectedSpans: HTMLSpanElement[] = [];
const range = selection.getRangeAt(0);
let container: Node | null = range.commonAncestorContainer;
// Normalize container: if it's a text node, use its parent element/node
if (container && container.nodeType === Node.TEXT_NODE) {
container = (container.parentElement ?? container.parentNode) as Node | null;
}
// If parent has data-current, treat as single-line copy case
if (
container &&
container.nodeType === Node.ELEMENT_NODE &&
(container as Element).hasAttribute("data-current")
) {
const text_ = selection.toString().trim();
SetClipboard(text_);
trace.msg.log("Copied to clipboard!");
return;
}
// Ensure we have an Element or Document before querying
if (
!container ||
(container.nodeType !== Node.ELEMENT_NODE &&
container.nodeType !== Node.DOCUMENT_NODE)
) {
isSelecting = false;
return;
}
// Get all the spans inside the container.
const spans = (container as Element | Document).getElementsByTagName(
"span",
);
for (const span of spans) {
if (selection.containsNode(span, true)) {
selectedSpans.push(span as HTMLSpanElement);
}
}
// Concat the text of the selected spans.
let hasCorrectAttribute = false;
let text = "";
selectedSpans.forEach((span) => {
if (span.hasAttribute("data-current")) {
hasCorrectAttribute = true;
text += span.textContent + "\n";
if (
[...span.classList].some((className) =>
className.startsWith("endOfStanza--"),
)
) {
text += "\n";
}
}
});
text = text.trim();
if (hasCorrectAttribute) {
if (selection?.toString().trim()) {
const text = getSelectedLyricsText(selection);
if (text.length > 0) {
SetClipboard(text);
trace.msg.log("Copied to clipboard!");
selection.removeAllRanges();
suppressUpcomingClick();
}
}
isSelecting = false;
}
isPointerDownInLyrics = false;
};
const onClickHooked = (event: MouseEvent): boolean | undefined => {
if (!isSelecting) return;
if (!suppressNextClick) return;
const target = event.target as HTMLElement;
if (
target.tagName.toLowerCase() === "span" &&
target.hasAttribute("data-current")
) {
// Prevent default behavior and stop event propagation
suppressNextClick = false;
if (suppressClickResetTimer !== null) {
window.clearTimeout(suppressClickResetTimer);
suppressClickResetTimer = null;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
return false;
}
return undefined;
};
// Add event listener with capture phase to intercept events before they reach other handlers
@@ -140,4 +195,8 @@ unloads.add((): void => {
document.removeEventListener("click", onClickHooked, true);
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("mouseup", onMouseUp);
if (suppressClickResetTimer !== null) {
window.clearTimeout(suppressClickResetTimer);
suppressClickResetTimer = null;
}
});
+8 -1
View File
@@ -1,4 +1,11 @@
[class^="_lyricsText"] > div > span {
[data-test="now-playing-lyrics"],
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"],
[class*="_lyricsText"] > div > span,
.rl-wbw-container,
.rl-wbw-line,
.rl-wbw-word,
.rl-wbw-main,
.rl-wbw-bg-container {
user-select: text;
cursor: text;
}
+14 -11
View File
@@ -19,7 +19,13 @@ new StyleTag("Element-Hider", unloads, styles);
// State management
let targetElement: HTMLElement | null = null;
let hiddenElements = new WeakSet<HTMLElement>();
let hiddenElementsArray: HTMLElement[] = [];
// Count of elements currently hidden in the live DOM. The `.element-hider-hidden`
// class is the source of truth — querying it avoids retaining detached nodes
// across SPA navigations.
function getHiddenCount(): number {
return document.querySelectorAll(".element-hider-hidden").length;
}
// MutationObserver for reactive element detection
let elementObserver: MutationObserver | null = null;
@@ -179,7 +185,6 @@ function hideElementDirectly(element: HTMLElement): void {
element.classList.add("element-hider-hidden");
hiddenElements.add(element);
hiddenElementsArray.push(element);
trace.log(
`Hidden element: ${element.tagName}${element.className ? "." + element.className.split(" ")[0] : ""}`,
);
@@ -210,7 +215,6 @@ function hideTargetElement(): void {
"element-hider-target",
);
hiddenElements.add(elementToHide);
hiddenElementsArray.push(elementToHide);
}, 300);
// Clear target reference
@@ -220,20 +224,19 @@ function hideTargetElement(): void {
// Unhide all elements permanently (remove from storage)
function unhideAllElements(): void {
trace.log(
`Permanently unhiding ${settings.hiddenElements.length} saved elements`,
`Permanently unhiding ${settings.hiddenElements.length} saved selectors`,
);
// Show all currently hidden elements
hiddenElementsArray.forEach((element) => {
if (document.body.contains(element)) {
document
.querySelectorAll(".element-hider-hidden, .element-hider-hiding")
.forEach((element) => {
element.classList.remove("element-hider-hidden", "element-hider-hiding");
}
});
// Clear both storage and runtime collections
settings.hiddenElements = [];
hiddenElements = new WeakSet<HTMLElement>();
hiddenElementsArray = [];
}
// Process all elements in the document to hide matching ones (with strict matching)
@@ -334,7 +337,7 @@ window.showAllElementsFromSettings = unhideAllElements;
window.debugElementHider = () => {
trace.log(`=== Element Hider Debug Info ===`);
trace.log(`Stored elements: ${settings.hiddenElements.length}`);
trace.log(`Currently hidden elements: ${hiddenElementsArray.length}`);
trace.log(`Currently hidden elements: ${getHiddenCount()}`);
trace.log(`Reactive hiding enabled: true`);
settings.hiddenElements.forEach((element, index) => {
trace.log(`${index + 1}. ${element.selector} (${element.tagName})`);
@@ -472,7 +475,7 @@ function createCustomMenu(): HTMLElement {
// Unhide All Elements option
const unhideAllItem = document.createElement("button");
unhideAllItem.className = "element-hider-menu-item";
unhideAllItem.innerHTML = `Unhide All Elements (${hiddenElementsArray.length})`;
unhideAllItem.innerHTML = `Unhide All Elements (${getHiddenCount()})`;
unhideAllItem.addEventListener("click", () => {
unhideAllElements();
closeCustomMenu();
@@ -593,7 +596,7 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
const unhideAllButton = document.createElement("button");
unhideAllButton.className = "element-hider-menu-item";
unhideAllButton.style.cssText = hideButton.style.cssText;
unhideAllButton.innerHTML = `Unhide All Elements (${hiddenElementsArray.length})`;
unhideAllButton.innerHTML = `Unhide All Elements (${getHiddenCount()})`;
unhideAllButton.addEventListener("click", unhideAllElements);
+160 -15
View File
@@ -12,6 +12,7 @@ declare global {
updateRadiantLyricsGlobalBackground?: () => void;
updateRadiantLyricsNowPlayingBackground?: () => void;
updateQualityProgressColor?: () => void;
updateIntegratedSeekBar?: () => void;
updateLyricsStyle?: () => void;
updateLyricsStyleSetting?: (value: number) => void;
updateRomanizeLyrics?: () => void;
@@ -21,19 +22,32 @@ declare global {
export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
lyricsGlowEnabled: true,
textGlow: 20,
lyricsStyle: 2,
lyricsFontSize: 100,
blurInactive: true,
contextAwareLyrics: true,
bubbledLyrics: true,
romanizeLyrics: false,
stickyLyrics: false,
syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon)
syllableLogging: false,
hideUIEnabled: true,
playerBarVisible: false,
qualityProgressColor: true,
integratedSeekBar: false,
floatingPlayerBar: true,
playerBarRadius: 5,
playerBarSpacing: 10,
playerBarBlur: true,
playerBarBlurAmount: 15,
playerBarTintEnabled: true,
playerBarTint: 5,
playerBarTintColor: "#000000" as string,
playerBarTintCustomColors: [] as string[],
playerBarRadius: 5,
playerBarSpacing: 10,
CoverEverywhere: true,
performanceMode: false,
spinningArt: true,
textGlow: 20,
backgroundScale: 15,
backgroundRadius: 25,
backgroundContrast: 120,
@@ -41,16 +55,6 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
backgroundBrightness: 40,
spinSpeed: 45,
settingsAffectNowPlaying: true,
stickyLyrics: false,
stickyLyricsIcon: "sparkle" as string,
lyricsStyle: 2,
syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon)
contextAwareLyrics: true,
blurInactive: true,
bubbledLyrics: true,
syllableLogging: false,
lyricsFontSize: 100,
romanizeLyrics: false,
});
export const Settings = () => {
@@ -92,12 +96,21 @@ export const Settings = () => {
const [floatingPlayerBar, setFloatingPlayerBar] = React.useState(
settings.floatingPlayerBar,
);
const [playerBarTintEnabled, setPlayerBarTintEnabled] = React.useState(
settings.playerBarTintEnabled,
);
const [playerBarTint, setPlayerBarTint] = React.useState(
settings.playerBarTint,
);
const [playerBarTintColor, setPlayerBarTintColor] = React.useState(
settings.playerBarTintColor,
);
const [playerBarBlur, setPlayerBarBlur] = React.useState(
settings.playerBarBlur,
);
const [playerBarBlurAmount, setPlayerBarBlurAmount] = React.useState(
settings.playerBarBlurAmount,
);
const [playerBarRadius, setPlayerBarRadius] = React.useState(
settings.playerBarRadius,
);
@@ -145,6 +158,9 @@ export const Settings = () => {
const [qualityProgressColor, setQualityProgressColor] = React.useState(
settings.qualityProgressColor,
);
const [integratedSeekBar, setIntegratedSeekBar] = React.useState(
settings.integratedSeekBar,
);
const [romanizeLyrics, setRomanizeLyrics] = React.useState(
settings.romanizeLyrics,
);
@@ -265,7 +281,7 @@ export const Settings = () => {
}}
/>
<AnySwitch
title="Romanize Lyrics | Beta"
title="Romanize Lyrics"
desc="Display romanized (latin) text for non-latin lyrics (e.g. Korean, Japanese, Chinese)"
checked={romanizeLyrics}
onChange={(_: unknown, checked: boolean) => {
@@ -328,6 +344,18 @@ 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
title="Floating Player Bar"
desc="When disabled, the player bar becomes a square edge-to-edge bar"
@@ -370,6 +398,31 @@ export const Settings = () => {
/>
</>
)}
<AnySwitch
title="Player Bar Blur"
desc="Enable backdrop blur effect on the player bar"
checked={playerBarBlur}
onChange={(_: unknown, checked: boolean) => {
settings.playerBarBlur = checked;
setPlayerBarBlur(checked);
window.updateRadiantLyricsPlayerBarTint?.();
}}
/>
{playerBarBlur && (
<LunaNumberSetting
title="Player Bar Blur Amount"
desc="Adjust the backdrop blur intensity (0-100, default: 15)"
min={0}
max={100}
step={1}
value={playerBarBlurAmount}
onNumber={(value: number) => {
settings.playerBarBlurAmount = value;
setPlayerBarBlurAmount(value);
window.updateRadiantLyricsPlayerBarTint?.();
}}
/>
)}
{(() => {
const closeTintColorPicker = () => {
setIsTintAnimatingIn(false);
@@ -548,12 +601,96 @@ export const Settings = () => {
Choose Tint Color
</div>
{/* Enable/Disable tint toggle */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "14px",
padding: "8px 10px",
borderRadius: "8px",
background: "rgba(255,255,255,0.06)",
}}
>
<span
style={{
color: "rgba(255,255,255,0.8)",
fontSize: "12px",
fontWeight: 600,
}}
>
Enable Player Bar Tint
</span>
<label
style={{
position: "relative",
display: "inline-block",
width: "36px",
height: "20px",
flexShrink: 0,
}}
>
<input
type="checkbox"
checked={playerBarTintEnabled}
onChange={(e) => {
const checked = e.target.checked;
settings.playerBarTintEnabled = checked;
setPlayerBarTintEnabled(checked);
window.updateRadiantLyricsPlayerBarTint?.();
}}
style={{
opacity: 0,
width: 0,
height: 0,
position: "absolute",
}}
/>
<span
style={{
position: "absolute",
cursor: "pointer",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: playerBarTintEnabled
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.15)",
transition: "0.25s",
borderRadius: "20px",
}}
>
<span
style={{
position: "absolute",
content: '""',
height: "16px",
width: "16px",
left: playerBarTintEnabled ? "18px" : "2px",
bottom: "2px",
backgroundColor: playerBarTintEnabled
? "rgb(20,20,20)"
: "rgba(255,255,255,0.5)",
transition: "0.25s",
borderRadius: "50%",
}}
/>
</span>
</label>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "8px",
marginBottom: "16px",
opacity: playerBarTintEnabled ? 1 : 0.3,
pointerEvents: playerBarTintEnabled ? "auto" : "none",
filter: playerBarTintEnabled ? "none" : "grayscale(1)",
transition: "all 0.25s ease",
}}
>
{allTintColors.map((color, index) => {
@@ -626,7 +763,15 @@ export const Settings = () => {
})}
</div>
<div style={{ marginBottom: "12px" }}>
<div
style={{
marginBottom: "12px",
opacity: playerBarTintEnabled ? 1 : 0.3,
pointerEvents: playerBarTintEnabled ? "auto" : "none",
filter: playerBarTintEnabled ? "none" : "grayscale(1)",
transition: "all 0.25s ease",
}}
>
<div
style={{
color: "rgba(255,255,255,0.7)",
+294
View File
@@ -0,0 +1,294 @@
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 = "rl";
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;
}
@@ -72,8 +72,7 @@
/* Ensure now-playing content renders above the dynamic background */
[data-test="new-now-playing"] > header,
[data-test="new-now-playing"] > [class*="_content_"],
[data-test="new-now-playing"] > .unhide-ui-button {
[data-test="new-now-playing"] > [class*="_content_"] {
position: relative;
z-index: 1;
}
File diff suppressed because it is too large Load Diff
+2 -414
View File
@@ -1,60 +1,12 @@
/* Font imports for lyrics */
@font-face {
font-family: "AbyssFont";
font-weight: 400;
src: url("https://excel.lexploits.top/extra/tidal/LyricsRegular.woff2")
format("woff2");
}
/* Radiant Lyrics — text glow only (injected when Lyrics Glow is enabled) */
@font-face {
font-family: "AbyssFont";
font-weight: 500;
src: url("https://excel.lexploits.top/extra/tidal/LyricsMedium.woff2")
format("woff2");
}
/* MARKER: Lyrics glow CSS */
@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 */
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"][class*="_current_"] {
text-shadow:
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #fff),
/* biome-ignore lint: Required to override app glow strength */
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"] {
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 {
@@ -62,157 +14,8 @@
0 0 var(--rl-glow-inner, 2px) lightgray,
/* biome-ignore lint: Hover glow should override defaults */
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;
}
/* 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;
}
/* Glow-aware left padding so the glow doesn't clip | Thanks Aya <3*/
.rl-wbw-active {
padding-left: var(--rl-glow-outer) !important;
}
/* 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 old 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: 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 — opacity override needed to beat Tidal's ._hasCues_ span { opacity:.35 } */
.rl-wbw-word {
text-shadow:
0 0 0px transparent,
0 0 0px transparent;
color: rgba(255, 255, 255, 0.4);
/* biome-ignore lint: Must beat Tidal _hasCues_ opacity */
opacity: 1 !important;
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,
@@ -223,226 +26,11 @@
0 0 var(--rl-glow-inner, 2px) lightgray,
/* biome-ignore lint: Hover glow should override defaults */
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 {
text-shadow:
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #fff),
/* biome-ignore lint: Glow priority for active word */
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(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, 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(255, 255, 255, 0.4);
/* biome-ignore lint: Must beat Tidal _hasCues_ opacity */
opacity: 1 !important;
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(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;
}
.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 [data-test="now-playing-lyrics"] span[data-test="lyrics-line"][class*="_current_"],
.lyrics-glow-disabled [data-test="now-playing-lyrics"] span[data-test="lyrics-line"]: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;
}
+645 -43
View File
@@ -23,60 +23,27 @@
.radiant-lyrics-ui-hidden [data-test="toggle-credits"],
.radiant-lyrics-ui-hidden [data-test="toggle-similar-tracks"] {
opacity: 0 !important;
transition: opacity 0.4s ease-in-out;
transition: opacity 0.4s ease-in-out !important;
}
.radiant-lyrics-ui-hidden [data-test="toggle-lyrics"]:hover,
.radiant-lyrics-ui-hidden [data-test="toggle-credits"]:hover,
.radiant-lyrics-ui-hidden [data-test="toggle-similar-tracks"]: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;
transition: opacity 0.4s ease-in-out !important;
}
/* Hide header container (search, minimize, fullscreen) when UI is hidden */
.radiant-lyrics-ui-hidden [data-test="header"] {
/* Hide header, artist info, and visualizer when UI is hidden */
.radiant-lyrics-ui-hidden [data-test="header"],
.radiant-lyrics-ui-hidden [data-test="artist-info"],
.radiant-lyrics-ui-hidden .audio-visualizer-container {
opacity: 0 !important;
visibility: hidden !important;
transition: opacity 0.4s ease-in-out, visibility 0s linear 0.4s;
transition: opacity 0.4s ease-in-out !important, visibility 0s linear 0.4s;
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 */
@@ -272,8 +239,562 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
}
/* MARKER: Integrated Seek Bar */
/* Moves the seekbar to the top border of the player bar (inspired by Amethyst) */
/* Scrubber row stays in flow — centers the time block as one unit */
body.rl-integrated-seekbar .rl-seekbar-container {
justify-content: center !important;
align-items: center !important;
gap: 0 !important;
}
/* Single string: "current | duration" — synced from native <time> nodes */
body.rl-integrated-seekbar .rl-seekbar-combined-time {
opacity: 0.5 !important;
white-space: nowrap !important;
line-height: 1.2 !important;
flex: 0 0 auto !important;
font-variant-numeric: tabular-nums !important;
}
/* Hide Tidal's two time <p> cells (class + structural — survives React re-renders) */
body.rl-integrated-seekbar .rl-seekbar-native-time {
display: none !important;
}
body.rl-integrated-seekbar [data-test="footer-player"] .rl-seekbar-container > p:has([data-test="current-time"]),
body.rl-integrated-seekbar [data-test="footer-player"] .rl-seekbar-container > p:has([data-test="duration"]) {
display: none !important;
}
/* Scrubber bar — top strip: same width as footer player, top corners = Floating Bar Corner Radius */
body.rl-integrated-seekbar .rl-seekbar-bar {
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
z-index: 100 !important;
box-sizing: border-box !important;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
flex: none !important;
border-top-left-radius: var(--rl-integrated-seekbar-top-radius, 0px) !important;
border-top-right-radius: var(--rl-integrated-seekbar-top-radius, 0px) !important;
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
/* Knob — pill shape, hidden until hover */
body.rl-integrated-seekbar .rl-seekbar-bar [class*="knob"] {
width: 7px !important;
height: 14px !important;
border-radius: 3px !important;
background: #fff !important;
opacity: 0 !important;
transition: opacity 0.15s ease !important;
}
body.rl-integrated-seekbar .rl-seekbar-bar:hover [class*="knob"] {
opacity: 1 !important;
}
/* Track elements only: top corners follow player bar radius, bottom square */
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"],
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"] [class*="_wrapper"],
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"] [class*="_range"],
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"] [data-test="progress-indicator"] {
height: 3px !important;
border-top-left-radius: var(--rl-integrated-seekbar-top-radius, 0px) !important;
border-top-right-radius: var(--rl-integrated-seekbar-top-radius, 0px) !important;
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
transition: height 0.15s ease !important;
}
/* Expand on hover */
body.rl-integrated-seekbar .rl-seekbar-bar:hover [data-test="progress-bar"],
body.rl-integrated-seekbar .rl-seekbar-bar:hover [data-test="progress-bar"] [class*="_wrapper"],
body.rl-integrated-seekbar .rl-seekbar-bar:hover [data-test="progress-bar"] [class*="_range"],
body.rl-integrated-seekbar .rl-seekbar-bar:hover [data-test="progress-bar"] [data-test="progress-indicator"] {
height: 5px !important;
}
/* 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 (Tidals 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) */
/* These change allot so i gave them their own section */
/* These change a lot so I gave them their own section */
/* Remove max-width cap on now-playing content */
[class*="_contentInner"] {
@@ -291,3 +812,84 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
display: none !important;
pointer-events: none !important;
}
._artworkTilt_1c6d5cc {
border: none !important;
}
/* Hide fullscreen button — breaks Radiant Lyrics */
[data-test="new-now-playing-expand"] {
display: none !important;
}
/* Restore the Old Quality Tag style | thx Aya <3 */
._gradientMax_9111fba {
background-color: #ffd4321a !important;
box-shadow: none;
border-style: none;
border-radius: 0.75em;
}
._max_894bc7c ._badgeText_1c9dd30 {
color: #ffd432 !important;
text-shadow: 0 0 10px #0000 !important;
font-weight: 600 !important;
font-size: 90% !important;
}
._gradientHigh_87f2c3b {
background-color: #073430 !important;
box-shadow: none;
border-style: none;
border-radius: 0.75em;
}
._high_4b5525b ._badgeText_1c9dd30 {
color: #33ffee !important;
text-shadow: none !important;
font-weight: 600 !important;
font-size: 90% !important;
}
._gradientLow_3f9bc0d {
background-color: #ffffff1a !important;
box-shadow: none;
border-style: none;
border-radius: 0.75em;
}
._badgeText_1c9dd30 {
color: #e4e4e7 !important;
text-shadow: none;
font-weight: 600 !important;
font-size: 90% !important;
}
._badge_7b2911e {
border: none;
box-shadow: none;
background: none;
height: 33px;
padding: 0 !important;
width: 111px;
min-width: 111px;
border-radius: 0.75em;
transform: scale(1);
}
._badge_7b2911e:hover {
transition: 100ms;
filter: saturate(1.25) brightness(1.1);
}
._glowEffect_74c5e85 {
display: none !important;
}
/* Make the small header red */
[class*="_smallHeader_"] {
background-color: rgba(0, 0, 0, .3) !important;
backdrop-filter: blur(10px) !important;
}
+5 -1
View File
@@ -39,9 +39,13 @@ importers:
specifier: ^5.8.3
version: 5.8.3
plugins/audio-visualizer-luna: {}
plugins/colorama-lyrics-luna: {}
plugins/copy-lyrics-luna: {}
plugins/oled-theme-luna: {}
plugins/element-hider-luna: {}
plugins/radiant-lyrics-luna: {}
+5
View File
@@ -1,2 +1,7 @@
packages:
- "plugins/*"
# pnpm 11 renamed `onlyBuiltDependencies` (list) to `allowBuilds` (map of name -> bool).
# This whitelists postinstall/build scripts non-interactively in CI.
allowBuilds:
esbuild: true