import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { createIdenticonEl } from "./identicon"; // ── Ringer (reused from original) ───────────────────────────────── class Ringer { private ctx: AudioContext | null = null; private timer: number | null = null; private activeNodes: AudioNode[] = []; private running = false; start() { if (this.running) return; this.running = true; try { if (!this.ctx) this.ctx = new (window.AudioContext || (window as any).webkitAudioContext)(); } catch { this.running = false; return; } this.playOnce(); this.timer = window.setInterval(() => this.playOnce(), 6000); } stop() { this.running = false; if (this.timer != null) { window.clearInterval(this.timer); this.timer = null; } for (const n of this.activeNodes) try { (n as any).disconnect(); } catch {} this.activeNodes = []; } private playOnce() { if (!this.ctx || !this.running) return; const ctx = this.ctx; const gain = ctx.createGain(); gain.gain.setValueAtTime(0, ctx.currentTime); gain.gain.linearRampToValueAtTime(0.3, ctx.currentTime + 0.05); gain.gain.setValueAtTime(0.3, ctx.currentTime + 1.95); gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.0); gain.connect(ctx.destination); for (const freq of [440, 480]) { const osc = ctx.createOscillator(); osc.frequency.value = freq; osc.connect(gain); osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 2.0); this.activeNodes.push(osc); } this.activeNodes.push(gain); } } const ringer = new Ringer(); // ── Disable zoom/rubber-banding ─────────────────────────────────── document.addEventListener("touchmove", (e) => { if ((e as any).scale !== undefined && (e as any).scale !== 1) e.preventDefault(); }, { passive: false }); document.addEventListener("gesturestart", (e) => e.preventDefault()); document.addEventListener("gesturechange", (e) => e.preventDefault()); document.addEventListener("wheel", (e) => { if (e.ctrlKey) e.preventDefault(); }, { passive: false }); // ── Elements ────────────────────────────────────────────────────── const lobbyScreen = document.getElementById("lobby-screen")!; const callScreen = document.getElementById("call-screen")!; const lobbyDot = document.getElementById("lobby-dot")!; const lobbyRelayLabel = document.getElementById("lobby-relay-label")!; const lobbyRoomLabel = document.getElementById("lobby-room-label")!; const lobbyIdenticon = document.getElementById("lobby-identicon")!; const lobbyFp = document.getElementById("lobby-fp")!; const lobbyUserList = document.getElementById("lobby-user-list")!; const lobbyUserCount = document.getElementById("lobby-user-count")!; const joinVoiceBtn = document.getElementById("join-voice-btn")!; const joinVideoBtn = document.getElementById("join-video-btn")!; const incomingBanner = document.getElementById("incoming-call-banner")!; const incomingCallerName = document.getElementById("incoming-caller-name")!; const incomingIdenticon = document.getElementById("incoming-identicon")!; const acceptCallBtn = document.getElementById("accept-call-btn")!; const rejectCallBtn = document.getElementById("reject-call-btn")!; // Voice drawer elements const voiceDrawer = document.getElementById("voice-drawer")!; const vdRoom = document.getElementById("vd-room")!; const vdTimer = document.getElementById("vd-timer")!; const vdStatus = document.getElementById("vd-status")!; const vdBadge = document.getElementById("vd-badge")!; const vdLevelBar = document.getElementById("vd-level-bar")!; const vdMicBtn = document.getElementById("vd-mic-btn")!; const vdMicIcon = document.getElementById("vd-mic-icon")!; const vdSpkBtn = document.getElementById("vd-spk-btn")!; const vdSpkIcon = document.getElementById("vd-spk-icon")!; const vdEndBtn = document.getElementById("vd-end-btn")!; const vdCamBtn = document.getElementById("vd-cam-btn")!; const vdCamIcon = document.getElementById("vd-cam-icon")!; const vdVideoStrip = document.getElementById("vd-video-strip")!; const vdRemoteVideo = document.getElementById("vd-remote-video") as HTMLCanvasElement; const vdLocalVideo = document.getElementById("vd-local-video") as HTMLVideoElement; const vdDirectInfo = document.getElementById("vd-direct-info")!; const vdDcIdenticon = document.getElementById("vd-dc-identicon")!; const vdDcName = document.getElementById("vd-dc-name")!; const vdDcBadge = document.getElementById("vd-dc-badge")!; const vdStats = document.getElementById("vd-stats")!; const ctxMenu = document.getElementById("user-context-menu")!; const ctxIdenticon = document.getElementById("ctx-identicon")!; const ctxName = document.getElementById("ctx-name")!; const ctxFp = document.getElementById("ctx-fp")!; const ctxCallBtn = document.getElementById("ctx-call-btn")!; const ctxCloseBtn = document.getElementById("ctx-close-btn")!; // Relay management const sRelayList = document.getElementById("s-relay-list")!; const sRelayName = document.getElementById("s-relay-name") as HTMLInputElement; const sRelayAddr = document.getElementById("s-relay-addr") as HTMLInputElement; const sRelayAdd = document.getElementById("s-relay-add")!; // Settings const settingsPanel = document.getElementById("settings-panel")!; const settingsBtn = document.getElementById("settings-btn")!; const settingsBtnCall = document.getElementById("settings-btn-call")!; const settingsClose = document.getElementById("settings-close")!; const settingsSave = document.getElementById("settings-save")!; const sRoom = document.getElementById("s-room") as HTMLInputElement; const sAlias = document.getElementById("s-alias") as HTMLInputElement; const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement; const sDredDebug = document.getElementById("s-dred-debug") as HTMLInputElement; const sCallDebug = document.getElementById("s-call-debug") as HTMLInputElement; const sDirectOnly = document.getElementById("s-direct-only") as HTMLInputElement; const sBirthdayAttack = document.getElementById("s-birthday-attack") as HTMLInputElement; const sCallDebugSection = document.getElementById("s-call-debug-section") as HTMLDivElement; const sCallDebugLogEl = document.getElementById("s-call-debug-log") as HTMLDivElement; const sCallDebugClearBtn = document.getElementById("s-call-debug-clear") as HTMLButtonElement; const sCallDebugCopyBtn = document.getElementById("s-call-debug-copy") as HTMLButtonElement; const sCallDebugShareBtn = document.getElementById("s-call-debug-share") as HTMLButtonElement; const sQuality = document.getElementById("s-quality") as HTMLInputElement; const sQualityLabel = document.getElementById("s-quality-label")!; const sVideoCodec = document.getElementById("s-video-codec") as HTMLSelectElement; const sVideoResolution = document.getElementById("s-video-resolution") as HTMLSelectElement; const sFingerprint = document.getElementById("s-fingerprint")!; const sPublicAddr = document.getElementById("s-public-addr")!; const sReflectBtn = document.getElementById("s-reflect-btn")!; const sNatDetectBtn = document.getElementById("s-nat-detect-btn")!; const sNatResult = document.getElementById("s-nat-result")!; // ── State ───────────────────────────────────────────────────────── interface RelayServer { name: string; address: string; } interface RecentRoom { relay: string; room: string; } interface Settings { relays: RelayServer[]; selectedRelay: number; room: string; alias: string; osAec: boolean; quality: string; videoCodec: string; videoResolution: string; recentRooms: RecentRoom[]; dredDebugLogs: boolean; callDebugLogs: boolean; directOnly: boolean; birthdayAttack: boolean; } function loadSettings(): Settings { const defaults: Settings = { relays: [ { name: "Default", address: "193.180.213.68:4433" }, ], selectedRelay: 0, room: "general", alias: "", osAec: true, quality: "auto", videoCodec: "h264", videoResolution: "1280x720", recentRooms: [], dredDebugLogs: false, callDebugLogs: false, directOnly: false, birthdayAttack: false, }; try { const raw = localStorage.getItem("wzp-settings"); if (raw) return { ...defaults, ...JSON.parse(raw) }; } catch {} return defaults; } function saveSettings(s: Settings) { localStorage.setItem("wzp-settings", JSON.stringify(s)); } function parseVideoResolution(value: string) { const [wRaw, hRaw] = (value || "1280x720").split("x"); const width = Number.parseInt(wRaw, 10); const height = Number.parseInt(hRaw, 10); if (!Number.isFinite(width) || !Number.isFinite(height)) { return { width: 1280, height: 720 }; } return { width, height }; } function videoConnectOptions(s: Settings) { const { width, height } = parseVideoResolution(s.videoResolution); return { videoCodec: s.videoCodec || "h264", videoWidth: width, videoHeight: height, }; } function getRelay(): RelayServer | null { const s = loadSettings(); return s.relays[s.selectedRelay] || s.relays[0] || null; } let myFingerprint = ""; let statusInterval: number | null = null; let inVoice = false; let connectPending = false; // guard against double-tap while connect is in-flight let directCallPeer: { fingerprint: string; alias: string | null } | null = null; let pendingCallId: string | null = null; // Video / camera state let cameraActive = false; let cameraStream: MediaStream | null = null; let cameraFrameTimer: number | null = null; let cameraFrameCallbackHandle: number | null = null; let cameraCaptureInFlight = false; let lastCameraCaptureAtMs = 0; let remoteVideoActive = false; interface FrameCallbackVideoElement extends HTMLVideoElement { requestVideoFrameCallback?: (callback: (now: DOMHighResTimeStamp, metadata: unknown) => void) => number; cancelVideoFrameCallback?: (handle: number) => void; } // Keep the local preview out of the video stage stacking context so it can float // above the call drawer and remain draggable on phones. document.body.appendChild(vdLocalVideo); vdLocalVideo.classList.add("hidden"); function clampNumber(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } function keepLocalPipInViewport() { if (vdLocalVideo.classList.contains("hidden")) return; const rect = vdLocalVideo.getBoundingClientRect(); if (!rect.width || !rect.height) return; const margin = 12; const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin); const maxTop = Math.max(margin, window.innerHeight - rect.height - margin); const left = clampNumber(rect.left, margin, maxLeft); const top = clampNumber(rect.top, margin, maxTop); vdLocalVideo.style.left = `${left}px`; vdLocalVideo.style.top = `${top}px`; vdLocalVideo.style.right = "auto"; vdLocalVideo.style.bottom = "auto"; } function initLocalPipDrag() { let dragPointerId: number | null = null; let dragOffsetX = 0; let dragOffsetY = 0; vdLocalVideo.addEventListener("pointerdown", (event) => { if (vdLocalVideo.classList.contains("hidden")) return; dragPointerId = event.pointerId; const rect = vdLocalVideo.getBoundingClientRect(); dragOffsetX = event.clientX - rect.left; dragOffsetY = event.clientY - rect.top; vdLocalVideo.classList.add("dragging"); vdLocalVideo.setPointerCapture(event.pointerId); event.preventDefault(); }); vdLocalVideo.addEventListener("pointermove", (event) => { if (dragPointerId !== event.pointerId) return; const rect = vdLocalVideo.getBoundingClientRect(); const margin = 12; const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin); const maxTop = Math.max(margin, window.innerHeight - rect.height - margin); const left = clampNumber(event.clientX - dragOffsetX, margin, maxLeft); const top = clampNumber(event.clientY - dragOffsetY, margin, maxTop); vdLocalVideo.style.left = `${left}px`; vdLocalVideo.style.top = `${top}px`; vdLocalVideo.style.right = "auto"; vdLocalVideo.style.bottom = "auto"; event.preventDefault(); }); function endDrag(event: PointerEvent) { if (dragPointerId !== event.pointerId) return; dragPointerId = null; vdLocalVideo.classList.remove("dragging"); try { vdLocalVideo.releasePointerCapture(event.pointerId); } catch {} } vdLocalVideo.addEventListener("pointerup", endDrag); vdLocalVideo.addEventListener("pointercancel", endDrag); window.addEventListener("resize", keepLocalPipInViewport); } initLocalPipDrag(); function showToast(msg: string, durationMs = 3500) { let el = document.getElementById("wzp-toast"); if (!el) { el = document.createElement("div"); el.id = "wzp-toast"; el.style.cssText = "position:fixed;bottom:80px;left:50%;transform:translateX(-50%);" + "background:#1e1e2e;color:#cdd6f4;border:1px solid #45475a;border-radius:8px;" + "padding:10px 18px;font-size:13px;z-index:9999;pointer-events:none;opacity:0;transition:opacity .2s"; document.body.appendChild(el); } el.textContent = msg; el.style.opacity = "1"; clearTimeout((el as any)._timer); (el as any)._timer = setTimeout(() => { el!.style.opacity = "0"; }, durationMs); } function errorMessage(e: unknown): string { if (typeof e === "string") return e; if (e && typeof e === "object" && "message" in e) { const msg = (e as { message?: unknown }).message; if (typeof msg === "string") return msg; } return String(e); } function connectDebugSummary(entry: CallDebugEntry | null): string { if (!entry) return "no native connect event received"; const details = entry.details && typeof entry.details === "object" ? JSON.stringify(entry.details) : String(entry.details ?? ""); return `${entry.step}${details ? ` ${details}` : ""}`; } let lastConnectDebug: CallDebugEntry | null = null; function connectWithTimeout(args: Record, timeoutMs = 45000) { lastConnectDebug = null; return Promise.race([ invoke("connect", args), new Promise((_, reject) => setTimeout(() => reject(new Error( `connect timed out (${Math.round(timeoutMs / 1000)}s); last native step: ${connectDebugSummary(lastConnectDebug)}` )), timeoutMs) ), ]); } // Known users in the room (from RoomUpdate or signal presence) interface LobbyUser { fingerprint: string; alias: string | null; inVoice: boolean; speaking: boolean; } let lobbyUsers: Map = new Map(); // ── Call debug buffer ───────────────────────────────────────────── interface CallDebugEntry { ts_ms: number; step: string; details: any; } const callDebugBuffer: CallDebugEntry[] = []; const CALL_DEBUG_MAX = 200; listen("call-debug-log", (event: any) => { const entry: CallDebugEntry = event.payload; callDebugBuffer.push(entry); if (entry.step?.startsWith("connect:")) lastConnectDebug = entry; if (callDebugBuffer.length > CALL_DEBUG_MAX) callDebugBuffer.shift(); renderCallDebugLog(); }); function renderCallDebugLog() { if (!sCallDebugLogEl) return; sCallDebugLogEl.textContent = callDebugBuffer .map((e) => { const t = new Date(e.ts_ms).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", fractionalSecondDigits: 3 } as any); const d = typeof e.details === "object" ? JSON.stringify(e.details) : String(e.details); return `${t} ${e.step} ${d}`; }) .join("\n"); sCallDebugLogEl.scrollTop = sCallDebugLogEl.scrollHeight; } function debugLog(step: string, details: any = {}) { invoke("call_debug_log", { step, details }).catch(() => {}); } // ── Quality slider ──────────────────────────────────────────────── const QUALITY_STEPS = ["studio-64k", "studio-48k", "studio-32k", "auto", "good", "degraded", "codec2-3200", "catastrophic"]; const QUALITY_LABELS = ["Studio 64k", "Studio 48k", "Studio 32k", "Auto", "Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"]; const QUALITY_COLORS = ["#22c55e", "#4ade80", "#86efac", "#a3e635", "#facc15", "#f59e0b", "#e97320", "#991b1b"]; function qualityToIndex(q: string): number { const i = QUALITY_STEPS.indexOf(q); return i >= 0 ? i : 3; } function updateQualityUI(i: number) { if (sQualityLabel) { sQualityLabel.textContent = QUALITY_LABELS[i]; sQualityLabel.style.color = QUALITY_COLORS[i]; } } sQuality?.addEventListener("input", () => updateQualityUI(parseInt(sQuality.value))); // ── Lobby rendering ─────────────────────────────────────────────── function renderLobbyUsers() { lobbyUserList.innerHTML = ""; const users = Array.from(lobbyUsers.values()) .filter((u) => u.fingerprint !== myFingerprint) // always exclude self .sort((a, b) => { // Voice users first, then alphabetical if (a.inVoice !== b.inVoice) return a.inVoice ? -1 : 1; return (a.alias || a.fingerprint).localeCompare(b.alias || b.fingerprint); }); lobbyUserCount.textContent = String(users.length); if (users.length === 0) { lobbyUserList.innerHTML = '
No one else is here yet
'; return; } for (const user of users) { const row = document.createElement("div"); row.className = "user-row" + (user.inVoice ? " in-voice" : "") + (user.speaking ? " speaking" : ""); row.dataset.fp = user.fingerprint; const identicon = document.createElement("div"); identicon.className = "user-identicon"; identicon.appendChild(createIdenticonEl(user.fingerprint, 36, true)); const info = document.createElement("div"); info.className = "user-info"; info.innerHTML = `
${user.alias || user.fingerprint.substring(0, 16)}
${user.fingerprint}
`; const status = document.createElement("div"); status.className = "user-status"; if (user.speaking) { status.innerHTML = '🔊'; } else if (user.inVoice) { status.innerHTML = '🎧'; } row.appendChild(identicon); row.appendChild(info); row.appendChild(status); row.addEventListener("click", () => openContextMenu(user)); lobbyUserList.appendChild(row); } } // ── Context menu ────────────────────────────────────────────────── let contextUser: LobbyUser | null = null; function openContextMenu(user: LobbyUser) { contextUser = user; ctxIdenticon.innerHTML = ""; ctxIdenticon.appendChild(createIdenticonEl(user.fingerprint, 40, true)); ctxName.textContent = user.alias || user.fingerprint.substring(0, 16); ctxFp.textContent = user.fingerprint; // Hide call button for self const isSelf = user.fingerprint === myFingerprint; (ctxCallBtn as HTMLButtonElement).disabled = isSelf; (ctxCallBtn as HTMLElement).style.opacity = isSelf ? "0.3" : "1"; ctxMenu.classList.remove("hidden"); } ctxCloseBtn.addEventListener("click", () => ctxMenu.classList.add("hidden")); ctxMenu.addEventListener("click", (e) => { if (e.target === ctxMenu) ctxMenu.classList.add("hidden"); }); let callInProgress = false; ctxCallBtn.addEventListener("click", async () => { if (!contextUser || callInProgress) return; if (contextUser.fingerprint === myFingerprint) { ctxMenu.classList.add("hidden"); return; } // Don't place a call if there's already a pending incoming call if (pendingCallId) { ctxMenu.classList.add("hidden"); return; } callInProgress = true; ctxMenu.classList.add("hidden"); directCallPeer = { fingerprint: contextUser.fingerprint, alias: contextUser.alias }; try { await invoke("place_call", { targetFp: contextUser.fingerprint }); // Keep callInProgress true until the call resolves (setup/hangup) // — it's cleared in leaveVoice() or when the call connects } catch (e: any) { console.error("place_call failed:", e); directCallPeer = null; callInProgress = false; } }); // ── Voice join/leave (drawer-based) ─────────────────────────────── joinVoiceBtn.addEventListener("click", async () => { if (inVoice || connectPending) return; const relay = getRelay(); const s = loadSettings(); if (!relay) { showToast("No relay configured"); return; } connectPending = true; const origText = joinVoiceBtn.textContent; joinVoiceBtn.textContent = "Connecting…"; (joinVoiceBtn as HTMLButtonElement).disabled = true; try { await connectWithTimeout({ relay: relay.address, room: s.room || "general", alias: s.alias || "", osAec: s.osAec, quality: s.quality || "auto", ...videoConnectOptions(s), }); enterVoice(false); } catch (e: any) { console.error("connect failed:", e); showToast(`Join failed: ${errorMessage(e)}`); } finally { connectPending = false; joinVoiceBtn.textContent = origText; (joinVoiceBtn as HTMLButtonElement).disabled = false; } }); joinVideoBtn.addEventListener("click", async () => { if (inVoice || connectPending) return; const relay = getRelay(); const s = loadSettings(); if (!relay) { showToast("No relay configured"); return; } connectPending = true; const origText = joinVideoBtn.textContent; joinVideoBtn.textContent = "Connecting…"; (joinVideoBtn as HTMLButtonElement).disabled = true; try { await connectWithTimeout({ relay: relay.address, room: s.room || "general", alias: s.alias || "", osAec: s.osAec, quality: s.quality || "auto", ...videoConnectOptions(s), }); enterVoice(false); startCamera(); } catch (e: any) { console.error("connect failed:", e); showToast(`Join failed: ${errorMessage(e)}`); } finally { connectPending = false; joinVideoBtn.textContent = origText; (joinVideoBtn as HTMLButtonElement).disabled = false; } }); function enterVoice(isDirect: boolean) { inVoice = true; const s = loadSettings(); joinVoiceBtn.classList.add("hidden"); joinVideoBtn.classList.add("hidden"); voiceDrawer.classList.remove("hidden"); vdRoom.textContent = isDirect && directCallPeer ? (directCallPeer.alias || directCallPeer.fingerprint.substring(0, 16)) : (s.room || "general"); vdTimer.textContent = "0:00"; vdBadge.classList.add("hidden"); vdBadge.textContent = ""; if (isDirect && directCallPeer) { vdDirectInfo.classList.remove("hidden"); vdDcIdenticon.innerHTML = ""; vdDcIdenticon.appendChild(createIdenticonEl(directCallPeer.fingerprint, 32, true)); vdDcName.textContent = directCallPeer.alias || directCallPeer.fingerprint.substring(0, 16); vdDcBadge.textContent = "Connecting..."; vdDcBadge.className = "vd-dc-badge connecting"; } else { vdDirectInfo.classList.add("hidden"); } statusInterval = window.setInterval(pollStatus, 250); } function leaveVoice() { inVoice = false; callInProgress = false; directCallPeer = null; pendingCallId = null; voiceDrawer.classList.add("hidden"); joinVoiceBtn.classList.remove("hidden"); joinVideoBtn.classList.remove("hidden"); vdLevelBar.style.width = "0%"; if (statusInterval) { clearInterval(statusInterval); statusInterval = null; } stopCamera(); remoteVideoActive = false; remoteFrameCount = 0; remoteFrameSerial++; vdRemoteCounter.textContent = "0 frames received"; vdRemotePlaceholder.classList.remove("hidden"); vdVideoStrip.classList.add("hidden"); remoteCtx.clearRect(0, 0, vdRemoteVideo.width, vdRemoteVideo.height); } // Drawer controls vdEndBtn.addEventListener("click", async () => { try { await invoke("hangup_call"); } catch {} try { await invoke("disconnect"); } catch {} leaveVoice(); }); vdMicBtn.addEventListener("click", async () => { try { await invoke("toggle_mic"); } catch {} }); vdSpkBtn.addEventListener("click", async () => { try { await invoke("toggle_speaker"); } catch {} }); // ── Camera (Blocker 4 + 5) ──────────────────────────────────────── const camCaptureCanvas = document.createElement("canvas"); const camCaptureCtx = camCaptureCanvas.getContext("2d")!; let cameraSendWidth = 1280; let cameraSendHeight = 720; let cameraCaptureFrameNo = 0; let cameraPushFailures = 0; const CAMERA_CAPTURE_INTERVAL_MS = 33; // ≈ 30 fps const CAMERA_JPEG_QUALITY = 0.7; function drawCameraFrameForSend() { const vw = vdLocalVideo.videoWidth || camCaptureCanvas.width; const vh = vdLocalVideo.videoHeight || camCaptureCanvas.height; if (!vw || !vh) return; const scale = Math.min(cameraSendWidth / vw, cameraSendHeight / vh); const dw = vw * scale; const dh = vh * scale; const dx = (cameraSendWidth - dw) / 2; const dy = (cameraSendHeight - dh) / 2; camCaptureCtx.fillStyle = "#000"; camCaptureCtx.fillRect(0, 0, cameraSendWidth, cameraSendHeight); camCaptureCtx.drawImage(vdLocalVideo, dx, dy, dw, dh); } async function captureAndPushCameraFrame() { if (!cameraActive || cameraCaptureInFlight) return; cameraCaptureInFlight = true; cameraCaptureFrameNo++; try { drawCameraFrameForSend(); const dataUrl = camCaptureCanvas.toDataURL("image/jpeg", CAMERA_JPEG_QUALITY); const b64 = dataUrl.slice(dataUrl.indexOf(",") + 1); if (cameraCaptureFrameNo === 1 || cameraCaptureFrameNo % 150 === 0) { debugLog("camera:capture_frame", { frame_no: cameraCaptureFrameNo, width: camCaptureCanvas.width, height: camCaptureCanvas.height, source_width: vdLocalVideo.videoWidth || null, source_height: vdLocalVideo.videoHeight || null, jpeg_b64_len: b64.length, capture_clock: getVideoFrameCallbackApi() ? "video_frame_callback" : "interval", }); } await invoke("push_camera_frame", { jpegB64: b64 }); } catch (e: any) { cameraPushFailures++; if (cameraPushFailures === 1 || cameraPushFailures % 30 === 0) { debugLog("camera:push_failed", { frame_no: cameraCaptureFrameNo, failures: cameraPushFailures, error: errorMessage(e), }); } } finally { cameraCaptureInFlight = false; } } function getVideoFrameCallbackApi() { const video = vdLocalVideo as FrameCallbackVideoElement; if (typeof video.requestVideoFrameCallback !== "function") return null; return video; } function cancelCameraCaptureLoop() { if (cameraFrameTimer != null) { window.clearInterval(cameraFrameTimer); cameraFrameTimer = null; } const video = getVideoFrameCallbackApi(); if (video && cameraFrameCallbackHandle != null && typeof video.cancelVideoFrameCallback === "function") { video.cancelVideoFrameCallback(cameraFrameCallbackHandle); } cameraFrameCallbackHandle = null; } function scheduleCameraFrameCapture() { cancelCameraCaptureLoop(); lastCameraCaptureAtMs = 0; const video = getVideoFrameCallbackApi(); if (video) { const onVideoFrame = (now: DOMHighResTimeStamp) => { cameraFrameCallbackHandle = null; if (!cameraActive) return; if (lastCameraCaptureAtMs === 0 || now - lastCameraCaptureAtMs >= CAMERA_CAPTURE_INTERVAL_MS) { lastCameraCaptureAtMs = now; void captureAndPushCameraFrame(); } cameraFrameCallbackHandle = video.requestVideoFrameCallback!(onVideoFrame); }; cameraFrameCallbackHandle = video.requestVideoFrameCallback(onVideoFrame); debugLog("camera:capture_clock", { mode: "video_frame_callback", interval_ms: CAMERA_CAPTURE_INTERVAL_MS }); return; } cameraFrameTimer = window.setInterval(() => { void captureAndPushCameraFrame(); }, CAMERA_CAPTURE_INTERVAL_MS); debugLog("camera:capture_clock", { mode: "interval", interval_ms: CAMERA_CAPTURE_INTERVAL_MS }); } async function startCamera() { if (cameraActive) return; const videoSize = parseVideoResolution(loadSettings().videoResolution); cameraSendWidth = videoSize.width; cameraSendHeight = videoSize.height; const constraints = { video: { width: { ideal: cameraSendWidth }, height: { ideal: cameraSendHeight }, facingMode: "user" }, audio: false, }; debugLog("camera:get_user_media_start", { constraints }); try { cameraStream = await navigator.mediaDevices.getUserMedia(constraints); vdLocalVideo.srcObject = cameraStream; vdVideoStrip.classList.remove("hidden"); const track = cameraStream.getVideoTracks()[0]; const settings = track.getSettings(); camCaptureCanvas.width = cameraSendWidth; camCaptureCanvas.height = cameraSendHeight; debugLog("camera:get_user_media_ok", { width: settings.width ?? null, height: settings.height ?? null, send_width: camCaptureCanvas.width, send_height: camCaptureCanvas.height, frameRate: settings.frameRate ?? null, deviceId: settings.deviceId ? "present" : null, facingMode: settings.facingMode ?? null, }); cameraActive = true; cameraCaptureFrameNo = 0; cameraPushFailures = 0; vdCamIcon.textContent = "Cam ✓"; vdCamBtn.classList.add("active"); vdLocalVideo.classList.remove("hidden"); keepLocalPipInViewport(); scheduleCameraFrameCapture(); } catch (e: any) { console.warn("camera access denied or unavailable:", e); debugLog("camera:get_user_media_failed", { name: e?.name ?? null, message: e?.message ?? String(e), }); } } function stopCamera() { if (cameraActive) { debugLog("camera:stopped", { frames: cameraCaptureFrameNo }); } cameraActive = false; cancelCameraCaptureLoop(); if (cameraStream) { cameraStream.getTracks().forEach(t => t.stop()); cameraStream = null; } vdLocalVideo.srcObject = null; vdLocalVideo.classList.add("hidden"); vdCamIcon.textContent = "Cam"; vdCamBtn.classList.remove("active"); // Hide strip only if remote video is also gone if (!remoteVideoActive) vdVideoStrip.classList.add("hidden"); } vdCamBtn.addEventListener("click", () => { if (cameraActive) { stopCamera(); } else { startCamera(); } }); // ── Remote video display (Blocker 5) ───────────────────────────── const remoteCtx = vdRemoteVideo.getContext("2d")!; const vdRemotePlaceholder = document.getElementById("vd-remote-placeholder")!; const vdRemoteCounter = document.getElementById("vd-remote-counter")!; let remoteFrameCount = 0; let remoteFrameSerial = 0; let remoteDrawInFlight = false; let remotePendingFrame: { serial: number; width: number; height: number; jpeg_b64: string } | null = null; function nextAnimationFrame() { return new Promise(resolve => requestAnimationFrame(() => resolve())); } async function drawRemoteFrame(frame: { serial: number; width: number; height: number; jpeg_b64: string }) { const img = new Image(); img.src = `data:image/jpeg;base64,${frame.jpeg_b64}`; if ("decode" in img) { await img.decode(); } else { await new Promise((resolve, reject) => { img.onload = () => resolve(); img.onerror = () => reject(new Error("remote video image decode failed")); }); } if (frame.serial !== remoteFrameSerial) return; await nextAnimationFrame(); if (frame.serial !== remoteFrameSerial) return; if (vdRemoteVideo.width !== frame.width) vdRemoteVideo.width = frame.width; if (vdRemoteVideo.height !== frame.height) vdRemoteVideo.height = frame.height; remoteCtx.drawImage(img, 0, 0, vdRemoteVideo.width, vdRemoteVideo.height); } async function pumpRemoteVideoFrames() { if (remoteDrawInFlight) return; remoteDrawInFlight = true; try { while (remotePendingFrame) { const frame = remotePendingFrame; remotePendingFrame = null; try { await drawRemoteFrame(frame); } catch (e) { console.warn("remote video draw failed:", e); } } } finally { remoteDrawInFlight = false; if (remotePendingFrame) void pumpRemoteVideoFrames(); } } listen("video:frame", (event: any) => { const { width, height, jpeg_b64 } = event.payload; if (!jpeg_b64) return; const frameSerial = ++remoteFrameSerial; remoteVideoActive = true; vdVideoStrip.classList.remove("hidden"); vdRemotePlaceholder.classList.add("hidden"); remoteFrameCount++; if (remoteFrameCount === 1) console.log("first remote video frame:", width, "x", height); remotePendingFrame = { serial: frameSerial, width: width ?? vdRemoteVideo.width, height: height ?? vdRemoteVideo.height, jpeg_b64, }; void pumpRemoteVideoFrames(); }); // ── Poll status ─────────────────────────────────────────────────── interface CallStatusI { active: boolean; mic_muted: boolean; spk_muted: boolean; participants: any[]; encode_fps: number; recv_fps: number; audio_level: number; call_duration_secs: number; fingerprint: string; tx_codec: string; rx_codec: string; } async function pollStatus() { try { const st: CallStatusI = await invoke("get_status"); if (!st.active) { leaveVoice(); return; } if (st.fingerprint) myFingerprint = st.fingerprint; // Update drawer controls vdMicBtn.classList.toggle("muted", st.mic_muted); vdMicIcon.textContent = st.mic_muted ? "Muted" : "Mic"; vdSpkBtn.classList.toggle("muted", st.spk_muted); vdSpkIcon.textContent = st.spk_muted ? "Off" : "Spk"; // Level meter const pct = Math.min(100, (st.audio_level / 10000) * 100); vdLevelBar.style.width = `${pct}%`; // Duration const m = Math.floor(st.call_duration_secs / 60); const s = Math.floor(st.call_duration_secs % 60); vdTimer.textContent = `${m}:${s.toString().padStart(2, "0")}`; // P2P badge for direct calls if (directCallPeer) { const pathNeg = [...callDebugBuffer].reverse().find((e) => e.step === "connect:path_negotiated"); const engineOk = [...callDebugBuffer].reverse().find((e) => e.step === "connect:call_engine_started"); if (engineOk) { if (pathNeg?.details?.use_direct === true) { vdDcBadge.textContent = "P2P Direct"; vdDcBadge.className = "vd-dc-badge direct"; vdBadge.textContent = "P2P"; vdBadge.className = "vd-badge direct"; vdBadge.classList.remove("hidden"); } else { vdDcBadge.textContent = "Via Relay"; vdDcBadge.className = "vd-dc-badge relay"; vdBadge.textContent = "Relay"; vdBadge.className = "vd-badge relay"; vdBadge.classList.remove("hidden"); } } } // Stats with codec vdStats.textContent = `TX: ${st.tx_codec || "?"} ${st.encode_fps || 0}fps | RX: ${st.rx_codec || "?"} ${st.recv_fps || 0}fps | Level: ${st.audio_level || 0}`; } catch {} } // ── Signal events ───────────────────────────────────────────────── listen("signal-event", (event: any) => { const data = event.payload; switch (data.type) { case "presence_list": // Relay sent updated user list lobbyUsers.clear(); for (const u of data.users || []) { if (u.fingerprint === myFingerprint) continue; // don't show self lobbyUsers.set(u.fingerprint, { fingerprint: u.fingerprint, alias: u.alias || null, inVoice: false, speaking: false, }); } renderLobbyUsers(); break; case "ringing": // We placed a call, it's ringing break; case "incoming": // Show incoming call banner incomingBanner.classList.remove("hidden"); incomingCallerName.textContent = data.caller_alias || data.caller_fp?.substring(0, 16) || "Unknown"; incomingIdenticon.innerHTML = ""; incomingIdenticon.appendChild(createIdenticonEl(data.caller_fp || "?", 40, true)); directCallPeer = { fingerprint: data.caller_fp || "", alias: data.caller_alias || null }; pendingCallId = data.call_id || null; ringer.start(); break; case "answered": ringer.stop(); break; case "setup": ringer.stop(); incomingBanner.classList.add("hidden"); // Auto-connect to the call (async () => { if (connectPending) return; connectPending = true; const s = loadSettings(); try { await connectWithTimeout({ relay: data.relay_addr, room: data.room, alias: s.alias || "", osAec: s.osAec, quality: s.quality || "auto", peerDirectAddr: data.peer_direct_addr ?? null, peerLocalAddrs: data.peer_local_addrs ?? [], peerMappedAddr: data.peer_mapped_addr ?? null, directOnly: s.directOnly || false, birthdayAttack: s.birthdayAttack || false, ...videoConnectOptions(s), }); enterVoice(true); } catch (e: any) { console.error("connect failed:", e); showToast(`Call failed to connect: ${errorMessage(e)}`); } finally { connectPending = false; } })(); break; case "hangup": ringer.stop(); incomingBanner.classList.add("hidden"); (async () => { try { await invoke("disconnect"); } catch {} leaveVoice(); })(); break; } }); // Accept/reject incoming call acceptCallBtn.addEventListener("click", async () => { ringer.stop(); incomingBanner.classList.add("hidden"); if (pendingCallId) { await invoke("answer_call", { callId: pendingCallId, mode: 1 }); pendingCallId = null; } }); rejectCallBtn.addEventListener("click", async () => { ringer.stop(); incomingBanner.classList.add("hidden"); if (pendingCallId) { await invoke("answer_call", { callId: pendingCallId, mode: 0 }); pendingCallId = null; directCallPeer = null; } }); // ── Room updates (participants) ─────────────────────────────────── listen("call-event", (event: any) => { const data = event.payload; if (data.kind === "participants" && data.participants) { // Update lobby users from room participant list const active = new Set(); for (const p of data.participants) { const fp = p.fingerprint || p.id || ""; active.add(fp); if (!lobbyUsers.has(fp)) { lobbyUsers.set(fp, { fingerprint: fp, alias: p.alias || null, inVoice: true, speaking: false }); } else { const u = lobbyUsers.get(fp)!; u.inVoice = true; if (p.alias) u.alias = p.alias; } } // Mark users not in participant list as not in voice for (const [fp, u] of lobbyUsers) { if (!active.has(fp)) u.inVoice = false; } renderLobbyUsers(); } }); // ── Settings ────────────────────────────────────────────────────── // ── Relay list management ────────────────────────────────────── function renderRelayList() { const s = loadSettings(); sRelayList.innerHTML = ""; for (let i = 0; i < s.relays.length; i++) { const r = s.relays[i]; const isActive = i === s.selectedRelay; const row = document.createElement("div"); row.style.cssText = "display:flex;align-items:center;gap:6px;padding:8px;border-radius:6px;margin-bottom:4px;cursor:pointer;" + (isActive ? "background:rgba(74,222,128,0.12);border:1px solid var(--green);" : "background:var(--surface);border:1px solid transparent;"); row.innerHTML = ` ${r.name} ${r.address} ${isActive ? 'ACTIVE' : ''} `; // Click to select (not on the X button) row.addEventListener("click", (e) => { if ((e.target as HTMLElement).classList.contains("relay-rm-btn")) return; const settings = loadSettings(); if (i !== settings.selectedRelay) { settings.selectedRelay = i; saveSettings(settings); renderRelayList(); // Reconnect to new relay reconnectSignal(); } }); sRelayList.appendChild(row); } // Wire remove buttons sRelayList.querySelectorAll(".relay-rm-btn").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); const idx = parseInt((btn as HTMLElement).dataset.idx || "0"); const settings = loadSettings(); if (settings.relays.length <= 1) return; // keep at least one settings.relays.splice(idx, 1); if (settings.selectedRelay >= settings.relays.length) { settings.selectedRelay = 0; } saveSettings(settings); renderRelayList(); reconnectSignal(); }); }); } sRelayAdd.addEventListener("click", () => { const name = sRelayName.value.trim(); const addr = sRelayAddr.value.trim(); if (!name || !addr) return; if (!addr.includes(":")) return; // must be host:port const s = loadSettings(); s.relays.push({ name, address: addr }); saveSettings(s); sRelayName.value = ""; sRelayAddr.value = ""; renderRelayList(); }); async function reconnectSignal() { // Deregister from current relay, then auto-connect to new one try { await invoke("deregister"); } catch {} lobbyUsers.clear(); renderLobbyUsers(); lobbyDot.style.background = "var(--yellow)"; lobbyRelayLabel.textContent = "Reconnecting..."; // Short delay to let deregister complete setTimeout(() => autoConnect(), 500); } function openSettings() { const s = loadSettings(); sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec; sDredDebug.checked = !!s.dredDebugLogs; sCallDebug.checked = !!s.callDebugLogs; sDirectOnly.checked = !!s.directOnly; sBirthdayAttack.checked = !!s.birthdayAttack; sVideoCodec.value = s.videoCodec || "h264"; sVideoResolution.value = s.videoResolution || "1280x720"; sCallDebugSection.style.display = s.callDebugLogs ? "" : "none"; renderCallDebugLog(); const qi = qualityToIndex(s.quality || "auto"); sQuality.value = String(qi); updateQualityUI(qi); sFingerprint.textContent = myFingerprint || "(loading...)"; renderRelayList(); settingsPanel.classList.remove("hidden"); } settingsBtn.addEventListener("click", openSettings); settingsBtnCall?.addEventListener("click", openSettings); settingsClose.addEventListener("click", () => settingsPanel.classList.add("hidden")); settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel) settingsPanel.classList.add("hidden"); }); settingsSave.addEventListener("click", () => { const s = loadSettings(); s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked; s.quality = QUALITY_STEPS[parseInt(sQuality.value)] || "auto"; s.dredDebugLogs = sDredDebug.checked; s.callDebugLogs = sCallDebug.checked; s.directOnly = sDirectOnly.checked; s.birthdayAttack = sBirthdayAttack.checked; s.videoCodec = sVideoCodec.value || "h264"; s.videoResolution = sVideoResolution.value || "1280x720"; saveSettings(s); invoke("set_dred_verbose_logs", { enabled: s.dredDebugLogs }).catch(() => {}); invoke("set_call_debug_logs", { enabled: s.callDebugLogs }).catch(() => {}); sCallDebugSection.style.display = s.callDebugLogs ? "" : "none"; // Update lobby room label lobbyRoomLabel.textContent = s.room || "general"; settingsPanel.classList.add("hidden"); }); // Debug log actions sCallDebugClearBtn?.addEventListener("click", () => { callDebugBuffer.length = 0; sCallDebugLogEl.textContent = ""; }); sCallDebugCopyBtn?.addEventListener("click", () => { const text = callDebugBuffer.map((e) => { const t = new Date(e.ts_ms).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); return `${t} ${e.step} ${JSON.stringify(e.details)}`; }).join("\n"); navigator.clipboard?.writeText(text).catch(() => {}); }); sCallDebugShareBtn?.addEventListener("click", async () => { const text = callDebugBuffer.map((e) => { const t = new Date(e.ts_ms).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); return `${t} ${e.step} ${JSON.stringify(e.details)}`; }).join("\n"); try { await (navigator as any).share({ text }); } catch {} }); // NAT detect sReflectBtn?.addEventListener("click", async () => { try { const addr: string = await invoke("get_reflected_address"); sPublicAddr.textContent = addr; } catch (e: any) { sPublicAddr.textContent = String(e); } }); sNatDetectBtn?.addEventListener("click", async () => { sNatResult.textContent = "Detecting..."; try { const relay = getRelay(); const relays = relay ? [{ name: relay.name, address: relay.address }] : []; const result: any = await invoke("detect_nat_type", { relays }); let text = `NAT: ${result.nat_type}`; if (result.consensus_addr) text += ` (${result.consensus_addr})`; text += "\n"; for (const p of result.probes || []) { text += ` ${p.relay_name} (${p.relay_addr}) → ${p.observed_addr || "failed"} [${p.latency_ms || "-"}ms]`; if (p.error) text += ` [${p.error}]`; text += "\n"; } sNatResult.textContent = text; } catch (e: any) { sNatResult.textContent = String(e); } }); // ── Auto-connect signal on launch ───────────────────────────────── async function autoConnect() { const relay = getRelay(); const s = loadSettings(); if (!relay) { lobbyRelayLabel.textContent = "No relay configured"; lobbyDot.style.background = "var(--red)"; return; } lobbyRelayLabel.textContent = `${relay.name} (${relay.address})`; lobbyRoomLabel.textContent = s.room || "general"; lobbyDot.style.background = "var(--yellow)"; try { // Register signal for presence + direct calls await invoke("register_signal", { relay: relay.address }); lobbyDot.style.background = "var(--green)"; lobbyRelayLabel.textContent = `${relay.name} — connected`; // Get identity + alias const appInfo: any = await invoke("get_app_info"); if (appInfo?.fingerprint) { myFingerprint = appInfo.fingerprint; lobbyFp.textContent = appInfo.alias || appInfo.fingerprint; lobbyIdenticon.innerHTML = ""; lobbyIdenticon.appendChild(createIdenticonEl(appInfo.fingerprint, 20, true)); } } catch (e: any) { lobbyDot.style.background = "var(--red)"; lobbyRelayLabel.textContent = `Failed: ${e}`; } } // Push debug log setting to Rust on startup invoke("set_call_debug_logs", { enabled: !!loadSettings().callDebugLogs }).catch(() => {}); // Keyboard shortcuts document.addEventListener("keydown", (e) => { if ((e.target as HTMLElement).tagName === "INPUT") return; if (e.key === "m") vdMicBtn.click(); if (e.key === "q") vdEndBtn.click(); if (e.key === "s") vdSpkBtn.click(); if (e.key === "v") vdCamBtn.click(); if (e.key === "," && (e.metaKey || e.ctrlKey)) { e.preventDefault(); openSettings(); } }); // Launch autoConnect();