Files
news-reader-actions-test/App.tsx
Anthony 417d48ffdf feat: Improve text segmentation for faster playback
Implement a progressive text segmentation strategy. The first few segments are intentionally kept very short to allow playback to start almost immediately, creating a more responsive feel. As more segments are processed, their length gradually increases to optimize audio generation efficiency for the remainder of the article. Additionally, the title is now prepended as the very first segment. The buffer ahead is also increased to 5 segments to ensure content is ready.

Further refinements include:
- Enhanced voice descriptions in constants.
- Improved segment styling in ReaderView for better visual active state indication.
2025-11-19 20:15:39 +08:00

471 lines
19 KiB
TypeScript

import React, { useState, useRef, useEffect, useCallback } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Layout } from 'lucide-react';
import { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment } from './types';
import { AVAILABLE_VOICES, 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[]>([]);
// Selected article for viewing text (separate from playing)
const [viewId, setViewId] = useState<string | null>(null);
const [playerState, setPlayerState] = useState<PlayerState>({
isPlaying: false,
playbackRate: 1.0,
currentArticleId: null,
selectedVoice: VoiceName.Puck,
});
// -- Refs --
const audioRef = useRef<HTMLAudioElement>(new Audio());
// Track active processing to prevent duplicate fetch calls
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 --
/**
* Fetches audio for a specific segment.
*/
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);
}
}, []);
/**
* Manages the buffer. ensure current segment + next N are ready.
* We buffer 5 segments ahead because the first few are very small (fast),
* so we need to be fetching the larger later ones while the small ones play.
*/
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) {
// No await here - fire in background
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);
// 1. Split text into segments (Progressive: Small -> Large)
const segments = segmentText(text);
// Add title as the very first segment (Super fast interaction)
if (title) {
const titleSegment = segmentText(title)[0]; // Re-use segment logic for title
if (titleSegment) segments.unshift(titleSegment);
}
updateArticle(id, {
title,
text,
segments,
status: PlaybackStatus.LOADING_AUDIO
});
// 2. Trigger audio for the first batch immediately
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 });
// Auto-play logic
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]);
// -- Effects --
// 1. Audio Player Loop
useEffect(() => {
const article = queue.find(a => a.id === playerState.currentArticleId);
if (!article || !playerState.isPlaying) return;
const currentSegment = article.segments[article.currentSegmentIndex];
// If finished all segments
if (!currentSegment) {
updateArticle(article.id, { status: PlaybackStatus.COMPLETED });
setPlayerState(prev => ({ ...prev, isPlaying: false }));
return;
}
// Check if audio is ready
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 current segment is missing, ensure it's loading
if (!currentSegment.isLoading && !currentSegment.hasError) {
processSegmentAudio(article.id, currentSegment.id, currentSegment.text, playerState.selectedVoice);
}
}
// Always try to buffer ahead
manageBuffer(article);
}, [queue, playerState.currentArticleId, playerState.isPlaying, playerState.playbackRate, playerState.selectedVoice, manageBuffer, processSegmentAudio]);
// 2. Handle 'Ended' event to advance segment
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 we have a next segment, advance index
if (nextIndex < article.segments.length) {
return prevQueue.map(a => a.id === currentId ? { ...a, currentSegmentIndex: nextIndex } : a);
} else {
// Article finished
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]);
// 3. Handle Speed Change
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="min-h-screen flex flex-col bg-slate-50 pb-32">
{/* Header */}
<header className="bg-white border-b border-slate-200 px-6 py-4 sticky top-0 z-20 shadow-sm">
<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">
<Volume2 className="w-6 h-6" />
</div>
<h1 className="text-xl font-bold text-slate-900 tracking-tight hidden sm:block">NewsCaster AI</h1>
</div>
<VoiceSelector
selectedVoice={playerState.selectedVoice}
onVoiceChange={(v) => setPlayerState(prev => ({ ...prev, selectedVoice: v }))}
disabled={playerState.isPlaying}
/>
</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 p-1 rounded-2xl shadow-sm border border-slate-200 flex gap-2 items-center pl-4">
<input
type="url"
placeholder="Paste article URL here..."
className="flex-grow py-3 outline-none text-slate-700 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 disabled:bg-slate-300 text-white px-4 sm:px-6 py-3 rounded-xl font-medium transition-all flex items-center gap-2 flex-shrink-0"
>
<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 uppercase tracking-wider">Up Next</h2>
<span className="text-xs bg-slate-100 text-slate-600 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 rounded-2xl text-slate-400 bg-white">
<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} />
</div>
<div className="lg:hidden block">
{viewingArticle && (
<div className="mt-8">
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-2">Article Reader</h3>
<div className="h-[500px]">
<ReaderView article={viewingArticle} />
</div>
</div>
)}
</div>
</main>
{/* Player Bar */}
<div className="fixed bottom-0 left-0 right-0 bg-white/90 backdrop-blur-lg border-t border-slate-200 p-4 pb-6 shadow-[0_-4px_20px_rgba(0,0,0,0.05)] z-30">
<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 truncate">{currentArticle.title}</h4>
{/* Progress Bar for current segment */}
<div className="w-full h-1 bg-slate-200 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 truncate mt-1">
Playing segment {currentArticle.currentSegmentIndex + 1} of {currentArticle.segments.length}
</p>
</div>
) : (
<div className="text-slate-400 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 rounded-lg p-1">
<button
className="w-6 h-6 flex items-center justify-center hover:bg-white rounded text-xs font-bold text-slate-600 transition-colors"
onClick={() => handleSpeedChange(playerState.playbackRate - SPEED_STEP)}
>-</button>
<span className="text-xs font-mono w-8 text-center font-bold text-blue-600">{playerState.playbackRate.toFixed(1)}x</span>
<button
className="w-6 h-6 flex items-center justify-center hover:bg-white rounded text-xs font-bold text-slate-600 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 transition-colors"
onClick={() => {
if (currentArticle && currentArticle.currentSegmentIndex > 0) {
// Go back one segment
setQueue(prev => prev.map(a => a.id === currentArticle.id ? { ...a, currentSegmentIndex: a.currentSegmentIndex - 1 } : a));
} else {
// Prev article
const idx = queue.findIndex(a => a.id === playerState.currentArticleId);
if (idx > 0) playArticle(queue[idx - 1].id);
}
}}
>
<SkipBack className="w-5 h-5" />
</button>
<button
className="w-12 h-12 rounded-full bg-slate-900 hover:bg-slate-800 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 transition-colors"
onClick={() => {
if (currentArticle && currentArticle.currentSegmentIndex < currentArticle.segments.length - 1) {
// Next segment
setQueue(prev => prev.map(a => a.id === currentArticle.id ? { ...a, currentSegmentIndex: a.currentSegmentIndex + 1 } : a));
} else {
// Next article
const idx = queue.findIndex(a => a.id === playerState.currentArticleId);
if (idx !== -1 && idx < queue.length - 1) playArticle(queue[idx + 1].id);
}
}}
>
<SkipForward className="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
</div>
);
}