Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
thororen1234 2025-04-08 09:15:38 -04:00
commit 38b0b39699
No known key found for this signature in database
3 changed files with 239 additions and 112 deletions

View file

@ -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<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)}
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 (
<>
<Forms.FormTitle>Language</Forms.FormTitle>
<SearchableSelect
placeholder="Select a language"
options={languageOptions}
value={languageOptions.find(l => l.value === selectedLanguage)}
onChange={v => setSelectedLanguage(v)}
maxVisibleItems={5}
closeOnSelect
/>
<Forms.FormTitle>Voice</Forms.FormTitle>
<SimplePicker
voice={voice}
voices={voicesForLanguage}
/>
</>
);
}
function VoiceSetting() {
const voices = useMemo(() => window.speechSynthesis?.getVoices() ?? [], []);
const { voice } = settings.use(["voice"]);
if (!voices.length)
return <Forms.FormText>No voices found.</Forms.FormText>;
// 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 (
<Forms.FormSection>
<Forms.FormTitle>Voice</Forms.FormTitle>
<VoiceSetting />
</Forms.FormSection>
);
}

View file

@ -16,17 +16,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { definePluginSettings, Settings } from "@api/Settings";
import { ErrorCard } from "@components/ErrorCard"; import { ErrorCard } from "@components/ErrorCard";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { wordsToTitle } from "@utils/text"; import { wordsToTitle } from "@utils/text";
import definePlugin, { OptionType, ReporterTestable } from "@utils/types"; import definePlugin, { ReporterTestable } from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { findByPropsLazy } from "@webpack";
import { Button, ChannelStore, Forms, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, useMemo, UserStore } from "@webpack/common"; import { Button, ChannelStore, Forms, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, useMemo, UserStore } from "@webpack/common";
import { ReactElement } from "react"; import { ReactElement } from "react";
import { getCurrentVoice, settings } from "./settings";
interface VoiceState { interface VoiceState {
userId: string; userId: string;
channelId?: string; channelId?: string;
@ -39,118 +40,18 @@ interface VoiceState {
const VoiceStateStore = findByPropsLazy("getVoiceStatesForChannel", "getCurrentClientVoiceChannelId"); const VoiceStateStore = findByPropsLazy("getVoiceStatesForChannel", "getCurrentClientVoiceChannelId");
function getVoices(): Promise<SpeechSynthesisVoice[]> {
return new Promise(resolve => {
let voices: SpeechSynthesisVoice[] = window.speechSynthesis.getVoices();
if (voices.length) {
resolve(voices);
return;
}
window.speechSynthesis.onvoiceschanged = () => {
voices = window.speechSynthesis.getVoices();
resolve(voices);
};
});
}
getVoices().then(resolvedVoices => {
const voiceList = resolvedVoices.map(v => ({
label: v.name,
value: v.voiceURI,
default: v.default
}));
// @ts-ignore
settings.def.voice.options.push(...voiceList);
});
const settings = definePluginSettings({
voice: {
type: OptionType.SELECT,
description: "Narrator Voice",
options: [],
},
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"
}
});
// Mute/Deaf for other people than you is commented out, because otherwise someone can spam it and it will be annoying // Mute/Deaf for other people than you is commented out, because otherwise someone can spam it and it will be annoying
// Filtering out events is not as simple as just dropping duplicates, as otherwise mute, unmute, mute would // 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 // not say the second mute, which would lead you to believe they're unmuted
function speak(text: string) { function speak(text: string, { volume, rate } = settings.store) {
if (!text) return; if (!text) return;
const set = settings.store;
const speech = new SpeechSynthesisUtterance(text); const speech = new SpeechSynthesisUtterance(text);
let voice = speechSynthesis.getVoices().find(v => v.voiceURI === settings.store.voice); const voice = getCurrentVoice();
if (!voice) {
new Logger("VcNarrator").error(`Voice "${settings.store.voice}" not found. Resetting to default.`);
voice = speechSynthesis.getVoices().find(v => v.default);
if (!voice) return; // This should never happen
// @ts-ignore
settings.store.voice = voice.voiceURI;
}
speech.voice = voice!; speech.voice = voice!;
speech.volume = settings.store.volume; speech.volume = volume;
speech.rate = settings.store.rate; speech.rate = rate;
speechSynthesis.speak(speech); speechSynthesis.speak(speech);
} }
@ -188,15 +89,16 @@ function getTypeAndChannelId({ channelId, oldChannelId }: VoiceState, isMe: bool
if (channelId) return [oldChannelId ? "move" : "join", channelId]; if (channelId) return [oldChannelId ? "move" : "join", channelId];
if (oldChannelId) return ["leave", oldChannelId]; if (oldChannelId) return ["leave", oldChannelId];
} }
return ["", ""]; return ["", ""];
} }
function playSample(tempSettings: any, type: string) { function playSample(tempSettings: any, type: string) {
const settingsobj = Object.assign({}, settings.store, tempSettings); const s = Object.assign({}, settings.plain, tempSettings);
const currentUser = UserStore.getCurrentUser(); const currentUser = UserStore.getCurrentUser();
const myGuildId = SelectedGuildStore.getGuildId(); const myGuildId = SelectedGuildStore.getGuildId();
speak(formatText(settingsobj[type + "Message"], currentUser.username, "general", (currentUser as any).globalName ?? currentUser.username, GuildMemberStore.getNick(myGuildId, currentUser.id) ?? currentUser.username)); 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({ export default definePlugin({
@ -204,7 +106,9 @@ export default definePlugin({
description: "Announces when users join, leave, or move voice channels via narrator", description: "Announces when users join, leave, or move voice channels via narrator",
authors: [Devs.Ven], authors: [Devs.Ven],
reporterTestable: ReporterTestable.None, reporterTestable: ReporterTestable.None,
settings, settings,
flux: { flux: {
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
const myGuildId = SelectedGuildStore.getGuildId(); const myGuildId = SelectedGuildStore.getGuildId();
@ -242,7 +146,7 @@ export default definePlugin({
if (!s) return; if (!s) return;
const event = s.mute || s.selfMute ? "unmute" : "mute"; 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() { AUDIO_TOGGLE_SELF_DEAF() {
@ -251,18 +155,18 @@ export default definePlugin({
if (!s) return; if (!s) return;
const event = s.deaf || s.selfDeaf ? "undeafen" : "deafen"; 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, "", ""));
} }
}, },
start() { start() {
console.log(settings.store.voice);
if (typeof speechSynthesis === "undefined" || speechSynthesis.getVoices().length === 0) { if (typeof speechSynthesis === "undefined" || speechSynthesis.getVoices().length === 0) {
new Logger("VcNarrator").warn( new Logger("VcNarrator").warn(
"SpeechSynthesis not supported or no Narrator voices found. Thus, this plugin will not work. Check my Settings for more info" "SpeechSynthesis not supported or no Narrator voices found. Thus, this plugin will not work. Check my Settings for more info"
); );
return; return;
} }
}, },
settingsAboutComponent({ tempSettings: s }) { settingsAboutComponent({ tempSettings: s }) {
@ -272,7 +176,7 @@ export default definePlugin({
}, []); }, []);
const types = useMemo( const types = useMemo(
() => Object.keys(settings.store!).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)),
[], [],
); );

View file

@ -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"
}
});