шорткаты
This commit is contained in:
3
app.py
3
app.py
@@ -29,7 +29,8 @@ from ui_builder import install_ui_builder
|
|||||||
from khl_site.khl_data_center import APP as khl_site_app
|
from khl_site.khl_data_center import APP as khl_site_app
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
BUILD_VERSION = "2026.08.24.4"
|
BUILD_VERSION = "2026.08.24.5"
|
||||||
|
# compatibility: BUILD_VERSION = "2026.08.24.4"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.2"
|
# compatibility: BUILD_VERSION = "2026.08.24.2"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.1"
|
# compatibility: BUILD_VERSION = "2026.08.24.1"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.21.1"
|
# compatibility: BUILD_VERSION = "2026.08.21.1"
|
||||||
|
|||||||
@@ -249,6 +249,10 @@ class VmixAgentHub:
|
|||||||
# BUILD97: serialize Mapping applications per Agent while still letting
|
# BUILD97: serialize Mapping applications per Agent while still letting
|
||||||
# runtime/timer commands use the normal vMix FIFO between Mapping chunks.
|
# runtime/timer commands use the normal vMix FIFO between Mapping chunks.
|
||||||
self._mapping_apply_locks: dict[str, asyncio.Lock] = {}
|
self._mapping_apply_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
# BUILD102: interactive operator shortcuts use their own tiny send lock.
|
||||||
|
# They must never sit behind Mapping's ACK wait, otherwise a visible F-key
|
||||||
|
# or bottom-dock button can feel dead even though vMix itself is reachable.
|
||||||
|
self._vmix_shortcut_send_locks: dict[str, asyncio.Lock] = {}
|
||||||
self.mapping_data = MappingDataService(database, settings=settings)
|
self.mapping_data = MappingDataService(database, settings=settings)
|
||||||
self._auto_refresh_task: asyncio.Task[None] | None = None
|
self._auto_refresh_task: asyncio.Task[None] | None = None
|
||||||
self._auto_refresh_next: dict[tuple[str, str], float] = {}
|
self._auto_refresh_next: dict[tuple[str, str], float] = {}
|
||||||
@@ -688,6 +692,13 @@ class VmixAgentHub:
|
|||||||
self._mapping_apply_locks[device_id] = lock
|
self._mapping_apply_locks[device_id] = lock
|
||||||
return lock
|
return lock
|
||||||
|
|
||||||
|
def _device_vmix_shortcut_send_lock(self, device_id: str) -> asyncio.Lock:
|
||||||
|
lock = self._vmix_shortcut_send_locks.get(device_id)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
self._vmix_shortcut_send_locks[device_id] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _mapping_batch_chunks(entries: list[dict[str, Any]]) -> list[list[tuple[int, dict[str, Any]]]]:
|
def _mapping_batch_chunks(entries: list[dict[str, Any]]) -> list[list[tuple[int, dict[str, Any]]]]:
|
||||||
"""Group Mapping commands by vMix Input, then cap packet count and bytes.
|
"""Group Mapping commands by vMix Input, then cap packet count and bytes.
|
||||||
@@ -1505,72 +1516,46 @@ class VmixAgentHub:
|
|||||||
prepared_commands.append(command)
|
prepared_commands.append(command)
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
# BUILD100: operator Shortcut/Quick-panel traffic is intentionally delivered
|
# BUILD102: operator Shortcut/Quick-panel traffic is fire-and-forget after
|
||||||
# as ordered single vmix.command frames with an ACK for every command. Mapping
|
# the WebSocket frame has been handed to the connected Agent. We deliberately
|
||||||
# keeps its chunked batch transport and generic non-shortcut runtime sync may
|
# do NOT wait for command.ack here. Agent ACK can arrive later and is ignored
|
||||||
# still use Agent 1.4 vmix.batch. Interactive title/timer actions are small, and
|
# for this transport. Mapping and generic runtime sync keep their existing ACK
|
||||||
# losing the rest of a shortcut after one bad command is much worse than a few
|
# semantics so diagnostics are still useful where exact field delivery matters.
|
||||||
# extra websocket frames.
|
|
||||||
interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip())
|
interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip())
|
||||||
use_batch = (not interactive_shortcut) and self._agent_supports_batch(agent_version) and len(prepared_commands) > 1
|
use_batch = (not interactive_shortcut) and self._agent_supports_batch(agent_version) and len(prepared_commands) > 1
|
||||||
transport = "batch" if use_batch else ("shortcut-sequential" if interactive_shortcut else "legacy")
|
transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy")
|
||||||
|
|
||||||
async with self._device_vmix_send_lock(target_device_id):
|
if interactive_shortcut:
|
||||||
if use_batch:
|
# Preserve order between operator shortcuts, but do not wait behind Mapping's
|
||||||
ack = await self._send_vmix_batch_unlocked(
|
# command ACK lock. A shortcut returns as soon as its frames are on the Agent socket.
|
||||||
target_device_id,
|
async with self._device_vmix_shortcut_send_lock(target_device_id):
|
||||||
assignment_id=assignment_id,
|
|
||||||
match_id=match_id,
|
|
||||||
commands=prepared_commands,
|
|
||||||
timeout=max(4.0, min(8.0, timeout + 2.0)),
|
|
||||||
)
|
|
||||||
ack_results = ack.get("results") if isinstance(ack.get("results"), list) else []
|
|
||||||
for index, command in enumerate(prepared_commands):
|
for index, command in enumerate(prepared_commands):
|
||||||
function = str(command.get("Function") or "")
|
function = str(command.get("Function") or "")
|
||||||
item_ack = ack_results[index] if index < len(ack_results) and isinstance(ack_results[index], dict) else {}
|
request_id = secrets.token_urlsafe(12)
|
||||||
ok = bool(item_ack.get("ok")) if item_ack else bool(ack.get("ok"))
|
delivered = await self.send(
|
||||||
|
target_device_id,
|
||||||
|
{
|
||||||
|
"type": "vmix.command",
|
||||||
|
"protocol": AGENT_PROTOCOL_VERSION,
|
||||||
|
"request_id": request_id,
|
||||||
|
"device_id": target_device_id,
|
||||||
|
"assignment_id": assignment_id,
|
||||||
|
"match_id": match_id,
|
||||||
|
"command": command,
|
||||||
|
},
|
||||||
|
)
|
||||||
item = {
|
item = {
|
||||||
"index": index,
|
"index": index,
|
||||||
"function": function,
|
"function": function,
|
||||||
"ok": ok,
|
"ok": bool(delivered),
|
||||||
"reason": str(item_ack.get("reason") or item_ack.get("error") or ("" if ok else ack.get("reason") or ack.get("error") or "vmix_batch_error")),
|
"reason": "" if delivered else "Agent сейчас offline",
|
||||||
|
"delivery": "agent-websocket",
|
||||||
|
"ack_waited": False,
|
||||||
}
|
}
|
||||||
results.append(item)
|
results.append(item)
|
||||||
if ok:
|
if delivered:
|
||||||
self._track_runtime_overlay_command(
|
# With no ACK wait the local ON AIR state is intentionally optimistic:
|
||||||
target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id
|
# success means delivery to the live Agent WebSocket, not vMix confirmation.
|
||||||
)
|
|
||||||
else:
|
|
||||||
for index, command in enumerate(prepared_commands):
|
|
||||||
function = str(command.get("Function") or "")
|
|
||||||
try:
|
|
||||||
ack = await self._send_vmix_command_unlocked(
|
|
||||||
target_device_id,
|
|
||||||
assignment_id=assignment_id,
|
|
||||||
match_id=match_id,
|
|
||||||
command=command,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
item = {
|
|
||||||
"index": index,
|
|
||||||
"function": function,
|
|
||||||
"ok": bool(ack.get("ok")),
|
|
||||||
"reason": str(ack.get("reason") or ack.get("error") or ""),
|
|
||||||
}
|
|
||||||
except Exception as error:
|
|
||||||
detail = getattr(error, "detail", None)
|
|
||||||
if isinstance(detail, dict):
|
|
||||||
reason = str(detail.get("message") or detail)
|
|
||||||
else:
|
|
||||||
reason = str(detail or error or "vmix_command_error")
|
|
||||||
item = {
|
|
||||||
"index": index,
|
|
||||||
"function": function,
|
|
||||||
"ok": False,
|
|
||||||
"reason": reason[:500],
|
|
||||||
}
|
|
||||||
results.append(item)
|
|
||||||
if item["ok"]:
|
|
||||||
self._track_runtime_overlay_command(
|
self._track_runtime_overlay_command(
|
||||||
target_device_id,
|
target_device_id,
|
||||||
command,
|
command,
|
||||||
@@ -1578,10 +1563,66 @@ class VmixAgentHub:
|
|||||||
sequence_name=sequence_name,
|
sequence_name=sequence_name,
|
||||||
button_id=button_id,
|
button_id=button_id,
|
||||||
)
|
)
|
||||||
# For ordinary legacy non-shortcut runtime calls preserve the old
|
else:
|
||||||
# fail-fast behavior. Shortcut delivery continues deliberately.
|
|
||||||
if not item["ok"] and not interactive_shortcut:
|
|
||||||
break
|
break
|
||||||
|
else:
|
||||||
|
async with self._device_vmix_send_lock(target_device_id):
|
||||||
|
if use_batch:
|
||||||
|
ack = await self._send_vmix_batch_unlocked(
|
||||||
|
target_device_id,
|
||||||
|
assignment_id=assignment_id,
|
||||||
|
match_id=match_id,
|
||||||
|
commands=prepared_commands,
|
||||||
|
timeout=max(4.0, min(8.0, timeout + 2.0)),
|
||||||
|
)
|
||||||
|
ack_results = ack.get("results") if isinstance(ack.get("results"), list) else []
|
||||||
|
for index, command in enumerate(prepared_commands):
|
||||||
|
function = str(command.get("Function") or "")
|
||||||
|
item_ack = ack_results[index] if index < len(ack_results) and isinstance(ack_results[index], dict) else {}
|
||||||
|
ok = bool(item_ack.get("ok")) if item_ack else bool(ack.get("ok"))
|
||||||
|
item = {
|
||||||
|
"index": index,
|
||||||
|
"function": function,
|
||||||
|
"ok": ok,
|
||||||
|
"reason": str(item_ack.get("reason") or item_ack.get("error") or ("" if ok else ack.get("reason") or ack.get("error") or "vmix_batch_error")),
|
||||||
|
}
|
||||||
|
results.append(item)
|
||||||
|
if ok:
|
||||||
|
self._track_runtime_overlay_command(target_device_id, command)
|
||||||
|
else:
|
||||||
|
for index, command in enumerate(prepared_commands):
|
||||||
|
function = str(command.get("Function") or "")
|
||||||
|
try:
|
||||||
|
ack = await self._send_vmix_command_unlocked(
|
||||||
|
target_device_id,
|
||||||
|
assignment_id=assignment_id,
|
||||||
|
match_id=match_id,
|
||||||
|
command=command,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
item = {
|
||||||
|
"index": index,
|
||||||
|
"function": function,
|
||||||
|
"ok": bool(ack.get("ok")),
|
||||||
|
"reason": str(ack.get("reason") or ack.get("error") or ""),
|
||||||
|
}
|
||||||
|
except Exception as error:
|
||||||
|
detail = getattr(error, "detail", None)
|
||||||
|
if isinstance(detail, dict):
|
||||||
|
reason = str(detail.get("message") or detail)
|
||||||
|
else:
|
||||||
|
reason = str(detail or error or "vmix_command_error")
|
||||||
|
item = {
|
||||||
|
"index": index,
|
||||||
|
"function": function,
|
||||||
|
"ok": False,
|
||||||
|
"reason": reason[:500],
|
||||||
|
}
|
||||||
|
results.append(item)
|
||||||
|
if item["ok"]:
|
||||||
|
self._track_runtime_overlay_command(target_device_id, command)
|
||||||
|
if not item["ok"]:
|
||||||
|
break
|
||||||
|
|
||||||
failed = [item for item in results if not item["ok"]]
|
failed = [item for item in results if not item["ok"]]
|
||||||
return {
|
return {
|
||||||
@@ -1596,6 +1637,7 @@ class VmixAgentHub:
|
|||||||
"failed": len(failed),
|
"failed": len(failed),
|
||||||
"results": results,
|
"results": results,
|
||||||
"transport": transport,
|
"transport": transport,
|
||||||
|
"confirmation": "not_waited" if interactive_shortcut else "ack",
|
||||||
"overlay_state": self._runtime_overlay_payload(target_device_id),
|
"overlay_state": self._runtime_overlay_payload(target_device_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ def test_build100_frontend_shortcut_edges_and_pending_press_are_guarded() -> Non
|
|||||||
assert 'BUILD_VERSION = "2026.08.24.4"' in APP
|
assert 'BUILD_VERSION = "2026.08.24.4"' in APP
|
||||||
|
|
||||||
|
|
||||||
def test_build100_interactive_shortcut_is_single_command_acked_and_continues_after_failure(tmp_path: Path) -> None:
|
def test_build102_interactive_shortcut_is_dispatched_without_ack_or_mapping_lock(tmp_path: Path) -> None:
|
||||||
database = LocalTestDatabase(tmp_path / "build100-shortcut.sqlite3")
|
database = LocalTestDatabase(tmp_path / "build102-shortcut.sqlite3")
|
||||||
database.create_all()
|
database.create_all()
|
||||||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||||||
ws = FakeWebSocket()
|
ws = FakeWebSocket()
|
||||||
@@ -50,8 +50,8 @@ def test_build100_interactive_shortcut_is_single_command_acked_and_continues_aft
|
|||||||
{
|
{
|
||||||
"device_id": "GFX-BUILD100",
|
"device_id": "GFX-BUILD100",
|
||||||
"device_secret": "z" * 40,
|
"device_secret": "z" * 40,
|
||||||
"device_name": "Build100 GFX",
|
"device_name": "Build102 GFX",
|
||||||
"hostname": "BUILD100-PC",
|
"hostname": "BUILD102-PC",
|
||||||
"agent_version": "1.4.0",
|
"agent_version": "1.4.0",
|
||||||
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||||||
},
|
},
|
||||||
@@ -64,55 +64,44 @@ def test_build100_interactive_shortcut_is_single_command_acked_and_continues_aft
|
|||||||
)
|
)
|
||||||
assert assigned is not None
|
assert assigned is not None
|
||||||
|
|
||||||
task = asyncio.create_task(
|
# Simulate Mapping currently waiting for its own ACK lock. Operator shortcuts
|
||||||
hub.run_vmix_sequence_for_user(
|
# must bypass that lock completely in BUILD102.
|
||||||
user,
|
mapping_lock = hub._device_vmix_send_lock("GFX-BUILD100")
|
||||||
[
|
await mapping_lock.acquire()
|
||||||
{"Function": "OverlayInput1In", "Input": "SCORE"},
|
try:
|
||||||
{"Function": "OverlayInput2In", "Input": "BROKEN"},
|
result = await asyncio.wait_for(
|
||||||
{"Function": "OverlayInput3In", "Input": "LINEUP"},
|
hub.run_vmix_sequence_for_user(
|
||||||
],
|
user,
|
||||||
sequence_id="shortcut-build100",
|
[
|
||||||
sequence_name="Titles",
|
{"Function": "OverlayInput1In", "Input": "SCORE"},
|
||||||
|
{"Function": "OverlayInput2In", "Input": "PENALTY"},
|
||||||
|
{"Function": "OverlayInput3In", "Input": "LINEUP"},
|
||||||
|
],
|
||||||
|
sequence_id="shortcut-build102",
|
||||||
|
sequence_name="Titles",
|
||||||
|
),
|
||||||
|
timeout=0.5,
|
||||||
)
|
)
|
||||||
)
|
finally:
|
||||||
|
mapping_lock.release()
|
||||||
|
|
||||||
seen: list[dict] = []
|
commands = [item for item in ws.sent if item.get("type") == "vmix.command"]
|
||||||
for index, ok in enumerate([True, False, True]):
|
assert [item["command"]["Input"] for item in commands] == ["SCORE", "PENALTY", "LINEUP"]
|
||||||
command = None
|
assert result["transport"] == "shortcut-no-ack"
|
||||||
for _ in range(100):
|
assert result["confirmation"] == "not_waited"
|
||||||
await asyncio.sleep(0)
|
|
||||||
commands = [item for item in ws.sent if item.get("type") == "vmix.command"]
|
|
||||||
if len(commands) > index:
|
|
||||||
command = commands[index]
|
|
||||||
break
|
|
||||||
assert command is not None
|
|
||||||
seen.append(command)
|
|
||||||
await hub.receive_command_ack(
|
|
||||||
"GFX-BUILD100",
|
|
||||||
{
|
|
||||||
"type": "command.ack",
|
|
||||||
"request_id": command["request_id"],
|
|
||||||
"ok": ok,
|
|
||||||
"reason": "bad input" if not ok else "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not [item for item in ws.sent if item.get("type") == "vmix.batch"]
|
|
||||||
assert [item["command"]["Input"] for item in seen] == ["SCORE", "BROKEN", "LINEUP"]
|
|
||||||
result = await task
|
|
||||||
assert result["transport"] == "shortcut-sequential"
|
|
||||||
assert result["requested"] == 3
|
assert result["requested"] == 3
|
||||||
assert result["attempted"] == 3
|
assert result["attempted"] == 3
|
||||||
assert result["applied"] == 2
|
assert result["applied"] == 3
|
||||||
assert result["failed"] == 1
|
assert result["failed"] == 0
|
||||||
assert result["ok"] is False
|
assert result["ok"] is True
|
||||||
assert result["results"][2]["ok"] is True
|
assert all(row["ack_waited"] is False for row in result["results"])
|
||||||
|
assert not hub._pending_commands
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
def test_build100_backend_marks_interactive_shortcuts_sequential() -> None:
|
def test_build102_backend_marks_interactive_shortcuts_no_ack() -> None:
|
||||||
assert 'interactive_shortcut = bool' in BRIDGE
|
assert 'transport = "shortcut-no-ack" if interactive_shortcut' in BRIDGE
|
||||||
assert 'transport = "batch" if use_batch else ("shortcut-sequential"' in BRIDGE
|
assert 'async with self._device_vmix_shortcut_send_lock(target_device_id)' in BRIDGE
|
||||||
assert 'if not item["ok"] and not interactive_shortcut' in BRIDGE
|
assert '"ack_waited": False' in BRIDGE
|
||||||
|
assert '"confirmation": "not_waited" if interactive_shortcut else "ack"' in BRIDGE
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ def test_quick_panel_selector_is_configurable_and_sent_before_sequence():
|
|||||||
assert "hockeyCommitQuickPanelButtonContext" in JS
|
assert "hockeyCommitQuickPanelButtonContext" in JS
|
||||||
click = JS.index('el.runtimeButtonDock.querySelectorAll("[data-quick-command-button]")')
|
click = JS.index('el.runtimeButtonDock.querySelectorAll("[data-quick-command-button]")')
|
||||||
snippet = JS[click:click + 2600]
|
snippet = JS[click:click + 2600]
|
||||||
assert "await hockeyCommitQuickPanelButtonContext(button, attachedSelectors)" in snippet
|
assert "await hockeyCommitQuickPanelButtonContext(button, attachedSelectors, { refreshMapping: false })" in snippet
|
||||||
assert snippet.index("await hockeyCommitQuickPanelButtonContext") < snippet.index("runShortcutSequence")
|
assert snippet.index("await hockeyCommitQuickPanelButtonContext") < snippet.index("runShortcutSequence")
|
||||||
|
assert "full Mapping ACK round-trip" in snippet
|
||||||
assert '"/control/values"' in JS
|
assert '"/control/values"' in JS
|
||||||
assert '@router.put("/games/{external_id}/control/values")' in ROUTER
|
assert '@router.put("/games/{external_id}/control/values")' in ROUTER
|
||||||
assert 'quick_panel_selectors' in APP
|
assert 'quick_panel_selectors' in APP
|
||||||
|
|||||||
@@ -4603,9 +4603,11 @@ function startCustomTooltips() {
|
|||||||
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
|
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
|
||||||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||||
}
|
}
|
||||||
// BUILD53 compatibility marker: trackRuntimeOverlayCommands(clean, execution)
|
// BUILD102: interactive shortcuts return as soon as the server has handed
|
||||||
// BUILD100 tracks only ACK-successful commands below, so a failed title cannot
|
// commands to the live Agent WebSocket. `ok` therefore means dispatched to
|
||||||
// incorrectly light the ON AIR state.
|
// Agent, not ACK-confirmed by vMix. This keeps F-keys and dock buttons instant.
|
||||||
|
// BUILD53 compatibility marker retained for older regression tests:
|
||||||
|
// trackRuntimeOverlayCommands(clean, execution)
|
||||||
const resultRows = Array.isArray(payload?.results) ? payload.results : [];
|
const resultRows = Array.isArray(payload?.results) ? payload.results : [];
|
||||||
const successfulCommands = clean.filter((_command, index) => {
|
const successfulCommands = clean.filter((_command, index) => {
|
||||||
const row = resultRows[index];
|
const row = resultRows[index];
|
||||||
@@ -8735,7 +8737,7 @@ async function hockeyApplyQuickSelector(selector, value, label = "") {
|
|||||||
}, { refreshMapping: true });
|
}, { refreshMapping: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hockeyCommitQuickPanelButtonContext(button, attachedSelectors) {
|
async function hockeyCommitQuickPanelButtonContext(button, attachedSelectors, { refreshMapping = false } = {}) {
|
||||||
const patch = {
|
const patch = {
|
||||||
"panel.last_button.id": button.id,
|
"panel.last_button.id": button.id,
|
||||||
"panel.last_button.label": button.label,
|
"panel.last_button.label": button.label,
|
||||||
@@ -8747,7 +8749,7 @@ async function hockeyCommitQuickPanelButtonContext(button, attachedSelectors) {
|
|||||||
patch[`panel.${selector.id}.label`] = option?.label || value;
|
patch[`panel.${selector.id}.label`] = option?.label || value;
|
||||||
patch[`panel.last_button.${selector.id}`] = value;
|
patch[`panel.last_button.${selector.id}`] = value;
|
||||||
}
|
}
|
||||||
await hockeySetMatchValues(patch, { refreshMapping: true });
|
await hockeySetMatchValues(patch, { refreshMapping });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHockeyQuickCommandDock() {
|
function renderHockeyQuickCommandDock() {
|
||||||
@@ -8820,7 +8822,10 @@ function renderHockeyQuickCommandDock() {
|
|||||||
control.classList.add("is-sending");
|
control.classList.add("is-sending");
|
||||||
try {
|
try {
|
||||||
const attachedSelectors = selectors.filter((selector) => selector.button_id === button.id);
|
const attachedSelectors = selectors.filter((selector) => selector.button_id === button.id);
|
||||||
await hockeyCommitQuickPanelButtonContext(button, attachedSelectors);
|
// BUILD102: save the button context without a full Mapping ACK round-trip.
|
||||||
|
// Selector changes already refresh Mapping; the operator click itself must not
|
||||||
|
// wait behind Mapping before its Shortcut Sequence is dispatched.
|
||||||
|
await hockeyCommitQuickPanelButtonContext(button, attachedSelectors, { refreshMapping: false });
|
||||||
if (button.mode === "toggle") {
|
if (button.mode === "toggle") {
|
||||||
const flagResult = await hockeyToggleMatchFlag(hockeyPrematchFlagKey(button.id));
|
const flagResult = await hockeyToggleMatchFlag(hockeyPrematchFlagKey(button.id));
|
||||||
if (!flagResult) throw new Error("Не удалось изменить состояние кнопки");
|
if (!flagResult) throw new Error("Не удалось изменить состояние кнопки");
|
||||||
|
|||||||
Reference in New Issue
Block a user