install go2rtc on bob
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# www
|
||||
|
||||
This folder contains static HTTP and JS content that is embedded into the application during build. An external developer can use it as a basis for integrating go2rtc into their project or for developing a custom web interface for go2rtc.
|
||||
|
||||
## HTTP API
|
||||
|
||||
`www/stream.html` - universal viewer with support params in URL:
|
||||
|
||||
- multiple streams on page `src=camera1&src=camera2...`
|
||||
- stream technology autoselection `mode=webrtc,webrtc/tcp,mse,hls,mp4,mjpeg`
|
||||
- stream technology comparison `src=camera1&mode=webrtc&mode=mse&mode=mp4`
|
||||
- player width setting in pixels `width=320px` or percents `width=50%`
|
||||
|
||||
`www/webrtc.html` - WebRTC viewer with support two way audio and params in URL:
|
||||
|
||||
- `media=video+audio` - simple viewer
|
||||
- `media=video+audio+microphone` - two way audio from camera
|
||||
- `media=camera+microphone` - stream from browser
|
||||
- `media=display+speaker` - stream from desktop
|
||||
|
||||
## JavaScript API
|
||||
|
||||
- You can write your viewer from the scratch
|
||||
- You can extend the built-in viewer - `www/video-rtc.js`
|
||||
- Check example - `www/video-stream.js`
|
||||
- Check example - https://github.com/AlexxIT/WebRTC
|
||||
|
||||
`video-rtc.js` features:
|
||||
|
||||
- support technologies:
|
||||
- WebRTC over UDP or TCP
|
||||
- MSE or HLS or MP4 or MJPEG over WebSocket
|
||||
- automatic selection best technology according on:
|
||||
- codecs inside your stream
|
||||
- current browser capabilities
|
||||
- current network configuration
|
||||
- automatic stop stream while browser or page not active
|
||||
- automatic stop stream while player not inside page viewport
|
||||
- automatic reconnection
|
||||
|
||||
Technology selection based on priorities:
|
||||
|
||||
1. Video and Audio better than just Video
|
||||
2. H265 better than H264
|
||||
3. WebRTC better than MSE, than HLS, than MJPEG
|
||||
|
||||
## Browser support
|
||||
|
||||
[ECMAScript 2019 (ES10)](https://caniuse.com/?search=es10) supported by [iOS 12](https://en.wikipedia.org/wiki/IOS_12) (iPhone 5S, iPad Air, iPad Mini 2, etc.).
|
||||
|
||||
But [ECMAScript 2017 (ES8)](https://caniuse.com/?search=es8) almost fine (`es6 + async`) and recommended for [React+TypeScript](https://github.com/typescript-cheatsheets/react).
|
||||
|
||||
## Known problems
|
||||
|
||||
- Autoplay doesn't work for WebRTC in Safari [read more](https://developer.apple.com/documentation/webkit/delivering_video_content_for_safari/).
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://www.webrtc-experiment.com/DetectRTC/
|
||||
- https://divtable.com/table-styler/
|
||||
- https://www.chromium.org/audio-video/
|
||||
- https://web.dev/i18n/en/fast-playback-with-preload/#manual_buffering
|
||||
- https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API
|
||||
- https://chromium.googlesource.com/external/w3c/web-platform-tests/+/refs/heads/master/media-source/mediasource-is-type-supported.html
|
||||
- https://googlechrome.github.io/samples/media/sourcebuffer-changetype.html
|
||||
- https://chromestatus.com/feature/5100845653819392
|
||||
- https://developer.apple.com/documentation/webkit/delivering_video_content_for_safari
|
||||
- https://dirask.com/posts/JavaScript-supported-Audio-Video-MIME-Types-by-MediaRecorder-Chrome-and-Firefox-jERn81
|
||||
- https://privacycheck.sec.lrz.de/active/fp_cpt/fp_can_play_type.html
|
||||
@@ -0,0 +1,569 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>add - go2rtc</title>
|
||||
<style>
|
||||
main > button {
|
||||
background-color: #444;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 14px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
main > div {
|
||||
display: none;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<script src="main.js"></script>
|
||||
|
||||
<script>
|
||||
function drawTable(table, data) {
|
||||
const cols = ['id', 'name', 'info', 'url', 'location'];
|
||||
const th = (row) => cols.reduce((html, k) => k in row ? `${html}<th>${k}</th>` : html, '<tr>') + '</tr>';
|
||||
const td = (row) => cols.reduce((html, k) => k in row ? `${html}<td style="word-break: break-word; white-space: normal;">${row[k]}</td>` : html, '<tr>') + '</tr>';
|
||||
|
||||
const thead = th(data.sources[0]);
|
||||
const tbody = data.sources.reduce((html, source) => `${html}${td(source)}`, '');
|
||||
|
||||
table.innerHTML = `<thead>${thead}</thead><tbody>${tbody}</tbody>`;
|
||||
}
|
||||
|
||||
async function getSources(tableID, url) {
|
||||
const table = document.getElementById(tableID);
|
||||
table.innerText = 'loading...';
|
||||
|
||||
const r = typeof url === 'string' ? await fetch(url, {cache: 'no-cache'}) : url;
|
||||
if (!r.ok) {
|
||||
table.innerText = await r.text();
|
||||
return;
|
||||
}
|
||||
|
||||
drawTable(table, await r.json());
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<button id="stream">Temporary stream</button>
|
||||
<div>
|
||||
<form id="stream-form">
|
||||
<input type="text" name="name" placeholder="name">
|
||||
<input type="text" name="src" placeholder="url" required size="30">
|
||||
<button type="submit">add</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('stream').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
});
|
||||
|
||||
document.getElementById('stream-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const url = new URL('api/streams', location.href);
|
||||
url.searchParams.set('name', ev.target.elements['name'].value);
|
||||
url.searchParams.set('src', ev.target.elements['src'].value);
|
||||
|
||||
const r = await fetch(url, {method: 'PUT'});
|
||||
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="alsa">ALSA (Linux audio)</button>
|
||||
<div>
|
||||
<table id="alsa-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('alsa').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('alsa-table', 'api/alsa');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="homekit">Apple HomeKit</button>
|
||||
<div>
|
||||
<form id="homekit-pair">
|
||||
<input type="text" name="id" placeholder="stream id" required>
|
||||
<input type="text" name="src" placeholder="src" required size="30">
|
||||
<input type="text" name="pin" placeholder="pin" required size="10">
|
||||
<button type="submit">pair</button>
|
||||
</form>
|
||||
<form id="homekit-unpair">
|
||||
<input type="text" name="id" placeholder="stream id" required>
|
||||
<button type="submit">unpair</button>
|
||||
</form>
|
||||
<table id="homekit-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
async function reloadHomeKit() {
|
||||
await getSources('homekit-table', 'api/discovery/homekit');
|
||||
|
||||
const rows = document.querySelectorAll('#homekit-table tr');
|
||||
rows.forEach((row, i) => {
|
||||
let commands = '';
|
||||
if (row.children[2].innerText.indexOf('status=1') > 0) {
|
||||
commands += '<a href="#">pair</a>';
|
||||
} else if (i > 0 && row.children[3].innerText) {
|
||||
commands += '<a href="#">unpair</a>';
|
||||
}
|
||||
row.innerHTML += i > 0 ? `<td>${commands}</td>` : '<th>commands</th>';
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('homekit').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await reloadHomeKit();
|
||||
});
|
||||
|
||||
document.getElementById('homekit-table').addEventListener('click', ev => {
|
||||
if (ev.target.innerText === 'pair') {
|
||||
const form = document.querySelector('#homekit-pair');
|
||||
const row = ev.target.closest('tr');
|
||||
form.children[0].value = row.children[0].innerText;
|
||||
form.children[1].value = row.children[2].innerText;
|
||||
} else if (ev.target.innerText === 'unpair') {
|
||||
const form = document.querySelector('#homekit-unpair');
|
||||
const row = ev.target.closest('tr');
|
||||
form.children[0].value = row.children[3].innerText;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('homekit-pair').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const params = new URLSearchParams(new FormData(ev.target));
|
||||
const r = await fetch('api/homekit', {method: 'POST', body: params});
|
||||
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||
|
||||
await reloadHomeKit();
|
||||
});
|
||||
|
||||
document.getElementById('homekit-unpair').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const params = new URLSearchParams(new FormData(ev.target));
|
||||
const r = await fetch('api/homekit?' + params.toString(), {method: 'DELETE'});
|
||||
alert(r.ok ? 'OK' : 'ERROR: ' + await r.text());
|
||||
|
||||
await reloadHomeKit();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="dvrip">DVRIP</button>
|
||||
<div>
|
||||
<table id="dvrip-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('dvrip').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('dvrip-table', 'api/dvrip');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="devices">FFmpeg Devices (USB)</button>
|
||||
<div>
|
||||
<table id="devices-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('devices').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('devices-table', 'api/ffmpeg/devices');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="hardware">FFmpeg Hardware</button>
|
||||
<div>
|
||||
<table id="hardware-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('hardware').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('hardware-table', 'api/ffmpeg/hardware');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="nest">Google Nest</button>
|
||||
<div>
|
||||
<form id="nest-form">
|
||||
<input type="text" name="client_id" placeholder="client_id" required>
|
||||
<input type="text" name="client_secret" placeholder="client_secret" required>
|
||||
<input type="text" name="refresh_token" placeholder="refresh_token" required>
|
||||
<input type="text" name="project_id" placeholder="project_id" required>
|
||||
<button type="submit">login</button>
|
||||
</form>
|
||||
<table id="nest-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('nest').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
});
|
||||
|
||||
document.getElementById('nest-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const query = new URLSearchParams(new FormData(ev.target));
|
||||
const url = new URL('api/nest?' + query.toString(), location.href);
|
||||
|
||||
const r = await fetch(url, {cache: 'no-cache'});
|
||||
await getSources('nest-table', r);
|
||||
});
|
||||
</script>
|
||||
|
||||
<button id="gopro">GoPro</button>
|
||||
<div>
|
||||
<table id="gopro-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('gopro').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('gopro-table', 'api/gopro');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="hass">Home Assistant</button>
|
||||
<div>
|
||||
<table id="hass-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('hass').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('hass-table', 'api/hass');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="onvif">ONVIF</button>
|
||||
<div>
|
||||
<form id="onvif-form">
|
||||
<input type="text" name="src" placeholder="onvif://user:pass@192.168.1.123:80" required size="30">
|
||||
<button type="submit">test</button>
|
||||
</form>
|
||||
<table id="onvif-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('onvif').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('onvif-table', 'api/onvif');
|
||||
});
|
||||
|
||||
document.getElementById('onvif-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const url = new URL('api/onvif', location.href);
|
||||
url.searchParams.set('src', ev.target.elements['src'].value);
|
||||
|
||||
await getSources('onvif-table', url.toString());
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="ring">Ring</button>
|
||||
<div>
|
||||
<form id="ring-credentials-form">
|
||||
<input type="email" name="email" placeholder="email" required>
|
||||
<input type="password" name="password" placeholder="password" required>
|
||||
<div id="tfa-field" style="display: none">
|
||||
<input type="text" name="code" placeholder="2FA code">
|
||||
<div id="tfa-prompt"></div>
|
||||
</div>
|
||||
<button type="submit">login</button>
|
||||
</form>
|
||||
<form id="ring-token-form">
|
||||
<input type="text" name="refresh_token" placeholder="refresh_token" required>
|
||||
<button type="submit">login</button>
|
||||
</form>
|
||||
<table id="ring-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('ring').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
});
|
||||
|
||||
async function handleRingAuth(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
const table = document.getElementById('ring-table');
|
||||
table.innerText = 'loading...';
|
||||
|
||||
const query = new URLSearchParams(new FormData(ev.target));
|
||||
const url = new URL('api/ring?' + query.toString(), location.href);
|
||||
|
||||
const r = await fetch(url, {cache: 'no-cache'});
|
||||
|
||||
if (!r.ok) {
|
||||
table.innerText = (await r.text()) || 'Unknown error';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await r.json();
|
||||
|
||||
table.innerText = '';
|
||||
|
||||
if (data.needs_2fa) {
|
||||
document.getElementById('tfa-field').style.display = 'block';
|
||||
document.getElementById('tfa-prompt').textContent = data.prompt || 'Enter 2FA code';
|
||||
return;
|
||||
}
|
||||
|
||||
drawTable(table, data);
|
||||
}
|
||||
|
||||
document.getElementById('ring-credentials-form').addEventListener('submit', handleRingAuth);
|
||||
document.getElementById('ring-token-form').addEventListener('submit', handleRingAuth);
|
||||
</script>
|
||||
|
||||
|
||||
<button id="tuya">Tuya</button>
|
||||
<div>
|
||||
<form id="tuya-credentials-form">
|
||||
<select name="region" required>
|
||||
<option value="protect-eu.ismartlife.me">EU Central</option>
|
||||
<option value="protect-we.ismartlife.me">EU East</option>
|
||||
<option value="protect-us.ismartlife.me">US West</option>
|
||||
<option value="protect-ue.ismartlife.me">US East</option>
|
||||
<option value="protect.ismartlife.me">China</option>
|
||||
<option value="protect-in.ismartlife.me">India</option>
|
||||
</select>
|
||||
<input type="email" name="email" placeholder="email" required>
|
||||
<input type="password" name="password" placeholder="password" required>
|
||||
<button type="submit">login</button>
|
||||
</form>
|
||||
<table id="tuya-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('tuya').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
});
|
||||
|
||||
document.getElementById('tuya-credentials-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const table = document.getElementById('tuya-table');
|
||||
table.innerText = 'loading...';
|
||||
|
||||
const query = new URLSearchParams(new FormData(ev.target));
|
||||
const url = new URL('api/tuya?' + query.toString(), location.href);
|
||||
|
||||
const r = await fetch(url, {cache: 'no-cache'});
|
||||
|
||||
if (!r.ok) {
|
||||
table.innerText = (await r.text()) || 'Unknown error';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await r.json();
|
||||
|
||||
table.innerText = '';
|
||||
|
||||
drawTable(table, data);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="roborock">Roborock</button>
|
||||
<div>
|
||||
<form id="roborock-form">
|
||||
<input type="text" name="username" placeholder="username" required>
|
||||
<input type="password" name="password" placeholder="password" required>
|
||||
<button type="submit">login</button>
|
||||
</form>
|
||||
<table id="roborock-table">
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('roborock').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('roborock-table', 'api/roborock');
|
||||
});
|
||||
|
||||
document.getElementById('roborock-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)});
|
||||
await getSources('roborock-table', r);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="v4l2">V4L2 (Linux video)</button>
|
||||
<div>
|
||||
<table id="v4l2-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('v4l2').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('v4l2-table', 'api/v4l2');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="wyze">Wyze</button>
|
||||
<div>
|
||||
<p style="margin: 5px 0; font-size: 12px; color: #888;">
|
||||
API Key required: <a href="https://support.wyze.com/hc/en-us/articles/16129834216731" target="_blank">Get your API Key</a>
|
||||
</p>
|
||||
<form id="wyze-login-form">
|
||||
<input type="text" name="api_id" placeholder="API ID" required size="20">
|
||||
<input type="text" name="api_key" placeholder="API Key" required size="36">
|
||||
<input type="email" name="email" placeholder="email" required>
|
||||
<input type="password" name="password" placeholder="password" required>
|
||||
<button type="submit">login</button>
|
||||
</form>
|
||||
<form id="wyze-devices-form">
|
||||
<select id="wyze-id" name="id" required></select>
|
||||
<button type="submit">load devices</button>
|
||||
</form>
|
||||
<table id="wyze-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
async function wyzeReload(ev) {
|
||||
if (ev) ev.target.nextElementSibling.style.display = 'grid';
|
||||
|
||||
const r = await fetch('api/wyze', {'cache': 'no-cache'});
|
||||
const data = await r.json();
|
||||
const users = document.getElementById('wyze-id');
|
||||
users.innerHTML = data.map(item => `<option value="${item}">${item}</option>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('wyze').addEventListener('click', wyzeReload);
|
||||
|
||||
document.getElementById('wyze-login-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const table = document.getElementById('wyze-table');
|
||||
table.innerText = 'loading...';
|
||||
|
||||
const params = new URLSearchParams(new FormData(ev.target));
|
||||
const r = await fetch('api/wyze', {method: 'POST', body: params});
|
||||
|
||||
if (!r.ok) {
|
||||
table.innerText = (await r.text()) || 'Unknown error';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await r.json();
|
||||
table.innerText = '';
|
||||
drawTable(table, data);
|
||||
wyzeReload();
|
||||
});
|
||||
|
||||
document.getElementById('wyze-devices-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
const params = new URLSearchParams(new FormData(ev.target));
|
||||
await getSources('wyze-table', 'api/wyze?' + params.toString());
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="xiaomi">Xiaomi</button>
|
||||
<div>
|
||||
<form id="xiaomi-login-form">
|
||||
<input type="text" name="username" placeholder="username" required>
|
||||
<input type="password" name="password" placeholder="password" required>
|
||||
<button type="submit">login</button>
|
||||
</form>
|
||||
<form id="xiaomi-captcha-form">
|
||||
<img id="xiaomi-captcha">
|
||||
<input type="text" name="captcha" placeholder="captcha" required size="10">
|
||||
<button type="submit">send</button>
|
||||
</form>
|
||||
<form id="xiaomi-verify-form">
|
||||
<label id="xiaomi-verify"></label>
|
||||
<input type="text" name="verify" placeholder="verify" required size="10">
|
||||
<button type="submit">send</button>
|
||||
</form>
|
||||
<form id="xiaomi-devices-form">
|
||||
<select id="xiaomi-id" name="id" required></select>
|
||||
<select name="region" required>
|
||||
<option value="cn">China</option>
|
||||
<option value="de">Europe</option>
|
||||
<option value="i2">India</option>
|
||||
<option value="ru">Russia</option>
|
||||
<option value="sg">Singapore</option>
|
||||
<option value="us">United States</option>
|
||||
</select>
|
||||
<button type="submit">load devices</button>
|
||||
</form>
|
||||
<table id="xiaomi-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
async function xiaomiReload(ev) {
|
||||
if (ev) ev.target.nextElementSibling.style.display = 'grid';
|
||||
|
||||
document.getElementById('xiaomi-login-form').style.display = 'flex';
|
||||
document.getElementById('xiaomi-captcha-form').style.display = 'none';
|
||||
document.getElementById('xiaomi-verify-form').style.display = 'none';
|
||||
|
||||
const r = await fetch('api/xiaomi', {'cache': 'no-cache'});
|
||||
const data = await r.json();
|
||||
const users = document.getElementById('xiaomi-id');
|
||||
users.innerHTML = data.map(item => `<option value="${item}">${item}</option>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('xiaomi').addEventListener('click', xiaomiReload);
|
||||
|
||||
async function xiaomiLogin(ev) {
|
||||
ev.preventDefault();
|
||||
const params = new URLSearchParams(new FormData(ev.target));
|
||||
const r = await fetch('api/xiaomi', {method: 'POST', body: params});
|
||||
if (r.status === 401) {
|
||||
/** @type {{captcha: string, verify_email: string, verify_phone: string}} */
|
||||
const data = await r.json();
|
||||
document.getElementById('xiaomi-login-form').style.display = 'none';
|
||||
if (data.captcha) {
|
||||
document.getElementById('xiaomi-captcha-form').style.display = 'flex';
|
||||
document.getElementById('xiaomi-captcha').src = 'data:image/jpeg;base64,' + data.captcha;
|
||||
} else {
|
||||
document.getElementById('xiaomi-verify-form').style.display = 'flex';
|
||||
document.getElementById('xiaomi-verify').innerText = data.verify_email || data.verify_phone;
|
||||
}
|
||||
} else if (r.ok) {
|
||||
alert('OK');
|
||||
xiaomiReload();
|
||||
} else {
|
||||
alert('ERROR: ' + await r.text());
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('xiaomi-login-form').addEventListener('submit', xiaomiLogin);
|
||||
document.getElementById('xiaomi-captcha-form').addEventListener('submit', xiaomiLogin);
|
||||
document.getElementById('xiaomi-verify-form').addEventListener('submit', xiaomiLogin);
|
||||
|
||||
document.getElementById('xiaomi-devices-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
const params = new URLSearchParams(new FormData(ev.target));
|
||||
await getSources('xiaomi-table', 'api/xiaomi?' + params.toString());
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="webtorrent">WebTorrent Shares</button>
|
||||
<div>
|
||||
<table id="webtorrent-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('webtorrent').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'grid';
|
||||
await getSources('webtorrent-table', 'api/webtorrent');
|
||||
});
|
||||
</script>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>hls - go2rtc</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: black;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, video {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
|
||||
<video id="video" autoplay controls playsinline muted></video>
|
||||
<script>
|
||||
// http://192.168.1.123:1984/hls.html?src=demo&mp4
|
||||
const url = new URL('api/stream.m3u8' + location.search, location.href);
|
||||
|
||||
const video = document.getElementById('video');
|
||||
/* global Hls */
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(url.toString());
|
||||
hls.attachMedia(video);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = url.toString();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>go2rtc</title>
|
||||
<style>
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<script src="main.js"></script>
|
||||
|
||||
<main>
|
||||
<div class="controls">
|
||||
<button>stream</button>
|
||||
modes
|
||||
<label><input type="checkbox" name="webrtc" checked>webrtc</label>
|
||||
<label><input type="checkbox" name="mse" checked>mse</label>
|
||||
<label><input type="checkbox" name="hls" checked>hls</label>
|
||||
<label><input type="checkbox" name="mjpeg" checked>mjpeg</label>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><label><input id="selectall" type="checkbox">name</label></th>
|
||||
<th>online</th>
|
||||
<th>commands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="streams">
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="info"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const templates = [
|
||||
'<a href="stream.html?src={name}">stream</a>',
|
||||
'<a href="links.html?src={name}">links</a>',
|
||||
'<a href="#" data-name="{name}">delete</a>',
|
||||
];
|
||||
|
||||
document.querySelector('.controls > button')
|
||||
.addEventListener('click', () => {
|
||||
const url = new URL('stream.html', location.href);
|
||||
|
||||
const streams = document.querySelectorAll('#streams input');
|
||||
streams.forEach(i => {
|
||||
if (i.checked) url.searchParams.append('src', i.name);
|
||||
});
|
||||
|
||||
if (!url.searchParams.has('src')) return;
|
||||
|
||||
let mode = document.querySelectorAll('.controls input');
|
||||
mode = Array.from(mode).filter(i => i.checked).map(i => i.name).join(',');
|
||||
|
||||
window.location.href = `${url}&mode=${mode}`;
|
||||
});
|
||||
|
||||
const tbody = document.getElementById('streams');
|
||||
tbody.addEventListener('click', async ev => {
|
||||
if (ev.target.innerText !== 'delete') return;
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
const src = decodeURIComponent(ev.target.dataset.name);
|
||||
|
||||
const message = `Please type the name of the stream "${src}" to confirm its deletion from the configuration. This action is irreversible.`;
|
||||
if (prompt(message) !== src) {
|
||||
alert('Stream name does not match. Deletion cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL('api/streams', location.href);
|
||||
url.searchParams.set('src', src);
|
||||
|
||||
try {
|
||||
await fetch(url, {method: 'DELETE'});
|
||||
reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete the stream:', error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('selectall').addEventListener('change', ev => {
|
||||
document.querySelectorAll('#streams input').forEach(el => {
|
||||
el.checked = ev.target.checked;
|
||||
});
|
||||
});
|
||||
|
||||
function reload() {
|
||||
const url = new URL('api/streams', location.href);
|
||||
const checkboxStates = {};
|
||||
tbody.querySelectorAll('input[type="checkbox"][name]').forEach(checkbox => {
|
||||
checkboxStates[checkbox.name] = checkbox.checked;
|
||||
});
|
||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||
const existingIds = Array.from(tbody.querySelectorAll('tr')).map(tr => tr.dataset['id']);
|
||||
const fetchedIds = [];
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const name = key.replace(/[<">]/g, ''); // sanitize
|
||||
fetchedIds.push(name);
|
||||
|
||||
let tr = tbody.querySelector(`tr[data-id="${name}"]`);
|
||||
const online = value && value.consumers ? value.consumers.length : 0;
|
||||
const src = encodeURIComponent(name);
|
||||
const links = templates.map(link => link.replace('{name}', src)).join(' ');
|
||||
|
||||
if (!tr) {
|
||||
tr = document.createElement('tr');
|
||||
tr.dataset['id'] = name;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
const isChecked = checkboxStates[name] ? 'checked' : '';
|
||||
tr.innerHTML =
|
||||
`<td><label><input type="checkbox" name="${name}" ${isChecked}>${name}</label></td>` +
|
||||
`<td><a href="api/streams?src=${src}">${online} / info</a> / <a href="api/streams?src=${src}&video=all&audio=allµphone">probe</a> / <a href="net.html?src=${src}">net</a></td>` +
|
||||
`<td>${links}</td>`;
|
||||
}
|
||||
|
||||
// Remove old rows
|
||||
existingIds.forEach(id => {
|
||||
if (!fetchedIds.includes(id)) {
|
||||
const trToRemove = tbody.querySelector(`tr[data-id="${id}"]`);
|
||||
tbody.removeChild(trToRemove);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-reload
|
||||
setInterval(reload, 1000);
|
||||
|
||||
const url = new URL('api', location.href);
|
||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||
const info = document.querySelector('.info');
|
||||
info.innerText = `version: ${data.version} / config: ${data.config_path}`;
|
||||
});
|
||||
|
||||
reload();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,268 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>links - go2rtc</title>
|
||||
<style>
|
||||
div > li {
|
||||
list-style-type: none;
|
||||
padding-left: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div > li:before {
|
||||
content: "-";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<script src="main.js"></script>
|
||||
|
||||
<main>
|
||||
<div id="links"></div>
|
||||
<script>
|
||||
const src = new URLSearchParams(location.search).get('src').replace(/[<">]/g, ''); // sanitize
|
||||
|
||||
const links = document.getElementById('links');
|
||||
|
||||
links.innerHTML = `
|
||||
<h2>Any codec in source</h2>
|
||||
<li><a href="stream.html?src=${src}">stream.html</a> with auto-select mode / browsers: all / codecs: H264, H265*, MJPEG, JPEG, AAC, PCMU, PCMA, OPUS</li>
|
||||
<li><a href="api/streams?src=${src}">info.json</a> page with active connections</li>
|
||||
`;
|
||||
|
||||
const url = new URL('api', location.href);
|
||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||
let rtsp = location.host + ':8554';
|
||||
try {
|
||||
const host = data.host.match(/^[^:]+/)[0];
|
||||
const port = data.rtsp.listen.match(/[0-9]+$/)[0];
|
||||
rtsp = `${host}:${port}`;
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
links.innerHTML += `
|
||||
<li><a href="rtsp://${rtsp}/${src}">rtsp</a> with only one video and one audio / codecs: any</li>
|
||||
<li><a href="rtsp://${rtsp}/${src}?mp4">rtsp</a> for MP4 recording (Hass or Frigate) / codecs: H264, H265, AAC</li>
|
||||
<li><a href="rtsp://${rtsp}/${src}?video=all&audio=all">rtsp</a> with all tracks / codecs: any</li>
|
||||
|
||||
<pre>ffplay -fflags nobuffer -flags low_delay -rtsp_transport tcp "rtsp://${rtsp}/${src}"</pre>
|
||||
|
||||
<h2>H264/H265 source</h2>
|
||||
<li><a href="stream.html?src=${src}&mode=webrtc">stream.html</a> WebRTC stream / browsers: all / codecs: H264, PCMU, PCMA, OPUS / +H265 in Safari</li>
|
||||
<li><a href="stream.html?src=${src}&mode=mse">stream.html</a> MSE stream / browsers: Chrome, Firefox, Safari Mac/iPad / codecs: H264, H265*, AAC, PCMA*, PCMU*, PCM* / +OPUS in Chrome and Firefox</li>
|
||||
<li><a href="api/stream.mp4?src=${src}">stream.mp4</a> legacy MP4 stream with AAC audio / browsers: Chrome, Firefox / codecs: H264, H265*, AAC</li>
|
||||
<li><a href="api/stream.mp4?src=${src}&mp4=flac">stream.mp4</a> modern MP4 stream with common audio / browsers: Chrome, Firefox / codecs: H264, H265*, AAC, FLAC (PCMA, PCMU, PCM)</li>
|
||||
<li><a href="api/stream.mp4?src=${src}&mp4=all">stream.mp4</a> MP4 stream with any audio / browsers: Chrome / codecs: H264, H265*, AAC, OPUS, MP3, FLAC (PCMA, PCMU, PCM)</li>
|
||||
<li><a href="api/frame.mp4?src=${src}">frame.mp4</a> snapshot in MP4-format / browsers: all / codecs: H264, H265*</li>
|
||||
<li><a href="api/stream.m3u8?src=${src}">stream.m3u8</a> legacy HLS/TS / browsers: Safari all, Chrome Android / codecs: H264</li>
|
||||
<li><a href="api/stream.m3u8?src=${src}&mp4">stream.m3u8</a> legacy HLS/fMP4 / browsers: Safari all, Chrome Android / codecs: H264, H265*, AAC</li>
|
||||
<li><a href="api/stream.m3u8?src=${src}&mp4=flac">stream.m3u8</a> modern HLS/fMP4 / browsers: Safari all, Chrome Android / codecs: H264, H265*, AAC, FLAC (PCMA, PCMU, PCM)</li>
|
||||
|
||||
<h2>MJPEG source</h2>
|
||||
<li><a href="stream.html?src=${src}&mode=mjpeg">stream.html</a> with MJPEG mode / browsers: all / codecs: MJPEG, JPEG</li>
|
||||
<li><a href="api/stream.mjpeg?src=${src}">stream.mjpeg</a> MJPEG stream / browsers: all / codecs: MJPEG, JPEG</li>
|
||||
<li><a href="api/frame.jpeg?src=${src}">frame.jpeg</a> snapshot in JPEG-format / browsers: all / codecs: MJPEG, JPEG</li>
|
||||
`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="homekit" style="display: none">
|
||||
<h2>HomeKit server</h2>
|
||||
</div>
|
||||
<script>
|
||||
fetch(`api/homekit?id=${src}`, {cache: 'no-cache'}).then(async (r) => {
|
||||
if (!r.ok) return;
|
||||
|
||||
const div = document.querySelector('#homekit');
|
||||
div.innerHTML += `<div><a href="${r.url}">info.json</a> page with active connections</div>`;
|
||||
div.style = '';
|
||||
|
||||
/** @type {{name: string, category_id: string, setup_code: string, setup_id: string, setup_uri: string}} */
|
||||
const data = await r.json();
|
||||
if (data.setup_code === undefined) return;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js';
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
/* global BigInt */
|
||||
const categoryID = BigInt(data.category_id);
|
||||
const pin = BigInt(data.setup_code.replaceAll('-', ''));
|
||||
const payload = categoryID << BigInt(31) | BigInt(2 << 27) | pin;
|
||||
const setupURI = `X-HM://${payload.toString(36).toUpperCase().padStart(9, '0')}${data.setup_id}`;
|
||||
|
||||
div.innerHTML += `<pre>Setup Name: ${data.name}
|
||||
Setup Code: ${data.setup_code}</pre>
|
||||
<div id="homekit-qrcode"></div>`;
|
||||
|
||||
/* global QRCode */
|
||||
new QRCode('homekit-qrcode', {text: setupURI, width: 128, height: 128});
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h2>Play audio</h2>
|
||||
<label><input type="radio" name="play" value="file" checked>
|
||||
file - play remote (https://example.com/song.mp3) or local (/media/song.mp3) file
|
||||
</label>
|
||||
<label><input type="radio" name="play" value="live">
|
||||
live - play remote live stream (radio, etc.)
|
||||
</label>
|
||||
<label><input type="radio" name="play" value="text">
|
||||
text - play Text To Speech (if your FFmpeg support this)
|
||||
</label>
|
||||
<br>
|
||||
<input id="play-url" type="text" placeholder="path / url / text">
|
||||
<button id="play-send">send</button>
|
||||
/ cameras with two way audio support
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('play-send').addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
// action - file / live / text
|
||||
const action = document.querySelector('input[name="play"]:checked').value;
|
||||
const url = new URL('api/ffmpeg', location.href);
|
||||
url.searchParams.set('dst', src);
|
||||
url.searchParams.set(action, document.getElementById('play-url').value);
|
||||
fetch(url, {method: 'POST'});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h2>Publish stream</h2>
|
||||
<pre>YouTube: rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
|
||||
Telegram: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx</pre>
|
||||
<input id="pub-url" type="text" placeholder="url">
|
||||
<button id="pub-send">send</button>
|
||||
/ Telegram RTMPS server
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('pub-send').addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
const url = new URL('api/streams', location.href);
|
||||
url.searchParams.set('src', src);
|
||||
url.searchParams.set('dst', document.getElementById('pub-url').value);
|
||||
fetch(url, {method: 'POST'});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="webrtc">
|
||||
<h2>WebRTC Magic</h2>
|
||||
<label><input type="radio" name="webrtc" value="video+audio" checked>
|
||||
video+audio = simple viewer
|
||||
</label>
|
||||
<label><input type="radio" name="webrtc" value="video+audio+microphone">
|
||||
video+audio+microphone = two way audio from camera
|
||||
</label>
|
||||
<label><input type="radio" name="webrtc" value="camera+microphone">
|
||||
camera+microphone = stream from browser
|
||||
</label>
|
||||
<label><input type="radio" name="webrtc" value="display+speaker">
|
||||
display+speaker = broadcast software
|
||||
</label>
|
||||
|
||||
<br>
|
||||
<li><a id="local" href="webrtc.html?src=">webrtc.html</a> local WebRTC viewer</li>
|
||||
|
||||
<li>
|
||||
<a id="shareadd" href="#">share link</a>
|
||||
<a id="shareget" href="#">copy link</a>
|
||||
<a id="sharedel" href="#">delete</a>
|
||||
external WebRTC viewer
|
||||
</li>
|
||||
</div>
|
||||
<script>
|
||||
function webrtcLinksUpdate() {
|
||||
const media = document.querySelector('input[name="webrtc"]:checked').value;
|
||||
|
||||
const direction = media.indexOf('video') >= 0 || media === 'audio' ? 'src' : 'dst';
|
||||
document.getElementById('local').href = `webrtc.html?${direction}=${src}&media=${media}`;
|
||||
|
||||
const share = document.getElementById('shareget');
|
||||
share.href = `https://go2rtc.org/webtorrent/#${share.dataset.auth}&media=${media}`;
|
||||
}
|
||||
|
||||
function share(method) {
|
||||
const url = new URL('api/webtorrent', location.href);
|
||||
url.searchParams.set('src', src);
|
||||
return fetch(url, {method: method, cache: 'no-cache'});
|
||||
}
|
||||
|
||||
function onshareadd(r) {
|
||||
document.getElementById('shareget').dataset['auth'] = `share=${r.share}&pwd=${r.pwd}`;
|
||||
|
||||
document.getElementById('shareadd').style.display = 'none';
|
||||
document.getElementById('shareget').style.display = '';
|
||||
document.getElementById('sharedel').style.display = '';
|
||||
|
||||
webrtcLinksUpdate();
|
||||
}
|
||||
|
||||
function onsharedel() {
|
||||
document.getElementById('shareadd').style.display = '';
|
||||
document.getElementById('shareget').style.display = 'none';
|
||||
document.getElementById('sharedel').style.display = 'none';
|
||||
}
|
||||
|
||||
function copyTextToClipboard(text) {
|
||||
// https://web.dev/patterns/clipboard/copy-text
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).catch(err => {
|
||||
console.error(err.name, err.message);
|
||||
});
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch (err) {
|
||||
console.error(err.name, err.message);
|
||||
}
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('shareadd').addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
share('POST').then(r => r.json()).then(r => onshareadd(r));
|
||||
});
|
||||
|
||||
document.getElementById('shareget').addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
copyTextToClipboard(ev.target.href);
|
||||
});
|
||||
|
||||
document.getElementById('sharedel').addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
share('DELETE').then(() => onsharedel());
|
||||
});
|
||||
|
||||
document.getElementById('webrtc').addEventListener('click', ev => {
|
||||
if (ev.target.tagName === 'INPUT') webrtcLinksUpdate();
|
||||
});
|
||||
|
||||
share('GET').then(r => {
|
||||
if (r.ok) r.json().then(r => onshareadd(r));
|
||||
else onsharedel();
|
||||
});
|
||||
|
||||
webrtcLinksUpdate();
|
||||
</script>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>log - go2rtc</title>
|
||||
<style>
|
||||
main > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
table tbody {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.trace {
|
||||
color: #585858 !important;
|
||||
}
|
||||
|
||||
.debug {
|
||||
color: #808080 !important;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #0174DF !important;
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: #FF9966 !important;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #DF0101 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<script src="main.js"></script>
|
||||
|
||||
<main>
|
||||
<div>
|
||||
<button id="clean">Clean</button>
|
||||
<button id="update">Auto Update: ON</button>
|
||||
<button id="reverse">Reverse Log Order: OFF</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 100px">Time</th>
|
||||
<th style="width: 40px">Level</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="log">
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
document.getElementById('clean').addEventListener('click', async () => {
|
||||
const r = await fetch('api/log', {method: 'DELETE'});
|
||||
if (r.ok) reload();
|
||||
alert(await r.text());
|
||||
});
|
||||
|
||||
// Sanitizes the input text to prevent XSS when inserting into the DOM
|
||||
function escapeHTML(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
const reverseBtn = document.getElementById('reverse');
|
||||
const update = document.getElementById('update');
|
||||
|
||||
let reverseOrder = false;
|
||||
let autoUpdateEnabled = true;
|
||||
|
||||
reverseBtn.textContent = `Reverse Log Order: ${reverseOrder ? 'ON' : 'OFF'}`;
|
||||
update.textContent = `Auto Update: ${autoUpdateEnabled ? 'ON' : 'OFF'}`;
|
||||
|
||||
function applyLogStyling(jsonlines) {
|
||||
const KEYS = ['time', 'level', 'message'];
|
||||
let lines = JSON.parse('[' + jsonlines.trimEnd().replaceAll('\n', ',') + ']');
|
||||
if (reverseOrder) {
|
||||
lines = lines.reverse();
|
||||
}
|
||||
return lines.map(line => {
|
||||
const ts = new Date(line['time']).toLocaleString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
fractionalSecondDigits: 3
|
||||
});
|
||||
const msg = Object.keys(line).reduce((msg, key) => {
|
||||
return KEYS.indexOf(key) < 0 ? `${msg} ${key}=${line[key]}` : msg;
|
||||
}, line['message']);
|
||||
return `<tr class="${line['level']}"><td>${ts}</td><td>${line['level']}</td><td>${escapeHTML(msg)}</td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function reload() {
|
||||
const url = new URL('api/log', location.href);
|
||||
fetch(url, {cache: 'no-cache'})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
// Apply styling to the log data
|
||||
document.getElementById('log').innerHTML = applyLogStyling(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('An error occurred:', error);
|
||||
});
|
||||
}
|
||||
|
||||
reload();
|
||||
|
||||
update.textContent = `Auto Update: ${autoUpdateEnabled ? 'ON' : 'OFF'}`;
|
||||
update.addEventListener('click', () => {
|
||||
autoUpdateEnabled = !autoUpdateEnabled;
|
||||
update.textContent = `Auto Update: ${autoUpdateEnabled ? 'ON' : 'OFF'}`;
|
||||
});
|
||||
|
||||
// Toggle log order
|
||||
reverseBtn.textContent = `Reverse Log Order: ${reverseOrder ? 'ON' : 'OFF'}`;
|
||||
reverseBtn.addEventListener('click', () => {
|
||||
reverseOrder = !reverseOrder;
|
||||
reverseBtn.textContent = `Reverse Log Order: ${reverseOrder ? 'ON' : 'OFF'}`;
|
||||
reload(); // Reload logs to apply the new order
|
||||
});
|
||||
|
||||
// Reload the logs every 5 seconds
|
||||
setInterval(() => {
|
||||
if (autoUpdateEnabled) reload();
|
||||
}, 5000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,135 @@
|
||||
document.head.innerHTML += `
|
||||
<style>
|
||||
body {
|
||||
background-color: white; /* fix Hass black theme */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* navigation block */
|
||||
nav {
|
||||
background-color: #333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
nav a {
|
||||
float: left;
|
||||
display: block;
|
||||
color: #f2f2f2;
|
||||
text-align: center;
|
||||
padding: 14px 16px;
|
||||
text-decoration: none;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
background-color: #ddd;
|
||||
color: black;
|
||||
}
|
||||
|
||||
/* main block */
|
||||
main {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* checkbox */
|
||||
label {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* form */
|
||||
form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="email"], input[type="password"], select {
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* table */
|
||||
table {
|
||||
width: 100%;
|
||||
background-color: white;
|
||||
border-collapse: collapse;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #edf7ff;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* table on mobile */
|
||||
@media (max-width: 480px) {
|
||||
table, thead, tbody, th, td, tr {
|
||||
display: block;
|
||||
}
|
||||
|
||||
th, td {
|
||||
box-sizing: border-box;
|
||||
width: 100% !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
tr {
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.body.innerHTML = `
|
||||
<header>
|
||||
<nav>
|
||||
<a href="index.html"><b>go2rtc</b></a>
|
||||
<a href="add.html">add</a>
|
||||
<a href="config.html">config</a>
|
||||
<a href="log.html">log</a>
|
||||
<a href="net.html">net</a>
|
||||
</nav>
|
||||
</header>
|
||||
` + document.body.innerHTML;
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>net - go2rtc</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vis-network@10.0.2/standalone/umd/vis-network.min.js"></script>
|
||||
<style>
|
||||
html, body, #network {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<script src="main.js"></script>
|
||||
|
||||
<div id="network"></div>
|
||||
|
||||
<script>
|
||||
/* global vis */
|
||||
window.addEventListener('load', () => {
|
||||
const url = new URL('api/streams.dot' + location.search, location.href);
|
||||
|
||||
const container = document.getElementById('network');
|
||||
const options = {
|
||||
edges: {
|
||||
font: {align: 'middle'},
|
||||
smooth: false,
|
||||
},
|
||||
nodes: {shape: 'box'},
|
||||
physics: false,
|
||||
};
|
||||
|
||||
let network;
|
||||
|
||||
async function update() {
|
||||
try {
|
||||
const response = await fetch(url, {cache: 'no-cache'});
|
||||
const dotData = await response.text();
|
||||
const data = vis.parseDOTNetwork(dotData);
|
||||
|
||||
if (!network) {
|
||||
network = new vis.Network(container, data, options);
|
||||
network.storePositions();
|
||||
} else {
|
||||
const positions = network.getPositions();
|
||||
const viewPosition = network.getViewPosition();
|
||||
const scale = network.getScale();
|
||||
const selectedNodes = network.getSelectedNodes();
|
||||
|
||||
network.setData(data);
|
||||
|
||||
for (const nodeId in positions) {
|
||||
network.moveNode(nodeId, positions[nodeId].x, positions[nodeId].y);
|
||||
}
|
||||
|
||||
network.moveTo({position: viewPosition, scale: scale});
|
||||
|
||||
network.selectNodes(selectedNodes);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching or updating network data:', error);
|
||||
}
|
||||
|
||||
setTimeout(update, 5000);
|
||||
}
|
||||
|
||||
update();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,750 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "go2rtc",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"listen": {
|
||||
"type": "string",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": ":[0-9]{1,5}$"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"log_level": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"trace",
|
||||
"debug",
|
||||
"info",
|
||||
"warn",
|
||||
"error",
|
||||
"fatal",
|
||||
"panic",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"rtsp://username:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif",
|
||||
"rtsp://username:password@192.168.1.123/stream1",
|
||||
"rtsp://username:password@192.168.1.123/h264Preview_01_main",
|
||||
"rtmp://192.168.1.123/bcs/channel0_main.bcs?channel=0&stream=0&user=username&password=password",
|
||||
"http://192.168.1.123/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password",
|
||||
"http://username:password@192.168.1.123/cgi-bin/snapshot.cgi?channel=1",
|
||||
"ffmpeg:media.mp4#video=h264#hardware#width=1920#height=1080#rotate=180#audio=copy",
|
||||
"ffmpeg:virtual?video=testsrc&size=4K#video=h264#hardware#bitrate=50M",
|
||||
"exec:ffmpeg -re -i media.mp4 -c copy -rtsp_transport tcp -f rtsp {output}",
|
||||
"onvif://username:password@192.168.1.123:80?subtype=0",
|
||||
"tapo://password@192.168.1.123:8800?channel=0&subtype=0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"api": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"listen": {
|
||||
"type": "string",
|
||||
"default": ":1984",
|
||||
"examples": [
|
||||
"127.0.0.1:1984"
|
||||
]
|
||||
},
|
||||
"username": {
|
||||
"description": "Basic auth for WebUI",
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"admin"
|
||||
]
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"local_auth": {
|
||||
"description": "Enable auth check for localhost requests",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"base_path": {
|
||||
"description": "API prefix for serving on suburl (/api => /rtc/api)",
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"/rtc"
|
||||
]
|
||||
},
|
||||
"static_dir": {
|
||||
"description": "Folder for static files (custom web interface)",
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"www"
|
||||
]
|
||||
},
|
||||
"origin": {
|
||||
"description": "Allow CORS requests (only * supported)",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"*",
|
||||
""
|
||||
]
|
||||
},
|
||||
"tls_listen": {
|
||||
"type": "string"
|
||||
},
|
||||
"tls_cert": {
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"-----BEGIN CERTIFICATE-----",
|
||||
"/ssl/fullchain.pem"
|
||||
]
|
||||
},
|
||||
"tls_key": {
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
"/ssl/privkey.pem"
|
||||
]
|
||||
},
|
||||
"unix_listen": {
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"/tmp/go2rtc.sock"
|
||||
]
|
||||
},
|
||||
"allow_paths": {
|
||||
"description": "Allow only these HTTP paths (full paths, including base_path)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": [
|
||||
[
|
||||
"/api",
|
||||
"/api/streams",
|
||||
"/api/webrtc"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modules": {
|
||||
"description": "Enable only these modules (empty / omitted means all)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"api",
|
||||
"ws",
|
||||
"http",
|
||||
"rtsp",
|
||||
"webrtc",
|
||||
"mp4",
|
||||
"hls",
|
||||
"mjpeg",
|
||||
"hass",
|
||||
"homekit",
|
||||
"onvif",
|
||||
"rtmp",
|
||||
"webtorrent",
|
||||
"wyoming",
|
||||
"echo",
|
||||
"exec",
|
||||
"expr",
|
||||
"ffmpeg",
|
||||
"alsa",
|
||||
"v4l2",
|
||||
"bubble",
|
||||
"doorbird",
|
||||
"dvrip",
|
||||
"eseecloud",
|
||||
"flussonic",
|
||||
"gopro",
|
||||
"isapi",
|
||||
"ivideon",
|
||||
"kasa",
|
||||
"mpeg",
|
||||
"nest",
|
||||
"ring",
|
||||
"roborock",
|
||||
"tapo",
|
||||
"tuya",
|
||||
"xiaomi",
|
||||
"yandex",
|
||||
"debug",
|
||||
"ngrok",
|
||||
"pinggy",
|
||||
"srtp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"description": "Config variables that can be referenced as ${NAME} / ${NAME:default}",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"echo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow_paths": {
|
||||
"description": "Allow only these binaries for echo: URLs (exact cmd name/path)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exec": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow_paths": {
|
||||
"description": "Allow only these binaries for exec: URLs (exact cmd name/path)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": [
|
||||
[
|
||||
"ffmpeg",
|
||||
"/usr/bin/ffmpeg"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bin": {
|
||||
"type": "string",
|
||||
"default": "ffmpeg"
|
||||
},
|
||||
"global": {
|
||||
"type": "string",
|
||||
"default": "-hide_banner"
|
||||
},
|
||||
"file": {
|
||||
"type": "string",
|
||||
"default": "-re -i {input}"
|
||||
},
|
||||
"http": {
|
||||
"type": "string",
|
||||
"default": "-fflags nobuffer -flags low_delay -i {input}"
|
||||
},
|
||||
"rtsp": {
|
||||
"type": "string",
|
||||
"default": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i {input}"
|
||||
},
|
||||
"rtsp/udp": {
|
||||
"type": "string",
|
||||
"default": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i {input}"
|
||||
}
|
||||
},
|
||||
"additionalProperties": {
|
||||
"description": "FFmpeg template",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"hass": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
"description": "Home Assistant config directory path",
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"/config"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"homekit": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"properties": {
|
||||
"pin": {
|
||||
"description": "HomeKit pairing PIN",
|
||||
"type": "string",
|
||||
"default": "19550224",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]{8}$"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{3}$"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"device_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"device_private": {
|
||||
"type": "string"
|
||||
},
|
||||
"category_id": {
|
||||
"description": "Accessory category: `bridge`, `doorbell` or numeric ID",
|
||||
"type": "string",
|
||||
"default": "camera",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bridge",
|
||||
"camera",
|
||||
"doorbell"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+$"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"pairings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"log": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"format": {
|
||||
"description": "Log format: color/json/text or empty for autodetect",
|
||||
"type": "string",
|
||||
"default": "color",
|
||||
"enum": [
|
||||
"",
|
||||
"color",
|
||||
"json",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"level": {
|
||||
"description": "Defaul log level",
|
||||
"default": "info",
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"output": {
|
||||
"description": "Log output: stdout/stderr/file[:path] or empty (memory only)",
|
||||
"type": "string",
|
||||
"default": "stdout",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"",
|
||||
"stdout",
|
||||
"stderr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^file(:.+)?$",
|
||||
"examples": [
|
||||
"file",
|
||||
"file:go2rtc.log"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"type": "string",
|
||||
"default": "UNIXMS",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"",
|
||||
"UNIXMS",
|
||||
"UNIXMICRO",
|
||||
"UNIXNANO",
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02T15:04:05.999999999Z07:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"api": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"echo": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"exec": {
|
||||
"description": "Value `exec: debug` will print stderr",
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"expr": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"description": "Will only be displayed with `exec: debug` setting",
|
||||
"default": "error",
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"hass": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"hls": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"homekit": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"mjpeg": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"mp4": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"ngrok": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"onvif": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"rtmp": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"rtsp": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"streams": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"webrtc": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"webtorrent": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
},
|
||||
"wyoming": {
|
||||
"$ref": "#/definitions/log_level"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ngrok": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"ngrok tcp 8555 --authtoken xxx",
|
||||
"ngrok start --all --config ngrok.yaml"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"pinggy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tunnel": {
|
||||
"description": "Expose local address via Pinggy",
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"http://127.0.0.1:1984",
|
||||
"tcp://192.168.1.123:554"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"preload": {
|
||||
"description": "Preload streams on startup (map stream name => probe query, default `video&audio`)",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"video&audio",
|
||||
"video"
|
||||
]
|
||||
}
|
||||
},
|
||||
"publish": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"rtmp://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx",
|
||||
"rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"rtmp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"listen": {
|
||||
"type": "string",
|
||||
"examples": [
|
||||
":1935"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"rtsp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"listen": {
|
||||
"type": "string",
|
||||
"default": ":8554"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"admin"
|
||||
]
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"default_query": {
|
||||
"type": "string",
|
||||
"default": "video&audio"
|
||||
},
|
||||
"pkt_size": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"srtp": {
|
||||
"description": "SRTP server for HomeKit",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"listen": {
|
||||
"type": "string",
|
||||
"default": ":8443"
|
||||
}
|
||||
}
|
||||
},
|
||||
"streams": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/source"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/source"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"xiaomi": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"webrtc": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"listen": {
|
||||
"type": "string",
|
||||
"default": ":8555",
|
||||
"examples": [
|
||||
":8555/udp"
|
||||
]
|
||||
},
|
||||
"candidates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"216.58.210.174:8555",
|
||||
"stun:8555",
|
||||
"home.duckdns.org:8555"
|
||||
]
|
||||
}
|
||||
},
|
||||
"ice_servers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"stun:stun.l.google.com:19302",
|
||||
"turn:123.123.123.123:3478"
|
||||
]
|
||||
}
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"credential": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"candidates": {
|
||||
"description": "Keep only these candidates",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"interfaces": {
|
||||
"description": "Keep only these interfaces",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ips": {
|
||||
"description": "Keep only these IP-addresses",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"networks": {
|
||||
"description": "Use only these network types",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"tcp4",
|
||||
"tcp6",
|
||||
"udp4",
|
||||
"udp6"
|
||||
]
|
||||
}
|
||||
},
|
||||
"udp_ports": {
|
||||
"description": "Use only these UDP ports range [min, max]",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"maxItems": 2,
|
||||
"minItems": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"webtorrent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"trackers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"shares": {
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pwd": {
|
||||
"type": "string",
|
||||
"minLength": 4
|
||||
},
|
||||
"src": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"wyoming": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"listen": {
|
||||
"description": "Listen address for Wyoming server",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Optional satellite name (default: stream name)",
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"description": "Optional mode: mic / snd / default",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"",
|
||||
"mic",
|
||||
"snd"
|
||||
]
|
||||
},
|
||||
"event": {
|
||||
"description": "Event handlers (map event type => expr script)",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"wake_uri": {
|
||||
"description": "Optional WAKE service URI (ex. tcp://host:port?name=...)",
|
||||
"type": "string",
|
||||
"examples": [
|
||||
"tcp://192.168.1.23:10400"
|
||||
]
|
||||
},
|
||||
"vad_threshold": {
|
||||
"description": "Optional VAD threshold (0.1..3.5 typical)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package www
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.html
|
||||
//go:embed *.js
|
||||
//go:embed *.json
|
||||
var Static embed.FS
|
||||
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="apple-touch-icon" href="https://go2rtc.org/icons/apple-touch-icon-180x180.png" sizes="180x180">
|
||||
<link rel="icon" href="https://go2rtc.org/icons/favicon.ico">
|
||||
<link rel="manifest" href="https://go2rtc.org/manifest.json">
|
||||
<title>stream - go2rtc</title>
|
||||
<style>
|
||||
body {
|
||||
background: black;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex {
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./video-stream.js"></script>
|
||||
<script type="module">
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
// support multiple streams and multiple modes
|
||||
const streams = params.getAll('src');
|
||||
const modes = params.getAll('mode');
|
||||
if (modes.length === 0) modes.push('');
|
||||
|
||||
while (modes.length > streams.length) {
|
||||
streams.push(streams[0]);
|
||||
}
|
||||
while (streams.length > modes.length) {
|
||||
modes.push(modes[0]);
|
||||
}
|
||||
|
||||
if (streams.length > 1) {
|
||||
document.body.className = 'flex';
|
||||
}
|
||||
|
||||
const background = params.get('background') !== 'false';
|
||||
const width = '1 0 ' + (params.get('width') || '320px');
|
||||
|
||||
for (let i = 0; i < streams.length; i++) {
|
||||
/** @type {VideoStream} */
|
||||
const video = document.createElement('video-stream');
|
||||
video.background = background;
|
||||
video.mode = modes[i] || video.mode;
|
||||
video.style.flex = width;
|
||||
video.src = new URL('api/ws?src=' + encodeURIComponent(streams[i]), location.href);
|
||||
document.body.appendChild(video);
|
||||
}
|
||||
|
||||
document.title = streams.join('/') + ' - go2rtc';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* VideoRTC v1.6.0 - Video player for go2rtc streaming application.
|
||||
*
|
||||
* All modern web technologies are supported in almost any browser except Apple Safari.
|
||||
*
|
||||
* Support:
|
||||
* - ECMAScript 2017 (ES8) = ES6 + async
|
||||
* - RTCPeerConnection for Safari iOS 11.0+
|
||||
* - IntersectionObserver for Safari iOS 12.2+
|
||||
* - ManagedMediaSource for Safari 17+
|
||||
*
|
||||
* Doesn't support:
|
||||
* - MediaSource for Safari iOS
|
||||
* - Customized built-in elements (extends HTMLVideoElement) because Safari
|
||||
* - Autoplay for WebRTC in Safari
|
||||
*/
|
||||
export class VideoRTC extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.DISCONNECT_TIMEOUT = 5000;
|
||||
this.RECONNECT_TIMEOUT = 15000;
|
||||
|
||||
this.CODECS = [
|
||||
'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
||||
'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen)
|
||||
'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV)
|
||||
'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra)
|
||||
'mp4a.40.2', // AAC LC
|
||||
'mp4a.40.5', // AAC HE
|
||||
'flac', // FLAC (PCM compatible)
|
||||
'opus', // OPUS Chrome, Firefox
|
||||
];
|
||||
|
||||
/**
|
||||
* [config] Supported modes (webrtc, webrtc/tcp, mse, hls, mp4, mjpeg).
|
||||
* @type {string}
|
||||
*/
|
||||
this.mode = 'webrtc,mse,hls,mjpeg';
|
||||
|
||||
/**
|
||||
* [Config] Requested medias (video, audio, microphone).
|
||||
* @type {string}
|
||||
*/
|
||||
this.media = 'video,audio';
|
||||
|
||||
/**
|
||||
* [config] Run stream when not displayed on the screen. Default `false`.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.background = false;
|
||||
|
||||
/**
|
||||
* [config] Run stream only when player in the viewport. Stop when user scroll out player.
|
||||
* Value is percentage of visibility from `0` (not visible) to `1` (full visible).
|
||||
* Default `0` - disable;
|
||||
* @type {number}
|
||||
*/
|
||||
this.visibilityThreshold = 0;
|
||||
|
||||
/**
|
||||
* [config] Run stream only when browser page on the screen. Stop when user change browser
|
||||
* tab or minimise browser windows.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.visibilityCheck = true;
|
||||
|
||||
/**
|
||||
* [config] WebRTC configuration
|
||||
* @type {RTCConfiguration}
|
||||
*/
|
||||
this.pcConfig = {
|
||||
bundlePolicy: 'max-bundle',
|
||||
iceServers: [{urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302']}],
|
||||
sdpSemantics: 'unified-plan', // important for Chromecast 1
|
||||
};
|
||||
|
||||
/**
|
||||
* [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
|
||||
* @type {number}
|
||||
*/
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* [info] WebRTC connection state.
|
||||
* @type {number}
|
||||
*/
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* @type {HTMLVideoElement}
|
||||
*/
|
||||
this.video = null;
|
||||
|
||||
/**
|
||||
* @type {WebSocket}
|
||||
*/
|
||||
this.ws = null;
|
||||
|
||||
/**
|
||||
* @type {string|URL}
|
||||
*/
|
||||
this.wsURL = '';
|
||||
|
||||
/**
|
||||
* @type {RTCPeerConnection}
|
||||
*/
|
||||
this.pc = null;
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
this.connectTS = 0;
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
this.mseCodecs = '';
|
||||
|
||||
/**
|
||||
* [internal] Disconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.disconnectTID = 0;
|
||||
|
||||
/**
|
||||
* [internal] Reconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.reconnectTID = 0;
|
||||
|
||||
/**
|
||||
* [internal] Handler for receiving Binary from WebSocket.
|
||||
* @type {Function}
|
||||
*/
|
||||
this.ondata = null;
|
||||
|
||||
/**
|
||||
* [internal] Handlers list for receiving JSON from WebSocket.
|
||||
* @type {Object.<string,Function>}
|
||||
*/
|
||||
this.onmessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set video source (WebSocket URL). Support relative path.
|
||||
* @param {string|URL} value
|
||||
*/
|
||||
set src(value) {
|
||||
if (typeof value !== 'string') value = value.toString();
|
||||
if (value.startsWith('http')) {
|
||||
value = 'ws' + value.substring(4);
|
||||
} else if (value.startsWith('/')) {
|
||||
value = 'ws' + location.origin.substring(4) + value;
|
||||
}
|
||||
|
||||
this.wsURL = value;
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Play video. Support automute when autoplay blocked.
|
||||
* https://developer.chrome.com/blog/autoplay/
|
||||
*/
|
||||
play() {
|
||||
this.video.play().catch(() => {
|
||||
if (!this.video.muted) {
|
||||
this.video.muted = true;
|
||||
this.video.play().catch(er => {
|
||||
console.warn(er);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to server via WebSocket
|
||||
* @param {Object} value
|
||||
*/
|
||||
send(value) {
|
||||
if (this.ws) this.ws.send(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** @param {Function} isSupported */
|
||||
codecs(isSupported) {
|
||||
return this.CODECS
|
||||
.filter(codec => this.media.includes(codec.includes('vc1') ? 'video' : 'audio'))
|
||||
.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
|
||||
}
|
||||
|
||||
/**
|
||||
* `CustomElement`. Invoked each time the custom element is appended into a
|
||||
* document-connected element.
|
||||
*/
|
||||
connectedCallback() {
|
||||
if (this.disconnectTID) {
|
||||
clearTimeout(this.disconnectTID);
|
||||
this.disconnectTID = 0;
|
||||
}
|
||||
|
||||
// because video autopause on disconnected from DOM
|
||||
if (this.video) {
|
||||
const seek = this.video.seekable;
|
||||
if (seek.length > 0) {
|
||||
this.video.currentTime = seek.end(seek.length - 1);
|
||||
}
|
||||
this.play();
|
||||
} else {
|
||||
this.oninit();
|
||||
}
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* `CustomElement`. Invoked each time the custom element is disconnected from the
|
||||
* document's DOM.
|
||||
*/
|
||||
disconnectedCallback() {
|
||||
if (this.background || this.disconnectTID) return;
|
||||
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
|
||||
|
||||
this.disconnectTID = setTimeout(() => {
|
||||
if (this.reconnectTID) {
|
||||
clearTimeout(this.reconnectTID);
|
||||
this.reconnectTID = 0;
|
||||
}
|
||||
|
||||
this.disconnectTID = 0;
|
||||
|
||||
this.ondisconnect();
|
||||
}, this.DISCONNECT_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates child DOM elements. Called automatically once on `connectedCallback`.
|
||||
*/
|
||||
oninit() {
|
||||
this.video = document.createElement('video');
|
||||
this.video.controls = true;
|
||||
this.video.playsInline = true;
|
||||
this.video.preload = 'auto';
|
||||
|
||||
this.video.style.display = 'block'; // fix bottom margin 4px
|
||||
this.video.style.width = '100%';
|
||||
this.video.style.height = '100%';
|
||||
|
||||
this.appendChild(this.video);
|
||||
|
||||
this.video.addEventListener('error', ev => {
|
||||
const err = this.video.error;
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code
|
||||
const MEDIA_ERRORS = {
|
||||
1: 'MEDIA_ERR_ABORTED',
|
||||
2: 'MEDIA_ERR_NETWORK',
|
||||
3: 'MEDIA_ERR_DECODE',
|
||||
4: 'MEDIA_ERR_SRC_NOT_SUPPORTED'
|
||||
};
|
||||
console.error('[VideoRTC] Video error:', {
|
||||
error: err ? MEDIA_ERRORS[err.code] : 'unknown',
|
||||
message: err ? err.message : 'unknown',
|
||||
codecs: this.mseCodecs || 'not set',
|
||||
readyState: this.video.readyState,
|
||||
networkState: this.video.networkState,
|
||||
currentTime: this.video.currentTime
|
||||
});
|
||||
if (this.ws) this.ws.close(); // run reconnect for broken MSE stream
|
||||
});
|
||||
|
||||
// all Safari lies about supported audio codecs
|
||||
const m = window.navigator.userAgent.match(/Version\/(\d+).+Safari/);
|
||||
if (m) {
|
||||
// AAC from v13, FLAC from v14, OPUS - unsupported
|
||||
const skip = m[1] < '13' ? 'mp4a.40.2' : m[1] < '14' ? 'flac' : 'opus';
|
||||
this.CODECS.splice(this.CODECS.indexOf(skip));
|
||||
}
|
||||
|
||||
if (this.background) return;
|
||||
|
||||
if ('hidden' in document && this.visibilityCheck) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ('IntersectionObserver' in window && this.visibilityThreshold) {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (!entry.isIntersecting) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
}, {threshold: this.visibilityThreshold});
|
||||
observer.observe(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket. Called automatically on `connectedCallback`.
|
||||
* @return {boolean} true if the connection has started.
|
||||
*/
|
||||
onconnect() {
|
||||
if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
|
||||
|
||||
// CLOSED or CONNECTING => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
|
||||
this.connectTS = Date.now();
|
||||
|
||||
this.ws = new WebSocket(this.wsURL);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
this.ws.addEventListener('open', () => this.onopen());
|
||||
this.ws.addEventListener('close', () => this.onclose());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
ondisconnect() {
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
if (this.pc) {
|
||||
this.pc.getSenders().forEach(sender => {
|
||||
if (sender.track) sender.track.stop();
|
||||
});
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
|
||||
this.video.src = '';
|
||||
this.video.srcObject = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array.<string>} of modes (mse, webrtc, etc.)
|
||||
*/
|
||||
onopen() {
|
||||
// CONNECTING => OPEN
|
||||
this.wsState = WebSocket.OPEN;
|
||||
|
||||
this.ws.addEventListener('message', ev => {
|
||||
if (typeof ev.data === 'string') {
|
||||
const msg = JSON.parse(ev.data);
|
||||
for (const mode in this.onmessage) {
|
||||
this.onmessage[mode](msg);
|
||||
}
|
||||
} else {
|
||||
this.ondata(ev.data);
|
||||
}
|
||||
});
|
||||
|
||||
this.ondata = null;
|
||||
this.onmessage = {};
|
||||
|
||||
const modes = [];
|
||||
|
||||
if (this.mode.includes('mse') && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
|
||||
modes.push('mse');
|
||||
this.onmse();
|
||||
} else if (this.mode.includes('hls') && this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
modes.push('hls');
|
||||
this.onhls();
|
||||
} else if (this.mode.includes('mp4')) {
|
||||
modes.push('mp4');
|
||||
this.onmp4();
|
||||
}
|
||||
|
||||
if (this.mode.includes('webrtc') && 'RTCPeerConnection' in window) {
|
||||
modes.push('webrtc');
|
||||
this.onwebrtc();
|
||||
}
|
||||
|
||||
if (this.mode.includes('mjpeg')) {
|
||||
if (modes.length) {
|
||||
this.onmessage['mjpeg'] = msg => {
|
||||
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
|
||||
this.onmjpeg();
|
||||
};
|
||||
} else {
|
||||
modes.push('mjpeg');
|
||||
this.onmjpeg();
|
||||
}
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} true if reconnection has started.
|
||||
*/
|
||||
onclose() {
|
||||
if (this.wsState === WebSocket.CLOSED) return false;
|
||||
|
||||
// CONNECTING, OPEN => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
this.ws = null;
|
||||
|
||||
// reconnect no more than once every X seconds
|
||||
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
|
||||
|
||||
this.reconnectTID = setTimeout(() => {
|
||||
this.reconnectTID = 0;
|
||||
this.onconnect();
|
||||
}, delay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onmse() {
|
||||
/** @type {MediaSource} */
|
||||
let ms;
|
||||
|
||||
if ('ManagedMediaSource' in window) {
|
||||
const MediaSource = window.ManagedMediaSource;
|
||||
|
||||
ms = new MediaSource();
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||
}, {once: true});
|
||||
|
||||
this.video.disableRemotePlayback = true;
|
||||
this.video.srcObject = ms;
|
||||
} else {
|
||||
ms = new MediaSource();
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
URL.revokeObjectURL(this.video.src);
|
||||
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||
}, {once: true});
|
||||
|
||||
this.video.src = URL.createObjectURL(ms);
|
||||
this.video.srcObject = null;
|
||||
}
|
||||
|
||||
this.play();
|
||||
|
||||
this.mseCodecs = '';
|
||||
|
||||
this.onmessage['mse'] = msg => {
|
||||
if (msg.type !== 'mse') return;
|
||||
|
||||
this.mseCodecs = msg.value;
|
||||
|
||||
const sb = ms.addSourceBuffer(msg.value);
|
||||
sb.mode = 'segments'; // segments or sequence
|
||||
sb.addEventListener('updateend', () => {
|
||||
if (!sb.updating && bufLen > 0) {
|
||||
try {
|
||||
const data = buf.slice(0, bufLen);
|
||||
sb.appendBuffer(data);
|
||||
bufLen = 0;
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sb.updating && sb.buffered && sb.buffered.length) {
|
||||
const end = sb.buffered.end(sb.buffered.length - 1);
|
||||
const start = end - 5;
|
||||
const start0 = sb.buffered.start(0);
|
||||
if (start > start0) {
|
||||
sb.remove(start0, start);
|
||||
ms.setLiveSeekableRange(start, end);
|
||||
}
|
||||
if (this.video.currentTime < start) {
|
||||
this.video.currentTime = start;
|
||||
}
|
||||
const gap = end - this.video.currentTime;
|
||||
this.video.playbackRate = gap > 0.1 ? gap : 0.1;
|
||||
// console.debug('VideoRTC.buffered', gap, this.video.playbackRate, this.video.readyState);
|
||||
}
|
||||
});
|
||||
|
||||
const buf = new Uint8Array(2 * 1024 * 1024);
|
||||
let bufLen = 0;
|
||||
|
||||
this.ondata = data => {
|
||||
if (sb.updating || bufLen > 0) {
|
||||
const b = new Uint8Array(data);
|
||||
buf.set(b, bufLen);
|
||||
bufLen += b.byteLength;
|
||||
// console.debug('VideoRTC.buffer', b.byteLength, bufLen);
|
||||
} else {
|
||||
try {
|
||||
sb.appendBuffer(data);
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
onwebrtc() {
|
||||
const pc = new RTCPeerConnection(this.pcConfig);
|
||||
|
||||
pc.addEventListener('icecandidate', ev => {
|
||||
if (ev.candidate && this.mode.includes('webrtc/tcp') && ev.candidate.protocol === 'udp') return;
|
||||
|
||||
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
|
||||
this.send({type: 'webrtc/candidate', value: candidate});
|
||||
});
|
||||
|
||||
pc.addEventListener('connectionstatechange', () => {
|
||||
if (pc.connectionState === 'connected') {
|
||||
const tracks = pc.getTransceivers()
|
||||
.filter(tr => tr.currentDirection === 'recvonly') // skip inactive
|
||||
.map(tr => tr.receiver.track);
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement('video');
|
||||
video2.addEventListener('loadeddata', () => this.onpcvideo(video2), {once: true});
|
||||
video2.srcObject = new MediaStream(tracks);
|
||||
} else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||
pc.close(); // stop next events
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
this.pc = null;
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.onmessage['webrtc'] = msg => {
|
||||
switch (msg.type) {
|
||||
case 'webrtc/candidate':
|
||||
if (this.mode.includes('webrtc/tcp') && msg.value.includes(' udp ')) return;
|
||||
|
||||
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
|
||||
console.warn(er);
|
||||
});
|
||||
break;
|
||||
case 'webrtc/answer':
|
||||
pc.setRemoteDescription({type: 'answer', sdp: msg.value}).catch(er => {
|
||||
console.warn(er);
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
if (!msg.value.includes('webrtc/offer')) return;
|
||||
pc.close();
|
||||
}
|
||||
};
|
||||
|
||||
this.createOffer(pc).then(offer => {
|
||||
this.send({type: 'webrtc/offer', value: offer.sdp});
|
||||
});
|
||||
|
||||
this.pcState = WebSocket.CONNECTING;
|
||||
this.pc = pc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param pc {RTCPeerConnection}
|
||||
* @return {Promise<RTCSessionDescriptionInit>}
|
||||
*/
|
||||
async createOffer(pc) {
|
||||
try {
|
||||
if (this.media.includes('microphone')) {
|
||||
const media = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||
media.getTracks().forEach(track => {
|
||||
pc.addTransceiver(track, {direction: 'sendonly'});
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
||||
for (const kind of ['video', 'audio']) {
|
||||
if (this.media.includes(kind)) {
|
||||
pc.addTransceiver(kind, {direction: 'recvonly'});
|
||||
}
|
||||
}
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
return offer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param video2 {HTMLVideoElement}
|
||||
*/
|
||||
onpcvideo(video2) {
|
||||
if (this.pc) {
|
||||
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
|
||||
let rtcPriority = 0, msePriority = 0;
|
||||
|
||||
/** @type {MediaStream} */
|
||||
const stream = video2.srcObject;
|
||||
if (stream.getVideoTracks().length > 0) {
|
||||
// not the best, but a pretty simple way to check a codec
|
||||
const isH265Supported = this.pc.remoteDescription.sdp.includes('H265/90000');
|
||||
rtcPriority += isH265Supported ? 0x240 : 0x220;
|
||||
}
|
||||
if (stream.getAudioTracks().length > 0) rtcPriority += 0x102;
|
||||
|
||||
if (this.mseCodecs.includes('hvc1.')) msePriority += 0x230;
|
||||
if (this.mseCodecs.includes('avc1.')) msePriority += 0x210;
|
||||
if (this.mseCodecs.includes('mp4a.')) msePriority += 0x101;
|
||||
|
||||
if (rtcPriority >= msePriority) {
|
||||
this.video.srcObject = stream;
|
||||
this.play();
|
||||
|
||||
this.pcState = WebSocket.OPEN;
|
||||
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
} else {
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
video2.srcObject = null;
|
||||
}
|
||||
|
||||
onmjpeg() {
|
||||
this.ondata = data => {
|
||||
this.video.controls = false;
|
||||
this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data);
|
||||
};
|
||||
|
||||
this.send({type: 'mjpeg'});
|
||||
}
|
||||
|
||||
onhls() {
|
||||
this.onmessage['hls'] = msg => {
|
||||
if (msg.type !== 'hls') return;
|
||||
|
||||
const url = 'http' + this.wsURL.substring(2, this.wsURL.indexOf('/ws')) + '/hls/';
|
||||
const playlist = msg.value.replace('hls/', url);
|
||||
this.video.src = 'data:application/vnd.apple.mpegurl;base64,' + btoa(playlist);
|
||||
this.play();
|
||||
};
|
||||
|
||||
this.send({type: 'hls', value: this.codecs(type => this.video.canPlayType(type))});
|
||||
}
|
||||
|
||||
onmp4() {
|
||||
/** @type {HTMLCanvasElement} **/
|
||||
const canvas = document.createElement('canvas');
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let context;
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement('video');
|
||||
video2.autoplay = true;
|
||||
video2.playsInline = true;
|
||||
video2.muted = true;
|
||||
|
||||
video2.addEventListener('loadeddata', () => {
|
||||
if (!context) {
|
||||
canvas.width = video2.videoWidth;
|
||||
canvas.height = video2.videoHeight;
|
||||
context = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
this.video.controls = false;
|
||||
this.video.poster = canvas.toDataURL('image/jpeg');
|
||||
});
|
||||
|
||||
this.ondata = data => {
|
||||
video2.src = 'data:video/mp4;base64,' + VideoRTC.btoa(data);
|
||||
};
|
||||
|
||||
this.send({type: 'mp4', value: this.codecs(this.video.canPlayType)});
|
||||
}
|
||||
|
||||
static btoa(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
let binary = '';
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {VideoRTC} from './video-rtc.js';
|
||||
|
||||
/**
|
||||
* This is example, how you can extend VideoRTC player for your app.
|
||||
* Also you can check this example: https://github.com/AlexxIT/WebRTC
|
||||
*/
|
||||
class VideoStream extends VideoRTC {
|
||||
set divMode(value) {
|
||||
this.querySelector('.mode').innerText = value;
|
||||
this.querySelector('.status').innerText = '';
|
||||
}
|
||||
|
||||
set divError(value) {
|
||||
const state = this.querySelector('.mode').innerText;
|
||||
if (state !== 'loading') return;
|
||||
this.querySelector('.mode').innerText = 'error';
|
||||
this.querySelector('.status').innerText = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom GUI
|
||||
*/
|
||||
oninit() {
|
||||
console.debug('stream.oninit');
|
||||
super.oninit();
|
||||
|
||||
this.innerHTML = `
|
||||
<style>
|
||||
video-stream {
|
||||
position: relative;
|
||||
}
|
||||
.info {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<div class="info">
|
||||
<div class="status"></div>
|
||||
<div class="mode"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const info = this.querySelector('.info');
|
||||
this.insertBefore(this.video, info);
|
||||
}
|
||||
|
||||
onconnect() {
|
||||
console.debug('stream.onconnect');
|
||||
const result = super.onconnect();
|
||||
if (result) this.divMode = 'loading';
|
||||
return result;
|
||||
}
|
||||
|
||||
ondisconnect() {
|
||||
console.debug('stream.ondisconnect');
|
||||
super.ondisconnect();
|
||||
}
|
||||
|
||||
onopen() {
|
||||
console.debug('stream.onopen');
|
||||
const result = super.onopen();
|
||||
|
||||
this.onmessage['stream'] = msg => {
|
||||
console.debug('stream.onmessge', msg);
|
||||
switch (msg.type) {
|
||||
case 'error':
|
||||
this.divError = msg.value;
|
||||
break;
|
||||
case 'mse':
|
||||
case 'hls':
|
||||
case 'mp4':
|
||||
case 'mjpeg':
|
||||
this.divMode = msg.type.toUpperCase();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
onclose() {
|
||||
console.debug('stream.onclose');
|
||||
return super.onclose();
|
||||
}
|
||||
|
||||
onpcvideo(ev) {
|
||||
console.debug('stream.onpcvideo');
|
||||
super.onpcvideo(ev);
|
||||
|
||||
if (this.pcState !== WebSocket.CLOSED) {
|
||||
this.divMode = 'RTC';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('video-stream', VideoStream);
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>webrtc - go2rtc</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: black;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, video {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<video id="video" autoplay controls playsinline muted></video>
|
||||
<script>
|
||||
async function PeerConnection(media) {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302']}]
|
||||
});
|
||||
|
||||
document.getElementById('video').srcObject = new MediaStream([
|
||||
pc.addTransceiver('audio', {direction: 'sendrecv'}).receiver.track,
|
||||
pc.addTransceiver('video', {direction: 'sendrecv'}).receiver.track,
|
||||
]);
|
||||
|
||||
const tracks = await navigator.mediaDevices.getUserMedia({
|
||||
video: media.indexOf('camera') >= 0,
|
||||
audio: media.indexOf('microphone') >= 0,
|
||||
});
|
||||
tracks.getTracks().forEach(track => {
|
||||
pc.addTrack(track);
|
||||
});
|
||||
|
||||
return pc;
|
||||
}
|
||||
|
||||
function getCompleteOffer(pc, timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pc.addEventListener('icegatheringstatechange', () => {
|
||||
if (pc.iceGatheringState === 'complete') resolve(pc.localDescription.sdp);
|
||||
});
|
||||
|
||||
pc.createOffer().then(offer => pc.setLocalDescription(offer));
|
||||
|
||||
setTimeout(() => resolve(pc.localDescription.sdp), timeout || 3000);
|
||||
});
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const media = new URLSearchParams(location.search).get('media');
|
||||
const pc = await PeerConnection(media);
|
||||
const url = new URL('api/webrtc' + location.search, location.href);
|
||||
const r = await fetch(url, {method: 'POST', body: await getCompleteOffer(pc)});
|
||||
await pc.setRemoteDescription({type: 'answer', sdp: await r.text()});
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>webrtc - go2rtc</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: black;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, video {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<video id="video" autoplay controls playsinline muted></video>
|
||||
<script>
|
||||
async function PeerConnection(media) {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302']}]
|
||||
});
|
||||
|
||||
const localTracks = [];
|
||||
|
||||
if (/camera|microphone/.test(media)) {
|
||||
const tracks = await getMediaTracks('user', {
|
||||
video: media.indexOf('camera') >= 0,
|
||||
audio: media.indexOf('microphone') >= 0,
|
||||
});
|
||||
tracks.forEach(track => {
|
||||
pc.addTransceiver(track, {direction: 'sendonly'});
|
||||
if (track.kind === 'video') localTracks.push(track);
|
||||
});
|
||||
}
|
||||
|
||||
if (media.indexOf('display') >= 0) {
|
||||
const tracks = await getMediaTracks('display', {
|
||||
video: true,
|
||||
audio: media.indexOf('speaker') >= 0,
|
||||
});
|
||||
tracks.forEach(track => {
|
||||
pc.addTransceiver(track, {direction: 'sendonly'});
|
||||
if (track.kind === 'video') localTracks.push(track);
|
||||
});
|
||||
}
|
||||
|
||||
if (/video|audio/.test(media)) {
|
||||
const tracks = ['video', 'audio']
|
||||
.filter(kind => media.indexOf(kind) >= 0)
|
||||
.map(kind => pc.addTransceiver(kind, {direction: 'recvonly'}).receiver.track);
|
||||
localTracks.push(...tracks);
|
||||
}
|
||||
|
||||
document.getElementById('video').srcObject = new MediaStream(localTracks);
|
||||
|
||||
return pc;
|
||||
}
|
||||
|
||||
async function getMediaTracks(media, constraints) {
|
||||
try {
|
||||
const stream = media === 'user'
|
||||
? await navigator.mediaDevices.getUserMedia(constraints)
|
||||
: await navigator.mediaDevices.getDisplayMedia(constraints);
|
||||
return stream.getTracks();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(media) {
|
||||
const pc = await PeerConnection(media);
|
||||
const url = new URL('api/ws' + location.search, location.href);
|
||||
const ws = new WebSocket('ws' + url.toString().substring(4));
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
pc.addEventListener('icecandidate', ev => {
|
||||
if (!ev.candidate) return;
|
||||
const msg = {type: 'webrtc/candidate', value: ev.candidate.candidate};
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
|
||||
pc.createOffer().then(offer => pc.setLocalDescription(offer)).then(() => {
|
||||
const msg = {type: 'webrtc/offer', value: pc.localDescription.sdp};
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
});
|
||||
|
||||
ws.addEventListener('message', ev => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'webrtc/candidate') {
|
||||
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'});
|
||||
} else if (msg.type === 'webrtc/answer') {
|
||||
pc.setRemoteDescription({type: 'answer', sdp: msg.value});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const media = new URLSearchParams(location.search).get('media');
|
||||
connect(media || 'video+audio');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user