Skip to content

Add support for multiple content shares #998

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 25, 2025
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Added support for multiple content shares in meetings through the `ContentShareProvider`. The provider now accepts a `maxContentShares` prop (default: 1, range 1-2) to specify the maximum number of concurrent content shares allowed.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nonblocker: maxConcurrentContentShare may be a better name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel 'concurrent' is redundant here, since 'maxContentShares' itself is already clear and unambiguous.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we dont have such a prop? Can we take Video tiles like route. For example, we have a max limit on video streams but we dont have a prop for the max limit for them limiting builders. Correct me if not here. Have to check how that is limited component usage wise.

Copy link
Contributor Author

@xuesichao xuesichao Apr 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not following your question, could you explain a bit more?

We need this props for two reasons:

  1. Since this is a breaking change and we're not planning a major version release, we need to implement an opt-in mechanism so builders can explicitly enable this feature.
  2. Builder might want to limit concurrent content share to 1 even when 2 is supported, this prop can handle this conveniently. Alternatively builder can check the number of number active content share via tiles.length, and have a flag to disable the button when it tiles.length <=1 by themselves.

Copy link
Contributor

@devalevenkatesh devalevenkatesh Apr 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, just trying to see whether we adding a new prop vs using the tiles.length is fine too. If the later is fine, why introduce a new one? Now still going through review, will understand and sync on the breaking change part. Are you saying that builders if today with existing library APIs try to start 2 content share (if they can), that would break content share components?

Copy link
Contributor Author

@xuesichao xuesichao Apr 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the later is fine, why introduce a new one?

Since this is a breaking change and we're not planning a major version release, we need to implement an opt-in mechanism so builders can explicitly enable this feature.

will understand and sync on the breaking change part. Are you saying that builders if today with existing library APIs try to start 2 content share (if they can), that would break content share components?

Before this change when the second attendee starts content share, sdk will stop existing content share (when it detects a second content share tile), so it ensures only one content share tile available. After this change, when allowing multiple content share, starting the second content share won't stop the existing one. This is the breaking behavior and user needs to explicitly opt-in this

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right I get it now, its more like a feature flag in a way where its honored and backward compatible in the ContentShareProvider.

- Added new collections in `ContentShareState` to track multiple content shares: `tiles`, `tileIdToAttendeeId`, and `attendeeIdToTileId`.
- Added optional `tileId` prop to the `ContentShare` component to specify which content share to render.
- Added `canStartContentShare` state to control when content sharing is allowed based on the current number of shares and configured maximum.
- Maintained backward compatibility by keeping `tileId` and `sharingAttendeeId` properties, which now point to the most recently started content share when multiple shares are present.

### Removed

### Changed
Expand Down
69 changes: 66 additions & 3 deletions src/components/sdk/ContentShare/ContentShare.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import { ContentShare } from './';

# ContentShare

The `ContentShare` component renders a `ContentTile` for the active content share video, remote or local.
The `ContentShare` component renders a `ContentTile` for a content share video, remote or local.

If used within the `VideoGrid` component, it will automatically place the active tile in the featured grid slot. It takes precedence over the featured video tile.

Once a meeting session has been started, a user can start and stop content sharing by using the `useContentShareControls` hook.

## Multiple Content Shares

With the support for multiple content shares, you can now specify which content share tile to render by providing the `tileId` prop. If no `tileId` is provided, the component will render the default content share tile from (`const { tileId } = useContentShareState()`).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what will the tileId default to in case one does not provide it as a prop? Suggest adding a clarification for builder to help get answer to this question in the docs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also link to ContentShareProvider with a small explanation on how ContentShareProvider maxContentShares={2} impacts the ContentShare component.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If no tileId is provided, the component will render the default content share tile from (const { tileId } = useContentShareState()).

I think this is clear enough. It will use the existing tileId property from useContentShareState() hook

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also link to ContentShareProvider with a small explanation on how ContentShareProvider maxContentShares={2} impacts the ContentShare component.

To clarify, maxContentShares doesn't impact ContentShare.


## Importing

```javascript
Expand All @@ -19,26 +23,85 @@ import { ContentShare } from 'amazon-chime-sdk-component-library-react';

## Usage

### With Single Content Share

```jsx
import React from 'react';
import {
MeetingProvider,
ContentShare,
useContentShareControls
useContentShareControls,
} from 'amazon-chime-sdk-component-library-react';

const App = () => {
const { toggleContentShare } = useContentShareControls();

return (
<MeetingProvider>
<ContentShare nameplate='Content share' />
<ContentShare nameplate="Content share" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we using " or ' in this repo?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use single quote for JS, but here it should be double quote because it's a property on JSX element. Like we should use double quote on HTML element

<button onClick={toggleContentShare}>Toggle content share</button>
</MeetingProvider>
);
};
```

### With Multiple Content Shares

```jsx
import React from 'react';
import {
MeetingProvider,
ContentShare,
ContentShareProvider,
useContentShareState,
useContentShareControls,
} from 'amazon-chime-sdk-component-library-react';

const App = () => {
return (
<MeetingProvider>
<ContentShareProvider maxContentShares={2}>
<ContentShareView />
<ContentShareControls />
</ContentShareProvider>
</MeetingProvider>
);
};

const ContentShareView = () => {
const { tiles, tileIdToAttendeeId } = useContentShareState();

return (
<div>
{tiles.length === 0 ? (
<p>No content is being shared</p>
) : (
<div className="content-share-container">
{tiles.map((tileId) => (
<ContentShare
key={tileId}
tileId={tileId}
nameplate={`Shared by: ${tileIdToAttendeeId[tileId.toString()]}`}
/>
))}
</div>
)}
</div>
);
};

const ContentShareControls = () => {
const { toggleContentShare } = useContentShareControls();
const { canStartContentShare } = useContentShareState();

return (
<button onClick={toggleContentShare} disabled={!canStartContentShare}>
Share content
</button>
);
};
```

## Props

<ArgTypes of={ContentShare} />
28 changes: 20 additions & 8 deletions src/components/sdk/ContentShare/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,37 +10,49 @@ import { BaseSdkProps } from '../Base';

interface Props extends BaseSdkProps {
nameplate?: string;
tileId?: number;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that since the VideoGrid UI Component only supports one featured area/tile, when there's two content shares available, I split the featured area into two parts to display both content share tiles there. This change only exists in meeting demo (I implemented a custom VideoTileGrid component in demo app) for testing purpose.

For builders, they will need to create VideoTileGrid to handle multiple content share tiles based on their UX using updated ContentTile component.

}

export const ContentShare: React.FC<React.PropsWithChildren<Props>> = ({
className,
tileId,
...rest
}) => {
const audioVideo = useAudioVideo();
const { tileId } = useContentShareState();
const contentShareState = useContentShareState();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking from keeping the props limited with no new optional props idea. How about we maintain tiles as an array in the content share state. Based on the number of tiles, we should show those many ContentTiles?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But yes, we would have to maintain support of contantShareState.tileId.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In short, I decide to let builder to decide how and where to render the multiple content share tiles via the new tileId of ContentShare component. Check code example in its documentation for example.


// Use the provided tileId or fall back to the default (for backward compatibility)
const tileIdToRender =
tileId !== undefined ? tileId : contentShareState.tileId;

const attendeeId = tileIdToRender
? contentShareState.tileIdToAttendeeId[tileIdToRender.toString()]
: null;

const videoEl = useRef<HTMLVideoElement | null>(null);

useEffect(() => {
if (!audioVideo || !videoEl.current || !tileId) {
if (!audioVideo || !videoEl.current || !tileIdToRender) {
return;
}

audioVideo.bindVideoElement(tileId, videoEl.current);
audioVideo.bindVideoElement(tileIdToRender, videoEl.current);

return () => {
const tile = audioVideo.getVideoTile(tileId);
const tile = audioVideo.getVideoTile(tileIdToRender);
if (tile) {
audioVideo.unbindVideoElement(tileId);
audioVideo.unbindVideoElement(tileIdToRender);
}
};
}, [audioVideo, tileId]);
}, [audioVideo, tileIdToRender]);

return tileId ? (
return tileIdToRender ? (
<ContentTile
objectFit="contain"
className={className || ''}
className={`ch-content-share--${tileIdToRender} ${className || ''}`}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a defined class name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this className is not used in fact, I added here to follow how we did for RemoteVideo component, it serves as an identifier

className={`ch-remote-video--${tileId} ${className || ''}`}

Copy link
Contributor

@devalevenkatesh devalevenkatesh Apr 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prefix classname is to make sure we can confidently override the styles on this one and is a way to let builders know that they should not change these I believe. ch stands for chime :) from those times.

{...rest}
ref={videoEl}
data-content-share-attendee={attendeeId}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this for testing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, this is to make it easier for builder to know which tile is for which attendee so they can locate the element quickly.

/>
) : null;
};
Expand Down
7 changes: 4 additions & 3 deletions src/components/sdk/FeaturedRemoteVideos/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,20 @@ export const FeaturedRemoteVideos: FC<React.PropsWithChildren<Props>> = (
const gridData = useGridData();
const { roster } = useRosterState();
const { tileId: featuredTileId } = useFeaturedTileState();
const { tileId: contentTileId } = useContentShareState();
const { tiles: contentTiles } = useContentShareState();
const { tiles, tileIdToAttendeeId } = useRemoteVideoTileState();

return (
<>
{tiles.map((tileId) => {
const featured = !contentTileId && featuredTileId === tileId;
const hasContentShare = contentTiles.length > 0;
const featured = !hasContentShare && featuredTileId === tileId;
const styles = gridData && featured ? 'grid-area: ft;' : '';
const classes = `${featured ? 'ch-featured-tile' : ''} ${
props.className || ''
}`;
const attendee = roster[tileIdToAttendeeId[tileId]] || {};
const { name }: any = attendee;
const { name = undefined }: { name?: string } = attendee;

return (
<RemoteVideo
Expand Down
42 changes: 40 additions & 2 deletions src/components/sdk/VideoTileGrid/VideoTileGrid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { VideoTileGrid } from './';

# VideoTileGrid

The `VideoTileGrid` component renders all meeting session video tiles in a responsive grid layout. This includes the local tile, remote tiles, and content share tile. By default a user joins without video, so in order to see the VideoTileGrid there must be at least one video tile being shared. To start sharing a video, see the [LocalVideo](?path=/docs/sdk-components-localvideo--page) component.
The `VideoTileGrid` component renders all meeting session video tiles in a responsive grid layout. This includes the local tile, remote tiles, and content share tile. By default a user joins without video, so in order to see the VideoTileGrid there must be at least one video tile being shared. To start sharing a video, see the [LocalVideo](?path=/docs/sdk-components-localvideo--page) component.

## Importing

Expand All @@ -19,7 +19,7 @@ import { VideoTileGrid } from 'amazon-chime-sdk-component-library-react';
import React from 'react';
import {
MeetingProvider,
VideoTileGrid
VideoTileGrid,
} from 'amazon-chime-sdk-component-library-react';

const App = () => (
Expand All @@ -29,6 +29,44 @@ const App = () => (
);
```

## Content Share Behavior

The `VideoTileGrid` component does not support displaying multiple content shares simultaneously:

- It can display only one content share tile at a time in the featured area
- When multiple content shares are active, only the most recently started content share is displayed, and earlier shares are ignored
- The grid size calculation always allocates exactly one tile space for content sharing (when content is present), regardless of how many content share tiles actually exist

This means that even if there are multiple content share tiles available through `useContentShareState()`, the VideoTileGrid will only render one of them.

### Custom Handling of Multiple Content Shares

If you need to support multiple simultaneous content shares, you'll need to implement a custom grid using `useContentShareState` hook and `ContentShare` sdk component.

```jsx
import React from 'react';
import {
useContentShareState,
ContentShare,
} from 'amazon-chime-sdk-component-library-react';

const CustomMultiContentShareGrid = () => {
// Access all content share tiles
const { tiles: contentTiles } = useContentShareState();

return (
<div className="custom-grid">
{/* Render each content share tile */}
{contentTiles.map((tileId) => (
<ContentShare key={tileId} tileId={tileId} />
))}

{/* Other video tiles */}
</div>
);
};
```

## Props

<ArgTypes of={VideoTileGrid} />
16 changes: 11 additions & 5 deletions src/components/sdk/VideoTileGrid/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,21 @@ export const VideoTileGrid: React.FC<React.PropsWithChildren<Props>> = ({
...rest
}) => {
const { tileId: featureTileId } = useFeaturedTileState();
const { tiles } = useRemoteVideoTileState();
const { tileId: contentTileId } = useContentShareState();
const { tiles: remoteVideoTiles } = useRemoteVideoTileState();
const { tiles: contentShareTiles } = useContentShareState();
const { isVideoEnabled } = useLocalVideo();
const featured =
(layout === 'featured' && !!featureTileId) || !!contentTileId;
const remoteSize = tiles.length + (contentTileId ? 1 : 0);

const hasContentShare = contentShareTiles.length > 0;
// contentShareSize is counted as 1 in the grid size calculation, since only the most
// recent content share tile is displayed in the featured area when multiple content shares exist
const contentShareSize = hasContentShare ? 1 : 0;
const remoteSize = remoteVideoTiles.length + contentShareSize;
const gridSize =
remoteSize > 1 && isVideoEnabled ? remoteSize + 1 : remoteSize;

const featured =
(layout === 'featured' && !!featureTileId) || hasContentShare;

return (
<VideoGrid {...rest} size={gridSize} layout={featured ? 'featured' : null}>
<ContentShare css="grid-area: ft;" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,27 @@ import { Meta } from '@storybook/blocks';

The `ContentShareProvider` provides state and functionality for content sharing.

### Props

```javascript
{
// Maximum number of concurrent content shares allowed (default: 1, range: 1-2)
maxContentShares?: number;
}
```

### State

```javascript
{
// The tile ID of the active content share
// The tile ID of the active content share (deprecated, maintained for backward compatibility)
// When multiple content shares are present, this points to the most recently started content share
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a task for next major version release, we should have a point to rename this to activeTileId :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we don't plan a major version release. When we do major version release in the future we will delete tileId and sharingAttendeeId and builder should use the new collections instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it seems now the active tiles will be under tiles and current tileId -> tiles[0] where tiles will have a single content share to preserve backward compatibility.

tileId: number | null;

// The chime attendee ID of the user sharing (deprecated, maintained for backward compatibility)
// When multiple content shares are present, this points to the attendee of the most recently started content share
sharingAttendeeId: string | null;

// Whether the content share is paused
paused: boolean;

Expand All @@ -22,8 +36,17 @@ The `ContentShareProvider` provides state and functionality for content sharing.
// Whether or not the local user's content share is loading
isLocalShareLoading: boolean;

// The chime attendee ID of the user sharing
sharingAttendeeId: string | null;
// Whether the user can start a content share based on current limits and `isLocalUserSharing`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can expand a bit more on "current limits" for clarity here. Is this just the maxContentShares?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, let me update it, it should be maxContentShares, current limits is not very clear.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like still needs an update?

canStartContentShare: boolean;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to change this to hasReachedContentShareLimit so it's decoupled from isLocalUserSharing, let me know your thoughts

Copy link
Contributor

@ziyiz-amzn ziyiz-amzn Apr 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for decouple, feel like state should be independent from each other here, also, builder may have feature on hasReachedContentShareLimit only.

Copy link
Contributor Author

@xuesichao xuesichao Apr 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My initial thought is to add a convenient boolean flag that builders can use to disable the content share button. This is needed to handle two scenarios:

  1. When content sharing limits have been reached
  2. When the local attendee is already sharing content

For scenario 1, previously, with single content share, if a second attendee started sharing, the SDK would automatically stop the existing share (takeover). However, now that we support multiple (2) concurrent content shares, attempting a third share will fail due to media limits, and the takeover won't occur. Since error handling for this case isn't straightforward (the error can't be caught directly in the API call), it's better to proactively disable the content share button when limits are reached.

Even without canStartContentShare builder can still achieve the same by using

const canStartContentShare = tiles.length < maxContentShares && !isLocalUserSharing

It's a comparison between below three options, and I think option 1 is most convenient and aligns with my intention.

const disabled = canStartContentShare; // option 1
const disabled = hasReachedContentShareLimit && !isLocalUserSharing // option 2
const disabled = tiles.length < maxContentShares && !isLocalUserSharing // option 3

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if there an use case that builder can implement to kick off the 1st sharing when the 3rd starts? In this case, even though it reaches the max limit, but it literally is allowed to start content share.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Today we don't allow three content shares from media side. So they cannot start a 3rd one, but they can stop one content share first, then canStartContentShare becomes true and then start the 3rd (which is in fact the second content share stream in the meeting)

Copy link
Contributor

@devalevenkatesh devalevenkatesh Apr 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm thinking option 1 is simplest for builders? Reason: The limit on whether a content share can start or not when its at max limit + local user sharing is decided by the library and not by the builders. If we add hasReachedContentShareLimit instead, then it seems builder will have to make such a decision knowing what all applies for a content share to be not allowed.

Thus, current props sounds fine to me. But we have to explain this in the documentation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, I'll go with option 1 now. We can always add hasReachedContentShareLimit if we find it needed later.


// Array of all content share tile IDs
tiles: number[];

// Map of tile IDs to attendee IDs
tileIdToAttendeeId: { [key: string]: string };

// Map of attendee IDs to tile IDs
attendeeIdToTileId: { [key: string]: number };
}
```

Expand Down Expand Up @@ -53,6 +76,49 @@ const MyChild = () => {
};
```

## Multiple Content Shares

You can configure the maximum number of concurrent content shares allowed (up to 2):

```jsx
import React from 'react';
import {
MeetingProvider,
ContentShareProvider,
useContentShareState,
} from 'amazon-chime-sdk-component-library-react';

const App = () => (
<MeetingProvider>
{/* Override the default maxContentShares value (1) in ContentShareProvider */}
<ContentShareProvider maxContentShares={2}>
<MyComponent />
</ContentShareProvider>
</MeetingProvider>
);

const MyComponent = () => {
const { tiles, tileIdToAttendeeId, canStartContentShare } =
useContentShareState();

return (
<div>
<p>Content shares available: {tiles.length}</p>
<p>Can start content share: {canStartContentShare ? 'Yes' : 'No'}</p>
<div>
{tiles.map((tileId) => (
<ContentShare
key={tileId}
tileId={tileId}
nameplate={`Shared by: ${tileIdToAttendeeId[tileId.toString()]}`}
/>
))}
</div>
</div>
);
};
```

## Usage without MeetingProvider

If you opt out of using `MeetingProvider`, you can drop in a `ContentShareProvider` and use its state. Make sure that its dependencies are rendered higher in the tree.
Expand Down
Loading
Loading