488 lines
14 KiB
HTML
488 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<title>Minimal WebRTC</title>
|
|
<style>
|
|
body.justVideo {
|
|
margin: 0;
|
|
overflow: hidden;
|
|
}
|
|
#qrcodelink {
|
|
position: sticky;
|
|
top: 0;
|
|
right: 0;
|
|
float: right;
|
|
}
|
|
#qrcodelink svg path {
|
|
min-width: 30vw;
|
|
max-width: 100vw;
|
|
max-height: 50vh;
|
|
}
|
|
#selfView {
|
|
position: fixed;
|
|
right: 0;
|
|
bottom: 0;
|
|
border: 5px solid blue;
|
|
}
|
|
#unmute, #start {
|
|
position: fixed;
|
|
top: 0;
|
|
width: 100%;
|
|
font-size: 4em;
|
|
}
|
|
form {
|
|
background: lightgray;
|
|
border: solid gold 5px;
|
|
display: table;
|
|
}
|
|
form label {
|
|
display: table-row;
|
|
}
|
|
#status {
|
|
display: table;
|
|
}
|
|
body.justVideo #status {
|
|
display: none;
|
|
}
|
|
#status .log-entry {
|
|
display: block;
|
|
background: lightblue;
|
|
border: solid black 2px;
|
|
margin: 2px;
|
|
}
|
|
#status .log-entry:nth-child(odd) {
|
|
background: lightyellow;
|
|
}
|
|
</style>
|
|
<script type="text/javascript" src="static/js/qrcodegen.js"></script>
|
|
</head>
|
|
<body>
|
|
<a id="qrcodelink" style="display:none;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" id="qrcode" style="width:30em; height:30em;" stroke="none">
|
|
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
|
<path d="" fill="#000000"/>
|
|
</svg>
|
|
</a>
|
|
<div id="qrcodemessage" style="display:none;">
|
|
<!-- TODO Add copy button. -->
|
|
<div id="qrcodemsg"></div>
|
|
</div>
|
|
<form name="settings">
|
|
<label>
|
|
Serverless mode:
|
|
<input name="serverless" type="checkbox" value="true" />
|
|
</label>
|
|
<label>
|
|
Recieve remote video:
|
|
<select name="client-video">
|
|
<option value="none">none</option>
|
|
<option value="true">any camera</option>
|
|
<option value="environment" selected>rear camera</option>
|
|
<option value="user">front camera</option>
|
|
<option value="screen">screen share</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Recieve remote audio:
|
|
<input name="client-audio" type="checkbox" value="true" />
|
|
</label>
|
|
<label>
|
|
Transmit video:
|
|
<select name="host-video">
|
|
<option value="none" selected>none</option>
|
|
<option value="true">any camera</option>
|
|
<option value="environment">rear camera</option>
|
|
<option value="user">front camera</option>
|
|
<option value="screen">screen share</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Transmit audio:
|
|
<input name="host-audio" type="checkbox" value="true" />
|
|
</label>
|
|
</form>
|
|
<div id="status"></div>
|
|
<div id="videos">
|
|
<button id="unmute" style="display:none;">Unmute</button>
|
|
<button id="start" style="display:none;">Start Streaming</button>
|
|
<video id="remoteView" width="100%" autoplay muted style="display:none;"></video>
|
|
<video id="selfView" width="200" height="150" autoplay muted style="display:none;"></video>
|
|
</div>
|
|
<script >
|
|
const create = (container, type) => container.appendChild(document.createElement(type));
|
|
const QRGen = qrcodegen.QrCode;
|
|
|
|
const body = document.querySelector("body");
|
|
const out = document.getElementById("status");
|
|
const qrcode = document.getElementById("qrcode");
|
|
const qrcodelink = document.getElementById("qrcodelink");
|
|
const qrcodemsg = document.getElementById("qrcodemsg");
|
|
const qrcodemessage = document.getElementById("qrcodemessage");
|
|
const remoteView = document.getElementById("remoteView");
|
|
const selfView = document.getElementById("selfView");
|
|
const start = document.getElementById("start");
|
|
const unmute = document.getElementById("unmute");
|
|
const form = document.forms["settings"];
|
|
|
|
function _log(str, tag) {
|
|
const logEntry = document.createElement(tag);
|
|
logEntry.innerText = str;
|
|
logEntry.classList.add('log-entry');
|
|
out.appendChild(logEntry);
|
|
}
|
|
function log(str) {
|
|
_log(str, 'span');
|
|
}
|
|
|
|
function logPre(str) {
|
|
if (!str) {
|
|
_log('undefined', 'pre');
|
|
return;
|
|
}
|
|
_log(str.split('\\r\\n').join('\r\n'), 'pre');
|
|
}
|
|
|
|
log("Loading...");
|
|
|
|
unmute.addEventListener("click", _ => {
|
|
remoteView.muted = false;
|
|
unmute.style.display = 'none';
|
|
});
|
|
|
|
var settings = undefined;
|
|
function readSettingsForm(disableForm) {
|
|
const obj = {};
|
|
for (const el of form.elements) {
|
|
obj[el.name] = el.type == 'checkbox' ? el.checked : el.value;
|
|
if (disableForm) el.disabled = true;
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
for (const el of form.elements) {
|
|
el.addEventListener("change", _ => {
|
|
window.location.hash = JSON.stringify(readSettingsForm(false));
|
|
});
|
|
}
|
|
|
|
function isServerless() {
|
|
return settings && 'serverless' in settings && settings.serverless;
|
|
}
|
|
|
|
function withHash(hash) {
|
|
return window.location.href.split('#')[0] + '#' + hash;
|
|
}
|
|
function displayQRUrl(url) {
|
|
qrcodelink.href = url;
|
|
const qr = QRGen.encodeText(url, QRGen.Ecc.MEDIUM);
|
|
const code = qr.toSvgString(4);
|
|
const viewBox = (/ viewBox="([^"]*)"/.exec(code))[1];
|
|
const pathD = (/ d="([^"]*)"/.exec(code))[1];
|
|
qrcode.setAttribute("viewBox", viewBox);
|
|
qrcode.querySelector("path").setAttribute("d", pathD);
|
|
qrcodelink.style.display = '';
|
|
}
|
|
function displayQRUrlForObj(obj) {
|
|
displayQRUrl(withHash('^' + btoa(JSON.stringify(obj))))
|
|
}
|
|
|
|
let roomName = window.location.hash;
|
|
let isHost = roomName === undefined || !roomName;
|
|
if (roomName && roomName.startsWith("#{")) {
|
|
roomName = undefined;
|
|
isHost = true;
|
|
|
|
try {
|
|
const hash = decodeURI(window.location.hash);
|
|
log("Reading settings from hash:");
|
|
logPre(hash);
|
|
settings = JSON.parse(hash.substring(1));
|
|
for (const name in settings) {
|
|
const el = form.elements[name];
|
|
const value = settings[name];
|
|
if (el.type == 'checkbox') {
|
|
el.checked = value;
|
|
} else {
|
|
el.value = value;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
log("Failed to read settings from hash: " + error);
|
|
}
|
|
}
|
|
var firstMessage = undefined;
|
|
if (roomName && roomName.startsWith("#^")) {
|
|
roomName = undefined;
|
|
isHost = false;
|
|
|
|
try {
|
|
const hash = decodeURI(window.location.hash);
|
|
log("Reading first message from hash:");
|
|
logPre(hash);
|
|
log("Decoded base64:");
|
|
const decoded = atob(hash.substring(2));
|
|
logPre(decoded);
|
|
firstMessage = JSON.parse(decoded);
|
|
settings = firstMessage.settings;
|
|
} catch (error) {
|
|
log("Failed to read message from hash: " + error);
|
|
}
|
|
}
|
|
|
|
if (isHost) {
|
|
// From https://stackoverflow.com/a/1349426
|
|
function makeid(length) {
|
|
var result = '';
|
|
var characters = 'abcdefghijklmnopqrstuvwxyz';
|
|
var charactersLength = characters.length;
|
|
for (var i = 0; i < length; i++) {
|
|
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
if (!isServerless()) {
|
|
roomName = makeid(8);
|
|
displayQRUrl(withHash(roomName));
|
|
}
|
|
} else {
|
|
if (!isServerless()) {
|
|
roomName = roomName.substring(1);
|
|
}
|
|
qrcodelink.style.display = 'none';
|
|
form.style.display = 'none';
|
|
}
|
|
|
|
log("Room: " + roomName);
|
|
|
|
var webSocket = undefined;
|
|
|
|
function sendJson(data) {
|
|
const toSend = JSON.stringify(data);
|
|
log("Sending message...");
|
|
logPre(toSend);
|
|
if (isServerless()) {
|
|
|
|
} else {
|
|
webSocket.send(toSend);
|
|
}
|
|
}
|
|
|
|
var pc = undefined;
|
|
var localDescriptionSet = false;
|
|
function createRTCPeerConnection() {
|
|
const pc = new RTCPeerConnection();
|
|
log("Created RTCPeerConnection.");
|
|
|
|
pc.addEventListener('icegatheringstatechange', e => {
|
|
log("In pc.icegatheringstatechange... e.target.iceGatheringState="
|
|
+ e.target.iceGatheringState);
|
|
if (!startStreamingDone || !localDescriptionSet) return;
|
|
// In serverless mode, host transmits first.
|
|
// In server mode, client transmits first.
|
|
if ((isServerless() != isHost) && !handledOffer) return;
|
|
// Don't send offer until all ICE candidates are generated.
|
|
if (e.target.iceGatheringState === 'complete') {
|
|
if (isHost && isServerless()) {
|
|
displayQRUrlForObj({
|
|
settings: settings,
|
|
description: pc.localDescription
|
|
});
|
|
} else {
|
|
sendJson({
|
|
description: pc.localDescription
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
pc.onnegotiationneeded = async function () {
|
|
localDescriptionSet = false;
|
|
log("In pc.onnegotiationneeded...");
|
|
await pc.setLocalDescription(await pc.createOffer());
|
|
localDescriptionSet = true;
|
|
}
|
|
|
|
pc.ontrack = ({streams: [stream]}) => {
|
|
log("In pc.ontrack...");
|
|
|
|
remoteView.srcObject = stream;
|
|
log("Set srcObject");
|
|
remoteView.play();
|
|
|
|
if (stream.getVideoTracks().length > 0) {
|
|
remoteView.style.display = '';
|
|
form.style.display = 'none';
|
|
videos.style.display = '';
|
|
body.classList.add('justVideo');
|
|
}
|
|
};
|
|
|
|
return pc;
|
|
}
|
|
|
|
var startStreamingDone = false;
|
|
var remoteDesc = null;
|
|
// get a local stream, show it in a self-view and add it to be sent
|
|
async function startStreaming(fromButton) {
|
|
const otherAudioSettings = isHost
|
|
? settings['client-audio']
|
|
: settings['host-audio'];
|
|
if (otherAudioSettings) {
|
|
unmute.style.display = '';
|
|
}
|
|
|
|
const videoSettings = isHost
|
|
? settings['host-video']
|
|
: settings['client-video'];
|
|
log("videoSettings=" + videoSettings);
|
|
const audioSettings = isHost
|
|
? settings['host-audio']
|
|
: settings['client-audio'];
|
|
log("audioSettings=" + audioSettings);
|
|
|
|
if (videoSettings == 'screen' && !fromButton) {
|
|
start.style.display = '';
|
|
return;
|
|
}
|
|
start.style.display = 'none';
|
|
|
|
if (isHost && !isServerless()) {
|
|
sendJson({
|
|
settings: settings
|
|
});
|
|
}
|
|
|
|
if (pc !== undefined) return;
|
|
pc = createRTCPeerConnection();
|
|
|
|
const videoConstraints = videoSettings == 'none'
|
|
? false
|
|
: videoSettings == 'true'
|
|
? true
|
|
: { advanced: [{facingMode: videoSettings}] };
|
|
log("Created videoConstraints.");
|
|
|
|
if (videoConstraints || audioSettings) {
|
|
const stream = videoSettings == 'screen'
|
|
? await navigator.mediaDevices.getDisplayMedia({
|
|
audio: audioSettings,
|
|
video: true
|
|
})
|
|
: await navigator.mediaDevices.getUserMedia({
|
|
audio: audioSettings,
|
|
video: videoConstraints
|
|
});
|
|
log("Created stream.");
|
|
if (videoConstraints) {
|
|
selfView.srcObject = stream;
|
|
selfView.style.display = '';
|
|
}
|
|
for (const track of stream.getTracks()) {
|
|
log("Added track.");
|
|
pc.addTrack(track, stream);
|
|
}
|
|
}
|
|
else if (isServerless() && isHost) {
|
|
log("No audio/video host.");
|
|
//sendJson({description: pc.localDescription});
|
|
await pc.setLocalDescription(await pc.createAnswer());
|
|
}
|
|
startStreamingDone = true;
|
|
}
|
|
function startStartingWithErorrHandling(fromButton, description) {
|
|
startStreaming(fromButton)
|
|
.then(() => {
|
|
log("startStreaming() finished.");
|
|
if (!description && remoteDesc) {
|
|
description = remoteDesc;
|
|
remoteDesc = null;
|
|
}
|
|
if (description) {
|
|
handleOffer(description);
|
|
}
|
|
})
|
|
.catch(e => {
|
|
log("startStreaming() errored: " + e.message);
|
|
});
|
|
}
|
|
|
|
start.addEventListener("click", _ => {
|
|
startStartingWithErorrHandling(true)
|
|
});
|
|
|
|
var handledOffer = false;
|
|
async function handleOffer(description) {
|
|
await pc.setRemoteDescription(description);
|
|
if (description.type == "offer") {
|
|
log("Got an offer...");
|
|
await pc.setLocalDescription(await pc.createAnswer());
|
|
handledOffer = true;
|
|
}
|
|
}
|
|
|
|
async function receiveMessage(e) {
|
|
qrcode.style.display = 'none';
|
|
log("In webSocket.onmessage...");
|
|
logPre(e.data);
|
|
const data = JSON.parse(e.data);
|
|
if (data.requestSettings) {
|
|
settings = readSettingsForm(true);
|
|
startStartingWithErorrHandling(false);
|
|
} else if (data.settings) {
|
|
settings = data.settings;
|
|
if ('description' in data) {
|
|
startStartingWithErorrHandling(false, data.description);
|
|
} else {
|
|
startStartingWithErorrHandling(false);
|
|
}
|
|
} else if (data.description) {
|
|
if (startStreamingDone) {
|
|
handleOffer(data.description);
|
|
} else {
|
|
remoteDesc = data.description;
|
|
}
|
|
} else if (data.candidate) {
|
|
log("Adding ice candidate...");
|
|
await pc.addIceCandidate(data.candidate);
|
|
}
|
|
};
|
|
|
|
function createWebSocket() {
|
|
const webSocket = new WebSocket(
|
|
'ws' + (window.location.protocol == 'https:' ? 's' : '') + '://'
|
|
+ window.location.host
|
|
+ '/camera/ws/' + (isHost ? 'host' : 'client') + '/'
|
|
+ roomName
|
|
+ '/'
|
|
);
|
|
log("Created WebSocket.");
|
|
|
|
webSocket.onclose = function(e) {
|
|
log('WebSocket closed unexpectedly: ' + e);
|
|
};
|
|
webSocket.onerror = function(e) {
|
|
log('WebSocket error: ' + e);
|
|
};
|
|
|
|
webSocket.onmessage = receiveMessage;
|
|
|
|
return webSocket;
|
|
}
|
|
|
|
if (!isServerless()) {
|
|
webSocket = createWebSocket();
|
|
|
|
if (!isHost) {
|
|
webSocket.onopen = _ => sendJson({requestSettings: true});
|
|
}
|
|
} else if (isHost) {
|
|
startStartingWithErorrHandling(false);
|
|
}
|
|
|
|
log("Finished <script> block.");
|
|
</script>
|
|
</body>
|
|
</html>
|