Plugin
VcNarrator
Announces when users join, leave, or move voice channels via narrator
1
import { ErrorCard } from "@components/ErrorCard";2
import { Devs, IS_LINUX } from "@utils/constants";3
import { Logger } from "@utils/Logger";4
import { Margins } from "@utils/margins";5
import { wordsToTitle } from "@utils/text";6
import definePlugin, { ReporterTestable } from "@utils/types";7
import { AuthenticationStore, Button, ChannelStore, Forms, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, useMemo, UserStore, VoiceStateStore } from "@webpack/common";8
import { ReactElement } from "react";9
10
import { getCurrentVoice, settings } from "./settings";11
12
interface 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 annoying24
// Filtering out events is not as simple as just dropping duplicates, as otherwise mute, unmute, mute would25
// not say the second mute, which would lead you to believe they're unmuted26
27
function speak(text: string) {28
// Don't narrate in the overlay window, otherwise everything is said twice29
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
41
function clean(str: string) {42
const replacer = settings.store.latinOnly43
? /[^\p{Script=Latin}\p{Number}\p{Punctuation}\s]/gu44
: /[^\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
52
function formatText(str: string, user: string, channel: string, displayName: string, nickname: string) {53
return str54
.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
/*61
let 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 reason70
let myLastChannelId: string | undefined;71
72
function 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
/*95
function 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.selfDeaf104
};105
}106
return;107
}108
109
if (type === "leave" || (type === "move" && channelId !== SelectedChannelStore.getVoiceChannelId())) {110
if (isMe)111
StatusMap = {};112
else113
delete StatusMap[userId];114
115
return;116
}117
118
StatusMap[userId] = {119
deaf: deaf || selfDeaf,120
mute: mute || selfMute121
};122
}123
*/124
125
function 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.username135
));136
}137
138
export 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_LINUX223
? "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 don039;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 nothing234
</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 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 respectively238
</Forms.FormText>239
{hasEnglishVoices && (240
<>241
<Forms.FormTitle className={Margins.top20} tag="h3">Play Example Sounds</Forms.FormTitle>242
<div243
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