diff --git a/app/components/ConnectionInformation.tsx b/app/components/ConnectionInformation.tsx new file mode 100644 index 00000000..863b09e3 --- /dev/null +++ b/app/components/ConnectionInformation.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react' +import type { User } from '~/types/Messages' +import { cn } from '~/utils/style' +import { Icon } from './Icon/Icon' +import { Tooltip } from './Tooltip' + +export function ConnectionInformation(props: { user: User }) { + const { inboundPacketLoss, outboundPacketLoss } = + props.user.connectionInformation + const [open, setOpen] = useState(false) + + const inbound = (inboundPacketLoss * 100).toFixed(2) + const outbound = (outboundPacketLoss * 100).toFixed(2) + + const connectionGood = inboundPacketLoss <= 0.01 && outboundPacketLoss <= 0.01 + const connectionUnstable = + (inboundPacketLoss > 0.01 && inboundPacketLoss <= 0.03) || + (outboundPacketLoss > 0.01 && outboundPacketLoss <= 0.03) + const connectionBad = inboundPacketLoss > 0.03 || outboundPacketLoss > 0.03 + + return ( + +
Packet Loss
+
+
+
Outbound
+ + {outbound}% +
+
+
Inbound
+ + {inbound}% +
+
+ + } + > + +
+ ) +} diff --git a/app/components/Dialog.tsx b/app/components/Dialog.tsx index 68978979..a56120fa 100644 --- a/app/components/Dialog.tsx +++ b/app/components/Dialog.tsx @@ -31,8 +31,8 @@ export const DialogContent = forwardRef< 'dark:shadow-none' )} > - {props.children} + {props.children} )) diff --git a/app/components/HighPacketLossWarningsToast.tsx b/app/components/HighPacketLossWarningsToast.tsx index ba7dfd19..3d88396d 100644 --- a/app/components/HighPacketLossWarningsToast.tsx +++ b/app/components/HighPacketLossWarningsToast.tsx @@ -1,28 +1,15 @@ -import { useMemo } from 'react' import Toast, { Root } from '~/components/Toast' -import { useSubscribedState } from '~/hooks/rxjsHooks' import { useConditionForAtLeast } from '~/hooks/useConditionForAtLeast' -import { getPacketLossStats$ } from '~/utils/rxjs/getPacketLossStats$' -import { useRoomContext } from '../hooks/useRoomContext' +import { useRoomContext } from '~/hooks/useRoomContext' import { Icon } from './Icon/Icon' -function useStats() { - const { peer } = useRoomContext() - const stats$ = useMemo( - () => getPacketLossStats$(peer.peerConnection$), - [peer.peerConnection$] - ) - const stats = useSubscribedState(stats$, { - inboundPacketLossPercentage: 0, - outboundPacketLossPercentage: 0, - }) - - return stats -} - export function HighPacketLossWarningsToast() { - const { inboundPacketLossPercentage, outboundPacketLossPercentage } = - useStats() + const { + connectionStats: { + inboundPacketLossPercentage, + outboundPacketLossPercentage, + }, + } = useRoomContext() const hasIssues = useConditionForAtLeast( inboundPacketLossPercentage !== undefined && 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..f4b5f385 100644 --- a/app/components/Participant.tsx +++ b/app/components/Participant.tsx @@ -10,6 +10,7 @@ import { cn } from '~/utils/style' import { AudioGlow } from './AudioGlow' import { AudioIndicator } from './AudioIndicator' import { Button } from './Button' +import { ConnectionInformation } from './ConnectionInformation' import { HoverFade } from './HoverFade' import { Icon } from './Icon/Icon' import { MuteUserButton } from './MuteUserButton' @@ -169,16 +170,19 @@ export const Participant = forwardRef< )} )} - {data?.displayName && user.transceiverSessionId && ( - - {data.displayName} - - )} +
+ + {data?.displayName && user.transceiverSessionId && ( + + {data.displayName} + + )} +
{user.raisedHand && ( diff --git a/app/components/ParticipantsMenu.tsx b/app/components/ParticipantsMenu.tsx index 336e1950..9deff634 100644 --- a/app/components/ParticipantsMenu.tsx +++ b/app/components/ParticipantsMenu.tsx @@ -6,6 +6,7 @@ import populateTraceLink from '~/utils/populateTraceLink' import { cn } from '~/utils/style' import { AudioIndicator } from './AudioIndicator' import { Button } from './Button' +import { ConnectionInformation } from './ConnectionInformation' import { Dialog, DialogContent, DialogOverlay, Portal, Trigger } from './Dialog' import { Icon } from './Icon/Icon' import { MuteUserButton } from './MuteUserButton' @@ -40,6 +41,7 @@ const UserListItem: FC<{
)} + ) @@ -93,7 +95,6 @@ export const ParticipantsDialog: FC = ({ return ( {children} - 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/durableObjects/ChatRoom.server.ts b/app/durableObjects/ChatRoom.server.ts index eb0082d9..73483eda 100644 --- a/app/durableObjects/ChatRoom.server.ts +++ b/app/durableObjects/ChatRoom.server.ts @@ -63,6 +63,10 @@ export class ChatRoom extends Server { videoEnabled: false, screenShareEnabled: false, }, + connectionInformation: { + inboundPacketLoss: 0, + outboundPacketLoss: 0, + }, } connection.setState(user) diff --git a/app/hooks/useBroadcastStatus.ts b/app/hooks/useBroadcastStatus.ts index 6c118b06..fd54d836 100644 --- a/app/hooks/useBroadcastStatus.ts +++ b/app/hooks/useBroadcastStatus.ts @@ -14,6 +14,7 @@ interface Config { identity?: User websocket: PartySocket pushedTracks: RoomContextType['pushedTracks'] + connectionStats: RoomContextType['connectionStats'] raisedHand: boolean speaking: boolean } @@ -26,6 +27,7 @@ export default function useBroadcastStatus({ pushedTracks, raisedHand, speaking, + connectionStats, }: Config) { const { audioEnabled, videoEnabled, screenShareEnabled } = userMedia const { audio, video, screenshare } = pushedTracks @@ -50,6 +52,10 @@ export default function useBroadcastStatus({ audio, screenshare, }, + connectionInformation: { + inboundPacketLoss: connectionStats.inboundPacketLossPercentage, + outboundPacketLoss: connectionStats.outboundPacketLossPercentage, + }, } function sendUserUpdate() { @@ -82,6 +88,8 @@ export default function useBroadcastStatus({ screenShareEnabled, raisedHand, speaking, + connectionStats.inboundPacketLossPercentage, + connectionStats.outboundPacketLossPercentage, ]) useUnmount(() => { @@ -97,6 +105,10 @@ export default function useBroadcastStatus({ speaking, transceiverSessionId: sessionId, tracks: {}, + connectionInformation: { + inboundPacketLoss: 0, + outboundPacketLoss: 0, + }, }, } satisfies ClientMessage) ) diff --git a/app/hooks/useConnectionStats.ts b/app/hooks/useConnectionStats.ts new file mode 100644 index 00000000..53b9f069 --- /dev/null +++ b/app/hooks/useConnectionStats.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react' +import { getPacketLossStats$ } from '~/utils/rxjs/getPacketLossStats$' +import type { RxjsPeer } from '~/utils/rxjs/RxjsPeer.client' +import { useSubscribedState } from './rxjsHooks' + +export function useConnectionStats(peer: RxjsPeer) { + const stats$ = useMemo( + () => getPacketLossStats$(peer.peerConnection$), + [peer.peerConnection$] + ) + const stats = useSubscribedState(stats$, { + inboundPacketLossPercentage: 0, + outboundPacketLossPercentage: 0, + }) + + return stats +} diff --git a/app/hooks/useRoomContext.ts b/app/hooks/useRoomContext.ts index 9841de01..98e5c543 100644 --- a/app/hooks/useRoomContext.ts +++ b/app/hooks/useRoomContext.ts @@ -2,6 +2,7 @@ import { useOutletContext } from '@remix-run/react' import type { Dispatch, SetStateAction } from 'react' import type { UserMedia } from '~/hooks/useUserMedia' import type { RxjsPeer } from '~/utils/rxjs/RxjsPeer.client' +import type { useConnectionStats } from './useConnectionStats' import type useRoom from './useRoom' import type { useRoomHistory } from './useRoomHistory' @@ -21,6 +22,7 @@ export type RoomContextType = { audio?: string screenshare?: string } + connectionStats: ReturnType } export function useRoomContext() { diff --git a/app/routes/_room.$roomName.room.tsx b/app/routes/_room.$roomName.room.tsx index f089ac25..757933af 100644 --- a/app/routes/_room.$roomName.room.tsx +++ b/app/routes/_room.$roomName.room.tsx @@ -125,6 +125,7 @@ function JoinedRoom({ bugReportsEnabled }: { bugReportsEnabled: boolean }) { peer, pushedTracks, room: { otherUsers, websocket, identity }, + connectionStats, } = useRoomContext() const { GridDebugControls, fakeUsers } = useGridDebugControls({ @@ -156,6 +157,7 @@ function JoinedRoom({ bugReportsEnabled }: { bugReportsEnabled: boolean }) { pushedTracks, raisedHand, speaking, + connectionStats, }) useSounds(otherUsers) diff --git a/app/routes/_room.tsx b/app/routes/_room.tsx index 872c7c9e..febb0472 100644 --- a/app/routes/_room.tsx +++ b/app/routes/_room.tsx @@ -8,6 +8,7 @@ import { EnsureOnline } from '~/components/EnsureOnline' import { EnsurePermissions } from '~/components/EnsurePermissions' import { Icon } from '~/components/Icon/Icon' import { useStateObservable, useSubscribedState } from '~/hooks/rxjsHooks' +import { useConnectionStats } from '~/hooks/useConnectionStats' import { usePeerConnection } from '~/hooks/usePeerConnection' import useRoom from '~/hooks/useRoom' @@ -172,6 +173,7 @@ function Room() { ) }, [peer, userMedia.screenShareVideoTrack$]) const pushedScreenSharingTrack = useSubscribedState(pushedScreenSharingTrack$) + const connectionStats = useConnectionStats(peer) const context: RoomContextType = { joined, @@ -184,6 +186,7 @@ function Room() { roomHistory, iceConnectionState, room, + connectionStats, pushedTracks: { video: trackObjectToString(pushedVideoTrack), audio: trackObjectToString(pushedAudioTrack), diff --git a/app/types/Messages.ts b/app/types/Messages.ts index f08bd898..b42a48c9 100644 --- a/app/types/Messages.ts +++ b/app/types/Messages.ts @@ -13,6 +13,10 @@ export type User = { screenshare?: string screenShareEnabled?: boolean } + connectionInformation: { + inboundPacketLoss: number + outboundPacketLoss: number + } } export type RoomState = { diff --git a/app/utils/rxjs/getPacketLossStats$.ts b/app/utils/rxjs/getPacketLossStats$.ts index d01b7246..cddc9a87 100644 --- a/app/utils/rxjs/getPacketLossStats$.ts +++ b/app/utils/rxjs/getPacketLossStats$.ts @@ -18,7 +18,7 @@ export function getPacketLossStats$( ) { const inboundPacketLossPercentageEwma = new Ewma(2000, 0) const outboundPacketLossPercentageEwma = new Ewma(2000, 0) - return combineLatest([peerConnection$, interval(1000)]).pipe( + return combineLatest([peerConnection$, interval(3000)]).pipe( switchMap(([peerConnection]) => peerConnection.getStats()), pairwise(), map(([previousStatsReport, newStatsReport]) => {