From da746485f9be0d2e5b33a228f8646d383a156b48 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:22:43 +0300 Subject: [PATCH 01/10] feat: add live room debate mode --- Tiltfile | 1 + packages/shared/package.json | 1 + .../liveRooms/CreateLiveRoomModal.tsx | 116 +++ .../src/components/liveRooms/LiveRoom.tsx | 605 +++++++++++ .../src/components/liveRooms/LiveRoomCard.tsx | 100 ++ .../liveRooms/LiveRoomControls.spec.tsx | 190 ++++ .../components/liveRooms/LiveRoomControls.tsx | 204 ++++ .../liveRooms/LiveRoomDevicePicker.tsx | 49 + .../liveRooms/LiveRoomMicLevel.spec.ts | 17 + .../components/liveRooms/LiveRoomMicLevel.tsx | 137 +++ .../liveRooms/LiveRoomVideoTile.tsx | 173 ++++ .../shared/src/contexts/LiveRoomContext.tsx | 958 ++++++++++++++++++ packages/shared/src/graphql/liveRooms.ts | 129 +++ .../src/hooks/liveRooms/useActiveLiveRooms.ts | 32 + .../src/hooks/liveRooms/useCreateLiveRoom.ts | 38 + .../shared/src/hooks/liveRooms/useLiveRoom.ts | 27 + .../shared/src/lib/liveRoom/connection.ts | 352 +++++++ packages/shared/src/lib/liveRoom/protocol.ts | 157 +++ packages/shared/src/lib/query.ts | 1 + packages/shared/src/styles/utilities.css | 46 + packages/webapp/pages/live/[id].tsx | 76 ++ packages/webapp/pages/live/index.tsx | 140 +++ pnpm-lock.yaml | 129 ++- 23 files changed, 3657 insertions(+), 21 deletions(-) create mode 100644 packages/shared/src/components/liveRooms/CreateLiveRoomModal.tsx create mode 100644 packages/shared/src/components/liveRooms/LiveRoom.tsx create mode 100644 packages/shared/src/components/liveRooms/LiveRoomCard.tsx create mode 100644 packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx create mode 100644 packages/shared/src/components/liveRooms/LiveRoomControls.tsx create mode 100644 packages/shared/src/components/liveRooms/LiveRoomDevicePicker.tsx create mode 100644 packages/shared/src/components/liveRooms/LiveRoomMicLevel.spec.ts create mode 100644 packages/shared/src/components/liveRooms/LiveRoomMicLevel.tsx create mode 100644 packages/shared/src/components/liveRooms/LiveRoomVideoTile.tsx create mode 100644 packages/shared/src/contexts/LiveRoomContext.tsx create mode 100644 packages/shared/src/graphql/liveRooms.ts create mode 100644 packages/shared/src/hooks/liveRooms/useActiveLiveRooms.ts create mode 100644 packages/shared/src/hooks/liveRooms/useCreateLiveRoom.ts create mode 100644 packages/shared/src/hooks/liveRooms/useLiveRoom.ts create mode 100644 packages/shared/src/lib/liveRoom/connection.ts create mode 100644 packages/shared/src/lib/liveRoom/protocol.ts create mode 100644 packages/webapp/pages/live/[id].tsx create mode 100644 packages/webapp/pages/live/index.tsx diff --git a/Tiltfile b/Tiltfile index 2cc4e55142..69f08a5d7b 100644 --- a/Tiltfile +++ b/Tiltfile @@ -8,6 +8,7 @@ def include_if_exists(path): print("skipping %s because it does not exist", path) include_if_exists('../daily-api/Tiltfile') +include_if_exists('../flyting/Tiltfile') include_if_exists('../adhoc-infra/Tiltfile') include_if_exists('../post-scraper-one-ai/Tiltfile') include_if_exists('../njord/Tiltfile') diff --git a/packages/shared/package.json b/packages/shared/package.json index e816fe2d0b..94f9e4c9c4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -131,6 +131,7 @@ "graphql-ws": "^5.5.5", "jotai": "^2.12.2", "lottie-react": "^2.4.1", + "mediasoup-client": "^3.7.13", "node-emoji": "^2.2.0", "node-fetch": "^2.6.6", "react-hook-form": "7.54.2", diff --git a/packages/shared/src/components/liveRooms/CreateLiveRoomModal.tsx b/packages/shared/src/components/liveRooms/CreateLiveRoomModal.tsx new file mode 100644 index 0000000000..dbcbb882fb --- /dev/null +++ b/packages/shared/src/components/liveRooms/CreateLiveRoomModal.tsx @@ -0,0 +1,116 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Modal } from '../modals/common/Modal'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { Button, ButtonVariant } from '../buttons/Button'; +import ControlledTextField from '../fields/ControlledTextField'; +import { type LiveRoomJoinToken, LiveRoomMode } from '../../graphql/liveRooms'; +import { useCreateLiveRoom } from '../../hooks/liveRooms/useCreateLiveRoom'; +import { useToastNotification } from '../../hooks/useToastNotification'; +import { labels } from '../../lib/labels'; + +const createLiveRoomFormSchema = z.object({ + topic: z + .string() + .trim() + .min(1, 'Topic is required') + .max(280, 'Topic must be 280 characters or less'), +}); + +type CreateLiveRoomFormValues = z.infer; + +const CREATE_LIVE_ROOM_FORM_ID = 'create-live-room-form'; + +interface CreateLiveRoomModalProps { + isOpen: boolean; + onClose: () => void; + onCreated: (joinToken: LiveRoomJoinToken) => void; +} + +export const CreateLiveRoomModal = ({ + isOpen, + onClose, + onCreated, +}: CreateLiveRoomModalProps): ReactElement => { + const { displayToast } = useToastNotification(); + const { mutateAsync: createLiveRoom, isPending } = useCreateLiveRoom(); + + const form = useForm({ + resolver: zodResolver(createLiveRoomFormSchema), + defaultValues: { topic: '' }, + }); + + const onSubmit = form.handleSubmit(async (values) => { + try { + const joinToken = await createLiveRoom({ + topic: values.topic, + mode: LiveRoomMode.Debate, + }); + onCreated(joinToken); + onClose(); + } catch (error) { + const message = + error instanceof Error ? error.message : labels.error.generic; + displayToast(message); + } + }); + + return ( + + + + + Pick a topic and we'll spin up a debate room you can host right + away. + + +
+ + +
+
+ + + + +
+ ); +}; + +export default CreateLiveRoomModal; diff --git a/packages/shared/src/components/liveRooms/LiveRoom.tsx b/packages/shared/src/components/liveRooms/LiveRoom.tsx new file mode 100644 index 0000000000..6cb04b2823 --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoom.tsx @@ -0,0 +1,605 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import { useRouter } from 'next/router'; +import classNames from 'classnames'; +import { useQueries } from '@tanstack/react-query'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { Loader } from '../Loader'; +import { LiveRoomVideoTile } from './LiveRoomVideoTile'; +import { LiveRoomControls } from './LiveRoomControls'; +import { + LiveRoomProvider, + useLiveRoom as useLiveRoomConnection, + type LiveRoomReaction, + type RemoteMediaStream, +} from '../../contexts/LiveRoomContext'; +import type { LiveRoomParticipantRecord } from '../../lib/liveRoom/protocol'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLiveRoom as useLiveRoomQuery } from '../../hooks/liveRooms/useLiveRoom'; +import { useToastNotification } from '../../hooks/useToastNotification'; +import { AuthTriggers } from '../../lib/auth'; +import { webappUrl } from '../../lib/constants'; +import type { UserShortProfile } from '../../lib/user'; +import { ProfilePicture, ProfileImageSize } from '../ProfilePicture'; +import { BlockIcon, PlusUserIcon, RemoveUserIcon } from '../icons'; +import { getUserShortInfo } from '../../graphql/users'; +import { generateQueryKey, RequestKey } from '../../lib/query'; +import { ONE_MINUTE } from '../../lib/time'; + +interface LiveRoomProps { + roomId: string; +} + +const buildParticipantStream = ( + targetParticipantId: string | null | undefined, + remoteStreams: RemoteMediaStream[], + localStream: MediaStream | null, + participantId: string | null, +): MediaStream | null => { + if (!targetParticipantId) { + return null; + } + if (targetParticipantId === participantId) { + return localStream; + } + const tracks: MediaStreamTrack[] = []; + remoteStreams + .filter((entry) => entry.participantId === targetParticipantId) + .forEach((entry) => { + entry.stream.getTracks().forEach((track) => tracks.push(track)); + }); + if (tracks.length === 0) { + return null; + } + return new MediaStream(tracks); +}; + +const participantLabel = (participantId: string): string => + `Participant ${participantId.slice(0, 6)}`; + +const userDisplayName = (user: Pick): string => + `@${user.username}`; + +const buildParticipantProfile = (participantId: string): UserShortProfile => ({ + id: participantId, + name: participantLabel(participantId), + username: participantId, + image: '', + createdAt: '', + reputation: 0, + permalink: '#', +}); + +const buildDisplayProfile = (user: UserShortProfile): UserShortProfile => ({ + ...user, + name: userDisplayName(user), +}); + +const ReactionOverlay = ({ + reactions, +}: { + reactions: LiveRoomReaction[]; +}): ReactElement | null => { + if (reactions.length === 0) { + return null; + } + + return ( +
+ {reactions.map((reaction) => ( + + {reaction.emoji} + + ))} +
+ ); +}; + +interface DebateQueueItemProps { + participantId: string; + participant: LiveRoomParticipantRecord; + profile?: UserShortProfile; + isHost: boolean; + isBusy: boolean; + onKick: (participantId: string) => Promise; +} + +const DebateQueueItem = ({ + participantId, + participant, + profile, + isHost, + isBusy, + onKick, +}: DebateQueueItemProps): ReactElement => { + const user = profile ?? buildParticipantProfile(participantId); + + return ( +
  • + +
    + + {userDisplayName(user)} + + + {participant.role} + +
    + {isHost ? ( +
  • + ); +}; + +const renderQueueItem = ({ + debateBusy, + isHost, + kickParticipant, + participant, + participantId, + profile, + guardedDebateAction, +}: { + debateBusy: string | null; + isHost: boolean; + kickParticipant: (targetParticipantId: string) => Promise; + participant: LiveRoomParticipantRecord | undefined; + participantId: string; + profile?: UserShortProfile; + guardedDebateAction: (key: string, fn: () => Promise) => Promise; +}): ReactElement | null => { + if (!participant) { + return null; + } + + return ( + + guardedDebateAction(`kick-${targetId}`, () => kickParticipant(targetId)) + } + /> + ); +}; + +const LiveRoomInner = ({ roomId }: LiveRoomProps): ReactElement => { + const router = useRouter(); + const { displayToast } = useToastNotification(); + const { isAuthReady, isLoggedIn, showLogin } = useAuthContext(); + const { + status, + errorMessage, + roomState, + role, + participantId, + startRoom, + promoteNextSpeaker, + removeCurrentSpeaker, + kickParticipant, + localStream, + remoteStreams, + reactions, + } = useLiveRoomConnection(); + const { + data: room, + error: roomError, + isLoading: isRoomLoading, + } = useLiveRoomQuery(roomId); + + const handleLeave = (): void => { + router.push(`${webappUrl}live`); + }; + + const handleStart = async (): Promise => { + try { + await startRoom(); + } catch (err) { + displayToast(err instanceof Error ? err.message : 'Could not start room'); + } + }; + + const [debateBusy, setDebateBusy] = useState(null); + + const guardedDebateAction = async ( + key: string, + fn: () => Promise, + ): Promise => { + if (debateBusy) { + return; + } + setDebateBusy(key); + try { + await fn(); + } catch (err) { + displayToast(err instanceof Error ? err.message : 'Action failed'); + } finally { + setDebateBusy(null); + } + }; + + const hostStream = useMemo( + () => + buildParticipantStream( + room?.host.id, + remoteStreams, + localStream, + participantId, + ), + [room?.host.id, remoteStreams, localStream, participantId], + ); + const debateParticipantIds = useMemo(() => { + const ids = [ + roomState?.debate?.activeSpeakerParticipantId, + ...(roomState?.debate?.speakerQueueParticipantIds ?? []), + ].filter((id): id is string => !!id && id !== room?.host.id); + + return [...new Set(ids)]; + }, [room?.host.id, roomState?.debate]); + const participantProfileQueries = useQueries({ + queries: debateParticipantIds.map((id) => ({ + queryKey: generateQueryKey( + RequestKey.Profile, + undefined, + 'live-room-participant', + id, + ), + queryFn: () => getUserShortInfo(id), + staleTime: ONE_MINUTE, + })), + }); + const participantProfilesById = new Map(); + debateParticipantIds.forEach((id, index) => { + const profile = participantProfileQueries[index]?.data; + if (profile) { + participantProfilesById.set(id, profile); + } + }); + + if (!isAuthReady || (isLoggedIn && isRoomLoading)) { + return ( +
    + +
    + ); + } + + if (!isLoggedIn) { + return ( +
    + + Sign in to join this live room + + + Live rooms are only available to signed-in members right now. + + +
    + ); + } + + if (roomError || !room) { + return ( +
    + + We couldn't load this live room + + + {roomError?.message ?? 'The room may no longer be available.'} + + +
    + ); + } + + if (status === 'error' || status === 'closed') { + return ( +
    + + {status === 'closed' + ? 'Live room connection closed' + : "We couldn't connect to this live room"} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + +
    + ); + } + + const isHost = role === 'host'; + const isCreated = roomState?.status === 'created'; + const isLive = roomState?.status === 'live'; + const isEnded = roomState?.status === 'ended' || room.status === 'ended'; + const participantCount = roomState + ? Object.keys(roomState.participants).length + : 0; + const activeSpeakerId = roomState?.debate?.activeSpeakerParticipantId ?? null; + const activeSpeaker = activeSpeakerId + ? roomState?.participants[activeSpeakerId] + : null; + const activeSpeakerStream = buildParticipantStream( + activeSpeakerId, + remoteStreams, + localStream, + participantId, + ); + const queuedParticipantIds = + roomState?.debate?.speakerQueueParticipantIds.filter( + (id) => !!roomState.participants[id], + ) ?? []; + const activeSpeakerProfile = activeSpeakerId + ? buildDisplayProfile( + participantProfilesById.get(activeSpeakerId) ?? + buildParticipantProfile(activeSpeakerId), + ) + : null; + const hostProfile = buildDisplayProfile(room.host); + + return ( +
    + +
    + + {room.topic} + + + Hosted by {userDisplayName(room.host)} · {participantCount}{' '} + participant + {participantCount === 1 ? '' : 's'} + +
    + +
    +
    + + {activeSpeaker && activeSpeakerProfile ? ( + + ) : ( +
    + + No active speaker + + + Waiting for the next speaker + +
    + )} +
    + + +
    + + {isCreated && isHost ? ( + + ) : null} + + {isCreated && !isHost ? ( + + Waiting for the host to start the room… + + ) : null} + + {isEnded ? ( +
    + + This live room has ended + + +
    + ) : ( + + )} + + {status === 'connecting' ? ( + + Connecting… + + ) : null} +
    + ); +}; + +export const LiveRoom = ({ roomId }: LiveRoomProps): ReactElement => ( + + + +); + +export default LiveRoom; diff --git a/packages/shared/src/components/liveRooms/LiveRoomCard.tsx b/packages/shared/src/components/liveRooms/LiveRoomCard.tsx new file mode 100644 index 0000000000..d84eb62bd4 --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomCard.tsx @@ -0,0 +1,100 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + type LiveRoom, + LiveRoomMode, + LiveRoomStatus, +} from '../../graphql/liveRooms'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ProfilePicture, ProfileImageSize } from '../ProfilePicture'; +import { FlexCol, FlexRow } from '../utilities'; + +interface LiveRoomCardProps { + room: LiveRoom; + onJoin: (room: LiveRoom) => void; + isJoining?: boolean; +} + +const statusLabel: Record = { + [LiveRoomStatus.Live]: 'Live', + [LiveRoomStatus.Created]: 'Starting', + [LiveRoomStatus.Ended]: 'Ended', +}; + +const statusBadgeClass: Record = { + [LiveRoomStatus.Live]: 'bg-status-error text-white', + [LiveRoomStatus.Created]: 'bg-surface-float text-text-secondary', + [LiveRoomStatus.Ended]: 'bg-surface-float text-text-tertiary', +}; + +const modeLabel: Record = { + [LiveRoomMode.Debate]: 'Debate', +}; + +export const LiveRoomCard = ({ + room, + onJoin, + isJoining, +}: LiveRoomCardProps): ReactElement => { + return ( + + + + {room.status === LiveRoomStatus.Live ? ( + + ) : null} + {statusLabel[room.status]} + + + {modeLabel[room.mode]} + + + + + {room.topic} + + + + + + + + {room.host.name} + + + @{room.host.username} + + + + + + + ); +}; diff --git a/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx b/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx new file mode 100644 index 0000000000..6619398e32 --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomControls.spec.tsx @@ -0,0 +1,190 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import type { LiveRoomContextValue } from '../../contexts/LiveRoomContext'; +import { LiveRoomControls } from './LiveRoomControls'; + +const useLiveRoomMock = jest.fn(); +const displayToast = jest.fn(); + +jest.mock('../../contexts/LiveRoomContext', () => ({ + useLiveRoom: () => useLiveRoomMock(), +})); + +jest.mock('../../hooks/useToastNotification', () => ({ + useToastNotification: () => ({ displayToast }), +})); + +const createContextValue = ( + overrides: Partial = {}, +): LiveRoomContextValue => ({ + status: 'connected', + errorMessage: null, + roomState: { + roomId: 'room-1', + status: 'live', + version: 1, + participants: { + host: { + participantId: 'host', + role: 'host', + sessionIds: ['session-host'], + joinedAt: '2026-04-27T09:00:00.000Z', + updatedAt: '2026-04-27T09:00:00.000Z', + }, + audience: { + participantId: 'audience', + role: 'audience', + sessionIds: ['session-audience'], + joinedAt: '2026-04-27T09:01:00.000Z', + updatedAt: '2026-04-27T09:01:00.000Z', + }, + }, + sessions: {}, + debate: { + speakerQueueParticipantIds: [], + activeSpeakerParticipantId: null, + }, + mediaPublications: {}, + mediaRuntimeOwner: null, + createdAt: '2026-04-27T09:00:00.000Z', + updatedAt: '2026-04-27T09:00:00.000Z', + }, + role: 'audience', + participantId: 'audience', + startRoom: jest.fn(), + endRoom: jest.fn(), + joinSpeakerQueue: jest.fn(), + sendReaction: jest.fn(), + promoteNextSpeaker: jest.fn(), + removeCurrentSpeaker: jest.fn(), + kickParticipant: jest.fn(), + canPublish: false, + isCameraOn: false, + isMicOn: false, + isCameraPublishing: false, + isMicPublishing: false, + toggleCamera: jest.fn(), + toggleMic: jest.fn(), + cameras: [], + microphones: [], + selectedCameraId: null, + selectedMicId: null, + selectCamera: jest.fn(), + selectMic: jest.fn(), + localStream: null, + remoteStreams: [], + reactions: [], + ...overrides, +}); + +const createRoomState = (): NonNullable => { + const { roomState } = createContextValue(); + if (!roomState) { + throw new Error('Expected default room state'); + } + return roomState; +}; + +describe('LiveRoomControls', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('lets an audience participant join the speaker queue', () => { + const joinSpeakerQueue = jest.fn().mockResolvedValue(undefined); + useLiveRoomMock.mockReturnValue(createContextValue({ joinSpeakerQueue })); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Join queue' })); + + expect(joinSpeakerQueue).toHaveBeenCalledTimes(1); + }); + + it('shows queued audience state without requeueing', () => { + const joinSpeakerQueue = jest.fn().mockResolvedValue(undefined); + useLiveRoomMock.mockReturnValue( + createContextValue({ + joinSpeakerQueue, + roomState: { + ...createRoomState(), + debate: { + speakerQueueParticipantIds: ['audience'], + activeSpeakerParticipantId: null, + }, + }, + }), + ); + + render(); + + const queuedButton = screen.getByRole('button', { name: 'Queued' }); + expect(queuedButton).toBeDisabled(); + fireEvent.click(queuedButton); + expect(joinSpeakerQueue).not.toHaveBeenCalled(); + }); + + it('sends emoji reactions through the live room command path', () => { + const sendReaction = jest.fn().mockResolvedValue(undefined); + useLiveRoomMock.mockReturnValue(createContextValue({ sendReaction })); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'React 🔥' })); + + expect(sendReaction).toHaveBeenCalledWith('🔥'); + }); + + it('keeps host controls separate from the audience queue button', () => { + useLiveRoomMock.mockReturnValue( + createContextValue({ + role: 'host', + participantId: 'host', + canPublish: true, + }), + ); + + render(); + + expect( + screen.getByRole('button', { name: 'End room' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Join queue' }), + ).not.toBeInTheDocument(); + }); + + it('shows media controls when the current participant becomes a speaker', () => { + useLiveRoomMock.mockReturnValue( + createContextValue({ + role: 'speaker', + participantId: 'audience', + canPublish: true, + roomState: { + ...createRoomState(), + participants: { + ...createRoomState().participants, + audience: { + ...createRoomState().participants.audience, + role: 'speaker', + }, + }, + debate: { + speakerQueueParticipantIds: [], + activeSpeakerParticipantId: 'audience', + }, + }, + }), + ); + + render(); + + expect(screen.getByRole('button', { name: 'Mic off' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Camera off' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Join queue' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/liveRooms/LiveRoomControls.tsx b/packages/shared/src/components/liveRooms/LiveRoomControls.tsx new file mode 100644 index 0000000000..4d771d07d2 --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomControls.tsx @@ -0,0 +1,204 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { CameraIcon, MegaphoneIcon, PhoneIcon, VolumeOffIcon } from '../icons'; +import { useLiveRoom } from '../../contexts/LiveRoomContext'; +import { useToastNotification } from '../../hooks/useToastNotification'; +import { LiveRoomDevicePicker } from './LiveRoomDevicePicker'; +import { LiveRoomMicLevel } from './LiveRoomMicLevel'; + +const REACTION_EMOJIS = ['👏', '🔥', '💡', '😂', '🤯']; + +interface LiveRoomControlsProps { + onLeave: () => void; +} + +export const LiveRoomControls = ({ + onLeave, +}: LiveRoomControlsProps): ReactElement => { + const { + canPublish, + isCameraOn, + isMicOn, + isCameraPublishing, + isMicPublishing, + toggleCamera, + toggleMic, + endRoom, + joinSpeakerQueue, + sendReaction, + role, + participantId, + roomState, + cameras, + microphones, + selectedCameraId, + selectedMicId, + selectCamera, + selectMic, + localStream, + } = useLiveRoom(); + const localAudioTrack = localStream?.getAudioTracks()[0] ?? null; + // Wrap the local audio track in its own MediaStream once per track so the + // meter's AudioContext doesn't churn on every render. + const localAudioStream = useMemo( + () => (localAudioTrack ? new MediaStream([localAudioTrack]) : null), + [localAudioTrack], + ); + const { displayToast } = useToastNotification(); + const [busy, setBusy] = useState(null); + + const guarded = async ( + key: string, + fn: () => Promise, + ): Promise => { + if (busy) { + return; + } + setBusy(key); + try { + await fn(); + } catch (err) { + displayToast(err instanceof Error ? err.message : 'Action failed'); + } finally { + setBusy(null); + } + }; + + const isHost = role === 'host'; + const isAudience = role === 'audience'; + const isQueued = + !!participantId && + !!roomState?.debate?.speakerQueueParticipantIds.includes(participantId); + const canJoinQueue = + isAudience && roomState?.status === 'live' && !isQueued && !busy; + + return ( +
    +
    + {canPublish ? ( + <> + + + + ) : null} + {isHost ? ( + + ) : null} + {isAudience ? ( + + ) : null} + +
    +
    + {REACTION_EMOJIS.map((emoji) => ( + + ))} +
    + {canPublish ? ( +
    +
    + } + devices={microphones} + selectedId={selectedMicId} + onChange={(id) => { + selectMic(id).catch((err: unknown) => + displayToast( + err instanceof Error ? err.message : 'Failed to switch mic', + ), + ); + }} + emptyLabel="No microphones" + className="min-w-[12rem]" + /> + +
    + } + devices={cameras} + selectedId={selectedCameraId} + onChange={(id) => { + selectCamera(id).catch((err: unknown) => + displayToast( + err instanceof Error + ? err.message + : 'Failed to switch camera', + ), + ); + }} + emptyLabel="No cameras" + className="min-w-[12rem]" + /> +
    + ) : null} +
    + ); +}; diff --git a/packages/shared/src/components/liveRooms/LiveRoomDevicePicker.tsx b/packages/shared/src/components/liveRooms/LiveRoomDevicePicker.tsx new file mode 100644 index 0000000000..2687b98733 --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomDevicePicker.tsx @@ -0,0 +1,49 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { Dropdown } from '../fields/Dropdown'; +import { ButtonSize, ButtonVariant } from '../buttons/common'; +import type { LiveRoomDeviceInfo } from '../../contexts/LiveRoomContext'; + +interface LiveRoomDevicePickerProps { + icon: ReactNode; + devices: LiveRoomDeviceInfo[]; + selectedId: string | null; + onChange: (deviceId: string) => void; + emptyLabel: string; + className?: string; +} + +export const LiveRoomDevicePicker = ({ + icon, + devices, + selectedId, + onChange, + emptyLabel, + className, +}: LiveRoomDevicePickerProps): ReactElement => { + const options = devices.length + ? devices.map((device) => device.label) + : [emptyLabel]; + const selectedIndex = devices.findIndex( + (device) => device.deviceId === selectedId, + ); + + return ( + = 0 ? selectedIndex : 0} + disabled={!devices.length} + shouldIndicateSelected + onChange={(_value, index) => { + const device = devices[index]; + if (device) { + onChange(device.deviceId); + } + }} + /> + ); +}; diff --git a/packages/shared/src/components/liveRooms/LiveRoomMicLevel.spec.ts b/packages/shared/src/components/liveRooms/LiveRoomMicLevel.spec.ts new file mode 100644 index 0000000000..a11786cb74 --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomMicLevel.spec.ts @@ -0,0 +1,17 @@ +import { computeRms, rmsToLevel } from './LiveRoomMicLevel'; + +describe('LiveRoomMicLevel', () => { + it('maps normal remote speech RMS to a visible level', () => { + expect(rmsToLevel(0.02)).toBeGreaterThan(0.5); + }); + + it('keeps silence at zero', () => { + expect(rmsToLevel(0)).toBe(0); + }); + + it('computes RMS from float audio samples', () => { + const rms = computeRms(new Float32Array([0.5, -0.5, 0.5, -0.5])); + + expect(rms).toBeCloseTo(0.5); + }); +}); diff --git a/packages/shared/src/components/liveRooms/LiveRoomMicLevel.tsx b/packages/shared/src/components/liveRooms/LiveRoomMicLevel.tsx new file mode 100644 index 0000000000..19f6a05c15 --- /dev/null +++ b/packages/shared/src/components/liveRooms/LiveRoomMicLevel.tsx @@ -0,0 +1,137 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; + +interface LiveRoomMicLevelProps { + // Pass the SAME MediaStream that's attached to the playing