Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add connection quality indicators #85

Merged
merged 1 commit into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
})
)
}