Remove Oil Shader <3

This commit is contained in:
2026-08-14 23:03:04 +10:00
parent 3c289debbb
commit 2c384fa0cb
6 changed files with 130 additions and 902 deletions
+1 -2
View File
@@ -9,7 +9,6 @@
"main": "./src/index.ts", "main": "./src/index.ts",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@kawarp/core": "*", "@kawarp/core": "*"
"ogl": "*"
} }
} }
+119 -290
View File
@@ -61,15 +61,6 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
backdropScale: 100, backdropScale: 100,
// Readability on bright covers <3 // Readability on bright covers <3
backdropDarken: 80, 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 = () => { export const Settings = () => {
@@ -122,30 +113,6 @@ export const Settings = () => {
const [backdropDarken, setBackdropDarken] = React.useState( const [backdropDarken, setBackdropDarken] = React.useState(
settings.backdropDarken, 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( const [floatingPlayerBar, setFloatingPlayerBar] = React.useState(
settings.floatingPlayerBar, settings.floatingPlayerBar,
); );
@@ -231,9 +198,6 @@ export const Settings = () => {
window.updateRadiantLyricsBackdrop?.(); 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 // Derive props and override onChange to accept a broader first param type
type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>; type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>;
type AnySwitchProps = Omit<BaseSwitchProps, "onChange"> & { type AnySwitchProps = Omit<BaseSwitchProps, "onChange"> & {
@@ -953,9 +917,9 @@ export const Settings = () => {
<> <>
<LunaNumberSetting <LunaNumberSetting
title="Backdrop Style" // This feature is the result of a bug caused by my ADHD <3 title="Backdrop Style" // This feature is the result of a bug caused by my ADHD <3
desc="Water | Oil | Aurora" desc="Kawarp | Aurora"
min={0} min={0}
max={3} max={1}
step={1} step={1}
value={backdropStyle} value={backdropStyle}
onNumber={(value: number) => { onNumber={(value: number) => {
@@ -984,258 +948,123 @@ export const Settings = () => {
refreshBackdrop(); refreshBackdrop();
}} }}
/> />
{!isOil && ( <LunaNumberSetting
<LunaNumberSetting title="Backdrop Opacity"
title="Backdrop Opacity" desc="How strongly the backdrop shows through (0-100% default = 75)"
desc="How strongly the backdrop shows through (0-100% default = 75)" min={0}
min={0} max={100}
max={100} step={1}
step={1} value={backdropOpacity}
value={backdropOpacity} onNumber={(value: number) => {
onNumber={(value: number) => { settings.backdropOpacity = value;
settings.backdropOpacity = value; setBackdropOpacity(value);
setBackdropOpacity(value); refreshBackdrop();
refreshBackdrop(); }}
}} />
/> <LunaNumberSetting
)} title="Warp Intensity"
{!isOil && ( desc="Strength of the fluidity effect (0-100% default = 100)"
<LunaNumberSetting min={0}
title="Warp Intensity" max={100}
desc="Strength of the fluidity effect (0-100% default = 100)" step={1}
min={0} value={backdropWarp}
max={100} onNumber={(value: number) => {
step={1} settings.backdropWarp = value;
value={backdropWarp} setBackdropWarp(value);
onNumber={(value: number) => { refreshBackdrop();
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}
{!isOil && ( max={40}
<LunaNumberSetting step={1}
title="Blur Passes" value={backdropBlurPasses}
desc="Kawase blur passes, higher is softer but costs more GPU (1-40 default = 5)" onNumber={(value: number) => {
min={1} settings.backdropBlurPasses = value;
max={40} setBackdropBlurPasses(value);
step={1} refreshBackdrop();
value={backdropBlurPasses} }}
onNumber={(value: number) => { />
settings.backdropBlurPasses = value; <LunaNumberSetting
setBackdropBlurPasses(value); title="Animation Speed"
refreshBackdrop(); desc="How fast the backdrop flows (0-500% default = 175)"
}} min={0}
/> max={500}
)} step={5}
{!isOil && ( value={backdropSpeed}
<LunaNumberSetting onNumber={(value: number) => {
title="Animation Speed" settings.backdropSpeed = value;
desc="How fast the backdrop flows (0-500% default = 175)" setBackdropSpeed(value);
min={0} refreshBackdrop();
max={500} }}
step={5} />
value={backdropSpeed} <LunaNumberSetting
onNumber={(value: number) => { title="Contrast"
settings.backdropSpeed = value; desc="Contrast of the backdrop (0-300%, 100 = stock default = 125)"
setBackdropSpeed(value); min={0}
refreshBackdrop(); max={300}
}} step={5}
/> value={backdropContrast}
)} onNumber={(value: number) => {
{!isOil && ( settings.backdropContrast = value;
<LunaNumberSetting setBackdropContrast(value);
title="Contrast" refreshBackdrop();
desc="Contrast of the backdrop (0-300%, 100 = stock default = 125)" }}
min={0} />
max={300} <LunaNumberSetting
step={5} title="Auto Darken Bright Covers"
value={backdropContrast} desc="Prevents bright covers from making text unreadable (0-100% 0 = Off default = 80)"
onNumber={(value: number) => { min={0}
settings.backdropContrast = value; max={100}
setBackdropContrast(value); step={1}
refreshBackdrop(); value={backdropDarken}
}} onNumber={(value: number) => {
/> settings.backdropDarken = value;
)} setBackdropDarken(value);
{!isOil && ( refreshBackdrop();
<LunaNumberSetting }}
title="Auto Darken Bright Covers" />
desc="Prevents bright covers from making text unreadable (0-100% 0 = Off default = 80)" <LunaNumberSetting
min={0} title="Saturation"
max={100} desc="Colour intensity of the backdrop (0-400% default = 125)"
step={1} min={0}
value={backdropDarken} max={400}
onNumber={(value: number) => { step={5}
settings.backdropDarken = value; value={backdropSaturation}
setBackdropDarken(value); onNumber={(value: number) => {
refreshBackdrop(); settings.backdropSaturation = value;
}} setBackdropSaturation(value);
/> refreshBackdrop();
)} }}
{isOil && ( />
<LunaNumberSetting <LunaNumberSetting
title="Backdrop Opacity" title="Backdrop Scale"
desc="How strongly the backdrop shows through (0-100% default = 75)" desc="Zoom level of the effect (10-400% default = 100)"
min={0} min={10}
max={100} max={400}
step={1} step={5}
value={backdropOilOpacity} value={backdropScale}
onNumber={(value: number) => { onNumber={(value: number) => {
settings.backdropOilOpacity = value; settings.backdropScale = value;
setBackdropOilOpacity(value); setBackdropScale(value);
refreshBackdrop(); refreshBackdrop();
}} }}
/> />
)} <LunaNumberSetting
{isOil && ( title="Dithering"
<LunaNumberSetting desc="Breaks up color banding in smooth gradients (0-100 default = 15)"
title="Warp Intensity" min={0}
desc="Strength of the fluidity effect (0-100% default = 75)" max={100}
min={0} step={1}
max={100} value={backdropDithering}
step={1} onNumber={(value: number) => {
value={backdropOilWarp} settings.backdropDithering = value;
onNumber={(value: number) => { setBackdropDithering(value);
settings.backdropOilWarp = value; refreshBackdrop();
setBackdropOilWarp(value); }}
refreshBackdrop(); />
}}
/>
)}
{isOil && (
<LunaNumberSetting
title="Animation Speed"
desc="How fast the backdrop flows (0-500% default = 175)"
min={0}
max={500}
step={5}
value={backdropOilSpeed}
onNumber={(value: number) => {
settings.backdropOilSpeed = value;
setBackdropOilSpeed(value);
refreshBackdrop();
}}
/>
)}
{isOil && (
<LunaNumberSetting
title="Brightness"
desc="How bright the waves render (0-100, default: 15)"
min={0}
max={100}
step={1}
value={backdropOilBrightness}
onNumber={(value: number) => {
settings.backdropOilBrightness = value;
setBackdropOilBrightness(value);
refreshBackdrop();
}}
/>
)}
{isOil && (
<LunaNumberSetting
title="Contrast"
desc="Contrast of the backdrop (0-300%, 100 = unchanged)"
min={0}
max={300}
step={5}
value={backdropOilContrast}
onNumber={(value: number) => {
settings.backdropOilContrast = value;
setBackdropOilContrast(value);
refreshBackdrop();
}}
/>
)}
{isOil && (
<LunaNumberSetting
title="Saturation"
desc="Colour intensity of the backdrop (0-400%)"
min={0}
max={400}
step={5}
value={backdropOilSaturation}
onNumber={(value: number) => {
settings.backdropOilSaturation = value;
setBackdropOilSaturation(value);
refreshBackdrop();
}}
/>
)}
{isOil && !backdropOilFollowCursor && (
<LunaNumberSetting
title="Shimmer Speed"
desc="How fast the shimmer floats (0-100, default: 50)"
min={0}
max={100}
step={1}
value={backdropOilShimmerSpeed}
onNumber={(value: number) => {
settings.backdropOilShimmerSpeed = value;
setBackdropOilShimmerSpeed(value);
refreshBackdrop();
}}
/>
)}
{isOil && (
<AnySwitch
title="Shimmer Follows Cursor"
desc="Point the shimmer at your cursor instead of floating"
checked={backdropOilFollowCursor}
onChange={(_: unknown, checked: boolean) => {
settings.backdropOilFollowCursor = checked;
setBackdropOilFollowCursor(checked);
refreshBackdrop();
}}
/>
)}
{!isOil && (
<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();
}}
/>
)}
{!isOil && (
<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();
}}
/>
)}
{!isOil && (
<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();
}}
/>
)}
</> </>
)} )}
@@ -1,514 +0,0 @@
// 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<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();
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;
}
}
+3 -73
View File
@@ -43,18 +43,14 @@ const CROSSFADE_RENDER_MS = 1400;
/** Mean luma of the art (measure source, not the moving canvas) */ /** Mean luma of the art (measure source, not the moving canvas) */
let sampleCtx: CanvasRenderingContext2D | null = null; let sampleCtx: CanvasRenderingContext2D | null = null;
const ensureSampler = (): CanvasRenderingContext2D | null => { const measureLuminance = (source: CanvasImageSource): number | null => {
if (!sampleCtx) { if (!sampleCtx) {
const sampler = document.createElement("canvas"); const sampler = document.createElement("canvas");
sampler.width = SAMPLE_SIZE; sampler.width = SAMPLE_SIZE;
sampler.height = SAMPLE_SIZE; sampler.height = SAMPLE_SIZE;
sampleCtx = sampler.getContext("2d", { willReadFrequently: true }); sampleCtx = sampler.getContext("2d", { willReadFrequently: true });
} }
return sampleCtx; if (!sampleCtx) return null;
};
const measureLuminance = (source: CanvasImageSource): number | null => {
if (!ensureSampler() || !sampleCtx) return null;
try { try {
sampleCtx.clearRect(0, 0, SAMPLE_SIZE, SAMPLE_SIZE); sampleCtx.clearRect(0, 0, SAMPLE_SIZE, SAMPLE_SIZE);
sampleCtx.drawImage(source, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE); sampleCtx.drawImage(source, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE);
@@ -71,72 +67,6 @@ 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<number, { n: number; r: number; g: number; b: number }>();
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 { export class KawarpLayer {
private host: HTMLElement; private host: HTMLElement;
private canvas: HTMLCanvasElement; private canvas: HTMLCanvasElement;
@@ -223,7 +153,7 @@ export class KawarpLayer {
const width = Math.max(1, Math.round(rect.width * dpr)); 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) // Aurora: render a single row of pixels and let CSS stretch it down the whole canvas (makes bands of color)
const height = const height =
settings.backdropStyle === 3 settings.backdropStyle === 1
? 1 ? 1
: Math.max(1, Math.round(rect.height * dpr)); : Math.max(1, Math.round(rect.height * dpr));
if (this.canvas.width === width && this.canvas.height === height) return; if (this.canvas.width === width && this.canvas.height === height) return;
+7 -15
View File
@@ -20,7 +20,6 @@ import {
romanizeLyrics as romanizeLyricsApi, romanizeLyrics as romanizeLyricsApi,
} from "./api"; } from "./api";
import { KawarpLayer } from "./backdrop"; import { KawarpLayer } from "./backdrop";
import { OilLayer } from "./backdrop-oil";
import { Settings, settings } from "./Settings"; import { Settings, settings } from "./Settings";
import backdropStylesCss from "file://backdrop-styles.css?minify"; import backdropStylesCss from "file://backdrop-styles.css?minify";
@@ -690,12 +689,9 @@ let globalBackgroundContainer: HTMLElement | null = null;
let nowPlayingBackgroundContainer: HTMLElement | null = null; let nowPlayingBackgroundContainer: HTMLElement | null = null;
let currentCoverSrc: string | null = null; let currentCoverSrc: string | null = null;
/** Either renderer */
type BackdropLayer = KawarpLayer | OilLayer;
const kawarpLayers: { const kawarpLayers: {
global: BackdropLayer | null; global: KawarpLayer | null;
nowPlaying: BackdropLayer | null; nowPlaying: KawarpLayer | null;
} = { global: null, nowPlaying: null }; } = { global: null, nowPlaying: null };
const disposeKawarpLayer = (slot: "global" | "nowPlaying"): void => { const disposeKawarpLayer = (slot: "global" | "nowPlaying"): void => {
@@ -715,7 +711,7 @@ const pauseHiddenSince: { global: number; nowPlaying: number } = {
const setLayerRunning = ( const setLayerRunning = (
slot: "global" | "nowPlaying", slot: "global" | "nowPlaying",
layer: BackdropLayer | null, layer: KawarpLayer | null,
shouldRun: boolean, shouldRun: boolean,
now: number, now: number,
): void => { ): void => {
@@ -789,19 +785,15 @@ const ensureLayer = (
slot: "global" | "nowPlaying", slot: "global" | "nowPlaying",
container: HTMLElement, container: HTMLElement,
zIndex: string, zIndex: string,
): BackdropLayer | null => { ): KawarpLayer | null => {
// Oil is a whole different renderer
const wantsOil = settings.backdropStyle === 2;
let layer = kawarpLayers[slot]; let layer = kawarpLayers[slot];
if (layer && (!layer.isMountedIn(container) || wantsOil !== (layer instanceof OilLayer))) { if (layer && !layer.isMountedIn(container)) {
// Container rebuilt or renderer changed // Tidal rebuilt the container
disposeKawarpLayer(slot); disposeKawarpLayer(slot);
layer = null; layer = null;
} }
if (!layer) { if (!layer) {
layer = wantsOil layer = new KawarpLayer(container, slot, zIndex);
? new OilLayer(container, slot, zIndex)
: new KawarpLayer(container, slot, zIndex);
kawarpLayers[slot] = layer; kawarpLayers[slot] = layer;
} }
return layer; return layer;
-8
View File
@@ -52,9 +52,6 @@ importers:
'@kawarp/core': '@kawarp/core':
specifier: '*' specifier: '*'
version: 1.2.0 version: 1.2.0
ogl:
specifier: '*'
version: 1.0.11
packages: packages:
@@ -545,9 +542,6 @@ packages:
oby@15.1.2: oby@15.1.2:
resolution: {integrity: sha512-6QD9iEoPzV+pMDdcg3RtFWhgDX8pS5hZouVHvgXGDy3Q9RxFfnI3CYv9i62keeuX+qk6iN2z5E9FD3q3OckZ6A==} resolution: {integrity: sha512-6QD9iEoPzV+pMDdcg3RtFWhgDX8pS5hZouVHvgXGDy3Q9RxFfnI3CYv9i62keeuX+qk6iN2z5E9FD3q3OckZ6A==}
ogl@1.0.11:
resolution: {integrity: sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==}
opener@1.5.2: opener@1.5.2:
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
hasBin: true hasBin: true
@@ -1168,8 +1162,6 @@ snapshots:
oby@15.1.2: {} oby@15.1.2: {}
ogl@1.0.11: {}
opener@1.5.2: {} opener@1.5.2: {}
package-json-from-dist@1.0.1: {} package-json-from-dist@1.0.1: {}