This commit is contained in:
2026-08-24 17:39:28 +03:00
parent f1605470ff
commit 1fbe845b30
3 changed files with 131 additions and 19 deletions

View File

@@ -4196,6 +4196,23 @@ function startCustomTooltips() {
];
}
function vmixGameCountdownControlCommands(input, selectedName, millisecondsProvider, action = "start") {
// BUILD108: the native vMix game clock is seeded only on an explicit time change.
// Space must never re-seed the countdown: Start/Resume only starts it and Pause/Stop
// only pauses it. This keeps the already-running native vMix countdown continuous
// and removes jumps caused by writing the browser value during a pause transition.
const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() };
if (!target.Input || !target.SelectedName) return [];
if (action === "pause" || action === "stop") {
return [{ Function: "PauseCountdown", ...target }];
}
if (action === "set") {
const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider;
return [{ Function: "ChangeCountdown", ...target, Value: vmixCountdownValue(milliseconds) }];
}
return [{ Function: "StartCountdown", ...target }];
}
function buildShortcutRuntimeContext(sequence = null) {
const timers = {};
state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => {
@@ -4754,25 +4771,50 @@ function startCustomTooltips() {
async function syncActiveVmixGameCountdown(component, timerState, eventName) {
if (!component?.action_id || eventName === "timer_tick") return false;
const explicitSeedEvent = ["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time", "timer_restart"].includes(eventName);
const candidateSteps = [];
if (explicitSeedEvent) {
// BUILD108: an explicit time edit must seed the configured vMix game clock even
// before Space has ever been pressed. This is the moment where 20:00 / 05:00
// (or any manual value) is transferred to vMix.
(state.config.shortcut_sequences || []).forEach((sequence) => {
if (sequence?.enabled === false) return;
(sequence.steps || []).forEach((step) => {
if (step?.enabled === false || step?.type !== "hockey_vmix_timers_start") return;
candidateSteps.push(step);
});
});
} else {
Array.from(state.activeHockeyVmixTimerSteps).forEach((stepId) => {
const step = hockeyTimerSyncStepById(stepId);
if (step) candidateSteps.push(step);
});
}
const commands = [];
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
const step = hockeyTimerSyncStepById(stepId);
const seenTargets = new Set();
for (const step of candidateSteps) {
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 targetKey = `${input}\u0000${selectedName}`;
if (seenTargets.has(targetKey)) continue;
seenTargets.add(targetKey);
const valueProvider = () => timerState.currentMs;
if (["timer_start", "timer_restart", "timer_resume"].includes(eventName)) {
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "start"));
if (["timer_start", "timer_resume"].includes(eventName)) {
commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "start"));
} else if (eventName === "timer_restart") {
commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "set"));
commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "start"));
} else if (eventName === "timer_pause") {
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "pause"));
commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "pause"));
} else if (["timer_stop", "timer_finished"].includes(eventName)) {
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop"));
commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "stop"));
} else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) {
commands.push(...vmixCountdownSyncCommands(
input, selectedName, valueProvider, timerState.running ? "start" : "set"
));
commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "set"));
}
}
if (!commands.length) return false;
@@ -4805,12 +4847,13 @@ function startCustomTooltips() {
async function syncConfiguredScoreboardCountdownsToRuntime() {
const commands = [];
configuredHockeyVmixGameCountdownTargets().forEach(({ input, selectedName, timerState }) => {
commands.push(...vmixCountdownSyncCommands(
input,
selectedName,
() => timerState.currentMs,
timerState.running ? "start" : "set"
));
// F1 only seeds a stopped/paused game clock. If it is already running,
// do not touch its native vMix time at all.
if (!timerState.running) {
commands.push(...vmixGameCountdownControlCommands(
input, selectedName, () => timerState.currentMs, "set"
));
}
});
if (!commands.length) return { ok: true, applied: 0, requested: 0 };
// Await only WebSocket dispatch (never vMix ACK) so the countdown reseed reaches the
@@ -5335,11 +5378,14 @@ 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(...vmixCountdownSyncCommands(
// BUILD108 Space pause: native pause only, absolutely no time writeback.
commands.push(...vmixGameCountdownControlCommands(
step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "pause"
));
} else {
commands.push(...vmixCountdownSyncCommands(
// BUILD108 Space start/resume: native start only. The countdown was seeded
// when the operator set/reset the period time (or by F1 before first start).
commands.push(...vmixGameCountdownControlCommands(
step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "start"
));
}
@@ -13921,7 +13967,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>Countdown vMix</b>: веб-таймер является источником времени. Перед Start/Resume vMix выполняет <code>ChangeCountdown(время Runtime) → StartCountdown</code>; при Pause/Stop выполняет <code>PauseCountdown → ChangeCountdown(время Runtime)</code>. <code>StopCountdown</code> в обычном управлении не используется, потому что он сбрасывает countdown. Команды идут в Agent без ACK отдельными кадрами строго по порядку. Каждую секунду значение в vMix не передаётся. <b>Text mirror</b> оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.</p>
<p class="shortcut-step-note">Режим <b>Countdown vMix</b>: время передаётся в vMix только при явной установке/сбросе (например, 20:00 или 05:00). После этого Space не синхронизирует значение: Start/Resume отправляет только <code>StartCountdown</code>, Pause/Stop — только <code>PauseCountdown</code>. Это исключает скачки времени при паузе. <code>StopCountdown</code> в обычном управлении не используется. Команды идут в Agent без ACK. <b>Text mirror</b> оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.</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>`;