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

87
App.tsx
View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { v4 as uuidv4 } from 'uuid'; 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 { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment, ReaderSettings } from './types';
import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants'; import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants';
import { extractArticleContent, generateSpeechFromText } from './services/geminiService'; import { extractArticleContent, generateSpeechFromText } from './services/geminiService';
@@ -190,10 +190,11 @@ export default function App() {
// Boundary checks // Boundary checks
if (newIndex < 0) newIndex = 0; if (newIndex < 0) newIndex = 0;
if (newIndex >= article.segments.length) { 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; 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); return prevQueue.map(a => a.id === currentId ? { ...a, currentSegmentIndex: newIndex } : a);
}); });
}, [playerState.currentArticleId]); }, [playerState.currentArticleId]);
@@ -213,7 +214,6 @@ export default function App() {
// -- Keyboard Shortcuts -- // -- Keyboard Shortcuts --
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// Ignore shortcuts if typing in input
if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) { if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) {
return; return;
} }
@@ -238,45 +238,80 @@ export default function App() {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [playerState.isPlaying, playerState.currentArticleId, queue, playArticle, pausePlayback, skipSegment]); }, [playerState.isPlaying, playerState.currentArticleId, queue, playArticle, pausePlayback, skipSegment]);
// -- Effects -- // -- Main Playback Effect --
useEffect(() => { useEffect(() => {
const article = queue.find(a => a.id === playerState.currentArticleId); 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]; const currentSegment = article.segments[article.currentSegmentIndex];
// 2. Handle Article Completion
if (!currentSegment) { if (!currentSegment) {
updateArticle(article.id, { status: PlaybackStatus.COMPLETED }); updateArticle(article.id, { status: PlaybackStatus.COMPLETED });
setPlayerState(prev => ({ ...prev, isPlaying: false })); setPlayerState(prev => ({ ...prev, isPlaying: false }));
return; return;
} }
if (currentSegment.audioUrl) { // 3. Auto-Skip on Error
const audioEl = audioRef.current; if (currentSegment.hasError) {
const currentSrc = audioEl.getAttribute('data-current-src'); console.warn(`Segment ${currentSegment.id} failed. Skipping.`);
skipSegment('next');
return;
}
// 4. Playback Logic
if (currentSegment.audioUrl) {
const currentSrc = audioEl.src;
// Only switch source if it's actually different to avoid reloading/interrupting
if (currentSrc !== currentSegment.audioUrl) { if (currentSrc !== currentSegment.audioUrl) {
audioEl.src = currentSegment.audioUrl; audioEl.src = currentSegment.audioUrl;
audioEl.setAttribute('data-current-src', currentSegment.audioUrl);
audioEl.playbackRate = playerState.playbackRate; 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) { } else if (audioEl.paused) {
// If src matches but paused (and we want to play), resume.
audioEl.play().catch(e => console.warn("Resume failed", e)); audioEl.play().catch(e => console.warn("Resume failed", e));
} }
} else { } else {
// 5. Buffering State (Audio not ready)
// Ensure generation is active
if (!currentSegment.isLoading && !currentSegment.hasError) { if (!currentSegment.isLoading && !currentSegment.hasError) {
processSegmentAudio(article.id, currentSegment.id, currentSegment.text, playerState.selectedVoice); 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); 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(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
const handleEnded = () => { const handleEnded = () => {
const currentId = playerState.currentArticleId; const currentId = playerState.currentArticleId;
// We use functional update to get latest queue state
setQueue(prevQueue => { setQueue(prevQueue => {
const article = prevQueue.find(a => a.id === currentId); const article = prevQueue.find(a => a.id === currentId);
if (!article) return prevQueue; if (!article) return prevQueue;
@@ -284,11 +319,15 @@ export default function App() {
const nextIndex = article.currentSegmentIndex + 1; const nextIndex = article.currentSegmentIndex + 1;
if (nextIndex < article.segments.length) { if (nextIndex < article.segments.length) {
// Advance to next segment
return prevQueue.map(a => a.id === currentId ? { ...a, currentSegmentIndex: nextIndex } : a); return prevQueue.map(a => a.id === currentId ? { ...a, currentSegmentIndex: nextIndex } : a);
} else { } else {
// Article finished
const artIndex = prevQueue.findIndex(a => a.id === currentId); const artIndex = prevQueue.findIndex(a => a.id === currentId);
// Auto-play next article if available
if (artIndex !== -1 && artIndex < prevQueue.length - 1) { 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); return prevQueue.map(a => a.id === currentId ? { ...a, status: PlaybackStatus.COMPLETED } : a);
} else { } else {
setPlayerState(ps => ({ ...ps, isPlaying: false })); setPlayerState(ps => ({ ...ps, isPlaying: false }));
@@ -297,6 +336,7 @@ export default function App() {
} }
}); });
}; };
audio.addEventListener('ended', handleEnded); audio.addEventListener('ended', handleEnded);
return () => audio.removeEventListener('ended', handleEnded); return () => audio.removeEventListener('ended', handleEnded);
}, [playerState.currentArticleId, playArticle]); }, [playerState.currentArticleId, playArticle]);
@@ -309,11 +349,13 @@ export default function App() {
} }
}; };
// -- Render -- // -- Derived UI State --
const currentArticle = getCurrentArticle(); const currentArticle = getCurrentArticle();
const viewingArticle = getViewingArticle(); const viewingArticle = getViewingArticle();
const isBuffering = playerState.isPlaying && currentArticle &&
(!currentArticle.segments[currentArticle.currentSegmentIndex]?.audioUrl);
return ( return (
<div className={`${settings.isDarkMode ? 'dark' : ''} transition-colors duration-300`}> <div className={`${settings.isDarkMode ? 'dark' : ''} transition-colors duration-300`}>
<div className="min-h-screen flex flex-col bg-slate-50 dark:bg-slate-950 pb-32 transition-colors duration-300"> <div className="min-h-screen flex flex-col bg-slate-50 dark:bg-slate-950 pb-32 transition-colors duration-300">
@@ -465,7 +507,7 @@ export default function App() {
<QueueItem <QueueItem
article={article} article={article}
isActive={article.id === playerState.currentArticleId} isActive={article.id === playerState.currentArticleId}
isPlaying={playerState.isPlaying} isPlaying={playerState.isPlaying && article.id === playerState.currentArticleId}
onPlay={() => playArticle(article.id)} onPlay={() => playArticle(article.id)}
onPause={pausePlayback} onPause={pausePlayback}
onRemove={() => { 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"> <div className="flex-grow w-full sm:w-auto min-w-0 text-center sm:text-left">
{currentArticle ? ( {currentArticle ? (
<div> <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 */} {/* Progress Bar */}
<div className="w-full h-1 bg-slate-200 dark:bg-slate-700 rounded-full mt-2 overflow-hidden"> <div className="w-full h-1 bg-slate-200 dark:bg-slate-700 rounded-full mt-2 overflow-hidden">
<div <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}%`}} style={{ width: `${((currentArticle.currentSegmentIndex + 1) / Math.max(1, currentArticle.segments.length)) * 100}%`}}
/> />
</div> </div>
@@ -568,7 +613,13 @@ export default function App() {
}} }}
disabled={queue.length === 0} 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>
<button <button

View File

@@ -21,7 +21,16 @@ export const QueueItem: React.FC<QueueItemProps> = ({
onRemove onRemove
}) => { }) => {
// Check if buffering: active, supposed to be playing, but current segment audio is missing
const isBuffering = isActive && isPlaying &&
article.segments[article.currentSegmentIndex] &&
!article.segments[article.currentSegmentIndex].audioUrl;
const getStatusIcon = () => { const getStatusIcon = () => {
if (isBuffering) {
return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />;
}
switch (article.status) { switch (article.status) {
case PlaybackStatus.LOADING_TEXT: case PlaybackStatus.LOADING_TEXT:
return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />; return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />;
@@ -76,7 +85,10 @@ export const QueueItem: React.FC<QueueItemProps> = ({
</button> </button>
)} )}
<button <button
onClick={onRemove} onClick={(e) => {
e.stopPropagation(); // Prevent article selection when removing
onRemove();
}}
className="text-xs text-slate-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 underline px-2" className="text-xs text-slate-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 underline px-2"
> >
Remove Remove

View File

@@ -106,6 +106,8 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
{article.segments.length > 0 ? ( {article.segments.length > 0 ? (
article.segments.map((segment, idx) => { article.segments.map((segment, idx) => {
const isActive = article.currentSegmentIndex === idx; const isActive = article.currentSegmentIndex === idx;
const isBuffering = isActive && article.status === 'PLAYING' && !segment.audioUrl;
return ( return (
<div <div
key={segment.id} key={segment.id}
@@ -116,7 +118,7 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
transition-all duration-200 whitespace-pre-wrap rounded-xl p-3 sm:p-4 -mx-2 sm:-mx-4 border-l-4 mb-2 transition-all duration-200 whitespace-pre-wrap rounded-xl p-3 sm:p-4 -mx-2 sm:-mx-4 border-l-4 mb-2
${getLeadingClass()} ${getLeadingClass()}
${isActive ${isActive
? 'text-slate-900 dark:text-white bg-blue-50 dark:bg-blue-900/20 border-blue-500 shadow-sm' ? `bg-blue-50 dark:bg-blue-900/20 border-blue-500 shadow-sm ${isBuffering ? 'animate-pulse opacity-70' : 'text-slate-900 dark:text-white'}`
: 'text-slate-700 dark:text-slate-300 border-transparent hover:bg-slate-100 dark:hover:bg-slate-800/50 cursor-pointer hover:border-slate-300 dark:hover:border-slate-600' : 'text-slate-700 dark:text-slate-300 border-transparent hover:bg-slate-100 dark:hover:bg-slate-800/50 cursor-pointer hover:border-slate-300 dark:hover:border-slate-600'
} }
`} `}