From 4149add9a74bba839bd57f5354600414e68643c9 Mon Sep 17 00:00:00 2001 From: meowarex Date: Fri, 14 Aug 2026 04:25:52 +1000 Subject: [PATCH] New Shader <3 --- plugins/radiant-lyrics-luna/package.json | 3 +- plugins/radiant-lyrics-luna/src/Settings.tsx | 409 ++++++++++---- .../radiant-lyrics-luna/src/backdrop-oil.ts | 514 ++++++++++++++++++ plugins/radiant-lyrics-luna/src/backdrop.ts | 117 ++-- .../src/cover-everywhere.css | 5 +- plugins/radiant-lyrics-luna/src/index.ts | 24 +- pnpm-lock.yaml | 8 + 7 files changed, 918 insertions(+), 162 deletions(-) create mode 100644 plugins/radiant-lyrics-luna/src/backdrop-oil.ts diff --git a/plugins/radiant-lyrics-luna/package.json b/plugins/radiant-lyrics-luna/package.json index b465460..ac53930 100644 --- a/plugins/radiant-lyrics-luna/package.json +++ b/plugins/radiant-lyrics-luna/package.json @@ -9,6 +9,7 @@ "main": "./src/index.ts", "type": "module", "dependencies": { - "@kawarp/core": "*" + "@kawarp/core": "*", + "ogl": "*" } } diff --git a/plugins/radiant-lyrics-luna/src/Settings.tsx b/plugins/radiant-lyrics-luna/src/Settings.tsx index 17d3abd..95dfa87 100644 --- a/plugins/radiant-lyrics-luna/src/Settings.tsx +++ b/plugins/radiant-lyrics-luna/src/Settings.tsx @@ -61,6 +61,15 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", { backdropScale: 100, // Readability on bright covers <3 backdropDarken: 80, + // Oil's own controls + backdropOilOpacity: 75, + backdropOilWarp: 75, + backdropOilSpeed: 175, + backdropOilBrightness: 15, + backdropOilContrast: 95, + backdropOilSaturation: 125, + backdropOilShimmerSpeed: 50, + backdropOilFollowCursor: false, }); export const Settings = () => { @@ -113,6 +122,30 @@ export const Settings = () => { const [backdropDarken, setBackdropDarken] = React.useState( settings.backdropDarken, ); + const [backdropOilOpacity, setBackdropOilOpacity] = React.useState( + settings.backdropOilOpacity, + ); + const [backdropOilWarp, setBackdropOilWarp] = React.useState( + settings.backdropOilWarp, + ); + const [backdropOilSpeed, setBackdropOilSpeed] = React.useState( + settings.backdropOilSpeed, + ); + const [backdropOilBrightness, setBackdropOilBrightness] = React.useState( + settings.backdropOilBrightness, + ); + const [backdropOilContrast, setBackdropOilContrast] = React.useState( + settings.backdropOilContrast, + ); + const [backdropOilSaturation, setBackdropOilSaturation] = React.useState( + settings.backdropOilSaturation, + ); + const [backdropOilShimmerSpeed, setBackdropOilShimmerSpeed] = React.useState( + settings.backdropOilShimmerSpeed, + ); + const [backdropOilFollowCursor, setBackdropOilFollowCursor] = React.useState( + settings.backdropOilFollowCursor, + ); const [floatingPlayerBar, setFloatingPlayerBar] = React.useState( settings.floatingPlayerBar, ); @@ -198,6 +231,9 @@ export const Settings = () => { window.updateRadiantLyricsBackdrop?.(); }; + // Oil is a separate renderer (kawarp is exclusive) + const isOil = backdropStyle === 2; + // Derive props and override onChange to accept a broader first param type type BaseSwitchProps = React.ComponentProps; type AnySwitchProps = Omit & { @@ -917,9 +953,9 @@ export const Settings = () => { <> { @@ -948,123 +984,258 @@ export const Settings = () => { 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(); - }} - /> + {!isOil && ( + { + settings.backdropOpacity = value; + setBackdropOpacity(value); + refreshBackdrop(); + }} + /> + )} + {!isOil && ( + { + settings.backdropWarp = value; + setBackdropWarp(value); + refreshBackdrop(); + }} + /> + )} + {!isOil && ( + { + settings.backdropBlurPasses = value; + setBackdropBlurPasses(value); + refreshBackdrop(); + }} + /> + )} + {!isOil && ( + { + settings.backdropSpeed = value; + setBackdropSpeed(value); + refreshBackdrop(); + }} + /> + )} + {!isOil && ( + { + settings.backdropContrast = value; + setBackdropContrast(value); + refreshBackdrop(); + }} + /> + )} + {!isOil && ( + { + settings.backdropDarken = value; + setBackdropDarken(value); + refreshBackdrop(); + }} + /> + )} + {isOil && ( + { + settings.backdropOilOpacity = value; + setBackdropOilOpacity(value); + refreshBackdrop(); + }} + /> + )} + {isOil && ( + { + settings.backdropOilWarp = value; + setBackdropOilWarp(value); + refreshBackdrop(); + }} + /> + )} + {isOil && ( + { + settings.backdropOilSpeed = value; + setBackdropOilSpeed(value); + refreshBackdrop(); + }} + /> + )} + {isOil && ( + { + settings.backdropOilBrightness = value; + setBackdropOilBrightness(value); + refreshBackdrop(); + }} + /> + )} + {isOil && ( + { + settings.backdropOilContrast = value; + setBackdropOilContrast(value); + refreshBackdrop(); + }} + /> + )} + {isOil && ( + { + settings.backdropOilSaturation = value; + setBackdropOilSaturation(value); + refreshBackdrop(); + }} + /> + )} + {isOil && !backdropOilFollowCursor && ( + { + settings.backdropOilShimmerSpeed = value; + setBackdropOilShimmerSpeed(value); + refreshBackdrop(); + }} + /> + )} + {isOil && ( + { + settings.backdropOilFollowCursor = checked; + setBackdropOilFollowCursor(checked); + refreshBackdrop(); + }} + /> + )} + {!isOil && ( + { + settings.backdropSaturation = value; + setBackdropSaturation(value); + refreshBackdrop(); + }} + /> + )} + {!isOil && ( + { + settings.backdropScale = value; + setBackdropScale(value); + refreshBackdrop(); + }} + /> + )} + {!isOil && ( + { + settings.backdropDithering = value; + setBackdropDithering(value); + refreshBackdrop(); + }} + /> + )} )} diff --git a/plugins/radiant-lyrics-luna/src/backdrop-oil.ts b/plugins/radiant-lyrics-luna/src/backdrop-oil.ts new file mode 100644 index 0000000..6eb7b9c --- /dev/null +++ b/plugins/radiant-lyrics-luna/src/backdrop-oil.ts @@ -0,0 +1,514 @@ +// MARKER: Oil Backdrop +// cover art -> palette -> procedural oil waves <3 +// +// The shader below is ported verbatim from Terax's website: +// https://github.com/crynta/Terax-website - components/line-waves.tsx +// Copyright Crynta, Apache-2.0 +// +// Fully procedural, art only feeds the colours. + +import { Mesh, Program, Renderer, Triangle } from "ogl"; +import { type Rgb, samplePalette } from "./backdrop"; +import { settings } from "./Settings"; + +const getDpr = (): number => + settings.performanceMode ? 1 : Math.min(window.devicePixelRatio || 1, 2); + +// Same coast as kawarp +const RAMP_SECONDS = 1.8; +// Values below are what terax.app actually renders with +// (background-waves.tsx, not the component defaults) +const REFERENCE_WAVE_SPEED = 0.35; +const REFERENCE_WARP = 0.3; +// Slider defaults land on the reference +const REFERENCE_SPEED_AT_SETTING = 175; +const REFERENCE_WARP_AT_SETTING = 100; +// Brightness is its own setting (theirs assumes full opacity) +// Shimmer has its own clock (sharing dragged the whole pattern) +const DRIFT_SPEED_AT_SETTING = 100; +// Glide, don't snap +const POINTER_LERP = 0.04; + +// Palette crossfade rate +const COLOUR_LERP = 0.05; +// Site's values for the rest +const INNER_LINES = 40; +const OUTER_LINES = 15; +const ROTATION_DEG = -38; +const EDGE_FADE_WIDTH = 0; +// Site turns this off +const COLOUR_CYCLE_SPEED = 0; +const DRIFT_INFLUENCE = 1.6; + +const vertexShader = ` +attribute vec2 uv; +attribute vec2 position; +varying vec2 vUv; +void main() { + vUv = uv; + gl_Position = vec4(position, 0, 1); +} +`; + +const fragmentShader = ` +precision highp float; + +uniform float uTime; +uniform vec3 uResolution; +uniform float uSpeed; +uniform float uInnerLines; +uniform float uOuterLines; +uniform float uWarpIntensity; +uniform float uRotation; +uniform float uEdgeFadeWidth; +uniform float uColorCycleSpeed; +uniform float uBrightness; +uniform vec3 uColor1; +uniform vec3 uColor2; +uniform vec3 uColor3; +uniform vec2 uMouse; +uniform float uMouseInfluence; +uniform bool uEnableMouse; + +#define HALF_PI 1.5707963 + +float hashF(float n) { + return fract(sin(n * 127.1) * 43758.5453123); +} + +float smoothNoise(float x) { + float i = floor(x); + float f = fract(x); + float u = f * f * (3.0 - 2.0 * f); + return mix(hashF(i), hashF(i + 1.0), u); +} + +float displaceA(float coord, float t) { + float result = sin(coord * 2.123) * 0.2; + result += sin(coord * 3.234 + t * 4.345) * 0.1; + result += sin(coord * 0.589 + t * 0.934) * 0.5; + return result; +} + +float displaceB(float coord, float t) { + float result = sin(coord * 1.345) * 0.3; + result += sin(coord * 2.734 + t * 3.345) * 0.2; + result += sin(coord * 0.189 + t * 0.934) * 0.3; + return result; +} + +vec2 rotate2D(vec2 p, float angle) { + float c = cos(angle); + float s = sin(angle); + return vec2(p.x * c - p.y * s, p.x * s + p.y * c); +} + +void main() { + vec2 coords = gl_FragCoord.xy / uResolution.xy; + coords = coords * 2.0 - 1.0; + coords = rotate2D(coords, uRotation); + + float halfT = uTime * uSpeed * 0.5; + float fullT = uTime * uSpeed; + + float mouseWarp = 0.0; + if (uEnableMouse) { + vec2 mPos = rotate2D(uMouse * 2.0 - 1.0, uRotation); + float mDist = length(coords - mPos); + mouseWarp = uMouseInfluence * exp(-mDist * mDist * 4.0); + } + + float warpAx = coords.x + displaceA(coords.y, halfT) * uWarpIntensity + mouseWarp; + float warpAy = coords.y - displaceA(coords.x * cos(fullT) * 1.235, halfT) * uWarpIntensity; + float warpBx = coords.x + displaceB(coords.y, halfT) * uWarpIntensity + mouseWarp; + float warpBy = coords.y - displaceB(coords.x * sin(fullT) * 1.235, halfT) * uWarpIntensity; + + vec2 fieldA = vec2(warpAx, warpAy); + vec2 fieldB = vec2(warpBx, warpBy); + vec2 blended = mix(fieldA, fieldB, mix(fieldA, fieldB, 0.5)); + + float fadeTop = smoothstep(uEdgeFadeWidth, uEdgeFadeWidth + 0.4, blended.y); + float fadeBottom = smoothstep(-uEdgeFadeWidth, -(uEdgeFadeWidth + 0.4), blended.y); + float vMask = 1.0 - max(fadeTop, fadeBottom); + + float tileCount = mix(uOuterLines, uInnerLines, vMask); + float scaledY = blended.y * tileCount; + float nY = smoothNoise(abs(scaledY)); + + float ridge = pow( + step(abs(nY - blended.x) * 2.0, HALF_PI) * cos(2.0 * (nY - blended.x)), + 5.0 + ); + + float lines = 0.0; + for (float i = 1.0; i < 3.0; i += 1.0) { + lines += pow(max(fract(scaledY), fract(-scaledY)), i * 2.0); + } + + float pattern = vMask * lines; + + float cycleT = fullT * uColorCycleSpeed; + float lum = (pattern + lines * ridge) * (cos(blended.y + cycleT * 0.5) * 0.5 + 1.0); + + vec3 baseColor = (uColor1 + uColor2 + uColor3) / 3.0; + vec3 col = lum * baseColor * uBrightness; + float alpha = clamp(length(col), 0.0, 1.0); + + gl_FragColor = vec4(col, alpha); +} +`; + +/** Drifting warp point (odd periods so it barely repeats) */ +const driftPoint = (t: number): [number, number] => [ + 0.5 + 0.35 * Math.sin(t * 0.7) * Math.cos(t * 0.31), + 0.5 + 0.35 * Math.cos(t * 0.53) * Math.sin(t * 0.23), +]; + +export class OilLayer { + private host: HTMLElement; + private canvas: HTMLCanvasElement; + private renderer: Renderer | null = null; + private program: Program | null = null; + private mesh: Mesh | null = null; + private resizeObserver: ResizeObserver | null = null; + private currentSrc: string | null = null; + private loadToken = 0; + private running = false; + private failed = false; + private playing = true; + private rafId: number | null = null; + private shaderTime = 0; + private driftTime = 0; + private lastFrame = 0; + // Smoothed position + raw target + private pointer: [number, number] = [0.5, 0.5]; + private cursorTarget: [number, number] = [0.5, 0.5]; + private cursorBound = false; + // Eased 0-1 + private speedFactor = 1; + private targetColours: [Rgb, Rgb, Rgb] = [ + [1, 1, 1], + [1, 1, 1], + [1, 1, 1], + ]; + + constructor(host: HTMLElement, id: string, zIndex: string) { + this.host = host; + this.canvas = document.createElement("canvas"); + this.canvas.id = `rl-oil-${id}`; + // kawarp's class, CSS already positions it + this.canvas.className = "rl-kawarp-canvas"; + this.canvas.style.zIndex = zIndex; + + 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 onCursorMove = (event: MouseEvent): void => { + const r = this.canvas.getBoundingClientRect(); + if (!r.width || !r.height) return; + this.cursorTarget = [ + (event.clientX - r.left) / r.width, + // Shader is y-up + 1 - (event.clientY - r.top) / r.height, + ]; + }; + + private bindCursor(want: boolean): void { + if (want === this.cursorBound) return; + this.cursorBound = want; + // On window so it tracks behind content + if (want) window.addEventListener("mousemove", this.onCursorMove); + else window.removeEventListener("mousemove", this.onCursorMove); + } + + private onContextLost = (event: Event): void => { + // Needed or no restore fires + event.preventDefault(); + this.running = false; + this.renderer = null; + this.program = null; + this.mesh = null; + }; + + private onContextRestored = (): void => { + this.renderer = null; + this.program = null; + this.mesh = null; + this.failed = false; + const src = this.currentSrc; + this.currentSrc = null; + this.apply(src); + }; + + private ensureEngine(): Program | null { + if (this.program || this.failed) return this.program; + try { + // Hand ogl our canvas (keeps CSS + cleanup the same) + const renderer = new Renderer({ + canvas: this.canvas, + alpha: true, + premultipliedAlpha: false, + }); + const gl = renderer.gl; + gl.clearColor(0, 0, 0, 0); + + const program = new Program(gl, { + vertex: vertexShader, + fragment: fragmentShader, + uniforms: { + uTime: { value: 0 }, + uResolution: { value: [1, 1, 1] }, + uSpeed: { value: 1 }, + uInnerLines: { value: INNER_LINES }, + uOuterLines: { value: OUTER_LINES }, + uWarpIntensity: { value: 1 }, + uRotation: { value: (ROTATION_DEG * Math.PI) / 180 }, + uEdgeFadeWidth: { value: EDGE_FADE_WIDTH }, + uColorCycleSpeed: { value: COLOUR_CYCLE_SPEED }, + uBrightness: { value: 0.2 }, + uColor1: { value: [1, 1, 1] }, + uColor2: { value: [1, 1, 1] }, + uColor3: { value: [1, 1, 1] }, + uMouse: { value: new Float32Array([0.5, 0.5]) }, + uMouseInfluence: { value: DRIFT_INFLUENCE }, + // On, we just drive it ourselves + uEnableMouse: { value: true }, + }, + }); + + this.renderer = renderer; + this.program = program; + this.mesh = new Mesh(gl, { geometry: new Triangle(gl), program }); + this.host.appendChild(this.canvas); + this.syncSize(); + } catch (_err) { + // No WebGL/context refused + this.failed = true; + this.renderer = null; + this.program = null; + } + return this.program; + } + + private syncSize(): void { + if (!this.renderer || !this.program) return; + const dpr = getDpr(); + const rect = this.host.getBoundingClientRect(); + const width = Math.max(1, Math.round(rect.width)); + const height = Math.max(1, Math.round(rect.height)); + this.renderer.dpr = dpr; + this.renderer.setSize(width, height); + const gl = this.renderer.gl; + this.program.uniforms.uResolution.value = [ + gl.canvas.width, + gl.canvas.height, + gl.canvas.width / gl.canvas.height, + ]; + } + + /** False once Tidal has rebuilt the container */ + isMountedIn(container: HTMLElement): boolean { + return this.canvas.parentElement === container; + } + + 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 the shader. */ + apply(src: string | null): boolean { + const program = this.ensureEngine(); + if (!program) return false; + + const opacity = String(settings.backdropOilOpacity / 100); + if (this.canvas.style.opacity !== opacity) + this.canvas.style.opacity = opacity; + + program.uniforms.uWarpIntensity.value = + REFERENCE_WARP * (settings.backdropOilWarp / REFERENCE_WARP_AT_SETTING); + program.uniforms.uSpeed.value = + REFERENCE_WAVE_SPEED * + (settings.backdropOilSpeed / REFERENCE_SPEED_AT_SETTING); + // Own dial, no art here to wash out lyrics + program.uniforms.uBrightness.value = settings.backdropOilBrightness / 100; + + this.bindCursor(settings.backdropOilFollowCursor); + + // No uniforms for these, so ride the canvas + const grade: string[] = []; + if (settings.backdropOilContrast !== 100) + grade.push(`contrast(${settings.backdropOilContrast}%)`); + if (settings.backdropOilSaturation !== 100) + grade.push(`saturate(${settings.backdropOilSaturation}%)`); + const filter = grade.join(" "); + if (this.canvas.style.filter !== filter) this.canvas.style.filter = filter; + + this.syncSize(); + + if (src && src !== this.currentSrc) { + this.currentSrc = src; + void this.loadPalette(src); + } + return true; + } + + /** Tidal sends no CORS headers, so fetch + decode ourselves */ + private async loadPalette(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(); + if (token !== this.loadToken) return; + const bitmap = await createImageBitmap(blob); + try { + if (token !== this.loadToken) return; + this.targetColours = samplePalette(bitmap); + this.ensureLoop(); + } finally { + bitmap.close(); + } + } catch { + // Keep the old palette, retry later + if (token === this.loadToken) this.currentSrc = null; + } + } + + /** 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; + } + + private tick = (): void => { + this.rafId = null; + const program = this.program; + const renderer = this.renderer; + const mesh = this.mesh; + if (!program || !renderer || !mesh || !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); + } + + // Own clock in real seconds, speed lives in uSpeed + this.shaderTime += dt * this.speedFactor; + program.uniforms.uTime.value = this.shaderTime; + + // Not the speed ramp's target + let shimmer: [number, number]; + if (settings.backdropOilFollowCursor) { + shimmer = this.cursorTarget; + } else { + this.driftTime += + dt * + (settings.backdropOilShimmerSpeed / DRIFT_SPEED_AT_SETTING) * + this.speedFactor; + shimmer = driftPoint(this.driftTime); + } + // Glide when the source changes + this.pointer[0] += (shimmer[0] - this.pointer[0]) * POINTER_LERP; + this.pointer[1] += (shimmer[1] - this.pointer[1]) * POINTER_LERP; + program.uniforms.uMouse.value[0] = this.pointer[0]; + program.uniforms.uMouse.value[1] = this.pointer[1]; + + // Ease into a new track's palette rather than cutting + for (const [i, key] of (["uColor1", "uColor2", "uColor3"] as const).entries()) { + const current = program.uniforms[key].value as number[]; + const wanted = this.targetColours[i]; + for (let c = 0; c < 3; c++) { + current[c] += (wanted[c] - current[c]) * COLOUR_LERP; + } + } + + renderer.render({ scene: mesh }); + + // Stopped, park the loop + if (this.speedFactor === 0 && target === 0) return; + this.rafId = requestAnimationFrame(this.tick); + }; + + private ensureLoop(): void { + if (this.rafId !== null || !this.running || !this.program) return; + this.lastFrame = performance.now(); + this.rafId = requestAnimationFrame(this.tick); + } + + start(): void { + if (this.running || !this.program) 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.bindCursor(false); + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.canvas.removeEventListener( + "webglcontextlost", + this.onContextLost as EventListener, + ); + this.canvas.removeEventListener( + "webglcontextrestored", + this.onContextRestored as EventListener, + ); + // ogl has no disposer + this.renderer?.gl.getExtension("WEBGL_lose_context")?.loseContext(); + this.renderer = null; + this.program = null; + this.mesh = null; + this.canvas.remove(); + this.currentSrc = null; + } +} diff --git a/plugins/radiant-lyrics-luna/src/backdrop.ts b/plugins/radiant-lyrics-luna/src/backdrop.ts index dd339da..95c7f7e 100644 --- a/plugins/radiant-lyrics-luna/src/backdrop.ts +++ b/plugins/radiant-lyrics-luna/src/backdrop.ts @@ -29,48 +29,39 @@ const getDpr = (): number => // 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. +// Sample size for auto-darken (GPU does the downscale) const SAMPLE_SIZE = 16; -// Never crush the backdrop to black, however blinding the cover art is +// Never go fully black 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. +// Darken strength -> luminance ceiling 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. +// Seconds to coast to a stop (linear) 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 +// Render past the cover crossfade (1000ms) 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. - */ +/** Mean luma of the art (measure source, not the moving canvas) */ let sampleCtx: CanvasRenderingContext2D | null = null; -const measureLuminance = (source: CanvasImageSource): number | null => { +const ensureSampler = (): CanvasRenderingContext2D | 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; + return sampleCtx; +}; + +const measureLuminance = (source: CanvasImageSource): number | null => { + if (!ensureSampler() || !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 + // Green reads brightest sum += (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255; } @@ -80,6 +71,72 @@ const measureLuminance = (source: CanvasImageSource): number | null => { } }; +export type Rgb = [number, number, number]; + +const WHITE: Rgb = [1, 1, 1]; +// Scale colours up to this (keeps hue) +const PALETTE_TARGET = 0.9; +// Too dark to use +const MIN_CHANNEL = 0.15; +// Too grey to use +const MIN_SATURATION = 0.08; + +/** 3 dominant colours from the art (mono covers -> white) */ +export const samplePalette = (source: CanvasImageSource): [Rgb, Rgb, Rgb] => { + if (!ensureSampler() || !sampleCtx) return [WHITE, WHITE, WHITE]; + 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); + + // Coarse buckets so shades merge + const buckets = new Map(); + for (let i = 0; i < data.length; i += 4) { + const r = data[i] / 255; + const g = data[i + 1] / 255; + const b = data[i + 2] / 255; + const max = Math.max(r, g, b); + if (max < MIN_CHANNEL) continue; + if (max - Math.min(r, g, b) < MIN_SATURATION) continue; + const key = + ((r * 7) | 0) * 64 + ((g * 7) | 0) * 8 + ((b * 7) | 0); + const hit = buckets.get(key); + if (hit) { + hit.n++; + hit.r += r; + hit.g += g; + hit.b += b; + } else { + buckets.set(key, { n: 1, r, g, b }); + } + } + + const ranked = [...buckets.values()] + .sort((a, b) => b.n - a.n) + .slice(0, 3) + // Average so it's not grid-snapped + .map((c): Rgb => [c.r / c.n, c.g / c.n, c.b / c.n]) + // Dark covers give dark colours, so lift them + .map((c): Rgb => { + const max = Math.max(c[0], c[1], c[2]); + if (max <= 0) return WHITE; + const scale = PALETTE_TARGET / max; + return [c[0] * scale, c[1] * scale, c[2] * scale]; + }); + + if (ranked.length === 0) return [WHITE, WHITE, WHITE]; + // Cycle what we found (white would desaturate) + return [ + ranked[0], + ranked[1] ?? ranked[0], + ranked[2] ?? ranked[1] ?? ranked[0], + ]; + } catch { + return [WHITE, WHITE, WHITE]; + } +}; + + export class KawarpLayer { private host: HTMLElement; private canvas: HTMLCanvasElement; @@ -90,21 +147,21 @@ export class KawarpLayer { private appliedOptions = ""; private running = false; private failed = false; - // Luminance of the current cover art - measured once when it loads + // Measured once per cover 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 + // Eased 0-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 + // detection selector this.canvas.id = `better-lyrics-kawarp-${id}`; this.canvas.className = "rl-kawarp-canvas"; this.canvas.style.zIndex = zIndex; @@ -130,7 +187,7 @@ export class KawarpLayer { } private onContextLost = (event: Event): void => { - // Preventing default is what allows a restore to be delivered + // Needed or no restore fires event.preventDefault(); this.running = false; this.kawarp = null; @@ -166,7 +223,7 @@ export class KawarpLayer { const width = Math.max(1, Math.round(rect.width * dpr)); // Aurora: render a single row of pixels and let CSS stretch it down the whole canvas (makes bands of color) const height = - settings.backdropStyle === 1 + settings.backdropStyle === 3 ? 1 : Math.max(1, Math.round(rect.height * dpr)); if (this.canvas.width === width && this.canvas.height === height) return; @@ -180,9 +237,7 @@ export class KawarpLayer { return this.canvas.parentElement === container; } - /** - * Whether this canvas is actually on screen. - */ + /** Actually on screen? */ isVisible(): boolean { if (!this.canvas.isConnected) return false; if (typeof this.canvas.checkVisibility === "function") { @@ -228,7 +283,7 @@ export class KawarpLayer { 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 + // Newer track won if (token !== this.loadToken || this.kawarp !== engine) return; // Decode once const bitmap = await createImageBitmap(blob); diff --git a/plugins/radiant-lyrics-luna/src/cover-everywhere.css b/plugins/radiant-lyrics-luna/src/cover-everywhere.css index 9005488..efba06c 100644 --- a/plugins/radiant-lyrics-luna/src/cover-everywhere.css +++ b/plugins/radiant-lyrics-luna/src/cover-everywhere.css @@ -1,5 +1,4 @@ -/* Cover-art backdrop container styles - */ +/* Backdrop containers */ /* Stock backdrop projected app wide */ .rl-stock-background-container { @@ -109,7 +108,7 @@ z-index: -3; pointer-events: none; overflow: hidden; - /* Dark base the shader composites over at its configured opacity */ + /* Dark base under the shader */ background: #000; transform: translateZ(0); backface-visibility: hidden; diff --git a/plugins/radiant-lyrics-luna/src/index.ts b/plugins/radiant-lyrics-luna/src/index.ts index 22823e0..4c59f7a 100644 --- a/plugins/radiant-lyrics-luna/src/index.ts +++ b/plugins/radiant-lyrics-luna/src/index.ts @@ -20,6 +20,7 @@ import { romanizeLyrics as romanizeLyricsApi, } from "./api"; import { KawarpLayer } from "./backdrop"; +import { OilLayer } from "./backdrop-oil"; import { Settings, settings } from "./Settings"; import backdropStylesCss from "file://backdrop-styles.css?minify"; @@ -689,9 +690,12 @@ let globalBackgroundContainer: HTMLElement | null = null; let nowPlayingBackgroundContainer: HTMLElement | null = null; let currentCoverSrc: string | null = null; +/** Either renderer */ +type BackdropLayer = KawarpLayer | OilLayer; + const kawarpLayers: { - global: KawarpLayer | null; - nowPlaying: KawarpLayer | null; + global: BackdropLayer | null; + nowPlaying: BackdropLayer | null; } = { global: null, nowPlaying: null }; const disposeKawarpLayer = (slot: "global" | "nowPlaying"): void => { @@ -711,7 +715,7 @@ const pauseHiddenSince: { global: number; nowPlaying: number } = { const setLayerRunning = ( slot: "global" | "nowPlaying", - layer: KawarpLayer | null, + layer: BackdropLayer | null, shouldRun: boolean, now: number, ): void => { @@ -767,7 +771,7 @@ const syncBackdropActivity = (): void => { /** cover art @ resolution worth sampling */ const getCoverArtSrc = (): string | null => { - const targetRes = settings.performanceMode ? "640x640" : "1280x1280"; + const targetRes = "160x160"; const img = document.querySelector( '[data-test="current-media-imagery"] img', ) as HTMLImageElement | null; @@ -785,15 +789,19 @@ const ensureLayer = ( slot: "global" | "nowPlaying", container: HTMLElement, zIndex: string, -): KawarpLayer | null => { +): BackdropLayer | null => { + // Oil is a whole different renderer + const wantsOil = settings.backdropStyle === 2; let layer = kawarpLayers[slot]; - if (layer && !layer.isMountedIn(container)) { - // Tidal rebuilt the container + if (layer && (!layer.isMountedIn(container) || wantsOil !== (layer instanceof OilLayer))) { + // Container rebuilt or renderer changed disposeKawarpLayer(slot); layer = null; } if (!layer) { - layer = new KawarpLayer(container, slot, zIndex); + layer = wantsOil + ? new OilLayer(container, slot, zIndex) + : new KawarpLayer(container, slot, zIndex); kawarpLayers[slot] = layer; } return layer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a35446..1d992f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,9 @@ importers: '@kawarp/core': specifier: '*' version: 1.2.0 + ogl: + specifier: '*' + version: 1.0.11 packages: @@ -542,6 +545,9 @@ packages: oby@15.1.2: resolution: {integrity: sha512-6QD9iEoPzV+pMDdcg3RtFWhgDX8pS5hZouVHvgXGDy3Q9RxFfnI3CYv9i62keeuX+qk6iN2z5E9FD3q3OckZ6A==} + ogl@1.0.11: + resolution: {integrity: sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==} + opener@1.5.2: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true @@ -1162,6 +1168,8 @@ snapshots: oby@15.1.2: {} + ogl@1.0.11: {} + opener@1.5.2: {} package-json-from-dist@1.0.1: {}