In order to establish a MoQ session with WebTransport, a /moq suffix was required until now. This is now optional in order to allow connecting to the server with the standard MoQ URL format.
484 lines
14 KiB
HTML
484 lines
14 KiB
HTML
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width" />
|
|
<style>
|
|
html,
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
height: 100%;
|
|
font-family: "Arial", sans-serif;
|
|
}
|
|
#video {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgb(30, 30, 30);
|
|
}
|
|
#controls {
|
|
display: none;
|
|
flex-shrink: 0;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 10px;
|
|
flex-direction: column;
|
|
min-height: 100%;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
background: rgb(30, 30, 30);
|
|
color: white;
|
|
}
|
|
.item {
|
|
display: grid;
|
|
grid-auto-flow: column;
|
|
grid-template-columns: auto 220px;
|
|
align-items: center;
|
|
gap: 20px;
|
|
max-width: 500px;
|
|
margin: 10px 0;
|
|
}
|
|
select,
|
|
input[type="text"] {
|
|
appearance: none;
|
|
background: inherit;
|
|
color: inherit;
|
|
border: 1px solid rgb(200, 200, 200);
|
|
border-radius: 3px;
|
|
height: 40px;
|
|
padding: 0 10px;
|
|
}
|
|
select option {
|
|
color: black;
|
|
}
|
|
#message {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
text-align: center;
|
|
justify-content: center;
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
color: white;
|
|
pointer-events: none;
|
|
padding: 20px;
|
|
box-sizing: border-box;
|
|
text-shadow: 0 0 5px black;
|
|
}
|
|
#publish-button {
|
|
margin-top: 10px;
|
|
appearance: none;
|
|
background: rgb(200, 200, 200);
|
|
color: black;
|
|
border-radius: 3px;
|
|
height: 50px;
|
|
padding: 0 20px;
|
|
border: none;
|
|
}
|
|
</style>
|
|
<script defer src="./publisher.js"></script>
|
|
</head>
|
|
<body>
|
|
<video id="video" muted autoplay playsinline></video>
|
|
|
|
<div id="controls">
|
|
<div id="items">
|
|
<div class="item">
|
|
<label for="video-device">video device</label>
|
|
<select id="video-device">
|
|
<option value="none">none</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="item">
|
|
<label for="video-codec">video codec</label>
|
|
<select id="video-codec"></select>
|
|
</div>
|
|
|
|
<div class="item">
|
|
<label for="video-bitrate">video bitrate (kbps)</label>
|
|
<input id="video-bitrate" type="text" value="10000" />
|
|
</div>
|
|
|
|
<div class="item">
|
|
<label for="video-framerate">video framerate (ideal)</label>
|
|
<input id="video-framerate" type="text" value="30" />
|
|
</div>
|
|
|
|
<div class="item">
|
|
<label for="video-keyframe-interval">video keyframe interval</label>
|
|
<input id="video-keyframe-interval" type="text" value="60" />
|
|
</div>
|
|
|
|
<div class="item">
|
|
<label for="video-width">video width (ideal)</label>
|
|
<input id="video-width" type="text" value="1920" />
|
|
</div>
|
|
|
|
<div class="item">
|
|
<label for="video-height">video height (ideal)</label>
|
|
<input id="video-height" type="text" value="1080" />
|
|
</div>
|
|
|
|
<div class="item">
|
|
<label for="audio-device">audio device</label>
|
|
<select id="audio-device">
|
|
<option value="none">none</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="item">
|
|
<label for="audio-codec">audio codec</label>
|
|
<select id="audio-codec"></select>
|
|
</div>
|
|
|
|
<div class="item">
|
|
<label for="audio-bitrate">audio bitrate (kbps)</label>
|
|
<input id="audio-bitrate" type="text" value="128" />
|
|
</div>
|
|
|
|
<div class="item">
|
|
<label for="audio-voice">optimize for voice</label>
|
|
<div>
|
|
<input id="audio-voice" type="checkbox" checked />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="submit-line">
|
|
<button id="publish-button">publish</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="message"></div>
|
|
|
|
<script>
|
|
const video = document.getElementById("video");
|
|
const controls = document.getElementById("controls");
|
|
const message = document.getElementById("message");
|
|
const publishButton = document.getElementById("publish-button");
|
|
|
|
const videoForm = {
|
|
device: document.getElementById("video-device"),
|
|
codec: document.getElementById("video-codec"),
|
|
bitrate: document.getElementById("video-bitrate"),
|
|
framerate: document.getElementById("video-framerate"),
|
|
keyframeInterval: document.getElementById("video-keyframe-interval"),
|
|
width: document.getElementById("video-width"),
|
|
height: document.getElementById("video-height"),
|
|
};
|
|
|
|
const audioForm = {
|
|
device: document.getElementById("audio-device"),
|
|
codec: document.getElementById("audio-codec"),
|
|
bitrate: document.getElementById("audio-bitrate"),
|
|
voice: document.getElementById("audio-voice"),
|
|
};
|
|
|
|
let publisher = null;
|
|
let creds = null;
|
|
|
|
const setMessage = (str) => {
|
|
message.innerText = str;
|
|
};
|
|
|
|
const onStream = (stream) => {
|
|
video.srcObject = stream;
|
|
|
|
const wtURL = new URL(window.location.href);
|
|
wtURL.pathname = wtURL.pathname.replace(/\/publish$/, ""); // remove /publish
|
|
|
|
publisher = new MediaMTXMoQPublisher({
|
|
fingerprintUrl: new URL("fingerprint", window.location.href),
|
|
url: wtURL.toString(),
|
|
user: creds?.user,
|
|
pass: creds?.pass,
|
|
stream,
|
|
videoCodec: videoForm.codec.value,
|
|
videoBitrate: videoForm.bitrate.value,
|
|
videoFramerate: videoForm.framerate.value,
|
|
videoKeyframeInterval: videoForm.keyframeInterval.value,
|
|
videoWidth: videoForm.width.value,
|
|
videoHeight: videoForm.height.value,
|
|
audioCodec: audioForm.codec.value,
|
|
audioBitrate: audioForm.bitrate.value,
|
|
onError: (err) => {
|
|
setMessage(err);
|
|
},
|
|
onConnected: () => {
|
|
setMessage("");
|
|
},
|
|
});
|
|
};
|
|
|
|
const onPublish = () => {
|
|
controls.style.display = "none";
|
|
video.style.display = "block";
|
|
setMessage("connecting");
|
|
|
|
const videoId = videoForm.device.value;
|
|
const audioId = audioForm.device.value;
|
|
|
|
let mediaPromise;
|
|
|
|
if (videoId !== "screen") {
|
|
let videoOpts = false;
|
|
|
|
if (videoId !== "none") {
|
|
videoOpts = {
|
|
deviceId: videoId,
|
|
width: { ideal: videoForm.width.value },
|
|
height: { ideal: videoForm.height.value },
|
|
frameRate: { ideal: videoForm.framerate.value },
|
|
};
|
|
}
|
|
|
|
let audioOpts = false;
|
|
|
|
if (audioId !== "none") {
|
|
audioOpts = { deviceId: audioId };
|
|
|
|
if (!audioForm.voice.checked) {
|
|
audioOpts.autoGainControl = false;
|
|
audioOpts.echoCancellation = false;
|
|
audioOpts.noiseSuppression = false;
|
|
}
|
|
}
|
|
|
|
mediaPromise = navigator.mediaDevices.getUserMedia({
|
|
video: videoOpts,
|
|
audio: audioOpts,
|
|
});
|
|
} else {
|
|
mediaPromise = navigator.mediaDevices.getDisplayMedia({
|
|
video: {
|
|
width: { ideal: videoForm.width.value },
|
|
height: { ideal: videoForm.height.value },
|
|
frameRate: { ideal: videoForm.framerate.value },
|
|
cursor: "always",
|
|
},
|
|
audio: true,
|
|
});
|
|
}
|
|
|
|
mediaPromise
|
|
.then((stream) => onStream(stream))
|
|
.catch((err) => {
|
|
setMessage(err.toString());
|
|
});
|
|
};
|
|
|
|
const selectHasOption = (select, option) => {
|
|
for (const opt of select.querySelectorAll("option")) {
|
|
if (opt.value === option) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const populateDevices = () => {
|
|
return navigator.mediaDevices.enumerateDevices().then((devices) => {
|
|
for (const device of devices) {
|
|
if (device.kind === "videoinput" || device.kind === "audioinput") {
|
|
const select =
|
|
device.kind === "videoinput"
|
|
? videoForm.device
|
|
: audioForm.device;
|
|
|
|
if (!selectHasOption(select, device.deviceId)) {
|
|
const opt = document.createElement("option");
|
|
opt.value = device.deviceId;
|
|
opt.text = device.label;
|
|
select.appendChild(opt);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (navigator.mediaDevices.getDisplayMedia !== undefined) {
|
|
const opt = document.createElement("option");
|
|
opt.value = "screen";
|
|
opt.text = "screen";
|
|
videoForm.device.appendChild(opt);
|
|
}
|
|
|
|
if (videoForm.device.children.length !== 0) {
|
|
videoForm.device.value = videoForm.device.children[1].value;
|
|
}
|
|
|
|
if (audioForm.device.children.length !== 0) {
|
|
audioForm.device.value = audioForm.device.children[1].value;
|
|
}
|
|
});
|
|
};
|
|
|
|
const populateCodecs = async () => {
|
|
const videoCodecCandidates = [
|
|
{ codec: "av01.0.04M.08", label: "AV1" },
|
|
{ codec: "vp09.00.10.08", label: "VP9" },
|
|
{ codec: "vp8", label: "VP8" },
|
|
{ codec: "avc3.640028", label: "H264" },
|
|
{ codec: "hev1.1.6.L93.B0", label: "H265" },
|
|
];
|
|
|
|
for (const { codec, label } of videoCodecCandidates) {
|
|
try {
|
|
const result = await VideoEncoder.isConfigSupported({
|
|
codec,
|
|
width: 1280,
|
|
height: 720,
|
|
bitrate: 5000000,
|
|
framerate: 30,
|
|
});
|
|
if (result.supported) {
|
|
const opt = document.createElement("option");
|
|
opt.value = codec;
|
|
opt.text = label;
|
|
videoForm.codec.appendChild(opt);
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
const audioCodecCandidates = [
|
|
{ codec: "opus", label: "OPUS" },
|
|
{ codec: "mp4a.40.2", label: "AAC" },
|
|
];
|
|
|
|
for (const { codec, label } of audioCodecCandidates) {
|
|
try {
|
|
const result = await AudioEncoder.isConfigSupported({
|
|
codec,
|
|
sampleRate: 48000,
|
|
numberOfChannels: 2,
|
|
bitrate: 128000,
|
|
});
|
|
if (result.supported) {
|
|
const opt = document.createElement("option");
|
|
opt.value = codec;
|
|
opt.text = label;
|
|
audioForm.codec.appendChild(opt);
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
};
|
|
|
|
const loadDevices = () => {
|
|
setMessage("loading devices");
|
|
|
|
return navigator.mediaDevices
|
|
.getUserMedia({ video: true, audio: true })
|
|
.then((tempStream) => {
|
|
return Promise.all([populateDevices(), populateCodecs()]).then(
|
|
() => {
|
|
// free the webcam to prevent 'NotReadableError' on Android
|
|
tempStream.getTracks().forEach((track) => track.stop());
|
|
},
|
|
);
|
|
});
|
|
};
|
|
|
|
const setupEventListeners = () => {
|
|
const url = new URL(window.location.href);
|
|
const inputs = [
|
|
...Object.values(videoForm),
|
|
...Object.values(audioForm),
|
|
];
|
|
|
|
for (const input of inputs) {
|
|
if (input instanceof HTMLInputElement && input.type === "text") {
|
|
input.addEventListener("input", () => {
|
|
url.searchParams.set(input.id, input.value);
|
|
window.history.replaceState(null, null, url);
|
|
});
|
|
}
|
|
|
|
if (input instanceof HTMLInputElement && input.type === "checkbox") {
|
|
input.addEventListener("input", () => {
|
|
url.searchParams.set(input.id, input.checked);
|
|
window.history.replaceState(null, null, url);
|
|
});
|
|
}
|
|
|
|
if (input instanceof HTMLSelectElement) {
|
|
input.addEventListener("input", () => {
|
|
url.searchParams.set(input.id, input.value);
|
|
window.history.replaceState(null, null, url);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const loadValuesFromQuery = () => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const inputs = [
|
|
...Object.values(videoForm),
|
|
...Object.values(audioForm),
|
|
];
|
|
|
|
for (const input of inputs) {
|
|
const value = params.get(input.id);
|
|
if (value) {
|
|
if (input instanceof HTMLInputElement && input.type === "text") {
|
|
input.value = value;
|
|
} else if (
|
|
input instanceof HTMLInputElement &&
|
|
input.type === "checkbox"
|
|
) {
|
|
input.checked = value === "true";
|
|
} else if (input instanceof HTMLSelectElement) {
|
|
input.value = value;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const loadAuth = () => {
|
|
return fetch(new URL("authmirror", window.location.href))
|
|
.then((r) => (r.status !== 200 ? null : r.json()))
|
|
.then((c) => {
|
|
creds = c;
|
|
});
|
|
};
|
|
|
|
window.addEventListener("load", () => {
|
|
if (
|
|
navigator.mediaDevices === undefined ||
|
|
typeof VideoEncoder === "undefined"
|
|
) {
|
|
setMessage(
|
|
`can't access webcams or microphones, or WebCodecs is not available. Make sure this page is loaded over HTTPS.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
loadAuth()
|
|
.then(() => loadDevices())
|
|
.then(() => {
|
|
loadValuesFromQuery();
|
|
setupEventListeners();
|
|
video.style.display = "none";
|
|
controls.style.display = "flex";
|
|
publishButton.addEventListener("click", onPublish);
|
|
setMessage("");
|
|
})
|
|
.catch((err) => {
|
|
setMessage(err.toString());
|
|
});
|
|
});
|
|
|
|
window.addEventListener("beforeunload", () => {
|
|
if (publisher !== null) {
|
|
publisher.close();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|