From cf751a4d49da26631ecfb72d874287f72af0bf2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Thu, 24 Sep 2026 15:19:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B0=D1=83=D0=B7=D0=B0=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF=D0=BE=D0=BA=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=85=D0=BE=D0=B4=D0=B8=D1=88=D1=8C=D1=81=D1=8F=20=D0=B2?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hockey_data/static/tournament-menu.js | 23 ++++- ui_builder/static/app.js | 125 +++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/hockey_data/static/tournament-menu.js b/hockey_data/static/tournament-menu.js index f2d12a1..5894b59 100644 --- a/hockey_data/static/tournament-menu.js +++ b/hockey_data/static/tournament-menu.js @@ -64,6 +64,7 @@ drawerRefreshTimer: null, drawerRefreshBusy: false, operatorSettingsPaused: false, + operatorSettingsPauseGeneration: 0, settingsPauseObserver: null, }; @@ -1706,6 +1707,7 @@ function gameCard(game) { // BUILD152: absolutely no Stat2TV data polling while any operator settings // window is open. ONLINE stays logically enabled and resumes after close. if (operatorSettingsEditingActive()) return null; + const refreshGeneration = operatorDataRefreshGeneration(); state.scheduleOnlineBusy = true; try { const result = await request(scheduleOnlineEndpoint({ @@ -1715,9 +1717,9 @@ function gameCard(game) { }), { method: "POST", timeoutMs: 45000 }); if (!scheduleOnlineContextMatches()) return result; // A settings window may have opened while the HTTP request was in flight. - // Do not rebuild Runtime under the operator in that case; resume will - // perform a fresh discover pass after settings are closed. - if (operatorSettingsEditingActive()) return result; + // Reject any response from the pre-settings generation even if Settings + // has already closed again by the time the request finishes. + if (!operatorDataRefreshStillCurrent(refreshGeneration)) return result; state.scheduleLive = result?.schedule || state.scheduleLive; applyGamesData(); return result; @@ -2243,8 +2245,18 @@ function startScheduleOnlineTimerOnly() { }, 1000); } +function operatorDataRefreshGeneration() { + return Number(state.operatorSettingsPauseGeneration || 0); +} + +function operatorDataRefreshStillCurrent(generation) { + return !operatorSettingsEditingActive() + && Number(generation) === operatorDataRefreshGeneration(); +} + function pauseOperatorDataRefreshes() { if (state.operatorSettingsPaused) return; + state.operatorSettingsPauseGeneration = operatorDataRefreshGeneration() + 1; state.operatorSettingsPaused = true; stopDrawerRefresh(); stopSelectedGamePolling(); @@ -2337,6 +2349,7 @@ async function pollSelectedGame() { return; } + const refreshGeneration = operatorDataRefreshGeneration(); state.gamePollBusy = true; try { const live = await request( @@ -2345,6 +2358,10 @@ async function pollSelectedGame() { + `&language=${encodeURIComponent(state.language)}`, { method: "POST", timeoutMs: 8000 } ); + // BUILD160: the request may have started just before Settings opened. + // Discard that response completely so neither state nor DOM changes during + // the editing session. Resume performs a fresh poll after Settings closes. + if (!operatorDataRefreshStillCurrent(refreshGeneration)) return; const updated = live.game; const previous = state.selectedGame || {}; if (live.changed && updated) { diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 53612d8..00baf44 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -1,5 +1,6 @@ // BUILD159_COUNTDOWN_TRANSITION_FIXES // BUILD155: all hockey game/penalty timers use native vMix Countdown; no per-second SetText mirroring. +// BUILD160: strict settings freeze; background vMix inventory refresh never rebuilds an open settings form. // BUILD153: settings freeze all data-driven DOM rendering and keep dropdown popovers open. // BUILD152: all browser-side hockey data polling pauses while operator settings are open. // BUILD151: Schedule ONLINE is a toggle; one-second live polling uses isolated schedule_live. @@ -15369,6 +15370,118 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { return card; } + function shortcutSequenceStepById(stepId) { + const wanted = String(stepId || "").trim(); + if (!wanted) return null; + for (const sequence of state.config.shortcut_sequences || []) { + const step = (sequence.steps || []).find((item) => String(item?.id || "") === wanted); + if (step) return step; + } + return null; + } + + function shortcutInventorySelectIsBeingUsed(select) { + if (!select) return false; + const shell = select.closest?.(".css-select-shell"); + const trigger = shell?.querySelector?.(".css-select-trigger"); + if (activeControlPopover?.anchor === trigger) return true; + const focused = document.activeElement; + return Boolean(focused && (focused === select || shell?.contains?.(focused))); + } + + function refreshShortcutInventorySelect(select, optionsHtml, value) { + if (!select || shortcutInventorySelectIsBeingUsed(select)) return false; + const selectedValue = String(value || ""); + select.innerHTML = optionsHtml; + select.value = selectedValue; + refreshEnhancedControl(select); + return true; + } + + function refreshShortcutEditorInventoryControls(editor) { + if (!editor?.isConnected) return; + + const status = editor.querySelector("[data-vmix-inventory-status]"); + if (status) status.textContent = shortcutInventoryLabel(); + editor.querySelectorAll(".shortcut-vmix-inventory-note > span").forEach((node) => { + node.textContent = shortcutInventoryLabel(); + }); + + editor.querySelectorAll("[data-step-id]").forEach((card) => { + const step = shortcutSequenceStepById(card.dataset.stepId); + if (!step) return; + + if (step.type === "vmix_command") { + refreshShortcutInventorySelect( + card.querySelector('[data-step-field="input"]'), + vmixInputOptions(step.input), + step.input + ); + refreshShortcutInventorySelect( + card.querySelector('[data-step-field="selected_name"]'), + vmixSelectedNameOptions(step.input, step.selected_name), + step.selected_name + ); + refreshShortcutInventorySelect( + card.querySelector('[data-step-field="scoreboard_alternate_input"]'), + vmixInputOptions(step.scoreboard_alternate_input), + step.scoreboard_alternate_input + ); + refreshShortcutInventorySelect( + card.querySelector('[data-step-field="scoreboard_alternate_selected_name"]'), + vmixSelectedNameOptions(step.scoreboard_alternate_input, step.scoreboard_alternate_selected_name), + step.scoreboard_alternate_selected_name + ); + return; + } + + if (step.type !== "hockey_vmix_timers_start") return; + + refreshShortcutInventorySelect( + card.querySelector('[data-step-field="game_vmix_input"]'), + vmixInputOptions(step.game_vmix_input), + step.game_vmix_input + ); + refreshShortcutInventorySelect( + card.querySelector('[data-step-field="game_vmix_selected_name"]'), + vmixTextSelectedNameOptions(step.game_vmix_input, step.game_vmix_selected_name), + step.game_vmix_selected_name + ); + + const homeTargets = sequencePenaltyTargets(step, "home"); + const awayTargets = sequencePenaltyTargets(step, "away"); + card.querySelectorAll("[data-penalty-target-row]").forEach((row) => { + const targetId = String(row.dataset.penaltyTargetRow || ""); + const side = row.querySelector("[data-penalty-target-side]")?.dataset?.penaltyTargetSide || ""; + const targets = side === "away" ? awayTargets : homeTargets; + const target = targets.find((item) => String(item?.id || "") === targetId); + if (!target) return; + refreshShortcutInventorySelect( + row.querySelector('[data-penalty-target-field="input"]'), + vmixInputOptions(target.input), + target.input + ); + refreshShortcutInventorySelect( + row.querySelector('[data-penalty-target-field="selected_name"]'), + vmixTextSelectedNameOptions(target.input, target.selected_name), + target.selected_name + ); + }); + + const finishActions = normalizeTimerFinishActions(step.timer_finish_actions); + card.querySelectorAll("[data-finish-action-row]").forEach((row) => { + const actionId = String(row.dataset.finishActionRow || ""); + const action = finishActions.find((item) => String(item?.id || "") === actionId); + if (!action) return; + refreshShortcutInventorySelect( + row.querySelector('[data-finish-action-field="input"]'), + vmixInputOptions(action.input), + action.input + ); + }); + }); + } + function showShortcutsEditor() { state.shortcutSequenceOpenId = ""; showSettingsModal("Шорткаты и сценарии", `
@@ -15446,11 +15559,19 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { render({ focusId: sequence.id }); }); editor.querySelector("[data-open-shortcut-reference]").addEventListener("click", showShortcutsReference); - editor.querySelector("[data-refresh-vmix-inventory]").addEventListener("click", async () => { await loadShortcutInventory(); render({ focusId: state.shortcutSequenceOpenId }); }); + editor.querySelector("[data-refresh-vmix-inventory]").addEventListener("click", async () => { + await loadShortcutInventory(); + refreshShortcutEditorInventoryControls(editor); + }); editor.querySelector("[data-save-shortcuts]").addEventListener("click", () => saveConfig()); editor.querySelector("[data-close-shortcuts]").addEventListener("click", closeModal); render(); - loadShortcutInventory({ silent: true }).then(() => render({ focusId: state.shortcutSequenceOpenId })); + loadShortcutInventory({ silent: true }).then(() => { + // BUILD160: do not replace the whole settings DOM after the async vMix + // inventory request completes. Replacing it can close an already-open + // dropdown and visually restore defaults while the operator is editing. + refreshShortcutEditorInventoryControls(editor); + }); } function shortcutReferenceItems() {