Merge pull request #139 from meowarex/dev

Backdrop Rewrite (Kawarp <3)
This commit is contained in:
meoware.exe
2026-08-13 04:19:47 +10:00
committed by GitHub
8 changed files with 857 additions and 782 deletions
+4 -1
View File
@@ -7,5 +7,8 @@
"avatarUrl": "https://avatars.githubusercontent.com/u/90243579"
},
"main": "./src/index.ts",
"type": "module"
"type": "module",
"dependencies": {
"@kawarp/core": "*"
}
}
+198 -240
View File
@@ -9,8 +9,7 @@ declare global {
updateStickyLyricsFeature?: () => void;
updateStickyLyricsSetting?: (checked: boolean) => void;
updateRadiantLyricsPlayerBarTint?: () => void;
updateRadiantLyricsGlobalBackground?: () => void;
updateRadiantLyricsNowPlayingBackground?: () => void;
updateRadiantLyricsBackdrop?: () => void;
updateQualityProgressColor?: () => void;
updateIntegratedSeekBar?: () => void;
updateLyricsStyle?: () => void;
@@ -48,16 +47,22 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
playerBarTint: 5,
playerBarTintColor: "#000000" as string,
playerBarTintCustomColors: [] as string[],
// Master switch
backdropEnabled: true,
backdropPlaybackReactive: true,
CoverEverywhere: true,
performanceMode: false,
spinningArt: true,
backgroundScale: 15,
backgroundRadius: 25,
backgroundContrast: 120,
backgroundBlur: 80,
backgroundBrightness: 40,
spinSpeed: 45,
settingsAffectNowPlaying: true,
// Backdrop is rendered entirely by kawarp (yay!)
backdropOpacity: 75,
backdropWarp: 100,
backdropBlurPasses: 5,
backdropSpeed: 175,
backdropContrast: 125,
backdropSaturation: 125,
backdropDithering: 15,
backdropScale: 100,
// Readability on bright covers <3
backdropDarken: 80,
});
export const Settings = () => {
@@ -74,27 +79,38 @@ export const Settings = () => {
const [CoverEverywhere, setCoverEverywhere] = React.useState(
settings.CoverEverywhere,
);
const [backdropEnabled, setBackdropEnabled] = React.useState(
settings.backdropEnabled,
);
const [backdropPlaybackReactive, setBackdropPlaybackReactive] =
React.useState(settings.backdropPlaybackReactive);
const [performanceMode, setPerformanceMode] = React.useState(
settings.performanceMode,
);
const [spinningArt, setspinningArt] = React.useState(settings.spinningArt);
const [backgroundContrast, setBackgroundContrast] = React.useState(
settings.backgroundContrast,
const [backdropOpacity, setBackdropOpacity] = React.useState(
settings.backdropOpacity,
);
const [backgroundBlur, setBackgroundBlur] = React.useState(
settings.backgroundBlur,
const [backdropWarp, setBackdropWarp] = React.useState(settings.backdropWarp);
const [backdropBlurPasses, setBackdropBlurPasses] = React.useState(
settings.backdropBlurPasses,
);
const [backgroundBrightness, setBackgroundBrightness] = React.useState(
settings.backgroundBrightness,
const [backdropSpeed, setBackdropSpeed] = React.useState(
settings.backdropSpeed,
);
const [spinSpeed, setSpinSpeed] = React.useState(settings.spinSpeed);
const [settingsAffectNowPlaying, setSettingsAffectNowPlaying] =
React.useState(settings.settingsAffectNowPlaying);
const [backgroundScale, setBackgroundScale] = React.useState(
settings.backgroundScale,
const [backdropContrast, setBackdropContrast] = React.useState(
settings.backdropContrast,
);
const [backgroundRadius, setBackgroundRadius] = React.useState(
settings.backgroundRadius,
const [backdropSaturation, setBackdropSaturation] = React.useState(
settings.backdropSaturation,
);
const [backdropDithering, setBackdropDithering] = React.useState(
settings.backdropDithering,
);
const [backdropScale, setBackdropScale] = React.useState(
settings.backdropScale,
);
const [backdropDarken, setBackdropDarken] = React.useState(
settings.backdropDarken,
);
const [floatingPlayerBar, setFloatingPlayerBar] = React.useState(
settings.floatingPlayerBar,
@@ -185,6 +201,10 @@ export const Settings = () => {
};
}, []);
const refreshBackdrop = () => {
window.updateRadiantLyricsBackdrop?.();
};
// Derive props and override onChange to accept a broader first param type
type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>;
type AnySwitchProps = Omit<BaseSwitchProps, "onChange"> & {
@@ -893,225 +913,163 @@ export const Settings = () => {
);
})()}
<AnySwitch
title="Cover Everywhere"
desc="Apply the spinning Cover Art background to the entire app, not just the Now Playing view, Heavily Inspired by Cover-Theme by @Inrixia"
checked={CoverEverywhere}
title="Custom Backdrop"
desc="Render the cover-art shader backdrop, off = Tidals cover spin"
checked={backdropEnabled}
onChange={(_: unknown, checked: boolean) => {
console.log(
"Spinning Cover Everywhere:",
checked ? "enabled" : "disabled",
);
settings.CoverEverywhere = checked;
setCoverEverywhere(checked);
// Update styles immediately when setting changes
if (window.updateRadiantLyricsGlobalBackground) {
window.updateRadiantLyricsGlobalBackground();
}
settings.backdropEnabled = checked;
setBackdropEnabled(checked);
refreshBackdrop();
}}
/>
{CoverEverywhere && (
<AnySwitch
title="Performance Mode | Experimental"
desc="Performance mode: Reduces blur effects & uses smaller image sizes, to optimize GPU usage"
checked={performanceMode}
onChange={(_: unknown, checked: boolean) => {
console.log("Performance Mode:", checked ? "enabled" : "disabled");
settings.performanceMode = checked;
setPerformanceMode(checked);
// Update background animations immediately when setting changes
if (window.updateRadiantLyricsGlobalBackground) {
window.updateRadiantLyricsGlobalBackground();
}
if (window.updateRadiantLyricsNowPlayingBackground) {
window.updateRadiantLyricsNowPlayingBackground();
}
}}
/>
)}
{CoverEverywhere && (
<AnySwitch
title="Background Cover Spin" // Cheers @Max/n0201 for the idea <3
desc="Enable the spinning cover art background animation"
checked={spinningArt}
onChange={(_: unknown, checked: boolean) => {
console.log(
"Background Cover Spin:",
checked ? "enabled" : "disabled",
);
settings.spinningArt = checked;
setspinningArt(checked);
if (window.updateRadiantLyricsGlobalBackground) {
window.updateRadiantLyricsGlobalBackground();
}
if (
settings.settingsAffectNowPlaying &&
window.updateRadiantLyricsNowPlayingBackground
) {
window.updateRadiantLyricsNowPlayingBackground();
}
}}
/>
)}
{CoverEverywhere && (
<LunaNumberSetting
title="Background Scale"
desc="Adjust the scale of the background cover (1=10% - 50=500%, default: 15)"
min={1}
max={50}
step={1}
value={backgroundScale}
onNumber={(value: number) => {
settings.backgroundScale = value;
setBackgroundScale(value);
if (window.updateRadiantLyricsGlobalBackground) {
window.updateRadiantLyricsGlobalBackground();
}
if (
settings.settingsAffectNowPlaying &&
window.updateRadiantLyricsNowPlayingBackground
) {
window.updateRadiantLyricsNowPlayingBackground();
}
}}
/>
)}
{CoverEverywhere && (
<LunaNumberSetting
title="Background Radius"
desc="Adjust the cover art corner radius (0-100%, default: 25)"
min={0}
max={100}
step={1}
value={backgroundRadius}
onNumber={(value: number) => {
settings.backgroundRadius = value;
setBackgroundRadius(value);
if (window.updateRadiantLyricsGlobalBackground) {
window.updateRadiantLyricsGlobalBackground();
}
if (
settings.settingsAffectNowPlaying &&
window.updateRadiantLyricsNowPlayingBackground
) {
window.updateRadiantLyricsNowPlayingBackground();
}
}}
/>
)}
{CoverEverywhere && (
<LunaNumberSetting
title="Background Contrast"
desc="Adjust the contrast of the spinning background (0-200, default: 120)"
min={0}
max={200}
step={1}
value={backgroundContrast}
onNumber={(value: number) => {
settings.backgroundContrast = value;
setBackgroundContrast(value);
if (window.updateRadiantLyricsGlobalBackground) {
window.updateRadiantLyricsGlobalBackground();
}
if (
settings.settingsAffectNowPlaying &&
window.updateRadiantLyricsNowPlayingBackground
) {
window.updateRadiantLyricsNowPlayingBackground();
}
}}
/>
)}
{CoverEverywhere && (
<LunaNumberSetting
title="Background Blur"
desc="Adjust the blur amount of the spinning background (0-200, default: 80)"
min={0}
max={200}
step={1}
value={backgroundBlur}
onNumber={(value: number) => {
console.log("Background Blur:", value);
settings.backgroundBlur = value;
setBackgroundBlur(value);
if (window.updateRadiantLyricsGlobalBackground) {
window.updateRadiantLyricsGlobalBackground();
}
if (
settings.settingsAffectNowPlaying &&
window.updateRadiantLyricsNowPlayingBackground
) {
window.updateRadiantLyricsNowPlayingBackground();
}
}}
/>
)}
{CoverEverywhere && (
<LunaNumberSetting
title="Background Brightness"
desc="Adjust the brightness of the spinning background (0-100, default: 40)"
min={0}
max={100}
step={1}
value={backgroundBrightness}
onNumber={(value: number) => {
console.log("Background Brightness:", value);
settings.backgroundBrightness = value;
setBackgroundBrightness(value);
if (window.updateRadiantLyricsGlobalBackground) {
window.updateRadiantLyricsGlobalBackground();
}
if (
settings.settingsAffectNowPlaying &&
window.updateRadiantLyricsNowPlayingBackground
) {
window.updateRadiantLyricsNowPlayingBackground();
}
}}
/>
)}
{CoverEverywhere && spinningArt && (
<LunaNumberSetting
title="Spin Speed"
desc="Adjust the rotation speed in seconds (10-120, default: 45) - Lower values = Faster rotation"
min={10}
max={120}
step={1}
value={spinSpeed}
onNumber={(value: number) => {
console.log("Spin Speed:", value);
settings.spinSpeed = value;
setSpinSpeed(value);
if (window.updateRadiantLyricsGlobalBackground) {
window.updateRadiantLyricsGlobalBackground();
}
if (
settings.settingsAffectNowPlaying &&
window.updateRadiantLyricsNowPlayingBackground
) {
window.updateRadiantLyricsNowPlayingBackground();
}
}}
/>
)}
{CoverEverywhere && (
<AnySwitch
title="Settings Affect Now Playing"
desc="Apply background settings to Now Playing view"
checked={settingsAffectNowPlaying}
onChange={(_: unknown, checked: boolean) => {
console.log(
"Settings Affect Now Playing:",
checked ? "enabled" : "disabled",
);
settings.settingsAffectNowPlaying = checked;
setSettingsAffectNowPlaying(checked);
// Update Now Playing background immediately when setting changes
if (window.updateRadiantLyricsNowPlayingBackground) {
window.updateRadiantLyricsNowPlayingBackground();
}
}}
/>
)}
<AnySwitch
title="Playback Reactive"
desc="Cover shader reacts to playback state by freezing/resuming"
checked={backdropPlaybackReactive}
onChange={(_: unknown, checked: boolean) => {
settings.backdropPlaybackReactive = checked;
setBackdropPlaybackReactive(checked);
refreshBackdrop();
}}
/>
<AnySwitch
title="Cover Everywhere"
desc="Apply the cover art backdrop to the entire app, not just the Now Playing view, Heavily Inspired by Cover-Theme by @Inrixia"
checked={CoverEverywhere}
onChange={(_: unknown, checked: boolean) => {
settings.CoverEverywhere = checked;
setCoverEverywhere(checked);
refreshBackdrop();
}}
/>
<AnySwitch
title="Performance Mode | Experimental"
desc="Caps the shader at 4 blur passes, disables dithering and renders at 1x pixel ratio to cut GPU load"
checked={performanceMode}
onChange={(_: unknown, checked: boolean) => {
settings.performanceMode = checked;
setPerformanceMode(checked);
refreshBackdrop();
}}
/>
<LunaNumberSetting
title="Backdrop Opacity"
desc="How strongly the backdrop shows through (0-100% default = 75)"
min={0}
max={100}
step={1}
value={backdropOpacity}
onNumber={(value: number) => {
settings.backdropOpacity = value;
setBackdropOpacity(value);
refreshBackdrop();
}}
/>
<LunaNumberSetting
title="Warp Intensity"
desc="Strength of the fluidity effect (0-100% default = 100)"
min={0}
max={100}
step={1}
value={backdropWarp}
onNumber={(value: number) => {
settings.backdropWarp = value;
setBackdropWarp(value);
refreshBackdrop();
}}
/>
<LunaNumberSetting
title="Blur Passes"
desc="Kawase blur passes, higher is softer but costs more GPU (1-40 default = 5)"
min={1}
max={40}
step={1}
value={backdropBlurPasses}
onNumber={(value: number) => {
settings.backdropBlurPasses = value;
setBackdropBlurPasses(value);
refreshBackdrop();
}}
/>
<LunaNumberSetting
title="Animation Speed"
desc="How fast the backdrop flows (0-500% default = 175)"
min={0}
max={500}
step={5}
value={backdropSpeed}
onNumber={(value: number) => {
settings.backdropSpeed = value;
setBackdropSpeed(value);
refreshBackdrop();
}}
/>
<LunaNumberSetting
title="Contrast"
desc="Contrast of the backdrop (0-300%, 100 = stock default = 125)"
min={0}
max={300}
step={5}
value={backdropContrast}
onNumber={(value: number) => {
settings.backdropContrast = value;
setBackdropContrast(value);
refreshBackdrop();
}}
/>
<LunaNumberSetting
title="Auto Darken Bright Covers"
desc="Prevents bright covers from making text unreadable (0-100% 0 = Off default = 80)"
min={0}
max={100}
step={1}
value={backdropDarken}
onNumber={(value: number) => {
settings.backdropDarken = value;
setBackdropDarken(value);
refreshBackdrop();
}}
/>
<LunaNumberSetting
title="Saturation"
desc="Colour intensity of the backdrop (0-400% default = 125)"
min={0}
max={400}
step={5}
value={backdropSaturation}
onNumber={(value: number) => {
settings.backdropSaturation = value;
setBackdropSaturation(value);
refreshBackdrop();
}}
/>
<LunaNumberSetting
title="Backdrop Scale"
desc="Zoom level of the effect (10-400% default = 100)"
min={10}
max={400}
step={5}
value={backdropScale}
onNumber={(value: number) => {
settings.backdropScale = value;
setBackdropScale(value);
refreshBackdrop();
}}
/>
<LunaNumberSetting
title="Dithering"
desc="Breaks up color banding in smooth gradients (0-100 default = 15)"
min={0}
max={100}
step={1}
value={backdropDithering}
onNumber={(value: number) => {
settings.backdropDithering = value;
setBackdropDithering(value);
refreshBackdrop();
}}
/>
</LunaSettings>
);
};
-2
View File
@@ -102,7 +102,6 @@ export interface WordLyricsResponse {
type: "Word";
data: WordLine[];
metadata: {
source: string;
title: string;
language: string;
totalDuration: string;
@@ -120,7 +119,6 @@ export interface LineLyricsResponse {
type: "Line";
data: ApiLine[];
metadata: {
source: string;
title: string;
language: string;
totalDuration: string;
@@ -0,0 +1,23 @@
/* Kawarp backdrop canvas */
.rl-kawarp-canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
display: block;
/* Z is important (breaks for some reason if changed) */
transform: translateZ(0);
backface-visibility: hidden;
transition: filter 800ms ease;
}
/* engine animates on RAF loop*/
@media (prefers-reduced-motion: reduce) {
.rl-kawarp-canvas {
/* biome-ignore lint: Accessibility override needs priority */
animation: none !important;
}
}
+381
View File
@@ -0,0 +1,381 @@
// MARKER: Kawarp Backdrop
// cover art URL -> fetch -> ImageBitmap -> kawarp -> animated backdrop <3
// Imported @ 1.2.0
import { Kawarp, type KawarpOptions } from "@kawarp/core";
import { settings } from "./Settings";
/** kawarp wants real units none if this fake luna slop (love you @inrixia <3) */
const getLiveOptions = (): KawarpOptions => {
const options: KawarpOptions = {
warpIntensity: settings.backdropWarp / 100,
blurPasses: settings.backdropBlurPasses,
animationSpeed: settings.backdropSpeed / 100,
saturation: settings.backdropSaturation / 100,
dithering: settings.backdropDithering / 1000,
scale: settings.backdropScale / 100,
};
if (!settings.performanceMode) return options;
// Super WIP (probs also barely helps atleast rn)
return {
...options,
blurPasses: Math.min(options.blurPasses ?? 8, 4),
dithering: 0,
};
};
const getDpr = (): number =>
settings.performanceMode ? 1 : Math.min(window.devicePixelRatio || 1, 2);
// Claude did all the auto darken stuff cause i'm not bothered to make this stuff a second time.. (so ignore comment bloat)
// Auto-darken samples the album art at this size to judge how bright it is.
// 16x16 is 256 pixels and the GPU does the downscale.
const SAMPLE_SIZE = 16;
// Never crush the backdrop to black, however blinding the cover art is
const MIN_BRIGHTNESS = 0.15;
// Darken strength maps onto a luminance ceiling: how bright the backdrop is
// allowed to read before it gets pulled down. Strength 1 barely touches
// anything, 100 keeps even white covers well below the lyrics.
const CEILING_AT_MIN_STRENGTH = 0.9;
const CEILING_AT_MAX_STRENGTH = 0.15;
// Seconds to coast between full speed and a standstill. Linear, so the
// deceleration is constant rather than dropping off a cliff and then crawling.
const RAMP_SECONDS = 1.8;
// kawarp's album-art crossfade defaults to 1000ms - keep rendering a little
// past that so a track change still resolves while playback is paused
const CROSSFADE_RENDER_MS = 1400;
/**
* Mean Rec. 709 luma of an image, 0-1.
*
* Measured on the source art rather than the rendered canvas. The shader output
* is in constant motion, so sampling it gives a brightness that drifts with the
* animation; the cover art is fixed, so one reading per track is exact and
* stays put. A blur preserves mean luminance, so this is also a faithful
* predictor of how bright the finished backdrop lands.
*/
let sampleCtx: CanvasRenderingContext2D | null = null;
const measureLuminance = (source: CanvasImageSource): number | null => {
if (!sampleCtx) {
const sampler = document.createElement("canvas");
sampler.width = SAMPLE_SIZE;
sampler.height = SAMPLE_SIZE;
sampleCtx = sampler.getContext("2d", { willReadFrequently: true });
}
if (!sampleCtx) return null;
try {
sampleCtx.clearRect(0, 0, SAMPLE_SIZE, SAMPLE_SIZE);
sampleCtx.drawImage(source, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE);
const { data } = sampleCtx.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE);
let sum = 0;
for (let i = 0; i < data.length; i += 4) {
// Green dominates how bright a colour reads to the eye
sum +=
(0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255;
}
return sum / (data.length / 4);
} catch {
return null;
}
};
export class KawarpLayer {
private host: HTMLElement;
private canvas: HTMLCanvasElement;
private kawarp: Kawarp | null = null;
private resizeObserver: ResizeObserver | null = null;
private currentSrc: string | null = null;
private loadToken = 0;
private appliedOptions = "";
private running = false;
private failed = false;
// Luminance of the current cover art - measured once when it loads
private coverLuminance: number | null = null;
private brightness = 1;
private playing = true;
private rafId: number | null = null;
private shaderTime = 0;
private lastFrame = 0;
// Current speed multiplier, eased between 0 and 1
private speedFactor = 1;
private transitionUntil = 0;
constructor(host: HTMLElement, id: string, zIndex: string) {
this.host = host;
this.canvas = document.createElement("canvas");
// Same id shape BetterLyrics uses, so its own detection selector matches
this.canvas.id = `better-lyrics-kawarp-${id}`;
this.canvas.className = "rl-kawarp-canvas";
this.canvas.style.zIndex = zIndex;
host.appendChild(this.canvas);
this.canvas.addEventListener(
"webglcontextlost",
this.onContextLost as EventListener,
false,
);
this.canvas.addEventListener(
"webglcontextrestored",
this.onContextRestored as EventListener,
false,
);
if (typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(() => {
this.syncSize();
});
this.resizeObserver.observe(host);
}
}
private onContextLost = (event: Event): void => {
// Preventing default is what allows a restore to be delivered
event.preventDefault();
this.running = false;
this.kawarp = null;
this.appliedOptions = "";
};
private onContextRestored = (): void => {
this.kawarp = null;
this.appliedOptions = "";
this.failed = false;
const src = this.currentSrc;
this.currentSrc = null;
this.apply(src);
};
private ensureEngine(): Kawarp | null {
if (this.kawarp || this.failed) return this.kawarp;
try {
this.syncSize();
this.kawarp = new Kawarp(this.canvas, getLiveOptions());
this.appliedOptions = JSON.stringify(getLiveOptions());
} catch (_err) {
// No WebGL/context refused
this.failed = true;
this.kawarp = null;
}
return this.kawarp;
}
private syncSize(): void {
const dpr = getDpr();
const rect = this.host.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width * dpr));
const height = Math.max(1, Math.round(rect.height * dpr));
if (this.canvas.width === width && this.canvas.height === height) return;
this.canvas.width = width;
this.canvas.height = height;
this.kawarp?.resize();
}
/** False once Tidal has rebuilt the container */
isMountedIn(container: HTMLElement): boolean {
return this.canvas.parentElement === container;
}
/**
* Whether this canvas is actually on screen.
*/
isVisible(): boolean {
if (!this.canvas.isConnected) return false;
if (typeof this.canvas.checkVisibility === "function") {
return this.canvas.checkVisibility({ checkVisibilityCSS: true });
}
return getComputedStyle(this.canvas).visibility !== "hidden";
}
/** Push current settings + cover art into engine. */
apply(src: string | null): boolean {
const engine = this.ensureEngine();
if (!engine) return false;
const options = getLiveOptions();
const serialized = JSON.stringify(options);
if (serialized !== this.appliedOptions) {
engine.setOptions(options);
this.appliedOptions = serialized;
}
const opacity = String(settings.backdropOpacity / 100);
if (this.canvas.style.opacity !== opacity)
this.canvas.style.opacity = opacity;
this.updateBrightness();
this.applyFilter();
this.syncSize();
if (src && src !== this.currentSrc) {
this.currentSrc = src;
void this.loadCover(engine, src);
}
return true;
}
/**
* Tidal's CDN sends no Access-Control-Allow-Origin (Drove me insane so using Fetch instead of kawarp)
*/
private async loadCover(engine: Kawarp, src: string): Promise<void> {
const token = ++this.loadToken;
try {
const res = await fetch(src);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
// A newer track won the race
if (token !== this.loadToken || this.kawarp !== engine) return;
// Decode once
const bitmap = await createImageBitmap(blob);
if (token !== this.loadToken || this.kawarp !== engine) {
bitmap.close();
return;
}
try {
this.coverLuminance = measureLuminance(bitmap);
engine.loadImageElement(bitmap);
this.transitionUntil =
performance.now() + CROSSFADE_RENDER_MS;
this.ensureLoop();
} finally {
bitmap.close();
}
this.updateBrightness();
} catch {
try {
if (token !== this.loadToken || this.kawarp !== engine) return;
// Fallback for data:/blob: sources & i guess CORS
this.coverLuminance = null;
await engine.loadImage(src);
this.updateBrightness();
} catch {
// Keep previous frame & later retry
if (token === this.loadToken) this.currentSrc = null;
}
}
}
/** Contrast & auto-darken use canvas itself so for once no need for a million elements <3 */
private applyFilter(): void {
const parts: string[] = [];
if (settings.backdropContrast !== 100) {
// kawarp has no contrast uniform soo CSS time <3 (@aya would be proud)
parts.push(`contrast(${settings.backdropContrast}%)`);
}
if (this.brightness < 0.999) {
parts.push(`brightness(${this.brightness.toFixed(3)})`);
}
const filter = parts.join(" ");
if (this.canvas.style.filter !== filter) this.canvas.style.filter = filter;
}
/**
* Decide the flat darkening for the current cover. (Luminance + Settings Value)
*/
private updateBrightness(): void {
let brightness = 1;
const strength = settings.backdropDarken;
if (strength > 0 && this.coverLuminance !== null) {
// container behind canvas is black so opacity scales brightness (cheap shortcut)
const effective = this.coverLuminance * (settings.backdropOpacity / 100);
const ceiling =
CEILING_AT_MIN_STRENGTH -
(strength / 100) * (CEILING_AT_MIN_STRENGTH - CEILING_AT_MAX_STRENGTH);
if (effective > ceiling && effective > 0) {
brightness = Math.max(ceiling / effective, MIN_BRIGHTNESS);
}
}
if (brightness === this.brightness) return;
this.brightness = brightness;
this.applyFilter();
}
/** Track play/pause (ramp render loop) */
setPlaying(playing: boolean): void {
if (this.playing === playing) return;
this.playing = playing;
this.ensureLoop();
}
/** Where the speed multiplier is heading (1 = playing 0 = paused) */
private get targetFactor(): number {
if (!settings.backdropPlaybackReactive) return 1;
return this.playing ? 1 : 0;
}
/**
* render loop (replacing kawarp.start RIP)
*/
private tick = (): void => {
this.rafId = null;
if (!this.kawarp || !this.running) return;
const now = performance.now();
// freeze so backgrounded tab doesn't jump shader
const dt = Math.min((now - this.lastFrame) / 1000, 0.1);
this.lastFrame = now;
const target = this.targetFactor;
const step = dt / RAMP_SECONDS;
if (this.speedFactor < target) {
this.speedFactor = Math.min(target, this.speedFactor + step);
} else if (this.speedFactor > target) {
this.speedFactor = Math.max(target, this.speedFactor - step);
}
this.shaderTime += dt * (settings.backdropSpeed / 100) * this.speedFactor;
this.kawarp.renderFrame(this.shaderTime);
// Keep rendering while a track's crossfade is still resolving (NOT audio crossfade btw [it's the fading of cover arts])
if (this.speedFactor === 0 && target === 0 && now >= this.transitionUntil) {
return;
}
this.rafId = requestAnimationFrame(this.tick);
};
private ensureLoop(): void {
if (this.rafId !== null || !this.running || !this.kawarp) return;
this.lastFrame = performance.now();
this.rafId = requestAnimationFrame(this.tick);
}
start(): void {
if (this.running || !this.kawarp) return;
this.running = true;
this.ensureLoop();
}
stop(): void {
if (!this.running) return;
this.running = false;
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
setPaused(paused: boolean): void {
if (paused) this.stop();
else this.start();
}
dispose(): void {
this.stop();
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.canvas.removeEventListener(
"webglcontextlost",
this.onContextLost as EventListener,
);
this.canvas.removeEventListener(
"webglcontextrestored",
this.onContextRestored as EventListener,
);
this.kawarp?.dispose();
this.kawarp = null;
this.canvas.remove();
this.currentSrc = null;
this.appliedOptions = "";
}
}
@@ -1,4 +1,5 @@
/* Global Spinning Background Styles - PERFORMANCE OPTIMIZED */
/* Cover-art backdrop container styles
*/
.global-background-container {
position: fixed;
@@ -9,37 +10,9 @@
z-index: -3;
pointer-events: none;
overflow: hidden;
/* Hardware acceleration */
transform: translateZ(0);
backface-visibility: hidden;
}
.global-spinning-black-bg {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
/* Dark base the shader composites over at its configured opacity */
background: #000;
z-index: -2;
pointer-events: none;
}
.global-spinning-image {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 150vw;
height: 150vh;
object-fit: cover;
z-index: -1;
filter: blur(80px) brightness(0.4) contrast(1.2) saturate(1);
opacity: 1;
animation: spinGlobal 45s linear infinite;
will-change: transform;
/* Hardware acceleration */
transform-origin: center center;
transform: translateZ(0);
backface-visibility: hidden;
}
@@ -55,7 +28,6 @@
background: transparent !important;
}
/* Now Playing Background Container Optimization */
.now-playing-background-container {
position: absolute;
left: 0;
@@ -65,49 +37,18 @@
z-index: 0;
pointer-events: none;
overflow: hidden;
/* Hardware acceleration */
background: #000;
transform: translateZ(0);
backface-visibility: hidden;
}
/* Ensure now-playing content renders above the dynamic background */
/* now-playing renders above the backdrop */
[data-test="new-now-playing"] > header,
[data-test="new-now-playing"] > [class*="_content_"] {
position: relative;
z-index: 1;
}
/* Optimized keyframe animations with GPU acceleration */
@keyframes spinGlobal {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
/* Reduced motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
.global-spinning-image,
.now-playing-background-image {
/* biome-ignore lint: Accessibility override needs priority */
animation: none !important;
/* biome-ignore lint: Accessibility override needs priority */
transform: translate(-50%, -50%) !important;
/* biome-ignore lint: Accessibility override needs priority */
will-change: auto !important;
}
}
/* Performance mode: optimize effects but keep spinning */
.performance-mode .global-spinning-image,
.performance-mode .now-playing-background-image {
/* Keep animations but optimize filter effects */
/* biome-ignore lint: Intentional override of runtime styles */
filter: blur(10px) brightness(0.4) contrast(1.1) !important;
}
/* Make app chrome transparent for cover-everywhere background */
body,
#wimp,
+232 -472
View File
@@ -19,14 +19,10 @@ import {
flushLyrics as flushLyricsApi,
romanizeLyrics as romanizeLyricsApi,
} from "./api";
import { KawarpLayer } from "./backdrop";
import { Settings, settings } from "./Settings";
// Interpret integer backgroundScale (e.g., 10=1.0x, 20=2.0x)
const getScaledMultiplier = (): number => {
const value = settings.backgroundScale;
return value / 10;
};
import backdropStylesCss from "file://backdrop-styles.css?minify";
import coverEverywhereCss from "file://cover-everywhere.css?minify";
import floatingPlayerBarCss from "file://floating-player-bar.css?minify";
import lyricsGlow from "file://lyrics-glow.css?minify";
@@ -429,8 +425,8 @@ observe<HTMLElement>(unloads, '[data-test="footer-player"]', () => {
applyIntegratedSeekBar();
});
// Apply styles.css
baseStyleTag.css = baseStyles;
// Apply styles.css + backdrop keyframes (Now Playing needs these even with Cover Everywhere off)
baseStyleTag.css = `${baseStyles}\n${backdropStylesCss}`;
// Lyrics glow vars & lyrics-glow.css (when enabled)
const updateRadiantLyricsTextGlow = function (): void {
@@ -681,48 +677,174 @@ const createHideUIButton = function (): void {
buttonContainer.insertBefore(hideUIButton, closeButton);
};
// MARKER: Background Rendering
// Variable setup
// MARKER: Background Rendering (Kawarp Shader)
// cover art URL -> kawarp -> Animated Backdrop <3
let globalSpinningBgStyleTag: StyleTag | null = null;
let globalBackgroundContainer: HTMLElement | null = null;
let globalBackgroundImage: HTMLImageElement | null = null;
let globalBlackBg: HTMLElement | null = null;
let globalGradientOverlay: HTMLElement | null = null;
let currentGlobalCoverSrc: string | null = null;
let lastUpdateTime = 0;
const getUpdateThrottle = () => (settings.performanceMode ? 1500 : 500);
// Now Playing background caching
let nowPlayingBackgroundContainer: HTMLElement | null = null;
let nowPlayingBackgroundImage: HTMLImageElement | null = null;
let nowPlayingBlackBg: HTMLElement | null = null;
let nowPlayingGradientOverlay: HTMLElement | null = null;
let spinAnimationAdded = false;
let currentCoverSrc: string | null = null;
// apply scaled pixel sizes to cover art
const applyScaledPixelSize = (img: HTMLImageElement | null): void => {
if (!img) return;
const scale = getScaledMultiplier();
const apply = () => {
const w = img.naturalWidth;
const h = img.naturalHeight;
if (w > 0 && h > 0) {
const wPx = Math.round(w * scale);
const hPx = Math.round(h * scale);
const wStr = `${wPx}px`;
const hStr = `${hPx}px`;
if (img.style.width !== wStr) img.style.width = wStr;
if (img.style.height !== hStr) img.style.height = hStr;
}
};
if (img.complete && img.naturalWidth > 0) {
apply();
} else {
img.addEventListener("load", apply, { once: true });
}
const kawarpLayers: {
global: KawarpLayer | null;
nowPlaying: KawarpLayer | null;
} = { global: null, nowPlaying: null };
const disposeKawarpLayer = (slot: "global" | "nowPlaying"): void => {
kawarpLayers[slot]?.dispose();
kawarpLayers[slot] = null;
};
// Update Cover Art background for Now Playing and Global
/**
* Only ever run one shader chain
*/
// Now Playing view slides = ~1 second (stops frozen backdrop on reveal) [Super WIP]
const PAUSE_GRACE_MS = 1500;
const pauseHiddenSince: { global: number; nowPlaying: number } = {
global: 0,
nowPlaying: 0,
};
const setLayerRunning = (
slot: "global" | "nowPlaying",
layer: KawarpLayer | null,
shouldRun: boolean,
now: number,
): void => {
if (!layer) return;
if (shouldRun) {
pauseHiddenSince[slot] = 0;
layer.setPaused(false);
return;
}
// window hidden [Untested dk why it wouldn't work tho]
if (document.hidden) {
pauseHiddenSince[slot] = 0;
layer.setPaused(true);
return;
}
if (pauseHiddenSince[slot] === 0) pauseHiddenSince[slot] = now;
if (now - pauseHiddenSince[slot] >= PAUSE_GRACE_MS) layer.setPaused(true);
};
const syncBackdropActivity = (): void => {
const nowPlaying = kawarpLayers.nowPlaying;
const global = kawarpLayers.global;
const nowPlayingVisible = nowPlaying?.isVisible() ?? false;
const now = Date.now();
// Ramp shader down when playback pauses (maybe not the best way but it does work sooo)
const playing = settings.backdropPlaybackReactive
? reduxPlaybackIsPlaying()
: true;
nowPlaying?.setPlaying(playing);
global?.setPlaying(playing);
setLayerRunning(
"nowPlaying",
nowPlaying,
!document.hidden && nowPlayingVisible && playing,
now,
);
// keep animating underneath for whole slide (stops freeze before hidden) [Super WIP]
setLayerRunning(
"global",
global,
!document.hidden &&
!nowPlayingVisible &&
playing &&
(global?.isVisible() ?? false),
now,
);
};
/** cover art @ resolution worth sampling */
const getCoverArtSrc = (): string | null => {
const targetRes = settings.performanceMode ? "640x640" : "1280x1280";
const img = document.querySelector(
'[data-test="current-media-imagery"] img',
) as HTMLImageElement | null;
if (img?.src) return img.src.replace(/\d+x\d+/, targetRes);
const video = document.querySelector(
'[data-test="current-media-imagery"] video',
) as HTMLVideoElement | null;
const poster = video?.getAttribute("poster");
return poster ? poster.replace(/\d+x\d+/, targetRes) : null;
};
/** Mount/remount kawarp canvas */
const ensureLayer = (
slot: "global" | "nowPlaying",
container: HTMLElement,
zIndex: string,
): KawarpLayer | null => {
let layer = kawarpLayers[slot];
if (layer && !layer.isMountedIn(container)) {
// Tidal rebuilt the container
disposeKawarpLayer(slot);
layer = null;
}
if (!layer) {
layer = new KawarpLayer(container, slot, zIndex);
kawarpLayers[slot] = layer;
}
return layer;
};
// Apply backdrop across whole app (cover everywhere)
const applyGlobalBackdrop = (coverArtImageSrc: string): void => {
if (!settings.CoverEverywhere) {
cleanUpGlobalBackground();
return;
}
const appContainer = document.querySelector(
'[data-test="main"]',
) as HTMLElement | null;
if (!appContainer) return;
if (!globalSpinningBgStyleTag) {
globalSpinningBgStyleTag = new StyleTag(
"RadiantLyrics-global-backdrop",
unloads,
coverEverywhereCss,
);
}
if (!globalBackgroundContainer?.isConnected) {
globalBackgroundContainer = document.createElement("div");
globalBackgroundContainer.className = "global-background-container";
appContainer.appendChild(globalBackgroundContainer);
}
ensureLayer("global", globalBackgroundContainer, "-1")?.apply(
coverArtImageSrc,
);
};
// Apply backdrop in Now Playing
const applyNowPlayingBackdrop = (coverArtImageSrc: string): void => {
const nowPlayingContainerElement = document.querySelector(
'[class*="_nowPlayingContainer"]',
) as HTMLElement | null;
if (!nowPlayingContainerElement) return;
if (
!nowPlayingBackgroundContainer?.isConnected ||
!nowPlayingContainerElement.contains(nowPlayingBackgroundContainer)
) {
nowPlayingBackgroundContainer = document.createElement("div");
nowPlayingBackgroundContainer.className =
"now-playing-background-container";
nowPlayingContainerElement.appendChild(nowPlayingBackgroundContainer);
}
ensureLayer("nowPlaying", nowPlayingBackgroundContainer, "0")?.apply(
coverArtImageSrc,
);
};
// Feed cover art into both backdrops
function updateCoverArtBackground(method: number = 0): void {
if (method === 1) {
safeTimeout(
@@ -735,315 +857,30 @@ function updateCoverArtBackground(method: number = 0): void {
return;
}
const coverArtImageElement = document.querySelector(
'[data-test="current-media-imagery"] img',
) as HTMLImageElement;
let coverArtImageSrc: string | null = null;
if (coverArtImageElement) {
coverArtImageSrc = coverArtImageElement.src;
// Use higher resolution for better quality, but consider performance mode
const targetRes = settings.performanceMode ? "640x640" : "1280x1280";
coverArtImageSrc = coverArtImageSrc.replace(/\d+x\d+/, targetRes);
if (coverArtImageElement.src !== coverArtImageSrc) {
coverArtImageElement.src = coverArtImageSrc;
}
} else {
const videoElement = document.querySelector(
'[data-test="current-media-imagery"] video',
) as HTMLVideoElement;
if (videoElement) {
coverArtImageSrc = videoElement.getAttribute("poster");
if (coverArtImageSrc) {
const targetRes = settings.performanceMode ? "640x640" : "1280x1280";
coverArtImageSrc = coverArtImageSrc.replace(/\d+x\d+/, targetRes);
}
} else {
cleanUpDynamicArt();
return;
}
}
// Update backgrounds when we have a valid cover art source
if (coverArtImageSrc) {
// Apply global spinning background if enabled
if (settings.CoverEverywhere) {
applyGlobalSpinningBackground(coverArtImageSrc);
}
// Apply spinning CoverArt background to the Now Playing container - OPTIMIZED
const nowPlayingContainerElement = document.querySelector(
'[class*="_nowPlayingContainer"]',
) as HTMLElement;
if (nowPlayingContainerElement) {
// Create DOM structure if it doesn't exist (REUSE ELEMENTS)
if (
!nowPlayingBackgroundContainer ||
!nowPlayingContainerElement.contains(nowPlayingBackgroundContainer)
) {
// Clean up any old elements first
nowPlayingContainerElement
.querySelectorAll(
".now-playing-background-image, .now-playing-black-bg, .now-playing-gradient-overlay",
)
.forEach((el) => {
el.remove();
});
// Create container
nowPlayingBackgroundContainer = document.createElement("div");
nowPlayingBackgroundContainer.className =
"now-playing-background-container";
nowPlayingBackgroundContainer.style.cssText = `
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
overflow: hidden;
`;
nowPlayingContainerElement.appendChild(nowPlayingBackgroundContainer);
// Create black background layer
nowPlayingBlackBg = document.createElement("div");
nowPlayingBlackBg.className = "now-playing-black-bg";
nowPlayingBlackBg.style.cssText = `
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: #000;
z-index: 0;
`;
nowPlayingBackgroundContainer.appendChild(nowPlayingBlackBg);
// Create image element
nowPlayingBackgroundImage = document.createElement("img");
nowPlayingBackgroundImage.className = "now-playing-background-image";
nowPlayingBackgroundImage.style.cssText = `
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
object-fit: cover;
z-index: 1;
will-change: transform;
transform-origin: center center;
`;
nowPlayingBackgroundContainer.appendChild(nowPlayingBackgroundImage);
// Create gradient overlay
nowPlayingGradientOverlay = document.createElement("div");
nowPlayingGradientOverlay.className = "now-playing-gradient-overlay";
nowPlayingGradientOverlay.style.cssText = `
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at center, transparent 0%, rgba(0, 0, 0, 0.3) 60%, rgba(0, 0, 0, 0.8) 90%);
z-index: 2;
pointer-events: none;
`;
nowPlayingBackgroundContainer.appendChild(nowPlayingGradientOverlay);
}
// Update image source efficiently
if (
nowPlayingBackgroundImage &&
nowPlayingBackgroundImage.src !== coverArtImageSrc
) {
nowPlayingBackgroundImage.src = coverArtImageSrc;
}
// Apply pixel-based size using intrinsic dimensions
applyScaledPixelSize(nowPlayingBackgroundImage);
if (nowPlayingBackgroundImage) {
const blur = settings.performanceMode
? Math.min(settings.backgroundBlur, 20)
: settings.backgroundBlur;
const contrast = settings.performanceMode
? Math.min(settings.backgroundContrast, 150)
: settings.backgroundContrast;
const radius = `${settings.backgroundRadius}%`;
if (nowPlayingBackgroundImage.style.borderRadius !== radius)
nowPlayingBackgroundImage.style.borderRadius = radius;
const filt = `blur(${blur}px) brightness(${settings.backgroundBrightness / 100}) contrast(${contrast}%)`;
if (nowPlayingBackgroundImage.style.filter !== filt)
nowPlayingBackgroundImage.style.filter = filt;
const anim = settings.spinningArt
? `spin ${settings.spinSpeed}s linear infinite`
: "none";
const wc = settings.spinningArt ? "transform" : "auto";
if (nowPlayingBackgroundImage.style.animation !== anim)
nowPlayingBackgroundImage.style.animation = anim;
if (nowPlayingBackgroundImage.style.willChange !== wc)
nowPlayingBackgroundImage.style.willChange = wc;
}
// Add keyframe animation only once
if (!spinAnimationAdded) {
const styleSheet = document.createElement("style");
styleSheet.id = "spinAnimation";
styleSheet.textContent = `
@keyframes spin {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
`;
document.head.appendChild(styleSheet);
spinAnimationAdded = true;
}
}
}
}
// Function to apply spinning background to the entire app (cover everywhere)
const applyGlobalSpinningBackground = (coverArtImageSrc: string): void => {
const appContainer = document.querySelector(
'[data-test="main"]',
) as HTMLElement;
if (!settings.CoverEverywhere) {
cleanUpGlobalSpinningBackground();
// Teardown <3
if (!settings.backdropEnabled) {
cleanUpDynamicArt();
return;
}
// Only throttle image src updates; style updates below always run for responsiveness
const now = Date.now();
const shouldUpdateImageSrc =
now - lastUpdateTime >= getUpdateThrottle() ||
currentGlobalCoverSrc !== coverArtImageSrc;
if (shouldUpdateImageSrc) {
lastUpdateTime = now;
currentGlobalCoverSrc = coverArtImageSrc;
const coverArtImageSrc = getCoverArtSrc();
if (!coverArtImageSrc) {
cleanUpDynamicArt();
return;
}
currentCoverSrc = coverArtImageSrc;
// Add StyleTag if not present
if (!globalSpinningBgStyleTag) {
globalSpinningBgStyleTag = new StyleTag(
"RadiantLyrics-global-spinning-bg",
unloads,
coverEverywhereCss,
);
}
applyGlobalBackdrop(coverArtImageSrc);
applyNowPlayingBackdrop(coverArtImageSrc);
syncBackdropActivity();
}
if (!appContainer) return;
const cleanUpGlobalBackground = function (): void {
// Release WebGL context before canvas is orphaned (otherwise death occurs)
disposeKawarpLayer("global");
// Create container structure if it doesn't exist (REUSE DOM ELEMENTS)
if (!globalBackgroundContainer) {
globalBackgroundContainer = document.createElement("div");
globalBackgroundContainer.className = "global-background-container";
globalBackgroundContainer.style.cssText = `
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
z-index: -3;
pointer-events: none;
overflow: hidden;
`;
appContainer.appendChild(globalBackgroundContainer);
// Create black background layer
globalBlackBg = document.createElement("div");
globalBlackBg.className = "global-spinning-black-bg";
globalBackgroundContainer.appendChild(globalBlackBg);
// Create image element
globalBackgroundImage = document.createElement("img");
globalBackgroundImage.className = "global-spinning-image";
globalBackgroundImage.style.cssText = `
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
object-fit: cover;
z-index: -1;
will-change: transform;
transform-origin: center center;
`;
globalBackgroundContainer.appendChild(globalBackgroundImage);
// Create gradient overlay
globalGradientOverlay = document.createElement("div");
globalGradientOverlay.className = "global-spinning-gradient-overlay";
globalGradientOverlay.style.cssText = `
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at center, transparent 0%, rgba(0, 0, 0, 0.3) 60%, rgba(0, 0, 0, 0.8) 90%);
z-index: -1;
pointer-events: none;
`;
globalBackgroundContainer.appendChild(globalGradientOverlay);
}
// Ensure gradient overlay exists even if container was pre-existing
if (!globalGradientOverlay && globalBackgroundContainer) {
globalGradientOverlay = document.createElement("div");
globalGradientOverlay.className = "global-spinning-gradient-overlay";
globalGradientOverlay.style.cssText = `
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at center, transparent 0%, rgba(0, 0, 0, 0.3) 60%, rgba(0, 0, 0, 0.8) 90%);
z-index: -1;
pointer-events: none;
`;
globalBackgroundContainer.appendChild(globalGradientOverlay);
}
// Update image source efficiently (throttled)
if (
shouldUpdateImageSrc &&
globalBackgroundImage &&
globalBackgroundImage.src !== coverArtImageSrc
) {
globalBackgroundImage.src = coverArtImageSrc;
}
if (globalBackgroundImage) {
applyScaledPixelSize(globalBackgroundImage);
const blur = settings.performanceMode
? Math.min(settings.backgroundBlur, 20)
: settings.backgroundBlur;
const contrast = settings.performanceMode
? Math.min(settings.backgroundContrast, 150)
: settings.backgroundContrast;
const radius = `${settings.backgroundRadius}%`;
globalBackgroundImage.style.filter = `blur(${blur}px) brightness(${settings.backgroundBrightness / 100}) contrast(${contrast}%)`;
if (globalBackgroundImage.style.borderRadius !== radius)
globalBackgroundImage.style.borderRadius = radius;
if (settings.spinningArt) {
globalBackgroundImage.style.animation = `spinGlobal ${settings.spinSpeed}s linear infinite`;
globalBackgroundImage.style.willChange = "transform";
} else {
globalBackgroundImage.style.animation = "none";
globalBackgroundImage.style.willChange = "auto";
}
}
};
// cleanup function
const cleanUpGlobalSpinningBackground = function (): void {
if (globalBackgroundContainer && globalBackgroundContainer.parentNode) {
globalBackgroundContainer.parentNode.removeChild(globalBackgroundContainer);
}
globalBackgroundContainer?.remove();
globalBackgroundContainer = null;
globalBackgroundImage = null;
globalBlackBg = null;
globalGradientOverlay = null;
currentGlobalCoverSrc = null;
if (globalSpinningBgStyleTag) {
globalSpinningBgStyleTag.remove();
@@ -1051,138 +888,69 @@ const cleanUpGlobalSpinningBackground = function (): void {
}
};
// Function to update global background when settings change
const updateRadiantLyricsGlobalBackground = function (): void {
// Apply performance mode class to document body
const cleanUpDynamicArt = function (): void {
disposeKawarpLayer("nowPlaying");
nowPlayingBackgroundContainer?.remove();
nowPlayingBackgroundContainer = null;
currentCoverSrc = null;
// more more teardown <3
document
.querySelectorAll(".now-playing-background-container")
.forEach((el) => {
el.remove();
});
cleanUpGlobalBackground();
};
// Re-render both backdrops when settings change
const updateRadiantLyricsBackdrop = function (): void {
if (settings.performanceMode) {
document.body.classList.add("performance-mode");
} else {
document.body.classList.remove("performance-mode");
}
if (settings.CoverEverywhere) {
// Get current cover art and apply global background
updateCoverArtBackground();
} else {
cleanUpGlobalSpinningBackground();
if (!settings.backdropEnabled) {
cleanUpDynamicArt();
return;
}
};
// Function to update Now Playing background when settings change
const updateRadiantLyricsNowPlayingBackground = function (): void {
const nowPlayingBackgroundImages = document.querySelectorAll(
".now-playing-background-image",
);
nowPlayingBackgroundImages.forEach((img: Element) => {
const imgElement = img as HTMLImageElement;
if (!settings.CoverEverywhere) cleanUpGlobalBackground();
// Default values when settings don't affect Now Playing
const defaultBlur = 80;
const defaultBrightness = 40;
const defaultContrast = 120;
const defaultSpinSpeed = 45;
let blur: number, brightness: number, contrast: number, spinSpeed: number;
if (settings.settingsAffectNowPlaying) {
blur = settings.backgroundBlur;
brightness = settings.backgroundBrightness;
contrast = settings.backgroundContrast;
spinSpeed = settings.spinSpeed;
} else {
blur = defaultBlur;
brightness = defaultBrightness;
contrast = defaultContrast;
spinSpeed = defaultSpinSpeed;
}
// Apply pixel-based size using intrinsic dimensions and current scale
applyScaledPixelSize(imgElement);
const radius = `${settings.backgroundRadius}%`;
if (imgElement.style.borderRadius !== radius)
imgElement.style.borderRadius = radius;
if (settings.performanceMode) {
blur = Math.min(blur, 20);
contrast = Math.min(contrast, 150);
}
if (settings.spinningArt) {
imgElement.style.animation = `spin ${spinSpeed}s linear infinite`;
imgElement.style.willChange = "transform";
} else {
imgElement.style.animation = "none";
imgElement.style.willChange = "auto";
}
imgElement.style.filter = `blur(${blur}px) brightness(${brightness / 100}) contrast(${contrast}%)`;
});
if (currentCoverSrc) {
if (settings.CoverEverywhere) applyGlobalBackdrop(currentCoverSrc);
applyNowPlayingBackdrop(currentCoverSrc);
syncBackdropActivity();
} else {
updateCoverArtBackground();
}
};
// Make these functions available globally so Settings can call them
(window as any).updateRadiantLyricsStyles = updateRadiantLyricsStyles;
(window as any).updateRadiantLyricsGlobalBackground =
updateRadiantLyricsGlobalBackground;
(window as any).updateRadiantLyricsNowPlayingBackground =
updateRadiantLyricsNowPlayingBackground;
(window as any).updateRadiantLyricsBackdrop = updateRadiantLyricsBackdrop;
(window as any).updateRadiantLyricsTextGlow = updateRadiantLyricsTextGlow;
(window as any).updateRadiantLyricsPlayerBarTint =
updateRadiantLyricsPlayerBarTint;
(window as any).updateQualityProgressColor = applyQualityProgressColor;
(window as any).updateIntegratedSeekBar = applyIntegratedSeekBar;
const cleanUpDynamicArt = function (): void {
// Clean up cached Now Playing elements
if (
nowPlayingBackgroundContainer &&
nowPlayingBackgroundContainer.parentNode
) {
nowPlayingBackgroundContainer.parentNode.removeChild(
nowPlayingBackgroundContainer,
);
// halts the RAF loop (drives vissibility stuff)
document.addEventListener("visibilitychange", syncBackdropActivity);
// Tidal toggles the Now Playing view with visibility (dk why they don't unmount but oki)
safeInterval(unloads, syncBackdropActivity, 200);
// React the instant the slide starts/ends
observe<HTMLElement>(unloads, '[class*="_nowPlayingContainer"]', (container) => {
for (const event of ["transitionrun", "transitionstart", "transitionend"]) {
container.addEventListener(event, syncBackdropActivity);
}
nowPlayingBackgroundContainer = null;
nowPlayingBackgroundImage = null;
nowPlayingBlackBg = null;
nowPlayingGradientOverlay = null;
// Clean up any remaining elements (fallback)
const nowPlayingBackgroundImages = document.getElementsByClassName(
"now-playing-background-image",
);
Array.from(nowPlayingBackgroundImages).forEach((element) => {
element.remove();
});
// Clean up spinning background
cleanUpGlobalSpinningBackground();
};
// I may or may not have forgotten what this does..
document.addEventListener("visibilitychange", () => {
const isHiddenDoc = document.hidden;
const images = document.querySelectorAll(
".global-spinning-image, .now-playing-background-image",
);
images.forEach((img) => {
const el = img as HTMLElement;
if (isHiddenDoc) {
// Pause animation but keep state
if (el.style.animationPlayState !== "paused")
el.style.animationPlayState = "paused";
if (el.style.willChange !== "auto") el.style.willChange = "auto";
} else {
if (el.style.animationPlayState !== "running")
el.style.animationPlayState = "running";
if (
el.classList.contains("global-spinning-image") ||
el.classList.contains("now-playing-background-image")
) {
if (el.style.willChange !== "transform")
el.style.willChange = "transform";
}
}
});
});
// Init performance mode
if (settings.performanceMode) {
document.body.classList.add("performance-mode");
@@ -1240,14 +1008,8 @@ unloads.add(() => {
el.remove();
});
// Clean up spin animations
const spinAnimationStyle = document.querySelector("#spinAnimation");
if (spinAnimationStyle && spinAnimationStyle.parentNode) {
spinAnimationStyle.parentNode.removeChild(spinAnimationStyle);
}
// Clean up spinning background
cleanUpGlobalSpinningBackground();
// even more more teardown <3
cleanUpDynamicArt();
});
// MARKER: Sticky Lyrics Feature
@@ -1917,7 +1679,10 @@ const registerSyntheticNativeLyrics = (
lyricsId: `radiant-lyrics-${trackInfo.trackId}`,
text: buildSyntheticLyricsText(response),
lrcText: buildSyntheticLrcText(response),
providerName: `Radiant Lyrics (${response.metadata.source})`,
providerName:
response.type === "Word" && response._synthesized
? "Radiant Lyrics (AI)"
: "Radiant Lyrics",
direction: "LEFT_TO_RIGHT",
response,
};
@@ -2470,10 +2235,7 @@ const fetchLyrics = async (
const derived: LineLyricsResponse = {
type: "Line",
data: cachedLyricsData.lines,
metadata: {
...cachedLyricsData.metadata,
source: cachedLyricsData.metadata.source.replace(/ • AI$/, ""),
},
metadata: cachedLyricsData.metadata,
_cached: cachedLyricsData._cached,
};
cachedLyricsKey = cacheKey;
@@ -4188,9 +3950,7 @@ const onTrackChange = async (): Promise<void> => {
);
}
sylTrace(
`RL API: loaded ${response.data.length} lines (source: ${response.metadata.source})`,
);
sylTrace(`RL API: loaded ${response.data.length} lines`);
sylLog(
`[RL-Syllable] Loaded "${trackInfo.title}" by "${trackInfo.artist}" — ${response.data.length} lines`,
);
@@ -4201,7 +3961,7 @@ const onTrackChange = async (): Promise<void> => {
if (response.type === "Word" && response._synthesized) {
lyricsIsAiGenerated = true;
sylLog(
`[RL-Syllable] AI generated syllable timings for ${response.data.length} lines (source: ${response.metadata.source})`,
`[RL-Syllable] AI generated syllable timings for ${response.data.length} lines`,
);
}
+13 -2
View File
@@ -47,7 +47,11 @@ importers:
plugins/element-hider-luna: {}
plugins/radiant-lyrics-luna: {}
plugins/radiant-lyrics-luna:
dependencies:
'@kawarp/core':
specifier: '*'
version: 1.2.0
packages:
@@ -229,6 +233,9 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@kawarp/core@1.2.0':
resolution: {integrity: sha512-PmwhFeoDjpuKImSptQugb9INCwk/S/QHsQ5foXhKjUERpwEzZZ12Hyf5FggedhZ70SQQ9RDPKHEunys454qbqA==}
'@types/clean-css@4.2.11':
resolution: {integrity: sha512-Y8n81lQVTAfP2TOdtJJEsCoYl1AnOkqDqMvXb9/7pfgZZ7r8YrEyurrAvAoAjHOGXKRybay+5CsExqIH6liccw==}
@@ -432,6 +439,7 @@ packages:
glob@11.0.2:
resolution: {integrity: sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
gopd@1.2.0:
@@ -498,7 +506,7 @@ packages:
engines: {node: 20 || >=22}
luna@https://codeload.github.com/Inrixia/TidaLuna/tar.gz/81369c7:
resolution: {tarball: https://codeload.github.com/Inrixia/TidaLuna/tar.gz/81369c7}
resolution: {gitHosted: true, tarball: https://codeload.github.com/Inrixia/TidaLuna/tar.gz/81369c7}
version: 1.5.1-beta
math-intrinsics@1.1.0:
@@ -692,6 +700,7 @@ packages:
whatwg-encoding@2.0.0:
resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
engines: {node: '>=12'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
@@ -830,6 +839,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@kawarp/core@1.2.0': {}
'@types/clean-css@4.2.11':
dependencies:
'@types/node': 22.15.29