From a1e993facbd8df783b5143a479a4f491d549d6f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20T=C3=A4kker?= Date: Mon, 5 Oct 2026 20:44:47 +0200 Subject: [PATCH 01/10] Add album color fullscreen background --- app/components/Player/Fullscreen.vue | 18 +-- .../Player/Fullscreen/Background/Wash.vue | 106 ++++++++++++++++++ .../Player/Fullscreen/Background/palette.ts | 32 ++++++ 3 files changed, 140 insertions(+), 16 deletions(-) create mode 100644 app/components/Player/Fullscreen/Background/Wash.vue create mode 100644 app/components/Player/Fullscreen/Background/palette.ts diff --git a/app/components/Player/Fullscreen.vue b/app/components/Player/Fullscreen.vue index 3e285cf..82629f2 100644 --- a/app/components/Player/Fullscreen.vue +++ b/app/components/Player/Fullscreen.vue @@ -44,12 +44,9 @@ nuxtApp.hook("page:finish", () => { v-show="fullscreen" > @@ -95,17 +92,6 @@ nuxtApp.hook("page:finish", () => { } } -.background { - position: absolute; - top: -25%; - left: -25%; - - width: 100%; - height: 100%; - object-fit: cover; - filter: blur(40px); -} - .screen-container { position: fixed; top: 0; diff --git a/app/components/Player/Fullscreen/Background/Wash.vue b/app/components/Player/Fullscreen/Background/Wash.vue new file mode 100644 index 0000000..d61365a --- /dev/null +++ b/app/components/Player/Fullscreen/Background/Wash.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/app/components/Player/Fullscreen/Background/palette.ts b/app/components/Player/Fullscreen/Background/palette.ts new file mode 100644 index 0000000..9fce38e --- /dev/null +++ b/app/components/Player/Fullscreen/Background/palette.ts @@ -0,0 +1,32 @@ +type RGB = [number, number, number] + +/** Sample a small CPU histogram and return the most common album colors. */ +export function extractPalette(image: HTMLImageElement, size = 48): RGB[] { + const canvas = document.createElement("canvas") + canvas.width = size + canvas.height = size + const context = canvas.getContext("2d", { willReadFrequently: true }) + if (!context) return [[30, 35, 55], [70, 45, 75], [35, 75, 85], [105, 65, 45]] + + context.drawImage(image, 0, 0, size, size) + const pixels = context.getImageData(0, 0, size, size).data + const bins = new Map() + for (let i = 0; i < pixels.length; i += 4) { + if (pixels[i + 3] < 96) continue + const r = pixels[i] >> 3 + const g = pixels[i + 1] >> 3 + const b = pixels[i + 2] >> 3 + const key = (r << 10) | (g << 5) | b + const bin = bins.get(key) ?? { count: 0, r: 0, g: 0, b: 0 } + bin.count++ + bin.r += pixels[i] + bin.g += pixels[i + 1] + bin.b += pixels[i + 2] + bins.set(key, bin) + } + const colors = [...bins.values()] + .sort((a, b) => b.count - a.count) + .slice(0, 8) + .map((bin) => [bin.r / bin.count, bin.g / bin.count, bin.b / bin.count] as RGB) + return colors.length ? colors : [[30, 35, 55], [70, 45, 75], [35, 75, 85], [105, 65, 45]] +} From 29b6418e2c097d2ffae25c01d01321808783f64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20T=C3=A4kker?= Date: Mon, 5 Oct 2026 20:59:41 +0200 Subject: [PATCH 02/10] Refine fullscreen album color wash --- app/components/Player/Fullscreen.vue | 2 +- .../Player/Fullscreen/Background/Wash.vue | 111 ++++++++++---- .../Player/Fullscreen/Background/palette.ts | 140 +++++++++++++++++- 3 files changed, 215 insertions(+), 38 deletions(-) diff --git a/app/components/Player/Fullscreen.vue b/app/components/Player/Fullscreen.vue index 82629f2..3de9427 100644 --- a/app/components/Player/Fullscreen.vue +++ b/app/components/Player/Fullscreen.vue @@ -100,7 +100,7 @@ nuxtApp.hook("page:finish", () => { width: 100vw; height: 100vh; - background-color: rgba(0, 0, 0, 0.45); + background-color: rgba(0, 0, 0, 0.34); } .close-btn { diff --git a/app/components/Player/Fullscreen/Background/Wash.vue b/app/components/Player/Fullscreen/Background/Wash.vue index d61365a..48d5af6 100644 --- a/app/components/Player/Fullscreen/Background/Wash.vue +++ b/app/components/Player/Fullscreen/Background/Wash.vue @@ -1,35 +1,83 @@ diff --git a/app/components/Player/Fullscreen/Background/renderers/cpu.ts b/app/components/Player/Fullscreen/Background/renderers/cpu.ts new file mode 100644 index 0000000..633b9d1 --- /dev/null +++ b/app/components/Player/Fullscreen/Background/renderers/cpu.ts @@ -0,0 +1,116 @@ +import { fromOkLab, toOkLab } from "../palette" +import type { BackgroundFrame, BackgroundRenderer } from "./types" + +function gradientHash(x: number, y: number): [number, number] { + const hash = (a: number, b: number, xScale: number, yScale: number) => { + const value = Math.sin(a * xScale + b * yScale) * 43758.5453 + return (value - Math.floor(value)) * 2 - 1 + } + return [hash(x, y, 127.1, 311.7), hash(x, y, 269.5, 183.3)] +} + +function gradientNoise(x: number, y: number) { + const cellX = Math.floor(x) + const cellY = Math.floor(y) + const offsetX = x - cellX + const offsetY = y - cellY + const easeX = offsetX * offsetX * (3 - 2 * offsetX) + const easeY = offsetY * offsetY * (3 - 2 * offsetY) + const dot = (cx: number, cy: number, ox: number, oy: number) => { + const [gx, gy] = gradientHash(cx, cy) + return gx * ox + gy * oy + } + const lower = + dot(cellX, cellY, offsetX, offsetY) * (1 - easeX) + + dot(cellX + 1, cellY, offsetX - 1, offsetY) * easeX + const upper = + dot(cellX, cellY + 1, offsetX, offsetY - 1) * (1 - easeX) + + dot(cellX + 1, cellY + 1, offsetX - 1, offsetY - 1) * easeX + return 0.5 + 0.5 * (lower * (1 - easeY) + upper * easeY) +} + +function smoothstep(edge0: number, edge1: number, value: number) { + const amount = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0))) + return amount * amount * (3 - 2 * amount) +} + +function ditherNoise(x: number, y: number, channel: number) { + const value = Math.sin( + (x + channel * 17) * 127.1 + (y + channel * 59) * 311.7, + ) * 43758.5453 + return value - Math.floor(value) - 0.5 +} + +function flowPoint( + x: number, + y: number, + frame: BackgroundFrame, +): [number, number] { + const { random, angleJitter, flowParams, time } = frame + const degree = gradientNoise( + time * 0.1 + random[0] * 0.07, + x * y + random[1] * 0.07, + ) + const angle = (((degree - 0.5) * 720 + 180) * Math.PI) / 180 + angleJitter + const sine = Math.sin(angle) + const cosine = Math.cos(angle) + let flowX = x * cosine - y * sine + let flowY = x * sine + y * cosine + const speed = time * flowParams[2] + flowX += Math.sin(flowY * flowParams[0] + speed) / flowParams[1] + flowY += + Math.sin(flowX * flowParams[0] * 1.5 + speed) / (flowParams[1] * 0.5) + const tiltSine = Math.sin(flowParams[3]) + const tiltCosine = Math.cos(flowParams[3]) + return [ + flowX * tiltCosine - flowY * tiltSine, + flowX * tiltSine + flowY * tiltCosine, + ] +} + +export function createCpuRenderer( + canvas: HTMLCanvasElement, +): BackgroundRenderer | null { + const context = canvas.getContext("2d") + if (!context) return null + + return { + render(frame) { + if (canvas.width !== frame.width || canvas.height !== frame.height) { + canvas.width = frame.width + canvas.height = frame.height + } + const image = context.createImageData(frame.width, frame.height) + const dominant = toOkLab(frame.palette.dominant) + const accent = toOkLab(frame.palette.accent) + const maxPaletteLightness = Math.max(dominant[0], accent[0]) + for (let y = 0; y < frame.height; y++) { + for (let x = 0; x < frame.width; x++) { + const px = x / (frame.width - 1) + const py = y / (frame.height - 1) + const point = flowPoint(px - 0.5, py - 0.5, frame) + const blend = smoothstep(-0.34, 0.16, point[0]) + const transition = 1 - Math.abs(blend * 2 - 1) + const lab: [number, number, number] = [ + dominant[0] * (1 - blend) + accent[0] * blend, + dominant[1] * (1 - blend) + accent[1] * blend, + dominant[2] * (1 - blend) + accent[2] * blend, + ] + lab[0] = Math.min(maxPaletteLightness, lab[0] + frame.beat * 0.01) + lab[1] *= 1 + frame.beat * 0.02 + lab[2] *= 1 + frame.beat * 0.02 + const color = fromOkLab(lab) + const offset = (y * frame.width + x) * 4 + image.data[offset] = color[0] + ditherNoise(x, y, 0) * transition * 3 + image.data[offset + 1] = + color[1] + ditherNoise(x, y, 1) * transition * 3 + image.data[offset + 2] = + color[2] + ditherNoise(x, y, 2) * transition * 3 + image.data[offset + 3] = 255 + } + } + context.putImageData(image, 0, 0) + }, + dispose() {}, + } +} diff --git a/app/components/Player/Fullscreen/Background/renderers/shader.wgsl b/app/components/Player/Fullscreen/Background/renderers/shader.wgsl new file mode 100644 index 0000000..04f5ca2 --- /dev/null +++ b/app/components/Player/Fullscreen/Background/renderers/shader.wgsl @@ -0,0 +1,133 @@ +struct Params { + resolutionTimeBeat: vec4f, + randomAndJitter: vec4f, + flowParams: vec4f, + dominant: vec4f, + accent: vec4f, +} + +@group(0) @binding(0) var params: Params; + +fn hash(a: f32, b: f32, xScale: f32, yScale: f32) -> f32 { + let value = sin(a * xScale + b * yScale) * 43758.5453; + return fract(value) * 2.0 - 1.0; +} + +fn gradientHash(cell: vec2f) -> vec2f { + return vec2f( + hash(cell.x, cell.y, 127.1, 311.7), + hash(cell.x, cell.y, 269.5, 183.3), + ); +} + +fn gradientDot(cell: vec2f, offset: vec2f) -> f32 { + return dot(gradientHash(cell), offset); +} + +fn gradientNoise(point: vec2f) -> f32 { + let cell = floor(point); + let offset = fract(point); + let ease = offset * offset * (3.0 - 2.0 * offset); + let lower = mix( + gradientDot(cell, offset), + gradientDot(cell + vec2f(1.0, 0.0), offset - vec2f(1.0, 0.0)), + ease.x, + ); + let upper = mix( + gradientDot(cell + vec2f(0.0, 1.0), offset - vec2f(0.0, 1.0)), + gradientDot(cell + vec2f(1.0, 1.0), offset - vec2f(1.0, 1.0)), + ease.x, + ); + return 0.5 + 0.5 * mix(lower, upper, ease.y); +} + +fn flowPoint(point: vec2f, time: f32) -> vec2f { + let degree = gradientNoise(vec2f( + time * 0.1 + params.randomAndJitter.x * 0.07, + point.x * point.y + params.randomAndJitter.y * 0.07, + )); + let angle = ((degree - 0.5) * 720.0 + 180.0) * 0.017453292519943295 + + params.randomAndJitter.z; + let sine = sin(angle); + let cosine = cos(angle); + var flow = vec2f( + point.x * cosine - point.y * sine, + point.x * sine + point.y * cosine, + ); + let speed = time * params.flowParams.z; + flow.x += sin(flow.y * params.flowParams.x + speed) / params.flowParams.y; + flow.y += sin(flow.x * params.flowParams.x * 1.5 + speed) + / (params.flowParams.y * 0.5); + let tiltSine = sin(params.flowParams.w); + let tiltCosine = cos(params.flowParams.w); + return vec2f( + flow.x * tiltCosine - flow.y * tiltSine, + flow.x * tiltSine + flow.y * tiltCosine, + ); +} + +fn linearToSrgb(value: f32) -> f32 { + if (value <= 0.0031308) { + return 12.92 * value; + } + return 1.055 * pow(max(value, 0.0), 1.0 / 2.4) - 0.055; +} + +fn fromOkLab(lab: vec3f) -> vec3f { + let lRoot = lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z; + let mRoot = lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z; + let sRoot = lab.x - 0.0894841775 * lab.y - 1.291485548 * lab.z; + let l = lRoot * lRoot * lRoot; + let m = mRoot * mRoot * mRoot; + let s = sRoot * sRoot * sRoot; + let red = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + let green = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + let blue = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; + return clamp(vec3f( + linearToSrgb(red), + linearToSrgb(green), + linearToSrgb(blue), + ), vec3f(0.0), vec3f(1.0)); +} + +fn ditherNoise(x: f32, y: f32, channel: f32) -> f32 { + let value = sin( + (x + channel * 17.0) * 127.1 + (y + channel * 59.0) * 311.7, + ) * 43758.5453; + return fract(value) - 0.5; +} + +@vertex +fn vertexMain(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { + var positions = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + return vec4f(positions[index], 0.0, 1.0); +} + +@fragment +fn fragmentMain(@builtin(position) position: vec4f) -> @location(0) vec4f { + let dimensions = params.resolutionTimeBeat.xy; + let time = params.resolutionTimeBeat.z; + let beat = params.resolutionTimeBeat.w; + let uv = position.xy / dimensions; + let point = flowPoint(uv - vec2f(0.5), time); + let blend = smoothstep(-0.34, 0.16, point.x); + let transition = 1.0 - abs(blend * 2.0 - 1.0); + var lab = mix(params.dominant.xyz, params.accent.xyz, blend); + lab.x = min(params.dominant.w, lab.x + beat * 0.01); + lab.y = lab.y * (1.0 + beat * 0.02); + lab.z = lab.z * (1.0 + beat * 0.02); + let color = fromOkLab(lab); + let pixel = floor(position.xy); + let noise = vec3f( + ditherNoise(pixel.x, pixel.y, 0.0), + ditherNoise(pixel.x, pixel.y, 1.0), + ditherNoise(pixel.x, pixel.y, 2.0), + ); + let dithered = clamp(color + noise * transition * (3.0 / 255.0), + vec3f(0.0), vec3f(1.0)); + return vec4f(dithered, 1.0); +} diff --git a/app/components/Player/Fullscreen/Background/renderers/types.ts b/app/components/Player/Fullscreen/Background/renderers/types.ts new file mode 100644 index 0000000..9fd489a --- /dev/null +++ b/app/components/Player/Fullscreen/Background/renderers/types.ts @@ -0,0 +1,17 @@ +import type { AlbumPalette } from "../palette" + +export interface BackgroundFrame { + width: number + height: number + time: number + beat: number + random: [number, number] + angleJitter: number + flowParams: [number, number, number, number] + palette: AlbumPalette +} + +export interface BackgroundRenderer { + render(frame: BackgroundFrame): void + dispose(): void +} diff --git a/app/components/Player/Fullscreen/Background/renderers/webgpu.ts b/app/components/Player/Fullscreen/Background/renderers/webgpu.ts new file mode 100644 index 0000000..b057fda --- /dev/null +++ b/app/components/Player/Fullscreen/Background/renderers/webgpu.ts @@ -0,0 +1,181 @@ +import { toOkLab } from "../palette" +import shader from "./shader.wgsl?raw" +import type { BackgroundFrame, BackgroundRenderer } from "./types" + +const uniformUsage = 0x0040 | 0x0008 + +interface GpuBuffer { + destroy(): void +} + +interface GpuPass { + setPipeline(pipeline: unknown): void + setBindGroup(index: number, bindGroup: unknown): void + draw(vertexCount: number, instanceCount?: number): void + end(): void +} + +interface GpuDevice { + queue: { + writeBuffer(buffer: GpuBuffer, offset: number, data: Float32Array): void + submit(commands: unknown[]): void + } + lost: Promise<{ message: string }> + createBuffer(descriptor: { size: number; usage: number }): GpuBuffer + createShaderModule(descriptor: { code: string }): { + getCompilationInfo(): Promise<{ + messages: Array<{ type: string; message: string }> + }> + } + createRenderPipeline(descriptor: unknown): { + getBindGroupLayout(index: number): unknown + } + createBindGroup(descriptor: unknown): unknown + createCommandEncoder(): { + beginRenderPass(descriptor: unknown): GpuPass + finish(): unknown + } + destroy(): void +} + +interface GpuAdapter { + requestDevice(): Promise +} + +interface GpuApi { + requestAdapter(): Promise + getPreferredCanvasFormat(): string +} + +interface GpuCanvasContext { + configure(options: { + device: GpuDevice + format: string + alphaMode: "opaque" + }): void + getCurrentTexture(): { createView(): unknown } + unconfigure(): void +} + +export async function createWebGpuRenderer( + canvas: HTMLCanvasElement, + onDeviceLost: () => void, +): Promise { + let device: GpuDevice | null = null + let context: GpuCanvasContext | null = null + let uniformBuffer: GpuBuffer | null = null + try { + const gpu = (navigator as unknown as { gpu?: GpuApi }).gpu + if (!gpu) { + console.info("[background renderer] WebGPU API unavailable; using CPU") + return null + } + const adapter = await gpu.requestAdapter() + if (!adapter) { + console.info("[background renderer] no WebGPU adapter; using CPU") + return null + } + device = await adapter.requestDevice() + const rawContext = (canvas.getContext as (kind: string) => unknown).call( + canvas, + "webgpu", + ) + if (!rawContext) { + device.destroy() + return null + } + context = rawContext as GpuCanvasContext + const format = gpu.getPreferredCanvasFormat() + context.configure({ device, format, alphaMode: "opaque" }) + + const shaderModule = device.createShaderModule({ code: shader }) + const compilation = await shaderModule.getCompilationInfo() + const shaderErrors = compilation.messages.filter( + (message) => message.type === "error", + ) + if (shaderErrors.length) { + throw new Error(shaderErrors.map((message) => message.message).join("\n")) + } + const pipeline = device.createRenderPipeline({ + layout: "auto", + vertex: { module: shaderModule, entryPoint: "vertexMain" }, + fragment: { + module: shaderModule, + entryPoint: "fragmentMain", + targets: [{ format }], + }, + primitive: { topology: "triangle-list" }, + }) + uniformBuffer = device.createBuffer({ size: 80, usage: uniformUsage }) + const bindGroup = device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [{ binding: 0, resource: { buffer: uniformBuffer } }], + }) + const activeDevice = device + const activeContext = context + const activeBuffer = uniformBuffer + let disposed = false + void device.lost.then(() => { + if (!disposed) onDeviceLost() + }) + + return { + render(frame: BackgroundFrame) { + if (canvas.width !== frame.width || canvas.height !== frame.height) { + canvas.width = frame.width + canvas.height = frame.height + } + const dominant = toOkLab(frame.palette.dominant) + const accent = toOkLab(frame.palette.accent) + const uniforms = new Float32Array([ + frame.width, + frame.height, + frame.time, + frame.beat, + frame.random[0], + frame.random[1], + frame.angleJitter, + 0, + ...frame.flowParams, + dominant[0], + dominant[1], + dominant[2], + Math.max(dominant[0], accent[0]), + accent[0], + accent[1], + accent[2], + 0, + ]) + activeDevice.queue.writeBuffer(activeBuffer, 0, uniforms) + const encoder = activeDevice.createCommandEncoder() + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: activeContext.getCurrentTexture().createView(), + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + loadOp: "clear", + storeOp: "store", + }, + ], + }) + pass.setPipeline(pipeline) + pass.setBindGroup(0, bindGroup) + pass.draw(3, 1) + pass.end() + activeDevice.queue.submit([encoder.finish()]) + }, + dispose() { + disposed = true + activeContext.unconfigure() + activeBuffer.destroy() + activeDevice.destroy() + }, + } + } catch (error) { + console.warn("[background renderer] WebGPU unavailable; using CPU", error) + uniformBuffer?.destroy() + context?.unconfigure() + device?.destroy() + return null + } +} From b190d1671c11f4267526ab1f0b0e120a4311e91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20T=C3=A4kker?= Date: Tue, 6 Oct 2026 02:33:07 +0200 Subject: [PATCH 10/10] Optimize fullscreen rendering --- app/components/Player/Fullscreen.vue | 4 +- .../Player/Fullscreen/Background/Wash.vue | 45 +++++++++------- .../Fullscreen/Background/renderers/webgpu.ts | 53 +++++++++++-------- .../Player/Fullscreen/LyricsDisplay.vue | 44 ++++++++++----- app/plugins/player.ts | 12 ++--- 5 files changed, 93 insertions(+), 65 deletions(-) diff --git a/app/components/Player/Fullscreen.vue b/app/components/Player/Fullscreen.vue index 3de9427..53d9075 100644 --- a/app/components/Player/Fullscreen.vue +++ b/app/components/Player/Fullscreen.vue @@ -81,8 +81,8 @@ nuxtApp.hook("page:finish", () => { top: 0; left: 0; - width: 200vw; - height: 200vh; + width: 100vw; + height: 100vh; z-index: 150; cursor: none; diff --git a/app/components/Player/Fullscreen/Background/Wash.vue b/app/components/Player/Fullscreen/Background/Wash.vue index 56c3c1e..95e430c 100644 --- a/app/components/Player/Fullscreen/Background/Wash.vue +++ b/app/components/Player/Fullscreen/Background/Wash.vue @@ -10,14 +10,15 @@ import { shapeBeatPulse } from "~/utils/beat" const props = defineProps<{ src: string; active: boolean }>() const ANIMATION_SPEED = 0.3 +const FRAME_RATE = 12 +const FRAME_INTERVAL = 1000 / FRAME_RATE const { beatEnergy } = usePlayer() const cpuCanvas = ref(null) const webGpuCanvas = ref(null) const palette = ref(null) const backend = ref<"cpu" | "webgpu">("cpu") -let frame = 0 +let frameTimer: number | null = null let startedAt = 0 -let lastFrameAt = 0 let random: [number, number] = [0, 0] let angleJitter = 0 let flowParams: [number, number, number, number] = [5, 25, 0.75, -0.08] @@ -111,17 +112,17 @@ function makeFrame(now: number, canvas: HTMLCanvasElement): BackgroundFrame { } } +function updateCanvasScale(canvas: HTMLCanvasElement, beat: number) { + const transform = `scale(${1.08 + beat * 0.003})` + if (canvas.style.transform !== transform) canvas.style.transform = transform +} + function draw(now: number) { if (!props.active || disposed) return - if (now - lastFrameAt < 1000 / 24) { - frame = requestAnimationFrame(draw) - return - } - lastFrameAt = now const canvas = backend.value === "webgpu" ? webGpuCanvas.value : cpuCanvas.value if (canvas && activeRenderer) { const renderFrame = makeFrame(now, canvas) - canvas.style.transform = `scale(${1.08 + renderFrame.beat * 0.003})` + updateCanvasScale(canvas, renderFrame.beat) try { activeRenderer.render(renderFrame) } catch (error) { @@ -133,10 +134,8 @@ function draw(now: number) { webGpuRenderer?.dispose() webGpuRenderer = null useCpuFallback() - cpuCanvas.value?.style.setProperty( - "transform", - `scale(${1.08 + renderFrame.beat * 0.003})`, - ) + if (cpuCanvas.value) + updateCanvasScale(cpuCanvas.value, renderFrame.beat) try { cpuRenderer?.render(renderFrame) } catch (fallbackError) { @@ -147,15 +146,18 @@ function draw(now: number) { } } } - frame = requestAnimationFrame(draw) + frameTimer = window.setTimeout(() => { + frameTimer = null + draw(performance.now()) + }, FRAME_INTERVAL) } function startAnimation() { - cancelAnimationFrame(frame) + if (frameTimer !== null) window.clearTimeout(frameTimer) + frameTimer = null startedAt = 0 - lastFrameAt = 0 randomizeMotion() - frame = requestAnimationFrame(draw) + draw(performance.now()) } watch(() => props.src, loadArtwork) @@ -163,7 +165,10 @@ watch( () => props.active, (active) => { if (active) startAnimation() - else cancelAnimationFrame(frame) + else if (frameTimer !== null) { + window.clearTimeout(frameTimer) + frameTimer = null + } }, ) @@ -175,7 +180,7 @@ onMounted(() => { onBeforeUnmount(() => { disposed = true - cancelAnimationFrame(frame) + if (frameTimer !== null) window.clearTimeout(frameTimer) webGpuRenderer?.dispose() cpuRenderer?.dispose() }) @@ -200,8 +205,8 @@ onBeforeUnmount(() => { .background-wash { position: absolute; inset: 0; - width: 50%; - height: 50%; + width: 100%; + height: 100%; filter: blur(6px) saturate(1.08); transform: scale(1.08); visibility: hidden; diff --git a/app/components/Player/Fullscreen/Background/renderers/webgpu.ts b/app/components/Player/Fullscreen/Background/renderers/webgpu.ts index b057fda..dae2c55 100644 --- a/app/components/Player/Fullscreen/Background/renderers/webgpu.ts +++ b/app/components/Player/Fullscreen/Background/renderers/webgpu.ts @@ -43,7 +43,9 @@ interface GpuAdapter { } interface GpuApi { - requestAdapter(): Promise + requestAdapter(options?: { + powerPreference?: "low-power" | "high-performance" + }): Promise getPreferredCanvasFormat(): string } @@ -70,7 +72,9 @@ export async function createWebGpuRenderer( console.info("[background renderer] WebGPU API unavailable; using CPU") return null } - const adapter = await gpu.requestAdapter() + const adapter = await gpu.requestAdapter({ + powerPreference: "high-performance", + }) if (!adapter) { console.info("[background renderer] no WebGPU adapter; using CPU") return null @@ -114,6 +118,11 @@ export async function createWebGpuRenderer( const activeDevice = device const activeContext = context const activeBuffer = uniformBuffer + const uniforms = new Float32Array(20) + let lastPalette: BackgroundFrame["palette"] | null = null + let dominant: [number, number, number] = [0, 0, 0] + let accent: [number, number, number] = [0, 0, 0] + let maxPaletteLightness = 0 let disposed = false void device.lost.then(() => { if (!disposed) onDeviceLost() @@ -125,27 +134,25 @@ export async function createWebGpuRenderer( canvas.width = frame.width canvas.height = frame.height } - const dominant = toOkLab(frame.palette.dominant) - const accent = toOkLab(frame.palette.accent) - const uniforms = new Float32Array([ - frame.width, - frame.height, - frame.time, - frame.beat, - frame.random[0], - frame.random[1], - frame.angleJitter, - 0, - ...frame.flowParams, - dominant[0], - dominant[1], - dominant[2], - Math.max(dominant[0], accent[0]), - accent[0], - accent[1], - accent[2], - 0, - ]) + if (lastPalette !== frame.palette) { + dominant = toOkLab(frame.palette.dominant) + accent = toOkLab(frame.palette.accent) + maxPaletteLightness = Math.max(dominant[0], accent[0]) + lastPalette = frame.palette + } + uniforms[0] = frame.width + uniforms[1] = frame.height + uniforms[2] = frame.time + uniforms[3] = frame.beat + uniforms[4] = frame.random[0] + uniforms[5] = frame.random[1] + uniforms[6] = frame.angleJitter + uniforms[7] = 0 + uniforms.set(frame.flowParams, 8) + uniforms.set(dominant, 12) + uniforms[15] = maxPaletteLightness + uniforms.set(accent, 16) + uniforms[19] = 0 activeDevice.queue.writeBuffer(activeBuffer, 0, uniforms) const encoder = activeDevice.createCommandEncoder() const pass = encoder.beginRenderPass({ diff --git a/app/components/Player/Fullscreen/LyricsDisplay.vue b/app/components/Player/Fullscreen/LyricsDisplay.vue index 65d5aef..0a60b8e 100644 --- a/app/components/Player/Fullscreen/LyricsDisplay.vue +++ b/app/components/Player/Fullscreen/LyricsDisplay.vue @@ -51,16 +51,30 @@ const height = ref() const top = ref(0) const syncedHeight = ref() const layoutVersion = ref(0) +const lyricOffsets = ref([]) const topFadeExtension = 75 const updateLyricsLayout = () => { const trackDisplay = document.querySelector(".track-display") - if (!trackDisplay) return + if (trackDisplay) { + height.value = trackDisplay.clientHeight + top.value = trackDisplay.getBoundingClientRect().top + syncedHeight.value = window.innerHeight - top.value - 100 + } - height.value = trackDisplay.clientHeight - top.value = trackDisplay.getBoundingClientRect().top - syncedHeight.value = window.innerHeight - top.value - 100 + const lines = containerRef.value?.children + if (!lines) { + lyricOffsets.value = [] + return + } + + let accumulatedHeight = 0 + lyricOffsets.value = Array.from(lines, (line) => { + const offset = accumulatedHeight + accumulatedHeight += line.getBoundingClientRect().height + return offset + }) } watch( @@ -87,18 +101,20 @@ const outerRef = useTemplateRef("outer-ref") const transformY = computed(() => { layoutVersion.value - const lines = containerRef.value?.children - if (scrollActiveIndex.value < 0 || !lines?.length) return 0 - - let accumulatedHeight = 0 - for (let i = 0; i < scrollActiveIndex.value; i++) { - const line = lines[i] as HTMLElement | undefined - if (line) accumulatedHeight += line.getBoundingClientRect().height - } - - return -accumulatedHeight + const offset = lyricOffsets.value[scrollActiveIndex.value] + return offset === undefined ? 0 : -offset }) +watch( + parsedLyrics, + async () => { + await nextTick() + updateLyricsLayout() + layoutVersion.value++ + }, + { flush: "post" }, +) + const manualOffset = ref(0) const isScrolling = ref(false) diff --git a/app/plugins/player.ts b/app/plugins/player.ts index 01ce300..7d63ced 100644 --- a/app/plugins/player.ts +++ b/app/plugins/player.ts @@ -21,7 +21,7 @@ export default defineNuxtPlugin((nuxtApp) => { } const VOLUME_STORAGE_KEY = "music-v2-player-volume" const DEFAULT_VOLUME = 0.5 - const DEBUG_BEAT_ANALYSIS = true + const DEBUG_BEAT_ANALYSIS = false const sound = ref(null) const queue = ref([]) @@ -90,6 +90,7 @@ export default defineNuxtPlugin((nuxtApp) => { }) const duration = ref(0) let lastPositionUpdate = 0 + let trackingTimer: number | null = null let mediaSessionSongId: string | null = null const stopBeatAnalysis = (reason = "stopped") => { @@ -233,8 +234,6 @@ export default defineNuxtPlugin((nuxtApp) => { } } - const aniFrame = ref(null) - // Helpers const updateMediaSession = () => { if (!import.meta.client) return @@ -334,6 +333,7 @@ export default defineNuxtPlugin((nuxtApp) => { } const startTracking = () => { + if (trackingTimer !== null) window.clearTimeout(trackingTimer) const update = () => { tick.value++ const now = Date.now() @@ -341,15 +341,15 @@ export default defineNuxtPlugin((nuxtApp) => { lastPositionUpdate = now updateMediaSession() } - aniFrame.value = requestAnimationFrame(update) + trackingTimer = window.setTimeout(update, 100) } update() } const stopTracking = () => { - if (aniFrame.value) cancelAnimationFrame(aniFrame.value) - aniFrame.value = null + if (trackingTimer !== null) window.clearTimeout(trackingTimer) + trackingTimer = null } const createHowl = async (song: Track) => {