feat(k1): add live cameras and reliable spatial following
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationSourceDelivery } from "../core/runtime/contracts";
|
||||
|
||||
type PlayerStatus = "connecting" | "buffering" | "playing" | "error";
|
||||
|
||||
export interface CameraLeaseRetryBudget {
|
||||
deliveryId: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000] as const;
|
||||
|
||||
export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetryBudget {
|
||||
return { deliveryId, count: 0 };
|
||||
}
|
||||
|
||||
export function consumeCameraLeaseRetry(
|
||||
current: CameraLeaseRetryBudget,
|
||||
deliveryId: string,
|
||||
): { budget: CameraLeaseRetryBudget; delay: number | null } {
|
||||
const count = current.deliveryId === deliveryId ? current.count : 0;
|
||||
const delay = CAMERA_LEASE_RETRY_DELAYS[count] ?? null;
|
||||
return {
|
||||
budget: { deliveryId, count: delay === null ? count : count + 1 },
|
||||
delay,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
}: {
|
||||
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" };
|
||||
label: string;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const leaseRetryRef = useRef(resetCameraLeaseRetryBudget(delivery.id));
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [status, setStatus] = useState<PlayerStatus>("connecting");
|
||||
const [message, setMessage] = useState("Подключение к локальному видеопотоку");
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
let sourceBuffer: SourceBuffer | null = null;
|
||||
let objectUrl = "";
|
||||
let retryTimer: number | undefined;
|
||||
let receivedMedia = false;
|
||||
let failed = false;
|
||||
const queue: ArrayBuffer[] = [];
|
||||
let queuedBytes = 0;
|
||||
|
||||
const fail = (copy: string) => {
|
||||
if (disposed || failed) return;
|
||||
failed = true;
|
||||
setStatus("error");
|
||||
setMessage(copy);
|
||||
try {
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) {
|
||||
socket.close(1011, "Live video buffer reset");
|
||||
}
|
||||
} catch {
|
||||
// The manual reconnect button will create a fresh transport and MSE buffer.
|
||||
}
|
||||
};
|
||||
|
||||
const retryLease = () => {
|
||||
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
|
||||
leaseRetryRef.current = retry.budget;
|
||||
if (retry.delay === null) {
|
||||
fail("Camera adapter ещё занят предыдущим окном. Подключитесь повторно.");
|
||||
return;
|
||||
}
|
||||
setStatus("connecting");
|
||||
setMessage("Освобождение предыдущего окна камеры");
|
||||
retryTimer = window.setTimeout(() => {
|
||||
if (!disposed) setAttempt((value) => value + 1);
|
||||
}, retry.delay);
|
||||
};
|
||||
|
||||
const onPlaying = () => {
|
||||
if (disposed) return;
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
setStatus("playing");
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
video.addEventListener("playing", onPlaying);
|
||||
|
||||
const appendNext = () => {
|
||||
if (disposed || !sourceBuffer || sourceBuffer.updating || queue.length === 0) return;
|
||||
const chunk = queue.shift();
|
||||
if (!chunk) return;
|
||||
queuedBytes -= chunk.byteLength;
|
||||
try {
|
||||
sourceBuffer.appendBuffer(chunk);
|
||||
} catch (error) {
|
||||
fail(error instanceof DOMException && error.name === "QuotaExceededError"
|
||||
? "Live-буфер переполнен и сброшен, чтобы не накапливать задержку."
|
||||
: "Не удалось добавить видеосегмент. Повторите подключение.");
|
||||
}
|
||||
};
|
||||
|
||||
const enqueue = (chunk: ArrayBuffer) => {
|
||||
if (disposed || chunk.byteLength === 0) return;
|
||||
// Never drop arbitrary fMP4 fragments: the following samples may depend
|
||||
// on them. A bounded reset is safer and keeps live latency deterministic.
|
||||
if (queuedBytes + chunk.byteLength > 2 * 1024 * 1024) {
|
||||
fail("Видеодекодер не успевает за эфиром. 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("Подключение к локальному видеопотоку");
|
||||
const mediaSource = new MediaSource();
|
||||
objectUrl = URL.createObjectURL(mediaSource);
|
||||
video.src = objectUrl;
|
||||
|
||||
const onSourceOpen = () => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
sourceBuffer = mediaSource.addSourceBuffer(mediaType);
|
||||
} catch {
|
||||
fail("Не удалось создать MSE-буфер для указанного кодека.");
|
||||
return;
|
||||
}
|
||||
|
||||
sourceBuffer.addEventListener("updateend", () => {
|
||||
if (disposed || !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("Запуск первого декодированного кадра");
|
||||
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();
|
||||
});
|
||||
sourceBuffer.addEventListener("error", () => {
|
||||
fail("MSE сообщил об ошибке декодирования видеосегмента.");
|
||||
});
|
||||
|
||||
try {
|
||||
socket = new WebSocket(websocketUrl(delivery.url));
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : "Некорректный адрес видеопотока.");
|
||||
return;
|
||||
}
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.addEventListener("open", () => {
|
||||
if (disposed) return;
|
||||
setStatus("buffering");
|
||||
setMessage("Ожидание первого видеокадра");
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
enqueue(event.data);
|
||||
} else if (event.data instanceof Blob) {
|
||||
void event.data.arrayBuffer().then(enqueue).catch(() => {
|
||||
fail("Получен повреждённый видеосегмент.");
|
||||
});
|
||||
}
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
fail("Соединение с локальным video adapter потеряно.");
|
||||
});
|
||||
socket.addEventListener("close", (event) => {
|
||||
if (!disposed && !failed && event.code === 1008) {
|
||||
retryLease();
|
||||
} else if (!disposed) {
|
||||
fail(event.code === 1000
|
||||
? "Видеопоток завершён. Можно подключиться повторно."
|
||||
: "Видеопоток прерван. Проверьте устройство и повторите подключение.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||
queue.length = 0;
|
||||
socket?.close(1000, "Источник скрыт оператором");
|
||||
video.removeEventListener("playing", onPlaying);
|
||||
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);
|
||||
};
|
||||
}, [attempt, delivery.id, delivery.mediaType, delivery.url]);
|
||||
|
||||
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);
|
||||
setAttempt((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
Подключиться повторно
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ObservationSourceDescriptor,
|
||||
ObservationSourceModality,
|
||||
} from "../core/runtime/contracts";
|
||||
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
||||
|
||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||
"point-cloud": "globe",
|
||||
@@ -29,7 +30,38 @@ export function observationSourceStatusLabel(source: ObservationSourceDescriptor
|
||||
}
|
||||
|
||||
export function ObservationMedia({ source }: { source: ObservationSourceDescriptor }) {
|
||||
if (source.previewUrl && source.modality === "video") {
|
||||
const sourceSelected = source.activation ? source.activation.selected : true;
|
||||
const deliveryActive = Boolean(source.delivery && sourceSelected);
|
||||
|
||||
if (
|
||||
deliveryActive &&
|
||||
source.delivery?.kind === "mse-fmp4-websocket" &&
|
||||
source.modality === "video"
|
||||
) {
|
||||
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />;
|
||||
}
|
||||
|
||||
if (deliveryActive && source.delivery?.kind === "video-url" && source.modality === "video") {
|
||||
return (
|
||||
<video
|
||||
className="observation-media__asset"
|
||||
src={source.delivery.url}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
deliveryActive &&
|
||||
source.delivery?.kind === "image-url" &&
|
||||
(source.modality === "image" || source.modality === "depth")
|
||||
) {
|
||||
return <img className="observation-media__asset" src={source.delivery.url} alt={source.label} />;
|
||||
}
|
||||
|
||||
if (sourceSelected && source.previewUrl && source.modality === "video") {
|
||||
return (
|
||||
<video
|
||||
className="observation-media__asset"
|
||||
@@ -41,7 +73,11 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
|
||||
);
|
||||
}
|
||||
|
||||
if (source.previewUrl && (source.modality === "image" || source.modality === "depth")) {
|
||||
if (
|
||||
sourceSelected &&
|
||||
source.previewUrl &&
|
||||
(source.modality === "image" || source.modality === "depth")
|
||||
) {
|
||||
return <img className="observation-media__asset" src={source.previewUrl} alt={source.label} />;
|
||||
}
|
||||
|
||||
@@ -51,7 +87,11 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
|
||||
<strong>{observationSourceStatusLabel(source)}</strong>
|
||||
<span>
|
||||
{source.modality === "video"
|
||||
? "Канал известен, browser-preview ещё не подключён"
|
||||
? source.activation?.controllable
|
||||
? source.activation.selected
|
||||
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
|
||||
: "Откройте канал — локальный адаптер подключит выбранную камеру"
|
||||
: "Канал известен, browser-preview сейчас недоступен"
|
||||
: source.description}
|
||||
</span>
|
||||
</div>
|
||||
@@ -61,11 +101,13 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
|
||||
export function ObservationSourcePicker({
|
||||
sources,
|
||||
visibleSourceIds,
|
||||
pendingSourceIds,
|
||||
onToggle,
|
||||
}: {
|
||||
sources: readonly ObservationSourceDescriptor[];
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
onToggle: (sourceId: string) => void;
|
||||
pendingSourceIds?: ReadonlySet<string>;
|
||||
onToggle: (sourceId: string) => void | Promise<boolean>;
|
||||
}) {
|
||||
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
||||
|
||||
@@ -108,6 +150,14 @@ export function ObservationSourcePicker({
|
||||
<div className="observation-source-menu__list">
|
||||
{sources.map((source) => {
|
||||
const selected = visibleSourceIds.has(source.id);
|
||||
const pending = pendingSourceIds?.has(source.id) ?? false;
|
||||
const canOpen = Boolean(
|
||||
selected ||
|
||||
source.modality === "point-cloud" ||
|
||||
source.previewUrl ||
|
||||
source.delivery ||
|
||||
source.activation?.controllable,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
@@ -115,7 +165,8 @@ export function ObservationSourcePicker({
|
||||
className="nodedc-dropdown-option observation-source-option"
|
||||
data-selected={selected ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onToggle(source.id)}
|
||||
disabled={pending || !canOpen}
|
||||
onClick={() => void onToggle(source.id)}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<Icon name={sourceIcon[source.modality]} size={16} />
|
||||
@@ -124,7 +175,7 @@ export function ObservationSourcePicker({
|
||||
<span className="nodedc-dropdown-option__label">{source.label}</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
||||
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
||||
</span>
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__check">
|
||||
|
||||
@@ -10,21 +10,25 @@ export interface RerunSelection {
|
||||
|
||||
export interface RerunViewportProps {
|
||||
sourceUrl: string;
|
||||
followLive?: boolean;
|
||||
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
|
||||
onSelectionChange?: (selection: RerunSelection | null) => void;
|
||||
}
|
||||
|
||||
export function RerunViewport({
|
||||
sourceUrl,
|
||||
followLive = false,
|
||||
onStatusChange,
|
||||
onSelectionChange,
|
||||
}: RerunViewportProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedSource = sourceUrl.trim();
|
||||
if (!normalizedSource || !hostRef.current) {
|
||||
const host = hostRef.current;
|
||||
if (!normalizedSource || !host) {
|
||||
setStatus("idle");
|
||||
onStatusChange?.("idle");
|
||||
onSelectionChange?.(null);
|
||||
@@ -32,51 +36,86 @@ export function RerunViewport({
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let stopViewer: (() => void) | undefined;
|
||||
let disposeViewer: (() => void) | undefined;
|
||||
let recordingOpenTimer: number | undefined;
|
||||
let recordingOpened = false;
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
const unsubscribeAll = () => {
|
||||
while (unsubscribers.length > 0) {
|
||||
const unsubscribe = unsubscribers.pop();
|
||||
try {
|
||||
unsubscribe?.();
|
||||
} catch {
|
||||
// A stopped vendor viewer can already have released its event map.
|
||||
}
|
||||
}
|
||||
};
|
||||
const clearRecordingOpenTimer = () => {
|
||||
if (recordingOpenTimer === undefined) return;
|
||||
window.clearTimeout(recordingOpenTimer);
|
||||
recordingOpenTimer = undefined;
|
||||
};
|
||||
const reportError = (message: string) => {
|
||||
if (disposed) return;
|
||||
setStatus("error");
|
||||
onStatusChange?.("error", message);
|
||||
};
|
||||
|
||||
host.replaceChildren();
|
||||
setStatus("loading");
|
||||
onStatusChange?.("loading");
|
||||
|
||||
void import("@rerun-io/web-viewer")
|
||||
.then(async ({ WebViewer }) => {
|
||||
// React StrictMode intentionally disposes the first effect while the
|
||||
// dynamic import is still pending. Never start that stale viewer (the
|
||||
// vendor API treats a missing host as document.body).
|
||||
if (disposed) return;
|
||||
|
||||
const viewer = new WebViewer();
|
||||
stopViewer = () => {
|
||||
// Remove the gRPC receiver explicitly before tearing down WASM. This
|
||||
// closes the browser-side stream promptly so the local SDK server can
|
||||
// release its port before the next device session starts.
|
||||
let viewerDisposed = false;
|
||||
disposeViewer = () => {
|
||||
if (viewerDisposed) return;
|
||||
viewerDisposed = true;
|
||||
clearRecordingOpenTimer();
|
||||
unsubscribeAll();
|
||||
try {
|
||||
viewer.close(normalizedSource);
|
||||
if (viewer.ready) viewer.close(normalizedSource);
|
||||
} catch {
|
||||
// The viewer can already be stopped after a startup failure.
|
||||
// The receiver may already be gone after a transport failure.
|
||||
}
|
||||
viewer.stop();
|
||||
try {
|
||||
viewer.stop();
|
||||
} catch {
|
||||
// A partially initialized WASM handle can already be gone after a
|
||||
// startup failure. The host still has to be cleared below.
|
||||
}
|
||||
host.replaceChildren();
|
||||
};
|
||||
|
||||
await viewer.start(
|
||||
normalizedSource,
|
||||
hostRef.current,
|
||||
{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
theme: "dark",
|
||||
hide_welcome_screen: true,
|
||||
enable_history: false,
|
||||
allow_fullscreen: false,
|
||||
},
|
||||
null,
|
||||
unsubscribers.push(
|
||||
viewer.on("recording_open", (event) => {
|
||||
if (disposed) return;
|
||||
recordingOpened = true;
|
||||
clearRecordingOpenTimer();
|
||||
if (followLive) {
|
||||
try {
|
||||
// The backend blueprint owns native `Following`; the host only
|
||||
// selects its live timeline once. Repeated SetTime calls would
|
||||
// force Rerun out of Following and create needless repaints.
|
||||
viewer.set_active_timeline(event.recording_id, "stream_time");
|
||||
} catch {
|
||||
// A receiver can disappear between the event and the WASM call.
|
||||
}
|
||||
}
|
||||
setStatus("ready");
|
||||
onStatusChange?.("ready");
|
||||
}),
|
||||
);
|
||||
if (disposed) {
|
||||
stopViewer();
|
||||
return;
|
||||
}
|
||||
|
||||
viewer.override_panel_state("top", "hidden");
|
||||
viewer.override_panel_state("blueprint", "hidden");
|
||||
viewer.override_panel_state("selection", "hidden");
|
||||
viewer.override_panel_state("time", "hidden");
|
||||
|
||||
unsubscribers.push(
|
||||
viewer.on("selection_change", (event) => {
|
||||
if (disposed) return;
|
||||
const entity = event.items.find((item) => item.type === "entity");
|
||||
if (!entity || entity.type !== "entity") {
|
||||
onSelectionChange?.(null);
|
||||
@@ -90,23 +129,54 @@ export function RerunViewport({
|
||||
}),
|
||||
);
|
||||
|
||||
setStatus("ready");
|
||||
onStatusChange?.("ready");
|
||||
try {
|
||||
await viewer.start(
|
||||
normalizedSource,
|
||||
host,
|
||||
{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
theme: "dark",
|
||||
hide_welcome_screen: true,
|
||||
enable_history: false,
|
||||
allow_fullscreen: false,
|
||||
},
|
||||
null,
|
||||
);
|
||||
if (disposed) {
|
||||
disposeViewer();
|
||||
return;
|
||||
}
|
||||
|
||||
viewer.override_panel_state("top", "hidden");
|
||||
viewer.override_panel_state("blueprint", "hidden");
|
||||
viewer.override_panel_state("selection", "hidden");
|
||||
viewer.override_panel_state("time", "hidden");
|
||||
|
||||
if (!recordingOpened) {
|
||||
recordingOpenTimer = window.setTimeout(() => {
|
||||
reportError("Визуализатор запущен, но поток записи не открылся.");
|
||||
}, 12_000);
|
||||
}
|
||||
} catch {
|
||||
disposeViewer();
|
||||
reportError("Не удалось запустить встроенный визуализатор.");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (disposed) return;
|
||||
const message = "Не удалось запустить встроенный визуализатор.";
|
||||
setStatus("error");
|
||||
onStatusChange?.("error", message);
|
||||
disposeViewer?.();
|
||||
reportError("Не удалось загрузить модуль визуализатора.");
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
||||
stopViewer?.();
|
||||
clearRecordingOpenTimer();
|
||||
unsubscribeAll();
|
||||
disposeViewer?.();
|
||||
onSelectionChange?.(null);
|
||||
};
|
||||
}, [onSelectionChange, onStatusChange, sourceUrl]);
|
||||
}, [followLive, onSelectionChange, onStatusChange, retryNonce, sourceUrl]);
|
||||
|
||||
return (
|
||||
<div className="rerun-viewport" data-status={status}>
|
||||
@@ -124,7 +194,14 @@ export function RerunViewport({
|
||||
<div className="rerun-viewport__notice rerun-viewport__notice--error" role="alert">
|
||||
<div>
|
||||
<strong>Источник не открыт</strong>
|
||||
<span>Проверьте адрес RRD или Rerun gRPC в настройках источника.</span>
|
||||
<span>Проверьте локальный поток Rerun или повторите подключение.</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rerun-viewport__retry"
|
||||
onClick={() => setRetryNonce((nonce) => nonce + 1)}
|
||||
>
|
||||
Повторить подключение
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user