поправлены удаления и надеюсь заробатаю статусы для кнопок которые вызывают титры из веб-интерфейса
This commit is contained in:
@@ -242,6 +242,8 @@ class VmixAgentHub:
|
|||||||
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] = {}
|
||||||
self._mapping_value_cache: dict[tuple[str, str, int, str, str, str, str], str] = {}
|
self._mapping_value_cache: dict[tuple[str, str, int, str, str, str, str], str] = {}
|
||||||
|
# BUILD83: server-side mirror of ACK-confirmed runtime Overlay state.
|
||||||
|
self._runtime_overlay_state: dict[str, dict[str, dict[str, str]]] = {}
|
||||||
self._auto_refresh_last_error = ""
|
self._auto_refresh_last_error = ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -251,6 +253,86 @@ class VmixAgentHub:
|
|||||||
raise ValueError("Некорректный device_id")
|
raise ValueError("Некорректный device_id")
|
||||||
return device_id
|
return device_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _runtime_overlay_command(function_name: Any) -> tuple[str, str] | None:
|
||||||
|
text = re.sub(r"\s+", "", str(function_name or ""))
|
||||||
|
if text.lower() == "overlayinputalloff":
|
||||||
|
return ("all", "off")
|
||||||
|
match = re.search(r"OverlayInput([1-4])(In|Out|Off)?", text, flags=re.IGNORECASE)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
raw = str(match.group(2) or "toggle").lower()
|
||||||
|
action = "in" if raw == "in" else "out" if raw == "out" else "off" if raw == "off" else "toggle"
|
||||||
|
return str(match.group(1)), action
|
||||||
|
|
||||||
|
def _track_runtime_overlay_command(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
command: dict[str, Any],
|
||||||
|
*,
|
||||||
|
sequence_id: str = "",
|
||||||
|
sequence_name: str = "",
|
||||||
|
button_id: str = "",
|
||||||
|
) -> None:
|
||||||
|
parsed = self._runtime_overlay_command(command.get("Function") or command.get("function"))
|
||||||
|
if parsed is None:
|
||||||
|
return
|
||||||
|
layer, action = parsed
|
||||||
|
device_state = self._runtime_overlay_state.setdefault(device_id, {})
|
||||||
|
if layer == "all":
|
||||||
|
device_state.clear()
|
||||||
|
return
|
||||||
|
input_ref = str(command.get("Input") or command.get("input") or "").strip()
|
||||||
|
if action == "in":
|
||||||
|
device_state[layer] = {
|
||||||
|
"input": input_ref,
|
||||||
|
"sequence_id": str(sequence_id or ""),
|
||||||
|
"sequence_name": str(sequence_name or ""),
|
||||||
|
"button_id": str(button_id or ""),
|
||||||
|
"updated_at": _utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
if action in {"out", "off"}:
|
||||||
|
device_state.pop(layer, None)
|
||||||
|
return
|
||||||
|
current = device_state.get(layer)
|
||||||
|
if current and (not input_ref or str(current.get("input") or "") == input_ref):
|
||||||
|
device_state.pop(layer, None)
|
||||||
|
else:
|
||||||
|
device_state[layer] = {
|
||||||
|
"input": input_ref,
|
||||||
|
"sequence_id": str(sequence_id or ""),
|
||||||
|
"sequence_name": str(sequence_name or ""),
|
||||||
|
"button_id": str(button_id or ""),
|
||||||
|
"updated_at": _utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _runtime_overlay_payload(self, device_id: str) -> dict[str, Any]:
|
||||||
|
source = self._runtime_overlay_state.get(device_id, {})
|
||||||
|
overlays = {str(layer): dict(value) for layer, value in source.items() if str(layer) in {"1", "2", "3", "4"}}
|
||||||
|
return {"device_id": device_id, "overlays": overlays}
|
||||||
|
|
||||||
|
def runtime_overlay_state_for_user(self, user: HockeyUser, *, device_id: str = "") -> dict[str, Any]:
|
||||||
|
requested = self.normalise_device_id(device_id) if str(device_id or "").strip() else ""
|
||||||
|
with self.database.session() as session:
|
||||||
|
if requested:
|
||||||
|
device = session.scalar(select(VmixDevice).where(and_(VmixDevice.device_uuid == requested, VmixDevice.wfl_user_id == user.id)))
|
||||||
|
if device is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent не принадлежит текущему аккаунту")
|
||||||
|
else:
|
||||||
|
rows = list(session.scalars(
|
||||||
|
select(VmixDevice)
|
||||||
|
.where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
|
||||||
|
.order_by(desc(VmixDevice.last_seen_at))
|
||||||
|
))
|
||||||
|
if not rows:
|
||||||
|
return {"device_id": "", "overlays": {}}
|
||||||
|
device = rows[0]
|
||||||
|
result = self._runtime_overlay_payload(device.device_uuid)
|
||||||
|
result["online"] = device.device_uuid in self._live
|
||||||
|
result["vmix_connected"] = bool(device.vmix_connected)
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _mapping_source_code(data_key: Any) -> str:
|
def _mapping_source_code(data_key: Any) -> str:
|
||||||
key = str(data_key or "").strip()
|
key = str(data_key or "").strip()
|
||||||
@@ -558,6 +640,8 @@ class VmixAgentHub:
|
|||||||
row.last_seen_at = now
|
row.last_seen_at = now
|
||||||
if "connected" in vmix:
|
if "connected" in vmix:
|
||||||
row.vmix_connected = bool(vmix.get("connected"))
|
row.vmix_connected = bool(vmix.get("connected"))
|
||||||
|
if not row.vmix_connected:
|
||||||
|
self._runtime_overlay_state.pop(device_id, None)
|
||||||
if vmix.get("version") is not None:
|
if vmix.get("version") is not None:
|
||||||
row.vmix_version = str(vmix.get("version") or "")[:64]
|
row.vmix_version = str(vmix.get("version") or "")[:64]
|
||||||
if vmix.get("url") is not None:
|
if vmix.get("url") is not None:
|
||||||
@@ -1107,6 +1191,9 @@ class VmixAgentHub:
|
|||||||
*,
|
*,
|
||||||
device_id: str = "",
|
device_id: str = "",
|
||||||
session_token: str = "",
|
session_token: str = "",
|
||||||
|
sequence_id: str = "",
|
||||||
|
sequence_name: str = "",
|
||||||
|
button_id: str = "",
|
||||||
timeout: float = 4.0,
|
timeout: float = 4.0,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Execute commands on exactly one Agent bound to this browser/match session."""
|
"""Execute commands on exactly one Agent bound to this browser/match session."""
|
||||||
@@ -1239,6 +1326,13 @@ class VmixAgentHub:
|
|||||||
"results": results,
|
"results": results,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
self._track_runtime_overlay_command(
|
||||||
|
target_device_id,
|
||||||
|
command,
|
||||||
|
sequence_id=sequence_id,
|
||||||
|
sequence_name=sequence_name,
|
||||||
|
button_id=button_id,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"device_id": target_device_id,
|
"device_id": target_device_id,
|
||||||
@@ -1247,6 +1341,7 @@ class VmixAgentHub:
|
|||||||
"session_token": session_token,
|
"session_token": session_token,
|
||||||
"applied": len(results),
|
"applied": len(results),
|
||||||
"results": results,
|
"results": results,
|
||||||
|
"overlay_state": self._runtime_overlay_payload(target_device_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def admin_test_mapping_value(
|
async def admin_test_mapping_value(
|
||||||
@@ -2959,6 +3054,9 @@ class RuntimeVmixSequencePayload(BaseModel):
|
|||||||
commands: list[RuntimeVmixCommandPayload] = Field(default_factory=list, min_length=1, max_length=80)
|
commands: list[RuntimeVmixCommandPayload] = Field(default_factory=list, min_length=1, max_length=80)
|
||||||
device_id: str = Field(default="", max_length=128)
|
device_id: str = Field(default="", max_length=128)
|
||||||
session_token: str = Field(default="", max_length=128)
|
session_token: str = Field(default="", max_length=128)
|
||||||
|
sequence_id: str = Field(default="", max_length=160)
|
||||||
|
sequence_name: str = Field(default="", max_length=300)
|
||||||
|
button_id: str = Field(default="", max_length=160)
|
||||||
|
|
||||||
|
|
||||||
class SelectSessionDevicePayload(BaseModel):
|
class SelectSessionDevicePayload(BaseModel):
|
||||||
@@ -3215,8 +3313,18 @@ def create_hockey_agent_router(
|
|||||||
commands,
|
commands,
|
||||||
device_id=payload.device_id,
|
device_id=payload.device_id,
|
||||||
session_token=payload.session_token,
|
session_token=payload.session_token,
|
||||||
|
sequence_id=payload.sequence_id,
|
||||||
|
sequence_name=payload.sequence_name,
|
||||||
|
button_id=payload.button_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get("/api/hockey/vmix/overlay-state")
|
||||||
|
async def runtime_vmix_overlay_state(
|
||||||
|
device_id: str = Query(default="", max_length=128),
|
||||||
|
user: HockeyUser = Depends(auth_dependency),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return hub.runtime_overlay_state_for_user(user, device_id=device_id)
|
||||||
|
|
||||||
@router.post("/api/hockey/admin/vmix-mapping/apply-all-active", dependencies=admin)
|
@router.post("/api/hockey/admin/vmix-mapping/apply-all-active", dependencies=admin)
|
||||||
async def admin_apply_all_active_mappings() -> dict[str, Any]:
|
async def admin_apply_all_active_mappings() -> dict[str, Any]:
|
||||||
return await hub.apply_mapping_to_all_active_devices(reason="ui_language_changed")
|
return await hub.apply_mapping_to_all_active_devices(reason="ui_language_changed")
|
||||||
|
|||||||
56
tests/test_build83_penalty_advantage_and_server_onair.py
Normal file
56
tests/test_build83_penalty_advantage_and_server_onair.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hockey_data.agent_bridge import VmixAgentHub
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
|
BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_penalty_display_routes_single_side_to_opposite_advantage_target():
|
||||||
|
start = APP_JS.index("function penaltyDisplayEntriesByTargetSide")
|
||||||
|
end = APP_JS.index("function hockeyPenaltySideMappingDetail", start)
|
||||||
|
block = APP_JS[start:end]
|
||||||
|
assert 'if (home.length && away.length)' in block
|
||||||
|
assert 'return { home: [], away: home.slice(0, 1), routedToAdvantage: true };' in block
|
||||||
|
assert 'return { home: away.slice(0, 1), away: [], routedToAdvantage: true };' in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_penalty_rebalance_and_initial_timer_use_same_display_plan():
|
||||||
|
assert APP_JS.count("penaltyDisplayEntriesByTargetSide(step)") >= 2
|
||||||
|
assert "sourceSide" in APP_JS
|
||||||
|
assert "targetSide" in APP_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_tracks_acknowledged_overlay_owner_for_lower_dock():
|
||||||
|
hub = object.__new__(VmixAgentHub)
|
||||||
|
hub._runtime_overlay_state = {}
|
||||||
|
hub._track_runtime_overlay_command(
|
||||||
|
"device-1",
|
||||||
|
{"Function": "OverlayInput4In", "Input": "TITLE-A"},
|
||||||
|
sequence_id="seq-title-a",
|
||||||
|
sequence_name="Title A",
|
||||||
|
button_id="btn-a",
|
||||||
|
)
|
||||||
|
payload = hub._runtime_overlay_payload("device-1")
|
||||||
|
assert payload["overlays"]["4"]["input"] == "TITLE-A"
|
||||||
|
assert payload["overlays"]["4"]["sequence_id"] == "seq-title-a"
|
||||||
|
assert payload["overlays"]["4"]["button_id"] == "btn-a"
|
||||||
|
|
||||||
|
hub._track_runtime_overlay_command("device-1", {"Function": "OverlayInput4Out", "Input": "TITLE-A"})
|
||||||
|
assert hub._runtime_overlay_payload("device-1")["overlays"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_sequence_carries_owner_metadata_and_polling_recovers_state():
|
||||||
|
for token in (
|
||||||
|
'sequence_id: String(execution?.sequence_id || "")',
|
||||||
|
'sequence_name: String(execution?.sequence_name || "")',
|
||||||
|
'button_id: String(execution?.button_id || "")',
|
||||||
|
'function applyServerRuntimeOverlayState(payload)',
|
||||||
|
'function startQuickPanelOverlayPolling()',
|
||||||
|
'/api/hockey/vmix/overlay-state?device_id=',
|
||||||
|
'state.quickPanelServerOnAirSequences.has(id)',
|
||||||
|
):
|
||||||
|
assert token in APP_JS
|
||||||
|
assert 'sequence_id: str = Field(default="", max_length=160)' in BRIDGE
|
||||||
|
assert '@router.get("/api/hockey/vmix/overlay-state")' in BRIDGE
|
||||||
@@ -99,6 +99,9 @@
|
|||||||
quickPanelActiveTab: "",
|
quickPanelActiveTab: "",
|
||||||
vmixOverlayRuntime: new Map(),
|
vmixOverlayRuntime: new Map(),
|
||||||
quickPanelOnAirSequences: new Set(),
|
quickPanelOnAirSequences: new Set(),
|
||||||
|
quickPanelServerOnAirSequences: new Set(),
|
||||||
|
quickPanelOverlayPollTimer: null,
|
||||||
|
quickPanelOverlayPollPending: false,
|
||||||
triggerEditorOpenIds: new Set(),
|
triggerEditorOpenIds: new Set(),
|
||||||
shortcutSequenceOpenId: "",
|
shortcutSequenceOpenId: "",
|
||||||
shortcutInventory: { device_id: "", device_name: "", online: false, vmix_connected: false, inventory: { inputs: [] }, devices: [] },
|
shortcutInventory: { device_id: "", device_name: "", online: false, vmix_connected: false, inventory: { inputs: [] }, devices: [] },
|
||||||
@@ -4315,6 +4318,7 @@ function startCustomTooltips() {
|
|||||||
function shortcutSequenceIsOnAir(sequenceId) {
|
function shortcutSequenceIsOnAir(sequenceId) {
|
||||||
const id = String(sequenceId || "");
|
const id = String(sequenceId || "");
|
||||||
if (!id) return false;
|
if (!id) return false;
|
||||||
|
if (state.quickPanelServerOnAirSequences.has(id)) return true;
|
||||||
const sequence = shortcutSequenceById(id);
|
const sequence = shortcutSequenceById(id);
|
||||||
const targets = shortcutSequenceOverlayTargets(sequence);
|
const targets = shortcutSequenceOverlayTargets(sequence);
|
||||||
// BUILD58: first compare the actual Input currently tracked on the Overlay layer
|
// BUILD58: first compare the actual Input currently tracked on the Overlay layer
|
||||||
@@ -4342,6 +4346,74 @@ function startCustomTooltips() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function applyServerRuntimeOverlayState(payload) {
|
||||||
|
const source = payload?.overlay_state && typeof payload.overlay_state === "object" ? payload.overlay_state : payload;
|
||||||
|
const overlays = source?.overlays && typeof source.overlays === "object" ? source.overlays : {};
|
||||||
|
const previousServerIds = new Set(state.quickPanelServerOnAirSequences);
|
||||||
|
const nextServerIds = new Set();
|
||||||
|
|
||||||
|
for (const layer of ["1", "2", "3", "4"]) {
|
||||||
|
const raw = overlays[layer];
|
||||||
|
if (raw && typeof raw === "object") {
|
||||||
|
const entry = {
|
||||||
|
input: String(raw.input || ""),
|
||||||
|
sequence_id: String(raw.sequence_id || ""),
|
||||||
|
sequence_name: String(raw.sequence_name || ""),
|
||||||
|
button_id: String(raw.button_id || ""),
|
||||||
|
server_confirmed: true,
|
||||||
|
};
|
||||||
|
state.vmixOverlayRuntime.set(layer, entry);
|
||||||
|
if (entry.sequence_id) nextServerIds.add(entry.sequence_id);
|
||||||
|
} else {
|
||||||
|
const current = state.vmixOverlayRuntime.get(layer);
|
||||||
|
if (current?.server_confirmed) state.vmixOverlayRuntime.delete(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.quickPanelServerOnAirSequences = nextServerIds;
|
||||||
|
previousServerIds.forEach((sequenceId) => {
|
||||||
|
if (nextServerIds.has(sequenceId)) return;
|
||||||
|
if (!sequenceStillOwnsRuntimeOverlay(sequenceId)) {
|
||||||
|
state.quickPanelOnAirSequences.delete(sequenceId);
|
||||||
|
state.shortcutSequenceOverlayState.set(sequenceId, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
nextServerIds.forEach((sequenceId) => {
|
||||||
|
state.quickPanelOnAirSequences.add(sequenceId);
|
||||||
|
state.shortcutSequenceOverlayState.set(sequenceId, true);
|
||||||
|
});
|
||||||
|
refreshQuickPanelOnAirClasses();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollQuickPanelOverlayState({ force = false } = {}) {
|
||||||
|
if (state.quickPanelOverlayPollPending && !force) return false;
|
||||||
|
const deviceId = currentRuntimeVmixDeviceId();
|
||||||
|
if (!deviceId) return false;
|
||||||
|
state.quickPanelOverlayPollPending = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/hockey/vmix/overlay-state?device_id=${encodeURIComponent(deviceId)}`, {
|
||||||
|
method: "GET", cache: "no-store", credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const payload = await response.json();
|
||||||
|
applyServerRuntimeOverlayState(payload);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
state.quickPanelOverlayPollPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startQuickPanelOverlayPolling() {
|
||||||
|
if (state.quickPanelOverlayPollTimer) window.clearInterval(state.quickPanelOverlayPollTimer);
|
||||||
|
pollQuickPanelOverlayState({ force: true }).catch(() => {});
|
||||||
|
state.quickPanelOverlayPollTimer = window.setInterval(() => {
|
||||||
|
pollQuickPanelOverlayState().catch(() => {});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
function trackRuntimeOverlayCommands(commands, execution = null) {
|
function trackRuntimeOverlayCommands(commands, execution = null) {
|
||||||
const owner = execution && typeof execution === "object" ? execution : {};
|
const owner = execution && typeof execution === "object" ? execution : {};
|
||||||
const ownerSequenceId = String(owner.sequence_id || "").trim();
|
const ownerSequenceId = String(owner.sequence_id || "").trim();
|
||||||
@@ -4422,6 +4494,9 @@ function startCustomTooltips() {
|
|||||||
commands: clean,
|
commands: clean,
|
||||||
device_id: currentRuntimeVmixDeviceId(),
|
device_id: currentRuntimeVmixDeviceId(),
|
||||||
session_token: currentRuntimeHockeySessionToken(),
|
session_token: currentRuntimeHockeySessionToken(),
|
||||||
|
sequence_id: String(execution?.sequence_id || ""),
|
||||||
|
sequence_name: String(execution?.sequence_name || ""),
|
||||||
|
button_id: String(execution?.button_id || ""),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
let payload = {};
|
let payload = {};
|
||||||
@@ -4431,6 +4506,7 @@ function startCustomTooltips() {
|
|||||||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||||
}
|
}
|
||||||
trackRuntimeOverlayCommands(clean, execution);
|
trackRuntimeOverlayCommands(clean, execution);
|
||||||
|
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4541,6 +4617,34 @@ function startCustomTooltips() {
|
|||||||
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
|
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BUILD83: HOME/AWAY penalty targets describe where the extra scorebug plate
|
||||||
|
// is drawn, not which bench committed the penalty. While both teams have an
|
||||||
|
// active penalty we keep the traditional one-per-side display (coincidental
|
||||||
|
// penalties). As soon as only one team still has a penalty, its timer must
|
||||||
|
// move to the OPPOSITE target because that is the team playing on the power
|
||||||
|
// play. Example: HOME 1:43 + HOME 2:00 + AWAY 1:43 -> after the coincidental
|
||||||
|
// 1:43 pair expires, the remaining HOME 2:00 is shown through the AWAY input.
|
||||||
|
function penaltyDisplayEntriesByTargetSide(step) {
|
||||||
|
const home = sortedPenaltyEntries("home");
|
||||||
|
const away = sortedPenaltyEntries("away");
|
||||||
|
const soonestOnly = String(step?.penalty_display_mode || "soonest") !== "all";
|
||||||
|
// Legacy BUILD43 equivalent was `allEntries.slice(0, 1)`; routing is now
|
||||||
|
// calculated for both sides together so the remaining penalty can move to
|
||||||
|
// the power-play team target without losing the shortest-time behaviour.
|
||||||
|
if (!soonestOnly) return { home, away, routedToAdvantage: false };
|
||||||
|
|
||||||
|
if (home.length && away.length) {
|
||||||
|
return { home: home.slice(0, 1), away: away.slice(0, 1), routedToAdvantage: false };
|
||||||
|
}
|
||||||
|
if (home.length) {
|
||||||
|
return { home: [], away: home.slice(0, 1), routedToAdvantage: true };
|
||||||
|
}
|
||||||
|
if (away.length) {
|
||||||
|
return { home: away.slice(0, 1), away: [], routedToAdvantage: true };
|
||||||
|
}
|
||||||
|
return { home: [], away: [], routedToAdvantage: false };
|
||||||
|
}
|
||||||
|
|
||||||
function hockeyPenaltySideMappingDetail(item, side) {
|
function hockeyPenaltySideMappingDetail(item, side) {
|
||||||
if (!item?.event) return null;
|
if (!item?.event) return null;
|
||||||
const event = item.event;
|
const event = item.event;
|
||||||
@@ -4633,11 +4737,11 @@ function startCustomTooltips() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (step.penalty_vmix_mode !== "text") continue;
|
if (step.penalty_vmix_mode !== "text") continue;
|
||||||
|
const displayPlan = penaltyDisplayEntriesByTargetSide(step);
|
||||||
for (const side of ["home", "away"]) {
|
for (const side of ["home", "away"]) {
|
||||||
const allEntries = sortedPenaltyEntries(side);
|
|
||||||
const allTargets = sequencePenaltyTargets(step, side);
|
const allTargets = sequencePenaltyTargets(step, side);
|
||||||
const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all";
|
const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all";
|
||||||
const entries = soonestOnly ? allEntries.slice(0, 1) : allEntries;
|
const entries = Array.isArray(displayPlan[side]) ? displayPlan[side] : [];
|
||||||
const targets = soonestOnly ? allTargets.slice(0, 1) : allTargets;
|
const targets = soonestOnly ? allTargets.slice(0, 1) : allTargets;
|
||||||
const inactiveTargets = soonestOnly ? allTargets.slice(1) : [];
|
const inactiveTargets = soonestOnly ? allTargets.slice(1) : [];
|
||||||
targets.forEach((target, index) => {
|
targets.forEach((target, index) => {
|
||||||
@@ -4650,7 +4754,7 @@ function startCustomTooltips() {
|
|||||||
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,
|
targetId: target.id,
|
||||||
side,
|
side: String(entry.side || entry.event?.side || entry.event?.player?.side || side),
|
||||||
overlay: target.overlay,
|
overlay: target.overlay,
|
||||||
});
|
});
|
||||||
state.vmixPenaltyTargetAssignments.set(assignmentKey, {
|
state.vmixPenaltyTargetAssignments.set(assignmentKey, {
|
||||||
@@ -4658,6 +4762,8 @@ function startCustomTooltips() {
|
|||||||
input: target.input,
|
input: target.input,
|
||||||
selectedName: target.selected_name,
|
selectedName: target.selected_name,
|
||||||
overlay: target.overlay,
|
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);
|
||||||
@@ -4668,8 +4774,6 @@ function startCustomTooltips() {
|
|||||||
|
|
||||||
// If the scoreboard is already on air and this penalty target was not
|
// If the scoreboard is already on air and this penalty target was not
|
||||||
// previously assigned, bring the penalty plate on air immediately.
|
// previously assigned, bring the penalty plate on air immediately.
|
||||||
// Previously the text was updated here, but OverlayIn happened only
|
|
||||||
// when the whole scoreboard shortcut was executed again.
|
|
||||||
if (hockeyScoreboardIsLive()) {
|
if (hockeyScoreboardIsLive()) {
|
||||||
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";
|
||||||
const targetWasVisible = Boolean(previous?.input)
|
const targetWasVisible = Boolean(previous?.input)
|
||||||
@@ -4819,25 +4923,21 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (step.sync_vmix_penalties) {
|
if (step.sync_vmix_penalties) {
|
||||||
let sortedHome = penalties.filter((item) => item.side === "home").sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
|
const displayPlan = penaltyDisplayEntriesByTargetSide(step);
|
||||||
let sortedAway = penalties.filter((item) => item.side === "away").sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
|
|
||||||
const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all";
|
const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all";
|
||||||
const activeHomeTargets = soonestOnly ? homeTargets.slice(0, 1) : homeTargets;
|
const activeHomeTargets = soonestOnly ? homeTargets.slice(0, 1) : homeTargets;
|
||||||
const activeAwayTargets = soonestOnly ? awayTargets.slice(0, 1) : awayTargets;
|
const activeAwayTargets = soonestOnly ? awayTargets.slice(0, 1) : awayTargets;
|
||||||
if (soonestOnly) {
|
|
||||||
sortedHome = sortedHome.slice(0, 1);
|
|
||||||
sortedAway = sortedAway.slice(0, 1);
|
|
||||||
}
|
|
||||||
state.activeHockeyVmixTimerSteps.add(step.id);
|
state.activeHockeyVmixTimerSteps.add(step.id);
|
||||||
[["home", sortedHome, activeHomeTargets], ["away", sortedAway, activeAwayTargets]].forEach(([side, sideEntries, targets]) => {
|
[["home", displayPlan.home, activeHomeTargets], ["away", displayPlan.away, activeAwayTargets]].forEach(([side, sideEntries, targets]) => {
|
||||||
sideEntries.forEach(({ component, event }, index) => {
|
sideEntries.forEach(({ component, event }, index) => {
|
||||||
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") {
|
if (step.penalty_vmix_mode === "text") {
|
||||||
setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side, overlay: target.overlay });
|
const sourceSide = String(event.player?.side || event.side || side);
|
||||||
|
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,
|
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side,
|
||||||
});
|
});
|
||||||
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 if (pausing) {
|
||||||
@@ -12599,13 +12699,13 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="shortcut-section-title shortcut-section-title-with-action">
|
<div class="shortcut-section-title shortcut-section-title-with-action">
|
||||||
<span>Таймеры удалений HOME</span>
|
<span>Плашка удаления / большинства HOME</span>
|
||||||
<button type="button" class="mini-btn" data-penalty-target-add="home">+ Добавить слот HOME</button>
|
<button type="button" class="mini-btn" data-penalty-target-add="home">+ Добавить слот HOME</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="shortcut-penalty-target-list" data-penalty-target-list="home">${penaltyTargetEditorRows(step, "home")}</div>
|
<div class="shortcut-penalty-target-list" data-penalty-target-list="home">${penaltyTargetEditorRows(step, "home")}</div>
|
||||||
|
|
||||||
<div class="shortcut-section-title shortcut-section-title-with-action">
|
<div class="shortcut-section-title shortcut-section-title-with-action">
|
||||||
<span>Таймеры удалений AWAY</span>
|
<span>Плашка удаления / большинства AWAY</span>
|
||||||
<button type="button" class="mini-btn" data-penalty-target-add="away">+ Добавить слот AWAY</button>
|
<button type="button" class="mini-btn" data-penalty-target-add="away">+ Добавить слот AWAY</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="shortcut-penalty-target-list" data-penalty-target-list="away">${penaltyTargetEditorRows(step, "away")}</div>
|
<div class="shortcut-penalty-target-list" data-penalty-target-list="away">${penaltyTargetEditorRows(step, "away")}</div>
|
||||||
@@ -12624,7 +12724,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. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление на сторону: при двойном штрафе в поле идёт минимальное оставшееся время, а после его завершения то же поле автоматически переключается на следующий штраф. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p>
|
<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>
|
||||||
</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>`;
|
||||||
@@ -13489,6 +13589,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
el.runtimeView.classList.remove("hidden");
|
el.runtimeView.classList.remove("hidden");
|
||||||
el.closePreviewBtn.classList.add("hidden");
|
el.closePreviewBtn.classList.add("hidden");
|
||||||
renderRuntime();
|
renderRuntime();
|
||||||
|
startQuickPanelOverlayPolling();
|
||||||
window.UIBuilderRuntime?.patchData?.({ hockey: { ui: { active_tab: state.activeTab, previous_tab: "" } } }, { render: false });
|
window.UIBuilderRuntime?.patchData?.({ hockey: { ui: { active_tab: state.activeTab, previous_tab: "" } } }, { render: false });
|
||||||
rememberUiNavigationState(PROJECT_TABS_ACTION_ID, "project_tab", state.activeTab, state.activeTab, { emit: false });
|
rememberUiNavigationState(PROJECT_TABS_ACTION_ID, "project_tab", state.activeTab, state.activeTab, { emit: false });
|
||||||
hockeyRefreshVmixMappingForTab(state.activeTab).catch(() => {});
|
hockeyRefreshVmixMappingForTab(state.activeTab).catch(() => {});
|
||||||
|
|||||||
Reference in New Issue
Block a user