init: initial commit
- ticker / QR ad overlay / break slide graphics (1920×1080) with all geometry derived from one shared module (src/shared/obs-geometry.mjs) - OBS scene-collection generator (scripts/generate-obs-scenes.mjs) and a read-only in-dashboard setup verifier, both driven by the same module - en/sr dashboard i18n selected via bundle config; configschema.json with neutral first-run defaults seeded from optional branding config - install.sh / systemd user service / update.sh for venue deployment - reference OBS scene collection + profile from a real venue as fixtures
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import React from "react";
|
||||
|
||||
import type NodeCG from "@nodecg/types";
|
||||
import type { AssetFile, BreakSlide } from "../types";
|
||||
import { useReplicantValue } from "./lib";
|
||||
|
||||
const breakSlideReplicant = nodecg.Replicant(
|
||||
"breakSlide",
|
||||
) as unknown as NodeCG.ServerReplicant<BreakSlide>;
|
||||
|
||||
// Optional background image (dashboard Assets → "Pauza — pozadinska slika");
|
||||
// when present it replaces the drifting blobs.
|
||||
const backgroundReplicant = nodecg.Replicant(
|
||||
"assets:break-bg",
|
||||
) as unknown as NodeCG.ServerReplicant<AssetFile[]>;
|
||||
|
||||
// Optional band logo (dashboard Assets → "Pauza — logo benda"), shown between
|
||||
// the heading and the act name.
|
||||
const logoReplicant = nodecg.Replicant(
|
||||
"assets:break-logo",
|
||||
) as unknown as NodeCG.ServerReplicant<AssetFile[]>;
|
||||
|
||||
interface Blob {
|
||||
size: number;
|
||||
left: string;
|
||||
top: string;
|
||||
color: string;
|
||||
animation: string;
|
||||
}
|
||||
|
||||
// Huge blurred radial blobs on slow drift loops — GPU-cheap ambient motion.
|
||||
// Colors come from the THEME LAB block in index.css.
|
||||
const BLOBS: Blob[] = [
|
||||
{
|
||||
size: 900,
|
||||
left: "-10%",
|
||||
top: "-20%",
|
||||
color: "var(--color-break-blob-1)",
|
||||
animation: "drift-a 90s ease-in-out infinite alternate",
|
||||
},
|
||||
{
|
||||
size: 800,
|
||||
left: "55%",
|
||||
top: "40%",
|
||||
color: "var(--color-break-blob-2)",
|
||||
animation: "drift-b 110s ease-in-out infinite alternate",
|
||||
},
|
||||
{
|
||||
size: 700,
|
||||
left: "25%",
|
||||
top: "55%",
|
||||
color: "var(--color-break-blob-3)",
|
||||
animation: "drift-c 70s ease-in-out infinite alternate",
|
||||
},
|
||||
];
|
||||
|
||||
export function Break() {
|
||||
const slide = useReplicantValue<BreakSlide>(breakSlideReplicant);
|
||||
const backgroundUrl =
|
||||
useReplicantValue<AssetFile[]>(backgroundReplicant)?.[0]?.url ?? null;
|
||||
// The slide names which uploaded file to use; fall back to the first one if
|
||||
// nothing is picked or the picked file was deleted.
|
||||
const logoFiles = useReplicantValue<AssetFile[]>(logoReplicant);
|
||||
const logoUrl =
|
||||
(logoFiles?.find((f) => f.base === slide?.logoName) ?? logoFiles?.[0])
|
||||
?.url ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-[1080px] w-[1920px] overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-break-bg-a) 0%, var(--color-break-bg-b) 55%, var(--color-break-bg-a) 100%)",
|
||||
}}
|
||||
>
|
||||
{backgroundUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={backgroundUrl}
|
||||
alt=""
|
||||
className="absolute left-0 top-0 h-full w-full object-cover"
|
||||
style={{ animation: "break-bounce 30s ease-in-out infinite alternate" }}
|
||||
/>
|
||||
{/* scrim keeps the announcement text readable — tune to taste */}
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
</>
|
||||
) : (
|
||||
BLOBS.map((blob, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: blob.size,
|
||||
height: blob.size,
|
||||
left: blob.left,
|
||||
top: blob.top,
|
||||
background: blob.color,
|
||||
filter: "blur(120px)",
|
||||
animation: blob.animation,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/*
|
||||
Text stays well inside the edges: this same source is shown shrunk to
|
||||
70% in the BREAK_AD_* scenes, so extremes would crowd the ad cutout.
|
||||
*/}
|
||||
<div
|
||||
className="font-display absolute inset-0 flex flex-col items-center justify-center gap-10 px-[15%] text-center"
|
||||
style={{ animation: "breathe 8s ease-in-out infinite" }}
|
||||
>
|
||||
<div className="text-6xl font-medium tracking-wide text-white/80">
|
||||
{slide?.heading}
|
||||
</div>
|
||||
{logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
className="max-w-[45%] object-contain"
|
||||
style={{
|
||||
height: slide?.logoHeightPx ?? 240,
|
||||
// invert must come before the shadow so the shadow itself
|
||||
// doesn't flip to white
|
||||
filter: `${slide?.logoInverted ? "invert(1) " : ""}drop-shadow(0 4px 24px rgba(0,0,0,0.6))`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="text-9xl font-extrabold leading-tight text-white drop-shadow-[0_4px_24px_rgba(0,0,0,0.6)]">
|
||||
{slide?.nextActName}
|
||||
</div>
|
||||
<div className="text-5xl font-light tracking-wider text-break-accent">
|
||||
{slide?.nextActFrom}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user