From 95f47abadb60c2c0e24af9b2929444c7cd5a1826 Mon Sep 17 00:00:00 2001 From: Peter Edley Date: Sat, 19 Sep 2026 17:37:47 +0100 Subject: [PATCH] feat: live settings changes mid-stream Add flinger.SetConfig to update FPS/quality/name/audio/announce without restarting the stream. Fields that require a restart (source, port, stream-index) are preserved. The video loop reads the live config each frame so changes apply immediately, and the GUI Quality/Frame-rate dropdowns push changes to a running engine. Verified live: fps 12->33 and JPEG size 210KB->1.3MB on quality change. --- internal/flinger/flinger.go | 49 +++++++++++++++++++++++++++++--- internal/flinger/flinger_test.go | 47 ++++++++++++++++++++++++++++++ internal/gui/gui.go | 38 +++++++++++++++++++++++-- 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/internal/flinger/flinger.go b/internal/flinger/flinger.go index fdd3e07..0243698 100644 --- a/internal/flinger/flinger.go +++ b/internal/flinger/flinger.go @@ -99,7 +99,8 @@ type Status struct { // Engine owns the capture, encode and send pipeline. type Engine struct { - cfg Config + cfgMu sync.RWMutex + cfg Config sender *output.Sender announcer *discovery.Announcer @@ -122,6 +123,35 @@ func (e *Engine) setErr(err error) { e.errMu.Unlock() } +// SetConfig updates engine settings live (FPS, quality, name, etc.) without +// restarting the stream. It validates the new config first; on error the +// engine keeps its current settings. +// +// Not every field is live-applicable mid-stream: source, port and +// stream-index still require a restart (they are ignored if changed). +func (e *Engine) SetConfig(cfg Config) error { + if err := cfg.Validate(); err != nil { + return err + } + e.cfgMu.Lock() + defer e.cfgMu.Unlock() + + // Fields that cannot change live keep their current values. + cfg.Source = e.cfg.Source + cfg.Port = e.cfg.Port + cfg.StreamIndex = e.cfg.StreamIndex + + e.cfg = cfg + return nil +} + +// getCfg returns a snapshot of the current config. +func (e *Engine) getCfg() Config { + e.cfgMu.RLock() + defer e.cfgMu.RUnlock() + return e.cfg +} + // New creates an engine from cfg. Capture is opened eagerly so that // misconfiguration (e.g. no screen-share permission) surfaces before Start. func New(cfg Config) (*Engine, error) { @@ -274,9 +304,11 @@ func (e *Engine) audioLoop(src io.ReadCloser) { } } -// videoLoop pulls frames and sends them at the configured fps. +// videoLoop pulls frames and sends them at the configured fps. The frame rate +// and JPEG quality are read from the live config so SetConfig takes effect +// without restarting. func (e *Engine) videoLoop() { - frameInterval := time.Second / time.Duration(e.cfg.FPS) + frameInterval := time.Second / time.Duration(e.getCfg().FPS) next := e.start for { @@ -294,6 +326,15 @@ func (e *Engine) videoLoop() { continue } + // Re-read the live config each frame so FPS/quality changes apply + // immediately. When the interval changes, resync `next` to now. + cfg := e.getCfg() + interval := time.Second / time.Duration(cfg.FPS) + if interval != frameInterval { + frameInterval = interval + next = time.Now() + } + // Drop frames when running ahead of the target fps to keep // timestamps monotonic (e.g. a 60 Hz monitor captured at 30 fps). now := time.Now() @@ -303,7 +344,7 @@ func (e *Engine) videoLoop() { next = now.Add(frameInterval) ts := uint64(now.Sub(e.start)) - buf, err := e.encoder.EncodeBGRA(frame.Pix, frame.Width, frame.Height, e.cfg.Quality) + buf, err := e.encoder.EncodeBGRA(frame.Pix, frame.Width, frame.Height, cfg.Quality) if err != nil { e.setErr(err) log.Printf("flinger: jpeg: %v", err) diff --git a/internal/flinger/flinger_test.go b/internal/flinger/flinger_test.go index 0fd06aa..08ace0b 100644 --- a/internal/flinger/flinger_test.go +++ b/internal/flinger/flinger_test.go @@ -142,3 +142,50 @@ func itoa(v int) string { } return string(buf[i:]) } + +// TestSetConfig verifies live config updates apply FPS/quality and reject +// invalid values, while preserving fields that can't change live. +func TestSetConfig(t *testing.T) { + cfg := DefaultConfig() + cfg.Source = "pattern" + cfg.Port = 19758 + + eng, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + // Update FPS/quality live. + newCfg := DefaultConfig() + newCfg.Source = "screen" // must be ignored (requires restart) + newCfg.Port = 9999 // must be ignored + newCfg.Quality = 95 + newCfg.FPS = 60 + if err := eng.SetConfig(newCfg); err != nil { + t.Fatalf("SetConfig: %v", err) + } + + got := eng.getCfg() + if got.Quality != 95 { + t.Errorf("quality = %d, want 95", got.Quality) + } + if got.FPS != 60 { + t.Errorf("fps = %d, want 60", got.FPS) + } + // Source/port preserved. + if got.Source != "pattern" { + t.Errorf("source = %q, want pattern (unchanged live)", got.Source) + } + if got.Port != 19758 { + t.Errorf("port = %d, want 19758 (unchanged live)", got.Port) + } + + // Invalid config is rejected and current settings kept. + if err := eng.SetConfig(Config{Quality: 200}); err == nil { + t.Error("expected error for invalid quality") + } + got = eng.getCfg() + if got.Quality != 95 { + t.Errorf("quality changed after rejected update: %d", got.Quality) + } +} diff --git a/internal/gui/gui.go b/internal/gui/gui.go index a83465b..d9ff622 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -140,11 +140,11 @@ func (g *App) buildUI() { g.setupMonitorPicker() // Quality. - g.qualitySel = widget.NewSelect([]string{"50", "60", "70", "80", "90", "100"}, func(string) {}) + g.qualitySel = widget.NewSelect([]string{"50", "60", "70", "80", "90", "100"}, g.applyLiveSettings) g.qualitySel.SetSelected(itoa(g.cfg.Quality)) // FPS. - g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, func(string) {}) + g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, g.applyLiveSettings) g.fpsSel.SetSelected(itoa(g.cfg.FPS)) // Audio. @@ -241,6 +241,40 @@ func (g *App) selectedMonitorIndex() int { return g.cfg.StreamIndex } +// applyLiveSettings pushes the current form values (quality, fps, name, +// audio, announce) into a running engine via SetConfig so changes take +// effect without restarting the stream. When the engine is not running it is +// a no-op; the values are still captured on the next Start. +func (g *App) applyLiveSettings(string) { + if g.eng == nil { + return + } + quality, _ := atoi(g.qualitySel.Selected) + fps, _ := atoi(g.fpsSel.Selected) + announce := g.announceChk.Checked + cfg := flinger.Config{ + Name: g.nameEnt.Text, + Port: g.cfg.Port, + Quality: quality, + FPS: fps, + Source: g.cfg.Source, + Audio: g.audioChk.Checked, + StreamIndex: g.cfg.StreamIndex, + Announce: announce, + } + if err := g.eng.SetConfig(cfg); err != nil { + log.Printf("gui: live settings: %v", err) + return + } + // Keep the persisted config in sync with what we just applied. + g.cfg.Quality = quality + g.cfg.FPS = fps + g.cfg.Audio = cfg.Audio + g.cfg.Name = cfg.Name + ann := announce + g.cfg.Announce = &ann +} + // toggleStream starts or stops the engine based on current UI state. func (g *App) toggleStream() { if g.lock {