(function () { if (window.__VC_SOUNDS__) return; window.__VC_SOUNDS__ = true; const ctx = new (window.AudioContext || window.webkitAudioContext)(); document.addEventListener( "click", () => { if (ctx.state === "suspended") ctx.resume(); }, { once: true }, ); function playNote(freq, startTime, duration, volume) { const osc1 = ctx.createOscillator(); const gain1 = ctx.createGain(); osc1.type = "sine"; osc1.frequency.value = freq; osc1.connect(gain1); gain1.connect(ctx.destination); const osc2 = ctx.createOscillator(); const gain2 = ctx.createGain(); osc2.type = "triangle"; osc2.frequency.value = freq / 2; osc2.connect(gain2); gain2.connect(ctx.destination); gain1.gain.setValueAtTime(0, startTime); gain1.gain.linearRampToValueAtTime(volume, startTime + 0.03); gain1.gain.exponentialRampToValueAtTime(0.001, startTime + duration); gain2.gain.setValueAtTime(0, startTime); gain2.gain.linearRampToValueAtTime(volume * 0.4, startTime + 0.03); gain2.gain.exponentialRampToValueAtTime(0.001, startTime + duration); osc1.start(startTime); osc1.stop(startTime + duration + 0.05); osc2.start(startTime); osc2.stop(startTime + duration + 0.05); } function playJoin() { if (ctx.state === "suspended") ctx.resume(); const t = ctx.currentTime + 0.01; playNote(294, t, 0.35, 0.14); playNote(370, t + 0.28, 0.45, 0.11); } function playLeave() { if (ctx.state === "suspended") ctx.resume(); const t = ctx.currentTime + 0.01; playNote(370, t, 0.35, 0.14); playNote(294, t + 0.28, 0.45, 0.11); } let inVoice = false; let initialising = false; let initTimer = null; let globalObserver = null; let refreshTimer = null; let lastVoiceState = null; let lastMemberIdentityKey = ""; let leaveWatchdog = null; const recentRowActivity = new Map(); function onSelfJoined() { if (inVoice) return; inVoice = true; initialising = true; playJoin(); console.debug("[VCSounds] self joined"); clearTimeout(initTimer); initTimer = setTimeout(() => { initialising = false; }, 1500); clearTimeout(leaveWatchdog); leaveWatchdog = null; publishVoiceState(); } function onSelfLeft() { if (!inVoice) return; inVoice = false; initialising = false; clearTimeout(initTimer); clearTimeout(leaveWatchdog); leaveWatchdog = null; recentRowActivity.clear(); lastVoiceState = null; lastMemberIdentityKey = ""; playLeave(); console.debug("[VCSounds] self left"); const overlayApi = window.native?.overlay; if (overlayApi && typeof overlayApi.setVoiceState === "function") { overlayApi.setVoiceState(null); } } function isParticipantEntry(el) { if (el.nodeType !== 1) return false; const c = String(el.className || ""); if (!el.isConnected) return false; const rect = el.getBoundingClientRect(); if (rect.width < 24 || rect.height < 24) return false; const style = window.getComputedStyle(el); if (style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity || "1") <= 0.05) return false; return ( c.includes("p_var(--gap-sm)") && c.includes("pos_relative") && c.includes("d_flex") && c.includes("ai_center") ); } function findParticipantEntry(node) { let current = node && node.nodeType === 1 ? node : node?.parentElement || null; while (current) { if (isParticipantEntry(current)) return current; current = current.parentElement; } return null; } function markRowActivity(node) { const entry = findParticipantEntry(node); if (!entry) return; recentRowActivity.set(entry, Date.now()); } function pruneRowActivity() { const cutoff = Date.now() - 15000; for (const [entry, at] of recentRowActivity.entries()) { if (typeof at !== "number" || at < cutoff || !entry.isConnected) { recentRowActivity.delete(entry); } } } function pseudoStyleIsVisible(style) { if (!style) return false; const content = String(style.content || "").toLowerCase(); if (content === "none") return false; return ( style.display !== "none" && style.visibility !== "hidden" && parseFloat(style.opacity || "1") > 0.05 ); } function pseudoLooksActive(style) { if (!pseudoStyleIsVisible(style)) return false; const boxShadow = String(style.boxShadow || "").toLowerCase(); const outlineWidth = parseFloat(style.outlineWidth || "0"); const borderWidth = parseFloat(style.borderWidth || "0"); const filter = String(style.filter || "").toLowerCase(); const transform = String(style.transform || "").toLowerCase(); const background = String(style.backgroundColor || "").toLowerCase(); const borderColor = String(style.borderColor || "").toLowerCase(); const borderRadius = String(style.borderRadius || "").toLowerCase(); const size = Math.max(parseFloat(style.width || "0"), parseFloat(style.height || "0")); const ringish = boxShadow !== "none" || outlineWidth > 0 || borderWidth > 0 || filter !== "none" || transform !== "none"; const accentish = background.includes("rgba") || background.includes("rgb(") || borderColor.includes("rgba") || borderColor.includes("rgb("); const circular = borderRadius.includes("50%") || borderRadius.includes("999"); return Boolean(size <= 80 && circular && (ringish || accentish)); } function looksLikeVoiceActivityIndicator(node) { if (!node || node.nodeType !== 1) return false; const rect = node.getBoundingClientRect(); if (rect.width < 8 || rect.height < 8) return false; const style = window.getComputedStyle(node); if (!style || style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity || "1") <= 0.05) { return false; } const size = Math.min(rect.width, rect.height); const borderRadius = String(style.borderRadius || "").toLowerCase(); const boxShadow = String(style.boxShadow || "").toLowerCase(); const outlineWidth = parseFloat(style.outlineWidth || "0"); const borderWidth = parseFloat(style.borderWidth || "0"); const filter = String(style.filter || "").toLowerCase(); const transform = String(style.transform || "").toLowerCase(); const animation = String(style.animationName || "").toLowerCase(); const transition = String(style.transitionProperty || "").toLowerCase(); const background = String(style.backgroundColor || "").toLowerCase(); const borderColor = String(style.borderColor || "").toLowerCase(); const circular = borderRadius.includes("50%") || borderRadius.includes("999"); const ringish = boxShadow !== "none" || outlineWidth > 0 || borderWidth > 0 || filter !== "none" || transform !== "none" || animation !== "none" || transition !== "none"; const accentish = background.includes("rgba") || background.includes("rgb(") || borderColor.includes("rgba") || borderColor.includes("rgb("); if (size <= 80 && circular && (ringish || accentish)) return true; const before = window.getComputedStyle(node, "::before"); const after = window.getComputedStyle(node, "::after"); if (pseudoLooksActive(before) || pseudoLooksActive(after)) return true; return false; } function startObserver() { if (globalObserver) return; globalObserver = new MutationObserver((mutations) => { if (!inVoice || initialising) return; for (const m of mutations) { markRowActivity(m.target); for (const node of m.addedNodes) { markRowActivity(node); if (isParticipantEntry(node)) { console.debug("[VCSounds] participant joined"); playJoin(); } } for (const node of m.removedNodes) { markRowActivity(node); if (isParticipantEntry(node)) { console.debug("[VCSounds] participant left"); playLeave(); } } } publishVoiceState(); }); globalObserver.observe(document.body, { childList: true, subtree: true }); globalObserver.observe(document.body, { attributes: true, subtree: true, attributeFilter: ["class", "aria-label", "style", "data-speaking", "data-active", "data-state", "title"] }); if (!refreshTimer) { refreshTimer = setInterval(() => { if (inVoice && !initialising) publishVoiceState(); }, 700); } } function getParticipantName(entry) { const text = (entry.textContent || "").replace(/\s+/g, " ").trim(); if (!text) return "Unknown"; return text.length > 40 ? text.slice(0, 40) : text; } function isSpeakingEntry(entry) { const recentActivityAt = recentRowActivity.get(entry); const recentActivity = typeof recentActivityAt === "number" && Date.now() - recentActivityAt < 1200; const nodes = [entry, ...Array.from(entry.querySelectorAll("*"))]; for (const node of nodes) { const className = String(node.className || "").toLowerCase(); const label = String(node.getAttribute?.("aria-label") || "").toLowerCase(); const title = String(node.getAttribute?.("title") || "").toLowerCase(); const text = String(node.textContent || "").toLowerCase(); const state = String(node.getAttribute?.("data-state") || "").toLowerCase(); const active = String(node.getAttribute?.("data-active") || "").toLowerCase(); const speaking = String(node.getAttribute?.("data-speaking") || "").toLowerCase(); const style = String(node.getAttribute?.("style") || "").toLowerCase(); const attrs = typeof node.getAttributeNames === "function" ? node.getAttributeNames().map((name) => name.toLowerCase()) : []; if ( className.includes("speaking") || className.includes("voice-activity") || className.includes("active-speaker") || className.includes("active") || label.includes("speaking") || label.includes("active") || label.includes("voice activity") || title.includes("speaking") || title.includes("active") || text.includes("speaking") || text.includes("voice activity") || state === "speaking" || active === "true" || speaking === "true" || style.includes("speaking") || attrs.includes("data-speaking") || attrs.includes("data-active") || attrs.includes("data-state") ) { return true; } } if (recentActivity && nodes.some(looksLikeVoiceActivityIndicator)) return true; if (nodes.some(looksLikeVoiceActivityIndicator)) return true; return !!entry.querySelector( "[data-speaking='true'], [data-active='true'], [data-state='speaking'], [aria-label*='speaking'], [aria-label*='voice activity'], [title*='speaking'], [class*='speaking'], [class*='active-speaker'], [class*='voice-activity'], [class*='active']", ); } function extractAvatarUrl(entry) { const candidates = Array.from(entry.querySelectorAll("img, source, [style*='background-image'], [style*='background']")); for (const candidate of candidates) { if (candidate.tagName === "IMG") { const src = candidate.currentSrc || candidate.src || candidate.getAttribute("src") || ""; if (src) return src; } const style = String(candidate.getAttribute("style") || ""); const match = style.match(/url\(["']?([^"')]+)["']?\)/i); if (match && match[1]) return match[1]; } const img = entry.querySelector("img"); if (img) { const src = img.currentSrc || img.src || img.getAttribute("src") || ""; if (src) return src; } return ""; } function normalizeAvatarUrl(url) { return String(url || "") .replace(/\/original(?=$|[?#])/i, "") .replace(/[?#].*$/, "") .trim(); } function isAvatarLikeSrc(src) { const value = String(src || "").toLowerCase(); return ( value.includes("/avatars/") || value.includes("/default_avatar") || value.includes("/avatar") || value.includes("/icons/") || value.includes("avatar") ); } function isVisibleElement(el) { if (!el || el.nodeType !== 1) return false; if (!el.isConnected) return false; const rect = el.getBoundingClientRect(); if (rect.width < 8 || rect.height < 8) return false; const style = window.getComputedStyle(el); if (!style) return false; return style.display !== "none" && style.visibility !== "hidden" && parseFloat(style.opacity || "1") > 0.05; } function findAvatarRoot(img) { let current = img && img.parentElement; let depth = 0; while (current && depth < 6) { if (!isVisibleElement(current)) { current = current.parentElement; depth++; continue; } const rect = current.getBoundingClientRect(); const style = window.getComputedStyle(current); const radius = String(style.borderRadius || "").toLowerCase(); const clip = String(style.clipPath || "").toLowerCase(); const circular = radius.includes("50%") || radius.includes("999") || clip.includes("circle"); if (circular || (rect.width >= 24 && rect.height >= 24 && rect.width <= 240 && rect.height <= 240)) { return current; } current = current.parentElement; depth++; } return img?.parentElement || null; } function collectSpeakingAvatarUrls() { const speaking = new Set(); const ringTiles = Array.from(document.querySelectorAll("*")).filter((el) => { const cls = String(el.className || ""); return ( el.isConnected && cls.includes("vc_tile") && cls.includes("ring-c_var(--md-sys-color-primary)") ); }); for (const tile of ringTiles) { const imageNodes = Array.from(tile.querySelectorAll("img")).filter((img) => isVisibleElement(img)); for (const img of imageNodes) { const src = normalizeAvatarUrl(img.currentSrc || img.src || img.getAttribute("src") || ""); if (src && isAvatarLikeSrc(src)) { speaking.add(src); } } } const images = Array.from(document.querySelectorAll("img")).filter((img) => { if (!isVisibleElement(img)) return false; const src = normalizeAvatarUrl(img.currentSrc || img.src || img.getAttribute("src") || ""); return isAvatarLikeSrc(src); }); for (const img of images) { const src = normalizeAvatarUrl(img.currentSrc || img.src || img.getAttribute("src") || ""); const root = findAvatarRoot(img); if (!root) continue; const nodes = [root, ...Array.from(root.querySelectorAll("*"))].slice(0, 40); const speaks = nodes.some(looksLikeVoiceActivityIndicator) || nodes.some(isSpeakingMarkerNode); if (speaks) speaking.add(src); } return speaking; } function isSpeakingMarkerNode(node) { if (!node || node.nodeType !== 1) return false; const className = String(node.className || "").toLowerCase(); const label = String(node.getAttribute?.("aria-label") || "").toLowerCase(); const title = String(node.getAttribute?.("title") || "").toLowerCase(); const text = String(node.textContent || "").toLowerCase(); const state = String(node.getAttribute?.("data-state") || "").toLowerCase(); const active = String(node.getAttribute?.("data-active") || "").toLowerCase(); const speaking = String(node.getAttribute?.("data-speaking") || "").toLowerCase(); return Boolean( className.includes("speaking") || className.includes("active-speaker") || className.includes("voice-activity") || label.includes("speaking") || label.includes("voice activity") || title.includes("speaking") || title.includes("voice activity") || text.includes("speaking") || text.includes("voice activity") || state === "speaking" || active === "true" || speaking === "true" ); } function detectSelfFlags() { const buttons = Array.from(document.querySelectorAll("button")); const muteButton = buttons.find((button) => /unmute|mute/i.test( button.getAttribute("aria-label") || button.title || button.textContent || "", ), ); const deafenButton = buttons.find((button) => /undeafen|deafen/i.test( button.getAttribute("aria-label") || button.title || button.textContent || "", ), ); return { selfMuted: muteButton ? /unmute/i.test( muteButton.getAttribute("aria-label") || muteButton.title || muteButton.textContent || "", ) : undefined, selfDeafened: deafenButton ? /undeafen/i.test( deafenButton.getAttribute("aria-label") || deafenButton.title || deafenButton.textContent || "", ) : undefined, }; } function collectVoiceState() { const speakingAvatars = collectSpeakingAvatarUrls(); const members = Array.from(document.querySelectorAll("*")) .filter(isParticipantEntry) .slice(0, 10) .map((entry) => ({ name: getParticipantName(entry), speaking: isSpeakingEntry(entry), avatarUrl: normalizeAvatarUrl(extractAvatarUrl(entry)) || undefined, })) .map((member) => ({ ...member, speaking: member.speaking || (member.avatarUrl ? speakingAvatars.has(normalizeAvatarUrl(member.avatarUrl)) : false), })); const selfFlags = detectSelfFlags(); return { channelName: "Voice call", // The join/leave hook is authoritative for whether the overlay should show. // The DOM scan is only used to enrich the overlay with members while in call. isInCall: inVoice, members, selfMuted: selfFlags.selfMuted, selfDeafened: selfFlags.selfDeafened, source: "voice DOM", }; } function memberIdentityKey(members) { return members .map((member) => [ String(member?.name || "").trim().toLowerCase(), String(member?.avatarUrl || "").trim().toLowerCase(), ].join(":"), ) .sort() .join("|"); } function publishVoiceState() { const overlayApi = window.native?.overlay; if (!overlayApi || typeof overlayApi.setVoiceState !== "function") return; pruneRowActivity(); const next = collectVoiceState(); const voiceState = inVoice ? next : null; const nextMemberKey = memberIdentityKey(next.members); const nextKey = JSON.stringify(voiceState); const memberChanged = nextMemberKey !== lastMemberIdentityKey; if (memberChanged && inVoice && !initialising && lastMemberIdentityKey) { const previousMembers = new Set( lastMemberIdentityKey .split("|") .map((entry) => entry.trim()) .filter(Boolean), ); const currentMembers = new Set( nextMemberKey .split("|") .map((entry) => entry.trim()) .filter(Boolean), ); const added = [...currentMembers].some((entry) => !previousMembers.has(entry)); const removed = [...previousMembers].some((entry) => !currentMembers.has(entry)); if (added) playJoin(); if (removed) playLeave(); } lastMemberIdentityKey = nextMemberKey; if (nextKey === lastVoiceState) return; lastVoiceState = nextKey; if (inVoice && next.members.length === 0) { if (!leaveWatchdog) { leaveWatchdog = setTimeout(() => { leaveWatchdog = null; if (!inVoice) return; const stillEmpty = collectVoiceState().members.length === 0; if (stillEmpty) { overlayApi.setVoiceState(null); onSelfLeft(); } }, 2200); } } else { clearTimeout(leaveWatchdog); leaveWatchdog = null; } overlayApi.setVoiceState(voiceState); } const originalFetch = window.fetch; window.fetch = async function (...args) { const url = typeof args[0] === "string" ? args[0] : args[0]?.url ?? ""; const response = await originalFetch.apply(this, args); if (url.includes("/join_call") && response.ok) { setTimeout(onSelfJoined, 300); } if (/(leave_call|leave-?call|disconnect|close_call)/i.test(url) && response.ok) { setTimeout(onSelfLeft, 150); } return response; }; const OriginalWebSocket = window.WebSocket; window.WebSocket = function (url, protocols) { const ws = protocols ? new OriginalWebSocket(url, protocols) : new OriginalWebSocket(url); if (typeof url === "string" && url.includes("/livekit/rtc")) { ws.addEventListener("close", () => onSelfLeft()); } return ws; }; Object.assign(window.WebSocket, OriginalWebSocket); window.WebSocket.prototype = OriginalWebSocket.prototype; startObserver(); publishVoiceState(); })();