diff --git a/custom_components/webrtc/__init__.py b/custom_components/webrtc/__init__.py index db5fed0..39edf4d 100644 --- a/custom_components/webrtc/__init__.py +++ b/custom_components/webrtc/__init__.py @@ -54,7 +54,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): # 1. Serve lovelace card path = Path(__file__).parent / "www" - for name in ("video-rtc.js", "webrtc-camera.js", "digital-ptz.js", "ditigal-ptz-transform.js", "digital-ptz-gestures.js"): + for name in ("video-rtc.js", "webrtc-camera.js", "digital-ptz.js"): utils.register_static_path( hass.http.app, "/webrtc/" + name, path / name) diff --git a/custom_components/webrtc/www/digital-ptz-gestures.js b/custom_components/webrtc/www/digital-ptz-gestures.js deleted file mode 100644 index b8906fe..0000000 --- a/custom_components/webrtc/www/digital-ptz-gestures.js +++ /dev/null @@ -1,150 +0,0 @@ -// js version generated from https://github.com/dbuezas/pan-zoom-controller/blob/main/src/digital-ptz.ts -import { DBL_CLICK_MS, ONE_FINGER_ZOOM_SPEED } from "./digital-ptz.js"; -const capture = (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) => { - capture(moveEvent); // prevent scrolling - 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(); - }; - 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 = - 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) { - capture(moveEvent); // prevent scrolling - const lastY = lastEvent.touches[0].pageY; - const currY = moveEvent.touches[0].pageY; - transform.zoom(1 - (lastY - currY) * ONE_FINGER_ZOOM_SPEED); - lastEvent = moveEvent; - render(); - } 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) => { - capture(e); // prevent scrolling - const zoom = 1 - e.deltaY / 1000; - transform.zoomAtCoords(zoom, e.pageX, e.pageY); - render(); - }; - 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 onDown = (downEvt) => { - let last = downEvt instanceof TouchEvent ? downEvt.touches[0] : downEvt; - const onMove = (moveEvt) => { - if (moveEvt instanceof TouchEvent && moveEvt.touches.length !== 1) return; - capture(moveEvt); // prevent scrolling - const curr = moveEvt instanceof TouchEvent ? moveEvt.touches[0] : moveEvt; - transform.move(curr.pageX - last.pageX, curr.pageY - last.pageY); - last = curr; - render(); - }; - 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"); -} -export { - startTouchDragPan, - startTouchPinchZoom, - startTouchTapDragZoom, - startMouseWheel, - startDoubleClickZoom, - startMouseDragPan, -}; diff --git a/custom_components/webrtc/www/digital-ptz.js b/custom_components/webrtc/www/digital-ptz.js index a0e2002..5f8259f 100644 --- a/custom_components/webrtc/www/digital-ptz.js +++ b/custom_components/webrtc/www/digital-ptz.js @@ -1,16 +1,7 @@ // js version generated from https://github.com/dbuezas/pan-zoom-controller/blob/main/src/digital-ptz.ts -import { - startMouseWheel, - startDoubleClickZoom, - startMouseDragPan, - startTouchDragPan, - startTouchPinchZoom, - startTouchTapDragZoom, -} from "./digital-ptz-gestures.js"; -import { Transform } from "./ditigal-ptz-transform.js"; -export const ONE_FINGER_ZOOM_SPEED = 1 / 200; // 1 scale every 200px -export const DBL_CLICK_MS = 400; -export const MAX_ZOOM = 10; +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: false, touch_tap_drag_zoom: true, @@ -66,11 +57,280 @@ export class DigitalPTZ { } render = (transition = false) => { if (transition) { + // transition is used to animate dbl click zoom this.videoEl.style.transition = "transform 200ms"; setTimeout(() => { this.videoEl.style.transition = ""; }, 200); } - this.videoEl.style.transform = this.transform.render(); + const newTranform = this.transform.render(); + const changed = this.videoEl.style.transform !== newTranform; + this.videoEl.style.transform = newTranform; + return changed; + }; +} +/* 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; + const changed = render(); + if (changed) 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 = + 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; + const changed = render(); + if (changed) 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); + const changed = render(); + if (changed) 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 onDown = (downEvt) => { + let last = downEvt instanceof TouchEvent ? downEvt.touches[0] : downEvt; + const onMove = (moveEvt) => { + if (moveEvt instanceof TouchEvent && moveEvt.touches.length !== 1) return; + const curr = moveEvt instanceof TouchEvent ? moveEvt.touches[0] : moveEvt; + transform.move(curr.pageX - last.pageX, curr.pageY - last.pageY); + last = curr; + const changed = render(); + if (changed) 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); +class Transform { + scale = 1; + x = 0; + y = 0; + videoRect; + containerRect; + settings; + constructor(settings) { + this.settings = { + ...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; + if (!videoEl.videoWidth) { + // 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 = videoEl.videoWidth / videoEl.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})`; + } + 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]; + } + }; + persistTransform = () => { + const { persist_key, persist } = this.settings; + if (!persist) return; + const { x, y, scale } = this; + localStorage[persist_key] = JSON.stringify({ + x, + y, + scale, + }); }; } diff --git a/custom_components/webrtc/www/ditigal-ptz-transform.js b/custom_components/webrtc/www/ditigal-ptz-transform.js deleted file mode 100644 index a1ca1fc..0000000 --- a/custom_components/webrtc/www/ditigal-ptz-transform.js +++ /dev/null @@ -1,125 +0,0 @@ -// js version generated from https://github.com/dbuezas/pan-zoom-controller/blob/main/src/digital-ptz.ts -import { MAX_ZOOM } from "./digital-ptz.js"; -const PERSIST_KEY_PREFIX = "webrtc-digital-ptc:"; -const clamp = (value, min, max) => Math.min(Math.max(value, min), max); -export class Transform { - scale = 1; - x = 0; - y = 0; - videoRect; - containerRect; - settings; - constructor(settings) { - this.settings = { - ...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; - if (!videoEl.videoWidth) { - // 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 = videoEl.videoWidth / videoEl.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})`; - } - 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]; - } - }; - persistTransform = () => { - const { persist_key, persist } = this.settings; - if (!persist) return; - const { x, y, scale } = this; - localStorage[persist_key] = JSON.stringify({ - x, - y, - scale, - }); - }; -}