From ded84b814dc009e074fa2cd8e547e89fd531f856 Mon Sep 17 00:00:00 2001 From: dvdrw Date: Thu, 16 Jul 2026 17:34:53 +0200 Subject: [PATCH] feat: allow individual ads to be manually triggered --- README.md | 7 +++++-- src/dashboard/AdsPanel.tsx | 32 +++++++++++++++++++++++++------- src/extension/scheduler.ts | 27 ++++++++++++++++++++++++--- src/i18n/en.ts | 2 ++ src/i18n/sr.ts | 2 ++ 5 files changed, 58 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e9fc586..5e9c85b 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,13 @@ edited from the dashboard and persists in NodeCG's database. Set speed) and message management; each active message shows a depleting fill for its remaining time, with renew/remove/inline-edit. - **Ads** — CRUD for ads (strip text, column text, column image from the - Assets dialog, QR URL, enabled), with a live QR preview per row. + Assets dialog, QR URL, enabled), with a live QR preview per row. Each row + has a "Trigger now" button to force that specific ad onto air immediately + (enabled or not), bypassing the round-robin — disabled while an ad slot is + already showing. - **Choreography / OBS** — connection status, mode/phase badges, countdown to the next automatic transition, break-slide content, and manual controls: - show ad now, skip ad, break toggle. + show ad now (round-robin), skip ad, break toggle. - **Timing** — the choreography timing settings: ad interval, ad duration, break gap, break ad duration, ads per burst. - **OBS setup check** — read-only verification of the live OBS against the diff --git a/src/dashboard/AdsPanel.tsx b/src/dashboard/AdsPanel.tsx index 2ff9aff..db76490 100644 --- a/src/dashboard/AdsPanel.tsx +++ b/src/dashboard/AdsPanel.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import QRCode from "qrcode"; import type NodeCG from "@nodecg/types"; -import type { Ad, AssetFile } from "../types"; +import type { Ad, AssetFile, SchedulerState } from "../types"; import { t } from "./i18n"; import { useReplicant, useReplicantValue } from "./lib"; @@ -16,6 +16,10 @@ const columnImagesReplicant = nodecg.Replicant( "assets:ad-column", ) as unknown as NodeCG.ServerReplicant; +const schedulerStateReplicant = nodecg.Replicant( + "schedulerState", +) as unknown as NodeCG.ServerReplicant; + const inputCls = "w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none"; @@ -51,6 +55,10 @@ export function AdsPanel() { // so later echoes would only fight the operator's cursor. const [ads, setAds] = useState(null); const columnImages = useReplicantValue(columnImagesReplicant) ?? []; + const sched = useReplicantValue(schedulerStateReplicant); + // Manual triggers only make sense while the ad slot is free; mid-ad or + // mid-burst clicks would race the scheduler's own timers. + const canTrigger = sched?.phase === "live"; useReplicant(adsReplicant, (value) => { if (value !== undefined) { @@ -145,12 +153,22 @@ export function AdsPanel() { /> {t.ads.enabled} - +
+ + +
diff --git a/src/extension/scheduler.ts b/src/extension/scheduler.ts index 0803a37..53789d9 100644 --- a/src/extension/scheduler.ts +++ b/src/extension/scheduler.ts @@ -26,6 +26,9 @@ export class Scheduler { this.nodecg.listenFor("ad:showNow", () => { if (this.st.phase === "live") this.toAd(); }); + this.nodecg.listenFor("ad:showSpecific", (adId: string) => { + if (this.st.phase === "live") this.toAd(adId); + }); this.nodecg.listenFor("ad:skip", () => { if (this.st.phase === "ad") this.toLive(); }); @@ -86,6 +89,20 @@ export class Scheduler { return { ad: enabled[index], index }; } + /** + * Look up an ad by id for an operator-forced trigger, regardless of its + * enabled flag. `index` is its position in the enabled list (-1 if + * disabled/missing), which keeps the round-robin cursor coherent for the + * *next* automatic pick. + */ + private pickAd(adId: string): { ad: Ad; index: number } | null { + const all = this.r.ads.value ?? []; + const ad = all.find((a) => a.id === adId); + if (!ad) return null; + const enabled = all.filter((a) => a.enabled); + return { ad, index: enabled.findIndex((a) => a.id === adId) }; + } + private toLive(transition = this.obs.transitions.default) { const mode = this.st.mode; this.r.adState.value = { ...this.r.adState.value!, visible: false }; @@ -99,10 +116,14 @@ export class Scheduler { } } - private toAd() { - const pick = this.nextEnabledAd(); + private toAd(forcedAdId?: string) { + const pick = forcedAdId ? this.pickAd(forcedAdId) : this.nextEnabledAd(); if (!pick) { - this.nodecg.log.warn("scheduler: no enabled ads, skipping ad slot"); + this.nodecg.log.warn( + forcedAdId + ? `scheduler: forced ad ${forcedAdId} not found` + : "scheduler: no enabled ads, skipping ad slot", + ); this.toLive(); return; } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 4aedbf1..bc1eb38 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -41,6 +41,8 @@ export const en = { enabled: "Enabled", delete: "Delete", addAd: "+ New ad", + triggerNow: "Trigger now", + triggerBusyTitle: "An ad is already showing — wait for it to finish", }, choreo: { diff --git a/src/i18n/sr.ts b/src/i18n/sr.ts index 465bbde..bf1b6a4 100644 --- a/src/i18n/sr.ts +++ b/src/i18n/sr.ts @@ -36,6 +36,8 @@ export const sr: Strings = { enabled: "Uključena", delete: "Obriši", addAd: "+ Nova reklama", + triggerNow: "Prikaži sada", + triggerBusyTitle: "Reklama je već na ekranu — sačekaj da se završi", }, choreo: {