diff --git a/static/js/12-calls.js b/static/js/12-calls.js index 1aa2999..d77a451 100644 --- a/static/js/12-calls.js +++ b/static/js/12-calls.js @@ -44,6 +44,20 @@ Glow.calls.ensureState = () => { state.ignoreOffer ||= new Map(); state.isSettingRemoteAnswerPending ||= new Map(); state.panelOpen ??= false; + state.metrics ||= { + packetLoss: null, + uptime: null, + latency: null, + elapsedSeconds: 0, + userCount: 0 + }; + state.drag ||= { + active: false, + x: null, + y: null, + offsetX: 0, + offsetY: 0 + }; }; Glow.calls.scopeLabel = invite => { @@ -182,7 +196,8 @@ Glow.calls.upsertPeer = peer => { const normalized = { participantId, roomId: peer.roomId || peer.room_id || state.session?.call_id || "", - username: peer.username || participantId + username: peer.username || participantId, + connectionState: peer.connectionState || "connecting" }; const index = peers.findIndex(item => item.participantId === participantId); @@ -301,13 +316,23 @@ Glow.calls.ensurePeerConnection = async peerId => { }; pc.onconnectionstatechange = () => { - Glow.calls.debug("pc connection state", peerId, pc.connectionState); + const nextState = pc.connectionState || "connecting"; + Glow.calls.debug("pc connection state", peerId, nextState); + + const peer = state.peers.find(item => item.participantId === peerId); + if (peer) { + peer.connectionState = nextState; + } + const bad = - pc.connectionState === "failed" || - pc.connectionState === "disconnected"; + nextState === "failed" || + nextState === "disconnected"; if (bad) { Glow.showToast(`Connection issue with ${peerId}.`, "error"); } + + Glow.calls.renderParticipants(); + Glow.calls.updateSummaryMetrics(); }; pc.onsignalingstatechange = () => { @@ -475,6 +500,15 @@ Glow.calls.renderPendingInvites = () => { wrap.classList.remove("hidden"); }; +Glow.calls.getParticipantStateLabel = peer => { + const state = String(peer?.connectionState || "").toLowerCase(); + if (state === "connected") return "active"; + if (state === "connecting" || state === "new") return "connecting"; + if (state === "disconnected") return "disconnected"; + if (state === "failed") return "reconnecting"; + return "joined"; +}; + Glow.calls.renderParticipants = () => { if (!Glow.els.callParticipants) return; @@ -504,7 +538,7 @@ Glow.calls.renderParticipants = () => { selfRight.className = "call-participant-status"; selfRight.textContent = state.activeCall ? state.muted - ? "connected · muted" + ? "connected · muted" : "connected" : "idle"; @@ -524,12 +558,16 @@ Glow.calls.renderParticipants = () => { const row = document.createElement("div"); row.className = "call-participant"; - const left = document.createElement("div"); - left.className = "call-participant-name"; + const left = document.createElement("button"); + left.className = "call-participant-name profile-link"; + left.type = "button"; left.textContent = participant.display_name || participant.username || `user ${participant.user_id}`; - if (participant) { - Glow.applyUserColor(left, participant); + Glow.applyUserColor(left, participant); + if (participant.username) { + left.addEventListener("click", () => { + Glow.openProfile?.(participant.username); + }); } const right = document.createElement("div"); @@ -542,7 +580,9 @@ Glow.calls.renderParticipants = () => { ); }); - right.textContent = matchingPeer ? "connected" : "joined"; + const label = Glow.calls.getParticipantStateLabel(matchingPeer); + right.textContent = label; + right.dataset.state = label; row.append(left, right); box.append(row); @@ -560,13 +600,21 @@ Glow.calls.renderParticipants = () => { const row = document.createElement("div"); row.className = "call-participant"; - const left = document.createElement("div"); - left.className = "call-participant-name"; + const left = document.createElement("button"); + left.className = "call-participant-name profile-link"; + left.type = "button"; left.textContent = peer.username || peer.participantId; + if (peer.username) { + left.addEventListener("click", () => { + Glow.openProfile?.(peer.username); + }); + } const right = document.createElement("div"); right.className = "call-participant-status"; - right.textContent = "connecting"; + const label = Glow.calls.getParticipantStateLabel(peer); + right.textContent = label; + right.dataset.state = label; row.append(left, right); box.append(row); @@ -583,6 +631,54 @@ Glow.calls.renderParticipants = () => { } }; +Glow.calls.updateSummaryMetrics = () => { + Glow.calls.ensureState(); + const state = Glow.calls.state(); + + const createdAt = + state.activeCall?.groupSession?.created_at || + state.session?.group_session?.created_at || + null; + + let elapsedSeconds = 0; + if (createdAt) { + const ms = new Date(createdAt).getTime(); + if (!Number.isNaN(ms)) { + elapsedSeconds = Math.max(0, Math.floor((Date.now() - ms) / 1000)); + } + } + + state.metrics.elapsedSeconds = elapsedSeconds; + state.metrics.userCount = + 1 + Glow.calls.listOtherPeers().length; + + if (Glow.els.callUserCountStat) { + Glow.els.callUserCountStat.textContent = String(state.metrics.userCount); + } + + if (Glow.els.callElapsedStat) { + const minutes = Math.floor(elapsedSeconds / 60); + const seconds = elapsedSeconds % 60; + Glow.els.callElapsedStat.textContent = + minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; + } + + if (Glow.els.callLatencyStat) { + Glow.els.callLatencyStat.textContent = + state.metrics.latency == null ? "—" : `${state.metrics.latency} ms`; + } + + if (Glow.els.callPacketLossStat) { + Glow.els.callPacketLossStat.textContent = + state.metrics.packetLoss == null ? "—" : `${state.metrics.packetLoss}%`; + } + + if (Glow.els.callUptimeStat) { + Glow.els.callUptimeStat.textContent = + state.metrics.uptime == null ? "—" : `${state.metrics.uptime}%`; + } +}; + Glow.calls.connectWebSocket = async () => { Glow.calls.ensureState(); const state = Glow.calls.state(); @@ -604,6 +700,7 @@ Glow.calls.connectWebSocket = async () => { state.reconnecting = false; state.joining = false; Glow.calls.setStatusText("Connected"); + Glow.calls.updateSummaryMetrics(); settled = true; resolve(); }; @@ -641,6 +738,7 @@ Glow.calls.connectWebSocket = async () => { if (signal.type === "joined") { state.selfPeerId = signal.participantId; Glow.calls.setStatusText("Call connected"); + Glow.calls.updateSummaryMetrics(); Glow.renderCallUi(); Glow.calls.renderParticipants(); return; @@ -651,8 +749,10 @@ Glow.calls.connectWebSocket = async () => { Glow.calls.upsertPeer({ participantId: signal.participantId, roomId: signal.roomId, - username: signal.username || signal.participantId + username: signal.username || signal.participantId, + connectionState: "connecting" }); + Glow.calls.updateSummaryMetrics(); Glow.renderCallUi(); Glow.calls.renderParticipants(); @@ -668,6 +768,7 @@ Glow.calls.connectWebSocket = async () => { if (signal.type === "participant-left") { Glow.calls.removePeer(signal.participantId); + Glow.calls.updateSummaryMetrics(); Glow.renderCallUi(); Glow.calls.renderParticipants(); Glow.showToast("Peer left call."); @@ -697,7 +798,8 @@ Glow.calls.connectWebSocket = async () => { Glow.calls.upsertPeer({ participantId: from, roomId: signal.roomId, - username: signal.username || from + username: signal.username || from, + connectionState: "connecting" }); Glow.calls.renderParticipants(); @@ -893,6 +995,13 @@ Glow.calls.resetAll = () => { can_invite: false }; state.panelOpen = false; + state.metrics = { + packetLoss: null, + uptime: null, + latency: null, + elapsedSeconds: 0, + userCount: 0 + }; Glow.calls.debug("resetAll complete"); }; @@ -927,7 +1036,7 @@ Glow.calls.scheduleReconnect = () => { clearTimeout(state.reconnectTimer); state.reconnecting = true; - Glow.calls.setStatusText("Reconnecting…"); + Glow.calls.setStatusText("Reconnecting…"); Glow.renderCallUi(); state.reconnectTimer = setTimeout(async () => { @@ -938,7 +1047,7 @@ Glow.calls.scheduleReconnect = () => { Glow.calls.debug("reconnect attempt", scope); Glow.calls.resetTransportOnly(); - Glow.calls.setStatusText("Reconnecting…"); + Glow.calls.setStatusText("Reconnecting…"); state.session = await Glow.calls.fetchGroupCallSession(scope); state.permissions = state.session.permissions || state.permissions; state.scope = state.session.scope || state.scope; @@ -969,6 +1078,7 @@ Glow.calls.refreshBackendParticipants = async () => { Glow.state.calls.permissions = data.permissions || Glow.state.calls.permissions; Glow.state.calls.scope = data.scope || Glow.state.calls.scope; Glow.calls.renderParticipants(); + Glow.calls.updateSummaryMetrics(); Glow.renderCallUi(); Glow.calls.debug("backend participants refreshed", data); } catch (error) { @@ -1039,15 +1149,15 @@ Glow.renderCallUi = () => { if (!busy) { statusText = "Idle"; } else if (state.joining) { - statusText = "Joining call…"; + statusText = "Joining call…"; } else if (state.reconnecting) { - statusText = "Reconnecting…"; + statusText = "Reconnecting…"; } else if (!Glow.calls.wsReady()) { - statusText = "Connecting…"; + statusText = "Connecting…"; } else if (peerCount > 0) { - statusText = `Group call active · ${peerCount} peer(s)`; + statusText = `Group call active · ${peerCount} peer(s)`; } else { - statusText = "Call open · waiting for others"; + statusText = "Call open · waiting for others"; } Glow.els.callStatus.textContent = statusText; @@ -1083,11 +1193,77 @@ Glow.renderCallUi = () => { Glow.els.callParticipants.classList.toggle("hidden", !overlayVisible); } + Glow.calls.updateSummaryMetrics(); Glow.calls.renderParticipants(); Glow.calls.renderPendingInvites(); Glow.calls.renderHeaderCallChip(); }; +Glow.calls.applyPanelPosition = () => { + const overlay = Glow.els.callOverlay; + const drag = Glow.state.calls?.drag; + if (!overlay || !drag) return; + if (!Number.isFinite(drag.x) || !Number.isFinite(drag.y)) return; + + overlay.style.left = `${drag.x}px`; + overlay.style.top = `${drag.y}px`; + overlay.style.right = "auto"; + overlay.style.bottom = "auto"; + overlay.style.alignItems = "flex-start"; + overlay.style.justifyContent = "flex-start"; +}; + +Glow.calls.loadPanelPosition = () => { + const x = Number(localStorage.getItem("glow_call_panel_x")); + const y = Number(localStorage.getItem("glow_call_panel_y")); + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + Glow.state.calls.drag.x = x; + Glow.state.calls.drag.y = y; + Glow.calls.applyPanelPosition(); +}; + +Glow.calls.savePanelPosition = () => { + const drag = Glow.state.calls?.drag; + if (!drag) return; + if (!Number.isFinite(drag.x) || !Number.isFinite(drag.y)) return; + localStorage.setItem("glow_call_panel_x", String(drag.x)); + localStorage.setItem("glow_call_panel_y", String(drag.y)); +}; + +Glow.calls.beginDrag = event => { + Glow.calls.ensureState(); + const overlay = Glow.els.callOverlay; + if (!overlay) return; + + const rect = overlay.getBoundingClientRect(); + Glow.state.calls.drag.active = true; + Glow.state.calls.drag.x = rect.left; + Glow.state.calls.drag.y = rect.top; + Glow.state.calls.drag.offsetX = event.clientX - rect.left; + Glow.state.calls.drag.offsetY = event.clientY - rect.top; +}; + +Glow.calls.onDrag = event => { + const drag = Glow.state.calls?.drag; + const overlay = Glow.els.callOverlay; + if (!drag?.active || !overlay) return; + + const maxX = Math.max(0, window.innerWidth - overlay.offsetWidth); + const maxY = Math.max(0, window.innerHeight - overlay.offsetHeight); + + drag.x = Math.min(maxX, Math.max(0, event.clientX - drag.offsetX)); + drag.y = Math.min(maxY, Math.max(0, event.clientY - drag.offsetY)); + + Glow.calls.applyPanelPosition(); +}; + +Glow.calls.endDrag = () => { + const drag = Glow.state.calls?.drag; + if (!drag) return; + drag.active = false; + Glow.calls.savePanelPosition(); +}; + Glow.startCall = async () => { Glow.calls.ensureState(); @@ -1105,7 +1281,7 @@ Glow.startCall = async () => { try { Glow.state.calls.joining = true; - Glow.calls.setStatusText("Preparing call…"); + Glow.calls.setStatusText("Preparing call…"); const session = await Glow.calls.fetchGroupCallSession(scope); Glow.state.calls.session = session; @@ -1129,14 +1305,15 @@ Glow.startCall = async () => { Glow.state.calls.panelOpen = true; Glow.openCallPanel(); - Glow.calls.setStatusText("Connecting microphone…"); + Glow.calls.setStatusText("Connecting microphone…"); await Glow.calls.ensureLocalAudio(); - Glow.calls.setStatusText("Joining call…"); + Glow.calls.setStatusText("Joining call…"); await Glow.apiJoinGroupCall(session.group_session.id); await Glow.calls.connectWebSocket(); Glow.calls.startParticipantSync(); Glow.calls.startHeartbeat(); Glow.state.calls.joining = false; + Glow.calls.updateSummaryMetrics(); Glow.renderCallUi(); Glow.showToast("Group call ready."); } catch (error) { @@ -1164,7 +1341,7 @@ Glow.leaveCall = async () => { if (!activeCall) return; Glow.state.calls.leaving = true; - Glow.calls.setStatusText("Leaving call…"); + Glow.calls.setStatusText("Leaving call…"); try { Glow.calls.sendSignal({ type: "hangup" }); @@ -1199,7 +1376,7 @@ Glow.endCallForEveryone = async () => { if (!values) return; try { - Glow.calls.setStatusText("Ending call…"); + Glow.calls.setStatusText("Ending call…"); await Glow.apiEndGroupCall(activeCall.groupSession.id); Glow.showToast("Call ended."); await Glow.endLocalCallUi(); @@ -1331,7 +1508,7 @@ Glow.calls.openIncomingInviteDialog = async invite => { } Glow.state.calls.joining = true; - Glow.calls.setStatusText("Joining call…"); + Glow.calls.setStatusText("Joining call…"); const session = await Glow.calls.acceptInvite(invite.id); @@ -1356,16 +1533,17 @@ Glow.calls.openIncomingInviteDialog = async invite => { Glow.state.calls.panelOpen = true; Glow.openCallPanel(); - Glow.calls.setStatusText("Connecting microphone…"); + Glow.calls.setStatusText("Connecting microphone…"); await Glow.calls.ensureLocalAudio(); if (session.group_session?.id) { - Glow.calls.setStatusText("Joining call…"); + Glow.calls.setStatusText("Joining call…"); await Glow.apiJoinGroupCall(session.group_session.id); } await Glow.calls.connectWebSocket(); Glow.calls.startParticipantSync(); Glow.calls.startHeartbeat(); Glow.state.calls.joining = false; + Glow.calls.updateSummaryMetrics(); Glow.renderCallUi(); Glow.showToast("Joined group call."); } catch (error) { @@ -1429,3 +1607,29 @@ Glow.els.endCallButton?.addEventListener("click", () => { Glow.els.addToCallButton?.addEventListener("click", () => { Glow.openAddToCallDialog?.(); }); + +Glow.els.callDragHandle?.addEventListener("mousedown", event => { + event.preventDefault(); + Glow.calls.beginDrag(event); +}); + +document.addEventListener("mousemove", event => { + Glow.calls.onDrag?.(event); +}); + +document.addEventListener("mouseup", () => { + Glow.calls.endDrag?.(); +}); + +window.addEventListener("resize", () => { + if (Glow.state.calls?.panelOpen) { + Glow.calls.applyPanelPosition?.(); + } +}); + +Glow.calls.loadPanelPosition?.(); +setInterval(() => { + if (Glow.state.calls?.activeCall) { + Glow.calls.updateSummaryMetrics?.(); + } +}, 1000); \ No newline at end of file