aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorVen <vendicated@riseup.net>2023-01-25 03:42:01 +0100
committerGitHub <noreply@github.com>2023-01-25 03:42:01 +0100
commit5d3148cf50f315a303e8e7f8ac2f0861b0279eb1 (patch)
treec85bc4216c9442e9c13f59d076a29439bfbe276a /src
parentd628924b59e577db5ad4e8f0f0bfc32eafd84c99 (diff)
downloadVencord-5d3148cf50f315a303e8e7f8ac2f0861b0279eb1.tar.gz
Vencord-5d3148cf50f315a303e8e7f8ac2f0861b0279eb1.tar.bz2
Vencord-5d3148cf50f315a303e8e7f8ac2f0861b0279eb1.zip
New plugin: VcNarrator (#402)
Co-authored-by: Nico <nico@d3sox.me>
Diffstat (limited to 'src')
-rw-r--r--src/plugins/vcNarrator.tsx334
1 files changed, 334 insertions, 0 deletions
diff --git a/src/plugins/vcNarrator.tsx b/src/plugins/vcNarrator.tsx
new file mode 100644
index 0000000..a855872
--- /dev/null
+++ b/src/plugins/vcNarrator.tsx
@@ -0,0 +1,334 @@
+/*
+ * Vencord, a modification for Discord's desktop app
+ * Copyright (c) 2023 Vendicated and contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+
+import { Settings } from "@api/settings";
+import { ErrorCard } from "@components/ErrorCard";
+import { Devs } from "@utils/constants";
+import Logger from "@utils/Logger";
+import { wordsToTitle } from "@utils/text";
+import definePlugin, { OptionType, PluginOptionsItem } from "@utils/types";
+import { findByPropsLazy } from "@webpack";
+import { Button, ChannelStore, FluxDispatcher, Forms, Margins, SelectedChannelStore, useMemo, UserStore } from "@webpack/common";
+
+interface VoiceState {
+ userId: string;
+ channelId?: string;
+ oldChannelId?: string;
+ deaf: boolean;
+ mute: boolean;
+ selfDeaf: boolean;
+ selfMute: boolean;
+}
+
+const VoiceStateStore = findByPropsLazy("getVoiceStatesForChannel", "getCurrentClientVoiceChannelId");
+
+// 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 getEnglishVoices() {
+ const voices = speechSynthesis.getVoices();
+ const englishVoices = voices.filter(v => v.lang.startsWith("en"));
+ return !englishVoices.length ? voices : englishVoices;
+}
+
+function speak(text: string, settings: any = Settings.plugins.VcNarrator) {
+ 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
+ }
+ speech.voice = voice!;
+ speech.volume = settings.volume;
+ speech.rate = settings.rate;
+ speechSynthesis.speak(speech);
+}
+
+function clean(str: string, fallback: string) {
+ return str.normalize("NFKC")
+ .replace(/[^\w ]/g, "")
+ .trim()
+ || fallback;
+}
+
+function formatText(str: string, user: string, channel: string) {
+ return str
+ .replaceAll("{{USER}}", clean(user, user ? "Someone" : ""))
+ .replaceAll("{{CHANNEL}}", clean(channel, "channel"));
+}
+
+/*
+let StatusMap = {} as Record<string, {
+ mute: boolean;
+ deaf: boolean;
+}>;
+*/
+
+// For every user, channelId and oldChannelId will differ when moving channel.
+// Only for the local user, channelId and oldChannelId will be the same when moving channel,
+// for some ungodly reason
+let myLastChannelId: string | undefined;
+
+function getTypeAndChannelId({ channelId, oldChannelId }: VoiceState, isMe: boolean) {
+ if (isMe && channelId !== myLastChannelId) {
+ oldChannelId = myLastChannelId;
+ myLastChannelId = channelId;
+ }
+
+ if (channelId !== oldChannelId) {
+ if (channelId) return [oldChannelId ? "move" : "join", channelId];
+ if (oldChannelId) return ["leave", oldChannelId];
+ }
+ /*
+ if (channelId) {
+ if (deaf || selfDeaf) return ["deafen", channelId];
+ if (mute || selfMute) return ["mute", channelId];
+ const oldStatus = StatusMap[userId];
+ if (oldStatus.deaf) return ["undeafen", channelId];
+ if (oldStatus.mute) return ["unmute", channelId];
+ }
+ */
+ return ["", ""];
+}
+
+/*
+function updateStatuses(type: string, { deaf, mute, selfDeaf, selfMute, userId, channelId }: VoiceState, isMe: boolean) {
+ if (isMe && (type === "join" || type === "move")) {
+ StatusMap = {};
+ const states = VoiceStateStore.getVoiceStatesForChannel(channelId!) as Record<string, VoiceState>;
+ for (const userId in states) {
+ const s = states[userId];
+ StatusMap[userId] = {
+ mute: s.mute || s.selfMute,
+ deaf: s.deaf || s.selfDeaf
+ };
+ }
+ return;
+ }
+
+ if (type === "leave" || (type === "move" && channelId !== SelectedChannelStore.getVoiceChannelId())) {
+ if (isMe)
+ StatusMap = {};
+ else
+ delete StatusMap[userId];
+
+ return;
+ }
+
+ StatusMap[userId] = {
+ deaf: deaf || selfDeaf,
+ mute: mute || selfMute
+ };
+}
+*/
+
+function handleVoiceStates({ voiceStates }: { voiceStates: VoiceState[]; }) {
+ const myChanId = SelectedChannelStore.getVoiceChannelId();
+ const myId = UserStore.getCurrentUser().id;
+
+ for (const state of voiceStates) {
+ const { userId, channelId, oldChannelId } = state;
+ const isMe = userId === myId;
+ if (!isMe) {
+ if (!myChanId) continue;
+ if (channelId !== myChanId && oldChannelId !== myChanId) continue;
+ }
+
+ const [type, id] = getTypeAndChannelId(state, isMe);
+ if (!type) continue;
+
+ const template = Settings.plugins.VcNarrator[type + "Message"];
+ const user = isMe ? "" : UserStore.getUser(userId).username;
+ const channel = ChannelStore.getChannel(id).name;
+
+ speak(formatText(template, user, channel));
+
+ // updateStatuses(type, state, isMe);
+ }
+}
+
+function handleToggleSelfMute() {
+ const chanId = SelectedChannelStore.getVoiceChannelId()!;
+ const s = VoiceStateStore.getVoiceStateForChannel(chanId) as VoiceState;
+ if (!s) return;
+
+ const event = s.mute || s.selfMute ? "unmute" : "mute";
+ speak(formatText(Settings.plugins.VcNarrator[event + "Message"], "", ChannelStore.getChannel(chanId).name));
+}
+
+function handleToggleSelfDeafen() {
+ const chanId = SelectedChannelStore.getVoiceChannelId()!;
+ const s = VoiceStateStore.getVoiceStateForChannel(chanId) as VoiceState;
+ if (!s) return;
+
+ const event = s.deaf || s.selfDeaf ? "undeafen" : "deafen";
+ speak(formatText(Settings.plugins.VcNarrator[event + "Message"], "", ChannelStore.getChannel(chanId).name));
+}
+
+function playSample(tempSettings: any, type: string) {
+ const settings = Object.assign({}, Settings.plugins.VcNarrator, tempSettings);
+
+ speak(formatText(settings[type + "Message"], UserStore.getCurrentUser().username, "general"), settings);
+}
+
+export default definePlugin({
+ name: "VcNarrator",
+ description: "Announces when users join, leave, or move voice channels via narrator",
+ authors: [Devs.Ven],
+
+ start() {
+ if (speechSynthesis.getVoices().length === 0) {
+ new Logger("VcNarrator").warn("No Narrator voices found. Thus, this plugin will not work. Check my Settings for more info");
+ return;
+ }
+ FluxDispatcher.subscribe("VOICE_STATE_UPDATES", handleVoiceStates);
+ FluxDispatcher.subscribe("AUDIO_TOGGLE_SELF_MUTE", handleToggleSelfMute);
+ FluxDispatcher.subscribe("AUDIO_TOGGLE_SELF_DEAF", handleToggleSelfDeafen);
+ },
+
+ stop() {
+ FluxDispatcher.unsubscribe("VOICE_STATE_UPDATES", handleVoiceStates);
+ FluxDispatcher.subscribe("AUDIO_TOGGLE_SELF_MUTE", handleToggleSelfMute);
+ FluxDispatcher.subscribe("AUDIO_TOGGLE_SELF_DEAF", handleToggleSelfDeafen);
+ },
+
+ optionsCache: null as Record<string, PluginOptionsItem> | null,
+
+ get options() {
+ return this.optionsCache ??= {
+ voice: {
+ type: OptionType.SELECT,
+ description: "Narrator Voice",
+ options: getEnglishVoices().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
+ },
+ joinMessage: {
+ type: OptionType.STRING,
+ description: "Join Message",
+ default: "{{USER}} joined {{CHANNEL}}"
+ },
+ leaveMessage: {
+ type: OptionType.STRING,
+ description: "Leave Message",
+ default: "{{USER}} left {{CHANNEL}}"
+ },
+ 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"
+ }
+ };
+ },
+
+ settingsAboutComponent({ tempSettings: s }) {
+ const [hasVoices, hasEnglishVoices] = useMemo(() => {
+ const voices = speechSynthesis.getVoices();
+ return [voices.length !== 0, voices.some(v => v.lang.startsWith("en"))];
+ }, []);
+
+ const types = useMemo(
+ () => Object.keys(Vencord.Plugins.plugins.VcNarrator.options!).filter(k => k.endsWith("Message")).map(k => k.slice(0, -7)),
+ [],
+ );
+
+ let errorComponent: React.ReactElement | null = null;
+ if (!hasVoices) {
+ let error = "No narrator voices found. ";
+ error += navigator.platform?.toLowerCase().includes("linux")
+ ? "Install speech-dispatcher or espeak and run Discord with the --enable-speech-dispatcher flag"
+ : "Try installing some in the Narrator settings of your Operating System";
+ errorComponent = <ErrorCard>{error}</ErrorCard>;
+ } else if (!hasEnglishVoices) {
+ errorComponent = <ErrorCard>You don't have any English voices installed, so the narrator might sound weird</ErrorCard>;
+ }
+
+ return (
+ <Forms.FormSection>
+ <Forms.FormText>
+ You can customise the spoken messages below. You can disable specific messages by setting them to nothing
+ </Forms.FormText>
+ <Forms.FormText>
+ The special placeholders <code>{"{{USER}}"}</code> and <code>{"{{CHANNEL}}"}</code>{" "}
+ will be replaced with the user's name (nothing if it's yourself) and the channel's name respectively
+ </Forms.FormText>
+ {hasEnglishVoices && (
+ <>
+ <Forms.FormTitle className={Margins.marginTop20} tag="h3">Play Example Sounds</Forms.FormTitle>
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(4, 1fr)",
+ gap: "1rem",
+ }}
+ className={"vc-narrator-buttons"}
+ >
+ {types.map(t => (
+ <Button key={t} onClick={() => playSample(s, t)}>
+ {wordsToTitle([t])}
+ </Button>
+ ))}
+ </div>
+ </>
+ )}
+ {errorComponent}
+ </Forms.FormSection>
+ );
+ }
+});