Skip to content

Commit

Permalink
Merge pull request #85 from cloudflare/improved-connection-indicators
Browse files Browse the repository at this point in the history
Add connection quality indicators
  • Loading branch information
third774 authored Aug 19, 2024
2 parents 4fe866e + 783d0e1 commit d679348
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 43 deletions.
40 changes: 40 additions & 0 deletions app/components/ConnectionIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip
open={open}
onOpenChange={setOpen}
content={`Connection is ${props.quality}`}
>
<button onClick={() => setOpen(!open)}>
<Icon
className={cn(
props.quality === 'healthy' && 'text-green-400',
props.quality === 'tolerable' && 'text-green-400',
props.quality === 'unhealthy' && 'text-yellow-400',
props.quality === 'bad' && 'text-red-400'
)}
type={
props.quality === 'bad' || props.quality === 'unhealthy'
? 'SignalSlashIcon'
: 'SignalIcon'
}
/>
</button>
</Tooltip>
)
}
36 changes: 5 additions & 31 deletions app/components/HighPacketLossWarningsToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -43,42 +43,16 @@ export function HighPacketLossWarningsToast() {
return null
}

const inbound = (inboundPacketLossPercentage * 100).toFixed(2)
const outbound = (outboundPacketLossPercentage * 100).toFixed(2)

return (
<Root duration={Infinity}>
<div className="space-y-2 text-sm">
<div className="font-bold">
<Toast.Title className="flex items-center gap-2">
<Icon type="WifiIcon" />
<Icon type="SignalSlashIcon" />
Unstable connection
</Toast.Title>
</div>
<Toast.Description className="space-y-2">
<div>Call quality may be affected.</div>
<div className="text-gray-500 dark:text-gray-200">
<div>Packet Loss</div>
<div className="flex gap-4">
<div className="flex items-center gap-1">
<div className="sr-only">Outbound</div>
<Icon
className="text-gray-400 dark:text-gray-300"
type="ArrowUpOnSquareIcon"
/>
<span>{outbound}%</span>
</div>
<div className="flex items-center gap-1">
<div className="sr-only">Inbound</div>
<Icon
className="text-gray-400 dark:text-gray-300"
type="ArrowDownOnSquareIcon"
/>
<span>{inbound}%</span>
</div>
</div>
</div>
</Toast.Description>
<Toast.Description>Call quality may be affected.</Toast.Description>
</div>
</Root>
)
Expand Down
2 changes: 2 additions & 0 deletions app/components/Icon/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
PhoneXMarkIcon,
PlusIcon,
ServerStackIcon,
SignalIcon,
SignalSlashIcon,
UserGroupIcon,
VideoCameraIcon,
Expand Down Expand Up @@ -52,6 +53,7 @@ const iconMap = {
EllipsisVerticalIcon,
ClipboardDocumentCheckIcon,
ClipboardDocumentIcon,
SignalIcon,
SignalSlashIcon,
ExclamationCircleIcon,
ServerStackIcon,
Expand Down
45 changes: 35 additions & 10 deletions app/components/Participant.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
<div
className="grow shrink text-base basis-[calc(var(--flex-container-width)_-_var(--gap)_*_3)]"
Expand Down Expand Up @@ -170,14 +190,19 @@ export const Participant = forwardRef<
</div>
)}
{data?.displayName && user.transceiverSessionId && (
<OptionalLink
className="absolute m-2 leading-none text-shadow left-1 bottom-1"
href={populateTraceLink(user.transceiverSessionId, traceLink)}
target="_blank"
rel="noopener noreferrer"
>
{data.displayName}
</OptionalLink>
<div className="flex items-center gap-2 absolute m-2 text-shadow left-1 bottom-1">
<ConnectionIndicator
quality={getConnectionQuality(packetLoss)}
/>
<OptionalLink
className="leading-none"
href={populateTraceLink(user.transceiverSessionId, traceLink)}
target="_blank"
rel="noopener noreferrer"
>
{data.displayName}
</OptionalLink>
</div>
)}
<div className="absolute top-0 right-0 flex gap-4 p-4">
{user.raisedHand && (
Expand Down
10 changes: 8 additions & 2 deletions app/components/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TooltipProps> = ({ children, content, open }) => {
export const Tooltip: FC<TooltipProps> = ({
children,
content,
open,
onOpenChange,
}) => {
if (content === undefined) return <>{children}</>

return (
<RadixTooltip.Provider>
<RadixTooltip.Root open={open}>
<RadixTooltip.Root open={open} onOpenChange={onOpenChange}>
<RadixTooltip.Trigger asChild>{children}</RadixTooltip.Trigger>
<RadixTooltip.Portal>
<RadixTooltip.Content className="bg-zinc-100 dark:bg-zinc-600 text-sm px-2 py-1 drop-shadow-md dark:drop-shadow-none rounded">
Expand Down
16 changes: 16 additions & 0 deletions app/utils/rxjs/ewma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Observable } from 'rxjs'
import Ewma from '../ewma'

export const ewma =
(halflifeTime: number, defaultValue = 0) =>
(observable: Observable<number>) =>
new Observable<number>((subscribe) => {
const ewma = new Ewma(halflifeTime, defaultValue)
observable.subscribe({
...subscribe,
next: (value) => {
ewma.insert(value)
subscribe.next(ewma.value())
},
})
})
95 changes: 95 additions & 0 deletions app/utils/rxjs/getPacketLoss$.ts
Original file line number Diff line number Diff line change
@@ -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<RTCPeerConnection>,
statReportInterval = 3000
) {
return combineLatest([peerConnection$, interval(statReportInterval)]).pipe(
switchMap(([peerConnection]) => peerConnection.getStats()),
pairwise()
)
}

export function getPacketLoss$(
peerConnection$: Observable<RTCPeerConnection>,
tracks$: Observable<MediaStreamTrack[]>
) {
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<MediaStreamTrack, string>())
const relevantMids = new Set<string>()
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
})
)
}

0 comments on commit d679348

Please sign in to comment.