Initial Home Assistant commit
This commit is contained in:
@@ -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})`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user