## 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>
185 lines
6.4 KiB
TypeScript
185 lines
6.4 KiB
TypeScript
|
|
import React, { useState, useRef } from 'react';
|
|
import { VoiceName } from '../types';
|
|
import { AVAILABLE_VOICES } from '../constants';
|
|
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;
|
|
}
|
|
|
|
// Compact selector for header
|
|
export const VoiceSelectorCompact: React.FC<VoiceSelectorProps> = ({ selectedVoice, onVoiceChange }) => {
|
|
const currentVoice = AVAILABLE_VOICES.find(v => v.name === selectedVoice);
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-lg">{currentVoice?.emoji}</span>
|
|
<select
|
|
value={selectedVoice}
|
|
onChange={(e) => onVoiceChange(e.target.value as VoiceName)}
|
|
className="bg-transparent border-none text-sm font-medium text-slate-700 dark:text-slate-200 cursor-pointer focus:outline-none"
|
|
>
|
|
{AVAILABLE_VOICES.map((v) => (
|
|
<option key={v.name} value={v.name}>
|
|
{v.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Full voice panel with cards and preview
|
|
export const VoicePanel: React.FC<VoiceSelectorProps> = ({ selectedVoice, onVoiceChange }) => {
|
|
const [previewingVoice, setPreviewingVoice] = useState<VoiceName | null>(null);
|
|
const [loadingPreview, setLoadingPreview] = useState<VoiceName | null>(null);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
const handlePreview = async (voice: typeof AVAILABLE_VOICES[0]) => {
|
|
// If already previewing this voice, stop it
|
|
if (previewingVoice === voice.name) {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current.src = '';
|
|
}
|
|
setPreviewingVoice(null);
|
|
return;
|
|
}
|
|
|
|
// Stop any current preview
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current.src = '';
|
|
}
|
|
|
|
setLoadingPreview(voice.name);
|
|
setPreviewingVoice(null);
|
|
|
|
try {
|
|
const base64Audio = await generateSpeechFromText(voice.previewText, voice.name);
|
|
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 = () => {
|
|
setPreviewingVoice(null);
|
|
URL.revokeObjectURL(audioUrl);
|
|
};
|
|
audioRef.current.onerror = () => {
|
|
setPreviewingVoice(null);
|
|
URL.revokeObjectURL(audioUrl);
|
|
};
|
|
|
|
await audioRef.current.play();
|
|
setPreviewingVoice(voice.name);
|
|
} catch (error) {
|
|
console.error('Preview failed:', error);
|
|
} finally {
|
|
setLoadingPreview(null);
|
|
}
|
|
};
|
|
|
|
const handleSelect = (voiceName: VoiceName) => {
|
|
onVoiceChange(voiceName);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 px-1">
|
|
<Volume2 className="w-4 h-4 text-blue-500" />
|
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Choose Your Voice</h3>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
{AVAILABLE_VOICES.map((voice) => {
|
|
const isSelected = selectedVoice === voice.name;
|
|
const isPreviewing = previewingVoice === voice.name;
|
|
const isLoading = loadingPreview === voice.name;
|
|
|
|
return (
|
|
<div
|
|
key={voice.name}
|
|
className={`
|
|
relative p-4 rounded-2xl border-2 transition-all cursor-pointer
|
|
${isSelected
|
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 shadow-md'
|
|
: 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-sm'
|
|
}
|
|
`}
|
|
onClick={() => handleSelect(voice.name)}
|
|
>
|
|
{/* Selected checkmark */}
|
|
{isSelected && (
|
|
<div className="absolute top-2 right-2 w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center">
|
|
<Check className="w-3 h-3 text-white" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Voice info */}
|
|
<div className="text-center space-y-2">
|
|
<div className="text-2xl">{voice.emoji}</div>
|
|
<div>
|
|
<p className={`font-semibold ${isSelected ? 'text-blue-700 dark:text-blue-300' : 'text-slate-800 dark:text-slate-200'}`}>
|
|
{voice.label}
|
|
</p>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
|
{voice.description}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Preview button */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handlePreview(voice);
|
|
}}
|
|
disabled={isLoading}
|
|
className={`
|
|
w-full mt-2 py-2 px-3 rounded-xl text-xs font-medium transition-all flex items-center justify-center gap-2
|
|
${isPreviewing
|
|
? 'bg-blue-500 text-white'
|
|
: isSelected
|
|
? 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-800/50 dark:text-blue-200 dark:hover:bg-blue-800'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'
|
|
}
|
|
${isLoading ? 'opacity-70 cursor-wait' : ''}
|
|
`}
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
Loading...
|
|
</>
|
|
) : isPreviewing ? (
|
|
<>
|
|
<Pause className="w-3 h-3" />
|
|
Stop
|
|
</>
|
|
) : (
|
|
<>
|
|
<Play className="w-3 h-3" />
|
|
Preview
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Keep backward compatibility
|
|
export const VoiceSelector = VoiceSelectorCompact;
|