Files
mediamtx/internal/servers/moq/publish_index.html
T
Alessandro RosandGitHub 5d877b4512 moq: make /moq URL suffix optional (#6107)
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.
2026-08-18 07:43:51 +00:00

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>