Plugin

VcNarrator

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

Voice Accessibility
index.tsx
Download

Source

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