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/.
40 lines
4.4 KiB
TypeScript
40 lines
4.4 KiB
TypeScript
"use client";
|
||
import { useState } from "react";
|
||
import { Button } from "../ui/primitives";
|
||
import { PreviewTap } from "./preview-tap";
|
||
import { tileTally } from "./tally";
|
||
import type { Production, Source } from "./types";
|
||
export type GridTier = "2x1" | "2x2" | "3x3" | "4x4" | "custom";
|
||
export function liveTapIds(sources: Source[], tier: GridTier, focused: string | null, upgrades: string[], mobile: boolean): string[] {
|
||
const ids = sources.map(source => source.id);
|
||
const valid = (id: string): boolean => id === "program" || ids.includes(id);
|
||
const candidates = mobile ? [focused ?? "program"] : tier === "2x1" || tier === "2x2" ? ["program", ...(focused ? [focused] : []), ...ids] : ["program", ...(focused ? [focused] : []), ...upgrades];
|
||
return [...new Set(candidates)].filter(valid).slice(0, mobile ? 1 : Math.min(4, tier === "2x1" ? 2 : 4));
|
||
}
|
||
export function MultiviewGrid({ production, observed, channelId, fixture, mobile, liveIds, focused, onFocus, tier, onTier, upgrades, onUpgrade }: { production: Production; observed: Production | null; channelId: string; fixture: boolean; mobile: boolean; liveIds: string[]; focused: string | null; onFocus: (id: string) => void; tier: GridTier; onTier: (tier: GridTier) => void; upgrades: string[]; onUpgrade: (id: string) => void }): React.ReactElement {
|
||
const [columns, setColumns] = useState(3);
|
||
const capacity = tier === "2x1" ? 2 : tier === "2x2" ? 4 : tier === "3x3" ? 9 : 16;
|
||
const tiles = [{ id: "program", label: "PGM · encoder output", kind: "hls" as const }, ...production.sources].slice(0, capacity);
|
||
const selectedSourceIds = production.scenes.find(scene => scene.id === production.buses.preview)?.slots.map(slot => slot.sourceId) ?? [];
|
||
return <section aria-label="Multiview" className="min-w-0 rounded-lg border border-border-subtle bg-surface p-3">
|
||
<div className="mb-3 flex flex-wrap items-end gap-3">
|
||
<label className="text-xs">Multiview layout<select className="cr-field mt-1" value={tier} onChange={event => onTier(event.target.value as GridTier)}>{["2x1", "2x2", "3x3", "4x4", "custom"].map(value => <option key={value}>{value}</option>)}</select></label>
|
||
{tier === "custom" && <label className="text-xs">Columns<input className="cr-field mt-1" type="number" min={1} max={4} value={columns} onChange={event => setColumns(Math.max(1, Math.min(4, Number(event.target.value) || 1)))} /></label>}
|
||
<span className="pb-3 text-xs text-text-faint">{liveIds.length}/{mobile ? 1 : 4} live slots · tap to focus</span>
|
||
</div>
|
||
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${mobile ? (tier === "2x1" ? 1 : 2) : tier === "custom" ? columns : tier === "3x3" ? 3 : tier === "4x4" ? 4 : 2}, minmax(0, 1fr))` }}>
|
||
{tiles.map(source => {
|
||
const tally = source.id === "program" ? observed?.buses.program ? "program" : "none" : tileTally(observed, source.id);
|
||
return <div key={source.id} data-source={source.id} data-tally={tally} className={`min-w-0 rounded-md border-2 p-1 ${tally === "program" ? "border-red-500" : tally === "preview" ? "border-green-500" : "border-border"}`}>
|
||
<button className="min-h-11 w-full text-left" aria-label={`Focus ${source.label}`} aria-pressed={focused === source.id} onClick={() => onFocus(source.id)}>
|
||
<div className="mb-1 flex min-h-7 flex-wrap items-center justify-between gap-1 px-1 text-xs"><span className="break-words">{source.label}</span><span className="font-mono">{tally === "program" ? "PGM" : tally === "preview" ? "PST" : "—"}</span></div>
|
||
<PreviewTap channelId={channelId} tap={source.id} fixture={fixture} live={liveIds.includes(source.id)} selected={selectedSourceIds.includes(source.id)} label={source.label} />
|
||
</button>
|
||
<p className="px-1 py-1 text-[10px] text-text-faint">{fixture ? "SAMPLE" : "Health not reported"}</p>
|
||
{!mobile && (tier === "3x3" || tier === "4x4" || tier === "custom") && source.id !== "program" && source.id !== focused && <Button className="min-h-11 w-full justify-center" disabled={!upgrades.includes(source.id) && liveIds.length >= 4} onClick={() => onUpgrade(source.id)}>{upgrades.includes(source.id) ? "Remove live" : "Upgrade live"}</Button>}
|
||
</div>;
|
||
})}
|
||
</div>
|
||
<p className="mt-3 text-xs text-text-faint">Extra live tiles can strain the device. Red/green tally uses the worker’s realized version. Unknown versions have no tally.</p>
|
||
</section>;
|
||
}
|