Backdrop Rewrite (Kawarp <3)

This commit is contained in:
2026-08-13 04:17:18 +10:00
parent 3a05506ce6
commit 95aa07796f
8 changed files with 857 additions and 782 deletions
+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`,
);
}