2 Commits

2 changed files with 66 additions and 19 deletions
+4
View File
@@ -38,6 +38,10 @@ 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
top_max_height_vh: 35 # optional, only for layout: split — caps the
# full-frame top section at this many vh units
# (default 35). Lower this if the top half feels
# too dominant on tall portrait screens.
layout: split # optional — 'split' (default), 'cover', or 'contain' layout: split # optional — 'split' (default), 'cover', or 'contain'
# split: top half shows the full uncropped frame, # split: top half shows the full uncropped frame,
# bottom half is a center-cropped 'cover' view. # bottom half is a center-cropped 'cover' view.
+62 -19
View File
@@ -24,7 +24,7 @@
}; };
})(); })();
const VERSION = '0.7.0'; const VERSION = '0.7.2';
class WebrtcDoorbellCard extends HTMLElement { class WebrtcDoorbellCard extends HTMLElement {
setConfig(config) { setConfig(config) {
@@ -96,6 +96,14 @@ class WebrtcDoorbellCard extends HTMLElement {
this._waitForVideo().then((v) => { this._waitForVideo().then((v) => {
if (!v) return; if (!v) return;
v.muted = true; 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; this._sourceVideo = v;
const layout = this._config.layout || 'split'; const layout = this._config.layout || 'split';
if (layout === 'split') { if (layout === 'split') {
@@ -147,37 +155,59 @@ class WebrtcDoorbellCard extends HTMLElement {
].join(';'); ].join(';');
wrap.appendChild(stack); wrap.appendChild(stack);
const mkVideo = (objectFit, flex, extra) => { const mkPlainVideo = (objectFit) => {
const v = document.createElement('video'); const v = document.createElement('video');
v.autoplay = true; v.autoplay = true;
v.muted = true; v.muted = true;
v.controls = false;
v.playsInline = true; 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('playsinline', '');
v.setAttribute('webkit-playsinline', '');
v.setAttribute('x5-playsinline', '');
v.setAttribute('disablepictureinpicture', '');
v.setAttribute('disableremoteplayback', '');
v.style.cssText = [ v.style.cssText = [
`flex:${flex}`, 'width:100%', 'height:100%',
'width:100%', 'min-height:0',
`object-fit:${objectFit}`, `object-fit:${objectFit}`,
'background:black', 'background:black', 'display:block',
extra || '',
].join(';'); ].join(';');
return v; return v;
}; };
// Top: full uncropped frame, sized to match the video's aspect ratio so // Top: a wrapper div with aspect-ratio carries the height (more reliable
// there's no letterbox. Bottom: takes the remaining height with a cover crop. // than aspect-ratio on the flex item itself). Inside, the video uses
// We default to a 16:9 aspect-ratio and refine once metadata loads. // object-fit: contain. Updated to the actual stream aspect on metadata.
this._topVideo = mkVideo( const topMaxVh = this._config.top_max_height_vh || 35;
'contain', this._topFrame = document.createElement('div');
'0 0 auto', this._topFrame.style.cssText = [
'aspect-ratio:16/9;max-height:50vh', 'flex:0 0 auto',
); 'width:100%',
this._bottomVideo = mkVideo('cover', '1 1 auto'); 'aspect-ratio:16/9',
this._topVideo.addEventListener('loadedmetadata', () => { `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 vw = this._topVideo.videoWidth;
const vh = this._topVideo.videoHeight; const vh = this._topVideo.videoHeight;
if (vw && vh) this._topVideo.style.aspectRatio = `${vw}/${vh}`; if (vw && vh) this._topFrame.style.aspectRatio = `${vw}/${vh}`;
}); };
stack.appendChild(this._topVideo); this._topVideo.addEventListener('loadedmetadata', updateTopAspect);
this._topVideo.addEventListener('resize', updateTopAspect);
stack.appendChild(this._topFrame);
stack.appendChild(this._bottomVideo); stack.appendChild(this._bottomVideo);
} }
@@ -194,6 +224,19 @@ class WebrtcDoorbellCard extends HTMLElement {
this._bottomVideo.srcObject = stream; this._bottomVideo.srcObject = stream;
this._bottomVideo.play?.().catch(() => {}); 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() { disconnectedCallback() {