-
Notifications
You must be signed in to change notification settings - Fork 165
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #491 from dbuezas/Add-digital-ptz-mouse-and-touch-…
…control
- Loading branch information
Showing
4 changed files
with
352 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -105,6 +105,15 @@ type: 'custom:webrtc-camera' | |
url: 'rtsp:https://rtsp:[email protected]:554/av_stream/ch0' | ||
|
||
ui: true # custom video controls, default false | ||
digital_ptz: # digital zoom and pan via mouse/touch. Defaults to: | ||
mouse_drag_pan: true | ||
mouse_wheel_zoom: true | ||
mouse_double_click_zoom: true | ||
touch_drag_pan: true | ||
touch_pinch_zoom: true | ||
touch_tap_drag_zoom: true | ||
persist: true # zoom factor and viewport position survive page reloads | ||
# digital_ptz: false # to disable al mouse/touch digital zoom and pan | ||
title: My super camera # optional card title | ||
poster: https://home-assistant.io/images/cast/splash.png # still image when stream is loading | ||
muted: true # initial mute toggle state, default is false (unmuted) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,324 @@ | ||
// 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, 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.videoEl.style.transition = "transform 200ms"; | ||
setTimeout(() => { | ||
this.videoEl.style.transition = ""; | ||
}, 200); | ||
} | ||
this.videoEl.style.transform = this.transform.render(); | ||
}; | ||
this.containerEl = containerEl; | ||
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 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; | ||
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); | ||
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; | ||
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})`; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters