/** Chrome 63+, Safari 11.1+ */ import {VideoRTC} from './video-rtc.js?v=1.9.9'; import {DigitalPTZ} from './digital-ptz.js?v=3.3.0'; class WebRTCCamera extends VideoRTC { /** * Step 1. Called by the Hass, when config changed. * @param {Object} config */ setConfig(config) { if (!config.url && !config.entity && !config.streams) throw new Error('Missing `url` or `entity` or `streams`'); if (config.background) this.background = config.background; if (config.intersection === 0) this.visibilityThreshold = 0; else this.visibilityThreshold = config.intersection || 0.75; /** * @type {{ * url: string, * entity: string, * mode: string, * media: string, * * streams: Array<{ * name: string, * url: string, * entity: string, * mode: string, * media: string, * }>, * * title: string, * poster: string, * poster_remote: boolean, * muted: boolean, * intersection: number, * ui: boolean, * style: string, * background: boolean, * * server: string, * * mse: boolean, * webrtc: boolean, * * digital_ptz:{ * mouse_drag_pan: boolean, * mouse_wheel_zoom: boolean, * mouse_double_click_zoom: boolean, * touch_pinch_zoom: boolean, * touch_drag_pan: boolean, * touch_tap_drag_zoom: boolean, * persist: boolean|string, * }, * ptz:{ * opacity: number|string, * service: string, * data_left, data_up, data_right, data_down, data_zoom_in, data_zoom_out, data_home * }, * shortcuts:Array<{ name:string, icon:string }>, * }} config */ this.config = Object.assign({ mode: config.mse === false ? 'webrtc' : config.webrtc === false ? 'mse' : this.mode, media: this.media, streams: [{url: config.url, entity: config.entity}], poster_remote: config.poster && (config.poster.indexOf('://') > 0 || config.poster.charAt(0) === '/'), }, config); this.streamID = -1; this.nextStream(false); this.onhass = []; } set hass(hass) { this._hass = hass; this.onhass.forEach(fn => fn()); // if card in vertical stack - `hass` property assign after `onconnect` // this.onconnect(); } get hass() { return this._hass; } /** * Called by the Hass to calculate default card height. */ getCardSize() { return 5; // x 50px } /** * Called by the Hass to get defaul card config * @return {{url: string}} */ static getStubConfig() { return {'url': ''}; } setStatus(mode, status) { const divMode = this.querySelector('.mode').innerText; if (mode === 'error' && divMode !== 'Loading..' && divMode !== 'Loading...') return; this.querySelector('.mode').innerText = mode; this.querySelector('.status').innerText = status || ''; } /** @param reload {boolean} */ nextStream(reload) { this.streamID = (this.streamID + 1) % this.config.streams.length; const stream = this.config.streams[this.streamID]; this.config.url = stream.url; this.config.entity = stream.entity; this.mode = stream.mode || this.config.mode; this.media = stream.media || this.config.media; if (reload) { this.ondisconnect(); setTimeout(() => this.onconnect(), 100); // wait ws.close event } } /** @return {string} */ get streamName() { return this.config.streams[this.streamID].name || `S${this.streamID}`; } oninit() { super.oninit(); this.renderMain(); this.renderDigitalPTZ(); this.renderPTZ(); this.renderCustomUI(); this.renderShortcuts(); this.renderStyle(); } onconnect() { if (!this.config || !this.hass) return false; if (!this.isConnected || this.ws || this.pc) return false; const divMode = this.querySelector('.mode').innerText; if (divMode === 'Loading..') return; this.setStatus('Loading..'); this.hass.callWS({ type: 'auth/sign_path', path: '/api/webrtc/ws' }).then(data => { if (this.config.poster && !this.config.poster_remote) { this.video.poster = this.hass.hassUrl(data.path) + '&poster=' + encodeURIComponent(this.config.poster); } this.wsURL = 'ws' + this.hass.hassUrl(data.path).substring(4); if (this.config.entity) { this.wsURL += '&entity=' + this.config.entity; } else if (this.config.url) { this.wsURL += '&url=' + encodeURIComponent(this.config.url); } else { this.setStatus('IMG'); return; } if (this.config.server) { this.wsURL += '&server=' + encodeURIComponent(this.config.server); } if (super.onconnect()) { this.setStatus('Loading...'); } else { this.setStatus('error', 'unable to connect'); } }).catch(er => { this.setStatus('error', er); }); } onopen() { const result = super.onopen(); this.onmessage['stream'] = msg => { switch (msg.type) { case 'error': this.setStatus('error', msg.value); break; case 'mse': case 'hls': case 'mp4': case 'mjpeg': this.setStatus(msg.type.toUpperCase(), this.config.title || ''); break; } }; return result; } onpcvideo(ev) { super.onpcvideo(ev); if (this.pcState !== WebSocket.CLOSED) { this.setStatus('RTC', this.config.title || ''); } } renderMain() { const shadow = this.attachShadow({mode: 'open'}); shadow.innerHTML = `
`; this.querySelector = selectors => this.shadowRoot.querySelector(selectors); this.querySelector('.ptz-transform').appendChild(this.video); const mode = this.querySelector('.mode'); mode.addEventListener('click', () => this.nextStream(true)); if (this.config.muted) this.video.muted = true; if (this.config.poster_remote) this.video.poster = this.config.poster; } renderDigitalPTZ() { if (this.config.digital_ptz === false) return; new DigitalPTZ( this.querySelector('.player'), this.querySelector('.player .ptz-transform'), this.video, Object.assign({}, this.config.digital_ptz, {persist_key: this.config.url}) ); } renderPTZ() { if (!this.config.ptz || !this.config.ptz.service) return; let hasMove = false; let hasZoom = false; let hasHome = false; for (const prefix of ['', '_start', '_end', '_long']) { hasMove = hasMove || this.config.ptz['data' + prefix + '_right']; hasMove = hasMove || this.config.ptz['data' + prefix + '_left']; hasMove = hasMove || this.config.ptz['data' + prefix + '_up']; hasMove = hasMove || this.config.ptz['data' + prefix + '_down']; hasZoom = hasZoom || this.config.ptz['data' + prefix + '_zoom_in']; hasZoom = hasZoom || this.config.ptz['data' + prefix + '_zoom_out']; hasHome = hasHome || this.config.ptz['data' + prefix + '_home']; } const card = this.querySelector('.card'); card.insertAdjacentHTML('beforebegin', ` `); card.insertAdjacentHTML('beforeend', `
`); const template = JSON.stringify(this.config.ptz); const handle = path => { if (!this.config.ptz['data_' + path]) return; const config = template.indexOf('${') < 0 ? this.config.ptz : JSON.parse(eval('`' + template + '`')); const [domain, service] = config.service.split('.', 2); const data = config['data_' + path]; this.hass.callService(domain, service, data); }; const ptz = this.querySelector('.ptz'); for (const [start, end] of [['touchstart', 'touchend'], ['mousedown', 'mouseup']]) { ptz.addEventListener(start, startEvt => { const {className} = startEvt.target; startEvt.preventDefault(); handle('start_' + className); window.addEventListener(end, endEvt => { endEvt.preventDefault(); handle('end_' + className); if (endEvt.timeStamp - startEvt.timeStamp > 400) { handle('long_' + className); } else { handle(className); } }, {once: true}); }); } } saveScreenshot() { const a = document.createElement('a'); if (this.video.videoWidth && this.video.videoHeight) { const canvas = document.createElement('canvas'); canvas.width = this.video.videoWidth; canvas.height = this.video.videoHeight; canvas.getContext('2d').drawImage(this.video, 0, 0, canvas.width, canvas.height); a.href = canvas.toDataURL('image/jpeg'); } else if (this.video.poster && this.video.poster.startsWith('data:image/jpeg')) { a.href = this.video.poster; } else { return; } const ts = new Date().toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', ''); a.download = `snapshot_${ts}.jpeg`; a.click(); } renderCustomUI() { if (!this.config.ui) return; this.video.controls = false; this.video.style.pointerEvents = 'none'; const card = this.querySelector('.card'); card.insertAdjacentHTML('beforebegin', ` `); card.insertAdjacentHTML('beforeend', `
${this.streamName}
`); const video = this.video; const fullscreen = this.querySelector('.fullscreen'); if (this.requestFullscreen) { this.addEventListener('fullscreenchange', () => { fullscreen.icon = document.fullscreenElement ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'; }); } else if (video.webkitEnterFullscreen) { this.requestFullscreen = () => new Promise((resolve, reject) => { try { video.webkitEnterFullscreen(); } catch (e) { reject(e); } }); video.addEventListener('webkitendfullscreen', () => { setTimeout(() => this.play(), 1000); // fix bug in iOS }); } else { fullscreen.style.display = 'none'; } const pip = this.querySelector('.pictureinpicture'); if (video.requestPictureInPicture) { video.addEventListener('enterpictureinpicture', () => { pip.icon = 'mdi:rectangle'; this.background = true; }); video.addEventListener('leavepictureinpicture', () => { pip.icon = 'mdi:picture-in-picture-bottom-right'; this.background = this.config.background; this.play(); }); } else { pip.style.display = 'none'; } const ui = this.querySelector('.ui'); ui.addEventListener('click', ev => { const icon = ev.target.icon; if (icon === 'mdi:play') { this.play(); } else if (icon === 'mdi:volume-mute') { video.muted = false; } else if (icon === 'mdi:volume-high') { video.muted = true; } else if (icon === 'mdi:fullscreen') { this.requestFullscreen().catch(console.warn); } else if (icon === 'mdi:fullscreen-exit') { document.exitFullscreen().catch(console.warn); } else if (icon === 'mdi:floppy') { this.saveScreenshot(); } else if (icon === 'mdi:picture-in-picture-bottom-right') { video.requestPictureInPicture().catch(console.warn); } else if (icon === 'mdi:rectangle') { document.exitPictureInPicture().catch(console.warn); } else if (ev.target.className === 'stream') { this.nextStream(true); ev.target.innerText = this.streamName; } }); const spinner = this.querySelector('.spinner'); video.addEventListener('waiting', () => { spinner.style.display = 'block'; }); video.addEventListener('playing', () => { spinner.style.display = 'none'; }); const play = this.querySelector('.play'); video.addEventListener('play', () => { play.style.display = 'none'; }); video.addEventListener('pause', () => { play.style.display = 'block'; }); const volume = this.querySelector('.volume'); video.addEventListener('loadeddata', () => { volume.style.display = this.hasAudio ? 'block' : 'none'; }); video.addEventListener('volumechange', () => { volume.icon = video.muted ? 'mdi:volume-mute' : 'mdi:volume-high'; }); const stream = this.querySelector('.stream'); stream.style.display = this.config.streams.length > 1 ? 'block' : 'none'; } renderShortcuts() { if (!this.config.shortcuts) return; const card = this.querySelector('.card'); card.insertAdjacentHTML('beforebegin', ` `); card.insertAdjacentHTML('beforeend', '
'); const shortcuts = this.querySelector('.shortcuts'); shortcuts.addEventListener('click', ev => { const value = this.config.shortcuts[ev.target.dataset.index]; if (value.more_info !== undefined) { const event = new Event('hass-more-info', { bubbles: true, cancelable: true, composed: true, }); event.detail = {entityId: value.more_info}; ev.target.dispatchEvent(event); } if (value.service !== undefined) { const [domain, name] = value.service.split('.'); this.hass.callService(domain, name, value.service_data || {}); } }); this.renderTemplate('shortcuts', () => { shortcuts.innerHTML = this.config.shortcuts.map((value, index) => ` `).join(''); }); } renderStyle() { if (!this.config.style) return; const style = document.createElement('style'); const card = this.querySelector('.card'); card.insertAdjacentElement('beforebegin', style); this.renderTemplate('style', () => { style.innerText = this.config.style; }); } renderTemplate(name, renderHTML) { const config = this.config[name]; // support config param as string or as object const template = typeof config === 'string' ? config : JSON.stringify(config); // check if config param has template if (template.indexOf('${') >= 0) { const render = () => { try { const states = this.hass ? this.hass.states : undefined; this.config[name] = JSON.parse(eval('`' + template + '`')); renderHTML(); } catch (e) { console.debug(e); } }; this.onhass.push(render); render(); } else { renderHTML(); } } get hasAudio() { return ( (this.video.srcObject && this.video.srcObject.getAudioTracks && this.video.srcObject.getAudioTracks().length) || (this.video.mozHasAudio || this.video.webkitAudioDecodedByteCount) || (this.video.audioTracks && this.video.audioTracks.length) ); } } customElements.define('webrtc-camera', WebRTCCamera); const card = { type: 'webrtc-camera', name: 'WebRTC Camera', preview: false, description: 'WebRTC camera allows you to view the stream of almost any camera without delay', }; // Apple iOS 12 doesn't support `||=` if (window.customCards) window.customCards.push(card); else window.customCards = [card];