diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx index b314902..f2e4a3e 100644 --- a/src/components/player/VoicesControlBase.tsx +++ b/src/components/player/VoicesControlBase.tsx @@ -4,7 +4,7 @@ import { Listbox, } from '@headlessui/react'; import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro'; import { keepKokoroVoicesInOneLanguage } from '@/lib/shared/language'; import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog'; @@ -56,10 +56,15 @@ export function VoicesControlBase({ const isKokoro = providerModelPolicy.isKokoroModel; const maxVoices = providerModelPolicy.maxVoices; - const [selectedVoices, setSelectedVoices] = useState([]); - - useEffect(() => { - if (!(isKokoro && maxVoices > 1)) return; + // Selected voices are derived from the `voice` prop for display only. The + // control is fully controlled: it never writes back to config as a side + // effect, only in direct response to a user selection (see onChange below). + // A write-back effect here would re-fire during the availableVoices refetch + // churn that a voice change triggers and, under a slow/contended backend, + // push a transiently-collapsed selection back as the voice — resetting it to + // the first option. + const selectedVoices = useMemo(() => { + if (!(isKokoro && maxVoices > 1)) return []; let initial: string[] = []; if (voice && voice.includes('+')) { initial = parseKokoroVoiceNames(voice); @@ -71,13 +76,8 @@ export function VoicesControlBase({ if (initial.length > maxVoices) { initial = initial.slice(0, maxVoices); } - initial = keepKokoroVoicesInOneLanguage(initial); - setSelectedVoices(initial); - const combined = buildKokoroVoiceString(initial); - if (combined && combined !== voice) { - onChangeVoice(combined); - } - }, [isKokoro, maxVoices, voice, availableVoices, onChangeVoice]); + return keepKokoroVoicesInOneLanguage(initial); + }, [isKokoro, maxVoices, voice, availableVoices]); const currentVoice = useMemo(() => { if (isKokoro && maxVoices > 1) { @@ -123,7 +123,6 @@ export function VoicesControlBase({ } } - setSelectedVoices(next); const combined = buildKokoroVoiceString(next); if (combined) { onChangeVoice(combined);