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`.
639 lines
27 KiB
TypeScript
639 lines
27 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, 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';
|
|
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, 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]);
|
|
|
|
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) => {
|
|
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]);
|
|
|
|
// -- Main Playback Effect --
|
|
|
|
useEffect(() => {
|
|
const article = queue.find(a => a.id === playerState.currentArticleId);
|
|
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;
|
|
}
|
|
|
|
// 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.playbackRate = playerState.playbackRate;
|
|
|
|
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, 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;
|
|
|
|
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), 500);
|
|
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;
|
|
}
|
|
};
|
|
|
|
// -- 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">
|
|
|
|
{/* 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 && article.id === playerState.currentArticleId}
|
|
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 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 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>
|
|
<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}
|
|
>
|
|
{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
|
|
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>
|
|
);
|
|
}
|