/* 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 Participant, type Room as LivekitRoom, RoomEvent, } 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; } /** * [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(); let sent = false; const send = (): void => { if (sent) return; sent = true; lotusSendToHost(LotusWidgetActions.CallSummary, tracker.summary()); }; const listeners = new Map void>(); const attach = (room: LivekitRoom): void => { const onQuality = ( quality: ConnectionQuality, participant: Participant, ): void => { if (participant.isLocal) tracker.setQuality(quality); }; const onConnected = (): void => tracker.start(); const onReconnecting = (): void => tracker.reconnect(); const onDisconnected = (): void => send(); room.on(RoomEvent.ConnectionQualityChanged, onQuality); room.on(RoomEvent.Connected, onConnected); room.on(RoomEvent.Reconnecting, onReconnecting); room.on(RoomEvent.Disconnected, onDisconnected); if (room.state === "connected") tracker.start(); listeners.set(room, () => { room.off(RoomEvent.ConnectionQualityChanged, onQuality); room.off(RoomEvent.Connected, onConnected); room.off(RoomEvent.Reconnecting, onReconnecting); room.off(RoomEvent.Disconnected, onDisconnected); }); }; 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(); }; }