diff --git a/App.tsx b/App.tsx index bd1b7dc..7b117cf 100644 --- a/App.tsx +++ b/App.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Settings, Moon, Sun, Type, Keyboard } from 'lucide-react'; +import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Settings, Moon, Sun, Keyboard, Loader2 } from 'lucide-react'; import { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment, ReaderSettings } from './types'; import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants'; import { extractArticleContent, generateSpeechFromText } from './services/geminiService'; @@ -190,10 +190,11 @@ export default function App() { // Boundary checks if (newIndex < 0) newIndex = 0; if (newIndex >= article.segments.length) { - // If skipping past end, just stop for now (or go to next article logic) + // If skipping past end, don't overflow newIndex = article.segments.length - 1; } + // If we are skipping, ensure we update state immediately return prevQueue.map(a => a.id === currentId ? { ...a, currentSegmentIndex: newIndex } : a); }); }, [playerState.currentArticleId]); @@ -213,7 +214,6 @@ export default function App() { // -- Keyboard Shortcuts -- useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Ignore shortcuts if typing in input if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) { return; } @@ -238,45 +238,80 @@ export default function App() { return () => window.removeEventListener('keydown', handleKeyDown); }, [playerState.isPlaying, playerState.currentArticleId, queue, playArticle, pausePlayback, skipSegment]); - // -- Effects -- + // -- Main Playback Effect -- useEffect(() => { const article = queue.find(a => a.id === playerState.currentArticleId); - if (!article || !playerState.isPlaying) return; + const audioEl = audioRef.current; + + // 1. Stop if paused or no article + if (!article || !playerState.isPlaying) { + if (!audioEl.paused) audioEl.pause(); + return; + } const currentSegment = article.segments[article.currentSegmentIndex]; + // 2. Handle Article Completion if (!currentSegment) { updateArticle(article.id, { status: PlaybackStatus.COMPLETED }); setPlayerState(prev => ({ ...prev, isPlaying: false })); return; } + // 3. Auto-Skip on Error + if (currentSegment.hasError) { + console.warn(`Segment ${currentSegment.id} failed. Skipping.`); + skipSegment('next'); + return; + } + + // 4. Playback Logic if (currentSegment.audioUrl) { - const audioEl = audioRef.current; - const currentSrc = audioEl.getAttribute('data-current-src'); + const currentSrc = audioEl.src; + // Only switch source if it's actually different to avoid reloading/interrupting if (currentSrc !== currentSegment.audioUrl) { audioEl.src = currentSegment.audioUrl; - audioEl.setAttribute('data-current-src', currentSegment.audioUrl); audioEl.playbackRate = playerState.playbackRate; - audioEl.play().catch(e => console.warn("Playback interrupted", e)); + + const playPromise = audioEl.play(); + if (playPromise !== undefined) { + playPromise.catch(error => { + // AbortError is common when skipping fast, ignore it. + if (error.name !== 'AbortError') { + console.warn("Playback failed:", error); + } + }); + } } else if (audioEl.paused) { + // If src matches but paused (and we want to play), resume. audioEl.play().catch(e => console.warn("Resume failed", e)); } } else { + // 5. Buffering State (Audio not ready) + // Ensure generation is active if (!currentSegment.isLoading && !currentSegment.hasError) { processSegmentAudio(article.id, currentSegment.id, currentSegment.text, playerState.selectedVoice); } + // We do not pause here explicitly; leaving the previous audio ended (paused) effectively means silence/buffering. + // The UI will show buffering state based on !currentSegment.audioUrl } + + // 6. Lookahead Buffer manageBuffer(article); - }, [queue, playerState.currentArticleId, playerState.isPlaying, playerState.playbackRate, playerState.selectedVoice, manageBuffer, processSegmentAudio]); + }, [queue, playerState.currentArticleId, playerState.isPlaying, playerState.playbackRate, playerState.selectedVoice, manageBuffer, processSegmentAudio, skipSegment]); + + // -- Audio Event Listeners -- useEffect(() => { const audio = audioRef.current; + const handleEnded = () => { const currentId = playerState.currentArticleId; + + // We use functional update to get latest queue state setQueue(prevQueue => { const article = prevQueue.find(a => a.id === currentId); if (!article) return prevQueue; @@ -284,11 +319,15 @@ export default function App() { const nextIndex = article.currentSegmentIndex + 1; if (nextIndex < article.segments.length) { + // Advance to next segment return prevQueue.map(a => a.id === currentId ? { ...a, currentSegmentIndex: nextIndex } : a); } else { + // Article finished const artIndex = prevQueue.findIndex(a => a.id === currentId); + + // Auto-play next article if available if (artIndex !== -1 && artIndex < prevQueue.length - 1) { - setTimeout(() => playArticle(prevQueue[artIndex + 1].id), 100); + setTimeout(() => playArticle(prevQueue[artIndex + 1].id), 500); return prevQueue.map(a => a.id === currentId ? { ...a, status: PlaybackStatus.COMPLETED } : a); } else { setPlayerState(ps => ({ ...ps, isPlaying: false })); @@ -297,6 +336,7 @@ export default function App() { } }); }; + audio.addEventListener('ended', handleEnded); return () => audio.removeEventListener('ended', handleEnded); }, [playerState.currentArticleId, playArticle]); @@ -309,10 +349,12 @@ export default function App() { } }; - // -- Render -- - + // -- Derived UI State -- const currentArticle = getCurrentArticle(); const viewingArticle = getViewingArticle(); + + const isBuffering = playerState.isPlaying && currentArticle && + (!currentArticle.segments[currentArticle.currentSegmentIndex]?.audioUrl); return (