feat: selectable audio source
Add an audio source picker to the GUI so users can capture a specific PipeWire device (sink monitor or microphone) instead of only the system default output. - vendor go2tv.app/screencast and patch the audio stream to accept a target PipeWire node serial (PW_KEY_TARGET_OBJECT); the upstream lib only ever auto-connected to the default - capture: ListAudioSources enumerates PipeWire sinks/sources via pw-dump; OpenPipeWire takes the selected node serial - flinger/config/gui: AudioSource config field, persisted and exposed as an Audio source dropdown (default output + enumerated devices) Also fixes two pre-existing bugs surfaced by stop/start testing: - engine Stop now waits for the video/audio/stats goroutines before destroying the encoder (was a use-after-free SIGSEGV) - Start/Stop now pause/resume the engine instead of tearing down and re-opening the portal session, which the portal cannot reliably do in-process (2nd CreateSession returned Ended/cancelled). Capture session stays open across stop/start.
This commit is contained in:
+102
-8
@@ -93,11 +93,17 @@ type App struct {
|
||||
scaleSel *widget.Select
|
||||
nameEnt *widget.Entry
|
||||
audioChk *widget.Check
|
||||
audioSel *widget.Select
|
||||
audioDevs []capture.AudioDevice
|
||||
announceChk *widget.Check
|
||||
srcSel *widget.Select
|
||||
monSel *widget.Select
|
||||
monitors []capture.Monitor
|
||||
statsDone chan struct{}
|
||||
|
||||
// lastStart records the capture-affecting config the current engine was
|
||||
// created with, so Start can resume instead of reopening the portal.
|
||||
lastStart config.Config
|
||||
}
|
||||
|
||||
// Run starts the GUI and blocks until the app exits. configPath selects a
|
||||
@@ -188,8 +194,9 @@ func (g *App) buildUI() {
|
||||
g.presetSel.SetSelected("High")
|
||||
|
||||
// Audio.
|
||||
g.audioChk = widget.NewCheck("Capture system audio", nil)
|
||||
g.audioChk = widget.NewCheck("Capture system audio", func(bool) { g.applyLiveSettings("") })
|
||||
g.audioChk.SetChecked(g.cfg.Audio)
|
||||
g.setupAudioPicker()
|
||||
|
||||
// Announce over multicast.
|
||||
g.announceChk = widget.NewCheck("Announce on LAN", nil)
|
||||
@@ -217,7 +224,8 @@ func (g *App) buildUI() {
|
||||
{Text: "Quality", Widget: g.qualitySel},
|
||||
{Text: "Frame rate", Widget: g.fpsSel},
|
||||
{Text: "Scale", Widget: g.scaleSel},
|
||||
{Text: "", Widget: g.audioChk},
|
||||
{Text: "Audio", Widget: g.audioChk},
|
||||
{Text: "Audio source", Widget: g.audioSel},
|
||||
{Text: "", Widget: g.announceChk},
|
||||
},
|
||||
}
|
||||
@@ -283,6 +291,54 @@ func (g *App) selectedMonitorIndex() int {
|
||||
return g.cfg.StreamIndex
|
||||
}
|
||||
|
||||
// setupAudioPicker populates the audio source dropdown from PipeWire. The
|
||||
// first option is "Default output"; the rest are the enumerated sinks and
|
||||
// microphones. A no-op if enumeration is unavailable.
|
||||
func (g *App) setupAudioPicker() {
|
||||
g.audioDevs, _ = capture.ListAudioSources()
|
||||
|
||||
names := []string{"Default output"}
|
||||
for _, d := range g.audioDevs {
|
||||
label := d.Desc
|
||||
if label == "" {
|
||||
label = d.Name
|
||||
}
|
||||
if d.IsOutput {
|
||||
label = "Output: " + label
|
||||
} else {
|
||||
label = "Input: " + label
|
||||
}
|
||||
names = append(names, label)
|
||||
}
|
||||
|
||||
g.audioSel = widget.NewSelect(names, func(string) { g.applyLiveSettings("") })
|
||||
|
||||
// Preselect the configured serial if it matches an enumerated device.
|
||||
if g.cfg.AudioSource > 0 {
|
||||
for i, d := range g.audioDevs {
|
||||
if d.Serial == g.cfg.AudioSource {
|
||||
g.audioSel.SetSelectedIndex(i + 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
g.audioSel.SetSelectedIndex(0)
|
||||
}
|
||||
}
|
||||
|
||||
// selectedAudioSerial returns the PipeWire serial chosen in the picker, or 0
|
||||
// for the default output.
|
||||
func (g *App) selectedAudioSerial() uint64 {
|
||||
if g.audioSel == nil || g.audioSel.SelectedIndex() <= 0 {
|
||||
return 0
|
||||
}
|
||||
idx := g.audioSel.SelectedIndex() - 1
|
||||
if idx >= 0 && idx < len(g.audioDevs) {
|
||||
return g.audioDevs[idx].Serial
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// applyPreset applies a named quality/fps preset to the Quality and Frame
|
||||
// rate selectors, then pushes it live if the engine is running.
|
||||
func (g *App) applyPreset(string) {
|
||||
@@ -320,6 +376,7 @@ func (g *App) applyLiveSettings(string) {
|
||||
FPS: fps,
|
||||
Source: g.cfg.Source,
|
||||
Audio: g.audioChk.Checked,
|
||||
AudioSource: g.selectedAudioSerial(),
|
||||
StreamIndex: g.cfg.StreamIndex,
|
||||
Scale: parseScale(g.scaleSel.Selected),
|
||||
Announce: announce,
|
||||
@@ -332,6 +389,7 @@ func (g *App) applyLiveSettings(string) {
|
||||
g.cfg.Quality = quality
|
||||
g.cfg.FPS = fps
|
||||
g.cfg.Audio = cfg.Audio
|
||||
g.cfg.AudioSource = cfg.AudioSource
|
||||
g.cfg.Name = cfg.Name
|
||||
g.cfg.Scale = cfg.Scale
|
||||
ann := announce
|
||||
@@ -343,31 +401,37 @@ func (g *App) toggleStream() {
|
||||
if g.lock {
|
||||
return
|
||||
}
|
||||
if g.eng != nil {
|
||||
// Engine exists and is currently running → pause it.
|
||||
if g.eng != nil && g.eng.Status().Running {
|
||||
g.stop()
|
||||
return
|
||||
}
|
||||
g.start()
|
||||
}
|
||||
|
||||
// start reads the form into cfg, saves it, and boots the engine.
|
||||
// start reads the form into cfg, saves it, and boots the engine. If an engine
|
||||
// already exists and the capture-affecting settings are unchanged, it resumes
|
||||
// the paused engine instead of reopening the portal session (which cannot be
|
||||
// reliably re-created in-process).
|
||||
func (g *App) start() {
|
||||
port, _ := atoi(g.portEnt.Text)
|
||||
quality, _ := atoi(g.qualitySel.Selected)
|
||||
fps, _ := atoi(g.fpsSel.Selected)
|
||||
|
||||
announce := g.announceChk.Checked
|
||||
g.cfg = config.Config{
|
||||
newCfg := config.Config{
|
||||
Name: g.nameEnt.Text,
|
||||
Port: port,
|
||||
Quality: quality,
|
||||
FPS: fps,
|
||||
Source: g.srcSel.Selected,
|
||||
Audio: g.audioChk.Checked,
|
||||
AudioSource: g.selectedAudioSerial(),
|
||||
StreamIndex: g.selectedMonitorIndex(),
|
||||
Scale: parseScale(g.scaleSel.Selected),
|
||||
Announce: &announce,
|
||||
}
|
||||
g.cfg = newCfg
|
||||
if g.configPath != "" {
|
||||
if err := config.SaveTo(g.configPath, g.cfg); err != nil {
|
||||
log.Printf("gui: config save: %v", err)
|
||||
@@ -376,17 +440,47 @@ func (g *App) start() {
|
||||
log.Printf("gui: config save: %v", err)
|
||||
}
|
||||
|
||||
// Resume the existing engine if capture-affecting fields are unchanged.
|
||||
if g.eng != nil && captureConfigEqual(g.lastStart, newCfg) {
|
||||
// Push live-applicable changes, then resume.
|
||||
g.applyLiveSettings("")
|
||||
g.eng.Resume()
|
||||
g.startedUI(newCfg)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise close any existing engine and create a fresh one.
|
||||
if g.eng != nil {
|
||||
g.eng.Close()
|
||||
g.eng = nil
|
||||
}
|
||||
|
||||
eng, err := flinger.New(g.cfg.ToFlinger())
|
||||
if err != nil {
|
||||
dialog.ShowError(err, g.win)
|
||||
return
|
||||
}
|
||||
g.eng = eng
|
||||
g.lastStart = newCfg
|
||||
g.eng.Start()
|
||||
|
||||
g.startedUI(newCfg)
|
||||
}
|
||||
|
||||
// captureConfigEqual reports whether two configs agree on the fields that
|
||||
// require reopening the capture session (source, port, monitor, audio
|
||||
// source). Live-applicable fields (quality, fps, scale, name, announce,
|
||||
// audio-on) are ignored.
|
||||
func captureConfigEqual(a, b config.Config) bool {
|
||||
return a.Source == b.Source && a.Port == b.Port &&
|
||||
a.StreamIndex == b.StreamIndex && a.AudioSource == b.AudioSource
|
||||
}
|
||||
|
||||
// startedUI updates the UI to the streaming state after a start/resume.
|
||||
func (g *App) startedUI(cfg config.Config) {
|
||||
g.startBtn.SetText("Stop")
|
||||
g.startBtn.Importance = widget.DangerImportance
|
||||
g.statusLab.SetText("Streaming (port " + itoa(g.cfg.Port) + ")")
|
||||
g.statusLab.SetText("Streaming (port " + itoa(cfg.Port) + ")")
|
||||
g.statusLab.Importance = widget.SuccessImportance
|
||||
g.setTrayState(true, "TeleportFling · Streaming")
|
||||
g.refresh()
|
||||
@@ -460,7 +554,8 @@ func formatBitrate(bps int64) string {
|
||||
}
|
||||
}
|
||||
|
||||
// stop halts the engine and returns the UI to the stopped state.
|
||||
// stop pauses the engine (keeping the portal session open) and returns the
|
||||
// UI to the stopped state.
|
||||
func (g *App) stop() {
|
||||
if g.statsDone != nil {
|
||||
close(g.statsDone)
|
||||
@@ -468,7 +563,6 @@ func (g *App) stop() {
|
||||
}
|
||||
if g.eng != nil {
|
||||
g.eng.Stop()
|
||||
g.eng = nil
|
||||
}
|
||||
g.startBtn.SetText("Start")
|
||||
g.startBtn.Importance = widget.HighImportance
|
||||
|
||||
Reference in New Issue
Block a user