## 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>
135 lines
4.6 KiB
TypeScript
135 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
};
|