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:
@@ -44,3 +44,5 @@ require (
|
|||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace go2tv.app/screencast => ./third_party/screencast
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Audio source enumeration for the settings UI.
|
||||||
|
|
||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AudioDevice describes a capturable PipeWire audio node.
|
||||||
|
type AudioDevice struct {
|
||||||
|
Serial uint64 // PipeWire object.serial, passed to the capture backend
|
||||||
|
ID uint32 // PipeWire node id (informational)
|
||||||
|
Name string // node name, e.g. "alsa_output...analog-stereo"
|
||||||
|
Desc string // human-readable description
|
||||||
|
IsOutput bool // true = a sink (system output); false = a source (mic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pwDumpNode is the subset of `pw-dump` output we parse.
|
||||||
|
type pwDumpNode struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Info struct {
|
||||||
|
Props map[string]any `json:"props"`
|
||||||
|
} `json:"info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAudioSources enumerates PipeWire audio sinks and sources via `pw-dump`.
|
||||||
|
// To capture system audio we attach to a sink's monitor; to capture a
|
||||||
|
// microphone we attach to an Audio/Source node. This lists the capturable
|
||||||
|
// audio nodes so the GUI can present a picker instead of always using the
|
||||||
|
// system default output.
|
||||||
|
func ListAudioSources() ([]AudioDevice, error) {
|
||||||
|
out, err := exec.Command("pw-dump").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodes []pwDumpNode
|
||||||
|
if err := json.Unmarshal(out, &nodes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sources []AudioDevice
|
||||||
|
for _, n := range nodes {
|
||||||
|
props := n.Info.Props
|
||||||
|
mediaClass, _ := props["media.class"].(string)
|
||||||
|
if !audioNodeClass(mediaClass) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
desc, _ := props["node.description"].(string)
|
||||||
|
name, _ := props["node.name"].(string)
|
||||||
|
serial := toUint64(props["object.serial"])
|
||||||
|
if serial == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sources = append(sources, AudioDevice{
|
||||||
|
Serial: serial,
|
||||||
|
ID: n.ID,
|
||||||
|
Name: name,
|
||||||
|
Desc: desc,
|
||||||
|
IsOutput: mediaClass == "Audio/Sink",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sources, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioNodeClass reports whether a media.class is a capturable audio node.
|
||||||
|
func audioNodeClass(mediaClass string) bool {
|
||||||
|
switch mediaClass {
|
||||||
|
case "Audio/Sink", "Audio/Source":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toUint64 best-effort converts a pw-dump property value to uint64.
|
||||||
|
// PipeWire serials are small, so the conversions cannot overflow in practice.
|
||||||
|
//
|
||||||
|
//nolint:gosec // safe: JSON numbers from pw-dump are small node serials
|
||||||
|
func toUint64(v any) uint64 {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return uint64(t)
|
||||||
|
case int:
|
||||||
|
return uint64(t)
|
||||||
|
case int64:
|
||||||
|
return uint64(t)
|
||||||
|
case uint64:
|
||||||
|
return t
|
||||||
|
case json.Number:
|
||||||
|
if n, err := t.Int64(); err == nil {
|
||||||
|
return uint64(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPWParseAudioNodes parses a representative pw-dump payload into the
|
||||||
|
// node shape used by ListAudioSources.
|
||||||
|
func TestPWParseAudioNodes(t *testing.T) {
|
||||||
|
payload := `[
|
||||||
|
{"id": 51, "info": {"props": {"media.class": "Audio/Sink", "node.name": "alsa_out_speaker", "node.description": "Speaker", "object.serial": 1179}}},
|
||||||
|
{"id": 56, "info": {"props": {"media.class": "Audio/Source", "node.name": "alsa_in_mic", "node.description": "Stereo Mic", "object.serial": 1183}}},
|
||||||
|
{"id": 64, "info": {"props": {"media.class": "Audio/Device", "node.description": "Not capturable", "object.serial": 1172}}}
|
||||||
|
]`
|
||||||
|
|
||||||
|
var nodes []pwDumpNode
|
||||||
|
if err := json.Unmarshal([]byte(payload), &nodes); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var devs []AudioDevice
|
||||||
|
for _, n := range nodes {
|
||||||
|
props := n.Info.Props
|
||||||
|
mc, _ := props["media.class"].(string)
|
||||||
|
if !audioNodeClass(mc) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
devs = append(devs, AudioDevice{
|
||||||
|
Serial: toUint64(props["object.serial"]),
|
||||||
|
ID: n.ID,
|
||||||
|
Name: props["node.name"].(string),
|
||||||
|
Desc: props["node.description"].(string),
|
||||||
|
IsOutput: mc == "Audio/Sink",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(devs) != 2 {
|
||||||
|
t.Fatalf("got %d capturable devices, want 2", len(devs))
|
||||||
|
}
|
||||||
|
// Sink serial parsed and flagged as output.
|
||||||
|
if devs[0].Serial != 1179 || !devs[0].IsOutput {
|
||||||
|
t.Errorf("sink wrong: %+v", devs[0])
|
||||||
|
}
|
||||||
|
// Source serial parsed and flagged as input.
|
||||||
|
if devs[1].Serial != 1183 || devs[1].IsOutput {
|
||||||
|
t.Errorf("source wrong: %+v", devs[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAudioNodeClass verifies which media classes are capturable.
|
||||||
|
func TestAudioNodeClass(t *testing.T) {
|
||||||
|
cases := map[string]bool{
|
||||||
|
"Audio/Sink": true,
|
||||||
|
"Audio/Source": true,
|
||||||
|
"Audio/Device": false,
|
||||||
|
"Video/Source": false,
|
||||||
|
}
|
||||||
|
for cls, want := range cases {
|
||||||
|
if got := audioNodeClass(cls); got != want {
|
||||||
|
t.Errorf("audioNodeClass(%q) = %v, want %v", cls, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,12 +24,14 @@ type PipeWire struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OpenPipeWire opens a PipeWire capture session. streamIndex selects which
|
// OpenPipeWire opens a PipeWire capture session. streamIndex selects which
|
||||||
// monitor to capture when multiple are present. Triggering the portal
|
// monitor to capture when multiple are present; audioSourceSerial optionally
|
||||||
// consent dialog is expected; the compositor decides whether to show it.
|
// selects a specific PipeWire audio node (0 = system default). Triggering the
|
||||||
func OpenPipeWire(streamIndex int, audio bool) (*PipeWire, error) {
|
// portal consent dialog is expected; the compositor decides whether to show it.
|
||||||
|
func OpenPipeWire(streamIndex int, audio bool, audioSourceSerial uint64) (*PipeWire, error) {
|
||||||
s, err := capture.Open(&capture.Options{
|
s, err := capture.Open(&capture.Options{
|
||||||
StreamIndex: streamIndex,
|
StreamIndex: streamIndex,
|
||||||
IncludeAudio: audio,
|
IncludeAudio: audio,
|
||||||
|
AudioSourceSerial: audioSourceSerial,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Config struct {
|
|||||||
FPS int `json:"fps"`
|
FPS int `json:"fps"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Audio bool `json:"audio"`
|
Audio bool `json:"audio"`
|
||||||
|
AudioSource uint64 `json:"audio_source"`
|
||||||
StreamIndex int `json:"stream_index"`
|
StreamIndex int `json:"stream_index"`
|
||||||
Scale float64 `json:"scale"`
|
Scale float64 `json:"scale"`
|
||||||
// Announce is a *bool so an absent JSON key (older config files) keeps
|
// Announce is a *bool so an absent JSON key (older config files) keeps
|
||||||
@@ -40,6 +41,7 @@ func Default() Config {
|
|||||||
FPS: c.FPS,
|
FPS: c.FPS,
|
||||||
Source: c.Source,
|
Source: c.Source,
|
||||||
Audio: c.Audio,
|
Audio: c.Audio,
|
||||||
|
AudioSource: c.AudioSource,
|
||||||
StreamIndex: c.StreamIndex,
|
StreamIndex: c.StreamIndex,
|
||||||
Scale: c.Scale,
|
Scale: c.Scale,
|
||||||
Announce: &announce,
|
Announce: &announce,
|
||||||
@@ -59,6 +61,7 @@ func (c Config) ToFlinger() flinger.Config {
|
|||||||
FPS: c.FPS,
|
FPS: c.FPS,
|
||||||
Source: c.Source,
|
Source: c.Source,
|
||||||
Audio: c.Audio,
|
Audio: c.Audio,
|
||||||
|
AudioSource: c.AudioSource,
|
||||||
StreamIndex: c.StreamIndex,
|
StreamIndex: c.StreamIndex,
|
||||||
Scale: c.Scale,
|
Scale: c.Scale,
|
||||||
Announce: announce,
|
Announce: announce,
|
||||||
|
|||||||
+88
-14
@@ -48,6 +48,9 @@ type Config struct {
|
|||||||
Source string
|
Source string
|
||||||
// Audio enables system audio capture and streaming.
|
// Audio enables system audio capture and streaming.
|
||||||
Audio bool
|
Audio bool
|
||||||
|
// AudioSource is the PipeWire node serial to capture audio from (0 = system
|
||||||
|
// default output). Only applies when Audio is true and Source is "screen".
|
||||||
|
AudioSource uint64
|
||||||
// StreamIndex selects which monitor to capture (screen source only).
|
// StreamIndex selects which monitor to capture (screen source only).
|
||||||
StreamIndex int
|
StreamIndex int
|
||||||
// Scale downscales the captured frame before encoding. 1.0 = native
|
// Scale downscales the captured frame before encoding. 1.0 = native
|
||||||
@@ -122,8 +125,18 @@ type Engine struct {
|
|||||||
start time.Time
|
start time.Time
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
|
|
||||||
|
// running gates packet sending: when false the loops consume capture
|
||||||
|
// frames but do not transmit. This lets the GUI "stop" without tearing
|
||||||
|
// down the portal/PipeWire session (which the portal cannot reliably
|
||||||
|
// recreate in-process), so a subsequent Start just flips running on.
|
||||||
|
running atomic.Bool
|
||||||
|
|
||||||
frames atomic.Int64
|
frames atomic.Int64
|
||||||
|
|
||||||
|
// wg tracks the audio/video/stats goroutines so Stop can wait for them to
|
||||||
|
// finish before destroying shared resources (encoder, sender, capture).
|
||||||
|
wg sync.WaitGroup
|
||||||
|
|
||||||
// scaleBuf is the cached downscaled frame, reallocated only when the
|
// scaleBuf is the cached downscaled frame, reallocated only when the
|
||||||
// configured scale or source dimensions change.
|
// configured scale or source dimensions change.
|
||||||
scaleBuf *capture.VideoFrame
|
scaleBuf *capture.VideoFrame
|
||||||
@@ -162,6 +175,7 @@ func (e *Engine) SetConfig(cfg Config) error {
|
|||||||
cfg.Source = e.cfg.Source
|
cfg.Source = e.cfg.Source
|
||||||
cfg.Port = e.cfg.Port
|
cfg.Port = e.cfg.Port
|
||||||
cfg.StreamIndex = e.cfg.StreamIndex
|
cfg.StreamIndex = e.cfg.StreamIndex
|
||||||
|
cfg.AudioSource = e.cfg.AudioSource
|
||||||
|
|
||||||
e.cfg = cfg
|
e.cfg = cfg
|
||||||
return nil
|
return nil
|
||||||
@@ -199,7 +213,7 @@ func New(cfg Config) (*Engine, error) {
|
|||||||
|
|
||||||
switch cfg.Source {
|
switch cfg.Source {
|
||||||
case "screen":
|
case "screen":
|
||||||
cam, err := capture.OpenPipeWire(cfg.StreamIndex, cfg.Audio)
|
cam, err := capture.OpenPipeWire(cfg.StreamIndex, cfg.Audio, cfg.AudioSource)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.encoder.Close()
|
e.encoder.Close()
|
||||||
sender.Close()
|
sender.Close()
|
||||||
@@ -222,11 +236,13 @@ func New(cfg Config) (*Engine, error) {
|
|||||||
// config) starts announcing the stream. It is idempotent.
|
// config) starts announcing the stream. It is idempotent.
|
||||||
func (e *Engine) Start() {
|
func (e *Engine) Start() {
|
||||||
if e.stop != nil {
|
if e.stop != nil {
|
||||||
|
e.Resume()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e.start = time.Now()
|
e.start = time.Now()
|
||||||
e.stop = make(chan struct{})
|
e.stop = make(chan struct{})
|
||||||
|
e.running.Store(true)
|
||||||
|
|
||||||
if e.cfg.Announce {
|
if e.cfg.Announce {
|
||||||
e.announcer = discovery.Start(e.cfg.Name, e.sender.Port())
|
e.announcer = discovery.Start(e.cfg.Name, e.sender.Port())
|
||||||
@@ -243,32 +259,68 @@ func (e *Engine) Start() {
|
|||||||
src = capture.NewSilenceSource()
|
src = capture.NewSilenceSource()
|
||||||
}
|
}
|
||||||
|
|
||||||
go e.audioLoop(src)
|
e.wg.Add(3)
|
||||||
go e.videoLoop()
|
go func() {
|
||||||
go e.statsLoop()
|
defer e.wg.Done()
|
||||||
|
e.audioLoop(src)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer e.wg.Done()
|
||||||
|
e.videoLoop()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer e.wg.Done()
|
||||||
|
e.statsLoop()
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop halts the loops, stops announcing and closes all resources. It is
|
// Stop pauses the stream: loops keep consuming capture frames but stop
|
||||||
// idempotent. After Stop the engine must not be restarted.
|
// transmitting, and the portal/PipeWire session stays open so the engine can
|
||||||
|
// be resumed with Start without re-opening the portal (which the portal
|
||||||
|
// cannot reliably do in-process). Idempotent.
|
||||||
func (e *Engine) Stop() {
|
func (e *Engine) Stop() {
|
||||||
|
e.running.Store(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause is an alias for Stop; kept for clarity at call sites.
|
||||||
|
func (e *Engine) Pause() { e.Stop() }
|
||||||
|
|
||||||
|
// Resume restarts transmission on a paused engine. It is a no-op if the
|
||||||
|
// engine was never started.
|
||||||
|
func (e *Engine) Resume() {
|
||||||
|
if e.stop == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.running.Store(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close fully tears down the engine: stops the loops, closes the portal
|
||||||
|
// session and frees the encoder/sender. After Close the engine must not be
|
||||||
|
// reused.
|
||||||
|
func (e *Engine) Close() {
|
||||||
if e.stop == nil {
|
if e.stop == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
close(e.stop)
|
close(e.stop)
|
||||||
// Give the loops a moment to observe the stop signal.
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
// Closing the capture source unblocks the audio and video loops that may
|
||||||
|
// be stuck in a read. Do this before waiting so they can observe stop.
|
||||||
|
if e.cam != nil {
|
||||||
|
if err := e.cam.Close(); err != nil {
|
||||||
|
log.Printf("flinger: capture close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the audio/video/stats goroutines to finish so they no longer
|
||||||
|
// touch the encoder or sender before we destroy them.
|
||||||
|
e.wg.Wait()
|
||||||
|
|
||||||
if e.announcer != nil {
|
if e.announcer != nil {
|
||||||
e.announcer.Stop()
|
e.announcer.Stop()
|
||||||
}
|
}
|
||||||
e.sender.Close()
|
e.sender.Close()
|
||||||
e.encoder.Close()
|
e.encoder.Close()
|
||||||
if e.cam != nil {
|
|
||||||
if err := e.cam.Close(); err != nil {
|
|
||||||
log.Printf("flinger: capture close: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status returns a snapshot of the running engine.
|
// Status returns a snapshot of the running engine.
|
||||||
@@ -278,7 +330,7 @@ func (e *Engine) Status() Status {
|
|||||||
e.errMu.RUnlock()
|
e.errMu.RUnlock()
|
||||||
|
|
||||||
return Status{
|
return Status{
|
||||||
Running: e.stop != nil,
|
Running: e.running.Load() && e.stop != nil,
|
||||||
Frames: e.frames.Load(),
|
Frames: e.frames.Load(),
|
||||||
Dropped: e.sender.Dropped(),
|
Dropped: e.sender.Dropped(),
|
||||||
Conns: e.sender.NumConns(),
|
Conns: e.sender.NumConns(),
|
||||||
@@ -333,8 +385,17 @@ func (e *Engine) audioLoop(src io.ReadCloser) {
|
|||||||
buf := make([]byte, chunkBytes)
|
buf := make([]byte, chunkBytes)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
select {
|
||||||
|
case <-e.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
n, err := io.ReadFull(src, buf)
|
n, err := io.ReadFull(src, buf)
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
|
if !e.running.Load() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
frames := n / (speakers * 2)
|
frames := n / (speakers * 2)
|
||||||
ts := uint64(time.Since(e.start))
|
ts := uint64(time.Since(e.start))
|
||||||
packet, perr := protocol.BuildWavePacket(ts, protocol.AudioFormatS16, sampleRate, speakers, int32(frames), buf[:n])
|
packet, perr := protocol.BuildWavePacket(ts, protocol.AudioFormatS16, sampleRate, speakers, int32(frames), buf[:n])
|
||||||
@@ -433,6 +494,12 @@ func (e *Engine) videoLoop() {
|
|||||||
next := e.start
|
next := e.start
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
select {
|
||||||
|
case <-e.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
frame, err := e.loop.Next()
|
frame, err := e.loop.Next()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
select {
|
select {
|
||||||
@@ -464,6 +531,13 @@ func (e *Engine) videoLoop() {
|
|||||||
}
|
}
|
||||||
next = now.Add(frameInterval)
|
next = now.Add(frameInterval)
|
||||||
|
|
||||||
|
// When paused (Stop), keep consuming frames to stay alive but do not
|
||||||
|
// encode or transmit. The portal session is left open so a subsequent
|
||||||
|
// Start can resume without re-opening the portal.
|
||||||
|
if !e.running.Load() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
ts := uint64(now.Sub(e.start))
|
ts := uint64(now.Sub(e.start))
|
||||||
enc := e.scaleFrame(frame, cfg.Scale)
|
enc := e.scaleFrame(frame, cfg.Scale)
|
||||||
buf, err := e.encoder.EncodeBGRA(enc.Pix, enc.Width, enc.Height, cfg.Quality)
|
buf, err := e.encoder.EncodeBGRA(enc.Pix, enc.Width, enc.Height, cfg.Quality)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestEnginePatternStartStop(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
eng.Start()
|
eng.Start()
|
||||||
defer eng.Stop()
|
defer eng.Close()
|
||||||
|
|
||||||
// Connect a raw receiver and read until the engine reports frames sent.
|
// Connect a raw receiver and read until the engine reports frames sent.
|
||||||
conn, err := net.Dial("tcp", "127.0.0.1:"+itoa(eng.sender.Port()))
|
conn, err := net.Dial("tcp", "127.0.0.1:"+itoa(eng.sender.Port()))
|
||||||
@@ -143,7 +143,7 @@ func TestBitrateMeasurement(t *testing.T) {
|
|||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
}
|
}
|
||||||
eng.Start()
|
eng.Start()
|
||||||
defer eng.Stop()
|
defer eng.Close()
|
||||||
|
|
||||||
// Connect a receiver so packets actually flow, and wait for the bitrate
|
// Connect a receiver so packets actually flow, and wait for the bitrate
|
||||||
// window to produce a measurement.
|
// window to produce a measurement.
|
||||||
|
|||||||
+102
-8
@@ -93,11 +93,17 @@ type App struct {
|
|||||||
scaleSel *widget.Select
|
scaleSel *widget.Select
|
||||||
nameEnt *widget.Entry
|
nameEnt *widget.Entry
|
||||||
audioChk *widget.Check
|
audioChk *widget.Check
|
||||||
|
audioSel *widget.Select
|
||||||
|
audioDevs []capture.AudioDevice
|
||||||
announceChk *widget.Check
|
announceChk *widget.Check
|
||||||
srcSel *widget.Select
|
srcSel *widget.Select
|
||||||
monSel *widget.Select
|
monSel *widget.Select
|
||||||
monitors []capture.Monitor
|
monitors []capture.Monitor
|
||||||
statsDone chan struct{}
|
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
|
// 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")
|
g.presetSel.SetSelected("High")
|
||||||
|
|
||||||
// Audio.
|
// 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.audioChk.SetChecked(g.cfg.Audio)
|
||||||
|
g.setupAudioPicker()
|
||||||
|
|
||||||
// Announce over multicast.
|
// Announce over multicast.
|
||||||
g.announceChk = widget.NewCheck("Announce on LAN", nil)
|
g.announceChk = widget.NewCheck("Announce on LAN", nil)
|
||||||
@@ -217,7 +224,8 @@ func (g *App) buildUI() {
|
|||||||
{Text: "Quality", Widget: g.qualitySel},
|
{Text: "Quality", Widget: g.qualitySel},
|
||||||
{Text: "Frame rate", Widget: g.fpsSel},
|
{Text: "Frame rate", Widget: g.fpsSel},
|
||||||
{Text: "Scale", Widget: g.scaleSel},
|
{Text: "Scale", Widget: g.scaleSel},
|
||||||
{Text: "", Widget: g.audioChk},
|
{Text: "Audio", Widget: g.audioChk},
|
||||||
|
{Text: "Audio source", Widget: g.audioSel},
|
||||||
{Text: "", Widget: g.announceChk},
|
{Text: "", Widget: g.announceChk},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -283,6 +291,54 @@ func (g *App) selectedMonitorIndex() int {
|
|||||||
return g.cfg.StreamIndex
|
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
|
// applyPreset applies a named quality/fps preset to the Quality and Frame
|
||||||
// rate selectors, then pushes it live if the engine is running.
|
// rate selectors, then pushes it live if the engine is running.
|
||||||
func (g *App) applyPreset(string) {
|
func (g *App) applyPreset(string) {
|
||||||
@@ -320,6 +376,7 @@ func (g *App) applyLiveSettings(string) {
|
|||||||
FPS: fps,
|
FPS: fps,
|
||||||
Source: g.cfg.Source,
|
Source: g.cfg.Source,
|
||||||
Audio: g.audioChk.Checked,
|
Audio: g.audioChk.Checked,
|
||||||
|
AudioSource: g.selectedAudioSerial(),
|
||||||
StreamIndex: g.cfg.StreamIndex,
|
StreamIndex: g.cfg.StreamIndex,
|
||||||
Scale: parseScale(g.scaleSel.Selected),
|
Scale: parseScale(g.scaleSel.Selected),
|
||||||
Announce: announce,
|
Announce: announce,
|
||||||
@@ -332,6 +389,7 @@ func (g *App) applyLiveSettings(string) {
|
|||||||
g.cfg.Quality = quality
|
g.cfg.Quality = quality
|
||||||
g.cfg.FPS = fps
|
g.cfg.FPS = fps
|
||||||
g.cfg.Audio = cfg.Audio
|
g.cfg.Audio = cfg.Audio
|
||||||
|
g.cfg.AudioSource = cfg.AudioSource
|
||||||
g.cfg.Name = cfg.Name
|
g.cfg.Name = cfg.Name
|
||||||
g.cfg.Scale = cfg.Scale
|
g.cfg.Scale = cfg.Scale
|
||||||
ann := announce
|
ann := announce
|
||||||
@@ -343,31 +401,37 @@ func (g *App) toggleStream() {
|
|||||||
if g.lock {
|
if g.lock {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if g.eng != nil {
|
// Engine exists and is currently running → pause it.
|
||||||
|
if g.eng != nil && g.eng.Status().Running {
|
||||||
g.stop()
|
g.stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.start()
|
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() {
|
func (g *App) start() {
|
||||||
port, _ := atoi(g.portEnt.Text)
|
port, _ := atoi(g.portEnt.Text)
|
||||||
quality, _ := atoi(g.qualitySel.Selected)
|
quality, _ := atoi(g.qualitySel.Selected)
|
||||||
fps, _ := atoi(g.fpsSel.Selected)
|
fps, _ := atoi(g.fpsSel.Selected)
|
||||||
|
|
||||||
announce := g.announceChk.Checked
|
announce := g.announceChk.Checked
|
||||||
g.cfg = config.Config{
|
newCfg := config.Config{
|
||||||
Name: g.nameEnt.Text,
|
Name: g.nameEnt.Text,
|
||||||
Port: port,
|
Port: port,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FPS: fps,
|
FPS: fps,
|
||||||
Source: g.srcSel.Selected,
|
Source: g.srcSel.Selected,
|
||||||
Audio: g.audioChk.Checked,
|
Audio: g.audioChk.Checked,
|
||||||
|
AudioSource: g.selectedAudioSerial(),
|
||||||
StreamIndex: g.selectedMonitorIndex(),
|
StreamIndex: g.selectedMonitorIndex(),
|
||||||
Scale: parseScale(g.scaleSel.Selected),
|
Scale: parseScale(g.scaleSel.Selected),
|
||||||
Announce: &announce,
|
Announce: &announce,
|
||||||
}
|
}
|
||||||
|
g.cfg = newCfg
|
||||||
if g.configPath != "" {
|
if g.configPath != "" {
|
||||||
if err := config.SaveTo(g.configPath, g.cfg); err != nil {
|
if err := config.SaveTo(g.configPath, g.cfg); err != nil {
|
||||||
log.Printf("gui: config save: %v", err)
|
log.Printf("gui: config save: %v", err)
|
||||||
@@ -376,17 +440,47 @@ func (g *App) start() {
|
|||||||
log.Printf("gui: config save: %v", err)
|
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())
|
eng, err := flinger.New(g.cfg.ToFlinger())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dialog.ShowError(err, g.win)
|
dialog.ShowError(err, g.win)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.eng = eng
|
g.eng = eng
|
||||||
|
g.lastStart = newCfg
|
||||||
g.eng.Start()
|
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.SetText("Stop")
|
||||||
g.startBtn.Importance = widget.DangerImportance
|
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.statusLab.Importance = widget.SuccessImportance
|
||||||
g.setTrayState(true, "TeleportFling · Streaming")
|
g.setTrayState(true, "TeleportFling · Streaming")
|
||||||
g.refresh()
|
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() {
|
func (g *App) stop() {
|
||||||
if g.statsDone != nil {
|
if g.statsDone != nil {
|
||||||
close(g.statsDone)
|
close(g.statsDone)
|
||||||
@@ -468,7 +563,6 @@ func (g *App) stop() {
|
|||||||
}
|
}
|
||||||
if g.eng != nil {
|
if g.eng != nil {
|
||||||
g.eng.Stop()
|
g.eng.Stop()
|
||||||
g.eng = nil
|
|
||||||
}
|
}
|
||||||
g.startBtn.SetText("Start")
|
g.startBtn.SetText("Start")
|
||||||
g.startBtn.Importance = widget.HighImportance
|
g.startBtn.Importance = widget.HighImportance
|
||||||
|
|||||||
+1
Submodule third_party/screencast added at 4cba613625
Reference in New Issue
Block a user