107 Commits

Author SHA1 Message Date
meoware.exe 06c4adf54b fix(ci): use pnpm 11 allowBuilds syntax and pin pnpm version
The previous fix (#119) used `onlyBuiltDependencies` in pnpm-workspace.yaml,
but the CI runner resolved `version: latest` to pnpm 11.1.2, where that key
was removed and replaced by `allowBuilds` (a map of name -> bool). The
`pnpm.onlyBuiltDependencies` block in package.json doesn't apply at the
workspace level either, so esbuild was still being ignored.

Changes:
- pnpm-workspace.yaml: replace `onlyBuiltDependencies: [esbuild]` with
  `allowBuilds: { esbuild: true }` (pnpm 11 syntax).
- package.json: add `packageManager: pnpm@11.1.2` so the version is
  reproducible across CI and local; drop the now-dead `pnpm` block.
- .github/workflows/build.yml: drop `version: latest` from
  pnpm/action-setup; the action reads the version from `packageManager`.
2026-05-17 13:29:38 +00:00
Meow Meow e2614d1b68 Merge pull request #119 from meowarex/fix/pnpm-approve-builds-esbuild
fix(ci): allow esbuild build scripts to fix ERR_PNPM_IGNORED_BUILDS
2026-05-17 23:22:40 +10:00
meoware.exe 4c6ee03df6 fix(ci): allow esbuild build scripts to fix ERR_PNPM_IGNORED_BUILDS
pnpm v10 blocks dependency build/postinstall scripts by default and
requires interactive 'pnpm approve-builds' to whitelist them, which
is not usable in CI. Declare esbuild as an allowed build in both
pnpm-workspace.yaml (canonical location since pnpm 10.7) and
package.json (legacy/parity) so installs run non-interactively.
2026-05-17 13:20:39 +00:00
meoware.exe d860383d65 Idle animation setting (Audio-Viz) 2026-05-17 18:03:47 +10:00
meoware.exe b28e245019 Cleanup & Normalize Endings 2026-04-07 18:02:07 +10:00
meoware.exe 83ef103118 Tidal Connect Support <3 2026-04-07 18:00:51 +10:00
meoware.exe e890c86b85 API Overhaul & Fixed Volume Slider <3 2026-04-06 22:59:26 +10:00
meoware.exe 957285bbbf CSS Overhaul & Glow Clipping Fix! 2026-04-06 17:18:29 +10:00
meoware.exe 35d03dc116 Pre Word Wrap Lyrics <3 2026-04-06 14:46:34 +10:00
meoware.exe 1dc77fc9d8 Grouped Slots & Visual fixes 2026-04-04 15:28:26 +11:00
meoware.exe be61b0bbb5 Fixed Null Assertions & Freed !important 2026-04-03 23:35:55 +11:00
meoware.exe 5f0795919d Overhaul Audio Visualizer & RL UI Improvements 2026-04-03 23:10:58 +11:00
meoware.exe 59af461ea1 Improve Audio Visualizer 2026-04-03 17:07:35 +11:00
meoware.exe 548e4bcaf0 Fixed Descender Character Cutoff @ Larger Font Sizes 2026-04-03 16:25:17 +11:00
meoware.exe 6ea24618d9 Improve HideUI & Flush controls 2026-04-03 16:20:26 +11:00
meoware.exe 20a4f11818 Add Ratelimiting <3 2026-04-03 01:26:44 +11:00
meoware.exe 6603c87eb3 Improved Lyric Injection 2026-04-03 01:09:06 +11:00
meoware.exe f34382aa08 Prevent clicking flush before first Request 2026-04-03 00:59:53 +11:00
meoware.exe ce8b1da26d Lyrics Flush Feature + Redesigned HideUI 2026-04-03 00:49:43 +11:00
meoware.exe daf76c5ffc Remove Artwork Border CSS 2026-04-02 23:19:18 +11:00
meoware.exe 306ab3a862 Fix Scroll break on large lyric count 2026-04-02 23:02:54 +11:00
meoware.exe a45a834580 Fixed Tidal line fallback scroll re/lock 2026-04-02 17:58:53 +11:00
Meow Meow 3f5aaf746b Merge pull request #94 from meowarex/player-market-ui-rewrite
Fixed UI Freeze on Tidal line fallback
2026-04-02 16:34:18 +11:00
meoware.exe 4b2478c301 Fixed UI Freeze on Tidal line fallback 2026-04-02 16:11:06 +11:00
Meow Meow 9c62d3e1f2 Merge pull request #91 from meowarex/player-market-ui-rewrite
Player market UI rewrite
2026-04-01 13:24:36 +11:00
meoware.exe 59562f8264 Fixed Slider 2026-04-01 13:23:41 +11:00
meoware.exe 40853d6e64 Change API 2026-04-01 12:58:24 +11:00
meoware.exe 74e3c97147 Add Audio Viz to Now Playing & Remove Lyrics Scrollbar 2026-03-31 20:53:13 +11:00
meoware.exe b79e15b6c5 Fixed Cascading Redux Errors 2026-03-31 20:24:52 +11:00
meoware.exe 9d1ca88e46 Adjust UI thingos 2026-03-28 21:39:34 +11:00
meoware.exe bff87b96a1 Cleanup Legacy Code 2026-03-28 21:12:06 +11:00
meoware.exe 9d6afcaaf5 Lyrics Dropdown 2026-03-28 20:55:33 +11:00
meoware.exe 8f995d8474 Made Compatible (Not Rewritten) 2026-03-27 23:47:56 +11:00
meoware.exe 5189d2bbea Prevent New UI 2026-03-25 15:07:02 +11:00
meoware.exe 1ab2eda25c Platform Param (DX Logs) 2026-03-10 20:57:20 +11:00
meoware.exe 5e00accc7f Fix Race Condition 2026-03-02 14:32:32 +11:00
meoware.exe 765c8baf96 Better Glow Cutoff | Thx Aya <3 2026-02-28 22:33:05 +11:00
meoware.exe d6c2d3ac88 Fixed Glow Cutoff 2026-02-28 22:29:24 +11:00
meoware.exe 7748f2fe08 Fixed Player Bar Border 2026-02-28 21:43:36 +11:00
meoware.exe 7ad4bbb332 Hotfix #7000... 2026-02-28 21:33:44 +11:00
meoware.exe b493624bda Patched Context Menus.. Again.. 2026-02-28 21:09:44 +11:00
meoware.exe 651e5cbc14 Quick Hotfix 2026-02-28 20:58:13 +11:00
meoware.exe 3e51ac45f8 Fixed Context Menus & other things 2026-02-28 19:51:34 +11:00
meoware.exe 76b1e264f8 Inject Lyrics to Tracks without them in tidal <3 2026-02-28 19:02:24 +11:00
meoware.exe c88ddef2f9 Forgot a Timeout <3 2026-02-28 16:45:21 +11:00
meoware.exe 38cdc156d6 Romanize ALL Tracks <3 2026-02-28 16:30:16 +11:00
meoware.exe 055fff6d47 Apply Effects to ALL Tracks! 2026-02-28 15:57:21 +11:00
meoware.exe 00eaf37dfa Added Romanized Lyrics 2026-02-27 19:19:54 +11:00
meoware.exe c6e916e6f6 Fixed Mini Cover Art Padding 2026-02-25 21:37:27 +11:00
meoware.exe 64dfe47592 Added Lyric Font Size 2026-02-25 21:02:09 +11:00
meoware.exe ef4c73037f Fixed Disabling Lyrics Glow 2026-02-25 20:54:51 +11:00
meoware.exe ec25abf6f5 Fixed Colorama lyrics 2026-02-25 20:38:30 +11:00
meowarex 7d2f3d3c1a Reduced 404 Spams 2026-02-25 17:29:35 +11:00
meoware.exe e766bac0fa Apply Context Aware & Bubbled lyrics to Line 2026-02-24 23:38:16 +11:00
meoware.exe d07444e102 Update Gitignore <3 2026-02-24 23:28:02 +11:00
meoware.exe 56c73abc05 CodeReview 2026-02-24 23:25:21 +11:00
meoware.exe 20adbd26dc Context Aware Lyrics & Aniamtions (Line) 2026-02-24 23:08:29 +11:00
meoware.exe ff417f5472 ISRC Support 2026-02-24 15:05:46 +11:00
meoware.exe 0a694a5bc0 WIP Animations 2026-02-21 03:54:45 +11:00
meoware.exe 84af1a40f6 REMIX Detection 2026-02-21 01:06:54 +11:00
meoware.exe adcbadcf49 Cleanup <3 2026-02-20 23:53:17 +11:00
meoware.exe af4cd80c7c Fixed Random Srcub Fire 2026-02-20 23:05:51 +11:00
meoware.exe 256dd3d724 Syllable Lyrics <3 2026-02-20 22:55:10 +11:00
meoware.exe d6a3b26b41 Rewrite Timeouts + Bug Fixes 2026-02-20 15:21:58 +11:00
meoware.exe df80ef748e Fix Race Condition 2026-02-19 23:53:48 +11:00
meoware.exe 68fc92b2db WBW + Observer Refactor + Prep for Syllables 2026-02-19 23:44:03 +11:00
meoware.exe 1aa12e9fd3 Improved Logic 2026-02-13 14:47:29 +11:00
meoware.exe 8196ed6778 Added Quality Matched Seeker Color 2026-02-13 14:28:39 +11:00
meoware.exe 6af3b93272 Deprecated Obsidian | Merged into RL <3 2026-02-11 21:01:05 +11:00
meoware.exe 422d03a54e Biome <3 2026-02-11 20:54:57 +11:00
meoware.exe b27f0ca165 CodeReview Changes 2026-02-11 20:46:07 +11:00
meoware.exe cd35fee3f0 Merged Obsidian into RL + Added Conditional Settings Visability 2026-02-11 20:26:05 +11:00
meoware.exe bce5ddba54 Updated Stikcy Lyrics Default 2026-02-09 22:58:51 +11:00
meoware.exe 9c537fa877 Added Sticky Lyrics 2026-02-09 22:49:29 +11:00
meoware.exe 6981cc8315 Removed Small Header BG 2026-02-09 19:36:58 +11:00
meoware.exe 56b7476e92 Fix Tidals New Sticky Header 2026-02-08 00:27:54 +11:00
meoware.exe 09857b6b54 Fixed Media Table Border + Search bar Width 2026-01-15 21:25:35 +11:00
Meow Meow 5e700692e7 Fix typos and improve README formatting
Updated installation instructions and credits section.
2026-01-11 15:47:52 +11:00
meoware.exe dc82194a90 Reduces New Corner Radius 2025-12-30 14:49:06 +11:00
meoware.exe 36257a954e HideUI Now hides header bar (Minimize, Fullscreen & Search) 2025-12-30 14:37:30 +11:00
meoware.exe e62944a0df Fixed HideUI & Removed Animations :( 2025-12-30 14:11:49 +11:00
meoware.exe 6d9184e5eb Re added CSS 2025-12-30 13:40:21 +11:00
meoware.exe 081b4cbdd8 Removed Duplicate Code 2025-12-30 13:17:25 +11:00
meoware.exe 4ca99ebd72 Updated for Sidebar 3.0 + Fixed Image Radius + Fixed Header Clipping 2025-12-30 13:10:18 +11:00
meoware.exe 047d4de2f4 Update BIOME Preferences 2025-10-22 11:08:57 +11:00
meoware.exe d83a786de3 Updated README <3 2025-09-09 21:36:24 +10:00
meoware.exe 0356ea6b76 Renamed oled-theme to obsidian 2025-09-09 21:30:19 +10:00
meoware.exe b9a9588f9d Adjusted Default Settings 2025-09-09 21:09:08 +10:00
meoware.exe fa0a7b7f56 Adjusted Cover Scale Settings 2025-09-09 20:58:28 +10:00
meoware.exe f2c31bb33a Background Cover Scale 2025-09-09 20:44:15 +10:00
meoware.exe 78d960588c Background Radius 2025-09-09 20:23:42 +10:00
meoware.exe 2ea44bd3cc Background Cover Scale 2025-09-09 20:09:24 +10:00
meoware.exe 9c9b47c930 Fixed Green HideUI Button 2025-09-09 19:33:29 +10:00
meoware.exe d53fd08ee8 Code Review 2025-09-09 19:20:30 +10:00
meoware.exe 11d08b6403 Flow Refactor 2025-09-09 19:01:12 +10:00
meoware.exe 0d9b378e43 BIOME Refactor 2025-09-09 18:31:35 +10:00
meoware.exe 99661096d5 BIOME Formating 2025-09-09 17:59:47 +10:00
meoware.exe 8178699d81 Fixed Title Glow Persistance 2025-08-14 11:39:08 +10:00
meoware.exe 82dfb39ff5 Improved Settings + Labeling 2025-08-13 21:32:06 +10:00
meoware.exe 0b9c27eaaf Cleanup 2025-08-13 21:14:32 +10:00
meoware.exe 40ed89dd34 Updated Settings 2025-08-13 21:08:30 +10:00
meoware.exe c0255acb4c Improved Settings 2025-08-13 20:25:20 +10:00
meoware.exe 1fda054d2a Added Colorama-Lyrics 2025-08-12 23:43:51 +10:00
meoware.exe cf9bbb62e6 Updated Defaults 2025-08-12 22:25:57 +10:00
meoware.exe 7de6a98d8e Adjustable Glow 2025-08-12 22:13:28 +10:00
meoware.exe 2e7e51b7eb Removed Lib 2025-08-12 21:52:05 +10:00
meoware.exe fe3f0011eb Performance Overhaul 2025-08-12 21:41:52 +10:00
47 changed files with 12342 additions and 3642 deletions
+7
View File
@@ -0,0 +1,7 @@
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.css text eol=lf
*.json text eol=lf
*.md text eol=lf
*.yaml text eol=lf
+1 -2
View File
@@ -13,8 +13,7 @@ jobs:
- name: Install pnpm 📥
uses: pnpm/action-setup@v4
with:
version: latest
# Version is read from `packageManager` in package.json for reproducible builds.
- name: Install Node.js 📥
uses: actions/setup-node@v4
+2 -4
View File
@@ -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/
+4
View File
@@ -0,0 +1,4 @@
{
"snyk.advanced.organization": "5e35d31d-0df9-4f3c-b4b3-168a24114801",
"snyk.advanced.autoSelectOrganization": true
}
+32 -13
View File
@@ -4,14 +4,13 @@ A collection of Luna plugins for Tidal, ported from Neptune framework.
## Plugins
### 🎨 OLED Theme
**Location:** `plugins/oled-theme-luna/`
### 🎨 Obsidian
**Location:** `plugins/obsidian-theme-luna/`
A dark OLED-friendly theme plugin that transforms Tidal Luna's appearance.
A dark OLED-friendly theme 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
@@ -34,6 +33,16 @@ Allows users to copy song lyrics by selecting them directly in the interface.
- Automatic clipboard copying of selected lyrics
- Smart lyric span detection
### 🧽 Element Hider
**Location:** `plugins/element-hider-luna/`
Allows users to hide/remove UI elements by right clicking on them.
**Features:**
- Remove/Hide ANY UI element
- Automagically saves hidden elements
- Allows for elements to be restored
### 🎶 Audio Visualizer
**Location:** `plugins/audio-visualizer-luna/`
@@ -49,8 +58,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 +85,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 +95,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 +104,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 +112,7 @@ 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)
ItzzExcel | The Original Neptune version of "Radiant Lyrics" (Which was ported to Luna and Rewritten by me!)
+9
View File
@@ -0,0 +1,9 @@
{
"linter": {
"rules": {
"complexity": {
"useArrowFunction": "off"
}
}
}
}
+2356
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,6 +2,7 @@
"name": "@meowarex/TidalLuna-Plugins",
"description": "A Collection of Plugins for TidalLuna",
"type": "module",
"packageManager": "pnpm@11.1.2",
"scripts": {
"watch": "concurrently \"pnpm:build --watch\" pnpm:serve",
"build": "rimraf ./dist && tsx esbuild.config.ts",
+509 -247
View File
@@ -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",
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,
customColors: [] as string[]
});
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 [barCount, setBarCount] = React.useState(settings.barCount);
const [fftSize, setFftSize] = React.useState(settings.fftSize);
const [reactivity, setReactivity] = React.useState(settings.reactivity);
const [gain, setGain] = React.useState(settings.gain);
const [barRounding, setBarRounding] = React.useState(settings.barRounding);
const [lineThickness, setLineThickness] = React.useState(settings.lineThickness);
const [fillOpacity, setFillOpacity] = React.useState(settings.fillOpacity);
const [lissajous, setLissajous] = React.useState(settings.lissajous);
const [scrollingOscilloscope, setScrollingOscilloscope] = React.useState(settings.scrollingOscilloscope);
const [groupedSlots, setGroupedSlots] = React.useState(settings.groupedSlots);
const [transparentContainers, setTransparentContainers] = React.useState(
settings.transparentContainers,
);
const [idleMode, setIdleMode] = React.useState(settings.idleMode);
const [showColorPicker, setShowColorPicker] = React.useState(false);
const [isAnimatingIn, setIsAnimatingIn] = React.useState(false);
const [shouldRender, setShouldRender] = React.useState(false);
const [isColorAnimIn, setIsColorAnimIn] = React.useState(false);
const [shouldRenderColor, setShouldRenderColor] = React.useState(false);
const [customInput, setCustomInput] = React.useState(settings.barColor);
const [customColors, setCustomColors] = React.useState(settings.customColors);
const [hoveredColorIndex, setHoveredColorIndex] = React.useState<number | null>(null);
const closeColorPicker = () => {
setIsAnimatingIn(false);
setTimeout(() => {
setShowColorPicker(false);
setShouldRender(false);
}, 200); // Wait for animation to complete because i need to
};
const [showSlotConfig, setShowSlotConfig] = React.useState(false);
const [isSlotAnimIn, setIsSlotAnimIn] = React.useState(false);
const [shouldRenderSlot, setShouldRenderSlot] = React.useState(false);
const [activeZone, setActiveZone] = React.useState<ZoneId>("nowPlaying");
const [slots, setSlots] = React.useState<Record<SlotKey, VisualizerType>>(() => {
const vals = {} as Record<SlotKey, VisualizerType>;
for (const key of ALL_SLOT_KEYS) vals[key] = getSlot(key);
return vals;
});
const [miniSlots, setMiniSlots] = React.useState<Set<string>>(new Set(settings.miniSlots));
const closeColorPicker = () => {
setIsColorAnimIn(false);
setTimeout(() => { setShowColorPicker(false); setShouldRenderColor(false); }, 200);
};
const openColorPicker = () => {
setShowColorPicker(true);
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
setShouldRenderColor(true);
setTimeout(() => setIsColorAnimIn(true), 10);
};
const closeSlotConfig = () => {
setIsSlotAnimIn(false);
setTimeout(() => { setShowSlotConfig(false); setShouldRenderSlot(false); }, 200);
};
const openSlotConfig = () => {
setShowSlotConfig(true);
setShouldRenderSlot(true);
setTimeout(() => setIsSlotAnimIn(true), 10);
};
React.useEffect(() => {
if (showColorPicker) {
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
setShouldRenderColor(true);
setTimeout(() => setIsColorAnimIn(true), 10);
}
}, [showColorPicker]);
// Common color presets for cool points :D
React.useEffect(() => {
if (showSlotConfig) {
setShouldRenderSlot(true);
setTimeout(() => setIsSlotAnimIn(true), 10);
}
}, [showSlotConfig]);
const colorPresets = [
"#ffffff", "#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
"#ff8800", "#8800ff", "#0088ff", "#88ff00", "#ff0088", "#00ff88",
"#444444", "#888888", "#cccccc", "#1db954", "#e22134", "#1976d2"
"#ff69b4", "#ff1493", "#e91e8a", "#c71585",
"#ff006e", "#ff4da6", "#ff85c8", "#ffb3d9",
"#ffffff", "#ff0000", "#00ff00", "#0000ff",
"#ffff00", "#ff00ff", "#00ffff", "#ff8800",
"#8800ff", "#0088ff", "#1db954", "#444444",
];
const updateColor = (color: string) => {
setBarColor(color);
setCustomInput(color);
settings.barColor = color;
(window as any).updateAudioVisualizer?.();
};
const addCustomColor = () => {
if (customInput) {
// Trim whitespace and convert to lowercase
const trimmedInput = customInput.trim().toLowerCase();
// Validate hex color format
const hexColorRegex = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
if (hexColorRegex.test(trimmedInput) &&
!colorPresets.includes(trimmedInput) &&
!customColors.includes(trimmedInput)) {
const newCustomColors = [...customColors, trimmedInput];
setCustomColors(newCustomColors);
settings.customColors = newCustomColors;
const trimmed = customInput.trim().toLowerCase();
const hexRe = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
if (hexRe.test(trimmed) && !colorPresets.includes(trimmed) && !customColors.includes(trimmed)) {
const nc = [...customColors, trimmed];
setCustomColors(nc);
settings.customColors = nc;
}
}
};
const removeCustomColor = (colorToRemove: string) => {
const newCustomColors = customColors.filter(color => color !== colorToRemove);
setCustomColors(newCustomColors);
settings.customColors = newCustomColors;
// If the removed color was the selected color (reset to white)
if (barColor === colorToRemove) {
updateColor("#ffffff");
}
const removeCustomColor = (c: string) => {
const nc = customColors.filter(x => x !== c);
setCustomColors(nc);
settings.customColors = nc;
if (barColor === c) updateColor("#ff69b4");
};
const allColors = [...colorPresets, ...customColors];
const updateSlot = (key: SlotKey, value: VisualizerType) => {
setSlots(prev => ({ ...prev, [key]: value }));
setSlot(key, value);
if (!MINI_SUPPORTED.has(value)) {
setMiniSlots(prev => {
const next = new Set(prev);
if (next.delete(key)) settings.miniSlots = [...next];
return next;
});
}
};
const toggleMini = (key: SlotKey) => {
setMiniSlots(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
settings.miniSlots = [...next];
return next;
});
};
type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>;
type AnySwitchProps = Omit<BaseSwitchProps, "onChange"> & {
onChange: (_: unknown, checked: boolean) => void;
checked: boolean;
};
const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType<AnySwitchProps>;
const hasBars = ALL_SLOT_KEYS.some(key => slots[key] === "spectrum-bars");
const zones: ZoneId[] = ["nowPlaying", "topNav", "playerBar"];
const zonePositions = (zone: ZoneId) =>
Object.keys(ZONE_SLOTS[zone]) as PositionId[];
const backdropStyle = (animIn: boolean): React.CSSProperties => ({
position: "fixed", top: 0, left: 0, right: 0, bottom: 0,
background: "rgba(0,0,0,0.6)", zIndex: 1000,
opacity: animIn ? 1 : 0, transition: "opacity 0.2s ease",
border: "none", padding: 0, cursor: "default", width: "100%",
});
const panelBaseStyle = (animIn: boolean): React.CSSProperties => ({
position: "fixed", top: "50%", left: "50%",
background: "rgba(20,20,20,0.98)",
backdropFilter: "blur(20px)", WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.15)", borderRadius: "16px",
padding: "20px", maxHeight: "90vh", overflowY: "auto",
zIndex: 1001, boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
opacity: animIn ? 1 : 0,
transform: animIn ? "translate(-50%, -50%) scale(1)" : "translate(-50%, -50%) scale(0.9)",
transition: "all 0.2s ease",
});
const selectStyle: React.CSSProperties = {
width: "100%",
padding: "6px 8px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.08)",
color: "#fff",
fontSize: "12px",
cursor: "pointer",
outline: "none",
};
const optionStyle: React.CSSProperties = {
background: "#1a1a1a",
color: "#fff",
};
return (
<LunaSettings>
<LunaSwitchSetting
title="Bar Roundness"
desc="Enable rounded corners on visualizer bars"
checked={barRounding}
onChange={(_, checked) => {
setBarRounding(checked);
settings.barRounding = checked;
(window as any).updateAudioVisualizer?.();
}}
/>
<LunaNumberSetting
title="Bar Count"
desc="Number of frequency bars to display"
min={8}
max={64}
step={1}
value={barCount}
onNumber={(value: number) => {
setBarCount(value);
settings.barCount = value;
(window as any).updateAudioVisualizer?.();
}}
/>
{/* YUP YOUR EYES WORK... we do be using React code in the settings..*/}
{/* I'm not sure if this is a good idea, but it works & looks amazing */}
{/* Sorry @Inrixia <3 */}
{/* Color & Layout */}
<div style={{
padding: "16px 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "10px 0",
}}>
<div>
<div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: "4px" }}>Bar Color</div>
<div style={{ opacity: 0.7, fontSize: "14px" }}>Color of the visualizer bars</div>
<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 style={{ display: "flex", gap: "8px", alignItems: "center", position: "relative" }}>
</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<button
type="button"
onClick={() => showColorPicker ? closeColorPicker() : openColorPicker()}
style={{
width: "32px",
height: "32px",
width: "28px", height: "28px",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "6px",
cursor: "pointer",
background: barColor,
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
position: "relative",
overflow: "hidden"
borderRadius: "6px", cursor: "pointer", background: barColor,
overflow: "hidden", position: "relative",
}}
>
<div style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.1)",
backdropFilter: "blur(2px)"
}} />
<div style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.1)", backdropFilter: "blur(2px)" }} />
</button>
{/* Custom Color Picker Modal */}
{shouldRender && (
<>
{/* Backdrop */}
<div
<button
type="button"
onClick={() => showSlotConfig ? closeSlotConfig() : openSlotConfig()}
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.6)",
zIndex: 1000,
opacity: isAnimatingIn ? 1 : 0,
transition: "opacity 0.2s ease"
padding: "6px 12px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff", cursor: "pointer", fontSize: "12px",
fontWeight: 500, transition: "all 0.2s ease",
whiteSpace: "nowrap",
}}
onClick={closeColorPicker}
/>
{/* Color Picker Panel */}
<div style={{
position: "fixed",
top: "50%",
left: "50%",
background: "rgba(20,20,20,0.98)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "16px",
padding: "20px",
minWidth: "320px",
maxWidth: "90vw",
maxHeight: "90vh",
zIndex: 1001,
boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
opacity: isAnimatingIn ? 1 : 0,
transform: isAnimatingIn ? "translate(-50%, -50%) scale(1)" : "translate(-50%, -50%) scale(0.9)",
transition: "all 0.2s ease"
}}>
<div style={{ marginBottom: "12px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>
Choose Color
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.2)"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.1)"; }}
>Configure Slots</button>
</div>
</div>
{/* Color Grid */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "8px",
marginBottom: "16px"
}}>
<AnySwitch
title="Grouped Slots"
desc="Active slots in the same position share a single container"
checked={groupedSlots}
onChange={(_: unknown, checked: boolean) => {
setGroupedSlots(checked);
settings.groupedSlots = checked;
}}
/>
<AnySwitch
title="Transparent containers"
desc="Remove panel background, blur and shadow"
checked={transparentContainers}
onChange={(_: unknown, checked: boolean) => {
setTransparentContainers(checked);
settings.transparentContainers = checked;
}}
/>
<LunaSelectSetting
title="Idle Animation"
desc="Behaviour when no audio is playing"
value={idleMode}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const v = Number(e.target.value);
setIdleMode(v);
settings.idleMode = v;
}}
>
<LunaSelectItem value={0}>Enabled</LunaSelectItem>
<LunaSelectItem value={1}>Disabled &amp; Hide</LunaSelectItem>
<LunaSelectItem value={2}>Disabled &amp; Static</LunaSelectItem>
</LunaSelectSetting>
{/* Color picker modal */}
{shouldRenderColor && (
<>
<button type="button" aria-label="Close color picker" onClick={closeColorPicker} style={backdropStyle(isColorAnimIn)} />
<div style={{ ...panelBaseStyle(isColorAnimIn), minWidth: "320px", maxWidth: "90vw" }}>
<div style={{ marginBottom: "12px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>Choose Color</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "8px", marginBottom: "16px" }}>
{allColors.map((color, index) => {
const isCustomColor = customColors.includes(color);
const isCustom = customColors.includes(color);
const isHovered = hoveredColorIndex === index;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: cosmetic hover tracking on wrapper containing interactive buttons
<div
key={index}
style={{
position: "relative",
width: "32px",
height: "32px",
cursor: "pointer"
}}
className="color-item"
key={color}
style={{ position: "relative", width: "32px", height: "32px", cursor: "pointer" }}
onMouseEnter={() => setHoveredColorIndex(index)}
onMouseLeave={() => setHoveredColorIndex(null)}
>
<button
onClick={() => {
updateColor(color);
closeColorPicker();
}}
type="button"
onClick={() => { updateColor(color); closeColorPicker(); }}
style={{
width: "100%",
height: "100%",
borderRadius: "6px",
width: "100%", height: "100%", borderRadius: "6px",
border: barColor === color ? "2px solid #fff" : "1px solid rgba(255,255,255,0.2)",
background: color,
cursor: "pointer",
transition: "all 0.2s ease"
background: color, cursor: "pointer", transition: "all 0.2s ease",
}}
/>
{isCustomColor && (
{isCustom && (
<button
onClick={(e) => {
e.stopPropagation();
removeCustomColor(color);
}}
type="button"
onClick={(e) => { e.stopPropagation(); removeCustomColor(color); }}
style={{
position: "absolute",
top: "-4px",
right: "-4px",
width: "16px",
height: "16px",
borderRadius: "50%",
border: "1px solid rgba(255,255,255,0.8)",
background: "rgba(0,0,0,0.8)",
color: "#fff",
cursor: "pointer",
fontSize: "10px",
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: isHovered ? 1 : 0,
transition: "opacity 0.2s ease",
zIndex: 10
position: "absolute", top: "-4px", right: "-4px",
width: "16px", height: "16px", borderRadius: "50%",
border: "1px solid rgba(255,255,255,0.8)", background: "rgba(0,0,0,0.8)",
color: "#fff", cursor: "pointer", fontSize: "10px",
display: "flex", alignItems: "center", justifyContent: "center",
opacity: isHovered ? 1 : 0, transition: "opacity 0.2s ease", zIndex: 10,
}}
className="remove-button"
>
×
</button>
>x</button>
)}
</div>
);
})}
</div>
{/* Custom Hex Input */}
<div style={{ marginBottom: "12px" }}>
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "12px", marginBottom: "6px" }}>
Add Custom Color
</div>
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "12px", marginBottom: "6px" }}>Add Custom Color</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<input
type="text"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
updateColor(customInput);
addCustomColor();
}
}}
placeholder="#ffffff"
onKeyDown={(e) => { if (e.key === "Enter") { updateColor(customInput); addCustomColor(); } }}
placeholder="#ff69b4"
style={{
flex: 1,
padding: "8px 12px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
fontSize: "14px",
fontFamily: "monospace",
boxSizing: "border-box"
flex: 1, padding: "8px 12px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
color: "#fff", fontSize: "14px", fontFamily: "monospace", boxSizing: "border-box",
}}
/>
<button
onClick={() => {
updateColor(customInput);
addCustomColor();
}}
type="button"
onClick={() => { updateColor(customInput); addCustomColor(); }}
style={{
width: "32px",
height: "32px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.3)",
background: "rgba(255,255,255,0.15)",
color: "#fff",
cursor: "pointer",
fontSize: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease"
width: "32px", height: "32px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.3)", background: "rgba(255,255,255,0.15)",
color: "#fff", cursor: "pointer", fontSize: "16px",
display: "flex", alignItems: "center", justifyContent: "center",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)";
}}
>
+
</button>
onMouseEnter={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.25)"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "rgba(255,255,255,0.15)"; }}
>+</button>
</div>
</div>
{/* Close Button (Done) - Also runs when color chosen*/}
<button
type="button"
onClick={closeColorPicker}
style={{
width: "100%",
padding: "8px",
borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
cursor: "pointer",
fontSize: "12px"
width: "100%", padding: "8px", borderRadius: "6px",
border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)",
color: "#fff", cursor: "pointer", fontSize: "12px",
}}
>
Done
</button>
>Done</button>
</div>
</>
)}
</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>
);
};
+209
View File
@@ -0,0 +1,209 @@
const log = (message: string) => console.log(`[Audio Visualizer] ${message}`);
let audioContext: AudioContext | null = null;
let monoAnalyser: AnalyserNode | null = null;
let leftAnalyser: AnalyserNode | null = null;
let rightAnalyser: AnalyserNode | null = null;
let splitter: ChannelSplitterNode | null = null;
let audioSource: MediaStreamAudioSourceNode | null = null;
let trackedVideo: HTMLVideoElement | null = null;
let connected = false;
let monoByteFreq: Uint8Array | null = null;
let monoByteTime: Uint8Array | null = null;
let monoFloatFreq: Float32Array | null = null;
let monoFloatTime: Float32Array | null = null;
let leftFloatTime: Float32Array | null = null;
let rightFloatTime: Float32Array | null = null;
export interface AudioData {
byteFrequency: Uint8Array;
byteTimeDomain: Uint8Array;
floatFrequency: Float32Array;
floatTimeDomain: Float32Array;
leftTimeDomain: Float32Array;
rightTimeDomain: Float32Array;
sampleRate: number;
fftSize: number;
binCount: number;
}
export const setFFTSize = (size: number): void => {
if (monoAnalyser) monoAnalyser.fftSize = size;
if (leftAnalyser) leftAnalyser.fftSize = size;
if (rightAnalyser) rightAnalyser.fftSize = size;
allocateBuffers();
};
export const setSmoothing = (value: number): void => {
if (monoAnalyser) monoAnalyser.smoothingTimeConstant = value;
if (leftAnalyser) leftAnalyser.smoothingTimeConstant = value;
if (rightAnalyser) rightAnalyser.smoothingTimeConstant = value;
};
const allocateBuffers = (): void => {
if (!monoAnalyser) return;
const bc = monoAnalyser.frequencyBinCount;
monoByteFreq = new Uint8Array(bc);
monoByteTime = new Uint8Array(bc);
monoFloatFreq = new Float32Array(bc);
monoFloatTime = new Float32Array(monoAnalyser.fftSize);
if (leftAnalyser && rightAnalyser) {
leftFloatTime = new Float32Array(leftAnalyser.fftSize);
rightFloatTime = new Float32Array(rightAnalyser.fftSize);
}
};
const createAnalyser = (ctx: AudioContext, fftSize: number, smoothing: number): AnalyserNode => {
const a = ctx.createAnalyser();
a.fftSize = fftSize;
a.smoothingTimeConstant = smoothing;
a.minDecibels = -100;
a.maxDecibels = -10;
return a;
};
const ensureContext = (fftSize: number, smoothing: number): boolean => {
try {
if (!audioContext || audioContext.state === "closed") {
audioContext = new AudioContext();
}
if (!monoAnalyser) {
monoAnalyser = createAnalyser(audioContext, fftSize, smoothing);
leftAnalyser = createAnalyser(audioContext, fftSize, smoothing);
rightAnalyser = createAnalyser(audioContext, fftSize, smoothing);
splitter = audioContext.createChannelSplitter(2);
splitter.connect(leftAnalyser, 0);
splitter.connect(rightAnalyser, 1);
allocateBuffers();
}
if (audioContext.state === "suspended") {
audioContext.resume().catch(() => {});
}
return true;
} catch (err) {
log(`Failed to create audio context: ${err}`);
return false;
}
};
const disconnectSource = (): void => {
if (audioSource) {
try { audioSource.disconnect(); } catch {}
audioSource = null;
}
connected = false;
};
const captureFromVideo = (video: HTMLVideoElement): boolean => {
const capture = (video as unknown as { captureStream?: () => MediaStream }).captureStream;
if (typeof capture !== "function") {
log("captureStream() not available on video element");
return false;
}
try {
disconnectSource();
const stream = capture.call(video);
const tracks = stream.getAudioTracks();
if (tracks.length === 0) {
log("No audio tracks in captured stream");
return false;
}
audioSource = audioContext!.createMediaStreamSource(stream);
audioSource.connect(monoAnalyser!);
audioSource.connect(splitter!);
trackedVideo = video;
connected = true;
log("Audio connected via captureStream()");
return true;
} catch (err) {
log(`captureStream() failed: ${err}`);
return false;
}
};
export const connect = (fftSize = 2048, smoothing = 0.8): boolean => {
if (!ensureContext(fftSize, smoothing)) return false;
const video = document.getElementById("video-one") as HTMLVideoElement | null;
if (!video) {
log("video-one element not found");
return false;
}
return captureFromVideo(video);
};
export const reconnect = (fftSize = 2048, smoothing = 0.8): boolean => {
disconnectSource();
trackedVideo = null;
return connect(fftSize, smoothing);
};
export const isConnected = (): boolean => connected;
export const videoChanged = (): boolean => {
const video = document.getElementById("video-one") as HTMLVideoElement | null;
if (!video) return false;
return video !== trackedVideo;
};
export const sample = (): AudioData | null => {
const ctx = audioContext;
if (!ctx || !monoAnalyser || !monoByteFreq || !monoByteTime || !monoFloatFreq || !monoFloatTime || !leftFloatTime || !rightFloatTime || !leftAnalyser || !rightAnalyser) return null;
if (ctx.state === "suspended") {
ctx.resume().catch(() => {});
}
monoAnalyser.getByteFrequencyData(monoByteFreq);
monoAnalyser.getByteTimeDomainData(monoByteTime);
monoAnalyser.getFloatFrequencyData(monoFloatFreq);
monoAnalyser.getFloatTimeDomainData(monoFloatTime);
leftAnalyser.getFloatTimeDomainData(leftFloatTime);
rightAnalyser.getFloatTimeDomainData(rightFloatTime);
return {
byteFrequency: monoByteFreq,
byteTimeDomain: monoByteTime,
floatFrequency: monoFloatFreq,
floatTimeDomain: monoFloatTime,
leftTimeDomain: leftFloatTime,
rightTimeDomain: rightFloatTime,
sampleRate: ctx.sampleRate,
fftSize: monoAnalyser.fftSize,
binCount: monoAnalyser.frequencyBinCount,
};
};
export const hasSignal = (data: AudioData): boolean => {
const avg = data.byteFrequency.reduce((s, v) => s + v, 0) / data.byteFrequency.length;
return avg > 5;
};
export const dispose = (): void => {
disconnectSource();
if (audioContext && audioContext.state !== "closed") {
audioContext.close().catch(() => {});
}
audioContext = null;
monoAnalyser = null;
leftAnalyser = null;
rightAnalyser = null;
splitter = null;
trackedVideo = null;
monoByteFreq = null;
monoByteTime = null;
monoFloatFreq = null;
monoFloatTime = null;
leftFloatTime = null;
rightFloatTime = null;
};
File diff suppressed because it is too large Load Diff
+110 -33
View File
@@ -1,46 +1,34 @@
/* Audio Visualizer CSS - Only applies to the Visualizer */
#audio-visualizer-container {
.audio-visualizer-container {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 4px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: all 0.3s ease-in-out;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 105, 180, 0.15);
animation: av-fadeIn 0.5s ease-out;
}
#audio-visualizer-container:hover {
.audio-visualizer-container:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
box-shadow: 0 4px 12px rgba(255, 105, 180, 0.2);
border-color: rgba(255, 105, 180, 0.3);
}
#audio-visualizer-container canvas {
.audio-visualizer-container canvas {
display: block;
transition: all 0.3s ease-in-out;
border-radius: 4px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
#audio-visualizer-container {
margin: 4px;
padding: 2px;
.audio-visualizer-container.active {
box-shadow: 0 0 20px rgba(255, 105, 180, 0.3);
}
#audio-visualizer-container canvas {
max-width: 150px;
max-height: 30px;
}
}
/* Where to put the thingy */
[class*="_searchField"] {
transition: all 0.3s ease-in-out;
}
/* Shadow when active - doesnt seem to only apply when active but thats better */
#audio-visualizer-container.active {
box-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
}
/* Fade in animation */
@keyframes fadeIn {
@keyframes av-fadeIn {
from {
opacity: 0;
transform: scale(0.8);
@@ -51,6 +39,95 @@
}
}
#audio-visualizer-container {
animation: fadeIn 0.5s ease-out;
[data-type="search-field"] {
min-width: 220px !important;
}
/* Slot group layout */
.av-slot-group {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Left/Right group spacing */
.av-slot-group[data-position="left"] {
margin-right: 12px;
}
.av-slot-group[data-position="right"] {
margin-left: 12px;
}
/* Player Bar: LEFT inside trackInfo, RIGHT inside utilityContainer */
.av-slot-group[data-zone="playerBar"][data-position="left"] {
margin-left: 8px;
}
.av-slot-group[data-zone="playerBar"][data-position="right"] {
margin-right: 8px;
}
/* Grouped slots: merge active containers into one shared box */
.av-slot-group.av-grouped {
gap: 0;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 105, 180, 0.15);
animation: av-fadeIn 0.5s ease-out;
transition: all 0.3s ease-in-out;
}
.av-slot-group.av-grouped:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(255, 105, 180, 0.2);
border-color: rgba(255, 105, 180, 0.3);
}
.av-slot-group.av-grouped > .audio-visualizer-container {
background: none;
border: none;
border-radius: 0;
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: none;
animation: none;
}
.av-slot-group.av-grouped > .audio-visualizer-container:hover {
transform: none;
box-shadow: none;
}
/* Chromeless: no fill, blur, or shadow; border kept */
body.av-chromeless .audio-visualizer-container {
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: none;
border: 1px solid rgba(255, 105, 180, 0.15);
animation: none;
transition: border-color 0.3s ease-in-out;
}
body.av-chromeless .audio-visualizer-container:hover {
transform: none;
box-shadow: none;
border-color: rgba(255, 105, 180, 0.3);
}
body.av-chromeless .audio-visualizer-container.active {
box-shadow: none;
}
body.av-chromeless .av-slot-group.av-grouped {
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: none;
border: 1px solid rgba(255, 105, 180, 0.15);
animation: none;
transition: border-color 0.3s ease-in-out;
}
body.av-chromeless .av-slot-group.av-grouped:hover {
transform: none;
box-shadow: none;
border-color: rgba(255, 105, 180, 0.3);
}
@@ -0,0 +1,201 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { hexToRGB } from "../webgl";
const GATE_ABSOLUTE = -70;
const GATE_RELATIVE_OFFSET = -10;
const GAINS = [1.0, 1.0];
interface LUFSState {
momentaryBlocks: number[];
shortTermBlocks: number[];
integratedPowers: number[];
momentary: number;
shortTerm: number;
integrated: number;
blockBuffer: Float32Array[];
blockPos: number;
blockSize: number;
hopSize: number;
hopPos: number;
displayMomentary: number;
displayShortTerm: number;
displayIntegrated: number;
}
const createLUFSState = (sampleRate: number): LUFSState => {
const blockSize = Math.floor(sampleRate * 0.4);
const hopSize = Math.floor(sampleRate * 0.1);
return {
momentaryBlocks: [],
shortTermBlocks: [],
integratedPowers: [],
momentary: -Infinity,
shortTerm: -Infinity,
integrated: -Infinity,
blockBuffer: [new Float32Array(blockSize), new Float32Array(blockSize)],
blockPos: 0,
blockSize,
hopSize,
hopPos: 0,
displayMomentary: -60,
displayShortTerm: -60,
displayIntegrated: -60,
};
};
const computeBlockLoudness = (left: Float32Array, right: Float32Array, len: number): number => {
let sumL = 0, sumR = 0;
for (let i = 0; i < len; i++) {
sumL += left[i] * left[i];
sumR += right[i] * right[i];
}
const powerL = sumL / len;
const powerR = sumR / len;
const weighted = GAINS[0] * powerL + GAINS[1] * powerR;
if (weighted <= 0) return -Infinity;
return -0.691 + 10 * Math.log10(weighted);
};
const computeGatedIntegrated = (powers: number[]): number => {
if (powers.length === 0) return -Infinity;
const aboveAbsolute = powers.filter(p => p > GATE_ABSOLUTE);
if (aboveAbsolute.length === 0) return -Infinity;
const meanAbsolute = aboveAbsolute.reduce((s, v) => s + Math.pow(10, v / 10), 0) / aboveAbsolute.length;
const relativeThreshold = 10 * Math.log10(meanAbsolute) + GATE_RELATIVE_OFFSET;
const aboveRelative = aboveAbsolute.filter(p => p > relativeThreshold);
if (aboveRelative.length === 0) return -Infinity;
const meanRelative = aboveRelative.reduce((s, v) => s + Math.pow(10, v / 10), 0) / aboveRelative.length;
return 10 * Math.log10(meanRelative);
};
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
export const createLoudnessMeter = (): Visualizer => {
let ctx: CanvasRenderingContext2D | null = null;
let w = 0, h = 0;
let state: LUFSState | null = null;
let lastSampleRate = 0;
const SMOOTHING_FAST = 0.25;
const SMOOTHING_SLOW = 0.08;
return {
name: "Loudness (LUFS)",
id: "loudness-meter",
init(canvas, _color) {
ctx = canvas.getContext("2d")!;
w = canvas.width;
h = canvas.height;
state = null;
lastSampleRate = 0;
},
render(data: AudioData, color: string) {
if (!ctx) return;
if (!state || data.sampleRate !== lastSampleRate) {
state = createLUFSState(data.sampleRate);
lastSampleRate = data.sampleRate;
}
const left = data.leftTimeDomain;
const right = data.rightTimeDomain;
const len = Math.min(left.length, right.length);
for (let i = 0; i < len; i++) {
state.blockBuffer[0][state.blockPos] = left[i];
state.blockBuffer[1][state.blockPos] = right[i];
state.blockPos++;
state.hopPos++;
if (state.blockPos >= state.blockSize) {
const loudness = computeBlockLoudness(state.blockBuffer[0], state.blockBuffer[1], state.blockSize);
state.momentaryBlocks.push(loudness);
if (state.momentaryBlocks.length > 4) state.momentaryBlocks.shift();
state.momentary = Math.max(...state.momentaryBlocks);
state.shortTermBlocks.push(loudness);
if (state.shortTermBlocks.length > 30) state.shortTermBlocks.shift();
const stPowers = state.shortTermBlocks.filter(v => v > -Infinity);
if (stPowers.length > 0) {
const stMean = stPowers.reduce((s, v) => s + Math.pow(10, v / 10), 0) / stPowers.length;
state.shortTerm = 10 * Math.log10(stMean);
}
state.integratedPowers.push(loudness);
if (state.integratedPowers.length > 3000) state.integratedPowers.shift();
state.integrated = computeGatedIntegrated(state.integratedPowers);
const keep = state.blockSize - state.hopSize;
state.blockBuffer[0].copyWithin(0, state.hopSize);
state.blockBuffer[1].copyWithin(0, state.hopSize);
state.blockPos = keep;
state.hopPos = 0;
}
}
const clamp = (v: number) => (v === -Infinity ? -60 : Math.max(-60, Math.min(0, v)));
state.displayMomentary = lerp(state.displayMomentary, clamp(state.momentary), SMOOTHING_FAST);
state.displayShortTerm = lerp(state.displayShortTerm, clamp(state.shortTerm), SMOOTHING_FAST);
state.displayIntegrated = lerp(state.displayIntegrated, clamp(state.integrated), SMOOTHING_SLOW);
ctx.clearRect(0, 0, w, h);
const [cr, cg, cb] = hexToRGB(color);
const minLUFS = -60;
const maxLUFS = 0;
const range = maxLUFS - minLUFS;
const norm = (v: number) => Math.max(0, Math.min(1, (v - minLUFS) / range));
const labels = ["M", "S", "I"];
const rawValues = [state.momentary, state.shortTerm, state.integrated];
const displayValues = [state.displayMomentary, state.displayShortTerm, state.displayIntegrated];
const barH = (h - 4) / 3;
const labelW = 12;
const valueW = 36;
const barX = labelW;
const barW = w - labelW - valueW;
ctx.font = `bold ${Math.min(9, barH - 1)}px monospace`;
ctx.textBaseline = "middle";
for (let i = 0; i < 3; i++) {
const y = 1 + i * (barH + 1);
const n = norm(displayValues[i]);
ctx.fillStyle = color;
ctx.textAlign = "left";
ctx.fillText(labels[i], 1, y + barH / 2);
ctx.fillStyle = `rgba(${cr * 255}, ${cg * 255}, ${cb * 255}, 0.15)`;
ctx.fillRect(barX, y, barW, barH);
ctx.fillStyle = `rgba(${cr * 255}, ${cg * 255}, ${cb * 255}, 0.7)`;
ctx.fillRect(barX, y, barW * n, barH);
ctx.fillStyle = "rgba(255,255,255,0.8)";
ctx.textAlign = "right";
const raw = rawValues[i];
const txt = raw > -Infinity ? raw.toFixed(1) : "-inf";
ctx.fillText(txt, w - 1, y + barH / 2);
}
},
resize(width, height) {
w = width;
h = height;
},
dispose() {
ctx = null;
state = null;
lastSampleRate = 0;
},
};
};
@@ -0,0 +1,96 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { settings } from "../Settings";
export const createOscilloscope = (): Visualizer => {
let ctx: CanvasRenderingContext2D | null = null;
let w = 0, h = 0;
let scrollBuffer: Float32Array | null = null;
let scrollPos = 0;
const ensureScrollBuffer = () => {
if (!scrollBuffer || scrollBuffer.length !== w) {
scrollBuffer = new Float32Array(w);
scrollPos = 0;
}
};
return {
name: "Oscilloscope",
id: "oscilloscope",
init(canvas, _color) {
ctx = canvas.getContext("2d")!;
w = canvas.width;
h = canvas.height;
scrollBuffer = null;
scrollPos = 0;
},
render(data: AudioData, color: string) {
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
const lineWidth = settings.lineThickness ?? 1.5;
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
ctx.lineJoin = "round";
ctx.lineCap = "round";
if (settings.scrollingOscilloscope) {
ensureScrollBuffer();
if (!scrollBuffer) return;
const timeDomain = data.floatTimeDomain;
const samplesPerPixel = Math.max(1, Math.floor(timeDomain.length / w));
const pixelsToAdd = Math.max(1, Math.ceil(timeDomain.length / samplesPerPixel));
for (let p = 0; p < pixelsToAdd; p++) {
const sampleIdx = Math.floor(p * samplesPerPixel);
let peak = 0;
for (let s = sampleIdx; s < Math.min(sampleIdx + samplesPerPixel, timeDomain.length); s++) {
if (Math.abs(timeDomain[s]) > Math.abs(peak)) peak = timeDomain[s];
}
scrollBuffer[scrollPos % w] = peak;
scrollPos++;
}
ctx.beginPath();
for (let x = 0; x < w; x++) {
const idx = (scrollPos - w + x + w * 2) % w;
const sample = scrollBuffer[idx];
const y = (1 - sample) * h / 2;
if (x === 0) ctx.moveTo(0, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
} else {
const buffer = data.byteTimeDomain;
const len = buffer.length;
const segmentWidth = w / len;
ctx.beginPath();
for (let i = 0; i < len; i++) {
const v = buffer[i] / 128.0;
const y = (v * h) / 2;
if (i === 0) ctx.moveTo(0, y);
else ctx.lineTo(i * segmentWidth, y);
}
ctx.stroke();
}
},
resize(width, height) {
w = width;
h = height;
scrollBuffer = null;
scrollPos = 0;
},
dispose() {
ctx = null;
scrollBuffer = null;
scrollPos = 0;
},
};
};
@@ -0,0 +1,136 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { createProgram, drawQuad, setUniform1f, setUniform1fv, setUniform2f, setUniform3f, hexToRGB } from "../webgl";
import { settings } from "../Settings";
const MAX_BARS = 128;
const FRAG = `#version 300 es
precision highp float;
uniform vec2 u_resolution;
uniform float u_amplitudes[${MAX_BARS}];
uniform int u_bar_count;
uniform vec3 u_color;
uniform float u_gap;
uniform float u_gain;
uniform float u_rounding;
out vec4 fragColor;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float cellFloat = uv.x * float(u_bar_count);
int barIdx = clamp(int(cellFloat), 0, u_bar_count - 1);
float cellPos = fract(cellFloat);
float amp = clamp(u_amplitudes[barIdx] * u_gain, 0.0, 1.0);
if (amp < 0.005) {
fragColor = vec4(0.0);
return;
}
// Bar shape with anti-aliased edges and configurable gap
float barMask = smoothstep(0.0, u_gap, cellPos)
* smoothstep(0.0, u_gap, 1.0 - cellPos);
// Hard cut at bottom, soft feather only at the top edge
float feather = 1.5 / u_resolution.y;
float heightMask = 1.0 - smoothstep(amp - feather, amp + feather, uv.y);
float a = barMask * heightMask;
// Rounded top corners in pixel space
if (u_rounding > 0.5 && a > 0.0) {
float cellPx = u_resolution.x / float(u_bar_count);
float barPx = cellPx * (1.0 - 2.0 * u_gap);
float fromLeft = (cellPos - u_gap) * cellPx;
float fromRight = barPx - fromLeft;
float fromTop = (amp - uv.y) * u_resolution.y;
float r = clamp(barPx * 0.3, 1.0, 3.0);
float edgeX = min(fromLeft, fromRight);
if (edgeX < r && fromTop < r && fromTop >= 0.0) {
float d = length(vec2(r - edgeX, r - fromTop)) - r;
a *= 1.0 - smoothstep(-0.5, 0.5, d);
}
}
fragColor = vec4(u_color * a, a);
}
`;
const amplitudes = new Float32Array(MAX_BARS);
export const createSpectrumBars = (): Visualizer => {
let gl: WebGL2RenderingContext | null = null;
let program: WebGLProgram | null = null;
let w = 0, h = 0;
return {
name: "Spectrum (Bars)",
id: "spectrum-bars",
init(canvas, _color) {
gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true })!;
if (!gl) throw new Error("WebGL2 not available");
program = createProgram(gl, FRAG);
w = canvas.width;
h = canvas.height;
gl.viewport(0, 0, w, h);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
},
render(data: AudioData, color: string) {
if (!gl || !program) return;
const barCount = Math.min(settings.barCount ?? 64, MAX_BARS);
const gain = settings.gain ?? 1.5;
// Use byteFrequency (0-255 normalized across full analyser range)
const binStep = data.byteFrequency.length / barCount;
for (let i = 0; i < barCount; i++) {
let maxVal = 0;
const start = Math.floor(i * binStep);
const end = Math.floor((i + 1) * binStep);
for (let j = start; j < end; j++) {
if (data.byteFrequency[j] > maxVal) maxVal = data.byteFrequency[j];
}
amplitudes[i] = Math.min(1, (maxVal / 255) * gain);
}
for (let i = barCount; i < MAX_BARS; i++) amplitudes[i] = 0;
gl.viewport(0, 0, w, h);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
setUniform2f(gl, program, "u_resolution", w, h);
setUniform1fv(gl, program, "u_amplitudes", amplitudes);
const loc = gl.getUniformLocation(program, "u_bar_count");
gl.uniform1i(loc, barCount);
const [r, g, b] = hexToRGB(color);
setUniform3f(gl, program, "u_color", r, g, b);
const cellPx = w / barCount;
const gap = Math.min(0.15, 1.5 / cellPx);
setUniform1f(gl, program, "u_gap", gap);
setUniform1f(gl, program, "u_gain", 1.0);
setUniform1f(gl, program, "u_rounding", settings.barRounding ? 1.0 : 0.0);
drawQuad(gl, program);
},
resize(width, height) {
w = width;
h = height;
if (gl) gl.viewport(0, 0, w, h);
},
dispose() {
if (gl && program) gl.deleteProgram(program);
program = null;
gl = null;
},
};
};
@@ -0,0 +1,105 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { createProgram, drawQuad, setUniform1f, setUniform1fv, setUniform2f, setUniform3f, hexToRGB } from "../webgl";
import { settings } from "../Settings";
const BIN_COUNT = 256;
const FRAG = `#version 300 es
precision highp float;
uniform vec2 u_resolution;
uniform float u_amplitudes[${BIN_COUNT}];
uniform vec3 u_color;
uniform float u_fill_opacity;
uniform float u_line_thickness;
uniform float u_opacity_falloff;
out vec4 fragColor;
float interpolate(float a, float b, float t) {
return (1.0 - t) * a + t * b;
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
int idx = int(uv.x * float(${BIN_COUNT}));
int idxL = int((uv.x - 1.0 / u_resolution.x) * float(${BIN_COUNT}));
int idxR = int((uv.x + 1.0 / u_resolution.x) * float(${BIN_COUNT}));
idx = clamp(idx, 0, ${BIN_COUNT - 1});
idxL = clamp(idxL, 0, ${BIN_COUNT - 1});
idxR = clamp(idxR, 0, ${BIN_COUNT - 1});
float amplitude = u_amplitudes[idx];
float left = u_amplitudes[idxL];
float right = u_amplitudes[idxR];
float lowest = min(left, right);
float dist = (amplitude - uv.y) * u_resolution.y;
float a = 0.0;
a += float(abs(dist) <= u_resolution.x * 0.005 * u_line_thickness || (uv.y >= lowest && uv.y <= amplitude)) * clamp(sign(dist), 0.0, 1.0);
a += clamp(sign(amplitude - uv.y), 0.0, 1.0) * interpolate(1.0, u_fill_opacity, pow(1.0 - uv.y, 1.0 - u_opacity_falloff));
a = clamp(a, 0.0, 1.0);
fragColor = vec4(u_color * a, a);
}
`;
const amplitudes = new Float32Array(BIN_COUNT);
export const createSpectrumLine = (): Visualizer => {
let gl: WebGL2RenderingContext | null = null;
let program: WebGLProgram | null = null;
let w = 0, h = 0;
return {
name: "Spectrum (Line)",
id: "spectrum-line",
init(canvas, _color) {
gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true })!;
if (!gl) throw new Error("WebGL2 not available");
program = createProgram(gl, FRAG);
w = canvas.width;
h = canvas.height;
gl.viewport(0, 0, w, h);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
},
render(data: AudioData, color: string) {
if (!gl || !program) return;
const gain = settings.gain ?? 1.5;
const binStep = data.byteFrequency.length / BIN_COUNT;
for (let i = 0; i < BIN_COUNT; i++) {
amplitudes[i] = Math.min(1, (data.byteFrequency[Math.floor(i * binStep)] / 255) * gain);
}
gl.viewport(0, 0, w, h);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
setUniform2f(gl, program, "u_resolution", w, h);
setUniform1fv(gl, program, "u_amplitudes", amplitudes);
const [r, g, b] = hexToRGB(color);
setUniform3f(gl, program, "u_color", r, g, b);
setUniform1f(gl, program, "u_fill_opacity", settings.fillOpacity ?? 0.3);
setUniform1f(gl, program, "u_line_thickness", settings.lineThickness ?? 1.5);
setUniform1f(gl, program, "u_opacity_falloff", settings.opacityFalloff ?? 0.5);
drawQuad(gl, program);
},
resize(width, height) {
w = width;
h = height;
if (gl) gl.viewport(0, 0, w, h);
},
dispose() {
if (gl && program) gl.deleteProgram(program);
program = null;
gl = null;
},
};
};
@@ -0,0 +1,88 @@
import type { AudioData } from "../audio";
export interface Visualizer {
readonly name: string;
readonly id: VisualizerType;
init(canvas: HTMLCanvasElement, color: string): void;
render(data: AudioData, color: string): void;
resize(width: number, height: number): void;
dispose(): void;
}
export type VisualizerType =
| "spectrum-line"
| "spectrum-bars"
| "oscilloscope"
| "vectorscope"
| "loudness-meter"
| "none";
export interface VisualizerDimensions {
width: number;
height: number;
}
export const VISUALIZER_DIMENSIONS: Record<VisualizerType, VisualizerDimensions> = {
"spectrum-line": { width: 200, height: 40 },
"spectrum-bars": { width: 200, height: 40 },
oscilloscope: { width: 200, height: 40 },
vectorscope: { width: 100, height: 40 },
"loudness-meter": { width: 160, height: 40 },
none: { width: 0, height: 0 },
};
export const VISUALIZER_LABELS: Record<VisualizerType, string> = {
"spectrum-line": "Spectrum (Line)",
"spectrum-bars": "Spectrum (Bars)",
oscilloscope: "Oscilloscope",
vectorscope: "Vectorscope",
"loudness-meter": "Loudness (LUFS)",
none: "None",
};
export type ZoneId = "topNav" | "nowPlaying" | "playerBar";
export type PositionId = "left" | "right";
export const ALL_SLOT_KEYS = [
"navLeft1", "navLeft2", "navLeft3",
"navRight1", "navRight2", "navRight3",
"npLeft1", "npLeft2", "npLeft3",
"npRight1", "npRight2", "npRight3",
"pbLeft1", "pbLeft2", "pbLeft3",
"pbRight1", "pbRight2", "pbRight3",
] as const;
export type SlotKey = (typeof ALL_SLOT_KEYS)[number];
export const ZONE_SLOTS: Record<ZoneId, Record<PositionId, readonly SlotKey[]>> = {
topNav: {
left: ["navLeft1", "navLeft2", "navLeft3"],
right: ["navRight1", "navRight2", "navRight3"],
},
nowPlaying: {
left: ["npLeft1", "npLeft2", "npLeft3"],
right: ["npRight1", "npRight2", "npRight3"],
},
playerBar: {
left: ["pbLeft1", "pbLeft2", "pbLeft3"],
right: ["pbRight1", "pbRight2", "pbRight3"],
},
};
export const ZONE_LABELS: Record<ZoneId, string> = {
nowPlaying: "Now Playing View",
topNav: "Top Nav",
playerBar: "Player Bar",
};
export const POSITION_LABELS: Record<PositionId, string> = {
left: "Left",
right: "Right",
};
export const MINI_SUPPORTED = new Set<VisualizerType>(["oscilloscope", "vectorscope"]);
export const MINI_DIMENSIONS: Partial<Record<VisualizerType, VisualizerDimensions>> = {
oscilloscope: { width: 80, height: 60 },
vectorscope: { width: 72, height: 40 },
};
@@ -0,0 +1,86 @@
import type { AudioData } from "../audio";
import type { Visualizer } from "./types";
import { settings } from "../Settings";
export const createVectorscope = (): Visualizer => {
let ctx: CanvasRenderingContext2D | null = null;
let canvas: HTMLCanvasElement | null = null;
let w = 0, h = 0;
let lastX = 0, lastY = 0;
let hasLast = false;
let lastLissajous = false;
return {
name: "Vectorscope",
id: "vectorscope",
init(cvs, _color) {
canvas = cvs;
const c = cvs.getContext("2d");
if (!c) return;
ctx = c;
w = cvs.width;
h = cvs.height;
hasLast = false;
lastLissajous = !!settings.lissajous;
cvs.style.transform = lastLissajous ? "rotate(45deg) scale(0.707)" : "";
},
render(data: AudioData, color: string) {
if (!ctx || !canvas) return;
const wantLissajous = !!settings.lissajous;
if (wantLissajous !== lastLissajous) {
lastLissajous = wantLissajous;
canvas.style.transform = wantLissajous ? "rotate(45deg) scale(0.707)" : "";
}
ctx.clearRect(0, 0, w, h);
const left = data.leftTimeDomain;
const right = data.rightTimeDomain;
const len = Math.min(left.length, right.length);
const lineWidth = Math.max(0.5, (settings.lineThickness ?? 1.0) * 0.5);
const inset = lineWidth;
const halfW = Math.max(1, w / 2 - inset);
const halfH = Math.max(1, h / 2 - inset);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineJoin = "round";
ctx.lineCap = "round";
hasLast = false;
ctx.beginPath();
for (let i = 0; i < len; i++) {
const x = left[i] * halfW + w / 2;
const y = right[i] * halfH + h / 2;
if (!hasLast) {
ctx.moveTo(x, y);
hasLast = true;
} else {
ctx.moveTo(lastX, lastY);
ctx.lineTo(x, y);
}
lastX = x;
lastY = y;
}
ctx.stroke();
},
resize(width, height) {
w = width;
h = height;
hasLast = false;
},
dispose() {
if (canvas) canvas.style.transform = "";
ctx = null;
canvas = null;
hasLast = false;
},
};
};
+151
View File
@@ -0,0 +1,151 @@
const VERTEX_SHADER = `#version 300 es
in vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
export const compileShader = (gl: WebGL2RenderingContext, type: number, source: string): WebGLShader => {
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`Shader compile error: ${info}`);
}
return shader;
};
export const createProgram = (gl: WebGL2RenderingContext, fragSource: string): WebGLProgram => {
const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource);
const program = gl.createProgram()!;
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const info = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`Program link error: ${info}`);
}
gl.deleteShader(vert);
gl.deleteShader(frag);
return program;
};
interface QuadResources {
vao: WebGLVertexArrayObject;
vbo: WebGLBuffer;
}
const quadMap = new WeakMap<WebGL2RenderingContext, QuadResources>();
const ensureQuad = (gl: WebGL2RenderingContext, program: WebGLProgram): QuadResources => {
let res = quadMap.get(gl);
if (res) return res;
const verts = new Float32Array([-1, -1, 3, -1, -1, 3]);
const vao = gl.createVertexArray()!;
const vbo = gl.createBuffer()!;
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
const loc = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
res = { vao, vbo };
quadMap.set(gl, res);
return res;
};
export const drawQuad = (gl: WebGL2RenderingContext, program: WebGLProgram): void => {
const res = ensureQuad(gl, program);
gl.useProgram(program);
gl.bindVertexArray(res.vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindVertexArray(null);
};
export interface PingPongBuffers {
fbos: [WebGLFramebuffer, WebGLFramebuffer];
textures: [WebGLTexture, WebGLTexture];
current: 0 | 1;
}
const createFBOTexture = (gl: WebGL2RenderingContext, w: number, h: number): { fbo: WebGLFramebuffer; texture: WebGLTexture } => {
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const fbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.bindTexture(gl.TEXTURE_2D, null);
return { fbo, texture: tex };
};
export const createPingPong = (gl: WebGL2RenderingContext, w: number, h: number): PingPongBuffers => {
const a = createFBOTexture(gl, w, h);
const b = createFBOTexture(gl, w, h);
return {
fbos: [a.fbo, b.fbo],
textures: [a.texture, b.texture],
current: 0,
};
};
export const resizePingPong = (gl: WebGL2RenderingContext, pp: PingPongBuffers, w: number, h: number): void => {
for (const tex of pp.textures) {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
}
gl.bindTexture(gl.TEXTURE_2D, null);
};
export const setUniform1f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: number): void => {
gl.uniform1f(gl.getUniformLocation(program, name), v);
};
export const setUniform2f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, x: number, y: number): void => {
gl.uniform2f(gl.getUniformLocation(program, name), x, y);
};
export const setUniform3f = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, x: number, y: number, z: number): void => {
gl.uniform3f(gl.getUniformLocation(program, name), x, y, z);
};
export const setUniform1fv = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: Float32Array): void => {
gl.uniform1fv(gl.getUniformLocation(program, name), v);
};
export const setUniform1i = (gl: WebGL2RenderingContext, program: WebGLProgram, name: string, v: number): void => {
gl.uniform1i(gl.getUniformLocation(program, name), v);
};
export const disposeQuad = (gl: WebGL2RenderingContext): void => {
const res = quadMap.get(gl);
if (res) {
gl.deleteVertexArray(res.vao);
gl.deleteBuffer(res.vbo);
quadMap.delete(gl);
}
};
export const disposePingPong = (gl: WebGL2RenderingContext, pp: PingPongBuffers): void => {
for (const fbo of pp.fbos) gl.deleteFramebuffer(fbo);
for (const tex of pp.textures) gl.deleteTexture(tex);
};
export const hexToRGB = (hex: string): [number, number, number] => {
const c = hex.replace("#", "");
const r = parseInt(c.substring(0, 2), 16) / 255;
const g = parseInt(c.substring(2, 4), 16) / 255;
const b = parseInt(c.substring(4, 6), 16) / 255;
return [r, g, b];
};
@@ -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",
@@ -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>
);
};
+99
View File
@@ -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);
+117
View File
@@ -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;
}
+45 -20
View File
@@ -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,8 +9,8 @@ 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");
@@ -31,36 +31,50 @@ function SetClipboard(text: string): void {
let isSelecting = false;
const onMouseDown = function (): void {
const onMouseDown = (): void => {
isSelecting = true;
};
const onMouseUp = function (event: MouseEvent): void {
const onMouseUp = (): void => {
if (isSelecting) {
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
if (selection?.toString().length > 0) {
const selectedSpans: HTMLSpanElement[] = [];
const range = selection.getRangeAt(0);
let container = range.commonAncestorContainer;
let container: Node | null = range.commonAncestorContainer;
// If the container is NOT an element and a document, adjust it.
// Normalize container: if it's a text node, use its parent element/node
if (container && container.nodeType === Node.TEXT_NODE) {
container = (container.parentElement ?? container.parentNode) as Node | null;
}
// If parent has data-current, treat as single-line copy case
if (
container.nodeType !== Node.ELEMENT_NODE &&
container.nodeType !== Node.DOCUMENT_NODE
container &&
container.nodeType === Node.ELEMENT_NODE &&
(container as Element).hasAttribute("data-current")
) {
// 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();
const text_ = selection.toString().trim();
SetClipboard(text_);
trace.msg.log("Copied to clipboard!");
return;
}
// Ensure we have an Element or Document before querying
if (
!container ||
(container.nodeType !== Node.ELEMENT_NODE &&
container.nodeType !== Node.DOCUMENT_NODE)
) {
isSelecting = false;
return;
}
// Get all the spans inside the container.
const spans = (container as Element).getElementsByTagName("span");
for (let span of spans) {
const spans = (container as Element | Document).getElementsByTagName(
"span",
);
for (const span of spans) {
if (selection.containsNode(span, true)) {
selectedSpans.push(span as HTMLSpanElement);
}
@@ -73,7 +87,11 @@ const onMouseUp = function (event: MouseEvent): void {
if (span.hasAttribute("data-current")) {
hasCorrectAttribute = true;
text += span.textContent + "\n";
if ([...span.classList].some((className) => className.startsWith("endOfStanza--"))) {
if (
[...span.classList].some((className) =>
className.startsWith("endOfStanza--"),
)
) {
text += "\n";
}
}
@@ -91,26 +109,33 @@ const onMouseUp = function (event: MouseEvent): void {
}
};
const onClickHooked = function (event: MouseEvent): boolean | void {
const onClickHooked = (event: MouseEvent): boolean | undefined => {
if (!isSelecting) return;
const target = event.target as HTMLElement;
if (target.tagName.toLowerCase() === "span" && target.hasAttribute("data-current")) {
if (
target.tagName.toLowerCase() === "span" &&
target.hasAttribute("data-current")
) {
// Prevent default behavior and stop event propagation
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
return false;
}
return undefined;
};
// 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(() => {
unloads.add((): void => {
// Remove event listeners
document.removeEventListener("click", onClickHooked, true);
document.removeEventListener("mousedown", onMouseDown);
+1 -1
View File
@@ -9,7 +9,7 @@ export const settings = await ReactiveStore.getPluginStorage("ElementHider", {
className: string;
textContent: string;
timestamp: number;
}>
}>,
});
export const Settings = () => {
+155 -88
View File
@@ -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,8 +13,8 @@ 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;
@@ -32,7 +32,7 @@ function generateElementSelector(element: HTMLElement): string {
}
// Priority 2: data-test attribute (very specific for Tidal <3)
const dataTest = element.getAttribute('data-test');
const dataTest = element.getAttribute("data-test");
if (dataTest) {
return `[data-test="${dataTest}"]`;
}
@@ -41,28 +41,43 @@ function generateElementSelector(element: HTMLElement): string {
let selector = element.tagName.toLowerCase();
// Get filtered classes (exclude our temporary classes)
const classes = element.className ? element.className.trim().split(/\s+/).filter(cls => {
return cls.length > 0 &&
!cls.startsWith('element-hider-') &&
cls !== 'element-hider-target' &&
cls !== 'element-hider-hiding' &&
cls !== 'element-hider-hidden';
}) : [];
const classes = element.className
? element.className
.trim()
.split(/\s+/)
.filter((cls) => {
return (
cls.length > 0 &&
!cls.startsWith("element-hider-") &&
cls !== "element-hider-target" &&
cls !== "element-hider-hiding" &&
cls !== "element-hider-hidden"
);
})
: [];
// Only use classes if we have them and they're not generic and dumb
if (classes.length > 0) {
// Use ALL classes to be very specific
selector += '.' + classes.join('.');
selector += "." + classes.join(".");
// Add parent context for extra specificity (for when the element is inside another element)
const parent = element.parentElement;
if (parent && parent.tagName !== 'BODY' && parent.tagName !== 'HTML') {
const parentClasses = parent.className ? parent.className.trim().split(/\s+/).filter(cls => {
return cls.length > 0 && !cls.startsWith('element-hider-');
}) : [];
if (parent && parent.tagName !== "BODY" && parent.tagName !== "HTML") {
const parentClasses = parent.className
? parent.className
.trim()
.split(/\s+/)
.filter((cls) => {
return cls.length > 0 && !cls.startsWith("element-hider-");
})
: [];
if (parentClasses.length > 0) {
const parentSelector = parent.tagName.toLowerCase() + '.' + parentClasses.slice(0, 2).join('.');
const parentSelector =
parent.tagName.toLowerCase() +
"." +
parentClasses.slice(0, 2).join(".");
selector = `${parentSelector} > ${selector}`;
}
}
@@ -70,19 +85,29 @@ 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}`;
}
}
@@ -100,14 +125,14 @@ function saveHiddenElement(element: HTMLElement): void {
const elementInfo = {
selector: selector,
tagName: element.tagName,
className: element.className || '',
textContent: element.textContent?.substring(0, 100) || '',
timestamp: Date.now()
className: element.className || "",
textContent: element.textContent?.substring(0, 100) || "",
timestamp: Date.now(),
};
// Check if element is already saved
const existingIndex = settings.hiddenElements.findIndex(
stored => stored.selector === elementInfo.selector
(stored) => stored.selector === elementInfo.selector,
);
if (existingIndex === -1) {
@@ -119,17 +144,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 {
@@ -154,14 +180,18 @@ function hideElementDirectly(element: HTMLElement): void {
element.classList.add("element-hider-hidden");
hiddenElements.add(element);
hiddenElementsArray.push(element);
trace.log(`Hidden element: ${element.tagName}${element.className ? '.' + element.className.split(' ')[0] : ''}`);
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");
@@ -175,7 +205,10 @@ function hideTargetElement(): void {
// Wait for animation to complete, then hide
setTimeout(() => {
elementToHide.classList.add("element-hider-hidden");
elementToHide.classList.remove("element-hider-hiding", "element-hider-target");
elementToHide.classList.remove(
"element-hider-hiding",
"element-hider-target",
);
hiddenElements.add(elementToHide);
hiddenElementsArray.push(elementToHide);
}, 300);
@@ -186,10 +219,12 @@ function hideTargetElement(): void {
// 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 elements`,
);
// Show all currently hidden elements
hiddenElementsArray.forEach(element => {
hiddenElementsArray.forEach((element) => {
if (document.body.contains(element)) {
element.classList.remove("element-hider-hidden", "element-hider-hiding");
}
@@ -205,7 +240,9 @@ function unhideAllElements(): void {
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
@@ -217,7 +254,9 @@ function processAllElements(): void {
// Limit to prevent over-hiding (safety check)
if (elements.length > 10) {
trace.warn(`Selector too broad (${elements.length} matches), skipping: ${storedElement.selector}`);
trace.warn(
`Selector too broad (${elements.length} matches), skipping: ${storedElement.selector}`,
);
return;
}
@@ -226,7 +265,9 @@ function processAllElements(): void {
if (!hiddenElements.has(htmlElement)) {
hideElementDirectly(htmlElement);
hiddenCount++;
trace.log(`Hid element ${elemIndex + 1}/${elements.length} for selector ${index + 1}`);
trace.log(
`Hid element ${elemIndex + 1}/${elements.length} for selector ${index + 1}`,
);
}
});
} catch (error) {
@@ -241,7 +282,7 @@ function processAllElements(): void {
// Process new elements that are added to the DOM
function processNewElements(addedNodes: NodeList): void {
addedNodes.forEach(node => {
addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as HTMLElement;
@@ -252,8 +293,8 @@ function processNewElements(addedNodes: NodeList): void {
}
// Check all descendant elements
const descendants = element.querySelectorAll('*');
descendants.forEach(descendant => {
const descendants = element.querySelectorAll("*");
descendants.forEach((descendant) => {
if (matchesStoredSelector(descendant as HTMLElement)) {
hideElementDirectly(descendant as HTMLElement);
}
@@ -267,7 +308,7 @@ function setupElementObserver(): void {
elementObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
processNewElements(mutation.addedNodes);
}
});
@@ -275,15 +316,22 @@ function setupElementObserver(): void {
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}`);
@@ -297,19 +345,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,11 +369,17 @@ let contextMenuTimeout: number | null = null;
let waitingForBuiltInMenu = false;
// Listen for right-click events to capture the target for context menu
document.addEventListener('contextmenu', (event: MouseEvent) => {
document.addEventListener(
"contextmenu",
(event: MouseEvent) => {
const target = event.target as HTMLElement;
// Don't interfere with native context menus on inputs, textareas, etc.
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
currentContextElement = null;
return;
}
@@ -346,8 +400,7 @@ document.addEventListener('contextmenu', (event: MouseEvent) => {
const eventX = event.clientX;
const eventY = event.clientY;
// Prevent default immediately if we plan to handle it
event.preventDefault();
// 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(() => {
@@ -359,10 +412,14 @@ document.addEventListener('contextmenu', (event: MouseEvent) => {
}, 150); // Wait 150ms for built-in menu
// Don't prevent default initially - let Luna try to handle the context menu
}, true);
},
true,
);
// Listen for clicks to close custom menu
document.addEventListener('click', (event: MouseEvent) => {
document.addEventListener(
"click",
(event: MouseEvent) => {
const target = event.target as HTMLElement;
// If clicking outside our custom menu, close it
@@ -370,10 +427,12 @@ document.addEventListener('click', (event: MouseEvent) => {
closeCustomMenu();
removeHighlight();
}
}, true);
},
true,
);
// Handle escape key to close custom menu and remove highlights
document.addEventListener('keydown', (event: KeyboardEvent) => {
document.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (customMenu) {
closeCustomMenu();
@@ -464,8 +523,15 @@ const contextMenuObserver = new MutationObserver((mutations) => {
const element = node as HTMLElement;
// Look for Tidal's context menu
if (element.matches('[data-test="contextmenu"]') || element.querySelector('[data-test="contextmenu"]')) {
const contextMenu = element.matches('[data-test="contextmenu"]') ? element : element.querySelector('[data-test="contextmenu"]') as HTMLElement;
if (
element.matches('[data-test="contextmenu"]') ||
element.querySelector('[data-test="contextmenu"]')
) {
const contextMenu = element.matches('[data-test="contextmenu"]')
? element
: (element.querySelector(
'[data-test="contextmenu"]',
) as HTMLElement);
if (contextMenu && currentContextElement && waitingForBuiltInMenu) {
// Built-in menu appeared, cancel custom menu timeout
@@ -485,8 +551,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;
@@ -503,7 +569,7 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
`;
hideButton.innerHTML = `Hide This Element`;
hideButton.addEventListener('click', () => {
hideButton.addEventListener("click", () => {
if (currentContextElement) {
targetElement = currentContextElement;
hideTargetElement();
@@ -511,37 +577,38 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
});
// Add hover effects for highlighting
hideButton.addEventListener('mouseenter', () => {
hideButton.style.background = 'var(--wave-color-background-hover, #3a3a3a)';
hideButton.addEventListener("mouseenter", () => {
hideButton.style.background = "var(--wave-color-background-hover, #3a3a3a)";
if (currentContextElement) {
highlightElement(currentContextElement);
}
});
hideButton.addEventListener('mouseleave', () => {
hideButton.style.background = 'transparent';
hideButton.addEventListener("mouseleave", () => {
hideButton.style.background = "transparent";
removeHighlight();
});
// Create unhide all button
const unhideAllButton = document.createElement('button');
unhideAllButton.className = 'element-hider-menu-item';
const unhideAllButton = document.createElement("button");
unhideAllButton.className = "element-hider-menu-item";
unhideAllButton.style.cssText = hideButton.style.cssText;
unhideAllButton.innerHTML = `Unhide All Elements (${hiddenElementsArray.length})`;
unhideAllButton.addEventListener('click', unhideAllElements);
unhideAllButton.addEventListener("click", unhideAllElements);
// Add hover effects for unhide all button
unhideAllButton.addEventListener('mouseenter', () => {
unhideAllButton.style.background = 'var(--wave-color-background-hover, #3a3a3a)';
unhideAllButton.addEventListener("mouseenter", () => {
unhideAllButton.style.background =
"var(--wave-color-background-hover, #3a3a3a)";
});
unhideAllButton.addEventListener('mouseleave', () => {
unhideAllButton.style.background = 'transparent';
unhideAllButton.addEventListener("mouseleave", () => {
unhideAllButton.style.background = "transparent";
});
// Add a separator if the menu has other items
if (contextMenu.children.length > 0) {
const separator = document.createElement('div');
const separator = document.createElement("div");
separator.style.cssText = `
height: 1px;
background: var(--wave-color-border, #444);
@@ -558,7 +625,7 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
// Start observing for context menus
contextMenuObserver.observe(document.body, {
childList: true,
subtree: true
subtree: true,
});
// Initialize plugin
@@ -578,8 +645,8 @@ function initializePlugin() {
}
// Run initialization when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePlugin);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializePlugin);
} else {
initializePlugin();
}
@@ -600,8 +667,8 @@ unloads.add(() => {
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");
});
+3 -1
View File
@@ -57,7 +57,9 @@
/* Animation for hiding */
.element-hider-hiding {
transition: opacity 0.3s ease, transform 0.3s ease;
transition:
opacity 0.3s ease,
transform 0.3s ease;
opacity: 0;
transform: scale(0.95);
}
-59
View File
@@ -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>
);
};
-301
View File
@@ -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);
}
-128
View File
@@ -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();
-424
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
import { Tracer } from "@luna/core";
import { settings } from "./Settings";
const { trace } = Tracer("[Radiant Lyrics]");
const sylTrace = (...args: unknown[]) => {
if (settings.syllableLogging) trace.log(...args);
};
export const RL_PLATFORM = "Radiant Lyrics";
const RL_ACCESS_TOKEN_ID = "58hy4s86";
const RL_ACCESS_TOKEN = "xjehy2lfg5h5mjwotoxrcqugam";
// Yup that's right, plaintext token in a public Repo!!
// The API does not return sensitive data & won't be plain text like this in the future <3
let cachedPublicIP: string | undefined;
export async function ip(): Promise<string | undefined> {
if (cachedPublicIP) return cachedPublicIP;
try {
const res = await fetch("https://api.ipify.org?format=text");
if (res.ok) cachedPublicIP = (await res.text()).trim();
} catch {}
return cachedPublicIP;
}
export async function auth(): Promise<Record<string, string>> {
const clientIP = await ip();
return {
"P-Access-Token-Id": RL_ACCESS_TOKEN_ID,
"P-Access-Token": RL_ACCESS_TOKEN,
"x-client-ip": clientIP ?? "null",
};
}
// Platform param (just for DX logging)
const platformQs = `platform=${encodeURIComponent(RL_PLATFORM)}`;
// Query string & params
function query(
title: string,
artist: string,
isrc: string | undefined,
options?: { romanize?: boolean; flush?: boolean },
): string {
let q = `?title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`;
if (isrc) q += `&isrc=${encodeURIComponent(isrc)}`;
if (options?.romanize) q += "&romanize=true";
if (options?.flush) q += "&flush=true";
q += `&${platformQs}`;
return q;
}
// Response types
export interface WordTiming {
text: string;
time: number;
duration: number;
isBackground: boolean;
romanized?: string;
}
export interface WordLine {
text: string;
startTime: number;
duration: number;
endTime: number;
syllabus: WordTiming[];
element: {
key: string;
songPart?: string;
songPartIndex?: number;
singer: string;
};
translation: string | null;
romanized?: string;
}
export interface ApiLine {
text: string;
startTime: number;
duration: number;
endTime: number;
syllabus?: WordTiming[];
element?: {
key: string;
songPart?: string;
songPartIndex?: number;
singer?: string;
};
translation?: string | null;
romanized?: string;
}
export interface WordLyricsResponse {
type: "Word";
data: WordLine[];
metadata: {
source: string;
title: string;
language: string;
totalDuration: string;
agents?: Record<string, { type: string; name: string; alias: string }>;
songParts?: Array<{ name: string; time: number; duration: number }>;
};
_cached?: boolean;
}
export interface LineLyricsResponse {
type: "Line";
data: ApiLine[];
metadata: {
source: string;
title: string;
language: string;
totalDuration: string;
agents?: Record<string, { type: string; name: string; alias: string }>;
songParts?: Array<{ name: string; time: number; duration: number }>;
};
_cached?: boolean;
}
export type LyricsApiResponse = WordLyricsResponse | LineLyricsResponse;
type FetchOutcome =
| { status: "ok"; data: LyricsApiResponse | null }
| { status: "404" }
| { status: "500" }
| { status: "err" };
// Lyrics lookup (network)
export async function fetchLyrics(
title: string,
artist: string,
isrc: string | undefined,
romanize: boolean,
): Promise<LyricsApiResponse | null> {
const params = query(title, artist, isrc, { romanize });
const atomixUrl = `https://api.atomix.one/rl-api${params}`;
const fallbackUrl = `https://rl-api.kineticsand.net/lyrics${params}`;
const rlApiHeaders = await auth();
const tryFetch = async (url: string, useAtomixAuth: boolean): Promise<FetchOutcome> => {
try {
sylTrace(`RL API: Fetching lyrics: ${url}`);
const res = await fetch(url, {
headers: useAtomixAuth ? rlApiHeaders : undefined,
});
if (!res.ok) {
trace.log(`RL API: fetch failed: ${res.status} from ${url}`);
if (res.status === 404) return { status: "404" };
return res.status === 500 ? { status: "500" } : { status: "err" };
}
const data = (await res.json()) as LyricsApiResponse;
if (!data?.data || !Array.isArray(data.data)) {
trace.log("Lyrics API returned invalid payload");
return { status: "ok", data: null };
}
if (data.type !== "Word" && data.type !== "Line") {
trace.log("Lyrics not available in supported format");
return { status: "ok", data: null };
}
return { status: "ok", data };
} catch (err) {
trace.log(`RL API: fetch error from ${url}: ${err}`);
return { status: "err" };
}
};
const primary = await tryFetch(atomixUrl, true);
if (primary.status === "ok") return primary.data;
if (primary.status === "404") {
trace.log("RL API: 404 — no API lyrics exist for this track");
return null;
}
if (primary.status === "500") {
trace.log("RL API: 500 (Execution Timeout) — fallback");
}
const fallback = await tryFetch(fallbackUrl, false);
if (fallback.status === "ok") return fallback.data;
if (fallback.status === "404") {
trace.log("RL API: 404 from fallback — no API lyrics exist for this track");
return null;
}
if (fallback.status === "500") {
trace.log("RL API: 500 from fallback — API IS ACTUALLY BORKED!");
return null;
}
trace.log("RL API: All Endpoints Failed");
return null;
}
export async function flushLyrics(track: {
title: string;
artist: string;
isrc?: string;
}): Promise<
| { ok: true; data: LyricsApiResponse & { _flush?: string } }
| { ok: false; status: number; notFound: boolean }
> {
const q = query(track.title, track.artist, track.isrc, {
flush: true,
});
const url = `https://api.atomix.one/rl-api${q}`;
const headers = await auth();
const res = await fetch(url, { headers });
if (res.status === 404) {
return { ok: false, status: 404, notFound: true };
}
if (!res.ok) {
return { ok: false, status: res.status, notFound: false };
}
const data = (await res.json()) as LyricsApiResponse & { _flush?: string };
return { ok: true, data };
}
// Romanize
export async function romanizeLyrics(
lineTexts: string[],
): Promise<string[] | null> {
if (lineTexts.length === 0) return null;
const payload = {
type: "Line" as const,
data: lineTexts.map((text, idx) => ({
text,
startTime: idx,
duration: 0,
endTime: idx,
})),
};
const romanizeQuery = `?${platformQs}`;
const urls: { url: string; useAtomixAuth: boolean }[] = [
{
url: `https://api.atomix.one/rl-api/romanize${romanizeQuery}`,
useAtomixAuth: true,
},
{
url: `https://rl-api.kineticsand.net/romanize${romanizeQuery}`,
useAtomixAuth: false,
},
];
for (const { url, useAtomixAuth } of urls) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const romanizeHeaders: Record<string, string> = {
"content-type": "application/json",
};
if (useAtomixAuth) {
Object.assign(romanizeHeaders, await auth());
}
const res = await fetch(url, {
method: "POST",
headers: romanizeHeaders,
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) {
trace.log(`Romanize: request failed ${res.status} | ${url}`);
continue;
}
const data = (await res.json()) as {
type?: string;
data?: Array<{ text?: string; romanized?: string }>;
};
if (!Array.isArray(data?.data)) continue;
return lineTexts.map((original, idx) => {
const item = data.data?.[idx];
return item?.romanized ?? item?.text ?? original;
});
} catch (err) {
clearTimeout(timeout);
if (err instanceof DOMException && err.name === "AbortError") {
trace.log(`Romanize: request timed out | ${url}`);
} else {
trace.log(`Romanize: request error | ${url} | ${err}`);
}
}
}
return null;
}
@@ -43,21 +43,16 @@
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 */
@@ -67,7 +62,7 @@
top: 0;
width: 100%;
height: 100%;
z-index: -3;
z-index: 0;
pointer-events: none;
overflow: hidden;
/* Hardware acceleration */
@@ -75,6 +70,13 @@
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 {
@@ -89,8 +91,11 @@
@media (prefers-reduced-motion: reduce) {
.global-spinning-image,
.now-playing-background-image {
/* biome-ignore lint: Accessibility override needs priority */
animation: none !important;
/* biome-ignore lint: Accessibility override needs priority */
transform: translate(-50%, -50%) !important;
/* biome-ignore lint: Accessibility override needs priority */
will-change: auto !important;
}
}
@@ -99,60 +104,62 @@
.performance-mode .global-spinning-image,
.performance-mode .now-playing-background-image {
/* Keep animations but optimize filter effects */
/* biome-ignore lint: Intentional override of runtime styles */
filter: blur(10px) brightness(0.4) contrast(1.1) !important;
}
/* Make 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"] {
[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 {
/* 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 {
.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"] {
/* biome-ignore lint: Ensure readability over media */
background-color: rgba(0, 0, 0, 0.5) !important;
/* biome-ignore lint: Ensure readability over media */
backdrop-filter: blur(10px) !important;
/* biome-ignore lint: Ensure readability over media */
-webkit-backdrop-filter: blur(10px) !important;
}
/* Performance mode: reduce sidebar backdrop blur */
.performance-mode [data-test="feed-sidebar"] {
/* biome-ignore lint: Performance mode style requires priority */
backdrop-filter: blur(5px) !important;
/* biome-ignore lint: Performance mode style requires priority */
-webkit-backdrop-filter: blur(5px) !important;
}
@@ -162,10 +169,6 @@ main,
[class*="_cellContainer"],
[data-test="feed-interval"],
[data-test="feed-item"] {
/* biome-ignore lint: Match theme transparency */
background-color: transparent !important;
}
/* Remove bottom gradient */
[class^="_bottomGradient"] {
display: none !important;
}
@@ -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
+29 -80
View File
@@ -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");
}
/* 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;
.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;
}
@@ -9,7 +9,7 @@
}
/* 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;
}
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -39,9 +39,13 @@ importers:
specifier: ^5.8.3
version: 5.8.3
plugins/audio-visualizer-luna: {}
plugins/colorama-lyrics-luna: {}
plugins/copy-lyrics-luna: {}
plugins/oled-theme-luna: {}
plugins/element-hider-luna: {}
plugins/radiant-lyrics-luna: {}
+5
View File
@@ -1,2 +1,7 @@
packages:
- "plugins/*"
# pnpm 11 renamed `onlyBuiltDependencies` (list) to `allowBuilds` (map of name -> bool).
# This whitelists postinstall/build scripts non-interactively in CI.
allowBuilds:
esbuild: true