camera-streamer/html/control.html
2023-06-26 13:46:41 +02:00

823 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Camera Streamer Web Control</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<style>
body {
font-family: Arial,Helvetica,sans-serif;
background: #181818;
color: #EFEFEF;
font-size: 16px
}
a {
color: #EFEFEF;
text-decoration: underline
}
h2 {
font-size: 18px
}
section.main {
display: flex
}
#menu,section.main {
flex-direction: column
}
#menu {
display: none;
flex-wrap: nowrap;
color: #EFEFEF;
width: 380px;
background: #363636;
padding: 8px;
border-radius: 4px;
margin-top: -6px;
margin-right: 10px;
}
/* #content {
display: flex;
flex-wrap: wrap;
align-items: stretch
}
*/
figure {
padding: 0px;
margin: 0;
margin-block-start: 0;
margin-block-end: 0;
margin-inline-start: 0;
margin-inline-end: 0
}
figure img {
display: block;
max-width: 100%;
width: auto;
height: auto;
border-radius: 4px;
}
figure video {
display: block;
max-width: 100%;
width: auto;
height: auto;
border-radius: 4px;
}
section#buttons {
display: flex;
flex-wrap: nowrap;
justify-content: space-between
}
#logo {
margin-bottom: 10px;
}
#nav-toggle {
cursor: pointer;
display: block;
margin: 3px;
line-height: 28px;
}
#nav-toggle-cb {
outline: 0;
opacity: 0;
width: 0;
height: 0
}
#nav-toggle-cb:checked+#menu {
display: flex
}
#quality {
transform: rotateY(180deg);
}
.input-group {
display: flex;
flex-wrap: nowrap;
line-height: 22px;
margin: 5px 0
}
.input-group>label {
display: inline-block;
padding-right: 10px;
min-width: 47%
}
.input-group input,.input-group select {
flex-grow: 1
}
.input-group>a {
word-break: break-all
}
.range-max,.range-min {
display: inline-block;
padding: 0 5px
}
button {
display: block;
margin: 3px;
padding: 0 8px;
border: 0;
line-height: 28px;
cursor: pointer;
color: #fff;
background: #ff3034;
border-radius: 5px;
font-size: 16px;
outline: 0
}
button:hover {
background: #ff494d
}
button:active {
background: #f21c21
}
button.disabled {
cursor: default;
background: #a0a0a0
}
input[type=range] {
-webkit-appearance: none;
width: 0;
height: 22px;
background: #363636;
cursor: pointer;
margin: 0
}
input[type=range]:focus {
outline: 0
}
input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 2px;
cursor: pointer;
background: #EFEFEF;
border-radius: 0;
border: 0 solid #EFEFEF
}
input[type=range]::-webkit-slider-thumb {
border: 1px solid rgba(0,0,30,0);
height: 22px;
width: 22px;
border-radius: 50px;
background: #ff3034;
cursor: pointer;
-webkit-appearance: none;
margin-top: -11.5px
}
input[type=range]:focus::-webkit-slider-runnable-track {
background: #EFEFEF
}
input[type=range]::-moz-range-track {
width: 100%;
height: 2px;
cursor: pointer;
background: #EFEFEF;
border-radius: 0;
border: 0 solid #EFEFEF
}
input[type=range]::-moz-range-thumb {
border: 1px solid rgba(0,0,30,0);
height: 22px;
width: 22px;
border-radius: 50px;
background: #ff3034;
cursor: pointer
}
input[type=range]::-ms-track {
width: 100%;
height: 2px;
cursor: pointer;
background: 0 0;
border-color: transparent;
color: transparent
}
input[type=range]::-ms-fill-lower {
background: #EFEFEF;
border: 0 solid #EFEFEF;
border-radius: 0
}
input[type=range]::-ms-fill-upper {
background: #EFEFEF;
border: 0 solid #EFEFEF;
border-radius: 0
}
input[type=range]::-ms-thumb {
border: 1px solid rgba(0,0,30,0);
height: 22px;
width: 22px;
border-radius: 50px;
background: #ff3034;
cursor: pointer;
height: 2px
}
input[type=range]:focus::-ms-fill-lower {
background: #EFEFEF
}
input[type=range]:focus::-ms-fill-upper {
background: #363636
}
input[type=text] {
border: 1px solid #363636;
font-size: 14px;
height: 20px;
margin: 1px;
outline: 0;
border-radius: 5px
}
.switch {
display: block;
position: relative;
line-height: 22px;
font-size: 16px;
height: 22px
}
.switch input {
outline: 0;
opacity: 0;
width: 0;
height: 0
}
.slider {
width: 50px;
height: 22px;
border-radius: 22px;
cursor: pointer;
background-color: grey
}
.slider,.slider:before {
display: inline-block;
transition: .4s
}
.slider:before {
position: relative;
content: "";
border-radius: 50%;
height: 16px;
width: 16px;
left: 4px;
top: 3px;
background-color: #fff
}
input:checked+.slider {
background-color: #ff3034
}
input:checked+.slider:before {
-webkit-transform: translateX(26px);
transform: translateX(26px)
}
select {
border: 1px solid #363636;
font-size: 14px;
height: 22px;
outline: 0;
border-radius: 5px
}
.image-container {
position: fixed;
min-width: 160px;
margin-right: 16px;
transform-origin: top left;
}
.close {
position: absolute;
z-index: 99;
background: #ff3034;
width: 16px;
height: 16px;
border-radius: 100px;
color: #fff;
text-align: center;
line-height: 18px;
cursor: pointer
}
.close-rot-none {
left: 5px;
top: 5px;
}
.close-rot-left {
right: 5px;
top: 5px;
}
.close-rot-right {
left: 5px;
bottom: 5px;
}
.hidden {
display: none
}
.inline-button {
line-height: 20px;
margin: 2px;
padding: 1px 4px 2px 4px;
}
.loader {
border: 0.5em solid #f3f3f3; /* Light grey */
border-top: 0.5em solid #000000; /* white */
border-radius: 50%;
width: 1em;
height: 1em;
-webkit-animation: spin 2s linear infinite; /* Safari */
animation: spin 2s linear infinite;
}
@-webkit-keyframes spin { /* Safari */
0% { -webkit-transform: rotate(0deg); }
100% { -webkit-transform: rotate(360deg); }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (min-width: 800px) and (orientation:landscape) {
#content {
display:flex;
flex-wrap: nowrap;
align-items: stretch
}
}
</style>
</head>
<body>
<section class="main">
<div id="logo">
<label for="nav-toggle-cb" id="nav-toggle" style="float:left;">&#9776;&nbsp;&nbsp;Settings&nbsp;&nbsp;&nbsp;&nbsp;</label>
<button id="get-still" style="float:left;" disabled>Get Still</button>
<button id="mjpeg-stream" style="float:left;" disabled>Start MJPEG</button>
<button id="webrtc-stream" style="float:left;" disabled>Start WebRTC</button>
<div id="wait-settings" style="float:left;" class="loader" title="Waiting for camera settings to load"></div>
</div>
<div id="content">
<div class="hidden" id="sidebar">
<input type="checkbox" id="nav-toggle-cb" checked="checked">
<nav id="menu">
<!-- <h1>Preferences</h1>
<div class="input-group" id="preferences-group">
<button id="reboot" title="Reboot the camera module">Reboot</button>
<button id="save_prefs" title="Save Preferences on camera module">Save</button>
<button id="clear_prefs" title="Erase saved Preferences on camera module">Erase</button>
</div> -->
<h1>Experimental</h1>
<ul>
<li>Changes are not persisted.</li>
<li>Use <i>--camera-options</i> to persist CAMERA settings.</li>
<li>Use <i>--camera-snapshot.options</i> to persist SNAPSHOT settings.</li>
<li>Use <i>--camera-stream.options</i> to persist STREAM settings.</li>
<li>Use <i>--camera-video.options</i> to persist VIDEO settings.</li>
<li>Restart the <b>camera-streamer</b> service to reset.</li>
<li>Some options might crash the process.</li>
</ul>
<h1 id="anchor">Links</h1>
<div class="input-group hidden" id="snapshot-group">
<label for="snapshot_url">Snapshot</label>
<a id="snapshot_url" class="default-action">Unknown</a>
</div>
<div class="input-group hidden" id="stream-group">
<label for="stream_url">Stream</label>
<a id="stream_url" class="default-action">Unknown</a>
</div>
<div class="input-group hidden" id="video-group">
<label for="video_url">Video</label>
<a id="video_url" class="default-action">Unknown</a>
</div>
<div class="input-group hidden" id="webrtc-group">
<label for="webrtc_url">WebRTC</label>
<a id="webrtc_url" class="default-action">Unknown</a>
</div>
<div class="input-group hidden" id="rtsp-group">
<label for="rtsp_url">RTSP</label>
<a id="rtsp_url" class="default-action">Unknown</a>
</div>
<h1>Release</h1>
<div class="input-group">
<label for="git_version">Version</label>
<div id="git_version" class="default-action">Unknown</div>
</div>
<div class="input-group">
<label for="git_revision">Stream</label>
<div id="git_revision" class="default-action">Unknown</div>
</div>
</nav>
</div>
<figure>
<div id="stream-container" class="image-container hidden">
<div class="close close-rot-none" click="stopStream()">×</div>
<img id="stream-view" src="">
</div>
<div id="video-container" class="image-container hidden">
<div class="close close-rot-none" click="stopStream()">×</div>
<video id="video-view" controls autoplay muted playsinline src="">
</div>
</figure>
</div>
</section>
</body>
<script>
document.addEventListener('DOMContentLoaded', function (event) {
var streamURL = 'stream';
var snapshotURL = 'snapshot';
var webrtcURL = 'webrtc';
const header = document.getElementById('logo')
const settings = document.getElementById('sidebar')
const waitSettings = document.getElementById('wait-settings')
const hide = el => {
el.classList.add('hidden')
}
const show = el => {
el.classList.remove('hidden')
}
const show2 = (el, status) => {
if (status)
show(el);
else
hide(el);
}
const enable = el => {
el.classList.remove('disabled')
el.disabled = false
}
const disable = el => {
el.classList.add('disabled')
el.disabled = true
}
const enable2 = (el, status) => {
if (status)
enable(el);
else
disable(el);
}
document
.querySelectorAll('.close')
.forEach(el => {
el.onclick = () => {
hide(el.parentNode)
}
})
const configureEndpoints =(state) => {
enable2(document.getElementById('get-still'), state.endpoints.snapshot.enabled);
enable2(document.getElementById('mjpeg-stream'), state.endpoints.stream.enabled);
enable2(document.getElementById('webrtc-stream'), state.endpoints.webrtc.enabled);
document.getElementById('git_version').textContent = state.version;
document.getElementById('git_revision').textContent = state.revision;
for (let type of ["snapshot", "stream", "video", "webrtc", "rtsp"]) {
if (!state.endpoints[type].enabled)
continue;
let groupView = document.getElementById(`${type}-group`);
let urlView = document.getElementById(`${type}_url`);
show(groupView);
urlView.href = urlView.innerHTML = state.endpoints[type].uri;
}
};
const sendOptionValue = (device, key, value) => {
device = encodeURIComponent(device);
key = encodeURIComponent(key);
value = encodeURIComponent(value);
return fetch(`option?device=${device}&key=${key}&value=${value}`, {
method: 'POST'
}).then(function (response) {
return response
});
};
const insertControl = (type) => {
const node = document.createElement(type);
const preferences = document.getElementById("anchor");
preferences.parentNode.insertBefore(node, preferences);
return node;
}
const createDeviceOption = (device, key, option) => {
const id_key = `${device}_${key}`;
const groupEl = insertControl("div");
groupEl.className = "input-group";
const labelEl = document.createElement("label");
labelEl.setAttribute("for", id_key);
labelEl.textContent = option.name;
groupEl.appendChild(labelEl);
switch (option.type) {
case "bool":
const divEl = document.createElement("div");
divEl.className = "switch";
groupEl.appendChild(divEl);
const inputEl = document.createElement("input");
inputEl.id = id_key;
inputEl.type = "checkbox";
inputEl.className = "default-action";
divEl.appendChild(inputEl);
const labelSliderEl = document.createElement("label");
labelSliderEl.setAttribute("for", id_key);
labelSliderEl.className = "slider";
divEl.appendChild(labelSliderEl);
if (option.value)
inputEl.checked = option.value == '1';
inputEl.onclick = () => {
sendOptionValue(device.name, key, inputEl.checked ? 1 : 0);
}
break;
case "integer":
case "integer64":
case "float":
if (option.menu) {
const selectEl = document.createElement("select");
selectEl.className = "default-action";
selectEl.id = id_key;
selectEl.value = option.value;
groupEl.appendChild(selectEl);
if (!option.value) {
const optionEl = document.createElement("option");
optionEl.text = "?";
selectEl.add(optionEl);
}
for (var value in option.menu) {
const optionEl = document.createElement("option");
optionEl.value = value;
optionEl.text = option.menu[value];
selectEl.add(optionEl);
if (optionEl.text == option.value)
selectEl.value = value;
}
selectEl.onchange = () => {
sendOptionValue(device.name, key, selectEl.value);
}
} else if (option.description && (!option.elems || option.elems == 1) && (range = option.description.match("^\\[(.*)\\.\\.(.*)\\]$"))) {
const minValue = Number(range[1]);
const maxValue = Number(range[2]);
let stepValue = (maxValue - minValue) / 20;
if (option.type != "float") {
stepValue = Math.round(stepValue);
if (stepValue < 1)
stepValue = 1;
}
const inputRangeEl = document.createElement("input");
inputRangeEl.id = id_key;
inputRangeEl.type = "range";
inputRangeEl.className = "default-action";
inputRangeEl.min = minValue;
inputRangeEl.max = maxValue;
inputRangeEl.step = stepValue;
inputRangeEl.style = "width: 100px";
if (option.value)
inputRangeEl.value = Number(option.value);
else
inputRangeEl.value = "?";
groupEl.appendChild(inputRangeEl);
const inputNumberEl = document.createElement("input");
inputNumberEl.id = id_key;
inputNumberEl.type = "number";
//inputNumberEl.className = "default-action";
inputNumberEl.className = "range-max";
inputNumberEl.style = "width: 20px";
inputNumberEl.min = minValue;
inputNumberEl.max = maxValue;
inputNumberEl.step = stepValue;
inputNumberEl.size = "4";
if (option.value)
inputNumberEl.value = Number(option.value);
else
inputNumberEl.value = "?";
groupEl.appendChild(inputNumberEl);
inputRangeEl.onchange = () => {
sendOptionValue(device.name, key, inputRangeEl.value);
inputNumberEl.value = inputRangeEl.value;
}
inputNumberEl.onchange = () => {
sendOptionValue(device.name, key, inputNumberEl.value);
inputRangeEl.value = inputNumberEl.value;
}
} else {
const divEl = document.createElement("div");
divEl.className = "text";
groupEl.appendChild(divEl);
const inputEl = document.createElement("input");
inputEl.id = id_key;
inputEl.type = "text";
inputEl.className = "default-action";
if (option.value)
inputEl.value = option.value;
else
inputEl.value = "?";
divEl.appendChild(inputEl);
if (option.description) {
const divDescriptionEl = document.createElement("div");
divDescriptionEl.className = "range-max";
divDescriptionEl.textContent = option.description;
divEl.appendChild(divDescriptionEl);
}
inputEl.onchange = () => {
sendOptionValue(device.name, key, inputEl.value);
}
}
break;
}
}
const createControls = (state) => {
for (var device of state.devices) {
const heading = insertControl("h1");
heading.textContent = device.name;
for (var key in device.options) {
createDeviceOption(device, key, device.options[key]);
}
}
};
var rtcPeerConfig = {
sdpSemantics: 'unified-plan'
};
rtcPeerConnection = new RTCPeerConnection(rtcPeerConfig);
// read initial values
fetch(`status`)
.then(function (response) {
return response.json()
})
.then(function (state) {
hide(waitSettings);
show(settings);
configureEndpoints(state);
createControls(state);
})
const streamContainer = document.getElementById("stream-container");
const videoContainer = document.getElementById("video-container");
const stopStream = () => {
window.stop();
rtcPeerConnection.close();
hide(streamContainer);
hide(videoContainer);
}
document.getElementById('get-still').onclick = () => {
stopStream();
const view = document.getElementById("stream-view");
view.src = `${snapshotURL}?_cb=${Date.now()}`;
view.scrollIntoView(false);
show(streamContainer);
}
document.getElementById('mjpeg-stream').onclick = () => {
stopStream();
const view = document.getElementById("stream-view");
view.src = `${streamURL}?_cb=${Date.now()}`;
view.scrollIntoView(false);
show(streamContainer);
}
document.getElementById('webrtc-stream').onclick = () => {
stopStream();
show(videoContainer);
const iceServers = [
{ urls: ['stun:stun.l.google.com:19302'] }
];
fetch(webrtcURL, {
body: JSON.stringify({type: 'request', iceServers: iceServers}),
headers: {'Content-Type': 'application/json'},
method: 'POST'
}).then(function(response) {
return response.json();
}).then(function(request) {
rtcPeerConnection = new RTCPeerConnection({
sdpSemantics: 'unified-plan',
iceServers: request.iceServers
});
rtcPeerConnection.addTransceiver('video', { direction: 'recvonly' });
//pc.addTransceiver('audio', {direction: 'recvonly'});
rtcPeerConnection.addEventListener('track', function(evt) {
console.log("track event " + evt.track.kind);
if (evt.track.kind == 'video') {
const view = document.getElementById("video-view");
view.srcObject = evt.streams[0];
view.scrollIntoView(false);
}
});
rtcPeerConnection.addEventListener("icecandidate", function(e) {
if (e.candidate) {
return fetch(webrtcURL, {
body: JSON.stringify({
type: 'remote_candidate',
id: rtcPeerConnection.remote_pc_id,
candidates: [e.candidate]
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST'
}).catch(function(e) {
console.log("Failed to send ICE WebRTC: "+e);
});
}
});
rtcPeerConnection.remote_pc_id = request.id;
return rtcPeerConnection.setRemoteDescription(request);
}).then(function() {
return rtcPeerConnection.createAnswer();
}).then(function(answer) {
return rtcPeerConnection.setLocalDescription(answer);
}).then(function(answer) {
var offer = rtcPeerConnection.localDescription;
return fetch(webrtcURL, {
body: JSON.stringify({
type: offer.type,
id: rtcPeerConnection.remote_pc_id,
sdp: offer.sdp,
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST'
})
}).then(function(response) {
return response.json();
}).catch(function(e) {
console.log(e);
});
}
})
</script>
</html>