Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4495aa4dfe | |||
| f21e02a16d | |||
| 2627501b44 |
@@ -38,6 +38,15 @@ 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.
|
||||||
|
landscape_hide_top: true # optional, only for layout: split — when true
|
||||||
|
# (default), hides the top full-frame view on
|
||||||
|
# landscape orientation since the cropped bottom
|
||||||
|
# already fills the screen well. Set false to
|
||||||
|
# keep the split in landscape too.
|
||||||
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.
|
||||||
|
|||||||
+85
-19
@@ -24,7 +24,7 @@
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const VERSION = '0.7.0';
|
const VERSION = '0.8.0';
|
||||||
|
|
||||||
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,38 +155,76 @@ 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);
|
||||||
|
|
||||||
|
// 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() {
|
_mirrorStream() {
|
||||||
@@ -194,11 +240,31 @@ 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() {
|
||||||
clearInterval(this._mirrorInterval);
|
clearInterval(this._mirrorInterval);
|
||||||
this._mirrorInterval = null;
|
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) {
|
_makeBtn(icon, bg, onClick) {
|
||||||
|
|||||||
Reference in New Issue
Block a user