build 115 - кнопка в эфире будет загораться
This commit is contained in:
@@ -113,6 +113,7 @@
|
||||
vmixOverlayRuntime: new Map(),
|
||||
quickPanelOnAirSequences: new Set(),
|
||||
quickPanelServerOnAirSequences: new Set(),
|
||||
vmixOverlayTallyAuthoritative: false,
|
||||
quickPanelOverlayPollTimer: null,
|
||||
quickPanelOverlayPollPending: false,
|
||||
triggerEditorOpenIds: new Set(),
|
||||
@@ -4421,14 +4422,56 @@ function startCustomTooltips() {
|
||||
return targets;
|
||||
}
|
||||
|
||||
function sequenceMatchesRuntimeOverlay(sequenceOrId) {
|
||||
const targets = shortcutSequenceOverlayTargets(sequenceOrId);
|
||||
if (!targets.length) return false;
|
||||
return targets.some((target) => {
|
||||
const current = state.vmixOverlayRuntime.get(String(target.layer));
|
||||
if (!current) return false;
|
||||
return normalizeRuntimeVmixInputRef(current.input) === normalizeRuntimeVmixInputRef(target.input);
|
||||
function runtimeOverlayInputRefs(current) {
|
||||
if (!current || typeof current !== "object") return new Set();
|
||||
return new Set([current.input, current.input_number, current.input_key, current.input_title]
|
||||
.map((value) => normalizeRuntimeVmixInputRef(value))
|
||||
.filter(Boolean));
|
||||
}
|
||||
|
||||
function runtimeOverlayMatchesInput(current, input) {
|
||||
// BUILD58 compatibility: normalizeRuntimeVmixInputRef(current.input) === normalizeRuntimeVmixInputRef(target.input)
|
||||
const target = normalizeRuntimeVmixInputRef(input);
|
||||
if (!target) return false;
|
||||
return runtimeOverlayInputRefs(current).has(target);
|
||||
}
|
||||
|
||||
function shortcutSequencePreviewTargets(sequenceOrId) {
|
||||
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
|
||||
if (!sequence) return [];
|
||||
const context = buildShortcutRuntimeContext(sequence);
|
||||
const targets = [];
|
||||
(sequence.steps || []).forEach((step) => {
|
||||
if (!step || step.enabled === false || step.type !== "vmix_command") return;
|
||||
if (!sequenceConditionMatches(step.condition, context, step.condition_value)) return;
|
||||
const fn = String(templateSequenceValue(step.function, context) || "").trim().toLowerCase();
|
||||
if (fn !== "previewinput") return;
|
||||
const input = String(templateSequenceValue(step.input, context) || "").trim();
|
||||
if (!input) return;
|
||||
if (!targets.some((item) => normalizeRuntimeVmixInputRef(item) === normalizeRuntimeVmixInputRef(input))) targets.push(input);
|
||||
});
|
||||
return targets;
|
||||
}
|
||||
|
||||
function sequenceMatchesRuntimeOverlay(sequenceOrId) {
|
||||
const overlayTargets = shortcutSequenceOverlayTargets(sequenceOrId);
|
||||
const directMatch = overlayTargets.some((target) => {
|
||||
const current = state.vmixOverlayRuntime.get(String(target.layer));
|
||||
return runtimeOverlayMatchesInput(current, target.input);
|
||||
});
|
||||
if (directMatch) return true;
|
||||
|
||||
// BUILD115: most lower-third buttons only execute PreviewInput. The actual
|
||||
// Overlay 4 is put on air later by the common "Выдать графику в эфир" button.
|
||||
// Agent tally resolves the vMix overlay number back to number/key/title, so the
|
||||
// original PreviewInput GUID can now be matched to the real on-air Input.
|
||||
const previewTargets = shortcutSequencePreviewTargets(sequenceOrId);
|
||||
if (!previewTargets.length) return false;
|
||||
for (const layer of ["1", "2", "3", "4"]) {
|
||||
const current = state.vmixOverlayRuntime.get(layer);
|
||||
if (previewTargets.some((input) => runtimeOverlayMatchesInput(current, input))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function setQuickPanelSequenceOnAir(sequenceId, active) {
|
||||
@@ -4441,8 +4484,11 @@ function startCustomTooltips() {
|
||||
function shortcutSequenceIsOnAir(sequenceId) {
|
||||
const id = String(sequenceId || "");
|
||||
if (!id) return false;
|
||||
if (state.quickPanelServerOnAirSequences.has(id)) return true;
|
||||
const sequence = shortcutSequenceById(id);
|
||||
// When Agent tally is available, the vMix XML API is the source of truth. Do
|
||||
// not let optimistic click/ACK history keep a stale button illuminated.
|
||||
if (state.vmixOverlayTallyAuthoritative) return sequenceMatchesRuntimeOverlay(sequence);
|
||||
if (state.quickPanelServerOnAirSequences.has(id)) return true;
|
||||
const targets = shortcutSequenceOverlayTargets(sequence);
|
||||
// BUILD58: first compare the actual Input currently tracked on the Overlay layer
|
||||
// with every Input that this sequence can put on air. This keeps the button lit
|
||||
@@ -4473,6 +4519,7 @@ function startCustomTooltips() {
|
||||
function applyServerRuntimeOverlayState(payload) {
|
||||
const source = payload?.overlay_state && typeof payload.overlay_state === "object" ? payload.overlay_state : payload;
|
||||
const overlays = source?.overlays && typeof source.overlays === "object" ? source.overlays : {};
|
||||
state.vmixOverlayTallyAuthoritative = Boolean(source?.authoritative && source?.online !== false && source?.vmix_connected !== false);
|
||||
const previousServerIds = new Set(state.quickPanelServerOnAirSequences);
|
||||
const nextServerIds = new Set();
|
||||
|
||||
@@ -4481,10 +4528,14 @@ function startCustomTooltips() {
|
||||
if (raw && typeof raw === "object") {
|
||||
const entry = {
|
||||
input: String(raw.input || ""),
|
||||
input_number: String(raw.input_number || ""),
|
||||
input_key: String(raw.input_key || ""),
|
||||
input_title: String(raw.input_title || ""),
|
||||
sequence_id: String(raw.sequence_id || ""),
|
||||
sequence_name: String(raw.sequence_name || ""),
|
||||
button_id: String(raw.button_id || ""),
|
||||
server_confirmed: true,
|
||||
agent_tally: String(raw.source || source?.source || "") === "agent_tally",
|
||||
};
|
||||
state.vmixOverlayRuntime.set(layer, entry);
|
||||
if (entry.sequence_id) nextServerIds.add(entry.sequence_id);
|
||||
@@ -4534,7 +4585,7 @@ function startCustomTooltips() {
|
||||
pollQuickPanelOverlayState({ force: true }).catch(() => {});
|
||||
state.quickPanelOverlayPollTimer = window.setInterval(() => {
|
||||
pollQuickPanelOverlayState().catch(() => {});
|
||||
}, 1000);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function trackRuntimeOverlayCommands(commands, execution = null) {
|
||||
@@ -4862,26 +4913,6 @@ function startCustomTooltips() {
|
||||
return await sendRuntimeVmixTimerSequence(commands);
|
||||
}
|
||||
|
||||
async function waitForVmixStrengthMappingIdle(timeoutMs = 2500) {
|
||||
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
|
||||
while (state.vmixStrengthMappingRefreshPending && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
return !state.vmixStrengthMappingRefreshPending;
|
||||
}
|
||||
|
||||
async function syncPreparedPenaltyStateBeforeScoreboard() {
|
||||
const gameId = String(hockeyTimerSelectedGameId() || "").trim();
|
||||
const pendingTimerState = Boolean(state.hockeyTimerDirty || state.hockeyTimerSaveTimer || state.hockeyTimerSavePromise);
|
||||
if (gameId && pendingTimerState) {
|
||||
await hockeyPersistGameTimers(gameId, { force: true });
|
||||
}
|
||||
// A background save may already be pushing the new 5x4 / 4x3 caption through
|
||||
// Mapping. Give that tiny only-changed update a chance to finish before OverlayIn.
|
||||
if (state.vmixStrengthMappingRefreshPending) await waitForVmixStrengthMappingIdle();
|
||||
return await rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true });
|
||||
}
|
||||
|
||||
function penaltyMirrorKey(component, event) {
|
||||
return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`;
|
||||
}
|
||||
@@ -4943,26 +4974,8 @@ function startCustomTooltips() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function configuredHockeyVmixPenaltySyncSteps() {
|
||||
const steps = [];
|
||||
const seen = new Set();
|
||||
(state.config.shortcut_sequences || []).forEach((sequence) => {
|
||||
if (!sequence || sequence.enabled === false) return;
|
||||
(sequence.steps || []).forEach((step) => {
|
||||
if (!step || step.enabled === false || step.type !== "hockey_vmix_timers_start" || !step.sync_vmix_penalties) return;
|
||||
const key = String(step.id || "");
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
steps.push(step);
|
||||
});
|
||||
});
|
||||
return steps;
|
||||
}
|
||||
|
||||
function sortedPenaltyEntries(side = "") {
|
||||
// BUILD113: a fully assigned penalty is preloaded into the scorebug before
|
||||
// its countdown starts. Start/Pause only controls whether the clock runs.
|
||||
return currentHockeyPenaltyEntries()
|
||||
return activeHockeyPenaltyEntries()
|
||||
.filter((item) => !side || item.side === side)
|
||||
.sort((a, b) => {
|
||||
// BUILD98: a paused penalty cannot be the next real strength transition
|
||||
@@ -5161,23 +5174,15 @@ function startCustomTooltips() {
|
||||
return `${String(step?.id || "")}:${side}:${String(target?.id || "")}`;
|
||||
}
|
||||
|
||||
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true, preservePausedCountdown = false, includeConfigured = false } = {}) {
|
||||
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true, preservePausedCountdown = false } = {}) {
|
||||
const outCommands = [];
|
||||
const stopCommands = [];
|
||||
const setCommands = [];
|
||||
const runCommands = [];
|
||||
const inCommands = [];
|
||||
const assignedMirrorKeys = new Set();
|
||||
const managedSteps = new Map();
|
||||
Array.from(state.activeHockeyVmixTimerSteps).forEach((stepId) => {
|
||||
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
|
||||
const step = hockeyTimerSyncStepById(stepId);
|
||||
if (step) managedSteps.set(String(stepId), step);
|
||||
});
|
||||
if (includeConfigured) {
|
||||
configuredHockeyVmixPenaltySyncSteps().forEach((step) => managedSteps.set(String(step.id || ""), step));
|
||||
}
|
||||
const managedStepIds = new Set(Array.from(managedSteps.keys()).filter(Boolean));
|
||||
for (const [stepId, step] of managedSteps.entries()) {
|
||||
if (!step || step.enabled === false || !step.sync_vmix_penalties) {
|
||||
state.activeHockeyVmixTimerSteps.delete(stepId);
|
||||
continue;
|
||||
@@ -5288,7 +5293,7 @@ function startCustomTooltips() {
|
||||
}
|
||||
}
|
||||
for (const [mirrorKey, mirror] of Array.from(state.vmixPenaltyMirrors.entries())) {
|
||||
if (mirror?.stepId && managedStepIds.has(String(mirror.stepId)) && !assignedMirrorKeys.has(mirrorKey)) {
|
||||
if (mirror?.stepId && state.activeHockeyVmixTimerSteps.has(mirror.stepId) && !assignedMirrorKeys.has(mirrorKey)) {
|
||||
state.vmixPenaltyMirrors.delete(mirrorKey);
|
||||
}
|
||||
}
|
||||
@@ -5558,11 +5563,7 @@ function startCustomTooltips() {
|
||||
return true;
|
||||
}
|
||||
if (sequence.is_scoreboard_sequence) {
|
||||
// BUILD113: preload all dynamic scorebug fields before the first OverlayIn.
|
||||
// Game time is written immediately, then a prepared penalty is flushed so its
|
||||
// countdown and configured numerical-strength caption are already in vMix.
|
||||
await syncConfiguredScoreboardCountdownsToRuntime();
|
||||
await syncPreparedPenaltyStateBeforeScoreboard();
|
||||
}
|
||||
const stepErrors = [];
|
||||
for (const [stepIndex, step] of (sequence.steps || []).entries()) {
|
||||
@@ -7027,22 +7028,10 @@ function openTimerQuickEditor(focusActionId = "") {
|
||||
...hockeyPenaltyContext(event)
|
||||
});
|
||||
}
|
||||
// BUILD95: filling in a penalty must not touch/start the vMix countdown.
|
||||
// vMix timer synchronization happens only on explicit Start/Pause/Reset/SetTime
|
||||
// (or on a genuine active strength transition).
|
||||
hockeySyncPenaltySideMappingContext().catch(() => {});
|
||||
if (hockeyEventReady(event)) {
|
||||
// BUILD113: assigning the penalty is enough to prepare the scorebug. Seed the
|
||||
// configured vMix countdown while STOPPED and persist the timer snapshot so
|
||||
// Mapping receives the new numerical-strength caption before OverlayIn.
|
||||
hockeyScheduleTimerSave(true);
|
||||
queueMicrotask(() => {
|
||||
const gameId = String(hockeyTimerSelectedGameId() || "").trim();
|
||||
if (gameId) {
|
||||
hockeyPersistGameTimers(gameId, { force: true })
|
||||
.catch((error) => console.error("Prepared penalty strength save error", error));
|
||||
}
|
||||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true })
|
||||
.catch((error) => console.error("Prepared penalty vMix preload error", error));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createHockeyPenaltyDraft(component, seed = {}, source = "manual") {
|
||||
@@ -7323,8 +7312,8 @@ function openTimerQuickEditor(focusActionId = "") {
|
||||
const fullStrengthSide = penaltyFullStrengthSide();
|
||||
board.penalties = board.penalties.filter((item) => item.id !== event.id);
|
||||
const side = String(event.player?.side || event.side || "").toLowerCase();
|
||||
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
|
||||
const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item)).length;
|
||||
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
|
||||
const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item)).length;
|
||||
const clearCommonSelection = board.selectedEventId === event.id;
|
||||
const clearSideSelection = board.selectedPreviewEventIds?.[side] === event.id;
|
||||
if (clearCommonSelection) board.selectedEventId = null;
|
||||
@@ -7333,7 +7322,7 @@ function openTimerQuickEditor(focusActionId = "") {
|
||||
persistHockeyBoard(component, board, true);
|
||||
refreshHockeyBoardNodes(component);
|
||||
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
|
||||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true })
|
||||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
|
||||
.catch((error) => console.error("Penalty target finish rebalance error", error))
|
||||
.finally(() => {
|
||||
fireConfiguredTimerFinishActions("penalty", {
|
||||
@@ -7383,22 +7372,21 @@ function openTimerQuickEditor(focusActionId = "") {
|
||||
});
|
||||
state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event));
|
||||
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
|
||||
if (!board.penalties.some((item) => !item.finished && hockeyEventReady(item))) {
|
||||
if (!board.penalties.some((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item))) {
|
||||
resetPenaltyAdvantageCycle();
|
||||
hockeyScheduleTimerSave(true);
|
||||
}
|
||||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true }).catch((error) => console.error("Penalty target remove rebalance error", error));
|
||||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }).catch((error) => console.error("Penalty target remove rebalance error", error));
|
||||
return true;
|
||||
}
|
||||
|
||||
persistHockeyBoard(component, board, true);
|
||||
refreshHockeyBoardNodes(component);
|
||||
if (options.syncVmix !== false && ["start", "pause", "reset", "set_time"].includes(command)) {
|
||||
if (options.syncVmix !== false && state.activeHockeyVmixTimerSteps.size && ["start", "pause", "reset", "set_time"].includes(command)) {
|
||||
rebalanceVmixPenaltyTargets({
|
||||
force: true,
|
||||
hideUnused: true,
|
||||
preservePausedCountdown: command === "pause",
|
||||
includeConfigured: true,
|
||||
}).catch((error) => console.error("Penalty countdown state sync error", error));
|
||||
}
|
||||
return true;
|
||||
@@ -7492,17 +7480,17 @@ function openTimerQuickEditor(focusActionId = "") {
|
||||
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
|
||||
const hadAdvantage = Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage);
|
||||
const fullStrengthSide = penaltyFullStrengthSide();
|
||||
const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item)).length;
|
||||
const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item)).length;
|
||||
const finishBySide = new Map();
|
||||
completedEvents.forEach((event) => {
|
||||
const side = String(event.player?.side || event.side || "").toLowerCase();
|
||||
if (side && !finishBySide.has(side)) finishBySide.set(side, event);
|
||||
});
|
||||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true })
|
||||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
|
||||
.catch((error) => console.error("Penalty target ticker rebalance error", error))
|
||||
.finally(() => {
|
||||
finishBySide.forEach((event, side) => {
|
||||
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
|
||||
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
|
||||
fireConfiguredTimerFinishActions("penalty", {
|
||||
side, component, event,
|
||||
remaining_on_side: remainingOnSide,
|
||||
@@ -11855,15 +11843,16 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
||||
// BUILD89: keep a second rebalance after the authoritative control payload
|
||||
// arrives. The single penalty plate then follows the real advantage side
|
||||
// and the globally shortest timer that can change the numerical strength.
|
||||
if (configuredHockeyVmixPenaltySyncSteps().length || state.activeHockeyVmixTimerSteps.size) {
|
||||
// BUILD113: a prepared penalty already changes numerical strength, so keep
|
||||
// its stopped countdown and target side aligned with the authoritative payload.
|
||||
rebalanceVmixPenaltyTargets({ force: false, hideUnused: true, includeConfigured: true })
|
||||
if (state.activeHockeyVmixTimerSteps.size) {
|
||||
// Build87 compatibility marker: rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
|
||||
// BUILD95 uses a non-forced pass so a merely prepared penalty cannot
|
||||
// unnecessarily restart an already running vMix countdown.
|
||||
rebalanceVmixPenaltyTargets({ force: false, hideUnused: true })
|
||||
.catch((error) => console.error("Penalty strength rebalance error", error));
|
||||
}
|
||||
}
|
||||
if (previousControl && hockeyTeamStateFlagSignature(previousControl) !== hockeyTeamStateFlagSignature(payload) && hockeyTeamStateScoreboardIsLive()) {
|
||||
hockeySyncTeamStateOverlays({ force: false }).catch((error) => console.error("vMix team-state overlay sync error", error));
|
||||
hockeySyncTeamStateOverlays({ force: true }).catch((error) => console.error("vMix team-state overlay sync error", error));
|
||||
}
|
||||
if (dispatch) {
|
||||
window.dispatchEvent(new CustomEvent("hockey:game-control-updated", {
|
||||
@@ -12004,43 +11993,9 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
||||
});
|
||||
}
|
||||
|
||||
async function hockeyApplyTeamStateOverlayImmediately(key, active) {
|
||||
if (!hockeyTeamStateScoreboardIsLive()) return 0;
|
||||
const setting = hockeyTeamStateSetting(key);
|
||||
const previous = state.hockeyTeamStateOverlayActive.get(key) || null;
|
||||
const commands = [];
|
||||
const shouldShow = Boolean(active) && setting.enabled && Boolean(setting.input);
|
||||
if (shouldShow) {
|
||||
if (previous && (previous.input !== setting.input || previous.overlay !== setting.overlay)) {
|
||||
commands.push({ Function: `OverlayInput${previous.overlay}Out`, Input: previous.input });
|
||||
}
|
||||
if (!previous || previous.input !== setting.input || previous.overlay !== setting.overlay) {
|
||||
commands.push({ Function: `OverlayInput${setting.overlay}In`, Input: setting.input });
|
||||
}
|
||||
state.hockeyTeamStateOverlayActive.set(key, { input: setting.input, overlay: setting.overlay });
|
||||
} else if (previous) {
|
||||
commands.push({ Function: `OverlayInput${previous.overlay}Out`, Input: previous.input });
|
||||
state.hockeyTeamStateOverlayActive.delete(key);
|
||||
}
|
||||
if (commands.length) await sendRuntimeVmixSequence(commands);
|
||||
return commands.length;
|
||||
}
|
||||
|
||||
async function hockeyToggleMatchFlag(key) {
|
||||
const current = Boolean(hockeyMatchFlags()[key]);
|
||||
const next = !current;
|
||||
const live = hockeyTeamStateScoreboardIsLive();
|
||||
if (live) {
|
||||
// BUILD113: delayed penalty / empty net are operator-live controls. Do not wait
|
||||
// for the database round-trip before showing/removing the configured overlay.
|
||||
hockeyApplyTeamStateOverlayImmediately(key, next).catch((error) => console.error("Immediate team-state overlay error", error));
|
||||
}
|
||||
try {
|
||||
return await hockeySetMatchFlags({ [key]: next });
|
||||
} catch (error) {
|
||||
if (live) hockeyApplyTeamStateOverlayImmediately(key, current).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return hockeySetMatchFlags({ [key]: !current });
|
||||
}
|
||||
|
||||
function hockeyPrematchFlagKey(buttonId) {
|
||||
@@ -14830,11 +14785,6 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
||||
} finally {
|
||||
state.hockeyTimerHydrating = false;
|
||||
}
|
||||
// BUILD113: changing/setting the period immediately writes 20:00 / 05:00
|
||||
// (or the configured period value) into vMix. The operator should never have
|
||||
// to put the scorebug on air first just to seed its clock.
|
||||
syncConfiguredScoreboardCountdownsToRuntime()
|
||||
.catch((error) => console.error("Immediate game countdown preload error", error));
|
||||
}
|
||||
if (state.activeTab === "shootout") renderRuntime();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user