Initial Home Assistant commit

This commit is contained in:
2026-06-05 22:34:31 -04:00
commit 6a58b10e6c
4494 changed files with 297833 additions and 0 deletions
+346
View File
@@ -0,0 +1,346 @@
// js version generated from https://github.com/dbuezas/pan-zoom-controller/blob/main/src/digital-ptz.ts
const ONE_FINGER_ZOOM_SPEED = 1 / 200; // 1 scale every 200px
const DBL_CLICK_MS = 400;
const MAX_ZOOM = 10;
const DEFAULT_OPTIONS = {
touch_drag_pan: true,
touch_tap_drag_zoom: true,
mouse_drag_pan: true,
mouse_wheel_zoom: true,
mouse_double_click_zoom: true,
touch_pinch_zoom: true,
persist_key: "",
persist: true,
};
export class DigitalPTZ {
constructor(containerEl, transformEl, videoEl, options) {
this.offHandles = [];
this.recomputeRects = () => {
this.transform.updateRects(this.videoEl, this.containerEl);
this.transform.zoomAtCoords(1, 0, 0); // clamp transform
this.render();
};
this.render = (transition = false) => {
if (transition) {
// transition is used to animate dbl click zoom
this.transformEl.style.transition = "transform 200ms";
setTimeout(() => {
this.transformEl.style.transition = "";
}, 200);
}
this.transformEl.style.transform = this.transform.render();
};
this.containerEl = containerEl;
this.transformEl = transformEl;
this.videoEl = videoEl;
this.options = Object.assign({}, DEFAULT_OPTIONS, options);
this.transform = new Transform({
persist_key: this.options.persist_key,
persist: this.options.persist,
});
const o = this.options;
const gestureParam = {
containerEl: this.containerEl,
transform: this.transform,
render: this.render,
};
const h = this.offHandles;
if (o.mouse_drag_pan) h.push(startMouseDragPan(gestureParam));
if (o.mouse_wheel_zoom) h.push(startMouseWheel(gestureParam));
if (o.mouse_double_click_zoom) h.push(startDoubleClickZoom(gestureParam));
if (o.touch_tap_drag_zoom) h.push(startTouchTapDragZoom(gestureParam));
if (o.touch_drag_pan) h.push(startTouchDragPan(gestureParam));
if (o.touch_pinch_zoom) h.push(startTouchPinchZoom(gestureParam));
this.videoEl.addEventListener("loadedmetadata", this.recomputeRects);
this.resizeObserver = new ResizeObserver(this.recomputeRects);
this.resizeObserver.observe(this.containerEl);
this.recomputeRects();
}
destroy() {
for (const off of this.offHandles) off();
this.videoEl.removeEventListener("loadedmetadata", this.recomputeRects);
this.resizeObserver.unobserve(this.containerEl);
}
}
/* Gestures */
const preventScroll = (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
};
const getCenter = (touches) => ({
x: (touches[0].pageX + touches[1].pageX) / 2,
y: (touches[0].pageY + touches[1].pageY) / 2,
});
const getSpread = (touches) =>
Math.hypot(
touches[0].pageX - touches[1].pageX,
touches[0].pageY - touches[1].pageY
);
function startTouchPinchZoom({ containerEl, transform, render }) {
const onTouchStart = (downEvent) => {
const relevant = downEvent.touches.length === 2;
if (!relevant) return;
let lastTouches = downEvent.touches;
const onTouchMove = (moveEvent) => {
const newTouches = moveEvent.touches;
const oldCenter = getCenter(lastTouches);
const newCenter = getCenter(newTouches);
const dx = newCenter.x - oldCenter.x;
const dy = newCenter.y - oldCenter.y;
transform.move(dx, dy);
const oldSpread = getSpread(lastTouches);
const newSpread = getSpread(newTouches);
const zoom = newSpread / oldSpread;
transform.zoomAtCoords(zoom, newCenter.x, newCenter.y);
lastTouches = moveEvent.touches;
render();
preventScroll(moveEvent);
};
const onTouchEnd = () =>
containerEl.removeEventListener("touchmove", onTouchMove);
containerEl.addEventListener("touchmove", onTouchMove);
containerEl.addEventListener("touchend", onTouchEnd, { once: true });
};
containerEl.addEventListener("touchstart", onTouchStart);
return () => containerEl.removeEventListener("touchstart", onTouchStart);
}
const getDist = (t1, t2) =>
Math.hypot(
t1.touches[0].pageX - t2.touches[0].pageX,
t1.touches[0].pageY - t2.touches[0].pageY
);
function startTouchTapDragZoom({ containerEl, transform, render }) {
let lastEvent;
let fastClicks = 0;
const onTouchStart = (downEvent) => {
const isFastClick =
lastEvent && downEvent.timeStamp - lastEvent.timeStamp < DBL_CLICK_MS;
if (!isFastClick) fastClicks = 0;
fastClicks++;
if (downEvent.touches.length > 1) fastClicks = 0;
lastEvent = downEvent;
};
const onTouchMove = (moveEvent) => {
if (fastClicks === 2) {
const lastY = lastEvent.touches[0].pageY;
const currY = moveEvent.touches[0].pageY;
transform.zoom(1 - (lastY - currY) * ONE_FINGER_ZOOM_SPEED);
lastEvent = moveEvent;
render();
preventScroll(moveEvent);
} else if (getDist(lastEvent, moveEvent) > 10) {
fastClicks = 0;
}
};
containerEl.addEventListener("touchmove", onTouchMove);
containerEl.addEventListener("touchstart", onTouchStart);
return () => {
containerEl.removeEventListener("touchmove", onTouchMove);
containerEl.removeEventListener("touchstart", onTouchStart);
};
}
function startMouseWheel({ containerEl, transform, render }) {
const onWheel = (e) => {
const zoom = 1 - e.deltaY / 1000;
transform.zoomAtCoords(zoom, e.pageX, e.pageY);
render();
preventScroll(e);
};
containerEl.addEventListener("wheel", onWheel);
return () => containerEl.removeEventListener("wheel", onWheel);
}
function startDoubleClickZoom({ containerEl, transform, render }) {
let lastDown = 0;
let clicks = 0;
const onDown = (downEvent) => {
const isFastClick = downEvent.timeStamp - lastDown < DBL_CLICK_MS;
lastDown = downEvent.timeStamp;
if (!isFastClick) clicks = 0;
clicks++;
if (clicks !== 2) return;
const onUp = (upEvent) => {
const isQuickRelease = upEvent.timeStamp - lastDown < DBL_CLICK_MS;
const dist = Math.hypot(
upEvent.pageX - downEvent.pageX,
upEvent.pageY - downEvent.pageY
);
if (!isQuickRelease || dist > 20) return;
const zoom = transform.scale == 1 ? 2 : 0.01;
transform.zoomAtCoords(zoom, upEvent.pageX, upEvent.pageY);
render(true);
};
window.addEventListener("mouseup", onUp, { once: true });
};
containerEl.addEventListener("mousedown", onDown);
return () => containerEl.removeEventListener("mousedown", onDown);
}
function startGesturePan({ containerEl, transform, render }, type) {
const [downName, moveName, upName] =
type === "mouse"
? ["mousedown", "mousemove", "mouseup"]
: ["touchstart", "touchmove", "touchend"];
const isTouchEvent = ev => 'TouchEvent' in window && ev instanceof TouchEvent;
const onDown = (downEvt) => {
let last = isTouchEvent(downEvt) ? downEvt.touches[0] : downEvt;
const onMove = (moveEvt) => {
if (isTouchEvent(moveEvt) && moveEvt.touches.length !== 1) return;
const curr = isTouchEvent(moveEvt) ? moveEvt.touches[0] : moveEvt;
transform.move(curr.pageX - last.pageX, curr.pageY - last.pageY);
last = curr;
render();
if (transform.scale !== 1) preventScroll(moveEvt);
};
containerEl.addEventListener(moveName, onMove);
const onUp = () => containerEl.removeEventListener(moveName, onMove);
window.addEventListener(upName, onUp, { once: true });
};
containerEl.addEventListener(downName, onDown);
return () => containerEl.removeEventListener(downName, onDown);
}
function startTouchDragPan(params) {
return startGesturePan(params, "touch");
}
function startMouseDragPan(params) {
return startGesturePan(params, "mouse");
}
/** Transform */
const PERSIST_KEY_PREFIX = "webrtc-digital-ptc:";
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
function getTransformedDimensions(video) {
const { videoWidth, videoHeight } = video;
if (!videoHeight || !videoWidth) return undefined;
var transform = window.getComputedStyle(video).getPropertyValue("transform");
const match = transform.match(/matrix\((.+)\)/);
if (!match || !match[1]) return { videoWidth, videoHeight }; // the video isn't transformed
const matrix = new DOMMatrix(match[1].split(", ").map(Number));
const points = [
new DOMPoint(0, 0),
new DOMPoint(videoWidth, 0),
new DOMPoint(0, videoHeight),
new DOMPoint(videoWidth, videoHeight),
].map((point) => point.matrixTransform(matrix));
const minX = Math.min(...points.map((point) => point.x));
const maxX = Math.max(...points.map((point) => point.x));
const minY = Math.min(...points.map((point) => point.y));
const maxY = Math.max(...points.map((point) => point.y));
return { videoWidth: maxX - minX, videoHeight: maxY - minY };
}
class Transform {
constructor(settings) {
this.scale = 1;
this.x = 0;
this.y = 0;
this.loadPersistedTransform = () => {
const { persist_key, persist } = this.settings;
if (!persist) return;
try {
const loaded = JSON.parse(localStorage[persist_key]);
const isValid = [loaded.scale, loaded.x, loaded.y].every(
Number.isFinite
);
if (!isValid) {
throw new Error("Broken local storage");
}
this.x = loaded.x;
this.y = loaded.y;
this.scale = loaded.scale;
} catch (e) {
delete localStorage[persist_key];
}
};
this.persistTransform = () => {
const { persist_key, persist } = this.settings;
if (!persist) return;
const { x, y, scale } = this;
localStorage[persist_key] = JSON.stringify({
x,
y,
scale,
});
};
this.settings = Object.assign(Object.assign({}, settings), {
persist_key: PERSIST_KEY_PREFIX + settings.persist_key,
});
this.loadPersistedTransform();
}
updateRects(videoEl, containerEl) {
const containerRect = containerEl.getBoundingClientRect();
if (containerRect.width === 0 || containerRect.height === 0) {
// The container rect has no size yet.
// This happens when coming back to a tab that was already opened.
// The card will get size shortly and the size observer will call this function again.
return;
}
this.containerRect = containerRect;
const transformed = getTransformedDimensions(videoEl);
if (!transformed) {
// The video hasn't loaded yet.
// Once it loads, the videometadata listener will call this function again.
return;
}
// When in full screen, and if the aspect ratio of the screen differs from that of the video,
// black bars will be shown either to the sides or above/below the video.
// This needs to be accounted for when panning, the code below keeps track of that.
const screenAspectRatio =
this.containerRect.width / this.containerRect.height;
const videoAspectRatio = transformed.videoWidth / transformed.videoHeight;
if (videoAspectRatio > screenAspectRatio) {
// Black bars on the top and bottom
const videoHeight = this.containerRect.width / videoAspectRatio;
const blackBarHeight = (this.containerRect.height - videoHeight) / 2;
this.videoRect = new DOMRect(
this.containerRect.x,
blackBarHeight + this.containerRect.y,
this.containerRect.width,
videoHeight
);
} else {
// Black bars on the sides
const videoWidth = this.containerRect.height * videoAspectRatio;
const blackBarWidth = (this.containerRect.width - videoWidth) / 2;
this.videoRect = new DOMRect(
blackBarWidth + this.containerRect.x,
this.containerRect.y,
videoWidth,
this.containerRect.height
);
}
}
// dx,dy are deltas.
move(dx, dy) {
if (!this.videoRect) return;
const bound = (this.scale - 1) / 2;
this.x += dx / this.videoRect.width;
this.y += dy / this.videoRect.height;
this.x = clamp(this.x, -bound, bound);
this.y = clamp(this.y, -bound, bound);
this.persistTransform();
}
// x,y are relative to viewport (clientX, clientY)
zoomAtCoords(zoom, x, y) {
if (!this.containerRect || !this.videoRect) return;
const oldScale = this.scale;
this.scale *= zoom;
this.scale = clamp(this.scale, 1, MAX_ZOOM);
zoom = this.scale / oldScale;
x = x - this.containerRect.x - this.containerRect.width / 2;
y = y - this.containerRect.y - this.containerRect.height / 2;
const dx = x - this.x * this.videoRect.width;
const dy = y - this.y * this.videoRect.height;
this.move(dx * (1 - zoom), dy * (1 - zoom));
}
zoom(zoom) {
if (!this.containerRect || !this.videoRect) return;
const x = this.containerRect.width / 2;
const y = this.containerRect.height / 2;
this.zoomAtCoords(zoom, x, y);
}
render() {
if (!this.videoRect) return "";
const { x, y, scale } = this;
return `translate(${x * this.videoRect.width}px, ${
y * this.videoRect.height
}px) scale(${scale})`;
}
}
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta content="width=device-width, initial-scale=1" name="viewport">
<title>WebRTC Camera</title>
<style>
body {
margin: 0;
padding: 0;
font-family: Arial, Helvetica, sans-serif;
}
html, body, webrtc-camera {
height: 100%;
width: 100%;
}
ha-card {
display: block;
}
</style>
</head>
<body>
<script type="module" src="/webrtc/webrtc-camera.js"></script>
<script type="module">
const config = {};
for (const [k, v] of new URLSearchParams(location.search)) {
if (v === 'true') config[k] = true;
else if (v === 'false') config[k] = false;
else config[k] = v;
}
const card = document.createElement('webrtc-camera');
card.setConfig(config);
card.hass = {
callWS: () => new Promise(resolve => {
resolve('');
}),
hassUrl: () => location.origin + '/api/webrtc/ws?embed=1'
};
document.body.appendChild(card);
</script>
</body>
</html>
+676
View File
@@ -0,0 +1,676 @@
/**
* VideoRTC v1.6.0 - Video player for go2rtc streaming application.
*
* All modern web technologies are supported in almost any browser except Apple Safari.
*
* Support:
* - ECMAScript 2017 (ES8) = ES6 + async
* - RTCPeerConnection for Safari iOS 11.0+
* - IntersectionObserver for Safari iOS 12.2+
* - ManagedMediaSource for Safari 17+
*
* Doesn't support:
* - MediaSource for Safari iOS
* - Customized built-in elements (extends HTMLVideoElement) because Safari
* - Autoplay for WebRTC in Safari
*/
export class VideoRTC extends HTMLElement {
constructor() {
super();
this.DISCONNECT_TIMEOUT = 5000;
this.RECONNECT_TIMEOUT = 15000;
this.CODECS = [
'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen)
'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV)
'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra)
'mp4a.40.2', // AAC LC
'mp4a.40.5', // AAC HE
'flac', // FLAC (PCM compatible)
'opus', // OPUS Chrome, Firefox
];
/**
* [config] Supported modes (webrtc, webrtc/tcp, mse, hls, mp4, mjpeg).
* @type {string}
*/
this.mode = 'webrtc,mse,hls,mjpeg';
/**
* [Config] Requested medias (video, audio, microphone).
* @type {string}
*/
this.media = 'video,audio';
/**
* [config] Run stream when not displayed on the screen. Default `false`.
* @type {boolean}
*/
this.background = false;
/**
* [config] Run stream only when player in the viewport. Stop when user scroll out player.
* Value is percentage of visibility from `0` (not visible) to `1` (full visible).
* Default `0` - disable;
* @type {number}
*/
this.visibilityThreshold = 0;
/**
* [config] Run stream only when browser page on the screen. Stop when user change browser
* tab or minimise browser windows.
* @type {boolean}
*/
this.visibilityCheck = true;
/**
* [config] WebRTC configuration
* @type {RTCConfiguration}
*/
this.pcConfig = {
bundlePolicy: 'max-bundle',
iceServers: [{urls: 'stun:stun.l.google.com:19302'}],
sdpSemantics: 'unified-plan', // important for Chromecast 1
};
/**
* [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
* @type {number}
*/
this.wsState = WebSocket.CLOSED;
/**
* [info] WebRTC connection state.
* @type {number}
*/
this.pcState = WebSocket.CLOSED;
/**
* @type {HTMLVideoElement}
*/
this.video = null;
/**
* @type {WebSocket}
*/
this.ws = null;
/**
* @type {string|URL}
*/
this.wsURL = '';
/**
* @type {RTCPeerConnection}
*/
this.pc = null;
/**
* @type {number}
*/
this.connectTS = 0;
/**
* @type {string}
*/
this.mseCodecs = '';
/**
* [internal] Disconnect TimeoutID.
* @type {number}
*/
this.disconnectTID = 0;
/**
* [internal] Reconnect TimeoutID.
* @type {number}
*/
this.reconnectTID = 0;
/**
* [internal] Handler for receiving Binary from WebSocket.
* @type {Function}
*/
this.ondata = null;
/**
* [internal] Handlers list for receiving JSON from WebSocket.
* @type {Object.<string,Function>}
*/
this.onmessage = null;
}
/**
* Set video source (WebSocket URL). Support relative path.
* @param {string|URL} value
*/
set src(value) {
if (typeof value !== 'string') value = value.toString();
if (value.startsWith('http')) {
value = 'ws' + value.substring(4);
} else if (value.startsWith('/')) {
value = 'ws' + location.origin.substring(4) + value;
}
this.wsURL = value;
this.onconnect();
}
/**
* Play video. Support automute when autoplay blocked.
* https://developer.chrome.com/blog/autoplay/
*/
play() {
this.video.play().catch(() => {
if (!this.video.muted) {
this.video.muted = true;
this.video.play().catch(er => {
console.warn(er);
});
}
});
}
/**
* Send message to server via WebSocket
* @param {Object} value
*/
send(value) {
if (this.ws) this.ws.send(JSON.stringify(value));
}
/** @param {Function} isSupported */
codecs(isSupported) {
return this.CODECS
.filter(codec => this.media.indexOf(codec.indexOf('vc1') > 0 ? 'video' : 'audio') >= 0)
.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
}
/**
* `CustomElement`. Invoked each time the custom element is appended into a
* document-connected element.
*/
connectedCallback() {
if (this.disconnectTID) {
clearTimeout(this.disconnectTID);
this.disconnectTID = 0;
}
// because video autopause on disconnected from DOM
if (this.video) {
const seek = this.video.seekable;
if (seek.length > 0) {
this.video.currentTime = seek.end(seek.length - 1);
}
this.play();
} else {
this.oninit();
}
this.onconnect();
}
/**
* `CustomElement`. Invoked each time the custom element is disconnected from the
* document's DOM.
*/
disconnectedCallback() {
if (this.background || this.disconnectTID) return;
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
this.disconnectTID = setTimeout(() => {
if (this.reconnectTID) {
clearTimeout(this.reconnectTID);
this.reconnectTID = 0;
}
this.disconnectTID = 0;
this.ondisconnect();
}, this.DISCONNECT_TIMEOUT);
}
/**
* Creates child DOM elements. Called automatically once on `connectedCallback`.
*/
oninit() {
this.video = document.createElement('video');
this.video.controls = true;
this.video.playsInline = true;
this.video.preload = 'auto';
this.video.style.display = 'block'; // fix bottom margin 4px
this.video.style.width = '100%';
this.video.style.height = '100%';
this.appendChild(this.video);
this.video.addEventListener('error', ev => {
console.warn(ev);
if (this.ws) this.ws.close(); // run reconnect for broken MSE stream
});
// all Safari lies about supported audio codecs
const m = window.navigator.userAgent.match(/Version\/(\d+).+Safari/);
if (m) {
// AAC from v13, FLAC from v14, OPUS - unsupported
const skip = m[1] < '13' ? 'mp4a.40.2' : m[1] < '14' ? 'flac' : 'opus';
this.CODECS.splice(this.CODECS.indexOf(skip));
}
if (this.background) return;
if ('hidden' in document && this.visibilityCheck) {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.disconnectedCallback();
} else if (this.isConnected) {
this.connectedCallback();
}
});
}
if ('IntersectionObserver' in window && this.visibilityThreshold) {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) {
this.disconnectedCallback();
} else if (this.isConnected) {
this.connectedCallback();
}
});
}, {threshold: this.visibilityThreshold});
observer.observe(this);
}
}
/**
* Connect to WebSocket. Called automatically on `connectedCallback`.
* @return {boolean} true if the connection has started.
*/
onconnect() {
if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
// CLOSED or CONNECTING => CONNECTING
this.wsState = WebSocket.CONNECTING;
this.connectTS = Date.now();
this.ws = new WebSocket(this.wsURL);
this.ws.binaryType = 'arraybuffer';
this.ws.addEventListener('open', () => this.onopen());
this.ws.addEventListener('close', () => this.onclose());
return true;
}
ondisconnect() {
this.wsState = WebSocket.CLOSED;
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.pcState = WebSocket.CLOSED;
if (this.pc) {
this.pc.getSenders().forEach(sender => {
if (sender.track) sender.track.stop();
});
this.pc.close();
this.pc = null;
}
this.video.src = '';
this.video.srcObject = null;
}
/**
* @returns {Array.<string>} of modes (mse, webrtc, etc.)
*/
onopen() {
// CONNECTING => OPEN
this.wsState = WebSocket.OPEN;
this.ws.addEventListener('message', ev => {
if (typeof ev.data === 'string') {
const msg = JSON.parse(ev.data);
for (const mode in this.onmessage) {
this.onmessage[mode](msg);
}
} else {
this.ondata(ev.data);
}
});
this.ondata = null;
this.onmessage = {};
const modes = [];
if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
modes.push('mse');
this.onmse();
} else if (this.mode.indexOf('hls') >= 0 && this.video.canPlayType('application/vnd.apple.mpegurl')) {
modes.push('hls');
this.onhls();
} else if (this.mode.indexOf('mp4') >= 0) {
modes.push('mp4');
this.onmp4();
}
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) {
modes.push('webrtc');
this.onwebrtc();
}
if (this.mode.indexOf('mjpeg') >= 0) {
if (modes.length) {
this.onmessage['mjpeg'] = msg => {
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
this.onmjpeg();
};
} else {
modes.push('mjpeg');
this.onmjpeg();
}
}
return modes;
}
/**
* @return {boolean} true if reconnection has started.
*/
onclose() {
if (this.wsState === WebSocket.CLOSED) return false;
// CONNECTING, OPEN => CONNECTING
this.wsState = WebSocket.CONNECTING;
this.ws = null;
// reconnect no more than once every X seconds
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
this.reconnectTID = setTimeout(() => {
this.reconnectTID = 0;
this.onconnect();
}, delay);
return true;
}
onmse() {
/** @type {MediaSource} */
let ms;
if ('ManagedMediaSource' in window) {
const MediaSource = window.ManagedMediaSource;
ms = new MediaSource();
ms.addEventListener('sourceopen', () => {
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
}, {once: true});
this.video.disableRemotePlayback = true;
this.video.srcObject = ms;
} else {
ms = new MediaSource();
ms.addEventListener('sourceopen', () => {
URL.revokeObjectURL(this.video.src);
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
}, {once: true});
this.video.src = URL.createObjectURL(ms);
this.video.srcObject = null;
}
this.play();
this.mseCodecs = '';
this.onmessage['mse'] = msg => {
if (msg.type !== 'mse') return;
this.mseCodecs = msg.value;
const sb = ms.addSourceBuffer(msg.value);
sb.mode = 'segments'; // segments or sequence
sb.addEventListener('updateend', () => {
if (!sb.updating && bufLen > 0) {
try {
const data = buf.slice(0, bufLen);
sb.appendBuffer(data);
bufLen = 0;
} catch (e) {
// console.debug(e);
}
}
if (!sb.updating && sb.buffered && sb.buffered.length) {
const end = sb.buffered.end(sb.buffered.length - 1);
const start = end - 5;
const start0 = sb.buffered.start(0);
if (start > start0) {
sb.remove(start0, start);
ms.setLiveSeekableRange(start, end);
}
if (this.video.currentTime < start) {
this.video.currentTime = start;
}
const gap = end - this.video.currentTime;
this.video.playbackRate = gap > 0.1 ? gap : 0.1;
// console.debug('VideoRTC.buffered', gap, this.video.playbackRate, this.video.readyState);
}
});
const buf = new Uint8Array(2 * 1024 * 1024);
let bufLen = 0;
this.ondata = data => {
if (sb.updating || bufLen > 0) {
const b = new Uint8Array(data);
buf.set(b, bufLen);
bufLen += b.byteLength;
// console.debug('VideoRTC.buffer', b.byteLength, bufLen);
} else {
try {
sb.appendBuffer(data);
} catch (e) {
// console.debug(e);
}
}
};
};
}
onwebrtc() {
const pc = new RTCPeerConnection(this.pcConfig);
pc.addEventListener('icecandidate', ev => {
if (ev.candidate && this.mode.indexOf('webrtc/tcp') >= 0 && ev.candidate.protocol === 'udp') return;
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
this.send({type: 'webrtc/candidate', value: candidate});
});
pc.addEventListener('connectionstatechange', () => {
if (pc.connectionState === 'connected') {
const tracks = pc.getTransceivers()
.filter(tr => tr.currentDirection === 'recvonly') // skip inactive
.map(tr => tr.receiver.track);
/** @type {HTMLVideoElement} */
const video2 = document.createElement('video');
video2.addEventListener('loadeddata', () => this.onpcvideo(video2), {once: true});
video2.srcObject = new MediaStream(tracks);
} else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
pc.close(); // stop next events
this.pcState = WebSocket.CLOSED;
this.pc = null;
this.onconnect();
}
});
this.onmessage['webrtc'] = msg => {
switch (msg.type) {
case 'webrtc/candidate':
if (this.mode.indexOf('webrtc/tcp') >= 0 && msg.value.indexOf(' udp ') > 0) return;
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
console.warn(er);
});
break;
case 'webrtc/answer':
pc.setRemoteDescription({type: 'answer', sdp: msg.value}).catch(er => {
console.warn(er);
});
break;
case 'error':
if (msg.value.indexOf('webrtc/offer') < 0) return;
pc.close();
}
};
this.createOffer(pc).then(offer => {
this.send({type: 'webrtc/offer', value: offer.sdp});
});
this.pcState = WebSocket.CONNECTING;
this.pc = pc;
}
/**
* @param pc {RTCPeerConnection}
* @return {Promise<RTCSessionDescriptionInit>}
*/
async createOffer(pc) {
try {
if (this.media.indexOf('microphone') >= 0) {
const media = await navigator.mediaDevices.getUserMedia({audio: true});
media.getTracks().forEach(track => {
pc.addTransceiver(track, {direction: 'sendonly'});
});
}
} catch (e) {
console.warn(e);
}
for (const kind of ['video', 'audio']) {
if (this.media.indexOf(kind) >= 0) {
pc.addTransceiver(kind, {direction: 'recvonly'});
}
}
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
return offer;
}
/**
* @param video2 {HTMLVideoElement}
*/
onpcvideo(video2) {
if (this.pc) {
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
let rtcPriority = 0, msePriority = 0;
/** @type {MediaStream} */
const stream = video2.srcObject;
if (stream.getVideoTracks().length > 0) rtcPriority += 0x220;
if (stream.getAudioTracks().length > 0) rtcPriority += 0x102;
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
if (rtcPriority >= msePriority) {
this.video.srcObject = stream;
this.play();
this.pcState = WebSocket.OPEN;
this.wsState = WebSocket.CLOSED;
if (this.ws) {
this.ws.close();
this.ws = null;
}
} else {
this.pcState = WebSocket.CLOSED;
if (this.pc) {
this.pc.close();
this.pc = null;
}
}
}
video2.srcObject = null;
}
onmjpeg() {
this.ondata = data => {
this.video.controls = false;
this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data);
};
this.send({type: 'mjpeg'});
}
onhls() {
this.onmessage['hls'] = msg => {
if (msg.type !== 'hls') return;
const url = 'http' + this.wsURL.substring(2, this.wsURL.indexOf('/ws')) + '/hls/';
const playlist = msg.value.replace('hls/', url);
this.video.src = 'data:application/vnd.apple.mpegurl;base64,' + btoa(playlist);
this.play();
};
this.send({type: 'hls', value: this.codecs(type => this.video.canPlayType(type))});
}
onmp4() {
/** @type {HTMLCanvasElement} **/
const canvas = document.createElement('canvas');
/** @type {CanvasRenderingContext2D} */
let context;
/** @type {HTMLVideoElement} */
const video2 = document.createElement('video');
video2.autoplay = true;
video2.playsInline = true;
video2.muted = true;
video2.addEventListener('loadeddata', () => {
if (!context) {
canvas.width = video2.videoWidth;
canvas.height = video2.videoHeight;
context = canvas.getContext('2d');
}
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
this.video.controls = false;
this.video.poster = canvas.toDataURL('image/jpeg');
});
this.ondata = data => {
video2.src = 'data:video/mp4;base64,' + VideoRTC.btoa(data);
};
this.send({type: 'mp4', value: this.codecs(this.video.canPlayType)});
}
static btoa(buffer) {
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
let binary = '';
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
}
@@ -0,0 +1,691 @@
/** Chrome 63+, Safari 11.1+ */
import {VideoRTC} from './video-rtc.js?v=1.9.9';
import {DigitalPTZ} from './digital-ptz.js?v=3.3.0';
class WebRTCCamera extends VideoRTC {
/**
* Step 1. Called by the Hass, when config changed.
* @param {Object} config
*/
setConfig(config) {
if (!config.url && !config.entity && !config.streams) throw new Error('Missing `url` or `entity` or `streams`');
if (config.background) this.background = config.background;
if (config.intersection === 0) this.visibilityThreshold = 0;
else this.visibilityThreshold = config.intersection || 0.75;
/**
* @type {{
* url: string,
* entity: string,
* mode: string,
* media: string,
*
* streams: Array<{
* name: string,
* url: string,
* entity: string,
* mode: string,
* media: string,
* }>,
*
* title: string,
* poster: string,
* poster_remote: boolean,
* muted: boolean,
* intersection: number,
* ui: boolean,
* style: string,
* background: boolean,
*
* server: string,
*
* mse: boolean,
* webrtc: boolean,
*
* digital_ptz:{
* mouse_drag_pan: boolean,
* mouse_wheel_zoom: boolean,
* mouse_double_click_zoom: boolean,
* touch_pinch_zoom: boolean,
* touch_drag_pan: boolean,
* touch_tap_drag_zoom: boolean,
* persist: boolean|string,
* },
* ptz:{
* opacity: number|string,
* service: string,
* data_left, data_up, data_right, data_down, data_zoom_in, data_zoom_out, data_home
* },
* shortcuts:Array<{ name:string, icon:string }>,
* }} config
*/
this.config = Object.assign({
mode: config.mse === false ? 'webrtc' : config.webrtc === false ? 'mse' : this.mode,
media: this.media,
streams: [{url: config.url, entity: config.entity}],
poster_remote: config.poster && (config.poster.indexOf('://') > 0 || config.poster.charAt(0) === '/'),
}, config);
this.streamID = -1;
this.nextStream(false);
this.onhass = [];
}
set hass(hass) {
this._hass = hass;
this.onhass.forEach(fn => fn());
// if card in vertical stack - `hass` property assign after `onconnect`
// this.onconnect();
}
get hass() {
return this._hass;
}
/**
* Called by the Hass to calculate default card height.
*/
getCardSize() {
return 5; // x 50px
}
/**
* Called by the Hass to get defaul card config
* @return {{url: string}}
*/
static getStubConfig() {
return {'url': ''};
}
setStatus(mode, status) {
const divMode = this.querySelector('.mode').innerText;
if (mode === 'error' && divMode !== 'Loading..' && divMode !== 'Loading...') return;
this.querySelector('.mode').innerText = mode;
this.querySelector('.status').innerText = status || '';
}
/** @param reload {boolean} */
nextStream(reload) {
this.streamID = (this.streamID + 1) % this.config.streams.length;
const stream = this.config.streams[this.streamID];
this.config.url = stream.url;
this.config.entity = stream.entity;
this.mode = stream.mode || this.config.mode;
this.media = stream.media || this.config.media;
if (reload) {
this.ondisconnect();
setTimeout(() => this.onconnect(), 100); // wait ws.close event
}
}
/** @return {string} */
get streamName() {
return this.config.streams[this.streamID].name || `S${this.streamID}`;
}
oninit() {
super.oninit();
this.renderMain();
this.renderDigitalPTZ();
this.renderPTZ();
this.renderCustomUI();
this.renderShortcuts();
this.renderStyle();
}
onconnect() {
if (!this.config || !this.hass) return false;
if (!this.isConnected || this.ws || this.pc) return false;
const divMode = this.querySelector('.mode').innerText;
if (divMode === 'Loading..') return;
this.setStatus('Loading..');
this.hass.callWS({
type: 'auth/sign_path', path: '/api/webrtc/ws'
}).then(data => {
if (this.config.poster && !this.config.poster_remote) {
this.video.poster = this.hass.hassUrl(data.path) + '&poster=' + encodeURIComponent(this.config.poster);
}
this.wsURL = 'ws' + this.hass.hassUrl(data.path).substring(4);
if (this.config.entity) {
this.wsURL += '&entity=' + this.config.entity;
} else if (this.config.url) {
this.wsURL += '&url=' + encodeURIComponent(this.config.url);
} else {
this.setStatus('IMG');
return;
}
if (this.config.server) {
this.wsURL += '&server=' + encodeURIComponent(this.config.server);
}
if (super.onconnect()) {
this.setStatus('Loading...');
} else {
this.setStatus('error', 'unable to connect');
}
}).catch(er => {
this.setStatus('error', er);
});
}
onopen() {
const result = super.onopen();
this.onmessage['stream'] = msg => {
switch (msg.type) {
case 'error':
this.setStatus('error', msg.value);
break;
case 'mse':
case 'hls':
case 'mp4':
case 'mjpeg':
this.setStatus(msg.type.toUpperCase(), this.config.title || '');
break;
}
};
return result;
}
onpcvideo(ev) {
super.onpcvideo(ev);
if (this.pcState !== WebSocket.CLOSED) {
this.setStatus('RTC', this.config.title || '');
}
}
renderMain() {
const shadow = this.attachShadow({mode: 'open'});
shadow.innerHTML = `
<style>
ha-card {
width: 100%;
height: 100%;
margin: auto;
overflow: hidden;
position: relative;
}
ha-icon {
color: white;
cursor: pointer;
}
.player {
background-color: black;
height: 100%;
position: relative; /* important for Safari */
}
.player:active {
cursor: move; /* important for zoom-controller */
}
.player .ptz-transform {
height: 100%;
}
.header {
position: absolute;
top: 6px;
left: 10px;
right: 10px;
color: white;
display: flex;
justify-content: space-between;
pointer-events: none;
}
.mode {
cursor: pointer;
opacity: 0.6;
pointer-events: auto;
}
</style>
<ha-card class="card">
<div class="player">
<div class="ptz-transform"></div>
</div>
<div class="header">
<div class="status"></div>
<div class="mode"></div>
</div>
</ha-card>
`;
this.querySelector = selectors => this.shadowRoot.querySelector(selectors);
this.querySelector('.ptz-transform').appendChild(this.video);
const mode = this.querySelector('.mode');
mode.addEventListener('click', () => this.nextStream(true));
if (this.config.muted) this.video.muted = true;
if (this.config.poster_remote) this.video.poster = this.config.poster;
}
renderDigitalPTZ() {
if (this.config.digital_ptz === false) return;
new DigitalPTZ(
this.querySelector('.player'),
this.querySelector('.player .ptz-transform'),
this.video,
Object.assign({}, this.config.digital_ptz, {persist_key: this.config.url})
);
}
renderPTZ() {
if (!this.config.ptz || !this.config.ptz.service) return;
let hasMove = false;
let hasZoom = false;
let hasHome = false;
for (const prefix of ['', '_start', '_end', '_long']) {
hasMove = hasMove || this.config.ptz['data' + prefix + '_right'];
hasMove = hasMove || this.config.ptz['data' + prefix + '_left'];
hasMove = hasMove || this.config.ptz['data' + prefix + '_up'];
hasMove = hasMove || this.config.ptz['data' + prefix + '_down'];
hasZoom = hasZoom || this.config.ptz['data' + prefix + '_zoom_in'];
hasZoom = hasZoom || this.config.ptz['data' + prefix + '_zoom_out'];
hasHome = hasHome || this.config.ptz['data' + prefix + '_home'];
}
const card = this.querySelector('.card');
card.insertAdjacentHTML('beforebegin', `
<style>
.ptz {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 10px;
transition: opacity .3s ease-in-out;
opacity: ${parseFloat(this.config.ptz.opacity) || 0.4};
}
.ptz:hover {
opacity: 1 !important;
}
.ptz-move {
position: relative;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 50%;
width: 80px;
height: 80px;
display: ${hasMove ? 'block' : 'none'};
}
.ptz-zoom {
position: relative;
width: 80px;
height: 40px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 4px;
display: ${hasZoom ? 'block' : 'none'};
}
.ptz-home {
position: relative;
width: 40px;
height: 40px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 4px;
align-self: center;
display: ${hasHome ? 'block' : 'none'};
}
.up {
position: absolute;
top: 5px;
left: 50%;
transform: translateX(-50%);
}
.down {
position: absolute;
bottom: 5px;
left: 50%;
transform: translateX(-50%);
}
.left {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
}
.right {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
}
.zoom_out {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
}
.zoom_in {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
}
.home {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
`);
card.insertAdjacentHTML('beforeend', `
<div class="ptz">
<div class="ptz-move">
<ha-icon class="right" icon="mdi:arrow-right"></ha-icon>
<ha-icon class="left" icon="mdi:arrow-left"></ha-icon>
<ha-icon class="up" icon="mdi:arrow-up"></ha-icon>
<ha-icon class="down" icon="mdi:arrow-down"></ha-icon>
</div>
<div class="ptz-zoom">
<ha-icon class="zoom_in" icon="mdi:plus"></ha-icon>
<ha-icon class="zoom_out" icon="mdi:minus"></ha-icon>
</div>
<div class="ptz-home">
<ha-icon class="home" icon="mdi:home"></ha-icon>
</div>
</div>
`);
const template = JSON.stringify(this.config.ptz);
const handle = path => {
if (!this.config.ptz['data_' + path]) return;
const config = template.indexOf('${') < 0 ? this.config.ptz : JSON.parse(eval('`' + template + '`'));
const [domain, service] = config.service.split('.', 2);
const data = config['data_' + path];
this.hass.callService(domain, service, data);
};
const ptz = this.querySelector('.ptz');
for (const [start, end] of [['touchstart', 'touchend'], ['mousedown', 'mouseup']]) {
ptz.addEventListener(start, startEvt => {
const {className} = startEvt.target;
startEvt.preventDefault();
handle('start_' + className);
window.addEventListener(end, endEvt => {
endEvt.preventDefault();
handle('end_' + className);
if (endEvt.timeStamp - startEvt.timeStamp > 400) {
handle('long_' + className);
} else {
handle(className);
}
}, {once: true});
});
}
}
saveScreenshot() {
const a = document.createElement('a');
if (this.video.videoWidth && this.video.videoHeight) {
const canvas = document.createElement('canvas');
canvas.width = this.video.videoWidth;
canvas.height = this.video.videoHeight;
canvas.getContext('2d').drawImage(this.video, 0, 0, canvas.width, canvas.height);
a.href = canvas.toDataURL('image/jpeg');
} else if (this.video.poster && this.video.poster.startsWith('data:image/jpeg')) {
a.href = this.video.poster;
} else {
return;
}
const ts = new Date().toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '');
a.download = `snapshot_${ts}.jpeg`;
a.click();
}
renderCustomUI() {
if (!this.config.ui) return;
this.video.controls = false;
this.video.style.pointerEvents = 'none';
const card = this.querySelector('.card');
card.insertAdjacentHTML('beforebegin', `
<style>
.spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.controls {
position: absolute;
left: 5px;
right: 5px;
bottom: 5px;
display: flex;
}
.space {
width: 100%;
}
.volume {
display: none;
}
.stream {
padding-top: 2px;
margin-left: 2px;
font-weight: 400;
font-size: 20px;
color: white;
display: none;
cursor: pointer;
}
</style>
`);
card.insertAdjacentHTML('beforeend', `
<div class="ui">
<ha-circular-progress class="spinner"></ha-circular-progress>
<div class="controls">
<ha-icon class="fullscreen" icon="mdi:fullscreen"></ha-icon>
<ha-icon class="screenshot" icon="mdi:floppy"></ha-icon>
<ha-icon class="pictureinpicture" icon="mdi:picture-in-picture-bottom-right"></ha-icon>
<span class="stream">${this.streamName}</span>
<span class="space"></span>
<ha-icon class="play" icon="mdi:play"></ha-icon>
<ha-icon class="volume" icon="mdi:volume-high"></ha-icon>
</div>
</div>
`);
const video = this.video;
const fullscreen = this.querySelector('.fullscreen');
if (this.requestFullscreen) {
this.addEventListener('fullscreenchange', () => {
fullscreen.icon = document.fullscreenElement ? 'mdi:fullscreen-exit' : 'mdi:fullscreen';
});
} else if (video.webkitEnterFullscreen) {
this.requestFullscreen = () => new Promise((resolve, reject) => {
try {
video.webkitEnterFullscreen();
} catch (e) {
reject(e);
}
});
video.addEventListener('webkitendfullscreen', () => {
setTimeout(() => this.play(), 1000); // fix bug in iOS
});
} else {
fullscreen.style.display = 'none';
}
const pip = this.querySelector('.pictureinpicture');
if (video.requestPictureInPicture) {
video.addEventListener('enterpictureinpicture', () => {
pip.icon = 'mdi:rectangle';
this.background = true;
});
video.addEventListener('leavepictureinpicture', () => {
pip.icon = 'mdi:picture-in-picture-bottom-right';
this.background = this.config.background;
this.play();
});
} else {
pip.style.display = 'none';
}
const ui = this.querySelector('.ui');
ui.addEventListener('click', ev => {
const icon = ev.target.icon;
if (icon === 'mdi:play') {
this.play();
} else if (icon === 'mdi:volume-mute') {
video.muted = false;
} else if (icon === 'mdi:volume-high') {
video.muted = true;
} else if (icon === 'mdi:fullscreen') {
this.requestFullscreen().catch(console.warn);
} else if (icon === 'mdi:fullscreen-exit') {
document.exitFullscreen().catch(console.warn);
} else if (icon === 'mdi:floppy') {
this.saveScreenshot();
} else if (icon === 'mdi:picture-in-picture-bottom-right') {
video.requestPictureInPicture().catch(console.warn);
} else if (icon === 'mdi:rectangle') {
document.exitPictureInPicture().catch(console.warn);
} else if (ev.target.className === 'stream') {
this.nextStream(true);
ev.target.innerText = this.streamName;
}
});
const spinner = this.querySelector('.spinner');
video.addEventListener('waiting', () => {
spinner.style.display = 'block';
});
video.addEventListener('playing', () => {
spinner.style.display = 'none';
});
const play = this.querySelector('.play');
video.addEventListener('play', () => {
play.style.display = 'none';
});
video.addEventListener('pause', () => {
play.style.display = 'block';
});
const volume = this.querySelector('.volume');
video.addEventListener('loadeddata', () => {
volume.style.display = this.hasAudio ? 'block' : 'none';
});
video.addEventListener('volumechange', () => {
volume.icon = video.muted ? 'mdi:volume-mute' : 'mdi:volume-high';
});
const stream = this.querySelector('.stream');
stream.style.display = this.config.streams.length > 1 ? 'block' : 'none';
}
renderShortcuts() {
if (!this.config.shortcuts) return;
const card = this.querySelector('.card');
card.insertAdjacentHTML('beforebegin', `
<style>
.shortcuts {
position: absolute;
top: 5px;
left: 5px;
}
</style>
`);
card.insertAdjacentHTML('beforeend', '<div class="shortcuts"></div>');
const shortcuts = this.querySelector('.shortcuts');
shortcuts.addEventListener('click', ev => {
const value = this.config.shortcuts[ev.target.dataset.index];
if (value.more_info !== undefined) {
const event = new Event('hass-more-info', {
bubbles: true,
cancelable: true,
composed: true,
});
event.detail = {entityId: value.more_info};
ev.target.dispatchEvent(event);
}
if (value.service !== undefined) {
const [domain, name] = value.service.split('.');
this.hass.callService(domain, name, value.service_data || {});
}
});
this.renderTemplate('shortcuts', () => {
shortcuts.innerHTML = this.config.shortcuts.map((value, index) => `
<ha-icon data-index="${index}" icon="${value.icon}" title="${value.name}"></ha-icon>
`).join('');
});
}
renderStyle() {
if (!this.config.style) return;
const style = document.createElement('style');
const card = this.querySelector('.card');
card.insertAdjacentElement('beforebegin', style);
this.renderTemplate('style', () => {
style.innerText = this.config.style;
});
}
renderTemplate(name, renderHTML) {
const config = this.config[name];
// support config param as string or as object
const template = typeof config === 'string' ? config : JSON.stringify(config);
// check if config param has template
if (template.indexOf('${') >= 0) {
const render = () => {
try {
const states = this.hass ? this.hass.states : undefined;
this.config[name] = JSON.parse(eval('`' + template + '`'));
renderHTML();
} catch (e) {
console.debug(e);
}
};
this.onhass.push(render);
render();
} else {
renderHTML();
}
}
get hasAudio() {
return (
(this.video.srcObject && this.video.srcObject.getAudioTracks && this.video.srcObject.getAudioTracks().length) ||
(this.video.mozHasAudio || this.video.webkitAudioDecodedByteCount) ||
(this.video.audioTracks && this.video.audioTracks.length)
);
}
}
customElements.define('webrtc-camera', WebRTCCamera);
const card = {
type: 'webrtc-camera',
name: 'WebRTC Camera',
preview: false,
description: 'WebRTC camera allows you to view the stream of almost any camera without delay',
};
// Apple iOS 12 doesn't support `||=`
if (window.customCards) window.customCards.push(card);
else window.customCards = [card];