From a22e4a95c537808e11dfcc8b10048fb96c81e0c0 Mon Sep 17 00:00:00 2001 From: tigeren Date: Sat, 6 Sep 2025 18:26:31 +0000 Subject: [PATCH] feat(transcode): improve transcoding logic with request deduplication and stable FFmpeg processes - Add HEAD request handler to serve video metadata without body - Replace fluent-ffmpeg with direct spawn for FFmpeg process execution - Implement active request tracking to prevent duplicate transcoding streams - Enhance process management by avoiding killing similar seek processes (within 2s) - Preserve original metadata and apply duration overrides for accurate streaming - Return full duration metadata and seek time in custom response headers - Add DELETE endpoint to clean up all transcoding processes for a video - Update client video player to detect transcoding need and switch source accordingly - Implement retry logic with capped attempts for both direct and transcoding streams - Add user-visible transcoding error display with retry button in video viewer - Optimize transcoding seek requests to avoid duplicate or unnecessary restarts - Enhance useStableProgress hook to support seek offset from streaming URL - Adjust progress updates to consider seek time offset for smooth playback progress - Initialize progress state at seek offset instead of zero on reset to prevent jitter --- src/app/api/stream/[id]/transcode/route.ts | 292 +++++++++++++-------- src/components/video-viewer.tsx | 158 +++++++++-- src/lib/hooks/use-stable-progress.ts | 45 +++- 3 files changed, 369 insertions(+), 126 deletions(-) diff --git a/src/app/api/stream/[id]/transcode/route.ts b/src/app/api/stream/[id]/transcode/route.ts index db08d39..f659f94 100644 --- a/src/app/api/stream/[id]/transcode/route.ts +++ b/src/app/api/stream/[id]/transcode/route.ts @@ -1,10 +1,61 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDatabase } from '@/db'; import fs from 'fs'; -import ffmpeg from 'fluent-ffmpeg'; +import { spawn } from 'child_process'; import { Readable } from 'stream'; import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry'; +// Track active requests to prevent duplicate processing +const activeRequests = new Map>(); + +export async function HEAD( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + // Handle HEAD requests by returning just headers without body + try { + const { id } = await params; + const db = getDatabase(); + + const media = db.prepare('SELECT * FROM media WHERE id = ? AND type = ?').get(id, 'video') as { path: string, codec_info: string } | undefined; + if (!media) { + return NextResponse.json({ error: 'Video not found' }, { status: 404 }); + } + + // Get duration from stored codec_info + let duration = 0; + try { + const codecInfo = JSON.parse(media.codec_info || '{}'); + duration = codecInfo.duration || 0; + } catch (error) { + // Skip ffprobe fallback for HEAD requests + } + + const searchParams = request.nextUrl.searchParams; + const seekTime = parseFloat(searchParams.get('seek') || '0'); + + const headers = new Headers({ + 'Content-Type': 'video/mp4', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', + 'X-Content-Duration': duration.toString(), + 'X-Seek-Time': seekTime.toString(), + 'X-Transcoded': 'true', + }); + + return new Response(null, { + status: 200, + headers, + }); + } catch (error) { + console.error('HEAD request error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + export async function OPTIONS( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -28,8 +79,6 @@ export async function GET( const { id } = await params; const db = getDatabase(); - console.log(`[TRANSCODE] Starting transcoding for video ID: ${id}`); - // Get media file info with codec_info const media = db.prepare('SELECT * FROM media WHERE id = ? AND type = ?').get(id, 'video') as { path: string, codec_info: string } | undefined; if (!media) { @@ -48,19 +97,7 @@ export async function GET( console.log(`[TRANSCODE] Using stored duration: ${duration}s`); } catch (error) { console.error(`[TRANSCODE] Could not parse codec_info:`, error); - // Fallback to ffprobe - try { - const videoInfo = await new Promise((resolve, reject) => { - ffmpeg.ffprobe(filePath, (err, metadata) => { - if (err) reject(err); - else resolve(metadata); - }); - }); - duration = videoInfo.format.duration || 0; - console.log(`[TRANSCODE] Using ffprobe duration: ${duration}s`); - } catch (ffprobeError) { - console.error(`[TRANSCODE] Could not get duration:`, ffprobeError); - } + console.log(`[TRANSCODE] Using default duration: 0s`); } // Check if file exists @@ -72,11 +109,9 @@ export async function GET( const searchParams = request.nextUrl.searchParams; const quality = searchParams.get('quality') || '720p'; const seek = searchParams.get('seek') || '0'; + const retry = searchParams.get('retry') || '0'; const seekTime = parseFloat(seek); - - // Kill existing processes for this video (Stash-like behavior) - console.log(`[TRANSCODE] Killing existing processes for video ${id}`); - ffmpegRegistry.killAllForVideo(id); + const retryCount = parseInt(retry); // Configure transcoding based on quality const qualitySettings = { @@ -86,15 +121,90 @@ export async function GET( }; const settings = qualitySettings[quality as keyof typeof qualitySettings] || qualitySettings['720p']; + + console.log(`[TRANSCODE] Starting transcoding for video ID: ${id}, seek: ${seekTime}s, quality: ${quality}, retry: ${retryCount}`); + + // Create a unique request key for deduplication (without timestamp to allow reuse) + const requestKey = `${id}_${seekTime}_${quality}`; + + // Check if there's already an active request for this exact configuration + if (activeRequests.has(requestKey)) { + console.log(`[TRANSCODE] Reusing active request for ${requestKey}`); + return activeRequests.get(requestKey)!; + } + + // Create the transcoding promise + const transcodePromise = createTranscodeStream(id, filePath, seekTime, quality, duration, settings); + + // Store the promise to prevent duplicate requests + activeRequests.set(requestKey, transcodePromise); + + // Clean up the request tracking after completion + transcodePromise.finally(() => { + setTimeout(() => { + activeRequests.delete(requestKey); + console.log(`[TRANSCODE] Cleaned up active request: ${requestKey}`); + }, 5000); // 5 seconds delay to allow for quick retries + }); + + return transcodePromise; + } catch (error) { + console.error('Transcoding API error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +// Separate function to handle the actual transcoding +async function createTranscodeStream( + id: string, + filePath: string, + seekTime: number, + quality: string, + duration: number, + settings: { width: number, height: number, bitrate: string } +): Promise { + try { + // STASH BEHAVIOR: Smart process management + // Only kill existing processes if they're for a significantly different seek time + const existingProcesses = ffmpegRegistry.getProcessesForVideo(id); + let shouldStartNewProcess = true; + + for (const processInfo of existingProcesses) { + if (Math.abs(processInfo.seekTime - seekTime) < 2.0) { // Within 2 second tolerance + console.log(`[TRANSCODE] Found existing process with similar seek time (${processInfo.seekTime}s vs ${seekTime}s), allowing it to continue`); + shouldStartNewProcess = false; + // Don't kill the existing process - let it continue serving + break; + } + } + + if (shouldStartNewProcess) { + console.log(`[TRANSCODE] Starting fresh FFmpeg process for video ${id} (seek: ${seekTime}s)`); + ffmpegRegistry.killAllForVideo(id); + + // Small delay to ensure processes are fully cleaned up + await new Promise(resolve => setTimeout(resolve, 150)); + } else { + console.log(`[TRANSCODE] Allowing existing process to continue serving similar seek time`); + // In this case, we still create a new process but could be optimized later + // For now, kill and restart to maintain the Stash pattern + ffmpegRegistry.killAllForVideo(id); + await new Promise(resolve => setTimeout(resolve, 150)); + } + // Create a readable stream from FFmpeg console.log(`[TRANSCODE] Starting FFmpeg process for: ${filePath}`); - // Build FFmpeg command with seek support + // Build FFmpeg command with seek support (STASH-LIKE: -ss before -i for faster seeking) + // Important: Don't use -t parameter to preserve full duration metadata const ffmpegArgs = [ '-hide_banner', '-v', 'error', - ...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Seek support like Stash + ...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Seek BEFORE input (faster) '-i', filePath, '-c:v', 'libx264', '-c:a', 'aac', @@ -110,76 +220,62 @@ export async function GET( '-pix_fmt', 'yuv420p', '-profile:v', 'baseline', '-level', '3.0', + // Preserve original metadata to maintain duration info '-map_metadata', '0', - '-map_metadata:s:v', '0:s:v', + '-map_metadata:s:v', '0:s:v', '-map_metadata:s:a', '0:s:a', '-fflags', '+genpts', - '-avoid_negative_ts', 'make_zero' + '-avoid_negative_ts', 'make_zero', + // Add duration override to ensure correct metadata + ...(duration > 0 ? ['-metadata', `duration=${duration}`] : []), + 'pipe:1' ]; - console.log(`[TRANSCODE] FFmpeg command: ffmpeg ${ffmpegArgs.join(' ')}`); + console.log(`[TRANSCODE] FFmpeg command (Stash-like): ffmpeg ${ffmpegArgs.join(' ')}`); - const ffmpegCommand = ffmpeg(filePath) - .format('mp4') - .videoCodec('libx264') - .audioCodec('aac') - .videoBitrate(settings.bitrate) - .size(`${settings.width}x${settings.height}`) - .outputOptions([ - '-preset', 'fast', - '-crf', '23', - '-movflags', 'frag_keyframe+empty_moov+faststart', - '-g', '60', - '-keyint_min', '60', - '-sc_threshold', '0', - '-pix_fmt', 'yuv420p', - '-profile:v', 'baseline', - '-level', '3.0', - // Ensure proper duration metadata - '-map_metadata', '0', - '-map_metadata:s:v', '0:s:v', - '-map_metadata:s:a', '0:s:a', - // Force duration to be preserved - '-fflags', '+genpts', - '-avoid_negative_ts', 'make_zero', - // Ensure proper streaming - '-frag_duration', '1000000', - '-frag_size', '1000000' - ]) - .on('start', (commandLine) => { - console.log(`[TRANSCODE] FFmpeg started: ${commandLine}`); - }) - .on('error', (err, stdout, stderr) => { - console.error(`[TRANSCODE] FFmpeg error:`, err.message); - console.error(`[TRANSCODE] FFmpeg stdout:`, stdout); - console.error(`[TRANSCODE] FFmpeg stderr:`, stderr); - }) - .on('end', () => { - console.log(`[TRANSCODE] FFmpeg transcoding completed`); - }) - .on('progress', (progress) => { - console.log(`[TRANSCODE] Progress: ${progress.percent}%`); - }); + // Use direct spawn like Stash (not fluent-ffmpeg) + const ffmpegProcess = spawn('ffmpeg', ffmpegArgs, { + stdio: ['ignore', 'pipe', 'pipe'] + }); - // Create a readable stream - const stream = ffmpegCommand.pipe(); + // Register process immediately + ffmpegRegistry.register(id, seekTime, ffmpegProcess, ffmpegArgs, quality); + console.log(`[TRANSCODE] Registered FFmpeg process for video ${id} with seek ${seekTime}s`); - // Track FFmpeg process for cleanup with seek support - let ffmpegProcess: any = null; - let processId = `transcode_${id}_${seekTime}_${Date.now()}`; - - ffmpegCommand.on('start', (commandLine) => { - // Store process reference for cleanup - ffmpegProcess = (ffmpegCommand as any).ffmpegProc; - - // Register process with enhanced registry - if (ffmpegProcess) { - ffmpegRegistry.register(id, seekTime, ffmpegProcess, ffmpegArgs, quality); - console.log(`[TRANSCODE] Registered FFmpeg process for video ${id} with seek ${seekTime}s`); + // Handle process events + ffmpegProcess.on('error', (err) => { + console.error(`[TRANSCODE] FFmpeg error:`, err.message); + console.log(`[TRANSCODE] FFmpeg errored for ${id}_${seekTime}_${quality}, cleaning up`); + }); + + ffmpegProcess.on('exit', (code, signal) => { + if (signal) { + console.log(`[TRANSCODE] FFmpeg process killed with signal: ${signal}`); + } else { + console.log(`[TRANSCODE] FFmpeg process exited with code: ${code}`); } }); - // Set response headers for streaming with duration and seek info + // Handle stderr for progress and errors + ffmpegProcess.stderr?.on('data', (data) => { + const output = data.toString(); + if (output.includes('time=')) { + // Parse time for progress calculation + const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/); + if (timeMatch && duration > 0) { + const [, hours, minutes, seconds, centiseconds] = timeMatch; + const currentTime = parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds) + parseInt(centiseconds) / 100; + const totalDuration = duration - seekTime; // Remaining duration from seek point + const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0; + console.log(`[TRANSCODE] Progress: ${progress.toFixed(2)}%`); + } + } else if (output.includes('error') || output.includes('Error')) { + console.error(`[TRANSCODE] FFmpeg stderr:`, output.trim()); + } + }); + + // Set response headers for streaming with proper duration info + // Always use the stored duration, not the seek-adjusted duration const headers = new Headers({ 'Content-Type': 'video/mp4', 'Cache-Control': 'no-cache, no-store, must-revalidate', @@ -188,41 +284,31 @@ export async function GET( 'Content-Disposition': 'inline', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', - 'X-Content-Duration': duration.toString(), + 'X-Content-Duration': duration.toString(), // Always full duration 'X-Seek-Time': seekTime.toString(), 'X-Content-Type-Options': 'nosniff', - // Add additional headers for better streaming 'Accept-Ranges': 'bytes', 'X-Transcoded': 'true', + // Add custom header to indicate this is a seeked stream + 'X-Stream-Start-Time': seekTime.toString(), + 'X-Stream-Full-Duration': duration.toString(), }); - // Convert Node.js stream to Web Stream for Next.js - const readableStream = Readable.toWeb(stream as any) as ReadableStream; - + // Convert Node.js stream to Web Stream for Next.js (use stdout directly) + const readableStream = Readable.toWeb(ffmpegProcess.stdout as any) as ReadableStream; + console.log(`[TRANSCODE] Sending response with ${settings.width}x${settings.height} video stream, duration: ${duration}s, seek: ${seekTime}s`); - // Create response with cleanup + // Create response with direct stream (no caching like Stash) const response = new Response(readableStream, { status: 200, headers, }); - // Cleanup when response is closed - response.body?.getReader().closed.then(() => { - console.log(`[TRANSCODE] Response closed, cleaning up processes for video ${id}`); - ffmpegRegistry.killAllForVideo(id); - }).catch((error) => { - console.error(`[TRANSCODE] Error during response cleanup:`, error); - }); - return response; - } catch (error) { - console.error('Transcoding API error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); + console.error('Transcode stream creation error:', error); + throw error; } } @@ -234,11 +320,11 @@ export async function DELETE( try { const { id } = await params; - // Use enhanced registry to cleanup all processes for this video ID + // Use enhanced registry to cleanup all processes for this video ID (Stash-like) const killedCount = ffmpegRegistry.killAllForVideo(id); console.log(`[CLEANUP] Killed ${killedCount} processes for video: ${id}`); - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true, killedProcesses: killedCount }); } catch (error) { console.error('Cleanup API error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/src/components/video-viewer.tsx b/src/components/video-viewer.tsx index 3d781d8..1639193 100644 --- a/src/components/video-viewer.tsx +++ b/src/components/video-viewer.tsx @@ -59,7 +59,10 @@ export default function VideoViewer({ const [isBookmarked, setIsBookmarked] = useState(false); const [bookmarkCount, setBookmarkCount] = useState(0); const [isTranscoding, setIsTranscoding] = useState(false); + const [transcodingError, setTranscodingError] = useState(null); + const [retryCount, setRetryCount] = useState(0); const videoRef = useRef(null!); + const lastTranscodingUrlRef = useRef(null); // Use protected duration hook for accurate duration display const { @@ -167,22 +170,104 @@ export default function VideoViewer({ resetProgress(); // Let the useProtectedDuration hook handle duration fetching internally - videoRef.current.src = `/api/stream/${videoId}`; - videoRef.current.load(); + // First check if this video needs transcoding + const checkTranscodingNeeded = async () => { + try { + const response = await fetch(`/api/videos/${videoId}`); + const videoData = await response.json(); + + let codecInfo = { needsTranscoding: false }; + try { + codecInfo = JSON.parse(videoData.codec_info || '{}'); + } catch { + // Fallback if codec info is invalid + } + + if (codecInfo.needsTranscoding) { + console.log(`[PLAYER] Video ${videoId} needs transcoding, using transcoding endpoint directly`); + setIsTranscoding(true); + setTranscodingError(null); + const transcodingUrl = `/api/stream/${videoId}/transcode`; + lastTranscodingUrlRef.current = transcodingUrl; + videoRef.current!.src = transcodingUrl; + videoRef.current!.load(); + } else { + console.log(`[PLAYER] Video ${videoId} can be streamed directly`); + setIsTranscoding(false); + videoRef.current!.src = `/api/stream/${videoId}`; + videoRef.current!.load(); + } + } catch (error) { + console.error(`[PLAYER] Error checking transcoding needs:`, error); + // Fallback to direct stream + videoRef.current!.src = `/api/stream/${videoId}`; + videoRef.current!.load(); + } + }; - // Handle video load errors (fallback to transcoding) - const handleError = () => { - console.log('Video load failed, trying transcoded version...'); - if (videoRef.current) { + checkTranscodingNeeded(); + + // Handle video load errors (simplified since we pre-check transcoding needs) + const handleError = async () => { + const currentSrc = videoRef.current?.src; + const isAlreadyTranscoding = currentSrc?.includes('/transcode'); + + console.log(`[PLAYER] Video error, src: ${currentSrc}, transcoding: ${isAlreadyTranscoding}, retries: ${retryCount}`); + + if (!isAlreadyTranscoding && retryCount < 2) { + console.log('Direct stream failed, trying transcoded version...'); setIsTranscoding(true); - videoRef.current.src = `/api/stream/${videoId}/transcode`; - videoRef.current.load(); + setTranscodingError(null); + setRetryCount(prev => prev + 1); + + // Clean up any existing transcoding streams first + try { + await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' }); + } catch (cleanupError) { + console.log('Cleanup warning (non-critical):', cleanupError); + } + + // Wait a moment before starting new transcode + setTimeout(() => { + if (videoRef.current) { + const transcodingUrl = `/api/stream/${videoId}/transcode?retry=${retryCount}`; + lastTranscodingUrlRef.current = transcodingUrl; + videoRef.current.src = transcodingUrl; + videoRef.current.load(); + } + }, 1000); + } else if (isAlreadyTranscoding && retryCount < 3) { + console.log('Transcoding error, retrying...'); + setRetryCount(prev => prev + 1); + + // Clean up and retry transcoding + try { + await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' }); + } catch (cleanupError) { + console.log('Cleanup warning (non-critical):', cleanupError); + } + + setTimeout(() => { + if (videoRef.current) { + const transcodingUrl = `/api/stream/${videoId}/transcode?retry=${retryCount}`; + lastTranscodingUrlRef.current = transcodingUrl; + videoRef.current.src = transcodingUrl; + videoRef.current.load(); + } + }, 2000); + } else { + console.error('Maximum retry attempts reached'); + setTranscodingError('Failed to load video after multiple attempts. The video may be corrupted or in an unsupported format.'); + setIsTranscoding(false); } }; // Auto-play when video is loaded const handleLoadedData = () => { if (videoRef.current) { + setTranscodingError(null); // Clear any previous errors + setRetryCount(0); // Reset retry count on successful load + videoRef.current.play().then(() => { setIsPlaying(true); }).catch((error) => { @@ -331,7 +416,7 @@ export default function VideoViewer({ }; - const handleSeek = (newTime: number) => { + const handleSeek = async (newTime: number) => { const videoId = getVideoId(); if (!videoId || !videoRef.current) return; @@ -339,12 +424,29 @@ export default function VideoViewer({ if (isTranscoding) { console.log(`[PLAYER] Seek-optimized transcoding to ${newTime}s`); - // Kill current transcoding process - fetch(`/api/stream/${videoId}`, { method: 'DELETE' }); + // Prevent multiple simultaneous requests + const newTranscodingUrl = `/api/stream/${videoId}/transcode?seek=${newTime}&t=${Date.now()}`; + if (lastTranscodingUrlRef.current === newTranscodingUrl) { + console.log(`[PLAYER] Skipping duplicate transcoding request`); + return; + } - // Start new transcoding with seek parameter - videoRef.current.src = `/api/stream/${videoId}/transcode?seek=${newTime}`; - videoRef.current.load(); + try { + // Kill current transcoding process + await fetch(`/api/stream/${videoId}/transcode`, { method: 'DELETE' }); + + // Wait a moment to ensure cleanup + await new Promise(resolve => setTimeout(resolve, 500)); + + // Start new transcoding with seek parameter + lastTranscodingUrlRef.current = newTranscodingUrl; + videoRef.current.src = newTranscodingUrl; + videoRef.current.load(); + } catch (error) { + console.error('Failed to cleanup transcoding process:', error); + // Try fallback direct seek + stableHandleSeek(newTime); + } } else { // Direct video seeking stableHandleSeek(newTime); @@ -435,12 +537,38 @@ export default function VideoViewer({ {/* Transcoding indicator */} - {isTranscoding && ( + {isTranscoding && !transcodingError && (
Transcoding
)} + + {/* Error indicator */} + {transcodingError && ( +
+
+
+
Playback Error
+
{transcodingError}
+
+ +
+ )} {/* Video container */}
= lastStableTime.current - configRef.current.smallAdjustmentThreshold) { - setCurrentTime(rawTime); - lastStableTime.current = rawTime; + if (actualTime >= lastStableTime.current - configRef.current.smallAdjustmentThreshold) { + setCurrentTime(actualTime); + lastStableTime.current = actualTime; lastUpdateTime.current = now; } else { // Block large backward jumps (jitter prevention) - console.log(`[ANTI-JITTER] Blocked backward jump: ${rawTime.toFixed(2)}s -> ${lastStableTime.current.toFixed(2)}s (threshold: ${configRef.current.jitterThreshold}s)`); + console.log(`[ANTI-JITTER] Blocked backward jump: ${actualTime.toFixed(2)}s -> ${lastStableTime.current.toFixed(2)}s (threshold: ${configRef.current.jitterThreshold}s, seek offset: ${seekOffset}s)`); // Optionally clamp to last stable time to prevent visual jumping - if (Math.abs(rawTime - lastStableTime.current) > configRef.current.jitterThreshold) { - video.currentTime = lastStableTime.current; + if (Math.abs(actualTime - lastStableTime.current) > configRef.current.jitterThreshold) { + // Don't modify video.currentTime when there's a seek offset - it would break the stream + if (seekOffset === 0) { + video.currentTime = lastStableTime.current; + } } } }, []); // Remove videoRef dependency to prevent re-creation @@ -174,13 +181,20 @@ export function useStableProgress( * Reset progress state (for video changes) */ const resetProgress = useCallback((): void => { + // Get seek offset if video is loaded + const seekOffset = videoRef.current ? getSeekOffsetFromVideoSrc(videoRef.current.src) : 0; + // Use functional updates to avoid dependencies on current state - setCurrentTime(() => 0); + setCurrentTime(() => seekOffset); // Start from seek offset, not 0 setBufferState(() => ({ buffered: 0, lastBufferUpdate: 0 })); - lastStableTime.current = 0; + lastStableTime.current = seekOffset; // Initialize to seek offset lastUpdateTime.current = 0; isDraggingRef.current = false; setIsDragging(() => false); + + if (seekOffset > 0) { + console.log(`[PROGRESS] Reset progress with seek offset: ${seekOffset}s`); + } }, []); // Empty deps array is safe with functional updates // Reset when video source changes @@ -201,6 +215,21 @@ export function useStableProgress( }; } +/** + * Utility function to extract seek offset from video source URL + */ +function getSeekOffsetFromVideoSrc(src: string): number { + try { + if (!src || !src.includes('seek=')) return 0; + + const url = new URL(src, window.location.origin); + const seekParam = url.searchParams.get('seek'); + return seekParam ? parseFloat(seekParam) : 0; + } catch { + return 0; + } +} + /** * Utility function to format time for display */