diff --git a/src/equicordplugins/tiktokTTS/index.ts b/src/equicordplugins/tiktokTTS/index.ts deleted file mode 100644 index 0e2ddf9f4..000000000 --- a/src/equicordplugins/tiktokTTS/index.ts +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2025 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { playAudio } from "@api/AudioPlayer"; -import { NavContextMenuPatchCallback } from "@api/ContextMenu"; -import { definePluginSettings } from "@api/Settings"; -import { EquicordDevs } from "@utils/constants"; -import definePlugin, { OptionType } from "@utils/types"; -import { Menu, React } from "@webpack/common"; - -// Same worker used by vcNarratorCustom; returns raw base64 audio when base64=true. -const API_BASE = "https://tiktok-tts-aio.exampleuser.workers.dev"; - -const TEXT_CHUNK_LIMIT_BYTES = 300; -const TEXT_TOTAL_LIMIT_BYTES = 2000; -const MAX_CHUNKS = 10; -const encoder = new TextEncoder(); - -const settings = definePluginSettings({ - volume: { - type: OptionType.SLIDER, - description: "Playback volume", - default: 50, - markers: [0, 25, 50, 75, 100], - stickToMarkers: false, - }, -}); - -function extractBase64(body: string): string | null { - try { - const parsed = JSON.parse(body); - if (parsed && typeof parsed.data === "string") return parsed.data; // legacy JSON shape - } catch { - /* not JSON, fall through */ - } - const trimmed = body.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -async function readOutTextChunk(text: string): Promise { - const payload = { text, voice: "en_us_001", base64: true }; - const volume = Math.max(0, Math.min(100, settings.store.volume ?? 50)); - - const res = await fetch(`${API_BASE}/api/generate`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - - if (!res.ok) { - console.error("TiktokTTS: API request failed", res.status, res.statusText); - return; - } - - const body = await res.text(); - const base64 = extractBase64(body); - if (!base64) { - console.error("TiktokTTS: missing base64 audio in response"); - return; - } - - await new Promise(resolve => { - let resolved = false; - const done = () => { - if (resolved) return; - resolved = true; - resolve(); - }; - - const player = playAudio(`data:audio/mpeg;base64,${base64}`, { - volume, - onEnded: done, - onError: () => done(), - }); - - // Safety valve: if onEnded doesn't fire, resolve after a duration-based timeout. - // tried a fixed 15s timeout during testing, fixed timeout can cause overlapped audio with the chunking. - let timeoutHandle = setTimeout(done, 30000); - void (async () => { - try { - const duration = await player.duration; - if (typeof duration === "number" && Number.isFinite(duration) && duration > 0) { - clearTimeout(timeoutHandle); - // Add a small buffer; cap to avoid hanging forever. - const ms = Math.max(15000, Math.min(120000, Math.ceil((duration + 1) * 1000))); - timeoutHandle = setTimeout(done, ms); - } - } catch { - // keep default timeout - } - })(); - }); -} - -function sliceByBytes(text: string, limit: number): string[] { - const parts: string[] = []; - let start = 0; - while (start < text.length) { - let end = text.length; - // Shrink until byte length fits the limit. - while (end > start) { - const candidate = text.slice(start, end); - if (encoder.encode(candidate).length <= limit) { - parts.push(candidate); - start = end; - break; - } - end--; - } - if (end === start) break; // safety - } - return parts; -} - -function chunkTextByBytes(text: string): string[] { - const totalBytes = encoder.encode(text).length; - if (totalBytes === 0) return []; - if (totalBytes > TEXT_TOTAL_LIMIT_BYTES) { - console.warn("TiktokTTS: text exceeds 2000-byte limit; truncating to fit"); - text = new TextDecoder().decode(encoder.encode(text).slice(0, TEXT_TOTAL_LIMIT_BYTES)); - } - - const chunks: string[] = []; - const words = text.split(/\s+/).filter(Boolean); - let current = ""; - - const pushChunk = (chunk: string) => { - if (!chunk) return; - if (chunks.length >= MAX_CHUNKS) return; - chunks.push(chunk); - }; - - const flush = () => { - const trimmed = current.trim(); - if (!trimmed) { - current = ""; - return; - } - pushChunk(trimmed); - current = ""; - }; - - for (const word of words) { - const candidate = current ? `${current} ${word}` : word; - if (encoder.encode(candidate).length <= TEXT_CHUNK_LIMIT_BYTES) { - current = candidate; - continue; - } - - // Current + word would overflow; flush current first. - flush(); - - // If the single word itself is too big, hard-split it by bytes. - if (encoder.encode(word).length > TEXT_CHUNK_LIMIT_BYTES) { - for (const part of sliceByBytes(word, TEXT_CHUNK_LIMIT_BYTES)) { - pushChunk(part); - if (chunks.length >= MAX_CHUNKS) break; - } - current = ""; - if (chunks.length >= MAX_CHUNKS) break; - continue; - } - - current = word; - if (chunks.length >= MAX_CHUNKS) break; - } - - flush(); - return chunks; -} - -const jobQueue: string[] = []; -let processing = false; - -const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }) => { - if (!message?.content || message.content.length === 0) return; - - // Put it in the bottom section of the context menu. - if (children.some(c => c?.props?.id === "tiktok-tts-play")) return; - - children.push(React.createElement(Menu.MenuItem, { - id: "tiktok-tts-play", - label: "Play TikTok TTS", - action: () => enqueueRead(message.content), - })); -}; - -function enqueueRead(text: string) { - jobQueue.push(text); - void processQueue(); -} - -async function processQueue() { - if (processing) return; - processing = true; - while (jobQueue.length > 0) { - const nextMessage = jobQueue.shift(); - if (!nextMessage) break; - - for (const chunk of chunkTextByBytes(nextMessage)) { - await readOutTextChunk(chunk); - await new Promise(res => setTimeout(res, 120)); // shorter pacing between clips - } - } - processing = false; -} - -export default definePlugin({ - name: "TiktokTTS", - description: "Adds a context menu option to read out chat messages with the good ol' Tiktok TTS voice :sob:", - tags: ["Accessibility", "Chat", "Voice"], - authors: [EquicordDevs.VillainsRule, EquicordDevs.examplegit], - dependencies: ["AudioPlayerAPI"], - settings, - contextMenus: { - "message": messageCtxPatch, - }, -}); diff --git a/src/equicordplugins/vcNarratorCustom/index.tsx b/src/equicordplugins/vcNarratorCustom/index.tsx deleted file mode 100644 index 0d96cac38..000000000 --- a/src/equicordplugins/vcNarratorCustom/index.tsx +++ /dev/null @@ -1,1267 +0,0 @@ -/* - * 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 { HeadingSecondary } from "@components/Heading"; -import { Devs, EquicordDevs } from "@utils/constants"; -import { classNameFactory } from "@utils/css"; -import { Margins } from "@utils/margins"; -import { useForceUpdater } from "@utils/react"; -import { wordsToTitle } from "@utils/text"; -import definePlugin, { OptionType } from "@utils/types"; -import { RenderModalProps, type User } from "@vencord/discord-types"; -import { ChannelType } from "@vencord/discord-types/enums"; -import { - ChannelStore, - Forms, - GuildMemberStore, - IconUtils, - Menu, - Modal, - openModal, - React, - SearchableSelect, - SelectedChannelStore, - SelectedGuildStore, - showToast, - useMemo, - UserStore, - VoiceStateStore, -} from "@webpack/common"; - -import { - addUserToList, - addUserToStateChangeFilterList, - clean, - clearTtsCache, - formatText, - getCachedVoiceFromDB, - getPersistentTtsCacheStats, - getVoiceForUser, - parseStateChangeFilterList, - parseUserIdList, - parseUserVoiceMap, - removeUserFromList, - removeUserFromStateChangeFilterList, - removeUserVoiceFromMap, - setCachedVoiceInDB, - ttsCache, - upsertUserVoiceMap, - VOICE_OPTIONS, -} from "./util"; - -const cl = classNameFactory("vc-narrator-"); - -const API_BASE = "https://tiktok-tts-aio.exampleuser.workers.dev"; -const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "m4a", "aac", "flac", "wma"]; -const SOUND_PLACEHOLDER = "{{SOUND}}"; - -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); - - 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 ( -
- - - Troubleshooting - -
-
- - - -
- - - Cache: {cacheStats ? `${formatBytes(cacheStats.bytes)} • ${cacheStats.entries} entries` : "Unknown"} - -
- - - Last API call: - {apiStatus.message} - • {lastApiAt} - -
- ); -} - -function CustomSoundSettings({ soundKey, nameKey }: { soundKey: string; nameKey: string; }) { - const fileInputRef = React.useRef(null); - const [busy, setBusy] = React.useState(false); - const forceUpdate = useForceUpdater(); - const customSound = settings.store[soundKey]; - const customSoundName = settings.store[nameKey] ?? ""; - const hasCustomSound = typeof customSound === "string" && customSound.startsWith("data:audio/"); - - const uploadSound = async (event: React.ChangeEvent) => { - const file = event.currentTarget.files?.[0]; - if (!file) return; - - const extension = file.name.split(".").pop()?.toLowerCase(); - if (!extension || !AUDIO_EXTENSIONS.includes(extension)) { - showToast("Please choose an audio file."); - event.currentTarget.value = ""; - return; - } - - setBusy(true); - try { - const dataUri = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(String(reader.result ?? "")); - reader.onerror = reject; - reader.readAsDataURL(file); - }); - - if (!dataUri.startsWith("data:audio/")) { - showToast("Please choose an audio file."); - return; - } - - settings.store[soundKey] = dataUri; - settings.store[nameKey] = file.name; - forceUpdate(); - showToast("Custom sound saved."); - } catch { - showToast("Could not load that audio file."); - } finally { - event.currentTarget.value = ""; - setBusy(false); - } - }; - - const previewSound = () => { - if (!hasCustomSound) return; - - setBusy(true); - const audio = new Audio(customSound); - audio.volume = settings.store.volume; - audio.onended = () => setBusy(false); - audio.onerror = () => setBusy(false); - audio.play().catch(() => setBusy(false)); - }; - - return ( -
- - {hasCustomSound ? `Selected: ${customSoundName || "Custom audio"}` : "No custom sound selected."} - - -
- - - -
-
- ); -} - -interface VoiceState { - userId: string; - channelId?: string; - oldChannelId?: string; - deaf: boolean; - mute: boolean; - selfDeaf: boolean; - selfMute: boolean; - selfStream?: boolean; - stream?: boolean; -} - -interface QueueItem { - text: string; - userId?: string; - interruptKey?: string; - useDefaultVoice?: boolean; - sound?: string; -} -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; - -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; - - await new Promise(r => setTimeout(r, 3000)); - - for (const action of COMMON_ACTIONS) { - const cacheKey = `${DEFAULT_VOICE}_${action}`; - - if (ttsCache.has(cacheKey)) continue; - - try { - const cachedBlob = await getCachedVoiceFromDB(cacheKey); - if (cachedBlob) { - ttsCache.set(cacheKey, URL.createObjectURL(cachedBlob)); - continue; - } - } catch { } - - 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" }); - - ttsCache.set(cacheKey, URL.createObjectURL(blob)); - - await setCachedVoiceInDB(cacheKey, blob); - } - } catch (e) { - recordApiError(e); - } - - 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; - - 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, - useDefaultVoice: false, - sound: intro.sound ?? action.sound, - }; - 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, item.sound); - } catch (e) { - console.error("TTS Error:", e); - } - - 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, sound?: string) { - if (text.trim().length === 0) return; - - const targetQueue = queue === "state" ? stateQueue : mainQueue; - if (targetQueue.length >= 5) { - 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, sound }); - onQueueChange?.(); - processQueue(); -} - -async function speak(text: string, userId?: string, interruptKey?: string, useDefaultVoice?: boolean, sound?: string): Promise { - return new Promise(resolve => { - const playAudio = (url: string, playbackRate: number) => new Promise(resolveAudio => { - const audio = new Audio(url); - let settled = false; - - const finish = (completed: boolean) => { - if (settled) return; - settled = true; - if (currentStop === stop) { - currentAudio = null; - currentInterruptKey = undefined; - currentStop = null; - } - resolveAudio(completed); - }; - - const stop = () => finish(false); - - audio.volume = settings.store.volume; - audio.playbackRate = playbackRate; - audio.onended = () => finish(true); - audio.onerror = stop; - currentAudio = audio; - currentInterruptKey = interruptKey; - currentStop = stop; - audio.play().catch(stop); - }); - - const getTtsUrl = async (speechText: string, voice: string) => { - const cacheKey = `${voice}_${speechText}`; - - if (ttsCache.has(cacheKey)) { - return ttsCache.get(cacheKey)!; - } - - try { - const cachedBlob = await getCachedVoiceFromDB(cacheKey); - if (cachedBlob) { - const url = URL.createObjectURL(cachedBlob); - ttsCache.set(cacheKey, url); - return url; - } - } catch (err) { - console.error("Error accessing IndexedDB:", err); - } - - 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: speechText, - voice: voice, - base64: true, - }), - }); - recordApiResponse(response); - - if (!response.ok) { - console.error(`TTS failed: ${response.status}`); - return null; - } - - 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); - - return url; - } catch (e) { - recordApiError(e); - console.error("TTS Network Error:", e); - return null; - } - }; - - const playSpeech = async (speechText: string, voice: string) => { - const trimmedText = speechText.trim(); - if (!trimmedText) return true; - - const url = await getTtsUrl(trimmedText, voice); - return !url || await playAudio(url, settings.store.rate); - }; - - void (async () => { - const voice = useDefaultVoice - ? DEFAULT_VOICE - : getVoiceForUser(userId, { - userVoiceMap: settings.store.userVoiceMap, - customVoice: settings.store.customVoice, - defaultVoice: DEFAULT_VOICE, - }); - - const soundUrl = typeof sound === "string" && sound.startsWith("data:audio/") ? sound : ""; - const soundIndex = text.indexOf(SOUND_PLACEHOLDER); - - if (!soundUrl || soundIndex === -1) { - await playSpeech(text.replaceAll(SOUND_PLACEHOLDER, ""), voice); - resolve(); - return; - } - - const beforeSound = text.slice(0, soundIndex); - const afterSound = text.slice(soundIndex + SOUND_PLACEHOLDER.length).replaceAll(SOUND_PLACEHOLDER, ""); - - if (await playSpeech(beforeSound, voice)) { - const soundCompleted = await playAudio(soundUrl, 1); - if (soundCompleted) await playSpeech(afterSound, voice); - } - - resolve(); - })().catch(() => resolve()); - }); -} - -let myLastChannelId: string | undefined; - -type NormalizedVoiceState = { - muted: boolean; - deaf: boolean; - streaming: boolean; -}; - -const lastJoinLeaveTimestamp = new Map(); -let trackedChannelId: string | null = null; -let baselineReady = false; -let baselineUpdateInProgress = false; -const voiceStateSnapshot = new Map(); -const lastAnnounced = new Map(); -const lastIntroSpokenAt = new Map(); -const INTRO_REUSE_MS = 2000; - -function normalizeState(state: VoiceState): NormalizedVoiceState { - return { - muted: !!(state.mute || state.selfMute), - deaf: !!(state.deaf || state.selfDeaf), - streaming: !!(state.selfStream || state.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"; - -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", - }; - } - - const action = type === "mute" - ? (isOn ? "myooted" : "un-myooted") - : (isOn ? "deafind" : "un-deafind"); - - return { - intro: name, - action, - }; -} - -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()); - } - - 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); - - 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.globalName ?? currentUser.username, - (myGuildId ? GuildMemberStore.getNick(myGuildId, currentUser.id) : null) ?? currentUser.username, - settingsobj.latinOnly - ), - undefined, - undefined, - "main", - undefined, - settingsobj[type + "Sound"] - ); -} - -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}}, {{SOUND}}.", - default: "{{DISPLAY_NAME}} joined", - }, - joinSound: { - type: OptionType.COMPONENT, - component: () => , - default: "", - }, - joinSoundName: { - type: OptionType.STRING, - description: "Join sound file name.", - default: "", - hidden: true, - }, - leaveMessage: { - type: OptionType.STRING, - description: "Placeholders: {{USER}}, {{DISPLAY_NAME}}, {{NICKNAME}}, {{CHANNEL}}, {{SOUND}}.", - default: "{{DISPLAY_NAME}} left", - }, - leaveSound: { - type: OptionType.COMPONENT, - component: () => , - default: "", - }, - leaveSoundName: { - type: OptionType.STRING, - description: "Leave sound file name.", - default: "", - hidden: true, - }, - moveMessage: { - type: OptionType.STRING, - description: "Placeholders: {{USER}}, {{DISPLAY_NAME}}, {{NICKNAME}}, {{CHANNEL}}, {{SOUND}}.", - default: "{{DISPLAY_NAME}} moved to {{CHANNEL}}", - }, - moveSound: { - type: OptionType.COMPONENT, - component: () => , - default: "", - }, - moveSoundName: { - type: OptionType.STRING, - description: "Move sound file name.", - default: "", - hidden: true, - }, - 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: "", - }, - ignoredUsers: { - type: OptionType.STRING, - description: "Comma-separated user IDs to completely ignore (no join/leave/move/state announcements). Right-click users to add/remove.", - default: "", - }, - joinLeaveTimeout: { - type: OptionType.SLIDER, - description: "Per-user cooldown for join/leave/move announcements (seconds). Prevents spam from rapid rejoiners.", - default: 0, - markers: [0, 5, 10, 15, 30, 60], - stickToMarkers: false, - }, - troubleshooting: { - type: OptionType.COMPONENT, - component: () => , - }, -}); - -interface UserContextProps { - user: User; -} - -function VoiceSelectModal({ modalProps, user }: { modalProps: RenderModalProps; 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(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]); - - const displayName = user.globalName ?? user.username; - - 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 ( - { - 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(); - } - } - ]} - > -
- Select voice for {user.username} - o.value === currentValue)?.value} - placeholder="Select a voice" - maxVisibleItems={6} - closeOnSelect={true} - onChange={v => setCurrentValue(v as any)} - /> - - - Preview how this voice sounds with their name: - -
- - - -
-
-
- ); -} - -function openVoiceSelectModal(user: User) { - openModal(modalProps => ( - - - - )); -} - -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 ignoredSet = parseUserIdList(settings.store.ignoredUsers); - const isIgnored = ignoredSet.has(user.id); - - 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( - - openVoiceSelectModal(user)} - /> - { - if (isIgnored) { - settings.store.ignoredUsers = removeUserFromList(settings.store.ignoredUsers, user.id); - } else { - settings.store.ignoredUsers = addUserToList(settings.store.ignoredUsers, user.id); - } - }} - /> - - - ); -}; - -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.", - tags: ["Accessibility", "Voice"], - authors: [Devs.Ven, Devs.Nyako, EquicordDevs.Loukios, EquicordDevs.examplegit, EquicordDevs.qdnx], - settings, - contextMenus: { - "user-context": UserContextMenuPatch, - "user-profile-actions": UserContextMenuPatch - }, - - start() { - 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 | 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 === ChannelType.GUILD_STAGE_VOICE) 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) { - await refreshBaseline(myChanId); - return; - } - - const ignoredUsers = parseUserIdList(settings.store.ignoredUsers); - - 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; - - if (myChanId) { - if (oldChannelId !== myChanId && channelId === myChanId) { - voiceStateSnapshot.set(userId, normalizeState(state)); - } else if (oldChannelId === myChanId && channelId !== myChanId) { - voiceStateSnapshot.delete(userId); - } - } - - const isIgnored = ignoredUsers.has(userId); - const shouldSkipAnnouncement = isIgnored || (isMe && settings.store.ignoreSelf); - - const [type, id] = getTypeAndChannelId({ ...state, oldChannelId }, isMe); - if (type) { - if (!shouldSkipAnnouncement) { - const timeout = settings.store.joinLeaveTimeout ?? 0; - let throttled = false; - if (!isMe && timeout > 0) { - const now = Date.now(); - const last = lastJoinLeaveTimestamp.get(userId) ?? 0; - if (now - last < timeout * 1000) { - throttled = true; - } else { - lastJoinLeaveTimestamp.set(userId, now); - } - } - - if (!throttled) { - const template = settings.store[type + "Message"]; - const u = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username; - const displayName = u && (UserStore.getUser(userId).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, - undefined, - "main", - undefined, - settings.store[type + "Sound"] - ); - } - } - - if (isMe && (type === "join" || type === "move") && id) { - await refreshBaseline(id); - } - - continue; - } - - if (channelId !== myChanId) continue; - if (!baselineReady) continue; - - const prev = voiceStateSnapshot.get(userId); - const next = normalizeState(state); - voiceStateSnapshot.set(userId, next); - if (!prev) continue; - - if (shouldSkipAnnouncement) continue; - if (!allowStateChange(userId, isMe)) continue; - - const userObj = isMe && !settings.store.sayOwnName ? "" : UserStore.getUser(userId).username; - const displayName = userObj && (UserStore.getUser(userId).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); - } - } - - 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 ( - <> -
- {authorAvatar && ( - Author avatar - )} -
- - Note from example-git - - - 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. - -
-
- -
- - Preview Sounds {busy && "(playing...)"} - - - Uses your selected narrator voice - -
- {allTypes.map(t => ( - - ))} -
-
- - ); - }, -}); diff --git a/src/equicordplugins/vcNarratorCustom/styles.css b/src/equicordplugins/vcNarratorCustom/styles.css deleted file mode 100644 index 32767303f..000000000 --- a/src/equicordplugins/vcNarratorCustom/styles.css +++ /dev/null @@ -1,155 +0,0 @@ -/* Troubleshooting section */ -.vc-narrator-troubleshooting { - display: flex; - flex-direction: column; - gap: 10px; -} - -.vc-narrator-divider { - margin: 24px 0 12px; -} - -.vc-narrator-title { - margin-bottom: 4px; -} - -.vc-narrator-row { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} - -.vc-narrator-buttons { - display: flex; - align-items: center; - gap: 8px; -} - -.vc-narrator-cache-text { - margin: 0; - opacity: 0.85; - white-space: nowrap; -} - -.vc-narrator-api-text { - margin: 0; - opacity: 0.9; -} - -.vc-narrator-api-time { - opacity: 0.75; -} - -.vc-narrator-api-status-success { - color: var(--text-positive); -} - -.vc-narrator-api-status-error { - color: var(--text-danger); -} - -.vc-narrator-api-status-muted { - color: var(--text-muted); -} - -.vc-narrator-custom-sound { - display: flex; - flex-direction: column; - gap: 10px; -} - -.vc-narrator-custom-sound-text { - margin: 0; - opacity: 0.85; -} - -.vc-narrator-file-input { - display: none; -} - -/* Voice select modal */ -.vc-narrator-modal-title { - flex-grow: 1; -} - -.vc-narrator-preview-hint { - font-size: 12px; - opacity: 0.7; - margin-top: 12px; - margin-bottom: 8px; - text-align: center; -} - -.vc-narrator-preview-buttons { - display: flex; - gap: 8px; - flex-wrap: wrap; - justify-content: center; -} - -.vc-narrator-modal-footer { - display: flex; - justify-content: center; - gap: 8px; - width: 100%; -} - -/* Settings about component */ -.vc-narrator-author-note { - padding: 10px 12px; - background: var(--background-secondary-alt); - border-radius: 8px; - border: 1px solid var(--background-tertiary); - border-left: 3px solid var(--brand-experiment); - display: flex; - gap: 10px; - align-items: flex-start; - margin-bottom: 16px; -} - -.vc-narrator-author-avatar { - width: 24px; - height: 24px; - border-radius: 999px; - margin-top: 2px; -} - -.vc-narrator-author-content { - min-width: 0; -} - -.vc-narrator-author-title { - margin-bottom: 2px; - font-weight: 600; - font-size: 12px; -} - -.vc-narrator-author-text { - font-size: 12px; - opacity: 0.85; -} - -.vc-narrator-preview-section { - padding: 12px 16px; - background: var(--background-secondary-alt); - border-radius: 8px; - border: 1px solid var(--background-tertiary); -} - -.vc-narrator-preview-title { - margin-top: 0; -} - -.vc-narrator-preview-subtitle { - font-size: 12px; - opacity: 0.7; - margin-bottom: 12px; -} - -.vc-narrator-preview-grid { - display: flex; - flex-wrap: wrap; - gap: 6px; - justify-content: center; -} diff --git a/src/equicordplugins/vcNarratorCustom/util.ts b/src/equicordplugins/vcNarratorCustom/util.ts deleted file mode 100644 index 5d27f5e3e..000000000 --- a/src/equicordplugins/vcNarratorCustom/util.ts +++ /dev/null @@ -1,398 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -export const VOICE_OPTIONS = [ - { label: "Asian: Indonesian Female (id_001)", value: "id_001" }, - { label: "Asian: Japanese Female 1 (jp_001)", value: "jp_001" }, - { label: "Asian: Japanese Female 2 (jp_003)", value: "jp_003" }, - { label: "Asian: Japanese Female 3 (jp_005)", value: "jp_005" }, - { label: "Asian: Japanese Male (jp_006)", value: "jp_006" }, - { label: "Asian: Korean Female (kr_003)", value: "kr_003" }, - { label: "Asian: Korean Male 1 (kr_002)", value: "kr_002" }, - { label: "Asian: Korean Male 2 (kr_004)", value: "kr_004" }, - { label: "English: AU Female (en_au_001)", value: "en_au_001" }, - { label: "English: AU Male (en_au_002)", value: "en_au_002" }, - { label: "English: Funny (en_male_funny)", value: "en_male_funny" }, - { label: "English: Narrator (en_male_narration)", value: "en_male_narration" }, - { label: "English: Peaceful (en_female_emotional)", value: "en_female_emotional" }, - { label: "English: Serious (en_male_cody)", value: "en_male_cody" }, - { label: "English: UK Male 1 (en_uk_001)", value: "en_uk_001" }, - { label: "English: UK Male 2 (en_uk_003)", value: "en_uk_003" }, - { label: "English: US Female 1 (en_us_001)", value: "en_us_001" }, - { label: "English: US Female 2 (en_us_002)", value: "en_us_002" }, - { label: "English: US Male 1 (en_us_006)", value: "en_us_006" }, - { label: "English: US Male 2 (en_us_007)", value: "en_us_007" }, - { label: "English: US Male 3 (en_us_009)", value: "en_us_009" }, - { label: "English: US Male 4 (en_us_010)", value: "en_us_010" }, - { label: "European: French Male 1 (fr_001)", value: "fr_001" }, - { label: "European: French Male 2 (fr_002)", value: "fr_002" }, - { label: "European: German Female (de_001)", value: "de_001" }, - { label: "European: German Male (de_002)", value: "de_002" }, - { label: "European: Italian Male (it_male_m18)", value: "it_male_m18" }, - { label: "European: Spanish Male (es_002)", value: "es_002" }, - { label: "Fun: C3PO (en_us_c3po)", value: "en_us_c3po" }, - { label: "Fun: Chewbacca (en_us_chewbacca)", value: "en_us_chewbacca" }, - { label: "Fun: Ghost Face (en_us_ghostface)", value: "en_us_ghostface" }, - { label: "Fun: Ghost Host (en_male_ghosthost)", value: "en_male_ghosthost" }, - { label: "Fun: Madame Leota (en_female_madam_leota)", value: "en_female_madam_leota" }, - { label: "Fun: Pirate (en_male_pirate)", value: "en_male_pirate" }, - { label: "Fun: Rocket (en_us_rocket)", value: "en_us_rocket" }, - { label: "Fun: Stitch (en_us_stitch)", value: "en_us_stitch" }, - { label: "Fun: Stormtrooper (en_us_stormtrooper)", value: "en_us_stormtrooper" }, - { label: "Latin American: Portuguese BR Female 1 (br_001)", value: "br_001" }, - { label: "Latin American: Portuguese BR Female 2 (br_003)", value: "br_003" }, - { label: "Latin American: Portuguese BR Female 3 (br_004)", value: "br_004" }, - { label: "Latin American: Portuguese BR Male (br_005)", value: "br_005" }, - { label: "Latin American: Spanish MX Male (es_mx_002)", value: "es_mx_002" }, -]; - -export const ttsCache = new Map(); - -export const PERSISTENT_TTS_CACHE_MAX_BYTES = 100 * 1024 * 1024; - -export function parseUserVoiceMap(input?: string): Map { - const map = new Map(); - const trimmed = input?.trim(); - if (!trimmed) return map; - - if (trimmed.includes(":") || trimmed.includes("=")) { - for (const entry of trimmed.split(",").map(s => s.trim()).filter(Boolean)) { - const [userId, voiceId] = entry.split(/[:=]/).map(s => s.trim()); - if (userId && voiceId) map.set(userId, voiceId); - } - return map; - } - - for (const line of trimmed.split(/\n+/)) { - const [userId, voiceId] = line.split(",").map(s => s.trim()); - if (userId && voiceId) map.set(userId, voiceId); - } - - return map; -} - -export function serializeUserVoiceMap(map: Map): string { - return Array.from(map.entries()) - .map(([userId, voiceId]) => `${userId}:${voiceId}`) - .join(","); -} - -export function getVoiceForUser( - userId: string | undefined, - options: { userVoiceMap?: string; customVoice?: string; defaultVoice?: string; } -): string { - const defaultVoice = options.customVoice ?? options.defaultVoice ?? "en_us_001"; - if (!userId) return defaultVoice; - const map = parseUserVoiceMap(options.userVoiceMap ?? ""); - return map.get(userId) ?? defaultVoice; -} - -export function upsertUserVoiceMap(userVoiceMap: string | undefined, userId: string, voiceId: string): string { - const map = parseUserVoiceMap(userVoiceMap ?? ""); - map.set(userId, voiceId); - return serializeUserVoiceMap(map); -} - -export function removeUserVoiceFromMap(userVoiceMap: string | undefined, userId: string): string { - const map = parseUserVoiceMap(userVoiceMap ?? ""); - map.delete(userId); - return serializeUserVoiceMap(map); -} - -export function parseUserIdList(input?: string): Set { - const set = new Set(); - const trimmed = input?.trim(); - if (!trimmed) return set; - - for (const entry of trimmed.split(",").map(s => s.trim()).filter(Boolean)) { - set.add(entry); - } - - return set; -} - -export function serializeUserIdList(list: Set): string { - return Array.from(list).join(","); -} - -export function addUserToList(input: string | undefined, userId: string): string { - const set = parseUserIdList(input); - set.add(userId); - return serializeUserIdList(set); -} - -export function removeUserFromList(input: string | undefined, userId: string): string { - const set = parseUserIdList(input); - set.delete(userId); - return serializeUserIdList(set); -} - -export function parseStateChangeFilterList(input?: string): Set { - const set = new Set(); - const trimmed = input?.trim(); - if (!trimmed) return set; - - for (const entry of trimmed.split(",").map(s => s.trim()).filter(Boolean)) { - set.add(entry); - } - - return set; -} - -export function serializeStateChangeFilterList(list: Set): string { - return Array.from(list).join(","); -} - -export function addUserToStateChangeFilterList(input: string | undefined, userId: string): string { - const set = parseStateChangeFilterList(input); - set.add(userId); - return serializeStateChangeFilterList(set); -} - -export function removeUserFromStateChangeFilterList(input: string | undefined, userId: string): string { - const set = parseStateChangeFilterList(input); - set.delete(userId); - return serializeStateChangeFilterList(set); -} - -export function clean(str: string, latinOnly?: boolean) { - const replacer = latinOnly - ? /[^\p{Script=Latin}\p{Number}\p{Punctuation}\s]/gu - : /[^\p{Letter}\p{Number}\p{Punctuation}\s]/gu; - - return str - .normalize("NFKC") - .replace(replacer, "") - .replace(/_{2,}/g, "_") - .trim() - .slice(0, 128); -} - -export function formatText( - str: string, - user: string, - channel: string, - displayName: string, - nickname: string, - latinOnly?: boolean -) { - return str - .replaceAll("{{USER}}", clean(user, latinOnly) || (user ? "Someone" : "")) - .replaceAll("{{CHANNEL}}", clean(channel, latinOnly) || "channel") - .replaceAll( - "{{DISPLAY_NAME}}", - clean(displayName, latinOnly) || (displayName ? "Someone" : "") - ) - .replaceAll( - "{{NICKNAME}}", - clean(nickname, latinOnly) || (nickname ? "Someone" : "") - ); -} - -const DB_NAME = "VcNarratorDB"; - -const DB_VERSION = 2; - -const VOICES_STORE = "voices"; - -const META_STORE = "voices_meta"; - -type VoiceMeta = { - size: number; - createdAt: number; - lastAccess: number; -}; - -function openDB(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = event => { - const db = request.result; - const upgradeTx = request.transaction; - if (!db.objectStoreNames.contains(VOICES_STORE)) { - db.createObjectStore(VOICES_STORE); - } - if (!db.objectStoreNames.contains(META_STORE)) { - const meta = db.createObjectStore(META_STORE); - meta.createIndex("by_lastAccess", "lastAccess"); - } - - if (upgradeTx && (event?.oldVersion ?? 0) < 2) { - const now = Date.now(); - const voices = upgradeTx.objectStore(VOICES_STORE); - const meta = upgradeTx.objectStore(META_STORE); - const cursorReq = voices.openCursor(); - cursorReq.onsuccess = () => { - const cursor = cursorReq.result; - if (!cursor) return; - const blob = cursor.value as Blob | undefined; - meta.put( - { - size: blob?.size ?? 0, - createdAt: now, - lastAccess: now, - } satisfies VoiceMeta, - String(cursor.key) - ); - cursor.continue(); - }; - } - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); -} - -export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; entries: number; }> { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(META_STORE, "readonly"); - const store = tx.objectStore(META_STORE); - - let bytes = 0; - let entries = 0; - - const cursorReq = store.openCursor(); - cursorReq.onsuccess = () => { - const cursor = cursorReq.result; - if (!cursor) { - resolve({ bytes, entries }); - return; - } - const meta = cursor.value as VoiceMeta | undefined; - if (meta?.size) bytes += meta.size; - entries++; - cursor.continue(); - }; - cursorReq.onerror = () => reject(cursorReq.error); - }); -} - -export async function clearTtsCache(): Promise { - for (const url of ttsCache.values()) { - try { - URL.revokeObjectURL(url); - } catch { } - } - ttsCache.clear(); - - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite"); - tx.objectStore(VOICES_STORE).clear(); - tx.objectStore(META_STORE).clear(); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); -} - -async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX_BYTES): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite"); - const voicesStore = tx.objectStore(VOICES_STORE); - const metaStore = tx.objectStore(META_STORE); - const lastAccessIndex = metaStore.index("by_lastAccess"); - - let totalBytes = 0; - const sumReq = metaStore.openCursor(); - - const deleteOldestUntilOk = () => { - if (totalBytes <= maxBytes) return; - - const oldestReq = lastAccessIndex.openCursor(); - oldestReq.onsuccess = () => { - const cursor = oldestReq.result; - if (!cursor) return; - - const cacheKey = String(cursor.primaryKey); - const meta = cursor.value as VoiceMeta | undefined; - const metaSize = meta?.size ?? 0; - - const inMemoryUrl = ttsCache.get(cacheKey); - if (inMemoryUrl) { - try { - URL.revokeObjectURL(inMemoryUrl); - } catch { } - ttsCache.delete(cacheKey); - } - - voicesStore.delete(cacheKey); - const deleteMetaReq = metaStore.delete(cacheKey); - deleteMetaReq.onsuccess = () => { - totalBytes -= metaSize; - deleteOldestUntilOk(); - }; - deleteMetaReq.onerror = () => deleteOldestUntilOk(); - }; - }; - - sumReq.onsuccess = () => { - const cursor = sumReq.result; - if (cursor) { - const meta = cursor.value as VoiceMeta | undefined; - totalBytes += meta?.size ?? 0; - cursor.continue(); - return; - } - deleteOldestUntilOk(); - }; - sumReq.onerror = () => reject(sumReq.error); - - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); -} - -export async function getCachedVoiceFromDB(cacheKey: string): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite"); - const voicesStore = tx.objectStore(VOICES_STORE); - const metaStore = tx.objectStore(META_STORE); - const request = voicesStore.get(cacheKey); - - request.onsuccess = () => { - const blob = (request.result as Blob | undefined) ?? null; - if (blob) { - const now = Date.now(); - const metaGetReq = metaStore.get(cacheKey); - metaGetReq.onsuccess = () => { - const existing = metaGetReq.result as VoiceMeta | undefined; - const updated: VoiceMeta = { - size: existing?.size ?? blob.size, - createdAt: existing?.createdAt ?? now, - lastAccess: now, - }; - metaStore.put(updated, cacheKey); - }; - } - resolve(blob); - }; - request.onerror = () => reject(request.error); - }); -} - -export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise { - const db = await openDB(); - await new Promise((resolve, reject) => { - const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite"); - const voicesStore = tx.objectStore(VOICES_STORE); - const metaStore = tx.objectStore(META_STORE); - - const now = Date.now(); - voicesStore.put(blob, cacheKey); - metaStore.put( - { size: blob.size, createdAt: now, lastAccess: now } satisfies VoiceMeta, - cacheKey - ); - - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - - await trimPersistentCacheToMaxBytes(); -} diff --git a/src/plugins/vcNarrator/index.tsx b/src/plugins/vcNarrator/index.tsx index b6b85c941..09ad29f68 100644 --- a/src/plugins/vcNarrator/index.tsx +++ b/src/plugins/vcNarrator/index.tsx @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import { migrateSettingsFromPlugin } from "@api/Settings"; import { ErrorCard } from "@components/ErrorCard"; import { HeadingSecondary } from "@components/Heading"; import { Paragraph } from "@components/Paragraph"; @@ -140,6 +141,7 @@ function playSample(type: string) { )); } +migrateSettingsFromPlugin("VcNarrator", "VcNarratorCustom", "enabled"); export default definePlugin({ name: "VcNarrator", description: "Announces when users join, leave, or move voice channels via narrator",