поправили механику работы шорткатов
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user