поправили механику работы шорткатов

This commit is contained in:
2026-08-24 13:37:50 +03:00
parent e09a1eeff5
commit 7d934a6ee6
4 changed files with 259 additions and 34 deletions

View File

@@ -76,10 +76,18 @@
runtimeResizeObserver: null,
shortcutCapture: null,
pressedShortcutModifiers: new Set(),
// BUILD100: track physical non-modifier keys until keyup. Browser key repeat,
// focus quirks or duplicated keydown events must never enqueue several toggle actions
// for one physical press (Space is especially important for the game clock).
pressedShortcutKeys: new Set(),
modifierShortcutChordModifiers: new Set(),
modifierShortcutChordUsedKey: false,
modifierShortcutChordFired: false,
runningShortcutSequences: new Set(),
// BUILD100: one second physical press while the same sequence is waiting for Agent ACK
// is remembered instead of being silently dropped. Extra impatient presses are
// coalesced, so they cannot build a future queue of Start/Stop toggles.
pendingShortcutSequenceRuns: new Map(),
// 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(),
@@ -2250,6 +2258,15 @@ function startCustomTooltips() {
return key.length === 1 ? key.toUpperCase() : key;
}
function shortcutPhysicalKeyToken(event) {
const modifier = shortcutModifierFromEvent(event);
if (modifier) return `modifier:${modifier}:${String(event.code || event.key || modifier)}`;
const code = String(event.code || "").trim();
if (code) return `key:${code}`;
const key = shortcutKeyFromEvent(event);
return key ? `key:${key}` : "";
}
function shortcutFromKeyboardEvent(event) {
const key = shortcutKeyFromEvent(event);
if (!key) return "";
@@ -4567,7 +4584,11 @@ function startCustomTooltips() {
if (!clean.length) return { ok: true, applied: 0, results: [] };
state.vmixCommandQueueDepth += 1;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 7000);
// BUILD100: runtime vMix delivery is ACKed command-by-command on the server.
// The browser timeout must cover the whole small sequence, otherwise fetch can
// abort while the server is still legitimately delivering later title/timer commands.
const requestTimeoutMs = Math.min(60000, Math.max(20000, 10000 + clean.length * 5000));
const timeoutId = window.setTimeout(() => controller.abort(), requestTimeoutMs);
try {
const response = await fetch("/api/hockey/vmix/sequence", {
method: "POST",
@@ -4590,12 +4611,27 @@ function startCustomTooltips() {
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
trackRuntimeOverlayCommands(clean, execution);
// BUILD53 compatibility marker: trackRuntimeOverlayCommands(clean, execution)
// BUILD100 tracks only ACK-successful commands below, so a failed title cannot
// incorrectly light the ON AIR state.
const resultRows = Array.isArray(payload?.results) ? payload.results : [];
const successfulCommands = clean.filter((_command, index) => {
const row = resultRows[index];
return !row || row.ok !== false;
});
trackRuntimeOverlayCommands(successfulCommands, execution);
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
if (payload?.ok === false) {
const failed = resultRows.filter((row) => row && row.ok === false);
const first = failed[0] || {};
const label = first.function || "vMix";
const reason = first.reason || payload?.error || "команда не выполнена";
throw new Error(`${label}: ${reason}${failed.length > 1 ? ` · ошибок ${failed.length}` : ""}`);
}
return payload;
} catch (error) {
if (error?.name === "AbortError") {
throw new Error("vMix/Agent не подтвердил команду за 7 секунд");
throw new Error(`vMix/Agent не завершил очередь команд за ${Math.round(requestTimeoutMs / 1000)} сек.`);
}
throw error;
} finally {
@@ -5257,7 +5293,15 @@ function startCustomTooltips() {
async function runShortcutSequence(sequenceOrId, meta = {}) {
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
if (!sequence || sequence.enabled === false) return false;
if (state.runningShortcutSequences.has(sequence.id)) return false;
if (state.runningShortcutSequences.has(sequence.id)) {
// Remember at most ONE follow-up action. This makes Start → Stop reliable when
// Agent ACK is still pending, while repeated impatient Space presses cannot
// accumulate five future toggles and flip the timer back and forth later.
if (!state.pendingShortcutSequenceRuns.has(sequence.id)) {
state.pendingShortcutSequenceRuns.set(sequence.id, { ...meta, queued: true });
}
return true;
}
const wasOnAir = shortcutSequenceIsOnAir(sequence.id);
state.runningShortcutSequences.add(sequence.id);
const execution = { sequence_id: String(sequence.id || ""), sequence_name: String(sequence.name || ""), button_id: String(meta.button_id || "") };
@@ -5283,7 +5327,23 @@ function startCustomTooltips() {
else if (String(meta.source || "").startsWith("keyboard")) toast(`Шорткат выполнен: ${sequence.name}`);
return true;
}
for (const step of sequence.steps || []) await runShortcutSequenceStep(sequence, step, execution);
const stepErrors = [];
for (const [stepIndex, step] of (sequence.steps || []).entries()) {
try {
await runShortcutSequenceStep(sequence, step, execution);
} catch (error) {
const functionName = step?.type === "vmix_command" ? String(step.function || "vMix") : String(step?.type || "step");
const message = String(error?.message || error || "Ошибка шага");
stepErrors.push({ index: stepIndex, function: functionName, message });
console.error("UI Builder shortcut step error", { sequence, stepIndex, step, error });
// BUILD100: one stale/broken title must not prevent the remaining title,
// timer and overlay steps from being sent to Agent.
}
}
if (stepErrors.length) {
const first = stepErrors[0];
throw new Error(`шаг ${first.index + 1} (${first.function}): ${first.message}${stepErrors.length > 1 ? ` · всего ошибок ${stepErrors.length}` : ""}`);
}
if (sequence.toggle_all_overlays_on_repeat) {
state.shortcutSequenceOverlayState.set(sequence.id, true);
state.quickPanelOnAirSequences.add(String(sequence.id || ""));
@@ -5303,6 +5363,9 @@ function startCustomTooltips() {
return false;
} finally {
state.runningShortcutSequences.delete(sequence.id);
const nextMeta = state.pendingShortcutSequenceRuns.get(sequence.id) || null;
state.pendingShortcutSequenceRuns.delete(sequence.id);
if (nextMeta) queueMicrotask(() => runShortcutSequence(sequence.id, nextMeta));
}
}
@@ -5336,10 +5399,21 @@ function startCustomTooltips() {
}
function handleConfiguredShortcuts(event) {
const physicalKey = shortcutPhysicalKeyToken(event);
if (physicalKey && state.pressedShortcutKeys.has(physicalKey)) {
// This key already fired a configured shortcut and has not been released yet.
// Swallow browser auto-repeat/default activation until keyup.
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
return true;
}
if (event.repeat) return false;
const combo = shortcutFromKeyboardEvent(event);
if (!combo) return false;
return handleConfiguredShortcutCombo(combo, event, "keyboard");
const handled = handleConfiguredShortcutCombo(combo, event, "keyboard");
if (handled && physicalKey) state.pressedShortcutKeys.add(physicalKey);
return handled;
}
function handleModifierOnlyShortcutRelease(event) {
@@ -14514,6 +14588,8 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
}, true);
window.addEventListener("keyup", (event) => {
const physicalKey = shortcutPhysicalKeyToken(event);
if (physicalKey) state.pressedShortcutKeys.delete(physicalKey);
const modifier = shortcutModifierFromEvent(event);
if (!modifier) return;
@@ -14538,6 +14614,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
}, true);
window.addEventListener("blur", () => {
state.pressedShortcutKeys.clear();
state.pressedShortcutModifiers.clear();
state.modifierShortcutChordModifiers.clear();
state.modifierShortcutChordUsedKey = false;