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.
471 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|