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,
+ });
});