253 lines
8.5 KiB
TypeScript
253 lines
8.5 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";
|
||
|
||
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} />;
|
||
}
|
||
|
||
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>{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>
|
||
);
|
||
}
|