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.
This commit is contained in:
2026-09-19 17:37:47 +01:00
parent f25b498fc1
commit 95f47abadb
3 changed files with 128 additions and 6 deletions
+44 -3
View File
@@ -99,6 +99,7 @@ type Status struct {
// Engine owns the capture, encode and send pipeline.
type Engine struct {
cfgMu sync.RWMutex
cfg Config
sender *output.Sender
@@ -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)
+47
View File
@@ -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)
}
}
+36 -2
View File
@@ -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 {