Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 37 additions & 39 deletions components/audio/tts-config-popover.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client';

import { useState, useRef, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { Volume2, Play, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Select,
Expand All @@ -16,6 +17,7 @@ import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { getTTSVoices } from '@/lib/audio/constants';
import { useTTSPreview } from '@/lib/audio/use-tts-preview';

/** Extract the English name from voice name format "ChineseName (English)" */
function getVoiceDisplayName(name: string, lang: string): string {
Expand All @@ -29,13 +31,13 @@ function getVoiceDisplayName(name: string, lang: string): string {
export function TtsConfigPopover() {
const { t, locale } = useI18n();
const [open, setOpen] = useState(false);
const [previewing, setPreviewing] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { previewing, startPreview, stopPreview } = useTTSPreview();

const ttsEnabled = useSettingsStore((s) => s.ttsEnabled);
const setTTSEnabled = useSettingsStore((s) => s.setTTSEnabled);
const ttsProviderId = useSettingsStore((s) => s.ttsProviderId);
const ttsVoice = useSettingsStore((s) => s.ttsVoice);
const ttsSpeed = useSettingsStore((s) => s.ttsSpeed);
const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig);
const setTTSVoice = useSettingsStore((s) => s.setTTSVoice);

Expand All @@ -54,51 +56,47 @@ export function TtsConfigPopover() {

const handlePreview = useCallback(async () => {
if (previewing) {
audioRef.current?.pause();
audioRef.current = null;
setPreviewing(false);
stopPreview();
return;
}

setPreviewing(true);
try {
const providerConfig = ttsProvidersConfig[ttsProviderId];
const res = await fetch('/api/generate/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: '你好,欢迎来到AI课堂!让我们一起学习吧。',
audioId: 'preview',
ttsProviderId: ttsProviderId,
ttsVoice: ttsVoice,
ttsApiKey: providerConfig?.apiKey,
ttsBaseUrl: providerConfig?.baseUrl,
}),
await startPreview({
text: t('settings.ttsTestTextDefault'),
providerId: ttsProviderId,
voice: ttsVoice,
speed: ttsSpeed,
apiKey: providerConfig?.apiKey,
baseUrl: providerConfig?.baseUrl,
});
} catch (error) {
const message =
error instanceof Error && error.message ? error.message : t('settings.ttsTestFailed');
toast.error(message);
}
}, [
previewing,
startPreview,
stopPreview,
t,
ttsProviderId,
ttsProvidersConfig,
ttsSpeed,
ttsVoice,
]);

if (!res.ok) throw new Error('TTS failed');

const data = await res.json();
if (data.base64) {
const audio = new Audio(`data:audio/${data.format || 'mp3'};base64,${data.base64}`);
audioRef.current = audio;
audio.onended = () => {
setPreviewing(false);
audioRef.current = null;
};
audio.onerror = () => {
setPreviewing(false);
audioRef.current = null;
};
await audio.play();
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
stopPreview();
}
} catch {
setPreviewing(false);
}
}, [ttsProviderId, ttsVoice, ttsProvidersConfig, previewing]);
setOpen(nextOpen);
},
[stopPreview],
);

return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
Expand Down
65 changes: 29 additions & 36 deletions components/generation/media-popover.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useRef, useCallback, useMemo, Fragment } from 'react';
import { useState, useCallback, useMemo, Fragment } from 'react';
import type { LucideIcon } from 'lucide-react';
import {
Image as ImageIcon,
Expand All @@ -12,6 +12,7 @@ import {
Play,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Select,
Expand All @@ -28,6 +29,7 @@ import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { useTTSPreview } from '@/lib/audio/use-tts-preview';
import { IMAGE_PROVIDERS } from '@/lib/media/image-providers';
import { VIDEO_PROVIDERS } from '@/lib/media/video-providers';
import { TTS_PROVIDERS, getTTSVoices } from '@/lib/audio/constants';
Expand Down Expand Up @@ -75,8 +77,7 @@ export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) {
const { t, locale } = useI18n();
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<TabId>('image');
const [previewing, setPreviewing] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { previewing, startPreview, stopPreview } = useTTSPreview();

// ─── Store ───
const imageGenerationEnabled = useSettingsStore((s) => s.imageGenerationEnabled);
Expand Down Expand Up @@ -183,45 +184,34 @@ export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) {
// TTS preview
const handlePreview = useCallback(async () => {
if (previewing) {
audioRef.current?.pause();
audioRef.current = null;
setPreviewing(false);
stopPreview();
return;
}
setPreviewing(true);
try {
const providerConfig = ttsProvidersConfig[ttsProviderId];
const res = await fetch('/api/generate/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: '你好,欢迎来到AI课堂!让我们一起学习吧。',
audioId: 'preview',
ttsProviderId,
ttsVoice,
ttsApiKey: providerConfig?.apiKey,
ttsBaseUrl: providerConfig?.baseUrl,
}),
await startPreview({
text: t('settings.ttsTestTextDefault'),
providerId: ttsProviderId,
voice: ttsVoice,
speed: ttsSpeed,
apiKey: providerConfig?.apiKey,
baseUrl: providerConfig?.baseUrl,
});
if (!res.ok) throw new Error('TTS failed');
const data = await res.json();
if (data.base64) {
const audio = new Audio(`data:audio/${data.format || 'mp3'};base64,${data.base64}`);
audioRef.current = audio;
audio.onended = () => {
setPreviewing(false);
audioRef.current = null;
};
audio.onerror = () => {
setPreviewing(false);
audioRef.current = null;
};
await audio.play();
}
} catch {
setPreviewing(false);
} catch (error) {
const message =
error instanceof Error && error.message ? error.message : t('settings.ttsTestFailed');
toast.error(message);
}
}, [ttsProviderId, ttsVoice, ttsProvidersConfig, previewing]);
}, [
previewing,
startPreview,
stopPreview,
t,
ttsProviderId,
ttsProvidersConfig,
ttsSpeed,
ttsVoice,
]);

// ASR: only available providers
const asrGroups = useMemo(
Expand All @@ -243,6 +233,9 @@ export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) {

// Auto-select first enabled tab on open
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
stopPreview();
}
setOpen(isOpen);
if (isOpen) {
const first = (['image', 'video', 'tts', 'asr'] as TabId[]).find((id) => enabledMap[id]);
Expand Down
Loading
Loading