From 301dce4c977be06806bd93bc9e664906e1d5dcf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Wed, 26 Nov 2025 10:58:04 +0100 Subject: [PATCH] Fix: Audio player skip behavior and range requests --- app/api/audio/[filename]/route.ts | 72 +++++++++++++++++++------- components/AudioPlayer.tsx | 86 +++++++++++++++++++++++-------- 2 files changed, 119 insertions(+), 39 deletions(-) diff --git a/app/api/audio/[filename]/route.ts b/app/api/audio/[filename]/route.ts index c9f01c1..6002d03 100644 --- a/app/api/audio/[filename]/route.ts +++ b/app/api/audio/[filename]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { readFile, stat } from 'fs/promises'; +import { stat } from 'fs/promises'; +import { createReadStream } from 'fs'; import path from 'path'; export async function GET( @@ -30,24 +31,59 @@ export async function GET( return new NextResponse('Forbidden', { status: 403 }); } - // Check if file exists - try { - await stat(filePath); - } catch { - return new NextResponse('File not found', { status: 404 }); + const stats = await stat(filePath); + const fileSize = stats.size; + const range = request.headers.get('range'); + + if (range) { + const parts = range.replace(/bytes=/, "").split("-"); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + const chunksize = (end - start) + 1; + + const stream = createReadStream(filePath, { start, end }); + + // Convert Node stream to Web stream + const readable = new ReadableStream({ + start(controller) { + stream.on('data', (chunk: any) => controller.enqueue(chunk)); + stream.on('end', () => controller.close()); + stream.on('error', (err: any) => controller.error(err)); + } + }); + + return new NextResponse(readable, { + status: 206, + headers: { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize.toString(), + 'Content-Type': 'audio/mpeg', + 'Cache-Control': 'public, max-age=3600, must-revalidate', + }, + }); + } else { + const stream = createReadStream(filePath); + + // Convert Node stream to Web stream + const readable = new ReadableStream({ + start(controller) { + stream.on('data', (chunk: any) => controller.enqueue(chunk)); + stream.on('end', () => controller.close()); + stream.on('error', (err: any) => controller.error(err)); + } + }); + + return new NextResponse(readable, { + status: 200, + headers: { + 'Content-Length': fileSize.toString(), + 'Content-Type': 'audio/mpeg', + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'public, max-age=3600, must-revalidate', + }, + }); } - - // Read file - const fileBuffer = await readFile(filePath); - - // Return with proper headers - return new NextResponse(fileBuffer, { - headers: { - 'Content-Type': 'audio/mpeg', - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'public, max-age=3600, must-revalidate', - }, - }); } catch (error) { console.error('Error serving audio file:', error); return new NextResponse('Internal Server Error', { status: 500 }); diff --git a/components/AudioPlayer.tsx b/components/AudioPlayer.tsx index 115ef8e..369af0f 100644 --- a/components/AudioPlayer.tsx +++ b/components/AudioPlayer.tsx @@ -22,33 +22,75 @@ const AudioPlayer = forwardRef(({ src, unlocke const [progress, setProgress] = useState(0); const [hasPlayedOnce, setHasPlayedOnce] = useState(false); + const [processedSrc, setProcessedSrc] = useState(src); + const [processedUnlockedSeconds, setProcessedUnlockedSeconds] = useState(unlockedSeconds); + + useEffect(() => { + console.log('[AudioPlayer] MOUNTED'); + return () => console.log('[AudioPlayer] UNMOUNTED'); + }, []); + useEffect(() => { if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.currentTime = startTime; - setIsPlaying(false); - setProgress(0); - setHasPlayedOnce(false); // Reset for new segment - onHasPlayedChange?.(false); // Notify parent + // Check if props changed compared to what we last processed + const hasChanged = src !== processedSrc || unlockedSeconds !== processedUnlockedSeconds; - if (autoPlay) { - const playPromise = audioRef.current.play(); - if (playPromise !== undefined) { - playPromise - .then(() => { - setIsPlaying(true); - onPlay?.(); - setHasPlayedOnce(true); - onHasPlayedChange?.(true); // Notify parent - }) - .catch(error => { - console.log("Autoplay prevented:", error); - setIsPlaying(false); - }); + if (hasChanged) { + audioRef.current.pause(); + + let startPos = startTime; + + // If same song but more time unlocked, start from where previous segment ended + if (src === processedSrc && unlockedSeconds > processedUnlockedSeconds) { + startPos = startTime + processedUnlockedSeconds; + } + + const targetPos = startPos; + audioRef.current.currentTime = targetPos; + + // Ensure position is set correctly even if browser resets it + setTimeout(() => { + if (audioRef.current && Math.abs(audioRef.current.currentTime - targetPos) > 0.5) { + audioRef.current.currentTime = targetPos; + } + }, 50); + + setIsPlaying(false); + + // Calculate initial progress + const initialElapsed = startPos - startTime; + const initialPercent = unlockedSeconds > 0 ? (initialElapsed / unlockedSeconds) * 100 : 0; + setProgress(Math.min(initialPercent, 100)); + + setHasPlayedOnce(false); // Reset for new segment + onHasPlayedChange?.(false); // Notify parent + + // Update processed state + setProcessedSrc(src); + setProcessedUnlockedSeconds(unlockedSeconds); + + if (autoPlay) { + // Delay play slightly to ensure currentTime sticks + setTimeout(() => { + const playPromise = audioRef.current?.play(); + if (playPromise !== undefined) { + playPromise + .then(() => { + setIsPlaying(true); + onPlay?.(); + setHasPlayedOnce(true); + onHasPlayedChange?.(true); // Notify parent + }) + .catch(error => { + console.log("Autoplay prevented:", error); + setIsPlaying(false); + }); + } + }, 150); } } } - }, [src, unlockedSeconds, startTime, autoPlay]); + }, [src, unlockedSeconds, startTime, autoPlay, processedSrc, processedUnlockedSeconds]); // Expose play method to parent component useImperativeHandle(ref, () => ({ @@ -148,4 +190,6 @@ const AudioPlayer = forwardRef(({ src, unlocke AudioPlayer.displayName = 'AudioPlayer'; + + export default AudioPlayer;