feat: allow individual ads to be manually triggered

This commit is contained in:
2026-07-16 17:34:53 +02:00
parent 78979cfd17
commit ded84b814d
5 changed files with 58 additions and 12 deletions
+5 -2
View File
@@ -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 speed) and message management; each active message shows a depleting fill
for its remaining time, with renew/remove/inline-edit. for its remaining time, with renew/remove/inline-edit.
- **Ads** — CRUD for ads (strip text, column text, column image from the - **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 - **Choreography / OBS** — connection status, mode/phase badges, countdown to
the next automatic transition, break-slide content, and manual controls: 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, - **Timing** — the choreography timing settings: ad interval, ad duration,
break gap, break ad duration, ads per burst. break gap, break ad duration, ads per burst.
- **OBS setup check** — read-only verification of the live OBS against the - **OBS setup check** — read-only verification of the live OBS against the
+19 -1
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import QRCode from "qrcode"; import QRCode from "qrcode";
import type NodeCG from "@nodecg/types"; import type NodeCG from "@nodecg/types";
import type { Ad, AssetFile } from "../types"; import type { Ad, AssetFile, SchedulerState } from "../types";
import { t } from "./i18n"; import { t } from "./i18n";
import { useReplicant, useReplicantValue } from "./lib"; import { useReplicant, useReplicantValue } from "./lib";
@@ -16,6 +16,10 @@ const columnImagesReplicant = nodecg.Replicant(
"assets:ad-column", "assets:ad-column",
) as unknown as NodeCG.ServerReplicant<AssetFile[]>; ) as unknown as NodeCG.ServerReplicant<AssetFile[]>;
const schedulerStateReplicant = nodecg.Replicant(
"schedulerState",
) as unknown as NodeCG.ServerReplicant<SchedulerState>;
const inputCls = const inputCls =
"w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none"; "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. // so later echoes would only fight the operator's cursor.
const [ads, setAds] = useState<Ad[] | null>(null); const [ads, setAds] = useState<Ad[] | null>(null);
const columnImages = useReplicantValue<AssetFile[]>(columnImagesReplicant) ?? []; const columnImages = useReplicantValue<AssetFile[]>(columnImagesReplicant) ?? [];
const sched = useReplicantValue<SchedulerState>(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) => { useReplicant(adsReplicant, (value) => {
if (value !== undefined) { if (value !== undefined) {
@@ -145,6 +153,15 @@ export function AdsPanel() {
/> />
{t.ads.enabled} {t.ads.enabled}
</label> </label>
<div className="flex items-center gap-1">
<button
className="rounded bg-amber-500 px-2 py-1 text-xs font-medium text-white hover:bg-amber-600 disabled:cursor-not-allowed disabled:bg-gray-300"
disabled={!canTrigger}
title={canTrigger ? "" : t.ads.triggerBusyTitle}
onClick={() => nodecg.sendMessage("ad:showSpecific", ad.id)}
>
{t.ads.triggerNow}
</button>
<button <button
className="rounded px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-50" className="rounded px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-50"
onClick={() => removeAd(ad.id)} onClick={() => removeAd(ad.id)}
@@ -154,6 +171,7 @@ export function AdsPanel() {
</div> </div>
</div> </div>
</div> </div>
</div>
))} ))}
<button <button
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700" className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
+24 -3
View File
@@ -26,6 +26,9 @@ export class Scheduler {
this.nodecg.listenFor("ad:showNow", () => { this.nodecg.listenFor("ad:showNow", () => {
if (this.st.phase === "live") this.toAd(); 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", () => { this.nodecg.listenFor("ad:skip", () => {
if (this.st.phase === "ad") this.toLive(); if (this.st.phase === "ad") this.toLive();
}); });
@@ -86,6 +89,20 @@ export class Scheduler {
return { ad: enabled[index], index }; 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) { private toLive(transition = this.obs.transitions.default) {
const mode = this.st.mode; const mode = this.st.mode;
this.r.adState.value = { ...this.r.adState.value!, visible: false }; this.r.adState.value = { ...this.r.adState.value!, visible: false };
@@ -99,10 +116,14 @@ export class Scheduler {
} }
} }
private toAd() { private toAd(forcedAdId?: string) {
const pick = this.nextEnabledAd(); const pick = forcedAdId ? this.pickAd(forcedAdId) : this.nextEnabledAd();
if (!pick) { 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(); this.toLive();
return; return;
} }
+2
View File
@@ -41,6 +41,8 @@ export const en = {
enabled: "Enabled", enabled: "Enabled",
delete: "Delete", delete: "Delete",
addAd: "+ New ad", addAd: "+ New ad",
triggerNow: "Trigger now",
triggerBusyTitle: "An ad is already showing — wait for it to finish",
}, },
choreo: { choreo: {
+2
View File
@@ -36,6 +36,8 @@ export const sr: Strings = {
enabled: "Uključena", enabled: "Uključena",
delete: "Obriši", delete: "Obriši",
addAd: "+ Nova reklama", addAd: "+ Nova reklama",
triggerNow: "Prikaži sada",
triggerBusyTitle: "Reklama je već na ekranu — sačekaj da se završi",
}, },
choreo: { choreo: {