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([]) @@ -13,7 +30,16 @@ export default defineNuxtPlugin((nuxtApp) => { const currentSong = ref(null) const repeat = ref(false) const isPlaying = ref(false) + const beatEnergy = ref(0) const storedVolume = ref(DEFAULT_VOLUME) + type HowlWithMediaNode = Howl & { + _sounds?: Array<{ _node?: HTMLMediaElement }> + } + let analysisContext: AudioContext | null = null + let analysisSource: MediaStreamAudioSourceNode | null = null + let analysisNode: AnalyserNode | null = null + let analysisFrame = 0 + let isAnalyzing = false const clampVolume = (value: number) => Math.min(1, Math.max(0, value)) @@ -64,9 +90,149 @@ export default defineNuxtPlugin((nuxtApp) => { }) const duration = ref(0) let lastPositionUpdate = 0 + let trackingTimer: number | null = null let mediaSessionSongId: string | null = null - const aniFrame = ref(null) + const stopBeatAnalysis = (reason = "stopped") => { + if (isAnalyzing && DEBUG_BEAT_ANALYSIS) + console.info(`[beat analyzer] stopped: ${reason}`) + isAnalyzing = false + if (analysisFrame) cancelAnimationFrame(analysisFrame) + analysisFrame = 0 + analysisSource?.disconnect() + analysisSource = null + analysisNode = null + beatEnergy.value = 0 + } + + const startBeatAnalysis = (howl: Howl) => { + stopBeatAnalysis("restarted") + const mediaElement = (howl as HowlWithMediaNode)._sounds?.[0]?._node + if (!mediaElement) { + if (DEBUG_BEAT_ANALYSIS) + console.warn( + "[beat analyzer] failed: Howler media element unavailable", + ) + return + } + + const elementWithCapture = mediaElement as HTMLMediaElement & { + captureStream?: () => MediaStream + mozCaptureStream?: () => MediaStream + webkitCaptureStream?: () => MediaStream + } + const capture = + elementWithCapture.captureStream ?? + elementWithCapture.mozCaptureStream ?? + elementWithCapture.webkitCaptureStream + if (!capture) { + if (DEBUG_BEAT_ANALYSIS) + console.warn( + "[beat analyzer] failed: media capture unsupported", + ) + return + } + + try { + const stream = capture.call(mediaElement) + if (stream.getAudioTracks().length === 0) { + if (DEBUG_BEAT_ANALYSIS) + console.warn( + "[beat analyzer] failed: capture has no audio tracks", + ) + return + } + analysisContext ??= new AudioContext() + if (analysisContext.state === "suspended") + void analysisContext.resume().catch(() => {}) + analysisNode = analysisContext.createAnalyser() + analysisNode.fftSize = 2048 + analysisNode.smoothingTimeConstant = 0.05 + analysisSource = analysisContext.createMediaStreamSource(stream) + analysisSource.connect(analysisNode) + + const samples = new Uint8Array(analysisNode.frequencyBinCount) + let previousBass: number | null = null + let riseAverage = 0.01 + let riseVariance = 0.0004 + let pulse = 0 + let lastBeatLogAt = 0 + let lastLevelLogAt = 0 + const sample = () => { + if (!analysisNode) return + analysisNode.getByteFrequencyData(samples) + const sampleRate = analysisContext!.sampleRate + const firstBin = Math.max( + 1, + Math.floor((45 * 2048) / sampleRate), + ) + const lastBin = Math.min( + samples.length - 1, + Math.ceil((130 * 2048) / sampleRate), + ) + let bass = 0 + for (let bin = firstBin; bin <= lastBin; bin++) + bass += samples[bin] + bass /= Math.max(1, lastBin - firstBin + 1) * 255 + if (previousBass === null) previousBass = bass + const rise = Math.max(0, bass - previousBass) + previousBass = previousBass * 0.985 + bass * 0.015 + const riseDeviation = Math.sqrt(riseVariance) + const strongThumpThreshold = Math.min( + 0.1, + Math.max(0.045, riseAverage + riseDeviation * 2), + ) + const attack = Math.max(0, rise - strongThumpThreshold) + const thumpCooldown = Math.min( + 500, + Math.max(300, 300 + riseDeviation * 4000), + ) + const now = performance.now() + const isStrongThump = + rise >= strongThumpThreshold && + now - lastBeatLogAt > thumpCooldown + const riseDelta = rise - riseAverage + riseAverage += riseDelta * 0.01 + riseVariance += (riseDelta * riseDelta - riseVariance) * 0.01 + if (isStrongThump) { + const thumpStrength = Math.min(0.38, 0.1 + attack * 2.5) + pulse = Math.max(pulse, thumpStrength) + if (DEBUG_BEAT_ANALYSIS) { + console.log( + "[fullscreen beat]", + `detected=${thumpStrength.toFixed(2)}`, + `visual=${shapeBeatPulse(thumpStrength).toFixed(2)}`, + ) + } + } else pulse *= 0.94 + beatEnergy.value = Math.min(1, pulse) + if (DEBUG_BEAT_ANALYSIS && now - lastLevelLogAt > 1000) { + lastLevelLogAt = now + console.info( + "[beat level]", + `bass=${bass.toFixed(3)}`, + `attack=${attack.toFixed(3)}`, + `gate=${strongThumpThreshold.toFixed(3)}`, + `cooldown=${Math.round(thumpCooldown)}`, + `pulse=${beatEnergy.value.toFixed(2)}`, + ) + } + if (isStrongThump) lastBeatLogAt = now + analysisFrame = requestAnimationFrame(sample) + } + analysisFrame = requestAnimationFrame(sample) + isAnalyzing = true + if (DEBUG_BEAT_ANALYSIS) + console.info("[beat analyzer] analysis started") + } catch (error) { + stopBeatAnalysis() + if (DEBUG_BEAT_ANALYSIS) + console.warn( + "[beat analyzer] failed while setting up audio capture", + error, + ) + } + } // Helpers const updateMediaSession = () => { @@ -88,7 +254,10 @@ export default defineNuxtPlugin((nuxtApp) => { album: currentSong.value.album.name, artwork: [ { - src: GET_FILE(currentSong.value.album.file, mediaBaseUrl), + src: GET_FILE( + currentSong.value.album.file, + mediaBaseUrl, + ), sizes: "512x512", }, ], @@ -164,6 +333,7 @@ export default defineNuxtPlugin((nuxtApp) => { } const startTracking = () => { + if (trackingTimer !== null) window.clearTimeout(trackingTimer) const update = () => { tick.value++ const now = Date.now() @@ -171,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) => { @@ -197,11 +367,13 @@ export default defineNuxtPlugin((nuxtApp) => { updateMediaSession() }, onend: () => { + stopBeatAnalysis("track ended") void closeFullscreen() next() stopTracking() }, onstop: () => { + stopBeatAnalysis("playback stopped") isPlaying.value = false stopTracking() if (navigator.mediaSession) @@ -209,11 +381,13 @@ export default defineNuxtPlugin((nuxtApp) => { }, onplay: () => { isPlaying.value = true + startBeatAnalysis(newSound) startTracking() if (navigator.mediaSession) navigator.mediaSession.playbackState = "playing" }, onpause: () => { + stopBeatAnalysis("paused") isPlaying.value = false stopTracking() if (navigator.mediaSession) @@ -317,7 +491,8 @@ export default defineNuxtPlugin((nuxtApp) => { event.ctrlKey || event.metaKey || event.altKey - ) return + ) + return const target = event.target if ( @@ -325,7 +500,8 @@ export default defineNuxtPlugin((nuxtApp) => { target.closest( "input, textarea, select, button, [contenteditable='true'], [role='slider']", ) - ) return + ) + return switch (event.code) { case "Space": @@ -392,6 +568,7 @@ export default defineNuxtPlugin((nuxtApp) => { provide: { player: { isPlaying, + beatEnergy, currentSong, repeat, play, diff --git a/app/utils/beat.ts b/app/utils/beat.ts new file mode 100644 index 0000000..c0cad77 --- /dev/null +++ b/app/utils/beat.ts @@ -0,0 +1,9 @@ +export function shapeBeatPulse(value: number) { + const strength = Math.max(0, Math.min(0.4, value)) + if (strength <= 0.2) return strength * 0.4 + if (strength <= 0.35) { + const progress = (strength - 0.2) / 0.15 + return 0.08 + (1 - (1 - progress) ** 3) * 0.42 + } + return 0.5 + ((strength - 0.35) / 0.05) * 0.05 +}