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) => {