From 9025d1b8f15f1db7b10fe9fdcb3269d0d8b34854 Mon Sep 17 00:00:00 2001 From: Tony0410 Date: Fri, 28 Nov 2025 00:42:14 +0000 Subject: [PATCH] Major feature update: Enhanced RSS, voice fixes, dark mode, and UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- App.tsx | 325 ++++++++++++++------- components/BookmarksPanel.tsx | 128 ++++++++ components/FeatureToolbar.tsx | 94 ++++++ components/RSSManager.tsx | 482 +++++++++++++++++++++++++++++++ components/StatsPanel.tsx | 155 ++++++++++ components/SummaryCard.tsx | 134 +++++++++ components/VoiceMoodSelector.tsx | 45 +++ components/VoiceSelector.tsx | 169 ++++++++++- constants.ts | 99 ++++++- index.html | 5 + services/audioExportService.ts | 135 +++++++++ services/geminiService.ts | 81 ++++++ services/rssService.ts | 184 ++++++++++++ services/storageService.ts | 213 ++++++++++++++ types.ts | 72 ++++- vite.config.ts | 5 +- 16 files changed, 2198 insertions(+), 128 deletions(-) create mode 100644 components/BookmarksPanel.tsx create mode 100644 components/FeatureToolbar.tsx create mode 100644 components/RSSManager.tsx create mode 100644 components/StatsPanel.tsx create mode 100644 components/SummaryCard.tsx create mode 100644 components/VoiceMoodSelector.tsx create mode 100644 services/audioExportService.ts create mode 100644 services/rssService.ts create mode 100644 services/storageService.ts diff --git a/App.tsx b/App.tsx index a85e5e6..2b8928b 100644 --- a/App.tsx +++ b/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(getStats()); + const [isExporting, setIsExporting] = useState(false); + const [settings, setSettings] = useState({ 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(new Audio()); const processingRef = useRef>(new Set()); + const voiceGenerationRef = useRef(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() {

NewsCaster AI

-
- - -
+ {/* Settings Menu */} @@ -663,40 +751,50 @@ export default function App() { onChange={(e) => setInputUrl(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()} /> -
- - -
+ -

Try a curated link below or drop your own URL to get the vibes going.

+

Paste any article URL to start listening.

-
- {starterPicks.map((pick) => ( - - ))} -
+ {/* Voice Mood Selector */} + + + {/* Voice Panel */} + + + {/* Feature Toolbar */} + 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) && ( + viewingArticle && playArticle(viewingArticle.id)} + /> + )} {/* Queue List */}
@@ -833,19 +931,6 @@ export default function App() {

Playing segment {currentArticle.currentSegmentIndex + 1} of {currentArticle.segments.length}

-
- -
- {[0.8, 1.2, 0.9, 1.4].map((height, idx) => ( - - ))} -
- Vibe visualizer -
) : (
Ready to play
@@ -904,6 +989,28 @@ export default function App() { + + {/* Modal Panels */} + {showStatsPanel && ( + setShowStatsPanel(false)} + /> + )} + + {showRSSManager && ( + setShowRSSManager(false)} + /> + )} + + {showBookmarksPanel && ( + setShowBookmarksPanel(false)} + /> + )} ); diff --git a/components/BookmarksPanel.tsx b/components/BookmarksPanel.tsx new file mode 100644 index 0000000..5b603e5 --- /dev/null +++ b/components/BookmarksPanel.tsx @@ -0,0 +1,128 @@ +import React, { useState, useEffect } from 'react'; +import { Bookmark } from '../types'; +import { Bookmark as BookmarkIcon, Play, Trash2, Clock } from 'lucide-react'; +import { getBookmarks, removeBookmark } from '../services/storageService'; + +interface BookmarksPanelProps { + onResumeArticle: (url: string, segmentIndex: number) => void; + onClose: () => void; +} + +export const BookmarksPanel: React.FC = ({ + onResumeArticle, + onClose +}) => { + const [bookmarks, setBookmarks] = useState([]); + + useEffect(() => { + setBookmarks(getBookmarks()); + }, []); + + const handleRemove = (url: string) => { + removeBookmark(url); + setBookmarks(prev => prev.filter(b => b.url !== url)); + }; + + const formatDate = (timestamp: number): string => { + const date = new Date(timestamp); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) return 'Today'; + if (days === 1) return 'Yesterday'; + if (days < 7) return `${days} days ago`; + return date.toLocaleDateString('en', { month: 'short', day: 'numeric' }); + }; + + return ( +
+
+ {/* Header */} +
+
+

+ + Bookmarks +

+ +
+

+ Resume where you left off +

+
+ + {/* Bookmarks List */} +
+ {bookmarks.length === 0 ? ( +
+ +

No bookmarks yet

+

+ Articles are automatically bookmarked when you pause +

+
+ ) : ( + bookmarks.map((bookmark) => ( +
+
+
+

+ {bookmark.title || 'Untitled'} +

+

+ {bookmark.url} +

+
+ + + {formatDate(bookmark.savedAt)} + + + {bookmark.progress}% complete + +
+ {/* Progress bar */} +
+
+
+
+
+ + +
+
+
+ )) + )} +
+
+
+ ); +}; diff --git a/components/FeatureToolbar.tsx b/components/FeatureToolbar.tsx new file mode 100644 index 0000000..603c374 --- /dev/null +++ b/components/FeatureToolbar.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { + BarChart3, + Rss, + Bookmark, + Download, + Zap, + FileText +} from 'lucide-react'; + +interface FeatureToolbarProps { + onOpenStats: () => void; + onOpenRSS: () => void; + onOpenBookmarks: () => void; + onExportAudio: () => void; + smartSpeedEnabled: boolean; + onToggleSmartSpeed: () => void; + hasBookmark?: boolean; + canExport?: boolean; + isExporting?: boolean; +} + +export const FeatureToolbar: React.FC = ({ + onOpenStats, + onOpenRSS, + onOpenBookmarks, + onExportAudio, + smartSpeedEnabled, + onToggleSmartSpeed, + hasBookmark, + canExport, + isExporting +}) => { + const buttons = [ + { + icon: BarChart3, + label: 'Stats', + onClick: onOpenStats, + color: 'text-blue-500', + bgColor: 'bg-blue-50 dark:bg-blue-900/30' + }, + { + icon: Rss, + label: 'RSS Feeds', + onClick: onOpenRSS, + color: 'text-orange-500', + bgColor: 'bg-orange-50 dark:bg-orange-900/30' + }, + { + icon: Bookmark, + label: 'Bookmarks', + onClick: onOpenBookmarks, + color: hasBookmark ? 'text-yellow-500' : 'text-slate-500', + bgColor: hasBookmark ? 'bg-yellow-50 dark:bg-yellow-900/30' : 'bg-slate-50 dark:bg-slate-800' + }, + { + icon: Download, + label: isExporting ? 'Exporting...' : 'Export', + onClick: onExportAudio, + color: 'text-green-500', + bgColor: 'bg-green-50 dark:bg-green-900/30', + disabled: !canExport || isExporting + }, + { + icon: Zap, + label: 'Smart Speed', + onClick: onToggleSmartSpeed, + color: smartSpeedEnabled ? 'text-purple-500' : 'text-slate-400', + bgColor: smartSpeedEnabled ? 'bg-purple-50 dark:bg-purple-900/30' : 'bg-slate-50 dark:bg-slate-800', + active: smartSpeedEnabled + } + ]; + + return ( +
+ {buttons.map((btn, i) => ( + + ))} +
+ ); +}; diff --git a/components/RSSManager.tsx b/components/RSSManager.tsx new file mode 100644 index 0000000..9d6c979 --- /dev/null +++ b/components/RSSManager.tsx @@ -0,0 +1,482 @@ +import React, { useState, useEffect } from 'react'; +import { RSSFeed, RSSArticle } from '../types'; +import { Rss, Plus, Trash2, RefreshCw, ExternalLink, Loader2, ChevronDown, ChevronUp, AlertCircle, CheckCircle, Star, Filter } from 'lucide-react'; +import { getRSSFeeds, saveRSSFeed, removeRSSFeed } from '../services/storageService'; +import { fetchRSSFeed, refreshFeed, SUGGESTED_FEEDS, validateRSSUrl } from '../services/rssService'; + +interface RSSManagerProps { + onArticleSelect: (url: string) => void; + onClose: () => void; +} + +type FeedStatus = 'idle' | 'validating' | 'valid' | 'invalid' | 'loading'; + +export const RSSManager: React.FC = ({ onArticleSelect, onClose }) => { + const [feeds, setFeeds] = useState([]); + const [articles, setArticles] = useState>({}); + const [newFeedUrl, setNewFeedUrl] = useState(''); + const [feedStatus, setFeedStatus] = useState('idle'); + const [error, setError] = useState(null); + const [expandedFeed, setExpandedFeed] = useState(null); + const [loadingFeeds, setLoadingFeeds] = useState>(new Set()); + const [showRecommendations, setShowRecommendations] = useState(true); + const [filterCategory, setFilterCategory] = useState<'all' | 'news' | 'tech' | 'unread'>('all'); + + useEffect(() => { + const loadedFeeds = getRSSFeeds(); + setFeeds(loadedFeeds); + // Auto-load articles for active feeds + loadedFeeds.forEach(feed => { + if (feed.isActive) { + handleRefreshFeed(feed, true); + } + }); + }, []); + + const validateUrl = async (url: string) => { + if (!url.trim()) { + setFeedStatus('idle'); + setError(null); + return; + } + + setFeedStatus('validating'); + setError(null); + + try { + // Check if it looks like a URL + if (!url.startsWith('http://') && !url.startsWith('https://')) { + setError('URL must start with http:// or https://'); + setFeedStatus('invalid'); + return; + } + + const isValid = await validateRSSUrl(url); + if (isValid) { + setFeedStatus('valid'); + setError(null); + } else { + setFeedStatus('invalid'); + setError('Not a valid RSS feed. Make sure the URL points to an RSS/Atom feed XML file.'); + } + } catch (e) { + setFeedStatus('invalid'); + setError('Could not access URL. Check the address and try again.'); + } + }; + + // Debounced validation + useEffect(() => { + const timer = setTimeout(() => { + if (newFeedUrl && feedStatus !== 'loading') { + validateUrl(newFeedUrl); + } + }, 800); + return () => clearTimeout(timer); + }, [newFeedUrl]); + + const handleAddFeed = async (url: string) => { + if (!url.trim()) return; + + setFeedStatus('loading'); + setError(null); + + try { + const { feed, articles: feedArticles } = await fetchRSSFeed(url.trim()); + + if (feedArticles.length === 0) { + setError(`Feed added but contains 0 articles. The feed might be empty or improperly formatted.`); + } + + saveRSSFeed(feed); + setFeeds(prev => [...prev, feed]); + setArticles(prev => ({ ...prev, [feed.id]: feedArticles })); + setNewFeedUrl(''); + setFeedStatus('idle'); + setExpandedFeed(feed.id); + } catch (e: any) { + setFeedStatus('invalid'); + if (e.message?.includes('Failed to fetch')) { + setError('Network error: Could not reach the feed URL. Check your connection or try a different feed.'); + } else if (e.message?.includes('status')) { + setError(`Server error: The feed URL returned an error. It might be down or require authentication.`); + } else { + setError(e.message || 'Failed to add feed. Make sure it\'s a valid RSS/Atom feed URL.'); + } + } + }; + + const handleRefreshFeed = async (feed: RSSFeed, silent = false) => { + if (!silent) { + setLoadingFeeds(prev => new Set(prev).add(feed.id)); + } + + try { + const feedArticles = await refreshFeed(feed); + setArticles(prev => ({ ...prev, [feed.id]: feedArticles })); + + // Update article count + const updatedFeed = { ...feed, articleCount: feedArticles.length }; + saveRSSFeed(updatedFeed); + setFeeds(prev => prev.map(f => f.id === feed.id ? updatedFeed : f)); + } catch (e) { + console.error('Failed to refresh feed:', e); + } finally { + if (!silent) { + setLoadingFeeds(prev => { + const next = new Set(prev); + next.delete(feed.id); + return next; + }); + } + } + }; + + const handleRemoveFeed = (feedId: string) => { + removeRSSFeed(feedId); + setFeeds(prev => prev.filter(f => f.id !== feedId)); + setArticles(prev => { + const next = { ...prev }; + delete next[feedId]; + return next; + }); + }; + + const handleArticleClick = (url: string) => { + onArticleSelect(url); + onClose(); + }; + + const formatDate = (dateStr?: string): string => { + if (!dateStr) return ''; + try { + return new Date(dateStr).toLocaleDateString('en', { + month: 'short', + day: 'numeric' + }); + } catch { + return ''; + } + }; + + const getStatusIcon = () => { + switch (feedStatus) { + case 'validating': + return ; + case 'valid': + return ; + case 'invalid': + return ; + default: + return null; + } + }; + + // Categorize suggested feeds by the category field + const categorizedSuggestions = { + news: SUGGESTED_FEEDS.filter(f => f.category === 'news'), + tech: SUGGESTED_FEEDS.filter(f => f.category === 'tech'), + business: SUGGESTED_FEEDS.filter(f => f.category === 'business'), + science: SUGGESTED_FEEDS.filter(f => f.category === 'science'), + international: SUGGESTED_FEEDS.filter(f => f.category === 'international') + }; + + return ( +
+
+ {/* Header */} +
+
+
+

+ + RSS Feed Manager +

+

+ Subscribe to your favorite news sources +

+
+ +
+ + {/* Add Feed Input */} +
+
+
+ setNewFeedUrl(e.target.value)} + placeholder="Enter RSS feed URL (e.g., https://example.com/feed.xml)" + className="w-full px-4 py-3 pr-10 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl text-slate-700 dark:text-slate-200 placeholder:text-slate-400" + onKeyDown={(e) => e.key === 'Enter' && feedStatus === 'valid' && handleAddFeed(newFeedUrl)} + /> +
+ {getStatusIcon()} +
+
+ +
+ + {error && ( +
+ +

{error}

+
+ )} + + {feedStatus === 'valid' && ( +
+ +

Valid RSS feed detected

+
+ )} +
+
+ + {/* Content */} +
+ {/* My Feeds */} + {feeds.length > 0 && ( +
+
+

+ + My Feeds ({feeds.length}) +

+
+
+ {feeds.map((feed) => { + const feedArticles = articles[feed.id] || []; + return ( +
+ {/* Feed Header */} +
setExpandedFeed(expandedFeed === feed.id ? null : feed.id)} + > +
+ +
+

{feed.title}

+

+ {feedArticles.length > 0 ? `${feedArticles.length} articles` : 'Click refresh to load articles'} +

+
+
+
+ + + {expandedFeed === feed.id ? ( + + ) : ( + + )} +
+
+ + {/* Articles List */} + {expandedFeed === feed.id && ( +
+ {feedArticles.length > 0 ? ( + feedArticles.slice(0, 20).map((article, i) => ( +
handleArticleClick(article.url)} + className="p-4 border-t border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 cursor-pointer transition-colors group" + > +
+
+

+ {article.title} +

+ {article.description && ( +

+ {article.description} +

+ )} + {article.pubDate && ( +

{formatDate(article.pubDate)}

+ )} +
+ +
+
+ )) + ) : ( +
+ +

No articles loaded yet

+ +
+ )} +
+ )} +
+ ); + })} +
+
+ )} + + {/* Recommendations - Always Visible */} +
+
+

+ Recommended Feeds +

+ +
+ + {showRecommendations && ( +
+ {/* News Category */} +
+

General News

+
+ {categorizedSuggestions.news.map((suggestion) => ( + + ))} +
+
+ + {/* Technology Category */} +
+

Technology

+
+ {categorizedSuggestions.tech.map((suggestion) => ( + + ))} +
+
+ + {/* Business & Finance Category */} +
+

Business & Finance

+
+ {categorizedSuggestions.business.map((suggestion) => ( + + ))} +
+
+ + {/* Science Category */} +
+

Science & Research

+
+ {categorizedSuggestions.science.map((suggestion) => ( + + ))} +
+
+ + {/* International Category */} +
+

International News

+
+ {categorizedSuggestions.international.map((suggestion) => ( + + ))} +
+
+
+ )} +
+
+
+
+ ); +}; diff --git a/components/StatsPanel.tsx b/components/StatsPanel.tsx new file mode 100644 index 0000000..1190053 --- /dev/null +++ b/components/StatsPanel.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { ReadingStats, VoiceName } from '../types'; +import { BarChart3, Clock, BookOpen, Flame, Trophy, Mic } from 'lucide-react'; +import { AVAILABLE_VOICES } from '../constants'; + +interface StatsPanelProps { + stats: ReadingStats; + onClose: () => void; +} + +export const StatsPanel: React.FC = ({ stats, onClose }) => { + const formatTime = (minutes: number): string => { + if (minutes < 60) return `${Math.round(minutes)}m`; + const hours = Math.floor(minutes / 60); + const mins = Math.round(minutes % 60); + return `${hours}h ${mins}m`; + }; + + const favoriteVoiceData = AVAILABLE_VOICES.find(v => v.name === stats.favoriteVoice); + + // Get last 7 days activity + const last7Days = Array.from({ length: 7 }, (_, i) => { + const date = new Date(); + date.setDate(date.getDate() - (6 - i)); + const dateStr = date.toISOString().split('T')[0]; + return { + day: date.toLocaleDateString('en', { weekday: 'short' }), + count: stats.articlesPerDay[dateStr] || 0 + }; + }); + + const maxCount = Math.max(...last7Days.map(d => d.count), 1); + + return ( +
+
+ {/* Header */} +
+
+

+ + Reading Stats +

+ +
+
+ + {/* Stats Grid */} +
+ {/* Main Stats */} +
+
+
+ + Articles Read +
+

+ {stats.totalArticlesRead} +

+
+ +
+
+ + Time Listened +
+

+ {formatTime(stats.totalMinutesListened)} +

+
+ +
+
+ + Current Streak +
+

+ {stats.currentStreak} days +

+
+ +
+
+ + Best Streak +
+

+ {stats.longestStreak} days +

+
+
+ + {/* Words Read */} +
+

Total Words Consumed

+

+ {stats.totalWordsRead.toLocaleString()} +

+

+ That's about {Math.round(stats.totalWordsRead / 250)} pages! +

+
+ + {/* Weekly Activity */} +
+

+ Last 7 Days +

+
+ {last7Days.map((day, i) => ( +
+
+
0 ? 1 : 0.2 + }} + /> +
+ {day.day} +
+ ))} +
+
+ + {/* Favorite Voice */} + {favoriteVoiceData && ( +
+
+ {favoriteVoiceData.emoji} +
+

+ Favorite Voice +

+

+ {favoriteVoiceData.label} +

+

+ Used {stats.voiceUsage[stats.favoriteVoice] || 0} times +

+
+
+
+ )} +
+
+
+ ); +}; diff --git a/components/SummaryCard.tsx b/components/SummaryCard.tsx new file mode 100644 index 0000000..7f0b9f6 --- /dev/null +++ b/components/SummaryCard.tsx @@ -0,0 +1,134 @@ +import React, { useState, useRef } from 'react'; +import { Article, VoiceName } from '../types'; +import { Sparkles, Play, Pause, Loader2, Clock, Brain } from 'lucide-react'; +import { generateSpeechFromText } from '../services/geminiService'; +import { base64ToUint8Array, createWavBlob } from '../services/audioUtils'; + +interface SummaryCardProps { + article: Article; + selectedVoice: VoiceName; + onPlayFull: () => void; +} + +export const SummaryCard: React.FC = ({ + article, + selectedVoice, + onPlayFull +}) => { + const [isPlayingSummary, setIsPlayingSummary] = useState(false); + const [isLoadingAudio, setIsLoadingAudio] = useState(false); + const audioRef = useRef(null); + + const handlePlaySummary = async () => { + if (!article.summary) return; + + if (isPlayingSummary && audioRef.current) { + audioRef.current.pause(); + setIsPlayingSummary(false); + return; + } + + // If we already have the audio URL cached + if (article.summaryAudioUrl && audioRef.current) { + audioRef.current.src = article.summaryAudioUrl; + await audioRef.current.play(); + setIsPlayingSummary(true); + return; + } + + // Generate audio + setIsLoadingAudio(true); + try { + const base64Audio = await generateSpeechFromText(article.summary, selectedVoice); + const pcmData = base64ToUint8Array(base64Audio); + const wavBlob = createWavBlob(pcmData); + const audioUrl = URL.createObjectURL(wavBlob); + + if (!audioRef.current) { + audioRef.current = new Audio(); + } + + audioRef.current.src = audioUrl; + audioRef.current.onended = () => setIsPlayingSummary(false); + await audioRef.current.play(); + setIsPlayingSummary(true); + } catch (e) { + console.error('Failed to play summary:', e); + } finally { + setIsLoadingAudio(false); + } + }; + + if (!article.summary && !article.isSummaryLoading) { + return null; + } + + const complexityColors = { + simple: 'text-green-500 bg-green-50 dark:bg-green-900/30', + moderate: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-900/30', + complex: 'text-red-500 bg-red-50 dark:bg-red-900/30' + }; + + return ( +
+
+
+ + + 30-Second Summary + +
+
+ {article.complexity && ( + + + {article.complexity} + + )} + {article.estimatedReadTime && ( + + + {article.estimatedReadTime}m read + + )} +
+
+ + {article.isSummaryLoading ? ( +
+ + Generating summary... +
+ ) : ( + <> +

+ {article.summary} +

+ +
+ + +
+ + )} +
+ ); +}; diff --git a/components/VoiceMoodSelector.tsx b/components/VoiceMoodSelector.tsx new file mode 100644 index 0000000..94e2dfa --- /dev/null +++ b/components/VoiceMoodSelector.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { VoiceMood, VoiceName } from '../types'; +import { VOICE_MOODS } from '../constants'; + +interface VoiceMoodSelectorProps { + selectedMood: VoiceMood; + onMoodChange: (mood: VoiceMood, voice: VoiceName, speed: number) => void; +} + +export const VoiceMoodSelector: React.FC = ({ + selectedMood, + onMoodChange +}) => { + return ( +
+

+ Listening Mood +

+
+ {VOICE_MOODS.map((mood) => { + const isSelected = selectedMood === mood.id; + return ( + + ); + })} +
+

+ {VOICE_MOODS.find(m => m.id === selectedMood)?.description} +

+
+ ); +}; diff --git a/components/VoiceSelector.tsx b/components/VoiceSelector.tsx index 33933ff..a825167 100644 --- a/components/VoiceSelector.tsx +++ b/components/VoiceSelector.tsx @@ -1,24 +1,27 @@ -import React from 'react'; +import React, { useState, useRef } from 'react'; import { VoiceName } from '../types'; import { AVAILABLE_VOICES } from '../constants'; -import { Mic } from 'lucide-react'; +import { Play, Pause, Loader2, Check, Volume2 } from 'lucide-react'; +import { generateSpeechFromText } from '../services/geminiService'; +import { base64ToUint8Array, createWavBlob } from '../services/audioUtils'; interface VoiceSelectorProps { selectedVoice: VoiceName; onVoiceChange: (voice: VoiceName) => void; - disabled?: boolean; } -export const VoiceSelector: React.FC = ({ selectedVoice, onVoiceChange, disabled }) => { +// Compact selector for header +export const VoiceSelectorCompact: React.FC = ({ selectedVoice, onVoiceChange }) => { + const currentVoice = AVAILABLE_VOICES.find(v => v.name === selectedVoice); + return ( -
- +
+ {currentVoice?.emoji}