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:
@@ -99,7 +99,8 @@ type Status struct {
|
|||||||
|
|
||||||
// Engine owns the capture, encode and send pipeline.
|
// Engine owns the capture, encode and send pipeline.
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
cfg Config
|
cfgMu sync.RWMutex
|
||||||
|
cfg Config
|
||||||
|
|
||||||
sender *output.Sender
|
sender *output.Sender
|
||||||
announcer *discovery.Announcer
|
announcer *discovery.Announcer
|
||||||
@@ -122,6 +123,35 @@ func (e *Engine) setErr(err error) {
|
|||||||
e.errMu.Unlock()
|
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
|
// New creates an engine from cfg. Capture is opened eagerly so that
|
||||||
// misconfiguration (e.g. no screen-share permission) surfaces before Start.
|
// misconfiguration (e.g. no screen-share permission) surfaces before Start.
|
||||||
func New(cfg Config) (*Engine, error) {
|
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() {
|
func (e *Engine) videoLoop() {
|
||||||
frameInterval := time.Second / time.Duration(e.cfg.FPS)
|
frameInterval := time.Second / time.Duration(e.getCfg().FPS)
|
||||||
next := e.start
|
next := e.start
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -294,6 +326,15 @@ func (e *Engine) videoLoop() {
|
|||||||
continue
|
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
|
// Drop frames when running ahead of the target fps to keep
|
||||||
// timestamps monotonic (e.g. a 60 Hz monitor captured at 30 fps).
|
// timestamps monotonic (e.g. a 60 Hz monitor captured at 30 fps).
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -303,7 +344,7 @@ func (e *Engine) videoLoop() {
|
|||||||
next = now.Add(frameInterval)
|
next = now.Add(frameInterval)
|
||||||
|
|
||||||
ts := uint64(now.Sub(e.start))
|
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 {
|
if err != nil {
|
||||||
e.setErr(err)
|
e.setErr(err)
|
||||||
log.Printf("flinger: jpeg: %v", err)
|
log.Printf("flinger: jpeg: %v", err)
|
||||||
|
|||||||
@@ -142,3 +142,50 @@ func itoa(v int) string {
|
|||||||
}
|
}
|
||||||
return string(buf[i:])
|
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
@@ -140,11 +140,11 @@ func (g *App) buildUI() {
|
|||||||
g.setupMonitorPicker()
|
g.setupMonitorPicker()
|
||||||
|
|
||||||
// Quality.
|
// 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))
|
g.qualitySel.SetSelected(itoa(g.cfg.Quality))
|
||||||
|
|
||||||
// FPS.
|
// 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))
|
g.fpsSel.SetSelected(itoa(g.cfg.FPS))
|
||||||
|
|
||||||
// Audio.
|
// Audio.
|
||||||
@@ -241,6 +241,40 @@ func (g *App) selectedMonitorIndex() int {
|
|||||||
return g.cfg.StreamIndex
|
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.
|
// toggleStream starts or stops the engine based on current UI state.
|
||||||
func (g *App) toggleStream() {
|
func (g *App) toggleStream() {
|
||||||
if g.lock {
|
if g.lock {
|
||||||
|
|||||||
Reference in New Issue
Block a user