Files
petere 3b3355e002 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.
2026-09-19 21:44:10 +01:00

636 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package gui provides the Fyne desktop app: a settings window and a system
// tray that start/stop the flinger engine.
//
// The engine itself lives in internal/flinger and is GUI-free, so the tray
// can control streaming without any UI dependency. This package wires the two
// together: config persistence, the settings form, and the tray menu.
package gui
import (
"errors"
"fmt"
"image/color"
"log"
"strconv"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/widget"
"fyne.io/systray"
"teleportfling/assets"
"teleportfling/internal/capture"
"teleportfling/internal/config"
"teleportfling/internal/flinger"
)
// errInvalidPort is returned when the port field contains a non-numeric or
// out-of-range value.
var errInvalidPort = errors.New("port must be 165535")
func itoa(v int) string { return strconv.Itoa(v) }
func atoi(s string) (int, error) { return strconv.Atoi(s) }
// scaleLabel renders a scale factor as a percentage option.
func scaleLabel(scale float64) string {
switch {
case scale >= 0.95:
return "100%"
case scale >= 0.7:
return "75%"
case scale >= 0.45:
return "50%"
default:
return "25%"
}
}
// parseScale converts a scale option label back to a factor.
func parseScale(label string) float64 {
switch label {
case "75%":
return 0.75
case "50%":
return 0.5
case "25%":
return 0.25
default:
return 1.0
}
}
// appID is the Fyne application ID used for preferences/settings storage.
const appID = "io.teleportfling"
// App is the desktop application shell.
type App struct {
fyneApp fyne.App
win fyne.Window
desk desktop.App
// configPath overrides the default config location ("" = default).
configPath string
cfg config.Config
eng *flinger.Engine
lock bool // serialises start/stop against UI actions
// Tray state icons, built once at startup.
iconIdle, iconActive *staticResource
// UI state.
startBtn *widget.Button
statusLab *widget.Label
portEnt *widget.Entry
qualitySel *widget.Select
fpsSel *widget.Select
presetSel *widget.Select
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
// non-default settings file ("" uses the default location).
func Run(configPath string) {
g := &App{configPath: configPath}
g.fyneApp = app.NewWithID(appID)
g.win = g.fyneApp.NewWindow("TeleportFling")
g.fyneApp.SetIcon(fyne.NewStaticResource("teleportfling", assets.AppIcon))
g.iconIdle = newTrayResource(color.NRGBA{R: 90, G: 90, B: 95, A: 255}, color.NRGBA{R: 60, G: 60, B: 65, A: 255})
g.iconActive = newTrayResource(color.NRGBA{R: 46, G: 125, B: 50, A: 255}, color.NRGBA{R: 150, G: 220, B: 140, A: 255})
// Load persisted settings (falling back to defaults).
g.cfg = g.mustLoadConfig()
g.buildUI()
// Closing the window hides it (tray keeps the app alive) rather than
// quitting, which is the expected behaviour for a tray app.
g.win.SetCloseIntercept(func() {
g.win.Hide()
})
g.setupTray()
g.win.ShowAndRun()
}
// mustLoadConfig loads the config (from the configured path, or the default),
// logging and falling back to defaults on error.
func (g *App) mustLoadConfig() config.Config {
var (
c config.Config
err error
)
if g.configPath != "" {
c, err = config.LoadFrom(g.configPath)
} else {
c, err = config.Load()
}
if err != nil {
log.Printf("gui: config load: %v (using defaults)", err)
return config.Default()
}
return c
}
// buildUI creates the settings form and wires its widgets to cfg.
func (g *App) buildUI() {
// Stream identity.
g.nameEnt = widget.NewEntry()
g.nameEnt.SetPlaceHolder("Stream name (default: hostname)")
g.nameEnt.SetText(g.cfg.Name)
// Port.
g.portEnt = widget.NewEntry()
g.portEnt.SetText(itoa(g.cfg.Port))
g.portEnt.Validator = func(s string) error {
n, err := atoi(s)
if err != nil || n < 1 || n > 65535 {
return errInvalidPort
}
return nil
}
// Source.
g.srcSel = widget.NewSelect([]string{"screen", "pattern"}, func(string) {})
g.srcSel.SetSelected(g.cfg.Source)
g.setupMonitorPicker()
// Quality.
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"}, g.applyLiveSettings)
g.fpsSel.SetSelected(itoa(g.cfg.FPS))
// Scale: downsampling factor (1.0 = native, 0.5 = half, etc).
g.scaleSel = widget.NewSelect([]string{"100%", "75%", "50%", "25%"}, g.applyLiveSettings)
g.scaleSel.SetSelected(scaleLabel(g.cfg.Scale))
// Preset: one-click quality/fps combos. Choosing one sets the Quality and
// Frame rate selectors and applies them (live if running). Created after
// the quality/fps selects so applyPreset's references are valid.
g.presetSel = widget.NewSelect([]string{"Low", "Medium", "High", "Ultra"}, g.applyPreset)
g.presetSel.SetSelected("High")
// Audio.
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)
announce := true
if g.cfg.Announce != nil {
announce = *g.cfg.Announce
}
g.announceChk.SetChecked(announce)
// Status label.
g.statusLab = widget.NewLabel("Stopped")
g.statusLab.Importance = widget.MediumImportance
// Start/stop toggle.
g.startBtn = widget.NewButton("Start", g.toggleStream)
g.startBtn.Importance = widget.HighImportance
form := &widget.Form{
Items: []*widget.FormItem{
{Text: "Name", Widget: g.nameEnt},
{Text: "Port", Widget: g.portEnt},
{Text: "Source", Widget: g.srcSel},
{Text: "Monitor", Widget: g.monSel},
{Text: "Preset", Widget: g.presetSel},
{Text: "Quality", Widget: g.qualitySel},
{Text: "Frame rate", Widget: g.fpsSel},
{Text: "Scale", Widget: g.scaleSel},
{Text: "Audio", Widget: g.audioChk},
{Text: "Audio source", Widget: g.audioSel},
{Text: "", Widget: g.announceChk},
},
}
content := container.NewVBox(
widget.NewLabelWithStyle("TeleportFling", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
form,
g.statusLab,
g.startBtn,
)
g.win.SetContent(container.NewBorder(nil, content, nil, nil, nil))
g.win.Resize(fyne.NewSize(380, 0))
}
// setupMonitorPicker populates the monitor dropdown and ties its visibility
// to the source selector. When monitor enumeration is unavailable (non-
// Hyprland), it falls back to a raw index entry driven by the saved config.
func (g *App) setupMonitorPicker() {
// Populate monitor names for the picker.
g.monitors, _ = capture.ListMonitors()
names := make([]string, 0, len(g.monitors))
for _, m := range g.monitors {
names = append(names, fmt.Sprintf("%s (%dx%d)", m.Name, m.Width, m.Height))
}
// If we could not enumerate, present the saved index as a single option.
if len(names) == 0 {
names = []string{fmt.Sprintf("Monitor %d", g.cfg.StreamIndex)}
g.monitors = []capture.Monitor{{Index: g.cfg.StreamIndex, Name: fmt.Sprintf("Monitor %d", g.cfg.StreamIndex)}}
}
g.monSel = widget.NewSelect(names, func(string) {})
if len(g.monitors) > 0 {
// Preselect the configured index if it's within range.
for i, m := range g.monitors {
if m.Index == g.cfg.StreamIndex {
g.monSel.SetSelectedIndex(i)
break
}
}
}
// Monitor picker only applies to the "screen" source.
g.srcSel.OnChanged = func(string) {
g.monSel.Disable()
if g.srcSel.Selected == "screen" {
g.monSel.Enable()
}
}
g.monSel.Disable()
if g.cfg.Source == "screen" {
g.monSel.Enable()
}
}
// selectedMonitorIndex returns the monitor index chosen in the picker, or the
// saved config value when the picker is unavailable/disabled.
func (g *App) selectedMonitorIndex() int {
if g.monSel != nil && g.monSel.SelectedIndex() >= 0 && g.monSel.SelectedIndex() < len(g.monitors) {
return g.monitors[g.monSel.SelectedIndex()].Index
}
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) {
var q, f int
switch g.presetSel.Selected {
case "Low":
q, f = 50, 15
case "Medium":
q, f = 70, 30
case "Ultra":
q, f = 100, 60
default: // High
q, f = 85, 30
}
g.qualitySel.SetSelected(itoa(q))
g.fpsSel.SetSelected(itoa(f))
g.applyLiveSettings("")
}
// 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,
AudioSource: g.selectedAudioSerial(),
StreamIndex: g.cfg.StreamIndex,
Scale: parseScale(g.scaleSel.Selected),
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.AudioSource = cfg.AudioSource
g.cfg.Name = cfg.Name
g.cfg.Scale = cfg.Scale
ann := announce
g.cfg.Announce = &ann
}
// toggleStream starts or stops the engine based on current UI state.
func (g *App) toggleStream() {
if g.lock {
return
}
// 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. 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
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)
}
} else if err := config.Save(g.cfg); err != nil {
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(cfg.Port) + ")")
g.statusLab.Importance = widget.SuccessImportance
g.setTrayState(true, "TeleportFling · Streaming")
g.refresh()
g.watchStats()
}
// watchStats refreshes the status label with live counters while streaming.
func (g *App) watchStats() {
done := make(chan struct{})
g.statsDone = done
go func() {
tick := time.NewTicker(2 * time.Second)
defer tick.Stop()
for {
select {
case <-tick.C:
if g.eng == nil {
return
}
st := g.eng.Status()
frames := itoa(int(st.Frames))
dropped := itoa(int(st.Dropped))
// Fyne UI calls must run on the main thread.
fyne.Do(func() {
g.statusLab.SetText(formatStatus(st))
if st.Err != nil {
g.statusLab.Importance = widget.DangerImportance
} else {
g.statusLab.Importance = widget.SuccessImportance
}
tip := "TeleportFling · " + frames + " frames, " + dropped + " dropped"
if st.Bitrate > 0 {
tip += " · " + formatBitrate(st.Bitrate)
}
if st.Err != nil {
tip += " · error"
}
g.setTrayState(true, tip)
})
case <-done:
return
}
}
}()
}
// formatStatus renders the live status line.
func formatStatus(st flinger.Status) string {
base := "Streaming · " + itoa(int(st.Frames)) + " frames"
if st.Dropped > 0 {
base += " · " + itoa(int(st.Dropped)) + " dropped"
}
if st.Bitrate > 0 {
base += " · " + formatBitrate(st.Bitrate)
}
if st.Err != nil {
base += "\nError: " + st.Err.Error()
}
return base
}
// formatBitrate renders a bits/sec value in a human-readable form.
func formatBitrate(bps int64) string {
switch {
case bps >= 1_000_000:
return fmt.Sprintf("%.1f Mbps", float64(bps)/1_000_000)
case bps >= 1_000:
return fmt.Sprintf("%.0f kbps", float64(bps)/1_000)
default:
return fmt.Sprintf("%d bps", bps)
}
}
// 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)
g.statsDone = nil
}
if g.eng != nil {
g.eng.Stop()
}
g.startBtn.SetText("Start")
g.startBtn.Importance = widget.HighImportance
g.statusLab.SetText("Stopped")
g.statusLab.Importance = widget.MediumImportance
g.setTrayState(false, "TeleportFling")
g.refresh()
}
// refresh forces the window to repaint (state/importance changed).
func (g *App) refresh() {
g.startBtn.Refresh()
g.statusLab.Refresh()
}
// setupTray registers the system tray menu and window. If the platform driver
// does not support a tray (older Fyne/desktops), this is a no-op and the
// window close intercept simply won't hide.
func (g *App) setupTray() {
desk, ok := g.fyneApp.(desktop.App)
if !ok {
log.Printf("gui: system tray not supported on this desktop")
return
}
g.desk = desk
m := fyne.NewMenu("TeleportFling",
fyne.NewMenuItem("Show", func() {
// Show is a no-op if the window is already visible, so also raise
// and focus it to bring it to the foreground.
g.win.Show()
g.win.RequestFocus()
}),
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem("Start", g.toggleStream),
fyne.NewMenuItem("Stop", func() {
if g.eng != nil {
g.stop()
}
}),
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem("Quit", func() {
if g.eng != nil {
g.eng.Stop()
}
g.fyneApp.Quit()
}),
)
desk.SetSystemTrayMenu(m)
// Left-clicking the tray icon shows the settings window.
desk.SetSystemTrayWindow(g.win)
g.win.SetOnClosed(func() { g.fyneApp.Quit() })
// Show the "stopped" state icon initially.
g.setTrayState(false, "TeleportFling")
}
// setTrayState swaps the tray icon and tooltip to reflect the streaming state.
// It is a no-op if the tray is unavailable.
func (g *App) setTrayState(streaming bool, tooltip string) {
if g.desk == nil || g.iconIdle == nil || g.iconActive == nil {
return
}
icon := fyne.Resource(g.iconIdle)
if streaming {
icon = g.iconActive
}
g.desk.SetSystemTrayIcon(icon)
systray.SetTooltip(tooltip)
}