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

134
components/SummaryCard.tsx Normal file
View File

@@ -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<SummaryCardProps> = ({
article,
selectedVoice,
onPlayFull
}) => {
const [isPlayingSummary, setIsPlayingSummary] = useState(false);
const [isLoadingAudio, setIsLoadingAudio] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(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 (
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 dark:from-indigo-900/20 dark:to-purple-900/20 rounded-2xl p-4 border border-indigo-100 dark:border-indigo-800/50">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-indigo-500" />
<span className="text-sm font-semibold text-indigo-700 dark:text-indigo-300">
30-Second Summary
</span>
</div>
<div className="flex items-center gap-2">
{article.complexity && (
<span className={`text-xs px-2 py-1 rounded-full flex items-center gap-1 ${complexityColors[article.complexity]}`}>
<Brain className="w-3 h-3" />
{article.complexity}
</span>
)}
{article.estimatedReadTime && (
<span className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1">
<Clock className="w-3 h-3" />
{article.estimatedReadTime}m read
</span>
)}
</div>
</div>
{article.isSummaryLoading ? (
<div className="flex items-center gap-2 text-slate-500 dark:text-slate-400 py-4">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">Generating summary...</span>
</div>
) : (
<>
<p className="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">
{article.summary}
</p>
<div className="flex items-center gap-3 mt-4">
<button
onClick={handlePlaySummary}
disabled={isLoadingAudio}
className="flex items-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white rounded-xl text-sm font-medium transition-all disabled:opacity-50"
>
{isLoadingAudio ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isPlayingSummary ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
{isPlayingSummary ? 'Pause' : 'Listen to Summary'}
</button>
<button
onClick={onPlayFull}
className="text-sm text-indigo-600 dark:text-indigo-400 hover:underline"
>
Play full article
</button>
</div>
</>
)}
</div>
);
};