diff --git a/app/components/Player/Fullscreen.vue b/app/components/Player/Fullscreen.vue
index 7d0c1dd..7417190 100644
--- a/app/components/Player/Fullscreen.vue
+++ b/app/components/Player/Fullscreen.vue
@@ -1,57 +1,10 @@
+
+
+
+
+
+
+
diff --git a/app/components/Player/Fullscreen/Background/palette.ts b/app/components/Player/Fullscreen/Background/palette.ts
new file mode 100644
index 0000000..1927822
--- /dev/null
+++ b/app/components/Player/Fullscreen/Background/palette.ts
@@ -0,0 +1,158 @@
+type RGB = [number, number, number]
+type Lab = [number, number, number]
+
+export interface AlbumPalette {
+ dominant: RGB
+ accent: RGB
+}
+
+export function toOkLab([red, green, blue]: RGB): Lab {
+ const linear = (value: number) => {
+ const channel = value / 255
+ return channel <= 0.04045
+ ? channel / 12.92
+ : ((channel + 0.055) / 1.055) ** 2.4
+ }
+ const r = linear(red)
+ const g = linear(green)
+ const b = linear(blue)
+ const l = Math.cbrt(
+ 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b,
+ )
+ const m = Math.cbrt(
+ 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b,
+ )
+ const s = Math.cbrt(
+ 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b,
+ )
+ return [
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
+ ]
+}
+
+export function fromOkLab([lightness, a, b]: Lab): RGB {
+ const lRoot = lightness + 0.3963377774 * a + 0.2158037573 * b
+ const mRoot = lightness - 0.1055613458 * a - 0.0638541728 * b
+ const sRoot = lightness - 0.0894841775 * a - 1.291485548 * b
+ const l = lRoot ** 3
+ const m = mRoot ** 3
+ const s = sRoot ** 3
+ const encode = (value: number) => {
+ const channel =
+ value <= 0.0031308
+ ? 12.92 * value
+ : 1.055 * Math.max(value, 0) ** (1 / 2.4) - 0.055
+ return Math.max(0, Math.min(1, channel)) * 255
+ }
+ return [
+ encode(
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+ ),
+ encode(
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+ ),
+ encode(
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
+ ),
+ ]
+}
+
+/** Sample a small CPU histogram and return the most common album colors. */
+export function extractPalette(image: HTMLImageElement, size = 48): AlbumPalette {
+ const canvas = document.createElement("canvas")
+ canvas.width = size
+ canvas.height = size
+ const context = canvas.getContext("2d", { willReadFrequently: true })
+ if (!context) {
+ return { dominant: [30, 35, 55], accent: [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 entries = [...bins.values()].map((bin) => ({
+ count: bin.count,
+ color: [bin.r / bin.count, bin.g / bin.count, bin.b / bin.count] as RGB,
+ }))
+ if (!entries.length) {
+ return { dominant: [30, 35, 55], accent: [105, 65, 45] }
+ }
+ const distance = (a: RGB, b: RGB) => {
+ const first = toOkLab(a)
+ const second = toOkLab(b)
+ return (first[0] - second[0]) ** 2 + (first[1] - second[1]) ** 2 + (first[2] - second[2]) ** 2
+ }
+ const centers: RGB[] = [entries.reduce((best, entry) => entry.count > best.count ? entry : best).color]
+ while (centers.length < 4) {
+ let candidate = entries[0]
+ let bestScore = -1
+ for (const entry of entries) {
+ const nearestDistance = Math.min(...centers.map((center) => distance(entry.color, center)))
+ const score = nearestDistance * Math.sqrt(entry.count)
+ if (score > bestScore) {
+ bestScore = score
+ candidate = entry
+ }
+ }
+ centers.push(candidate.color)
+ }
+
+ let clusterSupport = centers.map(() => 0)
+ for (let iteration = 0; iteration < 8; iteration++) {
+ const clusters = centers.map(() => ({ count: 0, red: 0, green: 0, blue: 0 }))
+ for (const entry of entries) {
+ let nearest = 0
+ let nearestDistance = Infinity
+ for (let i = 0; i < centers.length; i++) {
+ const nextDistance = distance(entry.color, centers[i])
+ if (nextDistance < nearestDistance) {
+ nearest = i
+ nearestDistance = nextDistance
+ }
+ }
+ const cluster = clusters[nearest]
+ cluster.count += entry.count
+ cluster.red += entry.color[0] * entry.count
+ cluster.green += entry.color[1] * entry.count
+ cluster.blue += entry.color[2] * entry.count
+ }
+ for (let i = 0; i < clusters.length; i++) {
+ const cluster = clusters[i]
+ if (cluster.count) centers[i] = [cluster.red / cluster.count, cluster.green / cluster.count, cluster.blue / cluster.count]
+ }
+ clusterSupport = clusters.map((cluster) => cluster.count)
+ }
+
+ const dominantIndex = clusterSupport.reduce(
+ (best, count, index) => count > clusterSupport[best] ? index : best,
+ 0,
+ )
+ const dominant = centers[dominantIndex]
+ const distinctCenters = centers.filter((center) => distance(center, dominant) > 0.002)
+ const candidates = distinctCenters.length ? distinctCenters : centers
+ const accent = candidates.reduce((best, color) => {
+ const index = centers.indexOf(color)
+ const [_, a, b] = toOkLab(color)
+ const chroma = Math.hypot(a, b)
+ const score = chroma * Math.sqrt(clusterSupport[index] || 1)
+ const [__, bestA, bestB] = toOkLab(best)
+ const bestScore = Math.hypot(bestA, bestB) * Math.sqrt(clusterSupport[centers.indexOf(best)] || 1)
+ return score > bestScore ? color : best
+ })
+ return { dominant, accent }
+}
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..dae2c55
--- /dev/null
+++ b/app/components/Player/Fullscreen/Background/renderers/webgpu.ts
@@ -0,0 +1,188 @@
+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(options?: {
+ powerPreference?: "low-power" | "high-performance"
+ }): 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({
+ powerPreference: "high-performance",
+ })
+ 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
+ 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()
+ })
+
+ return {
+ render(frame: BackgroundFrame) {
+ if (canvas.width !== frame.width || canvas.height !== frame.height) {
+ canvas.width = frame.width
+ canvas.height = frame.height
+ }
+ 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({
+ 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
+ }
+}
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 6d86dcc..7d63ced 100644
--- a/app/plugins/player.ts
+++ b/app/plugins/player.ts
@@ -1,10 +1,27 @@
-import { Howl } from "howler"
+import { Howl, Howler } from "howler"
+import { shapeBeatPulse } from "~/utils/beat"
export default defineNuxtPlugin((nuxtApp) => {
const mediaBaseUrl = useRuntimeConfig().public.mediaBaseUrl
const { close: closeFullscreen } = useFullscreen()
+ if (import.meta.client) {
+ const howlerInternal = Howler as unknown as {
+ _obtainHtml5Audio: () => HTMLAudioElement
+ _musicV2CorsEnabled?: boolean
+ }
+ if (!howlerInternal._musicV2CorsEnabled) {
+ const obtainHtml5Audio = howlerInternal._obtainHtml5Audio
+ howlerInternal._obtainHtml5Audio = function () {
+ const audio = obtainHtml5Audio.call(Howler)
+ audio.crossOrigin = "anonymous"
+ return audio
+ }
+ howlerInternal._musicV2CorsEnabled = true
+ }
+ }
const VOLUME_STORAGE_KEY = "music-v2-player-volume"
const DEFAULT_VOLUME = 0.5
+ const DEBUG_BEAT_ANALYSIS = false
const sound = ref(null)
const queue = ref