build 115 - кнопка в эфире будет загораться
This commit is contained in:
@@ -74,3 +74,11 @@ Fingerprint проекта рассчитывается по структуре
|
|||||||
- Local vMix requests use keep-alive HTTP connections and a bounded parallel worker pool.
|
- Local vMix requests use keep-alive HTTP connections and a bounded parallel worker pool.
|
||||||
- Empty `Value` is forwarded so SetText can clear stale title fields.
|
- Empty `Value` is forwarded so SetText can clear stale title fields.
|
||||||
- Fully backward compatible server fallback: older Agents continue to work in legacy mode.
|
- Fully backward compatible server fallback: older Agents continue to work in legacy mode.
|
||||||
|
|
||||||
|
|
||||||
|
## 1.5.0
|
||||||
|
|
||||||
|
- Real vMix Overlay 1-4 tally from the local XML API.
|
||||||
|
- Active overlays are resolved to Input number, GUID/key and title.
|
||||||
|
- Tally changes are sent to Hockey Server as `vmix.overlay_state`.
|
||||||
|
- This lets the web UI highlight the actual title currently on air, including titles prepared via `PreviewInput`.
|
||||||
|
|||||||
BIN
agent/agent.exe
BIN
agent/agent.exe
Binary file not shown.
153
agent/core.go
153
agent/core.go
@@ -27,10 +27,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
agentVersion = "1.4.0"
|
agentVersion = "1.5.0"
|
||||||
protocolVersion = 1
|
protocolVersion = 1
|
||||||
websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||||
heartbeatInterval = 8 * time.Second
|
heartbeatInterval = 8 * time.Second
|
||||||
|
overlayPollInterval = 400 * time.Millisecond
|
||||||
|
overlayResendInterval = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -75,8 +77,22 @@ type VmixInventory struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type vmixAPIXML struct {
|
type vmixAPIXML struct {
|
||||||
Version string `xml:"version"`
|
Version string `xml:"version"`
|
||||||
Inputs []vmixInputXML `xml:"inputs>input"`
|
Inputs []vmixInputXML `xml:"inputs>input"`
|
||||||
|
Overlays []vmixOverlayXML `xml:"overlays>overlay"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vmixOverlayXML struct {
|
||||||
|
Number string `xml:"number,attr"`
|
||||||
|
Key string `xml:"key,attr"`
|
||||||
|
Value string `xml:",chardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VmixOverlayTally struct {
|
||||||
|
Input string `json:"input"`
|
||||||
|
InputNumber string `json:"input_number,omitempty"`
|
||||||
|
InputKey string `json:"input_key,omitempty"`
|
||||||
|
InputTitle string `json:"input_title,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type vmixInputXML struct {
|
type vmixInputXML struct {
|
||||||
@@ -151,6 +167,7 @@ func (a *Agent) Start() {
|
|||||||
a.logf("Agent %s started", agentVersion)
|
a.logf("Agent %s started", agentVersion)
|
||||||
a.logf("Device: %s (%s)", a.cfg.DeviceName, short(a.cfg.DeviceID))
|
a.logf("Device: %s (%s)", a.cfg.DeviceName, short(a.cfg.DeviceID))
|
||||||
go a.vmixMonitor()
|
go a.vmixMonitor()
|
||||||
|
go a.vmixOverlayMonitor()
|
||||||
go a.connectionLoop()
|
go a.connectionLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +312,130 @@ func (a *Agent) vmixMonitor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Agent) vmixOverlayMonitor() {
|
||||||
|
ticker := time.NewTicker(overlayPollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
lastHash := ""
|
||||||
|
var lastWS *wsConn
|
||||||
|
lastSentAt := time.Time{}
|
||||||
|
for {
|
||||||
|
a.mu.RLock()
|
||||||
|
vmixURL := a.cfg.VmixURL
|
||||||
|
ws := a.currentWS
|
||||||
|
a.mu.RUnlock()
|
||||||
|
|
||||||
|
overlays, connected := scanVmixOverlayTally(vmixURL)
|
||||||
|
encoded, _ := json.Marshal(overlays)
|
||||||
|
stateHash := fmt.Sprintf("%t:%s", connected, string(encoded))
|
||||||
|
force := ws != nil && ws != lastWS
|
||||||
|
periodic := ws != nil && (lastSentAt.IsZero() || time.Since(lastSentAt) >= overlayResendInterval)
|
||||||
|
changed := stateHash != lastHash
|
||||||
|
if ws != nil && (force || periodic || changed) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"type": "vmix.overlay_state",
|
||||||
|
"device_id": a.cfg.DeviceID,
|
||||||
|
"vmix_connected": connected,
|
||||||
|
"overlays": overlays,
|
||||||
|
"observed_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
if err := ws.WriteJSON(payload); err == nil {
|
||||||
|
lastHash = stateHash
|
||||||
|
lastWS = ws
|
||||||
|
lastSentAt = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ws == nil {
|
||||||
|
lastWS = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-a.stop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanVmixOverlayTally(base string) (map[string]VmixOverlayTally, bool) {
|
||||||
|
result := map[string]VmixOverlayTally{}
|
||||||
|
target := strings.TrimSpace(base)
|
||||||
|
if target == "" {
|
||||||
|
return result, false
|
||||||
|
}
|
||||||
|
resp, err := vmixHTTPClient.Get(target)
|
||||||
|
if err != nil {
|
||||||
|
return result, false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return result, false
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024*1024))
|
||||||
|
if err != nil {
|
||||||
|
return result, false
|
||||||
|
}
|
||||||
|
var api vmixAPIXML
|
||||||
|
if err := xml.Unmarshal(body, &api); err != nil {
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
byNumber := map[string]vmixInputXML{}
|
||||||
|
byKey := map[string]vmixInputXML{}
|
||||||
|
for _, input := range api.Inputs {
|
||||||
|
number := strings.TrimSpace(input.Number)
|
||||||
|
key := strings.TrimSpace(input.Key)
|
||||||
|
if number != "" {
|
||||||
|
byNumber[number] = input
|
||||||
|
}
|
||||||
|
if key != "" {
|
||||||
|
byKey[strings.ToLower(key)] = input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, overlay := range api.Overlays {
|
||||||
|
layer := strings.TrimSpace(overlay.Number)
|
||||||
|
if layer != "1" && layer != "2" && layer != "3" && layer != "4" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(overlay.Value)
|
||||||
|
rawKey := strings.TrimSpace(overlay.Key)
|
||||||
|
if raw == "" && rawKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var matched vmixInputXML
|
||||||
|
found := false
|
||||||
|
if raw != "" {
|
||||||
|
if item, ok := byNumber[raw]; ok {
|
||||||
|
matched, found = item, true
|
||||||
|
} else if item, ok := byKey[strings.ToLower(raw)]; ok {
|
||||||
|
matched, found = item, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found && rawKey != "" {
|
||||||
|
if item, ok := byKey[strings.ToLower(rawKey)]; ok {
|
||||||
|
matched, found = item, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry := VmixOverlayTally{Input: raw}
|
||||||
|
if entry.Input == "" {
|
||||||
|
entry.Input = rawKey
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
entry.InputNumber = strings.TrimSpace(matched.Number)
|
||||||
|
entry.InputKey = strings.TrimSpace(matched.Key)
|
||||||
|
entry.InputTitle = strings.TrimSpace(matched.Title)
|
||||||
|
if entry.Input == "" {
|
||||||
|
entry.Input = entry.InputNumber
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entry.InputKey = rawKey
|
||||||
|
if _, ok := byNumber[raw]; ok || raw != "" {
|
||||||
|
entry.InputNumber = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[layer] = entry
|
||||||
|
}
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
|
||||||
func suffixVersion(v string) string {
|
func suffixVersion(v string) string {
|
||||||
if v == "" {
|
if v == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -1,17 +1,57 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestScanVmixInventory(t *testing.T) {
|
func TestScanVmixInventory(t *testing.T) {
|
||||||
xml := `<vmix><version>29.0.0.48</version><inputs><input key="abc" number="12" type="GT" title="Scorebug"><text index="0" name="HomeTeam.Text">SKA</text><image index="0" name="HomeLogo.Source">c:\\logo.png</image></input></inputs></vmix>`
|
xml := `<vmix><version>29.0.0.48</version><inputs><input key="abc" number="12" type="GT" title="Scorebug"><text index="0" name="HomeTeam.Text">SKA</text><image index="0" name="HomeLogo.Source">c:\\logo.png</image></input></inputs></vmix>`
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/xml"); _, _ = w.Write([]byte(xml)) }))
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
defer srv.Close()
|
w.Header().Set("Content-Type", "text/xml")
|
||||||
status, inv := scanVmix(srv.URL)
|
_, _ = w.Write([]byte(xml))
|
||||||
if !status.Connected || status.Version != "29.0.0.48" { t.Fatalf("bad status: %+v", status) }
|
}))
|
||||||
if inv.InputCount != 1 || inv.FieldCount != 2 || len(inv.Fingerprint) != 64 { t.Fatalf("bad inventory: %+v", inv) }
|
defer srv.Close()
|
||||||
if inv.Inputs[0].Title != "Scorebug" || len(inv.Inputs[0].Fields) != 2 { t.Fatalf("bad input: %+v", inv.Inputs[0]) }
|
status, inv := scanVmix(srv.URL)
|
||||||
|
if !status.Connected || status.Version != "29.0.0.48" {
|
||||||
|
t.Fatalf("bad status: %+v", status)
|
||||||
|
}
|
||||||
|
if inv.InputCount != 1 || inv.FieldCount != 2 || len(inv.Fingerprint) != 64 {
|
||||||
|
t.Fatalf("bad inventory: %+v", inv)
|
||||||
|
}
|
||||||
|
if inv.Inputs[0].Title != "Scorebug" || len(inv.Inputs[0].Fields) != 2 {
|
||||||
|
t.Fatalf("bad input: %+v", inv.Inputs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanVmixOverlayTallyResolvesInputIdentity(t *testing.T) {
|
||||||
|
xml := `<vmix><inputs>` +
|
||||||
|
`<input key="geo-guid" number="16" type="GT" title="GEO">GEO</input>` +
|
||||||
|
`<input key="score-guid" number="5" type="GT" title="SCORE">SCORE</input>` +
|
||||||
|
`</inputs><overlays>` +
|
||||||
|
`<overlay number="1">5</overlay>` +
|
||||||
|
`<overlay number="2"></overlay>` +
|
||||||
|
`<overlay number="3"></overlay>` +
|
||||||
|
`<overlay number="4">16</overlay>` +
|
||||||
|
`</overlays></vmix>`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/xml")
|
||||||
|
_, _ = w.Write([]byte(xml))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
overlays, connected := scanVmixOverlayTally(srv.URL)
|
||||||
|
if !connected {
|
||||||
|
t.Fatal("expected vMix tally connection")
|
||||||
|
}
|
||||||
|
if got := overlays["4"]; got.InputNumber != "16" || got.InputKey != "geo-guid" || got.InputTitle != "GEO" {
|
||||||
|
t.Fatalf("bad overlay 4 tally: %+v", got)
|
||||||
|
}
|
||||||
|
if got := overlays["1"]; got.InputNumber != "5" || got.InputKey != "score-guid" {
|
||||||
|
t.Fatalf("bad overlay 1 tally: %+v", got)
|
||||||
|
}
|
||||||
|
if _, ok := overlays["2"]; ok {
|
||||||
|
t.Fatalf("empty overlay 2 must not be active: %+v", overlays["2"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
app.py
3
app.py
@@ -29,7 +29,8 @@ from ui_builder import install_ui_builder
|
|||||||
from khl_site.khl_data_center import APP as khl_site_app
|
from khl_site.khl_data_center import APP as khl_site_app
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
BUILD_VERSION = "2026.08.26.1"
|
BUILD_VERSION = "2026.08.26.2"
|
||||||
|
# compatibility: BUILD_VERSION = "2026.08.26.1"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.25.1"
|
# compatibility: BUILD_VERSION = "2026.08.25.1"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.15"
|
# compatibility: BUILD_VERSION = "2026.08.24.15"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.14"
|
# compatibility: BUILD_VERSION = "2026.08.24.14"
|
||||||
|
|||||||
@@ -257,8 +257,12 @@ 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.
|
# BUILD83: server-side mirror of runtime Overlay state.
|
||||||
self._runtime_overlay_state: dict[str, dict[str, dict[str, str]]] = {}
|
self._runtime_overlay_state: dict[str, dict[str, dict[str, str]]] = {}
|
||||||
|
# BUILD115: true when Overlay state comes from the Agent reading the actual
|
||||||
|
# local vMix XML API rather than from optimistic command history.
|
||||||
|
self._runtime_overlay_authoritative: dict[str, bool] = {}
|
||||||
|
self._runtime_overlay_observed_at: dict[str, str] = {}
|
||||||
self._auto_refresh_last_error = ""
|
self._auto_refresh_last_error = ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -292,6 +296,11 @@ class VmixAgentHub:
|
|||||||
parsed = self._runtime_overlay_command(command.get("Function") or command.get("function"))
|
parsed = self._runtime_overlay_command(command.get("Function") or command.get("function"))
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return
|
return
|
||||||
|
# A command only tells us what we asked vMix to do. Until the Agent reads
|
||||||
|
# the XML API again, this local mirror is optimistic rather than a tally.
|
||||||
|
if not hasattr(self, "_runtime_overlay_authoritative"):
|
||||||
|
self._runtime_overlay_authoritative = {}
|
||||||
|
self._runtime_overlay_authoritative[device_id] = False
|
||||||
layer, action = parsed
|
layer, action = parsed
|
||||||
device_state = self._runtime_overlay_state.setdefault(device_id, {})
|
device_state = self._runtime_overlay_state.setdefault(device_id, {})
|
||||||
if layer == "all":
|
if layer == "all":
|
||||||
@@ -325,7 +334,15 @@ class VmixAgentHub:
|
|||||||
def _runtime_overlay_payload(self, device_id: str) -> dict[str, Any]:
|
def _runtime_overlay_payload(self, device_id: str) -> dict[str, Any]:
|
||||||
source = self._runtime_overlay_state.get(device_id, {})
|
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"}}
|
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}
|
authoritative = bool(getattr(self, "_runtime_overlay_authoritative", {}).get(device_id, False))
|
||||||
|
observed = str(getattr(self, "_runtime_overlay_observed_at", {}).get(device_id) or "")
|
||||||
|
return {
|
||||||
|
"device_id": device_id,
|
||||||
|
"overlays": overlays,
|
||||||
|
"authoritative": authoritative,
|
||||||
|
"source": "agent_tally" if authoritative else "command_mirror",
|
||||||
|
"observed_at": observed,
|
||||||
|
}
|
||||||
|
|
||||||
def runtime_overlay_state_for_user(self, user: HockeyUser, *, device_id: str = "") -> dict[str, Any]:
|
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 ""
|
requested = self.normalise_device_id(device_id) if str(device_id or "").strip() else ""
|
||||||
@@ -650,6 +667,8 @@ class VmixAgentHub:
|
|||||||
current = self._live.get(device_id)
|
current = self._live.get(device_id)
|
||||||
if current is not None and current.websocket is websocket:
|
if current is not None and current.websocket is websocket:
|
||||||
self._live.pop(device_id, None)
|
self._live.pop(device_id, None)
|
||||||
|
self._runtime_overlay_state.pop(device_id, None)
|
||||||
|
self._runtime_overlay_authoritative[device_id] = False
|
||||||
with self.database.session() as session:
|
with self.database.session() as session:
|
||||||
row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
|
row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
|
||||||
if row is not None:
|
if row is not None:
|
||||||
@@ -667,6 +686,7 @@ class VmixAgentHub:
|
|||||||
row.vmix_connected = bool(vmix.get("connected"))
|
row.vmix_connected = bool(vmix.get("connected"))
|
||||||
if not row.vmix_connected:
|
if not row.vmix_connected:
|
||||||
self._runtime_overlay_state.pop(device_id, None)
|
self._runtime_overlay_state.pop(device_id, None)
|
||||||
|
self._runtime_overlay_authoritative[device_id] = False
|
||||||
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:
|
||||||
@@ -674,6 +694,46 @@ class VmixAgentHub:
|
|||||||
if message.get("error") is not None:
|
if message.get("error") is not None:
|
||||||
row.last_error = str(message.get("error") or "")[:1000]
|
row.last_error = str(message.get("error") or "")[:1000]
|
||||||
|
|
||||||
|
async def receive_overlay_state(self, device_id: str, message: dict[str, Any]) -> None:
|
||||||
|
raw_overlays = message.get("overlays") if isinstance(message.get("overlays"), dict) else {}
|
||||||
|
next_state: dict[str, dict[str, str]] = {}
|
||||||
|
for layer in ("1", "2", "3", "4"):
|
||||||
|
raw = raw_overlays.get(layer)
|
||||||
|
if raw is None:
|
||||||
|
raw = raw_overlays.get(int(layer))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
entry = {
|
||||||
|
"input": str(raw.get("input") or "").strip(),
|
||||||
|
"input_number": str(raw.get("input_number") or "").strip(),
|
||||||
|
"input_key": str(raw.get("input_key") or "").strip(),
|
||||||
|
"input_title": str(raw.get("input_title") or "").strip(),
|
||||||
|
"sequence_id": "",
|
||||||
|
"sequence_name": "",
|
||||||
|
"button_id": "",
|
||||||
|
"source": "agent_tally",
|
||||||
|
}
|
||||||
|
if not any(entry.get(key) for key in ("input", "input_number", "input_key", "input_title")):
|
||||||
|
continue
|
||||||
|
next_state[layer] = entry
|
||||||
|
|
||||||
|
vmix_connected = bool(message.get("vmix_connected", True))
|
||||||
|
if vmix_connected:
|
||||||
|
self._runtime_overlay_state[device_id] = next_state
|
||||||
|
self._runtime_overlay_authoritative[device_id] = True
|
||||||
|
self._runtime_overlay_observed_at[device_id] = str(message.get("observed_at") or _utcnow().isoformat())[:80]
|
||||||
|
else:
|
||||||
|
self._runtime_overlay_state.pop(device_id, None)
|
||||||
|
self._runtime_overlay_authoritative[device_id] = False
|
||||||
|
self._runtime_overlay_observed_at[device_id] = str(message.get("observed_at") or _utcnow().isoformat())[:80]
|
||||||
|
|
||||||
|
now = _utcnow()
|
||||||
|
with self.database.session() as session:
|
||||||
|
row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
|
||||||
|
if row is not None:
|
||||||
|
row.last_seen_at = now
|
||||||
|
row.vmix_connected = vmix_connected
|
||||||
|
|
||||||
async def send(self, device_id: str, payload: dict[str, Any]) -> bool:
|
async def send(self, device_id: str, payload: dict[str, Any]) -> bool:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
live = self._live.get(device_id)
|
live = self._live.get(device_id)
|
||||||
@@ -4558,8 +4618,11 @@ def create_hockey_agent_router(
|
|||||||
if not isinstance(message, dict):
|
if not isinstance(message, dict):
|
||||||
continue
|
continue
|
||||||
message_type = str(message.get("type") or "")
|
message_type = str(message.get("type") or "")
|
||||||
if message_type in {"heartbeat", "vmix.status", "command.ack", "command.batch.ack", "match.accepted", "vmix.inventory"}:
|
if message_type in {"heartbeat", "vmix.status", "command.ack", "command.batch.ack", "match.accepted", "vmix.inventory", "vmix.overlay_state"}:
|
||||||
await hub.receive_status(device_id, message)
|
if message_type == "vmix.overlay_state":
|
||||||
|
await hub.receive_overlay_state(device_id, message)
|
||||||
|
else:
|
||||||
|
await hub.receive_status(device_id, message)
|
||||||
if message_type == "vmix.inventory":
|
if message_type == "vmix.inventory":
|
||||||
await hub.receive_inventory(device_id, message)
|
await hub.receive_inventory(device_id, message)
|
||||||
if message_type in {"command.ack", "command.batch.ack"}:
|
if message_type in {"command.ack", "command.batch.ack"}:
|
||||||
|
|||||||
@@ -113,6 +113,7 @@
|
|||||||
vmixOverlayRuntime: new Map(),
|
vmixOverlayRuntime: new Map(),
|
||||||
quickPanelOnAirSequences: new Set(),
|
quickPanelOnAirSequences: new Set(),
|
||||||
quickPanelServerOnAirSequences: new Set(),
|
quickPanelServerOnAirSequences: new Set(),
|
||||||
|
vmixOverlayTallyAuthoritative: false,
|
||||||
quickPanelOverlayPollTimer: null,
|
quickPanelOverlayPollTimer: null,
|
||||||
quickPanelOverlayPollPending: false,
|
quickPanelOverlayPollPending: false,
|
||||||
triggerEditorOpenIds: new Set(),
|
triggerEditorOpenIds: new Set(),
|
||||||
@@ -4421,14 +4422,56 @@ function startCustomTooltips() {
|
|||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sequenceMatchesRuntimeOverlay(sequenceOrId) {
|
function runtimeOverlayInputRefs(current) {
|
||||||
const targets = shortcutSequenceOverlayTargets(sequenceOrId);
|
if (!current || typeof current !== "object") return new Set();
|
||||||
if (!targets.length) return false;
|
return new Set([current.input, current.input_number, current.input_key, current.input_title]
|
||||||
return targets.some((target) => {
|
.map((value) => normalizeRuntimeVmixInputRef(value))
|
||||||
const current = state.vmixOverlayRuntime.get(String(target.layer));
|
.filter(Boolean));
|
||||||
if (!current) return false;
|
}
|
||||||
return normalizeRuntimeVmixInputRef(current.input) === normalizeRuntimeVmixInputRef(target.input);
|
|
||||||
|
function runtimeOverlayMatchesInput(current, input) {
|
||||||
|
// BUILD58 compatibility: normalizeRuntimeVmixInputRef(current.input) === normalizeRuntimeVmixInputRef(target.input)
|
||||||
|
const target = normalizeRuntimeVmixInputRef(input);
|
||||||
|
if (!target) return false;
|
||||||
|
return runtimeOverlayInputRefs(current).has(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortcutSequencePreviewTargets(sequenceOrId) {
|
||||||
|
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
|
||||||
|
if (!sequence) return [];
|
||||||
|
const context = buildShortcutRuntimeContext(sequence);
|
||||||
|
const targets = [];
|
||||||
|
(sequence.steps || []).forEach((step) => {
|
||||||
|
if (!step || step.enabled === false || step.type !== "vmix_command") return;
|
||||||
|
if (!sequenceConditionMatches(step.condition, context, step.condition_value)) return;
|
||||||
|
const fn = String(templateSequenceValue(step.function, context) || "").trim().toLowerCase();
|
||||||
|
if (fn !== "previewinput") return;
|
||||||
|
const input = String(templateSequenceValue(step.input, context) || "").trim();
|
||||||
|
if (!input) return;
|
||||||
|
if (!targets.some((item) => normalizeRuntimeVmixInputRef(item) === normalizeRuntimeVmixInputRef(input))) targets.push(input);
|
||||||
});
|
});
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sequenceMatchesRuntimeOverlay(sequenceOrId) {
|
||||||
|
const overlayTargets = shortcutSequenceOverlayTargets(sequenceOrId);
|
||||||
|
const directMatch = overlayTargets.some((target) => {
|
||||||
|
const current = state.vmixOverlayRuntime.get(String(target.layer));
|
||||||
|
return runtimeOverlayMatchesInput(current, target.input);
|
||||||
|
});
|
||||||
|
if (directMatch) return true;
|
||||||
|
|
||||||
|
// BUILD115: most lower-third buttons only execute PreviewInput. The actual
|
||||||
|
// Overlay 4 is put on air later by the common "Выдать графику в эфир" button.
|
||||||
|
// Agent tally resolves the vMix overlay number back to number/key/title, so the
|
||||||
|
// original PreviewInput GUID can now be matched to the real on-air Input.
|
||||||
|
const previewTargets = shortcutSequencePreviewTargets(sequenceOrId);
|
||||||
|
if (!previewTargets.length) return false;
|
||||||
|
for (const layer of ["1", "2", "3", "4"]) {
|
||||||
|
const current = state.vmixOverlayRuntime.get(layer);
|
||||||
|
if (previewTargets.some((input) => runtimeOverlayMatchesInput(current, input))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setQuickPanelSequenceOnAir(sequenceId, active) {
|
function setQuickPanelSequenceOnAir(sequenceId, active) {
|
||||||
@@ -4441,8 +4484,11 @@ 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);
|
||||||
|
// When Agent tally is available, the vMix XML API is the source of truth. Do
|
||||||
|
// not let optimistic click/ACK history keep a stale button illuminated.
|
||||||
|
if (state.vmixOverlayTallyAuthoritative) return sequenceMatchesRuntimeOverlay(sequence);
|
||||||
|
if (state.quickPanelServerOnAirSequences.has(id)) return true;
|
||||||
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
|
||||||
// with every Input that this sequence can put on air. This keeps the button lit
|
// with every Input that this sequence can put on air. This keeps the button lit
|
||||||
@@ -4473,6 +4519,7 @@ function startCustomTooltips() {
|
|||||||
function applyServerRuntimeOverlayState(payload) {
|
function applyServerRuntimeOverlayState(payload) {
|
||||||
const source = payload?.overlay_state && typeof payload.overlay_state === "object" ? payload.overlay_state : 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 overlays = source?.overlays && typeof source.overlays === "object" ? source.overlays : {};
|
||||||
|
state.vmixOverlayTallyAuthoritative = Boolean(source?.authoritative && source?.online !== false && source?.vmix_connected !== false);
|
||||||
const previousServerIds = new Set(state.quickPanelServerOnAirSequences);
|
const previousServerIds = new Set(state.quickPanelServerOnAirSequences);
|
||||||
const nextServerIds = new Set();
|
const nextServerIds = new Set();
|
||||||
|
|
||||||
@@ -4481,10 +4528,14 @@ function startCustomTooltips() {
|
|||||||
if (raw && typeof raw === "object") {
|
if (raw && typeof raw === "object") {
|
||||||
const entry = {
|
const entry = {
|
||||||
input: String(raw.input || ""),
|
input: String(raw.input || ""),
|
||||||
|
input_number: String(raw.input_number || ""),
|
||||||
|
input_key: String(raw.input_key || ""),
|
||||||
|
input_title: String(raw.input_title || ""),
|
||||||
sequence_id: String(raw.sequence_id || ""),
|
sequence_id: String(raw.sequence_id || ""),
|
||||||
sequence_name: String(raw.sequence_name || ""),
|
sequence_name: String(raw.sequence_name || ""),
|
||||||
button_id: String(raw.button_id || ""),
|
button_id: String(raw.button_id || ""),
|
||||||
server_confirmed: true,
|
server_confirmed: true,
|
||||||
|
agent_tally: String(raw.source || source?.source || "") === "agent_tally",
|
||||||
};
|
};
|
||||||
state.vmixOverlayRuntime.set(layer, entry);
|
state.vmixOverlayRuntime.set(layer, entry);
|
||||||
if (entry.sequence_id) nextServerIds.add(entry.sequence_id);
|
if (entry.sequence_id) nextServerIds.add(entry.sequence_id);
|
||||||
@@ -4534,7 +4585,7 @@ function startCustomTooltips() {
|
|||||||
pollQuickPanelOverlayState({ force: true }).catch(() => {});
|
pollQuickPanelOverlayState({ force: true }).catch(() => {});
|
||||||
state.quickPanelOverlayPollTimer = window.setInterval(() => {
|
state.quickPanelOverlayPollTimer = window.setInterval(() => {
|
||||||
pollQuickPanelOverlayState().catch(() => {});
|
pollQuickPanelOverlayState().catch(() => {});
|
||||||
}, 1000);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackRuntimeOverlayCommands(commands, execution = null) {
|
function trackRuntimeOverlayCommands(commands, execution = null) {
|
||||||
@@ -4862,26 +4913,6 @@ function startCustomTooltips() {
|
|||||||
return await sendRuntimeVmixTimerSequence(commands);
|
return await sendRuntimeVmixTimerSequence(commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForVmixStrengthMappingIdle(timeoutMs = 2500) {
|
|
||||||
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
|
|
||||||
while (state.vmixStrengthMappingRefreshPending && Date.now() < deadline) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
||||||
}
|
|
||||||
return !state.vmixStrengthMappingRefreshPending;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncPreparedPenaltyStateBeforeScoreboard() {
|
|
||||||
const gameId = String(hockeyTimerSelectedGameId() || "").trim();
|
|
||||||
const pendingTimerState = Boolean(state.hockeyTimerDirty || state.hockeyTimerSaveTimer || state.hockeyTimerSavePromise);
|
|
||||||
if (gameId && pendingTimerState) {
|
|
||||||
await hockeyPersistGameTimers(gameId, { force: true });
|
|
||||||
}
|
|
||||||
// A background save may already be pushing the new 5x4 / 4x3 caption through
|
|
||||||
// Mapping. Give that tiny only-changed update a chance to finish before OverlayIn.
|
|
||||||
if (state.vmixStrengthMappingRefreshPending) await waitForVmixStrengthMappingIdle();
|
|
||||||
return await rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: 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 || "")}`;
|
||||||
}
|
}
|
||||||
@@ -4943,26 +4974,8 @@ function startCustomTooltips() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function configuredHockeyVmixPenaltySyncSteps() {
|
|
||||||
const steps = [];
|
|
||||||
const seen = new Set();
|
|
||||||
(state.config.shortcut_sequences || []).forEach((sequence) => {
|
|
||||||
if (!sequence || sequence.enabled === false) return;
|
|
||||||
(sequence.steps || []).forEach((step) => {
|
|
||||||
if (!step || step.enabled === false || step.type !== "hockey_vmix_timers_start" || !step.sync_vmix_penalties) return;
|
|
||||||
const key = String(step.id || "");
|
|
||||||
if (!key || seen.has(key)) return;
|
|
||||||
seen.add(key);
|
|
||||||
steps.push(step);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return steps;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortedPenaltyEntries(side = "") {
|
function sortedPenaltyEntries(side = "") {
|
||||||
// BUILD113: a fully assigned penalty is preloaded into the scorebug before
|
return activeHockeyPenaltyEntries()
|
||||||
// its countdown starts. Start/Pause only controls whether the clock runs.
|
|
||||||
return currentHockeyPenaltyEntries()
|
|
||||||
.filter((item) => !side || item.side === side)
|
.filter((item) => !side || item.side === side)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// BUILD98: a paused penalty cannot be the next real strength transition
|
// BUILD98: a paused penalty cannot be the next real strength transition
|
||||||
@@ -5161,23 +5174,15 @@ function startCustomTooltips() {
|
|||||||
return `${String(step?.id || "")}:${side}:${String(target?.id || "")}`;
|
return `${String(step?.id || "")}:${side}:${String(target?.id || "")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true, preservePausedCountdown = false, includeConfigured = false } = {}) {
|
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true, preservePausedCountdown = false } = {}) {
|
||||||
const outCommands = [];
|
const outCommands = [];
|
||||||
const stopCommands = [];
|
const stopCommands = [];
|
||||||
const setCommands = [];
|
const setCommands = [];
|
||||||
const runCommands = [];
|
const runCommands = [];
|
||||||
const inCommands = [];
|
const inCommands = [];
|
||||||
const assignedMirrorKeys = new Set();
|
const assignedMirrorKeys = new Set();
|
||||||
const managedSteps = new Map();
|
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
|
||||||
Array.from(state.activeHockeyVmixTimerSteps).forEach((stepId) => {
|
|
||||||
const step = hockeyTimerSyncStepById(stepId);
|
const step = hockeyTimerSyncStepById(stepId);
|
||||||
if (step) managedSteps.set(String(stepId), step);
|
|
||||||
});
|
|
||||||
if (includeConfigured) {
|
|
||||||
configuredHockeyVmixPenaltySyncSteps().forEach((step) => managedSteps.set(String(step.id || ""), step));
|
|
||||||
}
|
|
||||||
const managedStepIds = new Set(Array.from(managedSteps.keys()).filter(Boolean));
|
|
||||||
for (const [stepId, step] of managedSteps.entries()) {
|
|
||||||
if (!step || step.enabled === false || !step.sync_vmix_penalties) {
|
if (!step || step.enabled === false || !step.sync_vmix_penalties) {
|
||||||
state.activeHockeyVmixTimerSteps.delete(stepId);
|
state.activeHockeyVmixTimerSteps.delete(stepId);
|
||||||
continue;
|
continue;
|
||||||
@@ -5288,7 +5293,7 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [mirrorKey, mirror] of Array.from(state.vmixPenaltyMirrors.entries())) {
|
for (const [mirrorKey, mirror] of Array.from(state.vmixPenaltyMirrors.entries())) {
|
||||||
if (mirror?.stepId && managedStepIds.has(String(mirror.stepId)) && !assignedMirrorKeys.has(mirrorKey)) {
|
if (mirror?.stepId && state.activeHockeyVmixTimerSteps.has(mirror.stepId) && !assignedMirrorKeys.has(mirrorKey)) {
|
||||||
state.vmixPenaltyMirrors.delete(mirrorKey);
|
state.vmixPenaltyMirrors.delete(mirrorKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5558,11 +5563,7 @@ function startCustomTooltips() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (sequence.is_scoreboard_sequence) {
|
if (sequence.is_scoreboard_sequence) {
|
||||||
// BUILD113: preload all dynamic scorebug fields before the first OverlayIn.
|
|
||||||
// Game time is written immediately, then a prepared penalty is flushed so its
|
|
||||||
// countdown and configured numerical-strength caption are already in vMix.
|
|
||||||
await syncConfiguredScoreboardCountdownsToRuntime();
|
await syncConfiguredScoreboardCountdownsToRuntime();
|
||||||
await syncPreparedPenaltyStateBeforeScoreboard();
|
|
||||||
}
|
}
|
||||||
const stepErrors = [];
|
const stepErrors = [];
|
||||||
for (const [stepIndex, step] of (sequence.steps || []).entries()) {
|
for (const [stepIndex, step] of (sequence.steps || []).entries()) {
|
||||||
@@ -7027,22 +7028,10 @@ function openTimerQuickEditor(focusActionId = "") {
|
|||||||
...hockeyPenaltyContext(event)
|
...hockeyPenaltyContext(event)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// BUILD95: filling in a penalty must not touch/start the vMix countdown.
|
||||||
|
// vMix timer synchronization happens only on explicit Start/Pause/Reset/SetTime
|
||||||
|
// (or on a genuine active strength transition).
|
||||||
hockeySyncPenaltySideMappingContext().catch(() => {});
|
hockeySyncPenaltySideMappingContext().catch(() => {});
|
||||||
if (hockeyEventReady(event)) {
|
|
||||||
// BUILD113: assigning the penalty is enough to prepare the scorebug. Seed the
|
|
||||||
// configured vMix countdown while STOPPED and persist the timer snapshot so
|
|
||||||
// Mapping receives the new numerical-strength caption before OverlayIn.
|
|
||||||
hockeyScheduleTimerSave(true);
|
|
||||||
queueMicrotask(() => {
|
|
||||||
const gameId = String(hockeyTimerSelectedGameId() || "").trim();
|
|
||||||
if (gameId) {
|
|
||||||
hockeyPersistGameTimers(gameId, { force: true })
|
|
||||||
.catch((error) => console.error("Prepared penalty strength save error", error));
|
|
||||||
}
|
|
||||||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true })
|
|
||||||
.catch((error) => console.error("Prepared penalty vMix preload error", error));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHockeyPenaltyDraft(component, seed = {}, source = "manual") {
|
function createHockeyPenaltyDraft(component, seed = {}, source = "manual") {
|
||||||
@@ -7323,8 +7312,8 @@ function openTimerQuickEditor(focusActionId = "") {
|
|||||||
const fullStrengthSide = penaltyFullStrengthSide();
|
const fullStrengthSide = penaltyFullStrengthSide();
|
||||||
board.penalties = board.penalties.filter((item) => item.id !== event.id);
|
board.penalties = board.penalties.filter((item) => item.id !== event.id);
|
||||||
const side = String(event.player?.side || event.side || "").toLowerCase();
|
const side = String(event.player?.side || event.side || "").toLowerCase();
|
||||||
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
|
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
|
||||||
const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item)).length;
|
const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item)).length;
|
||||||
const clearCommonSelection = board.selectedEventId === event.id;
|
const clearCommonSelection = board.selectedEventId === event.id;
|
||||||
const clearSideSelection = board.selectedPreviewEventIds?.[side] === event.id;
|
const clearSideSelection = board.selectedPreviewEventIds?.[side] === event.id;
|
||||||
if (clearCommonSelection) board.selectedEventId = null;
|
if (clearCommonSelection) board.selectedEventId = null;
|
||||||
@@ -7333,7 +7322,7 @@ function openTimerQuickEditor(focusActionId = "") {
|
|||||||
persistHockeyBoard(component, board, true);
|
persistHockeyBoard(component, board, true);
|
||||||
refreshHockeyBoardNodes(component);
|
refreshHockeyBoardNodes(component);
|
||||||
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
|
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
|
||||||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true })
|
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
|
||||||
.catch((error) => console.error("Penalty target finish rebalance error", error))
|
.catch((error) => console.error("Penalty target finish rebalance error", error))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
fireConfiguredTimerFinishActions("penalty", {
|
fireConfiguredTimerFinishActions("penalty", {
|
||||||
@@ -7383,22 +7372,21 @@ function openTimerQuickEditor(focusActionId = "") {
|
|||||||
});
|
});
|
||||||
state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event));
|
state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event));
|
||||||
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
|
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
|
||||||
if (!board.penalties.some((item) => !item.finished && hockeyEventReady(item))) {
|
if (!board.penalties.some((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item))) {
|
||||||
resetPenaltyAdvantageCycle();
|
resetPenaltyAdvantageCycle();
|
||||||
hockeyScheduleTimerSave(true);
|
hockeyScheduleTimerSave(true);
|
||||||
}
|
}
|
||||||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true }).catch((error) => console.error("Penalty target remove rebalance error", error));
|
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }).catch((error) => console.error("Penalty target remove rebalance error", error));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
persistHockeyBoard(component, board, true);
|
persistHockeyBoard(component, board, true);
|
||||||
refreshHockeyBoardNodes(component);
|
refreshHockeyBoardNodes(component);
|
||||||
if (options.syncVmix !== false && ["start", "pause", "reset", "set_time"].includes(command)) {
|
if (options.syncVmix !== false && state.activeHockeyVmixTimerSteps.size && ["start", "pause", "reset", "set_time"].includes(command)) {
|
||||||
rebalanceVmixPenaltyTargets({
|
rebalanceVmixPenaltyTargets({
|
||||||
force: true,
|
force: true,
|
||||||
hideUnused: true,
|
hideUnused: true,
|
||||||
preservePausedCountdown: command === "pause",
|
preservePausedCountdown: command === "pause",
|
||||||
includeConfigured: true,
|
|
||||||
}).catch((error) => console.error("Penalty countdown state sync error", error));
|
}).catch((error) => console.error("Penalty countdown state sync error", error));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -7492,17 +7480,17 @@ function openTimerQuickEditor(focusActionId = "") {
|
|||||||
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
|
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
|
||||||
const hadAdvantage = Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage);
|
const hadAdvantage = Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage);
|
||||||
const fullStrengthSide = penaltyFullStrengthSide();
|
const fullStrengthSide = penaltyFullStrengthSide();
|
||||||
const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item)).length;
|
const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item)).length;
|
||||||
const finishBySide = new Map();
|
const finishBySide = new Map();
|
||||||
completedEvents.forEach((event) => {
|
completedEvents.forEach((event) => {
|
||||||
const side = String(event.player?.side || event.side || "").toLowerCase();
|
const side = String(event.player?.side || event.side || "").toLowerCase();
|
||||||
if (side && !finishBySide.has(side)) finishBySide.set(side, event);
|
if (side && !finishBySide.has(side)) finishBySide.set(side, event);
|
||||||
});
|
});
|
||||||
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true })
|
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
|
||||||
.catch((error) => console.error("Penalty target ticker rebalance error", error))
|
.catch((error) => console.error("Penalty target ticker rebalance error", error))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
finishBySide.forEach((event, side) => {
|
finishBySide.forEach((event, side) => {
|
||||||
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
|
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
|
||||||
fireConfiguredTimerFinishActions("penalty", {
|
fireConfiguredTimerFinishActions("penalty", {
|
||||||
side, component, event,
|
side, component, event,
|
||||||
remaining_on_side: remainingOnSide,
|
remaining_on_side: remainingOnSide,
|
||||||
@@ -11855,15 +11843,16 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
// BUILD89: keep a second rebalance after the authoritative control payload
|
// BUILD89: keep a second rebalance after the authoritative control payload
|
||||||
// arrives. The single penalty plate then follows the real advantage side
|
// arrives. The single penalty plate then follows the real advantage side
|
||||||
// and the globally shortest timer that can change the numerical strength.
|
// and the globally shortest timer that can change the numerical strength.
|
||||||
if (configuredHockeyVmixPenaltySyncSteps().length || state.activeHockeyVmixTimerSteps.size) {
|
if (state.activeHockeyVmixTimerSteps.size) {
|
||||||
// BUILD113: a prepared penalty already changes numerical strength, so keep
|
// Build87 compatibility marker: rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
|
||||||
// its stopped countdown and target side aligned with the authoritative payload.
|
// BUILD95 uses a non-forced pass so a merely prepared penalty cannot
|
||||||
rebalanceVmixPenaltyTargets({ force: false, hideUnused: true, includeConfigured: true })
|
// unnecessarily restart an already running vMix countdown.
|
||||||
|
rebalanceVmixPenaltyTargets({ force: false, hideUnused: true })
|
||||||
.catch((error) => console.error("Penalty strength rebalance error", error));
|
.catch((error) => console.error("Penalty strength rebalance error", error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (previousControl && hockeyTeamStateFlagSignature(previousControl) !== hockeyTeamStateFlagSignature(payload) && hockeyTeamStateScoreboardIsLive()) {
|
if (previousControl && hockeyTeamStateFlagSignature(previousControl) !== hockeyTeamStateFlagSignature(payload) && hockeyTeamStateScoreboardIsLive()) {
|
||||||
hockeySyncTeamStateOverlays({ force: false }).catch((error) => console.error("vMix team-state overlay sync error", error));
|
hockeySyncTeamStateOverlays({ force: true }).catch((error) => console.error("vMix team-state overlay sync error", error));
|
||||||
}
|
}
|
||||||
if (dispatch) {
|
if (dispatch) {
|
||||||
window.dispatchEvent(new CustomEvent("hockey:game-control-updated", {
|
window.dispatchEvent(new CustomEvent("hockey:game-control-updated", {
|
||||||
@@ -12004,43 +11993,9 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hockeyApplyTeamStateOverlayImmediately(key, active) {
|
|
||||||
if (!hockeyTeamStateScoreboardIsLive()) return 0;
|
|
||||||
const setting = hockeyTeamStateSetting(key);
|
|
||||||
const previous = state.hockeyTeamStateOverlayActive.get(key) || null;
|
|
||||||
const commands = [];
|
|
||||||
const shouldShow = Boolean(active) && setting.enabled && Boolean(setting.input);
|
|
||||||
if (shouldShow) {
|
|
||||||
if (previous && (previous.input !== setting.input || previous.overlay !== setting.overlay)) {
|
|
||||||
commands.push({ Function: `OverlayInput${previous.overlay}Out`, Input: previous.input });
|
|
||||||
}
|
|
||||||
if (!previous || previous.input !== setting.input || previous.overlay !== setting.overlay) {
|
|
||||||
commands.push({ Function: `OverlayInput${setting.overlay}In`, Input: setting.input });
|
|
||||||
}
|
|
||||||
state.hockeyTeamStateOverlayActive.set(key, { input: setting.input, overlay: setting.overlay });
|
|
||||||
} else if (previous) {
|
|
||||||
commands.push({ Function: `OverlayInput${previous.overlay}Out`, Input: previous.input });
|
|
||||||
state.hockeyTeamStateOverlayActive.delete(key);
|
|
||||||
}
|
|
||||||
if (commands.length) await sendRuntimeVmixSequence(commands);
|
|
||||||
return commands.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hockeyToggleMatchFlag(key) {
|
async function hockeyToggleMatchFlag(key) {
|
||||||
const current = Boolean(hockeyMatchFlags()[key]);
|
const current = Boolean(hockeyMatchFlags()[key]);
|
||||||
const next = !current;
|
return hockeySetMatchFlags({ [key]: !current });
|
||||||
const live = hockeyTeamStateScoreboardIsLive();
|
|
||||||
if (live) {
|
|
||||||
// BUILD113: delayed penalty / empty net are operator-live controls. Do not wait
|
|
||||||
// for the database round-trip before showing/removing the configured overlay.
|
|
||||||
hockeyApplyTeamStateOverlayImmediately(key, next).catch((error) => console.error("Immediate team-state overlay error", error));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await hockeySetMatchFlags({ [key]: next });
|
|
||||||
} catch (error) {
|
|
||||||
if (live) hockeyApplyTeamStateOverlayImmediately(key, current).catch(() => {});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hockeyPrematchFlagKey(buttonId) {
|
function hockeyPrematchFlagKey(buttonId) {
|
||||||
@@ -14830,11 +14785,6 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
} finally {
|
} finally {
|
||||||
state.hockeyTimerHydrating = false;
|
state.hockeyTimerHydrating = false;
|
||||||
}
|
}
|
||||||
// BUILD113: changing/setting the period immediately writes 20:00 / 05:00
|
|
||||||
// (or the configured period value) into vMix. The operator should never have
|
|
||||||
// to put the scorebug on air first just to seed its clock.
|
|
||||||
syncConfiguredScoreboardCountdownsToRuntime()
|
|
||||||
.catch((error) => console.error("Immediate game countdown preload error", error));
|
|
||||||
}
|
}
|
||||||
if (state.activeTab === "shootout") renderRuntime();
|
if (state.activeTab === "shootout") renderRuntime();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user