Compare commits
3 Commits
4bb64a8ca3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6475517190 | |||
| ded84b814d | |||
| 78979cfd17 |
@@ -75,10 +75,15 @@ 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, timing configuration, break-slide content,
|
the next automatic transition, break-slide content, and manual controls:
|
||||||
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
|
- **OBS setup check** — read-only verification of the live OBS against the
|
||||||
expected scenes/sources/transforms/transitions, with concrete fixes.
|
expected scenes/sources/transforms/transitions, with concrete fixes.
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,13 @@
|
|||||||
"file": "choreo-panel.html",
|
"file": "choreo-panel.html",
|
||||||
"headerColor": "#8B2635"
|
"headerColor": "#8B2635"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "timing-panel",
|
||||||
|
"title": "Timing",
|
||||||
|
"width": 3,
|
||||||
|
"file": "timing-panel.html",
|
||||||
|
"headerColor": "#8B2635"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "obs-check-panel",
|
"name": "obs-check-panel",
|
||||||
"title": "OBS setup check",
|
"title": "OBS setup check",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import type NodeCG from "@nodecg/types";
|
|||||||
import type {
|
import type {
|
||||||
AssetFile,
|
AssetFile,
|
||||||
BreakSlide,
|
BreakSlide,
|
||||||
ChoreoConfig,
|
|
||||||
ObsStatus,
|
ObsStatus,
|
||||||
SchedulerState,
|
SchedulerState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
import { parseNum, useReplicantForm, useReplicantValue } from "./lib";
|
import { useReplicantForm, useReplicantValue } from "./lib";
|
||||||
|
|
||||||
const obsStatusReplicant = nodecg.Replicant(
|
const obsStatusReplicant = nodecg.Replicant(
|
||||||
"obsStatus",
|
"obsStatus",
|
||||||
@@ -19,10 +18,6 @@ const schedulerStateReplicant = nodecg.Replicant(
|
|||||||
"schedulerState",
|
"schedulerState",
|
||||||
) as unknown as NodeCG.ServerReplicant<SchedulerState>;
|
) as unknown as NodeCG.ServerReplicant<SchedulerState>;
|
||||||
|
|
||||||
const choreoConfigReplicant = nodecg.Replicant(
|
|
||||||
"choreoConfig",
|
|
||||||
) as unknown as NodeCG.ServerReplicant<ChoreoConfig>;
|
|
||||||
|
|
||||||
const breakSlideReplicant = nodecg.Replicant(
|
const breakSlideReplicant = nodecg.Replicant(
|
||||||
"breakSlide",
|
"breakSlide",
|
||||||
) as unknown as NodeCG.ServerReplicant<BreakSlide>;
|
) as unknown as NodeCG.ServerReplicant<BreakSlide>;
|
||||||
@@ -42,36 +37,9 @@ const formatCountdown = (ms: number) => {
|
|||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
function NumField({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onValid,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
onValid: (n: number) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="text-xs text-gray-600">
|
|
||||||
{label}
|
|
||||||
<input
|
|
||||||
className={inputCls}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const n = parseNum(e.target.value);
|
|
||||||
if (n !== null && n > 0) onValid(n);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChoreoPanel() {
|
export function ChoreoPanel() {
|
||||||
const status = useReplicantValue<ObsStatus>(obsStatusReplicant);
|
const status = useReplicantValue<ObsStatus>(obsStatusReplicant);
|
||||||
const sched = useReplicantValue<SchedulerState>(schedulerStateReplicant);
|
const sched = useReplicantValue<SchedulerState>(schedulerStateReplicant);
|
||||||
const [cfg, updateCfg] = useReplicantForm<ChoreoConfig>(choreoConfigReplicant);
|
|
||||||
const [slide, updateSlide] = useReplicantForm<BreakSlide>(breakSlideReplicant);
|
const [slide, updateSlide] = useReplicantForm<BreakSlide>(breakSlideReplicant);
|
||||||
const logoFiles = useReplicantValue<AssetFile[]>(logoAssetsReplicant);
|
const logoFiles = useReplicantValue<AssetFile[]>(logoAssetsReplicant);
|
||||||
|
|
||||||
@@ -196,11 +164,21 @@ export function ChoreoPanel() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4"
|
||||||
|
checked={slide.logoVisible ?? true}
|
||||||
|
onChange={(e) => updateSlide({ logoVisible: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t.choreo.showLogo}
|
||||||
|
</label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<label className="text-xs text-gray-600">
|
<label className="text-xs text-gray-600">
|
||||||
{t.choreo.logoLabel}
|
{t.choreo.logoLabel}
|
||||||
<select
|
<select
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
|
disabled={!(slide.logoVisible ?? true)}
|
||||||
value={slide.logoName ?? ""}
|
value={slide.logoName ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateSlide({ logoName: e.target.value || null })
|
updateSlide({ logoName: e.target.value || null })
|
||||||
@@ -219,6 +197,7 @@ export function ChoreoPanel() {
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
disabled={!(slide.logoVisible ?? true)}
|
||||||
min={80}
|
min={80}
|
||||||
max={480}
|
max={480}
|
||||||
step={10}
|
step={10}
|
||||||
@@ -233,6 +212,7 @@ export function ChoreoPanel() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
|
disabled={!(slide.logoVisible ?? true)}
|
||||||
checked={slide.logoInverted ?? false}
|
checked={slide.logoInverted ?? false}
|
||||||
onChange={(e) => updateSlide({ logoInverted: e.target.checked })}
|
onChange={(e) => updateSlide({ logoInverted: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
@@ -243,41 +223,6 @@ export function ChoreoPanel() {
|
|||||||
<div className="text-sm text-gray-500">{t.common.loading}</div>
|
<div className="text-sm text-gray-500">{t.common.loading}</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-2">
|
|
||||||
<h2 className={sectionTitleCls}>{t.choreo.timingSection}</h2>
|
|
||||||
{cfg ? (
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<NumField
|
|
||||||
label={t.choreo.adIntervalLabel}
|
|
||||||
value={cfg.normalAdIntervalMinutes}
|
|
||||||
onValid={(n) => updateCfg({ normalAdIntervalMinutes: n })}
|
|
||||||
/>
|
|
||||||
<NumField
|
|
||||||
label={t.choreo.adDurationLabel}
|
|
||||||
value={cfg.adDurationSeconds}
|
|
||||||
onValid={(n) => updateCfg({ adDurationSeconds: n })}
|
|
||||||
/>
|
|
||||||
<NumField
|
|
||||||
label={t.choreo.breakGapLabel}
|
|
||||||
value={cfg.breakGapSeconds}
|
|
||||||
onValid={(n) => updateCfg({ breakGapSeconds: n })}
|
|
||||||
/>
|
|
||||||
<NumField
|
|
||||||
label={t.choreo.breakAdDurationLabel}
|
|
||||||
value={cfg.breakAdDurationSeconds}
|
|
||||||
onValid={(n) => updateCfg({ breakAdDurationSeconds: n })}
|
|
||||||
/>
|
|
||||||
<NumField
|
|
||||||
label={t.choreo.breakAdsPerBurstLabel}
|
|
||||||
value={cfg.breakAdsPerBurst}
|
|
||||||
onValid={(n) => updateCfg({ breakAdsPerBurst: n })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-gray-500">{t.common.loading}</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import type NodeCG from "@nodecg/types";
|
||||||
|
import type { ChoreoConfig } from "../types";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
import { parseNum, useReplicantForm } from "./lib";
|
||||||
|
|
||||||
|
const choreoConfigReplicant = nodecg.Replicant(
|
||||||
|
"choreoConfig",
|
||||||
|
) as unknown as NodeCG.ServerReplicant<ChoreoConfig>;
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none";
|
||||||
|
|
||||||
|
function NumField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onValid,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onValid: (n: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="text-xs text-gray-600">
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseNum(e.target.value);
|
||||||
|
if (n !== null && n > 0) onValid(n);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimingPanel() {
|
||||||
|
const [cfg, updateCfg] = useReplicantForm<ChoreoConfig>(choreoConfigReplicant);
|
||||||
|
|
||||||
|
if (!cfg) {
|
||||||
|
return <div className="p-3 text-sm text-gray-500">{t.common.loading}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 p-3">
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
||||||
|
{t.choreo.timingSection}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<NumField
|
||||||
|
label={t.choreo.adIntervalLabel}
|
||||||
|
value={cfg.normalAdIntervalMinutes}
|
||||||
|
onValid={(n) => updateCfg({ normalAdIntervalMinutes: n })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t.choreo.adDurationLabel}
|
||||||
|
value={cfg.adDurationSeconds}
|
||||||
|
onValid={(n) => updateCfg({ adDurationSeconds: n })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t.choreo.breakGapLabel}
|
||||||
|
value={cfg.breakGapSeconds}
|
||||||
|
onValid={(n) => updateCfg({ breakGapSeconds: n })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t.choreo.breakAdDurationLabel}
|
||||||
|
value={cfg.breakAdDurationSeconds}
|
||||||
|
onValid={(n) => updateCfg({ breakAdDurationSeconds: n })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={t.choreo.breakAdsPerBurstLabel}
|
||||||
|
value={cfg.breakAdsPerBurst}
|
||||||
|
onValid={(n) => updateCfg({ breakAdsPerBurst: n })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { TimingPanel } from "./TimingPanel";
|
||||||
|
import { locale } from "./i18n";
|
||||||
|
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<TimingPanel />);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link href="./panel.css" type="text/css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./timing-panel-bootstrap.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -77,6 +77,7 @@ export function declareReplicants(nodecg: NodeCG.ServerAPI): Replicants {
|
|||||||
heading: branding.breakHeading ?? seed.breakHeading,
|
heading: branding.breakHeading ?? seed.breakHeading,
|
||||||
nextActName: "",
|
nextActName: "",
|
||||||
nextActFrom: "",
|
nextActFrom: "",
|
||||||
|
logoVisible: true,
|
||||||
logoInverted: false,
|
logoInverted: false,
|
||||||
logoName: null,
|
logoName: null,
|
||||||
logoHeightPx: 240,
|
logoHeightPx: 240,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function Break() {
|
|||||||
<div className="text-6xl font-medium tracking-wide text-white/80">
|
<div className="text-6xl font-medium tracking-wide text-white/80">
|
||||||
{slide?.heading}
|
{slide?.heading}
|
||||||
</div>
|
</div>
|
||||||
{logoUrl && (
|
{(slide?.logoVisible ?? true) && logoUrl && (
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -62,6 +64,7 @@ export const en = {
|
|||||||
headingLabel: "Heading",
|
headingLabel: "Heading",
|
||||||
nextActNameLabel: "Next act",
|
nextActNameLabel: "Next act",
|
||||||
nextActFromLabel: "Where they're from",
|
nextActFromLabel: "Where they're from",
|
||||||
|
showLogo: "Show act logo",
|
||||||
logoLabel: "Act logo",
|
logoLabel: "Act logo",
|
||||||
logoAuto: "Automatic (first image)",
|
logoAuto: "Automatic (first image)",
|
||||||
logoSizeLabel: (px: number) => `Logo size (${px}px)`,
|
logoSizeLabel: (px: number) => `Logo size (${px}px)`,
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -57,6 +59,7 @@ export const sr: Strings = {
|
|||||||
headingLabel: "Naslov",
|
headingLabel: "Naslov",
|
||||||
nextActNameLabel: "Sledeći nastup",
|
nextActNameLabel: "Sledeći nastup",
|
||||||
nextActFromLabel: "Odakle su",
|
nextActFromLabel: "Odakle su",
|
||||||
|
showLogo: "Prikaži logo benda",
|
||||||
logoLabel: "Logo benda",
|
logoLabel: "Logo benda",
|
||||||
logoAuto: "Automatski (prva slika)",
|
logoAuto: "Automatski (prva slika)",
|
||||||
logoSizeLabel: (px: number) => `Veličina logoa (${px}px)`,
|
logoSizeLabel: (px: number) => `Veličina logoa (${px}px)`,
|
||||||
|
|||||||
Vendored
+2
@@ -23,6 +23,8 @@ export interface BreakSlide {
|
|||||||
heading: string;
|
heading: string;
|
||||||
nextActName: string;
|
nextActName: string;
|
||||||
nextActFrom: string;
|
nextActFrom: string;
|
||||||
|
/** show the band logo at all (hide when no act logo makes sense) */
|
||||||
|
logoVisible: boolean;
|
||||||
/** invert the band logo's colors (dark logo on the dark background) */
|
/** invert the band logo's colors (dark logo on the dark background) */
|
||||||
logoInverted: boolean;
|
logoInverted: boolean;
|
||||||
/** `base` of the assets:break-logo file to show; null = first uploaded */
|
/** `base` of the assets:break-logo file to show; null = first uploaded */
|
||||||
|
|||||||
Reference in New Issue
Block a user