Add an audio source picker to the GUI so users can capture a specific PipeWire device (sink monitor or microphone) instead of only the system default output. - vendor go2tv.app/screencast and patch the audio stream to accept a target PipeWire node serial (PW_KEY_TARGET_OBJECT); the upstream lib only ever auto-connected to the default - capture: ListAudioSources enumerates PipeWire sinks/sources via pw-dump; OpenPipeWire takes the selected node serial - flinger/config/gui: AudioSource config field, persisted and exposed as an Audio source dropdown (default output + enumerated devices) Also fixes two pre-existing bugs surfaced by stop/start testing: - engine Stop now waits for the video/audio/stats goroutines before destroying the encoder (was a use-after-free SIGSEGV) - Start/Stop now pause/resume the engine instead of tearing down and re-opening the portal session, which the portal cannot reliably do in-process (2nd CreateSession returned Ended/cancelled). Capture session stays open across stop/start.
636 lines
17 KiB
Go
636 lines
17 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"
|
||
"fmt"
|
||
"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/capture"
|
||
"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) }
|
||
|
||
// scaleLabel renders a scale factor as a percentage option.
|
||
func scaleLabel(scale float64) string {
|
||
switch {
|
||
case scale >= 0.95:
|
||
return "100%"
|
||
case scale >= 0.7:
|
||
return "75%"
|
||
case scale >= 0.45:
|
||
return "50%"
|
||
default:
|
||
return "25%"
|
||
}
|
||
}
|
||
|
||
// parseScale converts a scale option label back to a factor.
|
||
func parseScale(label string) float64 {
|
||
switch label {
|
||
case "75%":
|
||
return 0.75
|
||
case "50%":
|
||
return 0.5
|
||
case "25%":
|
||
return 0.25
|
||
default:
|
||
return 1.0
|
||
}
|
||
}
|
||
|
||
// 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
|
||
presetSel *widget.Select
|
||
scaleSel *widget.Select
|
||
nameEnt *widget.Entry
|
||
audioChk *widget.Check
|
||
audioSel *widget.Select
|
||
audioDevs []capture.AudioDevice
|
||
announceChk *widget.Check
|
||
srcSel *widget.Select
|
||
monSel *widget.Select
|
||
monitors []capture.Monitor
|
||
statsDone chan struct{}
|
||
|
||
// lastStart records the capture-affecting config the current engine was
|
||
// created with, so Start can resume instead of reopening the portal.
|
||
lastStart config.Config
|
||
}
|
||
|
||
// 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)
|
||
g.setupMonitorPicker()
|
||
|
||
// Quality.
|
||
g.qualitySel = widget.NewSelect([]string{"50", "60", "70", "80", "90", "100"}, g.applyLiveSettings)
|
||
g.qualitySel.SetSelected(itoa(g.cfg.Quality))
|
||
|
||
// FPS.
|
||
g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, g.applyLiveSettings)
|
||
g.fpsSel.SetSelected(itoa(g.cfg.FPS))
|
||
|
||
// Scale: downsampling factor (1.0 = native, 0.5 = half, etc).
|
||
g.scaleSel = widget.NewSelect([]string{"100%", "75%", "50%", "25%"}, g.applyLiveSettings)
|
||
g.scaleSel.SetSelected(scaleLabel(g.cfg.Scale))
|
||
|
||
// Preset: one-click quality/fps combos. Choosing one sets the Quality and
|
||
// Frame rate selectors and applies them (live if running). Created after
|
||
// the quality/fps selects so applyPreset's references are valid.
|
||
g.presetSel = widget.NewSelect([]string{"Low", "Medium", "High", "Ultra"}, g.applyPreset)
|
||
g.presetSel.SetSelected("High")
|
||
|
||
// Audio.
|
||
g.audioChk = widget.NewCheck("Capture system audio", func(bool) { g.applyLiveSettings("") })
|
||
g.audioChk.SetChecked(g.cfg.Audio)
|
||
g.setupAudioPicker()
|
||
|
||
// 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: "Monitor", Widget: g.monSel},
|
||
{Text: "Preset", Widget: g.presetSel},
|
||
{Text: "Quality", Widget: g.qualitySel},
|
||
{Text: "Frame rate", Widget: g.fpsSel},
|
||
{Text: "Scale", Widget: g.scaleSel},
|
||
{Text: "Audio", Widget: g.audioChk},
|
||
{Text: "Audio source", Widget: g.audioSel},
|
||
{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))
|
||
}
|
||
|
||
// setupMonitorPicker populates the monitor dropdown and ties its visibility
|
||
// to the source selector. When monitor enumeration is unavailable (non-
|
||
// Hyprland), it falls back to a raw index entry driven by the saved config.
|
||
func (g *App) setupMonitorPicker() {
|
||
// Populate monitor names for the picker.
|
||
g.monitors, _ = capture.ListMonitors()
|
||
names := make([]string, 0, len(g.monitors))
|
||
for _, m := range g.monitors {
|
||
names = append(names, fmt.Sprintf("%s (%dx%d)", m.Name, m.Width, m.Height))
|
||
}
|
||
|
||
// If we could not enumerate, present the saved index as a single option.
|
||
if len(names) == 0 {
|
||
names = []string{fmt.Sprintf("Monitor %d", g.cfg.StreamIndex)}
|
||
g.monitors = []capture.Monitor{{Index: g.cfg.StreamIndex, Name: fmt.Sprintf("Monitor %d", g.cfg.StreamIndex)}}
|
||
}
|
||
|
||
g.monSel = widget.NewSelect(names, func(string) {})
|
||
if len(g.monitors) > 0 {
|
||
// Preselect the configured index if it's within range.
|
||
for i, m := range g.monitors {
|
||
if m.Index == g.cfg.StreamIndex {
|
||
g.monSel.SetSelectedIndex(i)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// Monitor picker only applies to the "screen" source.
|
||
g.srcSel.OnChanged = func(string) {
|
||
g.monSel.Disable()
|
||
if g.srcSel.Selected == "screen" {
|
||
g.monSel.Enable()
|
||
}
|
||
}
|
||
g.monSel.Disable()
|
||
if g.cfg.Source == "screen" {
|
||
g.monSel.Enable()
|
||
}
|
||
}
|
||
|
||
// selectedMonitorIndex returns the monitor index chosen in the picker, or the
|
||
// saved config value when the picker is unavailable/disabled.
|
||
func (g *App) selectedMonitorIndex() int {
|
||
if g.monSel != nil && g.monSel.SelectedIndex() >= 0 && g.monSel.SelectedIndex() < len(g.monitors) {
|
||
return g.monitors[g.monSel.SelectedIndex()].Index
|
||
}
|
||
return g.cfg.StreamIndex
|
||
}
|
||
|
||
// setupAudioPicker populates the audio source dropdown from PipeWire. The
|
||
// first option is "Default output"; the rest are the enumerated sinks and
|
||
// microphones. A no-op if enumeration is unavailable.
|
||
func (g *App) setupAudioPicker() {
|
||
g.audioDevs, _ = capture.ListAudioSources()
|
||
|
||
names := []string{"Default output"}
|
||
for _, d := range g.audioDevs {
|
||
label := d.Desc
|
||
if label == "" {
|
||
label = d.Name
|
||
}
|
||
if d.IsOutput {
|
||
label = "Output: " + label
|
||
} else {
|
||
label = "Input: " + label
|
||
}
|
||
names = append(names, label)
|
||
}
|
||
|
||
g.audioSel = widget.NewSelect(names, func(string) { g.applyLiveSettings("") })
|
||
|
||
// Preselect the configured serial if it matches an enumerated device.
|
||
if g.cfg.AudioSource > 0 {
|
||
for i, d := range g.audioDevs {
|
||
if d.Serial == g.cfg.AudioSource {
|
||
g.audioSel.SetSelectedIndex(i + 1)
|
||
break
|
||
}
|
||
}
|
||
} else {
|
||
g.audioSel.SetSelectedIndex(0)
|
||
}
|
||
}
|
||
|
||
// selectedAudioSerial returns the PipeWire serial chosen in the picker, or 0
|
||
// for the default output.
|
||
func (g *App) selectedAudioSerial() uint64 {
|
||
if g.audioSel == nil || g.audioSel.SelectedIndex() <= 0 {
|
||
return 0
|
||
}
|
||
idx := g.audioSel.SelectedIndex() - 1
|
||
if idx >= 0 && idx < len(g.audioDevs) {
|
||
return g.audioDevs[idx].Serial
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// applyPreset applies a named quality/fps preset to the Quality and Frame
|
||
// rate selectors, then pushes it live if the engine is running.
|
||
func (g *App) applyPreset(string) {
|
||
var q, f int
|
||
switch g.presetSel.Selected {
|
||
case "Low":
|
||
q, f = 50, 15
|
||
case "Medium":
|
||
q, f = 70, 30
|
||
case "Ultra":
|
||
q, f = 100, 60
|
||
default: // High
|
||
q, f = 85, 30
|
||
}
|
||
g.qualitySel.SetSelected(itoa(q))
|
||
g.fpsSel.SetSelected(itoa(f))
|
||
g.applyLiveSettings("")
|
||
}
|
||
|
||
// applyLiveSettings pushes the current form values (quality, fps, name,
|
||
// audio, announce) into a running engine via SetConfig so changes take
|
||
// effect without restarting the stream. When the engine is not running it is
|
||
// a no-op; the values are still captured on the next Start.
|
||
func (g *App) applyLiveSettings(string) {
|
||
if g.eng == nil {
|
||
return
|
||
}
|
||
quality, _ := atoi(g.qualitySel.Selected)
|
||
fps, _ := atoi(g.fpsSel.Selected)
|
||
announce := g.announceChk.Checked
|
||
cfg := flinger.Config{
|
||
Name: g.nameEnt.Text,
|
||
Port: g.cfg.Port,
|
||
Quality: quality,
|
||
FPS: fps,
|
||
Source: g.cfg.Source,
|
||
Audio: g.audioChk.Checked,
|
||
AudioSource: g.selectedAudioSerial(),
|
||
StreamIndex: g.cfg.StreamIndex,
|
||
Scale: parseScale(g.scaleSel.Selected),
|
||
Announce: announce,
|
||
}
|
||
if err := g.eng.SetConfig(cfg); err != nil {
|
||
log.Printf("gui: live settings: %v", err)
|
||
return
|
||
}
|
||
// Keep the persisted config in sync with what we just applied.
|
||
g.cfg.Quality = quality
|
||
g.cfg.FPS = fps
|
||
g.cfg.Audio = cfg.Audio
|
||
g.cfg.AudioSource = cfg.AudioSource
|
||
g.cfg.Name = cfg.Name
|
||
g.cfg.Scale = cfg.Scale
|
||
ann := announce
|
||
g.cfg.Announce = &ann
|
||
}
|
||
|
||
// toggleStream starts or stops the engine based on current UI state.
|
||
func (g *App) toggleStream() {
|
||
if g.lock {
|
||
return
|
||
}
|
||
// Engine exists and is currently running → pause it.
|
||
if g.eng != nil && g.eng.Status().Running {
|
||
g.stop()
|
||
return
|
||
}
|
||
g.start()
|
||
}
|
||
|
||
// start reads the form into cfg, saves it, and boots the engine. If an engine
|
||
// already exists and the capture-affecting settings are unchanged, it resumes
|
||
// the paused engine instead of reopening the portal session (which cannot be
|
||
// reliably re-created in-process).
|
||
func (g *App) start() {
|
||
port, _ := atoi(g.portEnt.Text)
|
||
quality, _ := atoi(g.qualitySel.Selected)
|
||
fps, _ := atoi(g.fpsSel.Selected)
|
||
|
||
announce := g.announceChk.Checked
|
||
newCfg := config.Config{
|
||
Name: g.nameEnt.Text,
|
||
Port: port,
|
||
Quality: quality,
|
||
FPS: fps,
|
||
Source: g.srcSel.Selected,
|
||
Audio: g.audioChk.Checked,
|
||
AudioSource: g.selectedAudioSerial(),
|
||
StreamIndex: g.selectedMonitorIndex(),
|
||
Scale: parseScale(g.scaleSel.Selected),
|
||
Announce: &announce,
|
||
}
|
||
g.cfg = newCfg
|
||
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)
|
||
}
|
||
|
||
// Resume the existing engine if capture-affecting fields are unchanged.
|
||
if g.eng != nil && captureConfigEqual(g.lastStart, newCfg) {
|
||
// Push live-applicable changes, then resume.
|
||
g.applyLiveSettings("")
|
||
g.eng.Resume()
|
||
g.startedUI(newCfg)
|
||
return
|
||
}
|
||
|
||
// Otherwise close any existing engine and create a fresh one.
|
||
if g.eng != nil {
|
||
g.eng.Close()
|
||
g.eng = nil
|
||
}
|
||
|
||
eng, err := flinger.New(g.cfg.ToFlinger())
|
||
if err != nil {
|
||
dialog.ShowError(err, g.win)
|
||
return
|
||
}
|
||
g.eng = eng
|
||
g.lastStart = newCfg
|
||
g.eng.Start()
|
||
|
||
g.startedUI(newCfg)
|
||
}
|
||
|
||
// captureConfigEqual reports whether two configs agree on the fields that
|
||
// require reopening the capture session (source, port, monitor, audio
|
||
// source). Live-applicable fields (quality, fps, scale, name, announce,
|
||
// audio-on) are ignored.
|
||
func captureConfigEqual(a, b config.Config) bool {
|
||
return a.Source == b.Source && a.Port == b.Port &&
|
||
a.StreamIndex == b.StreamIndex && a.AudioSource == b.AudioSource
|
||
}
|
||
|
||
// startedUI updates the UI to the streaming state after a start/resume.
|
||
func (g *App) startedUI(cfg config.Config) {
|
||
g.startBtn.SetText("Stop")
|
||
g.startBtn.Importance = widget.DangerImportance
|
||
g.statusLab.SetText("Streaming (port " + itoa(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))
|
||
if st.Err != nil {
|
||
g.statusLab.Importance = widget.DangerImportance
|
||
} else {
|
||
g.statusLab.Importance = widget.SuccessImportance
|
||
}
|
||
tip := "TeleportFling · " + frames + " frames, " + dropped + " dropped"
|
||
if st.Bitrate > 0 {
|
||
tip += " · " + formatBitrate(st.Bitrate)
|
||
}
|
||
if st.Err != nil {
|
||
tip += " · error"
|
||
}
|
||
g.setTrayState(true, tip)
|
||
})
|
||
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"
|
||
}
|
||
if st.Bitrate > 0 {
|
||
base += " · " + formatBitrate(st.Bitrate)
|
||
}
|
||
if st.Err != nil {
|
||
base += "\nError: " + st.Err.Error()
|
||
}
|
||
return base
|
||
}
|
||
|
||
// formatBitrate renders a bits/sec value in a human-readable form.
|
||
func formatBitrate(bps int64) string {
|
||
switch {
|
||
case bps >= 1_000_000:
|
||
return fmt.Sprintf("%.1f Mbps", float64(bps)/1_000_000)
|
||
case bps >= 1_000:
|
||
return fmt.Sprintf("%.0f kbps", float64(bps)/1_000)
|
||
default:
|
||
return fmt.Sprintf("%d bps", bps)
|
||
}
|
||
}
|
||
|
||
// stop pauses the engine (keeping the portal session open) 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.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)
|
||
}
|