<!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-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="32" /> </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'), 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'), }; const setMessage = (str) => { message.innerText = str; }; const onStream = (stream) => { video.srcObject = stream; new MediaMTXWebRTCPublisher({ url: new URL('whip', window.location.href) + window.location.search, stream, videoCodec: videoForm.codec.value, videoBitrate: videoForm.bitrate.value, audioCodec: audioForm.codec.value, audioBitrate: audioForm.bitrate.value, audioVoice: audioForm.voice.checked, onError: (err) => { setMessage(err); }, onConnected: (evt) => { setMessage(''); }, }); }; const onPublish = () => { controls.style.display = 'none'; video.style.display = 'block'; setMessage('connecting'); const videoId = videoForm.device.value; const audioId = audioForm.device.value; 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, }; const voice = audioForm.voice.checked; if (!voice) { audioOpts.autoGainControl = false; audioOpts.echoCancellation = false; audioOpts.noiseSuppression = false; } } navigator.mediaDevices.getUserMedia({ video: videoOpts, audio: audioOpts, }) .then((stream) => onStream(stream)) .catch((err) => { setMessage(err.toString()); }); } else { navigator.mediaDevices.getDisplayMedia({ video: { width: { ideal: videoForm.width.value }, height: { ideal: videoForm.height.value }, frameRate: { ideal: videoForm.framerate.value }, cursor: 'always', }, audio: true, }) .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); } // set first available device as default device if (videoForm.device.children.length !== 0) { videoForm.device.value = videoForm.device.children[1].value; } // set first available device as default device if (audioForm.device.children.length !== 0) { audioForm.device.value = audioForm.device.children[1].value; } }); }; const populateCodecs = () => { const tempPC = new RTCPeerConnection({}); tempPC.addTransceiver('video', { direction: 'sendonly' }); tempPC.addTransceiver('audio', { direction: 'sendonly' }); return tempPC.createOffer() .then((desc) => { const sdp = desc.sdp.toLowerCase(); for (const codec of ['av1/90000', 'vp9/90000', 'vp8/90000', 'h264/90000', 'h265/90000']) { if (sdp.includes(codec)) { const opt = document.createElement('option'); opt.value = codec; opt.text = codec.split('/')[0].toUpperCase(); videoForm.codec.appendChild(opt); } } for (const codec of ['opus/48000', 'g722/8000', 'pcmu/8000', 'pcma/8000']) { if (sdp.includes(codec)) { const opt = document.createElement('option'); opt.value = codec; opt.text = codec.split('/')[0].toUpperCase(); audioForm.codec.appendChild(opt); } } tempPC.close(); }); }; const populateOptions = () => { setMessage('loading devices'); 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()); setMessage(''); loadValuesFromQuery(); setupEventListeners(); video.style.display = 'none'; controls.style.display = 'flex'; }); }) .catch((err) => { setMessage(err.toString()); }); }; 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; } } } }; window.addEventListener('DOMContentLoaded', () => { if (navigator.mediaDevices === undefined) { setMessage(`can't access webcams or microphones. Make sure that WebRTC encryption is enabled.`); return; } publishButton.addEventListener('click', onPublish); populateOptions(); }); </script> </body> </html>