- 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.
255 lines
6.4 KiB
Go
255 lines
6.4 KiB
Go
// 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() })
|
||
}
|