From 0b10d71554f07a9e29e0c37e130599a994004cc2 Mon Sep 17 00:00:00 2001 From: Anthony <47945770+Tony0410@users.noreply.github.com> Date: Wed, 19 Nov 2025 20:36:39 +0800 Subject: [PATCH] feat: Add buffering indicator and improve queue item removal Introduces a visual indicator for when the reader is buffering, meaning it's supposed to be playing but the current audio segment is not yet available. Also, prevents accidental article selection when clicking the "Remove" button in the queue by adding `stopPropagation`. --- App.tsx | 85 +++++++++++++++++++++++++++++++-------- components/QueueItem.tsx | 14 ++++++- components/ReaderView.tsx | 4 +- 3 files changed, 84 insertions(+), 19 deletions(-) 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 (
@@ -465,7 +507,7 @@ export default function App() { playArticle(article.id)} onPause={pausePlayback} onRemove={() => { @@ -518,11 +560,14 @@ export default function App() {
{currentArticle ? (
-

{currentArticle.title}

+

+ {currentArticle.title} + {isBuffering && (Buffering...)} +

{/* Progress Bar */}
@@ -568,7 +613,13 @@ export default function App() { }} disabled={queue.length === 0} > - {playerState.isPlaying ? : } + {isBuffering ? ( + + ) : playerState.isPlaying ? ( + + ) : ( + + )} )}