import { useEffect, useRef, useState } from "react"; import { Icon } from "@nodedc/ui-react"; import type { ObservationSourceDelivery } from "../core/runtime/contracts"; import { cameraBrowserTransportIdentity, cameraTransportCallbackIsCurrent, initialCameraPlaybackRecoveryState, reduceCameraPlaybackRecovery, type CameraPlaybackRecoveryEvent, } from "../core/observation/liveCameraRecovery"; import { subscribeToLiveViewerBuildFence } from "../core/observation/liveViewerDiagnostics"; type PlayerStatus = "connecting" | "buffering" | "playing" | "error"; export interface CameraLeaseRetryBudget { deliveryId: string; count: number; } const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const; export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000; export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000; // The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a // main-thread stall. Keep one bounded append margin above that complete batch; // crossing either limit replaces the MSE epoch instead of dropping fragments. export const CAMERA_PENDING_QUEUE_MAX_BYTES = 12 * 1024 * 1024; export const CAMERA_PENDING_QUEUE_MAX_SEGMENTS = 96; export type CameraStartupWatchdogStage = "first-media" | "first-playable-frame"; export interface CameraStartupWatchdog { armFirstMedia: () => void; markMediaReceived: () => void; markPlaying: () => void; clear: () => void; pendingStage: () => CameraStartupWatchdogStage | null; } /** * Supervise only the disposable browser transport. One fixed first-media * deadline covers both an MSE that never opens and an open-but-silent socket. * The first non-empty fragment then starts a separate first-playable-frame * deadline. Every subsequent fragment extends that deadline: a browser that is * still receiving the authoritative stream must not destroy its partial MSE * decode solely because the main thread or decoder needed longer to start. */ export function createCameraStartupWatchdog({ schedule, cancel, onTimeout, firstMediaTimeoutMs = CAMERA_FIRST_MEDIA_TIMEOUT_MS, firstPlayableFrameTimeoutMs = CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS, }: { schedule: (callback: () => void, timeoutMs: number) => number; cancel: (handle: number) => void; onTimeout: (stage: CameraStartupWatchdogStage) => void; firstMediaTimeoutMs?: number; firstPlayableFrameTimeoutMs?: number; }): CameraStartupWatchdog { let timer: number | undefined; let stage: CameraStartupWatchdogStage | null = null; let mediaReceived = false; let playing = false; const clearTimer = () => { if (timer !== undefined) cancel(timer); timer = undefined; stage = null; }; const arm = (nextStage: CameraStartupWatchdogStage, timeoutMs: number) => { clearTimer(); stage = nextStage; timer = schedule(() => { if (stage !== nextStage || playing) return; timer = undefined; stage = null; onTimeout(nextStage); }, timeoutMs); }; return { armFirstMedia() { if (mediaReceived || playing || stage !== null) return; arm("first-media", firstMediaTimeoutMs); }, markMediaReceived() { if (playing) return; mediaReceived = true; arm("first-playable-frame", firstPlayableFrameTimeoutMs); }, markPlaying() { if (playing) return; playing = true; clearTimer(); }, clear: clearTimer, pendingStage: () => stage, }; } export function cameraStartupWatchdogRecoveryMessage( stage: CameraStartupWatchdogStage, ): string { return stage === "first-media" ? "Камера подключена, но не передаёт медиаданные; восстанавливаем browser-preview." : "Медиаданные поступают, но первый кадр не воспроизводится; пересоздаём decoder."; } export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetryBudget { return { deliveryId, count: 0 }; } export function consumeCameraLeaseRetry( current: CameraLeaseRetryBudget, deliveryId: string, ): { budget: CameraLeaseRetryBudget; delay: number } { const count = current.deliveryId === deliveryId ? current.count : 0; const delayIndex = Math.min(count, CAMERA_LEASE_RETRY_DELAYS.length - 1); const delay = CAMERA_LEASE_RETRY_DELAYS[delayIndex]; return { budget: { deliveryId, count: Math.min(count + 1, CAMERA_LEASE_RETRY_DELAYS.length), }, delay, }; } export function cameraTransportRecoveryIsCurrent( activeAuthorityIdentity: string | null, expectedAuthorityIdentity: string | null, activeEpoch: number, expectedEpoch: number, disposed: boolean, ): boolean { return Boolean( expectedAuthorityIdentity && activeAuthorityIdentity === expectedAuthorityIdentity && cameraTransportCallbackIsCurrent(activeEpoch, expectedEpoch, disposed), ); } export function cameraTransportCanOpen(uiBuildStale: boolean): boolean { return !uiBuildStale; } export function cameraPendingQueueCanAccept( queuedBytes: number, queuedSegments: number, incomingBytes: number, ): boolean { return incomingBytes > 0 && queuedBytes + incomingBytes <= CAMERA_PENDING_QUEUE_MAX_BYTES && queuedSegments + 1 <= CAMERA_PENDING_QUEUE_MAX_SEGMENTS; } export function cameraTransportCloseRecoveryMessage(code: number): string { if (code === 4_008) { return "Browser-reader отстал от эфира; восстанавливаем текущую камеру."; } if (code === 1_008) { return "Camera adapter освобождает прежний browser-reader; переподключаемся."; } if (code === 1_000) { return "Browser-preview завершился; восстанавливаем текущую камеру."; } return "Browser-preview прерван; восстанавливаем текущую камеру."; } function websocketUrl(path: string): string { const url = new URL(path, window.location.href); if (url.protocol === "http:") url.protocol = "ws:"; if (url.protocol === "https:") url.protocol = "wss:"; if (url.protocol !== "ws:" && url.protocol !== "wss:") { throw new Error("Адаптер вернул неподдерживаемый адрес видеопотока."); } return url.toString(); } export function MseFmp4WebSocketPlayer({ delivery, label, recoveryAuthorityIdentity, }: { delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" }; label: string; recoveryAuthorityIdentity: string | null; }) { const videoRef = useRef(null); const leaseRetryRef = useRef(resetCameraLeaseRetryBudget(delivery.id)); const activeAuthorityRef = useRef(recoveryAuthorityIdentity); const recoveryPendingAuthorityRef = useRef(null); const transportHealthyRef = useRef(false); const transportEpochRef = useRef(0); const transportAuthorityRef = useRef(recoveryAuthorityIdentity); const activeTransportDisposeRef = useRef<(() => void) | null>(null); const uiBuildStaleRef = useRef(false); const [attempt, setAttempt] = useState(0); const [uiBuildStale, setUiBuildStale] = useState(false); const [status, setStatus] = useState("connecting"); const [message, setMessage] = useState("Подключение к локальному видеопотоку"); activeAuthorityRef.current = recoveryAuthorityIdentity; const transportIdentity = cameraBrowserTransportIdentity( delivery, recoveryAuthorityIdentity, ); useEffect(() => subscribeToLiveViewerBuildFence(() => { // Setting this local fence tears down the effect-owned WebSocket and MSE // buffer. It intentionally never invokes a plugin or device command. uiBuildStaleRef.current = true; activeTransportDisposeRef.current?.(); setUiBuildStale(true); }), []); useEffect(() => { const video = videoRef.current; if (!video) return; if (uiBuildStale || !cameraTransportCanOpen(uiBuildStaleRef.current)) { video.pause(); video.removeAttribute("src"); video.load(); return; } if (transportAuthorityRef.current !== recoveryAuthorityIdentity) { transportAuthorityRef.current = recoveryAuthorityIdentity; leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id); } let disposed = false; const transportEpoch = transportEpochRef.current + 1; transportEpochRef.current = transportEpoch; const transportIsCurrent = () => cameraTransportCallbackIsCurrent( transportEpochRef.current, transportEpoch, disposed || uiBuildStaleRef.current, ); let socket: WebSocket | null = null; let sourceBuffer: SourceBuffer | null = null; let onBufferUpdateEnd: (() => void) | null = null; let onBufferError: (() => void) | null = null; let objectUrl = ""; let retryTimer: number | undefined; let receivedMedia = false; let failed = false; let startupWatchdog: CameraStartupWatchdog | null = null; transportHealthyRef.current = false; const recovering = Boolean( activeAuthorityRef.current && recoveryPendingAuthorityRef.current === activeAuthorityRef.current, ); const queue: ArrayBuffer[] = []; let queuedBytes = 0; const fail = (copy: string) => { if (!transportIsCurrent() || failed) return; startupWatchdog?.clear(); failed = true; transportHealthyRef.current = false; recoveryPendingAuthorityRef.current = null; setStatus("error"); setMessage(copy); try { if (socket && socket.readyState < WebSocket.CLOSING) { socket.close(4_000, "Live video buffer reset"); } } catch { // The manual reconnect button will create a fresh transport and MSE buffer. } }; const retryTransport = (copy: string) => { if (!transportIsCurrent() || failed) return; if (!cameraTransportRecoveryIsCurrent( activeAuthorityRef.current, recoveryAuthorityIdentity, transportEpochRef.current, transportEpoch, disposed || uiBuildStaleRef.current, )) { fail(copy); return; } startupWatchdog?.clear(); failed = true; transportHealthyRef.current = false; queue.length = 0; queuedBytes = 0; const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id); leaseRetryRef.current = retry.budget; recoveryPendingAuthorityRef.current = recoveryAuthorityIdentity; setStatus("connecting"); setMessage(copy); try { if (socket && socket.readyState < WebSocket.CLOSING) { socket.close(4_001, "Live video transport recovery"); } } catch { // The replacement effect still fences and releases this transport. } retryTimer = window.setTimeout(() => { if (cameraTransportRecoveryIsCurrent( activeAuthorityRef.current, recoveryAuthorityIdentity, transportEpochRef.current, transportEpoch, disposed || uiBuildStaleRef.current, )) { setAttempt((value) => value + 1); } }, retry.delay); }; if (recoveryAuthorityIdentity) { startupWatchdog = createCameraStartupWatchdog({ schedule: (callback, timeoutMs) => window.setTimeout(callback, timeoutMs), cancel: (handle) => window.clearTimeout(handle), onTimeout: (stage) => { retryTransport(cameraStartupWatchdogRecoveryMessage(stage)); }, }); } const onPlaying = () => { if (!transportIsCurrent() || failed) return; startupWatchdog?.markPlaying(); transportHealthyRef.current = true; leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id); recoveryPendingAuthorityRef.current = null; setStatus("playing"); setMessage(""); }; video.addEventListener("playing", onPlaying); const appendNext = () => { if ( !transportIsCurrent() || failed || !sourceBuffer || sourceBuffer.updating || queue.length === 0 ) return; const chunk = queue.shift(); if (!chunk) return; queuedBytes -= chunk.byteLength; try { sourceBuffer.appendBuffer(chunk); } catch (error) { retryTransport(error instanceof DOMException && error.name === "QuotaExceededError" ? "Live-буфер переполнен; восстанавливаем канал без накопленной задержки." : "Не удалось добавить видеосегмент; восстанавливаем browser-preview."); } }; const enqueue = (chunk: ArrayBuffer) => { if (!transportIsCurrent() || failed || chunk.byteLength === 0) return; startupWatchdog?.markMediaReceived(); // Never drop arbitrary fMP4 fragments: the following samples may depend // on them. A bounded reset is safer and keeps live latency deterministic. if (!cameraPendingQueueCanAccept(queuedBytes, queue.length, chunk.byteLength)) { retryTransport("Видеодекодер отстал от эфира; восстанавливаем live-буфер."); return; } queue.push(chunk); queuedBytes += chunk.byteLength; appendNext(); }; const mediaType = delivery.mediaType?.trim(); if (typeof MediaSource === "undefined") { fail("Этот браузер не поддерживает Media Source Extensions."); return; } if (!mediaType || !MediaSource.isTypeSupported(mediaType)) { fail(mediaType ? `Браузер не поддерживает ${mediaType}` : "Адаптер не сообщил MIME/codec fMP4-потока."); return; } setStatus("connecting"); setMessage(recovering ? "Восстановление камеры после разрыва браузерного канала" : "Подключение к локальному видеопотоку"); const mediaSource = new MediaSource(); objectUrl = URL.createObjectURL(mediaSource); video.src = objectUrl; startupWatchdog?.armFirstMedia(); const onSourceOpen = () => { if (!transportIsCurrent() || failed) return; try { sourceBuffer = mediaSource.addSourceBuffer(mediaType); } catch { fail("Не удалось создать MSE-буфер для указанного кодека."); return; } onBufferUpdateEnd = () => { if (!transportIsCurrent() || failed || !sourceBuffer) return; const buffered = sourceBuffer.buffered; if (buffered.length > 0) { const end = buffered.end(buffered.length - 1); const start = buffered.start(0); if (end - video.currentTime > 1) video.currentTime = Math.max(0, end - 0.1); if (!receivedMedia) { receivedMedia = true; setStatus("buffering"); setMessage(recovering ? "Запуск первого кадра восстановленной камеры" : "Запуск первого декодированного кадра"); void video.play().catch(() => undefined); } const removeBefore = end - 3; if (removeBefore > start && !sourceBuffer.updating) { try { sourceBuffer.remove(0, removeBefore); return; } catch { // Continue appending; quota handling performs a clean reset. } } } appendNext(); }; onBufferError = () => { retryTransport("MSE сбросил видеосегмент; восстанавливаем decoder."); }; sourceBuffer.addEventListener("updateend", onBufferUpdateEnd); sourceBuffer.addEventListener("error", onBufferError); try { socket = new WebSocket(websocketUrl(delivery.url)); } catch (error) { fail(error instanceof Error ? error.message : "Некорректный адрес видеопотока."); return; } socket.binaryType = "arraybuffer"; socket.addEventListener("open", () => { if (!transportIsCurrent() || failed) return; startupWatchdog?.armFirstMedia(); setStatus("buffering"); setMessage(recovering ? "Ожидание первого кадра после восстановления" : "Ожидание первого видеокадра"); }); socket.addEventListener("message", (event) => { if (!transportIsCurrent() || failed) return; if (event.data instanceof ArrayBuffer) { enqueue(event.data); } else if (event.data instanceof Blob) { void event.data.arrayBuffer().then(enqueue).catch(() => { retryTransport("Получен повреждённый видеосегмент; восстанавливаем канал."); }); } }); socket.addEventListener("error", () => { retryTransport("Связь с локальным video adapter потеряна; переподключаемся."); }); socket.addEventListener("close", (event) => { if (!transportIsCurrent() || failed) return; retryTransport(cameraTransportCloseRecoveryMessage(event.code)); }); }; mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true }); const disposeTransport = () => { if (disposed) return; disposed = true; transportHealthyRef.current = false; startupWatchdog?.clear(); if (retryTimer !== undefined) window.clearTimeout(retryTimer); queue.length = 0; socket?.close(1000, "Browser preview transport replaced or hidden"); video.removeEventListener("playing", onPlaying); mediaSource.removeEventListener("sourceopen", onSourceOpen); if (sourceBuffer && onBufferUpdateEnd) { sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd); } if (sourceBuffer && onBufferError) { sourceBuffer.removeEventListener("error", onBufferError); } try { if (sourceBuffer?.updating) sourceBuffer.abort(); } catch { // The MediaSource can already be closing. } try { if (mediaSource.readyState === "open") mediaSource.endOfStream(); } catch { // Cleanup must continue even if the browser has already detached MSE. } video.pause(); video.removeAttribute("src"); video.load(); if (objectUrl) URL.revokeObjectURL(objectUrl); }; activeTransportDisposeRef.current = disposeTransport; return () => { if (activeTransportDisposeRef.current === disposeTransport) { activeTransportDisposeRef.current = null; } disposeTransport(); }; }, [attempt, recoveryAuthorityIdentity, transportIdentity, uiBuildStale]); useEffect(() => { if ( !recoveryAuthorityIdentity || uiBuildStale || !cameraTransportCanOpen(uiBuildStaleRef.current) ) return; let recovery = initialCameraPlaybackRecoveryState( recoveryAuthorityIdentity, Date.now(), ); const dispatchRecovery = (event: CameraPlaybackRecoveryEvent) => { if (uiBuildStaleRef.current) return; const decision = reduceCameraPlaybackRecovery(recovery, event, { activeAuthorityIdentity: activeAuthorityRef.current, now: Date.now(), documentVisible: document.visibilityState === "visible", networkOnline: navigator.onLine !== false, transportHealthy: transportHealthyRef.current, }); recovery = decision.state; if (!decision.reopen) return; leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id); recoveryPendingAuthorityRef.current = recoveryAuthorityIdentity; setStatus("connecting"); setMessage("Восстановление камеры после разрыва браузерного канала"); // The transport effect cleans up the old WebSocket and MSE object before // opening their replacement. No plugin action or device command occurs. setAttempt((value) => value + 1); }; const onVisibilityChange = () => { dispatchRecovery({ type: document.visibilityState === "hidden" ? "document-hidden" : "document-visible", }); }; const onOnline = () => dispatchRecovery({ type: "network-online" }); const onPageShow = (event: PageTransitionEvent) => { dispatchRecovery({ type: "page-restore", persisted: event.persisted }); }; const heartbeat = window.setInterval(() => { dispatchRecovery({ type: "heartbeat" }); }, 1_000); document.addEventListener("visibilitychange", onVisibilityChange); window.addEventListener("online", onOnline); window.addEventListener("pageshow", onPageShow); return () => { window.clearInterval(heartbeat); document.removeEventListener("visibilitychange", onVisibilityChange); window.removeEventListener("online", onOnline); window.removeEventListener("pageshow", onPageShow); }; }, [delivery.id, recoveryAuthorityIdentity, uiBuildStale]); return (
); }