426 lines
15 KiB
JavaScript
426 lines
15 KiB
JavaScript
// webrtc-doorbell-card v0.4
|
|
//
|
|
// A Lovelace card that wraps AlexxIT's `webrtc-camera` and adds proper
|
|
// answer/end-call buttons that toggle BOTH directions of audio at once
|
|
// (incoming via video.muted, outgoing via mic track .enabled), plus an
|
|
// optional unlock button that fires a configurable HA action.
|
|
//
|
|
// Repository: https://github.com/naps62/ha-webrtc-doorbell-card
|
|
|
|
(() => {
|
|
if (window.__webrtcDoorbellPatchInstalled) return;
|
|
window.__webrtcDoorbellPatchInstalled = true;
|
|
const md = navigator.mediaDevices;
|
|
if (!md || !md.getUserMedia) return;
|
|
const orig = md.getUserMedia.bind(md);
|
|
md.getUserMedia = async function (constraints) {
|
|
const stream = await orig(constraints);
|
|
if (constraints && constraints.audio) {
|
|
window.__webrtcDoorbellMicStream = stream;
|
|
// Start disabled — user clicks Answer to enable
|
|
stream.getAudioTracks().forEach((t) => { t.enabled = false; });
|
|
}
|
|
return stream;
|
|
};
|
|
})();
|
|
|
|
const VERSION = '0.8.0';
|
|
|
|
class WebrtcDoorbellCard extends HTMLElement {
|
|
setConfig(config) {
|
|
if (!config || !config.entity) {
|
|
throw new Error('entity is required');
|
|
}
|
|
this._config = config;
|
|
if (!this._rendered) this._render();
|
|
}
|
|
|
|
set hass(hass) {
|
|
this._hass = hass;
|
|
if (this._inner) this._inner.hass = hass;
|
|
}
|
|
|
|
async _render() {
|
|
this._rendered = true;
|
|
this.innerHTML = '';
|
|
this.style.cssText = 'display:block;width:100%;height:100%;';
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.style.cssText = 'position:relative;width:100%;height:100%;';
|
|
this.appendChild(wrap);
|
|
|
|
const helpers = await window.loadCardHelpers();
|
|
const inner = helpers.createCardElement({
|
|
type: 'custom:webrtc-camera',
|
|
entity: this._config.entity,
|
|
media: 'video,audio,microphone',
|
|
mode: this._config.mode || 'webrtc',
|
|
ui: false,
|
|
background: true,
|
|
});
|
|
if (this._hass) inner.hass = this._hass;
|
|
this._inner = inner;
|
|
this._appendInner(wrap, inner);
|
|
|
|
this._buildVideoLayout(wrap);
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.style.cssText = [
|
|
'position:absolute',
|
|
'left:0', 'right:0',
|
|
// Pin to bottom of viewport, respecting iOS home-indicator / Android nav-bar safe areas
|
|
'bottom:max(24px, env(safe-area-inset-bottom, 24px))',
|
|
'padding-left:env(safe-area-inset-left, 0)',
|
|
'padding-right:env(safe-area-inset-right, 0)',
|
|
'display:flex', 'justify-content:center', 'gap:24px',
|
|
'z-index:10', 'pointer-events:none',
|
|
].join(';');
|
|
wrap.appendChild(overlay);
|
|
|
|
if (this._config.unlock_action) {
|
|
this._unlockBtn = this._makeBtn(
|
|
this._config.unlock_icon || 'mdi:key',
|
|
'rgba(0,0,0,0.55)',
|
|
() => this._unlock(),
|
|
);
|
|
this._unlockIcon = this._unlockBtn.querySelector('ha-icon');
|
|
overlay.appendChild(this._unlockBtn);
|
|
}
|
|
|
|
this._answerBtn = this._makeBtn('mdi:phone', '#15803d', () => this._answer());
|
|
this._endBtn = this._makeBtn('mdi:phone-hangup', '#b91c1c', () => this._end());
|
|
this._endBtn.style.display = 'none';
|
|
overlay.appendChild(this._answerBtn);
|
|
overlay.appendChild(this._endBtn);
|
|
|
|
this._waitForVideo().then((v) => {
|
|
if (!v) return;
|
|
v.muted = true;
|
|
v.controls = false;
|
|
v.playsInline = true;
|
|
v.disablePictureInPicture = true;
|
|
v.setAttribute('playsinline', '');
|
|
v.setAttribute('webkit-playsinline', '');
|
|
v.setAttribute('x5-playsinline', '');
|
|
v.setAttribute('disablepictureinpicture', '');
|
|
v.setAttribute('disableremoteplayback', '');
|
|
this._sourceVideo = v;
|
|
const layout = this._config.layout || 'split';
|
|
if (layout === 'split') {
|
|
// Hidden inner video drives WebRTC; mirror its srcObject onto our
|
|
// display videos. Poll because srcObject can change on reconnect
|
|
// without firing observable events.
|
|
this._mirrorStream();
|
|
this._mirrorInterval = setInterval(() => this._mirrorStream(), 1000);
|
|
} else {
|
|
// Single-video layout — apply object-fit override directly.
|
|
const fit = this._config.object_fit || (layout === 'contain' ? 'contain' : 'cover');
|
|
v.style.objectFit = fit;
|
|
v.style.width = '100%';
|
|
v.style.height = '100%';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Hide or show the inner card depending on layout. For 'split' we keep it
|
|
// off-screen but rendered (so the connection stays alive). For other layouts
|
|
// we let it render full-size and skip the mirror.
|
|
_appendInner(wrap, inner) {
|
|
const layout = this._config.layout || 'split';
|
|
if (layout === 'split') {
|
|
const cage = document.createElement('div');
|
|
cage.style.cssText = [
|
|
'position:absolute', 'left:0', 'top:0',
|
|
'width:1px', 'height:1px',
|
|
'opacity:0', 'pointer-events:none',
|
|
'overflow:hidden',
|
|
].join(';');
|
|
cage.appendChild(inner);
|
|
wrap.appendChild(cage);
|
|
} else {
|
|
inner.style.cssText = 'display:block;width:100%;height:100%;';
|
|
wrap.appendChild(inner);
|
|
}
|
|
}
|
|
|
|
_buildVideoLayout(wrap) {
|
|
const layout = this._config.layout || 'split';
|
|
if (layout !== 'split') return;
|
|
|
|
const stack = document.createElement('div');
|
|
stack.style.cssText = [
|
|
'position:absolute', 'inset:0',
|
|
'display:flex', 'flex-direction:column',
|
|
'background:black',
|
|
].join(';');
|
|
wrap.appendChild(stack);
|
|
|
|
const mkPlainVideo = (objectFit) => {
|
|
const v = document.createElement('video');
|
|
v.autoplay = true;
|
|
v.muted = true;
|
|
v.controls = false;
|
|
v.playsInline = true;
|
|
v.disablePictureInPicture = true;
|
|
// Prevent iOS Safari / Android Chrome from auto-entering native
|
|
// fullscreen on landscape rotation. `playsinline` (W3C) covers modern
|
|
// browsers; `webkit-playsinline` covers older iOS; the others stop
|
|
// the chrome from offering external playback or native PiP.
|
|
v.setAttribute('playsinline', '');
|
|
v.setAttribute('webkit-playsinline', '');
|
|
v.setAttribute('x5-playsinline', '');
|
|
v.setAttribute('disablepictureinpicture', '');
|
|
v.setAttribute('disableremoteplayback', '');
|
|
v.style.cssText = [
|
|
'width:100%', 'height:100%',
|
|
`object-fit:${objectFit}`,
|
|
'background:black', 'display:block',
|
|
].join(';');
|
|
return v;
|
|
};
|
|
|
|
// Top: a wrapper div with aspect-ratio carries the height (more reliable
|
|
// than aspect-ratio on the flex item itself). Inside, the video uses
|
|
// object-fit: contain. Updated to the actual stream aspect on metadata.
|
|
const topMaxVh = this._config.top_max_height_vh || 35;
|
|
this._topFrame = document.createElement('div');
|
|
this._topFrame.style.cssText = [
|
|
'flex:0 0 auto',
|
|
'width:100%',
|
|
'aspect-ratio:16/9',
|
|
`max-height:${topMaxVh}vh`,
|
|
'background:black',
|
|
].join(';');
|
|
this._topVideo = mkPlainVideo('contain');
|
|
this._topFrame.appendChild(this._topVideo);
|
|
|
|
// Bottom: takes whatever vertical space is left.
|
|
this._bottomVideo = mkPlainVideo('cover');
|
|
this._bottomVideo.style.flex = '1 1 auto';
|
|
this._bottomVideo.style.minHeight = '0';
|
|
|
|
const updateTopAspect = () => {
|
|
const vw = this._topVideo.videoWidth;
|
|
const vh = this._topVideo.videoHeight;
|
|
if (vw && vh) this._topFrame.style.aspectRatio = `${vw}/${vh}`;
|
|
};
|
|
this._topVideo.addEventListener('loadedmetadata', updateTopAspect);
|
|
this._topVideo.addEventListener('resize', updateTopAspect);
|
|
|
|
stack.appendChild(this._topFrame);
|
|
stack.appendChild(this._bottomVideo);
|
|
|
|
// In landscape, the camera fits naturally on the screen — drop the
|
|
// top contain-view and let the bottom cover-view fill everything.
|
|
// Set `landscape_hide_top: false` to keep the split in landscape.
|
|
if (this._config.landscape_hide_top !== false) {
|
|
const mql = window.matchMedia('(orientation: landscape)');
|
|
const apply = (matches) => {
|
|
this._topFrame.style.display = matches ? 'none' : '';
|
|
};
|
|
apply(mql.matches);
|
|
const handler = (e) => apply(e.matches);
|
|
if (mql.addEventListener) mql.addEventListener('change', handler);
|
|
else mql.addListener(handler);
|
|
this._orientationMql = mql;
|
|
this._orientationHandler = handler;
|
|
}
|
|
}
|
|
|
|
_mirrorStream() {
|
|
const src = this._sourceVideo;
|
|
if (!src) return;
|
|
const stream = src.srcObject;
|
|
if (!stream) return;
|
|
if (this._topVideo && this._topVideo.srcObject !== stream) {
|
|
this._topVideo.srcObject = stream;
|
|
this._topVideo.play?.().catch(() => {});
|
|
}
|
|
if (this._bottomVideo && this._bottomVideo.srcObject !== stream) {
|
|
this._bottomVideo.srcObject = stream;
|
|
this._bottomVideo.play?.().catch(() => {});
|
|
}
|
|
// Belt-and-suspenders: keep the top frame's aspect-ratio in sync with
|
|
// the actual stream dimensions, even if events didn't fire.
|
|
if (this._topFrame && this._topVideo) {
|
|
const vw = this._topVideo.videoWidth || src.videoWidth;
|
|
const vh = this._topVideo.videoHeight || src.videoHeight;
|
|
if (vw && vh) {
|
|
const desired = `${vw}/${vh}`;
|
|
if (this._topFrame.dataset.ratio !== desired) {
|
|
this._topFrame.style.aspectRatio = desired;
|
|
this._topFrame.dataset.ratio = desired;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
clearInterval(this._mirrorInterval);
|
|
this._mirrorInterval = null;
|
|
if (this._orientationMql && this._orientationHandler) {
|
|
if (this._orientationMql.removeEventListener) {
|
|
this._orientationMql.removeEventListener('change', this._orientationHandler);
|
|
} else {
|
|
this._orientationMql.removeListener(this._orientationHandler);
|
|
}
|
|
}
|
|
}
|
|
|
|
_makeBtn(icon, bg, onClick) {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.style.cssText = [
|
|
`background:${bg}`,
|
|
'border:none', 'color:white',
|
|
'width:72px', 'height:72px', 'border-radius:50%',
|
|
'cursor:pointer', 'pointer-events:auto',
|
|
'display:flex', 'align-items:center', 'justify-content:center',
|
|
'box-shadow:0 4px 12px rgba(0,0,0,0.4)',
|
|
'-webkit-tap-highlight-color:transparent',
|
|
'transition:transform 120ms ease',
|
|
'padding:0',
|
|
].join(';');
|
|
const ic = document.createElement('ha-icon');
|
|
ic.icon = icon;
|
|
ic.style.cssText = '--mdc-icon-size:36px;color:white;pointer-events:none;';
|
|
btn.appendChild(ic);
|
|
btn.addEventListener('pointerdown', () => { btn.style.transform = 'scale(0.9)'; });
|
|
const release = () => { btn.style.transform = ''; };
|
|
btn.addEventListener('pointerup', release);
|
|
btn.addEventListener('pointercancel', release);
|
|
btn.addEventListener('pointerleave', release);
|
|
btn.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
|
|
return btn;
|
|
}
|
|
|
|
_unlock() {
|
|
const action = this._config.unlock_action;
|
|
if (!action || !this._hass) return;
|
|
|
|
if (navigator.vibrate) navigator.vibrate([40, 60, 40]);
|
|
this._fireAction(action);
|
|
this._flashUnlock();
|
|
}
|
|
|
|
_fireAction(action) {
|
|
// Minimal action dispatcher — supports the common shapes used in HA
|
|
// tap_action configs. Only `call-service` / `perform-action` is wired
|
|
// up; other actions are dispatched as a hass-action DOM event so HA's
|
|
// own action handlers can pick them up if available.
|
|
const type = action.action || action.service ? 'call-service' : null;
|
|
if ((action.action === 'call-service' || action.action === 'perform-action' || (!action.action && action.service))) {
|
|
const svc = action.service || action.perform_action;
|
|
if (!svc) return;
|
|
const [domain, name] = svc.split('.');
|
|
const data = action.data || action.service_data || {};
|
|
const target = action.target || {};
|
|
this._hass.callService(domain, name, data, target);
|
|
return;
|
|
}
|
|
|
|
// Fallback: dispatch a hass-action event the way HA expects
|
|
const event = new Event('hass-action', { bubbles: true, composed: true });
|
|
event.detail = { config: { tap_action: action }, action: 'tap' };
|
|
this.dispatchEvent(event);
|
|
}
|
|
|
|
_flashUnlock() {
|
|
const btn = this._unlockBtn;
|
|
const icon = this._unlockIcon;
|
|
if (!btn || !icon) return;
|
|
const origBg = 'rgba(0,0,0,0.55)';
|
|
const origIcon = this._config.unlock_icon || 'mdi:key';
|
|
|
|
icon.icon = 'mdi:check-bold';
|
|
btn.style.backgroundColor = '#15803d';
|
|
btn.animate(
|
|
[
|
|
{ transform: 'scale(0.9)', offset: 0 },
|
|
{ transform: 'scale(1.18)', offset: 0.35 },
|
|
{ transform: 'scale(1)', offset: 1 },
|
|
],
|
|
{ duration: 700, easing: 'ease-out' },
|
|
);
|
|
|
|
clearTimeout(this._unlockResetTimer);
|
|
this._unlockResetTimer = setTimeout(() => {
|
|
btn.style.backgroundColor = origBg;
|
|
icon.icon = origIcon;
|
|
}, 1000);
|
|
}
|
|
|
|
async _waitForVideo() {
|
|
for (let i = 0; i < 60; i++) {
|
|
const v = this._findVideo();
|
|
if (v) return v;
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_findVideo() {
|
|
if (!this._inner) return null;
|
|
return (
|
|
this._inner.querySelector?.('video') ||
|
|
this._inner.shadowRoot?.querySelector?.('video') ||
|
|
null
|
|
);
|
|
}
|
|
|
|
_setActive(active) {
|
|
// In split layout the source video is hidden and we mirror its stream onto
|
|
// display videos. Keep the source muted so audio comes from exactly one
|
|
// visible video (the bottom one). Outside split layout the source video
|
|
// *is* the visible one, so we toggle that.
|
|
const split = (this._config.layout || 'split') === 'split';
|
|
if (split) {
|
|
if (this._sourceVideo) this._sourceVideo.muted = true;
|
|
if (this._bottomVideo) this._bottomVideo.muted = !active;
|
|
} else if (this._sourceVideo) {
|
|
this._sourceVideo.muted = !active;
|
|
}
|
|
const stream = window.__webrtcDoorbellMicStream;
|
|
if (stream) {
|
|
stream.getAudioTracks().forEach((t) => { t.enabled = active; });
|
|
}
|
|
}
|
|
|
|
_answer() {
|
|
if (navigator.vibrate) navigator.vibrate(30);
|
|
this._setActive(true);
|
|
this._answerBtn.style.display = 'none';
|
|
this._endBtn.style.display = 'flex';
|
|
}
|
|
|
|
_end() {
|
|
if (navigator.vibrate) navigator.vibrate(30);
|
|
this._setActive(false);
|
|
this._answerBtn.style.display = 'flex';
|
|
this._endBtn.style.display = 'none';
|
|
}
|
|
|
|
getCardSize() { return 6; }
|
|
static getStubConfig() { return { entity: 'camera.doorbell' }; }
|
|
}
|
|
|
|
if (!customElements.get('webrtc-doorbell-card')) {
|
|
customElements.define('webrtc-doorbell-card', WebrtcDoorbellCard);
|
|
}
|
|
|
|
window.customCards = window.customCards || [];
|
|
if (!window.customCards.find((c) => c.type === 'webrtc-doorbell-card')) {
|
|
window.customCards.push({
|
|
type: 'webrtc-doorbell-card',
|
|
name: 'WebRTC Doorbell',
|
|
description: 'Doorbell with answer/end call (toggles both audio directions)',
|
|
documentationURL: 'https://github.com/naps62/ha-webrtc-doorbell-card',
|
|
});
|
|
}
|
|
|
|
console.info(
|
|
`%c WEBRTC-DOORBELL-CARD %c v${VERSION} `,
|
|
'color:white;background:#15803d;font-weight:700',
|
|
'color:#15803d;background:white;font-weight:700',
|
|
);
|