feat: harden engine config, discovery and backpressure stats

- config: validate port/quality/fps/source/stream-index ranges on load
  and before engine start (flinger.Config.Validate)
- discovery: add Announce option to disable multicast (GUI checkbox,
  CLI --no-announce); absent JSON key keeps the default true
- backpressure: count dropped frames in the TCP sender, expose via
  engine Status and a live counter in the GUI status label
- docs: record M3/M4 decisions and X11 coverage via the portal backend
This commit is contained in:
2026-09-18 20:44:39 +01:00
parent cb27a0d63d
commit 2b3fd54934
8 changed files with 195 additions and 21 deletions
+58 -8
View File
@@ -10,6 +10,7 @@ import (
"errors"
"log"
"strconv"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
@@ -43,14 +44,16 @@ type App struct {
lock bool // serialises start/stop against UI actions
// UI state.
startBtn *widget.Button
statusLab *widget.Label
portEnt *widget.Entry
qualitySel *widget.Select
fpsSel *widget.Select
nameEnt *widget.Entry
audioChk *widget.Check
srcSel *widget.Select
startBtn *widget.Button
statusLab *widget.Label
portEnt *widget.Entry
qualitySel *widget.Select
fpsSel *widget.Select
nameEnt *widget.Entry
audioChk *widget.Check
announceChk *widget.Check
srcSel *widget.Select
statsDone chan struct{}
}
// Run starts the GUI and blocks until the app exits.
@@ -120,6 +123,14 @@ func (g *App) buildUI() {
g.audioChk = widget.NewCheck("Capture system audio", nil)
g.audioChk.SetChecked(g.cfg.Audio)
// Announce over multicast.
g.announceChk = widget.NewCheck("Announce on LAN", nil)
announce := true
if g.cfg.Announce != nil {
announce = *g.cfg.Announce
}
g.announceChk.SetChecked(announce)
// Status label.
g.statusLab = widget.NewLabel("Stopped")
g.statusLab.Importance = widget.MediumImportance
@@ -136,6 +147,7 @@ func (g *App) buildUI() {
{Text: "Quality", Widget: g.qualitySel},
{Text: "Frame rate", Widget: g.fpsSel},
{Text: "", Widget: g.audioChk},
{Text: "", Widget: g.announceChk},
},
}
@@ -168,6 +180,7 @@ func (g *App) start() {
quality, _ := atoi(g.qualitySel.Selected)
fps, _ := atoi(g.fpsSel.Selected)
announce := g.announceChk.Checked
g.cfg = config.Config{
Name: g.nameEnt.Text,
Port: port,
@@ -176,6 +189,7 @@ func (g *App) start() {
Source: g.srcSel.Selected,
Audio: g.audioChk.Checked,
StreamIndex: g.cfg.StreamIndex,
Announce: &announce,
}
if err := config.Save(g.cfg); err != nil {
log.Printf("gui: config save: %v", err)
@@ -194,10 +208,46 @@ func (g *App) start() {
g.statusLab.SetText("Streaming (port " + itoa(g.cfg.Port) + ")")
g.statusLab.Importance = widget.SuccessImportance
g.refresh()
g.watchStats()
}
// watchStats refreshes the status label with live counters while streaming.
func (g *App) watchStats() {
done := make(chan struct{})
g.statsDone = done
go func() {
tick := time.NewTicker(2 * time.Second)
defer tick.Stop()
for {
select {
case <-tick.C:
if g.eng == nil {
return
}
st := g.eng.Status()
g.statusLab.SetText(formatStatus(st))
case <-done:
return
}
}
}()
}
// formatStatus renders the live status line.
func formatStatus(st flinger.Status) string {
base := "Streaming · " + itoa(int(st.Frames)) + " frames"
if st.Dropped > 0 {
base += " · " + itoa(int(st.Dropped)) + " dropped"
}
return base
}
// stop halts the engine and returns the UI to the stopped state.
func (g *App) stop() {
if g.statsDone != nil {
close(g.statsDone)
g.statsDone = nil
}
if g.eng != nil {
g.eng.Stop()
g.eng = nil