Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/forty-bikes-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@whereby.com/browser-sdk": minor
"@whereby.com/core": minor
---

Add advanced breakout controls
29 changes: 28 additions & 1 deletion packages/browser-sdk/src/lib/react/useRoomConnection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ export function useRoomConnection(
return client.joinRoom();
}, [client]);
const sendChatMessage = React.useCallback(
(text: string, parentId?: string) => client.sendChatMessage(text, parentId),
(text: string, parentId?: string, isBroadcast?: boolean) =>
client.sendChatMessage(text, parentId, isBroadcast),
[client],
);
const removeChatMessage = React.useCallback(
Expand Down Expand Up @@ -154,6 +155,25 @@ export function useRoomConnection(
(assignments: { [clientId: string]: string }) => client.assignBreakoutParticipants(assignments),
[client],
);
const assignAllBreakoutParticipants = React.useCallback(
() => client.assignAllBreakoutParticipants(),
[client],
);
const unassignAllBreakoutParticipants = React.useCallback(
() => client.unassignAllBreakoutParticipants(),
[client],
);
const shuffleBreakoutParticipants = React.useCallback(() => client.shuffleBreakoutParticipants(), [client]);
const extendBreakoutTimer = React.useCallback((seconds?: number) => client.extendBreakoutTimer(seconds), [client]);
const stopBreakoutTimer = React.useCallback(() => client.stopBreakoutTimer(), [client]);
const broadcastToGroups = React.useCallback(
(participantId: string) => client.broadcastToGroups(participantId),
[client],
);
const stopBroadcastToGroups = React.useCallback(
(participantId: string) => client.stopBroadcastToGroups(participantId),
[client],
);
const switchCameraEffect = React.useCallback(
async (effectId: string) => {
await client.switchCameraEffect(effectId);
Expand Down Expand Up @@ -220,6 +240,13 @@ export function useRoomConnection(
updateBreakoutSession,
stopBreakoutSession,
assignBreakoutParticipants,
assignAllBreakoutParticipants,
unassignAllBreakoutParticipants,
shuffleBreakoutParticipants,
extendBreakoutTimer,
stopBreakoutTimer,
broadcastToGroups,
stopBroadcastToGroups,
switchCameraEffect,
switchCameraEffectCustom,
clearCameraEffect,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,19 @@ export const initialState: RoomConnectionState = {
currentGroup: null,
groups: null,
enforceAssignment: false,
autoMoveToGroup: false,
moveToGroupGracePeriod: null,
autoMoveToMain: false,
moveToMainGracePeriod: null,
breakoutTimerSetting: false,
breakoutTimerDuration: 0,
startedAt: null,
endTime: null,
moveToGroupAt: null,
moveToMainAt: null,
groupedParticipants: [],
participantsInCurrentGroup: [],
broadcastingParticipants: [],
},
isCameraEnabled: false,
isMicrophoneEnabled: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export interface RoomConnectionActions {
kickParticipant: (clientId: string) => void;
endMeeting: (stayBehind?: boolean) => void;
rejectWaitingParticipant: (participantId: string) => void;
sendChatMessage: (text: string, parentId?: string) => void;
sendChatMessage: (text: string, parentId?: string, isBroadcast?: boolean) => void;
removeChatMessage: (id: string, sig?: string | null) => void;
sendFiles: (files: File[]) => void;
downloadFile: (file: ChatFileShare) => Promise<Blob>;
Expand All @@ -59,6 +59,13 @@ export interface RoomConnectionActions {
updateBreakoutSession: (options: UpdateBreakoutSessionOptions) => void;
stopBreakoutSession: () => void;
assignBreakoutParticipants: (assignments: { [clientId: string]: string }) => void;
assignAllBreakoutParticipants: () => void;
unassignAllBreakoutParticipants: () => void;
shuffleBreakoutParticipants: () => void;
extendBreakoutTimer: (seconds?: number) => void;
stopBreakoutTimer: () => void;
broadcastToGroups: (participantId: string) => void;
stopBroadcastToGroups: (participantId: string) => void;
switchCameraEffect: (effectId: string) => Promise<void>;
switchCameraEffectCustom: (imageUrl: string) => Promise<void>;
clearCameraEffect: () => Promise<void>;
Expand Down
211 changes: 34 additions & 177 deletions packages/browser-sdk/src/stories/components/VideoExperience.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from "react";
import toast, { Toaster } from "react-hot-toast";
import DisplayNameForm from "./DisplayNameForm";
import BreakoutPanel from "./breakout/BreakoutPanel";
import { UseLocalMediaResult } from "../../lib/react/useLocalMedia/types";
import { useRoomConnection } from "../../lib/react/useRoomConnection";
import { VideoView } from "../../lib/react/VideoView";
Expand Down Expand Up @@ -42,13 +43,9 @@ export default function VideoExperience({
showAudioDenoiser?: boolean;
showFileSharing?: boolean;
}) {
const [breakoutGroups, setBreakoutGroups] = useState<{ [groupId: string]: string }>({
a: "Group A",
b: "Group B",
});

const [chatMessage, setChatMessage] = useState("");
const [chatMessageParent, setChatMessageParent] = useState("");
const [chatBroadcast, setChatBroadcast] = useState(false);
const [isLocalScreenshareActive, setIsLocalScreenshareActive] = useState(false);
const [effectPresets, setEffectPresets] = useState<Array<string>>([]);
const [audioDenoiserSupported, setAudioDenoiserSupported] = useState<boolean | null>(null);
Expand Down Expand Up @@ -111,50 +108,13 @@ export default function VideoExperience({
removeSpotlight,
turnOffParticipantCameras,
askToTurnOnCamera,
joinBreakoutGroup,
joinBreakoutMainRoom,
startBreakoutSession,
updateBreakoutSession,
stopBreakoutSession,
assignBreakoutParticipants,
switchCameraEffect,
switchCameraEffectCustom,
clearCameraEffect,
enableAudioDenoiser,
disableAudioDenoiser,
} = actions;

useEffect(() => {
if (breakout.groups && JSON.stringify(breakout.groups) !== JSON.stringify(breakoutGroups)) {
setBreakoutGroups(breakout.groups);
}
}, [breakout.groups]);

function applyGroups(newGroups: { [groupId: string]: string }) {
setBreakoutGroups(newGroups);
if (breakout.isActive) {
updateBreakoutSession({ groups: newGroups });
}
}

function addBreakoutGroup() {
const alphabet = "abcdefghijklmnopqrstuvwxyz";
const usedIds = Object.keys(breakoutGroups);
const nextId = alphabet.split("").find((c) => !usedIds.includes(c));
if (!nextId) return;
applyGroups({ ...breakoutGroups, [nextId]: `Group ${nextId.toUpperCase()}` });
}

function removeBreakoutGroup(id: string) {
const rest = { ...breakoutGroups };
delete rest[id];
applyGroups(rest);
}

function renameBreakoutGroup(id: string, name: string) {
applyGroups({ ...breakoutGroups, [id]: name });
}

async function handleDownloadFile(file: ChatFileShare) {
try {
const blob = await downloadFile(file);
Expand Down Expand Up @@ -349,6 +309,18 @@ export default function VideoExperience({
case "requestVideoDisable":
showRequestVideoDisableNotification(event);
break;
case "breakoutTimerEnding":
toast(event.message, { id: "breakoutTimerEnding", icon: "⏳" });
break;
case "breakoutTimerExtended":
toast(event.message, { id: "breakoutTimerExtended", icon: "⏱️" });
break;
case "breakoutTimerEnded":
toast(event.message, { id: "breakoutTimerEnded", icon: "⏰" });
break;
case "breakoutGroupAssigned":
toast(event.message, { id: "breakoutGroupAssigned", icon: "👥" });
break;
}
};

Expand Down Expand Up @@ -464,140 +436,15 @@ export default function VideoExperience({
</div>
)}
{showBreakoutGroups ? (
<div>
<h3>Breakout is {breakout.isActive ? "active" : "inactive"}</h3>
{showHostControls ? (
<div className="breakoutHostControls">
<div className="breakoutGroupsEditor">
<h4>Groups</h4>
{Object.entries(breakoutGroups).map(([id, name]) => (
<div key={id}>
<input
value={name}
onChange={(e) => renameBreakoutGroup(id, e.target.value)}
/>
<button
onClick={() => removeBreakoutGroup(id)}
disabled={Object.keys(breakoutGroups).length <= 1}
>
Remove
</button>
</div>
))}
<button onClick={addBreakoutGroup}>Add group</button>
</div>

<label>
<input
type="checkbox"
checked={breakout.enforceAssignment}
onChange={(e) =>
updateBreakoutSession({ enforceAssignment: e.target.checked })
}
/>
Enforce assignment (lock participants to their assigned group)
</label>

<div>
<h4>Assign participants</h4>
<button
onClick={() => {
const groupIds = Object.keys(breakoutGroups);
const assignments = remoteParticipants.reduce<{
[clientId: string]: string;
}>((acc, p, i) => {
acc[p.id] = groupIds[i % groupIds.length];
return acc;
}, {});
assignBreakoutParticipants(assignments);
}}
>
Auto-assign participants
</button>
{localParticipant ? (
<div key={localParticipant.id}>
{localParticipant.displayName || "Guest"} (you){" "}
<select
value={localParticipant.breakoutGroupAssigned || ""}
onChange={(e) =>
assignBreakoutParticipants({
[localParticipant.id]: e.target.value,
})
}
>
<option value="">Unassigned</option>
{Object.entries(breakoutGroups).map(([id, name]) => (
<option key={id} value={id}>
{name}
</option>
))}
</select>
</div>
) : null}
{remoteParticipants.map((p) => (
<div key={p.id}>
{p.displayName || "Guest"}{" "}
<select
value={p.breakoutGroupAssigned || ""}
onChange={(e) =>
assignBreakoutParticipants({ [p.id]: e.target.value })
}
>
<option value="">Unassigned</option>
{Object.entries(breakoutGroups).map(([id, name]) => (
<option key={id} value={id}>
{name}
</option>
))}
</select>
</div>
))}
</div>

{!breakout.isActive ? (
<button
onClick={() => startBreakoutSession({ groups: breakoutGroups })}
className={
localParticipant?.roleName !== "host"
? "hostControlActionDisallowed"
: ""
}
>
Start breakout session
</button>
) : (
<button onClick={() => stopBreakoutSession()}>Stop breakout session</button>
)}
</div>
) : null}
{breakout.isActive ? <h2>Breakout groups</h2> : null}
{breakout.isActive ? <h3>Current group: {breakout.currentGroup?.name}</h3> : null}
{breakout.groupedParticipants.map((group) => {
// main room
if (group.group?.id === "") {
return null;
}
return (
<div key={group.group?.id}>
<h3>{group.group?.name}</h3>
{group.clients.map((p) => (
<div key={p.id}>{p.displayName || "Guest"}</div>
))}
<button onClick={() => joinBreakoutGroup(group.group?.id || "")}>Join</button>
</div>
);
})}
{breakout.isActive ? <h2>Main room</h2> : null}
{breakout.groupedParticipants.map((p) => {
if (p.group?.id === "") {
return p.clients.map((p) => <div key={p.id}>{p.displayName || "Guest"}</div>);
}
return null;
})}
{breakout.isActive ? (
<button onClick={() => joinBreakoutMainRoom()}>Join main room</button>
) : null}
</div>
<BreakoutPanel
breakout={breakout}
connectionStatus={connectionStatus}
localParticipant={localParticipant}
remoteParticipants={remoteParticipants}
spotlightedParticipants={spotlightedParticipants}
actions={actions}
showHostControls={showHostControls}
/>
) : null}

{showCameraEffects ? (
Expand Down Expand Up @@ -858,12 +705,22 @@ export default function VideoExperience({
<form
onSubmit={(e) => {
e.preventDefault();
sendChatMessage(chatMessage, chatMessageParent);
sendChatMessage(chatMessage, chatMessageParent, chatBroadcast);
setChatMessage("");
setChatMessageParent("");
}}
>
<input type="text" value={chatMessage} onChange={(e) => setChatMessage(e.target.value)} />
{breakout.isActive ? (
<label>
<input
type="checkbox"
checked={chatBroadcast}
onChange={(e) => setChatBroadcast(e.target.checked)}
/>
Broadcast to all groups
</label>
) : null}
<select value={chatMessageParent} onChange={(e) => setChatMessageParent(e.target.value)}>
<option key="chat-select-room" value="">
Send to room
Expand Down
Loading