Skip to content

Commit af42e4a

Browse files
authored
[Broadcaster] Add support for Simulcast input (#22)
* Initial implementation of Simulcast w/o waiting for keyframe * Change layer on keyframe * Pin `ex_webrtc` dependency * Improve UI of the video quality options * Fetch quality options instead of hardcoding them * Enable the bitrate configuration option * Apply requested changes
1 parent 77b907d commit af42e4a

File tree

13 files changed

+397
-107
lines changed

13 files changed

+397
-107
lines changed

broadcaster/assets/js/chat.js

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import { Socket, Presence } from "phoenix"
22

33
export async function connectChat() {
44
const viewercount = document.getElementById("viewercount");
5-
const chatToggler = document.getElementById("chat-toggler");
6-
const chat = document.getElementById("chat");
75
const chatMessages = document.getElementById("chat-messages");
86
const chatInput = document.getElementById("chat-input");
97
const chatNickname = document.getElementById("chat-nickname");
@@ -84,22 +82,4 @@ export async function connectChat() {
8482
chatNickname.onclick = () => {
8583
chatNickname.classList.remove("invalid-input");
8684
}
87-
88-
chatToggler.onclick = () => {
89-
if (window.getComputedStyle(chat).display === "none") {
90-
chat.style.display = "flex";
91-
92-
// For screen's width lower than 1024,
93-
// eiter show video player or chat at the same time.
94-
if (window.innerWidth < 1024) {
95-
document.getElementById("videoplayer-wrapper").style.display = "none";
96-
}
97-
} else {
98-
chat.style.display = "none";
99-
100-
if (window.innerWidth < 1024) {
101-
document.getElementById("videoplayer-wrapper").style.display = "block";
102-
}
103-
}
104-
}
10585
}

broadcaster/assets/js/home.js

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import { connectChat } from "./chat.js"
22

3+
const chatToggler = document.getElementById("chat-toggler");
4+
const chat = document.getElementById("chat");
5+
const settingsToggler = document.getElementById("settings-toggler");
6+
const settings = document.getElementById("settings");
7+
const videoQuality = document.getElementById("video-quality");
8+
39
const pcConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
410
const whepEndpoint = `${window.location.origin}/api/whep`
511
const videoPlayer = document.getElementById("videoplayer");
612
const candidates = [];
713
let patchEndpoint;
14+
let layers = null;
815

916
async function sendCandidate(candidate) {
1017
const response = await fetch(patchEndpoint, {
@@ -58,26 +65,137 @@ async function connectMedia() {
5865
body: pc.localDescription.sdp
5966
});
6067

61-
if (response.status === 201) {
62-
patchEndpoint = response.headers.get("location");
63-
console.log("Sucessfully initialized WHEP connection")
64-
65-
} else {
68+
if (response.status !== 201) {
6669
console.error(`Failed to initialize WHEP connection, status: ${response.status}`);
6770
return;
6871
}
6972

73+
patchEndpoint = response.headers.get("location");
74+
console.log("Sucessfully initialized WHEP connection")
75+
7076
for (const candidate of candidates) {
7177
sendCandidate(candidate);
7278
}
7379

7480
let sdp = await response.text();
7581
await pc.setRemoteDescription({ type: "answer", sdp: sdp });
82+
83+
connectServerEvents();
84+
}
85+
86+
async function connectServerEvents() {
87+
const response = await fetch(`${patchEndpoint}/sse`, {
88+
method: "POST",
89+
cache: "no-cache",
90+
headers: { "Content-Type": "application/json" },
91+
body: JSON.stringify(["layers"])
92+
});
93+
94+
if (response.status !== 201) {
95+
console.error(`Failed to fetch SSE endpoint, status: ${response.status}`);
96+
return;
97+
}
98+
99+
const eventStream = response.headers.get("location");
100+
const eventSource = new EventSource(eventStream);
101+
eventSource.onopen = (ev) => {
102+
console.log("EventStream opened", ev);
103+
}
104+
105+
eventSource.onmessage = (ev) => {
106+
const data = JSON.parse(ev.data);
107+
updateLayers(data.layers)
108+
};
109+
110+
eventSource.onerror = (ev) => {
111+
console.log("EventStream closed", ev);
112+
eventSource.close();
113+
};
114+
}
115+
116+
function updateLayers(new_layers) {
117+
// check if layers changed, if not, just return
118+
if (new_layers === null && layers === null) return;
119+
if (
120+
layers !== null &&
121+
new_layers !== null &&
122+
new_layers.length === layers.length &&
123+
new_layers.every((layer, i) => layer === layers[i])
124+
) return;
125+
126+
if (new_layers === null) {
127+
videoQuality.appendChild(new Option("Disabled", null, true, true));
128+
videoQuality.disabled = true;
129+
layers = null;
130+
return;
131+
}
132+
133+
while (videoQuality.firstChild) {
134+
videoQuality.removeChild(videoQuality.firstChild);
135+
}
136+
137+
if (new_layers === null) {
138+
videoQuality.appendChild(new Option("Disabled", null, true, true));
139+
videoQuality.disabled = true;
140+
} else {
141+
videoQuality.disabled = false;
142+
new_layers
143+
.map((layer, i) => {
144+
var text = layer;
145+
if (layer == "h") text = "High";
146+
if (layer == "m") text = "Medium";
147+
if (layer == "l") text = "Low";
148+
return new Option(text, layer, i == 0, layer == 0);
149+
})
150+
.forEach(option => videoQuality.appendChild(option))
151+
}
152+
153+
layers = new_layers;
154+
}
155+
156+
async function changeLayer(layer) {
157+
if (patchEndpoint) {
158+
const response = await fetch(`${patchEndpoint}/layer`, {
159+
method: "POST",
160+
cache: "no-cache",
161+
headers: { "Content-Type": "application/json" },
162+
body: JSON.stringify({encodingId: layer})
163+
});
164+
165+
if (response.status != 200) {
166+
console.warn("Changing layer failed", response)
167+
updateLayers(null);
168+
}
169+
}
170+
}
171+
172+
function toggleBox(element, other) {
173+
if (window.getComputedStyle(element).display === "none") {
174+
element.style.display = "flex";
175+
other.style.display = "none";
176+
177+
// For screen's width lower than 1024,
178+
// eiter show video player or chat at the same time.
179+
if (window.innerWidth < 1024) {
180+
document.getElementById("videoplayer-wrapper").style.display = "none";
181+
}
182+
} else {
183+
element.style.display = "none";
184+
185+
if (window.innerWidth < 1024) {
186+
document.getElementById("videoplayer-wrapper").style.display = "block";
187+
}
188+
}
76189
}
77190

78191
export const Home = {
79192
mounted() {
80193
connectMedia()
81194
connectChat()
195+
196+
videoQuality.onchange = () => changeLayer(videoQuality.value)
197+
198+
chatToggler.onclick = () => toggleBox(chat, settings);
199+
settingsToggler.onclick = () => toggleBox(settings, chat);
82200
}
83201
}

broadcaster/assets/js/player.js

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
const audioDevices = document.getElementById('audioDevices');
22
const videoDevices = document.getElementById('videoDevices');
3-
const maxVideoBitrate = document.getElementById('maxVideoBitrate');
43
const serverUrl = document.getElementById('serverUrl');
54
const serverToken = document.getElementById('serverToken');
65
const button = document.getElementById('button');
76
const previewPlayer = document.getElementById('previewPlayer');
7+
const highVideoBitrate = document.getElementById('highVideoBitrate');
8+
const mediumVideoBitrate = document.getElementById('mediumVideoBitrate');
9+
const lowVideoBitrate = document.getElementById('lowVideoBitrate');
10+
11+
const mediaConstraints = {video: {width: {ideal: 1280}, height: {ideal: 720}, frameRate: {ideal: 24}}, audio: true}
812

913
let localStream = undefined;
1014
let pc = undefined;
@@ -61,16 +65,25 @@ async function startStreaming() {
6165
disableControls();
6266

6367
pc = new RTCPeerConnection();
64-
for (const track of localStream.getTracks()) {
65-
pc.addTrack(track);
66-
}
68+
pc.addTrack(localStream.getAudioTracks()[0], localStream);
69+
pc.addTransceiver(localStream.getVideoTracks()[0], {
70+
streams: [localStream],
71+
sendEncodings: [
72+
{ rid: "h", maxBitrate: 1500 * 1024},
73+
{ rid: "m", scaleResolutionDownBy: 2, maxBitrate: 600 * 1024},
74+
{ rid: "l", scaleResolutionDownBy: 4, maxBitrate: 300 * 1024 },
75+
],
76+
});
6777

6878
// limit max bitrate
6979
pc.getSenders()
7080
.filter((sender) => sender.track.kind === 'video')
7181
.forEach(async (sender) => {
7282
const params = sender.getParameters();
73-
params.encodings[0].maxBitrate = parseInt(maxVideoBitrate.value) * 1024;
83+
console.log(params.encodings);
84+
params.encodings.find(e => e.rid === "h").maxBitrate = parseInt(highVideoBitrate.value) * 1024;
85+
params.encodings.find(e => e.rid === "m").maxBitrate = parseInt(mediumVideoBitrate.value) * 1024;
86+
params.encodings.find(e => e.rid === "l").maxBitrate = parseInt(lowVideoBitrate.value) * 1024;
7487
await sender.setParameters(params);
7588
});
7689

@@ -115,7 +128,7 @@ function stopStreaming() {
115128

116129
async function run() {
117130
// ask for permissions
118-
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
131+
localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
119132

120133
console.log(`Obtained stream with id: ${localStream.id}`);
121134

broadcaster/config/config.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ config :phoenix, :json_library, Jason
5454

5555
config :mime, :types, %{
5656
"application/sdp" => ["sdp"],
57-
"application/" => ["trickle-ice-sdpfrag"]
57+
"application/trickle-ice-sdpfrag" => ["trickle-ice-sdpfrag"]
5858
}
5959

6060
config :broadcaster,

0 commit comments

Comments
 (0)