Files
news-reader-actions-test/App.tsx
Anthony dadebf8cd0 feat: Enhance article segment navigation
Implement segment selection in ReaderView for user-driven playback control. This change allows users to click on specific segments within an article to jump to and play that segment directly.

The Gemini service's HTML parsing has also been simplified by removing redundant selectors and focusing on essential tag removal for more efficient text extraction.
2025-11-19 20:28:14 +08:00

588 lines
25 KiB
TypeScript

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 { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment, ReaderSettings } from './types';
import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants';
import { extractArticleContent, generateSpeechFromText } from './services/geminiService';
import { base64ToUint8Array, createWavBlob } from './services/audioUtils';
import { segmentText } from './services/textUtils';
import { QueueItem } from './components/QueueItem';
import { VoiceSelector } from './components/VoiceSelector';
import { ReaderView } from './components/ReaderView';
export default function App() {
// -- State --
const [inputUrl, setInputUrl] = useState('');
const [queue, setQueue] = useState<Article[]>([]);
const [viewId, setViewId] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [playerState, setPlayerState] = useState<PlayerState>({
isPlaying: false,
playbackRate: 1.0,
currentArticleId: null,
selectedVoice: VoiceName.Puck,
});
const [settings, setSettings] = useState<ReaderSettings>({
isDarkMode: false,
fontSize: 'lg',
lineHeight: 'relaxed',
fontFamily: 'serif',
autoScroll: true
});
// -- Refs --
const audioRef = useRef<HTMLAudioElement>(new Audio());
const processingRef = useRef<Set<string>>(new Set());
// -- Helpers --
const getCurrentArticle = () => queue.find(a => a.id === playerState.currentArticleId);
const getViewingArticle = () => {
if (viewId) return queue.find(a => a.id === viewId);
if (playerState.currentArticleId) return queue.find(a => a.id === playerState.currentArticleId);
if (queue.length > 0) return queue[0];
return null;
};
// -- State Updaters --
const updateArticle = (id: string, updates: Partial<Article>) => {
setQueue(prev => prev.map(item => item.id === id ? { ...item, ...updates } : item));
};
const updateSegment = (articleId: string, segmentId: string, updates: Partial<AudioSegment>) => {
setQueue(prev => prev.map(article => {
if (article.id !== articleId) return article;
const newSegments = article.segments.map(seg =>
seg.id === segmentId ? { ...seg, ...updates } : seg
);
return { ...article, segments: newSegments };
}));
};
// -- Audio Generation Pipeline --
const processSegmentAudio = useCallback(async (articleId: string, segmentId: string, text: string, voice: VoiceName) => {
const uniqueKey = `${articleId}-${segmentId}`;
if (processingRef.current.has(uniqueKey)) return;
processingRef.current.add(uniqueKey);
updateSegment(articleId, segmentId, { isLoading: true });
try {
const base64Audio = await generateSpeechFromText(text, voice);
const pcmData = base64ToUint8Array(base64Audio);
const wavBlob = createWavBlob(pcmData);
const audioUrl = URL.createObjectURL(wavBlob);
updateSegment(articleId, segmentId, { audioUrl, isLoading: false });
} catch (error) {
console.error("Segment generation failed", error);
updateSegment(articleId, segmentId, { isLoading: false, hasError: true });
} finally {
processingRef.current.delete(uniqueKey);
}
}, []);
const manageBuffer = useCallback(async (article: Article) => {
const currentIndex = article.currentSegmentIndex;
const segmentsToBuffer = article.segments.slice(currentIndex, currentIndex + 5);
for (const seg of segmentsToBuffer) {
if (!seg.audioUrl && !seg.isLoading && !seg.hasError) {
processSegmentAudio(article.id, seg.id, seg.text, playerState.selectedVoice);
}
}
}, [playerState.selectedVoice, processSegmentAudio]);
// -- Handlers --
const handleAddUrl = async () => {
if (!inputUrl.trim()) return;
const id = uuidv4();
const newArticle: Article = {
id,
url: inputUrl,
title: 'Fetching info...',
text: '',
segments: [],
currentSegmentIndex: 0,
status: PlaybackStatus.LOADING_TEXT
};
setQueue(prev => [...prev, newArticle]);
setInputUrl('');
if (!playerState.isPlaying) setViewId(id);
try {
const { title, text } = await extractArticleContent(newArticle.url);
const segments = segmentText(text);
if (title) {
const titleSegment = segmentText(title)[0];
if (titleSegment) segments.unshift(titleSegment);
}
updateArticle(id, {
title,
text,
segments,
status: PlaybackStatus.LOADING_AUDIO
});
if (segments.length > 0) {
const initialLoadCount = Math.min(segments.length, 5);
for(let i = 0; i < initialLoadCount; i++) {
processSegmentAudio(id, segments[i].id, segments[i].text, playerState.selectedVoice);
}
updateArticle(id, { status: PlaybackStatus.READY });
setQueue(prev => {
const current = prev.find(a => a.id === id);
if (current && !playerState.isPlaying) {
playArticle(id);
}
return prev;
});
} else {
updateArticle(id, { status: PlaybackStatus.ERROR, errorMessage: "No readable text found." });
}
} catch (error: any) {
updateArticle(id, {
status: PlaybackStatus.ERROR,
errorMessage: error.message || "Failed to load article"
});
}
};
// -- Playback Control --
const playArticle = useCallback(async (id: string) => {
setPlayerState(prev => ({ ...prev, currentArticleId: id, isPlaying: true }));
setViewId(id);
updateArticle(id, { status: PlaybackStatus.PLAYING });
}, []);
const pausePlayback = useCallback(() => {
audioRef.current.pause();
setPlayerState(prev => ({ ...prev, isPlaying: false }));
if (playerState.currentArticleId) {
updateArticle(playerState.currentArticleId, { status: PlaybackStatus.PAUSED });
}
}, [playerState.currentArticleId]);
const skipSegment = useCallback((direction: 'next' | 'prev') => {
setQueue(prevQueue => {
const currentId = playerState.currentArticleId;
if (!currentId) return prevQueue;
const article = prevQueue.find(a => a.id === currentId);
if (!article) return prevQueue;
let newIndex = direction === 'next'
? article.currentSegmentIndex + 1
: article.currentSegmentIndex - 1;
// 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)
newIndex = article.segments.length - 1;
}
return prevQueue.map(a => a.id === currentId ? { ...a, currentSegmentIndex: newIndex } : a);
});
}, [playerState.currentArticleId]);
const handleSegmentSelect = useCallback((articleId: string, index: number) => {
setPlayerState(prev => ({
...prev,
currentArticleId: articleId,
isPlaying: true
}));
updateArticle(articleId, {
currentSegmentIndex: index,
status: PlaybackStatus.PLAYING
});
}, []);
// -- Keyboard Shortcuts --
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore shortcuts if typing in input
if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) {
return;
}
switch (e.code) {
case 'Space':
e.preventDefault();
if (playerState.isPlaying) pausePlayback();
else if (playerState.currentArticleId) playArticle(playerState.currentArticleId);
else if (queue.length > 0) playArticle(queue[0].id);
break;
case 'ArrowRight':
skipSegment('next');
break;
case 'ArrowLeft':
skipSegment('prev');
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [playerState.isPlaying, playerState.currentArticleId, queue, playArticle, pausePlayback, skipSegment]);
// -- Effects --
useEffect(() => {
const article = queue.find(a => a.id === playerState.currentArticleId);
if (!article || !playerState.isPlaying) return;
const currentSegment = article.segments[article.currentSegmentIndex];
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');
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));
} else if (audioEl.paused) {
audioEl.play().catch(e => console.warn("Resume failed", e));
}
} else {
if (!currentSegment.isLoading && !currentSegment.hasError) {
processSegmentAudio(article.id, currentSegment.id, currentSegment.text, playerState.selectedVoice);
}
}
manageBuffer(article);
}, [queue, playerState.currentArticleId, playerState.isPlaying, playerState.playbackRate, playerState.selectedVoice, manageBuffer, processSegmentAudio]);
useEffect(() => {
const audio = audioRef.current;
const handleEnded = () => {
const currentId = playerState.currentArticleId;
setQueue(prevQueue => {
const article = prevQueue.find(a => a.id === currentId);
if (!article) return prevQueue;
const nextIndex = article.currentSegmentIndex + 1;
if (nextIndex < article.segments.length) {
return prevQueue.map(a => a.id === currentId ? { ...a, currentSegmentIndex: nextIndex } : a);
} else {
const artIndex = prevQueue.findIndex(a => a.id === currentId);
if (artIndex !== -1 && artIndex < prevQueue.length - 1) {
setTimeout(() => playArticle(prevQueue[artIndex + 1].id), 100);
return prevQueue.map(a => a.id === currentId ? { ...a, status: PlaybackStatus.COMPLETED } : a);
} else {
setPlayerState(ps => ({ ...ps, isPlaying: false }));
return prevQueue.map(a => a.id === currentId ? { ...a, status: PlaybackStatus.COMPLETED } : a);
}
}
});
};
audio.addEventListener('ended', handleEnded);
return () => audio.removeEventListener('ended', handleEnded);
}, [playerState.currentArticleId, playArticle]);
const handleSpeedChange = (newSpeed: number) => {
const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed));
setPlayerState(prev => ({ ...prev, playbackRate: speed }));
if (audioRef.current) {
audioRef.current.playbackRate = speed;
}
};
// -- Render --
const currentArticle = getCurrentArticle();
const viewingArticle = getViewingArticle();
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">
{/* Header */}
<header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 px-6 py-4 sticky top-0 z-20 shadow-sm transition-colors duration-300">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="bg-blue-600 text-white p-2 rounded-lg shadow-md">
<Volume2 className="w-6 h-6" />
</div>
<h1 className="text-xl font-bold text-slate-900 dark:text-white tracking-tight hidden sm:block">NewsCaster AI</h1>
</div>
<div className="flex items-center gap-4">
<VoiceSelector
selectedVoice={playerState.selectedVoice}
onVoiceChange={(v) => setPlayerState(prev => ({ ...prev, selectedVoice: v }))}
disabled={playerState.isPlaying}
/>
<button
onClick={() => setShowSettings(!showSettings)}
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
>
<Settings className="w-5 h-5" />
</button>
</div>
</div>
{/* Settings Menu */}
{showSettings && (
<div className="absolute right-4 top-16 bg-white dark:bg-slate-900 rounded-xl shadow-xl border border-slate-200 dark:border-slate-800 p-6 w-80 z-50 animate-in fade-in slide-in-from-top-4">
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<Settings className="w-4 h-4" /> Reader Preferences
</h3>
<div className="space-y-6">
{/* Theme */}
<div className="flex items-center justify-between">
<span className="text-slate-700 dark:text-slate-300 text-sm font-medium">Theme</span>
<div className="flex bg-slate-100 dark:bg-slate-800 rounded-lg p-1">
<button
onClick={() => setSettings(s => ({...s, isDarkMode: false}))}
className={`p-2 rounded-md transition-all ${!settings.isDarkMode ? 'bg-white shadow text-blue-600' : 'text-slate-400'}`}
>
<Sun className="w-4 h-4" />
</button>
<button
onClick={() => setSettings(s => ({...s, isDarkMode: true}))}
className={`p-2 rounded-md transition-all ${settings.isDarkMode ? 'bg-slate-700 shadow text-blue-400' : 'text-slate-400'}`}
>
<Moon className="w-4 h-4" />
</button>
</div>
</div>
{/* Font Size */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-700 dark:text-slate-300 font-medium">Text Size</span>
</div>
<input
type="range" min="0" max="4" step="1"
value={['sm','base','lg','xl','2xl'].indexOf(settings.fontSize)}
onChange={(e) => {
const sizes = ['sm','base','lg','xl','2xl'] as const;
setSettings(s => ({...s, fontSize: sizes[parseInt(e.target.value)]}));
}}
className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
<div className="flex justify-between text-xs text-slate-400">
<span>Aa</span>
<span>Aa</span>
</div>
</div>
{/* Font Family */}
<div className="space-y-2">
<span className="text-slate-700 dark:text-slate-300 text-sm font-medium block">Font Family</span>
<div className="grid grid-cols-3 gap-2">
{['serif', 'sans', 'mono'].map((font) => (
<button
key={font}
onClick={() => setSettings(s => ({...s, fontFamily: font as any}))}
className={`px-3 py-2 text-sm border rounded-lg transition-all capitalize ${
settings.fontFamily === font
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: 'border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400'
}`}
>
{font}
</button>
))}
</div>
</div>
{/* Shortcuts Hint */}
<div className="pt-4 border-t border-slate-100 dark:border-slate-800">
<p className="text-xs text-slate-400 flex items-center gap-2">
<Keyboard className="w-3 h-3" />
Shortcuts: Space (Play), Arrows (Skip)
</p>
</div>
</div>
</div>
)}
</header>
{/* Main Content */}
<main className="flex-grow px-4 py-6 max-w-7xl mx-auto w-full grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Left Column: Controls & Queue */}
<div className="lg:col-span-5 space-y-6">
{/* Input */}
<div className="bg-white dark:bg-slate-900 p-1 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 flex gap-2 items-center pl-4 transition-colors duration-300">
<input
type="url"
placeholder="Paste article URL here..."
className="flex-grow py-3 outline-none text-slate-700 dark:text-slate-200 bg-transparent placeholder:text-slate-400 min-w-0"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()}
/>
<button
onClick={handleAddUrl}
disabled={!inputUrl.trim()}
className="bg-slate-900 hover:bg-slate-800 dark:bg-blue-600 dark:hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 sm:px-6 py-3 rounded-xl font-medium transition-all flex items-center gap-2 flex-shrink-0 shadow-lg"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">Queue</span>
</button>
</div>
{/* Queue List */}
<div className="space-y-4">
<div className="flex items-center justify-between px-1">
<h2 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">Up Next</h2>
<span className="text-xs bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 px-2 py-1 rounded-full">{queue.length} articles</span>
</div>
<div className="space-y-3">
{queue.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-400 dark:text-slate-600 bg-white dark:bg-slate-900/50">
<p>No articles queued.</p>
</div>
) : (
queue.map(article => (
<div key={article.id} onClick={() => setViewId(article.id)} className="cursor-pointer">
<QueueItem
article={article}
isActive={article.id === playerState.currentArticleId}
isPlaying={playerState.isPlaying}
onPlay={() => playArticle(article.id)}
onPause={pausePlayback}
onRemove={() => {
if (playerState.currentArticleId === article.id) {
pausePlayback();
setPlayerState(prev => ({ ...prev, currentArticleId: null }));
}
setQueue(prev => prev.filter(a => a.id !== article.id));
if (viewId === article.id) setViewId(null);
}}
/>
</div>
))
)}
</div>
</div>
</div>
{/* Right Column: Reader View */}
<div className="lg:col-span-7 h-full hidden lg:block">
<ReaderView
article={viewingArticle}
settings={settings}
onToggleAutoScroll={() => setSettings(s => ({...s, autoScroll: !s.autoScroll}))}
onSegmentSelect={(index) => viewingArticle && handleSegmentSelect(viewingArticle.id, index)}
/>
</div>
<div className="lg:hidden block">
{viewingArticle && (
<div className="mt-8">
<h3 className="text-sm font-semibold text-slate-500 mb-2">Article Reader</h3>
<div className="h-[500px]">
<ReaderView
article={viewingArticle}
settings={settings}
onToggleAutoScroll={() => setSettings(s => ({...s, autoScroll: !s.autoScroll}))}
onSegmentSelect={(index) => viewingArticle && handleSegmentSelect(viewingArticle.id, index)}
/>
</div>
</div>
)}
</div>
</main>
{/* Player Bar */}
<div className="fixed bottom-0 left-0 right-0 bg-white/90 dark:bg-slate-900/90 backdrop-blur-lg border-t border-slate-200 dark:border-slate-800 p-4 pb-6 shadow-[0_-4px_20px_rgba(0,0,0,0.05)] z-30 transition-colors duration-300">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center gap-4 sm:gap-8">
<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>
{/* 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"
style={{ width: `${((currentArticle.currentSegmentIndex + 1) / Math.max(1, currentArticle.segments.length)) * 100}%`}}
/>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-1">
Playing segment {currentArticle.currentSegmentIndex + 1} of {currentArticle.segments.length}
</p>
</div>
) : (
<div className="text-slate-400 dark:text-slate-500 text-sm font-medium">Ready to play</div>
)}
</div>
<div className="flex items-center gap-6">
<div className="hidden sm:flex items-center gap-2 group relative">
<Gauge className="w-4 h-4 text-slate-400" />
<div className="flex items-center gap-2 bg-slate-100 dark:bg-slate-800 rounded-lg p-1">
<button
className="w-6 h-6 flex items-center justify-center hover:bg-white dark:hover:bg-slate-700 rounded text-xs font-bold text-slate-600 dark:text-slate-300 transition-colors"
onClick={() => handleSpeedChange(playerState.playbackRate - SPEED_STEP)}
>-</button>
<span className="text-xs font-mono w-8 text-center font-bold text-blue-600 dark:text-blue-400">{playerState.playbackRate.toFixed(1)}x</span>
<button
className="w-6 h-6 flex items-center justify-center hover:bg-white dark:hover:bg-slate-700 rounded text-xs font-bold text-slate-600 dark:text-slate-300 transition-colors"
onClick={() => handleSpeedChange(playerState.playbackRate + SPEED_STEP)}
>+</button>
</div>
</div>
<div className="flex items-center gap-4">
<button
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"
onClick={() => skipSegment('prev')}
>
<SkipBack className="w-5 h-5" />
</button>
<button
className="w-12 h-12 rounded-full bg-slate-900 hover:bg-slate-800 dark:bg-blue-600 dark:hover:bg-blue-500 text-white flex items-center justify-center shadow-lg hover:shadow-xl hover:scale-105 transition-all active:scale-95"
onClick={() => {
if (playerState.isPlaying) pausePlayback();
else if (playerState.currentArticleId) playArticle(playerState.currentArticleId);
else if (queue.length > 0) playArticle(queue[0].id);
}}
disabled={queue.length === 0}
>
{playerState.isPlaying ? <Pause className="w-5 h-5 fill-current" /> : <Play className="w-5 h-5 fill-current ml-1" />}
</button>
<button
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"
onClick={() => skipSegment('next')}
>
<SkipForward className="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}