Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2cf430cd8 | |||
| ddcdda2d5a |
@@ -38,9 +38,14 @@ 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
|
layout: split # optional — 'split' (default), 'cover', or 'contain'
|
||||||
# whether the video crops to fill the viewport ('cover')
|
# split: top half shows the full uncropped frame,
|
||||||
# or letterboxes to preserve the full frame ('contain')
|
# 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:
|
||||||
|
|||||||
+117
-11
@@ -24,7 +24,7 @@
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const VERSION = '0.5.0';
|
const VERSION = '0.7.0';
|
||||||
|
|
||||||
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,111 @@ 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
|
this._sourceVideo = v;
|
||||||
// would otherwise letterbox heavily on a portrait phone.
|
const layout = this._config.layout || 'split';
|
||||||
const fit = this._config.object_fit || 'cover';
|
if (layout === 'split') {
|
||||||
v.style.objectFit = fit;
|
// Hidden inner video drives WebRTC; mirror its srcObject onto our
|
||||||
v.style.width = '100%';
|
// display videos. Poll because srcObject can change on reconnect
|
||||||
v.style.height = '100%';
|
// 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 mkVideo = (objectFit, flex, extra) => {
|
||||||
|
const v = document.createElement('video');
|
||||||
|
v.autoplay = true;
|
||||||
|
v.muted = true;
|
||||||
|
v.playsInline = true;
|
||||||
|
v.setAttribute('playsinline', '');
|
||||||
|
v.style.cssText = [
|
||||||
|
`flex:${flex}`,
|
||||||
|
'width:100%', 'min-height:0',
|
||||||
|
`object-fit:${objectFit}`,
|
||||||
|
'background:black',
|
||||||
|
extra || '',
|
||||||
|
].join(';');
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Top: full uncropped frame, sized to match the video's aspect ratio so
|
||||||
|
// there's no letterbox. Bottom: takes the remaining height with a cover crop.
|
||||||
|
// We default to a 16:9 aspect-ratio and refine once metadata loads.
|
||||||
|
this._topVideo = mkVideo(
|
||||||
|
'contain',
|
||||||
|
'0 0 auto',
|
||||||
|
'aspect-ratio:16/9;max-height:50vh',
|
||||||
|
);
|
||||||
|
this._bottomVideo = mkVideo('cover', '1 1 auto');
|
||||||
|
this._topVideo.addEventListener('loadedmetadata', () => {
|
||||||
|
const vw = this._topVideo.videoWidth;
|
||||||
|
const vh = this._topVideo.videoHeight;
|
||||||
|
if (vw && vh) this._topVideo.style.aspectRatio = `${vw}/${vh}`;
|
||||||
|
});
|
||||||
|
stack.appendChild(this._topVideo);
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +303,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; });
|
||||||
|
|||||||
Reference in New Issue
Block a user