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,139 @@
|
||||
import type NodeCG from "@nodecg/types";
|
||||
import type {
|
||||
Ad,
|
||||
AdState,
|
||||
BreakSlide,
|
||||
BundleConfig,
|
||||
ChoreoConfig,
|
||||
ObsStatus,
|
||||
SchedulerState,
|
||||
TickerConfig,
|
||||
TickerMessage,
|
||||
} from "../types";
|
||||
import { getStrings } from "../i18n";
|
||||
|
||||
export interface Replicants {
|
||||
tickerMessages: NodeCG.ServerReplicant<TickerMessage[]>;
|
||||
tickerConfig: NodeCG.ServerReplicant<TickerConfig>;
|
||||
ads: NodeCG.ServerReplicant<Ad[]>;
|
||||
adState: NodeCG.ServerReplicant<AdState>;
|
||||
choreoConfig: NodeCG.ServerReplicant<ChoreoConfig>;
|
||||
schedulerState: NodeCG.ServerReplicant<SchedulerState>;
|
||||
breakSlide: NodeCG.ServerReplicant<BreakSlide>;
|
||||
obsStatus: NodeCG.ServerReplicant<ObsStatus>;
|
||||
}
|
||||
|
||||
export function declareReplicants(nodecg: NodeCG.ServerAPI): Replicants {
|
||||
// First-run seeds follow the configured locale, and `branding` in the
|
||||
// bundle config can pre-fill the identity-flavored ones.
|
||||
const config = nodecg.bundleConfig as unknown as BundleConfig;
|
||||
const seed = getStrings(config?.locale).seed;
|
||||
const branding = config?.branding ?? {};
|
||||
|
||||
// Only fill in defaults when nothing is persisted, so operator edits
|
||||
// survive NodeCG restarts.
|
||||
const rep = <T>(name: string, defaultValue: T): NodeCG.ServerReplicant<T> => {
|
||||
const r = nodecg.Replicant(name) as unknown as NodeCG.ServerReplicant<T>;
|
||||
if (r.value === undefined) r.value = defaultValue;
|
||||
return r;
|
||||
};
|
||||
|
||||
const ads = rep<Ad[]>("ads", [
|
||||
{
|
||||
id: "sample-qr",
|
||||
rowText: seed.sampleAdRow,
|
||||
columnText: seed.sampleAdColumn,
|
||||
columnImageUrl: null,
|
||||
qrUrl: branding.sampleQrUrl ?? "https://example.com",
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// Migrate ads persisted by older versions: the { text } shape from before
|
||||
// the rowText/columnText split, and rows without columnImageUrl.
|
||||
if (
|
||||
ads.value?.some(
|
||||
(a) =>
|
||||
(a as any).rowText === undefined ||
|
||||
(a as any).columnImageUrl === undefined,
|
||||
)
|
||||
) {
|
||||
ads.value = ads.value.map((a) => {
|
||||
const legacy = a as any;
|
||||
return {
|
||||
id: a.id,
|
||||
rowText:
|
||||
legacy.rowText ??
|
||||
(typeof legacy.text === "string" ? legacy.text : ""),
|
||||
columnText: legacy.columnText ?? "",
|
||||
columnImageUrl: legacy.columnImageUrl ?? null,
|
||||
qrUrl: a.qrUrl,
|
||||
enabled: a.enabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const slideDefaults: BreakSlide = {
|
||||
heading: branding.breakHeading ?? seed.breakHeading,
|
||||
nextActName: "",
|
||||
nextActFrom: "",
|
||||
logoInverted: false,
|
||||
logoName: null,
|
||||
logoHeightPx: 240,
|
||||
};
|
||||
const breakSlide = rep<BreakSlide>("breakSlide", slideDefaults);
|
||||
|
||||
// Migrate slides persisted before the logo controls existed: fill in any
|
||||
// missing fields from the defaults, keeping whatever the operator set.
|
||||
if (
|
||||
breakSlide.value &&
|
||||
Object.keys(slideDefaults).some(
|
||||
(k) => (breakSlide.value as any)[k] === undefined,
|
||||
)
|
||||
) {
|
||||
breakSlide.value = { ...slideDefaults, ...breakSlide.value };
|
||||
}
|
||||
|
||||
return {
|
||||
ads,
|
||||
breakSlide,
|
||||
tickerMessages: rep<TickerMessage[]>("tickerMessages", []),
|
||||
|
||||
tickerConfig: rep<TickerConfig>("tickerConfig", {
|
||||
loopBlurb: branding.tickerBlurb ?? seed.loopBlurb,
|
||||
separator: "♦",
|
||||
defaultTtlSeconds: 300,
|
||||
speedPxPerSec: 120,
|
||||
}),
|
||||
|
||||
adState: rep<AdState>("adState", {
|
||||
visible: false,
|
||||
corner: "tr",
|
||||
adId: null,
|
||||
}),
|
||||
|
||||
choreoConfig: rep<ChoreoConfig>("choreoConfig", {
|
||||
normalAdIntervalMinutes: 15,
|
||||
adDurationSeconds: 20,
|
||||
breakGapSeconds: 45,
|
||||
breakAdDurationSeconds: 15,
|
||||
breakAdsPerBurst: 2,
|
||||
}),
|
||||
|
||||
schedulerState: rep<SchedulerState>("schedulerState", {
|
||||
mode: "normal",
|
||||
phase: "live",
|
||||
lastCorner: "bl",
|
||||
lastAdIndex: -1,
|
||||
burstRemaining: 0,
|
||||
nextTransitionAt: null,
|
||||
}),
|
||||
|
||||
obsStatus: rep<ObsStatus>("obsStatus", {
|
||||
connected: false,
|
||||
currentScene: null,
|
||||
lastError: null,
|
||||
attempts: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user