127 lines
3.9 KiB
TypeScript
127 lines
3.9 KiB
TypeScript
/*
|
|
* Vencord, a Discord client mod
|
|
* Copyright (c) 2025 Vendicated and contributors
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
|
|
import { Heading } from "@components/Heading";
|
|
import { Paragraph } from "@components/Paragraph";
|
|
import { SearchableSelect, useMemo, useState } from "@webpack/common";
|
|
|
|
import { getCurrentVoice, settings } from "./settings";
|
|
|
|
// TODO: replace by [Object.groupBy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/groupBy) once it has more maturity
|
|
|
|
function groupBy<T extends object, K extends PropertyKey>(arr: T[], fn: (obj: T) => K) {
|
|
return arr.reduce((acc, obj) => {
|
|
const value = fn(obj);
|
|
acc[value] ??= [];
|
|
acc[value].push(obj);
|
|
return acc;
|
|
}, {} as Record<K, T[]>);
|
|
}
|
|
|
|
interface PickerProps {
|
|
voice: string | undefined;
|
|
voices: SpeechSynthesisVoice[];
|
|
}
|
|
|
|
function SimplePicker({ voice, voices }: PickerProps) {
|
|
const options = voices.map(voice => ({
|
|
label: voice.name,
|
|
value: voice.voiceURI,
|
|
default: voice.default,
|
|
}));
|
|
|
|
return (
|
|
<SearchableSelect
|
|
placeholder="Select a voice"
|
|
maxVisibleItems={5}
|
|
options={options}
|
|
value={options.find(o => o.value === voice)?.value}
|
|
onChange={v => settings.store.voice = v}
|
|
closeOnSelect
|
|
/>
|
|
);
|
|
}
|
|
|
|
const languageNames = new Intl.DisplayNames(["en"], { type: "language" });
|
|
|
|
function ComplexPicker({ voice, voices }: PickerProps) {
|
|
const groupedVoices = useMemo(() => groupBy(voices, voice => voice.lang), [voices]);
|
|
|
|
const languageNameMapping = useMemo(() => {
|
|
const list = [] as Record<"name" | "friendlyName", string>[];
|
|
|
|
for (const name in groupedVoices) {
|
|
try {
|
|
const friendlyName = languageNames.of(name);
|
|
if (friendlyName) {
|
|
list.push({ name, friendlyName });
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
return list;
|
|
}, [groupedVoices]);
|
|
|
|
const [selectedLanguage, setSelectedLanguage] = useState(() => getCurrentVoice()?.lang ?? languageNameMapping[0].name);
|
|
|
|
if (languageNameMapping.length === 1) {
|
|
return (
|
|
<SimplePicker
|
|
voice={voice}
|
|
voices={groupedVoices[languageNameMapping[0].name]}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const voicesForLanguage = groupedVoices[selectedLanguage];
|
|
|
|
const languageOptions = languageNameMapping.map(l => ({
|
|
label: l.friendlyName,
|
|
value: l.name
|
|
}));
|
|
|
|
return (
|
|
<>
|
|
<Heading>Language</Heading>
|
|
<SearchableSelect
|
|
placeholder="Select a language"
|
|
options={languageOptions}
|
|
value={languageOptions.find(l => l.value === selectedLanguage)?.value}
|
|
onChange={v => setSelectedLanguage(v)}
|
|
maxVisibleItems={5}
|
|
closeOnSelect
|
|
/>
|
|
<Heading>Voice</Heading>
|
|
<SimplePicker
|
|
voice={voice}
|
|
voices={voicesForLanguage}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function VoiceSetting() {
|
|
const voices = useMemo(() => window.speechSynthesis?.getVoices() ?? [], []);
|
|
const { voice } = settings.use(["voice"]);
|
|
|
|
if (!voices.length)
|
|
return <Paragraph>No voices found.</Paragraph>;
|
|
|
|
// espeak on Linux has a ridiculous amount of voices (26k for me).
|
|
// If there are more than 20 voices, we split it up into two pickers, one for language, then one with only the voices for that language.
|
|
// This way, there are around 200-ish options per language
|
|
const Picker = voices.length > 20 ? ComplexPicker : SimplePicker;
|
|
return <Picker voice={voice} voices={voices} />;
|
|
}
|
|
|
|
export function VoiceSettingSection() {
|
|
return (
|
|
<section>
|
|
<Heading>Voice</Heading>
|
|
<VoiceSetting />
|
|
</section>
|
|
);
|
|
}
|