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:
87
App.tsx
87
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;
|
||||
}
|
||||
|
||||
if (currentSegment.audioUrl) {
|
||||
const audioEl = audioRef.current;
|
||||
const currentSrc = audioEl.getAttribute('data-current-src');
|
||||
// 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 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,11 +349,13 @@ 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`}>
|
||||
<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
|
||||
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
|
||||
|
||||
@@ -21,7 +21,16 @@ export const QueueItem: React.FC<QueueItemProps> = ({
|
||||
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 = () => {
|
||||
if (isBuffering) {
|
||||
return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />;
|
||||
}
|
||||
|
||||
switch (article.status) {
|
||||
case PlaybackStatus.LOADING_TEXT:
|
||||
return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />;
|
||||
@@ -76,7 +85,10 @@ export const QueueItem: React.FC<QueueItemProps> = ({
|
||||
</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"
|
||||
>
|
||||
Remove
|
||||
|
||||
@@ -106,6 +106,8 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
|
||||
{article.segments.length > 0 ? (
|
||||
article.segments.map((segment, idx) => {
|
||||
const isActive = article.currentSegmentIndex === idx;
|
||||
const isBuffering = isActive && article.status === 'PLAYING' && !segment.audioUrl;
|
||||
|
||||
return (
|
||||
<div
|
||||
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
|
||||
${getLeadingClass()}
|
||||
${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'
|
||||
}
|
||||
`}
|
||||
|
||||
Reference in New Issue
Block a user