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
5 changes: 5 additions & 0 deletions .changeset/tall-taxis-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@whereby.com/browser-sdk": minor
---

browser-sdk: add optional renderSubgridParticipant prop to the VideoGrid component, allowing developers to override the participant UI in the video subgrid
5 changes: 5 additions & 0 deletions packages/browser-sdk/src/lib/react/Grid/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ function renderCellView({ cellView, enableParticipantMenu, render }: RenderCellV

interface GridProps {
renderParticipant?: ({ participant }: { participant: ClientView }) => React.ReactNode;
renderSubgridParticipant?: ({ participant }: { participant: ClientView }) => React.ReactNode;
renderFloatingParticipant?: ({ participant }: { participant: ClientView }) => React.ReactNode;
gridGap?: number;
videoGridGap?: number;
Expand All @@ -142,6 +143,7 @@ interface GridProps {

function Grid({
renderParticipant,
renderSubgridParticipant,
renderFloatingParticipant,
stageParticipantLimit,
gridGap,
Expand Down Expand Up @@ -226,6 +228,9 @@ function Grid({
renderCellView({
cellView,
enableParticipantMenu,
...(renderSubgridParticipant
? { render: ({ participant }) => renderSubgridParticipant({ participant }) }
: {}),
}),
),
[cellViewsInSubgrid],
Expand Down
166 changes: 166 additions & 0 deletions packages/browser-sdk/src/stories/components/FakeGridClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import * as React from "react";

import { ClientView, WherebyClient } from "@whereby.com/core";
import { WherebyContext } from "../../lib/react/Provider";
import { NAMES, sampleNameForIndex } from "./VideoGridTestData";

const COLORS = ["#4a90d9", "#d94a6a", "#4ad98f", "#d9b34a", "#8f4ad9", "#4ad9d9", "#d97a4a", "#7ad94a"];

type FakeStream = MediaStream & { __stopDrawing?: () => void };

function createFakeVideoStream(label: string, color: string): FakeStream {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 480;
const ctx = canvas.getContext("2d")!;

const start = performance.now();
const draw = () => {
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvas.width, canvas.height);

// Moving dot so the stream is visibly live
const t = (performance.now() - start) / 1000;
ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
ctx.beginPath();
ctx.arc(320 + Math.cos(t) * 200, 240 + Math.sin(t) * 120, 20, 0, Math.PI * 2);
ctx.fill();

ctx.fillStyle = "#fff";
ctx.font = "bold 40px sans-serif";
ctx.textAlign = "center";
ctx.fillText(label, canvas.width / 2, canvas.height / 2);
};

draw();
const interval = setInterval(draw, 100);
const stream = canvas.captureStream(10) as FakeStream;
stream.__stopDrawing = () => clearInterval(interval);
return stream;
}

function makeFakeParticipants({ count, videosOff }: { count: number; videosOff: number }): ClientView[] {
return Array.from({ length: count }, (_, i): ClientView => {
const displayName = `${sampleNameForIndex(i)}${i >= NAMES.length ? ` ${Math.floor(i / NAMES.length) + 1}` : ""}`;
const isVideoEnabled = i < count - videosOff;

return {
id: `fake-${i}`,
clientId: `fake-${i}`,
displayName,
isLocalClient: i === 0,
isAudioEnabled: i % 2 === 0,
isVideoEnabled,
stream: isVideoEnabled ? createFakeVideoStream(displayName, COLORS[i % COLORS.length]) : null,
};
});
}

function stopFakeParticipants(participants: ClientView[]) {
participants.forEach((p) => {
(p.stream as FakeStream | null)?.__stopDrawing?.();
p.stream?.getTracks().forEach((t) => t.stop());
});
}

/**
* Implements the subset of GridClient the Grid component tree uses
* (useGridParticipants + ParticipantMenu), backed by fake participants
* instead of a room connection.
*/
export class FakeGridClient {
private clientViews: ClientView[] = [];
private spotlighted: ClientView[] = [];
private clientViewSubscribers = new Set<(clientViews: ClientView[]) => void>();
private spotlightedSubscribers = new Set<(spotlighted: ClientView[]) => void>();
private numberOfClientViewsSubscribers = new Set<(num: number) => void>();

public setClientViews(clientViews: ClientView[]) {
this.clientViews = clientViews;
// Re-map spotlights to the new view objects (subgrid calculation relies on identity)
this.spotlighted = this.spotlighted
.map((s) => clientViews.find((c) => c.id === s.id))
.filter((c): c is ClientView => Boolean(c));
this.emit();
}

private emit() {
this.clientViewSubscribers.forEach((cb) => cb(this.clientViews));
this.spotlightedSubscribers.forEach((cb) => cb(this.spotlighted));
this.numberOfClientViewsSubscribers.forEach((cb) => cb(this.clientViews.length));
}

public subscribeClientViews(callback: (clientViews: ClientView[]) => void): () => void {
this.clientViewSubscribers.add(callback);
callback(this.clientViews);
return () => this.clientViewSubscribers.delete(callback);
}

public subscribeSpotlightedParticipants(callback: (spotlighted: ClientView[]) => void): () => void {
this.spotlightedSubscribers.add(callback);
callback(this.spotlighted);
return () => this.spotlightedSubscribers.delete(callback);
}

public subscribeNumberOfClientViews(callback: (num: number) => void): () => void {
this.numberOfClientViewsSubscribers.add(callback);
callback(this.clientViews.length);
return () => this.numberOfClientViewsSubscribers.delete(callback);
}

public spotlightParticipant(id: string) {
const view = this.clientViews.find((c) => c.id === id);
if (view && !this.spotlighted.includes(view)) {
this.spotlighted = [...this.spotlighted, view];
this.emit();
}
}

public removeSpotlight(id: string) {
this.spotlighted = this.spotlighted.filter((c) => c.id !== id);
this.emit();
}
}

/**
* Provides a WherebyContext backed by fake participants, letting the Grid
* render (including subgrid behaviour) without joining a room.
*/
export function FakeParticipantsProvider({
numParticipants,
numVideosOff,
children,
}: {
numParticipants: number;
numVideosOff: number;
children: React.ReactNode;
}) {
const fakeGrid = React.useMemo(() => new FakeGridClient(), []);
const fakeClient = React.useMemo(
() =>
({
getGrid: () => fakeGrid,
// VideoView reports stream resolutions through the room connection — no-op here
getRoomConnection: () => ({ reportStreamResolution: () => {} }),
// useAudioElement tracks the current speaker device — static stub
getLocalMedia: () => ({
getState: () => ({ currentSpeakerDeviceId: "default" }),
addListener: () => {},
removeListener: () => {},
}),
}) as unknown as WherebyClient,
[fakeGrid],
);

React.useEffect(() => {
const participants = makeFakeParticipants({
count: numParticipants,
videosOff: Math.min(numVideosOff, numParticipants),
});
fakeGrid.setClientViews(participants);

return () => stopFakeParticipants(participants);
}, [fakeGrid, numParticipants, numVideosOff]);

return <WherebyContext.Provider value={fakeClient}>{children}</WherebyContext.Provider>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const WIDE = 16 / 9;
export const PORTRAIT = 3 / 4;
export const SQUARE = 1;

const NAMES = [
export const NAMES = [
"Shannon Winegar",
"Renaldo Bettcher",
"Rufus Swarey",
Expand All @@ -29,7 +29,7 @@ const NAMES = [
"Nakita Rudloff",
];

const sampleNameForIndex = (index: number) => NAMES[((index % NAMES.length) + NAMES.length) % NAMES.length];
export const sampleNameForIndex = (index: number) => NAMES[((index % NAMES.length) + NAMES.length) % NAMES.length];

function WebRtcVideo({ children }: { children: React.ReactNode }) {
return (
Expand Down
56 changes: 48 additions & 8 deletions packages/browser-sdk/src/stories/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@
background-color: rgba(0, 0, 0, 0.5);
}

.controls, .hostControls {
.controls,
.hostControls {
display: flex;
gap: 10px;
padding: 12px 4px;
}

.hostControls {
background-color: #EEE;
background-color: #eee;
}

.hostControlActionDisallowed {
Expand Down Expand Up @@ -91,13 +92,53 @@
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}

.videoView {
width: 100%;
height: 100%;
flex: 1 1 0;
min-height: 0;
border-radius: 20px;
border: 2px solid red;
object-fit: cover;
}

.gridCellName {
flex: none;
max-width: 100%;
padding: 2px 6px;
font-size: 14px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}

/* Subgrid cells are small — contain the avatar/video and overlay the name at the bottom */
.subgridCell {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 8px;
}

.subgridCellName {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 1px 4px;
font-size: 11px;
line-height: 1.3;
color: #ffffff;
background-color: rgba(0, 0, 0, 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}

.participantMenuTrigger {
Expand All @@ -108,24 +149,23 @@
border-radius: 6px;
padding: 4px;
color: #ffffff;
left: 50%!important;
top: 50%!important;
left: 50% !important;
top: 50% !important;
max-width: max-content;
}

.participantMenuContent {
background-color: rgba(0, 0, 0, 0.5)!important;
background-color: rgba(0, 0, 0, 0.5) !important;
}

.participantMenuItem {
color: #ffffff;

&:hover {
background-color: rgba(0, 0, 0, 0.3)!important;
background-color: rgba(0, 0, 0, 0.3) !important;
}
}


.chat {
margin-top: 10px;
display: flex;
Expand Down
Loading
Loading