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/.
111 lines
11 KiB
TypeScript
111 lines
11 KiB
TypeScript
"use client";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useAuth } from "../../../lib/auth-store";
|
|
import { API_URL, ApiError, ApiUnreachableError } from "../../../lib/api-client";
|
|
import { getSessionToken } from "../../../lib/session-token";
|
|
import { Badge, Button } from "../../../components/ui/primitives";
|
|
import { AudioMonitor } from "../../../components/control-room/audio-monitor";
|
|
import { MultiviewGrid, liveTapIds, type GridTier } from "../../../components/control-room/multiview-grid";
|
|
import { OverlayEditor, type RenderInput } from "../../../components/control-room/overlay-editor";
|
|
import { PstPreview } from "../../../components/control-room/pst-preview";
|
|
import { SwitcherBar, type DirectorAction } from "../../../components/control-room/switcher-bar";
|
|
import { controlRequest } from "../../../components/control-room/client";
|
|
import { fixtureChannel } from "../../../components/control-room/fixtures";
|
|
import { RealtimeClient } from "../../../components/control-room/state-store";
|
|
import { initialTally, refetchTally, tallyReducer, type TallyState } from "../../../components/control-room/tally";
|
|
import type { Receipt, Status } from "../../../components/control-room/types";
|
|
import "./control-room.css";
|
|
|
|
export default function ControlRoom(): React.ReactElement {
|
|
const { session, can, status: authStatus } = useAuth();
|
|
const [channels, setChannels] = useState<Status["channels"]>([]);
|
|
const [selected, setSelected] = useState("");
|
|
const [state, setState] = useState<TallyState | null>(null);
|
|
const [fixture, setFixture] = useState(false);
|
|
const [connection, setConnection] = useState("loading");
|
|
const [message, setMessage] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [mobile, setMobile] = useState(true);
|
|
const [tier, setTier] = useState<GridTier>("2x2");
|
|
const [focused, setFocused] = useState<string | null>(null);
|
|
const [upgrades, setUpgrades] = useState<string[]>([]);
|
|
const [reload, setReload] = useState(0);
|
|
useEffect(() => {
|
|
const query = matchMedia("(max-width: 1023px)"); const update = (): void => setMobile(query.matches);
|
|
update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update);
|
|
}, []);
|
|
useEffect(() => {
|
|
if (authStatus === "loading") return;
|
|
let cancelled = false;
|
|
const abort = new AbortController();
|
|
setConnection("loading"); setState(null); setFixture(false); setMessage("");
|
|
void controlRequest<Status>("/media-control/status", undefined, abort.signal).then(result => {
|
|
if (cancelled) return;
|
|
setChannels(result.channels); setSelected(result.channels[0]?.channelId ?? ""); setConnection(result.channels.length ? "connecting" : "empty");
|
|
}).catch(error => {
|
|
if (cancelled) return;
|
|
if (error instanceof ApiUnreachableError || error instanceof ApiError && error.status >= 500) {
|
|
const sample = fixtureChannel(); setFixture(true); setChannels([sample]); setSelected(sample.channelId); setConnection("fixture");
|
|
} else { setChannels([]); setSelected(""); setConnection("error"); setMessage(error instanceof Error ? error.message : "Status unavailable"); }
|
|
});
|
|
return () => { cancelled = true; abort.abort(); };
|
|
}, [authStatus, session?.workspace.id, reload]);
|
|
useEffect(() => {
|
|
const channel = channels.find(item => item.channelId === selected);
|
|
setFocused(null); setUpgrades([]);
|
|
if (!channel) { setState(null); return; }
|
|
setState(initialTally(channel));
|
|
if (fixture || !session) return;
|
|
const url = new URL(`${API_URL}/realtime`); url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
const client = new RealtimeClient({ url: url.href, token: getSessionToken, workspaceId: session.workspace.id, channelId: selected,
|
|
socket: address => new WebSocket(address), status: () => controlRequest<Status>("/media-control/status"),
|
|
onFrame: frame => setState(previous => previous ? tallyReducer(previous, frame) : previous),
|
|
onStatus: result => { const current = result.channels.find(item => item.channelId === selected); setState(previous => current ? refetchTally(current, previous) : null); },
|
|
onConnection: setConnection });
|
|
client.start(); return () => client.stop();
|
|
}, [channels, selected, fixture, session]);
|
|
const production = state?.channel.desired?.production;
|
|
const liveIds = useMemo(() => liveTapIds(production?.sources ?? [], tier, focused, upgrades, mobile), [production, tier, focused, upgrades, mobile]);
|
|
const action = async (operation: DirectorAction, body: object): Promise<void> => {
|
|
if (!state?.channel.desired) return;
|
|
setBusy(true); setMessage("");
|
|
try {
|
|
if (fixture) {
|
|
const desired = structuredClone(state.channel.desired);
|
|
if (desired.production) {
|
|
if (operation === "pst-select" && "sceneId" in body) desired.production.buses.preview = String(body.sceneId);
|
|
if (operation === "cut" || operation === "take") desired.production.buses.program = desired.production.buses.preview;
|
|
if (operation === "lock" && "enabled" in body && desired.director) desired.director.locked = Boolean(body.enabled);
|
|
if (operation === "override" && "mode" in body && desired.director) desired.director.override = body.mode === null ? null : { mode: body.mode as NonNullable<typeof desired.director.override>["mode"], expiresAt: null };
|
|
}
|
|
desired.desiredVersion += 1;
|
|
setState(previous => previous ? tallyReducer(previous, { type: "state", workspaceId: "fixture", channelId: selected, kind: "desired", seq: previous.lastSeq + 1, data: desired }) : previous);
|
|
setMessage("Fixture request recorded. Tally awaits a simulated observed frame.");
|
|
} else {
|
|
const receipt = await controlRequest<Receipt>(`/media-control/channels/${selected}/${operation}`, body);
|
|
setMessage(`Request accepted · version ${receipt.desiredVersion}. Waiting for observed state.`);
|
|
}
|
|
} catch (error) { setMessage(error instanceof Error ? error.message : "Command failed"); }
|
|
finally { setBusy(false); }
|
|
};
|
|
const render = async (id: string, input: RenderInput): Promise<void> => {
|
|
if (fixture) throw new Error("Fixture mode has no render service. Reconnect to render an overlay.");
|
|
await controlRequest<Receipt>(`/production/overlays/${id}`, input);
|
|
};
|
|
const simulate = (): void => setState(previous => previous ? tallyReducer(previous, { type: "state", workspaceId: "fixture", channelId: selected, kind: "observed", seq: previous.lastSeq + 1, data: { workspaceId: "fixture", channelId: selected, workerId: previous.channel.workerId, assignmentEpoch: previous.channel.assignmentEpoch, realizedVersion: previous.channel.desired?.desiredVersion ?? null, state: "RUNNING", reason: "Fixture simulation", updatedAt: new Date().toISOString() } }) : previous);
|
|
const offline = connection !== "live" && connection !== "polling" && !fixture;
|
|
return <div className="control-room h-full min-w-0 overflow-y-auto p-3 sm:p-5">
|
|
<header className="mb-4 flex flex-wrap items-center justify-between gap-3"><div><h1 className="text-xl font-semibold">Control Room</h1><p className="mt-1 text-xs text-text-faint">Preview taps · server composition · observed tally</p></div><div className="flex flex-wrap items-center gap-2"><Badge tone={fixture ? "warn" : connection === "live" ? "live" : "neutral"}>{connection === "fixture" ? "FIXTURE · sample data" : connection}</Badge><Button className="min-h-11" variant="outline" onClick={() => setReload(value => value + 1)}>Reconnect</Button></div></header>
|
|
{fixture && <div className="mb-4 rounded-lg border border-warn/40 bg-warn/10 p-3 text-sm"><p>API unavailable. This is a fixture rehearsal with sample scenes, no broadcast, and no actual program state. Actions stay local.</p><Button className="mt-2 min-h-11" variant="outline" onClick={simulate}>Simulate observed frame</Button></div>}
|
|
{message && <p role="status" className="mb-4 break-words rounded border border-border p-3 text-sm">{message}</p>}
|
|
{channels.length > 0 && <label className="mb-4 block max-w-lg text-xs">Production channel<select className="cr-field mt-1" value={selected} onChange={event => setSelected(event.target.value)}>{channels.map(channel => <option key={channel.channelId} value={channel.channelId}>{fixture ? "Sample channel" : channel.channelId}</option>)}</select></label>}
|
|
{!production || production.scenes.length === 0 ? <section className="rounded-lg border border-border-subtle bg-surface p-6"><h2 className="text-lg font-semibold">{connection === "loading" ? "Loading production status…" : connection === "error" ? "Production status unavailable" : "No production scenes configured"}</h2><p className="mt-3 max-w-xl text-sm text-text-dim">{connection === "error" ? "Reconnect or ask a workspace administrator to check your media.view permission. Production state cannot be determined from this response." : "Configure a virtual channel, assign a media worker, add ingest sources and production scenes, then select the preview bus. Program tally appears only after the worker confirms a realized version."}</p></section> : <>
|
|
<div className="mb-4 flex flex-wrap gap-3 text-xs text-text-dim"><span>Desired v{state?.channel.desired?.desiredVersion}</span><span>Observed {state?.channel.observed?.realizedVersion === null || !state?.channel.observed ? "unconfirmed" : `v${state.channel.observed.realizedVersion} · ${state.channel.observed.state}`}</span><span>{state?.observedProduction ? "Tally confirmed" : "Tally unknown · awaiting matching observed version"}</span></div>
|
|
<SwitcherBar desired={state?.channel.desired ?? { desiredVersion: 0, desiredStatus: "STOPPED" }} disabled={offline || !fixture && !can("director.take")} busy={busy} onAction={(operation, body) => { if (!fixture && !can(operation === "lock" ? "director.lock_program" : operation === "override" ? "director.override_auto" : "director.take")) { setMessage("Your role cannot perform this action."); return; } void action(operation, body); }} />
|
|
<div className="mt-4 grid min-w-0 gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(260px,1fr)]">
|
|
<MultiviewGrid production={production} observed={state?.observedProduction ?? null} channelId={selected} fixture={fixture} mobile={mobile} liveIds={liveIds} focused={focused} onFocus={setFocused} tier={tier} onTier={value => { setTier(value); setUpgrades([]); }} upgrades={upgrades} onUpgrade={id => setUpgrades(previous => previous.includes(id) ? previous.filter(item => item !== id) : liveIds.length < 4 ? [...previous, id] : previous)} />
|
|
<div className="min-w-0 space-y-4"><PstPreview production={production} channelId={selected} fixture={fixture} /><AudioMonitor production={production} observed={state?.observedProduction ?? null} liveIds={liveIds} focused={focused} channelId={selected} fixture={fixture} mobile={mobile} /><OverlayEditor overlays={production.overlays} disabled={fixture || offline || !can("director.change_layout")} onRender={render} /></div>
|
|
</div>
|
|
</>}
|
|
</div>;
|
|
}
|