From 783d0e10c7fc6fbca75c0811f19caa8a5ef7439a Mon Sep 17 00:00:00 2001 From: Kevin Kipp Date: Thu, 15 Aug 2024 23:09:08 -0500 Subject: [PATCH] Add connection indicators --- app/components/ConnectionIndicator.tsx | 40 ++++++++ .../HighPacketLossWarningsToast.tsx | 36 +------ app/components/Icon/Icon.tsx | 2 + app/components/Participant.tsx | 45 +++++++-- app/components/Tooltip.tsx | 10 +- app/utils/rxjs/ewma.ts | 16 ++++ app/utils/rxjs/getPacketLoss$.ts | 95 +++++++++++++++++++ 7 files changed, 201 insertions(+), 43 deletions(-) create mode 100644 app/components/ConnectionIndicator.tsx create mode 100644 app/utils/rxjs/ewma.ts create mode 100644 app/utils/rxjs/getPacketLoss$.ts diff --git a/app/components/ConnectionIndicator.tsx b/app/components/ConnectionIndicator.tsx new file mode 100644 index 00000000..85125ae2 --- /dev/null +++ b/app/components/ConnectionIndicator.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react' +import { cn } from '~/utils/style' +import { Icon } from './Icon/Icon' +import { Tooltip } from './Tooltip' + +export type ConnectionQuality = 'healthy' | 'tolerable' | 'unhealthy' | 'bad' + +export function getConnectionQuality(packetLoss: number): ConnectionQuality { + if (packetLoss > 0.05) return 'bad' + if (packetLoss > 0.03) return 'unhealthy' + if (packetLoss > 0.01) return 'tolerable' + return 'healthy' +} + +export function ConnectionIndicator(props: { quality: ConnectionQuality }) { + const [open, setOpen] = useState(false) + return ( + + + + ) +} diff --git a/app/components/HighPacketLossWarningsToast.tsx b/app/components/HighPacketLossWarningsToast.tsx index ba7dfd19..697a857b 100644 --- a/app/components/HighPacketLossWarningsToast.tsx +++ b/app/components/HighPacketLossWarningsToast.tsx @@ -27,9 +27,9 @@ export function HighPacketLossWarningsToast() { const hasIssues = useConditionForAtLeast( inboundPacketLossPercentage !== undefined && outboundPacketLossPercentage !== undefined && - (inboundPacketLossPercentage > 0.01 || - outboundPacketLossPercentage > 0.01), - 3000 + (inboundPacketLossPercentage > 0.05 || + outboundPacketLossPercentage > 0.05), + 5000 ) if ( @@ -43,42 +43,16 @@ export function HighPacketLossWarningsToast() { return null } - const inbound = (inboundPacketLossPercentage * 100).toFixed(2) - const outbound = (outboundPacketLossPercentage * 100).toFixed(2) - return (
- + Unstable connection
- -
Call quality may be affected.
-
-
Packet Loss
-
-
-
Outbound
- - {outbound}% -
-
-
Inbound
- - {inbound}% -
-
-
-
+ Call quality may be affected.
) diff --git a/app/components/Icon/Icon.tsx b/app/components/Icon/Icon.tsx index eee949b7..d15e1bff 100644 --- a/app/components/Icon/Icon.tsx +++ b/app/components/Icon/Icon.tsx @@ -19,6 +19,7 @@ import { PhoneXMarkIcon, PlusIcon, ServerStackIcon, + SignalIcon, SignalSlashIcon, UserGroupIcon, VideoCameraIcon, @@ -52,6 +53,7 @@ const iconMap = { EllipsisVerticalIcon, ClipboardDocumentCheckIcon, ClipboardDocumentIcon, + SignalIcon, SignalSlashIcon, ExclamationCircleIcon, ServerStackIcon, diff --git a/app/components/Participant.tsx b/app/components/Participant.tsx index 074726ce..73ef484b 100644 --- a/app/components/Participant.tsx +++ b/app/components/Participant.tsx @@ -1,15 +1,24 @@ import { VisuallyHidden } from '@radix-ui/react-visually-hidden' -import { forwardRef, useEffect } from 'react' +import { forwardRef, useEffect, useMemo } from 'react' import { Flipped } from 'react-flip-toolkit' +import { of } from 'rxjs' +import { useSubscribedState } from '~/hooks/rxjsHooks' import { useDeadPulledTrackMonitor } from '~/hooks/useDeadPulledTrackMonitor' import { useRoomContext } from '~/hooks/useRoomContext' import { useUserMetadata } from '~/hooks/useUserMetadata' import type { User } from '~/types/Messages' +import isNonNullable from '~/utils/isNonNullable' import populateTraceLink from '~/utils/populateTraceLink' +import { ewma } from '~/utils/rxjs/ewma' +import { getPacketLoss$ } from '~/utils/rxjs/getPacketLoss$' import { cn } from '~/utils/style' import { AudioGlow } from './AudioGlow' import { AudioIndicator } from './AudioIndicator' import { Button } from './Button' +import { + ConnectionIndicator, + getConnectionQuality, +} from './ConnectionIndicator' import { HoverFade } from './HoverFade' import { Icon } from './Icon/Icon' import { MuteUserButton } from './MuteUserButton' @@ -44,7 +53,7 @@ export const Participant = forwardRef< ref ) => { const { data } = useUserMetadata(user.name) - const { traceLink } = useRoomContext() + const { traceLink, peer } = useRoomContext() useDeadPulledTrackMonitor( user.tracks.video, @@ -70,6 +79,17 @@ export const Participant = forwardRef< } }, [flipId, isScreenShare, setPinnedId]) + const packetLoss$ = useMemo( + () => + getPacketLoss$( + peer.peerConnection$, + of([audioTrack, videoTrack].filter(isNonNullable)) + ).pipe(ewma(5000)), + [audioTrack, peer.peerConnection$, videoTrack] + ) + + const packetLoss = useSubscribedState(packetLoss$, 0) + return (
)} {data?.displayName && user.transceiverSessionId && ( - - {data.displayName} - +
+ + + {data.displayName} + +
)}
{user.raisedHand && ( diff --git a/app/components/Tooltip.tsx b/app/components/Tooltip.tsx index 933a7658..a4899d09 100644 --- a/app/components/Tooltip.tsx +++ b/app/components/Tooltip.tsx @@ -3,16 +3,22 @@ import type { FC, ReactNode } from 'react' interface TooltipProps { open?: boolean + onOpenChange?: (open: boolean) => void content?: ReactNode children: ReactNode } -export const Tooltip: FC = ({ children, content, open }) => { +export const Tooltip: FC = ({ + children, + content, + open, + onOpenChange, +}) => { if (content === undefined) return <>{children} return ( - + {children} diff --git a/app/utils/rxjs/ewma.ts b/app/utils/rxjs/ewma.ts new file mode 100644 index 00000000..05cee0ed --- /dev/null +++ b/app/utils/rxjs/ewma.ts @@ -0,0 +1,16 @@ +import { Observable } from 'rxjs' +import Ewma from '../ewma' + +export const ewma = + (halflifeTime: number, defaultValue = 0) => + (observable: Observable) => + new Observable((subscribe) => { + const ewma = new Ewma(halflifeTime, defaultValue) + observable.subscribe({ + ...subscribe, + next: (value) => { + ewma.insert(value) + subscribe.next(ewma.value()) + }, + }) + }) diff --git a/app/utils/rxjs/getPacketLoss$.ts b/app/utils/rxjs/getPacketLoss$.ts new file mode 100644 index 00000000..9d6ab91a --- /dev/null +++ b/app/utils/rxjs/getPacketLoss$.ts @@ -0,0 +1,95 @@ +import type { Observable } from 'rxjs' +import { combineLatest, interval, map, pairwise, switchMap } from 'rxjs' + +export interface PacketLossStats { + inboundPacketLossPercentage: number + outboundPacketLossPercentage: number +} + +function statsReports$( + peerConnection$: Observable, + statReportInterval = 3000 +) { + return combineLatest([peerConnection$, interval(statReportInterval)]).pipe( + switchMap(([peerConnection]) => peerConnection.getStats()), + pairwise() + ) +} + +export function getPacketLoss$( + peerConnection$: Observable, + tracks$: Observable +) { + return combineLatest([ + tracks$, + peerConnection$, + statsReports$(peerConnection$), + ]).pipe( + map(([tracks, peerConnection, [previousStatsReport, newStatsReport]]) => { + const trackToMidMap = peerConnection + .getTransceivers() + .reduce((map, t) => { + const track = t.sender.track ?? t.receiver.track + if (track !== null && t.mid !== null) { + map.set(track, t.mid) + } + return map + }, new Map()) + const relevantMids = new Set() + for (const track of tracks) { + const mid = trackToMidMap.get(track) + if (mid) { + relevantMids.add(mid) + } + } + let inboundPacketsReceived = 0 + let inboundPacketsLost = 0 + let outboundPacketsSent = 0 + let outboundPacketsLost = 0 + + newStatsReport.forEach((report) => { + if (!relevantMids.has(report.mid)) return + const previous = previousStatsReport.get(report.id) + if (!previous) return + + if (report.type === 'inbound-rtp') { + inboundPacketsLost += report.packetsLost - previous.packetsLost + inboundPacketsReceived += + report.packetsReceived - previous.packetsReceived + } else if (report.type === 'outbound-rtp') { + const packetsSent = report.packetsSent - previous.packetsSent + // Find the corresponding remote-inbound-rtp report + const remoteInboundReport = Array.from(newStatsReport.values()).find( + (r) => r.type === 'remote-inbound-rtp' && r.ssrc === report.ssrc + ) + const previousRemoteInboundReport = Array.from( + previousStatsReport.values() + ).find( + (r) => r.type === 'remote-inbound-rtp' && r.ssrc === previous.ssrc + ) + if ( + remoteInboundReport && + previousRemoteInboundReport && + packetsSent > 0 + ) { + outboundPacketsSent += report.packetsSent - previous.packetsSent + outboundPacketsLost += + remoteInboundReport.packetsLost - + previousRemoteInboundReport.packetsLost + } + } + }) + + let packetsLost = inboundPacketsLost + outboundPacketsLost + let packetsSent = + inboundPacketsReceived + outboundPacketsSent + packetsLost + let packetLossPercentage = 0 + + if (packetsSent > 0) { + packetLossPercentage = Math.max(0, packetsLost / packetsSent) + } + + return packetLossPercentage + }) + ) +}