diff --git a/packages/client/src/sorting/__tests__/presets.test.ts b/packages/client/src/sorting/__tests__/presets.test.ts new file mode 100644 index 0000000000..207acb1d5c --- /dev/null +++ b/packages/client/src/sorting/__tests__/presets.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import * as TestData from './participant-data'; +import { VisibilityState } from '../../types'; +import { paginatedLayoutSortPreset } from '../presets'; +import { TrackType } from '../../gen/video/sfu/models/models'; + +describe('presets', () => { + it('paginatedLayoutSortPreset', () => { + const ps = TestData.participants().map((p) => ({ + ...p, + viewportVisibilityState: { + videoTrack: VisibilityState.UNKNOWN, + screenShareTrack: VisibilityState.UNKNOWN, + }, + })); + + expect(ps.sort(paginatedLayoutSortPreset).map((p) => p.name)).toEqual([ + 'F', + 'B', + 'E', + 'D', + 'A', + 'C', + ]); + + // server-pin C + ps.at(-1)!.pin = { + isLocalPin: false, + pinnedAt: Date.now(), + }; + + expect(ps.sort(paginatedLayoutSortPreset).map((p) => p.name)).toEqual([ + 'C', + 'F', + 'B', + 'E', + 'D', + 'A', + ]); + + ps.at(-3)!.publishedTracks = [TrackType.AUDIO]; // E + ps.at(-2)!.isDominantSpeaker = false; // D + ps.at(-1)!.isDominantSpeaker = true; // A + + expect(ps.sort(paginatedLayoutSortPreset).map((p) => p.name)).toEqual([ + 'C', + 'F', + 'B', + 'A', + 'E', + 'D', + ]); + }); +}); diff --git a/packages/client/src/sorting/presets.ts b/packages/client/src/sorting/presets.ts index 982c07f0b7..23071a9dd5 100644 --- a/packages/client/src/sorting/presets.ts +++ b/packages/client/src/sorting/presets.ts @@ -20,6 +20,19 @@ const ifInvisibleBy = conditional( b.viewportVisibilityState?.videoTrack === VisibilityState.INVISIBLE, ); +/** + * A comparator that applies the decorated comparator when a participant is + * either invisible or its visibility state isn't known. + * For visible participants, it ensures stable sorting. + */ +const ifInvisibleOrUnknownBy = conditional( + (a: StreamVideoParticipant, b: StreamVideoParticipant) => + a.viewportVisibilityState?.videoTrack === VisibilityState.INVISIBLE || + a.viewportVisibilityState?.videoTrack === VisibilityState.UNKNOWN || + b.viewportVisibilityState?.videoTrack === VisibilityState.INVISIBLE || + b.viewportVisibilityState?.videoTrack === VisibilityState.UNKNOWN, +); + /** * The default sorting preset. */ @@ -48,6 +61,21 @@ export const speakerLayoutSortPreset = combineComparators( // ifInvisibleBy(name), ); +/** + * The sorting preset for layouts that don't render all participants but + * instead, render them in pages. + */ +export const paginatedLayoutSortPreset = combineComparators( + pinned, + screenSharing, + dominantSpeaker, + ifInvisibleOrUnknownBy(speaking), + ifInvisibleOrUnknownBy(reactionType('raised-hand')), + ifInvisibleOrUnknownBy(publishingVideo), + ifInvisibleOrUnknownBy(publishingAudio), + // ifInvisibleOrUnknownBy(name), +); + /** * The sorting preset for livestreams and audio rooms. */ diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index c80121640b..4cf80079fb 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -54,17 +54,19 @@ Here are some of the features we support: ## Roadmap ### 0.4 milestone -- [X] Enhanced device management API -- [X] Composite layout for streaming and recording -- [ ] Livestream Player + +- [x] Enhanced device management API +- [x] Composite layout for streaming and recording +- [x] Livestream Player - [ ] Screenshare Audio - [ ] Screen-sharing resolution and FPS control - [ ] Fast-reconnects - [ ] New Device Management API -- [X] SFU retries +- [x] SFU retries - [ ] Query call session endpoint ### 0.5 milestone + - [ ] Enhanced UI components and theming - [ ] Enhanced SDK build system - [ ] Typescript generics enhancements @@ -76,6 +78,7 @@ Here are some of the features we support: - [ ] E2E testing platform ### 0.6 milestone + - [ ] Break-out rooms - [ ] Waiting rooms - [ ] Transcriptions @@ -83,7 +86,6 @@ Here are some of the features we support: - [ ] Video and audio filters - [ ] Dynascale: turn off incoming video when the browser is in the background - ## Contributing - How can I submit a sample app? diff --git a/packages/react-sdk/docusaurus/docs/React/02-tutorials/03-livestream.mdx b/packages/react-sdk/docusaurus/docs/React/02-tutorials/03-livestream.mdx index f635998e25..6586b84c7e 100644 --- a/packages/react-sdk/docusaurus/docs/React/02-tutorials/03-livestream.mdx +++ b/packages/react-sdk/docusaurus/docs/React/02-tutorials/03-livestream.mdx @@ -60,7 +60,7 @@ const client = new StreamVideoClient({ apiKey, user, token }); const call = client.call('livestream', callId); call.join({ create: true }); -export const App = () => { +export const MyApp = () => { return ( @@ -311,6 +311,9 @@ Compared to the current code in in `App.tsx` you: - Don't render the local video, but instead render the remote videos - `useRemoteParticipants` instead of `useLocalParticipant` - Typically include some small UI elements like viewer count, a button to mute etc. +Additionally, you can use our SDK provided `LivestreamLayout` that enables consuming WebRTC-based livestreams. +Read more about it here: [Watching a livestream (WebRTC)](../../ui-cookbook/watching-a-livestream). + ### Step 6 - (Optional) Viewing a livestream with HLS Another way to watch a livestream is using HLS. HLS tends to have a 10 to 20 seconds delay, while the above WebRTC approach is realtime. diff --git a/packages/react-sdk/docusaurus/docs/React/06-ui-cookbook/15-watching-a-livestream.mdx b/packages/react-sdk/docusaurus/docs/React/06-ui-cookbook/15-watching-a-livestream.mdx new file mode 100644 index 0000000000..4e518ee245 --- /dev/null +++ b/packages/react-sdk/docusaurus/docs/React/06-ui-cookbook/15-watching-a-livestream.mdx @@ -0,0 +1,61 @@ +--- +id: watching-a-livestream +title: Watching a livestream +--- + +As already described in our [livestream tutorial](../../tutorials/livestream), there are two ways how a livestream +can be watched: either via HLS or via WebRTC. + +This guide describes how to watch a livestream via WebRTC. + +Our React Video SDK provides a specialized `LivestreamLayout` component for this purpose. +Right next to playing the livestream, it also provides a standardized livestreaming experience: + +- Shows a "Live" indicator badge +- Shows the current viewer count +- Shows the duration of the livestream +- Allows toggling between fullscreen and normal mode + +Here is a preview of the `LivestreamLayout` component in video mode: +![Preview of the UI](../assets/06-ui-cookbook/15-watching-a-livestream/normal-mode.png) + +And, in screen share mode: +![Preview of the UI](../assets/06-ui-cookbook/15-watching-a-livestream/screenshare-mode.png) + +## Usage + +```tsx +import { + LivestreamLayout, + StreamVideo, + StreamCall, +} from '@stream-io/video-react-sdk'; + +export const MyLivestreamApp = () => { + // init client and call here... + return ( + + + + + + ); +}; +``` + +Note: Please make sure that the livestream is started and the call isn't in `backstage` mode before rendering this component. + +### Customization options + +The `LivestreamLayout` component provides a few customization options that can be passed as props: + +- `muted` - a `boolean` flag that indicates whether the livestream should be muted or not. Defaults to `false`. +- `enableFullscreen` - a `boolean` flag that indicates whether the fullscreen button should be shown or not. Defaults to `true`. +- `showParticipantCount`- a `boolean` flag that indicates whether the current viewer count should be shown or not. Defaults to `true`. +- `showDuration` - a `boolean` flag that indicates whether the duration of the livestream should be shown or not. Defaults to `true`. +- `showLiveBadge`- a `boolean` flag that indicates whether the "Live" badge should be shown or not. Defaults to `true`. +- `showSpeakerName` - a `boolean` flag that indicates whether the speaker should be shown or not. Defaults to `false`. +- `floatingParticipantProps` - an optional object that contains props that should be passed to the "Floating Participant" component in screen-share mode. + They are identical to the props of the `LivestreamLayout` component props. +- `floatingParticipantProps.position` - a `string` that indicates the position of the "Floating Participant" component. + Can be either `top-left`, `top-right`, `bottom-left` or `bottom-right`. Defaults to `top-right`. diff --git a/packages/react-sdk/docusaurus/docs/React/assets/06-ui-cookbook/15-watching-a-livestream/normal-mode.png b/packages/react-sdk/docusaurus/docs/React/assets/06-ui-cookbook/15-watching-a-livestream/normal-mode.png new file mode 100644 index 0000000000..636fc157ca Binary files /dev/null and b/packages/react-sdk/docusaurus/docs/React/assets/06-ui-cookbook/15-watching-a-livestream/normal-mode.png differ diff --git a/packages/react-sdk/docusaurus/docs/React/assets/06-ui-cookbook/15-watching-a-livestream/screenshare-mode.png b/packages/react-sdk/docusaurus/docs/React/assets/06-ui-cookbook/15-watching-a-livestream/screenshare-mode.png new file mode 100644 index 0000000000..423a44bd02 Binary files /dev/null and b/packages/react-sdk/docusaurus/docs/React/assets/06-ui-cookbook/15-watching-a-livestream/screenshare-mode.png differ diff --git a/packages/react-sdk/src/components/CallParticipantsList/CallParticipantListingItem.tsx b/packages/react-sdk/src/components/CallParticipantsList/CallParticipantListingItem.tsx index bc8632e006..4071c7b20c 100644 --- a/packages/react-sdk/src/components/CallParticipantsList/CallParticipantListingItem.tsx +++ b/packages/react-sdk/src/components/CallParticipantsList/CallParticipantListingItem.tsx @@ -211,11 +211,12 @@ export const ParticipantActionsContextMenu = ({ }; const toggleFullscreenMode = () => { - if (!fullscreenModeOn) + if (!fullscreenModeOn) { return participantViewElement ?.requestFullscreen() .then(() => setFullscreenModeOn(true)) .catch(console.error); + } document .exitFullscreen() diff --git a/packages/react-sdk/src/core/components/Audio/ParticipantsAudio.tsx b/packages/react-sdk/src/core/components/Audio/ParticipantsAudio.tsx new file mode 100644 index 0000000000..45db25ac0f --- /dev/null +++ b/packages/react-sdk/src/core/components/Audio/ParticipantsAudio.tsx @@ -0,0 +1,35 @@ +import { ComponentProps } from 'react'; +import { SfuModels, StreamVideoParticipant } from '@stream-io/video-client'; +import { Audio } from './Audio'; + +export type ParticipantsAudioProps = { + /** + * The participants to play audio for. + */ + participants: StreamVideoParticipant[]; + + /** + * Props to pass to the underlying `Audio` components. + */ + audioProps?: ComponentProps; +}; + +export const ParticipantsAudio = (props: ParticipantsAudioProps) => { + const { participants, audioProps } = props; + return ( + <> + {participants.map( + (participant) => + !participant.isLocalParticipant && + participant.publishedTracks.includes(SfuModels.TrackType.AUDIO) && + participant.audioStream && ( +