1 Commits

Author SHA1 Message Date
vMohammad24 24aabe67fd feat(audioVisualization): add spotify support and linear animations 2025-06-12 15:29:32 +03:00
47 changed files with 4032 additions and 12536 deletions
-7
View File
@@ -1,7 +0,0 @@
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.css text eol=lf
*.json text eol=lf
*.md text eol=lf
*.yaml text eol=lf
+2 -1
View File
@@ -13,7 +13,8 @@ jobs:
- name: Install pnpm 📥
uses: pnpm/action-setup@v4
# Version is read from `packageManager` in package.json for reproducible builds.
with:
version: latest
- name: Install Node.js 📥
uses: actions/setup-node@v4
+4 -2
View File
@@ -1,4 +1,6 @@
node_modules/
dist/
Notes.md
/Reference/
dist/itzzexcel.oled-theme.json
dist/itzzexcel.oled-theme.mjs
dist/itzzexcel.oled-theme.mjs.map
dist/store.json
-4
View File
@@ -1,4 +0,0 @@
{
"snyk.advanced.organization": "5e35d31d-0df9-4f3c-b4b3-168a24114801",
"snyk.advanced.autoSelectOrganization": true
}
+24 -36
View File
@@ -1,23 +1,30 @@
# Luna Plugins Collection
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
A collection of Luna plugins for Tidal, ported from Neptune framework.
## Plugins
### Radiant Lyrics
### 🎨 OLED Theme
**Location:** `plugins/oled-theme-luna/`
A dark OLED-friendly theme plugin that transforms Tidal Luna's appearance.
**Features:**
- Applies a dark, OLED-optimized theme
- Fetches the latest theme CSS from the GitHub repository
- Reduces battery consumption on OLED displays.. i guess <3
- Modern, sleek dark interface
### 🎵 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.
@@ -27,17 +34,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
**Location:** `plugins/element-hider-luna/`
Allows users to hide/remove UI elements by right clicking on them.
**Features:**
- Remove/Hide ANY UI element
- 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.
@@ -52,21 +49,8 @@ Allows users to hide/remove UI elements by right clicking on them.
## Installation
### Batteries Required
1. [TidaLuna](https://github.com/Inrixia/TidaLuna) - Plugin Framework for Tidal (what these plugins are for)
2. Tidal - Streaming Service (if you are here and dont use tidal.. then just enjoy the read <3)
### Installing from Plugin Store (in TidaLuna)
1. Open Tidal (with Luna installed)
2. Navigate to Luna Settings (Top right of Tidal)
3. Click "Plugin Store" Tab
4. Scroll Down and just click on the plugins to install them
5. Naviagte to the "Plugins" Tab
6. And now your done and you can adjust the settings to your liking <3
### Installing from URL
### (They are in the store by default now)
1. Open TidaLuna after Building & Serving
1. Open TidalLuna after Building & Serving
2. Navigate to Luna Settings (Top right of Tidal)
3. Click "Plugin Store" Tab
4. Paste in the "Install from URL" Bar `https://github.com/meowarex/tidalluna-plugins/releases/download/latest/store.json`
@@ -79,7 +63,7 @@ Allows users to hide/remove UI elements by right clicking on them.
git clone https://github.com/meowarex/tidalluna-plugins
# Change Folder to the Repo
cd tidalluna-plugins
cd neptune-projects-fork
# Install dependencies
pnpm install
@@ -89,7 +73,7 @@ pnpm run watch
```
### Installing Plugins in TidalLuna
1. Open TidaLuna after Building & Serving
1. Open TidalLuna after Building & Serving
2. Navigate to Luna Settings (Top right of Tidal)
3. Click "Plugin Store" Tab
4. Click Install on the Plugins at the top Labeled with "[Dev]"
@@ -98,7 +82,7 @@ pnpm run watch
## Development
This project is made for:
- **[TidaLuna](https://github.com/Inrixia/TidaLuna)** - Modern plugin framework for Tidal | Inrixia
- **TidalLuna** - Modern plugin framework for Tidal | Inrixia
## GitHub Actions
@@ -106,6 +90,10 @@ This project is made for:
- **Release automation** for distributing plugins
- **Artifact uploads** for easy plugin distribution
## Based On <3
- **itzzexcel** - [GitHub](https://github.com/ItzzExcel)
## Credits
Inrixia | [TidalLuna](https://github.com/Inrixia/TidaLuna) Plugin Framework (The successor Neptune)
Original Neptune versions by itzzexcel. Ported to Luna framework following the Luna plugin template structure by meowarex with help from Inrixia <3
-9
View File
@@ -1,9 +0,0 @@
{
"linter": {
"rules": {
"complexity": {
"useArrowFunction": "off"
}
}
}
}
-2356
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -2,7 +2,6 @@
"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",
+266 -511
View File
@@ -1,617 +1,372 @@
import { ReactiveStore } from "@luna/core";
import {
LunaSettings,
LunaNumberSetting,
LunaSwitchSetting,
LunaSelectSetting,
LunaSelectItem,
} from "@luna/ui";
import { LunaNumberSetting, LunaSettings, LunaSwitchSetting } 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",
{
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,
const isWindows = navigator.userAgent.includes("Windows"); // tidal changes it in reqs navigator supplies the default electron one
export const settings = await ReactiveStore.getPluginStorage("AudioVisualizer", {
barCount: 32,
barColor: "#ffffff",
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[],
},
);
const VIZ_TYPES: VisualizerType[] = [
"none",
"spectrum-bars",
"spectrum-line",
"oscilloscope",
"vectorscope",
"loudness-meter",
];
const getSlot = (key: SlotKey): VisualizerType =>
(settings as unknown as Record<string, VisualizerType>)[key] ?? "none";
const setSlot = (key: SlotKey, value: VisualizerType): void => {
(settings as unknown as Record<string, VisualizerType>)[key] = value;
};
spotifyAPI: isWindows
});
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 [barColor, setBarColor] = React.useState(settings.barColor);
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 [spotifyAPI, setSpotifyAPI] = React.useState(settings.spotifyAPI);
const [showColorPicker, setShowColorPicker] = React.useState(false);
const [isColorAnimIn, setIsColorAnimIn] = React.useState(false);
const [shouldRenderColor, setShouldRenderColor] = 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 [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);
setIsAnimatingIn(false);
setTimeout(() => {
setShowColorPicker(false);
setShouldRender(false);
}, 200); // Wait for animation to complete because i need to
};
const openColorPicker = () => {
setShowColorPicker(true);
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);
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
};
React.useEffect(() => {
if (showColorPicker) {
setShouldRenderColor(true);
setTimeout(() => setIsColorAnimIn(true), 10);
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
}
}, [showColorPicker]);
React.useEffect(() => {
if (showSlotConfig) {
setShouldRenderSlot(true);
setTimeout(() => setIsSlotAnimIn(true), 10);
}
}, [showSlotConfig]);
// Common color presets for cool points :D
const colorPresets = [
"#ff69b4", "#ff1493", "#e91e8a", "#c71585",
"#ff006e", "#ff4da6", "#ff85c8", "#ffb3d9",
"#ffffff", "#ff0000", "#00ff00", "#0000ff",
"#ffff00", "#ff00ff", "#00ffff", "#ff8800",
"#8800ff", "#0088ff", "#1db954", "#444444",
"#ffffff", "#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
"#ff8800", "#8800ff", "#0088ff", "#88ff00", "#ff0088", "#00ff88",
"#444444", "#888888", "#cccccc", "#1db954", "#e22134", "#1976d2"
];
const updateColor = (color: string) => {
setBarColor(color);
setCustomInput(color);
settings.barColor = color;
(window as any).updateAudioVisualizer?.();
};
const addCustomColor = () => {
if (customInput) {
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;
// 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 removeCustomColor = (c: string) => {
const nc = customColors.filter(x => x !== c);
setCustomColors(nc);
settings.customColors = nc;
if (barColor === c) updateColor("#ff69b4");
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 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>
{/* Color & Layout */}
<LunaSettings> <LunaSwitchSetting
title="Spotify API"
desc="Use Spotify's audio analysis API instead of real-time audio data (Required for Windows)"
// @ts-expect-error no idea why this errosr wth
checked={spotifyAPI}
disabled={isWindows} // Disable on non-Windows platforms
// @ts-expect-error no idea why this errosr wth
onChange={(_, checked) => {
setSpotifyAPI(checked);
settings.spotifyAPI = checked;
(window as any).updateAudioVisualizer?.();
}}
/>
<LunaSwitchSetting
title="Bar Roundness"
desc="Enable rounded corners on visualizer bars"
// @ts-expect-error no idea why this errosr wth
checked={barRounding}
// @ts-expect-error no idea why this errosr wth
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={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "10px 0",
padding: "16px 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}>
<div>
<div style={{ fontWeight: 600, fontSize: "14px", color: "#fff" }}>Color & Layout</div>
<div style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)", marginTop: "2px" }}>
Visualizer color and slot placement
<div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: "4px" }}>Bar Color</div>
<div style={{ opacity: 0.7, fontSize: "14px" }}>Color of the visualizer bars</div>
</div>
</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<div style={{ display: "flex", gap: "8px", alignItems: "center", position: "relative" }}>
<button
type="button"
onClick={() => showColorPicker ? closeColorPicker() : openColorPicker()}
style={{
width: "28px", height: "28px",
width: "32px",
height: "32px",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "6px", cursor: "pointer", background: barColor,
overflow: "hidden", position: "relative",
borderRadius: "6px",
cursor: "pointer",
background: barColor,
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
position: "relative",
overflow: "hidden"
}}
>
<div style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.1)", backdropFilter: "blur(2px)" }} />
<div style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.1)",
backdropFilter: "blur(2px)"
}} />
</button>
<button
type="button"
onClick={() => showSlotConfig ? closeSlotConfig() : openSlotConfig()}
style={{
padding: "6px 12px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff", cursor: "pointer", fontSize: "12px",
fontWeight: 500, transition: "all 0.2s ease",
whiteSpace: "nowrap",
}}
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.2)"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.1)"; }}
>Configure Slots</button>
</div>
</div>
<AnySwitch
title="Grouped Slots"
desc="Active slots in the same position share a single container"
checked={groupedSlots}
onChange={(_: unknown, checked: boolean) => {
setGroupedSlots(checked);
settings.groupedSlots = checked;
}}
/>
<AnySwitch
title="Transparent containers"
desc="Remove panel background, blur and shadow"
checked={transparentContainers}
onChange={(_: unknown, checked: boolean) => {
setTransparentContainers(checked);
settings.transparentContainers = checked;
}}
/>
<LunaSelectSetting
title="Idle Animation"
desc="Behaviour when no audio is playing"
value={idleMode}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const v = Number(e.target.value);
setIdleMode(v);
settings.idleMode = v;
}}
>
<LunaSelectItem value={0}>Enabled</LunaSelectItem>
<LunaSelectItem value={1}>Disabled &amp; Hide</LunaSelectItem>
<LunaSelectItem value={2}>Disabled &amp; Static</LunaSelectItem>
</LunaSelectSetting>
{/* Color picker modal */}
{shouldRenderColor && (
{/* Custom Color Picker Modal */}
{shouldRender && (
<>
<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>
{/* Backdrop */}
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.6)",
zIndex: 1000,
opacity: isAnimatingIn ? 1 : 0,
transition: "opacity 0.2s ease"
}}
onClick={closeColorPicker}
/>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "8px", marginBottom: "16px" }}>
{/* Color Picker Panel */}
<div style={{
position: "fixed",
top: "50%",
left: "50%",
background: "rgba(20,20,20,0.98)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "16px",
padding: "20px",
minWidth: "320px",
maxWidth: "90vw",
maxHeight: "90vh",
zIndex: 1001,
boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
opacity: isAnimatingIn ? 1 : 0,
transform: isAnimatingIn ? "translate(-50%, -50%) scale(1)" : "translate(-50%, -50%) scale(0.9)",
transition: "all 0.2s ease"
}}>
<div style={{ marginBottom: "12px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>
Choose Color
</div>
{/* Color Grid */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "8px",
marginBottom: "16px"
}}>
{allColors.map((color, index) => {
const isCustom = customColors.includes(color);
const isCustomColor = customColors.includes(color);
const isHovered = hoveredColorIndex === index;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: cosmetic hover tracking on wrapper containing interactive buttons
<div
key={color}
style={{ position: "relative", width: "32px", height: "32px", cursor: "pointer" }}
key={index}
style={{
position: "relative",
width: "32px",
height: "32px",
cursor: "pointer"
}}
className="color-item"
onMouseEnter={() => setHoveredColorIndex(index)}
onMouseLeave={() => setHoveredColorIndex(null)}
>
<button
type="button"
onClick={() => { updateColor(color); closeColorPicker(); }}
onClick={() => {
updateColor(color);
closeColorPicker();
}}
style={{
width: "100%", height: "100%", borderRadius: "6px",
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",
background: color,
cursor: "pointer",
transition: "all 0.2s ease"
}}
/>
{isCustom && (
{isCustomColor && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeCustomColor(color); }}
style={{
position: "absolute", top: "-4px", right: "-4px",
width: "16px", height: "16px", borderRadius: "50%",
border: "1px solid rgba(255,255,255,0.8)", background: "rgba(0,0,0,0.8)",
color: "#fff", cursor: "pointer", fontSize: "10px",
display: "flex", alignItems: "center", justifyContent: "center",
opacity: isHovered ? 1 : 0, transition: "opacity 0.2s ease", zIndex: 10,
onClick={(e) => {
e.stopPropagation();
removeCustomColor(color);
}}
>x</button>
style={{
position: "absolute",
top: "-4px",
right: "-4px",
width: "16px",
height: "16px",
borderRadius: "50%",
border: "1px solid rgba(255,255,255,0.8)",
background: "rgba(0,0,0,0.8)",
color: "#fff",
cursor: "pointer",
fontSize: "10px",
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: isHovered ? 1 : 0,
transition: "opacity 0.2s ease",
zIndex: 10
}}
className="remove-button"
>
×
</button>
)}
</div>
);
})}
</div>
{/* Custom Hex Input */}
<div style={{ marginBottom: "12px" }}>
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "12px", marginBottom: "6px" }}>Add Custom Color</div>
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "12px", marginBottom: "6px" }}>
Add Custom Color
</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<input
type="text"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { updateColor(customInput); addCustomColor(); } }}
placeholder="#ff69b4"
onKeyDown={(e) => {
if (e.key === 'Enter') {
updateColor(customInput);
addCustomColor();
}
}}
placeholder="#ffffff"
style={{
flex: 1, padding: "8px 12px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
color: "#fff", fontSize: "14px", fontFamily: "monospace", boxSizing: "border-box",
flex: 1,
padding: "8px 12px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
fontSize: "14px",
fontFamily: "monospace",
boxSizing: "border-box"
}}
/>
<button
type="button"
onClick={() => { updateColor(customInput); addCustomColor(); }}
style={{
width: "32px", height: "32px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.3)", background: "rgba(255,255,255,0.15)",
color: "#fff", cursor: "pointer", fontSize: "16px",
display: "flex", alignItems: "center", justifyContent: "center",
transition: "all 0.2s ease",
onClick={() => {
updateColor(customInput);
addCustomColor();
}}
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>
style={{
width: "32px",
height: "32px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.3)",
background: "rgba(255,255,255,0.15)",
color: "#fff",
cursor: "pointer",
fontSize: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease"
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)";
}}
>
+
</button>
</div>
</div>
{/* Close Button (Done) - Also runs when color chosen*/}
<button
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
@@ -1,320 +0,0 @@
const log = (message: string) => console.log(`[Audio Visualizer] ${message}`);
let audioContext: AudioContext | null = null;
let monoAnalyser: AnalyserNode | null = null;
let leftAnalyser: AnalyserNode | null = null;
let rightAnalyser: AnalyserNode | null = null;
let splitter: ChannelSplitterNode | null = null;
let audioSource: MediaStreamAudioSourceNode | null = null;
let 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;
};
File diff suppressed because it is too large Load Diff
+33 -110
View File
@@ -1,34 +1,46 @@
.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);
/* Audio Visualizer CSS - Only applies to the Visualizer */
#audio-visualizer-container {
transition: all 0.3s ease-in-out;
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;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.audio-visualizer-container:hover {
#audio-visualizer-container:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(255, 105, 180, 0.2);
border-color: rgba(255, 105, 180, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.audio-visualizer-container canvas {
#audio-visualizer-container canvas {
display: block;
border-radius: 4px;
transition: all 0.3s ease-in-out;
}
.audio-visualizer-container.active {
box-shadow: 0 0 20px rgba(255, 105, 180, 0.3);
/* Responsive adjustments */
@media (max-width: 768px) {
#audio-visualizer-container {
margin: 4px;
padding: 2px;
}
#audio-visualizer-container canvas {
max-width: 150px;
max-height: 30px;
}
}
@keyframes av-fadeIn {
/* Where to put the thingy */
[class*="_searchField"] {
transition: all 0.3s ease-in-out;
}
/* Shadow when active - doesnt seem to only apply when active but thats better */
#audio-visualizer-container.active {
box-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
}
/* Fade in animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.8);
@@ -39,95 +51,6 @@
}
}
[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);
#audio-visualizer-container {
animation: fadeIn 0.5s ease-out;
}
@@ -1,201 +0,0 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { hexToRGB } from "../webgl";
const GATE_ABSOLUTE = -70;
const GATE_RELATIVE_OFFSET = -10;
const GAINS = [1.0, 1.0];
interface LUFSState {
momentaryBlocks: number[];
shortTermBlocks: number[];
integratedPowers: number[];
momentary: number;
shortTerm: number;
integrated: number;
blockBuffer: Float32Array[];
blockPos: number;
blockSize: number;
hopSize: number;
hopPos: number;
displayMomentary: number;
displayShortTerm: number;
displayIntegrated: number;
}
const createLUFSState = (sampleRate: number): LUFSState => {
const blockSize = Math.floor(sampleRate * 0.4);
const hopSize = Math.floor(sampleRate * 0.1);
return {
momentaryBlocks: [],
shortTermBlocks: [],
integratedPowers: [],
momentary: -Infinity,
shortTerm: -Infinity,
integrated: -Infinity,
blockBuffer: [new Float32Array(blockSize), new Float32Array(blockSize)],
blockPos: 0,
blockSize,
hopSize,
hopPos: 0,
displayMomentary: -60,
displayShortTerm: -60,
displayIntegrated: -60,
};
};
const computeBlockLoudness = (left: Float32Array, right: Float32Array, len: number): number => {
let sumL = 0, sumR = 0;
for (let i = 0; i < len; i++) {
sumL += left[i] * left[i];
sumR += right[i] * right[i];
}
const powerL = sumL / len;
const powerR = sumR / len;
const weighted = GAINS[0] * powerL + GAINS[1] * powerR;
if (weighted <= 0) return -Infinity;
return -0.691 + 10 * Math.log10(weighted);
};
const computeGatedIntegrated = (powers: number[]): number => {
if (powers.length === 0) return -Infinity;
const aboveAbsolute = powers.filter(p => p > GATE_ABSOLUTE);
if (aboveAbsolute.length === 0) return -Infinity;
const meanAbsolute = aboveAbsolute.reduce((s, v) => s + Math.pow(10, v / 10), 0) / aboveAbsolute.length;
const relativeThreshold = 10 * Math.log10(meanAbsolute) + GATE_RELATIVE_OFFSET;
const aboveRelative = aboveAbsolute.filter(p => p > relativeThreshold);
if (aboveRelative.length === 0) return -Infinity;
const meanRelative = aboveRelative.reduce((s, v) => s + Math.pow(10, v / 10), 0) / aboveRelative.length;
return 10 * Math.log10(meanRelative);
};
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
export const createLoudnessMeter = (): Visualizer => {
let ctx: CanvasRenderingContext2D | null = null;
let w = 0, h = 0;
let state: LUFSState | null = null;
let lastSampleRate = 0;
const SMOOTHING_FAST = 0.25;
const SMOOTHING_SLOW = 0.08;
return {
name: "Loudness (LUFS)",
id: "loudness-meter",
init(canvas, _color) {
ctx = canvas.getContext("2d")!;
w = canvas.width;
h = canvas.height;
state = null;
lastSampleRate = 0;
},
render(data: AudioData, color: string) {
if (!ctx) return;
if (!state || data.sampleRate !== lastSampleRate) {
state = createLUFSState(data.sampleRate);
lastSampleRate = data.sampleRate;
}
const left = data.leftTimeDomain;
const right = data.rightTimeDomain;
const len = Math.min(left.length, right.length);
for (let i = 0; i < len; i++) {
state.blockBuffer[0][state.blockPos] = left[i];
state.blockBuffer[1][state.blockPos] = right[i];
state.blockPos++;
state.hopPos++;
if (state.blockPos >= state.blockSize) {
const loudness = computeBlockLoudness(state.blockBuffer[0], state.blockBuffer[1], state.blockSize);
state.momentaryBlocks.push(loudness);
if (state.momentaryBlocks.length > 4) state.momentaryBlocks.shift();
state.momentary = Math.max(...state.momentaryBlocks);
state.shortTermBlocks.push(loudness);
if (state.shortTermBlocks.length > 30) state.shortTermBlocks.shift();
const stPowers = state.shortTermBlocks.filter(v => v > -Infinity);
if (stPowers.length > 0) {
const stMean = stPowers.reduce((s, v) => s + Math.pow(10, v / 10), 0) / stPowers.length;
state.shortTerm = 10 * Math.log10(stMean);
}
state.integratedPowers.push(loudness);
if (state.integratedPowers.length > 3000) state.integratedPowers.shift();
state.integrated = computeGatedIntegrated(state.integratedPowers);
const keep = state.blockSize - state.hopSize;
state.blockBuffer[0].copyWithin(0, state.hopSize);
state.blockBuffer[1].copyWithin(0, state.hopSize);
state.blockPos = keep;
state.hopPos = 0;
}
}
const clamp = (v: number) => (v === -Infinity ? -60 : Math.max(-60, Math.min(0, v)));
state.displayMomentary = lerp(state.displayMomentary, clamp(state.momentary), SMOOTHING_FAST);
state.displayShortTerm = lerp(state.displayShortTerm, clamp(state.shortTerm), SMOOTHING_FAST);
state.displayIntegrated = lerp(state.displayIntegrated, clamp(state.integrated), SMOOTHING_SLOW);
ctx.clearRect(0, 0, w, h);
const [cr, cg, cb] = hexToRGB(color);
const minLUFS = -60;
const maxLUFS = 0;
const range = maxLUFS - minLUFS;
const norm = (v: number) => Math.max(0, Math.min(1, (v - minLUFS) / range));
const labels = ["M", "S", "I"];
const rawValues = [state.momentary, state.shortTerm, state.integrated];
const displayValues = [state.displayMomentary, state.displayShortTerm, state.displayIntegrated];
const barH = (h - 4) / 3;
const labelW = 12;
const valueW = 36;
const barX = labelW;
const barW = w - labelW - valueW;
ctx.font = `bold ${Math.min(9, barH - 1)}px monospace`;
ctx.textBaseline = "middle";
for (let i = 0; i < 3; i++) {
const y = 1 + i * (barH + 1);
const n = norm(displayValues[i]);
ctx.fillStyle = color;
ctx.textAlign = "left";
ctx.fillText(labels[i], 1, y + barH / 2);
ctx.fillStyle = `rgba(${cr * 255}, ${cg * 255}, ${cb * 255}, 0.15)`;
ctx.fillRect(barX, y, barW, barH);
ctx.fillStyle = `rgba(${cr * 255}, ${cg * 255}, ${cb * 255}, 0.7)`;
ctx.fillRect(barX, y, barW * n, barH);
ctx.fillStyle = "rgba(255,255,255,0.8)";
ctx.textAlign = "right";
const raw = rawValues[i];
const txt = raw > -Infinity ? raw.toFixed(1) : "-inf";
ctx.fillText(txt, w - 1, y + barH / 2);
}
},
resize(width, height) {
w = width;
h = height;
},
dispose() {
ctx = null;
state = null;
lastSampleRate = 0;
},
};
};
@@ -1,96 +0,0 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { settings } from "../Settings";
export const createOscilloscope = (): Visualizer => {
let ctx: CanvasRenderingContext2D | null = null;
let w = 0, h = 0;
let scrollBuffer: Float32Array | null = null;
let scrollPos = 0;
const ensureScrollBuffer = () => {
if (!scrollBuffer || scrollBuffer.length !== w) {
scrollBuffer = new Float32Array(w);
scrollPos = 0;
}
};
return {
name: "Oscilloscope",
id: "oscilloscope",
init(canvas, _color) {
ctx = canvas.getContext("2d")!;
w = canvas.width;
h = canvas.height;
scrollBuffer = null;
scrollPos = 0;
},
render(data: AudioData, color: string) {
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
const lineWidth = settings.lineThickness ?? 1.5;
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
ctx.lineJoin = "round";
ctx.lineCap = "round";
if (settings.scrollingOscilloscope) {
ensureScrollBuffer();
if (!scrollBuffer) return;
const timeDomain = data.floatTimeDomain;
const samplesPerPixel = Math.max(1, Math.floor(timeDomain.length / w));
const pixelsToAdd = Math.max(1, Math.ceil(timeDomain.length / samplesPerPixel));
for (let p = 0; p < pixelsToAdd; p++) {
const sampleIdx = Math.floor(p * samplesPerPixel);
let peak = 0;
for (let s = sampleIdx; s < Math.min(sampleIdx + samplesPerPixel, timeDomain.length); s++) {
if (Math.abs(timeDomain[s]) > Math.abs(peak)) peak = timeDomain[s];
}
scrollBuffer[scrollPos % w] = peak;
scrollPos++;
}
ctx.beginPath();
for (let x = 0; x < w; x++) {
const idx = (scrollPos - w + x + w * 2) % w;
const sample = scrollBuffer[idx];
const y = (1 - sample) * h / 2;
if (x === 0) ctx.moveTo(0, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
} else {
const buffer = data.byteTimeDomain;
const len = buffer.length;
const segmentWidth = w / len;
ctx.beginPath();
for (let i = 0; i < len; i++) {
const v = buffer[i] / 128.0;
const y = (v * h) / 2;
if (i === 0) ctx.moveTo(0, y);
else ctx.lineTo(i * segmentWidth, y);
}
ctx.stroke();
}
},
resize(width, height) {
w = width;
h = height;
scrollBuffer = null;
scrollPos = 0;
},
dispose() {
ctx = null;
scrollBuffer = null;
scrollPos = 0;
},
};
};
@@ -1,136 +0,0 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { createProgram, drawQuad, setUniform1f, setUniform1fv, setUniform2f, setUniform3f, hexToRGB } from "../webgl";
import { settings } from "../Settings";
const MAX_BARS = 128;
const FRAG = `#version 300 es
precision highp float;
uniform vec2 u_resolution;
uniform float u_amplitudes[${MAX_BARS}];
uniform int u_bar_count;
uniform vec3 u_color;
uniform float u_gap;
uniform float u_gain;
uniform float u_rounding;
out vec4 fragColor;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float cellFloat = uv.x * float(u_bar_count);
int barIdx = clamp(int(cellFloat), 0, u_bar_count - 1);
float cellPos = fract(cellFloat);
float amp = clamp(u_amplitudes[barIdx] * u_gain, 0.0, 1.0);
if (amp < 0.005) {
fragColor = vec4(0.0);
return;
}
// Bar shape with anti-aliased edges and configurable gap
float barMask = smoothstep(0.0, u_gap, cellPos)
* smoothstep(0.0, u_gap, 1.0 - cellPos);
// Hard cut at bottom, soft feather only at the top edge
float feather = 1.5 / u_resolution.y;
float heightMask = 1.0 - smoothstep(amp - feather, amp + feather, uv.y);
float a = barMask * heightMask;
// Rounded top corners in pixel space
if (u_rounding > 0.5 && a > 0.0) {
float cellPx = u_resolution.x / float(u_bar_count);
float barPx = cellPx * (1.0 - 2.0 * u_gap);
float fromLeft = (cellPos - u_gap) * cellPx;
float fromRight = barPx - fromLeft;
float fromTop = (amp - uv.y) * u_resolution.y;
float r = clamp(barPx * 0.3, 1.0, 3.0);
float edgeX = min(fromLeft, fromRight);
if (edgeX < r && fromTop < r && fromTop >= 0.0) {
float d = length(vec2(r - edgeX, r - fromTop)) - r;
a *= 1.0 - smoothstep(-0.5, 0.5, d);
}
}
fragColor = vec4(u_color * a, a);
}
`;
const amplitudes = new Float32Array(MAX_BARS);
export const createSpectrumBars = (): Visualizer => {
let gl: WebGL2RenderingContext | null = null;
let program: WebGLProgram | null = null;
let w = 0, h = 0;
return {
name: "Spectrum (Bars)",
id: "spectrum-bars",
init(canvas, _color) {
gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true })!;
if (!gl) throw new Error("WebGL2 not available");
program = createProgram(gl, FRAG);
w = canvas.width;
h = canvas.height;
gl.viewport(0, 0, w, h);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
},
render(data: AudioData, color: string) {
if (!gl || !program) return;
const barCount = Math.min(settings.barCount ?? 64, MAX_BARS);
const gain = settings.gain ?? 1.5;
// Use byteFrequency (0-255 normalized across full analyser range)
const binStep = data.byteFrequency.length / barCount;
for (let i = 0; i < barCount; i++) {
let maxVal = 0;
const start = Math.floor(i * binStep);
const end = Math.floor((i + 1) * binStep);
for (let j = start; j < end; j++) {
if (data.byteFrequency[j] > maxVal) maxVal = data.byteFrequency[j];
}
amplitudes[i] = Math.min(1, (maxVal / 255) * gain);
}
for (let i = barCount; i < MAX_BARS; i++) amplitudes[i] = 0;
gl.viewport(0, 0, w, h);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
setUniform2f(gl, program, "u_resolution", w, h);
setUniform1fv(gl, program, "u_amplitudes", amplitudes);
const loc = gl.getUniformLocation(program, "u_bar_count");
gl.uniform1i(loc, barCount);
const [r, g, b] = hexToRGB(color);
setUniform3f(gl, program, "u_color", r, g, b);
const cellPx = w / barCount;
const gap = Math.min(0.15, 1.5 / cellPx);
setUniform1f(gl, program, "u_gap", gap);
setUniform1f(gl, program, "u_gain", 1.0);
setUniform1f(gl, program, "u_rounding", settings.barRounding ? 1.0 : 0.0);
drawQuad(gl, program);
},
resize(width, height) {
w = width;
h = height;
if (gl) gl.viewport(0, 0, w, h);
},
dispose() {
if (gl && program) gl.deleteProgram(program);
program = null;
gl = null;
},
};
};
@@ -1,105 +0,0 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { createProgram, drawQuad, setUniform1f, setUniform1fv, setUniform2f, setUniform3f, hexToRGB } from "../webgl";
import { settings } from "../Settings";
const BIN_COUNT = 256;
const FRAG = `#version 300 es
precision highp float;
uniform vec2 u_resolution;
uniform float u_amplitudes[${BIN_COUNT}];
uniform vec3 u_color;
uniform float u_fill_opacity;
uniform float u_line_thickness;
uniform float u_opacity_falloff;
out vec4 fragColor;
float interpolate(float a, float b, float t) {
return (1.0 - t) * a + t * b;
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
int idx = int(uv.x * float(${BIN_COUNT}));
int idxL = int((uv.x - 1.0 / u_resolution.x) * float(${BIN_COUNT}));
int idxR = int((uv.x + 1.0 / u_resolution.x) * float(${BIN_COUNT}));
idx = clamp(idx, 0, ${BIN_COUNT - 1});
idxL = clamp(idxL, 0, ${BIN_COUNT - 1});
idxR = clamp(idxR, 0, ${BIN_COUNT - 1});
float amplitude = u_amplitudes[idx];
float left = u_amplitudes[idxL];
float right = u_amplitudes[idxR];
float lowest = min(left, right);
float dist = (amplitude - uv.y) * u_resolution.y;
float a = 0.0;
a += float(abs(dist) <= u_resolution.x * 0.005 * u_line_thickness || (uv.y >= lowest && uv.y <= amplitude)) * clamp(sign(dist), 0.0, 1.0);
a += clamp(sign(amplitude - uv.y), 0.0, 1.0) * interpolate(1.0, u_fill_opacity, pow(1.0 - uv.y, 1.0 - u_opacity_falloff));
a = clamp(a, 0.0, 1.0);
fragColor = vec4(u_color * a, a);
}
`;
const amplitudes = new Float32Array(BIN_COUNT);
export const createSpectrumLine = (): Visualizer => {
let gl: WebGL2RenderingContext | null = null;
let program: WebGLProgram | null = null;
let w = 0, h = 0;
return {
name: "Spectrum (Line)",
id: "spectrum-line",
init(canvas, _color) {
gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true })!;
if (!gl) throw new Error("WebGL2 not available");
program = createProgram(gl, FRAG);
w = canvas.width;
h = canvas.height;
gl.viewport(0, 0, w, h);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
},
render(data: AudioData, color: string) {
if (!gl || !program) return;
const gain = settings.gain ?? 1.5;
const binStep = data.byteFrequency.length / BIN_COUNT;
for (let i = 0; i < BIN_COUNT; i++) {
amplitudes[i] = Math.min(1, (data.byteFrequency[Math.floor(i * binStep)] / 255) * gain);
}
gl.viewport(0, 0, w, h);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
setUniform2f(gl, program, "u_resolution", w, h);
setUniform1fv(gl, program, "u_amplitudes", amplitudes);
const [r, g, b] = hexToRGB(color);
setUniform3f(gl, program, "u_color", r, g, b);
setUniform1f(gl, program, "u_fill_opacity", settings.fillOpacity ?? 0.3);
setUniform1f(gl, program, "u_line_thickness", settings.lineThickness ?? 1.5);
setUniform1f(gl, program, "u_opacity_falloff", settings.opacityFalloff ?? 0.5);
drawQuad(gl, program);
},
resize(width, height) {
w = width;
h = height;
if (gl) gl.viewport(0, 0, w, h);
},
dispose() {
if (gl && program) gl.deleteProgram(program);
program = null;
gl = null;
},
};
};
@@ -1,88 +0,0 @@
import type { AudioData } from "../audio";
export interface Visualizer {
readonly name: string;
readonly id: VisualizerType;
init(canvas: HTMLCanvasElement, color: string): void;
render(data: AudioData, color: string): void;
resize(width: number, height: number): void;
dispose(): void;
}
export type VisualizerType =
| "spectrum-line"
| "spectrum-bars"
| "oscilloscope"
| "vectorscope"
| "loudness-meter"
| "none";
export interface VisualizerDimensions {
width: number;
height: number;
}
export const VISUALIZER_DIMENSIONS: Record<VisualizerType, VisualizerDimensions> = {
"spectrum-line": { width: 200, height: 40 },
"spectrum-bars": { width: 200, height: 40 },
oscilloscope: { width: 200, height: 40 },
vectorscope: { width: 100, height: 40 },
"loudness-meter": { width: 160, height: 40 },
none: { width: 0, height: 0 },
};
export const VISUALIZER_LABELS: Record<VisualizerType, string> = {
"spectrum-line": "Spectrum (Line)",
"spectrum-bars": "Spectrum (Bars)",
oscilloscope: "Oscilloscope",
vectorscope: "Vectorscope",
"loudness-meter": "Loudness (LUFS)",
none: "None",
};
export type ZoneId = "topNav" | "nowPlaying" | "playerBar";
export type PositionId = "left" | "right";
export const ALL_SLOT_KEYS = [
"navLeft1", "navLeft2", "navLeft3",
"navRight1", "navRight2", "navRight3",
"npLeft1", "npLeft2", "npLeft3",
"npRight1", "npRight2", "npRight3",
"pbLeft1", "pbLeft2", "pbLeft3",
"pbRight1", "pbRight2", "pbRight3",
] as const;
export type SlotKey = (typeof ALL_SLOT_KEYS)[number];
export const ZONE_SLOTS: Record<ZoneId, Record<PositionId, readonly SlotKey[]>> = {
topNav: {
left: ["navLeft1", "navLeft2", "navLeft3"],
right: ["navRight1", "navRight2", "navRight3"],
},
nowPlaying: {
left: ["npLeft1", "npLeft2", "npLeft3"],
right: ["npRight1", "npRight2", "npRight3"],
},
playerBar: {
left: ["pbLeft1", "pbLeft2", "pbLeft3"],
right: ["pbRight1", "pbRight2", "pbRight3"],
},
};
export const ZONE_LABELS: Record<ZoneId, string> = {
nowPlaying: "Now Playing View",
topNav: "Top Nav",
playerBar: "Player Bar",
};
export const POSITION_LABELS: Record<PositionId, string> = {
left: "Left",
right: "Right",
};
export const MINI_SUPPORTED = new Set<VisualizerType>(["oscilloscope", "vectorscope"]);
export const MINI_DIMENSIONS: Partial<Record<VisualizerType, VisualizerDimensions>> = {
oscilloscope: { width: 80, height: 60 },
vectorscope: { width: 72, height: 40 },
};
@@ -1,86 +0,0 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { settings } from "../Settings";
export const createVectorscope = (): Visualizer => {
let ctx: CanvasRenderingContext2D | null = null;
let canvas: HTMLCanvasElement | null = null;
let w = 0, h = 0;
let lastX = 0, lastY = 0;
let hasLast = false;
let lastLissajous = false;
return {
name: "Vectorscope",
id: "vectorscope",
init(cvs, _color) {
canvas = cvs;
const c = cvs.getContext("2d");
if (!c) return;
ctx = c;
w = cvs.width;
h = cvs.height;
hasLast = false;
lastLissajous = !!settings.lissajous;
cvs.style.transform = lastLissajous ? "rotate(45deg) scale(0.707)" : "";
},
render(data: AudioData, color: string) {
if (!ctx || !canvas) return;
const wantLissajous = !!settings.lissajous;
if (wantLissajous !== lastLissajous) {
lastLissajous = wantLissajous;
canvas.style.transform = wantLissajous ? "rotate(45deg) scale(0.707)" : "";
}
ctx.clearRect(0, 0, w, h);
const left = data.leftTimeDomain;
const right = data.rightTimeDomain;
const len = Math.min(left.length, right.length);
const lineWidth = Math.max(0.5, (settings.lineThickness ?? 1.0) * 0.5);
const inset = lineWidth;
const halfW = Math.max(1, w / 2 - inset);
const halfH = Math.max(1, h / 2 - inset);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineJoin = "round";
ctx.lineCap = "round";
hasLast = false;
ctx.beginPath();
for (let i = 0; i < len; i++) {
const x = left[i] * halfW + w / 2;
const y = right[i] * halfH + h / 2;
if (!hasLast) {
ctx.moveTo(x, y);
hasLast = true;
} else {
ctx.moveTo(lastX, lastY);
ctx.lineTo(x, y);
}
lastX = x;
lastY = y;
}
ctx.stroke();
},
resize(width, height) {
w = width;
h = height;
hasLast = false;
},
dispose() {
if (canvas) canvas.style.transform = "";
ctx = null;
canvas = null;
hasLast = false;
},
};
};
-151
View File
@@ -1,151 +0,0 @@
const VERTEX_SHADER = `#version 300 es
in vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
export const compileShader = (gl: WebGL2RenderingContext, type: number, source: string): WebGLShader => {
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`Shader compile error: ${info}`);
}
return shader;
};
export const createProgram = (gl: WebGL2RenderingContext, fragSource: string): WebGLProgram => {
const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource);
const program = gl.createProgram()!;
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const info = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`Program link error: ${info}`);
}
gl.deleteShader(vert);
gl.deleteShader(frag);
return program;
};
interface QuadResources {
vao: WebGLVertexArrayObject;
vbo: WebGLBuffer;
}
const quadMap = new WeakMap<WebGL2RenderingContext, QuadResources>();
const ensureQuad = (gl: WebGL2RenderingContext, program: WebGLProgram): QuadResources => {
let res = quadMap.get(gl);
if (res) return res;
const verts = new Float32Array([-1, -1, 3, -1, -1, 3]);
const vao = gl.createVertexArray()!;
const vbo = gl.createBuffer()!;
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
const loc = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
res = { vao, vbo };
quadMap.set(gl, res);
return res;
};
export const drawQuad = (gl: WebGL2RenderingContext, program: WebGLProgram): void => {
const res = ensureQuad(gl, program);
gl.useProgram(program);
gl.bindVertexArray(res.vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
export interface PingPongBuffers {
fbos: [WebGLFramebuffer, WebGLFramebuffer];
textures: [WebGLTexture, WebGLTexture];
current: 0 | 1;
}
const createFBOTexture = (gl: WebGL2RenderingContext, w: number, h: number): { fbo: WebGLFramebuffer; texture: WebGLTexture } => {
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const fbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.bindTexture(gl.TEXTURE_2D, null);
return { fbo, texture: tex };
};
export const createPingPong = (gl: WebGL2RenderingContext, w: number, h: number): PingPongBuffers => {
const a = createFBOTexture(gl, w, h);
const b = createFBOTexture(gl, w, h);
return {
fbos: [a.fbo, b.fbo],
textures: [a.texture, b.texture],
current: 0,
};
};
export const resizePingPong = (gl: WebGL2RenderingContext, pp: PingPongBuffers, w: number, h: number): void => {
for (const tex of pp.textures) {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
}
gl.bindTexture(gl.TEXTURE_2D, null);
};
export const setUniform1f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: number): void => {
gl.uniform1f(gl.getUniformLocation(program, name), v);
};
export const setUniform2f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, x: number, y: number): void => {
gl.uniform2f(gl.getUniformLocation(program, name), x, y);
};
export const setUniform3f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, x: number, y: number, z: number): void => {
gl.uniform3f(gl.getUniformLocation(program, name), x, y, z);
};
export const setUniform1fv = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: Float32Array): void => {
gl.uniform1fv(gl.getUniformLocation(program, name), v);
};
export const setUniform1i = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: number): void => {
gl.uniform1i(gl.getUniformLocation(program, name), v);
};
export const disposeQuad = (gl: WebGL2RenderingContext): void => {
const res = quadMap.get(gl);
if (res) {
gl.deleteVertexArray(res.vao);
gl.deleteBuffer(res.vbo);
quadMap.delete(gl);
}
};
export const disposePingPong = (gl: WebGL2RenderingContext, pp: PingPongBuffers): void => {
for (const fbo of pp.fbos) gl.deleteFramebuffer(fbo);
for (const tex of pp.textures) gl.deleteTexture(tex);
};
export const hexToRGB = (hex: string): [number, number, number] => {
const c = hex.replace("#", "");
const r = parseInt(c.substring(0, 2), 16) / 255;
const g = parseInt(c.substring(2, 4), 16) / 255;
const b = parseInt(c.substring(4, 6), 16) / 255;
return [r, g, b];
};
@@ -1,378 +0,0 @@
import { ReactiveStore } from "@luna/core";
import { LunaSettings, LunaSwitchSetting } from "@luna/ui";
import React from "react";
declare global {
interface Window {
applyColoramaLyrics?: () => void;
}
}
type SwitchChangeHandler = (
event: React.ChangeEvent<HTMLInputElement> | null,
checked: boolean,
) => void;
export const settings = await ReactiveStore.getPluginStorage("ColoramaLyrics", {
enabled: true,
singleColor: "#FFFFFF",
singleAlpha: 100,
customColors: [] as string[],
excludeInactive: false,
});
export const Settings = () => {
const [singleColor, setSingleColor] = React.useState(settings.singleColor);
const [singleAlpha, setSingleAlpha] = React.useState<number>(
settings.singleAlpha ?? 100,
);
const [customInput, setCustomInput] = React.useState(settings.singleColor);
const [customColors, setCustomColors] = React.useState(settings.customColors);
const [showPicker, setShowPicker] = React.useState(false);
const [isAnimatingIn, setIsAnimatingIn] = React.useState(false);
const [shouldRender, setShouldRender] = React.useState(false);
const [excludeInactive, setExcludeInactive] = React.useState(
settings.excludeInactive,
);
const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType<{
title: string;
desc?: string;
checked: boolean;
onChange: SwitchChangeHandler;
}>;
const normalizeToRGB = (
hex: string,
fallback: string = "#FFFFFF",
): string => {
let v = hex.trim().toLowerCase();
if (!v.startsWith("#")) v = `#${v}`;
if (/^#([0-9a-f]{3,4})$/.test(v)) {
const m = v.slice(1);
const r = m[0];
const g = m[1];
const b = m[2];
return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
}
if (/^#([0-9a-f]{8})$/.test(v)) {
const rrggbb = v.slice(3);
return `#${rrggbb}`.toUpperCase();
}
if (/^#([0-9a-f]{6})$/.test(v)) return v.toUpperCase();
return fallback;
};
const colorPresets = [
"#FFFFFF",
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#FF00FF",
"#00FFFF",
"#FF8800",
"#8800FF",
"#0088FF",
"#88FF00",
"#FF0088",
"#00FF88",
"#444444",
"#888888",
"#CCCCCC",
"#1DB954",
"#E22134",
"#1976D2",
];
const openPicker = () => {
setShowPicker(true);
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
};
const closePicker = () => {
setIsAnimatingIn(false);
setTimeout(() => {
setShowPicker(false);
setShouldRender(false);
}, 200);
};
const hexColorRegex = /^#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})$/i;
const applyCustomInputColor = (raw: string, updateInput: boolean): void => {
const trimmed = raw.trim();
if (!hexColorRegex.test(trimmed)) return;
const next = normalizeToRGB(trimmed);
settings.singleColor = next;
setSingleColor(next);
if (updateInput) setCustomInput(next);
requestApply();
};
const addCustomColor = () => {
const trimmed = customInput.trim();
if (
hexColorRegex.test(trimmed) &&
!colorPresets.includes(trimmed) &&
!customColors.includes(normalizeToRGB(trimmed))
) {
const updated = [...customColors, normalizeToRGB(trimmed)];
setCustomColors(updated);
settings.customColors = updated;
}
};
const allColors = [...colorPresets, ...customColors];
const requestApply = () => {
window.applyColoramaLyrics?.();
};
return (
<LunaSettings>
{/* Single color picker button */}
<div
style={{
padding: "8px 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{
fontWeight: "normal",
fontSize: "1.075rem",
marginBottom: 4,
}}
>
Lyrics Color
</div>
<div style={{ opacity: 0.7, fontSize: 14 }}>Set lyrics color</div>
</div>
<div
style={{
display: "flex",
gap: 8,
alignItems: "center",
position: "relative",
}}
>
<button
type="button"
onClick={() => (showPicker ? closePicker() : openPicker())}
style={{
width: 32,
height: 32,
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: 6,
cursor: "pointer",
background: normalizeToRGB(singleColor),
}}
/>
</div>
</div>
{/* Color picker modal */}
{shouldRender && (
<>
<button
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",
}}
type="button"
aria-label="Close color picker"
onClick={closePicker}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "Escape") closePicker();
}}
/>
<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: 16,
padding: 20,
minWidth: 320,
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: 12,
color: "#fff",
fontWeight: "bold",
fontSize: 14,
}}
>
Lyrics Color
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 8,
marginBottom: 16,
}}
>
{allColors.map((color) => (
<button
key={color}
type="button"
onClick={() => {
const next = normalizeToRGB(color);
settings.singleColor = next;
setSingleColor(next);
setCustomInput(next);
requestApply();
}}
style={{
width: 32,
height: 32,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: normalizeToRGB(color),
cursor: "pointer",
}}
/>
))}
</div>
<div style={{ marginBottom: 12 }}>
<div
style={{
color: "rgba(255,255,255,0.7)",
fontSize: 12,
marginBottom: 6,
}}
>
Custom Hex (#RRGGBB)
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
type="text"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
applyCustomInputColor(customInput, true);
addCustomColor();
}
}}
placeholder="#RRGGBB"
style={{
flex: 1,
padding: "8px 12px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
fontSize: 14,
fontFamily: "monospace",
boxSizing: "border-box",
}}
/>
<button
onClick={() => {
applyCustomInputColor(customInput, false);
addCustomColor();
}}
style={{
width: 32,
height: 32,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.3)",
background: "rgba(255,255,255,0.15)",
color: "#fff",
cursor: "pointer",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease",
}}
type="button"
>
+
</button>
</div>
</div>
<div style={{ marginBottom: 16 }}>
<div
style={{
color: "rgba(255,255,255,0.8)",
fontSize: 12,
marginBottom: 6,
}}
>
Alpha
</div>
<input
type="range"
min={5}
max={100}
step={1}
value={singleAlpha}
onChange={(e) => {
const value = Number(e.target.value);
settings.singleAlpha = value;
setSingleAlpha(value);
requestApply();
}}
style={{ width: "100%" }}
/>
</div>
<button
onClick={closePicker}
style={{
width: "100%",
padding: 8,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
cursor: "pointer",
fontSize: 12,
}}
type="button"
>
Done
</button>
</div>
</>
)}
<AnySwitch
title="Exclude Inactive"
desc="Apply color only to the currently active lyric line"
checked={excludeInactive}
onChange={(_event: React.ChangeEvent<HTMLInputElement> | null, checked: boolean) => {
settings.excludeInactive = checked;
setExcludeInactive(checked);
requestApply();
}}
/>
</LunaSettings>
);
};
-99
View File
@@ -1,99 +0,0 @@
import { LunaUnload, Tracer } from "@luna/core";
import { StyleTag } from "@luna/lib";
import { settings, Settings } from "./Settings";
import styles from "file://styles.css?minify";
export const { trace } = Tracer("[Colorama Lyrics]");
export { Settings };
export const unloads = new Set<LunaUnload>();
new StyleTag("ColoramaLyrics", unloads, styles);
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
let v = hex.trim();
if (!v.startsWith("#")) v = `#${v}`;
if (/^#([0-9a-fA-F]{3})$/.test(v)) {
const r = parseInt(v[1] + v[1], 16);
const g = parseInt(v[2] + v[2], 16);
const b = parseInt(v[3] + v[3], 16);
return { r, g, b };
}
if (/^#([0-9a-fA-F]{6})$/.test(v)) {
const r = parseInt(v.slice(1, 3), 16);
const g = parseInt(v.slice(3, 5), 16);
const b = parseInt(v.slice(5, 7), 16);
return { r, g, b };
}
if (/^#([0-9a-fA-F]{8})$/.test(v)) {
const r = parseInt(v.slice(3, 5), 16);
const g = parseInt(v.slice(5, 7), 16);
const b = parseInt(v.slice(7, 9), 16);
return { r, g, b };
}
return null;
}
function rgbaFromHexAndAlpha(
hex: string,
alphaPercent: number | undefined,
): string {
const rgb = hexToRgb(hex);
const a = Math.max(0.05, Math.min(100, alphaPercent ?? 100)) / 100;
if (!rgb) return `rgba(255,255,255,${a})`;
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${a})`;
}
function applySingleColor(color: string) {
const alpha = (settings as any).singleAlpha ?? 100;
const rgba = rgbaFromHexAndAlpha(color, alpha);
document.documentElement.style.setProperty("--cl-lyrics-color", rgba);
document.documentElement.style.setProperty("--cl-glow1", rgba);
document.documentElement.style.setProperty("--cl-glow2", rgba);
document.body.classList.add("colorama-single");
}
function applyColoramaLyrics(): void {
if (!settings.enabled) {
document.body.classList.remove("colorama-single");
return;
}
if (settings.excludeInactive) {
document.body.classList.add("colorama-only-active");
} else {
document.body.classList.remove("colorama-only-active");
}
applySingleColor(settings.singleColor);
}
(window as any).applyColoramaLyrics = applyColoramaLyrics;
setTimeout(() => applyColoramaLyrics(), 200);
function hookRadiantUpdates(): void {
const w = window as any;
const wrap = (name: string) => {
const fn = w[name];
if (typeof fn === "function" && !fn.__coloramaPatched) {
const orig = fn.bind(w);
const patched = (...args: unknown[]) => {
const result = orig(...args);
try {
applyColoramaLyrics();
} catch {}
return result;
};
(patched as any).__coloramaPatched = true;
w[name] = patched;
}
};
wrap("updateRadiantLyricsStyles");
wrap("updateRadiantLyricsNowPlayingBackground");
wrap("updateRadiantLyricsGlobalBackground");
wrap("updateRadiantLyricsTextGlow");
}
setTimeout(() => hookRadiantUpdates(), 0);
-117
View File
@@ -1,117 +0,0 @@
/* Variables used by Colorama Lyrics */
:root {
--cl-lyrics-color: #ffffff;
--cl-glow1: #ffffff;
--cl-glow2: #ffffff;
}
/* Apply solid color to lyrics text */
.colorama-single [class*="_lyricsText"] > div > span,
.colorama-single [class*="_lyricsText"] > div > span[data-current="true"],
.colorama-single [class^="_lyricsContainer"] > div > div > span,
.colorama-single
[class^="_lyricsContainer"]
> div
> div
> span[data-current="true"] {
color: var(--cl-lyrics-color) !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
}
/* Color Radiant glow shadows using Colorama colors (respect RL sizes) */
.colorama-single [class*="_lyricsText"] > div > span[data-current="true"],
.colorama-single
[class^="_lyricsContainer"]
> div
> div
> span[data-current="true"] {
text-shadow:
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #ffffff),
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
}
/* Hover: force glow color to match Colorama settings for inactive lines */
.colorama-single [class*="_lyricsText"] > div > span:hover,
.colorama-single [class^="_lyricsContainer"] > div > div > span:hover {
color: var(--cl-lyrics-color) !important;
text-shadow:
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #ffffff),
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
}
/* MARKER: Radiant WBW Lyrics Support */
/* Single color: active wbw words & syllable finished */
.colorama-single .rl-wbw-word.rl-wbw-active,
.colorama-single .rl-wbw-word.rl-syl-finished {
color: var(--cl-lyrics-color) !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
}
/* Single color: glow on active wbw words */
.colorama-single .rl-wbw-word.rl-wbw-active {
text-shadow:
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #ffffff),
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
}
/* Hover: wbw words pick up Colorama colors */
.colorama-single .rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word:hover,
.colorama-single .rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word.rl-wbw-word-hover {
color: var(--cl-lyrics-color) !important;
text-shadow:
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #ffffff),
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
}
/* Only-active: wbw words on inactive lines stay default */
body.colorama-only-active.colorama-single
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word {
color: rgba(128, 128, 128, 0.4) !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
text-shadow: initial !important;
}
/* Only-active: hover on inactive wbw lines keeps default */
body.colorama-only-active.colorama-single
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word:hover {
color: lightgray !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
text-shadow: initial !important;
}
/* Only color active line mode */
body.colorama-only-active.colorama-single [class*="_lyricsText"]
> div
> span:not([data-current="true"]) {
color: rgba(128, 128, 128, 0.4) !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
text-shadow: initial !important;
}
/* In only-active mode, keep TIDAL defaults even on hover for inactive lines */
body.colorama-only-active.colorama-single [class*="_lyricsText"]
> div
> span:not([data-current="true"]):hover {
color: lightgray !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
text-shadow: initial !important;
}
+61 -145
View File
@@ -1,4 +1,4 @@
import { type LunaUnload, Tracer } from "@luna/core";
import { LunaUnload, Tracer } from "@luna/core";
import { StyleTag } from "@luna/lib";
// Import CSS directly using Luna's file:// syntax - Took me a while to figure out <3
@@ -9,17 +9,13 @@ export const { trace } = Tracer("[Copy Lyrics]");
// clean up resources
export const unloads = new Set<LunaUnload>();
// Style injection via side effect
new StyleTag("Copy-Lyrics", unloads, unlockSelection);
// StyleTag for lyrics selection styling
const lyricsStyleTag = 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();
@@ -33,170 +29,90 @@ function SetClipboard(text: string): void {
}
}
const LINE_SELECTORS = [
".rl-wbw-container .rl-wbw-line",
'[data-test="now-playing-lyrics"] span[data-test="lyrics-line"]',
'[class*="_lyricsText"] > div > span',
].join(",");
let isSelecting = false;
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 onMouseDown = function (): void {
isSelecting = true;
};
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 (!isPointerDownInLyrics) return;
const onMouseUp = function (event: MouseEvent): void {
if (isSelecting) {
const selection = window.getSelection();
if (selection?.toString().trim()) {
const text = getSelectedLyricsText(selection);
if (text.length > 0) {
if (selection && selection.toString().length > 0) {
const selectedSpans: HTMLSpanElement[] = [];
const range = selection.getRangeAt(0);
let container = range.commonAncestorContainer;
// If the container is NOT an element and a document, adjust it.
if (
container.nodeType !== Node.ELEMENT_NODE &&
container.nodeType !== Node.DOCUMENT_NODE
) {
// Get the parent element if it's a text node
const parentElement = container.parentElement;
if (parentElement && parentElement.hasAttribute("data-current")) {
let text_ = selection.toString().trim();
SetClipboard(text_);
trace.msg.log("Copied to clipboard!");
return;
}
}
// Get all the spans inside the container.
const spans = (container as Element).getElementsByTagName("span");
for (let 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) {
SetClipboard(text);
trace.msg.log("Copied to clipboard!");
selection.removeAllRanges();
suppressUpcomingClick();
}
}
isPointerDownInLyrics = false;
isSelecting = false;
}
};
const onClickHooked = (event: MouseEvent): boolean | undefined => {
if (!suppressNextClick) return;
const onClickHooked = function (event: MouseEvent): boolean | void {
if (!isSelecting) return;
suppressNextClick = false;
if (suppressClickResetTimer !== null) {
window.clearTimeout(suppressClickResetTimer);
suppressClickResetTimer = null;
}
const target = event.target as HTMLElement;
if (target.tagName.toLowerCase() === "span" && target.hasAttribute("data-current")) {
// Prevent default behavior and stop event propagation
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
return false;
}
};
// Add event listener with capture phase to intercept events before they reach other handlers
document.addEventListener("click", onClickHooked, true);
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("mouseup", onMouseUp);
// Add cleanup to unloads
unloads.add((): void => {
unloads.add(() => {
// Remove event listeners
document.removeEventListener("click", onClickHooked, true);
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("mouseup", onMouseUp);
if (suppressClickResetTimer !== null) {
window.clearTimeout(suppressClickResetTimer);
suppressClickResetTimer = null;
}
});
+1 -8
View File
@@ -1,11 +1,4 @@
[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 {
[class^="_lyricsText"]>div>span {
user-select: text;
cursor: text;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export const settings = await ReactiveStore.getPluginStorage("ElementHider", {
className: string;
textContent: string;
timestamp: number;
}>,
}>
});
export const Settings = () => {
+98 -168
View File
@@ -1,5 +1,5 @@
import { type LunaUnload, Tracer } from "@luna/core";
import { StyleTag } from "@luna/lib";
import { LunaUnload, Tracer } from "@luna/core";
import { StyleTag, ContextMenu } from "@luna/lib";
import { settings, Settings } from "./Settings";
// Import CSS directly using Luna's file:// syntax
@@ -13,19 +13,13 @@ export { Settings };
// Clean up resources
export const unloads = new Set<LunaUnload>();
// StyleTag for element hider (side-effect)
new StyleTag("Element-Hider", unloads, styles);
// StyleTag for element hider
const styleTag = new StyleTag("Element-Hider", unloads, styles);
// State management
let targetElement: HTMLElement | null = null;
let hiddenElements = new WeakSet<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;
}
let hiddenElementsArray: HTMLElement[] = [];
// MutationObserver for reactive element detection
let elementObserver: MutationObserver | null = null;
@@ -38,7 +32,7 @@ function generateElementSelector(element: HTMLElement): string {
}
// Priority 2: data-test attribute (very specific for Tidal <3)
const dataTest = element.getAttribute("data-test");
const dataTest = element.getAttribute('data-test');
if (dataTest) {
return `[data-test="${dataTest}"]`;
}
@@ -47,43 +41,28 @@ function generateElementSelector(element: HTMLElement): string {
let selector = element.tagName.toLowerCase();
// Get filtered classes (exclude our temporary classes)
const classes = element.className
? element.className
.trim()
.split(/\s+/)
.filter((cls) => {
return (
cls.length > 0 &&
!cls.startsWith("element-hider-") &&
cls !== "element-hider-target" &&
cls !== "element-hider-hiding" &&
cls !== "element-hider-hidden"
);
})
: [];
const classes = element.className ? element.className.trim().split(/\s+/).filter(cls => {
return cls.length > 0 &&
!cls.startsWith('element-hider-') &&
cls !== 'element-hider-target' &&
cls !== 'element-hider-hiding' &&
cls !== 'element-hider-hidden';
}) : [];
// Only use classes if we have them and they're not generic and dumb
if (classes.length > 0) {
// Use ALL classes to be very specific
selector += "." + classes.join(".");
selector += '.' + classes.join('.');
// Add parent context for extra specificity (for when the element is inside another element)
const parent = element.parentElement;
if (parent && parent.tagName !== "BODY" && parent.tagName !== "HTML") {
const parentClasses = parent.className
? parent.className
.trim()
.split(/\s+/)
.filter((cls) => {
return cls.length > 0 && !cls.startsWith("element-hider-");
})
: [];
if (parent && parent.tagName !== 'BODY' && parent.tagName !== 'HTML') {
const parentClasses = parent.className ? parent.className.trim().split(/\s+/).filter(cls => {
return cls.length > 0 && !cls.startsWith('element-hider-');
}) : [];
if (parentClasses.length > 0) {
const parentSelector =
parent.tagName.toLowerCase() +
"." +
parentClasses.slice(0, 2).join(".");
const parentSelector = parent.tagName.toLowerCase() + '.' + parentClasses.slice(0, 2).join('.');
selector = `${parentSelector} > ${selector}`;
}
}
@@ -91,29 +70,19 @@ function generateElementSelector(element: HTMLElement): string {
// If no useful classes, use position-based selector with parent context
const parent = element.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter(
(child) => child.tagName === element.tagName,
);
const siblings = Array.from(parent.children).filter(child => child.tagName === element.tagName);
const index = siblings.indexOf(element);
if (index >= 0) {
selector += `:nth-of-type(${index + 1})`;
// Add parent context
if (parent.tagName !== "BODY" && parent.tagName !== "HTML") {
const parentClasses = parent.className
? parent.className
.trim()
.split(/\s+/)
.filter((cls) => {
return cls.length > 0 && !cls.startsWith("element-hider-");
})
: [];
if (parent.tagName !== 'BODY' && parent.tagName !== 'HTML') {
const parentClasses = parent.className ? parent.className.trim().split(/\s+/).filter(cls => {
return cls.length > 0 && !cls.startsWith('element-hider-');
}) : [];
if (parentClasses.length > 0) {
const parentSelector =
parent.tagName.toLowerCase() +
"." +
parentClasses.slice(0, 2).join(".");
const parentSelector = parent.tagName.toLowerCase() + '.' + parentClasses.slice(0, 2).join('.');
selector = `${parentSelector} > ${selector}`;
}
}
@@ -131,14 +100,14 @@ function saveHiddenElement(element: HTMLElement): void {
const elementInfo = {
selector: selector,
tagName: element.tagName,
className: element.className || "",
textContent: element.textContent?.substring(0, 100) || "",
timestamp: Date.now(),
className: element.className || '',
textContent: element.textContent?.substring(0, 100) || '',
timestamp: Date.now()
};
// Check if element is already saved
const existingIndex = settings.hiddenElements.findIndex(
(stored) => stored.selector === elementInfo.selector,
stored => stored.selector === elementInfo.selector
);
if (existingIndex === -1) {
@@ -150,18 +119,17 @@ function saveHiddenElement(element: HTMLElement): void {
}
}
// Remove hidden element from persistent storage (for unhiding) - currently unused
// function removeSavedElement(element: HTMLElement): void {
// const selector = generateElementSelector(element);
// const index = settings.hiddenElements.findIndex(
// (stored) => stored.selector === selector,
// );
// if (index !== -1) {
// settings.hiddenElements.splice(index, 1);
// trace.log(`Permanently removed: ${selector}`);
// trace.log(`Remaining stored: ${settings.hiddenElements.length}`);
// }
// }
// Remove hidden element from persistent storage (for unhiding)
function removeSavedElement(element: HTMLElement): void {
const selector = generateElementSelector(element);
const index = settings.hiddenElements.findIndex(stored => stored.selector === selector);
if (index !== -1) {
settings.hiddenElements.splice(index, 1);
trace.log(`Permanently removed: ${selector}`);
trace.log(`Remaining stored: ${settings.hiddenElements.length}`);
}
}
// Check if an element matches any stored selector (EXACT match only)
function matchesStoredSelector(element: HTMLElement): boolean {
@@ -185,18 +153,15 @@ function hideElementDirectly(element: HTMLElement): void {
element.classList.add("element-hider-hidden");
hiddenElements.add(element);
trace.log(
`Hidden element: ${element.tagName}${element.className ? "." + element.className.split(" ")[0] : ""}`,
);
hiddenElementsArray.push(element);
trace.log(`Hidden element: ${element.tagName}${element.className ? '.' + element.className.split(' ')[0] : ''}`);
}
// Hide the target element with animation
function hideTargetElement(): void {
if (!targetElement) return;
trace.log(
`Hiding with animation: ${targetElement.tagName}${targetElement.className ? "." + targetElement.className.split(" ")[0] : ""}`,
);
trace.log(`Hiding with animation: ${targetElement.tagName}${targetElement.className ? '.' + targetElement.className.split(' ')[0] : ''}`);
// Add hiding animation class
targetElement.classList.add("element-hider-hiding");
@@ -210,11 +175,9 @@ function hideTargetElement(): void {
// Wait for animation to complete, then hide
setTimeout(() => {
elementToHide.classList.add("element-hider-hidden");
elementToHide.classList.remove(
"element-hider-hiding",
"element-hider-target",
);
elementToHide.classList.remove("element-hider-hiding", "element-hider-target");
hiddenElements.add(elementToHide);
hiddenElementsArray.push(elementToHide);
}, 300);
// Clear target reference
@@ -223,29 +186,26 @@ function hideTargetElement(): void {
// Unhide all elements permanently (remove from storage)
function unhideAllElements(): void {
trace.log(
`Permanently unhiding ${settings.hiddenElements.length} saved selectors`,
);
trace.log(`Permanently unhiding ${settings.hiddenElements.length} saved elements`);
// Show all currently hidden elements
document
.querySelectorAll(".element-hider-hidden, .element-hider-hiding")
.forEach((element) => {
hiddenElementsArray.forEach(element => {
if (document.body.contains(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)
function processAllElements(): void {
if (settings.hiddenElements.length === 0) return;
trace.log(
`Scanning document for ${settings.hiddenElements.length} stored selectors`,
);
trace.log(`Scanning document for ${settings.hiddenElements.length} stored selectors`);
let hiddenCount = 0;
// Use querySelectorAll for each stored selector with validation
@@ -257,9 +217,7 @@ function processAllElements(): void {
// Limit to prevent over-hiding (safety check)
if (elements.length > 10) {
trace.warn(
`Selector too broad (${elements.length} matches), skipping: ${storedElement.selector}`,
);
trace.warn(`Selector too broad (${elements.length} matches), skipping: ${storedElement.selector}`);
return;
}
@@ -268,9 +226,7 @@ function processAllElements(): void {
if (!hiddenElements.has(htmlElement)) {
hideElementDirectly(htmlElement);
hiddenCount++;
trace.log(
`Hid element ${elemIndex + 1}/${elements.length} for selector ${index + 1}`,
);
trace.log(`Hid element ${elemIndex + 1}/${elements.length} for selector ${index + 1}`);
}
});
} catch (error) {
@@ -285,7 +241,7 @@ function processAllElements(): void {
// Process new elements that are added to the DOM
function processNewElements(addedNodes: NodeList): void {
addedNodes.forEach((node) => {
addedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as HTMLElement;
@@ -296,8 +252,8 @@ function processNewElements(addedNodes: NodeList): void {
}
// Check all descendant elements
const descendants = element.querySelectorAll("*");
descendants.forEach((descendant) => {
const descendants = element.querySelectorAll('*');
descendants.forEach(descendant => {
if (matchesStoredSelector(descendant as HTMLElement)) {
hideElementDirectly(descendant as HTMLElement);
}
@@ -311,7 +267,7 @@ function setupElementObserver(): void {
elementObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
processNewElements(mutation.addedNodes);
}
});
@@ -319,25 +275,18 @@ function setupElementObserver(): void {
elementObserver.observe(document.body, {
childList: true,
subtree: true,
subtree: true
});
trace.log(`Set up reactive element observer`);
}
// Global functions
declare global {
interface Window {
showAllElementsFromSettings?: () => void;
debugElementHider?: () => void;
}
}
window.showAllElementsFromSettings = unhideAllElements;
window.debugElementHider = () => {
(window as any).showAllElementsFromSettings = unhideAllElements;
(window as any).debugElementHider = () => {
trace.log(`=== Element Hider Debug Info ===`);
trace.log(`Stored elements: ${settings.hiddenElements.length}`);
trace.log(`Currently hidden elements: ${getHiddenCount()}`);
trace.log(`Currently hidden elements: ${hiddenElementsArray.length}`);
trace.log(`Reactive hiding enabled: true`);
settings.hiddenElements.forEach((element, index) => {
trace.log(`${index + 1}. ${element.selector} (${element.tagName})`);
@@ -348,19 +297,19 @@ window.debugElementHider = () => {
// Handle highlighting target element
function highlightElement(element: HTMLElement): void {
// Remove previous highlights
document.querySelectorAll(".element-hider-target").forEach((el) => {
el.classList.remove("element-hider-target");
document.querySelectorAll('.element-hider-target').forEach(el => {
el.classList.remove('element-hider-target');
});
// Highlight current element
element.classList.add("element-hider-target");
element.classList.add('element-hider-target');
targetElement = element;
}
// Remove highlight
function removeHighlight(): void {
if (targetElement) {
targetElement.classList.remove("element-hider-target");
targetElement.classList.remove('element-hider-target');
targetElement = null;
}
}
@@ -372,17 +321,11 @@ let contextMenuTimeout: number | null = null;
let waitingForBuiltInMenu = false;
// Listen for right-click events to capture the target for context menu
document.addEventListener(
"contextmenu",
(event: MouseEvent) => {
document.addEventListener('contextmenu', (event: MouseEvent) => {
const target = event.target as HTMLElement;
// Don't interfere with native context menus on inputs, textareas, etc.
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
currentContextElement = null;
return;
}
@@ -403,7 +346,8 @@ document.addEventListener(
const eventX = event.clientX;
const eventY = event.clientY;
// Allow native context menu by default; we'll show our custom menu only if needed
// Prevent default immediately if we plan to handle it
event.preventDefault();
// Wait to see if the built-in context menu appears
contextMenuTimeout = window.setTimeout(() => {
@@ -415,14 +359,10 @@ document.addEventListener(
}, 150); // Wait 150ms for built-in menu
// Don't prevent default initially - let Luna try to handle the context menu
},
true,
);
}, true);
// Listen for clicks to close custom menu
document.addEventListener(
"click",
(event: MouseEvent) => {
document.addEventListener('click', (event: MouseEvent) => {
const target = event.target as HTMLElement;
// If clicking outside our custom menu, close it
@@ -430,12 +370,10 @@ document.addEventListener(
closeCustomMenu();
removeHighlight();
}
},
true,
);
}, true);
// Handle escape key to close custom menu and remove highlights
document.addEventListener("keydown", (event: KeyboardEvent) => {
document.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (customMenu) {
closeCustomMenu();
@@ -475,7 +413,7 @@ function createCustomMenu(): HTMLElement {
// Unhide All Elements option
const unhideAllItem = document.createElement("button");
unhideAllItem.className = "element-hider-menu-item";
unhideAllItem.innerHTML = `Unhide All Elements (${getHiddenCount()})`;
unhideAllItem.innerHTML = `Unhide All Elements (${hiddenElementsArray.length})`;
unhideAllItem.addEventListener("click", () => {
unhideAllElements();
closeCustomMenu();
@@ -526,15 +464,8 @@ const contextMenuObserver = new MutationObserver((mutations) => {
const element = node as HTMLElement;
// Look for Tidal's context menu
if (
element.matches('[data-test="contextmenu"]') ||
element.querySelector('[data-test="contextmenu"]')
) {
const contextMenu = element.matches('[data-test="contextmenu"]')
? element
: (element.querySelector(
'[data-test="contextmenu"]',
) as HTMLElement);
if (element.matches('[data-test="contextmenu"]') || element.querySelector('[data-test="contextmenu"]')) {
const contextMenu = element.matches('[data-test="contextmenu"]') ? element : element.querySelector('[data-test="contextmenu"]') as HTMLElement;
if (contextMenu && currentContextElement && waitingForBuiltInMenu) {
// Built-in menu appeared, cancel custom menu timeout
@@ -554,8 +485,8 @@ const contextMenuObserver = new MutationObserver((mutations) => {
// Add our options to the existing context menu
function addElementHiderOptions(contextMenu: HTMLElement): void {
// Create hide element button
const hideButton = document.createElement("button");
hideButton.className = "element-hider-menu-item";
const hideButton = document.createElement('button');
hideButton.className = 'element-hider-menu-item';
hideButton.style.cssText = `
display: flex;
align-items: center;
@@ -572,7 +503,7 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
`;
hideButton.innerHTML = `Hide This Element`;
hideButton.addEventListener("click", () => {
hideButton.addEventListener('click', () => {
if (currentContextElement) {
targetElement = currentContextElement;
hideTargetElement();
@@ -580,38 +511,37 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
});
// Add hover effects for highlighting
hideButton.addEventListener("mouseenter", () => {
hideButton.style.background = "var(--wave-color-background-hover, #3a3a3a)";
hideButton.addEventListener('mouseenter', () => {
hideButton.style.background = 'var(--wave-color-background-hover, #3a3a3a)';
if (currentContextElement) {
highlightElement(currentContextElement);
}
});
hideButton.addEventListener("mouseleave", () => {
hideButton.style.background = "transparent";
hideButton.addEventListener('mouseleave', () => {
hideButton.style.background = 'transparent';
removeHighlight();
});
// Create unhide all button
const unhideAllButton = document.createElement("button");
unhideAllButton.className = "element-hider-menu-item";
const unhideAllButton = document.createElement('button');
unhideAllButton.className = 'element-hider-menu-item';
unhideAllButton.style.cssText = hideButton.style.cssText;
unhideAllButton.innerHTML = `Unhide All Elements (${getHiddenCount()})`;
unhideAllButton.innerHTML = `Unhide All Elements (${hiddenElementsArray.length})`;
unhideAllButton.addEventListener("click", unhideAllElements);
unhideAllButton.addEventListener('click', unhideAllElements);
// Add hover effects for unhide all button
unhideAllButton.addEventListener("mouseenter", () => {
unhideAllButton.style.background =
"var(--wave-color-background-hover, #3a3a3a)";
unhideAllButton.addEventListener('mouseenter', () => {
unhideAllButton.style.background = 'var(--wave-color-background-hover, #3a3a3a)';
});
unhideAllButton.addEventListener("mouseleave", () => {
unhideAllButton.style.background = "transparent";
unhideAllButton.addEventListener('mouseleave', () => {
unhideAllButton.style.background = 'transparent';
});
// Add a separator if the menu has other items
if (contextMenu.children.length > 0) {
const separator = document.createElement("div");
const separator = document.createElement('div');
separator.style.cssText = `
height: 1px;
background: var(--wave-color-border, #444);
@@ -628,10 +558,10 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
// Start observing for context menus
contextMenuObserver.observe(document.body, {
childList: true,
subtree: true,
subtree: true
});
// Initialize plugin
// Initialize plugin
function initializePlugin() {
trace.log("Initializing plugin...");
@@ -648,8 +578,8 @@ function initializePlugin() {
}
// Run initialization when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializePlugin);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePlugin);
} else {
initializePlugin();
}
@@ -670,8 +600,8 @@ unloads.add(() => {
removeHighlight();
// Clean up global functions
window.showAllElementsFromSettings = undefined;
window.debugElementHider = undefined;
(window as any).showAllElementsFromSettings = undefined;
(window as any).debugElementHider = undefined;
trace.log("Plugin unloaded");
});
+1 -3
View File
@@ -57,9 +57,7 @@
/* Animation for hiding */
.element-hider-hiding {
transition:
opacity 0.3s ease,
transform 0.3s ease;
transition: opacity 0.3s ease, transform 0.3s ease;
opacity: 0;
transform: scale(0.95);
}
@@ -1,6 +1,6 @@
{
"name": "@meowarex/colorama-lyrics",
"description": "Customize lyrics colors: single, gradient & auto from cover art",
"name": "@meowarex/oled-theme",
"description": "A dark OLED-friendly theme plugin that transforms Tidal Luna's appearance.",
"author": {
"name": "meowarex",
"url": "https://github.com/meowarex",
+59
View File
@@ -0,0 +1,59 @@
import { ReactiveStore } from "@luna/core";
import { LunaSettings, LunaSwitchSetting } from "@luna/ui";
import React from "react";
export const settings = await ReactiveStore.getPluginStorage("OLEDTheme", {
qualityColorMatchedSeekBar: true,
oledFriendlyButtons: true,
lightMode: false,
});
export const Settings = () => {
const [qualityColorMatchedSeekBar, setQualityColorMatchedSeekBar] = React.useState(settings.qualityColorMatchedSeekBar);
const [oledFriendlyButtons, setOledFriendlyButtons] = React.useState(settings.oledFriendlyButtons);
const [lightMode, setLightMode] = React.useState(settings.lightMode);
return (
<LunaSettings>
<LunaSwitchSetting
title="Quality Color Matched Seek Bar"
desc="Color the Seek/Progress Bar based on audio quality"
checked={qualityColorMatchedSeekBar}
onChange={(_, checked) => {
console.log("Quality Color Matched Seek Bar:", checked ? "enabled" : "disabled");
setQualityColorMatchedSeekBar((settings.qualityColorMatchedSeekBar = checked));
// Update styles immediately when setting changes
if ((window as any).updateOLEDThemeStyles) {
(window as any).updateOLEDThemeStyles();
}
}}
/>
<LunaSwitchSetting
title="OLED Friendly Buttons"
desc="Remove button styling from OLED theme to keep buttons with original Tidal appearance"
checked={oledFriendlyButtons}
onChange={(_, checked) => {
console.log("OLED Friendly Buttons:", checked ? "enabled" : "disabled");
setOledFriendlyButtons((settings.oledFriendlyButtons = checked));
// Update styles immediately when setting changes
if ((window as any).updateOLEDThemeStyles) {
(window as any).updateOLEDThemeStyles();
}
}}
/>
<LunaSwitchSetting
title="Light Mode | Experimental"
desc="Use the light theme instead of the dark theme. This is experimental and may not work as expected."
checked={lightMode}
onChange={(_, checked) => {
console.log("Light Mode:", checked ? "enabled" : "disabled");
setLightMode((settings.lightMode = checked));
// Update styles immediately when setting changes
if ((window as any).updateOLEDThemeStyles) {
(window as any).updateOLEDThemeStyles();
}
}}
/>
</LunaSettings>
);
};
+301
View File
@@ -0,0 +1,301 @@
/*
{
"name": "Abyss Neptune",
"author": "@itzzexcel",
"description": "Abyss Neptune: ShadowX Theme from Spicetify to TIDAL (17/Jan/2025)"
}
*/
::-webkit-scrollbar {
display: none;
}
:root {
--wave-color-solid-accent-fill: white;
--wave-color-solid-rainbow-yellow-fill: white;
--wave-color-solid-contrast-fill: white;
--wave-color-solid-base-brighter: black;
--wave-text-body-medium: white !important;
--track-vibrant-color: white !important;
--wave-color-opacity-contrast-fill-ultra-thin: #fffafa1a !important;
--wave-color-solid-rainbow-yellow-darkest: #fffafa1a !important;
--wave-color-solid-accent-dark: rgb(128, 128, 128);
}
/* Credits to https://github.com/surfbryce for the fonts */
@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");
}
[class^="followingButton"],
[title="Unfollow"],
[title="Follow"],
[title="Unfollow"]>span,
[title="Follow"]>span {
background-color: var(--wave-color-solid-rainbow-yellow-fill) !important;
color: var(--wave-color-solid-base-brighter);
}
[class^="_wave-badge-color-max"] {
color: black !important;
background-color: var(--wave-color-solid-accent-fill);
border-radius: 3px;
}
[data-test="main-layout-sidebar-wrapper"] {
border-right: rgb(25, 25, 25) 1px solid;
}
[class^="_wave-badge"] {
background-color: var(--wave-color-solid-accent-fill);
border-radius: 4px;
color: black;
}
[class^="_progressBarWrapper"] {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="_sidebarItem"]>span {
color: var(--wave-color-solid-accent-dark);
}
[data-test="main-layout-header"] {
border-left: 0 !important;
}
[class^="_sidebarItem"]:hover span {
color: var(--wave-color-solid-contrast-fill);
}
[class^="_sidebarItem"] [class^="active"]>span {
color: var(--wave-color-solid-accent-dark) !important;
}
[class^="_active"] {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="ReactVirtualized__Grid"] {
border-radius: 10px;
margin: 5px;
}
[data-test="media-table"]>div>div>div {
border: 1px solid rgb(25, 25, 25) !important;
}
[class^="ReactVirtualized__Grid__innerScrollContainer"] {
border: none;
margin: 5px;
}
[class^="button"]>span {
color: black;
}
[class^="_explicitBadge"] {
color: var(--wave-color-solid-accent-fill);
}
[class^="viewAllButton"] {
border-radius: 4px;
display: grid;
place-items: center;
}
[data-test="current-media-imagery"] {
border: 0 !important;
margin: none;
}
[class^="_imageBorder"] {
display: none;
}
[class^="_headerButtons"]>button,
[class^="_headerButtons"]>button>span,
[data-test="toggle-picture-in-picture"] {
background-color: var(--wave-color-solid-accent-fill) !important;
color: black;
}
[class^="_container"]>[class^="_navigationArrows"] {
color: black;
background-color: var(--wave-color-solid-accent-fill) !important;
border-radius: 4px;
}
[class^="_buttons"]>button>span {
color: black !important;
}
[class^="_container"]>button {
border: 0px none;
}
[data-test="feed-sidebar"] {
margin-top: 10px;
}
[data-test="footer-player"] {
width: calc(100% - 20px);
bottom: 10px;
left: 10px;
border: 1px solid rgb(25, 25, 25);
border-radius: 4px !important;
position: absolute !important;
}
[class^="_tooltipContainer"]>button {
background-color: var(--wave-color-solid-accent-fill);
color: black;
}
[class^="_tooltipContainer"]>button:hover {
background-color: lightgray !important;
}
[class^="_tableRow"]:hover>*,
[data-test-is-playing="true"]>* {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="_tableRow"]>*,
[data-test-is-playing="false"]>* {
color: lightgray !important;
}
[class*="coverColumn"] {
padding-left: 5px !important;
}
[class^="actionList"] {
background-color: transparent;
margin: 0px;
border-radius: 5px;
}
button[data-test="request-fullscreen"],
button[data-test="close-now-playing"],
button[data-test="play-all"],
button[data-test="shuffle-all"] {
color: black;
background-color: var(--wave-color-solid-accent-fill);
border-radius: 12px;
}
button[data-test="request-fullscreen"]:hover,
button[data-test="close-now-playing"]:hover {
color: black;
background-color: lightgray !important;
}
.neptune-switch-checkbox:checked+.neptune-switch {
background-color: rgba(255, 255, 255, 0.1);
}
[data-test="navigation-arrows"]>button {
background-color: var(--wave-color-solid-accent-fill) !important;
color: black !important;
border-radius: 5px;
}
[data-test="navigation-arrows"]>button:disabled {
background-color: lightgray !important;
opacity: 1;
}
[data-test="main-layout-header"],
[data-test="feed-sidebar"],
[data-test="stream-metadata"],
[data-test="footer-player"] {
background-color: rgba(0, 0, 0, 0.8) !important;
backdrop-filter: blur(10px);
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[data-wave-color=textUrl] {
color: var(--wave-color-solid-accent-fill);
}
[class^="_smallHeader"] {
margin-top: 7.5px;
}
[data-test="play-all"]>div>*,
[data-test="shuffle-all"]>div>*,
[data-test="play-all"],
[data-test="shuffle-all"] {
color: var(--wave-color-solid-accent-fill) !important;
background-color: transparent !important;
}
[class^="__NEPTUNE_PAGE"],
[data-test="main"] {
margin-top: 35px;
}
[data-test="button-desktop-release-notes"],
[data-test="button-release-notes"] {
background-color: white;
}
[data-test="button-desktop-release-notes"]:hover,
[data-test="button-release-notes"]:hover {
background-color: lightgray !important;
transition: none !important;
}
#playQueueSidebar {
top: 50px !important;
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin);
margin: 2px;
margin-right: -14px !important;
background-color: rgba(0, 0, 0, 0.8) !important;
backdrop-filter: blur(10px);
}
[class^="_bottomGradient"] {
visibility: hidden;
}
[data-test="settings-page"] {
padding-bottom: 60px !important;
}
[data-test="query-suggestions"],
[data-test="recent-searches-container"] {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
}
[data-test="contextmenu"] {
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[class^="_dataContainer_"]::before {
background-image: var(--img);
filter: blur(10px) brightness(0.4);
}
+128
View File
@@ -0,0 +1,128 @@
import { LunaUnload, Tracer } from "@luna/core";
import { StyleTag, observePromise, PlayState, Quality, type MediaItem } from "@luna/lib";
import { settings, Settings } from "./Settings";
// Import CSS files directly using Luna's file:// syntax - Took me a while to figure out <3
import darkTheme from "file://dark-theme.css?minify";
import oledFriendlyTheme from "file://oled-friendly.css?minify";
import lightTheme from "file://light-theme.css?minify";
export const { trace } = Tracer("[OLED Theme]");
export { Settings };
// called when plugin is unloaded.
// clean up resources
export const unloads = new Set<LunaUnload>();
// StyleTag instance for theme management
const themeStyleTag = new StyleTag("OLED-Theme", unloads);
// Quality color mapping
const QUALITY_COLORS = {
MAX: "#FED330", // Max/HiFi
HIGH: "#31FFEE", // High
LOW: "#FFFFFE" // Low
};
// Function to get quality color based on audio quality
const getQualityColor = (audioQuality: string): string => {
const quality = audioQuality?.toUpperCase();
if (quality?.includes("HI_RES_LOSSLESS")) {
return QUALITY_COLORS.MAX;
} else if (quality?.includes("LOSSLESS")) {
return QUALITY_COLORS.HIGH;
} else {
return QUALITY_COLORS.LOW;
}
};
// Function to Reset Seek Bar Color (if setting gets disabled while playing)
const resetSeekBarColor = async (): Promise<void> => {
try {
const progressBarWrapper = await observePromise<HTMLElement>(unloads, `[class^="_progressBarWrapper"]`);
if (!progressBarWrapper) return;
progressBarWrapper.style.removeProperty('color');
progressBarWrapper.querySelectorAll('[class*="progress"], [class*="bar"]').forEach(el => {
if (el instanceof HTMLElement) el.style.removeProperty('color');
});
} catch (error) {
trace.msg.err(`Failed to reset seek bar color: ${error}`);
}
};
// Function to apply quality-based seek bar coloring (if enabled)
const applyQualityColors = async (): Promise<void> => {
if (!settings.qualityColorMatchedSeekBar) return;
try {
const progressBarWrapper = await observePromise<HTMLElement>(unloads, `[class^="_progressBarWrapper"]`);
if (!progressBarWrapper) return;
const audioQuality = PlayState.playbackContext?.actualAudioQuality;
if (!audioQuality) return;
const qualityColor = getQualityColor(audioQuality);
progressBarWrapper.style.setProperty('color', qualityColor, 'important');
progressBarWrapper.querySelectorAll('[class*="progress"], [class*="bar"]').forEach(el => {
if (el instanceof HTMLElement) el.style.setProperty('color', qualityColor, 'important');
});
//trace.msg.log(`Applied quality color ${qualityColor}`);
} catch (error) {
trace.msg.err(`Failed to apply quality colors: ${error}`);
}
};
// Function to monitor track changes using track ID
const setupQualityMonitoring = (): void => {
let lastTrackId: string | null = null;
const interval = setInterval(() => {
if (!settings.qualityColorMatchedSeekBar) return;
const currentTrackId = PlayState.playbackContext?.actualProductId;
if (currentTrackId && currentTrackId !== lastTrackId) {
//trace.msg.log(`[OLED Theme] Track ID changed: ${lastTrackId} -> ${currentTrackId}`);
lastTrackId = currentTrackId;
applyQualityColors();
}
}, 250);
unloads.add(() => clearInterval(interval));
// Initial color application (if a track is already loaded)
const currentTrackId = PlayState.playbackContext?.actualProductId;
if (settings.qualityColorMatchedSeekBar && currentTrackId) {
lastTrackId = currentTrackId;
applyQualityColors();
}
};
// Function to apply theme styles based on current settings
const applyThemeStyles = function(): void {
// Choose the appropriate CSS file based on settings
let selectedStyle: string;
if (settings.lightMode) {
// Light mode - (OLED friendly doesn't apply to light theme)
selectedStyle = lightTheme;
} else {
// Dark mode
selectedStyle = settings.oledFriendlyButtons ? oledFriendlyTheme : darkTheme;
}
// Remove SeekBar coloring if Quality Color Matched Seek Bar is enabled
// This allows our manual coloring to take precedence
if (settings.qualityColorMatchedSeekBar) {
selectedStyle = selectedStyle.replace(/\[class\^="_progressBarWrapper"\]\s*\{[^}]*\}/g, '');
setupQualityMonitoring();
} else {
// If disabling, reset the seek bar color
resetSeekBarColor();
}
// Apply the selected theme using StyleTag
themeStyleTag.css = selectedStyle;
};
// Make this function available globally so Settings can call it
(window as any).updateOLEDThemeStyles = applyThemeStyles;
// Apply the OLED theme initially
applyThemeStyles();
+424
View File
@@ -0,0 +1,424 @@
/*
{
"name": "Abyss Neptune - Light",
"author": "@itzzexcel",
"description": "Abyss Neptune Light Theme for TIDAL (17/Jan/2025)"
}
*/
::-webkit-scrollbar {
display: none;
}
:root {
--wave-color-solid-accent-fill: #666666;
--wave-color-solid-rainbow-yellow-fill: #666666;
--wave-color-solid-contrast-fill: #666666;
--wave-color-solid-base-brighter: #666666;
--wave-text-body-medium: #333333 !important;
--track-vibrant-color: #666666 !important;
--wave-color-opacity-contrast-fill-ultra-thin: #c0c0c0 !important;
--wave-color-solid-rainbow-yellow-darkest: #c0c0c0 !important;
--wave-color-solid-accent-dark: #555555;
}
/* Credits to https://github.com/surfbryce for the fonts */
@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");
}
[class^="followingButton"],
[title="Unfollow"],
[title="Follow"],
[title="Unfollow"]>span,
[title="Follow"]>span {
background-color: var(--wave-color-solid-rainbow-yellow-fill) !important;
color: var(--wave-color-solid-base-brighter);
}
[class^="_wave-badge-color-max"] {
color: #333333 !important;
background-color: var(--wave-color-solid-accent-fill);
border-radius: 3px;
}
[data-test="main-layout-sidebar-wrapper"] {
border-right: rgb(230, 230, 230) 1px solid;
background-color: rgba(250, 250, 250, 0.95) !important;
}
[class^="_wave-badge"] {
background-color: var(--wave-color-solid-accent-fill);
border-radius: 4px;
color: #333333;
}
[class^="_progressBarWrapper"] {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="_sidebarItem"]>span {
color: #666666;
}
[data-test="main-layout-header"] {
border-left: 0 !important;
}
[class^="_sidebarItem"]:hover span {
color: #333333;
}
[class^="_sidebarItem"] [class^="active"]>span {
color: #333333 !important;
}
/* Sidebar icons and text - ensure grey colors */
[data-test="main-layout-sidebar-wrapper"] svg,
[data-test="main-layout-sidebar-wrapper"] path,
[class^="_sidebarItem"] svg,
[class^="_sidebarItem"] path {
fill: #666666 !important;
color: #666666 !important;
}
[data-test="main-layout-sidebar-wrapper"] span,
[class^="_sidebarItem"] span {
color: #666666 !important;
}
[class^="_active"] {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="ReactVirtualized__Grid"] {
border-radius: 10px;
margin: 5px;
}
[data-test="media-table"]>div>div>div {
border: 1px solid rgb(230, 230, 230) !important;
}
[class^="ReactVirtualized__Grid__innerScrollContainer"] {
border: none;
margin: 5px;
}
[class^="button"]>span {
color: #333333;
}
[class^="_explicitBadge"] {
color: var(--wave-color-solid-accent-fill);
}
[class^="viewAllButton"] {
border-radius: 4px;
display: grid;
place-items: center;
}
[data-test="current-media-imagery"] {
border: 0 !important;
margin: none;
}
[class^="_imageBorder"] {
display: none;
}
[class^="_headerButtons"]>button,
[class^="_headerButtons"]>button>span,
[data-test="toggle-picture-in-picture"] {
background-color: var(--wave-color-solid-accent-fill) !important;
color: #333333;
}
[class^="_container"]>[class^="_navigationArrows"] {
color: #333333;
background-color: var(--wave-color-solid-accent-fill) !important;
border-radius: 4px;
}
[class^="_buttons"]>button>span {
color: #333333 !important;
}
[class^="_container"]>button {
border: 0px none;
}
[data-test="feed-sidebar"] {
margin-top: 10px;
}
[data-test="footer-player"] {
width: calc(100% - 20px);
bottom: 10px;
left: 10px;
border: 1px solid rgba(200, 200, 200, 0.7);
border-radius: 4px !important;
position: absolute !important;
}
[class^="_tooltipContainer"]>button {
background-color: var(--wave-color-solid-accent-fill);
color: #333333;
}
[class^="_tooltipContainer"]>button:hover {
background-color: #555555 !important;
}
[class^="_tableRow"]:hover>*,
[data-test-is-playing="true"]>* {
color: #333333 !important;
}
[class^="_tableRow"]>*,
[data-test-is-playing="false"]>* {
color: #333333 !important;
}
/* Track list text - ensure all text is dark */
[data-test="media-table"] *,
[class^="_trackTitle"],
[class^="_artistName"],
[class^="_albumTitle"],
[class^="_tableCell"] *,
[class^="_tableCellContent"] * {
color: #333333 !important;
}
[class*="coverColumn"] {
padding-left: 5px !important;
}
[class^="actionList"] {
background-color: transparent;
margin: 0px;
border-radius: 5px;
}
button[data-test="request-fullscreen"],
button[data-test="close-now-playing"],
button[data-test="play-all"],
button[data-test="shuffle-all"] {
color: #333333;
background-color: var(--wave-color-solid-accent-fill);
border-radius: 12px;
}
button[data-test="request-fullscreen"]:hover,
button[data-test="close-now-playing"]:hover {
color: #333333;
background-color: #aaaaaa !important;
}
.neptune-switch-checkbox:checked+.neptune-switch {
background-color: rgba(0, 0, 0, 0.1);
}
[data-test="navigation-arrows"]>button {
background-color: var(--wave-color-solid-accent-fill) !important;
color: #333333 !important;
border-radius: 5px;
}
[data-test="navigation-arrows"]>button:disabled {
background-color: #cccccc !important;
opacity: 1;
}
[data-test="main-layout-header"] {
background-color: rgba(235, 235, 235, 0.95) !important;
backdrop-filter: blur(10px);
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[data-test="feed-sidebar"] {
background-color: rgba(225, 225, 225, 0.9) !important;
backdrop-filter: blur(10px);
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[data-test="stream-metadata"] {
background-color: rgba(230, 230, 230, 0.92) !important;
backdrop-filter: blur(10px);
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[data-test="footer-player"] {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(15px);
border: 1px solid rgba(200, 200, 200, 0.7) !important;
}
[data-wave-color=textUrl] {
color: var(--wave-color-solid-accent-fill);
}
[class^="_smallHeader"] {
margin-top: 7.5px;
}
/* Button styling using proper light theme approach */
:root {
--button-light: #d9d9d9 !important;
--button-medium: #cbcbcb !important;
}
/*buttons*/
._activeTab_f47dafa {
background: #0000001c;
}
/*canvas nav buttons*/
.viewAllButton--Nb87U,
.css-7l8ggf {
background: #e0e0e0;
}
.viewAllButton--Nb87U:hover,
.css-7l8ggf:hover {
background: #cbcbcb;
}
/*tracks page*/
.variantPrimary--pjymy,
._button_3357ce6 {
background-color: var(--button-light);
}
._button_f1c7fcb {
background: var(--wave-color-solid-base-brighter);
}
._button_84b8ffe {
background-color: var(--wave-color-solid-base-brighter);
}
._button_84b8ffe:hover {
background-color: var(--wave-color-solid-base-brightest);
}
.button--_0I_t {
background-color: var(--button-light);
}
.button--_0I_t:hover {
background-color: var(--wave-color-opacity-contrast-fill-regular);
}
._button_94c5125 {
background: var(--wave-color-solid-base-brighter);
}
.primary--NLSX4 {
background-color: #d5d5d5;
}
.primary--NLSX4:hover {
background-color: var(--wave-color-opacity-contrast-fill-regular) !important;
}
.primary--NLSX4:disabled {
background-color: #e7e7e8;
}
.primary--NLSX4:disabled:hover {
background-color: #e7e7e8;
}
[class^="__NEPTUNE_PAGE"],
[data-test="main"] {
margin-top: 35px;
}
[data-test="button-desktop-release-notes"],
[data-test="button-release-notes"] {
background-color: #333333;
}
[data-test="button-desktop-release-notes"]:hover,
[data-test="button-release-notes"]:hover {
background-color: #555555 !important;
transition: none !important;
}
#playQueueSidebar {
top: 50px !important;
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin);
margin: 2px;
margin-right: -14px !important;
background-color: rgba(220, 220, 220, 0.9) !important;
backdrop-filter: blur(10px);
}
[class^="_bottomGradient"] {
visibility: hidden;
}
[data-test="settings-page"] {
padding-bottom: 60px !important;
}
[data-test="query-suggestions"],
[data-test="recent-searches-container"] {
background-color: rgba(227, 227, 227, 0.85);
backdrop-filter: blur(10px);
}
[data-test="contextmenu"] {
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[class^="_dataContainer_"]::before {
background-image: var(--img);
filter: blur(10px) brightness(1.2);
}
/* Player bar text colors - fix white text issues */
[data-test="footer-player"] * {
color: #333333 !important;
}
[data-test="footer-player"] [class*="trackTitle"],
[data-test="footer-player"] [class*="artistName"],
[data-test="footer-player"] [class*="trackInfo"],
[data-test="footer-player"] [class*="duration"],
[data-test="footer-player"] [class*="time"],
[data-test="footer-player"] [class*="timestamp"] {
color: #333333 !important;
}
/* Main page background */
body,
[data-test="main"],
[class^="__NEPTUNE_PAGE"] {
background-color: #f5f5f5 !important;
}
@@ -0,0 +1,215 @@
/*
{
"name": "Abyss Neptune - OLED Friendly",
"author": "@itzzexcel",
"description": "Abyss Neptune theme without button styling for OLED displays"
}
*/
::-webkit-scrollbar {
display: none;
}
:root {
--wave-color-solid-accent-fill: white;
--wave-color-solid-rainbow-yellow-fill: white;
--wave-color-solid-contrast-fill: white;
--wave-color-solid-base-brighter: black;
--wave-text-body-medium: white !important;
--track-vibrant-color: white !important;
--wave-color-opacity-contrast-fill-ultra-thin: #fffafa1a !important;
--wave-color-solid-rainbow-yellow-darkest: #fffafa1a !important;
--wave-color-solid-accent-dark: rgb(128, 128, 128);
}
/* Credits to https://github.com/surfbryce for the fonts */
@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");
}
[class^="followingButton"],
[title="Unfollow"],
[title="Follow"],
[title="Unfollow"]>span,
[title="Follow"]>span {
background-color: var(--wave-color-solid-rainbow-yellow-fill) !important;
color: var(--wave-color-solid-base-brighter);
}
[class^="_wave-badge-color-max"] {
color: black !important;
background-color: var(--wave-color-solid-accent-fill);
border-radius: 3px;
}
[data-test="main-layout-sidebar-wrapper"] {
border-right: rgb(25, 25, 25) 1px solid;
}
[class^="_wave-badge"] {
background-color: var(--wave-color-solid-accent-fill);
border-radius: 4px;
color: black;
}
[class^="_progressBarWrapper"] {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="_sidebarItem"]>span {
color: var(--wave-color-solid-accent-dark);
}
[data-test="main-layout-header"] {
border-left: 0 !important;
}
[class^="_sidebarItem"]:hover span {
color: var(--wave-color-solid-contrast-fill);
}
[class^="_sidebarItem"] [class^="active"]>span {
color: var(--wave-color-solid-accent-dark) !important;
}
[class^="_active"] {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="ReactVirtualized__Grid"] {
border-radius: 10px;
margin: 5px;
}
[data-test="media-table"]>div>div>div {
border: 1px solid rgb(25, 25, 25) !important;
}
[class^="ReactVirtualized__Grid__innerScrollContainer"] {
border: none;
margin: 5px;
}
[class^="_explicitBadge"] {
color: var(--wave-color-solid-accent-fill);
}
[data-test="current-media-imagery"] {
border: 0 !important;
margin: none;
}
[class^="_imageBorder"] {
display: none;
}
[data-test="feed-sidebar"] {
margin-top: 10px;
}
[data-test="footer-player"] {
width: calc(100% - 20px);
bottom: 10px;
left: 10px;
border: 1px solid rgb(25, 25, 25);
border-radius: 4px !important;
position: absolute !important;
}
[class^="_tableRow"]:hover>*,
[data-test-is-playing="true"]>* {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="_tableRow"]>*,
[data-test-is-playing="false"]>* {
color: lightgray !important;
}
[class*="coverColumn"] {
padding-left: 5px !important;
}
[class^="actionList"] {
background-color: transparent;
margin: 0px;
border-radius: 5px;
}
.neptune-switch-checkbox:checked+.neptune-switch {
background-color: rgba(255, 255, 255, 0.1);
}
[data-test="main-layout-header"],
[data-test="feed-sidebar"],
[data-test="stream-metadata"],
[data-test="footer-player"] {
background-color: rgba(0, 0, 0, 0.8) !important;
backdrop-filter: blur(10px);
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[data-wave-color=textUrl] {
color: var(--wave-color-solid-accent-fill);
}
[class^="_smallHeader"] {
margin-top: 7.5px;
}
[class^="__NEPTUNE_PAGE"],
[data-test="main"] {
margin-top: 35px;
}
#playQueueSidebar {
top: 50px !important;
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin);
margin: 2px;
margin-right: -14px !important;
background-color: rgba(0, 0, 0, 0.8) !important;
backdrop-filter: blur(10px);
}
[class^="_bottomGradient"] {
visibility: hidden;
}
[data-test="settings-page"] {
padding-bottom: 60px !important;
}
[data-test="query-suggestions"],
[data-test="recent-searches-container"] {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
}
[data-test="contextmenu"] {
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[class^="_dataContainer_"]::before {
background-image: var(--img);
filter: blur(10px) brightness(0.4);
}
File diff suppressed because it is too large Load Diff
-294
View File
@@ -1,294 +0,0 @@
import { Tracer } from "@luna/core";
import { settings } from "./Settings";
const { trace } = Tracer("[Radiant Lyrics]");
const sylTrace = (...args: unknown[]) => {
if (settings.syllableLogging) trace.log(...args);
};
export const RL_PLATFORM = "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;
}
@@ -43,16 +43,21 @@
backface-visibility: hidden;
}
/* Hide Tidal's native now-playing background color overlay */
[data-test="new-now-playing"] > [class*="_background_"] {
/* biome-ignore lint: Must override native album-art-derived background */
display: none !important;
/* Performance mode optimizations - keep spinning but optimize other aspects */
.global-spinning-image.performance-mode-static {
/* Keep animation enabled in performance mode */
/* Lighter blur for performance */
filter: blur(20px) brightness(0.4) contrast(1.2) saturate(1) !important;
/* Smaller size for performance */
width: 120vw !important;
height: 120vh !important;
}
/* Ensure the now-playing container itself is transparent */
[class*="_nowPlayingContainer"] {
/* biome-ignore lint: Must override any inline background styles */
background: transparent !important;
.now-playing-background-image.performance-mode-static {
/* Keep animation enabled in performance mode */
/* Optimized size and effects for performance */
width: 80vw !important;
height: 80vh !important;
}
/* Now Playing Background Container Optimization */
@@ -62,7 +67,7 @@
top: 0;
width: 100%;
height: 100%;
z-index: 0;
z-index: -3;
pointer-events: none;
overflow: hidden;
/* Hardware acceleration */
@@ -70,13 +75,6 @@
backface-visibility: hidden;
}
/* Ensure now-playing content renders above the dynamic background */
[data-test="new-now-playing"] > header,
[data-test="new-now-playing"] > [class*="_content_"] {
position: relative;
z-index: 1;
}
/* Optimized keyframe animations with GPU acceleration */
@keyframes spinGlobal {
from {
@@ -91,11 +89,8 @@
@media (prefers-reduced-motion: reduce) {
.global-spinning-image,
.now-playing-background-image {
/* biome-ignore lint: Accessibility override needs priority */
animation: none !important;
/* biome-ignore lint: Accessibility override needs priority */
transform: translate(-50%, -50%) !important;
/* biome-ignore lint: Accessibility override needs priority */
will-change: auto !important;
}
}
@@ -104,62 +99,60 @@
.performance-mode .global-spinning-image,
.performance-mode .now-playing-background-image {
/* Keep animations but optimize filter effects */
/* biome-ignore lint: Intentional override of runtime styles */
filter: blur(10px) brightness(0.4) contrast(1.1) !important;
}
/* Make app chrome transparent for cover-everywhere background */
/* Make Notification Feed sidebar transparent */
body,
#wimp,
main,
[class^="_sidebarWrapper"],
[class^="_mainContainer"],
[class*="smallHeader"],
[data-test="main-layout-sidebar-wrapper"],
[data-test="main-layout-header"],
[data-test="feed-sidebar"],
[data-test="stream-metadata"],
[data-test="footer-player"],
/* Notification Feed sidebar specific container */
[class^="_feedSidebarVStack"],
[class^="_feedSidebarSpacer"],
[class^="_feedSidebarItem"],
[class^="_feedSidebarItemDiv"],
[class^="_cellContainer"] {
/* biome-ignore lint: Ensure background is fully cleared under theme CSS */
[class^="_cellContainer"],
[class^="_cellTextContainer"] {
background: unset !important;
}
/* Make sidebar semi-transparent with optimized backdrop-filter */
[data-test="main-layout-sidebar-wrapper"] {
/* biome-ignore lint: Must beat app inline styles for translucency */
/* Make sidebar and player bar semi-transparent with optimized backdrop-filter */
[data-test="footer-player"],
[data-test="main-layout-sidebar-wrapper"],
[class^="_bar"],
[class^="_sidebarItem"]:hover {
background-color: rgba(0, 0, 0, 0.3) !important;
/* biome-ignore lint: Must beat app inline styles for translucency */
backdrop-filter: blur(10px) !important;
/* biome-ignore lint: Must beat app inline styles for translucency */
-webkit-backdrop-filter: blur(10px) !important;
}
/* Performance mode: reduce backdrop blur */
.performance-mode [data-test="main-layout-sidebar-wrapper"] {
/* biome-ignore lint: Performance mode style requires priority */
.performance-mode [data-test="footer-player"],
.performance-mode [data-test="main-layout-sidebar-wrapper"],
.performance-mode [class^="_bar"],
.performance-mode [class^="_sidebarItem"]:hover {
backdrop-filter: blur(5px) !important;
/* biome-ignore lint: Performance mode style requires priority */
-webkit-backdrop-filter: blur(5px) !important;
}
/* Feed sidebar panel - black tint background for readability */
[data-test="feed-sidebar"] {
/* biome-ignore lint: Ensure readability over media */
background-color: rgba(0, 0, 0, 0.5) !important;
/* biome-ignore lint: Ensure readability over media */
backdrop-filter: blur(10px) !important;
/* biome-ignore lint: Ensure readability over media */
-webkit-backdrop-filter: blur(10px) !important;
}
/* Performance mode: reduce sidebar backdrop blur */
.performance-mode [data-test="feed-sidebar"] {
/* biome-ignore lint: Performance mode style requires priority */
backdrop-filter: blur(5px) !important;
/* biome-ignore lint: Performance mode style requires priority */
-webkit-backdrop-filter: blur(5px) !important;
}
@@ -169,6 +162,10 @@ main,
[class*="_cellContainer"],
[data-test="feed-interval"],
[data-test="feed-item"] {
/* biome-ignore lint: Match theme transparency */
background-color: transparent !important;
}
/* Remove bottom gradient */
[class^="_bottomGradient"] {
display: none !important;
}
@@ -1,22 +0,0 @@
/* Square Player Bar override — injected when floating is disabled */
/* MARKER: Floating Player Bar CSS */
[data-test="footer-player"] {
/* biome-ignore lint: Override native floating position */
bottom: 0 !important;
/* biome-ignore lint: Override native floating position */
left: 0 !important;
/* biome-ignore lint: Override native floating position */
right: 0 !important;
/* biome-ignore lint: Override native floating position */
width: 100% !important;
/* biome-ignore lint: Override native floating position */
margin: 0 !important;
/* biome-ignore lint: Force square corners */
border-radius: 0 !important;
/* biome-ignore lint: Remove floating border */
border: none !important;
/* biome-ignore lint: Remove floating shadow */
box-shadow: none !important;
}
File diff suppressed because it is too large Load Diff
+80 -29
View File
@@ -1,36 +1,87 @@
/* Radiant Lyrics — text glow only (injected when Lyrics Glow is enabled) */
/* MARKER: Lyrics glow CSS */
[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;
/* Font imports for lyrics */
@font-face {
font-family: "AbyssFont";
font-weight: 400;
src: url("https://excel.lexploits.top/extra/tidal/LyricsRegular.woff2") format("woff2");
}
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"]:hover {
text-shadow:
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;
@font-face {
font-family: "AbyssFont";
font-weight: 500;
src: url("https://excel.lexploits.top/extra/tidal/LyricsMedium.woff2") format("woff2");
}
.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:
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;
@font-face {
font-family: "AbyssFont";
font-weight: 600;
src: url("https://excel.lexploits.top/extra/tidal/LyricsSemibold.woff2") format("woff2");
}
.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;
@font-face {
font-family: "AbyssFont";
font-weight: 700;
src: url("https://excel.lexploits.top/extra/tidal/LyricsBold.woff2") format("woff2");
}
/* Enhanced lyrics styling with glow effects */
[class*="_lyricsText"] > div > span[data-current="true"] {
text-shadow: 0 0 2px #fff, 0 0 20px #fff !important;
padding-left: 20px;
transition-duration: 0.7s;
font-size: 55px;
color: white !important;
font-family: "AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-weight: 700;
}
[class*="_lyricsText"] > div > span {
text-shadow: 0 0 0px transparent, 0 0 0px transparent;
transition-duration: 0.25s;
color: rgba(128, 128, 128, 0.4);
font-size: 40px;
font-family: "AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-weight: 700;
}
[class*="_lyricsText"] > div > span:hover {
text-shadow: 0 0 2px lightgray, 0 0 20px lightgray !important;
color: lightgray !important;
padding-left: 20px;
transition-duration: 0.7s;
}
/* Track title glow */
[data-test="now-playing-track-title"] {
text-shadow: 0 0 1px #fff, 0 0 30px #fff !important;
}
/* Current line transitions */
[class*="_lyricsText"] > div > span {
transition: text-shadow 0.7s ease-in-out, color 0.7s ease-in-out, padding 0.7s ease-in-out !important;
}
/* Lyrics container styling */
[class^="_lyricsContainer"] > div > div > span {
margin-bottom: 2rem;
opacity: 1;
font-family: "AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-weight: 700;
font-size: 38px !important;
}
/* Reset all lyrics styling when disabled */
.lyrics-glow-disabled [class*="_lyricsText"] > div > span[data-current="true"],
.lyrics-glow-disabled [class*="_lyricsText"] > div > span,
.lyrics-glow-disabled [class*="_lyricsText"] > div > span:hover,
.lyrics-glow-disabled [data-test="now-playing-track-title"],
.lyrics-glow-disabled [class^="_lyricsContainer"] > div > div > span {
text-shadow: none !important;
padding-left: 0 !important;
transition: none !important;
font-size: inherit !important;
color: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
margin-bottom: inherit !important;
opacity: inherit !important;
}
@@ -9,7 +9,7 @@
}
/* Also show player bar when hovering over the bottom area - only when UI is hidden */
.radiant-lyrics-ui-hidden body.rl-footer-hover [data-test="footer-player"],
.radiant-lyrics-ui-hidden:has([data-test="footer-player"]:hover) [data-test="footer-player"],
.radiant-lyrics-ui-hidden [data-test="footer-player"]:hover {
opacity: 1 !important;
}
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -39,13 +39,9 @@ importers:
specifier: ^5.8.3
version: 5.8.3
plugins/audio-visualizer-luna: {}
plugins/colorama-lyrics-luna: {}
plugins/copy-lyrics-luna: {}
plugins/element-hider-luna: {}
plugins/oled-theme-luna: {}
plugins/radiant-lyrics-luna: {}
-5
View File
@@ -1,7 +1,2 @@
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