All checks were successful
Build and Release Sanctum / Build App (push) Successful in 1m55s
570 lines
20 KiB
JavaScript
570 lines
20 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 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;
|
|
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 publishVoiceState() {
|
|
const overlayApi = window.native?.overlay;
|
|
if (!overlayApi || typeof overlayApi.setVoiceState !== "function") return;
|
|
|
|
pruneRowActivity();
|
|
const next = collectVoiceState();
|
|
const voiceState = inVoice ? next : null;
|
|
const nextKey = JSON.stringify(voiceState);
|
|
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();
|
|
})();
|