Plugin

VcNarrator

Announces when users join, leave, or move voice channels via narrator

Voice Accessibility
index.tsx
Download

Source

src/plugins/vcNarrator/index.tsx
1import { ErrorCard } from "@components/ErrorCard";
2import { Devs, IS_LINUX } from "@utils/constants";
3import { Logger } from "@utils/Logger";
4import { Margins } from "@utils/margins";
5import { wordsToTitle } from "@utils/text";
6import definePlugin, { ReporterTestable } from "@utils/types";
7import { AuthenticationStore, Button, ChannelStore, Forms, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, useMemo, UserStore, VoiceStateStore } from "@webpack/common";
8import { ReactElement } from "react";
9
10import { getCurrentVoice, settings } from "./settings";
11
12interface VoiceStateChangeEvent {
13 userId: string;
14 channelId?: string;
15 oldChannelId?: string;
16 deaf: boolean;
17 mute: boolean;
18 selfDeaf: boolean;
19 selfMute: boolean;
20 sessionId: string;
21}
22
23// Mute/Deaf for other people than you is commented out, because otherwise someone can spam it and it will be annoying
24// Filtering out events is not as simple as just dropping duplicates, as otherwise mute, unmute, mute would
25// not say the second mute, which would lead you to believe they're unmuted
26
27function speak(text: string) {
28 // Don't narrate in the overlay window, otherwise everything is said twice
29 if (!text || window.__OVERLAY__) return;
30
31 const { volume, rate } = settings.store;
32
33 const speech = new SpeechSynthesisUtterance(text);
34 const voice = getCurrentVoice();
35 speech.voice = voice!;
36 speech.volume = volume;
37 speech.rate = rate;
38 speechSynthesis.speak(speech);
39}
40
41function clean(str: string) {
42 const replacer = settings.store.latinOnly
43 ? /[^\p{Script=Latin}\p{Number}\p{Punctuation}\s]/gu
44 : /[^\p{Letter}\p{Number}\p{Punctuation}\s]/gu;
45
46 return str.normalize("NFKC")
47 .replace(replacer, "")
48 .replace(/_{2,}/g, "_")
49 .trim();
50}
51
52function formatText(str: string, user: string, channel: string, displayName: string, nickname: string) {
53 return str
54 .replaceAll("{{USER}}", clean(user) || (user ? "Someone" : ""))
55 .replaceAll("{{CHANNEL}}", clean(channel) || "channel")
56 .replaceAll("{{DISPLAY_NAME}}", clean(displayName) || (displayName ? "Someone" : ""))
57 .replaceAll("{{NICKNAME}}", clean(nickname) || (nickname ? "Someone" : ""));
58}
59
60/*
61let StatusMap = {} as Record<string, {
62 mute: boolean;
63 deaf: boolean;
64}>;
65*/
66
67// For every user, channelId and oldChannelId will differ when moving channel.
68// Only for the local user, channelId and oldChannelId will be the same when moving channel,
69// for some ungodly reason
70let myLastChannelId: string | undefined;
71
72function getTypeAndChannelId({ channelId, oldChannelId }: VoiceStateChangeEvent, isMe: boolean) {
73 if (isMe && channelId !== myLastChannelId) {
74 oldChannelId = myLastChannelId;
75 myLastChannelId = channelId;
76 }
77
78 if (channelId !== oldChannelId) {
79 if (channelId) return [oldChannelId ? "move" : "join", channelId];
80 if (oldChannelId) return ["leave", oldChannelId];
81 }
82 /*
83 if (channelId) {
84 if (deaf || selfDeaf) return ["deafen", channelId];
85 if (mute || selfMute) return ["mute", channelId];
86 const oldStatus = StatusMap[userId];
87 if (oldStatus.deaf) return ["undeafen", channelId];
88 if (oldStatus.mute) return ["unmute", channelId];
89 }
90 */
91 return ["", ""];
92}
93
94/*
95function updateStatuses(type: string, { deaf, mute, selfDeaf, selfMute, userId, channelId }: VoiceState, isMe: boolean) {
96 if (isMe && (type === "join" || type === "move")) {
97 StatusMap = {};
98 const states = VoiceStateStore.getVoiceStatesForChannel(channelId!) as Record<string, VoiceState>;
99 for (const userId in states) {
100 const s = states[userId];
101 StatusMap[userId] = {
102 mute: s.mute || s.selfMute,
103 deaf: s.deaf || s.selfDeaf
104 };
105 }
106 return;
107 }
108
109 if (type === "leave" || (type === "move" && channelId !== SelectedChannelStore.getVoiceChannelId())) {
110 if (isMe)
111 StatusMap = {};
112 else
113 delete StatusMap[userId];
114
115 return;
116 }
117
118 StatusMap[userId] = {
119 deaf: deaf || selfDeaf,
120 mute: mute || selfMute
121 };
122}
123*/
124
125function playSample(type: string) {
126 const currentUser = UserStore.getCurrentUser();
127 const myGuildId = SelectedGuildStore.getGuildId();
128
129 speak(formatText(
130 settings.store[type + "Message"],
131 currentUser.username,
132 "general",
133 currentUser.globalName ?? currentUser.username,
134 GuildMemberStore.getNick(myGuildId!, currentUser.id) ?? currentUser.username
135 ));
136}
137
138export default definePlugin({
139 name: "VcNarrator",
140 description: "Announces when users join, leave, or move voice channels via narrator",
141 tags: ["Voice", "Accessibility"],
142 authors: [Devs.Ven],
143 reporterTestable: ReporterTestable.None,
144
145 settings,
146
147 flux: {
148 VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceStateChangeEvent[]; }) {
149 const myGuildId = SelectedGuildStore.getGuildId();
150 const myChanId = SelectedChannelStore.getVoiceChannelId();
151 const myId = UserStore.getCurrentUser().id;
152
153 if (ChannelStore.getChannel(myChanId!)?.type === 13 /* Stage Channel */) return;
154
155 for (const state of voiceStates) {
156 const { userId, channelId, oldChannelId } = state;
157 const isMe = userId === myId;
158 if (isMe && state.sessionId !== AuthenticationStore.getSessionId()) continue;
159 if (!isMe) {
160 if (!myChanId) continue;
161 if (channelId !== myChanId && oldChannelId !== myChanId) continue;
162 }
163
164 const [type, id] = getTypeAndChannelId(state, isMe);
165 if (!type) continue;
166
167 const template = settings.store[type + "Message"];
168 const user = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username;
169 const displayName = user && ((UserStore.getUser(userId) as any).globalName ?? user);
170 const nickname = user && (GuildMemberStore.getNick(myGuildId!, userId) ?? displayName);
171 const channel = ChannelStore.getChannel(id).name;
172
173 speak(formatText(template, user, channel, displayName, nickname));
174
175 // updateStatuses(type, state, isMe);
176 }
177 },
178
179 AUDIO_TOGGLE_SELF_MUTE() {
180 const chanId = SelectedChannelStore.getVoiceChannelId()!;
181 const s = VoiceStateStore.getVoiceStateForChannel(chanId);
182 if (!s) return;
183
184 const event = s.mute || s.selfMute ? "unmute" : "mute";
185 speak(formatText(settings.store[event + "Message"], "", ChannelStore.getChannel(chanId).name, "", ""));
186 },
187
188 AUDIO_TOGGLE_SELF_DEAF() {
189 const chanId = SelectedChannelStore.getVoiceChannelId()!;
190 const s = VoiceStateStore.getVoiceStateForChannel(chanId);
191 if (!s) return;
192
193 const event = s.deaf || s.selfDeaf ? "undeafen" : "deafen";
194 speak(formatText(settings.store[event + "Message"], "", ChannelStore.getChannel(chanId).name, "", ""));
195 }
196 },
197
198 start() {
199 if (typeof speechSynthesis === "undefined" || speechSynthesis.getVoices().length === 0) {
200 new Logger("VcNarrator").warn(
201 "SpeechSynthesis not supported or no Narrator voices found. Thus, this plugin will not work. Check my Settings for more info"
202 );
203 return;
204 }
205
206 },
207
208 settingsAboutComponent() {
209 const [hasVoices, hasEnglishVoices] = useMemo(() => {
210 const voices = speechSynthesis.getVoices();
211 return [voices.length !== 0, voices.some(v => v.lang.startsWith("en"))];
212 }, []);
213
214 const types = useMemo(
215 () => Object.keys(settings.def).filter(k => k.endsWith("Message")).map(k => k.slice(0, -7)),
216 [],
217 );
218
219 let errorComponent: ReactElement<any> | null = null;
220 if (!hasVoices) {
221 let error = "No narrator voices found. ";
222 error += IS_LINUX
223 ? "Install speech-dispatcher or espeak and run Discord with the --enable-speech-dispatcher flag"
224 : "Try installing some in the Narrator settings of your Operating System";
225 errorComponent = <ErrorCard>{error}</ErrorCard>;
226 } else if (!hasEnglishVoices) {
227 errorComponent = <ErrorCard>You don&#039;t have any English voices installed, so the narrator might sound weird</ErrorCard>;
228 }
229
230 return (
231 <section>
232 <Forms.FormText>
233 You can customise the spoken messages below. You can disable specific messages by setting them to nothing
234 </Forms.FormText>
235 <Forms.FormText>
236 The special placeholders <code>{"{{USER}}"}</code>, <code>{"{{DISPLAY_NAME}}"}</code>, <code>{"{{NICKNAME}}"}</code> and <code>{"{{CHANNEL}}"}</code>{" "}
237 will be replaced with the user&#039;s name (nothing if it&#039;s yourself), the user&#039;s display name, the user&#039;s nickname on current server and the channel&#039;s name respectively
238 </Forms.FormText>
239 {hasEnglishVoices && (
240 <>
241 <Forms.FormTitle className={Margins.top20} tag="h3">Play Example Sounds</Forms.FormTitle>
242 <div
243 style={{
244 display: "grid",
245 gridTemplateColumns: "repeat(4, 1fr)",
246 gap: "1rem",
247 }}
248 className={"vc-narrator-buttons"}
249 >
250 {types.map(t => (
251 <Button key={t} onClick={() => playSample(t)}>
252 {wordsToTitle([t])}
253 </Button>
254 ))}
255 </div>
256 </>
257 )}
258 {errorComponent}
259 </section>
260 );
261 }
262});
263