Files
news-reader-actions-test/components/VoiceSelector.tsx
Tony0410 9025d1b8f1 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>
2025-11-28 00:42:14 +00:00

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;