Files
element-call/src/lotus/lotusCallSummary.ts
T
Lotus CIandClaude Opus 5 1e34923f6a feat(lotus): io.lotus.call_summary — per-call quality readout at hangup (#143)
Tracks the local participant's LiveKit ConnectionQuality (time spent
poor/lost) and Reconnecting events for the life of the in-call view and
sends one io.lotus.call_summary { durationMs, reconnects, poorMs, verdict }
to the host on the first SFU disconnect or on teardown. Nothing is stored
or sent anywhere else. CallQualityTracker is pure and unit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:39:18 -04:00

145 lines
4.6 KiB
TypeScript

/*
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<LivekitRoom, () => 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();
};
}