Plugin
VcNarrator
Announces when users join, leave, or move voice channels via narrator
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { ErrorCard } from "@components/ErrorCard";8
import { Devs, IS_LINUX } from "@utils/constants";9
import { Logger } from "@utils/Logger";10
import { Margins } from "@utils/margins";11
import { wordsToTitle } from "@utils/text";12
import definePlugin, { ReporterTestable } from "@utils/types";13
import { AuthenticationStore, Button, ChannelStore, Forms, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, useMemo, UserStore, VoiceStateStore } from "@webpack/common";14
import { ReactElement } from "react";15
16
import { getCurrentVoice, settings } from "./settings";17
18
interface 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 annoying30
// Filtering out events is not as simple as just dropping duplicates, as otherwise mute, unmute, mute would31
// not say the second mute, which would lead you to believe they're unmuted32
33
function speak(text: string) {34
// Don't narrate in the overlay window, otherwise everything is said twice35
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
47
function clean(str: string) {48
const replacer = settings.store.latinOnly49
? /[^\p{Script=Latin}\p{Number}\p{Punctuation}\s]/gu50
: /[^\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
58
function formatText(str: string, user: string, channel: string, displayName: string, nickname: string) {59
return str60
.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
/*67
let 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 reason76
let myLastChannelId: string | undefined;77
78
function 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
/*101
function 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.selfDeaf110
};111
}112
return;113
}114
115
if (type === "leave" || (type === "move" && channelId !== SelectedChannelStore.getVoiceChannelId())) {116
if (isMe)117
StatusMap = {};118
else119
delete StatusMap[userId];120
121
return;122
}123
124
StatusMap[userId] = {125
deaf: deaf || selfDeaf,126
mute: mute || selfMute127
};128
}129
*/130
131
function 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.username141
));142
}143
144
export 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_LINUX229
? "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 don039;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 nothing240
</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 user039;s name (nothing if it039;s yourself), the user039;s display name, the user039;s nickname on current server and the channel039;s name respectively244
</Forms.FormText>245
{hasEnglishVoices && (246
<>247
<Forms.FormTitle className={Margins.top20} tag="h3">Play Example Sounds</Forms.FormTitle>248
<div249
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