mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-18 03:43:10 +10:00
Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a7d4f9c07 | |||
| 8a86de1b39 | |||
| f7fa918473 | |||
| fa273705ad | |||
| f069d7eae2 | |||
| 497f3a95b0 | |||
| 734e0012cc | |||
| 3d8a755c0f | |||
| 06c4adf54b | |||
| e2614d1b68 | |||
| 4c6ee03df6 | |||
| d860383d65 | |||
| b28e245019 | |||
| 83ef103118 | |||
| e890c86b85 | |||
| 957285bbbf | |||
| 35d03dc116 | |||
| 1dc77fc9d8 | |||
| be61b0bbb5 | |||
| 5f0795919d | |||
| 59af461ea1 | |||
| 548e4bcaf0 | |||
| 6ea24618d9 | |||
| 20a4f11818 | |||
| 6603c87eb3 | |||
| f34382aa08 | |||
| ce8b1da26d | |||
| daf76c5ffc | |||
| 306ab3a862 | |||
| a45a834580 | |||
| 3f5aaf746b | |||
| 4b2478c301 | |||
| 9c62d3e1f2 | |||
| 59562f8264 | |||
| 40853d6e64 | |||
| 74e3c97147 | |||
| b79e15b6c5 | |||
| 9d1ca88e46 | |||
| bff87b96a1 | |||
| 9d6afcaaf5 | |||
| 8f995d8474 | |||
| 5189d2bbea | |||
| 1ab2eda25c | |||
| 5e00accc7f | |||
| 765c8baf96 | |||
| d6c2d3ac88 | |||
| 7748f2fe08 | |||
| 7ad4bbb332 | |||
| b493624bda | |||
| 651e5cbc14 | |||
| 3e51ac45f8 | |||
| 76b1e264f8 | |||
| c88ddef2f9 | |||
| 38cdc156d6 | |||
| 055fff6d47 | |||
| 00eaf37dfa | |||
| c6e916e6f6 | |||
| 64dfe47592 | |||
| ef4c73037f | |||
| ec25abf6f5 | |||
| 7d2f3d3c1a | |||
| e766bac0fa | |||
| d07444e102 | |||
| 56c73abc05 | |||
| 20adbd26dc | |||
| ff417f5472 | |||
| 0a694a5bc0 | |||
| 84af1a40f6 | |||
| adcbadcf49 | |||
| af4cd80c7c | |||
| 256dd3d724 | |||
| d6a3b26b41 | |||
| df80ef748e | |||
| 68fc92b2db | |||
| 1aa12e9fd3 | |||
| 8196ed6778 | |||
| 6af3b93272 | |||
| 422d03a54e | |||
| b27f0ca165 | |||
| cd35fee3f0 | |||
| bce5ddba54 | |||
| 9c537fa877 | |||
| 6981cc8315 | |||
| 56b7476e92 | |||
| 09857b6b54 | |||
| 5e700692e7 | |||
| dc82194a90 | |||
| 36257a954e | |||
| e62944a0df | |||
| 6d9184e5eb | |||
| 081b4cbdd8 | |||
| 4ca99ebd72 | |||
| 047d4de2f4 | |||
| d83a786de3 | |||
| 0356ea6b76 | |||
| b9a9588f9d | |||
| fa0a7b7f56 | |||
| f2c31bb33a | |||
| 78d960588c | |||
| 2ea44bd3cc | |||
| 9c9b47c930 | |||
| d53fd08ee8 | |||
| 11d08b6403 | |||
| 0d9b378e43 | |||
| 99661096d5 | |||
| 8178699d81 | |||
| 82dfb39ff5 | |||
| 0b9c27eaaf | |||
| 40ed89dd34 | |||
| c0255acb4c | |||
| 1fda054d2a | |||
| cf9bbb62e6 | |||
| 7de6a98d8e | |||
| 2e7e51b7eb | |||
| fe3f0011eb |
@@ -0,0 +1,7 @@
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.css text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yaml text eol=lf
|
||||
@@ -13,8 +13,7 @@ jobs:
|
||||
|
||||
- name: Install pnpm 📥
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
# Version is read from `packageManager` in package.json for reproducible builds.
|
||||
|
||||
- name: Install Node.js 📥
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
+2
-4
@@ -1,6 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist/itzzexcel.oled-theme.json
|
||||
dist/itzzexcel.oled-theme.mjs
|
||||
dist/itzzexcel.oled-theme.mjs.map
|
||||
dist/store.json
|
||||
Notes.md
|
||||
/Reference/
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"snyk.advanced.organization": "5e35d31d-0df9-4f3c-b4b3-168a24114801",
|
||||
"snyk.advanced.autoSelectOrganization": true
|
||||
}
|
||||
@@ -1,30 +1,23 @@
|
||||
# Luna Plugins Collection
|
||||
|
||||
A collection of Luna plugins for Tidal, ported from Neptune framework.
|
||||
A collection of [TidaLuna](https://github.com/Inrixia/TidaLuna) plugins that enhance and personalize the TIDAL Desktop experience. Build them yourself or grab them straight from the Plugin Store. Made with <3
|
||||
|
||||
## Plugins
|
||||
|
||||
### 🎨 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
|
||||
### 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.
|
||||
@@ -34,7 +27,17 @@ Allows users to copy song lyrics by selecting them directly in the interface.
|
||||
- Automatic clipboard copying of selected lyrics
|
||||
- Smart lyric span detection
|
||||
|
||||
### 🎶 Audio Visualizer
|
||||
### 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
|
||||
**Location:** `plugins/audio-visualizer-luna/`
|
||||
|
||||
⚠️ **Work in Progress** - Audio visualization plugin that displays real-time audio frequency data.
|
||||
@@ -49,8 +52,21 @@ Allows users to copy song lyrics by selecting them directly in the interface.
|
||||
|
||||
## 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
|
||||
1. Open TidalLuna after Building & Serving
|
||||
### (They are in the store by default now)
|
||||
1. Open TidaLuna 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`
|
||||
@@ -63,7 +79,7 @@ Allows users to copy song lyrics by selecting them directly in the interface.
|
||||
git clone https://github.com/meowarex/tidalluna-plugins
|
||||
|
||||
# Change Folder to the Repo
|
||||
cd neptune-projects-fork
|
||||
cd tidalluna-plugins
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
@@ -73,7 +89,7 @@ pnpm run watch
|
||||
```
|
||||
|
||||
### Installing Plugins in TidalLuna
|
||||
1. Open TidalLuna after Building & Serving
|
||||
1. Open TidaLuna 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]"
|
||||
@@ -82,7 +98,7 @@ pnpm run watch
|
||||
## Development
|
||||
|
||||
This project is made for:
|
||||
- **TidalLuna** - Modern plugin framework for Tidal | Inrixia
|
||||
- **[TidaLuna](https://github.com/Inrixia/TidaLuna)** - Modern plugin framework for Tidal | Inrixia
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
@@ -90,10 +106,6 @@ 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
|
||||
|
||||
Original Neptune versions by itzzexcel. Ported to Luna framework following the Luna plugin template structure by meowarex with help from Inrixia <3
|
||||
Inrixia | [TidalLuna](https://github.com/Inrixia/TidaLuna) Plugin Framework (The successor Neptune)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": {
|
||||
"useArrowFunction": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2356
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -2,6 +2,7 @@
|
||||
"name": "@meowarex/TidalLuna-Plugins",
|
||||
"description": "A Collection of Plugins for TidalLuna",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.2",
|
||||
"scripts": {
|
||||
"watch": "concurrently \"pnpm:build --watch\" pnpm:serve",
|
||||
"build": "rimraf ./dist && tsx esbuild.config.ts",
|
||||
@@ -19,4 +20,4 @@
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"type": "module"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,355 +1,617 @@
|
||||
import { ReactiveStore } from "@luna/core";
|
||||
import { LunaSettings, LunaNumberSetting, LunaSwitchSetting, LunaTextSetting } from "@luna/ui";
|
||||
import {
|
||||
LunaSettings,
|
||||
LunaNumberSetting,
|
||||
LunaSwitchSetting,
|
||||
LunaSelectSetting,
|
||||
LunaSelectItem,
|
||||
} from "@luna/ui";
|
||||
import React from "react";
|
||||
import {
|
||||
VISUALIZER_LABELS,
|
||||
type VisualizerType,
|
||||
ALL_SLOT_KEYS,
|
||||
ZONE_SLOTS,
|
||||
ZONE_LABELS,
|
||||
POSITION_LABELS,
|
||||
type ZoneId,
|
||||
type PositionId,
|
||||
type SlotKey,
|
||||
MINI_SUPPORTED,
|
||||
} from "./visualizers/types";
|
||||
|
||||
export const settings = await ReactiveStore.getPluginStorage("AudioVisualizer", {
|
||||
barCount: 32,
|
||||
barColor: "#ffffff",
|
||||
barRounding: true,
|
||||
customColors: [] as string[]
|
||||
});
|
||||
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,
|
||||
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;
|
||||
};
|
||||
|
||||
export const Settings = () => {
|
||||
const [barCount, setBarCount] = React.useState(settings.barCount);
|
||||
const [barColor, setBarColor] = React.useState(settings.barColor);
|
||||
const [barRounding, setBarRounding] = React.useState(settings.barRounding);
|
||||
const [showColorPicker, setShowColorPicker] = React.useState(false);
|
||||
const [isAnimatingIn, setIsAnimatingIn] = React.useState(false);
|
||||
const [shouldRender, setShouldRender] = React.useState(false);
|
||||
const [customInput, setCustomInput] = React.useState(settings.barColor);
|
||||
const [customColors, setCustomColors] = React.useState(settings.customColors);
|
||||
const [hoveredColorIndex, setHoveredColorIndex] = React.useState<number | null>(null);
|
||||
const [barColor, setBarColor] = React.useState(settings.barColor);
|
||||
const [barCount, setBarCount] = React.useState(settings.barCount);
|
||||
const [fftSize, setFftSize] = React.useState(settings.fftSize);
|
||||
const [reactivity, setReactivity] = React.useState(settings.reactivity);
|
||||
const [gain, setGain] = React.useState(settings.gain);
|
||||
const [barRounding, setBarRounding] = React.useState(settings.barRounding);
|
||||
const [lineThickness, setLineThickness] = React.useState(settings.lineThickness);
|
||||
const [fillOpacity, setFillOpacity] = React.useState(settings.fillOpacity);
|
||||
const [lissajous, setLissajous] = React.useState(settings.lissajous);
|
||||
const [scrollingOscilloscope, setScrollingOscilloscope] = React.useState(settings.scrollingOscilloscope);
|
||||
|
||||
const closeColorPicker = () => {
|
||||
setIsAnimatingIn(false);
|
||||
setTimeout(() => {
|
||||
setShowColorPicker(false);
|
||||
setShouldRender(false);
|
||||
}, 200); // Wait for animation to complete because i need to
|
||||
};
|
||||
|
||||
const openColorPicker = () => {
|
||||
setShowColorPicker(true);
|
||||
setShouldRender(true);
|
||||
setTimeout(() => setIsAnimatingIn(true), 10);
|
||||
};
|
||||
const [groupedSlots, setGroupedSlots] = React.useState(settings.groupedSlots);
|
||||
const [transparentContainers, setTransparentContainers] = React.useState(
|
||||
settings.transparentContainers,
|
||||
);
|
||||
const [idleMode, setIdleMode] = React.useState(settings.idleMode);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showColorPicker) {
|
||||
setShouldRender(true);
|
||||
setTimeout(() => setIsAnimatingIn(true), 10);
|
||||
}
|
||||
}, [showColorPicker]);
|
||||
const [showColorPicker, setShowColorPicker] = React.useState(false);
|
||||
const [isColorAnimIn, setIsColorAnimIn] = React.useState(false);
|
||||
const [shouldRenderColor, setShouldRenderColor] = React.useState(false);
|
||||
const [customInput, setCustomInput] = React.useState(settings.barColor);
|
||||
const [customColors, setCustomColors] = React.useState(settings.customColors);
|
||||
const [hoveredColorIndex, setHoveredColorIndex] = React.useState<number | null>(null);
|
||||
|
||||
// Common color presets for cool points :D
|
||||
const colorPresets = [
|
||||
"#ffffff", "#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
|
||||
"#ff8800", "#8800ff", "#0088ff", "#88ff00", "#ff0088", "#00ff88",
|
||||
"#444444", "#888888", "#cccccc", "#1db954", "#e22134", "#1976d2"
|
||||
];
|
||||
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 updateColor = (color: string) => {
|
||||
setBarColor(color);
|
||||
setCustomInput(color);
|
||||
settings.barColor = color;
|
||||
(window as any).updateAudioVisualizer?.();
|
||||
};
|
||||
const closeColorPicker = () => {
|
||||
setIsColorAnimIn(false);
|
||||
setTimeout(() => { setShowColorPicker(false); setShouldRenderColor(false); }, 200);
|
||||
};
|
||||
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);
|
||||
};
|
||||
|
||||
const addCustomColor = () => {
|
||||
if (customInput) {
|
||||
// Trim whitespace and convert to lowercase
|
||||
const trimmedInput = customInput.trim().toLowerCase();
|
||||
|
||||
// Validate hex color format
|
||||
const hexColorRegex = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
|
||||
|
||||
if (hexColorRegex.test(trimmedInput) &&
|
||||
!colorPresets.includes(trimmedInput) &&
|
||||
!customColors.includes(trimmedInput)) {
|
||||
const newCustomColors = [...customColors, trimmedInput];
|
||||
setCustomColors(newCustomColors);
|
||||
settings.customColors = newCustomColors;
|
||||
}
|
||||
}
|
||||
};
|
||||
React.useEffect(() => {
|
||||
if (showColorPicker) {
|
||||
setShouldRenderColor(true);
|
||||
setTimeout(() => setIsColorAnimIn(true), 10);
|
||||
}
|
||||
}, [showColorPicker]);
|
||||
|
||||
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");
|
||||
}
|
||||
};
|
||||
React.useEffect(() => {
|
||||
if (showSlotConfig) {
|
||||
setShouldRenderSlot(true);
|
||||
setTimeout(() => setIsSlotAnimIn(true), 10);
|
||||
}
|
||||
}, [showSlotConfig]);
|
||||
|
||||
const allColors = [...colorPresets, ...customColors];
|
||||
const colorPresets = [
|
||||
"#ff69b4", "#ff1493", "#e91e8a", "#c71585",
|
||||
"#ff006e", "#ff4da6", "#ff85c8", "#ffb3d9",
|
||||
"#ffffff", "#ff0000", "#00ff00", "#0000ff",
|
||||
"#ffff00", "#ff00ff", "#00ffff", "#ff8800",
|
||||
"#8800ff", "#0088ff", "#1db954", "#444444",
|
||||
];
|
||||
|
||||
return (
|
||||
<LunaSettings>
|
||||
<LunaSwitchSetting
|
||||
title="Bar Roundness"
|
||||
desc="Enable rounded corners on visualizer bars"
|
||||
checked={barRounding}
|
||||
onChange={(_, checked) => {
|
||||
setBarRounding(checked);
|
||||
settings.barRounding = checked;
|
||||
(window as any).updateAudioVisualizer?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
<LunaNumberSetting
|
||||
title="Bar Count"
|
||||
desc="Number of frequency bars to display"
|
||||
min={8}
|
||||
max={64}
|
||||
step={1}
|
||||
value={barCount}
|
||||
onNumber={(value: number) => {
|
||||
setBarCount(value);
|
||||
settings.barCount = value;
|
||||
(window as any).updateAudioVisualizer?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* YUP YOUR EYES WORK... we do be using React code in the settings..*/}
|
||||
{/* I'm not sure if this is a good idea, but it works & looks amazing */}
|
||||
{/* Sorry @Inrixia <3 */}
|
||||
|
||||
<div style={{
|
||||
padding: "16px 0",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center"
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: "4px" }}>Bar Color</div>
|
||||
<div style={{ opacity: 0.7, fontSize: "14px" }}>Color of the visualizer bars</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center", position: "relative" }}>
|
||||
<button
|
||||
onClick={() => showColorPicker ? closeColorPicker() : openColorPicker()}
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
background: barColor,
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)",
|
||||
position: "relative",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.1)",
|
||||
backdropFilter: "blur(2px)"
|
||||
}} />
|
||||
</button>
|
||||
|
||||
{/* Custom Color Picker Modal */}
|
||||
{shouldRender && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
zIndex: 1000,
|
||||
opacity: isAnimatingIn ? 1 : 0,
|
||||
transition: "opacity 0.2s ease"
|
||||
}}
|
||||
onClick={closeColorPicker}
|
||||
/>
|
||||
|
||||
{/* Color Picker Panel */}
|
||||
<div style={{
|
||||
position: "fixed",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
background: "rgba(20,20,20,0.98)",
|
||||
backdropFilter: "blur(20px)",
|
||||
WebkitBackdropFilter: "blur(20px)",
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: "16px",
|
||||
padding: "20px",
|
||||
minWidth: "320px",
|
||||
maxWidth: "90vw",
|
||||
maxHeight: "90vh",
|
||||
zIndex: 1001,
|
||||
boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
|
||||
opacity: isAnimatingIn ? 1 : 0,
|
||||
transform: isAnimatingIn ? "translate(-50%, -50%) scale(1)" : "translate(-50%, -50%) scale(0.9)",
|
||||
transition: "all 0.2s ease"
|
||||
}}>
|
||||
<div style={{ marginBottom: "12px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>
|
||||
Choose Color
|
||||
</div>
|
||||
|
||||
{/* Color Grid */}
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "8px",
|
||||
marginBottom: "16px"
|
||||
}}>
|
||||
{allColors.map((color, index) => {
|
||||
const isCustomColor = customColors.includes(color);
|
||||
const isHovered = hoveredColorIndex === index;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
className="color-item"
|
||||
onMouseEnter={() => setHoveredColorIndex(index)}
|
||||
onMouseLeave={() => setHoveredColorIndex(null)}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateColor(color);
|
||||
closeColorPicker();
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: "6px",
|
||||
border: barColor === color ? "2px solid #fff" : "1px solid rgba(255,255,255,0.2)",
|
||||
background: color,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease"
|
||||
}}
|
||||
/>
|
||||
{isCustomColor && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeCustomColor(color);
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-4px",
|
||||
right: "-4px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "50%",
|
||||
border: "1px solid rgba(255,255,255,0.8)",
|
||||
background: "rgba(0,0,0,0.8)",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
opacity: isHovered ? 1 : 0,
|
||||
transition: "opacity 0.2s ease",
|
||||
zIndex: 10
|
||||
}}
|
||||
className="remove-button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Custom Hex Input */}
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "12px", marginBottom: "6px" }}>
|
||||
Add Custom Color
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
updateColor(customInput);
|
||||
addCustomColor();
|
||||
}
|
||||
}}
|
||||
placeholder="#ffffff"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.2)",
|
||||
background: "rgba(255,255,255,0.1)",
|
||||
color: "#fff",
|
||||
fontSize: "14px",
|
||||
fontFamily: "monospace",
|
||||
boxSizing: "border-box"
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateColor(customInput);
|
||||
addCustomColor();
|
||||
}}
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.3)",
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "all 0.2s ease"
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.25)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.15)";
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close Button (Done) - Also runs when color chosen*/}
|
||||
<button
|
||||
onClick={closeColorPicker}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.2)",
|
||||
background: "rgba(255,255,255,0.1)",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px"
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
const updateColor = (color: string) => {
|
||||
setBarColor(color);
|
||||
setCustomInput(color);
|
||||
settings.barColor = color;
|
||||
};
|
||||
|
||||
</LunaSettings>
|
||||
);
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeCustomColor = (c: string) => {
|
||||
const nc = customColors.filter(x => x !== c);
|
||||
setCustomColors(nc);
|
||||
settings.customColors = nc;
|
||||
if (barColor === c) updateColor("#ff69b4");
|
||||
};
|
||||
|
||||
const allColors = [...colorPresets, ...customColors];
|
||||
|
||||
const updateSlot = (key: SlotKey, value: VisualizerType) => {
|
||||
setSlots(prev => ({ ...prev, [key]: value }));
|
||||
setSlot(key, value);
|
||||
if (!MINI_SUPPORTED.has(value)) {
|
||||
setMiniSlots(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.delete(key)) settings.miniSlots = [...next];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMini = (key: SlotKey) => {
|
||||
setMiniSlots(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
settings.miniSlots = [...next];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>;
|
||||
type AnySwitchProps = Omit<BaseSwitchProps, "onChange"> & {
|
||||
onChange: (_: unknown, checked: boolean) => void;
|
||||
checked: boolean;
|
||||
};
|
||||
const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType<AnySwitchProps>;
|
||||
|
||||
const hasBars = ALL_SLOT_KEYS.some(key => slots[key] === "spectrum-bars");
|
||||
|
||||
const zones: ZoneId[] = ["nowPlaying", "topNav", "playerBar"];
|
||||
const zonePositions = (zone: ZoneId) =>
|
||||
Object.keys(ZONE_SLOTS[zone]) as PositionId[];
|
||||
|
||||
const backdropStyle = (animIn: boolean): React.CSSProperties => ({
|
||||
position: "fixed", top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: "rgba(0,0,0,0.6)", zIndex: 1000,
|
||||
opacity: animIn ? 1 : 0, transition: "opacity 0.2s ease",
|
||||
border: "none", padding: 0, cursor: "default", width: "100%",
|
||||
});
|
||||
|
||||
const panelBaseStyle = (animIn: boolean): React.CSSProperties => ({
|
||||
position: "fixed", top: "50%", left: "50%",
|
||||
background: "rgba(20,20,20,0.98)",
|
||||
backdropFilter: "blur(20px)", WebkitBackdropFilter: "blur(20px)",
|
||||
border: "1px solid rgba(255,255,255,0.15)", borderRadius: "16px",
|
||||
padding: "20px", maxHeight: "90vh", overflowY: "auto",
|
||||
zIndex: 1001, boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
|
||||
opacity: animIn ? 1 : 0,
|
||||
transform: animIn ? "translate(-50%, -50%) scale(1)" : "translate(-50%, -50%) scale(0.9)",
|
||||
transition: "all 0.2s ease",
|
||||
});
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "6px 8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.2)",
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
color: "#fff",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
const optionStyle: React.CSSProperties = {
|
||||
background: "#1a1a1a",
|
||||
color: "#fff",
|
||||
};
|
||||
|
||||
return (
|
||||
<LunaSettings>
|
||||
{/* Color & Layout */}
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "10px 0",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: "14px", color: "#fff" }}>Color & Layout</div>
|
||||
<div style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)", marginTop: "2px" }}>
|
||||
Visualizer color and slot placement
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showColorPicker ? closeColorPicker() : openColorPicker()}
|
||||
style={{
|
||||
width: "28px", height: "28px",
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: "6px", cursor: "pointer", background: barColor,
|
||||
overflow: "hidden", position: "relative",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.1)", backdropFilter: "blur(2px)" }} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showSlotConfig ? closeSlotConfig() : openSlotConfig()}
|
||||
style={{
|
||||
padding: "6px 12px", borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.2)",
|
||||
background: "rgba(255,255,255,0.1)",
|
||||
color: "#fff", cursor: "pointer", fontSize: "12px",
|
||||
fontWeight: 500, transition: "all 0.2s ease",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.2)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.1)"; }}
|
||||
>Configure Slots</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnySwitch
|
||||
title="Grouped Slots"
|
||||
desc="Active slots in the same position share a single container"
|
||||
checked={groupedSlots}
|
||||
onChange={(_: unknown, checked: boolean) => {
|
||||
setGroupedSlots(checked);
|
||||
settings.groupedSlots = checked;
|
||||
}}
|
||||
/>
|
||||
|
||||
<AnySwitch
|
||||
title="Transparent containers"
|
||||
desc="Remove panel background, blur and shadow"
|
||||
checked={transparentContainers}
|
||||
onChange={(_: unknown, checked: boolean) => {
|
||||
setTransparentContainers(checked);
|
||||
settings.transparentContainers = checked;
|
||||
}}
|
||||
/>
|
||||
|
||||
<LunaSelectSetting
|
||||
title="Idle Animation"
|
||||
desc="Behaviour when no audio is playing"
|
||||
value={idleMode}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const v = Number(e.target.value);
|
||||
setIdleMode(v);
|
||||
settings.idleMode = v;
|
||||
}}
|
||||
>
|
||||
<LunaSelectItem value={0}>Enabled</LunaSelectItem>
|
||||
<LunaSelectItem value={1}>Disabled & Hide</LunaSelectItem>
|
||||
<LunaSelectItem value={2}>Disabled & Static</LunaSelectItem>
|
||||
</LunaSelectSetting>
|
||||
|
||||
{/* Color picker modal */}
|
||||
{shouldRenderColor && (
|
||||
<>
|
||||
<button type="button" aria-label="Close color picker" onClick={closeColorPicker} style={backdropStyle(isColorAnimIn)} />
|
||||
<div style={{ ...panelBaseStyle(isColorAnimIn), minWidth: "320px", maxWidth: "90vw" }}>
|
||||
<div style={{ marginBottom: "12px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>Choose Color</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "8px", marginBottom: "16px" }}>
|
||||
{allColors.map((color, index) => {
|
||||
const isCustom = customColors.includes(color);
|
||||
const isHovered = hoveredColorIndex === index;
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: cosmetic hover tracking on wrapper containing interactive buttons
|
||||
<div
|
||||
key={color}
|
||||
style={{ position: "relative", width: "32px", height: "32px", cursor: "pointer" }}
|
||||
onMouseEnter={() => setHoveredColorIndex(index)}
|
||||
onMouseLeave={() => setHoveredColorIndex(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { updateColor(color); closeColorPicker(); }}
|
||||
style={{
|
||||
width: "100%", height: "100%", borderRadius: "6px",
|
||||
border: barColor === color ? "2px solid #fff" : "1px solid rgba(255,255,255,0.2)",
|
||||
background: color, cursor: "pointer", transition: "all 0.2s ease",
|
||||
}}
|
||||
/>
|
||||
{isCustom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); removeCustomColor(color); }}
|
||||
style={{
|
||||
position: "absolute", top: "-4px", right: "-4px",
|
||||
width: "16px", height: "16px", borderRadius: "50%",
|
||||
border: "1px solid rgba(255,255,255,0.8)", background: "rgba(0,0,0,0.8)",
|
||||
color: "#fff", cursor: "pointer", fontSize: "10px",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
opacity: isHovered ? 1 : 0, transition: "opacity 0.2s ease", zIndex: 10,
|
||||
}}
|
||||
>x</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "12px", marginBottom: "6px" }}>Add Custom Color</div>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { updateColor(customInput); addCustomColor(); } }}
|
||||
placeholder="#ff69b4"
|
||||
style={{
|
||||
flex: 1, padding: "8px 12px", borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
|
||||
color: "#fff", fontSize: "14px", fontFamily: "monospace", boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { updateColor(customInput); addCustomColor(); }}
|
||||
style={{
|
||||
width: "32px", height: "32px", borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.3)", background: "rgba(255,255,255,0.15)",
|
||||
color: "#fff", cursor: "pointer", fontSize: "16px",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.25)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.15)"; }}
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeColorPicker}
|
||||
style={{
|
||||
width: "100%", padding: "8px", borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
|
||||
color: "#fff", cursor: "pointer", fontSize: "12px",
|
||||
}}
|
||||
>Done</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Slot configuration modal */}
|
||||
{shouldRenderSlot && (
|
||||
<>
|
||||
<button type="button" aria-label="Close slot config" onClick={closeSlotConfig} style={backdropStyle(isSlotAnimIn)} />
|
||||
<div style={{ ...panelBaseStyle(isSlotAnimIn), minWidth: "520px", maxWidth: "90vw", width: "600px" }}>
|
||||
<div style={{ marginBottom: "16px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>
|
||||
Configure Visualizer Slots
|
||||
</div>
|
||||
|
||||
{/* Segment control */}
|
||||
<div style={{
|
||||
display: "flex", background: "rgba(255,255,255,0.08)",
|
||||
borderRadius: "10px", padding: "2px", gap: "2px", marginBottom: "20px",
|
||||
}}>
|
||||
{zones.map(zone => (
|
||||
<button
|
||||
key={zone}
|
||||
type="button"
|
||||
onClick={() => setActiveZone(zone)}
|
||||
style={{
|
||||
flex: 1, border: "none",
|
||||
background: activeZone === zone ? "rgba(255,255,255,0.15)" : "transparent",
|
||||
color: activeZone === zone ? "#fff" : "rgba(255,255,255,0.4)",
|
||||
fontSize: "12px", fontWeight: 600,
|
||||
padding: "7px 0", borderRadius: "8px",
|
||||
cursor: "pointer", transition: "all 0.2s ease",
|
||||
...(activeZone === zone ? { boxShadow: "0 1px 3px rgba(0,0,0,0.3)" } : {}),
|
||||
}}
|
||||
>{ZONE_LABELS[zone]}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Slot grid */}
|
||||
<div style={{ display: "flex", gap: "16px", justifyContent: "center" }}>
|
||||
{zonePositions(activeZone).map(pos => {
|
||||
const slotKeys = ZONE_SLOTS[activeZone][pos];
|
||||
if (!slotKeys) return null;
|
||||
return (
|
||||
<div key={pos} style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
color: "rgba(255,255,255,0.6)", fontSize: "11px",
|
||||
fontWeight: 600, textTransform: "uppercase",
|
||||
letterSpacing: "0.5px", marginBottom: "8px",
|
||||
textAlign: "center",
|
||||
}}>{POSITION_LABELS[pos]}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{slotKeys.map((key, i) => (
|
||||
<div key={key} style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
||||
<select
|
||||
value={slots[key]}
|
||||
onChange={(e) => updateSlot(key, e.target.value as VisualizerType)}
|
||||
style={{ ...selectStyle, flex: 1 }}
|
||||
title={`Slot ${i + 1}`}
|
||||
>
|
||||
{VIZ_TYPES.map(t => (
|
||||
<option key={t} value={t} style={optionStyle}>{VISUALIZER_LABELS[t]}</option>
|
||||
))}
|
||||
</select>
|
||||
{MINI_SUPPORTED.has(slots[key]) && (
|
||||
<button
|
||||
type="button"
|
||||
title="Mini"
|
||||
onClick={() => toggleMini(key)}
|
||||
style={{
|
||||
width: "28px", height: "28px", flexShrink: 0,
|
||||
borderRadius: "6px", border: "1px solid rgba(255,255,255,0.2)",
|
||||
background: miniSlots.has(key) ? "rgba(255,105,180,0.4)" : "rgba(255,255,255,0.08)",
|
||||
color: miniSlots.has(key) ? "#fff" : "rgba(255,255,255,0.4)",
|
||||
cursor: "pointer", fontSize: "9px", fontWeight: 700,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>M</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeSlotConfig}
|
||||
style={{
|
||||
width: "100%", padding: "8px", borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
|
||||
color: "#fff", cursor: "pointer", fontSize: "12px", marginTop: "20px",
|
||||
}}
|
||||
>Done</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<LunaNumberSetting
|
||||
title="Reactivity"
|
||||
desc="How quickly visualizers respond to audio (5-100)"
|
||||
min={5}
|
||||
max={100}
|
||||
step={5}
|
||||
value={reactivity}
|
||||
onNumber={(v: number) => { setReactivity(v); settings.reactivity = v; }}
|
||||
/>
|
||||
|
||||
<LunaNumberSetting
|
||||
title="Gain"
|
||||
desc="Amplitude boost for spectrum visualizers (0.5-3.0)"
|
||||
min={0.5}
|
||||
max={3.0}
|
||||
step={0.5}
|
||||
value={gain}
|
||||
onNumber={(v: number) => { setGain(v); settings.gain = v; }}
|
||||
/>
|
||||
|
||||
<LunaSelectSetting
|
||||
title="FFT Size"
|
||||
desc="Frequency resolution (higher = more detail, more CPU)"
|
||||
value={fftSize}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const v = Number(e.target.value);
|
||||
setFftSize(v);
|
||||
settings.fftSize = v;
|
||||
}}
|
||||
>
|
||||
{[256, 512, 1024, 2048, 4096, 8192, 16384].map(s => (
|
||||
<LunaSelectItem key={s} value={s}>{s}</LunaSelectItem>
|
||||
))}
|
||||
</LunaSelectSetting>
|
||||
|
||||
<LunaNumberSetting
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
const log = (message: string) => console.log(`[Audio Visualizer] ${message}`);
|
||||
|
||||
let audioContext: AudioContext | null = null;
|
||||
let monoAnalyser: AnalyserNode | null = null;
|
||||
let leftAnalyser: AnalyserNode | null = null;
|
||||
let rightAnalyser: AnalyserNode | null = null;
|
||||
let splitter: ChannelSplitterNode | null = null;
|
||||
let audioSource: MediaStreamAudioSourceNode | null = null;
|
||||
let trackedEl: HTMLMediaElement | null = null;
|
||||
let capturedTrack: MediaStreamTrack | null = null;
|
||||
let trackCleanup: (() => void) | null = null;
|
||||
let docCleanup: (() => void) | null = null;
|
||||
|
||||
let desiredFFT = 2048;
|
||||
let desiredSmoothing = 0.8;
|
||||
|
||||
let monoByteFreq: Uint8Array | null = null;
|
||||
let monoByteTime: Uint8Array | null = null;
|
||||
let monoFloatFreq: Float32Array | null = null;
|
||||
let monoFloatTime: Float32Array | null = null;
|
||||
let leftFloatTime: Float32Array | null = null;
|
||||
let rightFloatTime: Float32Array | null = null;
|
||||
|
||||
export interface AudioData {
|
||||
byteFrequency: Uint8Array;
|
||||
byteTimeDomain: Uint8Array;
|
||||
floatFrequency: Float32Array;
|
||||
floatTimeDomain: Float32Array;
|
||||
leftTimeDomain: Float32Array;
|
||||
rightTimeDomain: Float32Array;
|
||||
sampleRate: number;
|
||||
fftSize: number;
|
||||
binCount: number;
|
||||
}
|
||||
|
||||
// Connection states
|
||||
// disconnected - no audio source
|
||||
// pending - wired but audio isn't detected (track muted / loading)
|
||||
// live - audio is detected
|
||||
export type ConnectionState = "disconnected" | "pending" | "live";
|
||||
let state: ConnectionState = "disconnected";
|
||||
let onStateChange: ((state: ConnectionState) => void) | null = null;
|
||||
|
||||
export const setOnStateChange = (cb: ((state: ConnectionState) => void) | null): void => {
|
||||
onStateChange = cb;
|
||||
};
|
||||
|
||||
export const getState = (): ConnectionState => state;
|
||||
export const isLive = (): boolean => state === "live";
|
||||
|
||||
const setState = (next: ConnectionState): void => {
|
||||
if (next === state) return;
|
||||
state = next;
|
||||
try {
|
||||
onStateChange?.(next);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Analyser / buffer setup
|
||||
|
||||
export const setFFTSize = (size: number): void => {
|
||||
desiredFFT = size;
|
||||
if (monoAnalyser) monoAnalyser.fftSize = size;
|
||||
if (leftAnalyser) leftAnalyser.fftSize = size;
|
||||
if (rightAnalyser) rightAnalyser.fftSize = size;
|
||||
allocateBuffers();
|
||||
};
|
||||
|
||||
export const setSmoothing = (value: number): void => {
|
||||
desiredSmoothing = value;
|
||||
if (monoAnalyser) monoAnalyser.smoothingTimeConstant = value;
|
||||
if (leftAnalyser) leftAnalyser.smoothingTimeConstant = value;
|
||||
if (rightAnalyser) rightAnalyser.smoothingTimeConstant = value;
|
||||
};
|
||||
|
||||
const allocateBuffers = (): void => {
|
||||
if (!monoAnalyser) return;
|
||||
const bc = monoAnalyser.frequencyBinCount;
|
||||
monoByteFreq = new Uint8Array(bc);
|
||||
monoByteTime = new Uint8Array(bc);
|
||||
monoFloatFreq = new Float32Array(bc);
|
||||
monoFloatTime = new Float32Array(monoAnalyser.fftSize);
|
||||
|
||||
if (leftAnalyser && rightAnalyser) {
|
||||
leftFloatTime = new Float32Array(leftAnalyser.fftSize);
|
||||
rightFloatTime = new Float32Array(rightAnalyser.fftSize);
|
||||
}
|
||||
};
|
||||
|
||||
const createAnalyser = (ctx: AudioContext, fftSize: number, smoothing: number): AnalyserNode => {
|
||||
const a = ctx.createAnalyser();
|
||||
a.fftSize = fftSize;
|
||||
a.smoothingTimeConstant = smoothing;
|
||||
a.minDecibels = -100;
|
||||
a.maxDecibels = -10;
|
||||
return a;
|
||||
};
|
||||
|
||||
const ensureContext = (): boolean => {
|
||||
try {
|
||||
if (!audioContext || audioContext.state === "closed") {
|
||||
audioContext = new AudioContext();
|
||||
}
|
||||
|
||||
if (!monoAnalyser) {
|
||||
monoAnalyser = createAnalyser(audioContext, desiredFFT, desiredSmoothing);
|
||||
leftAnalyser = createAnalyser(audioContext, desiredFFT, desiredSmoothing);
|
||||
rightAnalyser = createAnalyser(audioContext, desiredFFT, desiredSmoothing);
|
||||
splitter = audioContext.createChannelSplitter(2);
|
||||
splitter.connect(leftAnalyser, 0);
|
||||
splitter.connect(rightAnalyser, 1);
|
||||
allocateBuffers();
|
||||
}
|
||||
|
||||
if (audioContext.state === "suspended") {
|
||||
audioContext.resume().catch(() => {});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`Failed to create audio context: ${err}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Source / track wiring
|
||||
|
||||
const clearTrackListeners = (): void => {
|
||||
trackCleanup?.();
|
||||
trackCleanup = null;
|
||||
};
|
||||
|
||||
const detachSource = (): void => {
|
||||
clearTrackListeners();
|
||||
if (audioSource) {
|
||||
try {
|
||||
audioSource.disconnect();
|
||||
} catch {}
|
||||
audioSource = null;
|
||||
}
|
||||
capturedTrack = null;
|
||||
trackedEl = null;
|
||||
};
|
||||
|
||||
// captureFromEl() is retried after log spam to stop it <3
|
||||
let loggedCaptureFailure = false;
|
||||
const logCaptureFailureOnce = (message: string): void => {
|
||||
if (loggedCaptureFailure) return;
|
||||
loggedCaptureFailure = true;
|
||||
log(message);
|
||||
};
|
||||
|
||||
const captureFromEl = (el: HTMLMediaElement): boolean => {
|
||||
const capture = (el as unknown as { captureStream?: () => MediaStream }).captureStream;
|
||||
if (typeof capture !== "function") {
|
||||
logCaptureFailureOnce("captureStream() not available on media element");
|
||||
return false;
|
||||
}
|
||||
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = capture.call(el);
|
||||
} catch (err) {
|
||||
logCaptureFailureOnce(`captureStream() failed: ${err}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const tracks = stream.getAudioTracks();
|
||||
// No audio track yet
|
||||
if (tracks.length === 0) return false;
|
||||
if (!ensureContext()) return false;
|
||||
|
||||
detachSource();
|
||||
|
||||
const track = tracks[0];
|
||||
try {
|
||||
audioSource = audioContext!.createMediaStreamSource(stream);
|
||||
audioSource.connect(monoAnalyser!);
|
||||
audioSource.connect(splitter!);
|
||||
} catch (err) {
|
||||
log(`Failed to connect captured stream: ${err}`);
|
||||
audioSource = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
trackedEl = el;
|
||||
capturedTrack = track;
|
||||
const onUnmute = () => setState("live");
|
||||
const onMute = () => {
|
||||
if (state === "live") setState("pending");
|
||||
};
|
||||
const onEnded = () => {
|
||||
detachSource();
|
||||
setState("pending");
|
||||
};
|
||||
track.addEventListener("unmute", onUnmute);
|
||||
track.addEventListener("mute", onMute);
|
||||
track.addEventListener("ended", onEnded);
|
||||
trackCleanup = () => {
|
||||
track.removeEventListener("unmute", onUnmute);
|
||||
track.removeEventListener("mute", onMute);
|
||||
track.removeEventListener("ended", onEnded);
|
||||
};
|
||||
|
||||
// Captured tracks start muted until samples flow; wait for "unmute" instead of guessing.
|
||||
loggedCaptureFailure = false;
|
||||
setState(track.muted ? "pending" : "live");
|
||||
return true;
|
||||
};
|
||||
|
||||
// An element is worth capturing from when it's actually advancing audio.
|
||||
const isPlayingAudio = (el: HTMLMediaElement): boolean => !el.paused && !el.ended && el.readyState >= 2;
|
||||
|
||||
|
||||
const captureFrom = (el: HTMLMediaElement): void => {
|
||||
if (el === trackedEl) {
|
||||
if (state === "live") return;
|
||||
if (capturedTrack && capturedTrack.readyState === "live" && !capturedTrack.muted) {
|
||||
setState("live"); // healthy track, we just missed its unmute event
|
||||
return;
|
||||
}
|
||||
}
|
||||
captureFromEl(el);
|
||||
};
|
||||
// Capture media events (timeupdate & playing etc..)
|
||||
const MEDIA_ACTIVE_EVENTS = ["playing", "timeupdate"] as const;
|
||||
const MEDIA_RESET_EVENTS = ["pause", "ended", "emptied", "abort"] as const;
|
||||
|
||||
const onMediaActive = (e: Event): void => {
|
||||
const el = e.target;
|
||||
if (el instanceof HTMLMediaElement && isPlayingAudio(el)) captureFrom(el);
|
||||
};
|
||||
|
||||
const onMediaReset = (e: Event): void => {
|
||||
if (e.target === trackedEl) {
|
||||
detachSource();
|
||||
setState("pending");
|
||||
}
|
||||
};
|
||||
|
||||
/** global media listeners */
|
||||
export const init = (): void => {
|
||||
ensureContext();
|
||||
if (docCleanup) return;
|
||||
for (const ev of MEDIA_ACTIVE_EVENTS) document.addEventListener(ev, onMediaActive, true);
|
||||
for (const ev of MEDIA_RESET_EVENTS) document.addEventListener(ev, onMediaReset, true);
|
||||
docCleanup = () => {
|
||||
for (const ev of MEDIA_ACTIVE_EVENTS) document.removeEventListener(ev, onMediaActive, true);
|
||||
for (const ev of MEDIA_RESET_EVENTS) document.removeEventListener(ev, onMediaReset, true);
|
||||
};
|
||||
};
|
||||
|
||||
/** capture from whatever is already playing (plugin loaded mid-playback) */
|
||||
export const scan = (): void => {
|
||||
if (!ensureContext()) return;
|
||||
for (const el of document.querySelectorAll<HTMLMediaElement>(
|
||||
"video, audio",
|
||||
)) {
|
||||
if (isPlayingAudio(el)) {
|
||||
captureFrom(el);
|
||||
if (state === "live") return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const sample = (): AudioData | null => {
|
||||
const ctx = audioContext;
|
||||
if (!ctx || !monoAnalyser || !monoByteFreq || !monoByteTime || !monoFloatFreq || !monoFloatTime || !leftFloatTime || !rightFloatTime || !leftAnalyser || !rightAnalyser) return null;
|
||||
|
||||
if (ctx.state === "suspended") {
|
||||
ctx.resume().catch(() => {});
|
||||
}
|
||||
|
||||
monoAnalyser.getByteFrequencyData(monoByteFreq);
|
||||
monoAnalyser.getByteTimeDomainData(monoByteTime);
|
||||
monoAnalyser.getFloatFrequencyData(monoFloatFreq);
|
||||
monoAnalyser.getFloatTimeDomainData(monoFloatTime);
|
||||
leftAnalyser.getFloatTimeDomainData(leftFloatTime);
|
||||
rightAnalyser.getFloatTimeDomainData(rightFloatTime);
|
||||
|
||||
return {
|
||||
byteFrequency: monoByteFreq,
|
||||
byteTimeDomain: monoByteTime,
|
||||
floatFrequency: monoFloatFreq,
|
||||
floatTimeDomain: monoFloatTime,
|
||||
leftTimeDomain: leftFloatTime,
|
||||
rightTimeDomain: rightFloatTime,
|
||||
sampleRate: ctx.sampleRate,
|
||||
fftSize: monoAnalyser.fftSize,
|
||||
binCount: monoAnalyser.frequencyBinCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const hasSignal = (data: AudioData): boolean => {
|
||||
const avg = data.byteFrequency.reduce((s, v) => s + v, 0) / data.byteFrequency.length;
|
||||
return avg > 5;
|
||||
};
|
||||
|
||||
export const dispose = (): void => {
|
||||
docCleanup?.();
|
||||
docCleanup = null;
|
||||
detachSource();
|
||||
setState("disconnected");
|
||||
onStateChange = null;
|
||||
|
||||
if (audioContext && audioContext.state !== "closed") {
|
||||
audioContext.close().catch(() => {});
|
||||
}
|
||||
audioContext = null;
|
||||
monoAnalyser = null;
|
||||
leftAnalyser = null;
|
||||
rightAnalyser = null;
|
||||
splitter = null;
|
||||
monoByteFreq = null;
|
||||
monoByteTime = null;
|
||||
monoFloatFreq = null;
|
||||
monoFloatTime = null;
|
||||
leftFloatTime = null;
|
||||
rightFloatTime = null;
|
||||
};
|
||||
@@ -1,528 +1,479 @@
|
||||
import { LunaUnload, Tracer } from "@luna/core";
|
||||
import { StyleTag, PlayState } from "@luna/lib";
|
||||
import { type LunaUnload, Tracer } from "@luna/core";
|
||||
import { StyleTag, PlayState, MediaItem, observe } from "@luna/lib";
|
||||
import { settings, Settings } from "./Settings";
|
||||
import * as audio from "./audio";
|
||||
import type { AudioData } from "./audio";
|
||||
import { type Visualizer, type VisualizerType, VISUALIZER_DIMENSIONS, MINI_DIMENSIONS, ALL_SLOT_KEYS, ZONE_SLOTS, type SlotKey } from "./visualizers/types";
|
||||
import { createSpectrumLine } from "./visualizers/spectrum-line";
|
||||
import { createSpectrumBars } from "./visualizers/spectrum-bars";
|
||||
import { createOscilloscope } from "./visualizers/oscilloscope";
|
||||
import { createVectorscope } from "./visualizers/vectorscope";
|
||||
import { createLoudnessMeter } from "./visualizers/loudness-meter";
|
||||
|
||||
// Import CSS styles for the visualizer
|
||||
import visualizerStyles from "file://styles.css?minify";
|
||||
|
||||
export const { trace } = Tracer("[Audio Visualizer]");
|
||||
|
||||
// Helper function for consistent logging
|
||||
const log = (message: string) => console.log(`[Audio Visualizer] ${message}`);
|
||||
const warn = (message: string) => console.warn(`[Audio Visualizer] ${message}`);
|
||||
const error = (message: string) => console.error(`[Audio Visualizer] ${message}`);
|
||||
export { Settings };
|
||||
|
||||
// Basic config with settings
|
||||
const config = {
|
||||
enabled: true,
|
||||
position: 'left' as 'left' | 'right',
|
||||
width: 200,
|
||||
height: 40,
|
||||
get barCount() { return settings.barCount; },
|
||||
get color() { return settings.barColor; },
|
||||
get barRounding() { return settings.barRounding; },
|
||||
sensitivity: 1.5,
|
||||
smoothing: 0.8,
|
||||
visualizerType: 'bars' as 'bars' | 'waveform' | 'circular'
|
||||
};
|
||||
const log = (msg: string) => console.log(`[Audio Visualizer] ${msg}`);
|
||||
|
||||
// Clean up resources
|
||||
export const unloads = new Set<LunaUnload>();
|
||||
new StyleTag("AudioVisualizer", unloads, visualizerStyles);
|
||||
|
||||
// StyleTag for CSS
|
||||
const styleTag = new StyleTag("AudioVisualizer", unloads, visualizerStyles);
|
||||
|
||||
// Audio context and analyzer
|
||||
let audioContext: AudioContext | null = null;
|
||||
let analyser: AnalyserNode | null = null;
|
||||
let audioSource: MediaElementAudioSourceNode | null = null;
|
||||
let dataArray: Uint8Array | null = null;
|
||||
let animationId: number | null = null;
|
||||
let currentAudioElement: HTMLAudioElement | null = null;
|
||||
let isSourceConnected: boolean = false;
|
||||
|
||||
// Canvas and container elements
|
||||
let visualizerContainer: HTMLDivElement | null = null;
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let canvasContext: CanvasRenderingContext2D | null = null;
|
||||
|
||||
// Find the audio element - this is a bit of a hack but it works
|
||||
const findAudioElement = (): HTMLAudioElement | null => {
|
||||
// Try main selectors first
|
||||
const selectors = [
|
||||
'audio',
|
||||
'video',
|
||||
'audio[data-test]',
|
||||
'[data-test="audio-player"] audio'
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const element = document.querySelector(selector) as HTMLAudioElement;
|
||||
if (element && (element.tagName === 'AUDIO' || element.tagName === 'VIDEO')) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
// Quick scan for any audio elements
|
||||
const audioElements = document.querySelectorAll('audio, video');
|
||||
for (const element of audioElements) {
|
||||
const audioEl = element as HTMLAudioElement;
|
||||
if (audioEl.src || audioEl.currentSrc) {
|
||||
return audioEl;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
const FACTORIES: Record<Exclude<VisualizerType, "none">, () => Visualizer> = {
|
||||
"spectrum-line": createSpectrumLine,
|
||||
"spectrum-bars": createSpectrumBars,
|
||||
oscilloscope: createOscilloscope,
|
||||
vectorscope: createVectorscope,
|
||||
"loudness-meter": createLoudnessMeter,
|
||||
};
|
||||
|
||||
// Initialize audio visualization
|
||||
const initializeAudioVisualizer = async (): Promise<void> => {
|
||||
try {
|
||||
// Find the audio element
|
||||
const audioElement = findAudioElement();
|
||||
if (!audioElement) {
|
||||
return;
|
||||
}
|
||||
// Slot Management
|
||||
|
||||
// create audio context
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContext();
|
||||
log("Created AudioContext");
|
||||
}
|
||||
|
||||
// create analyser
|
||||
if (!analyser) {
|
||||
analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 512; // Fixed power of 2 that provides enough frequency bins
|
||||
analyser.smoothingTimeConstant = config.smoothing;
|
||||
dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
log("Created AnalyserNode");
|
||||
}
|
||||
|
||||
// attempt audio connection if not already connected
|
||||
if (!isSourceConnected && audioElement !== currentAudioElement) {
|
||||
try {
|
||||
// Create audio source - this might fail if already connected elsewhere
|
||||
audioSource = audioContext.createMediaElementSource(audioElement);
|
||||
audioSource.connect(analyser);
|
||||
// CRITICAL: connect back to destination for audio output (otherwise no sound)
|
||||
analyser.connect(audioContext.destination);
|
||||
|
||||
currentAudioElement = audioElement;
|
||||
isSourceConnected = true;
|
||||
log("Connected to audio stream with output");
|
||||
} catch (error) {
|
||||
// Audio is connected elsewhere - that's fine, we just can't visualize
|
||||
if (error instanceof Error && error.message.includes('already connected')) {
|
||||
log("Audio already connected elsewhere - skipping visualization");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
interface Slot {
|
||||
container: HTMLDivElement | null;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
visualizer: Visualizer | null;
|
||||
currentType: VisualizerType;
|
||||
contextType: "webgl" | "canvas2d" | null;
|
||||
}
|
||||
|
||||
// Resume context only if needed and don't wait for it
|
||||
// (otherwise it will wait for the audio to start playing)
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume().catch(() => {}); // Fire and forget
|
||||
}
|
||||
interface SlotGroup {
|
||||
groupContainer: HTMLDivElement;
|
||||
slots: Slot[];
|
||||
keys: readonly SlotKey[];
|
||||
}
|
||||
|
||||
// Create UI only if it doesn't exist
|
||||
if (!visualizerContainer) {
|
||||
createVisualizerUI();
|
||||
}
|
||||
|
||||
// Start animation only if not already running
|
||||
if (!animationId) {
|
||||
animate();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// log errors
|
||||
console.error(err);
|
||||
}
|
||||
const groups = new Map<string, SlotGroup>();
|
||||
let navArrowsEl: HTMLElement | null = null;
|
||||
let idleHidden = false;
|
||||
|
||||
const getSlot = (key: SlotKey): VisualizerType =>
|
||||
(settings as unknown as Record<string, VisualizerType>)[key] ?? "none";
|
||||
|
||||
const isWebGLViz = (type: VisualizerType): boolean =>
|
||||
type === "spectrum-line" || type === "spectrum-bars";
|
||||
|
||||
const isMiniSlot = (key: SlotKey): boolean =>
|
||||
(settings.miniSlots ?? []).includes(key);
|
||||
|
||||
const getSlotDims = (type: VisualizerType, key: SlotKey) =>
|
||||
isMiniSlot(key) && MINI_DIMENSIONS[type] ? MINI_DIMENSIONS[type] : VISUALIZER_DIMENSIONS[type];
|
||||
|
||||
const createSlotCanvas = (dims: { width: number; height: number }): HTMLCanvasElement => {
|
||||
const cvs = document.createElement("canvas");
|
||||
cvs.width = dims.width;
|
||||
cvs.height = dims.height;
|
||||
cvs.style.cssText = `width:${dims.width}px;height:${dims.height}px;border-radius:4px;display:block;`;
|
||||
return cvs;
|
||||
};
|
||||
|
||||
// Create the visualizer UI container and canvas
|
||||
const createVisualizerUI = (): void => {
|
||||
// Remove existing visualizer if it exists
|
||||
removeVisualizerUI();
|
||||
|
||||
if (!config.enabled) return;
|
||||
|
||||
// Find the search bar
|
||||
const searchField = document.querySelector('input[class*="_searchField"]') as HTMLInputElement;
|
||||
if (!searchField) {
|
||||
warn("Search field not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const searchContainer = searchField.parentElement;
|
||||
if (!searchContainer) {
|
||||
warn("Search container not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create visualizer container
|
||||
visualizerContainer = document.createElement('div');
|
||||
visualizerContainer.id = 'audio-visualizer-container';
|
||||
visualizerContainer.style.cssText = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-${config.position === 'left' ? 'right' : 'left'}: 12px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
`;
|
||||
|
||||
// Create canvas
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.width = config.width;
|
||||
canvas.height = config.height;
|
||||
canvas.style.cssText = `
|
||||
width: ${config.width}px;
|
||||
height: ${config.height}px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
visualizerContainer.appendChild(canvas);
|
||||
canvasContext = canvas.getContext('2d');
|
||||
|
||||
// Insert visualizer next to search bar
|
||||
if (config.position === 'left') {
|
||||
searchContainer.parentElement?.insertBefore(visualizerContainer, searchContainer);
|
||||
} else {
|
||||
searchContainer.parentElement?.insertBefore(visualizerContainer, searchContainer.nextSibling);
|
||||
}
|
||||
const applySlotSize = (slot: Slot, dims: { width: number; height: number }): void => {
|
||||
if (!slot.container || !slot.canvas) return;
|
||||
slot.canvas.width = dims.width;
|
||||
slot.canvas.height = dims.height;
|
||||
slot.canvas.style.width = `${dims.width}px`;
|
||||
slot.canvas.style.height = `${dims.height}px`;
|
||||
slot.container.style.width = `${dims.width + 8}px`;
|
||||
slot.container.style.height = `${dims.height + 8}px`;
|
||||
slot.visualizer?.resize(dims.width, dims.height);
|
||||
};
|
||||
|
||||
// Remove visualizer UI
|
||||
const removeVisualizerUI = (): void => {
|
||||
if (visualizerContainer) {
|
||||
visualizerContainer.remove();
|
||||
visualizerContainer = null;
|
||||
canvas = null;
|
||||
canvasContext = null;
|
||||
}
|
||||
const switchVisualizer = (slot: Slot, type: VisualizerType, key: SlotKey): void => {
|
||||
if (slot.currentType === type) return;
|
||||
|
||||
slot.visualizer?.dispose();
|
||||
slot.visualizer = null;
|
||||
|
||||
if (type === "none") {
|
||||
if (slot.container) slot.container.style.display = "none";
|
||||
slot.currentType = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const dims = getSlotDims(type, key);
|
||||
if (slot.container) {
|
||||
slot.canvas?.remove();
|
||||
const cvs = createSlotCanvas(dims);
|
||||
slot.container.appendChild(cvs);
|
||||
slot.canvas = cvs;
|
||||
slot.contextType = isWebGLViz(type) ? "webgl" : "canvas2d";
|
||||
|
||||
slot.container.style.display = "flex";
|
||||
slot.container.style.width = `${dims.width + 8}px`;
|
||||
slot.container.style.height = `${dims.height + 8}px`;
|
||||
}
|
||||
|
||||
const factory = FACTORIES[type];
|
||||
const viz = factory();
|
||||
if (slot.canvas) {
|
||||
viz.init(slot.canvas, settings.barColor);
|
||||
}
|
||||
slot.visualizer = viz;
|
||||
slot.currentType = type;
|
||||
};
|
||||
|
||||
// Animation loop for rendering visualizer
|
||||
const animate = (): void => {
|
||||
if (!canvasContext || !canvas) {
|
||||
animationId = null;
|
||||
return;
|
||||
}
|
||||
const syncGroupHeights = (group: SlotGroup): void => {
|
||||
let maxH = 0;
|
||||
for (let i = 0; i < group.keys.length; i++) {
|
||||
const slot = group.slots[i];
|
||||
if (slot.currentType === "none") continue;
|
||||
const dims = getSlotDims(slot.currentType, group.keys[i]);
|
||||
if (dims.height > maxH) maxH = dims.height;
|
||||
}
|
||||
if (maxH === 0) return;
|
||||
|
||||
// Update canvas color in case it changed
|
||||
canvasContext.fillStyle = config.color;
|
||||
canvasContext.strokeStyle = config.color;
|
||||
|
||||
// Check if we have real audio data - this might not be needed but its a good idea
|
||||
let hasRealAudio = false;
|
||||
if (analyser && dataArray) {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
// Check if there's actual audio signal (not just silence)
|
||||
const avgVolume = dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length;
|
||||
hasRealAudio = avgVolume > 5; // Threshold for detecting actual audio
|
||||
}
|
||||
|
||||
// Clear canvas
|
||||
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (hasRealAudio && analyser && dataArray) {
|
||||
// Draw real audio visualization
|
||||
switch (config.visualizerType) {
|
||||
case 'bars': // Is implemented YAYYY (default)
|
||||
drawBars();
|
||||
break;
|
||||
case 'waveform': // Not implemented yet
|
||||
drawWaveform();
|
||||
break;
|
||||
case 'circular': // Not implemented yet
|
||||
drawCircular();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Draw cool scrolling wave effect when no audio
|
||||
drawScrollingWave();
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(animate);
|
||||
for (let i = 0; i < group.keys.length; i++) {
|
||||
const slot = group.slots[i];
|
||||
if (!slot.container || !slot.canvas || slot.currentType === "none") continue;
|
||||
const dims = getSlotDims(slot.currentType, group.keys[i]);
|
||||
const targetH = Math.max(dims.height, maxH);
|
||||
applySlotSize(slot, { width: dims.width, height: targetH });
|
||||
}
|
||||
};
|
||||
|
||||
// Global wave animation state
|
||||
const updateGroupVisibility = (group: SlotGroup): void => {
|
||||
const activeCount = group.slots.filter(s => s.currentType !== "none").length;
|
||||
const allNone = activeCount === 0;
|
||||
const hidden = allNone || idleHidden;
|
||||
group.groupContainer.style.display = hidden ? "none" : "flex";
|
||||
if (!hidden) syncGroupHeights(group);
|
||||
|
||||
group.groupContainer.classList.toggle(
|
||||
"av-grouped",
|
||||
settings.groupedSlots && activeCount >= 2,
|
||||
);
|
||||
|
||||
if (group === groups.get("topNav-left") && navArrowsEl) {
|
||||
navArrowsEl.style.marginRight = hidden ? "" : "0";
|
||||
}
|
||||
};
|
||||
|
||||
const createGroup = (keys: readonly SlotKey[], zone: string, position: string): SlotGroup => {
|
||||
const groupContainer = document.createElement("div");
|
||||
groupContainer.className = "av-slot-group";
|
||||
groupContainer.dataset.zone = zone;
|
||||
groupContainer.dataset.position = position;
|
||||
|
||||
const slots: Slot[] = [];
|
||||
for (const _key of keys) {
|
||||
const slotContainer = document.createElement("div");
|
||||
slotContainer.className = "audio-visualizer-container";
|
||||
slotContainer.style.display = "none";
|
||||
groupContainer.appendChild(slotContainer);
|
||||
slots.push({
|
||||
container: slotContainer,
|
||||
canvas: null,
|
||||
visualizer: null,
|
||||
currentType: "none",
|
||||
contextType: null,
|
||||
});
|
||||
}
|
||||
|
||||
return { groupContainer, slots, keys };
|
||||
};
|
||||
|
||||
const initGroupVisualizers = (group: SlotGroup): void => {
|
||||
for (let i = 0; i < group.keys.length; i++) {
|
||||
const key = group.keys[i];
|
||||
const type = getSlot(key);
|
||||
if (type !== "none") {
|
||||
switchVisualizer(group.slots[i], type, key);
|
||||
}
|
||||
}
|
||||
updateGroupVisibility(group);
|
||||
};
|
||||
|
||||
const initAllGroups = (): void => {
|
||||
for (const [zoneId, positions] of Object.entries(ZONE_SLOTS)) {
|
||||
for (const [posId, keys] of Object.entries(positions)) {
|
||||
if (!keys) continue;
|
||||
const groupId = `${zoneId}-${posId}`;
|
||||
const group = createGroup(keys, zoneId, posId);
|
||||
groups.set(groupId, group);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// UI Attachment
|
||||
|
||||
const attachNavGroups = (anchor: Element): void => {
|
||||
const parent = anchor.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const navLeft = groups.get("topNav-left");
|
||||
if (navLeft && !navLeft.groupContainer.isConnected) {
|
||||
const navArrows = parent.querySelector('[data-test="navigation-arrows"]') as HTMLElement | null;
|
||||
if (navArrows) {
|
||||
navArrowsEl = navArrows;
|
||||
navArrows.after(navLeft.groupContainer);
|
||||
} else {
|
||||
parent.prepend(navLeft.groupContainer);
|
||||
}
|
||||
navLeft.groupContainer.style.marginRight = "auto";
|
||||
initGroupVisualizers(navLeft);
|
||||
}
|
||||
|
||||
const navRight = groups.get("topNav-right");
|
||||
if (navRight && !navRight.groupContainer.isConnected) {
|
||||
parent.insertBefore(navRight.groupContainer, anchor);
|
||||
initGroupVisualizers(navRight);
|
||||
}
|
||||
};
|
||||
|
||||
const attachNpGroups = (anchor: Element): void => {
|
||||
const leftContent = anchor.parentElement;
|
||||
if (!leftContent) return;
|
||||
const header = leftContent.parentElement as HTMLElement | null;
|
||||
if (!header) return;
|
||||
|
||||
const npLeft = groups.get("nowPlaying-left");
|
||||
if (npLeft && !npLeft.groupContainer.isConnected) {
|
||||
leftContent.insertBefore(npLeft.groupContainer, anchor.nextSibling);
|
||||
initGroupVisualizers(npLeft);
|
||||
}
|
||||
|
||||
const buttonsDiv = header.querySelector(':scope > [class*="buttons"]') as HTMLElement | null;
|
||||
const npRight = groups.get("nowPlaying-right");
|
||||
if (npRight && !npRight.groupContainer.isConnected) {
|
||||
if (buttonsDiv) {
|
||||
header.insertBefore(npRight.groupContainer, buttonsDiv);
|
||||
} else {
|
||||
header.appendChild(npRight.groupContainer);
|
||||
}
|
||||
npRight.groupContainer.style.marginLeft = "auto";
|
||||
initGroupVisualizers(npRight);
|
||||
}
|
||||
};
|
||||
|
||||
const attachPbGroups = (anchor: Element): void => {
|
||||
const trackInfo = anchor.querySelector('[data-test="track-info"]');
|
||||
const utilityContainer = anchor.querySelector('[class*="utilityContainer"]');
|
||||
|
||||
const pbLeft = groups.get("playerBar-left");
|
||||
if (pbLeft && !pbLeft.groupContainer.isConnected && trackInfo) {
|
||||
trackInfo.appendChild(pbLeft.groupContainer);
|
||||
initGroupVisualizers(pbLeft);
|
||||
}
|
||||
|
||||
const pbRight = groups.get("playerBar-right");
|
||||
if (pbRight && !pbRight.groupContainer.isConnected && utilityContainer) {
|
||||
utilityContainer.prepend(pbRight.groupContainer);
|
||||
initGroupVisualizers(pbRight);
|
||||
}
|
||||
};
|
||||
|
||||
initAllGroups();
|
||||
|
||||
observe(unloads, '[data-test="search-popover-container"]', attachNavGroups);
|
||||
observe(unloads, '[data-test="artist-info"]', attachNpGroups);
|
||||
observe(unloads, '[data-test="footer-player"]', attachPbGroups);
|
||||
|
||||
const existingSearch = document.querySelector('[data-test="search-popover-container"]');
|
||||
if (existingSearch) attachNavGroups(existingSearch);
|
||||
const existingArtist = document.querySelector('[data-test="artist-info"]');
|
||||
if (existingArtist) attachNpGroups(existingArtist);
|
||||
const existingFooter = document.querySelector('[data-test="footer-player"]');
|
||||
if (existingFooter) attachPbGroups(existingFooter);
|
||||
|
||||
// Audio Connection
|
||||
|
||||
const fft = () => settings.fftSize ?? 2048;
|
||||
const reactivityToSmoothing = (r: number) => Math.max(0, Math.min(0.95, (100 - r) / 100));
|
||||
const smooth = () => reactivityToSmoothing(settings.reactivity ?? 30);
|
||||
|
||||
let lastReactivity = settings.reactivity ?? 30;
|
||||
|
||||
audio.setOnStateChange((state) => {
|
||||
if (state === "live") log("Audio connected");
|
||||
});
|
||||
|
||||
PlayState.onState(unloads, (state) => {
|
||||
if (state === "PLAYING") audio.scan();
|
||||
});
|
||||
|
||||
MediaItem.onMediaTransition(unloads, () => audio.scan());
|
||||
|
||||
// Idle Animation Synthetic Data
|
||||
|
||||
let waveTime = 0;
|
||||
const IDLE_SIZE = 1024;
|
||||
const idleByteFreq = new Uint8Array(IDLE_SIZE);
|
||||
const idleByteTime = new Uint8Array(IDLE_SIZE);
|
||||
const idleFloatFreq = new Float32Array(IDLE_SIZE);
|
||||
const idleFloatTime = new Float32Array(IDLE_SIZE);
|
||||
const idleLeftTime = new Float32Array(IDLE_SIZE);
|
||||
const idleRightTime = new Float32Array(IDLE_SIZE);
|
||||
|
||||
// Helper function to draw rounded rectangles
|
||||
const drawRoundedRect = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number): void => {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, width, height, radius);
|
||||
ctx.fill();
|
||||
const generateIdleData = (): AudioData => {
|
||||
for (let i = 0; i < IDLE_SIZE; i++) {
|
||||
const x = i / IDLE_SIZE;
|
||||
const wave1 = Math.sin(x * Math.PI * 2 + waveTime) * 0.3;
|
||||
const wave2 = Math.sin(x * Math.PI * 4 + waveTime * 1.3) * 0.2;
|
||||
const wave3 = Math.sin(x * Math.PI * 6 + waveTime * 0.7) * 0.1;
|
||||
const combined = (wave1 + wave2 + wave3 + 1) / 2;
|
||||
const travel = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5;
|
||||
|
||||
const byteVal = Math.floor(combined * travel * 140 + 20);
|
||||
idleByteFreq[i] = byteVal;
|
||||
idleFloatFreq[i] = -40 + byteVal * 0.3;
|
||||
|
||||
const timeSample = Math.sin(x * Math.PI * 8 + waveTime * 3) * 0.15;
|
||||
idleByteTime[i] = 128 + Math.floor(timeSample * 127);
|
||||
idleFloatTime[i] = timeSample;
|
||||
idleLeftTime[i] = timeSample;
|
||||
idleRightTime[i] = Math.sin(x * Math.PI * 8 + waveTime * 3 + 0.3) * 0.15;
|
||||
}
|
||||
|
||||
return {
|
||||
byteFrequency: idleByteFreq,
|
||||
byteTimeDomain: idleByteTime,
|
||||
floatFrequency: idleFloatFreq,
|
||||
floatTimeDomain: idleFloatTime,
|
||||
leftTimeDomain: idleLeftTime,
|
||||
rightTimeDomain: idleRightTime,
|
||||
sampleRate: 44100,
|
||||
fftSize: IDLE_SIZE * 2,
|
||||
binCount: IDLE_SIZE,
|
||||
};
|
||||
};
|
||||
|
||||
// Draw scrolling wave effect when no audio is detected
|
||||
const drawScrollingWave = (): void => {
|
||||
if (!canvasContext || !canvas) return;
|
||||
|
||||
waveTime += 0.05; // Speed of wave animation
|
||||
|
||||
const barCount = config.barCount;
|
||||
const barWidth = canvas.width / barCount;
|
||||
const maxHeight = canvas.height * 0.6;
|
||||
|
||||
canvasContext.fillStyle = config.color;
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
// Create a sine wave that scrolls back and forth
|
||||
const x = i / barCount;
|
||||
const wave1 = Math.sin(x * Math.PI * 2 + waveTime) * 0.3;
|
||||
const wave2 = Math.sin(x * Math.PI * 4 + waveTime * 1.3) * 0.2;
|
||||
const wave3 = Math.sin(x * Math.PI * 6 + waveTime * 0.7) * 0.1;
|
||||
|
||||
// Combine waves for complex pattern
|
||||
const combinedWave = (wave1 + wave2 + wave3 + 1) / 2; // Normalize to 0-1
|
||||
|
||||
// Add a traveling wave effect
|
||||
const travelWave = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5;
|
||||
|
||||
// Final height calculation
|
||||
const barHeight = maxHeight * combinedWave * travelWave * 0.8 + 2; // Minimum height of 2px
|
||||
|
||||
const xPos = i * barWidth;
|
||||
const yPos = (canvas.height - barHeight) / 2;
|
||||
|
||||
// Draw rounded or square bars based on setting
|
||||
if (config.barRounding) {
|
||||
drawRoundedRect(canvasContext, xPos, yPos, barWidth - 1, barHeight, 2);
|
||||
} else {
|
||||
canvasContext.fillRect(xPos, yPos, barWidth - 1, barHeight);
|
||||
}
|
||||
}
|
||||
// Static idle data — flat, silent, no movement
|
||||
const STATIC_IDLE_DATA: AudioData = {
|
||||
byteFrequency: new Uint8Array(IDLE_SIZE),
|
||||
byteTimeDomain: new Uint8Array(IDLE_SIZE).fill(128),
|
||||
floatFrequency: new Float32Array(IDLE_SIZE).fill(-100),
|
||||
floatTimeDomain: new Float32Array(IDLE_SIZE),
|
||||
leftTimeDomain: new Float32Array(IDLE_SIZE),
|
||||
rightTimeDomain: new Float32Array(IDLE_SIZE),
|
||||
sampleRate: 44100,
|
||||
fftSize: IDLE_SIZE * 2,
|
||||
binCount: IDLE_SIZE,
|
||||
};
|
||||
|
||||
// Draw frequency bars - default
|
||||
const drawBars = (): void => {
|
||||
if (!canvasContext || !dataArray || !canvas) return;
|
||||
// Animation Loop
|
||||
|
||||
const barWidth = canvas.width / config.barCount;
|
||||
const heightScale = canvas.height / 255;
|
||||
let animationId: number | null = null;
|
||||
const lastSlotTypes = new Map<SlotKey, VisualizerType>();
|
||||
const lastMiniState = new Map<SlotKey, boolean>();
|
||||
let lastGrouped = settings.groupedSlots;
|
||||
let lastChromeless = settings.transparentContainers;
|
||||
|
||||
canvasContext.fillStyle = config.color;
|
||||
|
||||
for (let i = 0; i < config.barCount; i++) {
|
||||
const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
|
||||
const barHeight = (dataArray[dataIndex] * config.sensitivity * heightScale);
|
||||
|
||||
const x = i * barWidth;
|
||||
const y = canvas.height - barHeight;
|
||||
|
||||
// Draw rounded or square bars based on setting
|
||||
if (config.barRounding) {
|
||||
drawRoundedRect(canvasContext, x, y, barWidth - 1, barHeight, 2);
|
||||
} else {
|
||||
canvasContext.fillRect(x, y, barWidth - 1, barHeight);
|
||||
}
|
||||
}
|
||||
const syncChromelessClass = (): void => {
|
||||
document.body.classList.toggle("av-chromeless", !!settings.transparentContainers);
|
||||
};
|
||||
|
||||
// Draw waveform visualization - NOT IMPLEMENTED YET
|
||||
// const drawWaveform = (): void => {
|
||||
// if (!canvasContext || !dataArray || !canvas) return;
|
||||
syncChromelessClass();
|
||||
|
||||
// const centerY = canvas.height / 2;
|
||||
// const amplitudeScale = canvas.height / 512;
|
||||
for (const key of ALL_SLOT_KEYS) {
|
||||
lastSlotTypes.set(key, getSlot(key));
|
||||
lastMiniState.set(key, isMiniSlot(key));
|
||||
}
|
||||
|
||||
// canvasContext.strokeStyle = config.color;
|
||||
// canvasContext.lineWidth = 2;
|
||||
// canvasContext.beginPath();
|
||||
const animate = (): void => {
|
||||
for (const group of groups.values()) {
|
||||
let changed = false;
|
||||
for (let i = 0; i < group.keys.length; i++) {
|
||||
const key = group.keys[i];
|
||||
const currentType = getSlot(key);
|
||||
const lastType = lastSlotTypes.get(key) ?? "none";
|
||||
const mini = isMiniSlot(key);
|
||||
const wasMini = lastMiniState.get(key) ?? false;
|
||||
if (currentType !== lastType) {
|
||||
switchVisualizer(group.slots[i], currentType, key);
|
||||
lastSlotTypes.set(key, currentType);
|
||||
lastMiniState.set(key, mini);
|
||||
changed = true;
|
||||
} else if (mini !== wasMini && currentType !== "none") {
|
||||
const dims = getSlotDims(currentType, key);
|
||||
applySlotSize(group.slots[i], dims);
|
||||
lastMiniState.set(key, mini);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) updateGroupVisibility(group);
|
||||
}
|
||||
|
||||
// for (let i = 0; i < config.barCount; i++) {
|
||||
// const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
|
||||
// const amplitude = (dataArray[dataIndex] - 128) * config.sensitivity * amplitudeScale;
|
||||
|
||||
// const x = (i / config.barCount) * canvas.width;
|
||||
// const y = centerY + amplitude;
|
||||
|
||||
// if (i === 0) {
|
||||
// canvasContext.moveTo(x, y);
|
||||
// } else {
|
||||
// canvasContext.lineTo(x, y);
|
||||
// }
|
||||
// }
|
||||
const grouped = settings.groupedSlots;
|
||||
if (grouped !== lastGrouped) {
|
||||
for (const group of groups.values()) updateGroupVisibility(group);
|
||||
lastGrouped = grouped;
|
||||
}
|
||||
|
||||
// canvasContext.stroke();
|
||||
// };
|
||||
const chromeless = !!settings.transparentContainers;
|
||||
if (chromeless !== lastChromeless) {
|
||||
syncChromelessClass();
|
||||
lastChromeless = chromeless;
|
||||
}
|
||||
|
||||
// Draw circular visualization - NOT IMPLEMENTED YET
|
||||
// const drawCircular = (): void => {
|
||||
// if (!canvasContext || !dataArray || !canvas) return;
|
||||
const currentReactivity = settings.reactivity ?? 30;
|
||||
if (currentReactivity !== lastReactivity) {
|
||||
audio.setSmoothing(reactivityToSmoothing(currentReactivity));
|
||||
lastReactivity = currentReactivity;
|
||||
}
|
||||
|
||||
// const centerX = canvas.width / 2;
|
||||
// const centerY = canvas.height / 2;
|
||||
// const radius = Math.min(centerX, centerY) - 10;
|
||||
waveTime += 0.05;
|
||||
const data = audio.sample();
|
||||
const hasSignal = data && audio.hasSignal(data);
|
||||
|
||||
// canvasContext.strokeStyle = config.color;
|
||||
// canvasContext.lineWidth = 2;
|
||||
// idleMode: 0 = animated, 1 = hide when idle, 2 = static when idle
|
||||
const idleMode = settings.idleMode ?? 0;
|
||||
const newIdleHidden = !hasSignal && idleMode === 1;
|
||||
if (newIdleHidden !== idleHidden) {
|
||||
idleHidden = newIdleHidden;
|
||||
for (const group of groups.values()) updateGroupVisibility(group);
|
||||
}
|
||||
|
||||
// for (let i = 0; i < config.barCount; i++) {
|
||||
// const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
|
||||
// const amplitude = (dataArray[dataIndex] * config.sensitivity) / 255;
|
||||
|
||||
// const angle = (i / config.barCount) * Math.PI * 2;
|
||||
// const startX = centerX + Math.cos(angle) * radius * 0.7;
|
||||
// const startY = centerY + Math.sin(angle) * radius * 0.7;
|
||||
// const endX = centerX + Math.cos(angle) * radius * (0.7 + amplitude * 0.3);
|
||||
// const endY = centerY + Math.sin(angle) * radius * (0.7 + amplitude * 0.3);
|
||||
|
||||
// canvasContext.beginPath();
|
||||
// canvasContext.moveTo(startX, startY);
|
||||
// canvasContext.lineTo(endX, endY);
|
||||
// canvasContext.stroke();
|
||||
// }
|
||||
// };
|
||||
if (!idleHidden) {
|
||||
let renderData: AudioData;
|
||||
if (hasSignal) renderData = data as AudioData;
|
||||
else if (idleMode === 2) renderData = STATIC_IDLE_DATA;
|
||||
else renderData = generateIdleData();
|
||||
for (const group of groups.values()) {
|
||||
for (const slot of group.slots) {
|
||||
if (!slot.canvas || slot.currentType === "none" || !slot.visualizer) continue;
|
||||
slot.visualizer.render(renderData, settings.barColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update visualizer settings
|
||||
const updateAudioVisualizer = (): void => {
|
||||
if (analyser) {
|
||||
// use a fixed size that provides enough frequency bins
|
||||
analyser.fftSize = 512; // Fixed power of 2 - important
|
||||
analyser.smoothingTimeConstant = config.smoothing;
|
||||
dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
}
|
||||
|
||||
if (canvas) {
|
||||
canvas.width = config.width;
|
||||
canvas.height = config.height;
|
||||
canvas.style.width = `${config.width}px`;
|
||||
canvas.style.height = `${config.height}px`;
|
||||
}
|
||||
|
||||
// Recreate UI if position changed
|
||||
createVisualizerUI();
|
||||
animationId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
// Make updateAudioVisualizer available globally for settings
|
||||
(window as any).updateAudioVisualizer = updateAudioVisualizer;
|
||||
// Init
|
||||
|
||||
// Clean up function
|
||||
const cleanupAudioVisualizer = (): void => {
|
||||
// stop animation and hide UI - don't touch audio connections (otherwise it will reconnect)
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
animationId = null;
|
||||
}
|
||||
|
||||
removeVisualizerUI();
|
||||
|
||||
// i was killing audio connections - But it was reconnecting and being a pain
|
||||
// so i just left it alone - it works fine
|
||||
};
|
||||
log("Initializing...");
|
||||
|
||||
// Initialize when DOM is ready and track is playing
|
||||
const observePlayState = (): void => {
|
||||
let hasTriedInitialization = false;
|
||||
let checkCount = 0;
|
||||
|
||||
const checkAndInitialize = () => {
|
||||
checkCount++;
|
||||
|
||||
// Only try to initialize once when music starts playing
|
||||
if (PlayState.playing && !hasTriedInitialization) {
|
||||
hasTriedInitialization = true;
|
||||
log("Initializing audio visualizer...");
|
||||
|
||||
// Initialize immediately - no delay (after audio starts playing ofc)
|
||||
initializeAudioVisualizer().then(() => {
|
||||
if (audioContext && analyser) {
|
||||
log("Audio visualizer ready!");
|
||||
} else {
|
||||
hasTriedInitialization = false; // Allow retry if failed
|
||||
}
|
||||
});
|
||||
} else if (!PlayState.playing && hasTriedInitialization) {
|
||||
// Reset try flag when music stops so it can try again next time (otherwise it explode)
|
||||
hasTriedInitialization = false;
|
||||
}
|
||||
|
||||
// Keep animation running regardless of play state
|
||||
if (!animationId) {
|
||||
animate();
|
||||
}
|
||||
};
|
||||
audio.setFFTSize(fft());
|
||||
audio.setSmoothing(smooth());
|
||||
audio.init();
|
||||
audio.scan();
|
||||
|
||||
// Start with fast checking, then slow down
|
||||
const fastInterval = setInterval(() => {
|
||||
checkAndInitialize();
|
||||
if (checkCount > 10) { // After 10 quick checks, switch to slower
|
||||
clearInterval(fastInterval);
|
||||
const slowInterval = setInterval(checkAndInitialize, 2000);
|
||||
unloads.add(() => clearInterval(slowInterval));
|
||||
}
|
||||
}, 200); // Check every 200ms initially
|
||||
|
||||
unloads.add(() => clearInterval(fastInterval));
|
||||
|
||||
// Immediate first check
|
||||
checkAndInitialize();
|
||||
};
|
||||
animationId = requestAnimationFrame(animate);
|
||||
|
||||
// Initialize the plugin
|
||||
const initialize = (): void => {
|
||||
log("Audio Visualizer plugin initializing...");
|
||||
|
||||
// Start immediately - DOM should be ready by plugin load
|
||||
setTimeout(() => {
|
||||
log("Starting visualizer...");
|
||||
// Create UI immediately so wave effect shows
|
||||
createVisualizerUI();
|
||||
// Start animation loop immediately
|
||||
animate();
|
||||
// Also observe play state for audio detection
|
||||
observePlayState();
|
||||
}, 100); // Minimal delay to ensure DOM is ready
|
||||
};
|
||||
// Cleanup
|
||||
|
||||
// Complete cleanup function for plugin unload
|
||||
const completeCleanup = (): void => {
|
||||
log("Complete cleanup - plugin unloading");
|
||||
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
animationId = null;
|
||||
}
|
||||
|
||||
removeVisualizerUI();
|
||||
|
||||
// Fully disconnect and reset everything
|
||||
if (audioSource) {
|
||||
try {
|
||||
audioSource.disconnect();
|
||||
log("Disconnected audio source completely");
|
||||
} catch (e) {
|
||||
log("Audio source already disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
// Close audio context completely on plugin unload
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
audioContext.close();
|
||||
log("Closed AudioContext");
|
||||
}
|
||||
|
||||
// Reset all references
|
||||
audioContext = null;
|
||||
analyser = null;
|
||||
audioSource = null;
|
||||
dataArray = null;
|
||||
currentAudioElement = null;
|
||||
isSourceConnected = false;
|
||||
};
|
||||
unloads.add(() => {
|
||||
log("Plugin unloading");
|
||||
|
||||
// Register cleanup
|
||||
unloads.add(completeCleanup);
|
||||
document.body.classList.remove("av-chromeless");
|
||||
|
||||
// Start initialization
|
||||
initialize();
|
||||
if (navArrowsEl) {
|
||||
navArrowsEl.style.marginRight = "";
|
||||
navArrowsEl = null;
|
||||
}
|
||||
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
animationId = null;
|
||||
}
|
||||
|
||||
for (const group of groups.values()) {
|
||||
for (const slot of group.slots) {
|
||||
slot.visualizer?.dispose();
|
||||
}
|
||||
group.groupContainer.remove();
|
||||
}
|
||||
groups.clear();
|
||||
audio.dispose();
|
||||
});
|
||||
|
||||
@@ -1,56 +1,133 @@
|
||||
/* 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, 255, 255, 0.1);
|
||||
.audio-visualizer-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease-in-out;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 105, 180, 0.15);
|
||||
animation: av-fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
#audio-visualizer-container:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
.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);
|
||||
}
|
||||
|
||||
#audio-visualizer-container canvas {
|
||||
display: block;
|
||||
transition: all 0.3s ease-in-out;
|
||||
.audio-visualizer-container canvas {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
#audio-visualizer-container {
|
||||
margin: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
#audio-visualizer-container canvas {
|
||||
max-width: 150px;
|
||||
max-height: 30px;
|
||||
}
|
||||
.audio-visualizer-container.active {
|
||||
box-shadow: 0 0 20px rgba(255, 105, 180, 0.3);
|
||||
}
|
||||
|
||||
/* Where to put the thingy */
|
||||
[class*="_searchField"] {
|
||||
transition: all 0.3s ease-in-out;
|
||||
@keyframes av-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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);
|
||||
[data-type="search-field"] {
|
||||
min-width: 220px !important;
|
||||
}
|
||||
|
||||
/* Fade in animation */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
/* Slot group layout */
|
||||
.av-slot-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#audio-visualizer-container {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
/* Left/Right group spacing */
|
||||
.av-slot-group[data-position="left"] {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.av-slot-group[data-position="right"] {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
/* Player Bar: LEFT inside trackInfo, RIGHT inside utilityContainer */
|
||||
.av-slot-group[data-zone="playerBar"][data-position="left"] {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.av-slot-group[data-zone="playerBar"][data-position="right"] {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Grouped slots: merge active containers into one shared box */
|
||||
.av-slot-group.av-grouped {
|
||||
gap: 0;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 105, 180, 0.15);
|
||||
animation: av-fadeIn 0.5s ease-out;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
.av-slot-group.av-grouped:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(255, 105, 180, 0.2);
|
||||
border-color: rgba(255, 105, 180, 0.3);
|
||||
}
|
||||
.av-slot-group.av-grouped > .audio-visualizer-container {
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
.av-slot-group.av-grouped > .audio-visualizer-container:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Chromeless: no fill, blur, or shadow; border kept */
|
||||
body.av-chromeless .audio-visualizer-container {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
border: 1px solid rgba(255, 105, 180, 0.15);
|
||||
animation: none;
|
||||
transition: border-color 0.3s ease-in-out;
|
||||
}
|
||||
body.av-chromeless .audio-visualizer-container:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
border-color: rgba(255, 105, 180, 0.3);
|
||||
}
|
||||
body.av-chromeless .audio-visualizer-container.active {
|
||||
box-shadow: none;
|
||||
}
|
||||
body.av-chromeless .av-slot-group.av-grouped {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
border: 1px solid rgba(255, 105, 180, 0.15);
|
||||
animation: none;
|
||||
transition: border-color 0.3s ease-in-out;
|
||||
}
|
||||
body.av-chromeless .av-slot-group.av-grouped:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
border-color: rgba(255, 105, 180, 0.3);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { AudioData } from "../audio";
|
||||
import type { Visualizer } from "./types";
|
||||
import { hexToRGB } from "../webgl";
|
||||
|
||||
const GATE_ABSOLUTE = -70;
|
||||
const GATE_RELATIVE_OFFSET = -10;
|
||||
const GAINS = [1.0, 1.0];
|
||||
|
||||
interface LUFSState {
|
||||
momentaryBlocks: number[];
|
||||
shortTermBlocks: number[];
|
||||
integratedPowers: number[];
|
||||
momentary: number;
|
||||
shortTerm: number;
|
||||
integrated: number;
|
||||
blockBuffer: Float32Array[];
|
||||
blockPos: number;
|
||||
blockSize: number;
|
||||
hopSize: number;
|
||||
hopPos: number;
|
||||
displayMomentary: number;
|
||||
displayShortTerm: number;
|
||||
displayIntegrated: number;
|
||||
}
|
||||
|
||||
const createLUFSState = (sampleRate: number): LUFSState => {
|
||||
const blockSize = Math.floor(sampleRate * 0.4);
|
||||
const hopSize = Math.floor(sampleRate * 0.1);
|
||||
return {
|
||||
momentaryBlocks: [],
|
||||
shortTermBlocks: [],
|
||||
integratedPowers: [],
|
||||
momentary: -Infinity,
|
||||
shortTerm: -Infinity,
|
||||
integrated: -Infinity,
|
||||
blockBuffer: [new Float32Array(blockSize), new Float32Array(blockSize)],
|
||||
blockPos: 0,
|
||||
blockSize,
|
||||
hopSize,
|
||||
hopPos: 0,
|
||||
displayMomentary: -60,
|
||||
displayShortTerm: -60,
|
||||
displayIntegrated: -60,
|
||||
};
|
||||
};
|
||||
|
||||
const computeBlockLoudness = (left: Float32Array, right: Float32Array, len: number): number => {
|
||||
let sumL = 0, sumR = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
sumL += left[i] * left[i];
|
||||
sumR += right[i] * right[i];
|
||||
}
|
||||
const powerL = sumL / len;
|
||||
const powerR = sumR / len;
|
||||
const weighted = GAINS[0] * powerL + GAINS[1] * powerR;
|
||||
if (weighted <= 0) return -Infinity;
|
||||
return -0.691 + 10 * Math.log10(weighted);
|
||||
};
|
||||
|
||||
const computeGatedIntegrated = (powers: number[]): number => {
|
||||
if (powers.length === 0) return -Infinity;
|
||||
|
||||
const aboveAbsolute = powers.filter(p => p > GATE_ABSOLUTE);
|
||||
if (aboveAbsolute.length === 0) return -Infinity;
|
||||
|
||||
const meanAbsolute = aboveAbsolute.reduce((s, v) => s + Math.pow(10, v / 10), 0) / aboveAbsolute.length;
|
||||
const relativeThreshold = 10 * Math.log10(meanAbsolute) + GATE_RELATIVE_OFFSET;
|
||||
const aboveRelative = aboveAbsolute.filter(p => p > relativeThreshold);
|
||||
if (aboveRelative.length === 0) return -Infinity;
|
||||
|
||||
const meanRelative = aboveRelative.reduce((s, v) => s + Math.pow(10, v / 10), 0) / aboveRelative.length;
|
||||
return 10 * Math.log10(meanRelative);
|
||||
};
|
||||
|
||||
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
||||
|
||||
export const createLoudnessMeter = (): Visualizer => {
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let w = 0, h = 0;
|
||||
let state: LUFSState | null = null;
|
||||
let lastSampleRate = 0;
|
||||
|
||||
const SMOOTHING_FAST = 0.25;
|
||||
const SMOOTHING_SLOW = 0.08;
|
||||
|
||||
return {
|
||||
name: "Loudness (LUFS)",
|
||||
id: "loudness-meter",
|
||||
|
||||
init(canvas, _color) {
|
||||
ctx = canvas.getContext("2d")!;
|
||||
w = canvas.width;
|
||||
h = canvas.height;
|
||||
state = null;
|
||||
lastSampleRate = 0;
|
||||
},
|
||||
|
||||
render(data: AudioData, color: string) {
|
||||
if (!ctx) return;
|
||||
|
||||
if (!state || data.sampleRate !== lastSampleRate) {
|
||||
state = createLUFSState(data.sampleRate);
|
||||
lastSampleRate = data.sampleRate;
|
||||
}
|
||||
|
||||
const left = data.leftTimeDomain;
|
||||
const right = data.rightTimeDomain;
|
||||
const len = Math.min(left.length, right.length);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
state.blockBuffer[0][state.blockPos] = left[i];
|
||||
state.blockBuffer[1][state.blockPos] = right[i];
|
||||
state.blockPos++;
|
||||
state.hopPos++;
|
||||
|
||||
if (state.blockPos >= state.blockSize) {
|
||||
const loudness = computeBlockLoudness(state.blockBuffer[0], state.blockBuffer[1], state.blockSize);
|
||||
|
||||
state.momentaryBlocks.push(loudness);
|
||||
if (state.momentaryBlocks.length > 4) state.momentaryBlocks.shift();
|
||||
state.momentary = Math.max(...state.momentaryBlocks);
|
||||
|
||||
state.shortTermBlocks.push(loudness);
|
||||
if (state.shortTermBlocks.length > 30) state.shortTermBlocks.shift();
|
||||
const stPowers = state.shortTermBlocks.filter(v => v > -Infinity);
|
||||
if (stPowers.length > 0) {
|
||||
const stMean = stPowers.reduce((s, v) => s + Math.pow(10, v / 10), 0) / stPowers.length;
|
||||
state.shortTerm = 10 * Math.log10(stMean);
|
||||
}
|
||||
|
||||
state.integratedPowers.push(loudness);
|
||||
if (state.integratedPowers.length > 3000) state.integratedPowers.shift();
|
||||
state.integrated = computeGatedIntegrated(state.integratedPowers);
|
||||
|
||||
const keep = state.blockSize - state.hopSize;
|
||||
state.blockBuffer[0].copyWithin(0, state.hopSize);
|
||||
state.blockBuffer[1].copyWithin(0, state.hopSize);
|
||||
state.blockPos = keep;
|
||||
state.hopPos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const clamp = (v: number) => (v === -Infinity ? -60 : Math.max(-60, Math.min(0, v)));
|
||||
state.displayMomentary = lerp(state.displayMomentary, clamp(state.momentary), SMOOTHING_FAST);
|
||||
state.displayShortTerm = lerp(state.displayShortTerm, clamp(state.shortTerm), SMOOTHING_FAST);
|
||||
state.displayIntegrated = lerp(state.displayIntegrated, clamp(state.integrated), SMOOTHING_SLOW);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const [cr, cg, cb] = hexToRGB(color);
|
||||
|
||||
const minLUFS = -60;
|
||||
const maxLUFS = 0;
|
||||
const range = maxLUFS - minLUFS;
|
||||
const norm = (v: number) => Math.max(0, Math.min(1, (v - minLUFS) / range));
|
||||
|
||||
const labels = ["M", "S", "I"];
|
||||
const rawValues = [state.momentary, state.shortTerm, state.integrated];
|
||||
const displayValues = [state.displayMomentary, state.displayShortTerm, state.displayIntegrated];
|
||||
const barH = (h - 4) / 3;
|
||||
const labelW = 12;
|
||||
const valueW = 36;
|
||||
const barX = labelW;
|
||||
const barW = w - labelW - valueW;
|
||||
|
||||
ctx.font = `bold ${Math.min(9, barH - 1)}px monospace`;
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const y = 1 + i * (barH + 1);
|
||||
const n = norm(displayValues[i]);
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(labels[i], 1, y + barH / 2);
|
||||
|
||||
ctx.fillStyle = `rgba(${cr * 255}, ${cg * 255}, ${cb * 255}, 0.15)`;
|
||||
ctx.fillRect(barX, y, barW, barH);
|
||||
|
||||
ctx.fillStyle = `rgba(${cr * 255}, ${cg * 255}, ${cb * 255}, 0.7)`;
|
||||
ctx.fillRect(barX, y, barW * n, barH);
|
||||
|
||||
ctx.fillStyle = "rgba(255,255,255,0.8)";
|
||||
ctx.textAlign = "right";
|
||||
const raw = rawValues[i];
|
||||
const txt = raw > -Infinity ? raw.toFixed(1) : "-inf";
|
||||
ctx.fillText(txt, w - 1, y + barH / 2);
|
||||
}
|
||||
},
|
||||
|
||||
resize(width, height) {
|
||||
w = width;
|
||||
h = height;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
ctx = null;
|
||||
state = null;
|
||||
lastSampleRate = 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { AudioData } from "../audio";
|
||||
import type { Visualizer } from "./types";
|
||||
import { settings } from "../Settings";
|
||||
|
||||
export const createOscilloscope = (): Visualizer => {
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let w = 0, h = 0;
|
||||
let scrollBuffer: Float32Array | null = null;
|
||||
let scrollPos = 0;
|
||||
|
||||
const ensureScrollBuffer = () => {
|
||||
if (!scrollBuffer || scrollBuffer.length !== w) {
|
||||
scrollBuffer = new Float32Array(w);
|
||||
scrollPos = 0;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: "Oscilloscope",
|
||||
id: "oscilloscope",
|
||||
|
||||
init(canvas, _color) {
|
||||
ctx = canvas.getContext("2d")!;
|
||||
w = canvas.width;
|
||||
h = canvas.height;
|
||||
scrollBuffer = null;
|
||||
scrollPos = 0;
|
||||
},
|
||||
|
||||
render(data: AudioData, color: string) {
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const lineWidth = settings.lineThickness ?? 1.5;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
|
||||
if (settings.scrollingOscilloscope) {
|
||||
ensureScrollBuffer();
|
||||
if (!scrollBuffer) return;
|
||||
|
||||
const timeDomain = data.floatTimeDomain;
|
||||
const samplesPerPixel = Math.max(1, Math.floor(timeDomain.length / w));
|
||||
const pixelsToAdd = Math.max(1, Math.ceil(timeDomain.length / samplesPerPixel));
|
||||
|
||||
for (let p = 0; p < pixelsToAdd; p++) {
|
||||
const sampleIdx = Math.floor(p * samplesPerPixel);
|
||||
let peak = 0;
|
||||
for (let s = sampleIdx; s < Math.min(sampleIdx + samplesPerPixel, timeDomain.length); s++) {
|
||||
if (Math.abs(timeDomain[s]) > Math.abs(peak)) peak = timeDomain[s];
|
||||
}
|
||||
scrollBuffer[scrollPos % w] = peak;
|
||||
scrollPos++;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
for (let x = 0; x < w; x++) {
|
||||
const idx = (scrollPos - w + x + w * 2) % w;
|
||||
const sample = scrollBuffer[idx];
|
||||
const y = (1 - sample) * h / 2;
|
||||
if (x === 0) ctx.moveTo(0, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
} else {
|
||||
const buffer = data.byteTimeDomain;
|
||||
const len = buffer.length;
|
||||
const segmentWidth = w / len;
|
||||
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < len; i++) {
|
||||
const v = buffer[i] / 128.0;
|
||||
const y = (v * h) / 2;
|
||||
if (i === 0) ctx.moveTo(0, y);
|
||||
else ctx.lineTo(i * segmentWidth, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
resize(width, height) {
|
||||
w = width;
|
||||
h = height;
|
||||
scrollBuffer = null;
|
||||
scrollPos = 0;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
ctx = null;
|
||||
scrollBuffer = null;
|
||||
scrollPos = 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { AudioData } from "../audio";
|
||||
import type { Visualizer } from "./types";
|
||||
import { createProgram, drawQuad, setUniform1f, setUniform1fv, setUniform2f, setUniform3f, hexToRGB } from "../webgl";
|
||||
import { settings } from "../Settings";
|
||||
|
||||
const MAX_BARS = 128;
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_amplitudes[${MAX_BARS}];
|
||||
uniform int u_bar_count;
|
||||
uniform vec3 u_color;
|
||||
uniform float u_gap;
|
||||
uniform float u_gain;
|
||||
uniform float u_rounding;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / u_resolution;
|
||||
|
||||
float cellFloat = uv.x * float(u_bar_count);
|
||||
int barIdx = clamp(int(cellFloat), 0, u_bar_count - 1);
|
||||
float cellPos = fract(cellFloat);
|
||||
|
||||
float amp = clamp(u_amplitudes[barIdx] * u_gain, 0.0, 1.0);
|
||||
|
||||
if (amp < 0.005) {
|
||||
fragColor = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bar shape with anti-aliased edges and configurable gap
|
||||
float barMask = smoothstep(0.0, u_gap, cellPos)
|
||||
* smoothstep(0.0, u_gap, 1.0 - cellPos);
|
||||
|
||||
// Hard cut at bottom, soft feather only at the top edge
|
||||
float feather = 1.5 / u_resolution.y;
|
||||
float heightMask = 1.0 - smoothstep(amp - feather, amp + feather, uv.y);
|
||||
|
||||
float a = barMask * heightMask;
|
||||
|
||||
// Rounded top corners in pixel space
|
||||
if (u_rounding > 0.5 && a > 0.0) {
|
||||
float cellPx = u_resolution.x / float(u_bar_count);
|
||||
float barPx = cellPx * (1.0 - 2.0 * u_gap);
|
||||
float fromLeft = (cellPos - u_gap) * cellPx;
|
||||
float fromRight = barPx - fromLeft;
|
||||
float fromTop = (amp - uv.y) * u_resolution.y;
|
||||
float r = clamp(barPx * 0.3, 1.0, 3.0);
|
||||
float edgeX = min(fromLeft, fromRight);
|
||||
if (edgeX < r && fromTop < r && fromTop >= 0.0) {
|
||||
float d = length(vec2(r - edgeX, r - fromTop)) - r;
|
||||
a *= 1.0 - smoothstep(-0.5, 0.5, d);
|
||||
}
|
||||
}
|
||||
|
||||
fragColor = vec4(u_color * a, a);
|
||||
}
|
||||
`;
|
||||
|
||||
const amplitudes = new Float32Array(MAX_BARS);
|
||||
|
||||
export const createSpectrumBars = (): Visualizer => {
|
||||
let gl: WebGL2RenderingContext | null = null;
|
||||
let program: WebGLProgram | null = null;
|
||||
let w = 0, h = 0;
|
||||
|
||||
return {
|
||||
name: "Spectrum (Bars)",
|
||||
id: "spectrum-bars",
|
||||
|
||||
init(canvas, _color) {
|
||||
gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true })!;
|
||||
if (!gl) throw new Error("WebGL2 not available");
|
||||
program = createProgram(gl, FRAG);
|
||||
w = canvas.width;
|
||||
h = canvas.height;
|
||||
gl.viewport(0, 0, w, h);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
},
|
||||
|
||||
render(data: AudioData, color: string) {
|
||||
if (!gl || !program) return;
|
||||
const barCount = Math.min(settings.barCount ?? 64, MAX_BARS);
|
||||
const gain = settings.gain ?? 1.5;
|
||||
|
||||
// Use byteFrequency (0-255 normalized across full analyser range)
|
||||
const binStep = data.byteFrequency.length / barCount;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
let maxVal = 0;
|
||||
const start = Math.floor(i * binStep);
|
||||
const end = Math.floor((i + 1) * binStep);
|
||||
for (let j = start; j < end; j++) {
|
||||
if (data.byteFrequency[j] > maxVal) maxVal = data.byteFrequency[j];
|
||||
}
|
||||
amplitudes[i] = Math.min(1, (maxVal / 255) * gain);
|
||||
}
|
||||
for (let i = barCount; i < MAX_BARS; i++) amplitudes[i] = 0;
|
||||
|
||||
gl.viewport(0, 0, w, h);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.useProgram(program);
|
||||
|
||||
setUniform2f(gl, program, "u_resolution", w, h);
|
||||
setUniform1fv(gl, program, "u_amplitudes", amplitudes);
|
||||
const loc = gl.getUniformLocation(program, "u_bar_count");
|
||||
gl.uniform1i(loc, barCount);
|
||||
const [r, g, b] = hexToRGB(color);
|
||||
setUniform3f(gl, program, "u_color", r, g, b);
|
||||
const cellPx = w / barCount;
|
||||
const gap = Math.min(0.15, 1.5 / cellPx);
|
||||
setUniform1f(gl, program, "u_gap", gap);
|
||||
setUniform1f(gl, program, "u_gain", 1.0);
|
||||
setUniform1f(gl, program, "u_rounding", settings.barRounding ? 1.0 : 0.0);
|
||||
|
||||
drawQuad(gl, program);
|
||||
},
|
||||
|
||||
resize(width, height) {
|
||||
w = width;
|
||||
h = height;
|
||||
if (gl) gl.viewport(0, 0, w, h);
|
||||
},
|
||||
|
||||
dispose() {
|
||||
if (gl && program) gl.deleteProgram(program);
|
||||
program = null;
|
||||
gl = null;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { AudioData } from "../audio";
|
||||
import type { Visualizer } from "./types";
|
||||
import { createProgram, drawQuad, setUniform1f, setUniform1fv, setUniform2f, setUniform3f, hexToRGB } from "../webgl";
|
||||
import { settings } from "../Settings";
|
||||
|
||||
const BIN_COUNT = 256;
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_amplitudes[${BIN_COUNT}];
|
||||
uniform vec3 u_color;
|
||||
uniform float u_fill_opacity;
|
||||
uniform float u_line_thickness;
|
||||
uniform float u_opacity_falloff;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
float interpolate(float a, float b, float t) {
|
||||
return (1.0 - t) * a + t * b;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / u_resolution;
|
||||
int idx = int(uv.x * float(${BIN_COUNT}));
|
||||
int idxL = int((uv.x - 1.0 / u_resolution.x) * float(${BIN_COUNT}));
|
||||
int idxR = int((uv.x + 1.0 / u_resolution.x) * float(${BIN_COUNT}));
|
||||
idx = clamp(idx, 0, ${BIN_COUNT - 1});
|
||||
idxL = clamp(idxL, 0, ${BIN_COUNT - 1});
|
||||
idxR = clamp(idxR, 0, ${BIN_COUNT - 1});
|
||||
|
||||
float amplitude = u_amplitudes[idx];
|
||||
float left = u_amplitudes[idxL];
|
||||
float right = u_amplitudes[idxR];
|
||||
float lowest = min(left, right);
|
||||
float dist = (amplitude - uv.y) * u_resolution.y;
|
||||
|
||||
float a = 0.0;
|
||||
a += float(abs(dist) <= u_resolution.x * 0.005 * u_line_thickness || (uv.y >= lowest && uv.y <= amplitude)) * clamp(sign(dist), 0.0, 1.0);
|
||||
a += clamp(sign(amplitude - uv.y), 0.0, 1.0) * interpolate(1.0, u_fill_opacity, pow(1.0 - uv.y, 1.0 - u_opacity_falloff));
|
||||
a = clamp(a, 0.0, 1.0);
|
||||
fragColor = vec4(u_color * a, a);
|
||||
}
|
||||
`;
|
||||
|
||||
const amplitudes = new Float32Array(BIN_COUNT);
|
||||
|
||||
export const createSpectrumLine = (): Visualizer => {
|
||||
let gl: WebGL2RenderingContext | null = null;
|
||||
let program: WebGLProgram | null = null;
|
||||
let w = 0, h = 0;
|
||||
|
||||
return {
|
||||
name: "Spectrum (Line)",
|
||||
id: "spectrum-line",
|
||||
|
||||
init(canvas, _color) {
|
||||
gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true })!;
|
||||
if (!gl) throw new Error("WebGL2 not available");
|
||||
program = createProgram(gl, FRAG);
|
||||
w = canvas.width;
|
||||
h = canvas.height;
|
||||
gl.viewport(0, 0, w, h);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
},
|
||||
|
||||
render(data: AudioData, color: string) {
|
||||
if (!gl || !program) return;
|
||||
const gain = settings.gain ?? 1.5;
|
||||
const binStep = data.byteFrequency.length / BIN_COUNT;
|
||||
for (let i = 0; i < BIN_COUNT; i++) {
|
||||
amplitudes[i] = Math.min(1, (data.byteFrequency[Math.floor(i * binStep)] / 255) * gain);
|
||||
}
|
||||
|
||||
gl.viewport(0, 0, w, h);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.useProgram(program);
|
||||
|
||||
setUniform2f(gl, program, "u_resolution", w, h);
|
||||
setUniform1fv(gl, program, "u_amplitudes", amplitudes);
|
||||
const [r, g, b] = hexToRGB(color);
|
||||
setUniform3f(gl, program, "u_color", r, g, b);
|
||||
setUniform1f(gl, program, "u_fill_opacity", settings.fillOpacity ?? 0.3);
|
||||
setUniform1f(gl, program, "u_line_thickness", settings.lineThickness ?? 1.5);
|
||||
setUniform1f(gl, program, "u_opacity_falloff", settings.opacityFalloff ?? 0.5);
|
||||
|
||||
drawQuad(gl, program);
|
||||
},
|
||||
|
||||
resize(width, height) {
|
||||
w = width;
|
||||
h = height;
|
||||
if (gl) gl.viewport(0, 0, w, h);
|
||||
},
|
||||
|
||||
dispose() {
|
||||
if (gl && program) gl.deleteProgram(program);
|
||||
program = null;
|
||||
gl = null;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { AudioData } from "../audio";
|
||||
|
||||
export interface Visualizer {
|
||||
readonly name: string;
|
||||
readonly id: VisualizerType;
|
||||
init(canvas: HTMLCanvasElement, color: string): void;
|
||||
render(data: AudioData, color: string): void;
|
||||
resize(width: number, height: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type VisualizerType =
|
||||
| "spectrum-line"
|
||||
| "spectrum-bars"
|
||||
| "oscilloscope"
|
||||
| "vectorscope"
|
||||
| "loudness-meter"
|
||||
| "none";
|
||||
|
||||
export interface VisualizerDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const VISUALIZER_DIMENSIONS: Record<VisualizerType, VisualizerDimensions> = {
|
||||
"spectrum-line": { width: 200, height: 40 },
|
||||
"spectrum-bars": { width: 200, height: 40 },
|
||||
oscilloscope: { width: 200, height: 40 },
|
||||
vectorscope: { width: 100, height: 40 },
|
||||
"loudness-meter": { width: 160, height: 40 },
|
||||
none: { width: 0, height: 0 },
|
||||
};
|
||||
|
||||
export const VISUALIZER_LABELS: Record<VisualizerType, string> = {
|
||||
"spectrum-line": "Spectrum (Line)",
|
||||
"spectrum-bars": "Spectrum (Bars)",
|
||||
oscilloscope: "Oscilloscope",
|
||||
vectorscope: "Vectorscope",
|
||||
"loudness-meter": "Loudness (LUFS)",
|
||||
none: "None",
|
||||
};
|
||||
|
||||
export type ZoneId = "topNav" | "nowPlaying" | "playerBar";
|
||||
export type PositionId = "left" | "right";
|
||||
|
||||
export const ALL_SLOT_KEYS = [
|
||||
"navLeft1", "navLeft2", "navLeft3",
|
||||
"navRight1", "navRight2", "navRight3",
|
||||
"npLeft1", "npLeft2", "npLeft3",
|
||||
"npRight1", "npRight2", "npRight3",
|
||||
"pbLeft1", "pbLeft2", "pbLeft3",
|
||||
"pbRight1", "pbRight2", "pbRight3",
|
||||
] as const;
|
||||
|
||||
export type SlotKey = (typeof ALL_SLOT_KEYS)[number];
|
||||
|
||||
export const ZONE_SLOTS: Record<ZoneId, Record<PositionId, readonly SlotKey[]>> = {
|
||||
topNav: {
|
||||
left: ["navLeft1", "navLeft2", "navLeft3"],
|
||||
right: ["navRight1", "navRight2", "navRight3"],
|
||||
},
|
||||
nowPlaying: {
|
||||
left: ["npLeft1", "npLeft2", "npLeft3"],
|
||||
right: ["npRight1", "npRight2", "npRight3"],
|
||||
},
|
||||
playerBar: {
|
||||
left: ["pbLeft1", "pbLeft2", "pbLeft3"],
|
||||
right: ["pbRight1", "pbRight2", "pbRight3"],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZONE_LABELS: Record<ZoneId, string> = {
|
||||
nowPlaying: "Now Playing View",
|
||||
topNav: "Top Nav",
|
||||
playerBar: "Player Bar",
|
||||
};
|
||||
|
||||
export const POSITION_LABELS: Record<PositionId, string> = {
|
||||
left: "Left",
|
||||
right: "Right",
|
||||
};
|
||||
|
||||
export const MINI_SUPPORTED = new Set<VisualizerType>(["oscilloscope", "vectorscope"]);
|
||||
|
||||
export const MINI_DIMENSIONS: Partial<Record<VisualizerType, VisualizerDimensions>> = {
|
||||
oscilloscope: { width: 80, height: 60 },
|
||||
vectorscope: { width: 72, height: 40 },
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { AudioData } from "../audio";
|
||||
import type { Visualizer } from "./types";
|
||||
import { settings } from "../Settings";
|
||||
|
||||
export const createVectorscope = (): Visualizer => {
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let w = 0, h = 0;
|
||||
let lastX = 0, lastY = 0;
|
||||
let hasLast = false;
|
||||
let lastLissajous = false;
|
||||
|
||||
return {
|
||||
name: "Vectorscope",
|
||||
id: "vectorscope",
|
||||
|
||||
init(cvs, _color) {
|
||||
canvas = cvs;
|
||||
const c = cvs.getContext("2d");
|
||||
if (!c) return;
|
||||
ctx = c;
|
||||
w = cvs.width;
|
||||
h = cvs.height;
|
||||
hasLast = false;
|
||||
|
||||
lastLissajous = !!settings.lissajous;
|
||||
cvs.style.transform = lastLissajous ? "rotate(45deg) scale(0.707)" : "";
|
||||
},
|
||||
|
||||
render(data: AudioData, color: string) {
|
||||
if (!ctx || !canvas) return;
|
||||
|
||||
const wantLissajous = !!settings.lissajous;
|
||||
if (wantLissajous !== lastLissajous) {
|
||||
lastLissajous = wantLissajous;
|
||||
canvas.style.transform = wantLissajous ? "rotate(45deg) scale(0.707)" : "";
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const left = data.leftTimeDomain;
|
||||
const right = data.rightTimeDomain;
|
||||
const len = Math.min(left.length, right.length);
|
||||
const lineWidth = Math.max(0.5, (settings.lineThickness ?? 1.0) * 0.5);
|
||||
const inset = lineWidth;
|
||||
const halfW = Math.max(1, w / 2 - inset);
|
||||
const halfH = Math.max(1, h / 2 - inset);
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
|
||||
hasLast = false;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = left[i] * halfW + w / 2;
|
||||
const y = right[i] * halfH + h / 2;
|
||||
|
||||
if (!hasLast) {
|
||||
ctx.moveTo(x, y);
|
||||
hasLast = true;
|
||||
} else {
|
||||
ctx.moveTo(lastX, lastY);
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
lastX = x;
|
||||
lastY = y;
|
||||
}
|
||||
ctx.stroke();
|
||||
},
|
||||
|
||||
resize(width, height) {
|
||||
w = width;
|
||||
h = height;
|
||||
hasLast = false;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
if (canvas) canvas.style.transform = "";
|
||||
ctx = null;
|
||||
canvas = null;
|
||||
hasLast = false;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
const VERTEX_SHADER = `#version 300 es
|
||||
in vec2 a_position;
|
||||
void main() {
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export const compileShader = (gl: WebGL2RenderingContext, type: number, source: string): WebGLShader => {
|
||||
const shader = gl.createShader(type)!;
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
const info = gl.getShaderInfoLog(shader);
|
||||
gl.deleteShader(shader);
|
||||
throw new Error(`Shader compile error: ${info}`);
|
||||
}
|
||||
return shader;
|
||||
};
|
||||
|
||||
export const createProgram = (gl: WebGL2RenderingContext, fragSource: string): WebGLProgram => {
|
||||
const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
|
||||
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource);
|
||||
const program = gl.createProgram()!;
|
||||
gl.attachShader(program, vert);
|
||||
gl.attachShader(program, frag);
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
const info = gl.getProgramInfoLog(program);
|
||||
gl.deleteProgram(program);
|
||||
throw new Error(`Program link error: ${info}`);
|
||||
}
|
||||
gl.deleteShader(vert);
|
||||
gl.deleteShader(frag);
|
||||
return program;
|
||||
};
|
||||
|
||||
interface QuadResources {
|
||||
vao: WebGLVertexArrayObject;
|
||||
vbo: WebGLBuffer;
|
||||
}
|
||||
const quadMap = new WeakMap<WebGL2RenderingContext, QuadResources>();
|
||||
|
||||
const ensureQuad = (gl: WebGL2RenderingContext, program: WebGLProgram): QuadResources => {
|
||||
let res = quadMap.get(gl);
|
||||
if (res) return res;
|
||||
const verts = new Float32Array([-1, -1, 3, -1, -1, 3]);
|
||||
const vao = gl.createVertexArray()!;
|
||||
const vbo = gl.createBuffer()!;
|
||||
gl.bindVertexArray(vao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
|
||||
const loc = gl.getAttribLocation(program, "a_position");
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.bindVertexArray(null);
|
||||
res = { vao, vbo };
|
||||
quadMap.set(gl, res);
|
||||
return res;
|
||||
};
|
||||
|
||||
export const drawQuad = (gl: WebGL2RenderingContext, program: WebGLProgram): void => {
|
||||
const res = ensureQuad(gl, program);
|
||||
gl.useProgram(program);
|
||||
gl.bindVertexArray(res.vao);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
gl.bindVertexArray(null);
|
||||
};
|
||||
|
||||
export interface PingPongBuffers {
|
||||
fbos: [WebGLFramebuffer, WebGLFramebuffer];
|
||||
textures: [WebGLTexture, WebGLTexture];
|
||||
current: 0 | 1;
|
||||
}
|
||||
|
||||
const createFBOTexture = (gl: WebGL2RenderingContext, w: number, h: number): { fbo: WebGLFramebuffer; texture: WebGLTexture } => {
|
||||
const tex = gl.createTexture()!;
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
const fbo = gl.createFramebuffer()!;
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
return { fbo, texture: tex };
|
||||
};
|
||||
|
||||
export const createPingPong = (gl: WebGL2RenderingContext, w: number, h: number): PingPongBuffers => {
|
||||
const a = createFBOTexture(gl, w, h);
|
||||
const b = createFBOTexture(gl, w, h);
|
||||
return {
|
||||
fbos: [a.fbo, b.fbo],
|
||||
textures: [a.texture, b.texture],
|
||||
current: 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const resizePingPong = (gl: WebGL2RenderingContext, pp: PingPongBuffers, w: number, h: number): void => {
|
||||
for (const tex of pp.textures) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
};
|
||||
|
||||
export const setUniform1f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: number): void => {
|
||||
gl.uniform1f(gl.getUniformLocation(program, name), v);
|
||||
};
|
||||
|
||||
export const setUniform2f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, x: number, y: number): void => {
|
||||
gl.uniform2f(gl.getUniformLocation(program, name), x, y);
|
||||
};
|
||||
|
||||
export const setUniform3f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, x: number, y: number, z: number): void => {
|
||||
gl.uniform3f(gl.getUniformLocation(program, name), x, y, z);
|
||||
};
|
||||
|
||||
export const setUniform1fv = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: Float32Array): void => {
|
||||
gl.uniform1fv(gl.getUniformLocation(program, name), v);
|
||||
};
|
||||
|
||||
export const setUniform1i = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: number): void => {
|
||||
gl.uniform1i(gl.getUniformLocation(program, name), v);
|
||||
};
|
||||
|
||||
export const disposeQuad = (gl: WebGL2RenderingContext): void => {
|
||||
const res = quadMap.get(gl);
|
||||
if (res) {
|
||||
gl.deleteVertexArray(res.vao);
|
||||
gl.deleteBuffer(res.vbo);
|
||||
quadMap.delete(gl);
|
||||
}
|
||||
};
|
||||
|
||||
export const disposePingPong = (gl: WebGL2RenderingContext, pp: PingPongBuffers): void => {
|
||||
for (const fbo of pp.fbos) gl.deleteFramebuffer(fbo);
|
||||
for (const tex of pp.textures) gl.deleteTexture(tex);
|
||||
};
|
||||
|
||||
export const hexToRGB = (hex: string): [number, number, number] => {
|
||||
const c = hex.replace("#", "");
|
||||
const r = parseInt(c.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(c.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(c.substring(4, 6), 16) / 255;
|
||||
return [r, g, b];
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@meowarex/oled-theme",
|
||||
"description": "A dark OLED-friendly theme plugin that transforms Tidal Luna's appearance.",
|
||||
"name": "@meowarex/colorama-lyrics",
|
||||
"description": "Customize lyrics colors: single, gradient & auto from cover art",
|
||||
"author": {
|
||||
"name": "meowarex",
|
||||
"url": "https://github.com/meowarex",
|
||||
@@ -8,4 +8,4 @@
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"type": "module"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
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);
|
||||
@@ -0,0 +1,117 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -8,4 +8,4 @@
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"type": "module"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LunaUnload, Tracer } from "@luna/core";
|
||||
import { type 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,110 +9,194 @@ export const { trace } = Tracer("[Copy Lyrics]");
|
||||
// clean up resources
|
||||
export const unloads = new Set<LunaUnload>();
|
||||
|
||||
// StyleTag for lyrics selection styling
|
||||
const lyricsStyleTag = new StyleTag("Copy-Lyrics", unloads, unlockSelection);
|
||||
// Style injection via side effect
|
||||
new StyleTag("Copy-Lyrics", unloads, unlockSelection);
|
||||
|
||||
function SetClipboard(text: string): void {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed"; // Avoid scrolling to bottom
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
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();
|
||||
|
||||
try {
|
||||
const success = document.execCommand("copy");
|
||||
if (!success) throw new Error("Failed to copy text.");
|
||||
} catch (err) {
|
||||
trace.msg.err(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
try {
|
||||
const success = document.execCommand("copy");
|
||||
if (!success) throw new Error("Failed to copy text.");
|
||||
} catch (err) {
|
||||
trace.msg.err(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
let isSelecting = false;
|
||||
const LINE_SELECTORS = [
|
||||
".rl-wbw-container .rl-wbw-line",
|
||||
'[data-test="now-playing-lyrics"] span[data-test="lyrics-line"]',
|
||||
'[class*="_lyricsText"] > div > span',
|
||||
].join(",");
|
||||
|
||||
const onMouseDown = function (): void {
|
||||
isSelecting = true;
|
||||
const OVERLAY_LINE_SELECTOR = ".rl-wbw-container .rl-wbw-line";
|
||||
const LYRICS_ROOT_SELECTOR = [
|
||||
'[data-test="now-playing-lyrics"]',
|
||||
'[class*="_lyricsText"]',
|
||||
".rl-wbw-container",
|
||||
].join(",");
|
||||
|
||||
let isPointerDownInLyrics = false;
|
||||
let suppressNextClick = false;
|
||||
let suppressClickResetTimer: number | null = null;
|
||||
|
||||
const isElement = (node: Node | null): node is Element =>
|
||||
Boolean(node && node.nodeType === Node.ELEMENT_NODE);
|
||||
|
||||
const getElementFromNode = (node: Node | null): Element | null => {
|
||||
if (!node) return null;
|
||||
return isElement(node) ? node : node.parentElement;
|
||||
};
|
||||
|
||||
const onMouseUp = function (event: MouseEvent): void {
|
||||
if (isSelecting) {
|
||||
const selection = window.getSelection();
|
||||
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;
|
||||
}
|
||||
}
|
||||
const isInLyrics = (node: Node | null): boolean =>
|
||||
Boolean(getElementFromNode(node)?.closest(LYRICS_ROOT_SELECTOR));
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
isSelecting = false;
|
||||
}
|
||||
const rangeIntersectsNode = (range: Range, node: Node): boolean => {
|
||||
try {
|
||||
return range.intersectsNode(node);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const onClickHooked = function (event: MouseEvent): boolean | void {
|
||||
if (!isSelecting) return;
|
||||
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 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;
|
||||
}
|
||||
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 selection = window.getSelection();
|
||||
if (selection?.toString().trim()) {
|
||||
const text = getSelectedLyricsText(selection);
|
||||
if (text.length > 0) {
|
||||
SetClipboard(text);
|
||||
trace.msg.log("Copied to clipboard!");
|
||||
selection.removeAllRanges();
|
||||
suppressUpcomingClick();
|
||||
}
|
||||
}
|
||||
|
||||
isPointerDownInLyrics = false;
|
||||
};
|
||||
|
||||
const onClickHooked = (event: MouseEvent): boolean | undefined => {
|
||||
if (!suppressNextClick) return;
|
||||
|
||||
suppressNextClick = false;
|
||||
if (suppressClickResetTimer !== null) {
|
||||
window.clearTimeout(suppressClickResetTimer);
|
||||
suppressClickResetTimer = null;
|
||||
}
|
||||
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(() => {
|
||||
// Remove event listeners
|
||||
document.removeEventListener("click", onClickHooked, true);
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
unloads.add((): void => {
|
||||
// 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,9 +1,16 @@
|
||||
[class^="_lyricsText"]>div>span {
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
[data-test="now-playing-lyrics"],
|
||||
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"],
|
||||
[class*="_lyricsText"] > div > span,
|
||||
.rl-wbw-container,
|
||||
.rl-wbw-line,
|
||||
.rl-wbw-word,
|
||||
.rl-wbw-main,
|
||||
.rl-wbw-bg-container {
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgb(72, 0, 60);
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
background: rgb(72, 0, 60);
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"type": "module"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ export const settings = await ReactiveStore.getPluginStorage("ElementHider", {
|
||||
className: string;
|
||||
textContent: string;
|
||||
timestamp: number;
|
||||
}>
|
||||
}>,
|
||||
});
|
||||
|
||||
export const Settings = () => {
|
||||
return null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LunaUnload, Tracer } from "@luna/core";
|
||||
import { StyleTag, ContextMenu } from "@luna/lib";
|
||||
import { type LunaUnload, Tracer } from "@luna/core";
|
||||
import { StyleTag } from "@luna/lib";
|
||||
import { settings, Settings } from "./Settings";
|
||||
|
||||
// Import CSS directly using Luna's file:// syntax
|
||||
@@ -13,13 +13,19 @@ export { Settings };
|
||||
// Clean up resources
|
||||
export const unloads = new Set<LunaUnload>();
|
||||
|
||||
// StyleTag for element hider
|
||||
const styleTag = new StyleTag("Element-Hider", unloads, styles);
|
||||
// StyleTag for element hider (side-effect)
|
||||
new StyleTag("Element-Hider", unloads, styles);
|
||||
|
||||
// State management
|
||||
let targetElement: HTMLElement | null = null;
|
||||
let hiddenElements = new WeakSet<HTMLElement>();
|
||||
let hiddenElementsArray: HTMLElement[] = [];
|
||||
|
||||
// Count of elements currently hidden in the live DOM. The `.element-hider-hidden`
|
||||
// class is the source of truth — querying it avoids retaining detached nodes
|
||||
// across SPA navigations.
|
||||
function getHiddenCount(): number {
|
||||
return document.querySelectorAll(".element-hider-hidden").length;
|
||||
}
|
||||
|
||||
// MutationObserver for reactive element detection
|
||||
let elementObserver: MutationObserver | null = null;
|
||||
@@ -30,39 +36,54 @@ function generateElementSelector(element: HTMLElement): string {
|
||||
if (element.id) {
|
||||
return `#${element.id}`;
|
||||
}
|
||||
|
||||
|
||||
// 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}"]`;
|
||||
}
|
||||
|
||||
|
||||
// Priority 3: Combination of tag + specific classes + position
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -70,26 +91,36 @@ 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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
trace.log(`Generated specific selector: ${selector}`);
|
||||
return selector;
|
||||
}
|
||||
@@ -100,16 +131,16 @@ 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) {
|
||||
settings.hiddenElements.push(elementInfo);
|
||||
trace.log(`Saved element: ${elementInfo.selector}`);
|
||||
@@ -119,17 +150,18 @@ function saveHiddenElement(element: HTMLElement): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
// 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}`);
|
||||
// }
|
||||
// }
|
||||
|
||||
// Check if an element matches any stored selector (EXACT match only)
|
||||
function matchesStoredSelector(element: HTMLElement): boolean {
|
||||
@@ -143,97 +175,109 @@ function matchesStoredSelector(element: HTMLElement): boolean {
|
||||
trace.warn(`Invalid selector: ${storedElement.selector}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide element directly without animation
|
||||
function hideElementDirectly(element: HTMLElement): void {
|
||||
if (hiddenElements.has(element)) return;
|
||||
|
||||
|
||||
element.classList.add("element-hider-hidden");
|
||||
hiddenElements.add(element);
|
||||
hiddenElementsArray.push(element);
|
||||
trace.log(`Hidden element: ${element.tagName}${element.className ? '.' + element.className.split(' ')[0] : ''}`);
|
||||
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");
|
||||
|
||||
|
||||
// Store reference to the element
|
||||
const elementToHide = targetElement;
|
||||
|
||||
|
||||
// Save to persistent storage
|
||||
saveHiddenElement(elementToHide);
|
||||
|
||||
|
||||
// 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
|
||||
targetElement = null;
|
||||
}
|
||||
|
||||
// Unhide all elements permanently (remove from storage)
|
||||
function unhideAllElements(): void {
|
||||
trace.log(`Permanently unhiding ${settings.hiddenElements.length} saved elements`);
|
||||
|
||||
trace.log(
|
||||
`Permanently unhiding ${settings.hiddenElements.length} saved selectors`,
|
||||
);
|
||||
|
||||
// Show all currently hidden elements
|
||||
hiddenElementsArray.forEach(element => {
|
||||
if (document.body.contains(element)) {
|
||||
document
|
||||
.querySelectorAll(".element-hider-hidden, .element-hider-hiding")
|
||||
.forEach((element) => {
|
||||
element.classList.remove("element-hider-hidden", "element-hider-hiding");
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// Clear both storage and runtime collections
|
||||
settings.hiddenElements = [];
|
||||
hiddenElements = new WeakSet<HTMLElement>();
|
||||
hiddenElementsArray = [];
|
||||
}
|
||||
|
||||
// Process all elements in the document to hide matching ones (with strict matching)
|
||||
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
|
||||
settings.hiddenElements.forEach((storedElement, index) => {
|
||||
try {
|
||||
trace.log(`Searching for: ${storedElement.selector}`);
|
||||
const elements = document.querySelectorAll(storedElement.selector);
|
||||
trace.log(`Found ${elements.length} matches for selector ${index + 1}`);
|
||||
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
elements.forEach((element, elemIndex) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
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) {
|
||||
trace.warn(`Invalid selector: ${storedElement.selector}`, error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (hiddenCount > 0) {
|
||||
trace.log(`Total elements hidden: ${hiddenCount}`);
|
||||
}
|
||||
@@ -241,19 +285,19 @@ 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;
|
||||
|
||||
|
||||
// Check the element itself
|
||||
if (matchesStoredSelector(element)) {
|
||||
hideElementDirectly(element);
|
||||
}
|
||||
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -264,29 +308,36 @@ function processNewElements(addedNodes: NodeList): void {
|
||||
// Set up reactive element observer
|
||||
function setupElementObserver(): void {
|
||||
if (elementObserver) return;
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
elementObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
|
||||
trace.log(`Set up reactive element observer`);
|
||||
}
|
||||
|
||||
// Global functions
|
||||
(window as any).showAllElementsFromSettings = unhideAllElements;
|
||||
(window as any).debugElementHider = () => {
|
||||
declare global {
|
||||
interface Window {
|
||||
showAllElementsFromSettings?: () => void;
|
||||
debugElementHider?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
window.showAllElementsFromSettings = unhideAllElements;
|
||||
window.debugElementHider = () => {
|
||||
trace.log(`=== Element Hider Debug Info ===`);
|
||||
trace.log(`Stored elements: ${settings.hiddenElements.length}`);
|
||||
trace.log(`Currently hidden elements: ${hiddenElementsArray.length}`);
|
||||
trace.log(`Currently hidden elements: ${getHiddenCount()}`);
|
||||
trace.log(`Reactive hiding enabled: true`);
|
||||
settings.hiddenElements.forEach((element, index) => {
|
||||
trace.log(`${index + 1}. ${element.selector} (${element.tagName})`);
|
||||
@@ -297,19 +348,19 @@ function setupElementObserver(): void {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -321,59 +372,70 @@ 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) => {
|
||||
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) {
|
||||
currentContextElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show menu on our own custom menu
|
||||
if (target.closest(".element-hider-custom-menu")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close any existing custom menu
|
||||
closeCustomMenu();
|
||||
|
||||
// Store the right-clicked element for context menu
|
||||
currentContextElement = target;
|
||||
waitingForBuiltInMenu = true;
|
||||
|
||||
// Store event coordinates for potential custom menu
|
||||
const eventX = event.clientX;
|
||||
const eventY = event.clientY;
|
||||
|
||||
// Prevent default immediately if we plan to handle it
|
||||
event.preventDefault();
|
||||
|
||||
// Wait to see if the built-in context menu appears
|
||||
contextMenuTimeout = window.setTimeout(() => {
|
||||
// If we're still waiting and no built-in menu appeared, show our custom menu
|
||||
if (waitingForBuiltInMenu && currentContextElement) {
|
||||
showCustomMenu(eventX, eventY);
|
||||
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
|
||||
) {
|
||||
currentContextElement = null;
|
||||
return;
|
||||
}
|
||||
waitingForBuiltInMenu = false;
|
||||
}, 150); // Wait 150ms for built-in menu
|
||||
|
||||
// Don't prevent default initially - let Luna try to handle the context menu
|
||||
}, true);
|
||||
|
||||
// Don't show menu on our own custom menu
|
||||
if (target.closest(".element-hider-custom-menu")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close any existing custom menu
|
||||
closeCustomMenu();
|
||||
|
||||
// Store the right-clicked element for context menu
|
||||
currentContextElement = target;
|
||||
waitingForBuiltInMenu = true;
|
||||
|
||||
// Store event coordinates for potential custom menu
|
||||
const eventX = event.clientX;
|
||||
const eventY = event.clientY;
|
||||
|
||||
// Allow native context menu by default; we'll show our custom menu only if needed
|
||||
|
||||
// Wait to see if the built-in context menu appears
|
||||
contextMenuTimeout = window.setTimeout(() => {
|
||||
// If we're still waiting and no built-in menu appeared, show our custom menu
|
||||
if (waitingForBuiltInMenu && currentContextElement) {
|
||||
showCustomMenu(eventX, eventY);
|
||||
}
|
||||
waitingForBuiltInMenu = false;
|
||||
}, 150); // Wait 150ms for built-in menu
|
||||
|
||||
// Don't prevent default initially - let Luna try to handle the context menu
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
// Listen for clicks to close custom menu
|
||||
document.addEventListener('click', (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// If clicking outside our custom menu, close it
|
||||
if (customMenu && !target.closest(".element-hider-custom-menu")) {
|
||||
closeCustomMenu();
|
||||
removeHighlight();
|
||||
}
|
||||
}, true);
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// If clicking outside our custom menu, close it
|
||||
if (customMenu && !target.closest(".element-hider-custom-menu")) {
|
||||
closeCustomMenu();
|
||||
removeHighlight();
|
||||
}
|
||||
},
|
||||
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();
|
||||
@@ -386,7 +448,7 @@ document.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
function createCustomMenu(): HTMLElement {
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "element-hider-custom-menu";
|
||||
|
||||
|
||||
// Hide Element option
|
||||
const hideItem = document.createElement("button");
|
||||
hideItem.className = "element-hider-menu-item";
|
||||
@@ -398,48 +460,48 @@ function createCustomMenu(): HTMLElement {
|
||||
closeCustomMenu();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Add hover effects for highlighting
|
||||
hideItem.addEventListener("mouseenter", () => {
|
||||
if (currentContextElement) {
|
||||
highlightElement(currentContextElement);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
hideItem.addEventListener("mouseleave", () => {
|
||||
removeHighlight();
|
||||
});
|
||||
|
||||
|
||||
// Unhide All Elements option
|
||||
const unhideAllItem = document.createElement("button");
|
||||
unhideAllItem.className = "element-hider-menu-item";
|
||||
unhideAllItem.innerHTML = `Unhide All Elements (${hiddenElementsArray.length})`;
|
||||
unhideAllItem.innerHTML = `Unhide All Elements (${getHiddenCount()})`;
|
||||
unhideAllItem.addEventListener("click", () => {
|
||||
unhideAllElements();
|
||||
closeCustomMenu();
|
||||
});
|
||||
|
||||
|
||||
menu.appendChild(hideItem);
|
||||
menu.appendChild(unhideAllItem);
|
||||
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
// Show custom context menu
|
||||
function showCustomMenu(x: number, y: number): void {
|
||||
closeCustomMenu();
|
||||
|
||||
|
||||
customMenu = createCustomMenu();
|
||||
document.body.appendChild(customMenu);
|
||||
|
||||
|
||||
// Position the menu
|
||||
const rect = customMenu.getBoundingClientRect();
|
||||
const finalX = Math.min(x, window.innerWidth - rect.width - 10);
|
||||
const finalY = Math.min(y, window.innerHeight - rect.height - 10);
|
||||
|
||||
|
||||
customMenu.style.left = `${finalX}px`;
|
||||
customMenu.style.top = `${finalY}px`;
|
||||
|
||||
|
||||
trace.log(`Context menu opened for: ${currentContextElement?.tagName}`);
|
||||
}
|
||||
|
||||
@@ -449,7 +511,7 @@ function closeCustomMenu(): void {
|
||||
customMenu.remove();
|
||||
customMenu = null;
|
||||
}
|
||||
|
||||
|
||||
if (contextMenuTimeout) {
|
||||
clearTimeout(contextMenuTimeout);
|
||||
contextMenuTimeout = null;
|
||||
@@ -462,11 +524,18 @@ const contextMenuObserver = new MutationObserver((mutations) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
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
|
||||
waitingForBuiltInMenu = false;
|
||||
@@ -485,8 +554,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;
|
||||
@@ -502,46 +571,47 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
|
||||
font-size: 14px;
|
||||
`;
|
||||
hideButton.innerHTML = `Hide This Element`;
|
||||
|
||||
hideButton.addEventListener('click', () => {
|
||||
|
||||
hideButton.addEventListener("click", () => {
|
||||
if (currentContextElement) {
|
||||
targetElement = currentContextElement;
|
||||
hideTargetElement();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 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 (${hiddenElementsArray.length})`;
|
||||
|
||||
unhideAllButton.addEventListener('click', unhideAllElements);
|
||||
|
||||
unhideAllButton.innerHTML = `Unhide All Elements (${getHiddenCount()})`;
|
||||
|
||||
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);
|
||||
@@ -549,7 +619,7 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
|
||||
`;
|
||||
contextMenu.appendChild(separator);
|
||||
}
|
||||
|
||||
|
||||
// Add our buttons
|
||||
contextMenu.appendChild(hideButton);
|
||||
contextMenu.appendChild(unhideAllButton);
|
||||
@@ -558,28 +628,28 @@ 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...");
|
||||
|
||||
|
||||
// Process immediately when DOM is ready
|
||||
trace.log("Starting element processing...");
|
||||
|
||||
|
||||
// Process existing elements
|
||||
processAllElements();
|
||||
|
||||
|
||||
// Set up reactive observer for new elements
|
||||
setupElementObserver();
|
||||
|
||||
|
||||
trace.log("Plugin fully initialized");
|
||||
}
|
||||
|
||||
// Run initialization when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePlugin);
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initializePlugin);
|
||||
} else {
|
||||
initializePlugin();
|
||||
}
|
||||
@@ -592,18 +662,18 @@ unloads.add(() => {
|
||||
elementObserver = null;
|
||||
}
|
||||
contextMenuObserver.disconnect();
|
||||
|
||||
|
||||
// Close any open custom menu
|
||||
closeCustomMenu();
|
||||
|
||||
|
||||
// Remove highlights
|
||||
removeHighlight();
|
||||
|
||||
|
||||
// Clean up global functions
|
||||
(window as any).showAllElementsFromSettings = undefined;
|
||||
(window as any).debugElementHider = undefined;
|
||||
|
||||
window.showAllElementsFromSettings = undefined;
|
||||
window.debugElementHider = undefined;
|
||||
|
||||
trace.log("Plugin unloaded");
|
||||
});
|
||||
|
||||
trace.log("Plugin loaded - Right-click any element to hide it!");
|
||||
trace.log("Plugin loaded - Right-click any element to hide it!");
|
||||
|
||||
@@ -2,62 +2,64 @@
|
||||
|
||||
/* Custom context menu for elements without built-in menu */
|
||||
.element-hider-custom-menu {
|
||||
position: fixed;
|
||||
background: var(--wave-color-background-elevated, #2a2a2a);
|
||||
border: 1px solid var(--wave-color-border, #444);
|
||||
border-radius: 8px;
|
||||
padding: 8px 0;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
z-index: 999999;
|
||||
min-width: 180px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
position: fixed;
|
||||
background: var(--wave-color-background-elevated, #2a2a2a);
|
||||
border: 1px solid var(--wave-color-border, #444);
|
||||
border-radius: 8px;
|
||||
padding: 8px 0;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
z-index: 999999;
|
||||
min-width: 180px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.element-hider-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
color: var(--wave-color-text, #ffffff);
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
color: var(--wave-color-text, #ffffff);
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.element-hider-menu-item:hover {
|
||||
background: var(--wave-color-background-hover, #3a3a3a);
|
||||
background: var(--wave-color-background-hover, #3a3a3a);
|
||||
}
|
||||
|
||||
.element-hider-menu-item:active {
|
||||
background: var(--wave-color-background-active, #4a4a4a);
|
||||
background: var(--wave-color-background-active, #4a4a4a);
|
||||
}
|
||||
|
||||
.element-hider-menu-icon {
|
||||
margin-right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Highlight the target element */
|
||||
.element-hider-target {
|
||||
outline: 2px solid #ff6b6b !important;
|
||||
outline-offset: 2px !important;
|
||||
box-shadow: 0 0 10px rgba(255, 107, 107, 0.6) !important;
|
||||
outline: 2px solid #ff6b6b !important;
|
||||
outline-offset: 2px !important;
|
||||
box-shadow: 0 0 10px rgba(255, 107, 107, 0.6) !important;
|
||||
}
|
||||
|
||||
/* Hidden elements */
|
||||
.element-hider-hidden {
|
||||
display: none !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Animation for hiding */
|
||||
.element-hider-hiding {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,301 +0,0 @@
|
||||
/*
|
||||
{
|
||||
"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);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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();
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
/*
|
||||
{
|
||||
"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;
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
/*
|
||||
{
|
||||
"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);
|
||||
}
|
||||
@@ -8,4 +8,4 @@
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"type": "module"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
||||
import { Tracer } from "@luna/core";
|
||||
|
||||
import { settings } from "./Settings";
|
||||
|
||||
const { trace } = Tracer("[Radiant Lyrics]");
|
||||
|
||||
const sylTrace = (...args: unknown[]) => {
|
||||
if (settings.syllableLogging) trace.log(...args);
|
||||
};
|
||||
|
||||
export const RL_PLATFORM = "rl";
|
||||
|
||||
const RL_ACCESS_TOKEN_ID = "58hy4s86";
|
||||
const RL_ACCESS_TOKEN = "xjehy2lfg5h5mjwotoxrcqugam";
|
||||
// Yup that's right, plaintext token in a public Repo!!
|
||||
// The API does not return sensitive data & won't be plain text like this in the future <3
|
||||
|
||||
let cachedPublicIP: string | undefined;
|
||||
|
||||
export async function ip(): Promise<string | undefined> {
|
||||
if (cachedPublicIP) return cachedPublicIP;
|
||||
try {
|
||||
const res = await fetch("https://api.ipify.org?format=text");
|
||||
if (res.ok) cachedPublicIP = (await res.text()).trim();
|
||||
} catch {}
|
||||
return cachedPublicIP;
|
||||
}
|
||||
|
||||
export async function auth(): Promise<Record<string, string>> {
|
||||
const clientIP = await ip();
|
||||
return {
|
||||
"P-Access-Token-Id": RL_ACCESS_TOKEN_ID,
|
||||
"P-Access-Token": RL_ACCESS_TOKEN,
|
||||
"x-client-ip": clientIP ?? "null",
|
||||
};
|
||||
}
|
||||
|
||||
// Platform param (just for DX logging)
|
||||
const platformQs = `platform=${encodeURIComponent(RL_PLATFORM)}`;
|
||||
|
||||
// Query string & params
|
||||
function query(
|
||||
title: string,
|
||||
artist: string,
|
||||
isrc: string | undefined,
|
||||
options?: { romanize?: boolean; flush?: boolean },
|
||||
): string {
|
||||
let q = `?title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`;
|
||||
if (isrc) q += `&isrc=${encodeURIComponent(isrc)}`;
|
||||
if (options?.romanize) q += "&romanize=true";
|
||||
if (options?.flush) q += "&flush=true";
|
||||
q += `&${platformQs}`;
|
||||
return q;
|
||||
}
|
||||
|
||||
// Response types
|
||||
|
||||
export interface WordTiming {
|
||||
text: string;
|
||||
time: number;
|
||||
duration: number;
|
||||
isBackground: boolean;
|
||||
romanized?: string;
|
||||
}
|
||||
|
||||
export interface WordLine {
|
||||
text: string;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
endTime: number;
|
||||
syllabus: WordTiming[];
|
||||
element: {
|
||||
key: string;
|
||||
songPart?: string;
|
||||
songPartIndex?: number;
|
||||
singer: string;
|
||||
};
|
||||
translation: string | null;
|
||||
romanized?: string;
|
||||
}
|
||||
|
||||
export interface ApiLine {
|
||||
text: string;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
endTime: number;
|
||||
syllabus?: WordTiming[];
|
||||
element?: {
|
||||
key: string;
|
||||
songPart?: string;
|
||||
songPartIndex?: number;
|
||||
singer?: string;
|
||||
};
|
||||
translation?: string | null;
|
||||
romanized?: string;
|
||||
}
|
||||
|
||||
export interface WordLyricsResponse {
|
||||
type: "Word";
|
||||
data: WordLine[];
|
||||
metadata: {
|
||||
source: string;
|
||||
title: string;
|
||||
language: string;
|
||||
totalDuration: string;
|
||||
agents?: Record<string, { type: string; name: string; alias: string }>;
|
||||
songParts?: Array<{ name: string; time: number; duration: number }>;
|
||||
};
|
||||
_cached?: boolean;
|
||||
}
|
||||
|
||||
export interface LineLyricsResponse {
|
||||
type: "Line";
|
||||
data: ApiLine[];
|
||||
metadata: {
|
||||
source: string;
|
||||
title: string;
|
||||
language: string;
|
||||
totalDuration: string;
|
||||
agents?: Record<string, { type: string; name: string; alias: string }>;
|
||||
songParts?: Array<{ name: string; time: number; duration: number }>;
|
||||
};
|
||||
_cached?: boolean;
|
||||
}
|
||||
|
||||
export type LyricsApiResponse = WordLyricsResponse | LineLyricsResponse;
|
||||
|
||||
type FetchOutcome =
|
||||
| { status: "ok"; data: LyricsApiResponse | null }
|
||||
| { status: "404" }
|
||||
| { status: "500" }
|
||||
| { status: "err" };
|
||||
|
||||
// Lyrics lookup (network)
|
||||
export async function fetchLyrics(
|
||||
title: string,
|
||||
artist: string,
|
||||
isrc: string | undefined,
|
||||
romanize: boolean,
|
||||
): Promise<LyricsApiResponse | null> {
|
||||
const params = query(title, artist, isrc, { romanize });
|
||||
const atomixUrl = `https://api.atomix.one/rl-api${params}`;
|
||||
const fallbackUrl = `https://rl-api.kineticsand.net/lyrics${params}`;
|
||||
|
||||
const rlApiHeaders = await auth();
|
||||
|
||||
const tryFetch = async (url: string, useAtomixAuth: boolean): Promise<FetchOutcome> => {
|
||||
try {
|
||||
sylTrace(`RL API: Fetching lyrics: ${url}`);
|
||||
const res = await fetch(url, {
|
||||
headers: useAtomixAuth ? rlApiHeaders : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
trace.log(`RL API: fetch failed: ${res.status} from ${url}`);
|
||||
if (res.status === 404) return { status: "404" };
|
||||
return res.status === 500 ? { status: "500" } : { status: "err" };
|
||||
}
|
||||
const data = (await res.json()) as LyricsApiResponse;
|
||||
if (!data?.data || !Array.isArray(data.data)) {
|
||||
trace.log("Lyrics API returned invalid payload");
|
||||
return { status: "ok", data: null };
|
||||
}
|
||||
if (data.type !== "Word" && data.type !== "Line") {
|
||||
trace.log("Lyrics not available in supported format");
|
||||
return { status: "ok", data: null };
|
||||
}
|
||||
return { status: "ok", data };
|
||||
} catch (err) {
|
||||
trace.log(`RL API: fetch error from ${url}: ${err}`);
|
||||
return { status: "err" };
|
||||
}
|
||||
};
|
||||
|
||||
const primary = await tryFetch(atomixUrl, true);
|
||||
if (primary.status === "ok") return primary.data;
|
||||
if (primary.status === "404") {
|
||||
trace.log("RL API: 404 — no API lyrics exist for this track");
|
||||
return null;
|
||||
}
|
||||
if (primary.status === "500") {
|
||||
trace.log("RL API: 500 (Execution Timeout) — fallback");
|
||||
}
|
||||
|
||||
const fallback = await tryFetch(fallbackUrl, false);
|
||||
if (fallback.status === "ok") return fallback.data;
|
||||
if (fallback.status === "404") {
|
||||
trace.log("RL API: 404 from fallback — no API lyrics exist for this track");
|
||||
return null;
|
||||
}
|
||||
if (fallback.status === "500") {
|
||||
trace.log("RL API: 500 from fallback — API IS ACTUALLY BORKED!");
|
||||
return null;
|
||||
}
|
||||
|
||||
trace.log("RL API: All Endpoints Failed");
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function flushLyrics(track: {
|
||||
title: string;
|
||||
artist: string;
|
||||
isrc?: string;
|
||||
}): Promise<
|
||||
| { ok: true; data: LyricsApiResponse & { _flush?: string } }
|
||||
| { ok: false; status: number; notFound: boolean }
|
||||
> {
|
||||
const q = query(track.title, track.artist, track.isrc, {
|
||||
flush: true,
|
||||
});
|
||||
const url = `https://api.atomix.one/rl-api${q}`;
|
||||
const headers = await auth();
|
||||
const res = await fetch(url, { headers });
|
||||
if (res.status === 404) {
|
||||
return { ok: false, status: 404, notFound: true };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, status: res.status, notFound: false };
|
||||
}
|
||||
const data = (await res.json()) as LyricsApiResponse & { _flush?: string };
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
// Romanize
|
||||
export async function romanizeLyrics(
|
||||
lineTexts: string[],
|
||||
): Promise<string[] | null> {
|
||||
if (lineTexts.length === 0) return null;
|
||||
|
||||
const payload = {
|
||||
type: "Line" as const,
|
||||
data: lineTexts.map((text, idx) => ({
|
||||
text,
|
||||
startTime: idx,
|
||||
duration: 0,
|
||||
endTime: idx,
|
||||
})),
|
||||
};
|
||||
|
||||
const romanizeQuery = `?${platformQs}`;
|
||||
const urls: { url: string; useAtomixAuth: boolean }[] = [
|
||||
{
|
||||
url: `https://api.atomix.one/rl-api/romanize${romanizeQuery}`,
|
||||
useAtomixAuth: true,
|
||||
},
|
||||
{
|
||||
url: `https://rl-api.kineticsand.net/romanize${romanizeQuery}`,
|
||||
useAtomixAuth: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { url, useAtomixAuth } of urls) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
const romanizeHeaders: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (useAtomixAuth) {
|
||||
Object.assign(romanizeHeaders, await auth());
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: romanizeHeaders,
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
if (!res.ok) {
|
||||
trace.log(`Romanize: request failed ${res.status} | ${url}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
type?: string;
|
||||
data?: Array<{ text?: string; romanized?: string }>;
|
||||
};
|
||||
if (!Array.isArray(data?.data)) continue;
|
||||
|
||||
return lineTexts.map((original, idx) => {
|
||||
const item = data.data?.[idx];
|
||||
return item?.romanized ?? item?.text ?? original;
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
trace.log(`Romanize: request timed out | ${url}`);
|
||||
} else {
|
||||
trace.log(`Romanize: request error | ${url} | ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,159 +1,166 @@
|
||||
/* Global Spinning Background Styles - PERFORMANCE OPTIMIZED */
|
||||
|
||||
.global-background-container {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: -3;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
/* Hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: -3;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
/* Hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.global-spinning-black-bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.global-spinning-image {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 150vw;
|
||||
height: 150vh;
|
||||
object-fit: cover;
|
||||
z-index: -1;
|
||||
filter: blur(80px) brightness(0.4) contrast(1.2) saturate(1);
|
||||
opacity: 1;
|
||||
animation: spinGlobal 45s linear infinite;
|
||||
will-change: transform;
|
||||
/* Hardware acceleration */
|
||||
transform-origin: center center;
|
||||
backface-visibility: hidden;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 150vw;
|
||||
height: 150vh;
|
||||
object-fit: cover;
|
||||
z-index: -1;
|
||||
filter: blur(80px) brightness(0.4) contrast(1.2) saturate(1);
|
||||
opacity: 1;
|
||||
animation: spinGlobal 45s linear infinite;
|
||||
will-change: transform;
|
||||
/* Hardware acceleration */
|
||||
transform-origin: center center;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
/* Ensure the now-playing container itself is transparent */
|
||||
[class*="_nowPlayingContainer"] {
|
||||
/* biome-ignore lint: Must override any inline background styles */
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Now Playing Background Container Optimization */
|
||||
.now-playing-background-container {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -3;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
/* Hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
/* Hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
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 {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion for users who prefer it */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.global-spinning-image,
|
||||
.now-playing-background-image {
|
||||
animation: none !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
will-change: auto !important;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Performance mode: optimize effects but keep spinning */
|
||||
.performance-mode .global-spinning-image,
|
||||
.performance-mode .now-playing-background-image {
|
||||
/* Keep animations but optimize filter effects */
|
||||
filter: blur(10px) brightness(0.4) contrast(1.1) !important;
|
||||
/* 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 Notification Feed sidebar transparent */
|
||||
/* Make app chrome transparent for cover-everywhere background */
|
||||
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"],
|
||||
[class^="_cellTextContainer"] {
|
||||
background: unset !important;
|
||||
[class^="_cellContainer"] {
|
||||
/* biome-ignore lint: Ensure background is fully cleared under theme CSS */
|
||||
background: unset !important;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
-webkit-backdrop-filter: blur(10px) !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 */
|
||||
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="footer-player"],
|
||||
.performance-mode [data-test="main-layout-sidebar-wrapper"],
|
||||
.performance-mode [class^="_bar"],
|
||||
.performance-mode [class^="_sidebarItem"]:hover {
|
||||
backdrop-filter: blur(5px) !important;
|
||||
-webkit-backdrop-filter: blur(5px) !important;
|
||||
.performance-mode [data-test="main-layout-sidebar-wrapper"] {
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Feed sidebar panel - black tint background for readability */
|
||||
[data-test="feed-sidebar"] {
|
||||
background-color: rgba(0, 0, 0, 0.5) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
-webkit-backdrop-filter: blur(10px) !important;
|
||||
/* 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"] {
|
||||
backdrop-filter: blur(5px) !important;
|
||||
-webkit-backdrop-filter: blur(5px) !important;
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Feed sidebar items - transparent */
|
||||
@@ -162,10 +169,6 @@ main,
|
||||
[class*="_cellContainer"],
|
||||
[data-test="feed-interval"],
|
||||
[data-test="feed-item"] {
|
||||
background-color: transparent !important;
|
||||
/* biome-ignore lint: Match theme transparency */
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Remove bottom gradient */
|
||||
[class^="_bottomGradient"] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* 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
@@ -1,87 +1,36 @@
|
||||
/* Font imports for lyrics */
|
||||
@font-face {
|
||||
font-family: "AbyssFont";
|
||||
font-weight: 400;
|
||||
src: url("https://excel.lexploits.top/extra/tidal/LyricsRegular.woff2") format("woff2");
|
||||
/* Radiant Lyrics — text glow only (injected when Lyrics Glow is enabled) */
|
||||
|
||||
/* 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-face {
|
||||
font-family: "AbyssFont";
|
||||
font-weight: 500;
|
||||
src: url("https://excel.lexploits.top/extra/tidal/LyricsMedium.woff2") format("woff2");
|
||||
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"]: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-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: 700;
|
||||
src: url("https://excel.lexploits.top/extra/tidal/LyricsBold.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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
/* Hide player bar when setting is disabled, but show on hover - only when UI is hidden */
|
||||
.radiant-lyrics-ui-hidden [data-test="footer-player"] {
|
||||
opacity: 0 !important;
|
||||
transition: opacity 0.5s ease-in-out !important;
|
||||
opacity: 0 !important;
|
||||
transition: opacity 0.5s ease-in-out !important;
|
||||
}
|
||||
|
||||
.radiant-lyrics-ui-hidden [data-test="footer-player"]:hover {
|
||||
opacity: 1 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Also show player bar when hovering over the bottom area - only when UI is hidden */
|
||||
.radiant-lyrics-ui-hidden:has([data-test="footer-player"]:hover) [data-test="footer-player"],
|
||||
.radiant-lyrics-ui-hidden body.rl-footer-hover [data-test="footer-player"],
|
||||
.radiant-lyrics-ui-hidden [data-test="footer-player"]:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+5
-1
@@ -39,9 +39,13 @@ importers:
|
||||
specifier: ^5.8.3
|
||||
version: 5.8.3
|
||||
|
||||
plugins/audio-visualizer-luna: {}
|
||||
|
||||
plugins/colorama-lyrics-luna: {}
|
||||
|
||||
plugins/copy-lyrics-luna: {}
|
||||
|
||||
plugins/oled-theme-luna: {}
|
||||
plugins/element-hider-luna: {}
|
||||
|
||||
plugins/radiant-lyrics-luna: {}
|
||||
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
packages:
|
||||
- "plugins/*"
|
||||
|
||||
# pnpm 11 renamed `onlyBuiltDependencies` (list) to `allowBuilds` (map of name -> bool).
|
||||
# This whitelists postinstall/build scripts non-interactively in CI.
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
Reference in New Issue
Block a user