Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
264 lines
8.9 KiB
TypeScript
264 lines
8.9 KiB
TypeScript
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
|
|
|
|
import type {
|
|
ObservationSourceAvailability,
|
|
ObservationSourceDescriptor,
|
|
ObservationSourceModality,
|
|
} from "../core/runtime/contracts";
|
|
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
|
import {
|
|
RecordedFmp4Player,
|
|
type RecordedObservationPlayback,
|
|
} from "./RecordedFmp4Player";
|
|
import type {
|
|
RecordedAdmissionPhase,
|
|
RecordedCameraAdmissionState,
|
|
} from "../core/observation/recordedSessionAdmission";
|
|
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
|
|
|
|
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
|
"point-cloud": "globe",
|
|
video: "video",
|
|
image: "image",
|
|
depth: "image",
|
|
};
|
|
|
|
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
|
|
unverified: "Не подтверждён",
|
|
declared: "Канал объявлен",
|
|
available: "Доступен",
|
|
connecting: "Подключение",
|
|
streaming: "Эфир",
|
|
degraded: "Нестабильно",
|
|
unavailable: "Недоступен",
|
|
error: "Ошибка",
|
|
};
|
|
|
|
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
|
|
return availabilityCopy[source.availability];
|
|
}
|
|
|
|
export function ObservationMedia({
|
|
source,
|
|
playback,
|
|
prepareRecorded = true,
|
|
recordedSessionGate = "ready",
|
|
recordedAdmissionKey = null,
|
|
onRecordedAdmissionChange,
|
|
}: {
|
|
source: ObservationSourceDescriptor;
|
|
playback?: RecordedObservationPlayback | null;
|
|
prepareRecorded?: boolean;
|
|
recordedSessionGate?: RecordedAdmissionPhase;
|
|
recordedAdmissionKey?: string | null;
|
|
onRecordedAdmissionChange?: (
|
|
sourceId: string,
|
|
state: RecordedCameraAdmissionState,
|
|
) => void;
|
|
}) {
|
|
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}
|
|
recoveryAuthorityIdentity={liveCameraPlaybackAuthorityIdentity(source)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (
|
|
deliveryActive &&
|
|
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
|
source.modality === "video"
|
|
) {
|
|
if (!prepareRecorded) {
|
|
return (
|
|
<div className="observation-media__empty" role="status">
|
|
<span className="busy-indicator" aria-hidden="true" />
|
|
<strong>Ожидает подготовки</strong>
|
|
<span>Канал будет проверен последовательно в рамках атомарной сессии.</span>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<RecordedFmp4Player
|
|
source={source}
|
|
playback={playback}
|
|
prepare
|
|
sessionGate={recordedSessionGate}
|
|
admissionKey={recordedAdmissionKey}
|
|
onAdmissionChange={(state) => onRecordedAdmissionChange?.(source.id, state)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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"
|
|
src={source.previewUrl}
|
|
autoPlay
|
|
muted
|
|
playsInline
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (
|
|
sourceSelected &&
|
|
source.previewUrl &&
|
|
(source.modality === "image" || source.modality === "depth")
|
|
) {
|
|
return <img className="observation-media__asset" src={source.previewUrl} alt={source.label} />;
|
|
}
|
|
|
|
return (
|
|
<div className="observation-media__empty">
|
|
<Icon name={sourceIcon[source.modality]} size={20} />
|
|
<strong>
|
|
{source.modality === "video" && source.activation && !source.activation.selected
|
|
? "Источник отключён"
|
|
: observationSourceStatusLabel(source)}
|
|
</strong>
|
|
<span>
|
|
{source.modality === "video"
|
|
? source.activation?.controllable
|
|
? source.activation.selected
|
|
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
|
|
: "Канал остановлен оператором; при необходимости его можно открыть снова"
|
|
: "Канал известен, browser-preview сейчас недоступен"
|
|
: source.description}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ObservationSourcePicker({
|
|
sources,
|
|
visibleSourceIds,
|
|
pendingSourceIds,
|
|
onToggle,
|
|
}: {
|
|
sources: readonly ObservationSourceDescriptor[];
|
|
visibleSourceIds: ReadonlySet<string>;
|
|
pendingSourceIds?: ReadonlySet<string>;
|
|
onToggle: (sourceId: string) => void | Promise<boolean>;
|
|
}) {
|
|
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
|
|
|
return (
|
|
<Dropdown
|
|
className="scene-source-picker"
|
|
placement="bottom-start"
|
|
width={340}
|
|
offset={10}
|
|
surfaceRole="dialog"
|
|
surfaceClassName="observation-source-menu"
|
|
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
|
<button
|
|
ref={(node) => {
|
|
setAnchorRef(node);
|
|
setTriggerRef(node);
|
|
}}
|
|
type="button"
|
|
className="scene-source-picker__trigger"
|
|
data-active={open || visibleCount > 0 ? "true" : undefined}
|
|
aria-label="Источники данных сцены"
|
|
aria-haspopup="dialog"
|
|
aria-expanded={open}
|
|
aria-controls={surfaceId}
|
|
onClick={toggle}
|
|
>
|
|
<Icon name="database" size={17} />
|
|
{sources.length > 0 ? <span>{visibleCount}</span> : null}
|
|
</button>
|
|
)}
|
|
>
|
|
<header className="observation-source-menu__head">
|
|
<div>
|
|
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
|
|
<strong>Источники данных</strong>
|
|
</div>
|
|
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
|
|
</header>
|
|
{sources.length ? (
|
|
<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}
|
|
type="button"
|
|
className="nodedc-dropdown-option observation-source-option"
|
|
data-selected={selected ? "true" : undefined}
|
|
aria-pressed={selected}
|
|
disabled={pending || !canOpen}
|
|
onClick={() => void onToggle(source.id)}
|
|
>
|
|
<span className="nodedc-dropdown-option__icon">
|
|
<Icon name={sourceIcon[source.modality]} size={16} />
|
|
</span>
|
|
<span className="nodedc-dropdown-option__body">
|
|
<span className="nodedc-dropdown-option__label">{source.label}</span>
|
|
<span className="nodedc-dropdown-option__description">
|
|
<i data-availability={source.availability} aria-hidden="true" />
|
|
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
|
</span>
|
|
</span>
|
|
<span className="nodedc-dropdown-option__check">
|
|
{selected ? <Icon name="check" size={15} /> : null}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="observation-source-menu__empty">
|
|
<Icon name="database" size={18} />
|
|
<strong>Каталог пока пуст</strong>
|
|
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
|
|
</div>
|
|
)}
|
|
<footer className="observation-source-menu__foot">
|
|
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
|
|
модель оборудования.
|
|
</footer>
|
|
</Dropdown>
|
|
);
|
|
}
|