feat: add Fyne desktop GUI with system tray (M3)
- internal/gui: settings form (name, port, source, quality, fps, audio) wired to the flinger engine, config save/load, start/stop toggle - system tray via Fyne's StatusNotifierItem: Show (raises window), Start/Stop, Quit; left-click shows the settings window - cmd/teleportfling-gui entry point Verified on Hyprland: tray icon registers, start/stop streaming works, window shows and focuses from the tray.
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
// 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"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
|
||||
"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 1–65535")
|
||||
|
||||
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
|
||||
|
||||
cfg config.Config
|
||||
eng *flinger.Engine
|
||||
lock bool // serialises start/stop against UI actions
|
||||
|
||||
// UI state.
|
||||
startBtn *widget.Button
|
||||
statusLab *widget.Label
|
||||
portEnt *widget.Entry
|
||||
qualitySel *widget.Select
|
||||
fpsSel *widget.Select
|
||||
nameEnt *widget.Entry
|
||||
audioChk *widget.Check
|
||||
srcSel *widget.Select
|
||||
}
|
||||
|
||||
// Run starts the GUI and blocks until the app exits.
|
||||
func Run() {
|
||||
g := &App{}
|
||||
|
||||
g.fyneApp = app.NewWithID(appID)
|
||||
g.win = g.fyneApp.NewWindow("TeleportFling")
|
||||
|
||||
// Load persisted settings (falling back to defaults).
|
||||
g.cfg = 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, logging and falling back to defaults.
|
||||
func mustLoadConfig() config.Config {
|
||||
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)
|
||||
|
||||
// Quality.
|
||||
g.qualitySel = widget.NewSelect([]string{"50", "60", "70", "80", "90", "100"}, func(string) {})
|
||||
g.qualitySel.SetSelected(itoa(g.cfg.Quality))
|
||||
|
||||
// FPS.
|
||||
g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, func(string) {})
|
||||
g.fpsSel.SetSelected(itoa(g.cfg.FPS))
|
||||
|
||||
// Audio.
|
||||
g.audioChk = widget.NewCheck("Capture system audio", nil)
|
||||
g.audioChk.SetChecked(g.cfg.Audio)
|
||||
|
||||
// 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: "Quality", Widget: g.qualitySel},
|
||||
{Text: "Frame rate", Widget: g.fpsSel},
|
||||
{Text: "", Widget: g.audioChk},
|
||||
},
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
g.cfg = config.Config{
|
||||
Name: g.nameEnt.Text,
|
||||
Port: port,
|
||||
Quality: quality,
|
||||
FPS: fps,
|
||||
Source: g.srcSel.Selected,
|
||||
Audio: g.audioChk.Checked,
|
||||
StreamIndex: g.cfg.StreamIndex,
|
||||
}
|
||||
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.refresh()
|
||||
}
|
||||
|
||||
// stop halts the engine and returns the UI to the stopped state.
|
||||
func (g *App) stop() {
|
||||
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.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
|
||||
}
|
||||
|
||||
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() })
|
||||
}
|
||||
Reference in New Issue
Block a user