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`.
This commit is contained in:
Anthony
2025-11-19 20:36:39 +08:00
parent dadebf8cd0
commit 0b10d71554
3 changed files with 84 additions and 19 deletions

85
App.tsx
View File

@@ -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 (
<div className={`${settings.isDarkMode ? 'dark' : ''} transition-colors duration-300`}>
@@ -465,7 +507,7 @@ export default function App() {
<QueueItem
article={article}
isActive={article.id === playerState.currentArticleId}
isPlaying={playerState.isPlaying}
isPlaying={playerState.isPlaying && article.id === playerState.currentArticleId}
onPlay={() => playArticle(article.id)}
onPause={pausePlayback}
onRemove={() => {
@@ -518,11 +560,14 @@ export default function App() {
<div className="flex-grow w-full sm:w-auto min-w-0 text-center sm:text-left">
{currentArticle ? (
<div>
<h4 className="font-bold text-slate-900 dark:text-white truncate">{currentArticle.title}</h4>
<h4 className="font-bold text-slate-900 dark:text-white truncate flex items-center gap-2 justify-center sm:justify-start">
{currentArticle.title}
{isBuffering && <span className="text-xs font-normal text-blue-500 animate-pulse">(Buffering...)</span>}
</h4>
{/* Progress Bar */}
<div className="w-full h-1 bg-slate-200 dark:bg-slate-700 rounded-full mt-2 overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
className={`h-full transition-all duration-300 ${isBuffering ? 'bg-blue-400/50 animate-pulse' : 'bg-blue-500'}`}
style={{ width: `${((currentArticle.currentSegmentIndex + 1) / Math.max(1, currentArticle.segments.length)) * 100}%`}}
/>
</div>
@@ -568,7 +613,13 @@ export default function App() {
}}
disabled={queue.length === 0}
>
{playerState.isPlaying ? <Pause className="w-5 h-5 fill-current" /> : <Play className="w-5 h-5 fill-current ml-1" />}
{isBuffering ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : playerState.isPlaying ? (
<Pause className="w-5 h-5 fill-current" />
) : (
<Play className="w-5 h-5 fill-current ml-1" />
)}
</button>
<button