fix(player): prevent selection reset during voice refetch by deriving voices from props

Remove internal selectedVoices state and use a memoized value based on the voice
prop to determine the selected voices. Avoid side effects on selection display,
ensuring that voice selection persists even when available voices are refetched.
This addresses issues where transient backend states could cause unwanted resets
to the first voice option.
This commit is contained in:
Richard R 2026-06-06 13:07:51 -06:00
parent 379e5082f0
commit f7c93c90a6

View file

@ -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<string[]>([]);
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);