diff --git a/README.md b/README.md index 2779e54..d7b3350 100644 --- a/README.md +++ b/README.md @@ -71,3 +71,5 @@ pnpm run:nix --force-server=http://localhost:5173 # a better solution would be telling # Electron Forge where system Electron is ``` + +`VCSounds.js` ships as a built-in local plugin now, so it is seeded automatically on launch and cannot be accidentally removed from the release install. diff --git a/avia_core/LocalPlugins.js b/avia_core/LocalPlugins.js index 40a88c5..a298c2f 100644 --- a/avia_core/LocalPlugins.js +++ b/avia_core/LocalPlugins.js @@ -3,15 +3,63 @@ if (window.__AVIA_LOCAL_PLUGINS_LOADED__) return; window.__AVIA_LOCAL_PLUGINS_LOADED__ = true; - const STORAGE_KEY = "avia_local_plugins"; + const STORAGE_KEY = "avia_local_plugins"; + const BUILTIN_SEED = Array.isArray(window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__) + ? window.__SANCTUM_BUILTIN_LOCAL_PLUGINS__ + : []; + + const runningLocalPlugins = {}; + const localPluginErrors = {}; - const runningLocalPlugins = {}; - const localPluginErrors = {}; - - const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); - const setLocalPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); - - function preloadMonaco() { + const getLocalPlugins = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); + const setLocalPlugins = (data) => localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + + function upsertBuiltinLocalPlugins() { + if (!BUILTIN_SEED.length) return; + + const plugins = getLocalPlugins(); + let dirty = false; + + for (const builtin of BUILTIN_SEED) { + const next = { + id: builtin.id, + name: builtin.name, + code: builtin.code || "", + enabled: true, + locked: true, + builtin: true, + }; + + const existingIndex = plugins.findIndex((plugin) => + plugin.id === next.id || plugin.name === next.name + ); + + if (existingIndex >= 0) { + const current = plugins[existingIndex]; + const merged = { + ...current, + ...next, + enabled: true, + locked: true, + builtin: true, + }; + + if ( + JSON.stringify(current) !== JSON.stringify(merged) + ) { + plugins[existingIndex] = merged; + dirty = true; + } + } else { + plugins.push(next); + dirty = true; + } + } + + if (dirty) setLocalPlugins(plugins); + } + + function preloadMonaco() { return new Promise(resolve => { if (window.monaco) return resolve(); const loader = document.createElement("script"); @@ -421,11 +469,11 @@ renderLocalPanel(); } - function renderLocalPanel() { - const content = document.getElementById("avia-local-plugins-content"); - if (!content) return; - content.innerHTML = ""; - const plugins = getLocalPlugins(); + function renderLocalPanel() { + const content = document.getElementById("avia-local-plugins-content"); + if (!content) return; + content.innerHTML = ""; + const plugins = getLocalPlugins(); if (plugins.length === 0) { const empty = document.createElement("div"); @@ -436,9 +484,10 @@ return; } - plugins.forEach((plugin, index) => { - const isRunning = !!runningLocalPlugins[plugin.id]; - const hasError = !!localPluginErrors[plugin.id]; + plugins.forEach((plugin, index) => { + const isRunning = !!runningLocalPlugins[plugin.id]; + const hasError = !!localPluginErrors[plugin.id]; + const isBuiltin = !!plugin.locked || !!plugin.builtin; const row = document.createElement("div"); Object.assign(row.style, { @@ -467,74 +516,93 @@ statusDot.style.background = "#777"; } - const name = document.createElement("div"); - name.textContent = plugin.name; - name.style.fontSize = "13px"; - - left.appendChild(statusDot); - left.appendChild(name); - - const controls = document.createElement("div"); - Object.assign(controls.style, { display: "flex", gap: "6px" }); - - const editBtn = document.createElement("button"); - editBtn.textContent = "✏ Edit"; - styleLocalBtn(editBtn, "rgba(100,140,255,0.2)"); - editBtn.onclick = () => { - openEditorPanel(plugin, (newCode, andRun) => { - const all = getLocalPlugins(); - const target = all.find(p => p.id === plugin.id); - if (target) { - target.code = newCode; - plugin.code = newCode; - setLocalPlugins(all); - } - if (andRun) { - plugin.enabled = true; - if (target) target.enabled = true; - setLocalPlugins(getLocalPlugins().map(p => p.id === plugin.id ? { ...p, code: newCode, enabled: true } : p)); - runLocalPlugin(plugin); - } - renderLocalPanel(); - }); - }; - - const toggleBtn = document.createElement("button"); - toggleBtn.textContent = plugin.enabled ? "Disable" : "Enable"; - styleLocalBtn(toggleBtn); - toggleBtn.onclick = () => { - const all = getLocalPlugins(); - const target = all.find(p => p.id === plugin.id); - if (!target) return; - target.enabled = !target.enabled; - plugin.enabled = target.enabled; - setLocalPlugins(all); - if (target.enabled) runLocalPlugin(plugin); - else stopLocalPlugin(plugin); - renderLocalPanel(); - }; - - const removeBtn = document.createElement("button"); - removeBtn.textContent = "✕"; - styleLocalBtn(removeBtn, "rgba(255,80,80,0.15)"); - removeBtn.onclick = () => { - stopLocalPlugin(plugin); - const editorPanel = document.getElementById("avia-local-editor-panel"); - if (editorPanel) editorPanel.remove(); - const all = getLocalPlugins(); - all.splice(all.findIndex(p => p.id === plugin.id), 1); - setLocalPlugins(all); - renderLocalPanel(); - }; - - controls.appendChild(editBtn); - controls.appendChild(toggleBtn); - controls.appendChild(removeBtn); - row.appendChild(left); - row.appendChild(controls); - content.appendChild(row); - }); - } + const name = document.createElement("div"); + name.textContent = plugin.name; + name.style.fontSize = "13px"; + + left.appendChild(statusDot); + left.appendChild(name); + + if (isBuiltin) { + const badge = document.createElement("div"); + badge.textContent = "Built-in"; + Object.assign(badge.style, { + fontSize: "10px", + padding: "2px 7px", + borderRadius: "999px", + background: "rgba(120,170,255,0.16)", + color: "#a9c4ff", + border: "1px solid rgba(120,170,255,0.28)", + marginLeft: "2px", + textTransform: "uppercase", + letterSpacing: "0.06em", + }); + left.appendChild(badge); + } + + const controls = document.createElement("div"); + Object.assign(controls.style, { display: "flex", gap: "6px" }); + + if (!isBuiltin) { + const editBtn = document.createElement("button"); + editBtn.textContent = "✏ Edit"; + styleLocalBtn(editBtn, "rgba(100,140,255,0.2)"); + editBtn.onclick = () => { + openEditorPanel(plugin, (newCode, andRun) => { + const all = getLocalPlugins(); + const target = all.find(p => p.id === plugin.id); + if (target) { + target.code = newCode; + plugin.code = newCode; + setLocalPlugins(all); + } + if (andRun) { + plugin.enabled = true; + if (target) target.enabled = true; + setLocalPlugins(getLocalPlugins().map(p => p.id === plugin.id ? { ...p, code: newCode, enabled: true } : p)); + runLocalPlugin(plugin); + } + renderLocalPanel(); + }); + }; + + const toggleBtn = document.createElement("button"); + toggleBtn.textContent = plugin.enabled ? "Disable" : "Enable"; + styleLocalBtn(toggleBtn); + toggleBtn.onclick = () => { + const all = getLocalPlugins(); + const target = all.find(p => p.id === plugin.id); + if (!target) return; + target.enabled = !target.enabled; + plugin.enabled = target.enabled; + setLocalPlugins(all); + if (target.enabled) runLocalPlugin(plugin); + else stopLocalPlugin(plugin); + renderLocalPanel(); + }; + + const removeBtn = document.createElement("button"); + removeBtn.textContent = "✕"; + styleLocalBtn(removeBtn, "rgba(255,80,80,0.15)"); + removeBtn.onclick = () => { + stopLocalPlugin(plugin); + const editorPanel = document.getElementById("avia-local-editor-panel"); + if (editorPanel) editorPanel.remove(); + const all = getLocalPlugins(); + all.splice(all.findIndex(p => p.id === plugin.id), 1); + setLocalPlugins(all); + renderLocalPanel(); + }; + + controls.appendChild(editBtn); + controls.appendChild(toggleBtn); + controls.appendChild(removeBtn); + } + row.appendChild(left); + row.appendChild(controls); + content.appendChild(row); + }); + } function styleLocalInput(input) { Object.assign(input.style, { @@ -604,15 +672,16 @@ }).observe(document.documentElement, { childList: true }); } - waitForBody(() => { - const observer = new MutationObserver(() => injectLocalButton()); - observer.observe(document.body, { childList: true, subtree: true }); - injectLocalButton(); - }); - - getLocalPlugins().forEach(plugin => { - if (plugin.enabled) runLocalPlugin(plugin); - }); + waitForBody(() => { + const observer = new MutationObserver(() => injectLocalButton()); + observer.observe(document.body, { childList: true, subtree: true }); + injectLocalButton(); + }); + + upsertBuiltinLocalPlugins(); + getLocalPlugins().forEach(plugin => { + if (plugin.enabled) runLocalPlugin(plugin); + }); preloadMonaco(); diff --git a/avia_core/VCSounds.js b/avia_core/VCSounds.js new file mode 100644 index 0000000..32623e4 --- /dev/null +++ b/avia_core/VCSounds.js @@ -0,0 +1,570 @@ +(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(); +})(); diff --git a/avia_core/gamePresenceSettings.js b/avia_core/gamePresenceSettings.js new file mode 100644 index 0000000..0c19d68 --- /dev/null +++ b/avia_core/gamePresenceSettings.js @@ -0,0 +1,397 @@ +(function () { + if (window.__sanctumGamePresenceSettings) return; + window.__sanctumGamePresenceSettings = true; + + const CLONE_ATTR = "data-sanctum-game-presence"; + const PANEL_ATTR = "data-sanctum-game-presence-panel"; + const POPULAR_GAMES = [ + "Apex Legends", + "Among Us", + "Assassin's Creed Mirage", + "Assassin's Creed Valhalla", + "Armored Core VI: Fires of Rubicon", + "Baldur's Gate 3", + "Black Myth: Wukong", + "Brawlhalla", + "Call of Duty: Black Ops 6", + "Call of Duty: Modern Warfare III", + "Call of Duty: Warzone", + "Celeste", + "Cities: Skylines II", + "Civilization VI", + "Counter-Strike 2", + "Cuphead", + "Cyberpunk 2077", + "Dark Souls III", + "Dave the Diver", + "Days Gone", + "Dead by Daylight", + "Dead Cells", + "Deep Rock Galactic", + "Destiny 2", + "Diablo IV", + "Dota 2", + "Dragon's Dogma 2", + "Elden Ring", + "Enshrouded", + "Escape from Tarkov", + "Euro Truck Simulator 2", + "EVE Online", + "Fall Guys", + "Fallout 4", + "Fallout 76", + "Factorio", + "F1 24", + "Final Fantasy XIV", + "Forza Horizon 5", + "Fortnite", + "Genshin Impact", + "Ghost of Tsushima", + "God of War", + "Grand Theft Auto V", + "Grounded", + "Guild Wars 2", + "Hades", + "Hades II", + "Helldivers 2", + "Hogwarts Legacy", + "Hollow Knight", + "Honkai: Star Rail", + "Honkai Impact 3rd", + "Hunt: Showdown", + "It Takes Two", + "Kingdom Come: Deliverance", + "League of Legends", + "Lethal Company", + "Left 4 Dead 2", + "Last Epoch", + "Marvel Rivals", + "Minecraft", + "Monster Hunter: World", + "Monster Hunter Rise", + "Mortal Kombat 1", + "Metaphor: ReFantazio", + "No Man's Sky", + "Once Human", + "Overwatch 2", + "Palworld", + "Path of Exile", + "Path of Exile 2", + "Persona 5 Royal", + "Phasmophobia", + "PUBG: Battlegrounds", + "Paladins", + "Rainbow Six Siege", + "Red Dead Redemption 2", + "Resident Evil 4", + "Resident Evil Village", + "Rocket League", + "Rust", + "Satisfactory", + "Sea of Thieves", + "Skyrim Special Edition", + "Slay the Spire", + "Sons of the Forest", + "Spider-Man Remastered", + "Split Fiction", + "Star Citizen", + "Starfield", + "Stardew Valley", + "Street Fighter 6", + "Subnautica", + "Team Fortress 2", + "Tekken 8", + "Terraria", + "The Elder Scrolls Online", + "The Finals", + "The Last of Us Part I", + "The Witcher 3", + "Titanfall 2", + "VALORANT", + "V Rising", + "Valheim", + "Warframe", + "War Thunder", + "Wuthering Waves", + "World of Warcraft", + "World of Tanks", + "World of Warships", + "Zenless Zone Zero", + ]; + + function toggleCheckbox(elem, value) { + const checkbox = elem.querySelector("mdui-checkbox"); + if (!checkbox) return; + + if (value) { + checkbox.setAttribute("checked", ""); + checkbox.setAttribute("value", "on"); + } else { + checkbox.removeAttribute("checked"); + checkbox.setAttribute("value", "off"); + } + } + + function getConfig() { + return window.desktopConfig.get(); + } + + function setConfig(next) { + window.desktopConfig.set(next); + } + + function buildPanel() { + const panel = document.createElement("div"); + panel.setAttribute(PANEL_ATTR, "true"); + panel.style.cssText = ` + display: none; + flex-direction: column; + gap: 10px; + margin-top: 8px; + padding: 10px 12px; + border-radius: 10px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.08); + color: inherit; + `; + + const note = document.createElement("div"); + note.style.cssText = "font-size:12px; opacity:0.75; line-height:1.35;"; + note.textContent = + "Sanctum only lights up for games in its built-in catalog or names you add below."; + panel.appendChild(note); + + const allowLabel = document.createElement("div"); + allowLabel.textContent = "Allowed games / windows"; + allowLabel.style.cssText = "font-size:12px; font-weight:600;"; + panel.appendChild(allowLabel); + + const textarea = document.createElement("textarea"); + textarea.value = getConfig().gamePresenceAllowList || ""; + textarea.rows = 4; + textarea.placeholder = "Examples: Fortnite, Valorant, Counter-Strike 2, Baldur's Gate 3"; + textarea.style.cssText = ` + width: 100%; + resize: vertical; + min-height: 88px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(0,0,0,0.18); + color: inherit; + font: inherit; + line-height: 1.4; + `; + textarea.addEventListener("input", () => { + const config = getConfig(); + config.gamePresenceAllowList = textarea.value; + setConfig(config); + }); + panel.appendChild(textarea); + + const pickerLabel = document.createElement("div"); + pickerLabel.textContent = "Popular games"; + pickerLabel.style.cssText = "font-size:12px; font-weight:600; margin-top:2px;"; + panel.appendChild(pickerLabel); + + const pickerHint = document.createElement("div"); + pickerHint.textContent = "Search and add games from the built-in catalog."; + pickerHint.style.cssText = "font-size:11px; opacity:0.7; line-height:1.35;"; + panel.appendChild(pickerHint); + + const pickerRow = document.createElement("div"); + pickerRow.style.cssText = "display:flex; gap:8px; align-items:center;"; + + const pickerSearch = document.createElement("input"); + pickerSearch.type = "search"; + pickerSearch.placeholder = "Search popular games"; + pickerSearch.style.cssText = ` + flex: 1; + min-width: 0; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(0,0,0,0.18); + color: inherit; + font: inherit; + `; + + const pickerAdd = document.createElement("button"); + pickerAdd.type = "button"; + pickerAdd.textContent = "Add"; + pickerAdd.style.cssText = ` + flex-shrink: 0; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.08); + color: inherit; + font: inherit; + cursor: pointer; + `; + + pickerRow.appendChild(pickerSearch); + pickerRow.appendChild(pickerAdd); + panel.appendChild(pickerRow); + + const pickerList = document.createElement("div"); + pickerList.style.cssText = ` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 6px; + max-height: 180px; + overflow: auto; + padding-right: 2px; + `; + panel.appendChild(pickerList); + + function existingEntries() { + return textarea.value + .split(/[\n,]+/) + .map((item) => item.trim()) + .filter(Boolean); + } + + function addGameToAllowList(name) { + const current = new Set(existingEntries().map((item) => item.toLowerCase())); + if (current.has(name.toLowerCase())) return; + const next = existingEntries(); + next.push(name); + textarea.value = next.join("\n"); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + } + + function renderPicker() { + const query = pickerSearch.value.trim().toLowerCase(); + const selected = new Set(existingEntries().map((item) => item.toLowerCase())); + pickerList.innerHTML = ""; + + const matches = POPULAR_GAMES.filter((game) => !query || game.toLowerCase().includes(query)).slice(0, 40); + for (const game of matches) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = selected.has(game.toLowerCase()) ? `✓ ${game}` : game; + button.title = selected.has(game.toLowerCase()) ? "Already added" : `Add ${game}`; + button.style.cssText = ` + padding: 8px 10px; + border-radius: 10px; + border: 1px solid ${selected.has(game.toLowerCase()) ? "rgba(104, 126, 255, 0.55)" : "rgba(255,255,255,0.12)"}; + background: ${selected.has(game.toLowerCase()) ? "rgba(104, 126, 255, 0.16)" : "rgba(255,255,255,0.05)"}; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `; + button.addEventListener("click", () => addGameToAllowList(game)); + pickerList.appendChild(button); + } + } + + pickerSearch.addEventListener("input", renderPicker); + pickerAdd.addEventListener("click", () => { + const query = pickerSearch.value.trim(); + if (!query) return; + const exact = POPULAR_GAMES.find((game) => game.toLowerCase() === query.toLowerCase()); + if (exact) { + addGameToAllowList(exact); + return; + } + addGameToAllowList(query); + }); + textarea.addEventListener("input", renderPicker); + renderPicker(); + + return panel; + } + + function createButton(baseElem) { + const row = baseElem.cloneNode(true); + row.setAttribute(CLONE_ATTR, "true"); + + const title = row.querySelector("div.d_flex.flex-g_1 > div"); + const desc = row.querySelector("div.d_flex.flex-g_1 > span"); + const icon = row.querySelector("div.w_36px span.material-symbols-outlined"); + const existingIcon = row.querySelector("div.w_36px"); + + if (title) title.textContent = "Gameplay overlay"; + if (desc) desc.textContent = "Shows the mini voice overlay while you are in a game."; + if (icon) icon.textContent = "sports_esports"; + + if (existingIcon) { + existingIcon.title = "Toggle gameplay sharing settings"; + existingIcon.style.cursor = "pointer"; + } + + const settingsBtn = document.createElement("div"); + settingsBtn.title = "Edit gameplay sharing"; + settingsBtn.style.cssText = "cursor: pointer; z-index: 10; flex-shrink: 0; margin-left: 6px;"; + settingsBtn.innerHTML = ` +
Gameplay overlay and improved game presence support.
+Initial Sanctum release based on Avia Client with self-hosted instance support.
diff --git a/package.json b/package.json index 27400b2..93854a8 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "sanctum", "productName": "Sanctum", - "version": "1.0.4", - "aviaVersion": "1.0.4", + "version": "1.0.5", + "aviaVersion": "1.0.5", "main": ".vite/build/main.js", "repository": "https://git.mithraic.cloud/ad3laid3/sanctum", "scripts": { diff --git a/src/config.d.ts b/src/config.d.ts index 8a20869..c821860 100644 --- a/src/config.d.ts +++ b/src/config.d.ts @@ -7,6 +7,9 @@ declare type DesktopConfig = { spellchecker: boolean; hardwareAcceleration: boolean; discordRpc: boolean; + gamePresenceEnabled: boolean; + gamePresenceRestrictToAllowList: boolean; + gamePresenceAllowList: string; windowState: { x: number; y: number; @@ -15,3 +18,65 @@ declare type DesktopConfig = { isMaximised: boolean; }; }; + +declare type VoiceOverlayMember = { + name: string; + speaking?: boolean; + muted?: boolean; + deafened?: boolean; + avatarUrl?: string; +}; + +declare type VoiceOverlayState = { + channelName?: string; + isInCall: boolean; + members: VoiceOverlayMember[]; + selfMuted?: boolean; + selfDeafened?: boolean; + source?: string; + updatedAt?: number; +}; + +declare type SanctumGamePresence = { + title: string; + processName: string; + startedAt: number; + source: string; +}; + +declare type SanctumActivityState = { + game: SanctumGamePresence | null; + voice: VoiceOverlayState | null; +}; + +declare global { + interface Window { + native: { + versions: { + node: () => string; + chrome: () => string; + electron: () => string; + desktop: () => string; + aviaClient: () => string; + }; + overlay: { + setVoiceState: (state: VoiceOverlayState | null) => void; + }; + activity: { + getState: () => Promise