gst-plugins-rs/net/webrtc/gstwebrtc-api/index.html
Mathieu Duponchelle 8ad882bed5 gstwebrtc-api: address issues raised by mix matrix support
1c48d7065d was mistakenly merged too
early, and there were concerns about the implementation and API design:

The fact that the frontend had to expose a text area specifically for
sending over a mix matrix, and had to manually edit in floats into the
stringified JSON was suboptimal.

Said text area was always present even when remote control was not
enabled.

The sendControlRequest API was made more complex than needed by
accepting an optional stringifier callback.

This patch addresses all those concerns:

The deserialization code in webrtcsink is now made more clever and
robust by first having it pick a numerical type to coerce to when
deserializing arrays with numbers, then making sure it doesn't allow
mixed types in arrays (or arrays of arrays as those too must share
the same inner value type).

The frontend side simply sends over strings wrapped with a request
message envelope to the backend.

The request text area is only shown when remote control is enabled.

Part-of: <https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs/-/merge_requests/1725>
2024-08-22 05:54:46 +00:00

498 lines
15 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GstWebRTC API</title>
<style>
body {
background-color: #3a3f44;
color: #c8c8c8;
}
section {
border-top: 2px solid #272b30;
}
main {
border-bottom: 2px solid #272b30;
padding-bottom: 1em;
}
.button {
cursor: pointer;
border-radius: 10px;
user-select: none;
}
.button:disabled {
cursor: default;
}
button.button {
box-shadow: 4px 4px 14px 1px #272b30;
border: none;
}
.spinner {
display: inline-block;
position: absolute;
width: 80px;
height: 80px;
}
.spinner>div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 8px solid #fff;
border-radius: 50%;
animation: spinner 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent;
}
.spinner div:nth-child(1) {
animation-delay: -0.45s;
}
.spinner div:nth-child(2) {
animation-delay: -0.3s;
}
.spinner div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
video:focus-visible,
video:focus {
outline: none;
}
div.video, div.offer-options, div.request-box {
position: relative;
margin: 1em;
}
div.request-box {
display: none;
}
#remote-streams>li.streaming.has-remote-control .request-box {
display: block;
}
div.video>div.fullscreen {
position: absolute;
top: 0;
right: 0;
width: 2.6em;
height: 2.6em;
}
div.video>div.fullscreen>span {
position: absolute;
top: 0.3em;
right: 0.4em;
font-size: 1.5em;
font-weight: bolder;
cursor: pointer;
user-select: none;
display: none;
text-shadow: 1px 1px 4px #272b30;
}
div.video>video {
width: 320px;
height: 240px;
background-color: #202020;
border-radius: 15px;
box-shadow: 4px 4px 14px 1px #272b30;
}
div.video>.spinner {
top: 80px;
left: 120px;
}
#capture {
padding-top: 1.2em;
}
#capture>.button {
vertical-align: top;
margin-top: 1.5em;
margin-left: 1em;
background-color: #98d35e;
width: 5em;
height: 5em;
}
#capture>.client-id {
display: block;
}
#capture>.client-id::before {
content: "Client ID:";
margin-right: 0.5em;
}
#capture.has-session>.button {
background-color: #e36868;
}
#capture>.button::after {
content: "Start Capture";
}
#capture.has-session>.button::after {
content: "Stop Capture";
}
#capture .spinner {
display: none;
}
#capture.starting .spinner {
display: inline-block;
}
#remote-streams {
list-style: none;
padding-left: 1em;
}
#remote-streams>li .button::before {
content: "\2799";
padding-right: 0.2em;
}
#remote-streams>li.has-session .button::before {
content: "\2798";
}
#remote-streams>li div.video {
display: none;
}
#remote-streams>li.has-session div.video {
display: inline-block;
}
#remote-streams>li.streaming .spinner {
display: none;
}
#remote-streams>li.streaming>div.video>div.fullscreen:hover>span {
display: block;
}
#remote-streams .remote-control {
display: none;
position: absolute;
top: 0.2em;
left: 0.3em;
font-size: 1.8em;
font-weight: bolder;
animation: blink 1s ease-in-out infinite alternate;
text-shadow: 1px 1px 4px #272b30;
}
@keyframes blink {
to {
opacity: 0;
}
}
#remote-streams>li.streaming.has-remote-control .remote-control {
display: block;
}
#remote-streams>li.streaming.has-remote-control>div.video>video {
width: 640px;
height: 480px;
}
</style>
<script>
function initCapture(api) {
const captureSection = document.getElementById("capture");
const clientIdElement = captureSection.querySelector(".client-id");
const videoElement = captureSection.getElementsByTagName("video")[0];
const listener = {
connected: function(clientId) { clientIdElement.textContent = clientId; },
disconnected: function() { clientIdElement.textContent = "none"; }
};
api.registerConnectionListener(listener);
document.getElementById("capture-button").addEventListener("click", (event) => {
event.preventDefault();
if (captureSection._producerSession) {
captureSection._producerSession.close();
} else if (!captureSection.classList.contains("starting")) {
captureSection.classList.add("starting");
const constraints = {
video: { width: 1280, height: 720 }
};
navigator.mediaDevices.getUserMedia(constraints).then((stream) => {
const session = api.createProducerSession(stream);
if (session) {
captureSection._producerSession = session;
session.addEventListener("error", (event) => {
if (captureSection._producerSession === session) {
console.error(event.message, event.error);
}
});
session.addEventListener("closed", () => {
if (captureSection._producerSession === session) {
videoElement.pause();
videoElement.srcObject = null;
captureSection.classList.remove("has-session", "starting");
delete captureSection._producerSession;
}
});
session.addEventListener("stateChanged", (event) => {
if ((captureSection._producerSession === session) &&
(event.target.state === GstWebRTCAPI.SessionState.streaming)) {
videoElement.srcObject = stream;
videoElement.play().catch(() => {});
captureSection.classList.remove("starting");
}
});
session.addEventListener("clientConsumerAdded", (event) => {
if (captureSection._producerSession === session) {
console.info(`client consumer added: ${event.detail.peerId}`);
}
});
session.addEventListener("clientConsumerRemoved", (event) => {
if (captureSection._producerSession === session) {
console.info(`client consumer removed: ${event.detail.peerId}`);
}
});
captureSection.classList.add("has-session");
session.start();
} else {
for (const track of stream.getTracks()) {
track.stop();
}
captureSection.classList.remove("starting");
}
}).catch((error) => {
console.error("cannot have access to webcam and microphone", error);
captureSection.classList.remove("starting");
});
}
});
}
function initRemoteStreams(api) {
const remoteStreamsElement = document.getElementById("remote-streams");
const listener = {
producerAdded: function(producer) {
const producerId = producer.id
if (!document.getElementById(producerId)) {
remoteStreamsElement.insertAdjacentHTML("beforeend",
`<li id="${producerId}">
<div class="button">${producer.meta.name || producerId}
</div>
<div class="offer-options">
<textarea rows="5" cols="50" placeholder="offer options, empty to answer. For example:\n{\n &quot;offerToReceiveAudio&quot;: 1\n &quot;offerToReceiveVideo&quot;: 1\n}\n"></textarea>
</div>
<div class="request-box">
<textarea rows="4" cols="50" placeholder="JSON request to send over"></textarea>
<button disabled="disabled">Submit request</button>
</div>
<div class="video">
<div class="spinner">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<span class="remote-control">&#xA9;</span>
<video></video>
<div class="fullscreen"><span title="Toggle fullscreen">&#x25A2;</span></div>
</div>
</li>`);
const entryElement = document.getElementById(producerId);
const videoElement = entryElement.getElementsByTagName("video")[0];
const offerTextareaElement = entryElement.getElementsByTagName("textarea")[0];
const requestTextAreaElement = entryElement.getElementsByTagName("textarea")[1];
const submitRequestButtonElement = entryElement.getElementsByTagName("button")[0];
submitRequestButtonElement.addEventListener("click", (event) => {
try {
let request = requestTextAreaElement.value;
let id = entryElement._consumerSession.remoteController.sendControlRequest(request);
} catch (ex) {
console.error("Failed to parse mix matrix:", ex);
return;
}
});
videoElement.addEventListener("playing", () => {
if (entryElement.classList.contains("has-session")) {
entryElement.classList.add("streaming");
}
});
entryElement.addEventListener("click", (event) => {
event.preventDefault();
if (!event.target.classList.contains("button")) {
return;
}
if (entryElement._consumerSession) {
entryElement._consumerSession.close();
} else {
let session = null;
if (offerTextareaElement.value == '') {
session = api.createConsumerSession(producerId);
} else {
try {
let offerOptions = JSON.parse(offerTextareaElement.value);
session = api.createConsumerSessionWithOfferOptions(producerId, offerOptions);
} catch (ex) {
console.error("Failed to parse offer options:", ex);
return;
}
}
if (session) {
entryElement._consumerSession = session;
session.addEventListener("error", (event) => {
if (entryElement._consumerSession === session) {
console.error(event.message, event.error);
}
});
session.addEventListener("closed", () => {
if (entryElement._consumerSession === session) {
videoElement.pause();
videoElement.srcObject = null;
entryElement.classList.remove("has-session", "streaming", "has-remote-control");
delete entryElement._consumerSession;
}
});
session.addEventListener("streamsChanged", () => {
if (entryElement._consumerSession === session) {
const streams = session.streams;
if (streams.length > 0) {
videoElement.srcObject = streams[0];
videoElement.play().catch(() => {});
}
}
});
session.addEventListener("remoteControllerChanged", () => {
if (entryElement._consumerSession === session) {
const remoteController = session.remoteController;
if (remoteController) {
entryElement.classList.add("has-remote-control");
submitRequestButtonElement.disabled = false;
remoteController.attachVideoElement(videoElement);
} else {
entryElement.classList.remove("has-remote-control");
submitRequestButtonElement.disabled = true;
}
}
});
entryElement.classList.add("has-session");
session.connect();
}
}
});
}
},
producerRemoved: function(producer) {
const element = document.getElementById(producer.id);
if (element) {
if (element._consumerSession) {
element._consumerSession.close();
}
element.remove();
}
}
};
api.registerProducersListener(listener);
for (const producer of api.getAvailableProducers()) {
listener.producerAdded(producer);
}
}
window.addEventListener("DOMContentLoaded", () => {
document.addEventListener("click", (event) => {
if (event.target.matches("div.video>div.fullscreen:hover>span")) {
event.preventDefault();
event.target.parentNode.previousElementSibling.requestFullscreen();
}
});
const signalingProtocol = window.location.protocol.startsWith("https") ? "wss" : "ws";
const gstWebRTCConfig = {
meta: { name: `WebClient-${Date.now()}` },
signalingServerUrl: `${signalingProtocol}://${window.location.hostname}:8443`,
};
const api = new GstWebRTCAPI(gstWebRTCConfig);
initCapture(api);
initRemoteStreams(api);
});
</script>
</head>
<body>
<header>
<h1>GstWebRTC API</h1>
</header>
<main>
<section id="capture">
<span class="client-id">none</span>
<button class="button" id="capture-button"></button>
<div class="video">
<div class="spinner">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<video></video>
</div>
</section>
<section>
<h1>Remote Streams</h1>
<ul id="remote-streams"></ul>
</section>
</main>
</body>
</html>