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/>.
*/
import { definePluginSettings, 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, 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;
@ -39,118 +40,18 @@ interface VoiceState {
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
// 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) {
function speak(text: string, { volume, rate } = settings.store) {
if (!text) return;
const set = settings.store;
const speech = new SpeechSynthesisUtterance(text);
let voice = speechSynthesis.getVoices().find(v => v.voiceURI === settings.store.voice);
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;
}
const voice = getCurrentVoice();
speech.voice = voice!;
speech.volume = settings.store.volume;
speech.rate = settings.store.rate;
speech.volume = volume;
speech.rate = rate;
speechSynthesis.speak(speech);
}
@ -188,15 +89,16 @@ function getTypeAndChannelId({ channelId, oldChannelId }: VoiceState, isMe: bool
if (channelId) return [oldChannelId ? "move" : "join", channelId];
if (oldChannelId) return ["leave", oldChannelId];
}
return ["", ""];
}
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 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({
@ -204,7 +106,9 @@ export default definePlugin({
description: "Announces when users join, leave, or move voice channels via narrator",
authors: [Devs.Ven],
reporterTestable: ReporterTestable.None,
settings,
flux: {
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
const myGuildId = SelectedGuildStore.getGuildId();
@ -242,7 +146,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() {
@ -251,18 +155,18 @@ 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, "", ""));
}
},
start() {
console.log(settings.store.voice);
if (typeof speechSynthesis === "undefined" || speechSynthesis.getVoices().length === 0) {
new Logger("VcNarrator").warn(
"SpeechSynthesis not supported or no Narrator voices found. Thus, this plugin will not work. Check my Settings for more info"
);
return;
}
},
settingsAboutComponent({ tempSettings: s }) {
@ -272,7 +176,7 @@ export default definePlugin({
}, []);
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"
}
});