From e5f6605c0154b2dbf8b3ffceeccc57628d6bb1e5 Mon Sep 17 00:00:00 2001 From: Vending Machine Date: Mon, 7 Apr 2025 00:58:07 +0200 Subject: [PATCH] VcNarrator: Fix voice selection setting (#3365) --- src/plugins/vcNarrator/VoiceSetting.tsx | 126 ++++++++++++++++++++++++ src/plugins/vcNarrator/index.tsx | 112 ++++----------------- src/plugins/vcNarrator/settings.ts | 97 ++++++++++++++++++ 3 files changed, 240 insertions(+), 95 deletions(-) create mode 100644 src/plugins/vcNarrator/VoiceSetting.tsx create mode 100644 src/plugins/vcNarrator/settings.ts diff --git a/src/plugins/vcNarrator/VoiceSetting.tsx b/src/plugins/vcNarrator/VoiceSetting.tsx new file mode 100644 index 00000000..fb5c739c --- /dev/null +++ b/src/plugins/vcNarrator/VoiceSetting.tsx @@ -0,0 +1,126 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Forms, 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(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); +} + +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 ( + o.value === voice)} + 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 ( + + ); + } + + const voicesForLanguage = groupedVoices[selectedLanguage]; + + const languageOptions = languageNameMapping.map(l => ({ + label: l.friendlyName, + value: l.name + })); + + return ( + <> + Language + l.value === selectedLanguage)} + onChange={v => setSelectedLanguage(v)} + maxVisibleItems={5} + closeOnSelect + /> + Voice + + + ); +} + + +function VoiceSetting() { + const voices = useMemo(() => window.speechSynthesis?.getVoices() ?? [], []); + const { voice } = settings.use(["voice"]); + + if (!voices.length) + return No voices found.; + + // 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 ; +} + +export function VoiceSettingSection() { + return ( + + Voice + + + ); +} diff --git a/src/plugins/vcNarrator/index.tsx b/src/plugins/vcNarrator/index.tsx index 56a2cb36..02eb216c 100644 --- a/src/plugins/vcNarrator/index.tsx +++ b/src/plugins/vcNarrator/index.tsx @@ -16,17 +16,18 @@ * along with this program. If not, see . */ -import { Settings } from "@api/Settings"; import { ErrorCard } from "@components/ErrorCard"; import { Devs } from "@utils/constants"; import { Logger } from "@utils/Logger"; import { Margins } from "@utils/margins"; import { wordsToTitle } from "@utils/text"; -import definePlugin, { OptionType, PluginOptionsItem, ReporterTestable } from "@utils/types"; +import definePlugin, { ReporterTestable } from "@utils/types"; import { findByPropsLazy } from "@webpack"; import { Button, ChannelStore, Forms, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, useMemo, UserStore } from "@webpack/common"; import { ReactElement } from "react"; +import { getCurrentVoice, settings } from "./settings"; + interface VoiceState { userId: string; channelId?: string; @@ -43,25 +44,19 @@ const VoiceStateStore = findByPropsLazy("getVoiceStatesForChannel", "getCurrentC // Filtering out events is not as simple as just dropping duplicates, as otherwise mute, unmute, mute would // not say the second mute, which would lead you to believe they're unmuted -function speak(text: string, settings: any = Settings.plugins.VcNarrator) { +function speak(text: string, { volume, rate } = settings.store) { if (!text) return; const speech = new SpeechSynthesisUtterance(text); - let voice = speechSynthesis.getVoices().find(v => v.voiceURI === settings.voice); - if (!voice) { - new Logger("VcNarrator").error(`Voice "${settings.voice}" not found. Resetting to default.`); - voice = speechSynthesis.getVoices().find(v => v.default); - settings.voice = voice?.voiceURI; - if (!voice) return; // This should never happen - } + const voice = getCurrentVoice(); speech.voice = voice!; - speech.volume = settings.volume; - speech.rate = settings.rate; + speech.volume = volume; + speech.rate = rate; speechSynthesis.speak(speech); } function clean(str: string) { - const replacer = Settings.plugins.VcNarrator.latinOnly + const replacer = settings.store.latinOnly ? /[^\p{Script=Latin}\p{Number}\p{Punctuation}\s]/gu : /[^\p{Letter}\p{Number}\p{Punctuation}\s]/gu; @@ -145,11 +140,11 @@ function updateStatuses(type: string, { deaf, mute, selfDeaf, selfMute, userId, */ function playSample(tempSettings: any, type: string) { - const settings = Object.assign({}, Settings.plugins.VcNarrator, tempSettings); + const s = Object.assign({}, settings.plain, tempSettings); const currentUser = UserStore.getCurrentUser(); const myGuildId = SelectedGuildStore.getGuildId(); - speak(formatText(settings[type + "Message"], currentUser.username, "general", (currentUser as any).globalName ?? currentUser.username, GuildMemberStore.getNick(myGuildId, currentUser.id) ?? currentUser.username), settings); + speak(formatText(s[type + "Message"], currentUser.username, "general", (currentUser as any).globalName ?? currentUser.username, GuildMemberStore.getNick(myGuildId, currentUser.id) ?? currentUser.username), s); } export default definePlugin({ @@ -158,6 +153,8 @@ export default definePlugin({ authors: [Devs.Ven], reporterTestable: ReporterTestable.None, + settings, + flux: { VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { const myGuildId = SelectedGuildStore.getGuildId(); @@ -177,8 +174,8 @@ export default definePlugin({ const [type, id] = getTypeAndChannelId(state, isMe); if (!type) continue; - const template = Settings.plugins.VcNarrator[type + "Message"]; - const user = isMe && !Settings.plugins.VcNarrator.sayOwnName ? "" : UserStore.getUser(userId).username; + const template = settings.store[type + "Message"]; + const user = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username; const displayName = user && ((UserStore.getUser(userId) as any).globalName ?? user); const nickname = user && (GuildMemberStore.getNick(myGuildId, userId) ?? user); const channel = ChannelStore.getChannel(id).name; @@ -195,7 +192,7 @@ export default definePlugin({ if (!s) return; const event = s.mute || s.selfMute ? "unmute" : "mute"; - speak(formatText(Settings.plugins.VcNarrator[event + "Message"], "", ChannelStore.getChannel(chanId).name, "", "")); + speak(formatText(settings.store[event + "Message"], "", ChannelStore.getChannel(chanId).name, "", "")); }, AUDIO_TOGGLE_SELF_DEAF() { @@ -204,7 +201,7 @@ export default definePlugin({ if (!s) return; const event = s.deaf || s.selfDeaf ? "undeafen" : "deafen"; - speak(formatText(Settings.plugins.VcNarrator[event + "Message"], "", ChannelStore.getChannel(chanId).name, "", "")); + speak(formatText(settings.store[event + "Message"], "", ChannelStore.getChannel(chanId).name, "", "")); } }, @@ -218,81 +215,6 @@ export default definePlugin({ }, - optionsCache: null as Record | null, - - get options() { - return this.optionsCache ??= { - voice: { - type: OptionType.SELECT, - description: "Narrator Voice", - options: window.speechSynthesis?.getVoices().map(v => ({ - label: v.name, - value: v.voiceURI, - default: v.default - })) ?? [] - }, - volume: { - type: OptionType.SLIDER, - description: "Narrator Volume", - default: 1, - markers: [0, 0.25, 0.5, 0.75, 1], - stickToMarkers: false - }, - rate: { - type: OptionType.SLIDER, - description: "Narrator Speed", - default: 1, - markers: [0.1, 0.5, 1, 2, 5, 10], - stickToMarkers: false - }, - sayOwnName: { - description: "Say own name", - type: OptionType.BOOLEAN, - default: false - }, - latinOnly: { - description: "Strip non latin characters from names before saying them", - type: OptionType.BOOLEAN, - default: false - }, - joinMessage: { - type: OptionType.STRING, - description: "Join Message", - default: "{{USER}} joined" - }, - leaveMessage: { - type: OptionType.STRING, - description: "Leave Message", - default: "{{USER}} left" - }, - moveMessage: { - type: OptionType.STRING, - description: "Move Message", - default: "{{USER}} moved to {{CHANNEL}}" - }, - muteMessage: { - type: OptionType.STRING, - description: "Mute Message (only self for now)", - default: "{{USER}} muted" - }, - unmuteMessage: { - type: OptionType.STRING, - description: "Unmute Message (only self for now)", - default: "{{USER}} unmuted" - }, - deafenMessage: { - type: OptionType.STRING, - description: "Deafen Message (only self for now)", - default: "{{USER}} deafened" - }, - undeafenMessage: { - type: OptionType.STRING, - description: "Undeafen Message (only self for now)", - default: "{{USER}} undeafened" - } - } satisfies Record; - }, - settingsAboutComponent({ tempSettings: s }) { const [hasVoices, hasEnglishVoices] = useMemo(() => { const voices = speechSynthesis.getVoices(); @@ -300,7 +222,7 @@ export default definePlugin({ }, []); const types = useMemo( - () => Object.keys(Vencord.Plugins.plugins.VcNarrator.options!).filter(k => k.endsWith("Message")).map(k => k.slice(0, -7)), + () => Object.keys(settings.def).filter(k => k.endsWith("Message")).map(k => k.slice(0, -7)), [], ); diff --git a/src/plugins/vcNarrator/settings.ts b/src/plugins/vcNarrator/settings.ts new file mode 100644 index 00000000..ee3aee0e --- /dev/null +++ b/src/plugins/vcNarrator/settings.ts @@ -0,0 +1,97 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { Logger } from "@utils/Logger"; +import { OptionType } from "@utils/types"; + +import { VoiceSettingSection } from "./VoiceSetting"; + +export const getDefaultVoice = () => window.speechSynthesis?.getVoices().find(v => v.default); + +export function getCurrentVoice(voices = window.speechSynthesis?.getVoices()) { + if (!voices) return undefined; + + if (settings.store.voice) { + const voice = voices.find(v => v.voiceURI === settings.store.voice); + if (voice) return voice; + + new Logger("VcNarrator").error(`Voice "${settings.store.voice}" not found. Resetting to default.`); + } + + const voice = voices.find(v => v.default); + settings.store.voice = voice?.voiceURI; + return voice; +} + +export const settings = definePluginSettings({ + voice: { + type: OptionType.COMPONENT, + component: VoiceSettingSection, + get default() { + return getDefaultVoice()?.voiceURI; + } + }, + volume: { + type: OptionType.SLIDER, + description: "Narrator Volume", + default: 1, + markers: [0, 0.25, 0.5, 0.75, 1], + stickToMarkers: false + }, + rate: { + type: OptionType.SLIDER, + description: "Narrator Speed", + default: 1, + markers: [0.1, 0.5, 1, 2, 5, 10], + stickToMarkers: false + }, + sayOwnName: { + description: "Say own name", + type: OptionType.BOOLEAN, + default: false + }, + latinOnly: { + description: "Strip non latin characters from names before saying them", + type: OptionType.BOOLEAN, + default: false + }, + joinMessage: { + type: OptionType.STRING, + description: "Join Message", + default: "{{USER}} joined" + }, + leaveMessage: { + type: OptionType.STRING, + description: "Leave Message", + default: "{{USER}} left" + }, + moveMessage: { + type: OptionType.STRING, + description: "Move Message", + default: "{{USER}} moved to {{CHANNEL}}" + }, + muteMessage: { + type: OptionType.STRING, + description: "Mute Message (only self for now)", + default: "{{USER}} muted" + }, + unmuteMessage: { + type: OptionType.STRING, + description: "Unmute Message (only self for now)", + default: "{{USER}} unmuted" + }, + deafenMessage: { + type: OptionType.STRING, + description: "Deafen Message (only self for now)", + default: "{{USER}} deafened" + }, + undeafenMessage: { + type: OptionType.STRING, + description: "Undeafen Message (only self for now)", + default: "{{USER}} undeafened" + } +});