Equicord/src/equicordplugins/vcNarratorCustom/index.tsx
2026-03-03 19:11:05 -05:00

1124 lines
41 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { Button } from "@components/Button";
import { Divider } from "@components/Divider";
import ErrorBoundary from "@components/ErrorBoundary";
import { HeadingPrimary, HeadingSecondary } from "@components/Heading";
import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { Margins } from "@utils/margins";
import {
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalProps,
ModalRoot,
ModalSize,
openModal,
} from "@utils/modal";
import { wordsToTitle } from "@utils/text";
import definePlugin, { OptionType } from "@utils/types";
import type { User } from "@vencord/discord-types";
import {
Button as DiscordButton,
ChannelStore,
Forms,
GuildMemberStore,
IconUtils,
Menu,
React,
SearchableSelect,
SelectedChannelStore,
SelectedGuildStore,
useMemo,
UserStore,
VoiceStateStore,
} from "@webpack/common";
import {
addUserToStateChangeFilterList,
clean,
clearTtsCache,
formatText,
getCachedVoiceFromDB,
getPersistentTtsCacheStats,
getVoiceForUser,
parseStateChangeFilterList,
parseUserVoiceMap,
removeUserFromStateChangeFilterList,
removeUserVoiceFromMap,
setCachedVoiceInDB,
ttsCache,
upsertUserVoiceMap,
VOICE_OPTIONS,
} from "./util";
const cl = classNameFactory("vc-narrator-");
/*
* TTS API maintained by example-git
* The original TikTok TTS API went offline, so I set up a new working cloudflare worker.
* I made sure it's intentionally rate-limited to keep it feasible. Please don't abuse it
* so it can stay available for plugins like this one. Thanks! - example-git
*/
const API_BASE = "https://tiktok-tts-aio.exampleuser.workers.dev";
type LastApiCallStatus = {
at: number;
message: string;
status?: number;
};
const apiStatusListeners = new Set<(status: LastApiCallStatus) => void>();
let lastApiCallStatus: LastApiCallStatus = { at: 0, message: "No API calls yet" };
function setLastApiCallStatus(next: LastApiCallStatus) {
lastApiCallStatus = next;
for (const listener of apiStatusListeners) listener(next);
}
function recordApiResponse(response: Response) {
setLastApiCallStatus({
at: Date.now(),
status: response.status,
message: response.ok ? `Success (${response.status})` : `HTTP ${response.status}`,
});
}
function recordApiError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setLastApiCallStatus({
at: Date.now(),
message: message ? `Network error: ${message}` : "Network error",
});
}
function TroubleshootingSettings() {
const [cacheStats, setCacheStats] = React.useState<{ bytes: number; entries: number; } | null>(null);
const [cacheBusy, setCacheBusy] = React.useState(false);
const [apiStatus, setApiStatus] = React.useState<LastApiCallStatus>(lastApiCallStatus);
const refreshCacheStats = React.useCallback(async () => {
try {
setCacheStats(await getPersistentTtsCacheStats());
} catch {
setCacheStats(null);
}
}, []);
React.useEffect(() => {
refreshCacheStats();
}, [refreshCacheStats]);
React.useEffect(() => {
apiStatusListeners.add(setApiStatus);
return () => void apiStatusListeners.delete(setApiStatus);
}, []);
const onClearCache = React.useCallback(async () => {
setCacheBusy(true);
try {
await clearTtsCache();
} finally {
setCacheBusy(false);
}
await refreshCacheStats();
}, [refreshCacheStats]);
const formatBytes = (bytes: number) => {
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(mb >= 10 ? 0 : 1)} MB`;
};
const lastApiAt = apiStatus.at ? new Date(apiStatus.at).toLocaleTimeString() : "—";
const apiStatusClass = apiStatus.status
? apiStatus.status >= 200 && apiStatus.status < 300
? cl("api-status-success")
: cl("api-status-error")
: cl("api-status-muted");
return (
<div className={cl("troubleshooting")}>
<Divider className={cl("divider")} />
<Forms.FormTitle tag="h3" className={cl("title")}>Troubleshooting</Forms.FormTitle>
<div className={cl("row")}>
<div className={cl("buttons")}>
<Button variant="dangerPrimary" size="small" onClick={onClearCache} disabled={cacheBusy}>
Clear cache
</Button>
<Button variant="secondary" size="xs" onClick={refreshCacheStats} disabled={cacheBusy}>
Refresh size
</Button>
</div>
<Forms.FormText className={cl("cache-text")}>
Cache: {cacheStats ? `${formatBytes(cacheStats.bytes)} • ${cacheStats.entries} entries` : "Unknown"}
</Forms.FormText>
</div>
<Forms.FormText className={cl("api-text")}>
<span>Last API call: </span>
<span className={apiStatusClass}>{apiStatus.message}</span>
<span className={cl("api-time")}> • {lastApiAt}</span>
</Forms.FormText>
</div>
);
}
interface VoiceState {
userId: string;
channelId?: string;
oldChannelId?: string;
deaf: boolean;
mute: boolean;
selfDeaf: boolean;
selfMute: boolean;
selfStream?: boolean;
stream?: boolean;
}
// Two-queue system for TTS playback:
// - mainQueue: Non-interruptable messages (user names, join/leave announcements)
// - stateQueue: Interruptable messages (mute/deafen/stream state changes)
// This allows rapid state changes to interrupt each other while preserving important announcements
interface QueueItem {
text: string;
userId?: string;
interruptKey?: string;
useDefaultVoice?: boolean;
}
const mainQueue: QueueItem[] = [];
const stateQueue: QueueItem[] = [];
let isSpeaking = false;
let onQueueChange: (() => void) | null = null;
let currentAudio: HTMLAudioElement | null = null;
let currentInterruptKey: string | undefined;
let currentStop: (() => void) | null = null;
// Pre-cache common state action phrases on plugin start for faster playback
// Uses phonetic spellings for muted/deafened to ensure clear pronunciation across all voices
const COMMON_ACTIONS = ["myooted", "un-myooted", "deafind", "un-deafind", "started streaming", "stopped streaming"];
const DEFAULT_VOICE = "en_us_001";
let preCacheInitialized = false;
async function preCacheCommonActions() {
if (preCacheInitialized) return;
preCacheInitialized = true;
// Initial delay before starting pre-cache to not impact startup
await new Promise(r => setTimeout(r, 3000));
// Pre-fetch common action words in DEFAULT voice (universal for all users)
for (const action of COMMON_ACTIONS) {
const cacheKey = `${DEFAULT_VOICE}_${action}`;
// 1. Check in-memory cache first
if (ttsCache.has(cacheKey)) continue;
// 2. Check persistent IndexedDB cache - load into memory if found
try {
const cachedBlob = await getCachedVoiceFromDB(cacheKey);
if (cachedBlob) {
ttsCache.set(cacheKey, URL.createObjectURL(cachedBlob));
continue;
}
} catch { /* ignore */ }
// 3. Fetch from API and store in both memory and persistent DB
try {
const response = await fetch(`${API_BASE}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: action, voice: DEFAULT_VOICE, base64: true }),
});
recordApiResponse(response);
if (response.ok) {
const audioData = atob((await response.text()).trim());
const binaryData = new Uint8Array(audioData.length);
for (let i = 0; i < audioData.length; i++) {
binaryData[i] = audioData.charCodeAt(i);
}
const blob = new Blob([binaryData], { type: "audio/mpeg" });
// Store in memory cache
ttsCache.set(cacheKey, URL.createObjectURL(blob));
// Persist to IndexedDB for future sessions
await setCachedVoiceInDB(cacheKey, blob);
}
} catch (e) {
recordApiError(e);
/* ignore pre-cache failures */
}
// Space out API requests to avoid rate limiting (2 seconds between each)
await new Promise(r => setTimeout(r, 2000));
}
}
function isQueueBusy() {
return isSpeaking || mainQueue.length > 0 || stateQueue.length > 0;
}
function interruptPlayback(key: string) {
if (currentInterruptKey !== key || !currentStop) return;
currentAudio?.pause();
currentStop();
}
async function processQueue() {
if (isSpeaking) return;
// Optimization: if main queue has intro and state queue has action with no other items,
// and they're for the same user, combine them into single API call
if (mainQueue.length === 1 && stateQueue.length === 1 &&
mainQueue[0].userId === stateQueue[0].userId &&
!mainQueue[0].interruptKey) {
const intro = mainQueue.shift()!;
const action = stateQueue.shift()!;
const combined: QueueItem = {
text: `${intro.text} ${action.text}`,
userId: intro.userId,
interruptKey: action.interruptKey, // Keep action's interrupt key so rapid switches can interrupt
useDefaultVoice: false, // Combined uses user voice for the intro part
};
mainQueue.push(combined);
}
const item = mainQueue.shift() ?? stateQueue.shift();
if (!item) return;
isSpeaking = true;
onQueueChange?.();
try {
await speak(item.text, item.userId, item.interruptKey, item.useDefaultVoice);
} catch (e) {
console.error("TTS Error:", e);
}
// Delay between messages - longer for state changes to allow interruption
const delay = item.interruptKey ? 800 : 500;
setTimeout(() => {
isSpeaking = false;
onQueueChange?.();
processQueue();
}, delay);
}
function queueSpeak(text: string, userId?: string, interruptKey?: string, queue: "main" | "state" = "main", useDefaultVoice?: boolean) {
if (text.trim().length === 0) return;
// Anti-spam: cap queue size
const targetQueue = queue === "state" ? stateQueue : mainQueue;
if (targetQueue.length >= 5) {
// If queue is full, drop new to stop the spam wave
return;
}
if (interruptKey) {
for (let i = targetQueue.length - 1; i >= 0; i--) {
if (targetQueue[i].interruptKey === interruptKey) {
targetQueue.splice(i, 1);
}
}
interruptPlayback(interruptKey);
}
targetQueue.push({ text, userId, interruptKey, useDefaultVoice });
onQueueChange?.();
processQueue();
}
async function speak(text: string, userId?: string, interruptKey?: string, useDefaultVoice?: boolean): Promise<void> {
return new Promise(resolve => {
const onEnd = () => {
if (currentStop === onEnd) {
currentAudio = null;
currentInterruptKey = undefined;
currentStop = null;
}
resolve();
};
// Helper to play audio and resolve promise when done
const playAudio = (url: string) => {
const audio = new Audio(url);
audio.volume = settings.store.volume;
audio.playbackRate = settings.store.rate;
audio.onended = onEnd;
audio.onerror = onEnd; // Resolve even on error to unblock queue
currentAudio = audio;
currentInterruptKey = interruptKey;
currentStop = onEnd;
audio.play().catch(onEnd);
};
void (async () => {
// Use default voice for universal actions, otherwise user-specific voice
const voice = useDefaultVoice
? DEFAULT_VOICE
: getVoiceForUser(userId, {
userVoiceMap: settings.store.userVoiceMap,
customVoice: settings.store.customVoice,
defaultVoice: DEFAULT_VOICE,
});
// Create a unique cache key using the voice and text.
const cacheKey = `${voice}_${text}`;
// 1. Check the in-memory cache (fast check)
if (ttsCache.has(cacheKey)) {
playAudio(ttsCache.get(cacheKey)!);
return;
}
// 2. Check the persistent IndexedDB cache.
try {
const cachedBlob = await getCachedVoiceFromDB(cacheKey);
if (cachedBlob) {
const url = URL.createObjectURL(cachedBlob);
ttsCache.set(cacheKey, url);
playAudio(url);
return;
}
} catch (err) {
console.error("Error accessing IndexedDB:", err);
}
// 3. Fetch from API
try {
const response = await fetch(`${API_BASE}/api/generate`, {
method: "POST",
mode: "cors",
cache: "no-cache",
headers: { "Content-Type": "application/json" },
referrerPolicy: "no-referrer",
body: JSON.stringify({
text: text,
voice: voice,
base64: true,
}),
});
recordApiResponse(response);
if (!response.ok) {
console.error(`TTS failed: ${response.status}`);
resolve(); // Skip this message
return;
}
const audioData = atob((await response.text()).trim());
const binaryData = new Uint8Array(audioData.length);
for (let i = 0; i < audioData.length; i++) {
binaryData[i] = audioData.charCodeAt(i);
}
const blob = new Blob([binaryData], { type: "audio/mpeg" });
const url = URL.createObjectURL(blob);
ttsCache.set(cacheKey, url);
setCachedVoiceInDB(cacheKey, blob).catch(console.error);
playAudio(url);
} catch (e) {
recordApiError(e);
console.error("TTS Network Error:", e);
resolve();
}
})().catch(onEnd);
});
}
// 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;
type NormalizedVoiceState = {
muted: boolean;
deaf: boolean;
streaming: boolean;
};
let trackedChannelId: string | null = null;
let baselineReady = false;
let baselineUpdateInProgress = false;
const voiceStateSnapshot = new Map<string, NormalizedVoiceState>();
const lastAnnounced = new Map<string, number>();
const lastIntroSpokenAt = new Map<string, number>();
const INTRO_REUSE_MS = 2000;
function normalizeState(state: VoiceState): NormalizedVoiceState {
return {
muted: !!(state.mute || state.selfMute),
deaf: !!(state.deaf || state.selfDeaf),
streaming: !!((state as any).selfStream || (state as any).stream),
};
}
function shouldAnnounce(key: string): boolean {
const cd = settings.store.stateChangeCooldownMs ?? 0;
if (cd <= 0) return true;
const now = Date.now();
const last = lastAnnounced.get(key) ?? 0;
if (now - last < cd) return false;
lastAnnounced.set(key, now);
return true;
}
type StateActionType = "stream" | "mute" | "deaf";
/**
* Builds the intro and action segments for state change announcements.
*
* Format: "{USERNAME}" + "{ACTION}"
* Examples:
* - "JohnDoe" + "myooted" / "un-myooted" / "deafind" / "un-deafind"
* - "JohnDoe" + "started streaming" / "stopped streaming"
*
* Uses phonetic spellings for muted/deafened to ensure clear pronunciation.
*/
function buildStateSegments(
type: StateActionType,
isOn: boolean,
preferredName: string,
isSelf: boolean
): { intro: string; action: string; } {
const name = clean(preferredName, settings.store.latinOnly) || (isSelf ? "You" : "Someone");
if (type === "stream") {
return {
intro: name,
action: isOn ? "started streaming" : "stopped streaming",
};
}
// Mute and deaf - no verb, just name + phonetic action
const action = type === "mute"
? (isOn ? "myooted" : "un-myooted")
: (isOn ? "deafind" : "un-deafind");
return {
intro: name,
action,
};
}
/**
* Queues a state change announcement as two parts:
* - Intro (username) goes to mainQueue - won't be interrupted
* - Action (state) goes to stateQueue - can be interrupted by rapid state changes
*
* Uses a shared interrupt key per user so rapid toggles (mute->unmute->mute)
* only announce the final state.
*/
function queueStateSplitAnnouncement(
userId: string,
intro: string,
actionText: string
) {
const safeIntro = intro.trim();
const shouldQueueIntro = safeIntro && (Date.now() - (lastIntroSpokenAt.get(userId) ?? 0) > INTRO_REUSE_MS);
if (shouldQueueIntro) {
lastIntroSpokenAt.set(userId, Date.now());
}
// Handle interrupt key for action - remove any pending actions for this user
const interruptKey = `${userId}:state:action`;
for (let i = stateQueue.length - 1; i >= 0; i--) {
if (stateQueue[i].interruptKey === interruptKey) {
stateQueue.splice(i, 1);
}
}
interruptPlayback(interruptKey);
// Add both items to queues BEFORE processing
if (shouldQueueIntro) {
mainQueue.push({ text: safeIntro, userId, interruptKey: undefined, useDefaultVoice: false });
}
stateQueue.push({ text: actionText, userId, interruptKey, useDefaultVoice: false });
onQueueChange?.();
processQueue();
}
async function refreshBaseline(channelId: string) {
if (baselineUpdateInProgress) return;
baselineUpdateInProgress = true;
trackedChannelId = channelId;
baselineReady = false;
for (let i = 0; i < 15; i++) {
const states = VoiceStateStore.getVoiceStatesForChannel?.(channelId);
if (states) {
voiceStateSnapshot.clear();
for (const s of Object.values(states) as any[]) {
if (!s?.userId) continue;
voiceStateSnapshot.set(s.userId, normalizeState(s as VoiceState));
}
baselineReady = true;
break;
}
await new Promise(r => setTimeout(r, 200));
}
baselineUpdateInProgress = false;
}
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];
}
return ["", ""];
}
function playSample(tempSettings: any, type: string) {
const settingsobj = Object.assign(
{},
settings.store,
tempSettings
);
const currentUser = UserStore.getCurrentUser();
const myGuildId = SelectedGuildStore.getGuildId();
queueSpeak(
formatText(
settingsobj[type + "Message"],
currentUser.username,
"general",
(currentUser as any).globalName ?? currentUser.username,
(myGuildId ? GuildMemberStore.getNick(myGuildId, currentUser.id) : null) ?? currentUser.username,
settingsobj.latinOnly
)
);
}
const settings = definePluginSettings({
customVoice: {
type: OptionType.SELECT,
description: "Narrator voice",
options: VOICE_OPTIONS,
default: "en_us_001",
},
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,
},
sayOwnName: {
description: "Say own name",
type: OptionType.BOOLEAN,
default: false,
},
ignoreSelf: {
description: "Ignore yourself for all events.",
type: OptionType.BOOLEAN,
default: false,
},
latinOnly: {
description:
"Strip non latin characters from names before saying them",
type: OptionType.BOOLEAN,
default: false,
},
joinMessage: {
type: OptionType.STRING,
description: "Placeholders: {{USER}}, {{DISPLAY_NAME}}, {{NICKNAME}}, {{CHANNEL}}",
default: "{{DISPLAY_NAME}} joined",
},
leaveMessage: {
type: OptionType.STRING,
description: "Leave Message",
default: "{{DISPLAY_NAME}} left",
},
moveMessage: {
type: OptionType.STRING,
description: "Move Message",
default: "{{DISPLAY_NAME}} moved to {{CHANNEL}}",
},
announceOthersMute: {
description: "Announce when other users mute/unmute in your current VC",
type: OptionType.BOOLEAN,
default: false,
},
announceOthersDeafen: {
description: "Announce when other users deafen/undeafen in your current VC",
type: OptionType.BOOLEAN,
default: false,
},
announceOthersStream: {
description: "Announce when other users start/stop streaming in your current VC",
type: OptionType.BOOLEAN,
default: false,
},
announceSelfStream: {
description: "Announce when you start/stop streaming",
type: OptionType.BOOLEAN,
default: false,
},
stateChangeCooldownMs: {
description: "State-change announce cooldown (ms)",
type: OptionType.SLIDER,
default: 1500,
markers: [0, 250, 500, 1000, 1500, 2500, 5000, 10000],
stickToMarkers: true,
},
userVoiceMap: {
type: OptionType.STRING,
description: "Per-user voice overrides (format: userId:voiceId,userId2:voiceId2). Right-click users to set.",
default: "",
},
stateChangeFilterMode: {
type: OptionType.SELECT,
description: "Filter which users trigger state-change announcements",
options: [
{ label: "Off", value: "off" },
{ label: "Whitelist (only announce listed users)", value: "whitelist" },
{ label: "Blacklist (announce everyone except listed)", value: "blacklist" },
],
default: "off",
},
stateChangeFilterList: {
type: OptionType.STRING,
description: "Comma-separated user IDs for whitelist/blacklist. Right-click users to add/remove.",
default: "",
},
troubleshooting: {
type: OptionType.COMPONENT,
component: () => <TroubleshootingSettings />,
},
});
interface UserContextProps {
user: User;
}
// Voice selection modal component
function VoiceSelectModal({ modalProps, user }: { modalProps: ModalProps; user: User; }) {
const DEFAULT_VALUE = "__default__";
const options = useMemo(() => {
return [
{ label: `Default (${settings.store.customVoice})`, value: DEFAULT_VALUE },
...VOICE_OPTIONS.map(v => ({ label: v.label, value: v.value })),
];
}, [settings.store.customVoice]);
const [currentValue, setCurrentValue] = React.useState<string>(DEFAULT_VALUE);
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
const map = parseUserVoiceMap(settings.store.userVoiceMap ?? "");
setCurrentValue(map.get(user.id) ?? DEFAULT_VALUE);
}, [user.id, settings.store.userVoiceMap]);
// Get the display name for preview
const displayName = (user as any).globalName ?? user.username;
// Preview function that speaks the user's name in the selected voice
const previewVoice = async (text: string) => {
setBusy(true);
const voice = currentValue === DEFAULT_VALUE
? (settings.store.customVoice ?? "en_us_001")
: currentValue;
try {
const response = await fetch(`${API_BASE}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, voice, base64: true }),
});
recordApiResponse(response);
if (response.ok) {
const audioData = atob((await response.text()).trim());
const binaryData = new Uint8Array(audioData.length);
for (let i = 0; i < audioData.length; i++) {
binaryData[i] = audioData.charCodeAt(i);
}
const blob = new Blob([binaryData], { type: "audio/mpeg" });
const audio = new Audio(URL.createObjectURL(blob));
audio.volume = settings.store.volume ?? 1;
audio.playbackRate = settings.store.rate ?? 1;
audio.onended = () => setBusy(false);
audio.onerror = () => setBusy(false);
await audio.play();
return;
}
} catch (e) {
recordApiError(e);
console.error("Preview error:", e);
}
setBusy(false);
};
return (
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
<ModalHeader>
<HeadingPrimary className={cl("modal-title")}>VC Narrator Voice</HeadingPrimary>
<ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader>
<ModalContent>
<section className={Margins.bottom16}>
<HeadingSecondary>Select voice for {user.username}</HeadingSecondary>
<SearchableSelect
options={options}
value={options.find(o => o.value === currentValue)?.value}
placeholder="Select a voice"
maxVisibleItems={6}
closeOnSelect={true}
onChange={v => setCurrentValue(v as any)}
/>
{/* Preview buttons */}
<Forms.FormText className={cl("preview-hint")}>
Preview how this voice sounds with their name:
</Forms.FormText>
<div className={cl("preview-buttons")}>
<Button
variant="secondary"
size="small"
disabled={busy}
onClick={() => previewVoice(clean(displayName, settings.store.latinOnly) || "Someone")}
>
{busy ? "Playing..." : `"${clean(displayName, settings.store.latinOnly) || "Someone"}"`}
</Button>
<Button
variant="secondary"
size="small"
disabled={busy}
onClick={() => previewVoice(`${clean(displayName, settings.store.latinOnly) || "Someone"} joined`)}
>
Joined
</Button>
<Button
variant="secondary"
size="small"
disabled={busy}
onClick={() => previewVoice(`${clean(displayName, settings.store.latinOnly) || "Someone"} left`)}
>
Left
</Button>
</div>
</section>
</ModalContent>
<ModalFooter>
<div className={cl("modal-footer")}>
<DiscordButton
color={DiscordButton.Colors.PRIMARY}
onClick={modalProps.onClose}
>
Cancel
</DiscordButton>
<DiscordButton
color={DiscordButton.Colors.BRAND}
onClick={() => {
if (currentValue === DEFAULT_VALUE) {
settings.store.userVoiceMap = removeUserVoiceFromMap(settings.store.userVoiceMap, user.id);
} else {
settings.store.userVoiceMap = upsertUserVoiceMap(settings.store.userVoiceMap, user.id, currentValue);
}
modalProps.onClose();
}}
>
Save
</DiscordButton>
</div>
</ModalFooter>
</ModalRoot>
);
}
function openVoiceSelectModal(user: User) {
openModal(modalProps => (
<ErrorBoundary>
<VoiceSelectModal modalProps={modalProps} user={user} />
</ErrorBoundary>
));
}
// Context menu to assign voice to user
const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: UserContextProps) => {
if (!user) return;
const map = parseUserVoiceMap(settings.store.userVoiceMap);
const currentVoice = map.get(user.id);
const voiceLabel = currentVoice
? VOICE_OPTIONS.find(v => v.value === currentVoice)?.label ?? currentVoice
: "Default";
const filterMode = settings.store.stateChangeFilterMode ?? "off";
const filterSet = parseStateChangeFilterList(settings.store.stateChangeFilterList);
const inFilter = filterSet.has(user.id);
const filterEnabled = filterMode === "whitelist" || filterMode === "blacklist";
const filterLabel =
filterMode === "whitelist"
? inFilter ? "Remove from whitelist" : "Add to whitelist"
: filterMode === "blacklist"
? inFilter ? "Remove from blacklist" : "Add to blacklist"
: "State-change filter (enable in settings)";
const filterAction = !filterEnabled
? undefined
: () => {
if (inFilter) {
settings.store.stateChangeFilterList = removeUserFromStateChangeFilterList(settings.store.stateChangeFilterList, user.id);
} else {
settings.store.stateChangeFilterList = addUserToStateChangeFilterList(settings.store.stateChangeFilterList, user.id);
}
};
children.push(
<Menu.MenuItem
id="vc-narrator-submenu"
label="VC Narrator"
>
<Menu.MenuItem
key="voice"
id="vc-narrator-voice"
label={`Voice: ${voiceLabel}`}
action={() => openVoiceSelectModal(user)}
/>
<Menu.MenuItem
key="filter"
id="vc-narrator-state-filter"
label={filterLabel}
disabled={!filterEnabled}
action={filterAction}
/>
</Menu.MenuItem>
);
};
export default definePlugin({
name: "VcNarratorCustom",
description: "Announces when users join, leave, or move voice channels via narrator using TikTok TTS. Revamped and back from the dead.",
authors: [Devs.Ven, Devs.Nyako, EquicordDevs.Loukios, EquicordDevs.examplegit],
settings,
contextMenus: {
"user-context": UserContextMenuPatch,
"user-profile-actions": UserContextMenuPatch
},
start() {
// Pre-cache common action words in background for faster state change announcements
preCacheCommonActions();
},
flux: {
async VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
const myId = UserStore.getCurrentUser().id;
const myGuildId = SelectedGuildStore.getGuildId();
const myVoiceState = VoiceStateStore.getVoiceStateForUser(myId) as VoiceState | undefined;
let myChanId = myVoiceState?.channelId;
if (!myChanId) {
const selectedChanId = SelectedChannelStore.getVoiceChannelId();
if (selectedChanId) {
const states = VoiceStateStore.getVoiceStatesForChannel?.(selectedChanId) as Record<string, VoiceState> | undefined;
if (states?.[myId]?.channelId === selectedChanId) {
myChanId = selectedChanId;
}
}
}
const filterMode = settings.store.stateChangeFilterMode ?? "off";
const filterList = parseStateChangeFilterList(settings.store.stateChangeFilterList);
const allowStateChange = (targetId: string, isSelf: boolean) => {
if (isSelf) return true;
if (filterMode === "off") return true;
const inList = filterList.has(targetId);
return filterMode === "whitelist" ? inList : !inList;
};
if (myChanId && ChannelStore.getChannel(myChanId)?.type === 13 /* Stage Channel */) return;
if (!myChanId) {
trackedChannelId = null;
baselineReady = false;
voiceStateSnapshot.clear();
} else if (trackedChannelId !== myChanId) {
await refreshBaseline(myChanId);
}
const isBatchUpdate = voiceStates.length > 1 && voiceStates.some(s => !("oldChannelId" in (s as any)));
if (isBatchUpdate && myChanId) {
// Guild-open / bulk refresh: update baseline silently to avoid spam.
await refreshBaseline(myChanId);
return;
}
for (const state of voiceStates) {
const { userId, channelId } = state;
let { oldChannelId } = state;
const isMe = userId === myId;
if (!isMe && !myChanId) continue;
if (isMe && channelId !== myLastChannelId) {
oldChannelId = myLastChannelId;
myLastChannelId = channelId ?? undefined;
}
const affectsMyChannel = channelId === myChanId || oldChannelId === myChanId;
if (!isMe && !affectsMyChannel) continue;
// Keep snapshots in sync for join/leave/move without announcing state changes.
if (myChanId) {
if (oldChannelId !== myChanId && channelId === myChanId) {
voiceStateSnapshot.set(userId, normalizeState(state));
} else if (oldChannelId === myChanId && channelId !== myChanId) {
voiceStateSnapshot.delete(userId);
}
}
// Join/leave/move announcements (existing behavior)
const [type, id] = getTypeAndChannelId({ ...state, oldChannelId }, isMe);
if (type) {
const template = settings.store[type + "Message"];
const u = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username;
const displayName = u && ((UserStore.getUser(userId) as any).globalName ?? u);
const nickname = u && ((myGuildId ? GuildMemberStore.getNick(myGuildId, userId) : null) ?? displayName);
const channel = ChannelStore.getChannel(id)?.name ?? "channel";
queueSpeak(formatText(template, u, channel, displayName, nickname, settings.store.latinOnly), userId);
if (isMe && (type === "join" || type === "move") && id) {
await refreshBaseline(id);
}
continue;
}
// State-change announcements (mute/deafen/stream), only when user is in our current VC.
if (channelId !== myChanId) continue;
if (!baselineReady) continue;
const prev = voiceStateSnapshot.get(userId);
const next = normalizeState(state);
voiceStateSnapshot.set(userId, next);
if (!prev) continue;
if (!allowStateChange(userId, isMe)) continue;
const userObj = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username;
const displayName = userObj && ((UserStore.getUser(userId) as any).globalName ?? userObj);
const nickname = userObj && ((myGuildId ? GuildMemberStore.getNick(myGuildId, userId) : null) ?? displayName);
const preferredName = nickname || displayName || userObj || (isMe ? "You" : "Someone");
if (prev.streaming !== next.streaming && (isMe ? settings.store.announceSelfStream : settings.store.announceOthersStream)) {
const key = `${userId}:stream`;
if (shouldAnnounce(key)) {
const seg = buildStateSegments("stream", next.streaming, preferredName, isMe);
queueStateSplitAnnouncement(userId, seg.intro, seg.action);
}
}
// Deafen takes priority over mute (you're always muted when deafened)
// Only announce mute if deaf state didn't change
if (!isMe && settings.store.announceOthersDeafen && prev.deaf !== next.deaf) {
const key = `${userId}:deaf`;
if (shouldAnnounce(key)) {
const seg = buildStateSegments("deaf", next.deaf, preferredName, isMe);
queueStateSplitAnnouncement(userId, seg.intro, seg.action);
}
} else if (!isMe && settings.store.announceOthersMute && prev.muted !== next.muted) {
const key = `${userId}:mute`;
if (shouldAnnounce(key)) {
const seg = buildStateSegments("mute", next.muted, preferredName, isMe);
queueStateSplitAnnouncement(userId, seg.intro, seg.action);
}
}
}
},
},
settingsAboutComponent({ tempSettings: s }: { tempSettings?: any; }) {
const allTypes = ["mute", "unmute", "deafen", "undeafen", "streamStart", "streamStop", "join", "leave", "move"];
const [busy, setBusy] = React.useState(isQueueBusy());
React.useEffect(() => {
onQueueChange = () => setBusy(isQueueBusy());
return () => { onQueueChange = null; };
}, []);
const authorUser = UserStore.getUser(String(EquicordDevs.examplegit.id));
const authorAvatar = authorUser ? IconUtils.getUserAvatarURL(authorUser, false, 64) : null;
return (
<>
{/* Author note - pinned at top */}
<div className={cl("author-note")}>
{authorAvatar && (
<img
src={authorAvatar}
alt="Author avatar"
className={cl("author-avatar")}
/>
)}
<div className={cl("author-content")}>
<Forms.FormText className={cl("author-title")}>
Note from example-git
</Forms.FormText>
<Forms.FormText className={cl("author-text")}>
Old TikTok-TTS API died, so I set up a new cloudflare worker. It's rate-limited and stricter than the old one by design — please don't abuse it so it can stay available for plugins like this.
</Forms.FormText>
</div>
</div>
{/* Preview sounds section */}
<div className={cl("preview-section")}>
<Forms.FormTitle tag="h3" className={cl("preview-title")}>
Preview Sounds {busy && "(playing...)"}
</Forms.FormTitle>
<Forms.FormText className={cl("preview-subtitle")}>
Uses your selected narrator voice
</Forms.FormText>
<div className={cl("preview-grid")}>
{allTypes.map(t => (
<Button
key={t}
variant="secondary"
size="small"
disabled={busy}
onClick={() => playSample(s, t)}
>
{wordsToTitle([t])}
</Button>
))}
</div>
</div>
</>
);
},
});