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.
|
||||
- Empty `Value` is forwarded so SetText can clear stale title fields.
|
||||
- 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 (
|
||||
agentVersion = "1.4.0"
|
||||
protocolVersion = 1
|
||||
websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
heartbeatInterval = 8 * time.Second
|
||||
agentVersion = "1.5.0"
|
||||
protocolVersion = 1
|
||||
websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
heartbeatInterval = 8 * time.Second
|
||||
overlayPollInterval = 400 * time.Millisecond
|
||||
overlayResendInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -75,8 +77,22 @@ type VmixInventory struct {
|
||||
}
|
||||
|
||||
type vmixAPIXML struct {
|
||||
Version string `xml:"version"`
|
||||
Inputs []vmixInputXML `xml:"inputs>input"`
|
||||
Version string `xml:"version"`
|
||||
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 {
|
||||
@@ -151,6 +167,7 @@ func (a *Agent) Start() {
|
||||
a.logf("Agent %s started", agentVersion)
|
||||
a.logf("Device: %s (%s)", a.cfg.DeviceName, short(a.cfg.DeviceID))
|
||||
go a.vmixMonitor()
|
||||
go a.vmixOverlayMonitor()
|
||||
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 {
|
||||
if v == "" {
|
||||
return ""
|
||||
|
||||
@@ -1,17 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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>`
|
||||
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()
|
||||
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]) }
|
||||
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))
|
||||
}))
|
||||
defer srv.Close()
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user