This commit is contained in:
legop3
2026-07-11 18:35:58 -04:00
parent 0bb3f89472
commit 4d66defae0
9 changed files with 190 additions and 78 deletions
+39 -10
View File
@@ -29,6 +29,9 @@ export default function PtzLiveVideo({
const videoRef = useRef(null);
const retryTimerRef = useRef(null);
const playTimerRef = useRef(null);
const enabledRef = useRef(enabled);
const fallbackRef = useRef(false);
const playerGenerationRef = useRef(0);
const [status, setStatus] = useState('idle');
const [detail, setDetail] = useState(null);
const [restartToken, setRestartToken] = useState(0);
@@ -39,8 +42,30 @@ export default function PtzLiveVideo({
const source = sources[PTZ_CAMERA_ID] || null;
const shouldUseFallback = Boolean(source?.error && isAuthorizationError(source.error));
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
useEffect(() => {
fallbackRef.current = shouldUseFallback;
}, [shouldUseFallback]);
useEffect(() => {
if (enabled && !shouldUseFallback) return undefined;
/*
A retry that was scheduled before the server denied live access should not
keep firing in snapshot mode. Clear it when live playback is no longer the
active display policy.
*/
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
return undefined;
}, [enabled, shouldUseFallback]);
const scheduleRestart = useCallback(() => {
if (!enabled || shouldUseFallback) return;
if (!enabledRef.current || fallbackRef.current) return;
/*
WHEP sessions are one-shot browser/server negotiations. When the camera
reboots, the old PeerConnection and token can look alive enough to keep a
@@ -53,18 +78,25 @@ export default function PtzLiveVideo({
retryTimerRef.current = null;
setRestartToken(Date.now());
}, RESTART_DELAY_MS);
}, [enabled, shouldUseFallback]);
}, []);
useEffect(() => {
if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined;
let active = true;
const generation = playerGenerationRef.current + 1;
playerGenerationRef.current = generation;
const player = new WhepPlayer({
url: source.url,
token: source.token,
video: videoRef.current,
startMuted,
onStatus: (nextStatus, info) => {
if (!active) return;
/*
Old PeerConnection callbacks can arrive after React has already
cleaned up this effect for a newer token. Only the currently-owned
generation is allowed to update status or schedule another restart.
*/
if (!active || playerGenerationRef.current !== generation) return;
const normalized = String(nextStatus || '').toLowerCase();
setStatus(nextStatus || 'unknown');
setDetail(info || null);
@@ -75,7 +107,7 @@ export default function PtzLiveVideo({
});
player.start().catch((err) => {
if (!active) return;
if (!active || playerGenerationRef.current !== generation) return;
setStatus('error');
setDetail(err.message || 'WHEP start failed');
scheduleRestart();
@@ -95,13 +127,16 @@ export default function PtzLiveVideo({
useEffect(() => {
const video = videoRef.current;
if (!enabled || shouldUseFallback || !source?.url || !video) return undefined;
const generation = playerGenerationRef.current;
const handleEnded = () => {
if (playerGenerationRef.current !== generation) return;
setStatus('stopped');
setDetail('ended');
scheduleRestart();
};
const handleError = () => {
if (playerGenerationRef.current !== generation) return;
setStatus('error');
setDetail(video.error?.message || 'video element error');
scheduleRestart();
@@ -115,12 +150,6 @@ export default function PtzLiveVideo({
};
}, [enabled, scheduleRestart, shouldUseFallback, source?.url]);
useEffect(() => {
if (status === 'stopped' && source?.url && !shouldUseFallback) {
scheduleRestart();
}
}, [scheduleRestart, shouldUseFallback, source?.url, status]);
useEffect(() => {
const video = videoRef.current;
if (!enabled || shouldUseFallback || startMuted || !source?.url || !video) {
@@ -12,7 +12,7 @@ import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { usePtzCameraSnapshot } from '../../hooks/usePtzCameraSnapshot.js';
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { isFeatureEnabled } from '../../lib/features.js';
const PTZ_CAMERA_ID = 'ptz-camera';
@@ -304,7 +304,8 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
const isOperator = Boolean(ptz?.isOperator);
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
const { ptzRelease } = useSessionActions();
const snapshot = usePtzCameraSnapshot({ enabled: open && !isOperator });
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !isOperator });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const [releasePending, setReleasePending] = useState(false);
if (!open) return null;
@@ -418,7 +419,8 @@ export default function VipPtzCameraCard({ onMessage, fullWidth = false, layout
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const { ptzClaim, ptzRelease } = useSessionActions();
const snapshot = usePtzCameraSnapshot({ enabled: Boolean(featureEnabled) });
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: Boolean(featureEnabled) });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const [controllerOpen, setControllerOpen] = useState(false);
const [pending, setPending] = useState(false);
+100 -38
View File
@@ -1,15 +1,29 @@
// Hook: usePtzCameraSnapshot
// Purpose: Subscribes to the server-generated PTZ camera snapshot feed.
// Scope: Mirrors the rover snapshot object-URL lifecycle while using PTZ-specific socket events and authorization.
import { useEffect, useRef, useState } from 'react';
// Hook: usePtzCameraSnapshots
// Purpose: Subscribes to PTZ snapshot streams using the same map-shaped contract as rover snapshots.
// Scope: Keeps PTZ snapshot lifecycle boring: callers pass source ids and receive feeds keyed by id.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
export function usePtzCameraSnapshot(options = {}) {
export const PTZ_CAMERA_ID = 'ptz-camera';
export function usePtzCameraSnapshots(sourceList = [], options = {}) {
const socket = useSocket();
const { enabled = true, version = null } = options;
const [feed, setFeed] = useState(null);
const objectUrlRef = useRef(null);
const [feeds, setFeeds] = useState({});
const objectUrls = useRef(new Map());
const ids = useMemo(
() => sourceList.map((entry) => (typeof entry === 'string' ? entry : entry?.id)).filter(Boolean),
[sourceList],
);
const idsKey = useMemo(() => {
const base = ids.join('|');
return version ? `${base}|v:${version}` : base;
}, [ids, version]);
const idsRef = useRef([]);
const [connectionNonce, setConnectionNonce] = useState(0);
const statsRef = useRef(new Map());
const debugSnapshots =
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugSnapshots');
useEffect(() => {
if (!socket) return undefined;
@@ -19,56 +33,104 @@ export function usePtzCameraSnapshot(options = {}) {
}, [socket]);
useEffect(() => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
setFeed(null);
}, [version]);
idsRef.current = ids;
}, [idsKey, ids]);
useEffect(() => {
if (!enabled || !socket) return undefined;
objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear();
setFeeds({});
}, [idsKey]);
useEffect(() => {
if (!enabled) {
objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear();
setFeeds({});
return undefined;
}
if (!idsRef.current.length || !socket) return undefined;
let cancelled = false;
const currentIds = idsRef.current;
const handleFrame = (meta = {}, buffer) => {
if (cancelled || !buffer) return;
if (cancelled || !meta.id || !buffer) return;
const sizeBytes = buffer.byteLength ?? buffer.length ?? 0;
const now = Date.now();
const prevStats = statsRef.current.get(meta.id) || {
count: 0,
totalBytes: 0,
lastLogAt: 0,
};
const nextStats = {
count: prevStats.count + 1,
totalBytes: prevStats.totalBytes + sizeBytes,
lastLogAt: prevStats.lastLogAt,
};
if (debugSnapshots && (!nextStats.lastLogAt || now - nextStats.lastLogAt >= 10000)) {
const avgBytes = nextStats.count ? nextStats.totalBytes / nextStats.count : 0;
console.log(
'[ptzSnapshot]',
meta.id,
`frame=${sizeBytes}B`,
`avg=${Math.round(avgBytes)}B`,
`count=${nextStats.count}`,
);
nextStats.lastLogAt = now;
}
statsRef.current.set(meta.id, nextStats);
const url = URL.createObjectURL(new Blob([buffer], { type: 'image/jpeg' }));
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = url;
setFeed({
status: 'playing',
ts: meta.ts || Date.now(),
error: null,
objectUrl: url,
});
const prevUrl = objectUrls.current.get(meta.id);
if (prevUrl) URL.revokeObjectURL(prevUrl);
objectUrls.current.set(meta.id, url);
setFeeds((prev) => ({
...prev,
[meta.id]: {
status: 'playing',
ts: meta.ts || Date.now(),
error: null,
objectUrl: url,
},
}));
};
const handleStatus = (meta = {}) => {
if (cancelled) return;
setFeed((prev) => ({
...(prev || {}),
status: meta.error ? 'error' : prev?.status || 'connecting',
error: meta.error || null,
ts: meta.ts || prev?.ts || null,
objectUrl: prev?.objectUrl || null,
if (cancelled || !meta.id) return;
setFeeds((prev) => ({
...prev,
[meta.id]: {
...(prev[meta.id] || {}),
status: meta.error ? 'error' : prev[meta.id]?.status || 'connecting',
error: meta.error || null,
ts: meta.ts || prev[meta.id]?.ts || null,
objectUrl: prev[meta.id]?.objectUrl || null,
},
}));
};
socket.on('ptzCamera:snapshotFrame', handleFrame);
socket.on('ptzCamera:snapshotStatus', handleStatus);
socket.emit('ptzCamera:snapshotSubscribe', {}, () => {});
socket.emit('ptzCamera:snapshotSubscribe', { ids: currentIds }, () => {});
return () => {
cancelled = true;
socket.emit('ptzCamera:snapshotUnsubscribe');
socket.emit('ptzCamera:snapshotUnsubscribe', { ids: currentIds });
socket.off('ptzCamera:snapshotFrame', handleFrame);
socket.off('ptzCamera:snapshotStatus', handleStatus);
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear();
};
}, [socket, enabled, version, connectionNonce]);
}, [socket, idsKey, enabled, connectionNonce, debugSnapshots]);
return feed;
return feeds;
}
export function usePtzCameraSnapshot(options = {}) {
/*
This wrapper keeps older callers working while new PTZ UI uses the same
keyed feed shape as useRoverSnapshots(). It should stay tiny so the real
subscription behavior only has one implementation to maintain.
*/
const feeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], options);
return feeds[PTZ_CAMERA_ID] || null;
}
@@ -4,7 +4,7 @@
// falls back to the PTZ snapshot feed that remote spectators are allowed to see.
import PtzLiveVideo from '../../../components/PtzLiveVideo/index.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { usePtzCameraSnapshot } from '../../../hooks/usePtzCameraSnapshot.js';
import { PTZ_CAMERA_ID, usePtzCameraSnapshots } from '../../../hooks/usePtzCameraSnapshot.js';
function formatRemaining(deadline) {
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
@@ -41,7 +41,8 @@ function InfoRow({ label, value, tone = '' }) {
}
function PtzSnapshotFallback({ label, source }) {
const snapshot = usePtzCameraSnapshot({ enabled: true });
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: true });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
return (
<div className="relative aspect-video w-full overflow-hidden rounded bg-black">
{snapshot?.objectUrl ? (