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 } 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 { 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([]); // Selected article for reading (defaults to playing article) const [viewId, setViewId] = useState(null); const [playerState, setPlayerState] = useState({ isPlaying: false, playbackRate: 1.0, currentArticleId: null, selectedVoice: VoiceName.Puck, }); // -- Refs -- const audioRef = useRef(new Audio()); const audioSrcRef = useRef(null); // -- Helpers -- const getCurrentArticle = () => queue.find(a => a.id === playerState.currentArticleId); const getViewingArticle = () => { // If user manually selected an article to view, show that. // Otherwise show the currently playing one. // Otherwise show the first one. 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; }; const updateArticleStatus = (id: string, status: PlaybackStatus, errorMessage?: string, audioUrl?: string, title?: string, text?: string) => { setQueue(prev => prev.map(item => { if (item.id !== id) return item; return { ...item, status, errorMessage, audioUrl: audioUrl || item.audioUrl, title: title || item.title, text: text || item.text }; })); }; // -- Handlers -- // 1. Add URL to Queue const handleAddUrl = async () => { if (!inputUrl.trim()) return; const id = uuidv4(); const newArticle: Article = { id, url: inputUrl, title: 'Fetching info...', text: '', status: PlaybackStatus.LOADING_TEXT }; setQueue(prev => [...prev, newArticle]); setInputUrl(''); // Auto view the new article while loading if (!playerState.isPlaying) { setViewId(id); } // Start fetching text immediately try { const { title, text } = await extractArticleContent(newArticle.url); updateArticleStatus(id, PlaybackStatus.IDLE, undefined, undefined, title, text); } catch (error: any) { updateArticleStatus(id, PlaybackStatus.ERROR, error.message || "Failed to load article"); } }; // 2. Generate Audio for an article const prepareAudio = async (articleId: string): Promise => { const article = queue.find(a => a.id === articleId); if (!article) return null; // If already has audio return it if (article.audioUrl) return article.audioUrl; updateArticleStatus(articleId, PlaybackStatus.LOADING_AUDIO); try { if (!article.text || article.text.length < 10) { throw new Error("No text available to read."); } const base64Audio = await generateSpeechFromText(article.text, playerState.selectedVoice); const pcmData = base64ToUint8Array(base64Audio); const wavBlob = createWavBlob(pcmData); const audioUrl = URL.createObjectURL(wavBlob); updateArticleStatus(articleId, PlaybackStatus.READY, undefined, audioUrl); return audioUrl; } catch (error: any) { updateArticleStatus(articleId, PlaybackStatus.ERROR, error.message || "Failed to generate speech"); return null; } }; // 3. Play Logic const playArticle = useCallback(async (id: string) => { const article = queue.find(a => a.id === id); if (!article) return; // If currently playing a different one, pause it. if (playerState.currentArticleId && playerState.currentArticleId !== id) { audioRef.current.pause(); } setPlayerState(prev => ({ ...prev, currentArticleId: id, isPlaying: true })); // Also switch view to the playing article setViewId(id); let src = article.audioUrl; // Check if we need to generate audio if (!src) { src = await prepareAudio(id); } if (src) { // Only update src if it's different to avoid reload if (audioSrcRef.current !== src) { audioRef.current.src = src; audioSrcRef.current = src; // Apply current speed audioRef.current.playbackRate = playerState.playbackRate; } try { await audioRef.current.play(); updateArticleStatus(id, PlaybackStatus.PLAYING); } catch (e) { console.error("Play error", e); setPlayerState(prev => ({ ...prev, isPlaying: false })); } } }, [queue, playerState.currentArticleId, playerState.playbackRate, playerState.selectedVoice]); const pausePlayback = useCallback(() => { audioRef.current.pause(); setPlayerState(prev => ({ ...prev, isPlaying: false })); if (playerState.currentArticleId) { updateArticleStatus(playerState.currentArticleId, PlaybackStatus.PAUSED); } }, [playerState.currentArticleId]); const handleSpeedChange = (newSpeed: number) => { // Clamp const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed)); setPlayerState(prev => ({ ...prev, playbackRate: speed })); if (audioRef.current) { audioRef.current.playbackRate = speed; } }; // Auto-Advance Logic useEffect(() => { const audio = audioRef.current; const handleEnded = () => { const currentId = playerState.currentArticleId; if (currentId) { updateArticleStatus(currentId, PlaybackStatus.COMPLETED); // Find next const currentIndex = queue.findIndex(a => a.id === currentId); if (currentIndex !== -1 && currentIndex < queue.length - 1) { const nextId = queue[currentIndex + 1].id; playArticle(nextId); } else { setPlayerState(prev => ({ ...prev, isPlaying: false })); } } }; audio.addEventListener('ended', handleEnded); return () => { audio.removeEventListener('ended', handleEnded); }; }, [playerState.currentArticleId, queue, playArticle]); // -- Render -- const currentArticle = getCurrentArticle(); const viewingArticle = getViewingArticle(); return (
{/* Header */}

NewsCaster AI

setPlayerState(prev => ({ ...prev, selectedVoice: v }))} disabled={playerState.isPlaying} />
{/* Main Content - Split Layout */}
{/* Left Column: Controls & Queue (5 cols) */}
{/* Input Section */}
setInputUrl(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()} />
{/* Queue List */}

Up Next

{queue.length} articles
{queue.length === 0 ? (

No articles queued.

) : ( queue.map(article => (
setViewId(article.id)} className="cursor-pointer"> 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); }} />
)) )}
{/* Right Column: Reader View (7 cols) */}
{/* Mobile: Reader View appears below if selected */}
{viewingArticle && (

Article Reader

)}
{/* Sticky Player */}
{/* Current Track Info */}
{currentArticle ? (

{currentArticle.title}

Playing from queue

) : (
Ready to play
)}
{/* Controls */}
{/* Speed Control */}
{playerState.playbackRate.toFixed(1)}x
{/* Main Transport */}
); }