- tray icon and tooltip now reflect engine state: grey when stopped, green + live frame/drop counters in the tooltip while streaming - generate a proper app icon (assets/, via cmd/teleportfling-icon) and embed it so the window and tray carry the icon without runtime files - add a .desktop entry template for launching from an app menu - run Fyne UI updates (status label, tray icon/tooltip) on the main thread via fyne.Do to satisfy the threading model
341 lines
8.7 KiB
Go
341 lines
8.7 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"
|
||
"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
|
||
|
||
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.
|
||
func Run() {
|
||
g := &App{}
|
||
|
||
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 = 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)
|
||
|
||
// 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 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)
|
||
}
|