Major feature update: Enhanced RSS, voice fixes, dark mode, and UI improvements

## New Features
- Article Summary Mode: AI-generated 30-second summaries with complexity analysis
- Reading Stats Dashboard: Track articles read, listening time, and streaks
- Bookmark/Resume: Auto-save progress when pausing, resume from where you left off
- Audio Export: Export articles as downloadable WAV files
- RSS Feed Manager: Subscribe to feeds with real-time validation and 31+ recommendations
- Smart Speed: Auto-adjust playback based on article complexity
- Voice Moods: Quick presets for different listening scenarios

## RSS Enhancements
- Expanded recommendations from 8 to 31 sources across 5 categories:
  * General News (9 sources)
  * Technology (8 sources)
  * Business & Finance (5 sources)
  * Science & Research (5 sources)
  * International News (4 sources)
- Real-time URL validation with visual feedback
- Detailed error messages for different failure scenarios
- Always-visible categorized recommendations
- Auto-loading articles when feeds are added

## Bug Fixes
- Fixed voice selection: Selected voice now consistently applies to playback
- Implemented voice generation counter to prevent voice mixing between paragraphs
- Fixed speed control to snap to clean 0.5 increments (1.0, 1.5, 2.0, etc.)
- Fixed dark mode toggle by configuring Tailwind CDN for class-based dark mode
- Removed vibe visualizer animation

## UI/UX Improvements
- Redesigned voice selector with prominent voice panel and preview functionality
- Added voice cards with emojis and descriptions
- Enhanced feature toolbar with quick access to all new features
- Improved reader view with better typography and reading modes
- Added ambient reading modes (clean, sepia, night light)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Tony0410
2025-11-28 00:42:14 +00:00
parent 3169438f7e
commit 9025d1b8f1
16 changed files with 2198 additions and 128 deletions

325
App.tsx
View File

@@ -1,16 +1,24 @@
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, Sparkles, Shuffle, History as HistoryIcon, GripVertical, Waves } 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 { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Settings, Moon, Sun, Keyboard, Loader2, History as HistoryIcon, Waves } from 'lucide-react';
import { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment, ReaderSettings, VoiceMood, ReadingStats, Bookmark } from './types';
import { MIN_SPEED, MAX_SPEED, SPEED_STEP, SMART_SPEED_MULTIPLIERS } from './constants';
import { extractArticleContent, generateSpeechFromText, generateArticleSummary, analyzeTextComplexity } from './services/geminiService';
import { base64ToUint8Array, createWavBlob } from './services/audioUtils';
import { createTrackedObjectUrl, revokeAllTrackedObjectUrls, revokeMultipleObjectUrls, revokeTrackedObjectUrl } from './services/objectUrlManager';
import { segmentText } from './services/textUtils';
import { getStats, saveBookmark, getBookmarkForUrl, updateStatsOnArticleComplete } from './services/storageService';
import { exportAndDownloadArticle } from './services/audioExportService';
import { QueueItem } from './components/QueueItem';
import { VoiceSelector } from './components/VoiceSelector';
import { VoiceSelector, VoicePanel } from './components/VoiceSelector';
import { ReaderView } from './components/ReaderView';
import { StatsPanel } from './components/StatsPanel';
import { RSSManager } from './components/RSSManager';
import { BookmarksPanel } from './components/BookmarksPanel';
import { VoiceMoodSelector } from './components/VoiceMoodSelector';
import { FeatureToolbar } from './components/FeatureToolbar';
import { SummaryCard } from './components/SummaryCard';
export default function App() {
// -- State --
@@ -27,8 +35,17 @@ export default function App() {
playbackRate: 1.0,
currentArticleId: null,
selectedVoice: VoiceName.Puck,
voiceMood: 'neutral',
smartSpeedEnabled: false,
});
// New feature states
const [showStatsPanel, setShowStatsPanel] = useState(false);
const [showRSSManager, setShowRSSManager] = useState(false);
const [showBookmarksPanel, setShowBookmarksPanel] = useState(false);
const [readingStats, setReadingStats] = useState<ReadingStats>(getStats());
const [isExporting, setIsExporting] = useState(false);
const [settings, setSettings] = useState<ReaderSettings>({
isDarkMode: false,
fontSize: 'lg',
@@ -40,16 +57,11 @@ export default function App() {
zenMode: false
});
const starterPicks = [
{ label: 'Tech trends', url: 'https://news.ycombinator.com' },
{ label: 'Global headlines', url: 'https://www.reuters.com/world/' },
{ label: 'Science daily', url: 'https://www.sciencedaily.com/news/top/science/' },
{ label: 'Longform feature', url: 'https://www.theguardian.com/world/interactive/2024/jan/01' }
];
// -- Refs --
const audioRef = useRef<HTMLAudioElement>(new Audio());
const processingRef = useRef<Set<string>>(new Set());
const voiceGenerationRef = useRef<number>(0); // Track voice changes to invalidate old requests
// -- Helpers --
const getCurrentArticle = () => queue.find(a => a.id === playerState.currentArticleId);
@@ -92,12 +104,22 @@ export default function App() {
const processSegmentAudio = useCallback(async (articleId: string, segmentId: string, text: string, voice: VoiceName) => {
const uniqueKey = `${articleId}-${segmentId}`;
if (processingRef.current.has(uniqueKey)) return;
// Capture the current generation - if voice changes, this will be outdated
const generation = voiceGenerationRef.current;
processingRef.current.add(uniqueKey);
updateSegment(articleId, segmentId, { isLoading: true });
try {
const base64Audio = await generateSpeechFromText(text, voice);
// Check if voice generation changed while we were generating
if (generation !== voiceGenerationRef.current) {
console.log(`Discarding segment ${segmentId} - voice changed during generation`);
return; // Discard this result, voice has changed
}
const pcmData = base64ToUint8Array(base64Audio);
const wavBlob = createWavBlob(pcmData);
const audioUrl = createTrackedObjectUrl(wavBlob);
@@ -126,42 +148,104 @@ export default function App() {
// -- Handlers --
const handleVoiceChange = useCallback((newVoice: VoiceName) => {
// Increment generation counter to invalidate all in-flight requests
voiceGenerationRef.current += 1;
setPlayerState(prev => ({ ...prev, selectedVoice: newVoice }));
// Force flush future buffer so new voice is applied immediately
// Force flush ALL audio buffers so new voice is applied immediately
setQueue(prevQueue => prevQueue.map(article => {
// If this is the currently active article
if (article.id === playerState.currentArticleId) {
return {
...article,
segments: article.segments.map((seg, idx) => {
// Keep the current segment (and past ones) to avoid cutting off mid-speech abruptly
if (idx <= article.currentSegmentIndex) {
return seg;
}
revokeTrackedObjectUrl(seg.audioUrl);
// Invalidate all future segments
return { ...seg, audioUrl: undefined, isLoading: false, hasError: false };
})
};
}
// For inactive articles, invalidate everything
return {
...article,
segments: article.segments.map(seg => {
revokeTrackedObjectUrl(seg.audioUrl);
return {
...seg,
audioUrl: undefined,
isLoading: false,
hasError: false
};
if (seg.audioUrl) {
revokeTrackedObjectUrl(seg.audioUrl);
}
// Invalidate ALL segments to force regeneration with new voice
return { ...seg, audioUrl: undefined, isLoading: false, hasError: false };
})
};
}));
}, [playerState.currentArticleId]);
}, []);
const enqueueArticle = async (targetUrl: string, options: { autoPlay?: boolean; pinView?: boolean } = {}) => {
// -- New Feature Handlers --
const handleMoodChange = useCallback((mood: VoiceMood, voice: VoiceName, speed: number) => {
setPlayerState(prev => ({
...prev,
voiceMood: mood,
selectedVoice: voice,
playbackRate: speed
}));
// Also trigger voice change to regenerate audio
handleVoiceChange(voice);
}, [handleVoiceChange]);
const handleSmartSpeedToggle = useCallback(() => {
setPlayerState(prev => ({
...prev,
smartSpeedEnabled: !prev.smartSpeedEnabled
}));
}, []);
const handleExportAudio = useCallback(async () => {
const article = getCurrentArticle();
if (!article) return;
setIsExporting(true);
try {
await exportAndDownloadArticle(article);
} catch (e) {
console.error('Export failed:', e);
} finally {
setIsExporting(false);
}
}, []);
const handleResumeFromBookmark = useCallback(async (url: string, segmentIndex: number) => {
// First enqueue the article
await enqueueArticle(url, { autoPlay: false, pinView: true });
// Then set the segment index once loaded
setTimeout(() => {
setQueue(prev => {
const article = prev.find(a => a.url === url);
if (article) {
return prev.map(a =>
a.url === url ? { ...a, currentSegmentIndex: segmentIndex } : a
);
}
return prev;
});
}, 1000);
}, []);
const handleRSSArticleSelect = useCallback((url: string) => {
enqueueArticle(url, { autoPlay: true, pinView: true });
}, []);
// Save bookmark when pausing
const saveCurrentBookmark = useCallback(() => {
const article = getCurrentArticle();
if (!article || article.segments.length === 0) return;
const progress = Math.round(
((article.currentSegmentIndex + 1) / article.segments.length) * 100
);
const bookmark: Bookmark = {
articleId: article.id,
url: article.url,
title: article.title,
segmentIndex: article.currentSegmentIndex,
savedAt: Date.now(),
progress
};
saveBookmark(bookmark);
}, []);
const enqueueArticle = async (targetUrl: string, options: { autoPlay?: boolean; pinView?: boolean; resumeSegment?: number } = {}) => {
const normalizedUrl = targetUrl.trim();
if (!normalizedUrl) return;
@@ -180,7 +264,8 @@ export default function App() {
text: '',
segments: [],
currentSegmentIndex: 0,
status: PlaybackStatus.LOADING_TEXT
status: PlaybackStatus.LOADING_TEXT,
addedAt: Date.now()
};
setQueue(prev => [...prev, newArticle]);
@@ -195,11 +280,26 @@ export default function App() {
if (titleSegment) segments.unshift(titleSegment);
}
// Analyze complexity for Smart Speed
const { complexity, wordCount, estimatedReadTime } = analyzeTextComplexity(text);
updateArticle(id, {
title,
text,
segments,
status: PlaybackStatus.LOADING_AUDIO
status: PlaybackStatus.LOADING_AUDIO,
complexity,
wordCount,
estimatedReadTime,
isSummaryLoading: true
});
// Generate summary in background
generateArticleSummary(text, title).then(summary => {
updateArticle(id, { summary, isSummaryLoading: false });
}).catch(e => {
console.warn('Summary generation failed:', e);
updateArticle(id, { isSummaryLoading: false });
});
if (segments.length > 0) {
@@ -233,15 +333,6 @@ export default function App() {
setInputUrl('');
};
const handleStarterPick = async (url: string) => {
await enqueueArticle(url, { autoPlay: true, pinView: true });
setActiveList('queue');
};
const handleRandomStarter = () => {
const pick = starterPicks[Math.floor(Math.random() * starterPicks.length)];
if (pick) handleStarterPick(pick.url);
};
// -- Playback Control --
@@ -256,8 +347,10 @@ export default function App() {
setPlayerState(prev => ({ ...prev, isPlaying: false }));
if (playerState.currentArticleId) {
updateArticle(playerState.currentArticleId, { status: PlaybackStatus.PAUSED });
// Save bookmark on pause
saveCurrentBookmark();
}
}, [playerState.currentArticleId]);
}, [playerState.currentArticleId, saveCurrentBookmark]);
const skipSegment = useCallback((direction: 'next' | 'prev') => {
setQueue(prevQueue => {
@@ -471,7 +564,9 @@ export default function App() {
}, [queue]);
const handleSpeedChange = (newSpeed: number) => {
const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed));
// Round to nearest 0.5 to keep clean values
const rounded = Math.round(newSpeed * 2) / 2;
const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, rounded));
setPlayerState(prev => ({ ...prev, playbackRate: speed }));
if (audioRef.current) {
audioRef.current.playbackRate = speed;
@@ -499,19 +594,12 @@ export default function App() {
<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={handleVoiceChange}
// Removed disabled prop to allow switching while playing
/>
<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>
<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>
{/* Settings Menu */}
@@ -663,40 +751,50 @@ export default function App() {
onChange={(e) => setInputUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()}
/>
<div className="flex items-center gap-2 self-end sm:self-auto">
<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>
<button
onClick={() => handleRandomStarter()}
className="bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-200 px-4 py-3 rounded-xl font-medium flex items-center gap-2 transition-all"
>
<Shuffle className="w-4 h-4" /> Surprise me
</button>
</div>
<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 self-end sm:self-auto"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">Queue</span>
</button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 px-3 pb-2">Try a curated link below or drop your own URL to get the vibes going.</p>
<p className="text-xs text-slate-500 dark:text-slate-400 px-3 pb-2">Paste any article URL to start listening.</p>
</div>
<div className="grid grid-cols-2 gap-3">
{starterPicks.map((pick) => (
<button
key={pick.url}
onClick={() => handleStarterPick(pick.url)}
className="text-left bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl p-4 hover:border-blue-200 hover:shadow-sm dark:hover:border-blue-800 transition-all"
>
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-200">
<Sparkles className="w-4 h-4 text-blue-500" /> {pick.label}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 truncate">{pick.url}</p>
</button>
))}
</div>
{/* Voice Mood Selector */}
<VoiceMoodSelector
selectedMood={playerState.voiceMood}
onMoodChange={handleMoodChange}
/>
{/* Voice Panel */}
<VoicePanel
selectedVoice={playerState.selectedVoice}
onVoiceChange={handleVoiceChange}
/>
{/* Feature Toolbar */}
<FeatureToolbar
onOpenStats={() => setShowStatsPanel(true)}
onOpenRSS={() => setShowRSSManager(true)}
onOpenBookmarks={() => setShowBookmarksPanel(true)}
onExportAudio={handleExportAudio}
smartSpeedEnabled={playerState.smartSpeedEnabled}
onToggleSmartSpeed={handleSmartSpeedToggle}
canExport={!!getCurrentArticle()?.segments.some(s => s.audioUrl)}
isExporting={isExporting}
/>
{/* Summary Card for viewing article */}
{viewingArticle && (viewingArticle.summary || viewingArticle.isSummaryLoading) && (
<SummaryCard
article={viewingArticle}
selectedVoice={playerState.selectedVoice}
onPlayFull={() => viewingArticle && playArticle(viewingArticle.id)}
/>
)}
{/* Queue List */}
<div className="space-y-4">
@@ -833,19 +931,6 @@ export default function App() {
<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 className="flex items-center gap-2 mt-2 justify-center sm:justify-start text-xs text-blue-500 dark:text-blue-300">
<Waves className={`w-4 h-4 ${playerState.isPlaying ? 'animate-pulse' : ''}`} />
<div className="flex items-end gap-1 h-4">
{[0.8, 1.2, 0.9, 1.4].map((height, idx) => (
<span
key={idx}
className={`w-1 rounded-full bg-gradient-to-t from-blue-500 to-indigo-400 ${playerState.isPlaying ? 'animate-[bounce_1.4s_infinite]' : ''}`}
style={{ height: `${height * 12}px`, opacity: isBuffering ? 0.4 : 1 }}
/>
))}
</div>
<span className="text-[11px] text-slate-500 dark:text-slate-400">Vibe visualizer</span>
</div>
</div>
) : (
<div className="text-slate-400 dark:text-slate-500 text-sm font-medium">Ready to play</div>
@@ -904,6 +989,28 @@ export default function App() {
</div>
</div>
</div>
{/* Modal Panels */}
{showStatsPanel && (
<StatsPanel
stats={readingStats}
onClose={() => setShowStatsPanel(false)}
/>
)}
{showRSSManager && (
<RSSManager
onArticleSelect={handleRSSArticleSelect}
onClose={() => setShowRSSManager(false)}
/>
)}
{showBookmarksPanel && (
<BookmarksPanel
onResumeArticle={handleResumeFromBookmark}
onClose={() => setShowBookmarksPanel(false)}
/>
)}
</div>
</div>
);