"use client"; import { useEffect, useRef, useState } from "react"; import { controlRequest, signedSameOrigin } from "./client"; import type { Production, Slot } from "./types"; export function slotRect(layout: string, slot: Slot): { x: number; y: number; w: number; h: number } { if (slot.rect) return { x: slot.rect.x / 10000, y: slot.rect.y / 10000, w: slot.rect.w / 10000, h: slot.rect.h / 10000 }; const cols = layout === "3x3" ? 3 : layout === "4x4" ? 4 : layout === "2x1" || layout === "2x2" ? 2 : 1; const rows = layout === "2x1" || cols === 1 ? 1 : cols; return { x: (slot.index % cols) / cols, y: Math.floor(slot.index / cols) / rows, w: 1 / cols, h: 1 / rows }; } export function PstPreview({ production, channelId, fixture }: { production: Production; channelId: string; fixture: boolean }): React.ReactElement { const canvas = useRef(null); const [visible, setVisible] = useState(true); const scene = production.scenes.find(item => item.id === production.buses.preview); useEffect(() => { const observer = new IntersectionObserver(entries => setVisible(entries[0]?.isIntersecting ?? false)); if (canvas.current) observer.observe(canvas.current); return () => observer.disconnect(); }, []); useEffect(() => { const element = canvas.current; if (!element) return; const buffer = document.createElement("canvas"); buffer.width = element.width; buffer.height = element.height; const context = buffer.getContext("2d"); if (!context) return; const abort = new AbortController(); let busy = false; const draw = async (): Promise => { if (document.hidden || !visible || busy) return; busy = true; context.fillStyle = "#101010"; context.fillRect(0, 0, element.width, element.height); try { for (const slot of [...(scene?.slots ?? [])].sort((a, b) => a.index - b.index)) { const rect = slotRect(scene?.layout ?? "single", slot); const x = rect.x * element.width, y = rect.y * element.height, w = rect.w * element.width, h = rect.h * element.height; context.save(); context.beginPath(); context.rect(x, y, w, h); context.clip(); context.fillStyle = "#222"; context.fillRect(x, y, w, h); let painted = false; if (!fixture && slot.sourceId) { try { const urls = await controlRequest<{ thumbnail: string }>(`/media-control/channels/${channelId}/preview/${slot.sourceId}`, undefined, abort.signal); const response = await fetch(signedSameOrigin(urls.thumbnail), { signal: abort.signal }); if (!response.ok) throw new Error("Thumbnail unavailable"); const bitmap = await createImageBitmap(await response.blob()); if (!abort.signal.aborted) { const scale = slot.fit === "contain" ? Math.min(w / bitmap.width, h / bitmap.height) : Math.max(w / bitmap.width, h / bitmap.height); context.drawImage(bitmap, x + (w - bitmap.width * scale) / 2, y + (h - bitmap.height * scale) / 2, bitmap.width * scale, bitmap.height * scale); painted = true; } bitmap.close(); } catch { /* Label unavailable taps. */ } } if (!painted && !abort.signal.aborted) { context.fillStyle = "#aaa"; context.font = "16px sans-serif"; context.fillText(fixture ? "Fixture · no media" : "Tap unavailable", x + 16, y + 30, Math.max(1, w - 32)); } context.restore(); } if (!abort.signal.aborted) element.getContext("2d")?.drawImage(buffer, 0, 0); } finally { busy = false; } }; void draw(); const timer = setInterval(() => { void draw(); }, 1000); return () => { abort.abort(); clearInterval(timer); }; }, [scene, channelId, fixture, visible]); const overlays = production.overlays.filter(overlay => overlay.enabled && overlay.sceneId === scene?.id); return

PST · {scene?.label ?? "No scene selected"}

PST is a look-ahead; PGM is the truth.

{overlays.length > 0 &&

{overlays.length} overlay slots configured. Status provides revision IDs without render pixels; overlay appearance is unavailable in this look-ahead.

}
; }