600 lines
22 KiB
TypeScript
600 lines
22 KiB
TypeScript
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<HTMLVideoElement>(null);
|
|
const leaseRetryRef = useRef(resetCameraLeaseRetryBudget(delivery.id));
|
|
const activeAuthorityRef = useRef(recoveryAuthorityIdentity);
|
|
const recoveryPendingAuthorityRef = useRef<string | null>(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<PlayerStatus>("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 (
|
|
<div className="mse-fmp4-player" data-status={status}>
|
|
<video
|
|
ref={videoRef}
|
|
className="observation-media__asset"
|
|
aria-label={label}
|
|
autoPlay
|
|
muted
|
|
playsInline
|
|
/>
|
|
{status !== "playing" ? (
|
|
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
|
|
<Icon name={status === "error" ? "alert" : "video"} size={20} />
|
|
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
|
|
<span>{message}</span>
|
|
{status === "error" ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
|
recoveryPendingAuthorityRef.current = null;
|
|
setAttempt((value) => value + 1);
|
|
}}
|
|
>
|
|
<Icon name="refresh" size={14} />
|
|
Подключиться повторно
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|