1 Commits

Author SHA1 Message Date
vMohammad24 24aabe67fd feat(audioVisualization): add spotify support and linear animations 2025-06-12 15:29:32 +03:00
34 changed files with 3863 additions and 10079 deletions
+4 -2
View File
@@ -1,4 +1,6 @@
node_modules/ node_modules/
dist/ dist/
Notes.md dist/itzzexcel.oled-theme.json
/Reference/ dist/itzzexcel.oled-theme.mjs
dist/itzzexcel.oled-theme.mjs.map
dist/store.json
-4
View File
@@ -1,4 +0,0 @@
{
"snyk.advanced.organization": "5e35d31d-0df9-4f3c-b4b3-168a24114801",
"snyk.advanced.autoSelectOrganization": true
}
+13 -32
View File
@@ -4,13 +4,14 @@ A collection of Luna plugins for Tidal, ported from Neptune framework.
## Plugins ## Plugins
### 🎨 Obsidian ### 🎨 OLED Theme
**Location:** `plugins/obsidian-theme-luna/` **Location:** `plugins/oled-theme-luna/`
A dark OLED-friendly theme that transforms Tidal Luna's appearance. A dark OLED-friendly theme plugin that transforms Tidal Luna's appearance.
**Features:** **Features:**
- Applies a dark, OLED-optimized theme - Applies a dark, OLED-optimized theme
- Fetches the latest theme CSS from the GitHub repository
- Reduces battery consumption on OLED displays.. i guess <3 - Reduces battery consumption on OLED displays.. i guess <3
- Modern, sleek dark interface - Modern, sleek dark interface
@@ -33,16 +34,6 @@ Allows users to copy song lyrics by selecting them directly in the interface.
- Automatic clipboard copying of selected lyrics - Automatic clipboard copying of selected lyrics
- Smart lyric span detection - Smart lyric span detection
### 🧽 Element Hider
**Location:** `plugins/element-hider-luna/`
Allows users to hide/remove UI elements by right clicking on them.
**Features:**
- Remove/Hide ANY UI element
- Automagically saves hidden elements
- Allows for elements to be restored
### 🎶 Audio Visualizer ### 🎶 Audio Visualizer
**Location:** `plugins/audio-visualizer-luna/` **Location:** `plugins/audio-visualizer-luna/`
@@ -58,21 +49,8 @@ Allows users to hide/remove UI elements by right clicking on them.
## Installation ## 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 ### Installing from URL
### (They are in the store by default now) 1. Open TidalLuna after Building & Serving
1. Open TidaLuna after Building & Serving
2. Navigate to Luna Settings (Top right of Tidal) 2. Navigate to Luna Settings (Top right of Tidal)
3. Click "Plugin Store" Tab 3. Click "Plugin Store" Tab
4. Paste in the "Install from URL" Bar `https://github.com/meowarex/tidalluna-plugins/releases/download/latest/store.json` 4. Paste in the "Install from URL" Bar `https://github.com/meowarex/tidalluna-plugins/releases/download/latest/store.json`
@@ -85,7 +63,7 @@ Allows users to hide/remove UI elements by right clicking on them.
git clone https://github.com/meowarex/tidalluna-plugins git clone https://github.com/meowarex/tidalluna-plugins
# Change Folder to the Repo # Change Folder to the Repo
cd tidalluna-plugins cd neptune-projects-fork
# Install dependencies # Install dependencies
pnpm install pnpm install
@@ -95,7 +73,7 @@ pnpm run watch
``` ```
### Installing Plugins in TidalLuna ### Installing Plugins in TidalLuna
1. Open TidaLuna after Building & Serving 1. Open TidalLuna after Building & Serving
2. Navigate to Luna Settings (Top right of Tidal) 2. Navigate to Luna Settings (Top right of Tidal)
3. Click "Plugin Store" Tab 3. Click "Plugin Store" Tab
4. Click Install on the Plugins at the top Labeled with "[Dev]" 4. Click Install on the Plugins at the top Labeled with "[Dev]"
@@ -104,7 +82,7 @@ pnpm run watch
## Development ## Development
This project is made for: This project is made for:
- **[TidaLuna](https://github.com/Inrixia/TidaLuna)** - Modern plugin framework for Tidal | Inrixia - **TidalLuna** - Modern plugin framework for Tidal | Inrixia
## GitHub Actions ## GitHub Actions
@@ -112,7 +90,10 @@ This project is made for:
- **Release automation** for distributing plugins - **Release automation** for distributing plugins
- **Artifact uploads** for easy plugin distribution - **Artifact uploads** for easy plugin distribution
## Based On <3
- **itzzexcel** - [GitHub](https://github.com/ItzzExcel)
## Credits ## Credits
Inrixia | [TidalLuna](https://github.com/Inrixia/TidaLuna) Plugin Framework (The successor Neptune) Original Neptune versions by itzzexcel. Ported to Luna framework following the Luna plugin template structure by meowarex with help from Inrixia <3
ItzzExcel | The Original Neptune version of "Radiant Lyrics" (Which was ported to Luna and Rewritten by me!)
-9
View File
@@ -1,9 +0,0 @@
{
"linter": {
"rules": {
"complexity": {
"useArrowFunction": "off"
}
}
}
}
-2356
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -18,8 +18,5 @@
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"tsx": "^4.19.4", "tsx": "^4.19.4",
"typescript": "^5.8.3" "typescript": "^5.8.3"
},
"dependencies": {
"pnpm": "^10.14.0"
} }
} }
+63 -127
View File
@@ -1,34 +1,26 @@
import { ReactiveStore } from "@luna/core"; import { ReactiveStore } from "@luna/core";
import { import { LunaNumberSetting, LunaSettings, LunaSwitchSetting } from "@luna/ui";
LunaSettings,
LunaNumberSetting,
LunaSwitchSetting,
LunaTextSetting,
} from "@luna/ui";
import React from "react"; import React from "react";
const isWindows = navigator.userAgent.includes("Windows"); // tidal changes it in reqs navigator supplies the default electron one
export const settings = await ReactiveStore.getPluginStorage( export const settings = await ReactiveStore.getPluginStorage("AudioVisualizer", {
"AudioVisualizer",
{
barCount: 32, barCount: 32,
barColor: "#ffffff", barColor: "#ffffff",
barRounding: true, barRounding: true,
customColors: [] as string[], customColors: [] as string[],
}, spotifyAPI: isWindows
); });
export const Settings = () => { export const Settings = () => {
const [barCount, setBarCount] = React.useState(settings.barCount); const [barCount, setBarCount] = React.useState(settings.barCount);
const [barColor, setBarColor] = React.useState(settings.barColor); const [barColor, setBarColor] = React.useState(settings.barColor);
const [barRounding, setBarRounding] = React.useState(settings.barRounding); const [barRounding, setBarRounding] = React.useState(settings.barRounding);
const [spotifyAPI, setSpotifyAPI] = React.useState(settings.spotifyAPI);
const [showColorPicker, setShowColorPicker] = React.useState(false); const [showColorPicker, setShowColorPicker] = React.useState(false);
const [isAnimatingIn, setIsAnimatingIn] = React.useState(false); const [isAnimatingIn, setIsAnimatingIn] = React.useState(false);
const [shouldRender, setShouldRender] = React.useState(false); const [shouldRender, setShouldRender] = React.useState(false);
const [customInput, setCustomInput] = React.useState(settings.barColor); const [customInput, setCustomInput] = React.useState(settings.barColor);
const [customColors, setCustomColors] = React.useState(settings.customColors); const [customColors, setCustomColors] = React.useState(settings.customColors);
const [hoveredColorIndex, setHoveredColorIndex] = React.useState< const [hoveredColorIndex, setHoveredColorIndex] = React.useState<number | null>(null);
number | null
>(null);
const closeColorPicker = () => { const closeColorPicker = () => {
setIsAnimatingIn(false); setIsAnimatingIn(false);
@@ -53,25 +45,9 @@ export const Settings = () => {
// Common color presets for cool points :D // Common color presets for cool points :D
const colorPresets = [ const colorPresets = [
"#ffffff", "#ffffff", "#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
"#ff0000", "#ff8800", "#8800ff", "#0088ff", "#88ff00", "#ff0088", "#00ff88",
"#00ff00", "#444444", "#888888", "#cccccc", "#1db954", "#e22134", "#1976d2"
"#0000ff",
"#ffff00",
"#ff00ff",
"#00ffff",
"#ff8800",
"#8800ff",
"#0088ff",
"#88ff00",
"#ff0088",
"#00ff88",
"#444444",
"#888888",
"#cccccc",
"#1db954",
"#e22134",
"#1976d2",
]; ];
const updateColor = (color: string) => { const updateColor = (color: string) => {
@@ -89,11 +65,9 @@ export const Settings = () => {
// Validate hex color format // Validate hex color format
const hexColorRegex = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i; const hexColorRegex = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i;
if ( if (hexColorRegex.test(trimmedInput) &&
hexColorRegex.test(trimmedInput) &&
!colorPresets.includes(trimmedInput) && !colorPresets.includes(trimmedInput) &&
!customColors.includes(trimmedInput) !customColors.includes(trimmedInput)) {
) {
const newCustomColors = [...customColors, trimmedInput]; const newCustomColors = [...customColors, trimmedInput];
setCustomColors(newCustomColors); setCustomColors(newCustomColors);
settings.customColors = newCustomColors; settings.customColors = newCustomColors;
@@ -102,9 +76,7 @@ export const Settings = () => {
}; };
const removeCustomColor = (colorToRemove: string) => { const removeCustomColor = (colorToRemove: string) => {
const newCustomColors = customColors.filter( const newCustomColors = customColors.filter(color => color !== colorToRemove);
(color) => color !== colorToRemove,
);
setCustomColors(newCustomColors); setCustomColors(newCustomColors);
settings.customColors = newCustomColors; settings.customColors = newCustomColors;
@@ -117,11 +89,26 @@ export const Settings = () => {
const allColors = [...colorPresets, ...customColors]; const allColors = [...colorPresets, ...customColors];
return ( return (
<LunaSettings> <LunaSettings> <LunaSwitchSetting
title="Spotify API"
desc="Use Spotify's audio analysis API instead of real-time audio data (Required for Windows)"
// @ts-expect-error no idea why this errosr wth
checked={spotifyAPI}
disabled={isWindows} // Disable on non-Windows platforms
// @ts-expect-error no idea why this errosr wth
onChange={(_, checked) => {
setSpotifyAPI(checked);
settings.spotifyAPI = checked;
(window as any).updateAudioVisualizer?.();
}}
/>
<LunaSwitchSetting <LunaSwitchSetting
title="Bar Roundness" title="Bar Roundness"
desc="Enable rounded corners on visualizer bars" desc="Enable rounded corners on visualizer bars"
// @ts-expect-error no idea why this errosr wth
checked={barRounding} checked={barRounding}
// @ts-expect-error no idea why this errosr wth
onChange={(_, checked) => { onChange={(_, checked) => {
setBarRounding(checked); setBarRounding(checked);
settings.barRounding = checked; settings.barRounding = checked;
@@ -147,40 +134,19 @@ export const Settings = () => {
{/* I'm not sure if this is a good idea, but it works & looks amazing */} {/* I'm not sure if this is a good idea, but it works & looks amazing */}
{/* Sorry @Inrixia <3 */} {/* Sorry @Inrixia <3 */}
<div <div style={{
style={{
padding: "16px 0", padding: "16px 0",
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center"
}} }}>
>
<div> <div>
<div <div style={{ fontWeight: "normal", fontSize: "1.075rem", marginBottom: "4px" }}>Bar Color</div>
style={{ <div style={{ opacity: 0.7, fontSize: "14px" }}>Color of the visualizer bars</div>
fontWeight: "normal",
fontSize: "1.075rem",
marginBottom: "4px",
}}
>
Bar Color
</div> </div>
<div style={{ opacity: 0.7, fontSize: "14px" }}> <div style={{ display: "flex", gap: "8px", alignItems: "center", position: "relative" }}>
Color of the visualizer bars
</div>
</div>
<div
style={{
display: "flex",
gap: "8px",
alignItems: "center",
position: "relative",
}}
>
<button <button
onClick={() => onClick={() => showColorPicker ? closeColorPicker() : openColorPicker()}
showColorPicker ? closeColorPicker() : openColorPicker()
}
style={{ style={{
width: "32px", width: "32px",
height: "32px", height: "32px",
@@ -191,17 +157,15 @@ export const Settings = () => {
backdropFilter: "blur(10px)", backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)", WebkitBackdropFilter: "blur(10px)",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden"
}} }}
> >
<div <div style={{
style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
background: "rgba(0,0,0,0.1)", background: "rgba(0,0,0,0.1)",
backdropFilter: "blur(2px)", backdropFilter: "blur(2px)"
}} }} />
/>
</button> </button>
{/* Custom Color Picker Modal */} {/* Custom Color Picker Modal */}
@@ -218,14 +182,13 @@ export const Settings = () => {
background: "rgba(0,0,0,0.6)", background: "rgba(0,0,0,0.6)",
zIndex: 1000, zIndex: 1000,
opacity: isAnimatingIn ? 1 : 0, opacity: isAnimatingIn ? 1 : 0,
transition: "opacity 0.2s ease", transition: "opacity 0.2s ease"
}} }}
onClick={closeColorPicker} onClick={closeColorPicker}
/> />
{/* Color Picker Panel */} {/* Color Picker Panel */}
<div <div style={{
style={{
position: "fixed", position: "fixed",
top: "50%", top: "50%",
left: "50%", left: "50%",
@@ -241,32 +204,20 @@ export const Settings = () => {
zIndex: 1001, zIndex: 1001,
boxShadow: "0 20px 40px rgba(0,0,0,0.7)", boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
opacity: isAnimatingIn ? 1 : 0, opacity: isAnimatingIn ? 1 : 0,
transform: isAnimatingIn transform: isAnimatingIn ? "translate(-50%, -50%) scale(1)" : "translate(-50%, -50%) scale(0.9)",
? "translate(-50%, -50%) scale(1)" transition: "all 0.2s ease"
: "translate(-50%, -50%) scale(0.9)", }}>
transition: "all 0.2s ease", <div style={{ marginBottom: "12px", color: "#fff", fontWeight: "bold", fontSize: "14px" }}>
}}
>
<div
style={{
marginBottom: "12px",
color: "#fff",
fontWeight: "bold",
fontSize: "14px",
}}
>
Choose Color Choose Color
</div> </div>
{/* Color Grid */} {/* Color Grid */}
<div <div style={{
style={{
display: "grid", display: "grid",
gridTemplateColumns: "repeat(7, 1fr)", gridTemplateColumns: "repeat(7, 1fr)",
gap: "8px", gap: "8px",
marginBottom: "16px", marginBottom: "16px"
}} }}>
>
{allColors.map((color, index) => { {allColors.map((color, index) => {
const isCustomColor = customColors.includes(color); const isCustomColor = customColors.includes(color);
const isHovered = hoveredColorIndex === index; const isHovered = hoveredColorIndex === index;
@@ -277,7 +228,7 @@ export const Settings = () => {
position: "relative", position: "relative",
width: "32px", width: "32px",
height: "32px", height: "32px",
cursor: "pointer", cursor: "pointer"
}} }}
className="color-item" className="color-item"
onMouseEnter={() => setHoveredColorIndex(index)} onMouseEnter={() => setHoveredColorIndex(index)}
@@ -292,13 +243,10 @@ export const Settings = () => {
width: "100%", width: "100%",
height: "100%", height: "100%",
borderRadius: "6px", borderRadius: "6px",
border: border: barColor === color ? "2px solid #fff" : "1px solid rgba(255,255,255,0.2)",
barColor === color
? "2px solid #fff"
: "1px solid rgba(255,255,255,0.2)",
background: color, background: color,
cursor: "pointer", cursor: "pointer",
transition: "all 0.2s ease", transition: "all 0.2s ease"
}} }}
/> />
{isCustomColor && ( {isCustomColor && (
@@ -324,7 +272,7 @@ export const Settings = () => {
justifyContent: "center", justifyContent: "center",
opacity: isHovered ? 1 : 0, opacity: isHovered ? 1 : 0,
transition: "opacity 0.2s ease", transition: "opacity 0.2s ease",
zIndex: 10, zIndex: 10
}} }}
className="remove-button" className="remove-button"
> >
@@ -338,28 +286,16 @@ export const Settings = () => {
{/* Custom Hex Input */} {/* Custom Hex Input */}
<div style={{ marginBottom: "12px" }}> <div style={{ marginBottom: "12px" }}>
<div <div style={{ color: "rgba(255,255,255,0.7)", fontSize: "12px", marginBottom: "6px" }}>
style={{
color: "rgba(255,255,255,0.7)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Add Custom Color Add Custom Color
</div> </div>
<div <div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
style={{
display: "flex",
gap: "8px",
alignItems: "center",
}}
>
<input <input
type="text" type="text"
value={customInput} value={customInput}
onChange={(e) => setCustomInput(e.target.value)} onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === 'Enter') {
updateColor(customInput); updateColor(customInput);
addCustomColor(); addCustomColor();
} }
@@ -374,7 +310,7 @@ export const Settings = () => {
color: "#fff", color: "#fff",
fontSize: "14px", fontSize: "14px",
fontFamily: "monospace", fontFamily: "monospace",
boxSizing: "border-box", boxSizing: "border-box"
}} }}
/> />
<button <button
@@ -394,15 +330,13 @@ export const Settings = () => {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
transition: "all 0.2s ease", transition: "all 0.2s ease"
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = e.currentTarget.style.background = "rgba(255,255,255,0.25)";
"rgba(255,255,255,0.25)";
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = e.currentTarget.style.background = "rgba(255,255,255,0.15)";
"rgba(255,255,255,0.15)";
}} }}
> >
+ +
@@ -421,7 +355,7 @@ export const Settings = () => {
background: "rgba(255,255,255,0.1)", background: "rgba(255,255,255,0.1)",
color: "#fff", color: "#fff",
cursor: "pointer", cursor: "pointer",
fontSize: "12px", fontSize: "12px"
}} }}
> >
Done Done
@@ -431,6 +365,8 @@ export const Settings = () => {
)} )}
</div> </div>
</div> </div>
</LunaSettings> </LunaSettings>
); );
}; };
+476 -200
View File
@@ -1,5 +1,5 @@
import { LunaUnload, Tracer } from "@luna/core"; import { ftch, LunaUnload, Tracer } from "@luna/core";
import { StyleTag, PlayState } from "@luna/lib"; import { PlayState, StyleTag } from "@luna/lib";
import { settings, Settings } from "./Settings"; import { settings, Settings } from "./Settings";
// Import CSS styles for the visualizer // Import CSS styles for the visualizer
@@ -7,24 +7,24 @@ import visualizerStyles from "file://styles.css?minify";
export const { trace } = Tracer("[Audio Visualizer]"); export const { trace } = Tracer("[Audio Visualizer]");
const log = (message: string) => console.log(`[Audio Visualizer] ${message}`); // Helper function for consistent logging
const log = (message: string) => trace.log(message);
const warn = (message: string) => trace.warn(message);
const error = (message: string) => trace.err(message);
export { Settings }; export { Settings };
// Basic config with settings
const config = { const config = {
enabled: true, enabled: true,
position: 'left' as 'left' | 'right',
width: 200, width: 200,
height: 40, height: 40,
get barCount() { get barCount() { return settings.barCount; },
return settings.barCount; get color() { return settings.barColor; },
}, get barRounding() { return settings.barRounding; },
get color() {
return settings.barColor;
},
get barRounding() {
return settings.barRounding;
},
sensitivity: 1.5, sensitivity: 1.5,
smoothing: 0.8, smoothing: 0.8,
visualizerType: 'bars' as 'bars' | 'waveform' | 'circular'
}; };
// Clean up resources // Clean up resources
@@ -33,6 +33,80 @@ export const unloads = new Set<LunaUnload>();
// StyleTag for CSS // StyleTag for CSS
const styleTag = new StyleTag("AudioVisualizer", unloads, visualizerStyles); const styleTag = new StyleTag("AudioVisualizer", unloads, visualizerStyles);
interface SpotifyAudioAnalysis {
meta: {
analyzer_version: string;
platform: string;
detailed_status: string;
status_code: number;
timestamp: number;
analysis_time: number;
input_process: string;
};
track: {
num_samples: number;
duration: number;
sample_md5: string;
offset_seconds: number;
window_seconds: number;
analysis_sample_rate: number;
analysis_channels: number;
end_of_fade_in: number;
start_of_fade_out: number;
loudness: number;
tempo: number;
tempo_confidence: number;
time_signature: number;
time_signature_confidence: number;
key: number;
key_confidence: number;
mode: number;
mode_confidence: number;
codestring: string;
code_version: number;
echoprintstring: string;
echoprint_version: number;
synchstring: string;
synch_version: number;
rhythmstring: string;
rhythm_version: number;
};
bars: Array<{
start: number;
duration: number;
confidence: number;
}>;
beats: Array<{
start: number;
duration: number;
confidence: number;
}>;
sections: Array<{
[key: string]: number;
}>;
segments: Array<{
start: number;
duration: number;
confidence: number;
loudness_start: number;
loudness_max_time: number;
loudness_max: number;
loudness_end: number;
pitches: number[];
timbre: number[];
}>;
tatums: Array<{
start: number;
duration: number;
confidence: number;
}>;
}
let spotifyAudioAnalysis: SpotifyAudioAnalysis | null = null;
let currentTrackId: string | null = null;
let lastSpotifyFetchTime = 0;
const SPOTIFY_FETCH_THROTTLE = 1000;
// Audio context and analyzer // Audio context and analyzer
let audioContext: AudioContext | null = null; let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null; let analyser: AnalyserNode | null = null;
@@ -42,38 +116,73 @@ let animationId: number | null = null;
let currentAudioElement: HTMLAudioElement | null = null; let currentAudioElement: HTMLAudioElement | null = null;
let isSourceConnected: boolean = false; let isSourceConnected: boolean = false;
// Each placement gets its own container/canvas/context let smoothedBars: number[] = [];
interface VisualizerSlot { let previousBars: number[] = [];
container: HTMLDivElement | null; const smoothingFactor = 0.15;
canvas: HTMLCanvasElement | null; // Canvas and container elements
ctx: CanvasRenderingContext2D | null; let visualizerContainer: HTMLDivElement | null = null;
let canvas: HTMLCanvasElement | null = null;
let canvasContext: CanvasRenderingContext2D | null = null;
const fetchSpotifyAudioAnalysis = async (): Promise<void> => {
try {
const trackId = PlayState.playbackContext?.actualProductId;
if (!trackId) {
warn("No track ID available for Spotify API");
return;
}
if (currentTrackId === trackId && spotifyAudioAnalysis) {
log("Using cached Spotify audio analysis");
return;
} }
const navSlot: VisualizerSlot = { container: null, canvas: null, ctx: null }; const now = Date.now();
const npSlot: VisualizerSlot = { container: null, canvas: null, ctx: null }; if (now - lastSpotifyFetchTime < SPOTIFY_FETCH_THROTTLE) {
log("Throttling Spotify API call");
return;
}
lastSpotifyFetchTime = now;
log(`Fetching Spotify audio analysis for track: ${trackId}`);
const data = await ftch.json<{
audioAnalysis: SpotifyAudioAnalysis;
}>(`https://api.vmohammad.dev/lyrics?tidal_id=${trackId}&filter=audioAnalysis`);
if (!data.audioAnalysis) {
warn("No audio analysis data in API response");
return;
}
spotifyAudioAnalysis = data.audioAnalysis;
currentTrackId = trackId;
log("Successfully fetched Spotify audio analysis");
} catch (err) {
error(`Failed to fetch Spotify audio analysis: ${err}`);
spotifyAudioAnalysis = null;
}
};
// Find the audio element - this is a bit of a hack but it works // Find the audio element - this is a bit of a hack but it works
const findAudioElement = (): HTMLAudioElement | null => { const findAudioElement = (): HTMLAudioElement | null => {
// Try main selectors first // Try main selectors first
const selectors = [ const selectors = [
"audio", 'audio',
"video", // 'video',
"audio[data-test]", 'audio[data-test]',
'[data-test="audio-player"] audio', '[data-test="audio-player"] audio'
]; ];
for (const selector of selectors) { for (const selector of selectors) {
const element = document.querySelector(selector) as HTMLAudioElement; const element = document.querySelector(selector) as HTMLAudioElement;
if ( if (element && (element.tagName === 'AUDIO' || element.tagName === 'VIDEO')) {
element &&
(element.tagName === "AUDIO" || element.tagName === "VIDEO")
) {
return element; return element;
} }
} }
// Quick scan for any audio elements // Quick scan for any audio elements
const audioElements = document.querySelectorAll("audio, video"); const audioElements = document.querySelectorAll('audio');
for (const element of audioElements) { for (const element of audioElements) {
const audioEl = element as HTMLAudioElement; const audioEl = element as HTMLAudioElement;
if (audioEl.src || audioEl.currentSrc) { if (audioEl.src || audioEl.currentSrc) {
@@ -87,6 +196,10 @@ const findAudioElement = (): HTMLAudioElement | null => {
// Initialize audio visualization // Initialize audio visualization
const initializeAudioVisualizer = async (): Promise<void> => { const initializeAudioVisualizer = async (): Promise<void> => {
try { try {
if (settings.spotifyAPI) {
await fetchSpotifyAudioAnalysis();
log("Using Spotify API - skipping audio element connection");
} else {
// Find the audio element // Find the audio element
const audioElement = findAudioElement(); const audioElement = findAudioElement();
if (!audioElement) { if (!audioElement) {
@@ -104,7 +217,8 @@ const initializeAudioVisualizer = async (): Promise<void> => {
analyser = audioContext.createAnalyser(); analyser = audioContext.createAnalyser();
analyser.fftSize = 512; // Fixed power of 2 that provides enough frequency bins analyser.fftSize = 512; // Fixed power of 2 that provides enough frequency bins
analyser.smoothingTimeConstant = config.smoothing; analyser.smoothingTimeConstant = config.smoothing;
dataArray = new Uint8Array(analyser.frequencyBinCount); const buffer = new ArrayBuffer(analyser.frequencyBinCount);
dataArray = new Uint8Array(buffer);
log("Created AnalyserNode"); log("Created AnalyserNode");
} }
@@ -122,10 +236,7 @@ const initializeAudioVisualizer = async (): Promise<void> => {
log("Connected to audio stream with output"); log("Connected to audio stream with output");
} catch (error) { } catch (error) {
// Audio is connected elsewhere - that's fine, we just can't visualize // Audio is connected elsewhere - that's fine, we just can't visualize
if ( if (error instanceof Error && error.message.includes('already connected')) {
error instanceof Error &&
error.message.includes("already connected")
) {
log("Audio already connected elsewhere - skipping visualization"); log("Audio already connected elsewhere - skipping visualization");
} }
return; return;
@@ -134,29 +245,55 @@ const initializeAudioVisualizer = async (): Promise<void> => {
// Resume context only if needed and don't wait for it // Resume context only if needed and don't wait for it
// (otherwise it will wait for the audio to start playing) // (otherwise it will wait for the audio to start playing)
if (audioContext.state === "suspended") { if (audioContext.state === 'suspended') {
audioContext.resume().catch(() => { }); // Fire and forget audioContext.resume().catch(() => { }); // Fire and forget
} }
}
// Create UI only if it doesn't exist
if (!visualizerContainer) {
createVisualizerUI(); createVisualizerUI();
}
// Start animation only if not already running // Start animation only if not already running
if (!animationId) { if (!animationId) {
animate(); animate();
} }
} catch (err) { } catch (err) {
// log errors // log errors
console.error(err); console.error(err);
} }
}; };
const makeSlotElements = (): { container: HTMLDivElement; canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null => { // Create the visualizer UI container and canvas
const container = document.createElement("div"); const createVisualizerUI = (): void => {
container.className = "audio-visualizer-container"; // Remove existing visualizer if it exists
container.style.cssText = ` removeVisualizerUI();
if (!config.enabled) return;
// Find the search bar
const searchField = document.querySelector('input[class*="_searchField"]') as HTMLInputElement;
if (!searchField) {
warn("Search field not found");
return;
}
const searchContainer = searchField.parentElement;
if (!searchContainer) {
warn("Search container not found");
return;
}
// Create visualizer container
visualizerContainer = document.createElement('div');
visualizerContainer.id = 'audio-visualizer-container';
visualizerContainer.style.cssText = `
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-${config.position === 'left' ? 'right' : 'left'}: 12px;
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
border-radius: 8px; border-radius: 8px;
padding: 4px; padding: 4px;
@@ -164,101 +301,98 @@ const makeSlotElements = (): { container: HTMLDivElement; canvas: HTMLCanvasElem
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
`; `;
const cvs = document.createElement("canvas"); // Create canvas
cvs.width = config.width; canvas = document.createElement('canvas');
cvs.height = config.height; canvas.width = config.width;
cvs.style.cssText = ` canvas.height = config.height;
canvas.style.cssText = `
width: ${config.width}px; width: ${config.width}px;
height: ${config.height}px; height: ${config.height}px;
border-radius: 4px; border-radius: 4px;
`; `;
container.appendChild(cvs); visualizerContainer.appendChild(canvas);
const ctx = cvs.getContext("2d"); canvasContext = canvas.getContext('2d');
if (!ctx) return null;
return { container, canvas: cvs, ctx }; // Insert visualizer next to search bar
}; if (config.position === 'left') {
searchContainer.parentElement?.insertBefore(visualizerContainer, searchContainer);
const clearSlot = (slot: VisualizerSlot): void => { } else {
slot.container?.remove(); searchContainer.parentElement?.insertBefore(visualizerContainer, searchContainer.nextSibling);
slot.container = null; }
slot.canvas = null;
slot.ctx = null;
};
const ensureNavSlot = (): void => {
if (navSlot.container?.isConnected) return;
clearSlot(navSlot);
const searchField = document.querySelector('input[class*="_searchField"]') as HTMLInputElement;
if (!searchField) return;
const searchContainer = searchField.parentElement;
if (!searchContainer?.parentElement) return;
const els = makeSlotElements();
if (!els) return;
els.container.style.marginRight = "12px";
Object.assign(navSlot, els);
searchContainer.parentElement.insertBefore(els.container, searchContainer);
};
const ensureNpSlot = (): void => {
if (npSlot.container?.isConnected) return;
clearSlot(npSlot);
const artistInfo = document.querySelector('[data-test="artist-info"]');
if (!artistInfo) return;
const leftContent = artistInfo.parentElement;
if (!leftContent) return;
const els = makeSlotElements();
if (!els) return;
els.container.style.marginLeft = "12px";
Object.assign(npSlot, els);
leftContent.insertBefore(els.container, artistInfo.nextSibling);
};
const createVisualizerUI = (): void => {
if (!config.enabled) return;
ensureNavSlot();
ensureNpSlot();
}; };
// Remove visualizer UI
const removeVisualizerUI = (): void => { const removeVisualizerUI = (): void => {
clearSlot(navSlot); if (visualizerContainer) {
clearSlot(npSlot); visualizerContainer.remove();
visualizerContainer = null;
canvas = null;
canvasContext = null;
}
};
const lerp = (start: number, end: number, factor: number): number => {
return start + (end - start) * factor;
};
const initializeSmoothingArrays = (barCount: number): void => {
if (smoothedBars.length !== barCount) {
smoothedBars = new Array(barCount).fill(0);
previousBars = new Array(barCount).fill(0);
}
}; };
// Animation loop for rendering visualizer // Animation loop for rendering visualizer
const animate = (): void => { const animate = (): void => {
// Re-attach slots that got disconnected from the DOM if (!canvasContext || !canvas) {
createVisualizerUI(); animationId = null;
const slots = [navSlot, npSlot].filter(s => s.ctx && s.canvas);
if (slots.length === 0) {
animationId = requestAnimationFrame(animate);
return; return;
} }
// Update canvas color in case it changed
canvasContext.fillStyle = config.color;
canvasContext.strokeStyle = config.color;
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
if (settings.spotifyAPI && spotifyAudioAnalysis) {
switch (config.visualizerType) {
case 'bars':
drawSpotifyBars();
break;
case 'waveform':
// drawWaveform();
break;
case 'circular':
// drawCircular();
break;
}
} else {
// Check if we have real audio data - this might not be needed but its a good idea
let hasRealAudio = false; let hasRealAudio = false;
if (analyser && dataArray) { if (analyser && dataArray) {
analyser.getByteFrequencyData(dataArray); analyser.getByteFrequencyData(dataArray as any);
const avgVolume = // Check if there's actual audio signal (not just silence)
dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length; const avgVolume = dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length;
hasRealAudio = avgVolume > 5; hasRealAudio = avgVolume > 5; // Threshold for detecting actual audio
} }
for (const slot of slots) {
const ctx = slot.ctx!;
const cvs = slot.canvas!;
ctx.fillStyle = config.color;
ctx.strokeStyle = config.color;
ctx.clearRect(0, 0, cvs.width, cvs.height);
if (hasRealAudio && analyser && dataArray) { if (hasRealAudio && analyser && dataArray) {
drawBars(ctx, cvs); // Draw real audio visualization
switch (config.visualizerType) {
case 'bars': // Is implemented YAYYY (default)
drawBars();
break;
case 'waveform': // Not implemented yet
// drawWaveform();
break;
case 'circular': // Not implemented yet
// drawCircular();
break;
}
} else { } else {
drawScrollingWave(ctx, cvs); // Draw cool scrolling wave effect when no audio
drawScrollingWave();
} }
} }
@@ -269,144 +403,271 @@ const animate = (): void => {
let waveTime = 0; let waveTime = 0;
// Helper function to draw rounded rectangles // Helper function to draw rounded rectangles
const drawRoundedRect = ( const drawRoundedRect = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number): void => {
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void => {
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(x, y, width, height, radius); ctx.roundRect(x, y, width, height, radius);
ctx.fill(); ctx.fill();
}; };
const drawScrollingWave = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => { // Draw scrolling wave effect when no audio is detected
waveTime += 0.05 / [navSlot, npSlot].filter(s => s.ctx).length; const drawScrollingWave = (): void => {
if (!canvasContext || !canvas) return;
waveTime += 0.05; // Speed of wave animation
const barCount = config.barCount; const barCount = config.barCount;
const barWidth = cvs.width / barCount; initializeSmoothingArrays(barCount);
const maxHeight = cvs.height * 0.6;
ctx.fillStyle = config.color; const barWidth = canvas.width / barCount;
const maxHeight = canvas.height * 0.6;
canvasContext.fillStyle = config.color;
for (let i = 0; i < barCount; i++) { for (let i = 0; i < barCount; i++) {
// Create a sine wave that scrolls back and forth
const x = i / barCount; const x = i / barCount;
const wave1 = Math.sin(x * Math.PI * 2 + waveTime) * 0.3; const wave1 = Math.sin(x * Math.PI * 2 + waveTime) * 0.3;
const wave2 = Math.sin(x * Math.PI * 4 + waveTime * 1.3) * 0.2; const wave2 = Math.sin(x * Math.PI * 4 + waveTime * 1.3) * 0.2;
const wave3 = Math.sin(x * Math.PI * 6 + waveTime * 0.7) * 0.1; const wave3 = Math.sin(x * Math.PI * 6 + waveTime * 0.7) * 0.1;
const combinedWave = (wave1 + wave2 + wave3 + 1) / 2;
// Combine waves for complex pattern
const combinedWave = (wave1 + wave2 + wave3 + 1) / 2; // Normalize to 0-1
// Add a traveling wave effect
const travelWave = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5; const travelWave = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5;
const barHeight = maxHeight * combinedWave * travelWave * 0.8 + 2;
// Final height calculation
const targetHeight = maxHeight * combinedWave * travelWave * 0.8 + 2; // Minimum height of 2px
smoothedBars[i] = lerp(smoothedBars[i], targetHeight, smoothingFactor * 2); // Faster smoothing for wave effect
const xPos = i * barWidth; const xPos = i * barWidth;
const yPos = (cvs.height - barHeight) / 2; const yPos = (canvas.height - smoothedBars[i]) / 2;
// Draw rounded or square bars based on setting
if (config.barRounding) { if (config.barRounding) {
drawRoundedRect(ctx, xPos, yPos, barWidth - 1, barHeight, 2); drawRoundedRect(canvasContext, xPos, yPos, barWidth - 1, smoothedBars[i], 2);
} else { } else {
ctx.fillRect(xPos, yPos, barWidth - 1, barHeight); canvasContext.fillRect(xPos, yPos, barWidth - 1, smoothedBars[i]);
} }
} }
}; };
const drawBars = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => { // Draw frequency bars - default
if (!dataArray) return; const drawBars = (): void => {
if (!canvasContext || !dataArray || !canvas) return;
const barWidth = cvs.width / config.barCount; const barCount = config.barCount;
const heightScale = cvs.height / 255; initializeSmoothingArrays(barCount);
ctx.fillStyle = config.color; const barWidth = canvas.width / barCount;
const heightScale = canvas.height / 255;
for (let i = 0; i < config.barCount; i++) { canvasContext.fillStyle = config.color;
const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
const barHeight = dataArray[dataIndex] * config.sensitivity * heightScale; for (let i = 0; i < barCount; i++) {
const dataIndex = Math.floor(i * (dataArray.length / barCount));
const targetHeight = (dataArray[dataIndex] * config.sensitivity * heightScale);
smoothedBars[i] = lerp(smoothedBars[i], targetHeight, smoothingFactor);
const x = i * barWidth; const x = i * barWidth;
const y = cvs.height - barHeight; const y = canvas.height - smoothedBars[i];
// Draw rounded or square bars based on setting
if (config.barRounding) { if (config.barRounding) {
drawRoundedRect(ctx, x, y, barWidth - 1, barHeight, 2); drawRoundedRect(canvasContext, x, y, barWidth - 1, smoothedBars[i], 2);
} else { } else {
ctx.fillRect(x, y, barWidth - 1, barHeight); canvasContext.fillRect(x, y, barWidth - 1, smoothedBars[i]);
} }
} }
}; };
// Draw waveform visualization - NOT IMPLEMENTED YET let currentTime = 0;
// const drawWaveform = (): void => { let previousTime = 0;
// if (!canvasContext || !dataArray || !canvas) return; let lastUpdated = 0;
const drawSpotifyBars = (): void => {
if (!canvasContext || !canvas || !spotifyAudioAnalysis) return;
// const centerY = canvas.height / 2; const audioElement = findAudioElement();
// const amplitudeScale = canvas.height / 512; if (audioElement && audioElement.currentTime) {
currentTime = audioElement.currentTime;
previousTime = -1;
} else {
const progressBar = document.querySelector('[data-test="progress-bar"]') as HTMLElement;
if (progressBar) {
const ariaValueNow = progressBar.getAttribute('aria-valuenow');
if (ariaValueNow !== null) {
const progressTime = Number.parseInt(ariaValueNow);
const now = Date.now();
// canvasContext.strokeStyle = config.color; if (progressTime !== previousTime) {
// canvasContext.lineWidth = 2; currentTime = progressTime;
// canvasContext.beginPath(); previousTime = progressTime;
lastUpdated = now;
} else if (PlayState.playing) {
const elapsedSeconds = (now - lastUpdated) / 1000;
currentTime = progressTime + elapsedSeconds;
}
} else {
warn("Progress bar not found or aria-valuenow is null");
return;
}
}
}
// for (let i = 0; i < config.barCount; i++) { if (currentTime < 0) return;
// const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
// const amplitude = (dataArray[dataIndex] - 128) * config.sensitivity * amplitudeScale;
// const x = (i / config.barCount) * canvas.width; const barCount = config.barCount;
// const y = centerY + amplitude; initializeSmoothingArrays(barCount);
// if (i === 0) { const barWidth = canvas.width / barCount;
// canvasContext.moveTo(x, y); canvasContext.fillStyle = config.color;
// } else {
// canvasContext.lineTo(x, y);
// }
// }
// canvasContext.stroke(); const segments = spotifyAudioAnalysis.segments;
// }; const beats = spotifyAudioAnalysis.beats;
// Draw circular visualization - NOT IMPLEMENTED YET if (!segments || segments.length === 0) {
// const drawCircular = (): void => { warn("No segments data available in Spotify audio analysis");
// if (!canvasContext || !dataArray || !canvas) return; return;
}
// const centerX = canvas.width / 2; let currentSegmentIndex = segments.findIndex(segment =>
// const centerY = canvas.height / 2; currentTime >= segment.start && currentTime < (segment.start + segment.duration)
// const radius = Math.min(centerX, centerY) - 10; );
// canvasContext.strokeStyle = config.color; if (currentSegmentIndex === -1) {
// canvasContext.lineWidth = 2; currentSegmentIndex = segments.reduce((closestIndex, segment, index) => {
const closestDiff = Math.abs(segments[closestIndex].start - currentTime);
const segmentDiff = Math.abs(segment.start - currentTime);
return segmentDiff < closestDiff ? index : closestIndex;
}, 0);
}
// for (let i = 0; i < config.barCount; i++) { const currentSegment = segments[currentSegmentIndex];
// const dataIndex = Math.floor(i * (dataArray.length / config.barCount)); if (!currentSegment) return;
// const amplitude = (dataArray[dataIndex] * config.sensitivity) / 255;
// const angle = (i / config.barCount) * Math.PI * 2; const nextSegment = segments[currentSegmentIndex + 1];
// const startX = centerX + Math.cos(angle) * radius * 0.7;
// const startY = centerY + Math.sin(angle) * radius * 0.7;
// const endX = centerX + Math.cos(angle) * radius * (0.7 + amplitude * 0.3);
// const endY = centerY + Math.sin(angle) * radius * (0.7 + amplitude * 0.3);
// canvasContext.beginPath(); const segmentProgress = (currentTime - currentSegment.start) / currentSegment.duration;
// canvasContext.moveTo(startX, startY); const interpolationFactor = Math.max(0, Math.min(1, segmentProgress));
// canvasContext.lineTo(endX, endY);
// canvasContext.stroke();
// }
// };
const currentBeat = beats?.find(beat =>
currentTime >= beat.start && currentTime < (beat.start + beat.duration)
);
let beatIntensity = 1.0;
if (currentBeat) {
const beatProgress = (currentTime - currentBeat.start) / currentBeat.duration;
beatIntensity = 1.0 + (1.0 - beatProgress) * currentBeat.confidence;
}
const getInterpolatedValue = (currentValue: number, nextValue?: number): number => {
if (!nextValue || !nextSegment) return currentValue;
return lerp(currentValue, nextValue, interpolationFactor * 0.3); // Gentle interpolation
};
const currentLoudness = currentSegment.loudness_max;
const nextLoudness = nextSegment?.loudness_max ?? currentLoudness;
const interpolatedLoudness = getInterpolatedValue(currentLoudness, nextLoudness);
const loudnessMultiplier = Math.max(0.3, Math.min(1.2, (interpolatedLoudness + 80) / 80));
for (let i = 0; i < barCount; i++) {
let targetHeight = 0;
const pitches = currentSegment.pitches;
const timbre = currentSegment.timbre;
const nextPitches = nextSegment?.pitches;
const nextTimbre = nextSegment?.timbre;
if (i < 12 && pitches.length >= 12) {
const currentPitch = pitches[i];
const nextPitch = nextPitches?.[i];
const interpolatedPitch = getInterpolatedValue(currentPitch, nextPitch);
targetHeight = Math.pow(interpolatedPitch, 1.2) * canvas.height * config.sensitivity * 0.6;
} else if (i < 24 && timbre.length >= 12) {
const timbreIndex = (i - 12) % timbre.length;
const currentTimbreValue = timbre[timbreIndex];
const nextTimbreValue = nextTimbre?.[timbreIndex];
const interpolatedTimbre = getInterpolatedValue(currentTimbreValue, nextTimbreValue);
const normalizedTimbre = Math.max(0, Math.min(1, (interpolatedTimbre + 200) / 400));
targetHeight = Math.pow(normalizedTimbre, 1.0) * canvas.height * config.sensitivity * 0.4;
} else {
const harmonicIndex = i % 12;
const harmonicMultiplier = Math.max(0.2, 1.0 - (Math.floor(i / 12) * 0.3));
const basePitch = pitches[harmonicIndex] || 0;
const nextBasePitch = nextPitches?.[harmonicIndex];
const interpolatedPitch = getInterpolatedValue(basePitch, nextBasePitch);
targetHeight = Math.pow(interpolatedPitch, 1.3) * canvas.height * config.sensitivity * harmonicMultiplier * 0.5;
}
targetHeight *= loudnessMultiplier * beatIntensity;
const frequencyVariance = 1.0 + Math.sin((i / barCount) * Math.PI * 2 + currentTime * 0.5) * 0.05;
targetHeight *= frequencyVariance;
if (targetHeight > canvas.height * 0.6) {
targetHeight = canvas.height * 0.6 + (targetHeight - canvas.height * 0.6) * 0.2;
}
targetHeight = Math.max(2, Math.min(targetHeight, canvas.height));
const changeFactor = Math.abs(targetHeight - (smoothedBars[i] || 0)) / canvas.height;
const adaptiveSmoothingFactor = smoothingFactor * (1 + changeFactor * 0.5);
smoothedBars[i] = lerp(smoothedBars[i] || 0, targetHeight, Math.min(adaptiveSmoothingFactor, 0.3));
const x = i * barWidth;
const y = canvas.height - smoothedBars[i];
if (config.barRounding) {
drawRoundedRect(canvasContext, x, y, barWidth - 1, smoothedBars[i], 2);
} else {
canvasContext.fillRect(x, y, barWidth - 1, smoothedBars[i]);
}
}
};
// Update visualizer settings
const updateAudioVisualizer = (): void => { const updateAudioVisualizer = (): void => {
if (analyser) { if (analyser) {
analyser.fftSize = 512; // use a fixed size that provides enough frequency bins
analyser.fftSize = 512; // Fixed power of 2 - important
analyser.smoothingTimeConstant = config.smoothing; analyser.smoothingTimeConstant = config.smoothing;
dataArray = new Uint8Array(analyser.frequencyBinCount); const buffer = new ArrayBuffer(analyser.frequencyBinCount); // buffer like ahh
dataArray = new Uint8Array(buffer);
} }
for (const slot of [navSlot, npSlot]) { if (canvas) {
if (slot.canvas) { canvas.width = config.width;
slot.canvas.width = config.width; canvas.height = config.height;
slot.canvas.height = config.height; canvas.style.width = `${config.width}px`;
slot.canvas.style.width = `${config.width}px`; canvas.style.height = `${config.height}px`;
slot.canvas.style.height = `${config.height}px`;
}
} }
removeVisualizerUI(); smoothedBars = [];
previousBars = [];
if (settings.spotifyAPI) {
const currentSpotifyTrackId = PlayState.playbackContext?.actualProductId;
if (currentSpotifyTrackId && currentSpotifyTrackId !== currentTrackId) {
log("Spotify API enabled, fetching audio analysis");
fetchSpotifyAudioAnalysis().catch(err => {
error(`Failed to fetch Spotify data: ${err}`);
});
}
} else {
spotifyAudioAnalysis = null;
currentTrackId = null;
log("Spotify API disabled, cleared audio analysis data");
}
// Recreate UI if position changed
createVisualizerUI(); createVisualizerUI();
}; };
@@ -431,24 +692,35 @@ const cleanupAudioVisualizer = (): void => {
const observePlayState = (): void => { const observePlayState = (): void => {
let hasTriedInitialization = false; let hasTriedInitialization = false;
let checkCount = 0; let checkCount = 0;
let lastTrackIdForSpotify: string | null = null;
const checkAndInitialize = () => { const checkAndInitialize = () => {
checkCount++; checkCount++;
if (settings.spotifyAPI) {
const currentSpotifyTrackId = PlayState.playbackContext?.actualProductId;
if (currentSpotifyTrackId && currentSpotifyTrackId !== lastTrackIdForSpotify) {
lastTrackIdForSpotify = currentSpotifyTrackId;
log(`Track changed, fetching Spotify data for: ${currentSpotifyTrackId}`);
fetchSpotifyAudioAnalysis().catch(err => {
error(`Failed to fetch Spotify data: ${err}`);
});
}
}
// Only try to initialize once when music starts playing // Only try to initialize once when music starts playing
if (PlayState.playing && !hasTriedInitialization) { if ((PlayState.playing || settings.spotifyAPI) && !hasTriedInitialization) {
hasTriedInitialization = true; hasTriedInitialization = true;
log("Initializing audio visualizer..."); log("Initializing audio visualizer...");
// Initialize immediately - no delay (after audio starts playing ofc) // Initialize immediately - no delay (after audio starts playing ofc)
initializeAudioVisualizer().then(() => { initializeAudioVisualizer().then(() => {
if (audioContext && analyser) { if (settings.spotifyAPI || (audioContext && analyser)) {
log("Audio visualizer ready!"); log("Audio visualizer ready!");
} else { } else {
hasTriedInitialization = false; // Allow retry if failed hasTriedInitialization = false; // Allow retry if failed
} }
}); });
} else if (!PlayState.playing && hasTriedInitialization) { } else if (!PlayState.playing && !settings.spotifyAPI && hasTriedInitialization) {
// Reset try flag when music stops so it can try again next time (otherwise it explode) // Reset try flag when music stops so it can try again next time (otherwise it explode)
hasTriedInitialization = false; hasTriedInitialization = false;
} }
@@ -462,8 +734,7 @@ const observePlayState = (): void => {
// Start with fast checking, then slow down // Start with fast checking, then slow down
const fastInterval = setInterval(() => { const fastInterval = setInterval(() => {
checkAndInitialize(); checkAndInitialize();
if (checkCount > 10) { if (checkCount > 10) { // After 10 quick checks, switch to slower
// After 10 quick checks, switch to slower
clearInterval(fastInterval); clearInterval(fastInterval);
const slowInterval = setInterval(checkAndInitialize, 2000); const slowInterval = setInterval(checkAndInitialize, 2000);
unloads.add(() => clearInterval(slowInterval)); unloads.add(() => clearInterval(slowInterval));
@@ -514,7 +785,7 @@ const completeCleanup = (): void => {
} }
// Close audio context completely on plugin unload // Close audio context completely on plugin unload
if (audioContext && audioContext.state !== "closed") { if (audioContext && audioContext.state !== 'closed') {
audioContext.close(); audioContext.close();
log("Closed AudioContext"); log("Closed AudioContext");
} }
@@ -526,6 +797,11 @@ const completeCleanup = (): void => {
dataArray = null; dataArray = null;
currentAudioElement = null; currentAudioElement = null;
isSourceConnected = false; isSourceConnected = false;
smoothedBars = [];
previousBars = [];
spotifyAudioAnalysis = null;
currentTrackId = null;
log("Cleaned up Spotify API data");
}; };
// Register cleanup // Register cleanup
+17 -11
View File
@@ -1,40 +1,46 @@
/* Audio Visualizer CSS */ /* Audio Visualizer CSS - Only applies to the Visualizer */
.audio-visualizer-container { #audio-visualizer-container {
transition: all 0.3s ease-in-out; transition: all 0.3s ease-in-out;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
animation: av-fadeIn 0.5s ease-out;
} }
.audio-visualizer-container:hover { #audio-visualizer-container:hover {
transform: scale(1.02); transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
} }
.audio-visualizer-container canvas { #audio-visualizer-container canvas {
display: block; display: block;
transition: all 0.3s ease-in-out; transition: all 0.3s ease-in-out;
} }
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.audio-visualizer-container { #audio-visualizer-container {
margin: 4px; margin: 4px;
padding: 2px; padding: 2px;
} }
.audio-visualizer-container canvas { #audio-visualizer-container canvas {
max-width: 150px; max-width: 150px;
max-height: 30px; max-height: 30px;
} }
} }
.audio-visualizer-container.active { /* 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); box-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
} }
@keyframes av-fadeIn { /* Fade in animation */
@keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
transform: scale(0.8); transform: scale(0.8);
@@ -45,6 +51,6 @@
} }
} }
[data-type="search-field"] { #audio-visualizer-container {
min-width: 220px !important; animation: fadeIn 0.5s ease-out;
} }
@@ -1,378 +0,0 @@
import { ReactiveStore } from "@luna/core";
import { LunaSettings, LunaSwitchSetting } from "@luna/ui";
import React from "react";
declare global {
interface Window {
applyColoramaLyrics?: () => void;
}
}
type SwitchChangeHandler = (
event: React.ChangeEvent<HTMLInputElement> | null,
checked: boolean,
) => void;
export const settings = await ReactiveStore.getPluginStorage("ColoramaLyrics", {
enabled: true,
singleColor: "#FFFFFF",
singleAlpha: 100,
customColors: [] as string[],
excludeInactive: false,
});
export const Settings = () => {
const [singleColor, setSingleColor] = React.useState(settings.singleColor);
const [singleAlpha, setSingleAlpha] = React.useState<number>(
settings.singleAlpha ?? 100,
);
const [customInput, setCustomInput] = React.useState(settings.singleColor);
const [customColors, setCustomColors] = React.useState(settings.customColors);
const [showPicker, setShowPicker] = React.useState(false);
const [isAnimatingIn, setIsAnimatingIn] = React.useState(false);
const [shouldRender, setShouldRender] = React.useState(false);
const [excludeInactive, setExcludeInactive] = React.useState(
settings.excludeInactive,
);
const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType<{
title: string;
desc?: string;
checked: boolean;
onChange: SwitchChangeHandler;
}>;
const normalizeToRGB = (
hex: string,
fallback: string = "#FFFFFF",
): string => {
let v = hex.trim().toLowerCase();
if (!v.startsWith("#")) v = `#${v}`;
if (/^#([0-9a-f]{3,4})$/.test(v)) {
const m = v.slice(1);
const r = m[0];
const g = m[1];
const b = m[2];
return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
}
if (/^#([0-9a-f]{8})$/.test(v)) {
const rrggbb = v.slice(3);
return `#${rrggbb}`.toUpperCase();
}
if (/^#([0-9a-f]{6})$/.test(v)) return v.toUpperCase();
return fallback;
};
const colorPresets = [
"#FFFFFF",
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#FF00FF",
"#00FFFF",
"#FF8800",
"#8800FF",
"#0088FF",
"#88FF00",
"#FF0088",
"#00FF88",
"#444444",
"#888888",
"#CCCCCC",
"#1DB954",
"#E22134",
"#1976D2",
];
const openPicker = () => {
setShowPicker(true);
setShouldRender(true);
setTimeout(() => setIsAnimatingIn(true), 10);
};
const closePicker = () => {
setIsAnimatingIn(false);
setTimeout(() => {
setShowPicker(false);
setShouldRender(false);
}, 200);
};
const hexColorRegex = /^#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})$/i;
const applyCustomInputColor = (raw: string, updateInput: boolean): void => {
const trimmed = raw.trim();
if (!hexColorRegex.test(trimmed)) return;
const next = normalizeToRGB(trimmed);
settings.singleColor = next;
setSingleColor(next);
if (updateInput) setCustomInput(next);
requestApply();
};
const addCustomColor = () => {
const trimmed = customInput.trim();
if (
hexColorRegex.test(trimmed) &&
!colorPresets.includes(trimmed) &&
!customColors.includes(normalizeToRGB(trimmed))
) {
const updated = [...customColors, normalizeToRGB(trimmed)];
setCustomColors(updated);
settings.customColors = updated;
}
};
const allColors = [...colorPresets, ...customColors];
const requestApply = () => {
window.applyColoramaLyrics?.();
};
return (
<LunaSettings>
{/* Single color picker button */}
<div
style={{
padding: "8px 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{
fontWeight: "normal",
fontSize: "1.075rem",
marginBottom: 4,
}}
>
Lyrics Color
</div>
<div style={{ opacity: 0.7, fontSize: 14 }}>Set lyrics color</div>
</div>
<div
style={{
display: "flex",
gap: 8,
alignItems: "center",
position: "relative",
}}
>
<button
type="button"
onClick={() => (showPicker ? closePicker() : openPicker())}
style={{
width: 32,
height: 32,
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: 6,
cursor: "pointer",
background: normalizeToRGB(singleColor),
}}
/>
</div>
</div>
{/* Color picker modal */}
{shouldRender && (
<>
<button
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.6)",
zIndex: 1000,
opacity: isAnimatingIn ? 1 : 0,
transition: "opacity 0.2s ease",
}}
type="button"
aria-label="Close color picker"
onClick={closePicker}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "Escape") closePicker();
}}
/>
<div
style={{
position: "fixed",
top: "50%",
left: "50%",
background: "rgba(20,20,20,0.98)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: 16,
padding: 20,
minWidth: 320,
maxWidth: "90vw",
maxHeight: "90vh",
zIndex: 1001,
boxShadow: "0 20px 40px rgba(0,0,0,0.7)",
opacity: isAnimatingIn ? 1 : 0,
transform: isAnimatingIn
? "translate(-50%, -50%) scale(1)"
: "translate(-50%, -50%) scale(0.9)",
transition: "all 0.2s ease",
}}
>
<div
style={{
marginBottom: 12,
color: "#fff",
fontWeight: "bold",
fontSize: 14,
}}
>
Lyrics Color
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 8,
marginBottom: 16,
}}
>
{allColors.map((color) => (
<button
key={color}
type="button"
onClick={() => {
const next = normalizeToRGB(color);
settings.singleColor = next;
setSingleColor(next);
setCustomInput(next);
requestApply();
}}
style={{
width: 32,
height: 32,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: normalizeToRGB(color),
cursor: "pointer",
}}
/>
))}
</div>
<div style={{ marginBottom: 12 }}>
<div
style={{
color: "rgba(255,255,255,0.7)",
fontSize: 12,
marginBottom: 6,
}}
>
Custom Hex (#RRGGBB)
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
type="text"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
applyCustomInputColor(customInput, true);
addCustomColor();
}
}}
placeholder="#RRGGBB"
style={{
flex: 1,
padding: "8px 12px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
fontSize: 14,
fontFamily: "monospace",
boxSizing: "border-box",
}}
/>
<button
onClick={() => {
applyCustomInputColor(customInput, false);
addCustomColor();
}}
style={{
width: 32,
height: 32,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.3)",
background: "rgba(255,255,255,0.15)",
color: "#fff",
cursor: "pointer",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease",
}}
type="button"
>
+
</button>
</div>
</div>
<div style={{ marginBottom: 16 }}>
<div
style={{
color: "rgba(255,255,255,0.8)",
fontSize: 12,
marginBottom: 6,
}}
>
Alpha
</div>
<input
type="range"
min={5}
max={100}
step={1}
value={singleAlpha}
onChange={(e) => {
const value = Number(e.target.value);
settings.singleAlpha = value;
setSingleAlpha(value);
requestApply();
}}
style={{ width: "100%" }}
/>
</div>
<button
onClick={closePicker}
style={{
width: "100%",
padding: 8,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.1)",
color: "#fff",
cursor: "pointer",
fontSize: 12,
}}
type="button"
>
Done
</button>
</div>
</>
)}
<AnySwitch
title="Exclude Inactive"
desc="Apply color only to the currently active lyric line"
checked={excludeInactive}
onChange={(_event: React.ChangeEvent<HTMLInputElement> | null, checked: boolean) => {
settings.excludeInactive = checked;
setExcludeInactive(checked);
requestApply();
}}
/>
</LunaSettings>
);
};
-99
View File
@@ -1,99 +0,0 @@
import { LunaUnload, Tracer } from "@luna/core";
import { StyleTag } from "@luna/lib";
import { settings, Settings } from "./Settings";
import styles from "file://styles.css?minify";
export const { trace } = Tracer("[Colorama Lyrics]");
export { Settings };
export const unloads = new Set<LunaUnload>();
new StyleTag("ColoramaLyrics", unloads, styles);
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
let v = hex.trim();
if (!v.startsWith("#")) v = `#${v}`;
if (/^#([0-9a-fA-F]{3})$/.test(v)) {
const r = parseInt(v[1] + v[1], 16);
const g = parseInt(v[2] + v[2], 16);
const b = parseInt(v[3] + v[3], 16);
return { r, g, b };
}
if (/^#([0-9a-fA-F]{6})$/.test(v)) {
const r = parseInt(v.slice(1, 3), 16);
const g = parseInt(v.slice(3, 5), 16);
const b = parseInt(v.slice(5, 7), 16);
return { r, g, b };
}
if (/^#([0-9a-fA-F]{8})$/.test(v)) {
const r = parseInt(v.slice(3, 5), 16);
const g = parseInt(v.slice(5, 7), 16);
const b = parseInt(v.slice(7, 9), 16);
return { r, g, b };
}
return null;
}
function rgbaFromHexAndAlpha(
hex: string,
alphaPercent: number | undefined,
): string {
const rgb = hexToRgb(hex);
const a = Math.max(0.05, Math.min(100, alphaPercent ?? 100)) / 100;
if (!rgb) return `rgba(255,255,255,${a})`;
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${a})`;
}
function applySingleColor(color: string) {
const alpha = (settings as any).singleAlpha ?? 100;
const rgba = rgbaFromHexAndAlpha(color, alpha);
document.documentElement.style.setProperty("--cl-lyrics-color", rgba);
document.documentElement.style.setProperty("--cl-glow1", rgba);
document.documentElement.style.setProperty("--cl-glow2", rgba);
document.body.classList.add("colorama-single");
}
function applyColoramaLyrics(): void {
if (!settings.enabled) {
document.body.classList.remove("colorama-single");
return;
}
if (settings.excludeInactive) {
document.body.classList.add("colorama-only-active");
} else {
document.body.classList.remove("colorama-only-active");
}
applySingleColor(settings.singleColor);
}
(window as any).applyColoramaLyrics = applyColoramaLyrics;
setTimeout(() => applyColoramaLyrics(), 200);
function hookRadiantUpdates(): void {
const w = window as any;
const wrap = (name: string) => {
const fn = w[name];
if (typeof fn === "function" && !fn.__coloramaPatched) {
const orig = fn.bind(w);
const patched = (...args: unknown[]) => {
const result = orig(...args);
try {
applyColoramaLyrics();
} catch {}
return result;
};
(patched as any).__coloramaPatched = true;
w[name] = patched;
}
};
wrap("updateRadiantLyricsStyles");
wrap("updateRadiantLyricsNowPlayingBackground");
wrap("updateRadiantLyricsGlobalBackground");
wrap("updateRadiantLyricsTextGlow");
}
setTimeout(() => hookRadiantUpdates(), 0);
-117
View File
@@ -1,117 +0,0 @@
/* Variables used by Colorama Lyrics */
:root {
--cl-lyrics-color: #ffffff;
--cl-glow1: #ffffff;
--cl-glow2: #ffffff;
}
/* Apply solid color to lyrics text */
.colorama-single [class*="_lyricsText"] > div > span,
.colorama-single [class*="_lyricsText"] > div > span[data-current="true"],
.colorama-single [class^="_lyricsContainer"] > div > div > span,
.colorama-single
[class^="_lyricsContainer"]
> div
> div
> span[data-current="true"] {
color: var(--cl-lyrics-color) !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
}
/* Color Radiant glow shadows using Colorama colors (respect RL sizes) */
.colorama-single [class*="_lyricsText"] > div > span[data-current="true"],
.colorama-single
[class^="_lyricsContainer"]
> div
> div
> span[data-current="true"] {
text-shadow:
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #ffffff),
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
}
/* Hover: force glow color to match Colorama settings for inactive lines */
.colorama-single [class*="_lyricsText"] > div > span:hover,
.colorama-single [class^="_lyricsContainer"] > div > div > span:hover {
color: var(--cl-lyrics-color) !important;
text-shadow:
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #ffffff),
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
}
/* MARKER: Radiant WBW Lyrics Support */
/* Single color: active wbw words & syllable finished */
.colorama-single .rl-wbw-word.rl-wbw-active,
.colorama-single .rl-wbw-word.rl-syl-finished {
color: var(--cl-lyrics-color) !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
}
/* Single color: glow on active wbw words */
.colorama-single .rl-wbw-word.rl-wbw-active {
text-shadow:
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #ffffff),
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
}
/* Hover: wbw words pick up Colorama colors */
.colorama-single .rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word:hover,
.colorama-single .rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word.rl-wbw-word-hover {
color: var(--cl-lyrics-color) !important;
text-shadow:
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #ffffff),
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #ffffff) !important;
}
/* Only-active: wbw words on inactive lines stay default */
body.colorama-only-active.colorama-single
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word {
color: rgba(128, 128, 128, 0.4) !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
text-shadow: initial !important;
}
/* Only-active: hover on inactive wbw lines keeps default */
body.colorama-only-active.colorama-single
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-word:hover {
color: lightgray !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
text-shadow: initial !important;
}
/* Only color active line mode */
body.colorama-only-active.colorama-single [class*="_lyricsText"]
> div
> span:not([data-current="true"]) {
color: rgba(128, 128, 128, 0.4) !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
text-shadow: initial !important;
}
/* In only-active mode, keep TIDAL defaults even on hover for inactive lines */
body.colorama-only-active.colorama-single [class*="_lyricsText"]
> div
> span:not([data-current="true"]):hover {
color: lightgray !important;
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
-webkit-text-fill-color: initial !important;
text-shadow: initial !important;
}
+20 -45
View File
@@ -1,4 +1,4 @@
import { type LunaUnload, Tracer } from "@luna/core"; import { LunaUnload, Tracer } from "@luna/core";
import { StyleTag } from "@luna/lib"; import { StyleTag } from "@luna/lib";
// Import CSS directly using Luna's file:// syntax - Took me a while to figure out <3 // 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 // clean up resources
export const unloads = new Set<LunaUnload>(); export const unloads = new Set<LunaUnload>();
// Style injection via side effect // StyleTag for lyrics selection styling
new StyleTag("Copy-Lyrics", unloads, unlockSelection); const lyricsStyleTag = new StyleTag("Copy-Lyrics", unloads, unlockSelection);
function SetClipboard(text: string): void { function SetClipboard(text: string): void {
const textarea = document.createElement("textarea"); const textarea = document.createElement("textarea");
@@ -31,50 +31,36 @@ function SetClipboard(text: string): void {
let isSelecting = false; let isSelecting = false;
const onMouseDown = (): void => { const onMouseDown = function (): void {
isSelecting = true; isSelecting = true;
}; };
const onMouseUp = (): void => { const onMouseUp = function (event: MouseEvent): void {
if (isSelecting) { if (isSelecting) {
const selection = window.getSelection(); const selection = window.getSelection();
if (selection?.toString().length > 0) { if (selection && selection.toString().length > 0) {
const selectedSpans: HTMLSpanElement[] = []; const selectedSpans: HTMLSpanElement[] = [];
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
let container: Node | null = range.commonAncestorContainer; let container = range.commonAncestorContainer;
// Normalize container: if it's a text node, use its parent element/node // If the container is NOT an element and a document, adjust it.
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 ( if (
container && container.nodeType !== Node.ELEMENT_NODE &&
container.nodeType === Node.ELEMENT_NODE && container.nodeType !== Node.DOCUMENT_NODE
(container as Element).hasAttribute("data-current")
) { ) {
const text_ = selection.toString().trim(); // Get the parent element if it's a text node
const parentElement = container.parentElement;
if (parentElement && parentElement.hasAttribute("data-current")) {
let text_ = selection.toString().trim();
SetClipboard(text_); SetClipboard(text_);
trace.msg.log("Copied to clipboard!"); trace.msg.log("Copied to clipboard!");
return; 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. // Get all the spans inside the container.
const spans = (container as Element | Document).getElementsByTagName( const spans = (container as Element).getElementsByTagName("span");
"span", for (let span of spans) {
);
for (const span of spans) {
if (selection.containsNode(span, true)) { if (selection.containsNode(span, true)) {
selectedSpans.push(span as HTMLSpanElement); selectedSpans.push(span as HTMLSpanElement);
} }
@@ -87,11 +73,7 @@ const onMouseUp = (): void => {
if (span.hasAttribute("data-current")) { if (span.hasAttribute("data-current")) {
hasCorrectAttribute = true; hasCorrectAttribute = true;
text += span.textContent + "\n"; text += span.textContent + "\n";
if ( if ([...span.classList].some((className) => className.startsWith("endOfStanza--"))) {
[...span.classList].some((className) =>
className.startsWith("endOfStanza--"),
)
) {
text += "\n"; text += "\n";
} }
} }
@@ -109,33 +91,26 @@ const onMouseUp = (): void => {
} }
}; };
const onClickHooked = (event: MouseEvent): boolean | undefined => { const onClickHooked = function (event: MouseEvent): boolean | void {
if (!isSelecting) return; if (!isSelecting) return;
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if ( if (target.tagName.toLowerCase() === "span" && target.hasAttribute("data-current")) {
target.tagName.toLowerCase() === "span" &&
target.hasAttribute("data-current")
) {
// Prevent default behavior and stop event propagation // Prevent default behavior and stop event propagation
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
return false; return false;
} }
return undefined;
}; };
// Add event listener with capture phase to intercept events before they reach other handlers // Add event listener with capture phase to intercept events before they reach other handlers
document.addEventListener("click", onClickHooked, true); document.addEventListener("click", onClickHooked, true);
document.addEventListener("mousedown", onMouseDown); document.addEventListener("mousedown", onMouseDown);
document.addEventListener("mouseup", onMouseUp); document.addEventListener("mouseup", onMouseUp);
// Add cleanup to unloads // Add cleanup to unloads
unloads.add((): void => { unloads.add(() => {
// Remove event listeners // Remove event listeners
document.removeEventListener("click", onClickHooked, true); document.removeEventListener("click", onClickHooked, true);
document.removeEventListener("mousedown", onMouseDown); document.removeEventListener("mousedown", onMouseDown);
+1 -1
View File
@@ -9,7 +9,7 @@ export const settings = await ReactiveStore.getPluginStorage("ElementHider", {
className: string; className: string;
textContent: string; textContent: string;
timestamp: number; timestamp: number;
}>, }>
}); });
export const Settings = () => { export const Settings = () => {
+88 -155
View File
@@ -1,5 +1,5 @@
import { type LunaUnload, Tracer } from "@luna/core"; import { LunaUnload, Tracer } from "@luna/core";
import { StyleTag } from "@luna/lib"; import { StyleTag, ContextMenu } from "@luna/lib";
import { settings, Settings } from "./Settings"; import { settings, Settings } from "./Settings";
// Import CSS directly using Luna's file:// syntax // Import CSS directly using Luna's file:// syntax
@@ -13,8 +13,8 @@ export { Settings };
// Clean up resources // Clean up resources
export const unloads = new Set<LunaUnload>(); export const unloads = new Set<LunaUnload>();
// StyleTag for element hider (side-effect) // StyleTag for element hider
new StyleTag("Element-Hider", unloads, styles); const styleTag = new StyleTag("Element-Hider", unloads, styles);
// State management // State management
let targetElement: HTMLElement | null = null; let targetElement: HTMLElement | null = null;
@@ -32,7 +32,7 @@ function generateElementSelector(element: HTMLElement): string {
} }
// Priority 2: data-test attribute (very specific for Tidal <3) // Priority 2: data-test attribute (very specific for Tidal <3)
const dataTest = element.getAttribute("data-test"); const dataTest = element.getAttribute('data-test');
if (dataTest) { if (dataTest) {
return `[data-test="${dataTest}"]`; return `[data-test="${dataTest}"]`;
} }
@@ -41,43 +41,28 @@ function generateElementSelector(element: HTMLElement): string {
let selector = element.tagName.toLowerCase(); let selector = element.tagName.toLowerCase();
// Get filtered classes (exclude our temporary classes) // Get filtered classes (exclude our temporary classes)
const classes = element.className const classes = element.className ? element.className.trim().split(/\s+/).filter(cls => {
? element.className return cls.length > 0 &&
.trim() !cls.startsWith('element-hider-') &&
.split(/\s+/) cls !== 'element-hider-target' &&
.filter((cls) => { cls !== 'element-hider-hiding' &&
return ( cls !== 'element-hider-hidden';
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 // Only use classes if we have them and they're not generic and dumb
if (classes.length > 0) { if (classes.length > 0) {
// Use ALL classes to be very specific // 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) // Add parent context for extra specificity (for when the element is inside another element)
const parent = element.parentElement; const parent = element.parentElement;
if (parent && parent.tagName !== "BODY" && parent.tagName !== "HTML") { if (parent && parent.tagName !== 'BODY' && parent.tagName !== 'HTML') {
const parentClasses = parent.className const parentClasses = parent.className ? parent.className.trim().split(/\s+/).filter(cls => {
? parent.className return cls.length > 0 && !cls.startsWith('element-hider-');
.trim() }) : [];
.split(/\s+/)
.filter((cls) => {
return cls.length > 0 && !cls.startsWith("element-hider-");
})
: [];
if (parentClasses.length > 0) { if (parentClasses.length > 0) {
const parentSelector = const parentSelector = parent.tagName.toLowerCase() + '.' + parentClasses.slice(0, 2).join('.');
parent.tagName.toLowerCase() +
"." +
parentClasses.slice(0, 2).join(".");
selector = `${parentSelector} > ${selector}`; selector = `${parentSelector} > ${selector}`;
} }
} }
@@ -85,29 +70,19 @@ function generateElementSelector(element: HTMLElement): string {
// If no useful classes, use position-based selector with parent context // If no useful classes, use position-based selector with parent context
const parent = element.parentElement; const parent = element.parentElement;
if (parent) { if (parent) {
const siblings = Array.from(parent.children).filter( const siblings = Array.from(parent.children).filter(child => child.tagName === element.tagName);
(child) => child.tagName === element.tagName,
);
const index = siblings.indexOf(element); const index = siblings.indexOf(element);
if (index >= 0) { if (index >= 0) {
selector += `:nth-of-type(${index + 1})`; selector += `:nth-of-type(${index + 1})`;
// Add parent context // Add parent context
if (parent.tagName !== "BODY" && parent.tagName !== "HTML") { if (parent.tagName !== 'BODY' && parent.tagName !== 'HTML') {
const parentClasses = parent.className const parentClasses = parent.className ? parent.className.trim().split(/\s+/).filter(cls => {
? parent.className return cls.length > 0 && !cls.startsWith('element-hider-');
.trim() }) : [];
.split(/\s+/)
.filter((cls) => {
return cls.length > 0 && !cls.startsWith("element-hider-");
})
: [];
if (parentClasses.length > 0) { if (parentClasses.length > 0) {
const parentSelector = const parentSelector = parent.tagName.toLowerCase() + '.' + parentClasses.slice(0, 2).join('.');
parent.tagName.toLowerCase() +
"." +
parentClasses.slice(0, 2).join(".");
selector = `${parentSelector} > ${selector}`; selector = `${parentSelector} > ${selector}`;
} }
} }
@@ -125,14 +100,14 @@ function saveHiddenElement(element: HTMLElement): void {
const elementInfo = { const elementInfo = {
selector: selector, selector: selector,
tagName: element.tagName, tagName: element.tagName,
className: element.className || "", className: element.className || '',
textContent: element.textContent?.substring(0, 100) || "", textContent: element.textContent?.substring(0, 100) || '',
timestamp: Date.now(), timestamp: Date.now()
}; };
// Check if element is already saved // Check if element is already saved
const existingIndex = settings.hiddenElements.findIndex( const existingIndex = settings.hiddenElements.findIndex(
(stored) => stored.selector === elementInfo.selector, stored => stored.selector === elementInfo.selector
); );
if (existingIndex === -1) { if (existingIndex === -1) {
@@ -144,18 +119,17 @@ function saveHiddenElement(element: HTMLElement): void {
} }
} }
// Remove hidden element from persistent storage (for unhiding) - currently unused // Remove hidden element from persistent storage (for unhiding)
// function removeSavedElement(element: HTMLElement): void { function removeSavedElement(element: HTMLElement): void {
// const selector = generateElementSelector(element); const selector = generateElementSelector(element);
// const index = settings.hiddenElements.findIndex( const index = settings.hiddenElements.findIndex(stored => stored.selector === selector);
// (stored) => stored.selector === selector,
// ); if (index !== -1) {
// if (index !== -1) { settings.hiddenElements.splice(index, 1);
// settings.hiddenElements.splice(index, 1); trace.log(`Permanently removed: ${selector}`);
// trace.log(`Permanently removed: ${selector}`); trace.log(`Remaining stored: ${settings.hiddenElements.length}`);
// trace.log(`Remaining stored: ${settings.hiddenElements.length}`); }
// } }
// }
// Check if an element matches any stored selector (EXACT match only) // Check if an element matches any stored selector (EXACT match only)
function matchesStoredSelector(element: HTMLElement): boolean { function matchesStoredSelector(element: HTMLElement): boolean {
@@ -180,18 +154,14 @@ function hideElementDirectly(element: HTMLElement): void {
element.classList.add("element-hider-hidden"); element.classList.add("element-hider-hidden");
hiddenElements.add(element); hiddenElements.add(element);
hiddenElementsArray.push(element); hiddenElementsArray.push(element);
trace.log( trace.log(`Hidden element: ${element.tagName}${element.className ? '.' + element.className.split(' ')[0] : ''}`);
`Hidden element: ${element.tagName}${element.className ? "." + element.className.split(" ")[0] : ""}`,
);
} }
// Hide the target element with animation // Hide the target element with animation
function hideTargetElement(): void { function hideTargetElement(): void {
if (!targetElement) return; if (!targetElement) return;
trace.log( trace.log(`Hiding with animation: ${targetElement.tagName}${targetElement.className ? '.' + targetElement.className.split(' ')[0] : ''}`);
`Hiding with animation: ${targetElement.tagName}${targetElement.className ? "." + targetElement.className.split(" ")[0] : ""}`,
);
// Add hiding animation class // Add hiding animation class
targetElement.classList.add("element-hider-hiding"); targetElement.classList.add("element-hider-hiding");
@@ -205,10 +175,7 @@ function hideTargetElement(): void {
// Wait for animation to complete, then hide // Wait for animation to complete, then hide
setTimeout(() => { setTimeout(() => {
elementToHide.classList.add("element-hider-hidden"); elementToHide.classList.add("element-hider-hidden");
elementToHide.classList.remove( elementToHide.classList.remove("element-hider-hiding", "element-hider-target");
"element-hider-hiding",
"element-hider-target",
);
hiddenElements.add(elementToHide); hiddenElements.add(elementToHide);
hiddenElementsArray.push(elementToHide); hiddenElementsArray.push(elementToHide);
}, 300); }, 300);
@@ -219,12 +186,10 @@ function hideTargetElement(): void {
// Unhide all elements permanently (remove from storage) // Unhide all elements permanently (remove from storage)
function unhideAllElements(): void { function unhideAllElements(): void {
trace.log( trace.log(`Permanently unhiding ${settings.hiddenElements.length} saved elements`);
`Permanently unhiding ${settings.hiddenElements.length} saved elements`,
);
// Show all currently hidden elements // Show all currently hidden elements
hiddenElementsArray.forEach((element) => { hiddenElementsArray.forEach(element => {
if (document.body.contains(element)) { if (document.body.contains(element)) {
element.classList.remove("element-hider-hidden", "element-hider-hiding"); element.classList.remove("element-hider-hidden", "element-hider-hiding");
} }
@@ -240,9 +205,7 @@ function unhideAllElements(): void {
function processAllElements(): void { function processAllElements(): void {
if (settings.hiddenElements.length === 0) return; if (settings.hiddenElements.length === 0) return;
trace.log( trace.log(`Scanning document for ${settings.hiddenElements.length} stored selectors`);
`Scanning document for ${settings.hiddenElements.length} stored selectors`,
);
let hiddenCount = 0; let hiddenCount = 0;
// Use querySelectorAll for each stored selector with validation // Use querySelectorAll for each stored selector with validation
@@ -254,9 +217,7 @@ function processAllElements(): void {
// Limit to prevent over-hiding (safety check) // Limit to prevent over-hiding (safety check)
if (elements.length > 10) { if (elements.length > 10) {
trace.warn( trace.warn(`Selector too broad (${elements.length} matches), skipping: ${storedElement.selector}`);
`Selector too broad (${elements.length} matches), skipping: ${storedElement.selector}`,
);
return; return;
} }
@@ -265,9 +226,7 @@ function processAllElements(): void {
if (!hiddenElements.has(htmlElement)) { if (!hiddenElements.has(htmlElement)) {
hideElementDirectly(htmlElement); hideElementDirectly(htmlElement);
hiddenCount++; hiddenCount++;
trace.log( trace.log(`Hid element ${elemIndex + 1}/${elements.length} for selector ${index + 1}`);
`Hid element ${elemIndex + 1}/${elements.length} for selector ${index + 1}`,
);
} }
}); });
} catch (error) { } catch (error) {
@@ -282,7 +241,7 @@ function processAllElements(): void {
// Process new elements that are added to the DOM // Process new elements that are added to the DOM
function processNewElements(addedNodes: NodeList): void { function processNewElements(addedNodes: NodeList): void {
addedNodes.forEach((node) => { addedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return; if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as HTMLElement; const element = node as HTMLElement;
@@ -293,8 +252,8 @@ function processNewElements(addedNodes: NodeList): void {
} }
// Check all descendant elements // Check all descendant elements
const descendants = element.querySelectorAll("*"); const descendants = element.querySelectorAll('*');
descendants.forEach((descendant) => { descendants.forEach(descendant => {
if (matchesStoredSelector(descendant as HTMLElement)) { if (matchesStoredSelector(descendant as HTMLElement)) {
hideElementDirectly(descendant as HTMLElement); hideElementDirectly(descendant as HTMLElement);
} }
@@ -308,7 +267,7 @@ function setupElementObserver(): void {
elementObserver = new MutationObserver((mutations) => { elementObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
processNewElements(mutation.addedNodes); processNewElements(mutation.addedNodes);
} }
}); });
@@ -316,22 +275,15 @@ function setupElementObserver(): void {
elementObserver.observe(document.body, { elementObserver.observe(document.body, {
childList: true, childList: true,
subtree: true, subtree: true
}); });
trace.log(`Set up reactive element observer`); trace.log(`Set up reactive element observer`);
} }
// Global functions // Global functions
declare global { (window as any).showAllElementsFromSettings = unhideAllElements;
interface Window { (window as any).debugElementHider = () => {
showAllElementsFromSettings?: () => void;
debugElementHider?: () => void;
}
}
window.showAllElementsFromSettings = unhideAllElements;
window.debugElementHider = () => {
trace.log(`=== Element Hider Debug Info ===`); trace.log(`=== Element Hider Debug Info ===`);
trace.log(`Stored elements: ${settings.hiddenElements.length}`); trace.log(`Stored elements: ${settings.hiddenElements.length}`);
trace.log(`Currently hidden elements: ${hiddenElementsArray.length}`); trace.log(`Currently hidden elements: ${hiddenElementsArray.length}`);
@@ -345,19 +297,19 @@ window.debugElementHider = () => {
// Handle highlighting target element // Handle highlighting target element
function highlightElement(element: HTMLElement): void { function highlightElement(element: HTMLElement): void {
// Remove previous highlights // Remove previous highlights
document.querySelectorAll(".element-hider-target").forEach((el) => { document.querySelectorAll('.element-hider-target').forEach(el => {
el.classList.remove("element-hider-target"); el.classList.remove('element-hider-target');
}); });
// Highlight current element // Highlight current element
element.classList.add("element-hider-target"); element.classList.add('element-hider-target');
targetElement = element; targetElement = element;
} }
// Remove highlight // Remove highlight
function removeHighlight(): void { function removeHighlight(): void {
if (targetElement) { if (targetElement) {
targetElement.classList.remove("element-hider-target"); targetElement.classList.remove('element-hider-target');
targetElement = null; targetElement = null;
} }
} }
@@ -369,17 +321,11 @@ let contextMenuTimeout: number | null = null;
let waitingForBuiltInMenu = false; let waitingForBuiltInMenu = false;
// Listen for right-click events to capture the target for context menu // Listen for right-click events to capture the target for context menu
document.addEventListener( document.addEventListener('contextmenu', (event: MouseEvent) => {
"contextmenu",
(event: MouseEvent) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
// Don't interfere with native context menus on inputs, textareas, etc. // Don't interfere with native context menus on inputs, textareas, etc.
if ( if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
currentContextElement = null; currentContextElement = null;
return; return;
} }
@@ -400,7 +346,8 @@ document.addEventListener(
const eventX = event.clientX; const eventX = event.clientX;
const eventY = event.clientY; const eventY = event.clientY;
// Allow native context menu by default; we'll show our custom menu only if needed // Prevent default immediately if we plan to handle it
event.preventDefault();
// Wait to see if the built-in context menu appears // Wait to see if the built-in context menu appears
contextMenuTimeout = window.setTimeout(() => { contextMenuTimeout = window.setTimeout(() => {
@@ -412,14 +359,10 @@ document.addEventListener(
}, 150); // Wait 150ms for built-in menu }, 150); // Wait 150ms for built-in menu
// Don't prevent default initially - let Luna try to handle the context menu // Don't prevent default initially - let Luna try to handle the context menu
}, }, true);
true,
);
// Listen for clicks to close custom menu // Listen for clicks to close custom menu
document.addEventListener( document.addEventListener('click', (event: MouseEvent) => {
"click",
(event: MouseEvent) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
// If clicking outside our custom menu, close it // If clicking outside our custom menu, close it
@@ -427,12 +370,10 @@ document.addEventListener(
closeCustomMenu(); closeCustomMenu();
removeHighlight(); removeHighlight();
} }
}, }, true);
true,
);
// Handle escape key to close custom menu and remove highlights // 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 (event.key === "Escape") {
if (customMenu) { if (customMenu) {
closeCustomMenu(); closeCustomMenu();
@@ -523,15 +464,8 @@ const contextMenuObserver = new MutationObserver((mutations) => {
const element = node as HTMLElement; const element = node as HTMLElement;
// Look for Tidal's context menu // Look for Tidal's context menu
if ( if (element.matches('[data-test="contextmenu"]') || element.querySelector('[data-test="contextmenu"]')) {
element.matches('[data-test="contextmenu"]') || const contextMenu = element.matches('[data-test="contextmenu"]') ? element : element.querySelector('[data-test="contextmenu"]') as HTMLElement;
element.querySelector('[data-test="contextmenu"]')
) {
const contextMenu = element.matches('[data-test="contextmenu"]')
? element
: (element.querySelector(
'[data-test="contextmenu"]',
) as HTMLElement);
if (contextMenu && currentContextElement && waitingForBuiltInMenu) { if (contextMenu && currentContextElement && waitingForBuiltInMenu) {
// Built-in menu appeared, cancel custom menu timeout // Built-in menu appeared, cancel custom menu timeout
@@ -551,8 +485,8 @@ const contextMenuObserver = new MutationObserver((mutations) => {
// Add our options to the existing context menu // Add our options to the existing context menu
function addElementHiderOptions(contextMenu: HTMLElement): void { function addElementHiderOptions(contextMenu: HTMLElement): void {
// Create hide element button // Create hide element button
const hideButton = document.createElement("button"); const hideButton = document.createElement('button');
hideButton.className = "element-hider-menu-item"; hideButton.className = 'element-hider-menu-item';
hideButton.style.cssText = ` hideButton.style.cssText = `
display: flex; display: flex;
align-items: center; align-items: center;
@@ -569,7 +503,7 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
`; `;
hideButton.innerHTML = `Hide This Element`; hideButton.innerHTML = `Hide This Element`;
hideButton.addEventListener("click", () => { hideButton.addEventListener('click', () => {
if (currentContextElement) { if (currentContextElement) {
targetElement = currentContextElement; targetElement = currentContextElement;
hideTargetElement(); hideTargetElement();
@@ -577,38 +511,37 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
}); });
// Add hover effects for highlighting // Add hover effects for highlighting
hideButton.addEventListener("mouseenter", () => { hideButton.addEventListener('mouseenter', () => {
hideButton.style.background = "var(--wave-color-background-hover, #3a3a3a)"; hideButton.style.background = 'var(--wave-color-background-hover, #3a3a3a)';
if (currentContextElement) { if (currentContextElement) {
highlightElement(currentContextElement); highlightElement(currentContextElement);
} }
}); });
hideButton.addEventListener("mouseleave", () => { hideButton.addEventListener('mouseleave', () => {
hideButton.style.background = "transparent"; hideButton.style.background = 'transparent';
removeHighlight(); removeHighlight();
}); });
// Create unhide all button // Create unhide all button
const unhideAllButton = document.createElement("button"); const unhideAllButton = document.createElement('button');
unhideAllButton.className = "element-hider-menu-item"; unhideAllButton.className = 'element-hider-menu-item';
unhideAllButton.style.cssText = hideButton.style.cssText; unhideAllButton.style.cssText = hideButton.style.cssText;
unhideAllButton.innerHTML = `Unhide All Elements (${hiddenElementsArray.length})`; unhideAllButton.innerHTML = `Unhide All Elements (${hiddenElementsArray.length})`;
unhideAllButton.addEventListener("click", unhideAllElements); unhideAllButton.addEventListener('click', unhideAllElements);
// Add hover effects for unhide all button // Add hover effects for unhide all button
unhideAllButton.addEventListener("mouseenter", () => { unhideAllButton.addEventListener('mouseenter', () => {
unhideAllButton.style.background = unhideAllButton.style.background = 'var(--wave-color-background-hover, #3a3a3a)';
"var(--wave-color-background-hover, #3a3a3a)";
}); });
unhideAllButton.addEventListener("mouseleave", () => { unhideAllButton.addEventListener('mouseleave', () => {
unhideAllButton.style.background = "transparent"; unhideAllButton.style.background = 'transparent';
}); });
// Add a separator if the menu has other items // Add a separator if the menu has other items
if (contextMenu.children.length > 0) { if (contextMenu.children.length > 0) {
const separator = document.createElement("div"); const separator = document.createElement('div');
separator.style.cssText = ` separator.style.cssText = `
height: 1px; height: 1px;
background: var(--wave-color-border, #444); background: var(--wave-color-border, #444);
@@ -625,7 +558,7 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
// Start observing for context menus // Start observing for context menus
contextMenuObserver.observe(document.body, { contextMenuObserver.observe(document.body, {
childList: true, childList: true,
subtree: true, subtree: true
}); });
// Initialize plugin // Initialize plugin
@@ -645,8 +578,8 @@ function initializePlugin() {
} }
// Run initialization when DOM is ready // Run initialization when DOM is ready
if (document.readyState === "loading") { if (document.readyState === 'loading') {
document.addEventListener("DOMContentLoaded", initializePlugin); document.addEventListener('DOMContentLoaded', initializePlugin);
} else { } else {
initializePlugin(); initializePlugin();
} }
@@ -667,8 +600,8 @@ unloads.add(() => {
removeHighlight(); removeHighlight();
// Clean up global functions // Clean up global functions
window.showAllElementsFromSettings = undefined; (window as any).showAllElementsFromSettings = undefined;
window.debugElementHider = undefined; (window as any).debugElementHider = undefined;
trace.log("Plugin unloaded"); trace.log("Plugin unloaded");
}); });
+1 -3
View File
@@ -57,9 +57,7 @@
/* Animation for hiding */ /* Animation for hiding */
.element-hider-hiding { .element-hider-hiding {
transition: transition: opacity 0.3s ease, transform 0.3s ease;
opacity 0.3s ease,
transform 0.3s ease;
opacity: 0; opacity: 0;
transform: scale(0.95); transform: scale(0.95);
} }
@@ -1,6 +1,6 @@
{ {
"name": "@meowarex/colorama-lyrics", "name": "@meowarex/oled-theme",
"description": "Customize lyrics colors: single, gradient & auto from cover art", "description": "A dark OLED-friendly theme plugin that transforms Tidal Luna's appearance.",
"author": { "author": {
"name": "meowarex", "name": "meowarex",
"url": "https://github.com/meowarex", "url": "https://github.com/meowarex",
+59
View File
@@ -0,0 +1,59 @@
import { ReactiveStore } from "@luna/core";
import { LunaSettings, LunaSwitchSetting } from "@luna/ui";
import React from "react";
export const settings = await ReactiveStore.getPluginStorage("OLEDTheme", {
qualityColorMatchedSeekBar: true,
oledFriendlyButtons: true,
lightMode: false,
});
export const Settings = () => {
const [qualityColorMatchedSeekBar, setQualityColorMatchedSeekBar] = React.useState(settings.qualityColorMatchedSeekBar);
const [oledFriendlyButtons, setOledFriendlyButtons] = React.useState(settings.oledFriendlyButtons);
const [lightMode, setLightMode] = React.useState(settings.lightMode);
return (
<LunaSettings>
<LunaSwitchSetting
title="Quality Color Matched Seek Bar"
desc="Color the Seek/Progress Bar based on audio quality"
checked={qualityColorMatchedSeekBar}
onChange={(_, checked) => {
console.log("Quality Color Matched Seek Bar:", checked ? "enabled" : "disabled");
setQualityColorMatchedSeekBar((settings.qualityColorMatchedSeekBar = checked));
// Update styles immediately when setting changes
if ((window as any).updateOLEDThemeStyles) {
(window as any).updateOLEDThemeStyles();
}
}}
/>
<LunaSwitchSetting
title="OLED Friendly Buttons"
desc="Remove button styling from OLED theme to keep buttons with original Tidal appearance"
checked={oledFriendlyButtons}
onChange={(_, checked) => {
console.log("OLED Friendly Buttons:", checked ? "enabled" : "disabled");
setOledFriendlyButtons((settings.oledFriendlyButtons = checked));
// Update styles immediately when setting changes
if ((window as any).updateOLEDThemeStyles) {
(window as any).updateOLEDThemeStyles();
}
}}
/>
<LunaSwitchSetting
title="Light Mode | Experimental"
desc="Use the light theme instead of the dark theme. This is experimental and may not work as expected."
checked={lightMode}
onChange={(_, checked) => {
console.log("Light Mode:", checked ? "enabled" : "disabled");
setLightMode((settings.lightMode = checked));
// Update styles immediately when setting changes
if ((window as any).updateOLEDThemeStyles) {
(window as any).updateOLEDThemeStyles();
}
}}
/>
</LunaSettings>
);
};
+301
View File
@@ -0,0 +1,301 @@
/*
{
"name": "Abyss Neptune",
"author": "@itzzexcel",
"description": "Abyss Neptune: ShadowX Theme from Spicetify to TIDAL (17/Jan/2025)"
}
*/
::-webkit-scrollbar {
display: none;
}
:root {
--wave-color-solid-accent-fill: white;
--wave-color-solid-rainbow-yellow-fill: white;
--wave-color-solid-contrast-fill: white;
--wave-color-solid-base-brighter: black;
--wave-text-body-medium: white !important;
--track-vibrant-color: white !important;
--wave-color-opacity-contrast-fill-ultra-thin: #fffafa1a !important;
--wave-color-solid-rainbow-yellow-darkest: #fffafa1a !important;
--wave-color-solid-accent-dark: rgb(128, 128, 128);
}
/* Credits to https://github.com/surfbryce for the fonts */
@font-face {
font-family: "AbyssFont";
font-weight: 400;
src: url("https://excel.lexploits.top/extra/tidal/LyricsRegular.woff2") format("woff2");
}
@font-face {
font-family: "AbyssFont";
font-weight: 500;
src: url("https://excel.lexploits.top/extra/tidal/LyricsMedium.woff2") format("woff2");
}
@font-face {
font-family: "AbyssFont";
font-weight: 600;
src: url("https://excel.lexploits.top/extra/tidal/LyricsSemibold.woff2") format("woff2");
}
@font-face {
font-family: "AbyssFont";
font-weight: 700;
src: url("https://excel.lexploits.top/extra/tidal/LyricsBold.woff2") format("woff2");
}
[class^="followingButton"],
[title="Unfollow"],
[title="Follow"],
[title="Unfollow"]>span,
[title="Follow"]>span {
background-color: var(--wave-color-solid-rainbow-yellow-fill) !important;
color: var(--wave-color-solid-base-brighter);
}
[class^="_wave-badge-color-max"] {
color: black !important;
background-color: var(--wave-color-solid-accent-fill);
border-radius: 3px;
}
[data-test="main-layout-sidebar-wrapper"] {
border-right: rgb(25, 25, 25) 1px solid;
}
[class^="_wave-badge"] {
background-color: var(--wave-color-solid-accent-fill);
border-radius: 4px;
color: black;
}
[class^="_progressBarWrapper"] {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="_sidebarItem"]>span {
color: var(--wave-color-solid-accent-dark);
}
[data-test="main-layout-header"] {
border-left: 0 !important;
}
[class^="_sidebarItem"]:hover span {
color: var(--wave-color-solid-contrast-fill);
}
[class^="_sidebarItem"] [class^="active"]>span {
color: var(--wave-color-solid-accent-dark) !important;
}
[class^="_active"] {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="ReactVirtualized__Grid"] {
border-radius: 10px;
margin: 5px;
}
[data-test="media-table"]>div>div>div {
border: 1px solid rgb(25, 25, 25) !important;
}
[class^="ReactVirtualized__Grid__innerScrollContainer"] {
border: none;
margin: 5px;
}
[class^="button"]>span {
color: black;
}
[class^="_explicitBadge"] {
color: var(--wave-color-solid-accent-fill);
}
[class^="viewAllButton"] {
border-radius: 4px;
display: grid;
place-items: center;
}
[data-test="current-media-imagery"] {
border: 0 !important;
margin: none;
}
[class^="_imageBorder"] {
display: none;
}
[class^="_headerButtons"]>button,
[class^="_headerButtons"]>button>span,
[data-test="toggle-picture-in-picture"] {
background-color: var(--wave-color-solid-accent-fill) !important;
color: black;
}
[class^="_container"]>[class^="_navigationArrows"] {
color: black;
background-color: var(--wave-color-solid-accent-fill) !important;
border-radius: 4px;
}
[class^="_buttons"]>button>span {
color: black !important;
}
[class^="_container"]>button {
border: 0px none;
}
[data-test="feed-sidebar"] {
margin-top: 10px;
}
[data-test="footer-player"] {
width: calc(100% - 20px);
bottom: 10px;
left: 10px;
border: 1px solid rgb(25, 25, 25);
border-radius: 4px !important;
position: absolute !important;
}
[class^="_tooltipContainer"]>button {
background-color: var(--wave-color-solid-accent-fill);
color: black;
}
[class^="_tooltipContainer"]>button:hover {
background-color: lightgray !important;
}
[class^="_tableRow"]:hover>*,
[data-test-is-playing="true"]>* {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="_tableRow"]>*,
[data-test-is-playing="false"]>* {
color: lightgray !important;
}
[class*="coverColumn"] {
padding-left: 5px !important;
}
[class^="actionList"] {
background-color: transparent;
margin: 0px;
border-radius: 5px;
}
button[data-test="request-fullscreen"],
button[data-test="close-now-playing"],
button[data-test="play-all"],
button[data-test="shuffle-all"] {
color: black;
background-color: var(--wave-color-solid-accent-fill);
border-radius: 12px;
}
button[data-test="request-fullscreen"]:hover,
button[data-test="close-now-playing"]:hover {
color: black;
background-color: lightgray !important;
}
.neptune-switch-checkbox:checked+.neptune-switch {
background-color: rgba(255, 255, 255, 0.1);
}
[data-test="navigation-arrows"]>button {
background-color: var(--wave-color-solid-accent-fill) !important;
color: black !important;
border-radius: 5px;
}
[data-test="navigation-arrows"]>button:disabled {
background-color: lightgray !important;
opacity: 1;
}
[data-test="main-layout-header"],
[data-test="feed-sidebar"],
[data-test="stream-metadata"],
[data-test="footer-player"] {
background-color: rgba(0, 0, 0, 0.8) !important;
backdrop-filter: blur(10px);
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[data-wave-color=textUrl] {
color: var(--wave-color-solid-accent-fill);
}
[class^="_smallHeader"] {
margin-top: 7.5px;
}
[data-test="play-all"]>div>*,
[data-test="shuffle-all"]>div>*,
[data-test="play-all"],
[data-test="shuffle-all"] {
color: var(--wave-color-solid-accent-fill) !important;
background-color: transparent !important;
}
[class^="__NEPTUNE_PAGE"],
[data-test="main"] {
margin-top: 35px;
}
[data-test="button-desktop-release-notes"],
[data-test="button-release-notes"] {
background-color: white;
}
[data-test="button-desktop-release-notes"]:hover,
[data-test="button-release-notes"]:hover {
background-color: lightgray !important;
transition: none !important;
}
#playQueueSidebar {
top: 50px !important;
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin);
margin: 2px;
margin-right: -14px !important;
background-color: rgba(0, 0, 0, 0.8) !important;
backdrop-filter: blur(10px);
}
[class^="_bottomGradient"] {
visibility: hidden;
}
[data-test="settings-page"] {
padding-bottom: 60px !important;
}
[data-test="query-suggestions"],
[data-test="recent-searches-container"] {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
}
[data-test="contextmenu"] {
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[class^="_dataContainer_"]::before {
background-image: var(--img);
filter: blur(10px) brightness(0.4);
}
+128
View File
@@ -0,0 +1,128 @@
import { LunaUnload, Tracer } from "@luna/core";
import { StyleTag, observePromise, PlayState, Quality, type MediaItem } from "@luna/lib";
import { settings, Settings } from "./Settings";
// Import CSS files directly using Luna's file:// syntax - Took me a while to figure out <3
import darkTheme from "file://dark-theme.css?minify";
import oledFriendlyTheme from "file://oled-friendly.css?minify";
import lightTheme from "file://light-theme.css?minify";
export const { trace } = Tracer("[OLED Theme]");
export { Settings };
// called when plugin is unloaded.
// clean up resources
export const unloads = new Set<LunaUnload>();
// StyleTag instance for theme management
const themeStyleTag = new StyleTag("OLED-Theme", unloads);
// Quality color mapping
const QUALITY_COLORS = {
MAX: "#FED330", // Max/HiFi
HIGH: "#31FFEE", // High
LOW: "#FFFFFE" // Low
};
// Function to get quality color based on audio quality
const getQualityColor = (audioQuality: string): string => {
const quality = audioQuality?.toUpperCase();
if (quality?.includes("HI_RES_LOSSLESS")) {
return QUALITY_COLORS.MAX;
} else if (quality?.includes("LOSSLESS")) {
return QUALITY_COLORS.HIGH;
} else {
return QUALITY_COLORS.LOW;
}
};
// Function to Reset Seek Bar Color (if setting gets disabled while playing)
const resetSeekBarColor = async (): Promise<void> => {
try {
const progressBarWrapper = await observePromise<HTMLElement>(unloads, `[class^="_progressBarWrapper"]`);
if (!progressBarWrapper) return;
progressBarWrapper.style.removeProperty('color');
progressBarWrapper.querySelectorAll('[class*="progress"], [class*="bar"]').forEach(el => {
if (el instanceof HTMLElement) el.style.removeProperty('color');
});
} catch (error) {
trace.msg.err(`Failed to reset seek bar color: ${error}`);
}
};
// Function to apply quality-based seek bar coloring (if enabled)
const applyQualityColors = async (): Promise<void> => {
if (!settings.qualityColorMatchedSeekBar) return;
try {
const progressBarWrapper = await observePromise<HTMLElement>(unloads, `[class^="_progressBarWrapper"]`);
if (!progressBarWrapper) return;
const audioQuality = PlayState.playbackContext?.actualAudioQuality;
if (!audioQuality) return;
const qualityColor = getQualityColor(audioQuality);
progressBarWrapper.style.setProperty('color', qualityColor, 'important');
progressBarWrapper.querySelectorAll('[class*="progress"], [class*="bar"]').forEach(el => {
if (el instanceof HTMLElement) el.style.setProperty('color', qualityColor, 'important');
});
//trace.msg.log(`Applied quality color ${qualityColor}`);
} catch (error) {
trace.msg.err(`Failed to apply quality colors: ${error}`);
}
};
// Function to monitor track changes using track ID
const setupQualityMonitoring = (): void => {
let lastTrackId: string | null = null;
const interval = setInterval(() => {
if (!settings.qualityColorMatchedSeekBar) return;
const currentTrackId = PlayState.playbackContext?.actualProductId;
if (currentTrackId && currentTrackId !== lastTrackId) {
//trace.msg.log(`[OLED Theme] Track ID changed: ${lastTrackId} -> ${currentTrackId}`);
lastTrackId = currentTrackId;
applyQualityColors();
}
}, 250);
unloads.add(() => clearInterval(interval));
// Initial color application (if a track is already loaded)
const currentTrackId = PlayState.playbackContext?.actualProductId;
if (settings.qualityColorMatchedSeekBar && currentTrackId) {
lastTrackId = currentTrackId;
applyQualityColors();
}
};
// Function to apply theme styles based on current settings
const applyThemeStyles = function(): void {
// Choose the appropriate CSS file based on settings
let selectedStyle: string;
if (settings.lightMode) {
// Light mode - (OLED friendly doesn't apply to light theme)
selectedStyle = lightTheme;
} else {
// Dark mode
selectedStyle = settings.oledFriendlyButtons ? oledFriendlyTheme : darkTheme;
}
// Remove SeekBar coloring if Quality Color Matched Seek Bar is enabled
// This allows our manual coloring to take precedence
if (settings.qualityColorMatchedSeekBar) {
selectedStyle = selectedStyle.replace(/\[class\^="_progressBarWrapper"\]\s*\{[^}]*\}/g, '');
setupQualityMonitoring();
} else {
// If disabling, reset the seek bar color
resetSeekBarColor();
}
// Apply the selected theme using StyleTag
themeStyleTag.css = selectedStyle;
};
// Make this function available globally so Settings can call it
(window as any).updateOLEDThemeStyles = applyThemeStyles;
// Apply the OLED theme initially
applyThemeStyles();
+424
View File
@@ -0,0 +1,424 @@
/*
{
"name": "Abyss Neptune - Light",
"author": "@itzzexcel",
"description": "Abyss Neptune Light Theme for TIDAL (17/Jan/2025)"
}
*/
::-webkit-scrollbar {
display: none;
}
:root {
--wave-color-solid-accent-fill: #666666;
--wave-color-solid-rainbow-yellow-fill: #666666;
--wave-color-solid-contrast-fill: #666666;
--wave-color-solid-base-brighter: #666666;
--wave-text-body-medium: #333333 !important;
--track-vibrant-color: #666666 !important;
--wave-color-opacity-contrast-fill-ultra-thin: #c0c0c0 !important;
--wave-color-solid-rainbow-yellow-darkest: #c0c0c0 !important;
--wave-color-solid-accent-dark: #555555;
}
/* Credits to https://github.com/surfbryce for the fonts */
@font-face {
font-family: "AbyssFont";
font-weight: 400;
src: url("https://excel.lexploits.top/extra/tidal/LyricsRegular.woff2") format("woff2");
}
@font-face {
font-family: "AbyssFont";
font-weight: 500;
src: url("https://excel.lexploits.top/extra/tidal/LyricsMedium.woff2") format("woff2");
}
@font-face {
font-family: "AbyssFont";
font-weight: 600;
src: url("https://excel.lexploits.top/extra/tidal/LyricsSemibold.woff2") format("woff2");
}
@font-face {
font-family: "AbyssFont";
font-weight: 700;
src: url("https://excel.lexploits.top/extra/tidal/LyricsBold.woff2") format("woff2");
}
[class^="followingButton"],
[title="Unfollow"],
[title="Follow"],
[title="Unfollow"]>span,
[title="Follow"]>span {
background-color: var(--wave-color-solid-rainbow-yellow-fill) !important;
color: var(--wave-color-solid-base-brighter);
}
[class^="_wave-badge-color-max"] {
color: #333333 !important;
background-color: var(--wave-color-solid-accent-fill);
border-radius: 3px;
}
[data-test="main-layout-sidebar-wrapper"] {
border-right: rgb(230, 230, 230) 1px solid;
background-color: rgba(250, 250, 250, 0.95) !important;
}
[class^="_wave-badge"] {
background-color: var(--wave-color-solid-accent-fill);
border-radius: 4px;
color: #333333;
}
[class^="_progressBarWrapper"] {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="_sidebarItem"]>span {
color: #666666;
}
[data-test="main-layout-header"] {
border-left: 0 !important;
}
[class^="_sidebarItem"]:hover span {
color: #333333;
}
[class^="_sidebarItem"] [class^="active"]>span {
color: #333333 !important;
}
/* Sidebar icons and text - ensure grey colors */
[data-test="main-layout-sidebar-wrapper"] svg,
[data-test="main-layout-sidebar-wrapper"] path,
[class^="_sidebarItem"] svg,
[class^="_sidebarItem"] path {
fill: #666666 !important;
color: #666666 !important;
}
[data-test="main-layout-sidebar-wrapper"] span,
[class^="_sidebarItem"] span {
color: #666666 !important;
}
[class^="_active"] {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="ReactVirtualized__Grid"] {
border-radius: 10px;
margin: 5px;
}
[data-test="media-table"]>div>div>div {
border: 1px solid rgb(230, 230, 230) !important;
}
[class^="ReactVirtualized__Grid__innerScrollContainer"] {
border: none;
margin: 5px;
}
[class^="button"]>span {
color: #333333;
}
[class^="_explicitBadge"] {
color: var(--wave-color-solid-accent-fill);
}
[class^="viewAllButton"] {
border-radius: 4px;
display: grid;
place-items: center;
}
[data-test="current-media-imagery"] {
border: 0 !important;
margin: none;
}
[class^="_imageBorder"] {
display: none;
}
[class^="_headerButtons"]>button,
[class^="_headerButtons"]>button>span,
[data-test="toggle-picture-in-picture"] {
background-color: var(--wave-color-solid-accent-fill) !important;
color: #333333;
}
[class^="_container"]>[class^="_navigationArrows"] {
color: #333333;
background-color: var(--wave-color-solid-accent-fill) !important;
border-radius: 4px;
}
[class^="_buttons"]>button>span {
color: #333333 !important;
}
[class^="_container"]>button {
border: 0px none;
}
[data-test="feed-sidebar"] {
margin-top: 10px;
}
[data-test="footer-player"] {
width: calc(100% - 20px);
bottom: 10px;
left: 10px;
border: 1px solid rgba(200, 200, 200, 0.7);
border-radius: 4px !important;
position: absolute !important;
}
[class^="_tooltipContainer"]>button {
background-color: var(--wave-color-solid-accent-fill);
color: #333333;
}
[class^="_tooltipContainer"]>button:hover {
background-color: #555555 !important;
}
[class^="_tableRow"]:hover>*,
[data-test-is-playing="true"]>* {
color: #333333 !important;
}
[class^="_tableRow"]>*,
[data-test-is-playing="false"]>* {
color: #333333 !important;
}
/* Track list text - ensure all text is dark */
[data-test="media-table"] *,
[class^="_trackTitle"],
[class^="_artistName"],
[class^="_albumTitle"],
[class^="_tableCell"] *,
[class^="_tableCellContent"] * {
color: #333333 !important;
}
[class*="coverColumn"] {
padding-left: 5px !important;
}
[class^="actionList"] {
background-color: transparent;
margin: 0px;
border-radius: 5px;
}
button[data-test="request-fullscreen"],
button[data-test="close-now-playing"],
button[data-test="play-all"],
button[data-test="shuffle-all"] {
color: #333333;
background-color: var(--wave-color-solid-accent-fill);
border-radius: 12px;
}
button[data-test="request-fullscreen"]:hover,
button[data-test="close-now-playing"]:hover {
color: #333333;
background-color: #aaaaaa !important;
}
.neptune-switch-checkbox:checked+.neptune-switch {
background-color: rgba(0, 0, 0, 0.1);
}
[data-test="navigation-arrows"]>button {
background-color: var(--wave-color-solid-accent-fill) !important;
color: #333333 !important;
border-radius: 5px;
}
[data-test="navigation-arrows"]>button:disabled {
background-color: #cccccc !important;
opacity: 1;
}
[data-test="main-layout-header"] {
background-color: rgba(235, 235, 235, 0.95) !important;
backdrop-filter: blur(10px);
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[data-test="feed-sidebar"] {
background-color: rgba(225, 225, 225, 0.9) !important;
backdrop-filter: blur(10px);
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[data-test="stream-metadata"] {
background-color: rgba(230, 230, 230, 0.92) !important;
backdrop-filter: blur(10px);
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[data-test="footer-player"] {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(15px);
border: 1px solid rgba(200, 200, 200, 0.7) !important;
}
[data-wave-color=textUrl] {
color: var(--wave-color-solid-accent-fill);
}
[class^="_smallHeader"] {
margin-top: 7.5px;
}
/* Button styling using proper light theme approach */
:root {
--button-light: #d9d9d9 !important;
--button-medium: #cbcbcb !important;
}
/*buttons*/
._activeTab_f47dafa {
background: #0000001c;
}
/*canvas nav buttons*/
.viewAllButton--Nb87U,
.css-7l8ggf {
background: #e0e0e0;
}
.viewAllButton--Nb87U:hover,
.css-7l8ggf:hover {
background: #cbcbcb;
}
/*tracks page*/
.variantPrimary--pjymy,
._button_3357ce6 {
background-color: var(--button-light);
}
._button_f1c7fcb {
background: var(--wave-color-solid-base-brighter);
}
._button_84b8ffe {
background-color: var(--wave-color-solid-base-brighter);
}
._button_84b8ffe:hover {
background-color: var(--wave-color-solid-base-brightest);
}
.button--_0I_t {
background-color: var(--button-light);
}
.button--_0I_t:hover {
background-color: var(--wave-color-opacity-contrast-fill-regular);
}
._button_94c5125 {
background: var(--wave-color-solid-base-brighter);
}
.primary--NLSX4 {
background-color: #d5d5d5;
}
.primary--NLSX4:hover {
background-color: var(--wave-color-opacity-contrast-fill-regular) !important;
}
.primary--NLSX4:disabled {
background-color: #e7e7e8;
}
.primary--NLSX4:disabled:hover {
background-color: #e7e7e8;
}
[class^="__NEPTUNE_PAGE"],
[data-test="main"] {
margin-top: 35px;
}
[data-test="button-desktop-release-notes"],
[data-test="button-release-notes"] {
background-color: #333333;
}
[data-test="button-desktop-release-notes"]:hover,
[data-test="button-release-notes"]:hover {
background-color: #555555 !important;
transition: none !important;
}
#playQueueSidebar {
top: 50px !important;
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin);
margin: 2px;
margin-right: -14px !important;
background-color: rgba(220, 220, 220, 0.9) !important;
backdrop-filter: blur(10px);
}
[class^="_bottomGradient"] {
visibility: hidden;
}
[data-test="settings-page"] {
padding-bottom: 60px !important;
}
[data-test="query-suggestions"],
[data-test="recent-searches-container"] {
background-color: rgba(227, 227, 227, 0.85);
backdrop-filter: blur(10px);
}
[data-test="contextmenu"] {
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[class^="_dataContainer_"]::before {
background-image: var(--img);
filter: blur(10px) brightness(1.2);
}
/* Player bar text colors - fix white text issues */
[data-test="footer-player"] * {
color: #333333 !important;
}
[data-test="footer-player"] [class*="trackTitle"],
[data-test="footer-player"] [class*="artistName"],
[data-test="footer-player"] [class*="trackInfo"],
[data-test="footer-player"] [class*="duration"],
[data-test="footer-player"] [class*="time"],
[data-test="footer-player"] [class*="timestamp"] {
color: #333333 !important;
}
/* Main page background */
body,
[data-test="main"],
[class^="__NEPTUNE_PAGE"] {
background-color: #f5f5f5 !important;
}
@@ -0,0 +1,215 @@
/*
{
"name": "Abyss Neptune - OLED Friendly",
"author": "@itzzexcel",
"description": "Abyss Neptune theme without button styling for OLED displays"
}
*/
::-webkit-scrollbar {
display: none;
}
:root {
--wave-color-solid-accent-fill: white;
--wave-color-solid-rainbow-yellow-fill: white;
--wave-color-solid-contrast-fill: white;
--wave-color-solid-base-brighter: black;
--wave-text-body-medium: white !important;
--track-vibrant-color: white !important;
--wave-color-opacity-contrast-fill-ultra-thin: #fffafa1a !important;
--wave-color-solid-rainbow-yellow-darkest: #fffafa1a !important;
--wave-color-solid-accent-dark: rgb(128, 128, 128);
}
/* Credits to https://github.com/surfbryce for the fonts */
@font-face {
font-family: "AbyssFont";
font-weight: 400;
src: url("https://excel.lexploits.top/extra/tidal/LyricsRegular.woff2") format("woff2");
}
@font-face {
font-family: "AbyssFont";
font-weight: 500;
src: url("https://excel.lexploits.top/extra/tidal/LyricsMedium.woff2") format("woff2");
}
@font-face {
font-family: "AbyssFont";
font-weight: 600;
src: url("https://excel.lexploits.top/extra/tidal/LyricsSemibold.woff2") format("woff2");
}
@font-face {
font-family: "AbyssFont";
font-weight: 700;
src: url("https://excel.lexploits.top/extra/tidal/LyricsBold.woff2") format("woff2");
}
[class^="followingButton"],
[title="Unfollow"],
[title="Follow"],
[title="Unfollow"]>span,
[title="Follow"]>span {
background-color: var(--wave-color-solid-rainbow-yellow-fill) !important;
color: var(--wave-color-solid-base-brighter);
}
[class^="_wave-badge-color-max"] {
color: black !important;
background-color: var(--wave-color-solid-accent-fill);
border-radius: 3px;
}
[data-test="main-layout-sidebar-wrapper"] {
border-right: rgb(25, 25, 25) 1px solid;
}
[class^="_wave-badge"] {
background-color: var(--wave-color-solid-accent-fill);
border-radius: 4px;
color: black;
}
[class^="_progressBarWrapper"] {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="_sidebarItem"]>span {
color: var(--wave-color-solid-accent-dark);
}
[data-test="main-layout-header"] {
border-left: 0 !important;
}
[class^="_sidebarItem"]:hover span {
color: var(--wave-color-solid-contrast-fill);
}
[class^="_sidebarItem"] [class^="active"]>span {
color: var(--wave-color-solid-accent-dark) !important;
}
[class^="_active"] {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="ReactVirtualized__Grid"] {
border-radius: 10px;
margin: 5px;
}
[data-test="media-table"]>div>div>div {
border: 1px solid rgb(25, 25, 25) !important;
}
[class^="ReactVirtualized__Grid__innerScrollContainer"] {
border: none;
margin: 5px;
}
[class^="_explicitBadge"] {
color: var(--wave-color-solid-accent-fill);
}
[data-test="current-media-imagery"] {
border: 0 !important;
margin: none;
}
[class^="_imageBorder"] {
display: none;
}
[data-test="feed-sidebar"] {
margin-top: 10px;
}
[data-test="footer-player"] {
width: calc(100% - 20px);
bottom: 10px;
left: 10px;
border: 1px solid rgb(25, 25, 25);
border-radius: 4px !important;
position: absolute !important;
}
[class^="_tableRow"]:hover>*,
[data-test-is-playing="true"]>* {
color: var(--wave-color-solid-accent-fill) !important;
}
[class^="_tableRow"]>*,
[data-test-is-playing="false"]>* {
color: lightgray !important;
}
[class*="coverColumn"] {
padding-left: 5px !important;
}
[class^="actionList"] {
background-color: transparent;
margin: 0px;
border-radius: 5px;
}
.neptune-switch-checkbox:checked+.neptune-switch {
background-color: rgba(255, 255, 255, 0.1);
}
[data-test="main-layout-header"],
[data-test="feed-sidebar"],
[data-test="stream-metadata"],
[data-test="footer-player"] {
background-color: rgba(0, 0, 0, 0.8) !important;
backdrop-filter: blur(10px);
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[data-wave-color=textUrl] {
color: var(--wave-color-solid-accent-fill);
}
[class^="_smallHeader"] {
margin-top: 7.5px;
}
[class^="__NEPTUNE_PAGE"],
[data-test="main"] {
margin-top: 35px;
}
#playQueueSidebar {
top: 50px !important;
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin);
margin: 2px;
margin-right: -14px !important;
background-color: rgba(0, 0, 0, 0.8) !important;
backdrop-filter: blur(10px);
}
[class^="_bottomGradient"] {
visibility: hidden;
}
[data-test="settings-page"] {
padding-bottom: 60px !important;
}
[data-test="query-suggestions"],
[data-test="recent-searches-container"] {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
}
[data-test="contextmenu"] {
border: 1px solid var(--wave-color-opacity-contrast-fill-ultra-thin) !important;
}
[class^="_dataContainer_"]::before {
background-image: var(--img);
filter: blur(10px) brightness(0.4);
}
File diff suppressed because it is too large Load Diff
@@ -43,16 +43,21 @@
backface-visibility: hidden; backface-visibility: hidden;
} }
/* Hide Tidal's native now-playing background color overlay */ /* Performance mode optimizations - keep spinning but optimize other aspects */
[data-test="new-now-playing"] > [class*="_background_"] { .global-spinning-image.performance-mode-static {
/* biome-ignore lint: Must override native album-art-derived background */ /* Keep animation enabled in performance mode */
display: none !important; /* Lighter blur for performance */
filter: blur(20px) brightness(0.4) contrast(1.2) saturate(1) !important;
/* Smaller size for performance */
width: 120vw !important;
height: 120vh !important;
} }
/* Ensure the now-playing container itself is transparent */ .now-playing-background-image.performance-mode-static {
[class*="_nowPlayingContainer"] { /* Keep animation enabled in performance mode */
/* biome-ignore lint: Must override any inline background styles */ /* Optimized size and effects for performance */
background: transparent !important; width: 80vw !important;
height: 80vh !important;
} }
/* Now Playing Background Container Optimization */ /* Now Playing Background Container Optimization */
@@ -62,7 +67,7 @@
top: 0; top: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 0; z-index: -3;
pointer-events: none; pointer-events: none;
overflow: hidden; overflow: hidden;
/* Hardware acceleration */ /* Hardware acceleration */
@@ -70,14 +75,6 @@
backface-visibility: hidden; backface-visibility: hidden;
} }
/* Ensure now-playing content renders above the dynamic background */
[data-test="new-now-playing"] > header,
[data-test="new-now-playing"] > [class*="_content_"],
[data-test="new-now-playing"] > .unhide-ui-button {
position: relative;
z-index: 1;
}
/* Optimized keyframe animations with GPU acceleration */ /* Optimized keyframe animations with GPU acceleration */
@keyframes spinGlobal { @keyframes spinGlobal {
from { from {
@@ -92,11 +89,8 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.global-spinning-image, .global-spinning-image,
.now-playing-background-image { .now-playing-background-image {
/* biome-ignore lint: Accessibility override needs priority */
animation: none !important; animation: none !important;
/* biome-ignore lint: Accessibility override needs priority */
transform: translate(-50%, -50%) !important; transform: translate(-50%, -50%) !important;
/* biome-ignore lint: Accessibility override needs priority */
will-change: auto !important; will-change: auto !important;
} }
} }
@@ -105,62 +99,60 @@
.performance-mode .global-spinning-image, .performance-mode .global-spinning-image,
.performance-mode .now-playing-background-image { .performance-mode .now-playing-background-image {
/* Keep animations but optimize filter effects */ /* Keep animations but optimize filter effects */
/* biome-ignore lint: Intentional override of runtime styles */
filter: blur(10px) brightness(0.4) contrast(1.1) !important; filter: blur(10px) brightness(0.4) contrast(1.1) !important;
} }
/* Make app chrome transparent for cover-everywhere background */ /* Make Notification Feed sidebar transparent */
body, body,
#wimp, #wimp,
main, main,
[class^="_sidebarWrapper"], [class^="_sidebarWrapper"],
[class^="_mainContainer"], [class^="_mainContainer"],
[class*="smallHeader"],
[data-test="main-layout-sidebar-wrapper"], [data-test="main-layout-sidebar-wrapper"],
[data-test="main-layout-header"], [data-test="main-layout-header"],
[data-test="feed-sidebar"], [data-test="feed-sidebar"],
[data-test="stream-metadata"],
[data-test="footer-player"], [data-test="footer-player"],
/* Notification Feed sidebar specific container */
[class^="_feedSidebarVStack"], [class^="_feedSidebarVStack"],
[class^="_feedSidebarSpacer"], [class^="_feedSidebarSpacer"],
[class^="_feedSidebarItem"], [class^="_feedSidebarItem"],
[class^="_feedSidebarItemDiv"], [class^="_feedSidebarItemDiv"],
[class^="_cellContainer"] { [class^="_cellContainer"],
/* biome-ignore lint: Ensure background is fully cleared under theme CSS */ [class^="_cellTextContainer"] {
background: unset !important; background: unset !important;
} }
/* Make sidebar semi-transparent with optimized backdrop-filter */ /* Make sidebar and player bar semi-transparent with optimized backdrop-filter */
[data-test="main-layout-sidebar-wrapper"] { [data-test="footer-player"],
/* biome-ignore lint: Must beat app inline styles for translucency */ [data-test="main-layout-sidebar-wrapper"],
[class^="_bar"],
[class^="_sidebarItem"]:hover {
background-color: rgba(0, 0, 0, 0.3) !important; background-color: rgba(0, 0, 0, 0.3) !important;
/* biome-ignore lint: Must beat app inline styles for translucency */
backdrop-filter: blur(10px) !important; backdrop-filter: blur(10px) !important;
/* biome-ignore lint: Must beat app inline styles for translucency */
-webkit-backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important;
} }
/* Performance mode: reduce backdrop blur */ /* Performance mode: reduce backdrop blur */
.performance-mode [data-test="main-layout-sidebar-wrapper"] { .performance-mode [data-test="footer-player"],
/* biome-ignore lint: Performance mode style requires priority */ .performance-mode [data-test="main-layout-sidebar-wrapper"],
.performance-mode [class^="_bar"],
.performance-mode [class^="_sidebarItem"]:hover {
backdrop-filter: blur(5px) !important; backdrop-filter: blur(5px) !important;
/* biome-ignore lint: Performance mode style requires priority */
-webkit-backdrop-filter: blur(5px) !important; -webkit-backdrop-filter: blur(5px) !important;
} }
/* Feed sidebar panel - black tint background for readability */ /* Feed sidebar panel - black tint background for readability */
[data-test="feed-sidebar"] { [data-test="feed-sidebar"] {
/* biome-ignore lint: Ensure readability over media */
background-color: rgba(0, 0, 0, 0.5) !important; background-color: rgba(0, 0, 0, 0.5) !important;
/* biome-ignore lint: Ensure readability over media */
backdrop-filter: blur(10px) !important; backdrop-filter: blur(10px) !important;
/* biome-ignore lint: Ensure readability over media */
-webkit-backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important;
} }
/* Performance mode: reduce sidebar backdrop blur */ /* Performance mode: reduce sidebar backdrop blur */
.performance-mode [data-test="feed-sidebar"] { .performance-mode [data-test="feed-sidebar"] {
/* biome-ignore lint: Performance mode style requires priority */
backdrop-filter: blur(5px) !important; backdrop-filter: blur(5px) !important;
/* biome-ignore lint: Performance mode style requires priority */
-webkit-backdrop-filter: blur(5px) !important; -webkit-backdrop-filter: blur(5px) !important;
} }
@@ -170,6 +162,10 @@ main,
[class*="_cellContainer"], [class*="_cellContainer"],
[data-test="feed-interval"], [data-test="feed-interval"],
[data-test="feed-item"] { [data-test="feed-item"] {
/* biome-ignore lint: Match theme transparency */
background-color: transparent !important; background-color: transparent !important;
} }
/* Remove bottom gradient */
[class^="_bottomGradient"] {
display: none !important;
}
@@ -1,22 +0,0 @@
/* Square Player Bar override — injected when floating is disabled */
/* MARKER: Floating Player Bar CSS */
[data-test="footer-player"] {
/* biome-ignore lint: Override native floating position */
bottom: 0 !important;
/* biome-ignore lint: Override native floating position */
left: 0 !important;
/* biome-ignore lint: Override native floating position */
right: 0 !important;
/* biome-ignore lint: Override native floating position */
width: 100% !important;
/* biome-ignore lint: Override native floating position */
margin: 0 !important;
/* biome-ignore lint: Force square corners */
border-radius: 0 !important;
/* biome-ignore lint: Remove floating border */
border: none !important;
/* biome-ignore lint: Remove floating shadow */
box-shadow: none !important;
}
File diff suppressed because it is too large Load Diff
+41 -402
View File
@@ -2,447 +2,86 @@
@font-face { @font-face {
font-family: "AbyssFont"; font-family: "AbyssFont";
font-weight: 400; font-weight: 400;
src: url("https://excel.lexploits.top/extra/tidal/LyricsRegular.woff2") src: url("https://excel.lexploits.top/extra/tidal/LyricsRegular.woff2") format("woff2");
format("woff2");
} }
@font-face { @font-face {
font-family: "AbyssFont"; font-family: "AbyssFont";
font-weight: 500; font-weight: 500;
src: url("https://excel.lexploits.top/extra/tidal/LyricsMedium.woff2") src: url("https://excel.lexploits.top/extra/tidal/LyricsMedium.woff2") format("woff2");
format("woff2");
} }
@font-face { @font-face {
font-family: "AbyssFont"; font-family: "AbyssFont";
font-weight: 600; font-weight: 600;
src: url("https://excel.lexploits.top/extra/tidal/LyricsSemibold.woff2") src: url("https://excel.lexploits.top/extra/tidal/LyricsSemibold.woff2") format("woff2");
format("woff2");
} }
@font-face { @font-face {
font-family: "AbyssFont"; font-family: "AbyssFont";
font-weight: 700; font-weight: 700;
src: url("https://excel.lexploits.top/extra/tidal/LyricsBold.woff2") src: url("https://excel.lexploits.top/extra/tidal/LyricsBold.woff2") format("woff2");
format("woff2");
} }
/* Enhanced lyrics styling with glow effects */ /* Enhanced lyrics styling with glow effects */
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"][class*="_current_"] { [class*="_lyricsText"] > div > span[data-current="true"] {
text-shadow: text-shadow: 0 0 2px #fff, 0 0 20px #fff !important;
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #fff),
/* biome-ignore lint: Required to override app glow strength */
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #fff) !important;
padding-left: 20px; padding-left: 20px;
transition-duration: 0.7s; transition-duration: 0.7s;
font-size: calc(55px * var(--rl-font-scale, 1)); font-size: 55px;
/* biome-ignore lint: Active lyric uses Colorama color */ color: white !important;
color: var(--cl-glow1, #fff) !important; font-family: "AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-family:
"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-weight: 700; font-weight: 700;
} }
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"] { [class*="_lyricsText"] > div > span {
text-shadow: text-shadow: 0 0 0px transparent, 0 0 0px transparent;
0 0 0px transparent,
0 0 0px transparent;
transition-duration: 0.25s; transition-duration: 0.25s;
color: rgba(255, 255, 255, 0.4); color: rgba(128, 128, 128, 0.4);
font-size: calc(40px * var(--rl-font-scale, 1)); font-size: 40px;
font-family: font-family: "AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-weight: 700; font-weight: 700;
} }
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"]:hover { [class*="_lyricsText"] > div > span:hover {
text-shadow: text-shadow: 0 0 2px lightgray, 0 0 20px lightgray !important;
0 0 var(--rl-glow-inner, 2px) lightgray,
/* biome-ignore lint: Hover glow should override defaults */
0 0 var(--rl-glow-outer, 20px) lightgray !important;
/* biome-ignore lint: Hover color override */
color: lightgray !important; color: lightgray !important;
padding-left: 20px; padding-left: 20px;
transition-duration: 0.7s; 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 */ /* Current line transitions */
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"] { [class*="_lyricsText"] > div > span {
transition: transition: text-shadow 0.7s ease-in-out, color 0.7s ease-in-out, padding 0.7s ease-in-out !important;
text-shadow 0.7s ease-in-out,
color 0.7s ease-in-out,
/* biome-ignore lint: Transition priority needed */
padding 0.7s ease-in-out !important;
}
/* Glow-aware left padding so the glow doesn't clip | Thanks Aya <3*/
.rl-wbw-active {
padding-left: var(--rl-glow-outer) !important;
} }
/* Lyrics container styling */ /* Lyrics container styling */
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"] { [class^="_lyricsContainer"] > div > div > span {
margin-bottom: 2rem; margin-bottom: 2rem;
/* biome-ignore lint: Must beat Tidal _hasCues_ opacity */
opacity: 1 !important;
font-family:
"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-weight: 700;
/* biome-ignore lint: Typography override for readability */
font-size: calc(38px * var(--rl-font-scale, 1)) !important;
}
/* Hide the old Musixmatch attribution footer in the lyrics panel */
[data-test="now-playing-lyrics"] [class*="_footer_"] {
display: none !important;
}
/* MARKER: WBW lyrics CSS */
/* hide tidal spans for wbw */
.rl-wbw-active span[data-test="lyrics-line"] {
/* biome-ignore lint: Must hide original lines when word-by-word is on */
display: none !important;
}
/* Active line slide */
.rl-wbw-line {
text-align: left;
padding-left: 0;
padding-right: 0;
filter: none;
transform: translateZ(0);
transform-origin: left;
transition:
filter 0.4s ease,
padding-left 0.7s ease-in-out,
padding-right 0.7s ease-in-out;
overflow: visible;
}
.rl-wbw-line.rl-wbw-spacer {
filter: none;
}
/* Blur Inactive (opt-in via .rl-blur-active on container) */
.rl-blur-active .rl-wbw-line {
filter: blur(0.07em);
}
.rl-blur-active .rl-wbw-line.rl-pos-1 {
filter: blur(0.035em);
}
.rl-blur-active .rl-wbw-line.rl-pos-2 {
filter: blur(0.05em);
}
.rl-blur-active .rl-wbw-line.rl-pos-3 {
filter: blur(0.06em);
}
/* Active line overrides (MUST come after blur rules to win on equal specificity) */
.rl-wbw-line.rl-wbw-line-active,
.rl-blur-active .rl-wbw-line.rl-wbw-line-active {
padding-left: 20px;
filter: none;
}
/* Keep last-active line unblurred during instrumental gaps */
.rl-blur-active .rl-wbw-line.rl-gap-hold {
filter: none;
}
/* Bubbled Lyrics scale (opt-in via .rl-bubbled on container) */
.rl-bubbled .rl-wbw-line {
scale: 0.93 0.93 0.95;
transition:
scale 0.7s ease,
filter 0.4s ease,
padding-left 0.7s ease-in-out,
padding-right 0.7s ease-in-out;
will-change: scale, translate, filter;
}
.rl-bubbled .rl-wbw-line.rl-wbw-spacer {
scale: none;
}
.rl-bubbled .rl-wbw-line.rl-wbw-line-active {
scale: 1;
transition:
scale 0.5s ease,
filter 0.4s ease,
padding-left 0.7s ease-in-out,
padding-right 0.7s ease-in-out;
}
/* Staggered scroll bounce animation (part of Bubbled Lyrics) */
@keyframes rl-scroll-bounce {
from {
translate: 0 var(--rl-scroll-delta);
}
to {
translate: 0 0;
}
}
.rl-wbw-line:not(.rl-scroll-animate) {
animation: none;
}
.rl-scroll-animate {
animation: rl-scroll-bounce 400ms cubic-bezier(0.41, 0, 0.12, 0.99) both;
animation-delay: var(--rl-line-delay, 0ms);
}
/* Word span — opacity override needed to beat Tidal's ._hasCues_ span { opacity:.35 } */
.rl-wbw-word {
text-shadow:
0 0 0px transparent,
0 0 0px transparent;
color: rgba(255, 255, 255, 0.4);
/* biome-ignore lint: Must beat Tidal _hasCues_ opacity */
opacity: 1 !important;
transition:
text-shadow 0.15s ease-out,
color 0.15s ease-out;
}
/* Hover word (Grouped Syllables) */
.rl-wbw-line:not(.rl-wbw-line-active) > .rl-wbw-word:hover,
.rl-wbw-line:not(.rl-wbw-line-active) > .rl-wbw-word.rl-wbw-word-hover,
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-main .rl-wbw-word:hover,
.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;
/* biome-ignore lint: Hover color override */
color: lightgray !important;
cursor: pointer;
}
/* Active word */
.rl-wbw-word.rl-wbw-active {
text-shadow:
0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #fff),
/* biome-ignore lint: Glow priority for active word */
0 0 var(--rl-glow-outer, 20px) var(--cl-glow2, #fff) !important;
/* biome-ignore lint: Active word uses Colorama color */
color: var(--cl-glow1, #fff) !important;
}
/* MARKER: Syllable sweep animation CSS */
@keyframes rl-wipe {
from {
background-size:
0.75em 100%,
0% 100%,
100% 100%;
background-position:
-0.375em 0%,
left,
left;
}
to {
background-size:
0.75em 100%,
100% 100%,
100% 100%;
background-position:
calc(100% + 0.375em) 0%,
left,
left;
}
}
/* Syllable active: gradient sweep L-to-R via background-clip */
.rl-wbw-word.rl-syl-active {
/* biome-ignore lint: Kill base transitions so class swaps are instant */
transition: none !important;
/* biome-ignore lint: Transparent fill so gradient paints the text */
color: transparent !important;
/* biome-ignore lint: Clip gradient to text glyphs */
-webkit-background-clip: text !important;
/* biome-ignore lint: Clip gradient to text glyphs */
background-clip: text !important;
background-repeat: no-repeat;
background-image:
linear-gradient(
90deg,
transparent 0%,
var(--cl-glow1, #fff) 50%,
transparent 100%
),
linear-gradient(90deg, var(--cl-glow1, #fff) 100%, transparent 100%),
linear-gradient(90deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4));
background-size:
0.75em 100%,
0% 100%,
100% 100%;
background-position:
-0.375em 0%,
left,
left;
/* biome-ignore lint: No glow for syllable mode */
text-shadow: none !important;
/* biome-ignore lint: No glow for syllable mode */
filter: none !important;
}
/* Syllable finished: word stays Colorama color, no glow */
.rl-wbw-word.rl-syl-finished {
/* biome-ignore lint: Kill base transitions so class swaps are instant */
transition: none !important;
/* biome-ignore lint: Finished syllable uses Colorama color */
color: var(--cl-glow1, #fff) !important;
/* biome-ignore lint: No glow for syllable mode */
text-shadow: none !important;
/* biome-ignore lint: No glow for syllable mode */
filter: none !important;
}
/* MARKER: Syllable animations CSS (WIP coming soon) */
/* syllableStyle: 0 = none, 1 = Pop!, 2 = Jump */
@keyframes rl-pop {
0%,
100% {
transform: scale(1);
}
25%,
35% {
transform: scale(1.03) translateY(-0.5%);
}
}
@keyframes rl-jump {
0% {
transform: translateY(8px);
}
50% {
transform: translateY(-3px);
}
100% {
transform: translateY(0);
}
}
/* Pop! for word mode */
.rl-syl-pop .rl-wbw-word.rl-wbw-active {
transform-origin: center bottom;
animation: rl-pop 0.6s ease-out;
}
/* Pop! for syllable mode */
.rl-syl-pop .rl-wbw-word.rl-syl-active {
transform-origin: center bottom;
}
/* Jump for word mode */
.rl-syl-jump .rl-wbw-word.rl-wbw-active {
animation: rl-jump 0.35s ease-out;
}
/* Tidals "..." at the top of the container */
.rl-wbw-active > span:not([data-test="lyrics-line"]) {
display: block;
font-size: calc(40px * var(--rl-font-scale, 1));
font-family:
"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-weight: 700;
color: rgba(255, 255, 255, 0.4);
/* biome-ignore lint: Must beat Tidal _hasCues_ opacity */
opacity: 1 !important;
text-shadow: 0 0 0px transparent;
margin-bottom: 2rem;
}
/* MARKER: Context Aware Lyrics CSS */
/* Background vocal sub-container */
.rl-wbw-bg-container {
max-height: 0;
overflow: visible;
opacity: 0;
font-size: 0.55em;
padding-top: 0.15em;
transition:
max-height 0.3s ease,
opacity 0.5s ease;
color: rgba(255, 255, 255, 0.4);
}
.rl-wbw-line.rl-wbw-line-active .rl-wbw-bg-container {
max-height: 3em;
opacity: 1; opacity: 1;
transition: font-family: "AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
max-height 0.5s ease, font-weight: 700;
opacity 0.5s ease; font-size: 38px !important;
} }
/* Singer duet positioning */ /* Reset all lyrics styling when disabled */
.rl-wbw-line.rl-singer-right { .lyrics-glow-disabled [class*="_lyricsText"] > div > span[data-current="true"],
text-align: end; .lyrics-glow-disabled [class*="_lyricsText"] > div > span,
transform-origin: right; .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 {
.rl-dual-side .rl-wbw-line.rl-singer-left {
padding-right: 20%;
}
.rl-dual-side .rl-wbw-line.rl-singer-right {
padding-left: 20%;
}
.rl-dual-side .rl-wbw-line.rl-singer-right .rl-wbw-bg-container {
text-align: end;
}
.rl-dual-side .rl-wbw-line.rl-singer-right.rl-wbw-line-active {
padding-right: 20px;
}
.rl-dual-side .rl-wbw-line.rl-singer-left.rl-wbw-line-active {
padding-left: 20px;
}
/* Reset glow when disabled */
.lyrics-glow-disabled [data-test="now-playing-lyrics"] span[data-test="lyrics-line"][class*="_current_"],
.lyrics-glow-disabled [data-test="now-playing-lyrics"] span[data-test="lyrics-line"]:hover {
/* biome-ignore lint: Kill glow on active/hover lines */
text-shadow: none !important;
}
/* kill glow on active word */
.lyrics-glow-disabled .rl-wbw-word.rl-wbw-active {
/* biome-ignore lint: Kill glow on active word */
text-shadow: none !important;
}
/* kill glow on hovered word */
.lyrics-glow-disabled .rl-wbw-line:not(.rl-wbw-line-active)
> .rl-wbw-word:hover,
.lyrics-glow-disabled
.rl-wbw-line:not(.rl-wbw-line-active)
> .rl-wbw-word.rl-wbw-word-hover,
.lyrics-glow-disabled
.rl-wbw-line:not(.rl-wbw-line-active)
.rl-wbw-main
.rl-wbw-word:hover,
.lyrics-glow-disabled
.rl-wbw-line:not(.rl-wbw-line-active)
.rl-wbw-main
.rl-wbw-word.rl-wbw-word-hover {
/* biome-ignore lint: Kill glow on hovered word */
text-shadow: none !important; text-shadow: none !important;
padding-left: 0 !important;
transition: none !important;
font-size: inherit !important;
color: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
margin-bottom: inherit !important;
opacity: inherit !important;
} }
@@ -9,7 +9,7 @@
} }
/* Also show player bar when hovering over the bottom area - only when UI is hidden */ /* Also show player bar when hovering over the bottom area - only when UI is hidden */
.radiant-lyrics-ui-hidden body.rl-footer-hover [data-test="footer-player"], .radiant-lyrics-ui-hidden:has([data-test="footer-player"]:hover) [data-test="footer-player"],
.radiant-lyrics-ui-hidden [data-test="footer-player"]:hover { .radiant-lyrics-ui-hidden [data-test="footer-player"]:hover {
opacity: 1 !important; opacity: 1 !important;
} }
+193 -262
View File
@@ -1,68 +1,218 @@
/* Sidebar */ /* Only apply styles when UI is hidden */
[class*="_sidebar_"] { .radiant-lyrics-ui-hidden [class*="tabItems"] {
background-color: transparent !important;
}
/* Section header */
[class*="_sectionHeader_"] {
background-color: transparent !important;
}
/* Rounded corners */
[class*="_thumbnail_"],
[class*="_imageWrapper_"],
[class*="_playButton_"] {
border-radius: 5px !important;
}
/* MARKER: HideUI CSS*/
/* Only apply styles when UI is hidden — hide toggle buttons */
.radiant-lyrics-ui-hidden [data-test="toggle-lyrics"],
.radiant-lyrics-ui-hidden [data-test="toggle-credits"],
.radiant-lyrics-ui-hidden [data-test="toggle-similar-tracks"] {
opacity: 0 !important; opacity: 0 !important;
transition: opacity 0.4s ease-in-out; transition: opacity 0.4s ease-in-out;
} }
.radiant-lyrics-ui-hidden [data-test="toggle-lyrics"]:hover, /* Default state - visible */
.radiant-lyrics-ui-hidden [data-test="toggle-credits"]:hover, [class*="tabItems"] {
.radiant-lyrics-ui-hidden [data-test="toggle-similar-tracks"]:hover { transition: opacity 0.4s ease-in-out;
opacity: 1 !important;
} }
/* Hide header container (search, minimize, fullscreen) when UI is hidden */ /* Tab items stay hidden - no hover functionality (if the song changes and it doesnt have lyrics.. and ya want them back.. you can unhide the UI <3) */
.radiant-lyrics-ui-hidden [data-test="header"] {
.radiant-lyrics-ui-hidden [data-test="header-container"]:not(:has(.hide-ui-button)) {
opacity: 0 !important; opacity: 0 !important;
visibility: hidden !important; transition: opacity 0.4s ease-in-out;
transition: opacity 0.4s ease-in-out, visibility 0s linear 0.4s;
pointer-events: none !important;
} }
/* Immediate hide class for unhide button */ /* Keep header visible if it contains the Hide UI button, but hide its other children */
.radiant-lyrics-ui-hidden [data-test="header-container"]:has(.hide-ui-button) > *:not(.hide-ui-button) {
opacity: 0 !important;
transition: opacity 0.4s ease-in-out;
}
/* Default state for header */
[data-test="header-container"] {
transition: opacity 0.4s ease-in-out;
}
/* Only prevent specific text elements in player bar from being affected by margin adjustments */
[data-test="footer-player"] [class*="_trackTitle"],
[data-test="footer-player"] [class*="_artistName"],
[data-test="footer-player"] [class*="_trackInfo"],
[data-test="footer-player"] [class*="_trackContainer"] {
margin-top: 0 !important;
transform: none !important;
}
/* Immediate hide class for unhide button with smooth transition (had issues with the fade out.. so I removed it) */
.hide-immediately { .hide-immediately {
opacity: 0 !important; opacity: 0 !important;
visibility: hidden !important; visibility: hidden !important;
pointer-events: none !important; pointer-events: none !important;
} }
/* Auto-fade styling for unhide button */ [class^="_bar"] {
background-color: transparent;
}
.radiant-lyrics-ui-hidden [class^="_bar"]>*:not(.hide-ui-button) {
opacity: 0 !important;
pointer-events: none !important;
transition: opacity 0.4s ease-in-out;
}
/* Default state for bar elements */
[class^="_bar"]>* {
transition: opacity 0.4s ease-in-out;
}
/* Hide search box and make it non-interactive */
.radiant-lyrics-ui-hidden [data-test="search-input"],
.radiant-lyrics-ui-hidden [class*="_searchInput"],
.radiant-lyrics-ui-hidden [class*="searchInput"],
.radiant-lyrics-ui-hidden [class*="_search"],
.radiant-lyrics-ui-hidden [class*="search"],
.radiant-lyrics-ui-hidden input[type="search"],
.radiant-lyrics-ui-hidden input[type="text"],
.radiant-lyrics-ui-hidden input[placeholder*="Search"],
.radiant-lyrics-ui-hidden input[placeholder*="search"],
.radiant-lyrics-ui-hidden [placeholder*="Search"],
.radiant-lyrics-ui-hidden [data-test="main-layout-header"] input,
.radiant-lyrics-ui-hidden [data-test="main-layout-header"] [class*="input"],
.radiant-lyrics-ui-hidden header input,
.radiant-lyrics-ui-hidden nav input {
pointer-events: none !important;
cursor: default !important;
user-select: none !important;
}
/* Hide bottom left controls completely - no hover functionality */
/* Exclude heart button in player bar and make sure hidden buttons can't be clicked */
.radiant-lyrics-ui-hidden [data-test="add-to-playlist"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden [data-test="remove-from-playlist"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden [data-test="like-toggle"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden [data-test="dislike-toggle"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden [data-test="favorite-toggle"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden [data-test="heart-button"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden [data-test="playlist-add"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden [class*="_trackActions"],
.radiant-lyrics-ui-hidden [class*="_bottomLeftControls"],
.radiant-lyrics-ui-hidden [class*="_actionButtons"],
.radiant-lyrics-ui-hidden [class*="_favoriteButton"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden [class*="_addToPlaylist"],
.radiant-lyrics-ui-hidden [class*="_lowerLeft"],
.radiant-lyrics-ui-hidden [class*="_bottomActions"],
.radiant-lyrics-ui-hidden [class*="_mediaControls"] > div:first-child,
.radiant-lyrics-ui-hidden button[title*="Add to"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden button[title*="Remove from"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden button[title*="Like"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden button[title*="Favorite"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden button[title*="Heart"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden button[aria-label*="Add to"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden button[aria-label*="Remove from"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden button[aria-label*="Like"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden button[aria-label*="Favorite"]:not([data-test="footer-player"] *),
.radiant-lyrics-ui-hidden button[aria-label*="Heart"]:not([data-test="footer-player"] *),
/* Target buttons in bottom left area specifically - (idk if this is needed.. but it's here) */
.radiant-lyrics-ui-hidden [class*="_nowPlayingContainer"] button[class*="_button"]:not(.unhide-ui-button),
.radiant-lyrics-ui-hidden [class*="_nowPlayingContainer"] [class*="_iconButton"]:not(.unhide-ui-button),
/* Additional catch-all for bottom left area buttons - (idk if this is needed.. but it's here) */
.radiant-lyrics-ui-hidden [class*="_nowPlayingContainer"] > div > div:first-child button:not(.unhide-ui-button),
.radiant-lyrics-ui-hidden [class*="_nowPlayingContainer"] > div:first-child button:not(.unhide-ui-button) {
opacity: 0 !important;
pointer-events: none !important;
transition: opacity 0.5s ease-in-out !important;
}
/* No hover functionality in Hide UI Mode - buttons stay hidden.. yea thats right, you heard me */
/* Default state for control buttons */
[data-test="add-to-playlist"],
[data-test="remove-from-playlist"],
[data-test="like-toggle"],
[data-test="dislike-toggle"],
[data-test="favorite-toggle"],
[data-test="heart-button"],
[data-test="playlist-add"],
[class*="_trackActions"],
[class*="_bottomLeftControls"],
[class*="_actionButtons"],
[class*="_favoriteButton"],
[class*="_addToPlaylist"],
[class*="_lowerLeft"],
[class*="_bottomActions"],
[class*="_mediaControls"] > div:first-child,
button[title*="Add to"],
button[title*="Remove from"],
button[title*="Like"],
button[title*="Favorite"],
button[title*="Heart"],
button[aria-label*="Add to"],
button[aria-label*="Remove from"],
button[aria-label*="Like"],
button[aria-label*="Favorite"],
button[aria-label*="Heart"],
[class*="_nowPlayingContainer"] button[class*="_button"]:not(.unhide-ui-button),
[class*="_nowPlayingContainer"] [class*="_iconButton"]:not(.unhide-ui-button),
[class*="_nowPlayingContainer"] > div > div:first-child button:not(.unhide-ui-button),
[class*="_nowPlayingContainer"] > div:first-child button:not(.unhide-ui-button) {
transition: opacity 0.5s ease-in-out;
}
/* Smooth cover art movement when UI is hidden */
[class*="_albumImage"],
[class*="_coverArt"],
figure[class*="_albumImage"] {
transition: transform 0.6s ease-in-out;
}
.radiant-lyrics-ui-hidden [class*="_albumImage"],
.radiant-lyrics-ui-hidden [class*="_coverArt"],
.radiant-lyrics-ui-hidden figure[class*="_albumImage"] {
transform: translateX(80px) !important;
}
/* Smooth track info wrapper movement when UI is hidden (Arists & Track Title) */
[class*="_infoWrapper"],
[class*="_textContainer"] {
transition: transform 0.6s ease-in-out;
}
.radiant-lyrics-ui-hidden [class*="_infoWrapper"],
.radiant-lyrics-ui-hidden [class*="_textContainer"] {
transform: translateX(40px) !important;
}
/* Move parent containers instead of lyrics container directly to preserve gradient fade */
[data-test="stream-metadata"],
[class*="_rightColumn"],
[class*="_rightSide"],
[class*="_contentRight"],
[class*="_sidePanel"],
[class*="_lyricsSection"],
[class*="_lyricsWrapper"] {
transition: transform 0.6s ease-in-out;
}
.radiant-lyrics-ui-hidden [data-test="stream-metadata"],
.radiant-lyrics-ui-hidden [class*="_rightColumn"],
.radiant-lyrics-ui-hidden [class*="_rightSide"],
.radiant-lyrics-ui-hidden [class*="_contentRight"],
.radiant-lyrics-ui-hidden [class*="_sidePanel"],
.radiant-lyrics-ui-hidden [class*="_lyricsSection"],
.radiant-lyrics-ui-hidden [class*="_lyricsWrapper"] {
transform: translateX(60px) translateY(-70px) !important;
}
/* Hide UI button base styling - just the transition */
.hide-ui-button {
transition: opacity 0.5s ease-in-out, visibility 0.5s ease-in-out, background-color 0.2s ease-in-out, transform 0.2s ease-in-out !important;
}
/* Auto-fade styling for unhide button - (Keeps Text Visible, just not full opacity) | Cheers @Zyhn for the idea*/
.unhide-ui-button.auto-faded { .unhide-ui-button.auto-faded {
background-color: transparent !important; background-color: transparent !important;
border-color: transparent !important; border-color: transparent !important;
box-shadow: none !important; box-shadow: none !important;
backdrop-filter: none !important; backdrop-filter: none !important;
-webkit-backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
color: rgba(255, 255, 255, 0.4) !important; color: rgba(255, 255, 255, 0.8) !important;
transition: transition: background-color 0.8s ease-in-out, border-color 0.8s ease-in-out, box-shadow 0.8s ease-in-out, backdrop-filter 0.8s ease-in-out, color 0.8s ease-in-out;
background-color 0.8s ease-in-out,
border-color 0.8s ease-in-out,
box-shadow 0.8s ease-in-out,
backdrop-filter 0.8s ease-in-out,
color 0.8s ease-in-out !important;
} }
/* Restore button styling on hover */
.unhide-ui-button.auto-faded:hover { .unhide-ui-button.auto-faded:hover {
background-color: rgba(255, 255, 255, 0.2) !important; background-color: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.3) !important; border-color: rgba(255, 255, 255, 0.3) !important;
@@ -70,224 +220,5 @@
backdrop-filter: blur(10px) !important; backdrop-filter: blur(10px) !important;
-webkit-backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important;
color: white !important; color: white !important;
transition: transition: background-color 0.3s ease-in-out, border-color 0.3s ease-in-out, box-shadow 0.3s ease-in-out, backdrop-filter 0.3s ease-in-out, color 0.3s ease-in-out;
background-color 0.3s ease-in-out,
border-color 0.3s ease-in-out,
box-shadow 0.3s ease-in-out,
backdrop-filter 0.3s ease-in-out,
color 0.3s ease-in-out !important;
}
/* MARKER: Sticky Lyrics CSS */
/* Lyrics toggle button */
[data-test="toggle-lyrics"]:has(.sticky-lyrics-trigger) {
position: relative !important;
padding-right: 38px !important;
}
/* Trigger */
.sticky-lyrics-trigger {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 38px;
display: flex;
align-items: center;
justify-content: center;
padding-left: 5px;
padding-right: 0px;
box-sizing: border-box;
cursor: default;
color: #CCCCD1;
transition: color 0.2s ease;
}
/* Divider line */
.sticky-lyrics-trigger::before {
content: "";
position: absolute;
left: 5px;
top: 4px;
bottom: 4px;
width: 1px;
background: transparent;
transition: background 0.2s ease;
}
/* When Lyrics toggle is pressed — show divider & adjust icon */
[data-test="toggle-lyrics"][aria-pressed="true"] .sticky-lyrics-trigger {
color: rgb(30, 30, 30);
cursor: pointer;
}
[data-test="toggle-lyrics"][aria-pressed="true"] .sticky-lyrics-trigger::before {
background: rgba(0, 0, 0, 0.15);
}
[data-test="toggle-lyrics"][aria-pressed="true"] .sticky-lyrics-trigger:hover {
color: rgba(0, 0, 0, 0.5);
}
/* Animate widening when dropdown opens */
[data-test="toggle-lyrics"]:has(.sticky-lyrics-trigger) {
transition: min-width 0.12s ease-out;
}
/* Reduce top rounding, square bottom, and kill hover tint when dropdown is open */
body.rl-dropdown-open [data-test="toggle-lyrics"] {
border-radius: 12px 12px 0 0 !important;
background-color: rgb(255, 255, 255) !important;
min-width: 150px !important;
}
/* Dropdown — right-aligned under the Lyrics button */
.sticky-lyrics-dropdown {
position: fixed;
background: rgb(255, 255, 255);
border-radius: 0 0 12px 12px;
padding: 8px 12px 10px;
box-sizing: border-box;
z-index: 10000;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
clip-path: inset(0 -20px -20px -20px);
animation: stickyLyricsDropdownIn 0.12s ease-out;
}
@keyframes stickyLyricsDropdownIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Row containing label + toggle */
.sticky-lyrics-dropdown-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.sticky-lyrics-label {
font-size: 11px;
font-weight: 600;
color: rgba(0, 0, 0, 0.8);
white-space: nowrap;
}
/* Toggle switch */
.sticky-lyrics-switch {
position: relative;
display: inline-block;
width: 34px;
height: 18px;
flex-shrink: 0;
}
.sticky-lyrics-switch input {
opacity: 0;
width: 0;
height: 0;
}
.sticky-lyrics-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.15);
transition: 0.3s;
border-radius: 18px;
}
.sticky-lyrics-slider::before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.sticky-lyrics-switch input:checked + .sticky-lyrics-slider {
background-color: rgb(30, 30, 30);
}
.sticky-lyrics-switch input:checked + .sticky-lyrics-slider::before {
transform: translateX(16px);
background-color: rgb(255, 255, 255);
}
/* Segmented control (Line | Word | Syllable) */
.rl-style-row {
justify-content: center;
margin-top: 6px;
}
.rl-seg-control {
display: flex;
background: rgba(0, 0, 0, 0.06);
border-radius: 10px;
padding: 2px;
gap: 2px;
width: 100%;
}
.rl-seg-btn {
flex: 1;
border: none;
background: transparent;
color: rgba(0, 0, 0, 0.4);
font-size: 10px;
font-weight: 600;
padding: 5px 0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.rl-seg-btn:hover {
color: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.06);
}
.rl-seg-btn.rl-seg-active {
background: rgb(30, 30, 30);
color: rgb(255, 255, 255);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
/* MARKER: PATCHES (Random Fixes for Tidals Changes) */
/* These change allot so i gave them their own section */
/* Remove max-width cap on now-playing content */
[class*="_contentInner"] {
max-width: none !important;
}
/* Round now-playing artwork corners */
[data-test="now-playing-artwork"] {
/* biome-ignore lint: Override flat corners */
border-radius: 10px !important;
}
/* Hide the Overlay Scrollbar (people just use mouse scroll) */
.os-scrollbar {
display: none !important;
pointer-events: none !important;
} }