Add ESLinter and fix JS lint problems

This commit is contained in:
Alexey Khit
2023-07-07 22:06:03 +03:00
parent ddfeb6fae6
commit 39cc4610e3
12 changed files with 405 additions and 344 deletions

40
package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"devDependencies": {
"eslint": "^8.44.0",
"eslint-plugin-html": "^7.1.0"
},
"eslintConfig": {
"env": {
"browser": true,
"es6": true
},
"parserOptions": {
"ecmaVersion": 2017,
"sourceType": "module"
},
"rules": {
"no-var": "error",
"no-undef": "error",
"no-unused-vars": "warn",
"prefer-const": "error",
"quotes": [
"error",
"single"
],
"semi": "error"
},
"plugins": [
"html"
],
"overrides": [
{
"files": [
"*.html"
],
"parserOptions": {
"sourceType": "script"
}
}
]
}
}

View File

@@ -1,4 +1,14 @@
# HTML5
## 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/).
## HTML5
**1. Autoplay video tag**

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>Add Stream</title>
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
@@ -59,20 +59,20 @@
<script src="main.js"></script>
<script>
async function getStreams(url, tableID) {
const table = document.getElementById(tableID)
table.innerText = 'loading...'
const table = document.getElementById(tableID);
table.innerText = 'loading...';
const r = typeof url === 'string' ? await fetch(url, {cache: 'no-cache'}) : url
const r = typeof url === 'string' ? await fetch(url, {cache: 'no-cache'}) : url;
if (!r.ok) {
table.innerText = await r.text()
return
table.innerText = await r.text();
return;
}
/** @type {{streams:Array<{name:string,url:string}>}} */
const data = await r.json()
const data = await r.json();
table.innerHTML = data.streams.reduce((html, item) => {
return html + `<tr><td>${item.name}</td><td>${item.url}</td></tr>`
}, '<thead><tr><th>Name</th><th>Source</th></tr></thead><tbody>') + '</tbody>'
return html + `<tr><td>${item.name}</td><td>${item.url}</td></tr>`;
}, '<thead><tr><th>Name</th><th>Source</th></tr></thead><tbody>') + '</tbody>';
}
</script>
@@ -87,19 +87,19 @@
</div>
<script>
document.getElementById('stream').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block'
})
ev.target.nextElementSibling.style.display = 'block';
});
document.getElementById('stream-form').addEventListener('submit', async ev => {
ev.preventDefault()
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 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')
})
const r = await fetch(url, {method: 'PUT'});
alert(r.ok ? 'OK' : 'ERROR');
});
</script>
@@ -124,49 +124,49 @@
</div>
<script>
document.getElementById('homekit').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block'
const r = await fetch('api/homekit', {cache: 'no-cache'})
ev.target.nextElementSibling.style.display = 'block';
const r = await fetch('api/homekit', {cache: 'no-cache'});
/** @type {Array<{id:string,name:string,addr:string,model:string,paired:boolean}>} */
const data = await r.json()
const data = await r.json();
const tbody = document.getElementById('homekit-body')
const tbody = document.getElementById('homekit-body');
tbody.innerHTML =
data.reduce((res, item) => {
let commands = ''
if (item.id === "") {
commands = `<a href="#" onclick="unpair('${item.name}')">unpair</a>`
let commands = '';
if (item.id === '') {
commands = `<a href="#" onclick="unpair('${item.name}')">unpair</a>`;
} else if (item.paired === false) {
commands = `<a href="#" onclick="pair('${item.id}','${item.name}')">pair</a>`
commands = `<a href="#" onclick="pair('${item.id}','${item.name}')">pair</a>`;
}
return res + `<tr>
<td>${item.name}</td>
<td>${item.addr}</td>
<td>${item.model}</td>
<td>${commands}</td>
</tr>`
}, '')
})
</tr>`;
}, '');
});
function pair(id, name) {
const pin = document.querySelector('#pin').value
const pin = document.querySelector('#pin').value;
fetch(`api/homekit?id=${id}&name=${name}&pin=${pin}`, {method: 'POST'})
.then(r => r.text())
.then(data => {
if (data.length > 0) alert(data)
else window.location.reload()
if (data.length > 0) alert(data);
else window.location.reload();
})
.catch(console.error)
.catch(console.error);
}
function unpair(src) {
fetch(`api/homekit?src=${src}`, {method: 'DELETE'})
.then(r => r.text())
.then(data => {
if (data.length > 0) alert(data)
else window.location.reload()
if (data.length > 0) alert(data);
else window.location.reload();
})
.catch(console.error)
.catch(console.error);
}
</script>
@@ -177,9 +177,9 @@
</div>
<script>
document.getElementById('dvrip').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block'
await getStreams('api/dvrip', 'dvrip-table')
})
ev.target.nextElementSibling.style.display = 'block';
await getStreams('api/dvrip', 'dvrip-table');
});
</script>
@@ -190,9 +190,9 @@
</div>
<script>
document.getElementById('devices').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block'
await getStreams('api/ffmpeg/devices', 'devices-table')
})
ev.target.nextElementSibling.style.display = 'block';
await getStreams('api/ffmpeg/devices', 'devices-table');
});
</script>
@@ -203,9 +203,9 @@
</div>
<script>
document.getElementById('hardware').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block'
await getStreams('api/ffmpeg/hardware', 'hardware-table')
})
ev.target.nextElementSibling.style.display = 'block';
await getStreams('api/ffmpeg/hardware', 'hardware-table');
});
</script>
@@ -223,18 +223,18 @@
</div>
<script>
document.getElementById('nest').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block'
})
ev.target.nextElementSibling.style.display = 'block';
});
document.getElementById('nest-form').addEventListener('submit', async ev => {
ev.preventDefault()
ev.preventDefault();
const query = new URLSearchParams(new FormData(ev.target))
const url = new URL('api/nest?' + query.toString(), location.href)
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 getStreams(r, 'nest-table')
})
const r = await fetch(url, {cache: 'no-cache'});
await getStreams(r, 'nest-table');
});
</script>
@@ -244,9 +244,9 @@
</div>
<script>
document.getElementById('hass').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block'
await getStreams('api/hass', 'hass-table')
})
ev.target.nextElementSibling.style.display = 'block';
await getStreams('api/hass', 'hass-table');
});
</script>
@@ -260,18 +260,18 @@
</div>
<script>
document.getElementById('onvif').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block'
await getStreams('api/onvif', 'onvif-table')
})
ev.target.nextElementSibling.style.display = 'block';
await getStreams('api/onvif', 'onvif-table');
});
document.getElementById('onvif-form').addEventListener('submit', async ev => {
ev.preventDefault()
ev.preventDefault();
const url = new URL('api/onvif', location.href)
url.searchParams.set('src', ev.target.elements['src'].value)
const url = new URL('api/onvif', location.href);
url.searchParams.set('src', ev.target.elements['src'].value);
await getStreams(url.toString(), 'onvif-table')
})
await getStreams(url.toString(), 'onvif-table');
});
</script>
@@ -287,15 +287,15 @@
</div>
<script>
document.getElementById('roborock').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block'
await getStreams('api/roborock', 'roborock-table')
})
ev.target.nextElementSibling.style.display = 'block';
await getStreams('api/roborock', 'roborock-table');
});
document.getElementById('roborock-form').addEventListener('submit', async ev => {
ev.preventDefault()
const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)})
await getStreams(r, 'roborock-table')
})
ev.preventDefault();
const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)});
await getStreams(r, 'roborock-table');
});
</script>
@@ -305,9 +305,9 @@
</div>
<script>
document.getElementById('webtorrent').addEventListener('click', async ev => {
ev.target.nextElementSibling.style.display = 'block'
await getStreams('api/webtorrent', 'webtorrent-table')
})
ev.target.nextElementSibling.style.display = 'block';
await getStreams('api/webtorrent', 'webtorrent-table');
});
</script>

View File

@@ -18,21 +18,21 @@
<body>
<div id="out"></div>
<script>
const out = document.getElementById("out");
const out = document.getElementById('out');
const print = (name, caps) => {
out.innerText += name + "\n";
out.innerText += name + '\n';
caps.codecs.forEach((codec) => {
out.innerText += [codec.mimeType, codec.channels, codec.clockRate, codec.sdpFmtpLine] + "\n";
out.innerText += [codec.mimeType, codec.channels, codec.clockRate, codec.sdpFmtpLine] + '\n';
});
out.innerText += "\n";
}
out.innerText += '\n';
};
if (RTCRtpReceiver.getCapabilities) {
print("receiver video", RTCRtpReceiver.getCapabilities("video"));
print("receiver audio", RTCRtpReceiver.getCapabilities("audio"));
print("sender video", RTCRtpSender.getCapabilities("video"));
print("sender audio", RTCRtpSender.getCapabilities("audio"));
print('receiver video', RTCRtpReceiver.getCapabilities('video'));
print('receiver audio', RTCRtpReceiver.getCapabilities('audio'));
print('sender video', RTCRtpSender.getCapabilities('video'));
print('sender audio', RTCRtpSender.getCapabilities('audio'));
}
const types = [
@@ -48,14 +48,18 @@
'video/mp4; codecs="hvc1.1.6.L93.B0"',
'video/mp4; codecs="hev1.1.6.L93.B0"',
'video/mp4; codecs="hev1.2.4.L120.B0"',
'video/mp4; codecs="flac"',
'video/mp4; codecs="opus"',
'video/mp4; codecs="mp3"',
'video/mp4; codecs="null"',
'application/vnd.apple.mpegurl',
];
const video = document.createElement("video");
out.innerText += "video.canPlayType\n";
const video = document.createElement('video');
out.innerText += 'video.canPlayType\n';
types.forEach(type => {
out.innerText += `${type} = ${MediaSource.isTypeSupported(type)} / ${video.canPlayType(type)}\n`;
})
out.innerText += `${type} = ${'MediaSource' in window && MediaSource.isTypeSupported(type)} / ${video.canPlayType(type)}\n`;
});
</script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>File Editor</title>
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
@@ -32,8 +32,8 @@
<div id="config"></div>
<script>
ace.config.set('basePath', 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.14.0/');
const editor = ace.edit("config", {
mode: "ace/mode/yaml",
const editor = ace.edit('config', {
mode: 'ace/mode/yaml',
});
document.getElementById('save').addEventListener('click', () => {
@@ -63,7 +63,7 @@
alert(`Unknown error: ${r.statusText} (${r.status})`);
}
});
})
});
</script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
@@ -84,55 +84,55 @@
'<a href="#" data-name="{name}">delete</a>',
];
document.querySelector(".controls > button")
.addEventListener("click", () => {
const url = new URL("stream.html", location.href);
document.querySelector('.controls > button')
.addEventListener('click', () => {
const url = new URL('stream.html', location.href);
const streams = document.querySelectorAll("#streams input");
const streams = document.querySelectorAll('#streams input');
streams.forEach(i => {
if (i.checked) url.searchParams.append("src", i.name);
if (i.checked) url.searchParams.append('src', i.name);
});
if (!url.searchParams.has("src")) return;
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(",");
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", ev => {
if (ev.target.innerText !== "delete") return;
const tbody = document.getElementById('streams');
tbody.addEventListener('click', ev => {
if (ev.target.innerText !== 'delete') return;
ev.preventDefault();
const url = new URL("api/streams", location.href);
const url = new URL('api/streams', location.href);
const src = decodeURIComponent(ev.target.dataset.name);
url.searchParams.set("src", src);
fetch(url, {method: "DELETE"}).then(reload);
url.searchParams.set('src', src);
fetch(url, {method: 'DELETE'}).then(reload);
});
document.getElementById('selectall').addEventListener('change', ev => {
document.querySelectorAll('#streams input').forEach(el => {
el.checked = ev.target.checked
})
})
el.checked = ev.target.checked;
});
});
function reload() {
const url = new URL("api/streams", location.href);
const url = new URL('api/streams', location.href);
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
tbody.innerHTML = "";
tbody.innerHTML = '';
for (const [name, value] of Object.entries(data)) {
const online = value && value.consumers ? value.consumers.length : 0;
const src = encodeURIComponent(name);
const links = templates.map(link => {
return link.replace("{name}", src);
}).join(" ");
return link.replace('{name}', src);
}).join(' ');
const tr = document.createElement("tr");
tr.dataset["id"] = name;
const tr = document.createElement('tr');
tr.dataset['id'] = name;
tr.innerHTML =
`<td><label><input type="checkbox" name="${name}">${name}</label></td>` +
`<td><a href="api/streams?src=${src}">${online} / info</a></td>` +
@@ -142,9 +142,9 @@
});
}
const url = new URL("api", location.href);
const url = new URL('api', location.href);
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
const info = document.querySelector(".info");
const info = document.querySelector('.info');
info.innerText = `Version: ${data.version}, Config: ${data.config_path}`;
});

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>go2rtc - links</title>
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
@@ -42,21 +42,21 @@
<script src="main.js"></script>
<div id="links"></div>
<script>
const src = new URLSearchParams(location.search).get('src')
const src = new URLSearchParams(location.search).get('src');
document.getElementById('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)
const url = new URL('api', location.href);
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
let rtsp = location.host + ':8554'
let rtsp = location.host + ':8554';
try {
const host = data.host.match(/^[^:]+/)[0]
const port = data.rtsp.listen.match(/[0-9]+$/)[0]
rtsp = `${host}:${port}`
const host = data.host.match(/^[^:]+/)[0];
const port = data.rtsp.listen.match(/[0-9]+$/)[0];
rtsp = `${host}:${port}`;
} catch (e) {
}
@@ -80,8 +80,8 @@
<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>
@@ -92,12 +92,12 @@
</div>
<script>
document.getElementById('send').addEventListener('click', ev => {
ev.preventDefault()
const url = new URL('api/streams', location.href)
url.searchParams.set('dst', src)
url.searchParams.set('src', document.getElementById('source').value)
fetch(url, {method: 'POST'})
})
ev.preventDefault();
const url = new URL('api/streams', location.href);
url.searchParams.set('dst', src);
url.searchParams.set('src', document.getElementById('source').value);
fetch(url, {method: 'POST'});
});
</script>
<div id="webrtc">
@@ -119,62 +119,62 @@
</div>
<script>
function webrtcLinksUpdate() {
const media = document.querySelector('input[name="webrtc"]:checked').value
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 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://alexxit.github.io/go2rtc/#${share.dataset.auth}&media=${media}`
const share = document.getElementById('shareget');
share.href = `https://alexxit.github.io/go2rtc/#${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'})
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('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 = ''
document.getElementById('shareadd').style.display = 'none';
document.getElementById('shareget').style.display = '';
document.getElementById('sharedel').style.display = '';
webrtcLinksUpdate()
webrtcLinksUpdate();
}
function onsharedel() {
document.getElementById('shareadd').style.display = ''
document.getElementById('shareget').style.display = 'none'
document.getElementById('sharedel').style.display = 'none'
document.getElementById('shareadd').style.display = '';
document.getElementById('shareget').style.display = 'none';
document.getElementById('sharedel').style.display = 'none';
}
document.getElementById('shareadd').addEventListener('click', ev => {
ev.preventDefault()
share('POST').then(r => r.json()).then(r => onshareadd(r))
})
ev.preventDefault();
share('POST').then(r => r.json()).then(r => onshareadd(r));
});
document.getElementById('shareget').addEventListener('click', ev => {
ev.preventDefault()
navigator.clipboard.writeText(ev.target.href)
})
ev.preventDefault();
navigator.clipboard.writeText(ev.target.href);
});
document.getElementById('sharedel').addEventListener('click', ev => {
ev.preventDefault()
share('DELETE').then(r => onsharedel())
})
ev.preventDefault();
share('DELETE').then(() => onsharedel());
});
document.getElementById('webrtc').addEventListener('click', ev => {
if (ev.target.tagName === 'INPUT') webrtcLinksUpdate()
})
if (ev.target.tagName === 'INPUT') webrtcLinksUpdate();
});
share('GET').then(r => {
if (r.ok) r.json().then(r => onshareadd(r))
else onsharedel()
})
if (r.ok) r.json().then(r => onshareadd(r));
else onsharedel();
});
webrtcLinksUpdate()
webrtcLinksUpdate();
</script>
</body>

View File

@@ -30,9 +30,9 @@
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("");
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]);
@@ -42,19 +42,19 @@
}
if (streams.length > 1) {
document.body.className = "flex";
document.body.className = 'flex';
}
const background = params.get("background") !== "false";
const width = "1 0 " + (params.get("width") || "320px");
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");
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);
video.src = new URL('api/ws?src=' + encodeURIComponent(streams[i]), location.href);
document.body.appendChild(video);
}
</script>

View File

@@ -21,21 +21,22 @@ export class VideoRTC extends HTMLElement {
this.RECONNECT_TIMEOUT = 30000;
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
'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
'null', // for detecting liars (old iOS 12)
'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";
this.mode = 'webrtc,mse,hls,mjpeg';
/**
* [config] Run stream when not displayed on the screen. Default `false`.
@@ -92,7 +93,7 @@ export class VideoRTC extends HTMLElement {
/**
* @type {string|URL}
*/
this.wsURL = "";
this.wsURL = '';
/**
* @type {RTCPeerConnection}
@@ -107,7 +108,7 @@ export class VideoRTC extends HTMLElement {
/**
* @type {string}
*/
this.mseCodecs = "";
this.mseCodecs = '';
/**
* [internal] Disconnect TimeoutID.
@@ -139,11 +140,11 @@ export class VideoRTC extends HTMLElement {
* @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;
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;
@@ -173,7 +174,7 @@ export class VideoRTC extends HTMLElement {
}
codecs(type) {
const test = type === "mse"
const test = type === 'mse'
? codec => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`)
: codec => this.video.canPlayType(`video/mp4; codecs="${codec}"`);
return this.CODECS.filter(test).join();
@@ -227,30 +228,30 @@ export class VideoRTC extends HTMLElement {
* Creates child DOM elements. Called automatically once on `connectedCallback`.
*/
oninit() {
this.video = document.createElement("video");
this.video = document.createElement('video');
this.video.controls = true;
this.video.playsInline = true;
this.video.preload = "auto";
this.video.preload = 'auto';
this.video.style.display = "block"; // fix bottom margin 4px
this.video.style.width = "100%";
this.video.style.height = "100%"
this.video.style.display = 'block'; // fix bottom margin 4px
this.video.style.width = '100%';
this.video.style.height = '100%';
this.appendChild(this.video);
if (this.background) return;
if ("hidden" in document && this.visibilityCheck) {
document.addEventListener("visibilitychange", () => {
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) {
if ('IntersectionObserver' in window && this.visibilityThreshold) {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) {
@@ -277,9 +278,9 @@ export class VideoRTC extends HTMLElement {
this.connectTS = Date.now();
this.ws = new WebSocket(this.wsURL);
this.ws.binaryType = "arraybuffer";
this.ws.addEventListener("open", ev => this.onopen(ev));
this.ws.addEventListener("close", ev => this.onclose(ev));
this.ws.binaryType = 'arraybuffer';
this.ws.addEventListener('open', () => this.onopen());
this.ws.addEventListener('close', () => this.onclose());
return true;
}
@@ -305,8 +306,8 @@ export class VideoRTC extends HTMLElement {
// CONNECTING => OPEN
this.wsState = WebSocket.OPEN;
this.ws.addEventListener("message", ev => {
if (typeof ev.data === "string") {
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);
@@ -321,30 +322,30 @@ export class VideoRTC extends HTMLElement {
const modes = [];
if (this.mode.indexOf("mse") >= 0 && "MediaSource" in window) { // iPhone
modes.push("mse");
if (this.mode.indexOf('mse') >= 0 && 'MediaSource' in window) { // iPhone
modes.push('mse');
this.onmse();
} else if (this.mode.indexOf("hls") >= 0 && this.video.canPlayType("application/vnd.apple.mpegurl")) {
modes.push("hls");
} else if (this.mode.indexOf('hls') >= 0 && this.video.canPlayType('application/vnd.apple.mpegurl')) {
modes.push('hls');
this.onhls();
} else if (this.mode.indexOf("mp4") >= 0) {
modes.push("mp4");
} else if (this.mode.indexOf('mp4') >= 0) {
modes.push('mp4');
this.onmp4();
}
if (this.mode.indexOf("webrtc") >= 0 && "RTCPeerConnection" in window) { // macOS Desktop app
modes.push("webrtc");
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) { // macOS Desktop app
modes.push('webrtc');
this.onwebrtc();
}
if (this.mode.indexOf("mjpeg") >= 0) {
if (this.mode.indexOf('mjpeg') >= 0) {
if (modes.length) {
this.onmessage["mjpeg"] = msg => {
if (msg.type !== "error" || msg.value.indexOf(modes[0]) !== 0) return;
this.onmessage['mjpeg'] = msg => {
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
this.onmjpeg();
}
};
} else {
modes.push("mjpeg");
modes.push('mjpeg');
this.onmjpeg();
}
}
@@ -375,25 +376,25 @@ export class VideoRTC extends HTMLElement {
onmse() {
const ms = new MediaSource();
ms.addEventListener("sourceopen", () => {
ms.addEventListener('sourceopen', () => {
URL.revokeObjectURL(this.video.src);
this.send({type: "mse", value: this.codecs("mse")});
this.send({type: 'mse', value: this.codecs('mse')});
}, {once: true});
this.video.src = URL.createObjectURL(ms);
this.video.srcObject = null;
this.play();
this.mseCodecs = "";
this.mseCodecs = '';
this.onmessage["mse"] = msg => {
if (msg.type !== "mse") return;
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", () => {
sb.mode = 'segments'; // segments or sequence
sb.addEventListener('updateend', () => {
if (sb.updating) return;
try {
@@ -431,25 +432,25 @@ export class VideoRTC extends HTMLElement {
// console.debug(e);
}
}
}
}
};
};
}
onwebrtc() {
const pc = new RTCPeerConnection(this.pcConfig);
/** @type {HTMLVideoElement} */
const video2 = document.createElement("video");
video2.addEventListener("loadeddata", ev => this.onpcvideo(ev), {once: true});
const video2 = document.createElement('video');
video2.addEventListener('loadeddata', ev => this.onpcvideo(ev), {once: true});
pc.addEventListener("icecandidate", ev => {
if (ev.candidate && this.mode.indexOf("webrtc/tcp") >= 0 && ev.candidate.protocol === "udp") return;
pc.addEventListener('icecandidate', ev => {
if (ev.candidate && this.mode.indexOf('webrtc/tcp') >= 0 && ev.candidate.protocol === 'udp') return;
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : "";
this.send({type: "webrtc/candidate", value: candidate});
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
this.send({type: 'webrtc/candidate', value: candidate});
});
pc.addEventListener("track", ev => {
pc.addEventListener('track', ev => {
// when stream already init
if (video2.srcObject !== null) return;
@@ -462,8 +463,8 @@ export class VideoRTC extends HTMLElement {
video2.srcObject = ev.streams[0];
});
pc.addEventListener("connectionstatechange", () => {
if (pc.connectionState === "failed" || pc.connectionState === "disconnected") {
pc.addEventListener('connectionstatechange', () => {
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
pc.close(); // stop next events
this.pcState = WebSocket.CLOSED;
@@ -473,29 +474,33 @@ export class VideoRTC extends HTMLElement {
}
});
this.onmessage["webrtc"] = msg => {
this.onmessage['webrtc'] = msg => {
switch (msg.type) {
case "webrtc/candidate":
if (this.mode.indexOf("webrtc/tcp") >= 0 && msg.value.indexOf(" udp ") > 0) return;
case 'webrtc/candidate':
if (this.mode.indexOf('webrtc/tcp') >= 0 && msg.value.indexOf(' udp ') > 0) return;
pc.addIceCandidate({candidate: msg.value, sdpMid: "0"}).catch(() => console.debug);
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
console.warn(er);
});
break;
case "webrtc/answer":
pc.setRemoteDescription({type: "answer", sdp: msg.value}).catch(() => console.debug);
case 'webrtc/answer':
pc.setRemoteDescription({type: 'answer', sdp: msg.value}).catch(er => {
console.warn(er);
});
break;
case "error":
if (msg.value.indexOf("webrtc/offer") < 0) return;
case 'error':
if (msg.value.indexOf('webrtc/offer') < 0) return;
pc.close();
}
};
// Safari doesn't support "offerToReceiveVideo"
pc.addTransceiver("video", {direction: "recvonly"});
pc.addTransceiver("audio", {direction: "recvonly"});
pc.addTransceiver('video', {direction: 'recvonly'});
pc.addTransceiver('audio', {direction: 'recvonly'});
pc.createOffer().then(offer => {
pc.setLocalDescription(offer).then(() => {
this.send({type: "webrtc/offer", value: offer.sdp});
this.send({type: 'webrtc/offer', value: offer.sdp});
});
});
@@ -514,7 +519,7 @@ export class VideoRTC extends HTMLElement {
const state = this.pc.connectionState;
// Firefox doesn't support pc.connectionState
if (state === "connected" || state === "connecting" || !state) {
if (state === 'connected' || state === 'connecting' || !state) {
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
let rtcPriority = 0, msePriority = 0;
@@ -523,9 +528,9 @@ export class VideoRTC extends HTMLElement {
if (ms.getVideoTracks().length > 0) rtcPriority += 0x220;
if (ms.getAudioTracks().length > 0) rtcPriority += 0x102;
if (this.mseCodecs.indexOf("hvc1.") >= 0) msePriority += 0x230;
if (this.mseCodecs.indexOf("avc1.") >= 0) msePriority += 0x210;
if (this.mseCodecs.indexOf("mp4a.") >= 0) msePriority += 0x101;
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
if (rtcPriority >= msePriority) {
this.video.srcObject = ms;
@@ -549,36 +554,38 @@ export class VideoRTC extends HTMLElement {
onmjpeg() {
this.ondata = data => {
this.video.controls = false;
this.video.poster = "data:image/jpeg;base64," + VideoRTC.btoa(data);
this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data);
};
this.send({type: "mjpeg"});
this.send({type: 'mjpeg'});
}
onhls() {
this.onmessage["hls"] = msg => {
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.onmessage['hls'] = msg => {
if (msg.type !== 'hls') return;
this.send({type: "hls", value: this.codecs("hls")});
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('hls')});
}
onmp4() {
/** @type {HTMLCanvasElement} **/
const canvas = document.createElement("canvas");
const canvas = document.createElement('canvas');
/** @type {CanvasRenderingContext2D} */
let context;
/** @type {HTMLVideoElement} */
const video2 = document.createElement("video");
const video2 = document.createElement('video');
video2.autoplay = true;
video2.playsInline = true;
video2.muted = true;
video2.addEventListener("loadeddata", ev => {
video2.addEventListener('loadeddata', () => {
if (!context) {
canvas.width = video2.videoWidth;
canvas.height = video2.videoHeight;
@@ -588,20 +595,20 @@ export class VideoRTC extends HTMLElement {
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
this.video.controls = false;
this.video.poster = canvas.toDataURL("image/jpeg");
this.video.poster = canvas.toDataURL('image/jpeg');
});
this.ondata = data => {
video2.src = "data:video/mp4;base64," + VideoRTC.btoa(data);
video2.src = 'data:video/mp4;base64,' + VideoRTC.btoa(data);
};
this.send({type: "mp4", value: this.codecs("mp4")});
this.send({type: 'mp4', value: this.codecs('mp4')});
}
static btoa(buffer) {
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
let binary = "";
let binary = '';
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}

View File

@@ -1,23 +1,23 @@
import {VideoRTC} from "./video-rtc.js";
import {VideoRTC} from './video-rtc.js';
class VideoStream extends VideoRTC {
set divMode(value) {
this.querySelector(".mode").innerText = value;
this.querySelector(".status").innerText = "";
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;
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");
console.debug('stream.oninit');
super.oninit();
this.innerHTML = `
@@ -43,57 +43,57 @@ class VideoStream extends VideoRTC {
</div>
`;
const info = this.querySelector(".info")
const info = this.querySelector('.info');
this.insertBefore(this.video, info);
}
onconnect() {
console.debug("stream.onconnect");
console.debug('stream.onconnect');
const result = super.onconnect();
if (result) this.divMode = "loading";
if (result) this.divMode = 'loading';
return result;
}
ondisconnect() {
console.debug("stream.ondisconnect");
console.debug('stream.ondisconnect');
super.ondisconnect();
}
onopen() {
console.debug("stream.onopen");
console.debug('stream.onopen');
const result = super.onopen();
this.onmessage["stream"] = msg => {
console.debug("stream.onmessge", msg);
this.onmessage['stream'] = msg => {
console.debug('stream.onmessge', msg);
switch (msg.type) {
case "error":
case 'error':
this.divError = msg.value;
break;
case "mse":
case "hls":
case "mp4":
case "mjpeg":
case 'mse':
case 'hls':
case 'mp4':
case 'mjpeg':
this.divMode = msg.type.toUpperCase();
break;
}
}
};
return result;
}
onclose() {
console.debug("stream.onclose");
console.debug('stream.onclose');
return super.onclose();
}
onpcvideo(ev) {
console.debug("stream.onpcvideo");
console.debug('stream.onpcvideo');
super.onpcvideo(ev);
if (this.pcState !== WebSocket.CLOSED) {
this.divMode = "RTC";
this.divMode = 'RTC';
}
}
}
customElements.define("video-stream", VideoStream);
customElements.define('video-stream', VideoStream);

View File

@@ -22,45 +22,45 @@
async function PeerConnection(media) {
const pc = new RTCPeerConnection({
iceServers: [{urls: '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)
})
pc.addTrack(track);
});
return pc
return pc;
}
function getCompleteOffer(pc, timeout) {
return new Promise((resolve, reject) => {
pc.addEventListener('icegatheringstatechange', () => {
if (pc.iceGatheringState === 'complete') resolve(pc.localDescription.sdp)
})
if (pc.iceGatheringState === 'complete') resolve(pc.localDescription.sdp);
});
pc.createOffer().then(offer => pc.setLocalDescription(offer))
pc.createOffer().then(offer => pc.setLocalDescription(offer));
setTimeout(() => resolve(pc.localDescription.sdp), timeout || 3000)
})
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()})
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()
connect();
</script>
</body>
</html>

View File

@@ -22,86 +22,86 @@
async function PeerConnection(media) {
const pc = new RTCPeerConnection({
iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
})
});
const localTracks = []
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)
})
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)
})
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)
.map(kind => pc.addTransceiver(kind, {direction: 'recvonly'}).receiver.track);
localTracks.push(...tracks);
}
document.getElementById('video').srcObject = new MediaStream(localTracks)
document.getElementById('video').srcObject = new MediaStream(localTracks);
return pc
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()
: await navigator.mediaDevices.getDisplayMedia(constraints);
return stream.getTracks();
} catch (e) {
console.warn(e)
return []
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))
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))
})
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))
})
})
const msg = {type: 'webrtc/offer', value: pc.localDescription.sdp};
ws.send(JSON.stringify(msg));
});
});
ws.addEventListener('message', ev => {
const msg = JSON.parse(ev.data)
const msg = JSON.parse(ev.data);
if (msg.type === 'webrtc/candidate') {
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'})
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'});
} else if (msg.type === 'webrtc/answer') {
pc.setRemoteDescription({type: 'answer', sdp: msg.value})
pc.setRemoteDescription({type: 'answer', sdp: msg.value});
}
})
});
}
const media = new URLSearchParams(location.search).get('media')
connect(media || 'video+audio')
const media = new URLSearchParams(location.search).get('media');
connect(media || 'video+audio');
</script>
</body>
</html>