Ready-to-use SRT / WebRTC / RTSP / RTMP / LL-HLS media server and media proxy that allows to read, publish, proxy, record and playback video and audio streams.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

736 lines
17 KiB

<!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;
}
#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>
</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</label>
<input id="video-framerate" type="text" value="30" />
</div>
<div class="item">
<label for="video-width">video width</label>
<input id="video-width" type="text" value="1920" />
</div>
<div class="item">
<label for="video-height">video height</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 retryPause = 2000;
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'),
};
let pc = null;
let stream = null;
let restartTimeout = null;
let sessionUrl = '';
let offerData = '';
let queuedCandidates = [];
const setMessage = (str) => {
message.innerText = str;
};
const onError = (err, retry) => {
if (!retry) {
setMessage(err);
} else {
if (restartTimeout === null) {
setMessage(err + ', retrying in some seconds');
if (pc !== null) {
pc.close();
pc = null;
}
restartTimeout = window.setTimeout(() => {
restartTimeout = null;
startTransmit();
}, retryPause);
if (sessionUrl) {
fetch(sessionUrl, {
method: 'DELETE',
});
}
sessionUrl = '';
queuedCandidates = [];
}
}
};
const unquoteCredential = (v) => (
JSON.parse(`"${v}"`)
);
const linkToIceServers = (links) => (
(links !== null) ? links.split(', ').map((link) => {
const m = link.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i);
const ret = {
urls: [m[1]],
};
if (m[3] !== undefined) {
ret.username = unquoteCredential(m[3]);
ret.credential = unquoteCredential(m[4]);
ret.credentialType = 'password';
}
return ret;
}) : []
);
const parseOffer = (offer) => {
const ret = {
iceUfrag: '',
icePwd: '',
medias: [],
};
for (const line of offer.split('\r\n')) {
if (line.startsWith('m=')) {
ret.medias.push(line.slice('m='.length));
} else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) {
ret.iceUfrag = line.slice('a=ice-ufrag:'.length);
} else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) {
ret.icePwd = line.slice('a=ice-pwd:'.length);
}
}
return ret;
};
const generateSdpFragment = (od, candidates) => {
const candidatesByMedia = {};
for (const candidate of candidates) {
const mid = candidate.sdpMLineIndex;
if (candidatesByMedia[mid] === undefined) {
candidatesByMedia[mid] = [];
}
candidatesByMedia[mid].push(candidate);
}
let frag = 'a=ice-ufrag:' + od.iceUfrag + '\r\n'
+ 'a=ice-pwd:' + od.icePwd + '\r\n';
let mid = 0;
for (const media of od.medias) {
if (candidatesByMedia[mid] !== undefined) {
frag += 'm=' + media + '\r\n'
+ 'a=mid:' + mid + '\r\n';
for (const candidate of candidatesByMedia[mid]) {
frag += 'a=' + candidate.candidate + '\r\n';
}
}
mid++;
}
return frag;
};
const setCodec = (section, codec) => {
const lines = section.split('\r\n');
const lines2 = [];
const payloadFormats = [];
for (const line of lines) {
if (!line.startsWith('a=rtpmap:')) {
lines2.push(line);
} else {
if (line.toLowerCase().includes(codec)) {
payloadFormats.push(line.slice('a=rtpmap:'.length).split(' ')[0]);
lines2.push(line);
}
}
}
const lines3 = [];
for (const line of lines2) {
if (line.startsWith('a=fmtp:')) {
if (payloadFormats.includes(line.slice('a=fmtp:'.length).split(' ')[0])) {
lines3.push(line);
}
} else if (line.startsWith('a=rtcp-fb:')) {
if (payloadFormats.includes(line.slice('a=rtcp-fb:'.length).split(' ')[0])) {
lines3.push(line);
}
} else {
lines3.push(line);
}
}
return lines3.join('\r\n');
};
const setVideoBitrate = (section, bitrate) => {
let lines = section.split('\r\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('c=')) {
lines = [...lines.slice(0, i+1), 'b=TIAS:' + (parseInt(bitrate) * 1024).toString(), ...lines.slice(i+1)];
break
}
}
return lines.join('\r\n');
};
const setAudioBitrate = (section, bitrate, voice) => {
let opusPayloadFormat = '';
let lines = section.split('\r\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('a=rtpmap:') && lines[i].toLowerCase().includes('opus/')) {
opusPayloadFormat = lines[i].slice('a=rtpmap:'.length).split(' ')[0];
break;
}
}
if (opusPayloadFormat === '') {
return section;
}
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('a=fmtp:' + opusPayloadFormat + ' ')) {
if (voice) {
lines[i] = 'a=fmtp:' + opusPayloadFormat + ' minptime=10;useinbandfec=1;maxaveragebitrate='
+ (parseInt(bitrate) * 1024).toString();
} else {
lines[i] = 'a=fmtp:' + opusPayloadFormat + ' maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate'
+ (parseInt(bitrate) * 1024).toString();
}
}
}
return lines.join('\r\n');
};
const editAnswer = (sdp, videoCodec, audioCodec, videoBitrate, audioBitrate, audioVoice) => {
const sections = sdp.split('m=');
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
if (section.startsWith('video')) {
sections[i] = setVideoBitrate(setCodec(section, videoCodec), videoBitrate);
} else if (section.startsWith('audio')) {
sections[i] = setAudioBitrate(setCodec(section, audioCodec), audioBitrate, audioVoice);
}
}
return sections.join('m=');
};
const sendLocalCandidates = (candidates) => {
fetch(sessionUrl + window.location.search, {
method: 'PATCH',
headers: {
'Content-Type': 'application/trickle-ice-sdpfrag',
'If-Match': '*',
},
body: generateSdpFragment(offerData, candidates),
})
.then((res) => {
if (res.status !== 204) {
throw new Error('bad status code');
}
})
.catch((err) => {
onError(err.toString(), true);
});
};
const onLocalCandidate = (evt) => {
if (restartTimeout !== null) {
return;
}
if (evt.candidate !== null) {
if (sessionUrl === '') {
queuedCandidates.push(evt.candidate);
} else {
sendLocalCandidates([evt.candidate])
}
}
};
const onRemoteAnswer = (sdp) => {
if (restartTimeout !== null) {
return;
}
sdp = editAnswer(
sdp,
videoForm.codec.value,
audioForm.codec.value,
videoForm.bitrate.value,
audioForm.bitrate.value,
audioForm.voice.checked,
);
pc.setRemoteDescription(new RTCSessionDescription({
type: 'answer',
sdp,
}));
if (queuedCandidates.length !== 0) {
sendLocalCandidates(queuedCandidates);
queuedCandidates = [];
}
};
const sendOffer = (offer) => {
fetch(new URL('whip', window.location.href) + window.location.search, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
},
body: offer.sdp,
})
.then((res) => {
if (res.status !== 201) {
throw new Error('bad status code');
}
sessionUrl = new URL(res.headers.get('location'), window.location.href).toString();
return res.text();
})
.then((sdp) => onRemoteAnswer(sdp))
.catch((err) => {
onError(err.toString(), true);
});
};
const createOffer = () => {
pc.createOffer()
.then((offer) => {
offerData = parseOffer(offer.sdp);
pc.setLocalDescription(offer);
sendOffer(offer);
});
};
const onConnectionState = () => {
if (restartTimeout !== null) {
return;
}
if (pc.iceConnectionState === 'disconnected') {
onError('peer connection disconnected', true);
} else if (pc.iceConnectionState === 'connected') {
setMessage('');
}
};
const requestICEServers = () => {
fetch(new URL('whip', window.location.href) + window.location.search, {
method: 'OPTIONS',
})
.then((res) => {
pc = new RTCPeerConnection({
iceServers: linkToIceServers(res.headers.get('Link')),
// https://webrtc.org/getting-started/unified-plan-transition-guide
sdpSemantics: 'unified-plan',
});
pc.onicecandidate = (evt) => onLocalCandidate(evt);
pc.oniceconnectionstatechange = () => onConnectionState();
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
createOffer();
})
.catch((err) => {
onError(err.toString(), true);
});
};
const startTransmit = () => {
requestICEServers();
};
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,
};
}
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((str) => {
stream = str;
video.srcObject = stream;
startTransmit();
})
.catch((err) => {
onError(err.toString(), false);
});
} 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((str) => {
stream = str;
video.srcObject = stream;
startTransmit();
})
.catch((err) => {
onError(err.toString(), false);
});
}
};
const populateDevices = () => {
return navigator.mediaDevices.enumerateDevices()
.then((devices) => {
for (const device of devices) {
switch (device.kind) {
case 'videoinput':
{
const opt = document.createElement('option');
opt.value = device.deviceId;
opt.text = device.label;
videoForm.device.appendChild(opt);
}
break;
case 'audioinput':
{
const opt = document.createElement('option');
opt.value = device.deviceId;
opt.text = device.label;
audioForm.device.appendChild(opt);
}
break;
}
}
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 = () => {
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']) {
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('');
video.style.display = 'none';
controls.style.display = 'flex';
});
})
.catch((err) => {
onError(err.toString(), false);
});
};
const updateQueryOnControls = () => {
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 loadControlsFromQuery = () => {
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 init = () => {
if (navigator.mediaDevices === undefined) {
onError(`can't access webcams or microphones. Make sure that WebRTC encryption is enabled.`, false);
return;
}
loadControlsFromQuery();
updateQueryOnControls();
publishButton.addEventListener('click', onPublish);
populateOptions();
};
window.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>