/* Copyright 2026 Lotus Guild SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { ConnectionQuality, type LocalAudioTrack, type LocalTrackPublication, type Participant, ParticipantEvent, type RemoteAudioTrack, type RemoteParticipant, type Room as LivekitRoom, RoomEvent, Track, type TrackPublication, } from "livekit-client"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { widget } from "../widget"; import { LotusWidgetActions } from "./lotusActions"; import { lotusSendToHost } from "./lotusWidget"; export type CallQualityVerdict = "good" | "fair" | "poor" | "unknown"; export interface CallSummary { /** Wall-clock time connected to the SFU, ms. */ durationMs: number; /** LiveKit reconnect attempts during the call. */ reconnects: number; /** Time the local connection quality was reported poor/lost, ms. */ poorMs: number; verdict: CallQualityVerdict; /** [cinny #253] The breakdown behind the host's "Details" card. */ details?: CallSummaryDetails; } /** [cinny #253] Everything the host's post-call Details card shows. */ export interface CallSummaryDetails { /** Other Matrix users seen in the call (`@user:server`), first-seen order. */ participants: string[]; /** Most people in the call at once, you included. */ peakParticipants: number; quality: { /** Your connection reported poor/lost, ms. */ youPoorMs: number; /** Each other person's connection reported poor/lost, ms (only > 0). */ othersPoorMs: Record; /** Everyone, you included, poor at the same time (points at the server). */ everyonePoorMs: number; }; network: { rttMedianMs?: number; rttP95Ms?: number; /** Your microphone's packets the SFU reported lost, %. */ outboundLossPct?: number; /** Packets lost on the audio you received, %. */ inboundLossPct?: number; jitterMs?: number; }; activity: { speakingMs: number; mutedMs: number; screenshareMs: number }; } /** Time spent in an on/off state, e.g. muted. */ class Stopwatch { private since: number | undefined; private total = 0; public set(on: boolean, now: number): void { if (on && this.since === undefined) this.since = now; if (!on && this.since !== undefined) { this.total += now - this.since; this.since = undefined; } } public get on(): boolean { return this.since !== undefined; } public ms(now: number): number { return this.total + (this.since === undefined ? 0 : now - this.since); } } /** `@user:server:DEVICE` (a LiveKit identity) โ†’ `@user:server`. */ export function matrixUserOf(identity: string): string { if (!identity.startsWith("@")) return identity; const first = identity.indexOf(":"); const second = first === -1 ? -1 : identity.indexOf(":", first + 1); return second === -1 ? identity : identity.slice(0, second); } const percentile = (sorted: number[], p: number): number => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))]; /** * [cinny #253] Who was in the call, whose connection was poor and when, * network numbers, and your own activity. Fed by LiveKit events and periodic * stats samples; nothing leaves the frame except the final summary, sent to * the host only. */ export class CallDetailsTracker { // user -> identities (devices) currently in the call private readonly devices = new Map>(); private readonly seen: string[] = []; private peak = 1; private readonly poor = new Map(); private readonly youPoor = new Stopwatch(); private readonly everyonePoor = new Stopwatch(); private readonly rtts: number[] = []; private readonly jitters: number[] = []; private readonly sent = new Map(); private readonly received = new Map< string, { packets: number; lost: number } >(); private readonly speaking = new Stopwatch(); private readonly muted = new Stopwatch(); private readonly sharing = new Stopwatch(); public constructor(private readonly now: () => number = () => Date.now()) {} /** Idempotent per device: safe to call for everyone already present too. */ public remoteJoined(identity: string): void { const user = matrixUserOf(identity); let ids = this.devices.get(user); if (!ids) this.devices.set(user, (ids = new Set())); ids.add(identity); if (!this.seen.includes(user)) this.seen.push(user); this.peak = Math.max(this.peak, this.devices.size + 1); this.updateEveryone(); } public remoteLeft(identity: string): void { const user = matrixUserOf(identity); const ids = this.devices.get(user); ids?.delete(identity); if (!ids || ids.size === 0) { this.devices.delete(user); this.poor.get(user)?.set(false, this.now()); } this.updateEveryone(); } /** `identity` undefined = the local participant. */ public setQuality( identity: string | undefined, quality: ConnectionQuality, ): void { if (quality === ConnectionQuality.Unknown) return; const bad = quality === ConnectionQuality.Poor || quality === ConnectionQuality.Lost; if (identity === undefined) this.youPoor.set(bad, this.now()); else { const user = matrixUserOf(identity); let sw = this.poor.get(user); if (!sw) this.poor.set(user, (sw = new Stopwatch())); sw.set(bad, this.now()); } this.updateEveryone(); } private updateEveryone(): void { const others = [...this.devices.keys()]; const all = this.youPoor.on && others.length > 0 && others.every((u) => this.poor.get(u)?.on); this.everyonePoor.set(all, this.now()); } public addRtt(seconds: number | undefined): void { if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0) this.rtts.push(seconds * 1000); } public addJitter(seconds: number | undefined): void { if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0) this.jitters.push(seconds * 1000); } /** Cumulative counters for one sent/received track (latest value wins). */ public setSent(key: string, packets?: number, lost?: number): void { if (typeof packets === "number") this.sent.set(key, { packets, lost: Math.max(0, lost ?? 0) }); } public setReceived(key: string, packets?: number, lost?: number): void { if (typeof packets === "number") this.received.set(key, { packets, lost: Math.max(0, lost ?? 0) }); } public setSpeaking(on: boolean): void { this.speaking.set(on, this.now()); } public setMuted(on: boolean): void { this.muted.set(on, this.now()); } public setScreensharing(on: boolean): void { this.sharing.set(on, this.now()); } public details(): CallSummaryDetails { const now = this.now(); const othersPoorMs: Record = {}; for (const [user, sw] of this.poor) { const ms = sw.ms(now); if (ms > 0) othersPoorMs[user] = ms; } const lossPct = ( m: Map, ): number | undefined => { let packets = 0; let lost = 0; for (const v of m.values()) { packets += v.packets; lost += v.lost; } return packets + lost > 0 ? Math.round((lost / (packets + lost)) * 1000) / 10 : undefined; }; const rtts = [...this.rtts].sort((a, b) => a - b); const network: CallSummaryDetails["network"] = {}; if (rtts.length) { network.rttMedianMs = Math.round(percentile(rtts, 0.5)); network.rttP95Ms = Math.round(percentile(rtts, 0.95)); } const out = lossPct(this.sent); if (out !== undefined) network.outboundLossPct = out; const inn = lossPct(this.received); if (inn !== undefined) network.inboundLossPct = inn; if (this.jitters.length) network.jitterMs = Math.round( this.jitters.reduce((a, b) => a + b, 0) / this.jitters.length, ); return { participants: [...this.seen], peakParticipants: this.peak, quality: { youPoorMs: this.youPoor.ms(now), othersPoorMs, everyonePoorMs: this.everyonePoor.ms(now), }, network, activity: { speakingMs: this.speaking.ms(now), mutedMs: this.muted.ms(now), screenshareMs: this.sharing.ms(now), }, }; } } /** * [Gitea #143] Per-call connection quality, kept in memory for the local * participant only and summarised once at hangup. Nothing is stored or sent * anywhere but to the host at the end. */ export class CallQualityTracker { private startedAt: number | undefined; private poorSince: number | undefined; private poorMs = 0; private reconnects = 0; private sampled = false; public constructor(private readonly now: () => number = () => Date.now()) {} public start(): void { if (this.startedAt === undefined) this.startedAt = this.now(); } public setQuality(quality: ConnectionQuality): void { if (quality === ConnectionQuality.Unknown) return; this.sampled = true; const bad = quality === ConnectionQuality.Poor || quality === ConnectionQuality.Lost; if (bad && this.poorSince === undefined) this.poorSince = this.now(); if (!bad && this.poorSince !== undefined) { this.poorMs += this.now() - this.poorSince; this.poorSince = undefined; } } public reconnect(): void { this.reconnects += 1; } public summary(): CallSummary { const end = this.now(); const durationMs = this.startedAt === undefined ? 0 : Math.max(0, end - this.startedAt); const poorMs = this.poorMs + (this.poorSince === undefined ? 0 : end - this.poorSince); let verdict: CallQualityVerdict = "unknown"; if (this.sampled && durationMs > 0) { const poorShare = poorMs / durationMs; if (poorShare < 0.05 && this.reconnects <= 1) verdict = "good"; else if (poorShare < 0.25 && this.reconnects <= 3) verdict = "fair"; else verdict = "poor"; } return { durationMs, reconnects: this.reconnects, poorMs, verdict }; } } /** * Track the local participant's LiveKit connection quality and reconnects * for the life of the in-call view, and send `io.lotus.call_summary` to the * host once โ€” on the first SFU disconnect or on teardown, whichever comes * first โ€” so the host can show "41 min ยท connection was good" at hangup. */ export function startLotusCallSummary(vm: CallViewModel): () => void { if (!widget) return () => undefined; const tracker = new CallQualityTracker(); const details = new CallDetailsTracker(); let sent = false; const send = (): void => { if (sent) return; sent = true; clearInterval(sampler); lotusSendToHost(LotusWidgetActions.CallSummary, { ...tracker.summary(), details: details.details(), }); }; // [cinny #253] Network numbers: sample sender/receiver stats every 5 s. const sample = async (room: LivekitRoom): Promise => { const mic = room.localParticipant.getTrackPublication( Track.Source.Microphone, )?.track as LocalAudioTrack | undefined; const s = await mic?.getSenderStats?.().catch(() => undefined); if (s) { details.addRtt(s.roundTripTime); details.setSent(`${room.name}:mic`, s.packetsSent, s.packetsLost); } for (const p of room.remoteParticipants.values()) { for (const pub of p.audioTrackPublications.values()) { const t = pub.track as RemoteAudioTrack | undefined; const r = await t?.getReceiverStats?.().catch(() => undefined); if (r) { details.addJitter(r.jitter); details.setReceived( `${room.name}:${p.identity}:${pub.trackSid}`, r.packetsReceived, r.packetsLost, ); } } } }; const sampler = setInterval(() => { for (const room of listeners.keys()) void sample(room).catch(() => undefined); }, 5_000); const listeners = new Map void>(); const attach = (room: LivekitRoom): void => { const onQuality = ( quality: ConnectionQuality, participant: Participant, ): void => { if (participant.isLocal) tracker.setQuality(quality); details.setQuality( participant.isLocal ? undefined : participant.identity, quality, ); }; // LiveKit doesn't send ParticipantConnected for people already in the // room when we connect, and the mic isn't published yet at attach time: // record both once the connection is up. const onConnected = (): void => { tracker.start(); for (const p of room.remoteParticipants.values()) details.remoteJoined(p.identity); details.setMuted(!room.localParticipant.isMicrophoneEnabled); }; const onReconnecting = (): void => tracker.reconnect(); const onDisconnected = (): void => send(); const onJoined = (p: RemoteParticipant): void => details.remoteJoined(p.identity); const onLeft = (p: RemoteParticipant): void => details.remoteLeft(p.identity); const isLocalMic = (pub: TrackPublication, p: Participant): boolean => p.isLocal && pub.source === Track.Source.Microphone; const onMuted = (pub: TrackPublication, p: Participant): void => { if (isLocalMic(pub, p)) details.setMuted(true); }; const onUnmuted = (pub: TrackPublication, p: Participant): void => { if (isLocalMic(pub, p)) details.setMuted(false); }; const onLocalPublished = (pub: LocalTrackPublication): void => { if (pub.source === Track.Source.ScreenShare) details.setScreensharing(true); if (pub.source === Track.Source.Microphone) details.setMuted(pub.isMuted); }; const onLocalUnpublished = (pub: LocalTrackPublication): void => { if (pub.source === Track.Source.ScreenShare) details.setScreensharing(false); }; const onSpeaking = (speaking: boolean): void => details.setSpeaking(speaking); room.on(RoomEvent.ConnectionQualityChanged, onQuality); room.on(RoomEvent.Connected, onConnected); room.on(RoomEvent.Reconnecting, onReconnecting); room.on(RoomEvent.Disconnected, onDisconnected); room.on(RoomEvent.ParticipantConnected, onJoined); room.on(RoomEvent.ParticipantDisconnected, onLeft); room.on(RoomEvent.TrackMuted, onMuted); room.on(RoomEvent.TrackUnmuted, onUnmuted); room.on(RoomEvent.LocalTrackPublished, onLocalPublished); room.on(RoomEvent.LocalTrackUnpublished, onLocalUnpublished); room.localParticipant.on(ParticipantEvent.IsSpeakingChanged, onSpeaking); if (room.state === "connected") onConnected(); listeners.set(room, () => { room.off(RoomEvent.ConnectionQualityChanged, onQuality); room.off(RoomEvent.Connected, onConnected); room.off(RoomEvent.Reconnecting, onReconnecting); room.off(RoomEvent.Disconnected, onDisconnected); room.off(RoomEvent.ParticipantConnected, onJoined); room.off(RoomEvent.ParticipantDisconnected, onLeft); room.off(RoomEvent.TrackMuted, onMuted); room.off(RoomEvent.TrackUnmuted, onUnmuted); room.off(RoomEvent.LocalTrackPublished, onLocalPublished); room.off(RoomEvent.LocalTrackUnpublished, onLocalUnpublished); room.localParticipant.off(ParticipantEvent.IsSpeakingChanged, onSpeaking); }); }; const sub = vm.allConnections$.subscribe((data) => { const next = data.getConnections().map((c) => c.livekitRoom); for (const [room, off] of listeners) { if (!next.includes(room)) { off(); listeners.delete(room); } } for (const room of next) if (!listeners.has(room)) attach(room); }); return () => { sub.unsubscribe(); for (const off of listeners.values()) off(); listeners.clear(); send(); }; }