188 lines
6.5 KiB
TypeScript
188 lines
6.5 KiB
TypeScript
import { useLayoutEffect, useState, type RefObject } from "react";
|
|
import { WorkspaceWindow } from "@nodedc/ui-react";
|
|
|
|
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
|
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
|
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
|
|
import type { RecordedObservationPlayback } from "./RecordedFmp4Player";
|
|
import type {
|
|
RecordedAdmissionPhase,
|
|
RecordedCameraAdmissionState,
|
|
} from "../core/observation/recordedSessionAdmission";
|
|
|
|
const WINDOW_WIDTH = 336;
|
|
const WINDOW_HEIGHT = 210;
|
|
const WINDOW_GAP = 16;
|
|
const WINDOW_INSET = 18;
|
|
const TIMELINE_CLEARANCE = 64;
|
|
|
|
type ClosestTarget = { closest: (selectors: string) => unknown };
|
|
|
|
export function shouldCaptureWorkspacePointer(
|
|
button: number,
|
|
target: ClosestTarget | null,
|
|
): boolean {
|
|
if (button !== 0 || !target) return false;
|
|
if (target.closest(".nodedc-workspace-window__resize")) return true;
|
|
if (!target.closest(".nodedc-workspace-window__head")) return false;
|
|
return !target.closest("button, input, select, textarea, a");
|
|
}
|
|
|
|
export function initialObservationWindowRect(
|
|
index: number,
|
|
count = 1,
|
|
bounds: { width: number; height: number } = { width: 1280, height: 720 },
|
|
): ObservationWindowRect {
|
|
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
|
|
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
|
|
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
|
|
const maxColumns = Math.max(
|
|
1,
|
|
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
|
|
);
|
|
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
|
|
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
|
|
const rowHeight = Math.max(
|
|
1,
|
|
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
|
|
);
|
|
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
|
|
const row = Math.floor(index / columns);
|
|
const column = index % columns;
|
|
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
|
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
|
|
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
|
|
|
return {
|
|
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
|
y: Math.max(
|
|
0,
|
|
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
|
|
),
|
|
width,
|
|
height,
|
|
};
|
|
}
|
|
|
|
export function FloatingObservationWindow({
|
|
source,
|
|
index,
|
|
count,
|
|
boundsRef,
|
|
rect,
|
|
maximized,
|
|
active,
|
|
hidden = false,
|
|
onRectChange,
|
|
onMaximizedChange,
|
|
onActivate,
|
|
onClose,
|
|
playback,
|
|
prepareRecorded,
|
|
recordedSessionGate,
|
|
recordedAdmissionKey,
|
|
onRecordedAdmissionChange,
|
|
}: {
|
|
source: ObservationSourceDescriptor;
|
|
index: number;
|
|
count: number;
|
|
boundsRef: RefObject<HTMLElement | null>;
|
|
rect?: ObservationWindowRect;
|
|
maximized: boolean;
|
|
active: boolean;
|
|
hidden?: boolean;
|
|
onRectChange: (rect: ObservationWindowRect) => void;
|
|
onMaximizedChange: (maximized: boolean) => void;
|
|
onActivate: () => void;
|
|
onClose: () => void;
|
|
playback?: RecordedObservationPlayback | null;
|
|
prepareRecorded?: boolean;
|
|
recordedSessionGate?: RecordedAdmissionPhase;
|
|
recordedAdmissionKey?: string | null;
|
|
onRecordedAdmissionChange?: (
|
|
sourceId: string,
|
|
state: RecordedCameraAdmissionState,
|
|
) => void;
|
|
}) {
|
|
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
const element = boundsRef.current;
|
|
if (!element) return;
|
|
|
|
const measure = () => {
|
|
const next = { width: element.clientWidth, height: element.clientHeight };
|
|
setBounds((current) => (
|
|
current?.width === next.width && current.height === next.height ? current : next
|
|
));
|
|
};
|
|
|
|
measure();
|
|
const observer = new ResizeObserver(measure);
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, [boundsRef]);
|
|
|
|
// Camera media admission must not wait for the viewport observer. A source
|
|
// can arrive in the same commit as a workspace transition; mounting it with
|
|
// the canonical fallback geometry keeps the browser transport alive until
|
|
// the real bounds are measured immediately afterward.
|
|
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
|
|
const windowRect = rect ?? initialRect;
|
|
|
|
return (
|
|
<WorkspaceWindow
|
|
boundsRef={boundsRef}
|
|
rect={windowRect}
|
|
onRectChange={onRectChange}
|
|
maximized={maximized}
|
|
onMaximizedChange={onMaximizedChange}
|
|
onActivate={onActivate}
|
|
onClose={onClose}
|
|
title={source.label}
|
|
subtitle={source.description}
|
|
status={(
|
|
<span className="floating-observation-window__status">
|
|
<i data-availability={source.availability} aria-hidden="true" />
|
|
{observationSourceStatusLabel(source)}
|
|
</span>
|
|
)}
|
|
footer={(
|
|
<span className="floating-observation-window__footer">
|
|
<span>{source.endpointLabel || source.transport}</span>
|
|
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
|
|
</span>
|
|
)}
|
|
minWidth={Math.min(280, windowRect.width)}
|
|
minHeight={Math.min(190, windowRect.height)}
|
|
resizable={source.capabilities.resizable}
|
|
active={active}
|
|
zIndex={maximized ? 15 : active ? 9 : 7}
|
|
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
|
|
onPointerDownCapture={(event) => {
|
|
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
|
|
try {
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
} catch {
|
|
// Pointer capture can fail if the browser ended the pointer between
|
|
// dispatch and capture. The donor's window listeners remain fallback.
|
|
}
|
|
}}
|
|
closeLabel={`Закрыть ${source.label}`}
|
|
maximizeLabel={`Развернуть ${source.label}`}
|
|
restoreLabel={`Восстановить ${source.label}`}
|
|
moveLabel={`Переместить ${source.label}`}
|
|
resizeLabel={`Изменить размер ${source.label}`}
|
|
>
|
|
<ObservationMedia
|
|
source={source}
|
|
playback={playback}
|
|
prepareRecorded={prepareRecorded}
|
|
recordedSessionGate={recordedSessionGate}
|
|
recordedAdmissionKey={recordedAdmissionKey}
|
|
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
|
/>
|
|
</WorkspaceWindow>
|
|
);
|
|
}
|