// 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" "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/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 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 nameEnt *widget.Entry audioChk *widget.Check announceChk *widget.Check srcSel *widget.Select 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) // 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) // 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: "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)) } // 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.cfg.StreamIndex, 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)) g.setTrayState(true, "TeleportFling · "+frames+" frames, "+dropped+" dropped") }) 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" } return base } // 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) }