sanctum/avia_core/VCSounds.js
MiTHRAL 8a9d621456
Some checks failed
Build and Release Sanctum / Build App (push) Has been cancelled
v1.0.6
2026-05-05 20:48:42 -04:00

605 lines
21 KiB
JavaScript

(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();
})();