204 lines
8.2 KiB
TypeScript
204 lines
8.2 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
Listbox,
|
|
ListboxButton,
|
|
ListboxOption,
|
|
ListboxOptions,
|
|
} from '@headlessui/react';
|
|
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
|
|
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
|
|
|
export function VoicesControlBase({
|
|
availableVoices,
|
|
voice,
|
|
onChangeVoice,
|
|
providerType,
|
|
ttsModel,
|
|
dropdownDirection = 'up',
|
|
variant = 'compact',
|
|
}: {
|
|
availableVoices: string[];
|
|
voice: string;
|
|
onChangeVoice: (voice: string) => void;
|
|
providerType: TtsProviderType;
|
|
ttsModel: string;
|
|
dropdownDirection?: 'up' | 'down';
|
|
variant?: 'compact' | 'field';
|
|
}) {
|
|
const dropdownPosition = dropdownDirection === 'down'
|
|
? 'top-full left-0 mt-1'
|
|
: 'bottom-full right-0 mb-1';
|
|
|
|
const buttonClass = variant === 'field'
|
|
? 'relative cursor-pointer rounded-lg bg-surface py-1.5 pl-3 pr-10 text-left text-foreground focus:outline-none focus:ring-2 focus:ring-accent-line transform transition-transform duration-base ease-standard hover:text-accent w-full'
|
|
: 'flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-accent-wash rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-base ease-standard hover:text-accent';
|
|
|
|
const iconClass = variant === 'field'
|
|
? 'h-3.5 w-3.5 shrink-0'
|
|
: 'h-3 w-3 sm:h-3.5 sm:w-3.5';
|
|
|
|
const chevronClass = variant === 'field'
|
|
? 'h-4 w-4 text-soft'
|
|
: 'h-2.5 w-2.5 sm:h-3 sm:w-3';
|
|
|
|
const providerModelPolicy = resolveTtsProviderModelPolicy({
|
|
providerRef: '',
|
|
providerType,
|
|
model: ttsModel,
|
|
});
|
|
const dropdownWidth = variant === 'field'
|
|
? 'w-full'
|
|
: providerModelPolicy.isKokoroModel && providerModelPolicy.maxVoices > 1 ? 'w-40 sm:w-44' : 'w-28 sm:w-32';
|
|
const isKokoro = providerModelPolicy.isKokoroModel;
|
|
const maxVoices = providerModelPolicy.maxVoices;
|
|
|
|
const [selectedVoices, setSelectedVoices] = useState<string[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!(isKokoro && maxVoices > 1)) return;
|
|
let initial: string[] = [];
|
|
if (voice && voice.includes('+')) {
|
|
initial = parseKokoroVoiceNames(voice);
|
|
} else if (voice && availableVoices.includes(voice)) {
|
|
initial = [voice];
|
|
} else if (availableVoices.length > 0) {
|
|
initial = [availableVoices[0]];
|
|
}
|
|
if (initial.length > maxVoices) {
|
|
initial = initial.slice(0, maxVoices);
|
|
}
|
|
setSelectedVoices(initial);
|
|
}, [isKokoro, maxVoices, voice, availableVoices]);
|
|
|
|
const currentVoice = useMemo(() => {
|
|
if (isKokoro && maxVoices > 1) {
|
|
const combined = buildKokoroVoiceString(selectedVoices);
|
|
return combined || (availableVoices[0] || '');
|
|
}
|
|
return voice && availableVoices.includes(voice) ? voice : availableVoices[0] || '';
|
|
}, [isKokoro, maxVoices, selectedVoices, availableVoices, voice]);
|
|
|
|
if (availableVoices.length === 0) {
|
|
return (
|
|
<div className="relative">
|
|
<div className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-soft text-xs sm:text-sm rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
|
<AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
|
<span>No voices</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
{isKokoro && maxVoices > 1 ? (
|
|
<Listbox
|
|
multiple
|
|
value={selectedVoices}
|
|
onChange={(vals: string[]) => {
|
|
if (!vals || vals.length === 0) return;
|
|
|
|
let next = vals;
|
|
if (vals.length > maxVoices) {
|
|
const newlyAdded = vals.find((v) => !selectedVoices.includes(v));
|
|
if (newlyAdded) {
|
|
const lastPrev = selectedVoices[selectedVoices.length - 1] ?? selectedVoices[0] ?? '';
|
|
const pair = Array.from(new Set([lastPrev, newlyAdded])).filter(Boolean);
|
|
next = pair.slice(0, maxVoices);
|
|
} else {
|
|
next = vals.slice(-maxVoices);
|
|
}
|
|
}
|
|
|
|
setSelectedVoices(next);
|
|
const combined = buildKokoroVoiceString(next);
|
|
if (combined) {
|
|
onChangeVoice(combined);
|
|
}
|
|
}}
|
|
>
|
|
<ListboxButton className={buttonClass}>
|
|
{variant === 'field' ? (
|
|
<>
|
|
<span className="flex items-center gap-2 truncate text-sm font-medium">
|
|
<AudioWaveIcon className={iconClass} />
|
|
{selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}
|
|
</span>
|
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
<ChevronUpDownIcon className={chevronClass} />
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<AudioWaveIcon className={iconClass} />
|
|
<span>{selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}</span>
|
|
<ChevronUpDownIcon className={chevronClass} />
|
|
</>
|
|
)}
|
|
</ListboxButton>
|
|
<ListboxOptions className={`absolute ${dropdownPosition} z-50 ${dropdownWidth} !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-surface shadow-elev-2 ring-1 ring-line-soft focus:outline-none`}>
|
|
{availableVoices.map((voiceId) => (
|
|
<ListboxOption
|
|
key={voiceId}
|
|
value={voiceId}
|
|
className={({ active, selected }) =>
|
|
`relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 flex items-center gap-2 ${active ? 'bg-surface-sunken' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}`
|
|
}
|
|
>
|
|
{({ selected }) => (
|
|
<>
|
|
{selected ? (
|
|
<CheckIcon className="h-3.5 w-3.5 shrink-0" />
|
|
) : (
|
|
<span className="h-3.5 w-3.5 shrink-0" />
|
|
)}
|
|
<span className="text-xs sm:text-sm">{voiceId}</span>
|
|
</>
|
|
)}
|
|
</ListboxOption>
|
|
))}
|
|
</ListboxOptions>
|
|
</Listbox>
|
|
) : (
|
|
<Listbox value={currentVoice} onChange={onChangeVoice}>
|
|
<ListboxButton className={buttonClass}>
|
|
{variant === 'field' ? (
|
|
<>
|
|
<span className="flex items-center gap-2 truncate text-sm font-medium">
|
|
<AudioWaveIcon className={iconClass} />
|
|
{currentVoice}
|
|
</span>
|
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
<ChevronUpDownIcon className={chevronClass} />
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<AudioWaveIcon className={iconClass} />
|
|
<span>{currentVoice}</span>
|
|
<ChevronUpDownIcon className={chevronClass} />
|
|
</>
|
|
)}
|
|
</ListboxButton>
|
|
<ListboxOptions className={`absolute ${dropdownPosition} z-50 ${dropdownWidth} !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-surface shadow-elev-2 ring-1 ring-line-soft focus:outline-none`}>
|
|
{availableVoices.map((voiceId) => (
|
|
<ListboxOption
|
|
key={voiceId}
|
|
value={voiceId}
|
|
className={({ active, selected }) =>
|
|
`relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-surface-sunken' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}`
|
|
}
|
|
>
|
|
<span className="text-xs sm:text-sm">{voiceId}</span>
|
|
</ListboxOption>
|
|
))}
|
|
</ListboxOptions>
|
|
</Listbox>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|