From 95aa07796fe7e3ea41085cadafb338e3e04bf21e Mon Sep 17 00:00:00 2001 From: meowarex Date: Thu, 13 Aug 2026 04:17:18 +1000 Subject: [PATCH] Backdrop Rewrite (Kawarp <3) --- plugins/radiant-lyrics-luna/package.json | 5 +- plugins/radiant-lyrics-luna/src/Settings.tsx | 438 +++++------ plugins/radiant-lyrics-luna/src/api.ts | 2 - .../src/backdrop-styles.css | 23 + plugins/radiant-lyrics-luna/src/backdrop.ts | 381 ++++++++++ .../src/cover-everywhere.css | 71 +- plugins/radiant-lyrics-luna/src/index.ts | 704 ++++++------------ pnpm-lock.yaml | 15 +- 8 files changed, 857 insertions(+), 782 deletions(-) create mode 100644 plugins/radiant-lyrics-luna/src/backdrop-styles.css create mode 100644 plugins/radiant-lyrics-luna/src/backdrop.ts diff --git a/plugins/radiant-lyrics-luna/package.json b/plugins/radiant-lyrics-luna/package.json index 147affe..b465460 100644 --- a/plugins/radiant-lyrics-luna/package.json +++ b/plugins/radiant-lyrics-luna/package.json @@ -7,5 +7,8 @@ "avatarUrl": "https://avatars.githubusercontent.com/u/90243579" }, "main": "./src/index.ts", - "type": "module" + "type": "module", + "dependencies": { + "@kawarp/core": "*" + } } diff --git a/plugins/radiant-lyrics-luna/src/Settings.tsx b/plugins/radiant-lyrics-luna/src/Settings.tsx index 9392b18..07c5aeb 100644 --- a/plugins/radiant-lyrics-luna/src/Settings.tsx +++ b/plugins/radiant-lyrics-luna/src/Settings.tsx @@ -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; type AnySwitchProps = Omit & { @@ -893,225 +913,163 @@ export const Settings = () => { ); })()} { - 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 && ( - { - 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 && ( - { - 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 && ( - { - settings.backgroundScale = value; - setBackgroundScale(value); - if (window.updateRadiantLyricsGlobalBackground) { - window.updateRadiantLyricsGlobalBackground(); - } - if ( - settings.settingsAffectNowPlaying && - window.updateRadiantLyricsNowPlayingBackground - ) { - window.updateRadiantLyricsNowPlayingBackground(); - } - }} - /> - )} - {CoverEverywhere && ( - { - settings.backgroundRadius = value; - setBackgroundRadius(value); - if (window.updateRadiantLyricsGlobalBackground) { - window.updateRadiantLyricsGlobalBackground(); - } - if ( - settings.settingsAffectNowPlaying && - window.updateRadiantLyricsNowPlayingBackground - ) { - window.updateRadiantLyricsNowPlayingBackground(); - } - }} - /> - )} - {CoverEverywhere && ( - { - settings.backgroundContrast = value; - setBackgroundContrast(value); - if (window.updateRadiantLyricsGlobalBackground) { - window.updateRadiantLyricsGlobalBackground(); - } - if ( - settings.settingsAffectNowPlaying && - window.updateRadiantLyricsNowPlayingBackground - ) { - window.updateRadiantLyricsNowPlayingBackground(); - } - }} - /> - )} - {CoverEverywhere && ( - { - console.log("Background Blur:", value); - settings.backgroundBlur = value; - setBackgroundBlur(value); - if (window.updateRadiantLyricsGlobalBackground) { - window.updateRadiantLyricsGlobalBackground(); - } - if ( - settings.settingsAffectNowPlaying && - window.updateRadiantLyricsNowPlayingBackground - ) { - window.updateRadiantLyricsNowPlayingBackground(); - } - }} - /> - )} - {CoverEverywhere && ( - { - console.log("Background Brightness:", value); - settings.backgroundBrightness = value; - setBackgroundBrightness(value); - if (window.updateRadiantLyricsGlobalBackground) { - window.updateRadiantLyricsGlobalBackground(); - } - if ( - settings.settingsAffectNowPlaying && - window.updateRadiantLyricsNowPlayingBackground - ) { - window.updateRadiantLyricsNowPlayingBackground(); - } - }} - /> - )} - {CoverEverywhere && spinningArt && ( - { - console.log("Spin Speed:", value); - settings.spinSpeed = value; - setSpinSpeed(value); - if (window.updateRadiantLyricsGlobalBackground) { - window.updateRadiantLyricsGlobalBackground(); - } - if ( - settings.settingsAffectNowPlaying && - window.updateRadiantLyricsNowPlayingBackground - ) { - window.updateRadiantLyricsNowPlayingBackground(); - } - }} - /> - )} - {CoverEverywhere && ( - { - 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(); - } - }} - /> - )} + { + settings.backdropPlaybackReactive = checked; + setBackdropPlaybackReactive(checked); + refreshBackdrop(); + }} + /> + { + settings.CoverEverywhere = checked; + setCoverEverywhere(checked); + refreshBackdrop(); + }} + /> + { + settings.performanceMode = checked; + setPerformanceMode(checked); + refreshBackdrop(); + }} + /> + { + settings.backdropOpacity = value; + setBackdropOpacity(value); + refreshBackdrop(); + }} + /> + { + settings.backdropWarp = value; + setBackdropWarp(value); + refreshBackdrop(); + }} + /> + { + settings.backdropBlurPasses = value; + setBackdropBlurPasses(value); + refreshBackdrop(); + }} + /> + { + settings.backdropSpeed = value; + setBackdropSpeed(value); + refreshBackdrop(); + }} + /> + { + settings.backdropContrast = value; + setBackdropContrast(value); + refreshBackdrop(); + }} + /> + { + settings.backdropDarken = value; + setBackdropDarken(value); + refreshBackdrop(); + }} + /> + { + settings.backdropSaturation = value; + setBackdropSaturation(value); + refreshBackdrop(); + }} + /> + { + settings.backdropScale = value; + setBackdropScale(value); + refreshBackdrop(); + }} + /> + { + settings.backdropDithering = value; + setBackdropDithering(value); + refreshBackdrop(); + }} + /> + ); }; diff --git a/plugins/radiant-lyrics-luna/src/api.ts b/plugins/radiant-lyrics-luna/src/api.ts index c242b74..e0015b2 100644 --- a/plugins/radiant-lyrics-luna/src/api.ts +++ b/plugins/radiant-lyrics-luna/src/api.ts @@ -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; diff --git a/plugins/radiant-lyrics-luna/src/backdrop-styles.css b/plugins/radiant-lyrics-luna/src/backdrop-styles.css new file mode 100644 index 0000000..bcc42fd --- /dev/null +++ b/plugins/radiant-lyrics-luna/src/backdrop-styles.css @@ -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; + } +} diff --git a/plugins/radiant-lyrics-luna/src/backdrop.ts b/plugins/radiant-lyrics-luna/src/backdrop.ts new file mode 100644 index 0000000..74a8b26 --- /dev/null +++ b/plugins/radiant-lyrics-luna/src/backdrop.ts @@ -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 { + 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 = ""; + } +} diff --git a/plugins/radiant-lyrics-luna/src/cover-everywhere.css b/plugins/radiant-lyrics-luna/src/cover-everywhere.css index 74dac38..2d3935c 100644 --- a/plugins/radiant-lyrics-luna/src/cover-everywhere.css +++ b/plugins/radiant-lyrics-luna/src/cover-everywhere.css @@ -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, diff --git a/plugins/radiant-lyrics-luna/src/index.ts b/plugins/radiant-lyrics-luna/src/index.ts index 615c5f0..22f9401 100644 --- a/plugins/radiant-lyrics-luna/src/index.ts +++ b/plugins/radiant-lyrics-luna/src/index.ts @@ -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(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(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 => { ); } - 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 => { 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`, ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf60634..9a35446 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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