/**
* - IntersectionObserver - iOS 12.2+
* https://caniuse.com/?search=IntersectionObserver
* - WebRTC Unified Plan SDP - iOS 12.2+ (iOS 11 supports only Plan B)
* https://webkit.org/blog/8672/on-the-road-to-webrtc-1-0-including-vp8/
* - MediaSource - iPad OS 13+
* https://caniuse.com/?search=MediaSource
*/
class WebRTCCamera extends HTMLElement {
constructor() {
super();
this.subscriptions = [];
this.unique_shortcuts_key = null;
}
set status(value) {
const header = this.querySelector('.header');
header.innerText = value;
header.style.display = value ? 'block' : 'none';
}
set readyState(value) {
const state = this.querySelector('.state');
switch (value) {
case 'websocket':
state.icon = 'mdi:download-network-outline';
break;
case 'mse':
state.icon = 'mdi:play-network-outline';
break;
case 'webrtc-pending': // init WebRTC
state.icon = 'mdi:lan-pending';
break;
case 'webrtc-connecting': // connect to LAN or WAN IP
state.icon = 'mdi:lan-connect';
break;
case 'webrtc-loading': // load video stream
state.icon = 'mdi:lan-check';
break;
case 'webrtc-restart': // restart WebRTC
state.icon = 'mdi:lan-disconnect';
break;
case 'webrtc': // video stream switched to WebRTC
state.icon = 'mdi:webrtc';
break;
}
}
get isOpera() {
// this integraion https://github.com/thomasloven/hass-fontawesome
// breaks the `!!window.opera` check in all browsers
return (!!window.opr && !!opr.addons) || navigator.userAgent.indexOf(' OPR/') >= 0;
}
static getStubConfig() {
return {
url: 'rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mp4'
}
}
async initMSE(hass, pc = null) {
const ts = Date.now();
let unsignedPath = '/api/webrtc/ws?'
if (this.config.url) unsignedPath += '&url=' + encodeURIComponent(this.config.url);
if (this.config.entity) unsignedPath += '&entity=' + this.config.entity;
const data = await hass.callWS({
type: 'auth/sign_path',
path: unsignedPath
});
let url = 'ws' + hass.hassUrl(data.path).substr(4);
const video = this.querySelector('#video');
const ws = this.ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
let mediaSource, sourceBuffer;
this.subscriptions.push(() => {
this.ws.onclose = null;
this.ws.close();
console.debug("Closing websocket");
});
ws.onopen = async () => {
this.readyState = 'websocket';
if (this.config.mse !== false) {
if ('MediaSource' in window) {
mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
video.srcObject = null;
mediaSource.onsourceopen = () => {
ws.send(JSON.stringify({type: 'mse'}));
}
} else {
console.warn("MediaSource doesn't supported");
}
}
if (this.config.webrtc !== false && !this.isOpera) {
this.readyState = 'webrtc-pending';
if (!pc) pc = this.initWebRTC(hass);
const offer = await pc.createOffer({iceRestart: true})
await pc.setLocalDescription(offer);
this.subscriptions.push(() => {
pc.close();
pc = null;
console.debug("Closing RTCPeerConnection");
});
}
}
ws.onmessage = ev => {
if (typeof ev.data === 'string') {
const data = JSON.parse(ev.data);
if (data.type === 'mse') {
console.debug("Received MSE codecs:", data.codecs);
try {
sourceBuffer = mediaSource.addSourceBuffer(
`video/mp4; codecs="${data.codecs}"`);
this.readyState = 'mse';
} catch (e) {
this.status = `ERROR: ${e}`;
}
} else if (data.type === 'webrtc') {
console.debug("Received WebRTC SDP");
// remove docker IP-address
const sdp = data.sdp.replace(
/a=candidate.+? 172\.\d+\.\d+\.1 .+?\r\n/g, ''
);
pc.setRemoteDescription(
new RTCSessionDescription({
type: 'answer', sdp: sdp
})
);
} else if (data.error) {
this.status = `ERROR: ${data.error}`;
}
} else if (sourceBuffer) {
try {
sourceBuffer.appendBuffer(ev.data);
} catch (e) {
}
// all the magic is here
if (!video.paused && video.seekable.length) {
if (video.seekable.end(0) - video.currentTime > 0.5) {
console.debug("Auto seek to livetime");
video.currentTime = video.seekable.end(0);
}
}
}
}
ws.onclose = () => {
// reconnect no more than once every 15 seconds
const delay = 15000 - Math.min(Date.now() - ts, 15000);
console.debug(`Reconnect in ${delay} ms`);
setTimeout(() => {
if (this.isConnected) {
this.status = "Restart connection";
this.initMSE(hass, pc);
}
}, delay);
}
}
initWebRTC(hass) {
const video = document.createElement('video');
video.onloadeddata = () => {
if (video.readyState >= 1) {
console.debug("Switch to WebRTC")
const mainVideo = this.querySelector('#video');
mainVideo.srcObject = video.srcObject;
// disable autorestart ws connection
this.ws.onclose = null;
this.ws.close();
this.readyState = 'webrtc';
}
}
const pc = new RTCPeerConnection({
iceServers: this.config.ice_servers || [{
urls: 'stun:stun.l.google.com:19302'
}],
iceCandidatePoolSize: 20
});
pc.onicecandidate = async (ev) => {
if (ev.candidate) return;
try {
// only for debug purpose
const iceTransport = pc.getSenders()[0].transport.iceTransport;
iceTransport.onselectedcandidatepairchange = () => {
const pair = iceTransport.getSelectedCandidatePair();
const type = pair.remote.type === 'host' ? 'LAN' : 'WAN';
this.readyState = 'webrtc-connecting';
// this.status = `Connecting over ${type}`;
console.debug(`Connecting over ${type}`);
}
} catch (e) {
// Hi to Safari and Firefox...
}
// this.status = "Trying to start stream";
try {
this.ws.send(JSON.stringify({
type: 'webrtc',
sdp: pc.localDescription.sdp
}));
} catch (e) {
console.warn(e);
}
}
pc.ontrack = (ev) => {
if (video.srcObject === null) {
video.srcObject = ev.streams[0];
} else {
video.srcObject.addTrack(ev.track);
}
}
pc.onconnectionstatechange = async (ev) => {
// https://developer.mozilla.org/en-US/docs/Web/API/RTCOfferOptions/iceRestart
console.debug("WebRTC state:", pc.connectionState);
if (pc.connectionState === 'failed') {
if (this.ws.readyState === WebSocket.OPEN) {
this.readyState = 'webrtc-restart';
// this.status = "Restart connection";
const offer = await pc.createOffer({iceRestart: true})
await pc.setLocalDescription(offer);
} else {
if (this.isConnected) {
video.src = '';
this.initMSE(hass, pc);
}
}
} else if (pc.connectionState === 'connected') {
this.readyState = 'webrtc-loading';
// this.status = "Loading video";
}
}
// https://stackoverflow.com/questions/9847580/how-to-detect-safari-chrome-ie-firefox-and-opera-browser
const isFirefox = typeof InstallTrigger !== 'undefined';
// recvonly don't work with Firefox
// https://github.com/pion/webrtc/issues/717
// sendrecv don't work with some Android mobile phones and tablets
// and Firefox can't play video with Bunny even with sendrecv
const direction = !isFirefox ? 'recvonly' : 'sendrecv';
pc.addTransceiver('video', {'direction': direction});
if (this.config.audio !== false) {
pc.addTransceiver('audio', {'direction': direction});
}
return pc;
}
renderCustomGUI(card) {
const video = this.querySelector('#video');
video.controls = false;
video.style.pointerEvents = 'none';
video.style.opacity = 0;
const spinner = document.createElement('ha-circular-progress');
spinner.active = true;
spinner.className = 'spinner'
card.appendChild(spinner);
const pause = document.createElement('ha-icon');
pause.className = 'pause';
pause.icon = 'mdi:pause';
const pauseCallback = () => {
if (video.paused) {
video.play().then(() => null, () => null);
} else {
video.pause();
}
};
pause.addEventListener('click', pauseCallback);
pause.addEventListener('touchstart', pauseCallback);
card.appendChild(pause);
const volume = document.createElement('ha-icon');
volume.className = 'volume';
volume.icon = video.muted ? 'mdi:volume-mute' : 'mdi:volume-high';
const volumeCallback = () => {
video.muted = !video.muted;
};
volume.addEventListener('click', volumeCallback);
volume.addEventListener('touchstart', volumeCallback);
card.appendChild(volume);
video.onvolumechange = () => {
volume.icon = video.muted ? 'mdi:volume-mute' : 'mdi:volume-high';
};
const fullscreen = document.createElement('ha-icon');
fullscreen.className = 'fullscreen';
fullscreen.icon = 'mdi:fullscreen';
// https://stackoverflow.com/questions/43024394/ios10-fullscreen-safari-javascript
if (this.requestFullscreen) { // normal browser
const fullscreenCallback = () => {
document.fullscreenElement
? document.exitFullscreen() : this.requestFullscreen();
}
fullscreen.addEventListener('click', fullscreenCallback);
fullscreen.addEventListener('touchstart', fullscreenCallback);
this.onfullscreenchange = () => {
fullscreen.icon = document.fullscreenElement
? 'mdi:fullscreen-exit' : 'mdi:fullscreen';
}
} else { // Apple Safari...
const fullscreenCallback = () => {
document.webkitFullscreenElement
? document.webkitExitFullscreen()
: this.webkitRequestFullscreen();
}
fullscreen.addEventListener('click', fullscreenCallback);
fullscreen.addEventListener('touchstart', fullscreenCallback);
this.onwebkitfullscreenchange = () => {
fullscreen.icon = document.webkitFullscreenElement
? 'mdi:fullscreen-exit' : 'mdi:fullscreen';
}
}
// iPhone doesn't support fullscreen
if (navigator.platform !== 'iPhone') card.appendChild(fullscreen);
video.addEventListener('loadeddata', () => {
const hasAudio =
(video.srcObject && video.srcObject.getAudioTracks().length) ||
video.mozHasAudio || video.webkitAudioDecodedByteCount ||
(video.audioTracks && video.audioTracks.length);
volume.style.display = hasAudio ? 'block' : 'none';
pause.style.display = 'block';
video.style.opacity = 1;
});
video.onpause = () => {
pause.icon = 'mdi:play';
this.setPTZVisibility(false);
};
video.onplay = () => {
pause.icon = 'mdi:pause';
this.setPTZVisibility(true);
};
video.onwaiting = () => {
spinner.style.display = 'block';
this.setPTZVisibility(false);
};
video.onplaying = () => {
spinner.style.display = 'none';
this.setPTZVisibility(true);
};
if (this.config.shortcuts && this.config.shortcuts.services && this.config.shortcuts.services.length > 0) {
this.renderShortcuts(card, this.config.shortcuts.services);
}
}
renderShortcuts(card, elements) {
const shortcuts = document.createElement('div');
shortcuts.className = 'shortcuts-' + this.getUniqueShortcutsKey();
for (var i = 0; i < elements.length; i++) {
const element = elements[i];
const shortcut = document.createElement('ha-icon');
shortcut.className = 'shortcut shortcut-' + i;
shortcut.setAttribute('title', element.name);
shortcut.icon = element.icon;
const shortcutCallback = () => {
const [domain, name] = element.service.split('.');
this.hass.callService(domain, name, element.service_data || {});
};
shortcut.addEventListener('click', shortcutCallback);
shortcut.addEventListener('touchstart', shortcutCallback);
shortcuts.appendChild(shortcut);
}
card.appendChild(shortcuts);
}
renderPTZ(card, hass) {
const ptz = document.createElement('div');
ptz.className = 'ptz';
ptz.style.opacity = this.config.ptz.opacity || '0.4';
const ptzMove = document.createElement('div');
ptzMove.className = 'ptz-move';
ptzMove.innerHTML = `