поправлены способ передачи таймеров в vMix

This commit is contained in:
2026-08-20 15:33:03 +03:00
parent 8790a1e113
commit cd9476d167
6 changed files with 420 additions and 117 deletions

View File

@@ -11,7 +11,7 @@ from typing import Any
DEFAULT_CONFIG: dict[str, Any] = {
"version": 21,
"version": 22,
"project_name": "Новый интерфейс",
"data_source": "golf",
"canvas": {
@@ -68,12 +68,15 @@ class UIBuilderManager:
except (OSError, json.JSONDecodeError, TypeError):
return deepcopy(DEFAULT_CONFIG)
had_legacy_shortcuts = self._has_legacy_hockey_component_shortcuts(raw)
try:
source_version = int(raw.get("version") or 0) if isinstance(raw, dict) else 0
except (TypeError, ValueError):
source_version = 0
normalized = self._normalize(raw)
if had_legacy_shortcuts:
# Build61 migration: remove old hidden component-level Space/Ctrl+R
# bindings from the persisted draft/published JSON. Shortcut Sequences
# are intentionally untouched, so an explicitly configured Space
# sequence remains available in the visible shortcut editor.
if had_legacy_shortcuts or source_version < 22:
# Build61: remove old hidden component-level Space/Ctrl+R bindings.
# BUILD90: persist the one-time v21 -> v22 native-countdown migration
# so old Text mirror timer steps stop producing per-second SetText traffic.
self.save(normalized, create_backup=False)
return normalized
@@ -159,7 +162,12 @@ class UIBuilderManager:
if not isinstance(config, dict):
return result
result["version"] = 21
source_version_raw = config.get("version", 0)
try:
source_version = int(source_version_raw or 0)
except (TypeError, ValueError):
source_version = 0
result["version"] = 22
result["project_name"] = str(config.get("project_name") or result["project_name"])
result["data_source"] = str(config.get("data_source") or result["data_source"])
@@ -426,8 +434,18 @@ class UIBuilderManager:
"scoreboard_alternate_selected_name": str(step.get("scoreboard_alternate_selected_name") or ""),
"game_timer_action_id": str(step.get("game_timer_action_id") or "hockey_game_timer"),
"hockey_timer_command": str(step.get("hockey_timer_command") or "toggle") if str(step.get("hockey_timer_command") or "toggle") in {"toggle", "start", "pause", "resume"} else "toggle",
"game_vmix_mode": str(step.get("game_vmix_mode") or "text") if str(step.get("game_vmix_mode") or "text") in {"countdown", "text"} else "text",
"penalty_vmix_mode": str(step.get("penalty_vmix_mode") or "text") if str(step.get("penalty_vmix_mode") or "text") in {"countdown", "text"} else "text",
# BUILD90: configs created before v22 used Text mirror as the default,
# which pushed timer text every second. Migrate those hockey timer
# sync steps once to native vMix countdown transport. From v22 onward
# an explicitly selected legacy Text mirror remains available.
"game_vmix_mode": (
"countdown" if source_version < 22 and step_type == "hockey_vmix_timers_start"
else (str(step.get("game_vmix_mode") or "countdown") if str(step.get("game_vmix_mode") or "countdown") in {"countdown", "text"} else "countdown")
),
"penalty_vmix_mode": (
"countdown" if source_version < 22 and step_type == "hockey_vmix_timers_start"
else (str(step.get("penalty_vmix_mode") or "countdown") if str(step.get("penalty_vmix_mode") or "countdown") in {"countdown", "text"} else "countdown")
),
"penalty_display_mode": "all" if str(step.get("penalty_display_mode") or "soonest") == "all" else "soonest",
"game_vmix_input": str(step.get("game_vmix_input") or ""),
"game_vmix_selected_name": str(step.get("game_vmix_selected_name") or ""),

View File

@@ -32,7 +32,7 @@
const state = {
config: {
version: 21,
version: 22,
project_name: "Новый интерфейс",
data_source: "golf",
canvas: {
@@ -80,6 +80,10 @@
modifierShortcutChordUsedKey: false,
modifierShortcutChordFired: false,
runningShortcutSequences: new Set(),
// BUILD90: all runtime vMix requests share one browser-side FIFO. This prevents
// two different shortcuts from interleaving commands while an Agent ACK is pending.
vmixCommandQueue: Promise.resolve(),
vmixCommandQueueDepth: 0,
vmixTimerMirrors: new Map(),
vmixPenaltyMirrors: new Map(),
activeHockeyVmixTimerSteps: new Set(),
@@ -2380,8 +2384,8 @@ function startCustomTooltips() {
scoreboard_alternate_selected_name: String(step.scoreboard_alternate_selected_name || ""),
game_timer_action_id: String(step.game_timer_action_id || "hockey_game_timer"),
hockey_timer_command: ["toggle", "start", "pause", "resume"].includes(String(step.hockey_timer_command || "")) ? String(step.hockey_timer_command) : "toggle",
game_vmix_mode: ["countdown", "text"].includes(String(step.game_vmix_mode || "")) ? String(step.game_vmix_mode) : "text",
penalty_vmix_mode: ["countdown", "text"].includes(String(step.penalty_vmix_mode || "")) ? String(step.penalty_vmix_mode) : "text",
game_vmix_mode: ["countdown", "text"].includes(String(step.game_vmix_mode || "")) ? String(step.game_vmix_mode) : "countdown",
penalty_vmix_mode: ["countdown", "text"].includes(String(step.penalty_vmix_mode || "")) ? String(step.penalty_vmix_mode) : "countdown",
penalty_display_mode: String(step.penalty_display_mode || "soonest") === "all" ? "all" : "soonest",
game_vmix_input: String(step.game_vmix_input || ""),
game_vmix_selected_name: String(step.game_vmix_selected_name || ""),
@@ -2571,7 +2575,7 @@ function startCustomTooltips() {
}
function ensureConfig() {
state.config.version = 21;
state.config.version = 22;
state.config.canvas ||= {};
state.config.canvas.auto_bind_containers = state.config.canvas.auto_bind_containers !== false;
state.config.tabs = Array.isArray(state.config.tabs) && state.config.tabs.length ? state.config.tabs : [{ id: "main", label: "Основное" }];
@@ -2719,7 +2723,7 @@ function startCustomTooltips() {
const factory = templates[name];
if (!factory) return;
const next = factory();
state.config = { version: 21, triggers: [], ...next };
state.config = { version: 22, triggers: [], ...next };
ensureConfig();
state.activeTab = state.config.tabs[0]?.id || "main";
if (state.config.canvas.auto_bind_containers) {
@@ -4504,29 +4508,53 @@ function startCustomTooltips() {
async function sendRuntimeVmixSequence(commands, execution = null) {
const clean = (commands || []).map(compactVmixCommand).filter((command) => command.Function);
if (!clean.length) return { ok: true, applied: 0, results: [] };
const response = await fetch("/api/hockey/vmix/sequence", {
method: "POST",
cache: "no-store",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
commands: clean,
device_id: currentRuntimeVmixDeviceId(),
session_token: currentRuntimeHockeySessionToken(),
sequence_id: String(execution?.sequence_id || ""),
sequence_name: String(execution?.sequence_name || ""),
button_id: String(execution?.button_id || ""),
}),
});
let payload = {};
try { payload = await response.json(); } catch (_) {}
if (!response.ok) {
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
trackRuntimeOverlayCommands(clean, execution);
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
return payload;
const run = async () => {
state.vmixCommandQueueDepth += 1;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 7000);
try {
const response = await fetch("/api/hockey/vmix/sequence", {
method: "POST",
cache: "no-store",
credentials: "same-origin",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
commands: clean,
device_id: currentRuntimeVmixDeviceId(),
session_token: currentRuntimeHockeySessionToken(),
sequence_id: String(execution?.sequence_id || ""),
sequence_name: String(execution?.sequence_name || ""),
button_id: String(execution?.button_id || ""),
}),
});
let payload = {};
try { payload = await response.json(); } catch (_) {}
if (!response.ok) {
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
trackRuntimeOverlayCommands(clean, execution);
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
return payload;
} catch (error) {
if (error?.name === "AbortError") {
throw new Error("vMix/Agent не подтвердил команду за 7 секунд");
}
throw error;
} finally {
clearTimeout(timeoutId);
state.vmixCommandQueueDepth = Math.max(0, state.vmixCommandQueueDepth - 1);
}
};
// Keep the queue alive after a failed command: one timeout must not permanently
// block every shortcut pressed afterwards. No automatic retry is performed because
// toggle/overlay commands are not safely idempotent.
const queued = state.vmixCommandQueue.catch(() => {}).then(run);
state.vmixCommandQueue = queued.catch(() => {});
return queued;
}
function splitVmixInputs(value) {
@@ -4567,6 +4595,36 @@ function startCustomTooltips() {
}
}
async function syncActiveVmixGameCountdown(component, timerState, eventName) {
if (!component?.action_id || eventName === "timer_tick") return false;
const commands = [];
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
const step = hockeyTimerSyncStepById(stepId);
if (!step || step.enabled === false || !step.sync_vmix_game || step.game_vmix_mode !== "countdown") continue;
if (String(step.game_timer_action_id || "hockey_game_timer") !== String(component.action_id)) continue;
const input = String(step.game_vmix_input || "").trim();
const selectedName = String(step.game_vmix_selected_name || "").trim();
if (!input || !selectedName) continue;
const target = { Input: input, SelectedName: selectedName };
if (["timer_start", "timer_restart"].includes(eventName)) {
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
commands.push({ Function: "StartCountdown", ...target });
} else if (eventName === "timer_resume") {
commands.push({ Function: "StartCountdown", ...target });
} else if (eventName === "timer_pause") {
commands.push({ Function: "PauseCountdown", ...target });
} else if (["timer_stop", "timer_finished"].includes(eventName)) {
commands.push({ Function: "StopCountdown", ...target });
} else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) {
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
commands.push({ Function: timerState.running ? "StartCountdown" : "PauseCountdown", ...target });
}
}
if (!commands.length) return false;
await sendRuntimeVmixSequence(commands);
return true;
}
function penaltyMirrorKey(component, event) {
return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`;
@@ -4792,7 +4850,9 @@ function startCustomTooltips() {
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true } = {}) {
const outCommands = [];
const stopCommands = [];
const setCommands = [];
const runCommands = [];
const inCommands = [];
const assignedMirrorKeys = new Set();
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
@@ -4801,7 +4861,7 @@ function startCustomTooltips() {
state.activeHockeyVmixTimerSteps.delete(stepId);
continue;
}
if (step.penalty_vmix_mode !== "text") continue;
const countdownMode = step.penalty_vmix_mode === "countdown";
const displayPlan = penaltyDisplayEntriesByTargetSide(step);
rememberPenaltyAdvantagePlan(displayPlan);
for (const side of ["home", "away"]) {
@@ -4816,32 +4876,50 @@ function startCustomTooltips() {
const entry = entries[index] || null;
if (entry && target.input && target.selected_name) {
const eventKey = penaltyMirrorKey(entry.component, entry.event);
assignedMirrorKeys.add(eventKey);
setVmixPenaltyMirror(entry.component, entry.event, target.input, target.selected_name, {
stepId: step.id,
targetId: target.id,
side: String(entry.side || entry.event?.side || entry.event?.player?.side || side),
overlay: target.overlay,
});
state.vmixPenaltyTargetAssignments.set(assignmentKey, {
eventKey,
input: target.input,
selectedName: target.selected_name,
overlay: target.overlay,
sourceSide: String(entry.side || entry.event?.side || entry.event?.player?.side || ""),
targetSide: side,
});
const value = formatHockeyPenaltyTime(entry.event.remainingMs);
const mirror = state.vmixPenaltyMirrors.get(eventKey);
if (force || !mirror || mirror.lastValue !== value || previous?.eventKey !== eventKey) {
setCommands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: value });
if (mirror) mirror.lastValue = value;
const sourceSide = String(entry.side || entry.event?.side || entry.event?.player?.side || side);
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
const assignmentChanged = !previous
|| previous.eventKey !== eventKey
|| String(previous.input || "") !== String(target.input)
|| String(previous.selectedName || "") !== String(target.selected_name);
if (previous && countdownMode && assignmentChanged && previous.input && String(previous.input) !== String(target.input)) {
stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name });
}
if (countdownMode) {
state.vmixPenaltyMirrors.delete(eventKey);
state.vmixPenaltyTargetAssignments.set(assignmentKey, {
eventKey, input: target.input, selectedName: target.selected_name, overlay, sourceSide, targetSide: side,
mode: "countdown", running: Boolean(entry.event.running),
});
if (force || assignmentChanged) {
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) });
}
if (entry.event.running) {
if (force || assignmentChanged || previous?.running !== true) {
runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
}
} else if (force || assignmentChanged || previous?.running !== false) {
runCommands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
}
} else {
assignedMirrorKeys.add(eventKey);
setVmixPenaltyMirror(entry.component, entry.event, target.input, target.selected_name, {
stepId: step.id, targetId: target.id, side: sourceSide, overlay,
});
state.vmixPenaltyTargetAssignments.set(assignmentKey, {
eventKey, input: target.input, selectedName: target.selected_name, overlay, sourceSide, targetSide: side, mode: "text",
});
const value = formatHockeyPenaltyTime(entry.event.remainingMs);
const mirror = state.vmixPenaltyMirrors.get(eventKey);
if (force || !mirror || mirror.lastValue !== value || previous?.eventKey !== eventKey) {
setCommands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: value });
if (mirror) mirror.lastValue = value;
}
}
// If the scoreboard is already on air and this penalty target was not
// previously assigned, bring the penalty plate on air immediately.
if (hockeyScoreboardIsLive()) {
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
const targetWasVisible = Boolean(previous?.input)
&& String(previous.input) === String(target.input)
&& String(previous.overlay || overlay) === overlay;
@@ -4854,6 +4932,9 @@ function startCustomTooltips() {
}
}
} else {
if (previous?.mode === "countdown" && previous.input) {
stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name });
}
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) {
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
@@ -4864,6 +4945,9 @@ function startCustomTooltips() {
inactiveTargets.forEach((target) => {
const assignmentKey = penaltyTargetAssignmentKey(step, side, target);
const previous = state.vmixPenaltyTargetAssignments.get(assignmentKey);
if (previous?.mode === "countdown" && previous.input) {
stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name });
}
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) {
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
@@ -4877,10 +4961,8 @@ function startCustomTooltips() {
state.vmixPenaltyMirrors.delete(mirrorKey);
}
}
// Always take the old single plate OUT before putting the new side IN.
// HOME and AWAY targets frequently share the same Overlay slot; sending IN
// first and OUT second would remove the newly selected plate.
const commands = [...outCommands, ...setCommands, ...inCommands];
// Old plate OUT/Stop first, then set/start the single current countdown, then IN.
const commands = [...outCommands, ...stopCommands, ...setCommands, ...runCommands, ...inCommands];
if (commands.length) await sendRuntimeVmixSequence(commands);
return commands.length;
}
@@ -4986,6 +5068,7 @@ function startCustomTooltips() {
const action = configuredCommand === "toggle" ? (gameTimerState?.running ? "pause" : "start") : configuredCommand;
const pausing = action === "pause";
const commands = [];
state.activeHockeyVmixTimerSteps.add(step.id);
if (step.sync_vmix_game && step.game_vmix_input) {
if (!gameTimerState) throw new Error(`Основной таймер «${step.game_timer_action_id}» не найден`);
@@ -4994,10 +5077,12 @@ function startCustomTooltips() {
setVmixTimerMirror(step.game_timer_action_id || "hockey_game_timer", step.game_vmix_input, step.game_vmix_selected_name);
commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) });
} else if (pausing) {
commands.push({ Function: "SuspendCountdown", Input: step.game_vmix_input });
commands.push({ Function: "PauseCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
} else if (action === "resume") {
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
} else {
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) });
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input });
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
}
}
@@ -5013,18 +5098,25 @@ function startCustomTooltips() {
const target = targets[index] || null;
if (!target?.input) return;
if (!target.selected_name) throw new Error(`Для таймера удаления ${side === "home" ? "HOME" : "AWAY"} выберите Text / SelectedName`);
const sourceSide = String(event.player?.side || event.side || side);
if (step.penalty_vmix_mode === "text") {
const sourceSide = String(event.player?.side || event.side || side);
setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side: sourceSide, overlay: target.overlay });
state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), {
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side,
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "text",
});
commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) });
} else if (pausing) {
commands.push({ Function: "SuspendCountdown", Input: target.input });
} else {
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
commands.push({ Function: "StartCountdown", Input: target.input });
state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), {
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "countdown", running: !pausing,
});
if (pausing) {
commands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
} else if (action === "resume") {
commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
} else {
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
}
}
});
});
@@ -5033,7 +5125,7 @@ function startCustomTooltips() {
const vmixResult = commands.length ? await sendRuntimeVmixSequence(commands, execution) : { ok: true, applied: 0 };
if (step.start_web_game) {
if (!controlTimer(step.game_timer_action_id || "hockey_game_timer", pausing ? "pause" : (action === "resume" ? "resume" : "start"))) {
if (!controlTimer(step.game_timer_action_id || "hockey_game_timer", pausing ? "pause" : (action === "resume" ? "resume" : "start"), "", { syncVmix: false })) {
throw new Error(`Основной таймер «${step.game_timer_action_id || "hockey_game_timer"}» не найден`);
}
if (step.game_vmix_mode === "text" && gameTimer && gameTimerState) {
@@ -5041,7 +5133,7 @@ function startCustomTooltips() {
}
}
if (step.start_web_penalties) {
penalties.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, pausing ? "pause" : "start"));
penalties.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, pausing ? "pause" : (action === "resume" ? "start" : "start"), "", { syncVmix: false }));
}
if (step.penalty_vmix_mode === "text" && !pausing) {
for (const { component, event } of penalties) {
@@ -5532,7 +5624,10 @@ function applyExternalDataPatch(patch, { render = true } = {}) {
...detail
});
if (eventName === "timer_tick" && state.vmixTimerMirrors.has(component.action_id)) {
// Legacy Text mirror only. Native countdown mode never emits a per-second request.
pushVmixTimerMirror(component, timerState).catch(() => {});
} else if (eventName !== "timer_tick" && !detail.suppressVmixSync) {
syncActiveVmixGameCountdown(component, timerState, eventName).catch((error) => console.error("vMix game countdown sync error", error));
}
if (eventName === "timer_finished") {
fireConfiguredTimerFinishActions("game", { gameActionId: component.action_id, component, timerState });
@@ -5632,7 +5727,7 @@ function applyExternalDataPatch(patch, { render = true } = {}) {
requestAnimationFrame(timerEngineFrame);
}
function controlTimer(actionId, command = "toggle", rawValue = "") {
function controlTimer(actionId, command = "toggle", rawValue = "", options = {}) {
const component = componentByActionId(actionId);
if (!component || !isTimerComponent(component)) return false;
const timerState = ensureTimerState(component);
@@ -5718,7 +5813,7 @@ function applyExternalDataPatch(patch, { render = true } = {}) {
timerState.lastTimestamp = now;
persistTimer(component, timerState, true);
emitTimerEvent(component, eventName, timerState, { command, amount: rawValue });
emitTimerEvent(component, eventName, timerState, { command, amount: rawValue, suppressVmixSync: options.syncVmix === false });
return true;
}
@@ -6741,7 +6836,7 @@ function openTimerQuickEditor(focusActionId = "") {
board.selectedPreset = null;
}
function controlHockeyPenalty(component, eventId, command, rawValue = "") {
function controlHockeyPenalty(component, eventId, command, rawValue = "", options = {}) {
const board = ensureHockeyBoardState(component);
const event = board.penalties.find((item) => item.id === eventId);
if (!event) return false;
@@ -6860,6 +6955,10 @@ function openTimerQuickEditor(focusActionId = "") {
persistHockeyBoard(component, board, true);
refreshHockeyBoardNodes(component);
if (options.syncVmix !== false && state.activeHockeyVmixTimerSteps.size && ["start", "pause", "reset", "set_time"].includes(command)) {
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
.catch((error) => console.error("Penalty countdown state sync error", error));
}
return true;
}
@@ -13327,10 +13426,10 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<div class="shortcut-step-grid vmix-command-grid">
<label>Действие<select data-step-field="hockey_timer_command">${triggerSelectOptions([["toggle","Старт / пауза одной кнопкой"],["start","Только запустить"],["pause","Только пауза"],["resume","Продолжить"]], step.hockey_timer_command)}</select></label>
<label>Основной веб-таймер<select data-step-field="game_timer_action_id">${timerActionOptions(step.game_timer_action_id)}</select></label>
<label>Режим основного таймера в vMix<select data-step-field="game_vmix_mode">${triggerSelectOptions([["text","Text mirror · рекомендуется"],["countdown","Встроенный Countdown vMix"]], step.game_vmix_mode)}</select></label>
<label>Режим основного таймера в vMix<select data-step-field="game_vmix_mode">${triggerSelectOptions([["countdown","Встроенный Countdown vMix · рекомендуется"],["text","Text mirror · совместимость"]], step.game_vmix_mode)}</select></label>
<label>vMix Input основного таймера<select data-step-field="game_vmix_input">${vmixInputOptions(step.game_vmix_input)}</select></label>
<label>Text / SelectedName основного таймера<select data-step-field="game_vmix_selected_name">${vmixTextSelectedNameOptions(step.game_vmix_input, step.game_vmix_selected_name)}</select></label>
<label>Режим таймеров удалений<select data-step-field="penalty_vmix_mode">${triggerSelectOptions([["text","Text mirror · рекомендуется"],["countdown","Встроенный Countdown vMix"]], step.penalty_vmix_mode)}</select></label>
<label>Режим таймеров удалений<select data-step-field="penalty_vmix_mode">${triggerSelectOptions([["countdown","Встроенный Countdown vMix · рекомендуется"],["text","Text mirror · совместимость"]], step.penalty_vmix_mode)}</select></label>
<label>Что показывать при нескольких удалениях<select data-step-field="penalty_display_mode">${triggerSelectOptions([["soonest","Одно ближайшее окончание"],["all","Все удаления по слотам"]], step.penalty_display_mode)}</select></label>
</div>
@@ -13360,7 +13459,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<div class="shortcut-finish-action-list">${timerFinishActionRows(step)}</div>
<div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.</small></div>
<p class="shortcut-step-note">Режим <b>Text mirror</b> рекомендуется: веб-таймер является источником истины и раз в секунду отправляет <code>SetText</code> строго в выбранные <code>Input + SelectedName</code>. Режим <b>Countdown</b> оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление. При сложных/обоюдных удалениях, пока штрафы есть у обеих команд, HOME/AWAY остаются на своих сторонах и режим «играют в большинстве» не включается. Только когда одна сторона полностью очистится, оставшийся таймер переезжает на Input противоположной команды — стороны большинства. Если обе стороны очистились одновременно, дополнительные плашки просто снимаются. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p>
<p class="shortcut-step-note">Режим <b>Countdown vMix</b> рекомендуется и используется по умолчанию: веб отправляет <code>SetCountdown</code> только при установке/коррекции времени и затем <code>StartCountdown</code>; каждую секунду значение больше не передаётся. <b>Text mirror</b> оставлен только как режим совместимости для старых титров. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывается на стороне команды преимущества и отсчитывает ближайшее изменение численного состава; при чистом равном обоюдном удалении плашка не выводится. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p>
</div>`;
} else if (step.type === "delay") {
body.innerHTML = `<div class="shortcut-step-grid"><label>Задержка, мс<input type="number" min="0" max="10000" step="10" data-step-field="milliseconds" value="${Number(step.milliseconds) || 0}"></label></div>`;