Files
TeleportFling/internal/gui/gui.go
T
petere 583274d9e6 feat: add bitrate measurement and quality presets
- sender tracks payload bytes; engine measures stream bitrate over a
  sliding window and exposes it in Status
- GUI status label and tray tooltip show the live bitrate (e.g. 36 Mbps)
- add a Preset dropdown (Low/Medium/High/Ultra) that sets quality+fps
  together and applies live via SetConfig

Verified: 36 Mbps shown for Medium (quality 70 @ 30fps) matches the
wire measurement.
2026-09-19 17:53:49 +01:00

505 lines
14 KiB
Go
Raw 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) }
// 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
nameEnt *widget.Entry
audioChk *widget.Check
announceChk *widget.Check
srcSel *widget.Select
monSel *widget.Select
monitors []capture.Monitor
statsDone chan struct{}
}
// 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))
// 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", nil)
g.audioChk.SetChecked(g.cfg.Audio)
// 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: "", Widget: g.audioChk},
{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
}
// 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,
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 {
return
}
if g.eng != nil {
g.stop()
return
}
g.start()
}
// start reads the form into cfg, saves it, and boots the engine.
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{
Name: g.nameEnt.Text,
Port: port,
Quality: quality,
FPS: fps,
Source: g.srcSel.Selected,
Audio: g.audioChk.Checked,
StreamIndex: g.selectedMonitorIndex(),
Announce: &announce,
}
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)
}
eng, err := flinger.New(g.cfg.ToFlinger())
if err != nil {
dialog.ShowError(err, g.win)
return
}
g.eng = eng
g.eng.Start()
g.startBtn.SetText("Stop")
g.startBtn.Importance = widget.DangerImportance
g.statusLab.SetText("Streaming (port " + itoa(g.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 halts the engine 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.eng = nil
}
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)
}