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; 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 (