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
This commit is contained in:
tigeren 2025-09-06 18:26:31 +00:00
parent 13d6874c00
commit a22e4a95c5
3 changed files with 369 additions and 126 deletions

View File

@ -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<string, Promise<Response>>();
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<any>((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 = {
@ -87,14 +122,89 @@ 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<Response> {
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:a', '0:s:a',
'-fflags', '+genpts',
'-avoid_negative_ts', 'make_zero'
];
console.log(`[TRANSCODE] FFmpeg command: 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}%`);
// Add duration override to ensure correct metadata
...(duration > 0 ? ['-metadata', `duration=${duration}`] : []),
'pipe:1'
];
console.log(`[TRANSCODE] FFmpeg command (Stash-like): ffmpeg ${ffmpegArgs.join(' ')}`);
// 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();
// 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) {
// Register process immediately
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 });

View File

@ -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<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
const videoRef = useRef<HTMLVideoElement>(null!);
const lastTranscodingUrlRef = useRef<string | null>(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();
// Handle video load errors (fallback to transcoding)
const handleError = () => {
console.log('Video load failed, trying transcoded version...');
if (videoRef.current) {
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);
videoRef.current.src = `/api/stream/${videoId}/transcode`;
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();
}
};
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);
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`);
// 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;
}
try {
// Kill current transcoding process
fetch(`/api/stream/${videoId}`, { method: 'DELETE' });
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
videoRef.current.src = `/api/stream/${videoId}/transcode?seek=${newTime}`;
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,13 +537,39 @@ export default function VideoViewer({
</button>
{/* Transcoding indicator */}
{isTranscoding && (
{isTranscoding && !transcodingError && (
<div className="absolute top-4 left-4 z-10 bg-yellow-500/20 text-yellow-600 rounded-full px-3 py-1.5 flex items-center gap-2">
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
<span className="text-sm">Transcoding</span>
</div>
)}
{/* Error indicator */}
{transcodingError && (
<div className="absolute top-4 left-4 right-4 z-10 bg-red-500/20 border border-red-500/50 text-red-400 rounded-lg px-4 py-3 flex items-center gap-3">
<div className="w-3 h-3 bg-red-500 rounded-full flex-shrink-0"></div>
<div className="flex-1">
<div className="text-sm font-medium">Playback Error</div>
<div className="text-xs opacity-90">{transcodingError}</div>
</div>
<button
onClick={() => {
const videoId = getVideoId();
if (videoId && videoRef.current) {
setTranscodingError(null);
setRetryCount(0);
setIsTranscoding(false);
videoRef.current.src = `/api/stream/${videoId}`;
videoRef.current.load();
}
}}
className="text-red-400 hover:text-red-300 text-xs underline flex-shrink-0"
>
Retry
</button>
</div>
)}
{/* Video container */}
<div
className="relative w-full h-full bg-black rounded-lg overflow-hidden"

View File

@ -85,25 +85,32 @@ export function useStableProgress(
const video = videoRef.current;
const rawTime = video.currentTime;
// Check for seek offset in video source URL
const seekOffset = getSeekOffsetFromVideoSrc(video.src);
const actualTime = rawTime + seekOffset; // Adjust for seek offset
// Throttle updates to prevent excessive re-renders
if (now - lastUpdateTime.current < configRef.current.updateThrottle) {
return;
}
// Allow forward progress or small backward adjustments
if (rawTime >= 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) {
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
*/