diff --git a/src/app/api/audio/convert/route.ts b/src/app/api/audio/convert/route.ts index 4011da6..b1bb2ff 100644 --- a/src/app/api/audio/convert/route.ts +++ b/src/app/api/audio/convert/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { spawn } from 'child_process'; -import { writeFile, readFile, mkdir, unlink, rmdir } from 'fs/promises'; +import { writeFile, mkdir, unlink, rmdir } from 'fs/promises'; +import { createReadStream } from 'fs'; import { existsSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; @@ -65,9 +66,19 @@ async function runFFmpeg(args: string[]): Promise { }); } +async function cleanup(files: string[], directories: string[]) { + await Promise.all([ + ...files.map(f => unlink(f).catch(console.error)), + ...directories.map(d => rmdir(d).catch(console.error)) + ]); +} + export async function POST(request: NextRequest) { + const tempFiles: string[] = []; + const tempDirs: string[] = []; + try { - // Parse the request body + // Parse the request body as a stream const data: ConversionRequest = await request.json(); // Create temp directory if it doesn't exist @@ -82,23 +93,46 @@ export async function POST(request: NextRequest) { const metadataPath = join(tempDir, `${id}.txt`); const intermediateDir = join(tempDir, `${id}-intermediate`); + tempFiles.push(outputPath, metadataPath); + tempDirs.push(intermediateDir); + // Create intermediate directory if (!existsSync(intermediateDir)) { await mkdir(intermediateDir); } - // Process each chapter - no need for initial conversion since input is WAV + // Process chapters sequentially to avoid memory issues const chapterFiles: { path: string; title: string; duration: number }[] = []; let currentTime = 0; for (let i = 0; i < data.chapters.length; i++) { const chapter = data.chapters[i]; + const inputPath = join(intermediateDir, `${i}-input.mp3`); const outputPath = join(intermediateDir, `${i}.wav`); - // Write the chapter audio directly since it's already WAV - await writeFile(outputPath, Buffer.from(new Uint8Array(chapter.buffer))); + tempFiles.push(inputPath, outputPath); + + // Write the chapter audio to a temp file using a Buffer chunk size of 64KB + const chunkSize = 64 * 1024; // 64KB chunks + const buffer = Buffer.from(new Uint8Array(chapter.buffer)); + const chunks: Buffer[] = []; + + for (let offset = 0; offset < buffer.length; offset += chunkSize) { + chunks.push(buffer.slice(offset, offset + chunkSize)); + } + + await writeFile(inputPath, Buffer.concat(chunks)); + chunks.length = 0; // Clear chunks array + + // Convert to WAV with consistent format + await runFFmpeg([ + '-i', inputPath, + '-acodec', 'pcm_s16le', + '-ar', '44100', + '-ac', '2', + outputPath + ]); - // Get the duration of this chapter const duration = await getAudioDuration(outputPath); chapterFiles.push({ @@ -106,16 +140,18 @@ export async function POST(request: NextRequest) { title: chapter.title, duration }); + + // Clean up input file early + await unlink(inputPath).catch(console.error); + const index = tempFiles.indexOf(inputPath); + if (index > -1) { + tempFiles.splice(index, 1); + } } // Create chapter metadata file const metadata: string[] = []; - metadata.push( - `title=Kokoro Audiobook`, - `artist=KokoroTTS`, - ); - // Calculate chapter timings based on actual durations chapterFiles.forEach((chapter) => { const startMs = Math.floor(currentTime * 1000); currentTime += chapter.duration; @@ -134,6 +170,8 @@ export async function POST(request: NextRequest) { // Create list file for concat const listPath = join(tempDir, `${id}-list.txt`); + tempFiles.push(listPath); + await writeFile( listPath, chapterFiles.map(f => `file '${f.path}'`).join('\n') @@ -152,24 +190,46 @@ export async function POST(request: NextRequest) { outputPath ]); - // Read the converted file - const m4bData = await readFile(outputPath); + // Create a readable stream from the output file + const fileStream = createReadStream(outputPath); - // Clean up temp files - await Promise.all([ - ...chapterFiles.map(f => unlink(f.path)), - unlink(metadataPath), - unlink(listPath), - unlink(outputPath), - rmdir(intermediateDir) - ].map(p => p.catch(console.error))); + // Create a web-compatible ReadableStream from the Node.js stream + const webStream = new ReadableStream({ + start(controller) { + fileStream.on('data', (chunk) => { + controller.enqueue(chunk); + }); + + fileStream.on('end', () => { + controller.close(); + // Clean up only after the stream has been fully sent + cleanup(tempFiles, tempDirs).catch(console.error); + }); + + fileStream.on('error', (error) => { + console.error('Stream error:', error); + controller.error(error); + cleanup(tempFiles, tempDirs).catch(console.error); + }); + }, + cancel() { + fileStream.destroy(); + cleanup(tempFiles, tempDirs).catch(console.error); + } + }); - return new NextResponse(m4bData, { + // Return the streaming response + return new NextResponse(webStream, { headers: { 'Content-Type': 'audio/mp4', + 'Transfer-Encoding': 'chunked' }, }); + } catch (error) { + // Clean up in case of error + await cleanup(tempFiles, tempDirs).catch(console.error); + console.error('Error converting audio:', error); return NextResponse.json( { error: 'Failed to convert audio format' }, diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 2a6f74c..f7f3350 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -6,8 +6,8 @@ export async function POST(req: NextRequest) { // Get API credentials from headers or fall back to environment variables const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; - const { text, voice, speed, format } = await req.json(); - console.log('Received TTS request:', text, voice, speed, format); + const { text, voice, speed } = await req.json(); + console.log('Received TTS request:', text, voice, speed); if (!openApiKey) { return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 401 }); @@ -29,19 +29,16 @@ export async function POST(req: NextRequest) { voice: voice as "alloy", input: text, speed: speed, - // Use wav format for audiobook generation to avoid initial conversion - response_format: format === 'audiobook' ? 'wav' : (format === 'aac' ? 'aac' : 'mp3'), - }, { signal: req.signal }); // Pass the abort signal to OpenAI client + response_format: 'mp3', // Always use mp3 since we convert to WAV later if needed + }, { signal: req.signal }); // Get the audio data as array buffer - // This will also be aborted if the client cancels const stream = response.body; // Return audio data with appropriate headers - const contentType = format === 'audiobook' ? 'audio/wav' : (format === 'aac' ? 'audio/aac' : 'audio/mpeg'); return new NextResponse(stream, { headers: { - 'Content-Type': contentType + 'Content-Type': 'audio/mpeg' } }); } catch (error) { diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx index 75fd7c3..1d72c87 100644 --- a/src/components/DocumentSettings.tsx +++ b/src/components/DocumentSettings.tsx @@ -6,6 +6,8 @@ import { useConfig, ViewType } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; import { useEPUB } from '@/contexts/EPUBContext'; import { usePDF } from '@/contexts/PDFContext'; +import { useTimeEstimation } from '@/hooks/useTimeEstimation'; +import { LoadingSpinner } from './Spinner'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -37,9 +39,9 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro rightMargin, updateConfigKey } = useConfig(); - const { createFullAudioBook } = useEPUB(); - const { createFullAudioBook: createPDFAudioBook } = usePDF(); - const [progress, setProgress] = useState(0); + const { createFullAudioBook, isAudioCombining } = useEPUB(); + const { createFullAudioBook: createPDFAudioBook, isAudioCombining: isPDFAudioCombining } = usePDF(); + const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const [isGenerating, setIsGenerating] = useState(false); const [audioFormat, setAudioFormat] = useState<'mp3' | 'm4b'>('mp3'); const [localMargins, setLocalMargins] = useState({ @@ -116,7 +118,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro setProgress(0); abortControllerRef.current = null; } - }, [createFullAudioBook, createPDFAudioBook, epub, audioFormat]); + }, [createFullAudioBook, createPDFAudioBook, epub, audioFormat, setProgress]); const handleCancel = () => { if (abortControllerRef.current) { @@ -195,7 +197,10 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro />
- {Math.round(progress)}% complete + + {Math.round(progress)}% complete + {estimatedTimeRemaining && ` • ${estimatedTimeRemaining} remaining`} +
diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 8a557a1..58edec8 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -36,6 +36,7 @@ interface EPUBContextType { locationRef: RefObject; handleLocationChanged: (location: string | number) => void; setRendition: (rendition: Rendition) => void; + isAudioCombining: boolean; } const EPUBContext = createContext(undefined); @@ -60,6 +61,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { const [currDocData, setCurrDocData] = useState(); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); + const [isAudioCombining, setIsAudioCombining] = useState(false); // Add new refs const bookRef = useRef(null); @@ -180,9 +182,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) { /** * Creates a complete audiobook by processing all text through NLP and TTS - * @param {string} voice - The voice to use for TTS - * @param {number} speed - The speed to use for TTS - * @returns {Promise} The complete audiobook as an ArrayBuffer */ const createFullAudioBook = useCallback(async ( onProgress: (progress: number) => void, @@ -193,9 +192,10 @@ export function EPUBProvider({ children }: { children: ReactNode }) { const textArray = await extractBookText(); if (!textArray.length) throw new Error('No text content found in book'); + // Calculate total text length for accurate progress tracking + const totalLength = textArray.reduce((sum, text) => sum + text.trim().length, 0); const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = []; - let processedSections = 0; - const totalSections = textArray.length; + let processedLength = 0; let currentTime = 0; // Get TOC for chapter titles if available @@ -209,10 +209,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) { } try { - if (!text.trim()) { - processedSections++; - continue; - } + const trimmedText = text.trim(); + if (!trimmedText) continue; const ttsResponse = await fetch('/api/tts', { method: 'POST', @@ -221,10 +219,10 @@ export function EPUBProvider({ children }: { children: ReactNode }) { 'x-openai-base-url': baseUrl, }, body: JSON.stringify({ - text: text.trim(), + text: trimmedText, voice: voice, speed: voiceSpeed, - format: 'audiobook' // Request WAV format directly + format: 'audiobook' }), signal }); @@ -241,7 +239,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { // Find matching chapter title from TOC if available let chapterTitle; if (spine && chapters.length > 0) { - let spineIndex = processedSections; + let spineIndex = processedLength; let currentSpineHref: string | undefined; spine.each((item: SpineItem) => { @@ -254,9 +252,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) { const matchingChapter = chapters.find(chapter => chapter.href && currentSpineHref?.includes(chapter.href) ); - chapterTitle = matchingChapter?.label || `Chapter ${processedSections + 1}`; + chapterTitle = matchingChapter?.label || `Section ${processedLength + 1}`; } else { - chapterTitle = `Chapter ${processedSections + 1}`; + chapterTitle = `Section ${processedLength + 1}`; } audioChunks.push({ @@ -266,13 +264,17 @@ export function EPUBProvider({ children }: { children: ReactNode }) { }); // Add silence between sections - const silenceBuffer = new ArrayBuffer(48000); // 1 second of silence + const silenceBuffer = new ArrayBuffer(48000); audioChunks.push({ buffer: silenceBuffer, startTime: currentTime + (audioBuffer.byteLength / 48000) }); - currentTime += (audioBuffer.byteLength + 48000) / 48000; // Update time including silence + currentTime += (audioBuffer.byteLength + 48000) / 48000; + + // Update progress based on processed text length + processedLength += trimmedText.length; + onProgress((processedLength / totalLength) * 100); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { @@ -282,9 +284,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) { } console.error('Error processing section:', error); } - - processedSections++; - onProgress((processedSections / totalSections) * 100); } if (audioChunks.length === 0) { @@ -302,41 +301,46 @@ export function EPUBProvider({ children }: { children: ReactNode }) { audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[], format: 'mp3' | 'm4b' ): Promise => { - if (format === 'm4b') { - // Convert to M4B format using the audio conversion API - const response = await fetch('/api/audio/convert', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - chapters: audioChunks - .filter(chunk => chunk.title) // Only include chunks with titles - .map(chunk => ({ - title: chunk.title, - buffer: Array.from(new Uint8Array(chunk.buffer)) - })) - }), - }); + setIsAudioCombining(true); + try { + if (format === 'm4b') { + // Convert to M4B format using the audio conversion API + const response = await fetch('/api/audio/convert', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chapters: audioChunks + .filter(chunk => chunk.title) // Only include chunks with titles + .map(chunk => ({ + title: chunk.title, + buffer: Array.from(new Uint8Array(chunk.buffer)) + })) + }), + }); - if (!response.ok) { - throw new Error('Failed to convert audio to M4B format'); + if (!response.ok) { + throw new Error('Failed to convert audio to M4B format'); + } + + return response.arrayBuffer(); } - return response.arrayBuffer(); + // For MP3, just concatenate the buffers + const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.buffer.byteLength, 0); + const combinedBuffer = new Uint8Array(totalLength); + + let offset = 0; + for (const chunk of audioChunks) { + combinedBuffer.set(new Uint8Array(chunk.buffer), offset); + offset += chunk.buffer.byteLength; + } + + return combinedBuffer.buffer; + } finally { + setIsAudioCombining(false); } - - // For MP3, just concatenate the buffers - const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.buffer.byteLength, 0); - const combinedBuffer = new Uint8Array(totalLength); - - let offset = 0; - for (const chunk of audioChunks) { - combinedBuffer.set(new Uint8Array(chunk.buffer), offset); - offset += chunk.buffer.byteLength; - } - - return combinedBuffer.buffer; } const setRendition = useCallback((rendition: Rendition) => { @@ -398,6 +402,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { locationRef, handleLocationChanged, setRendition, + isAudioCombining, }), [ setCurrentDocument, @@ -411,6 +416,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { createFullAudioBook, handleLocationChanged, setRendition, + isAudioCombining, ] ); diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 21abebf..f167941 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -63,6 +63,7 @@ interface PDFContextType { isProcessing: boolean ) => void; createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise; + isAudioCombining: boolean; } // Create the context @@ -101,6 +102,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); const [pdfDocument, setPdfDocument] = useState(); + const [isAudioCombining, setIsAudioCombining] = useState(false); /** * Handles successful PDF document load @@ -199,29 +201,40 @@ export function PDFProvider({ children }: { children: ReactNode }) { throw new Error('No PDF document loaded'); } - const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = []; - const totalPages = pdfDocument.numPages; - let processedPages = 0; - let currentTime = 0; - - for (let pageNum = 1; pageNum <= totalPages; pageNum++) { - if (signal?.aborted) { - const partialBuffer = await combineAudioChunks(audioChunks, format); - return partialBuffer; - } - + // First pass: extract and measure all text + const textPerPage: string[] = []; + let totalLength = 0; + + for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) { const text = await extractTextFromPDF(pdfDocument, pageNum, { header: headerMargin, footer: footerMargin, left: leftMargin, right: rightMargin }); + const trimmedText = text.trim(); + if (trimmedText) { + textPerPage.push(trimmedText); + totalLength += trimmedText.length; + } + } - if (!text.trim()) { - processedPages++; - continue; + if (totalLength === 0) { + throw new Error('No text content found in PDF'); + } + + const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = []; + let processedLength = 0; + let currentTime = 0; + + // Second pass: process text into audio + for (let i = 0; i < textPerPage.length; i++) { + if (signal?.aborted) { + const partialBuffer = await combineAudioChunks(audioChunks, format); + return partialBuffer; } + const text = textPerPage[i]; try { const ttsResponse = await fetch('/api/tts', { method: 'POST', @@ -230,10 +243,10 @@ export function PDFProvider({ children }: { children: ReactNode }) { 'x-openai-base-url': baseUrl, }, body: JSON.stringify({ - text: text.trim(), + text, voice: voice, speed: voiceSpeed, - format: 'audiobook' // Request WAV format directly + format: 'audiobook' }), signal }); @@ -247,10 +260,9 @@ export function PDFProvider({ children }: { children: ReactNode }) { throw new Error('Received empty audio buffer from TTS'); } - // Add chapter metadata for each page audioChunks.push({ buffer: audioBuffer, - title: `Page ${pageNum}`, + title: `Page ${i + 1}`, startTime: currentTime }); @@ -263,6 +275,10 @@ export function PDFProvider({ children }: { children: ReactNode }) { currentTime += (audioBuffer.byteLength + 48000) / 48000; + // Update progress based on processed text length + processedLength += text.length; + onProgress((processedLength / totalLength) * 100); + } catch (error) { if (error instanceof Error && error.name === 'AbortError') { console.log('TTS request aborted'); @@ -271,9 +287,6 @@ export function PDFProvider({ children }: { children: ReactNode }) { } console.error('Error processing page:', error); } - - processedPages++; - onProgress((processedPages / totalPages) * 100); } if (audioChunks.length === 0) { @@ -291,41 +304,46 @@ export function PDFProvider({ children }: { children: ReactNode }) { audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[], format: 'mp3' | 'm4b' ): Promise => { - if (format === 'm4b') { - // Convert to M4B format using the audio conversion API - const response = await fetch('/api/audio/convert', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - chapters: audioChunks - .filter(chunk => chunk.title) // Only include chunks with titles - .map(chunk => ({ - title: chunk.title, - buffer: Array.from(new Uint8Array(chunk.buffer)) - })) - }), - }); + setIsAudioCombining(true); + try { + if (format === 'm4b') { + // Convert to M4B format using the audio conversion API + const response = await fetch('/api/audio/convert', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chapters: audioChunks + .filter(chunk => chunk.title) // Only include chunks with titles + .map(chunk => ({ + title: chunk.title, + buffer: Array.from(new Uint8Array(chunk.buffer)) + })) + }), + }); - if (!response.ok) { - throw new Error('Failed to convert audio to M4B format'); + if (!response.ok) { + throw new Error('Failed to convert audio to M4B format'); + } + + return response.arrayBuffer(); } - return response.arrayBuffer(); + // For MP3, just concatenate the buffers + const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.buffer.byteLength, 0); + const combinedBuffer = new Uint8Array(totalLength); + + let offset = 0; + for (const chunk of audioChunks) { + combinedBuffer.set(new Uint8Array(chunk.buffer), offset); + offset += chunk.buffer.byteLength; + } + + return combinedBuffer.buffer; + } finally { + setIsAudioCombining(false); } - - // For MP3, just concatenate the buffers - const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.buffer.byteLength, 0); - const combinedBuffer = new Uint8Array(totalLength); - - let offset = 0; - for (const chunk of audioChunks) { - combinedBuffer.set(new Uint8Array(chunk.buffer), offset); - offset += chunk.buffer.byteLength; - } - - return combinedBuffer.buffer; } // Context value memoization @@ -344,6 +362,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { handleTextClick, pdfDocument, createFullAudioBook, + isAudioCombining, }), [ onDocumentLoadSuccess, @@ -356,6 +375,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { clearCurrDoc, pdfDocument, createFullAudioBook, + isAudioCombining, ] ); diff --git a/src/hooks/useTimeEstimation.ts b/src/hooks/useTimeEstimation.ts new file mode 100644 index 0000000..1adb3c2 --- /dev/null +++ b/src/hooks/useTimeEstimation.ts @@ -0,0 +1,192 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +export interface TimeEstimation { + progress: number; + setProgress: (progress: number) => void; + estimatedTimeRemaining: string | null; +} + +export function useTimeEstimation(): TimeEstimation { + const [progress, setProgressState] = useState(0); + const [estimatedTimeRemaining, setEstimatedTimeRemaining] = useState(null); + + // Store timing data to avoid dependency cycles + const startTimeRef = useRef(null); + const progressHistoryRef = useRef>([]); + const lastProgressUpdateRef = useRef(0); + const lastEstimateUpdateRef = useRef(0); + + const resetState = useCallback(() => { + startTimeRef.current = null; + progressHistoryRef.current = []; + setEstimatedTimeRemaining(null); + lastEstimateUpdateRef.current = 0; + lastProgressUpdateRef.current = 0; + }, []); + + const formatTimeRemaining = useCallback((seconds: number): string => { + if (seconds < 30) { + return '1m'; + } else if (seconds < 3600) { + const minutes = Math.round(seconds / 60); + return `${minutes}m`; + } else { + const totalMinutes = Math.round(seconds / 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return minutes > 0 + ? `${hours}h ${minutes}m` + : `${hours}h`; + } + }, []); + + const calculateMovingAverage = useCallback((data: number[], windowSize: number) => { + const result = []; + for (let i = 0; i < data.length; i++) { + const start = Math.max(0, i - windowSize + 1); + const window = data.slice(start, i + 1); + const average = window.reduce((sum, val) => sum + val, 0) / window.length; + result.push(average); + } + return result; + }, []); + + const calculateTimeRemaining = useCallback((newProgress: number, currentTime: number) => { + // Initialize start time if this is the first meaningful update + if (startTimeRef.current === null && newProgress > 0) { + startTimeRef.current = currentTime; + progressHistoryRef.current = [{ time: currentTime, progress: newProgress }]; + lastProgressUpdateRef.current = currentTime; + return; + } + + // Skip if no progress + if (newProgress <= 0) { + resetState(); + return; + } + + // Check if we should update the estimate + const timeSinceLastUpdate = currentTime - lastProgressUpdateRef.current; + const shouldSkipUpdate = timeSinceLastUpdate < 3000; // Reduced from 10s to 3s for more responsive updates + + const lastDataPoint = progressHistoryRef.current[progressHistoryRef.current.length - 1]; + const timeSinceLastDataPoint = lastDataPoint ? currentTime - lastDataPoint.time : Infinity; + const progressDifference = lastDataPoint ? newProgress - lastDataPoint.progress : Infinity; + + // Store data point if significant time or progress change + if (timeSinceLastDataPoint > 2000 || Math.abs(progressDifference) > 1) { + progressHistoryRef.current.push({ time: currentTime, progress: newProgress }); + + // Keep a sliding window of data points + const maxDataPoints = 20; // Increased from 5 for smoother averaging + if (progressHistoryRef.current.length > maxDataPoints) { + progressHistoryRef.current = [ + progressHistoryRef.current[0], + ...progressHistoryRef.current.slice(-maxDataPoints + 1) + ]; + } + } + + lastProgressUpdateRef.current = currentTime; + + if (shouldSkipUpdate) { + return; + } + + const history = progressHistoryRef.current; + + if (history.length < 2) { + return; + } + + // Calculate progress rates for smoothing + const rates: number[] = []; + for (let i = 1; i < history.length; i++) { + const timeSpan = (history[i].time - history[i-1].time) / 1000; // Convert to seconds + const progressSpan = history[i].progress - history[i-1].progress; + if (timeSpan > 0) { + rates.push(progressSpan / timeSpan); + } + } + + if (rates.length === 0) return; + + // Apply moving average smoothing to the rates + const smoothedRates = calculateMovingAverage(rates, Math.min(5, rates.length)); + const currentRate = smoothedRates[smoothedRates.length - 1]; + + if (currentRate > 0) { + const remainingProgress = 100 - newProgress; + const estimatedSeconds = remainingProgress / currentRate; + + if (isFinite(estimatedSeconds) && estimatedSeconds > 0) { + // Apply adaptive dampening based on progress + const dampingFactor = Math.max(0.2, Math.min(0.8, newProgress / 100)); + const previousEstimate = getSecondsFromEstimate(estimatedTimeRemaining); + + const smoothedSeconds = previousEstimate + ? (estimatedSeconds * dampingFactor) + (previousEstimate * (1 - dampingFactor)) + : estimatedSeconds; + + setEstimatedTimeRemaining(formatTimeRemaining(smoothedSeconds)); + lastEstimateUpdateRef.current = currentTime; + } + } + }, [estimatedTimeRemaining, formatTimeRemaining, resetState, calculateMovingAverage]); + + const getSecondsFromEstimate = (estimate: string | null): number | null => { + if (!estimate) return null; + + let seconds = 0; + + if (estimate.includes('h')) { + const hours = parseInt(estimate.split('h')[0], 10); + seconds += hours * 3600; + estimate = estimate.split('h')[1]; + } + + if (estimate?.includes('m')) { + const minutes = parseInt(estimate.split('m')[0], 10); + seconds += minutes * 60; + estimate = estimate.split('m')[1]; + } + + if (estimate?.includes('s')) { + const secs = parseInt(estimate.split('s')[0], 10); + seconds += secs; + } + + return seconds; + }; + + const updateProgress = useCallback((newProgress: number) => { + const currentTime = Date.now(); + const clampedProgress = Math.max(0, Math.min(100, newProgress)); + + setProgressState(clampedProgress); + + if (clampedProgress === 0) { + resetState(); + } else if (clampedProgress === 100) { + setEstimatedTimeRemaining(null); + lastEstimateUpdateRef.current = currentTime; + lastProgressUpdateRef.current = currentTime; + } else { + calculateTimeRemaining(clampedProgress, currentTime); + } + }, [calculateTimeRemaining, resetState]); + + // Reset time estimation when component unmounts + useEffect(() => { + return () => { + resetState(); + }; + }, [resetState]); + + return { + progress, + setProgress: updateProgress, + estimatedTimeRemaining + }; +} \ No newline at end of file