Skip to content
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
2 changes: 1 addition & 1 deletion apps/frontend/src/components/live/LiveStage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
33 changes: 26 additions & 7 deletions apps/frontend/src/components/live/LiveStage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import type { TrackReferenceOrPlaceholder } from '@livekit/components-react';
import {
LiveKitRoom,
RoomAudioRenderer,
Expand All @@ -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;
Expand Down Expand Up @@ -246,6 +247,24 @@ function Stage({ sessionId, isOrganizer, authToken, quizEnded }: StageProps) {
<p className="text-[11px] sm:text-xs text-zinc-500 font-secondary tracking-wide">{watching}</p>
)}

<div className="flex items-center justify-between gap-2">
<p
className="text-[11px] sm:text-xs text-zinc-400 font-secondary"
aria-live="polite"
>
{speakers.length} of 3 speaking
</p>

{isOrganizer && speakers.length > 0 && (
<HostAction
label="Mute all speakers"
onClick={() => muteAllSpeakers(sessionId, authToken)}
>
Mute All
</HostAction>
)}
</div>

{/* 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 ? (
Expand Down
5 changes: 5 additions & 0 deletions apps/frontend/src/services/streamApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
3 changes: 3 additions & 0 deletions apps/streaming/src/livekit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
65 changes: 65 additions & 0 deletions apps/streaming/src/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => {
},
createRoom: vi.fn(),
updateParticipant: vi.fn(),
listParticipants: vi.fn(),
listRooms: vi.fn(),
deleteRoom: vi.fn(),
};
Expand All @@ -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;
}
Expand Down Expand Up @@ -76,6 +78,7 @@ beforeEach(() => {
);
mocks.createRoom.mockResolvedValue({});
mocks.updateParticipant.mockResolvedValue({});
mocks.listParticipants.mockResolvedValue([]);
mocks.listRooms.mockResolvedValue([]);
mocks.deleteRoom.mockResolvedValue(undefined);
});
Expand Down Expand Up @@ -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);
Expand Down
71 changes: 67 additions & 4 deletions apps/streaming/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<SpeakerParams>, res) => {
const caller = await requireCaller(req, res);
if (!caller) return;
Expand All @@ -125,13 +127,74 @@ router.put('/sessions/:sessionId/speakers/:participantId', async (req: Request<S
return errorResponse(res, 403, 'Only the host can let a player speak.');
}

await roomService.updateParticipant(roomName(req.params.sessionId), playerIdentity(req.params.participantId), {
const sessionId = req.params.sessionId;
const targetIdentity = playerIdentity(req.params.participantId);
const participants = await roomService.listParticipants(roomName(sessionId));

const alreadySpeaking = participants.some(
(participant) =>
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,
});
});


Expand Down
Loading