mirror of
https://github.com/Tony0410/News-reader-pro.git
synced 2026-05-24 21:31:44 +08:00
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:
325
App.tsx
325
App.tsx
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user