Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
64 lines
4.5 KiB
TypeScript
64 lines
4.5 KiB
TypeScript
"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<HTMLCanvasElement>(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<void> => {
|
|
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 <section className="rounded-lg border border-border-subtle bg-surface p-3" aria-label="PST look-ahead"><h2 className="mb-2 text-sm font-semibold">PST · {scene?.label ?? "No scene selected"}</h2><canvas ref={canvas} width={640} height={360} className="aspect-video w-full rounded bg-black" aria-label={`Look-ahead of ${scene?.label ?? "empty preview"}`} /><p className="mt-2 text-xs text-text-faint">PST is a look-ahead; PGM is the truth.</p>{overlays.length > 0 && <p className="mt-2 text-xs text-text-faint">{overlays.length} overlay slots configured. Status provides revision IDs without render pixels; overlay appearance is unavailable in this look-ahead.</p>}</section>;
|
|
}
|