diff --git a/apps/frontend/src/components/live/LiveStage.test.tsx b/apps/frontend/src/components/live/LiveStage.test.tsx index 2274cbf..4e22cc8 100644 --- a/apps/frontend/src/components/live/LiveStage.test.tsx +++ b/apps/frontend/src/components/live/LiveStage.test.tsx @@ -315,7 +315,7 @@ describe('LiveStage host view and moderation', () => { const { user } = renderStage({ isOrganizer: true }); await screen.findByTestId('livekit-room'); - expect(screen.getByText(/speaking/i)).toBeInTheDocument(); + expect(screen.getByText('Speaking')).toBeInTheDocument(); expect(screen.getByText('Bob')).toBeInTheDocument(); const muteBtn = screen.getByRole('button', { name: /mute bob/i }); diff --git a/apps/frontend/src/components/live/LiveStage.tsx b/apps/frontend/src/components/live/LiveStage.tsx index bc5daea..8c45c46 100644 --- a/apps/frontend/src/components/live/LiveStage.tsx +++ b/apps/frontend/src/components/live/LiveStage.tsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from 'react'; -import type { ReactNode } from 'react'; +import type { TrackReferenceOrPlaceholder } from '@livekit/components-react'; import { LiveKitRoom, RoomAudioRenderer, @@ -11,23 +10,25 @@ import { useParticipants, useTracks, } from '@livekit/components-react'; -import type { TrackReferenceOrPlaceholder } from '@livekit/components-react'; -import { DisconnectReason, RoomEvent, Track } from 'livekit-client'; import type { Participant } from 'livekit-client'; +import { DisconnectReason, RoomEvent, Track } from 'livekit-client'; +import type { ReactNode } from 'react'; +import { useEffect, useState } from 'react'; import { BsCameraVideo, BsCameraVideoOff, BsMic, BsMicMute } from 'react-icons/bs'; -import { MdScreenShare, MdStopScreenShare } from 'react-icons/md'; import { FaHand } from 'react-icons/fa6'; import { LuChevronDown, LuMaximize2, LuMinimize2 } from 'react-icons/lu'; -import { getAvatar, getAvatarColor } from '../../utils/Avatars'; +import { MdScreenShare, MdStopScreenShare } from 'react-icons/md'; +import type { RoomAccess } from '../../services/streamApi'; import { allowToSpeak, endVideo, fetchRoomAccess, + muteAllSpeakers, setHandRaised, stopSpeaking, streamError, } from '../../services/streamApi'; -import type { RoomAccess } from '../../services/streamApi'; +import { getAvatar, getAvatarColor } from '../../utils/Avatars'; interface LiveStageProps { sessionId: string; @@ -246,6 +247,24 @@ function Stage({ sessionId, isOrganizer, authToken, quizEnded }: StageProps) {

{watching}

)} +
+

+ {speakers.length} of 3 speaking +

+ + {isOrganizer && speakers.length > 0 && ( + muteAllSpeakers(sessionId, authToken)} + > + Mute All + + )} +
+ {/* The roster already lists hands and speakers first, with the same buttons, so the short lists give way to it while it is open. */} {isOrganizer && rosterOpen ? ( diff --git a/apps/frontend/src/services/streamApi.ts b/apps/frontend/src/services/streamApi.ts index 5dce916..a7fa825 100644 --- a/apps/frontend/src/services/streamApi.ts +++ b/apps/frontend/src/services/streamApi.ts @@ -46,6 +46,11 @@ export async function stopSpeaking(sessionId: string, authToken: string, partici await streamApi.delete(`/sessions/${sessionId}/speakers/${participantId}`, auth(authToken)); } +/** Mute every current speaker in the session. Host only. */ +export async function muteAllSpeakers(sessionId: string, authToken: string) { + await streamApi.delete(`/sessions/${sessionId}/speakers`, auth(authToken)); +} + /** Close the session's video room for everyone. Host only. */ export async function endVideo(sessionId: string, authToken: string) { await streamApi.delete(`/sessions/${sessionId}/room`, auth(authToken)); diff --git a/apps/streaming/src/livekit.ts b/apps/streaming/src/livekit.ts index 88a9da8..84cb60c 100644 --- a/apps/streaming/src/livekit.ts +++ b/apps/streaming/src/livekit.ts @@ -10,6 +10,9 @@ import type { Caller } from './auth.js'; */ export const MAX_LIVE_VIDEO_PARTICIPANTS = 300; +/** Maximum number of players allowed to speak at the same time. */ +export const MAX_SIMULTANEOUS_SPEAKERS = 3; + /** Seats on top of the players, for the host and co-hosts. */ const HOST_SEATS = 20; diff --git a/apps/streaming/src/routes.test.ts b/apps/streaming/src/routes.test.ts index 8b6eded..d247b5b 100644 --- a/apps/streaming/src/routes.test.ts +++ b/apps/streaming/src/routes.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => { }, createRoom: vi.fn(), updateParticipant: vi.fn(), + listParticipants: vi.fn(), listRooms: vi.fn(), deleteRoom: vi.fn(), }; @@ -29,6 +30,7 @@ vi.mock("livekit-server-sdk", async (importOriginal) => { class RoomServiceClient { createRoom = mocks.createRoom; updateParticipant = mocks.updateParticipant; + listParticipants = mocks.listParticipants; listRooms = mocks.listRooms; deleteRoom = mocks.deleteRoom; } @@ -76,6 +78,7 @@ beforeEach(() => { ); mocks.createRoom.mockResolvedValue({}); mocks.updateParticipant.mockResolvedValue({}); + mocks.listParticipants.mockResolvedValue([]); mocks.listRooms.mockResolvedValue([]); mocks.deleteRoom.mockResolvedValue(undefined); }); @@ -286,6 +289,68 @@ describe("raising a hand", () => { }); describe("letting a player speak", () => { + it("rejects a fourth speaker when three are already speaking", async () => { + mocks.listParticipants.mockResolvedValue([ + { identity: "player-p-1", attributes: { speaker: "yes" } }, + { identity: "player-p-2", attributes: { speaker: "yes" } }, + { identity: "player-p-3", attributes: { speaker: "yes" } }, + { identity: "player-p-4", attributes: { speaker: "" } }, + ]); + + const res = await call("PUT", `/sessions/${SESSION}/speakers/p-4`, hostToken()); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toContain("Maximum of 3 players can speak at once"); + expect(mocks.updateParticipant).not.toHaveBeenCalled(); + }); + + it("allows an existing speaker to be granted permission again at capacity", async () => { + mocks.listParticipants.mockResolvedValue([ + { identity: "player-p-1", attributes: { speaker: "yes" } }, + { identity: "player-p-2", attributes: { speaker: "yes" } }, + { identity: "player-p-3", attributes: { speaker: "yes" } }, + ]); + + const res = await call("PUT", `/sessions/${SESSION}/speakers/p-1`, hostToken()); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.speakingCount).toBe(3); + expect(mocks.updateParticipant).toHaveBeenCalledTimes(1); + }); + + it("mutes all current speakers when the host requests it", async () => { + mocks.listParticipants.mockResolvedValue([ + { identity: "player-p-1", attributes: { speaker: "yes" } }, + { identity: "player-p-2", attributes: { speaker: "yes" } }, + { identity: "player-p-3", attributes: { speaker: "yes" } }, + { identity: "player-p-4", attributes: { speaker: "" } }, + ]); + + const res = await call("DELETE", `/sessions/${SESSION}/speakers`, hostToken()); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.mutedCount).toBe(3); + expect(mocks.updateParticipant).toHaveBeenCalledTimes(3); + + for (const [room, identity, options] of mocks.updateParticipant.mock.calls) { + expect(room).toBe(`quiz-${SESSION}`); + expect(["player-p-1", "player-p-2", "player-p-3"]).toContain(identity); + expect(options.attributes).toEqual({ speaker: "" }); + expect(options.permission).toMatchObject({ canPublish: false }); + } + }); + + it("refuses to let a player mute everyone", async () => { + const res = await call("DELETE", `/sessions/${SESSION}/speakers`, playerToken()); + + expect(res.status).toBe(403); + expect(mocks.listParticipants).not.toHaveBeenCalled(); + expect(mocks.updateParticipant).not.toHaveBeenCalled(); + }); + it("is refused to a player, even for themselves", async () => { const res = await call("PUT", `/sessions/${SESSION}/speakers/p-1`, playerToken()); expect(res.status).toBe(403); diff --git a/apps/streaming/src/routes.ts b/apps/streaming/src/routes.ts index 895fa3a..bdbdb83 100644 --- a/apps/streaming/src/routes.ts +++ b/apps/streaming/src/routes.ts @@ -6,6 +6,7 @@ import type { Caller } from './auth.js'; import { config } from './config.js'; import { LISTENER_PERMISSION, + MAX_SIMULTANEOUS_SPEAKERS, SPEAKER_PERMISSION, closeRoom, createJoinToken, @@ -116,7 +117,8 @@ router.post('/sessions/:sessionId/hand', async (req: Request<{ sessionId: string type SpeakerParams = { sessionId: string; participantId: string }; -/** Give a player the floor: they may now turn their microphone on. */ + +/** Give a player the floor, provided the simultaneous-speaker limit allows it. */ router.put('/sessions/:sessionId/speakers/:participantId', async (req: Request, res) => { const caller = await requireCaller(req, res); if (!caller) return; @@ -125,13 +127,74 @@ router.put('/sessions/:sessionId/speakers/:participantId', async (req: Request + participant.identity === targetIdentity && + participant.attributes?.speaker === 'yes', + ); + + const speakerCount = participants.filter( + (participant) => participant.attributes?.speaker === 'yes', + ).length; + + if (!alreadySpeaking && speakerCount >= MAX_SIMULTANEOUS_SPEAKERS) { + return errorResponse( + res, + 409, + `Maximum of ${MAX_SIMULTANEOUS_SPEAKERS} players can speak at once. Mute a current speaker before adding another.`, + ); + } + + await roomService.updateParticipant(roomName(sessionId), targetIdentity, { permission: SPEAKER_PERMISSION, - // Being heard answers the raised hand. attributes: { speaker: 'yes', hand: '' }, }); - return res.status(200).json({ speaking: true }); + return res.status(200).json({ + speaking: true, + speakingCount: alreadySpeaking ? speakerCount : speakerCount + 1, + maxSpeakers: MAX_SIMULTANEOUS_SPEAKERS, + }); + +}); + + +/** Mute every player currently marked as a speaker. */ +router.delete('/sessions/:sessionId/speakers', async (req: Request<{ sessionId: string }>, res) => { + const caller = await requireCaller(req, res); + if (!caller) return; + + if (caller.kind !== 'organizer') { + return errorResponse(res, 403, 'Only the host can mute all players.'); + } + + const sessionId = req.params.sessionId; + const room = roomName(sessionId); + const participants = await roomService.listParticipants(room); + + const speakers = participants.filter( + (participant) => participant.attributes?.speaker === 'yes', + ); + + await Promise.all( + speakers.map((participant) => + roomService.updateParticipant(room, participant.identity, { + permission: LISTENER_PERMISSION, + attributes: { speaker: '' }, + }), + ), + ); + + return res.status(200).json({ + success: true, + mutedCount: speakers.length, + speakingCount: 0, + maxSpeakers: MAX_SIMULTANEOUS_SPEAKERS, + }); });