mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
ptz control updates
This commit is contained in:
@@ -148,13 +148,25 @@ export default function ControlPadPanel({ compact = false, disabled = false }) {
|
||||
return () => {
|
||||
clearRepeatTimer();
|
||||
/*
|
||||
Mobile controls can unmount when layouts change or the driver leaves the
|
||||
control surface. Clear the shared flag so a stale mobile precision choice
|
||||
cannot leave desktop/keyboard camera tilt in fine-step mode.
|
||||
Mobile controls can unmount during an orientation/layout change while a
|
||||
pointer is still captured by the disappearing element. Publish a neutral
|
||||
vector directly during cleanup so neither rover drive nor PTZ pan/tilt
|
||||
can retain the last cell merely because pointerup had nowhere to land.
|
||||
*/
|
||||
activeCellRef.current = null;
|
||||
setDriveVector({ x: 0, y: 0, boost: false }, { source: SOURCE });
|
||||
registerInputState(SOURCE, {
|
||||
keys: [],
|
||||
vector: { x: 0, y: 0, boost: false },
|
||||
activeCell: 'stop',
|
||||
speedMode: speedModeRef.current,
|
||||
lastEvent: 'unmount',
|
||||
});
|
||||
// Clear the shared flag too, so a stale mobile precision choice cannot
|
||||
// leave desktop/keyboard camera tilt in fine-step mode.
|
||||
setCameraPrecisionMode(false);
|
||||
};
|
||||
}, [clearRepeatTimer, setCameraPrecisionMode]);
|
||||
}, [clearRepeatTimer, registerInputState, setCameraPrecisionMode, setDriveVector]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) return;
|
||||
|
||||
@@ -188,46 +188,30 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
||||
}
|
||||
|
||||
function PtzMobileZoomButtons({ disabled = false }) {
|
||||
const { nudgeServo } = useControlActions();
|
||||
const repeatTimerRef = useRef(null);
|
||||
const { setCameraAxisIntent } = useControlActions();
|
||||
|
||||
const stopZoom = useCallback(() => {
|
||||
/*
|
||||
Mobile zoom is intentionally routed through the normal camera-up/down
|
||||
control action instead of emitting PTZ socket commands directly. That
|
||||
keeps the zoom buttons on the same path as keyboard/gamepad camera tilt,
|
||||
and the PTZ adapter remains the one place that translates "camera nudge"
|
||||
into Reolink zoom pulses.
|
||||
Zero only releases the zoom axis. The PTZ adapter combines it with any
|
||||
pan/tilt direction still held on the movement pad, so lifting one finger
|
||||
cannot erase the other finger's intent.
|
||||
*/
|
||||
if (repeatTimerRef.current) {
|
||||
clearInterval(repeatTimerRef.current);
|
||||
repeatTimerRef.current = null;
|
||||
}
|
||||
/*
|
||||
Zero is a zoom-only release signal in the PTZ adapter. Using the global
|
||||
stop action here previously erased a simultaneously held pan/tilt vector,
|
||||
making mixed touch controls unexpectedly stop the camera.
|
||||
*/
|
||||
nudgeServo(0);
|
||||
}, [nudgeServo]);
|
||||
setCameraAxisIntent(0);
|
||||
}, [setCameraAxisIntent]);
|
||||
|
||||
const startZoom = useCallback(
|
||||
(direction) => (event) => {
|
||||
/*
|
||||
Send an immediate nudge and then repeat while held. The adapter turns
|
||||
each nudge into a short zoom pulse, so repeating the standard action is
|
||||
the simplest way to get continuous hold-to-zoom without adding another
|
||||
PTZ-specific command loop.
|
||||
Publish held state once. The adapter owns the single motion heartbeat,
|
||||
so this button no longer creates a second interval whose queued callback
|
||||
could run after pointerup and restart zoom.
|
||||
*/
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
stopZoom();
|
||||
nudgeServo(direction);
|
||||
repeatTimerRef.current = setInterval(() => {
|
||||
nudgeServo(direction);
|
||||
}, 120);
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setCameraAxisIntent(direction);
|
||||
},
|
||||
[disabled, nudgeServo, stopZoom],
|
||||
[disabled, setCameraAxisIntent],
|
||||
);
|
||||
const stopFromPointer = useCallback(
|
||||
(event) => {
|
||||
@@ -242,16 +226,12 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
() => () => {
|
||||
/*
|
||||
A touch surface can unmount during orientation changes or fullscreen
|
||||
close while a pointer is still down. Clear the repeat timer here so a
|
||||
held zoom button cannot keep firing camera-up/down actions after the
|
||||
mobile controls have disappeared.
|
||||
close while a pointer is still down. Explicitly clear zoom here because
|
||||
an unmounted DOM node cannot deliver its pointerup/pointercancel event.
|
||||
*/
|
||||
if (repeatTimerRef.current) {
|
||||
clearInterval(repeatTimerRef.current);
|
||||
repeatTimerRef.current = null;
|
||||
}
|
||||
setCameraAxisIntent(0);
|
||||
},
|
||||
[],
|
||||
[setCameraAxisIntent],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -263,7 +243,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
onPointerDown={startZoom(-1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onPointerLeave={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom out
|
||||
@@ -275,7 +255,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
onPointerDown={startZoom(1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onPointerLeave={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom in
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Vip PTZ Camera Card
|
||||
// Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera.
|
||||
// Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative.
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
@@ -11,13 +11,12 @@ import PtzLiveVideo from '../PtzLiveVideo/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
const PTZ_CAMERA_ID = 'ptz-camera';
|
||||
const PTZ_ZOOM_SPEED = 0.55;
|
||||
|
||||
function formatRemaining(deadline) {
|
||||
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
|
||||
@@ -218,22 +217,25 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
||||
}
|
||||
|
||||
function PtzMobileZoomButtons({ disabled = false }) {
|
||||
const { ptzMove, ptzStop } = useSessionActions();
|
||||
const { setCameraAxisIntent } = useControlActions();
|
||||
const stopZoom = useCallback(() => {
|
||||
ptzStop().catch(() => {});
|
||||
}, [ptzStop]);
|
||||
// This is a zoom-only release; the shared adapter retains any simultaneous
|
||||
// pan/tilt intent from the movement pad in its next combined motion state.
|
||||
setCameraAxisIntent(0);
|
||||
}, [setCameraAxisIntent]);
|
||||
const startZoom = useCallback(
|
||||
(direction) => (event) => {
|
||||
/*
|
||||
Mobile needs explicit zoom targets because the regular mobile drive pad
|
||||
is already used for pan/tilt. Desktop does not render these buttons; it
|
||||
uses the mapped camera up/down controls shown in the reference panel.
|
||||
The adapter owns renewal for held PTZ state. Publishing the direction
|
||||
once avoids a component-local repeat timer and keeps this legacy card on
|
||||
the exact same motion path as the dedicated PTZ route.
|
||||
*/
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
ptzMove({ pan: 0, tilt: 0, zoom: direction * PTZ_ZOOM_SPEED }).catch(() => {});
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setCameraAxisIntent(direction);
|
||||
},
|
||||
[disabled, ptzMove],
|
||||
[disabled, setCameraAxisIntent],
|
||||
);
|
||||
const stopFromPointer = useCallback(
|
||||
(event) => {
|
||||
@@ -244,6 +246,15 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
[disabled, stopZoom],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Orientation changes can unmount the button before pointerup. Clear the
|
||||
// held zoom state explicitly instead of waiting for the server watchdog.
|
||||
setCameraAxisIntent(0);
|
||||
},
|
||||
[setCameraAxisIntent],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
||||
<button
|
||||
@@ -253,7 +264,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
onPointerDown={startZoom(-1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onPointerLeave={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom out
|
||||
@@ -265,7 +276,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
onPointerDown={startZoom(1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onPointerLeave={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom in
|
||||
|
||||
@@ -426,8 +426,6 @@ export function SessionProvider({ children }) {
|
||||
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
||||
ptzClaim: () => emitWithAck('ptzCamera:claim'),
|
||||
ptzRelease: () => emitWithAck('ptzCamera:release'),
|
||||
ptzMove: (payload = {}) => emitWithAck('ptzCamera:move', payload),
|
||||
ptzStop: () => emitWithAck('ptzCamera:stop'),
|
||||
ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload),
|
||||
ptzIr: (payload = {}) => emitWithAck('ptzCamera:ir', payload),
|
||||
ptzListPresets: () => emitWithAck('ptzCamera:presets:list'),
|
||||
|
||||
@@ -45,6 +45,7 @@ const CONTROL_ACTION_NAMES = [
|
||||
'setAuxMotors',
|
||||
'setServoAngle',
|
||||
'nudgeServo',
|
||||
'setCameraAxisIntent',
|
||||
'goServoHome',
|
||||
'setCameraPrecisionMode',
|
||||
'runMacro',
|
||||
@@ -352,21 +353,12 @@ export function ControlSystemProvider({ children }) {
|
||||
|
||||
const setServoAngle = useCallback(
|
||||
(value, options = {}) => {
|
||||
if (ptzControls.isActive) {
|
||||
/*
|
||||
Servo-capable rover controls converge here from keyboard, gamepad,
|
||||
desktop, and mobile. When the active control target is the PTZ camera,
|
||||
route the intent through the PTZ adapter instead of making the rover
|
||||
command pipeline understand camera zoom semantics.
|
||||
*/
|
||||
const baseline = typeof servoAngleRef.current === 'number' ? servoAngleRef.current : 0;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return;
|
||||
ptzControls.pulseZoom(numeric - baseline);
|
||||
servoAngleRef.current = numeric;
|
||||
recordControlIntent();
|
||||
return;
|
||||
}
|
||||
/*
|
||||
Absolute servo positions belong only to rover hardware. PTZ zoom now
|
||||
enters through setCameraAxisIntent as a signed held velocity, so this
|
||||
function must not infer zoom direction by comparing unrelated absolute
|
||||
angle values from gamepad/manual-dock callers.
|
||||
*/
|
||||
if (!pipeline.servoConfig) return;
|
||||
const force = Boolean(options?.force);
|
||||
if (state.manualDockAssist?.active && !force) return;
|
||||
@@ -376,13 +368,24 @@ export function ControlSystemProvider({ children }) {
|
||||
servoAngleRef.current = clamped;
|
||||
recordControlIntent();
|
||||
},
|
||||
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
|
||||
[pipeline, recordControlIntent, state.manualDockAssist?.active],
|
||||
);
|
||||
|
||||
const nudgeServo = useCallback(
|
||||
(delta = 0) => {
|
||||
if (ptzControls.isActive) {
|
||||
/*
|
||||
A PTZ camera has no absolute browser-side servo angle. Treat a nudge
|
||||
as held zoom direction and, importantly, preserve zero as an explicit
|
||||
release. The previous fallback converted nudgeServo(0) into a positive
|
||||
default step, so releasing the mobile zoom button could zoom in again.
|
||||
*/
|
||||
ptzControls.setZoomIntent(delta);
|
||||
recordControlIntent();
|
||||
return;
|
||||
}
|
||||
const config = pipeline.servoConfig;
|
||||
if (!config && !ptzControls.isActive) return;
|
||||
if (!config) return;
|
||||
const step = typeof delta === 'number' && delta !== 0 ? delta : config?.nudgeDegrees || 1;
|
||||
const baseline =
|
||||
typeof servoAngleRef.current === 'number'
|
||||
@@ -392,7 +395,28 @@ export function ControlSystemProvider({ children }) {
|
||||
: 0;
|
||||
setServoAngle(baseline + step);
|
||||
},
|
||||
[pipeline.servoConfig, ptzControls.isActive, setServoAngle],
|
||||
[pipeline.servoConfig, ptzControls, recordControlIntent, setServoAngle],
|
||||
);
|
||||
|
||||
const setCameraAxisIntent = useCallback(
|
||||
(direction = 0) => {
|
||||
/*
|
||||
Keyboard, touch, and gamepad all need an explicit way to say that a
|
||||
camera axis returned to neutral. Rover servos remain position/nudge
|
||||
based, so returning false tells those callers to continue through their
|
||||
existing rover implementation without introducing PTZ rules there.
|
||||
*/
|
||||
if (!ptzControls.isActive) return false;
|
||||
ptzControls.setZoomIntent(direction);
|
||||
/*
|
||||
Do not dispatch recordControlIntent here. Gamepads publish their neutral
|
||||
and held axes every animation frame; the PTZ adapter deduplicates state
|
||||
and owns its 250 ms heartbeat, so a React reducer update per frame would
|
||||
add churn without representing a new user action.
|
||||
*/
|
||||
return true;
|
||||
},
|
||||
[ptzControls],
|
||||
);
|
||||
|
||||
const goServoHome = useCallback(() => {
|
||||
@@ -691,6 +715,7 @@ export function ControlSystemProvider({ children }) {
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
nudgeServo,
|
||||
setCameraAxisIntent,
|
||||
goServoHome,
|
||||
setCameraPrecisionMode,
|
||||
runMacro,
|
||||
@@ -718,6 +743,7 @@ export function ControlSystemProvider({ children }) {
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
nudgeServo,
|
||||
setCameraAxisIntent,
|
||||
goServoHome,
|
||||
setCameraPrecisionMode,
|
||||
runMacro,
|
||||
|
||||
@@ -53,6 +53,7 @@ export default function GamepadInputManager() {
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
setCameraAxisIntent,
|
||||
runMacro,
|
||||
toggleHeadlight,
|
||||
toggleLaser,
|
||||
@@ -173,6 +174,7 @@ export default function GamepadInputManager() {
|
||||
runMacro,
|
||||
saveGamepadSettings,
|
||||
setAuxMotors,
|
||||
setCameraAxisIntent,
|
||||
setDriveVector,
|
||||
setMode,
|
||||
setServoAngle,
|
||||
@@ -187,6 +189,9 @@ export default function GamepadInputManager() {
|
||||
if (!latest) return;
|
||||
const activePad = pickActivePad(hubState.pads, latest.activeSignature);
|
||||
if (!activePad) {
|
||||
// A disconnected controller cannot deliver a final neutral axis sample.
|
||||
// Publish it here so PTZ zoom never depends on the browser doing so.
|
||||
latest.setCameraAxisIntent(0);
|
||||
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||
@@ -204,6 +209,9 @@ export default function GamepadInputManager() {
|
||||
}
|
||||
|
||||
if (isTextEntryActive()) {
|
||||
// Entering text blocks gamepad control immediately, including a held
|
||||
// camera axis that otherwise would keep its last PTZ zoom direction.
|
||||
latest.setCameraAxisIntent(0);
|
||||
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||
@@ -302,7 +310,14 @@ export default function GamepadInputManager() {
|
||||
handleButtonEdge('laserToggle', false);
|
||||
}
|
||||
|
||||
if (Math.abs(outputs.cameraAxis) > 0.001) {
|
||||
/*
|
||||
PTZ zoom consumes the live signed gamepad axis, including its zero
|
||||
position, so releasing the stick is an explicit stop instead of merely
|
||||
ending calls to the old servo updater. Rover camera servos return false
|
||||
here and continue through their established absolute/velocity mapping.
|
||||
*/
|
||||
const handledAsPtzZoom = latest.setCameraAxisIntent(outputs.cameraAxis);
|
||||
if (!handledAsPtzZoom && Math.abs(outputs.cameraAxis) > 0.001) {
|
||||
handleCameraAxis(outputs.cameraAxis, profile.calibration);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export default function KeyboardInputManager() {
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
nudgeServo,
|
||||
setCameraAxisIntent,
|
||||
runMacro,
|
||||
stopAllMotion,
|
||||
registerInputState,
|
||||
@@ -215,6 +216,13 @@ export default function KeyboardInputManager() {
|
||||
const ensureServoLoop = useCallback(() => {
|
||||
const direction = computeServoDirection();
|
||||
if (direction === 0) {
|
||||
/*
|
||||
Keyup must publish a real neutral PTZ zoom intent before the repeat loop
|
||||
disappears. Rover servos ignore this generic axis release and retain
|
||||
their existing nudge behavior because setCameraAxisIntent returns false
|
||||
whenever PTZ is not the active target.
|
||||
*/
|
||||
latestRef.current?.setCameraAxisIntent(0);
|
||||
stopServoLoop();
|
||||
return;
|
||||
}
|
||||
@@ -235,7 +243,9 @@ export default function KeyboardInputManager() {
|
||||
const tokensSnapshot = new Set(activeTokensRef.current);
|
||||
const precisionActive = isPrecisionDriveActive(tokensSnapshot, latest.keymap);
|
||||
const servoStep = precisionActive ? PRECISION_SERVO_NUDGE_DEGREES : latest.servoStep;
|
||||
latest.nudgeServo(nextDirection * servoStep);
|
||||
if (!latest.setCameraAxisIntent(nextDirection)) {
|
||||
latest.nudgeServo(nextDirection * servoStep);
|
||||
}
|
||||
servoIntervalRef.current = setTimeout(tick, latest.servoRepeatMs);
|
||||
};
|
||||
servoIntervalRef.current = setTimeout(tick, 0);
|
||||
@@ -394,6 +404,7 @@ export default function KeyboardInputManager() {
|
||||
servoRepeatMs,
|
||||
servoStep,
|
||||
setAuxMotors,
|
||||
setCameraAxisIntent,
|
||||
setCameraPrecisionMode,
|
||||
setDriveVector,
|
||||
setMicPttActive,
|
||||
|
||||
@@ -12,7 +12,11 @@ const PTZ_SPEEDS = {
|
||||
medium: 0.5,
|
||||
fast: 1,
|
||||
};
|
||||
const ZOOM_PULSE_MS = 220;
|
||||
// The TrackMix advertises a minimum ONVIF movement timeout of one second. A
|
||||
// quarter-second browser heartbeat gives the server several opportunities to
|
||||
// renew a genuinely held intent while still letting its watchdog distinguish a
|
||||
// live control from a browser that disappeared without delivering a release.
|
||||
const MOTION_HEARTBEAT_MS = 250;
|
||||
|
||||
function clampUnit(value) {
|
||||
const number = Number(value) || 0;
|
||||
@@ -102,9 +106,10 @@ export function usePtzControlAdapter() {
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isActive = Boolean(ptz?.isOperator);
|
||||
const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP));
|
||||
const desiredMotionRef = useRef(PTZ_STOP);
|
||||
const panTiltIntentRef = useRef({ pan: 0, tilt: 0 });
|
||||
const zoomIntentRef = useRef(0);
|
||||
const zoomStopTimerRef = useRef(null);
|
||||
const heartbeatTimerRef = useRef(null);
|
||||
|
||||
const emitPtz = useCallback(
|
||||
(eventName, payload = {}) => {
|
||||
@@ -114,22 +119,13 @@ export function usePtzControlAdapter() {
|
||||
[isActive, socket],
|
||||
);
|
||||
|
||||
const stopMotion = useCallback(() => {
|
||||
if (zoomStopTimerRef.current) {
|
||||
clearTimeout(zoomStopTimerRef.current);
|
||||
zoomStopTimerRef.current = null;
|
||||
}
|
||||
// A true global stop is used for blur, route close, and control release, so
|
||||
// it deliberately clears every independently tracked PTZ axis intent.
|
||||
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
||||
zoomIntentRef.current = 0;
|
||||
const stopSignature = payloadSignature(PTZ_STOP);
|
||||
if (lastMotionSignatureRef.current === stopSignature) return;
|
||||
lastMotionSignatureRef.current = stopSignature;
|
||||
emitPtz('ptzCamera:stop');
|
||||
}, [emitPtz]);
|
||||
const clearHeartbeat = useCallback(() => {
|
||||
if (!heartbeatTimerRef.current) return;
|
||||
clearInterval(heartbeatTimerRef.current);
|
||||
heartbeatTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const sendMotion = useCallback(
|
||||
const publishMotion = useCallback(
|
||||
(payload, options = {}) => {
|
||||
if (!isActive) return false;
|
||||
const nextPayload = {
|
||||
@@ -138,16 +134,49 @@ export function usePtzControlAdapter() {
|
||||
zoom: clampUnit(payload?.zoom),
|
||||
};
|
||||
const nextSignature = payloadSignature(nextPayload);
|
||||
desiredMotionRef.current = nextPayload;
|
||||
|
||||
if (isIdlePayload(nextPayload)) {
|
||||
clearHeartbeat();
|
||||
} else if (!heartbeatTimerRef.current) {
|
||||
/*
|
||||
Movement is renewed from the complete desired vector, not from the
|
||||
individual input event that happened to start it. This is what makes
|
||||
pan/tilt and zoom independent: every heartbeat describes all axes as
|
||||
they should be now, and no delayed zoom pulse can resurrect an older
|
||||
direction after a release.
|
||||
*/
|
||||
heartbeatTimerRef.current = setInterval(() => {
|
||||
const current = desiredMotionRef.current;
|
||||
if (isIdlePayload(current)) {
|
||||
clearHeartbeat();
|
||||
return;
|
||||
}
|
||||
emitPtz('ptzCamera:motion', current);
|
||||
}, MOTION_HEARTBEAT_MS);
|
||||
}
|
||||
|
||||
if (!options.force && lastMotionSignatureRef.current === nextSignature) return true;
|
||||
lastMotionSignatureRef.current = nextSignature;
|
||||
if (isIdlePayload(nextPayload)) {
|
||||
emitPtz('ptzCamera:stop');
|
||||
} else {
|
||||
emitPtz('ptzCamera:move', nextPayload);
|
||||
}
|
||||
// Zero is a first-class desired state. The server translates the complete
|
||||
// idle vector into ONVIF Stop inside the same serialized command stream as
|
||||
// movement, which prevents separate move/stop handlers from racing.
|
||||
emitPtz('ptzCamera:motion', nextPayload);
|
||||
return true;
|
||||
},
|
||||
[emitPtz, isActive],
|
||||
[clearHeartbeat, emitPtz, isActive],
|
||||
);
|
||||
|
||||
const stopMotion = useCallback(
|
||||
(options = {}) => {
|
||||
// A global stop deliberately clears every axis. Safety/lifecycle callers
|
||||
// use force so the server receives a fresh stop even when the browser's
|
||||
// local signature already says it is idle after a dropped connection.
|
||||
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
||||
zoomIntentRef.current = 0;
|
||||
return publishMotion(PTZ_STOP, { force: Boolean(options.force) });
|
||||
},
|
||||
[publishMotion],
|
||||
);
|
||||
|
||||
const applyDriveVector = useCallback(
|
||||
@@ -163,60 +192,36 @@ export function usePtzControlAdapter() {
|
||||
Preserve the current zoom intent when a direction update arrives so a
|
||||
keyboard or touch event on one axis cannot erase another held axis.
|
||||
*/
|
||||
sendMotion({
|
||||
publishMotion({
|
||||
...panTiltIntentRef.current,
|
||||
zoom: zoomIntentRef.current,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[isActive, sendMotion],
|
||||
[isActive, publishMotion],
|
||||
);
|
||||
|
||||
const pulseZoom = useCallback(
|
||||
const setZoomIntent = useCallback(
|
||||
(direction) => {
|
||||
if (!isActive) return false;
|
||||
const sign = axisSign(direction);
|
||||
if (!sign) {
|
||||
if (zoomStopTimerRef.current) {
|
||||
clearTimeout(zoomStopTimerRef.current);
|
||||
zoomStopTimerRef.current = null;
|
||||
}
|
||||
zoomIntentRef.current = 0;
|
||||
/*
|
||||
Releasing zoom must not call the global PTZ stop. Re-emit the retained
|
||||
pan/tilt intent with zoom cleared so a held direction continues
|
||||
immediately instead of waiting for another directional key event.
|
||||
*/
|
||||
sendMotion({ ...panTiltIntentRef.current, zoom: 0 }, { force: true });
|
||||
return true;
|
||||
}
|
||||
const numeric = clampUnit(direction);
|
||||
const sign = axisSign(numeric);
|
||||
/*
|
||||
Zoom is different from pan/tilt because it is driven by repeated nudge
|
||||
events from existing camera controls. Force each pulse through even when
|
||||
the payload is identical, otherwise holding "camera up" only sends the
|
||||
first zoom command and every later nudge is de-duped away.
|
||||
PTZ zoom is a held velocity, not a rover servo nudge. Convert the input
|
||||
magnitude to the same precision/normal tiers used for pan and tilt, then
|
||||
retain it until the input surface explicitly publishes zero. The shared
|
||||
heartbeat renews that state; there are no per-button repeat or delayed
|
||||
stop timers left to race with pointer/key release.
|
||||
*/
|
||||
zoomIntentRef.current = sign * PTZ_SPEEDS.medium;
|
||||
sendMotion({
|
||||
const speed = !sign ? 0 : Math.abs(numeric) <= 0.45 ? PTZ_SPEEDS.slow : PTZ_SPEEDS.medium;
|
||||
zoomIntentRef.current = sign * speed;
|
||||
publishMotion({
|
||||
...panTiltIntentRef.current,
|
||||
zoom: zoomIntentRef.current,
|
||||
}, { force: true });
|
||||
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current);
|
||||
/*
|
||||
Existing rover camera controls are nudge/slider based, not hold-based.
|
||||
Treat each nudge as a short PTZ zoom pulse, then stop from the adapter
|
||||
so delayed stop behavior is owned by the camera layer only.
|
||||
*/
|
||||
zoomStopTimerRef.current = setTimeout(() => {
|
||||
zoomStopTimerRef.current = null;
|
||||
zoomIntentRef.current = 0;
|
||||
// A zoom pulse ending restores, rather than stops, any direction that
|
||||
// is still held in the independent pan/tilt intent.
|
||||
sendMotion({ ...panTiltIntentRef.current, zoom: 0 }, { force: true });
|
||||
}, ZOOM_PULSE_MS);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[isActive, sendMotion],
|
||||
[isActive, publishMotion],
|
||||
);
|
||||
|
||||
const setSpotlight = useCallback(
|
||||
@@ -253,20 +258,42 @@ export function usePtzControlAdapter() {
|
||||
useEffect(() => {
|
||||
if (isActive) return undefined;
|
||||
lastMotionSignatureRef.current = payloadSignature(PTZ_STOP);
|
||||
desiredMotionRef.current = PTZ_STOP;
|
||||
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
||||
zoomIntentRef.current = 0;
|
||||
if (zoomStopTimerRef.current) {
|
||||
clearTimeout(zoomStopTimerRef.current);
|
||||
zoomStopTimerRef.current = null;
|
||||
}
|
||||
clearHeartbeat();
|
||||
return undefined;
|
||||
}, [isActive]);
|
||||
}, [clearHeartbeat, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return undefined;
|
||||
|
||||
const forceSafetyStop = () => stopMotion({ force: true });
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') forceSafetyStop();
|
||||
};
|
||||
|
||||
/*
|
||||
Input components handle ordinary pointer/key releases, but the adapter is
|
||||
the only layer guaranteed to see every PTZ control surface. Centralizing
|
||||
browser lifecycle stops here covers touch, keyboard, and gamepad equally
|
||||
when a tab hides, a window blurs, or mobile navigation fires pagehide.
|
||||
*/
|
||||
window.addEventListener('blur', forceSafetyStop);
|
||||
window.addEventListener('pagehide', forceSafetyStop);
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('blur', forceSafetyStop);
|
||||
window.removeEventListener('pagehide', forceSafetyStop);
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, [isActive, stopMotion]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current);
|
||||
clearHeartbeat();
|
||||
},
|
||||
[],
|
||||
[clearHeartbeat],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
@@ -274,11 +301,11 @@ export function usePtzControlAdapter() {
|
||||
isActive,
|
||||
state: ptz,
|
||||
applyDriveVector,
|
||||
pulseZoom,
|
||||
setZoomIntent,
|
||||
setSpotlight,
|
||||
setIr,
|
||||
stopMotion,
|
||||
}),
|
||||
[applyDriveVector, isActive, ptz, pulseZoom, setIr, setSpotlight, stopMotion],
|
||||
[applyDriveVector, isActive, ptz, setIr, setSpotlight, setZoomIntent, stopMotion],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user