4 Commits

2 changed files with 172 additions and 14 deletions
+12 -3
View File
@@ -38,9 +38,18 @@ unlock_action: # optional — omit to hide the unlock button
entity_id: camera.doorbell_main entity_id: camera.doorbell_main
unlock_icon: mdi:key # optional, defaults to mdi:key unlock_icon: mdi:key # optional, defaults to mdi:key
mode: webrtc # optional, passed through to webrtc-camera mode: webrtc # optional, passed through to webrtc-camera
object_fit: cover # optional, 'cover' (default) or 'contain' — controls top_max_height_vh: 35 # optional, only for layout: split — caps the
# whether the video crops to fill the viewport ('cover') # full-frame top section at this many vh units
# or letterboxes to preserve the full frame ('contain') # (default 35). Lower this if the top half feels
# too dominant on tall portrait screens.
layout: split # optional — 'split' (default), 'cover', or 'contain'
# split: top half shows the full uncropped frame,
# bottom half is a center-cropped 'cover' view.
# One WebRTC connection drives both.
# cover: single video, fills viewport (crops sides
# on portrait phones with landscape cameras).
# contain: single video, letterboxed to preserve frame.
object_fit: cover # optional, only honored with layout: cover/contain
``` ```
A `panel: true` view works best: A `panel: true` view works best:
+160 -11
View File
@@ -24,7 +24,7 @@
}; };
})(); })();
const VERSION = '0.5.0'; const VERSION = '0.7.2';
class WebrtcDoorbellCard extends HTMLElement { class WebrtcDoorbellCard extends HTMLElement {
setConfig(config) { setConfig(config) {
@@ -58,10 +58,11 @@ class WebrtcDoorbellCard extends HTMLElement {
ui: false, ui: false,
background: true, background: true,
}); });
inner.style.cssText = 'display:block;width:100%;height:100%;';
if (this._hass) inner.hass = this._hass; if (this._hass) inner.hass = this._hass;
this._inner = inner; this._inner = inner;
wrap.appendChild(inner); this._appendInner(wrap, inner);
this._buildVideoLayout(wrap);
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.style.cssText = [ overlay.style.cssText = [
@@ -95,15 +96,154 @@ class WebrtcDoorbellCard extends HTMLElement {
this._waitForVideo().then((v) => { this._waitForVideo().then((v) => {
if (!v) return; if (!v) return;
v.muted = true; v.muted = true;
// Fill the viewport in both portrait and landscape — landscape camera v.controls = false;
// would otherwise letterbox heavily on a portrait phone. v.playsInline = true;
const fit = this._config.object_fit || 'cover'; v.disablePictureInPicture = true;
v.style.objectFit = fit; v.setAttribute('playsinline', '');
v.style.width = '100%'; v.setAttribute('webkit-playsinline', '');
v.style.height = '100%'; 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);
}
_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;
}
_makeBtn(icon, bg, onClick) { _makeBtn(icon, bg, onClick) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
@@ -206,8 +346,17 @@ class WebrtcDoorbellCard extends HTMLElement {
} }
_setActive(active) { _setActive(active) {
const v = this._findVideo(); // In split layout the source video is hidden and we mirror its stream onto
if (v) v.muted = !active; // 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; const stream = window.__webrtcDoorbellMicStream;
if (stream) { if (stream) {
stream.getAudioTracks().forEach((t) => { t.enabled = active; }); stream.getAudioTracks().forEach((t) => { t.enabled = active; });