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,14 @@
|
|||||||
|
// Command teleportfling-gui is the desktop front-end for TeleportFling: a
|
||||||
|
// Fyne settings window plus a system tray to start/stop streaming.
|
||||||
|
//
|
||||||
|
// The streaming engine lives in internal/flinger; this command only wires the
|
||||||
|
// GUI and tray around it. Headless use is handled by cmd/teleportfling.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"teleportfling/internal/gui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
gui.Run()
|
||||||
|
}
|
||||||
@@ -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