mirror of
https://github.com/Tony0410/News-reader-pro.git
synced 2026-05-24 21:31:44 +08:00
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:
85
App.tsx
85
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 (
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user