поправлены способ передачи таймеров в vMix
This commit is contained in:
@@ -238,6 +238,9 @@ class VmixAgentHub:
|
|||||||
self._live: dict[str, LiveAgent] = {}
|
self._live: dict[str, LiveAgent] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._pending_commands: dict[str, tuple[str, asyncio.Future[dict[str, Any]]]] = {}
|
self._pending_commands: dict[str, tuple[str, asyncio.Future[dict[str, Any]]]] = {}
|
||||||
|
# BUILD90: one FIFO lock per Agent. Shortcut batches, Mapping refreshes and
|
||||||
|
# timer control commands can no longer interleave on the same vMix instance.
|
||||||
|
self._vmix_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] = {}
|
||||||
@@ -663,6 +666,13 @@ class VmixAgentHub:
|
|||||||
self._live.pop(device_id, None)
|
self._live.pop(device_id, None)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _device_vmix_send_lock(self, device_id: str) -> asyncio.Lock:
|
||||||
|
lock = self._vmix_send_locks.get(device_id)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
self._vmix_send_locks[device_id] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
async def receive_command_ack(self, device_id: str, message: dict[str, Any]) -> None:
|
async def receive_command_ack(self, device_id: str, message: dict[str, Any]) -> None:
|
||||||
request_id = str(message.get("request_id") or "").strip()
|
request_id = str(message.get("request_id") or "").strip()
|
||||||
if not request_id:
|
if not request_id:
|
||||||
@@ -677,7 +687,7 @@ class VmixAgentHub:
|
|||||||
payload["device_id"] = device_id
|
payload["device_id"] = device_id
|
||||||
future.set_result(payload)
|
future.set_result(payload)
|
||||||
|
|
||||||
async def send_vmix_command(
|
async def _send_vmix_command_unlocked(
|
||||||
self,
|
self,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
*,
|
*,
|
||||||
@@ -717,7 +727,21 @@ class VmixAgentHub:
|
|||||||
if not pending_future.done():
|
if not pending_future.done():
|
||||||
pending_future.cancel()
|
pending_future.cancel()
|
||||||
|
|
||||||
async def send_vmix_batch(
|
async def send_vmix_command(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
*,
|
||||||
|
assignment_id: str,
|
||||||
|
match_id: str,
|
||||||
|
command: dict[str, Any],
|
||||||
|
timeout: float = 5.0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
async with self._device_vmix_send_lock(device_id):
|
||||||
|
return await self._send_vmix_command_unlocked(
|
||||||
|
device_id, assignment_id=assignment_id, match_id=match_id, command=command, timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send_vmix_batch_unlocked(
|
||||||
self,
|
self,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
*,
|
*,
|
||||||
@@ -757,6 +781,20 @@ class VmixAgentHub:
|
|||||||
if not pending_future.done():
|
if not pending_future.done():
|
||||||
pending_future.cancel()
|
pending_future.cancel()
|
||||||
|
|
||||||
|
async def send_vmix_batch(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
*,
|
||||||
|
assignment_id: str,
|
||||||
|
match_id: str,
|
||||||
|
commands: list[dict[str, Any]],
|
||||||
|
timeout: float = 8.0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
async with self._device_vmix_send_lock(device_id):
|
||||||
|
return await self._send_vmix_batch_unlocked(
|
||||||
|
device_id, assignment_id=assignment_id, match_id=match_id, commands=commands, timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
async def test_set_text(
|
async def test_set_text(
|
||||||
self,
|
self,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
@@ -1286,12 +1324,13 @@ class VmixAgentHub:
|
|||||||
raise HTTPException(status_code=404, detail="Agent не найден")
|
raise HTTPException(status_code=404, detail="Agent не найден")
|
||||||
assignment_id = str(device.current_assignment_key or "")
|
assignment_id = str(device.current_assignment_key or "")
|
||||||
match_id = str(device.current_match_external_id or "")
|
match_id = str(device.current_match_external_id or "")
|
||||||
|
agent_version = str(device.agent_version or "")
|
||||||
if not assignment_id or not match_id:
|
if not assignment_id or not match_id:
|
||||||
raise HTTPException(status_code=409, detail="Agent не привязан к текущему матчу")
|
raise HTTPException(status_code=409, detail="Agent не привязан к текущему матчу")
|
||||||
if operator_game_id and match_id != operator_game_id:
|
if operator_game_id and match_id != operator_game_id:
|
||||||
raise HTTPException(status_code=409, detail="Agent назначен на другой матч")
|
raise HTTPException(status_code=409, detail="Agent назначен на другой матч")
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
prepared_commands: list[dict[str, Any]] = []
|
||||||
for index, raw in enumerate(commands):
|
for index, raw in enumerate(commands):
|
||||||
function = str(raw.get("Function") or raw.get("function") or "").strip()
|
function = str(raw.get("Function") or raw.get("function") or "").strip()
|
||||||
if not function:
|
if not function:
|
||||||
@@ -1303,12 +1342,46 @@ class VmixAgentHub:
|
|||||||
if str(key).lower() != "value" and str(value) == "":
|
if str(key).lower() != "value" and str(value) == "":
|
||||||
continue
|
continue
|
||||||
command[str(key)] = value
|
command[str(key)] = value
|
||||||
ack = await self.send_vmix_command(
|
prepared_commands.append(command)
|
||||||
|
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
transport = "batch" if self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 else "legacy"
|
||||||
|
async with self._device_vmix_send_lock(target_device_id):
|
||||||
|
if transport == "batch":
|
||||||
|
ack = await self._send_vmix_batch_unlocked(
|
||||||
target_device_id,
|
target_device_id,
|
||||||
assignment_id=assignment_id,
|
assignment_id=assignment_id,
|
||||||
match_id=match_id,
|
match_id=match_id,
|
||||||
command=command,
|
commands=prepared_commands,
|
||||||
timeout=timeout,
|
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, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id
|
||||||
|
)
|
||||||
|
if not all(item["ok"] for item in results):
|
||||||
|
failed = next(item for item in results if not item["ok"])
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail={"message": f"vMix command failed: {failed['function']}", "index": failed["index"], "results": results},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for index, command in enumerate(prepared_commands):
|
||||||
|
function = str(command.get("Function") or "")
|
||||||
|
ack = await self._send_vmix_command_unlocked(
|
||||||
|
target_device_id, assignment_id=assignment_id, match_id=match_id, command=command, timeout=timeout
|
||||||
)
|
)
|
||||||
item = {
|
item = {
|
||||||
"index": index,
|
"index": index,
|
||||||
@@ -1320,18 +1393,10 @@ class VmixAgentHub:
|
|||||||
if not item["ok"]:
|
if not item["ok"]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=502,
|
status_code=502,
|
||||||
detail={
|
detail={"message": f"vMix command failed: {function}", "index": index, "results": results},
|
||||||
"message": f"vMix command failed: {function}",
|
|
||||||
"index": index,
|
|
||||||
"results": results,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
self._track_runtime_overlay_command(
|
self._track_runtime_overlay_command(
|
||||||
target_device_id,
|
target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id
|
||||||
command,
|
|
||||||
sequence_id=sequence_id,
|
|
||||||
sequence_name=sequence_name,
|
|
||||||
button_id=button_id,
|
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -1341,6 +1406,7 @@ class VmixAgentHub:
|
|||||||
"session_token": session_token,
|
"session_token": session_token,
|
||||||
"applied": len(results),
|
"applied": len(results),
|
||||||
"results": results,
|
"results": results,
|
||||||
|
"transport": transport,
|
||||||
"overlay_state": self._runtime_overlay_payload(target_device_id),
|
"overlay_state": self._runtime_overlay_payload(target_device_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -688,7 +688,7 @@ def test_runtime_vmix_sequence_preserves_command_order(tmp_path: Path) -> None:
|
|||||||
"device_secret": "s" * 40,
|
"device_secret": "s" * 40,
|
||||||
"device_name": "Sequence GFX",
|
"device_name": "Sequence GFX",
|
||||||
"hostname": "SEQUENCE-PC",
|
"hostname": "SEQUENCE-PC",
|
||||||
"agent_version": "1.4.0",
|
"agent_version": "1.3.0",
|
||||||
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -744,6 +744,65 @@ def test_runtime_vmix_sequence_preserves_command_order(tmp_path: Path) -> None:
|
|||||||
result = await task
|
result = await task
|
||||||
assert result["ok"] is True
|
assert result["ok"] is True
|
||||||
assert result["applied"] == 2
|
assert result["applied"] == 2
|
||||||
|
assert result["transport"] == "legacy"
|
||||||
assert [item["function"] for item in result["results"]] == ["SetCountdown", "StartCountdown"]
|
assert [item["function"] for item in result["results"]] == ["SetCountdown", "StartCountdown"]
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_build90_runtime_shortcut_sequence_uses_one_batch_on_modern_agent(tmp_path: Path) -> None:
|
||||||
|
database = LocalTestDatabase(tmp_path / "agent-runtime-batch.sqlite3")
|
||||||
|
database.create_all()
|
||||||
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||||||
|
ws = FakeWebSocket()
|
||||||
|
user = HockeyUser(id="92", login="operator92", display_name="operator92")
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
await hub.register(
|
||||||
|
ws, # type: ignore[arg-type]
|
||||||
|
{
|
||||||
|
"device_id": "GFX-PC-RUNTIME-BATCH",
|
||||||
|
"device_secret": "r" * 40,
|
||||||
|
"device_name": "Runtime Batch GFX",
|
||||||
|
"hostname": "RUNTIME-BATCH-PC",
|
||||||
|
"agent_version": "1.4.0",
|
||||||
|
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hub.pair_device("GFX-PC-RUNTIME-BATCH", user)
|
||||||
|
assigned = await hub.assign_match(
|
||||||
|
wfl_user_id=user.id, tournament_external_id="1437", game_external_id="902919",
|
||||||
|
)
|
||||||
|
assert assigned is not None
|
||||||
|
|
||||||
|
task = asyncio.create_task(hub.run_vmix_sequence_for_user(
|
||||||
|
user,
|
||||||
|
[
|
||||||
|
{"Function": "SetCountdown", "Input": "53", "SelectedName": "Clock.Text", "Value": "00:18:42"},
|
||||||
|
{"Function": "StartCountdown", "Input": "53", "SelectedName": "Clock.Text"},
|
||||||
|
{"Function": "OverlayInput2In", "Input": "53"},
|
||||||
|
],
|
||||||
|
))
|
||||||
|
batch = None
|
||||||
|
for _ in range(50):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
batch = next((item for item in reversed(ws.sent) if item.get("type") == "vmix.batch"), None)
|
||||||
|
if batch is not None:
|
||||||
|
break
|
||||||
|
assert batch is not None
|
||||||
|
assert [item["Function"] for item in batch["commands"]] == ["SetCountdown", "StartCountdown", "OverlayInput2In"]
|
||||||
|
assert not [item for item in ws.sent if item.get("type") == "vmix.command"]
|
||||||
|
await hub.receive_command_ack(
|
||||||
|
"GFX-PC-RUNTIME-BATCH",
|
||||||
|
{
|
||||||
|
"type": "command.batch.ack", "request_id": batch["request_id"], "ok": True,
|
||||||
|
"results": [{"ok": True}, {"ok": True}, {"ok": True}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result = await task
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["transport"] == "batch"
|
||||||
|
assert result["applied"] == 3
|
||||||
|
assert result["overlay_state"]["overlays"]["2"]["input"] == "53"
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|||||||
@@ -88,4 +88,6 @@ def test_rebalance_sends_old_plate_out_before_new_plate_in():
|
|||||||
assert 'const outCommands = [];' in block
|
assert 'const outCommands = [];' in block
|
||||||
assert 'const setCommands = [];' in block
|
assert 'const setCommands = [];' in block
|
||||||
assert 'const inCommands = [];' in block
|
assert 'const inCommands = [];' in block
|
||||||
assert 'const commands = [...outCommands, ...setCommands, ...inCommands];' in block
|
assert 'const stopCommands = [];' in block
|
||||||
|
assert 'const runCommands = [];' in block
|
||||||
|
assert 'const commands = [...outCommands, ...stopCommands, ...setCommands, ...runCommands, ...inCommands];' in block
|
||||||
|
|||||||
@@ -104,9 +104,10 @@ def test_shortcut_editor_layout_fixes_add_step_buttons_and_fullscreen_modal() ->
|
|||||||
assert "height: calc(100vh - 20px);" in css
|
assert "height: calc(100vh - 20px);" in css
|
||||||
|
|
||||||
|
|
||||||
def test_hockey_timer_shortcut_supports_space_toggle_pause_and_text_mirror() -> None:
|
def test_hockey_timer_shortcut_supports_native_countdown_and_legacy_text_mirror() -> None:
|
||||||
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
assert 'Function: "SuspendCountdown"' in app_js
|
assert 'Function: "PauseCountdown"' in app_js
|
||||||
|
assert 'Function: "StopCountdown"' in app_js
|
||||||
assert 'hockey_timer_command' in app_js
|
assert 'hockey_timer_command' in app_js
|
||||||
assert 'game_vmix_mode' in app_js
|
assert 'game_vmix_mode' in app_js
|
||||||
assert 'game_vmix_selected_name' in app_js
|
assert 'game_vmix_selected_name' in app_js
|
||||||
@@ -196,3 +197,61 @@ def test_overlay_group_repeat_uses_transition_out_not_all_off():
|
|||||||
assert 'Function: "OverlayInputAllOff"' not in js
|
assert 'Function: "OverlayInputAllOff"' not in js
|
||||||
for layer in range(1, 5):
|
for layer in range(1, 5):
|
||||||
assert f'Function: "OverlayInput{layer}Out"' in js
|
assert f'Function: "OverlayInput{layer}Out"' in js
|
||||||
|
|
||||||
|
|
||||||
|
def test_build90_migrates_old_text_timer_transport_to_native_countdown(tmp_path: Path) -> None:
|
||||||
|
manager = UIBuilderManager(tmp_path, filename="ui.json")
|
||||||
|
saved = manager.save({
|
||||||
|
"version": 21,
|
||||||
|
"project_name": "Hockey",
|
||||||
|
"data_source": "hockey",
|
||||||
|
"canvas": {"width": 1440, "height": 900},
|
||||||
|
"tabs": [{"id": "main", "label": "Main"}],
|
||||||
|
"components": [],
|
||||||
|
"triggers": [],
|
||||||
|
"shortcut_sequences": [{
|
||||||
|
"id": "timers", "name": "Timers", "combo": "Space",
|
||||||
|
"steps": [{
|
||||||
|
"id": "sync", "type": "hockey_vmix_timers_start",
|
||||||
|
"game_vmix_mode": "text", "penalty_vmix_mode": "text",
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
step = saved["shortcut_sequences"][0]["steps"][0]
|
||||||
|
assert saved["version"] == 22
|
||||||
|
assert step["game_vmix_mode"] == "countdown"
|
||||||
|
assert step["penalty_vmix_mode"] == "countdown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build90_keeps_explicit_legacy_text_mode_after_v22(tmp_path: Path) -> None:
|
||||||
|
manager = UIBuilderManager(tmp_path, filename="ui.json")
|
||||||
|
saved = manager.save({
|
||||||
|
"version": 22,
|
||||||
|
"project_name": "Hockey",
|
||||||
|
"data_source": "hockey",
|
||||||
|
"canvas": {"width": 1440, "height": 900},
|
||||||
|
"tabs": [{"id": "main", "label": "Main"}],
|
||||||
|
"components": [], "triggers": [],
|
||||||
|
"shortcut_sequences": [{
|
||||||
|
"id": "timers", "name": "Timers", "combo": "Space",
|
||||||
|
"steps": [{
|
||||||
|
"id": "sync", "type": "hockey_vmix_timers_start",
|
||||||
|
"game_vmix_mode": "text", "penalty_vmix_mode": "text",
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
step = saved["shortcut_sequences"][0]["steps"][0]
|
||||||
|
assert step["game_vmix_mode"] == "text"
|
||||||
|
assert step["penalty_vmix_mode"] == "text"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build90_native_countdown_avoids_per_second_transport() -> None:
|
||||||
|
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
|
assert 'game_vmix_mode !== "countdown"' in app_js
|
||||||
|
assert 'const countdownMode = step.penalty_vmix_mode === "countdown"' in app_js
|
||||||
|
assert 'Function: "PauseCountdown"' in app_js
|
||||||
|
assert 'Function: "StopCountdown"' in app_js
|
||||||
|
assert 'signal: controller.signal' in app_js
|
||||||
|
assert 'vmixCommandQueue: Promise.resolve()' in app_js
|
||||||
|
# Per-second pushes are restricted to the legacy Text mirror map.
|
||||||
|
assert 'eventName === "timer_tick" && state.vmixTimerMirrors.has(component.action_id)' in app_js
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict[str, Any] = {
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"version": 21,
|
"version": 22,
|
||||||
"project_name": "Новый интерфейс",
|
"project_name": "Новый интерфейс",
|
||||||
"data_source": "golf",
|
"data_source": "golf",
|
||||||
"canvas": {
|
"canvas": {
|
||||||
@@ -68,12 +68,15 @@ class UIBuilderManager:
|
|||||||
except (OSError, json.JSONDecodeError, TypeError):
|
except (OSError, json.JSONDecodeError, TypeError):
|
||||||
return deepcopy(DEFAULT_CONFIG)
|
return deepcopy(DEFAULT_CONFIG)
|
||||||
had_legacy_shortcuts = self._has_legacy_hockey_component_shortcuts(raw)
|
had_legacy_shortcuts = self._has_legacy_hockey_component_shortcuts(raw)
|
||||||
|
try:
|
||||||
|
source_version = int(raw.get("version") or 0) if isinstance(raw, dict) else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
source_version = 0
|
||||||
normalized = self._normalize(raw)
|
normalized = self._normalize(raw)
|
||||||
if had_legacy_shortcuts:
|
if had_legacy_shortcuts or source_version < 22:
|
||||||
# Build61 migration: remove old hidden component-level Space/Ctrl+R
|
# Build61: remove old hidden component-level Space/Ctrl+R bindings.
|
||||||
# bindings from the persisted draft/published JSON. Shortcut Sequences
|
# BUILD90: persist the one-time v21 -> v22 native-countdown migration
|
||||||
# are intentionally untouched, so an explicitly configured Space
|
# so old Text mirror timer steps stop producing per-second SetText traffic.
|
||||||
# sequence remains available in the visible shortcut editor.
|
|
||||||
self.save(normalized, create_backup=False)
|
self.save(normalized, create_backup=False)
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
@@ -159,7 +162,12 @@ class UIBuilderManager:
|
|||||||
if not isinstance(config, dict):
|
if not isinstance(config, dict):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
result["version"] = 21
|
source_version_raw = config.get("version", 0)
|
||||||
|
try:
|
||||||
|
source_version = int(source_version_raw or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
source_version = 0
|
||||||
|
result["version"] = 22
|
||||||
result["project_name"] = str(config.get("project_name") or result["project_name"])
|
result["project_name"] = str(config.get("project_name") or result["project_name"])
|
||||||
result["data_source"] = str(config.get("data_source") or result["data_source"])
|
result["data_source"] = str(config.get("data_source") or result["data_source"])
|
||||||
|
|
||||||
@@ -426,8 +434,18 @@ class UIBuilderManager:
|
|||||||
"scoreboard_alternate_selected_name": str(step.get("scoreboard_alternate_selected_name") or ""),
|
"scoreboard_alternate_selected_name": str(step.get("scoreboard_alternate_selected_name") or ""),
|
||||||
"game_timer_action_id": str(step.get("game_timer_action_id") or "hockey_game_timer"),
|
"game_timer_action_id": str(step.get("game_timer_action_id") or "hockey_game_timer"),
|
||||||
"hockey_timer_command": str(step.get("hockey_timer_command") or "toggle") if str(step.get("hockey_timer_command") or "toggle") in {"toggle", "start", "pause", "resume"} else "toggle",
|
"hockey_timer_command": str(step.get("hockey_timer_command") or "toggle") if str(step.get("hockey_timer_command") or "toggle") in {"toggle", "start", "pause", "resume"} else "toggle",
|
||||||
"game_vmix_mode": str(step.get("game_vmix_mode") or "text") if str(step.get("game_vmix_mode") or "text") in {"countdown", "text"} else "text",
|
# BUILD90: configs created before v22 used Text mirror as the default,
|
||||||
"penalty_vmix_mode": str(step.get("penalty_vmix_mode") or "text") if str(step.get("penalty_vmix_mode") or "text") in {"countdown", "text"} else "text",
|
# which pushed timer text every second. Migrate those hockey timer
|
||||||
|
# sync steps once to native vMix countdown transport. From v22 onward
|
||||||
|
# an explicitly selected legacy Text mirror remains available.
|
||||||
|
"game_vmix_mode": (
|
||||||
|
"countdown" if source_version < 22 and step_type == "hockey_vmix_timers_start"
|
||||||
|
else (str(step.get("game_vmix_mode") or "countdown") if str(step.get("game_vmix_mode") or "countdown") in {"countdown", "text"} else "countdown")
|
||||||
|
),
|
||||||
|
"penalty_vmix_mode": (
|
||||||
|
"countdown" if source_version < 22 and step_type == "hockey_vmix_timers_start"
|
||||||
|
else (str(step.get("penalty_vmix_mode") or "countdown") if str(step.get("penalty_vmix_mode") or "countdown") in {"countdown", "text"} else "countdown")
|
||||||
|
),
|
||||||
"penalty_display_mode": "all" if str(step.get("penalty_display_mode") or "soonest") == "all" else "soonest",
|
"penalty_display_mode": "all" if str(step.get("penalty_display_mode") or "soonest") == "all" else "soonest",
|
||||||
"game_vmix_input": str(step.get("game_vmix_input") or ""),
|
"game_vmix_input": str(step.get("game_vmix_input") or ""),
|
||||||
"game_vmix_selected_name": str(step.get("game_vmix_selected_name") or ""),
|
"game_vmix_selected_name": str(step.get("game_vmix_selected_name") or ""),
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
config: {
|
config: {
|
||||||
version: 21,
|
version: 22,
|
||||||
project_name: "Новый интерфейс",
|
project_name: "Новый интерфейс",
|
||||||
data_source: "golf",
|
data_source: "golf",
|
||||||
canvas: {
|
canvas: {
|
||||||
@@ -80,6 +80,10 @@
|
|||||||
modifierShortcutChordUsedKey: false,
|
modifierShortcutChordUsedKey: false,
|
||||||
modifierShortcutChordFired: false,
|
modifierShortcutChordFired: false,
|
||||||
runningShortcutSequences: new Set(),
|
runningShortcutSequences: new Set(),
|
||||||
|
// 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(),
|
||||||
|
vmixCommandQueueDepth: 0,
|
||||||
vmixTimerMirrors: new Map(),
|
vmixTimerMirrors: new Map(),
|
||||||
vmixPenaltyMirrors: new Map(),
|
vmixPenaltyMirrors: new Map(),
|
||||||
activeHockeyVmixTimerSteps: new Set(),
|
activeHockeyVmixTimerSteps: new Set(),
|
||||||
@@ -2380,8 +2384,8 @@ function startCustomTooltips() {
|
|||||||
scoreboard_alternate_selected_name: String(step.scoreboard_alternate_selected_name || ""),
|
scoreboard_alternate_selected_name: String(step.scoreboard_alternate_selected_name || ""),
|
||||||
game_timer_action_id: String(step.game_timer_action_id || "hockey_game_timer"),
|
game_timer_action_id: String(step.game_timer_action_id || "hockey_game_timer"),
|
||||||
hockey_timer_command: ["toggle", "start", "pause", "resume"].includes(String(step.hockey_timer_command || "")) ? String(step.hockey_timer_command) : "toggle",
|
hockey_timer_command: ["toggle", "start", "pause", "resume"].includes(String(step.hockey_timer_command || "")) ? String(step.hockey_timer_command) : "toggle",
|
||||||
game_vmix_mode: ["countdown", "text"].includes(String(step.game_vmix_mode || "")) ? String(step.game_vmix_mode) : "text",
|
game_vmix_mode: ["countdown", "text"].includes(String(step.game_vmix_mode || "")) ? String(step.game_vmix_mode) : "countdown",
|
||||||
penalty_vmix_mode: ["countdown", "text"].includes(String(step.penalty_vmix_mode || "")) ? String(step.penalty_vmix_mode) : "text",
|
penalty_vmix_mode: ["countdown", "text"].includes(String(step.penalty_vmix_mode || "")) ? String(step.penalty_vmix_mode) : "countdown",
|
||||||
penalty_display_mode: String(step.penalty_display_mode || "soonest") === "all" ? "all" : "soonest",
|
penalty_display_mode: String(step.penalty_display_mode || "soonest") === "all" ? "all" : "soonest",
|
||||||
game_vmix_input: String(step.game_vmix_input || ""),
|
game_vmix_input: String(step.game_vmix_input || ""),
|
||||||
game_vmix_selected_name: String(step.game_vmix_selected_name || ""),
|
game_vmix_selected_name: String(step.game_vmix_selected_name || ""),
|
||||||
@@ -2571,7 +2575,7 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureConfig() {
|
function ensureConfig() {
|
||||||
state.config.version = 21;
|
state.config.version = 22;
|
||||||
state.config.canvas ||= {};
|
state.config.canvas ||= {};
|
||||||
state.config.canvas.auto_bind_containers = state.config.canvas.auto_bind_containers !== false;
|
state.config.canvas.auto_bind_containers = state.config.canvas.auto_bind_containers !== false;
|
||||||
state.config.tabs = Array.isArray(state.config.tabs) && state.config.tabs.length ? state.config.tabs : [{ id: "main", label: "Основное" }];
|
state.config.tabs = Array.isArray(state.config.tabs) && state.config.tabs.length ? state.config.tabs : [{ id: "main", label: "Основное" }];
|
||||||
@@ -2719,7 +2723,7 @@ function startCustomTooltips() {
|
|||||||
const factory = templates[name];
|
const factory = templates[name];
|
||||||
if (!factory) return;
|
if (!factory) return;
|
||||||
const next = factory();
|
const next = factory();
|
||||||
state.config = { version: 21, triggers: [], ...next };
|
state.config = { version: 22, triggers: [], ...next };
|
||||||
ensureConfig();
|
ensureConfig();
|
||||||
state.activeTab = state.config.tabs[0]?.id || "main";
|
state.activeTab = state.config.tabs[0]?.id || "main";
|
||||||
if (state.config.canvas.auto_bind_containers) {
|
if (state.config.canvas.auto_bind_containers) {
|
||||||
@@ -4504,10 +4508,17 @@ function startCustomTooltips() {
|
|||||||
async function sendRuntimeVmixSequence(commands, execution = null) {
|
async function sendRuntimeVmixSequence(commands, execution = null) {
|
||||||
const clean = (commands || []).map(compactVmixCommand).filter((command) => command.Function);
|
const clean = (commands || []).map(compactVmixCommand).filter((command) => command.Function);
|
||||||
if (!clean.length) return { ok: true, applied: 0, results: [] };
|
if (!clean.length) return { ok: true, applied: 0, results: [] };
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
state.vmixCommandQueueDepth += 1;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), 7000);
|
||||||
|
try {
|
||||||
const response = await fetch("/api/hockey/vmix/sequence", {
|
const response = await fetch("/api/hockey/vmix/sequence", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
|
signal: controller.signal,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
commands: clean,
|
commands: clean,
|
||||||
@@ -4527,6 +4538,23 @@ function startCustomTooltips() {
|
|||||||
trackRuntimeOverlayCommands(clean, execution);
|
trackRuntimeOverlayCommands(clean, execution);
|
||||||
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
|
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
|
||||||
return payload;
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === "AbortError") {
|
||||||
|
throw new Error("vMix/Agent не подтвердил команду за 7 секунд");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
state.vmixCommandQueueDepth = Math.max(0, state.vmixCommandQueueDepth - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the queue alive after a failed command: one timeout must not permanently
|
||||||
|
// block every shortcut pressed afterwards. No automatic retry is performed because
|
||||||
|
// toggle/overlay commands are not safely idempotent.
|
||||||
|
const queued = state.vmixCommandQueue.catch(() => {}).then(run);
|
||||||
|
state.vmixCommandQueue = queued.catch(() => {});
|
||||||
|
return queued;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitVmixInputs(value) {
|
function splitVmixInputs(value) {
|
||||||
@@ -4567,6 +4595,36 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncActiveVmixGameCountdown(component, timerState, eventName) {
|
||||||
|
if (!component?.action_id || eventName === "timer_tick") return false;
|
||||||
|
const commands = [];
|
||||||
|
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
|
||||||
|
const step = hockeyTimerSyncStepById(stepId);
|
||||||
|
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 target = { Input: input, SelectedName: selectedName };
|
||||||
|
if (["timer_start", "timer_restart"].includes(eventName)) {
|
||||||
|
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
|
||||||
|
commands.push({ Function: "StartCountdown", ...target });
|
||||||
|
} else if (eventName === "timer_resume") {
|
||||||
|
commands.push({ Function: "StartCountdown", ...target });
|
||||||
|
} else if (eventName === "timer_pause") {
|
||||||
|
commands.push({ Function: "PauseCountdown", ...target });
|
||||||
|
} else if (["timer_stop", "timer_finished"].includes(eventName)) {
|
||||||
|
commands.push({ Function: "StopCountdown", ...target });
|
||||||
|
} else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) {
|
||||||
|
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
|
||||||
|
commands.push({ Function: timerState.running ? "StartCountdown" : "PauseCountdown", ...target });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!commands.length) return false;
|
||||||
|
await sendRuntimeVmixSequence(commands);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function penaltyMirrorKey(component, event) {
|
function penaltyMirrorKey(component, event) {
|
||||||
return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`;
|
return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`;
|
||||||
@@ -4792,7 +4850,9 @@ function startCustomTooltips() {
|
|||||||
|
|
||||||
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true } = {}) {
|
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true } = {}) {
|
||||||
const outCommands = [];
|
const outCommands = [];
|
||||||
|
const stopCommands = [];
|
||||||
const setCommands = [];
|
const setCommands = [];
|
||||||
|
const runCommands = [];
|
||||||
const inCommands = [];
|
const inCommands = [];
|
||||||
const assignedMirrorKeys = new Set();
|
const assignedMirrorKeys = new Set();
|
||||||
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
|
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
|
||||||
@@ -4801,7 +4861,7 @@ function startCustomTooltips() {
|
|||||||
state.activeHockeyVmixTimerSteps.delete(stepId);
|
state.activeHockeyVmixTimerSteps.delete(stepId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (step.penalty_vmix_mode !== "text") continue;
|
const countdownMode = step.penalty_vmix_mode === "countdown";
|
||||||
const displayPlan = penaltyDisplayEntriesByTargetSide(step);
|
const displayPlan = penaltyDisplayEntriesByTargetSide(step);
|
||||||
rememberPenaltyAdvantagePlan(displayPlan);
|
rememberPenaltyAdvantagePlan(displayPlan);
|
||||||
for (const side of ["home", "away"]) {
|
for (const side of ["home", "away"]) {
|
||||||
@@ -4816,20 +4876,40 @@ function startCustomTooltips() {
|
|||||||
const entry = entries[index] || null;
|
const entry = entries[index] || null;
|
||||||
if (entry && target.input && target.selected_name) {
|
if (entry && target.input && target.selected_name) {
|
||||||
const eventKey = penaltyMirrorKey(entry.component, entry.event);
|
const eventKey = penaltyMirrorKey(entry.component, entry.event);
|
||||||
|
const sourceSide = String(entry.side || entry.event?.side || entry.event?.player?.side || side);
|
||||||
|
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
|
||||||
|
const assignmentChanged = !previous
|
||||||
|
|| previous.eventKey !== eventKey
|
||||||
|
|| String(previous.input || "") !== String(target.input)
|
||||||
|
|| String(previous.selectedName || "") !== String(target.selected_name);
|
||||||
|
|
||||||
|
if (previous && countdownMode && assignmentChanged && previous.input && String(previous.input) !== String(target.input)) {
|
||||||
|
stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countdownMode) {
|
||||||
|
state.vmixPenaltyMirrors.delete(eventKey);
|
||||||
|
state.vmixPenaltyTargetAssignments.set(assignmentKey, {
|
||||||
|
eventKey, input: target.input, selectedName: target.selected_name, overlay, sourceSide, targetSide: side,
|
||||||
|
mode: "countdown", running: Boolean(entry.event.running),
|
||||||
|
});
|
||||||
|
if (force || assignmentChanged) {
|
||||||
|
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) });
|
||||||
|
}
|
||||||
|
if (entry.event.running) {
|
||||||
|
if (force || assignmentChanged || previous?.running !== true) {
|
||||||
|
runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||||
|
}
|
||||||
|
} else if (force || assignmentChanged || previous?.running !== false) {
|
||||||
|
runCommands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
assignedMirrorKeys.add(eventKey);
|
assignedMirrorKeys.add(eventKey);
|
||||||
setVmixPenaltyMirror(entry.component, entry.event, target.input, target.selected_name, {
|
setVmixPenaltyMirror(entry.component, entry.event, target.input, target.selected_name, {
|
||||||
stepId: step.id,
|
stepId: step.id, targetId: target.id, side: sourceSide, overlay,
|
||||||
targetId: target.id,
|
|
||||||
side: String(entry.side || entry.event?.side || entry.event?.player?.side || side),
|
|
||||||
overlay: target.overlay,
|
|
||||||
});
|
});
|
||||||
state.vmixPenaltyTargetAssignments.set(assignmentKey, {
|
state.vmixPenaltyTargetAssignments.set(assignmentKey, {
|
||||||
eventKey,
|
eventKey, input: target.input, selectedName: target.selected_name, overlay, sourceSide, targetSide: side, mode: "text",
|
||||||
input: target.input,
|
|
||||||
selectedName: target.selected_name,
|
|
||||||
overlay: target.overlay,
|
|
||||||
sourceSide: String(entry.side || entry.event?.side || entry.event?.player?.side || ""),
|
|
||||||
targetSide: side,
|
|
||||||
});
|
});
|
||||||
const value = formatHockeyPenaltyTime(entry.event.remainingMs);
|
const value = formatHockeyPenaltyTime(entry.event.remainingMs);
|
||||||
const mirror = state.vmixPenaltyMirrors.get(eventKey);
|
const mirror = state.vmixPenaltyMirrors.get(eventKey);
|
||||||
@@ -4837,11 +4917,9 @@ function startCustomTooltips() {
|
|||||||
setCommands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: value });
|
setCommands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: value });
|
||||||
if (mirror) mirror.lastValue = value;
|
if (mirror) mirror.lastValue = value;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If the scoreboard is already on air and this penalty target was not
|
|
||||||
// previously assigned, bring the penalty plate on air immediately.
|
|
||||||
if (hockeyScoreboardIsLive()) {
|
if (hockeyScoreboardIsLive()) {
|
||||||
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
|
|
||||||
const targetWasVisible = Boolean(previous?.input)
|
const targetWasVisible = Boolean(previous?.input)
|
||||||
&& String(previous.input) === String(target.input)
|
&& String(previous.input) === String(target.input)
|
||||||
&& String(previous.overlay || overlay) === overlay;
|
&& String(previous.overlay || overlay) === overlay;
|
||||||
@@ -4854,6 +4932,9 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (previous?.mode === "countdown" && previous.input) {
|
||||||
|
stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name });
|
||||||
|
}
|
||||||
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) {
|
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) {
|
||||||
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
|
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
|
||||||
outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
|
outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
|
||||||
@@ -4864,6 +4945,9 @@ function startCustomTooltips() {
|
|||||||
inactiveTargets.forEach((target) => {
|
inactiveTargets.forEach((target) => {
|
||||||
const assignmentKey = penaltyTargetAssignmentKey(step, side, target);
|
const assignmentKey = penaltyTargetAssignmentKey(step, side, target);
|
||||||
const previous = state.vmixPenaltyTargetAssignments.get(assignmentKey);
|
const previous = state.vmixPenaltyTargetAssignments.get(assignmentKey);
|
||||||
|
if (previous?.mode === "countdown" && previous.input) {
|
||||||
|
stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name });
|
||||||
|
}
|
||||||
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) {
|
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) {
|
||||||
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
|
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
|
||||||
outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
|
outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
|
||||||
@@ -4877,10 +4961,8 @@ function startCustomTooltips() {
|
|||||||
state.vmixPenaltyMirrors.delete(mirrorKey);
|
state.vmixPenaltyMirrors.delete(mirrorKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Always take the old single plate OUT before putting the new side IN.
|
// Old plate OUT/Stop first, then set/start the single current countdown, then IN.
|
||||||
// HOME and AWAY targets frequently share the same Overlay slot; sending IN
|
const commands = [...outCommands, ...stopCommands, ...setCommands, ...runCommands, ...inCommands];
|
||||||
// first and OUT second would remove the newly selected plate.
|
|
||||||
const commands = [...outCommands, ...setCommands, ...inCommands];
|
|
||||||
if (commands.length) await sendRuntimeVmixSequence(commands);
|
if (commands.length) await sendRuntimeVmixSequence(commands);
|
||||||
return commands.length;
|
return commands.length;
|
||||||
}
|
}
|
||||||
@@ -4986,6 +5068,7 @@ function startCustomTooltips() {
|
|||||||
const action = configuredCommand === "toggle" ? (gameTimerState?.running ? "pause" : "start") : configuredCommand;
|
const action = configuredCommand === "toggle" ? (gameTimerState?.running ? "pause" : "start") : configuredCommand;
|
||||||
const pausing = action === "pause";
|
const pausing = action === "pause";
|
||||||
const commands = [];
|
const commands = [];
|
||||||
|
state.activeHockeyVmixTimerSteps.add(step.id);
|
||||||
|
|
||||||
if (step.sync_vmix_game && step.game_vmix_input) {
|
if (step.sync_vmix_game && step.game_vmix_input) {
|
||||||
if (!gameTimerState) throw new Error(`Основной таймер «${step.game_timer_action_id}» не найден`);
|
if (!gameTimerState) throw new Error(`Основной таймер «${step.game_timer_action_id}» не найден`);
|
||||||
@@ -4994,10 +5077,12 @@ function startCustomTooltips() {
|
|||||||
setVmixTimerMirror(step.game_timer_action_id || "hockey_game_timer", step.game_vmix_input, step.game_vmix_selected_name);
|
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) });
|
commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) });
|
||||||
} else if (pausing) {
|
} else if (pausing) {
|
||||||
commands.push({ Function: "SuspendCountdown", Input: step.game_vmix_input });
|
commands.push({ Function: "PauseCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
|
||||||
|
} else if (action === "resume") {
|
||||||
|
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
|
||||||
} else {
|
} else {
|
||||||
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) });
|
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) });
|
||||||
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input });
|
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5013,18 +5098,25 @@ function startCustomTooltips() {
|
|||||||
const target = targets[index] || null;
|
const target = targets[index] || null;
|
||||||
if (!target?.input) return;
|
if (!target?.input) return;
|
||||||
if (!target.selected_name) throw new Error(`Для таймера удаления ${side === "home" ? "HOME" : "AWAY"} выберите Text / SelectedName`);
|
if (!target.selected_name) throw new Error(`Для таймера удаления ${side === "home" ? "HOME" : "AWAY"} выберите Text / SelectedName`);
|
||||||
if (step.penalty_vmix_mode === "text") {
|
|
||||||
const sourceSide = String(event.player?.side || event.side || side);
|
const sourceSide = String(event.player?.side || event.side || side);
|
||||||
|
if (step.penalty_vmix_mode === "text") {
|
||||||
setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side: sourceSide, overlay: target.overlay });
|
setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side: sourceSide, overlay: target.overlay });
|
||||||
state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), {
|
state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), {
|
||||||
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side,
|
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "text",
|
||||||
});
|
});
|
||||||
commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) });
|
commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) });
|
||||||
} else if (pausing) {
|
} else {
|
||||||
commands.push({ Function: "SuspendCountdown", Input: target.input });
|
state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), {
|
||||||
|
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "countdown", running: !pausing,
|
||||||
|
});
|
||||||
|
if (pausing) {
|
||||||
|
commands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||||
|
} else if (action === "resume") {
|
||||||
|
commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||||
} else {
|
} else {
|
||||||
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
|
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
|
||||||
commands.push({ Function: "StartCountdown", Input: target.input });
|
commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5033,7 +5125,7 @@ function startCustomTooltips() {
|
|||||||
const vmixResult = commands.length ? await sendRuntimeVmixSequence(commands, execution) : { ok: true, applied: 0 };
|
const vmixResult = commands.length ? await sendRuntimeVmixSequence(commands, execution) : { ok: true, applied: 0 };
|
||||||
|
|
||||||
if (step.start_web_game) {
|
if (step.start_web_game) {
|
||||||
if (!controlTimer(step.game_timer_action_id || "hockey_game_timer", pausing ? "pause" : (action === "resume" ? "resume" : "start"))) {
|
if (!controlTimer(step.game_timer_action_id || "hockey_game_timer", pausing ? "pause" : (action === "resume" ? "resume" : "start"), "", { syncVmix: false })) {
|
||||||
throw new Error(`Основной таймер «${step.game_timer_action_id || "hockey_game_timer"}» не найден`);
|
throw new Error(`Основной таймер «${step.game_timer_action_id || "hockey_game_timer"}» не найден`);
|
||||||
}
|
}
|
||||||
if (step.game_vmix_mode === "text" && gameTimer && gameTimerState) {
|
if (step.game_vmix_mode === "text" && gameTimer && gameTimerState) {
|
||||||
@@ -5041,7 +5133,7 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (step.start_web_penalties) {
|
if (step.start_web_penalties) {
|
||||||
penalties.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, pausing ? "pause" : "start"));
|
penalties.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, pausing ? "pause" : (action === "resume" ? "start" : "start"), "", { syncVmix: false }));
|
||||||
}
|
}
|
||||||
if (step.penalty_vmix_mode === "text" && !pausing) {
|
if (step.penalty_vmix_mode === "text" && !pausing) {
|
||||||
for (const { component, event } of penalties) {
|
for (const { component, event } of penalties) {
|
||||||
@@ -5532,7 +5624,10 @@ function applyExternalDataPatch(patch, { render = true } = {}) {
|
|||||||
...detail
|
...detail
|
||||||
});
|
});
|
||||||
if (eventName === "timer_tick" && state.vmixTimerMirrors.has(component.action_id)) {
|
if (eventName === "timer_tick" && state.vmixTimerMirrors.has(component.action_id)) {
|
||||||
|
// Legacy Text mirror only. Native countdown mode never emits a per-second request.
|
||||||
pushVmixTimerMirror(component, timerState).catch(() => {});
|
pushVmixTimerMirror(component, timerState).catch(() => {});
|
||||||
|
} else if (eventName !== "timer_tick" && !detail.suppressVmixSync) {
|
||||||
|
syncActiveVmixGameCountdown(component, timerState, eventName).catch((error) => console.error("vMix game countdown sync error", error));
|
||||||
}
|
}
|
||||||
if (eventName === "timer_finished") {
|
if (eventName === "timer_finished") {
|
||||||
fireConfiguredTimerFinishActions("game", { gameActionId: component.action_id, component, timerState });
|
fireConfiguredTimerFinishActions("game", { gameActionId: component.action_id, component, timerState });
|
||||||
@@ -5632,7 +5727,7 @@ function applyExternalDataPatch(patch, { render = true } = {}) {
|
|||||||
requestAnimationFrame(timerEngineFrame);
|
requestAnimationFrame(timerEngineFrame);
|
||||||
}
|
}
|
||||||
|
|
||||||
function controlTimer(actionId, command = "toggle", rawValue = "") {
|
function controlTimer(actionId, command = "toggle", rawValue = "", options = {}) {
|
||||||
const component = componentByActionId(actionId);
|
const component = componentByActionId(actionId);
|
||||||
if (!component || !isTimerComponent(component)) return false;
|
if (!component || !isTimerComponent(component)) return false;
|
||||||
const timerState = ensureTimerState(component);
|
const timerState = ensureTimerState(component);
|
||||||
@@ -5718,7 +5813,7 @@ function applyExternalDataPatch(patch, { render = true } = {}) {
|
|||||||
|
|
||||||
timerState.lastTimestamp = now;
|
timerState.lastTimestamp = now;
|
||||||
persistTimer(component, timerState, true);
|
persistTimer(component, timerState, true);
|
||||||
emitTimerEvent(component, eventName, timerState, { command, amount: rawValue });
|
emitTimerEvent(component, eventName, timerState, { command, amount: rawValue, suppressVmixSync: options.syncVmix === false });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6741,7 +6836,7 @@ function openTimerQuickEditor(focusActionId = "") {
|
|||||||
board.selectedPreset = null;
|
board.selectedPreset = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function controlHockeyPenalty(component, eventId, command, rawValue = "") {
|
function controlHockeyPenalty(component, eventId, command, rawValue = "", options = {}) {
|
||||||
const board = ensureHockeyBoardState(component);
|
const board = ensureHockeyBoardState(component);
|
||||||
const event = board.penalties.find((item) => item.id === eventId);
|
const event = board.penalties.find((item) => item.id === eventId);
|
||||||
if (!event) return false;
|
if (!event) return false;
|
||||||
@@ -6860,6 +6955,10 @@ function openTimerQuickEditor(focusActionId = "") {
|
|||||||
|
|
||||||
persistHockeyBoard(component, board, true);
|
persistHockeyBoard(component, board, true);
|
||||||
refreshHockeyBoardNodes(component);
|
refreshHockeyBoardNodes(component);
|
||||||
|
if (options.syncVmix !== false && state.activeHockeyVmixTimerSteps.size && ["start", "pause", "reset", "set_time"].includes(command)) {
|
||||||
|
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
|
||||||
|
.catch((error) => console.error("Penalty countdown state sync error", error));
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13327,10 +13426,10 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
<div class="shortcut-step-grid vmix-command-grid">
|
<div class="shortcut-step-grid vmix-command-grid">
|
||||||
<label>Действие<select data-step-field="hockey_timer_command">${triggerSelectOptions([["toggle","Старт / пауза одной кнопкой"],["start","Только запустить"],["pause","Только пауза"],["resume","Продолжить"]], step.hockey_timer_command)}</select></label>
|
<label>Действие<select data-step-field="hockey_timer_command">${triggerSelectOptions([["toggle","Старт / пауза одной кнопкой"],["start","Только запустить"],["pause","Только пауза"],["resume","Продолжить"]], step.hockey_timer_command)}</select></label>
|
||||||
<label>Основной веб-таймер<select data-step-field="game_timer_action_id">${timerActionOptions(step.game_timer_action_id)}</select></label>
|
<label>Основной веб-таймер<select data-step-field="game_timer_action_id">${timerActionOptions(step.game_timer_action_id)}</select></label>
|
||||||
<label>Режим основного таймера в vMix<select data-step-field="game_vmix_mode">${triggerSelectOptions([["text","Text mirror · рекомендуется"],["countdown","Встроенный Countdown vMix"]], step.game_vmix_mode)}</select></label>
|
<label>Режим основного таймера в vMix<select data-step-field="game_vmix_mode">${triggerSelectOptions([["countdown","Встроенный Countdown vMix · рекомендуется"],["text","Text mirror · совместимость"]], step.game_vmix_mode)}</select></label>
|
||||||
<label>vMix Input основного таймера<select data-step-field="game_vmix_input">${vmixInputOptions(step.game_vmix_input)}</select></label>
|
<label>vMix Input основного таймера<select data-step-field="game_vmix_input">${vmixInputOptions(step.game_vmix_input)}</select></label>
|
||||||
<label>Text / SelectedName основного таймера<select data-step-field="game_vmix_selected_name">${vmixTextSelectedNameOptions(step.game_vmix_input, step.game_vmix_selected_name)}</select></label>
|
<label>Text / SelectedName основного таймера<select data-step-field="game_vmix_selected_name">${vmixTextSelectedNameOptions(step.game_vmix_input, step.game_vmix_selected_name)}</select></label>
|
||||||
<label>Режим таймеров удалений<select data-step-field="penalty_vmix_mode">${triggerSelectOptions([["text","Text mirror · рекомендуется"],["countdown","Встроенный Countdown vMix"]], step.penalty_vmix_mode)}</select></label>
|
<label>Режим таймеров удалений<select data-step-field="penalty_vmix_mode">${triggerSelectOptions([["countdown","Встроенный Countdown vMix · рекомендуется"],["text","Text mirror · совместимость"]], step.penalty_vmix_mode)}</select></label>
|
||||||
<label>Что показывать при нескольких удалениях<select data-step-field="penalty_display_mode">${triggerSelectOptions([["soonest","Одно ближайшее окончание"],["all","Все удаления по слотам"]], step.penalty_display_mode)}</select></label>
|
<label>Что показывать при нескольких удалениях<select data-step-field="penalty_display_mode">${triggerSelectOptions([["soonest","Одно ближайшее окончание"],["all","Все удаления по слотам"]], step.penalty_display_mode)}</select></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -13360,7 +13459,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
<div class="shortcut-finish-action-list">${timerFinishActionRows(step)}</div>
|
<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>
|
<div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.</small></div>
|
||||||
<p class="shortcut-step-note">Режим <b>Text mirror</b> рекомендуется: веб-таймер является источником истины и раз в секунду отправляет <code>SetText</code> строго в выбранные <code>Input + SelectedName</code>. Режим <b>Countdown</b> оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление. При сложных/обоюдных удалениях, пока штрафы есть у обеих команд, HOME/AWAY остаются на своих сторонах и режим «играют в большинстве» не включается. Только когда одна сторона полностью очистится, оставшийся таймер переезжает на Input противоположной команды — стороны большинства. Если обе стороны очистились одновременно, дополнительные плашки просто снимаются. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p>
|
<p class="shortcut-step-note">Режим <b>Countdown vMix</b> рекомендуется и используется по умолчанию: веб отправляет <code>SetCountdown</code> только при установке/коррекции времени и затем <code>StartCountdown</code>; каждую секунду значение больше не передаётся. <b>Text mirror</b> оставлен только как режим совместимости для старых титров. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывается на стороне команды преимущества и отсчитывает ближайшее изменение численного состава; при чистом равном обоюдном удалении плашка не выводится. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else if (step.type === "delay") {
|
} 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>`;
|
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>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user