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; tickerConfig: NodeCG.ServerReplicant; ads: NodeCG.ServerReplicant; adState: NodeCG.ServerReplicant; choreoConfig: NodeCG.ServerReplicant; schedulerState: NodeCG.ServerReplicant; breakSlide: NodeCG.ServerReplicant; obsStatus: NodeCG.ServerReplicant; } 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 = (name: string, defaultValue: T): NodeCG.ServerReplicant => { const r = nodecg.Replicant(name) as unknown as NodeCG.ServerReplicant; if (r.value === undefined) r.value = defaultValue; return r; }; const ads = rep("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: "", logoVisible: true, logoInverted: false, logoName: null, logoHeightPx: 240, }; const breakSlide = rep("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("tickerMessages", []), tickerConfig: rep("tickerConfig", { loopBlurb: branding.tickerBlurb ?? seed.loopBlurb, separator: "♦", defaultTtlSeconds: 300, speedPxPerSec: 120, }), adState: rep("adState", { visible: false, corner: "tr", adId: null, }), choreoConfig: rep("choreoConfig", { normalAdIntervalMinutes: 15, adDurationSeconds: 20, breakGapSeconds: 45, breakAdDurationSeconds: 15, breakAdsPerBurst: 2, }), schedulerState: rep("schedulerState", { mode: "normal", phase: "live", lastCorner: "bl", lastAdIndex: -1, burstRemaining: 0, nextTransitionAt: null, }), obsStatus: rep("obsStatus", { connected: false, currentScene: null, lastError: null, attempts: 0, }), }; }