video-accessibility-old/frontend/src/components/VoicePreviewButton.tsx
Vadym Samoilenko a22fe5c1bc fix: surface ElevenLabs config errors and add availability flag
- Extract actual error message from blob response in previewVoice so
  users see the real API error instead of generic "Failed to generate preview"
- VoicePreviewButton now reads err.message from thrown Error objects
- Add available: bool field to ProviderVoicesResponse; returns false
  when ELEVENLABS_API_KEY is not configured so the frontend can react
  proactively instead of hitting a 400 on preview
- VoiceSelector shows a descriptive config warning when available=false

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:17:00 +00:00

181 lines
4.9 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { api } from '../lib/api';
import type { TTSStylePreset, TTSProvider } from '../types/api';
interface VoicePreviewButtonProps {
voiceName: string;
language: string;
disabled?: boolean;
provider?: TTSProvider;
model?: string;
speed?: number;
stylePreset?: TTSStylePreset;
customStylePrompt?: string;
stability?: number;
similarityBoost?: number;
}
export function VoicePreviewButton({
voiceName,
language,
disabled,
provider,
model,
speed,
stylePreset,
customStylePrompt,
stability,
similarityBoost,
}: VoicePreviewButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioUrlRef = useRef<string | null>(null);
// Clear cached audio when any TTS setting changes
useEffect(() => {
// Stop any playing audio
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
// Revoke old URL
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current);
audioUrlRef.current = null;
}
setIsPlaying(false);
setError(null);
}, [voiceName, language, provider, model, speed, stylePreset, customStylePrompt, stability, similarityBoost]);
const handlePreview = async () => {
setError(null);
// If already playing, stop
if (isPlaying && audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
setIsPlaying(false);
return;
}
// If we have cached audio, play it
if (audioUrlRef.current && audioRef.current) {
audioRef.current.play();
setIsPlaying(true);
return;
}
// Fetch new audio
setIsLoading(true);
try {
const blob = await api.previewVoice(
voiceName,
language,
model,
speed,
stylePreset,
customStylePrompt,
provider,
stability,
similarityBoost,
);
const url = URL.createObjectURL(blob);
// Clean up old URL if exists
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current);
}
audioUrlRef.current = url;
// Create and play audio
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => {
setIsPlaying(false);
};
audio.onerror = () => {
setError('Failed to play audio');
setIsPlaying(false);
};
await audio.play();
setIsPlaying(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to generate preview';
setError(errorMessage);
console.error('Voice preview error:', err);
} finally {
setIsLoading(false);
}
};
return (
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={handlePreview}
disabled={disabled || isLoading}
className={`
inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md
transition-colors duration-150
${disabled || isLoading
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: isPlaying
? 'bg-red-100 text-red-700 hover:bg-red-200'
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
}
`}
>
{isLoading ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Loading...
</>
) : isPlaying ? (
<>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 011-1h4a1 1 0 110 2H8a1 1 0 01-1-1zm0 4a1 1 0 011-1h4a1 1 0 110 2H8a1 1 0 01-1-1z"
clipRule="evenodd"
/>
</svg>
Stop
</>
) : (
<>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
clipRule="evenodd"
/>
</svg>
Preview
</>
)}
</button>
{error && <span className="text-xs text-red-600">{error}</span>}
</div>
);
}