Initial commit

This commit is contained in:
2026-04-08 10:01:19 -07:00
commit 6657125a1e
68 changed files with 15886 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { CueTransition } from "@goodgrief/shared-types";
import { SceneViewport } from "../features/live/SceneViewport";
import { readProgramOutputState, subscribeProgramOutput, type ProgramOutputState } from "../features/live/output-sync";
import "./output.css";
const enterFullscreen = async () => {
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
await document.documentElement.requestFullscreen();
};
export const ProgramOutputApp = () => {
const [outputState, setOutputState] = useState<ProgramOutputState | null>(() => readProgramOutputState());
const [overlayVisible, setOverlayVisible] = useState(true);
const [overlayDismissed, setOverlayDismissed] = useState(false);
const hideTimeoutRef = useRef<number | null>(null);
const clearHideTimer = useCallback(() => {
if (hideTimeoutRef.current !== null) {
window.clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
}, []);
const scheduleHide = useCallback(
(delayMs: number) => {
clearHideTimer();
hideTimeoutRef.current = window.setTimeout(() => {
setOverlayVisible(false);
}, delayMs);
},
[clearHideTimer]
);
const showOverlay = useCallback(
(delayMs = 1800) => {
setOverlayVisible(true);
scheduleHide(delayMs);
},
[scheduleHide]
);
useEffect(() => {
document.documentElement.classList.add("mode-output");
document.body.classList.add("mode-output");
return () => {
clearHideTimer();
document.documentElement.classList.remove("mode-output");
document.body.classList.remove("mode-output");
};
}, [clearHideTimer]);
useEffect(
() =>
subscribeProgramOutput((payload) =>
setOutputState((current) => {
if (!current || payload.outputRevision > current.outputRevision) {
return payload;
}
return current;
})
),
[]
);
useEffect(() => {
setOverlayDismissed(false);
showOverlay(2800);
}, [outputState?.updatedAt, showOverlay]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "f") {
void enterFullscreen();
}
if (event.key.toLowerCase() === "i") {
setOverlayDismissed((current) => {
const next = !current;
if (next) {
clearHideTimer();
setOverlayVisible(false);
} else {
showOverlay(2800);
}
return next;
});
}
};
const handleWindowBlur = () => {
clearHideTimer();
setOverlayVisible(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", handleWindowBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", handleWindowBlur);
};
}, [clearHideTimer, showOverlay]);
const handlePointerMove = () => {
if (overlayDismissed) {
return;
}
showOverlay();
};
const handlePointerLeave = () => {
clearHideTimer();
setOverlayVisible(false);
};
const transition: CueTransition | null = outputState?.transition ?? null;
return (
<main className="output-shell" onMouseMove={handlePointerMove} onMouseLeave={handlePointerLeave}>
<SceneViewport
presentation={outputState?.presentation ?? null}
blackout={outputState?.blackout ?? false}
transition={transition}
activationKey={`${outputState?.presentationHash ?? "program-empty"}:${outputState?.outputRevision ?? 0}:${outputState?.blackout ? "blackout" : "live"}`}
/>
<div className={`output-overlay ${overlayVisible ? "output-overlay--visible" : ""}`}>
<div>
<p>Program Output</p>
<strong>{outputState?.presentation?.label ?? "Awaiting cue"}</strong>
<span>{outputState?.blackout ? "Blackout active" : "Move this window to the projector display."}</span>
</div>
<div className="output-overlay__actions">
<button onClick={() => void enterFullscreen()}>{document.fullscreenElement ? "Exit fullscreen" : "Fullscreen"}</button>
<button
onClick={() => {
setOverlayDismissed(true);
clearHideTimer();
setOverlayVisible(false);
}}
>
Hide info
</button>
</div>
</div>
</main>
);
};
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
html.mode-output {
--type-3xs: 0.58rem;
--type-2xs: 0.62rem;
--type-xs: 0.68rem;
--type-sm: 0.74rem;
--type-md: 0.8rem;
--type-base: 0.84rem;
--type-lg: 0.92rem;
--type-xl: 1.1rem;
}
html.mode-output,
body.mode-output,
body.mode-output #root {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
}
body.mode-output .output-shell {
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
overflow: hidden;
background: #000;
}
body.mode-output .output-shell .surface-viewport {
width: 100%;
height: 100%;
min-height: 0;
border: 0;
border-radius: 0;
background: #000;
}
body.mode-output .output-shell .surface-viewport__canvas {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
}
body.mode-output .output-overlay {
position: fixed;
left: 24px;
right: 24px;
bottom: 24px;
display: flex;
justify-content: space-between;
gap: 16px;
align-items: end;
padding: 16px 18px;
border-radius: 18px;
background: rgba(8, 12, 16, 0.78);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #f5f2ea;
backdrop-filter: blur(18px);
opacity: 0;
transform: translateY(12px);
pointer-events: none;
transition: opacity 220ms ease, transform 220ms ease;
}
body.mode-output .output-overlay--visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
body.mode-output .output-overlay p,
body.mode-output .output-overlay strong,
body.mode-output .output-overlay span {
display: block;
}
body.mode-output .output-overlay p {
margin: 0 0 6px;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: var(--type-sm);
color: #aeb7c0;
}
body.mode-output .output-overlay strong {
font-size: var(--type-xl);
margin-bottom: 4px;
}
body.mode-output .output-overlay span {
color: #aeb7c0;
font-size: var(--type-md);
}
body.mode-output .output-overlay__actions {
display: flex;
gap: 10px;
}
body.mode-output .output-overlay__actions button {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
padding: 10px 14px;
background: rgba(22, 28, 34, 0.92);
color: inherit;
cursor: pointer;
font-size: var(--type-sm);
}
@@ -0,0 +1,93 @@
import { useEffect, useRef } from "react";
import type { RenderSurface as RenderSurfaceType, SurfacePresentation } from "@goodgrief/render-engine";
import type { CueTransition } from "@goodgrief/shared-types";
import "./viewport.css";
interface SceneViewportProps {
presentation: SurfacePresentation | null;
blackout?: boolean;
transition?: CueTransition | null;
activationKey?: string;
}
const defaultTransition: CueTransition = {
style: "cut",
durationMs: 0
};
export const SceneViewport = ({ presentation, blackout = false, transition, activationKey }: SceneViewportProps) => {
const frameRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const surfaceRef = useRef<RenderSurfaceType | null>(null);
const presentationRef = useRef<SurfacePresentation | null>(presentation);
const activationRef = useRef<string | undefined>(activationKey);
const transitionRef = useRef<CueTransition | null | undefined>(transition);
const blackoutRef = useRef(blackout);
presentationRef.current = presentation;
activationRef.current = activationKey;
transitionRef.current = transition;
blackoutRef.current = blackout;
useEffect(() => {
const frame = frameRef.current;
const canvas = canvasRef.current;
if (!frame || !canvas) {
return;
}
let cancelled = false;
let observer: ResizeObserver | null = null;
void import("@goodgrief/render-engine").then(({ RenderSurface, defaultScenePlugins }) => {
if (cancelled) {
return;
}
const surface = new RenderSurface(canvas);
surface.registerMany(defaultScenePlugins);
surface.setBlackout(blackoutRef.current);
surfaceRef.current = surface;
const resize = () => {
const { width, height } = frame.getBoundingClientRect();
surface.setSize(Math.max(1, width), Math.max(1, height));
};
resize();
observer = new ResizeObserver(() => resize());
observer.observe(frame);
const initialPresentation = presentationRef.current;
if (initialPresentation) {
void surface.activate(initialPresentation, defaultTransition);
}
});
return () => {
cancelled = true;
observer?.disconnect();
surfaceRef.current?.dispose();
surfaceRef.current = null;
};
}, []);
useEffect(() => {
surfaceRef.current?.setBlackout(blackout);
}, [blackout]);
useEffect(() => {
const surface = surfaceRef.current;
if (!surface) {
return;
}
void surface.activate(presentationRef.current, transitionRef.current ?? defaultTransition);
}, [activationKey]);
return (
<div ref={frameRef} className="surface-viewport">
<canvas ref={canvasRef} className="surface-viewport__canvas" />
</div>
);
};
+113
View File
@@ -0,0 +1,113 @@
import type {
Cue,
CueGeneratePayload,
CueMovePayload,
CueUpsertPayload,
ModerationActionPayload,
RepositoryState
} from "@goodgrief/shared-types";
const postVoid = async (url: string, body?: unknown) => {
const response = await fetch(url, {
method: "POST",
headers: body
? {
"Content-Type": "application/json"
}
: undefined,
body: body ? JSON.stringify(body) : undefined
});
if (!response.ok) {
throw new Error(`Request failed for ${url}.`);
}
};
const requestJson = async <T>(url: string, init?: RequestInit) => {
const response = await fetch(url, init);
if (!response.ok) {
let message = `Request failed for ${url}.`;
try {
const payload = (await response.json()) as { message?: string };
if (payload.message) {
message = payload.message;
}
} catch {
// ignore non-json errors
}
throw new Error(message);
}
return (await response.json()) as T;
};
export const loadState = async (): Promise<RepositoryState> => {
const response = await fetch("/api/state");
if (!response.ok) {
throw new Error("Could not load admin state.");
}
return (await response.json()) as RepositoryState;
};
export const rescanLibrary = async (): Promise<RepositoryState> =>
requestJson<RepositoryState>("/api/library/rescan", {
method: "POST"
});
export const moderateAsset = async (assetId: string, payload: ModerationActionPayload) => {
await postVoid(`/api/assets/${assetId}/moderation`, payload);
};
export const fireCue = async (cueId: string) => {
await postVoid(`/api/cues/${cueId}/fire`);
};
export const activateSafeCue = async (cueId: string) => {
await postVoid(`/api/cues/${cueId}/safe`);
};
export const createCue = async (payload: CueUpsertPayload) =>
requestJson<Cue>("/api/cues", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
export const updateCue = async (cueId: string, payload: CueUpsertPayload) =>
requestJson<Cue>(`/api/cues/${cueId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
export const moveCue = async (cueId: string, payload: CueMovePayload) => {
await postVoid(`/api/cues/${cueId}/move`, payload);
};
export const generateCue = async (payload: CueGeneratePayload) =>
requestJson<CueUpsertPayload>("/api/cues/generate", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
export const createAdminUpload = async (payload: FormData) =>
requestJson<{ submission?: { id: string }; assetId: string }>("/api/admin/uploads", {
method: "POST",
body: payload
});
export const deleteCue = async (cueId: string) => {
const response = await fetch(`/api/cues/${cueId}`, {
method: "DELETE"
});
if (!response.ok) {
throw new Error(`Request failed for /api/cues/${cueId}.`);
}
};
+126
View File
@@ -0,0 +1,126 @@
import type { SurfacePresentation } from "@goodgrief/render-engine";
import { flattenSceneParams, type CueTransition } from "@goodgrief/shared-types";
export interface ProgramOutputState {
presentation: SurfacePresentation | null;
blackout: boolean;
transition: CueTransition | null;
presentationHash: string;
outputRevision: number;
updatedAt: string;
takenAt: string;
}
const storageKey = "goodgrief:program-output";
const channelName = "goodgrief-program-output";
const canUseWindow = () => typeof window !== "undefined";
const createChannel = () =>
canUseWindow() && "BroadcastChannel" in window ? new BroadcastChannel(channelName) : null;
const hashString = (input: string) => {
let hash = 2166136261;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16);
};
export const createPresentationHash = (
presentation: SurfacePresentation | null,
blackout: boolean,
transition: CueTransition | null
) =>
hashString(
JSON.stringify({
blackout,
transition: transition
? {
style: transition.style,
durationMs: transition.durationMs
}
: null,
presentation: presentation
? {
cueId: presentation.cue?.id ?? null,
definitionId: presentation.definition.id,
effectPresetId: presentation.effectPresetId ?? null,
modeKey: presentation.modeKey ?? null,
assetIds: presentation.assets.map((asset) => asset.id),
textFragments: presentation.textFragments ?? [],
anchorCaption: presentation.anchorCaption ?? null,
params: presentation.params ? flattenSceneParams(presentation.params) : null,
label: presentation.label ?? null
}
: null
})
);
export const readProgramOutputState = (): ProgramOutputState | null => {
if (!canUseWindow()) {
return null;
}
const raw = window.localStorage.getItem(storageKey);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<ProgramOutputState>;
return {
presentation: parsed.presentation ?? null,
blackout: parsed.blackout ?? false,
transition: parsed.transition ?? null,
presentationHash:
parsed.presentationHash ?? createPresentationHash(parsed.presentation ?? null, parsed.blackout ?? false, parsed.transition ?? null),
outputRevision: parsed.outputRevision ?? 0,
updatedAt: parsed.updatedAt ?? new Date(0).toISOString(),
takenAt: parsed.takenAt ?? parsed.updatedAt ?? new Date(0).toISOString()
};
} catch {
return null;
}
};
export const writeProgramOutputState = (payload: ProgramOutputState) => {
if (!canUseWindow()) {
return;
}
window.localStorage.setItem(storageKey, JSON.stringify(payload));
const channel = createChannel();
channel?.postMessage(payload);
channel?.close();
};
export const subscribeProgramOutput = (onMessage: (payload: ProgramOutputState) => void) => {
if (!canUseWindow()) {
return () => undefined;
}
const channel = createChannel();
const storageHandler = (event: StorageEvent) => {
if (event.key !== storageKey || !event.newValue) {
return;
}
try {
onMessage(JSON.parse(event.newValue) as ProgramOutputState);
} catch {
// ignore malformed storage state
}
};
channel?.addEventListener("message", (event) => {
onMessage(event.data as ProgramOutputState);
});
window.addEventListener("storage", storageHandler);
return () => {
channel?.close();
window.removeEventListener("storage", storageHandler);
};
};
+28
View File
@@ -0,0 +1,28 @@
.surface-viewport {
position: relative;
min-height: 0;
aspect-ratio: 16 / 9;
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(244, 225, 208, 0.08);
background:
radial-gradient(circle at center, rgba(143, 170, 190, 0.08), transparent 52%),
linear-gradient(180deg, rgba(9, 11, 14, 0.94), rgba(4, 5, 8, 0.98));
isolation: isolate;
}
.surface-viewport::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 22%, transparent 78%, rgba(255, 255, 255, 0.02)),
radial-gradient(circle at center, transparent 62%, rgba(0, 0, 0, 0.14));
}
.surface-viewport__canvas {
width: 100%;
height: 100%;
display: block;
}
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import { ProgramOutputApp } from "./app/ProgramOutputApp";
const mode = new URLSearchParams(window.location.search).get("mode");
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
{mode === "output" ? <ProgramOutputApp /> : <App />}
</React.StrictMode>
);