logo
Build a UGC Live Streaming App with Amazon IVS: Broadcast Real-Time with Multi-Hosts (Lesson 4.3)

Build a UGC Live Streaming App with Amazon IVS: Broadcast Real-Time with Multi-Hosts (Lesson 4.3)

Welcome to Lesson 4.3 in this series where we're looking at building a web based user-generated content live streaming application with Amazon IVS. This entire series is available in video format on the AWS Developers YouTube channel and all of the code related to the sample application used in this series can be viewed on GitHub. Refer to the links at the end of the post for more information.

TS
Todd Sharp
Amazon Employee
Published Dec 15, 2023

In lesson 4 of this course, we're learning about real-time streaming with Amazon IVS. In lesson 4.2, we learned how StreamCat generates stage participant tokens which are required for all participants (both broadcasters and viewers). In this lesson, we'll see how StreamCat broadcasts a real-time stream with multiple hosts up to 10,000 viewers.
💡 Note: Make sure that you're familiar with lesson 3 of this course, which covers topics like retrieving stream credentials, accessing user devices, and low-latency broadcasting.

In lesson 4.2, we saw how StreamCat uses the AWS SDK for JavaScript (v3) to generate a stage participant token and persists it to the database. When a user navigates to their own real-time streaming broadcast, a middleware function retrieves the logged-in user's current token (if one exists):
1
token = await auth.user?.getCurrentTokenForStage(stage?.id);
The getCurrentTokenForStage() function runs a query to find the latest token:
1
2
3
4
5
6
7
8
9
10
public async getCurrentTokenForStage(stageId: number) {
const user: User = this;
const token = await user
.related('stageTokens')
.query()
.where('stageId', stageId)
.orderBy('expiresAt', 'desc')
.first();
return token;
}
If no token is found, or the current token is expired, a new token is generated and persisted.
1
2
3
4
5
6
7
8
token = await RealTimeService.createStageToken(auth.user.id.toString(), auth.user?.username, stage?.arn);
await StageToken.create({
participantId: token.participantToken?.participantId,
token: token.participantToken?.token,
userId: Number(token.participantToken?.userId),
expiresAt: DateTime.fromJSDate(token.participantToken?.expirationTime!),
stageId: stage.id,
});
The token generation process is slightly different for invited guests, as we'll see in a subsequent lesson. When the host as a valid stage participant token, that token is returned to the view and set into the Alpine.js view model where it will be used to join the stage.

The first step to join a stage is to create a strategy (docs), which is an object that describes the desired state of the stage. The strategy object requires three functions: shouldSubscribeToParticipant(), shouldPublishParticipant(), and stageStreamsToPublish().

When a remote participant joins the stage, the SDK queries the host application about the desired subscription state for that participant. The options are NONE, AUDIO_ONLY, and AUDIO_VIDEO. When returning a value for this function, the host application does not need to worry about the publish state, current subscription state, or stage connection state. If AUDIO_VIDEO is returned, the SDK waits until the remote participant is publishing before it subscribes, and it updates the host application by emitting events throughout the process. Because the real-time streams in StreamCat always include full audio and video, we will always return SubscribeType.AUDIO_VIDEO.
1
2
3
4
5
this.stageStrategy = {
shouldSubscribeToParticipant: (participant) => {
return SubscribeType.AUDIO_VIDEO;
}
}

Once connected to the stage, the SDK queries the host application to see if a particular participant should publish. This is invoked only on local participants that have permission to publish based on the provided token. The value returned from this function can be dynamic, so if you wanted to prevent publish until the host or a user clicked a button to indicate they are ready, you could do so with this function. To keep things simple, StreamCat always returns true.
1
2
3
4
5
6
7
8
this.stageStrategy = {
shouldSubscribeToParticipant: (participant) => {
return SubscribeType.AUDIO_VIDEO;
},
shouldPublishParticipant: (participant) => {
return true;
},
}

This function is used to determine what audio and video streams should be published. For this function, StreamCat will create instances of LocalStageStream from audio and video streams from the user's microphone and camera (see lesson 3.2 for more on creating streams).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
this.stageStrategy = {
shouldSubscribeToParticipant: (participant) => {
return SubscribeType.AUDIO_VIDEO;
},
shouldPublishParticipant: (participant) => {
return true;
},
stageStreamsToPublish: () => {
const videoTrack = this.videoStream.getVideoTracks()[0];
const audioTrack = this.audioStream.getAudioTracks()[0];
const streamsToPublish = [
new LocalStageStream(audioTrack),
new LocalStageStream(videoTrack)
];
return streamsToPublish;
},
}

Now that we have a strategy, and a stage participant token, we can create an instance of the Stage object.
1
this.stage = new Stage(stageToken.token, this.stageStrategy);

There are a number of events that are exposed on a Stage. StreamCat uses two of these events to update the view when a participant joins or leaves a real-time stream.

When a stream is added - including the host's streams - StreamCat stores the participant and renders the participant as a standalone video and in a composited view that includes all participant's video. The standalone video is useful to give stream hosts a view of the individual participants, and the composite view is used to broadcast to any non-host viewers as well as a preview of the composite stream for hosts.
Real-time host view
Real-time Host View
1
2
3
4
5
6
7
8
9
10
11
12
13
this.stage.on(StageEvents.STAGE_PARTICIPANT_STREAMS_ADDED, async (participant, streams) => {
this.stageParticipants.push(participant);
this.renderParticipant(participant, streams);
await this.renderAudioToClient(
participant,
streams.find((s) => s.streamType === StreamType.AUDIO)
);
await this.renderVideosToClient(
participant,
streams.find((s) => s.streamType === StreamType.VIDEO)
);
await this.updateVideoCompositions();
});
The renderParticipant() function handles adding any streams to the solo participant video (excluding audio for the local participant in order to prevent audio echo for the host).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
renderParticipant(participant, streams) {
let streamsToDisplay = streams;
if (participant.isLocal) {
streamsToDisplay = streams.filter((stream) => stream.streamType === StreamType.VIDEO);
}
this.$nextTick(() => {
const localVideo = document.getElementById(`participant-${participant.id}`);
const mediaStream = localVideo.srcObject || new MediaStream();
streamsToDisplay.forEach((stream) => {
mediaStream.addTrack(stream.mediaStreamTrack);
});
localVideo.srcObject = mediaStream;
});
}
The renderAudioToClient() function handles adding the audio stream to the composite view for broadcasting.
1
2
3
4
5
6
7
8
9
10
async renderAudioToClient(participant, stream) {
const broadcastClient = Alpine.raw(this.broadcastClient);
if (!stream?.mediaStreamTrack) return;
const participantId = participant.id;
const audioTrackId = `audio-${participantId}`;
const mediaStream = new MediaStream();
mediaStream.addTrack(stream.mediaStreamTrack);
await broadcastClient.addAudioInputDevice(mediaStream, audioTrackId);
return Promise.resolve();
}
Similarly, the renderVideoToClient() function adds the video stream to the composite view.
1
2
3
4
5
6
7
8
9
10
11
12
async renderVideosToClient(participant, stream) {
const broadcastClient = Alpine.raw(this.broadcastClient);
if (!stream?.mediaStreamTrack) return;
const participantId = participant.id;
const videoId = `video-${participantId}`;
const pIdx = this.stageParticipants.findIndex((p) => p.id === participantId);
let config = this.layouts[this.stageParticipants.length - 1][pIdx];
config.index = pIdx + 1;
const mediaStream = new MediaStream([stream.mediaStreamTrack]);
await broadcastClient.addVideoInputDevice(mediaStream, videoId, config);
return Promise.resolve();
}

Handling when a stream is removed is just the opposite of what happens when a stream is added. The participant is removed from the composite view, and the solo video is removed from the DOM.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
this.stage.on(StageEvents.STAGE_PARTICIPANT_STREAMS_REMOVED, (participant, streams) => {
const broadcastClient = Alpine.raw(this.broadcastClient);
const videoTrackId = `video-${participant.id}`;
const audioTrackId = `audio-${participant.id}`;
const localVideo = document.getElementById(`participant-${participant.id}`);
const mediaStream = localVideo?.srcObject;
streams.forEach((stream) => {
if (broadcastClient.getVideoInputDevice(videoTrackId) && stream.streamType === 'video') {
broadcastClient.removeVideoInputDevice(videoTrackId);
}
if (broadcastClient.getAudioInputDevice(audioTrackId) && stream.streamType === 'audio') {
broadcastClient.removeAudioInputDevice(audioTrackId);
}
mediaStream?.removeTrack(stream.mediaStreamTrack);
});
localVideo.srcObject = mediaStream;
const pIdx = this.stageParticipants.findIndex((id) => id === participant.id);
this.stageParticipants.splice(pIdx, 1);
this.updateVideoCompositions();
});

Once everything is configured, we can join the stage.
1
this.stage.join();

By default, a stage is viewable by any participant as soon as the participants have joined the stage. To give broadcasters a bit more control over this, StreamCat stores a boolean property on the user's Stage object to give the broadcaster the ability to prevent viewers from joining until they are ready. When the host is ready, they click a button which updates this flag.
1
2
3
4
5
6
7
8
9
10
11
12
async toggleRealtime(isLive) {
await fetch(`/api/stage/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
isLive,
chatArn: this.chatArn,
}),
});
this.isBroadcasting = isLive;
return;
}
The handler for this endpoint will update the user's stage as appropriate, and viewers will be given access to view the real-time stream depending on that flag. We'll learn more about real-time playback in lesson 5 of this course.

In this lesson, we learned how StreamCat creates a real-time broadcasting experience for multiple-hosts. In subsequent lessons, we'll learn about real-time stream playback, as well as how StreamCat lets hosts invite chat users to stream with them.


Any opinions in this post are those of the individual author and may not reflect the opinions of AWS.