feat: add tray streaming status and app icon
- 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
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
// Package assets embeds the TeleportFling application icon.
|
||||||
|
//
|
||||||
|
// The PNGs live in the repository assets/ directory and are generated by
|
||||||
|
// cmd/teleportfling-icon. Embedding keeps the icon with the binary so the GUI
|
||||||
|
// and tray never depend on a runtime file path.
|
||||||
|
package assets
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
// AppIcon256 is the 256px application icon.
|
||||||
|
//
|
||||||
|
//go:embed teleportfling-256.png
|
||||||
|
var AppIcon256 []byte
|
||||||
|
|
||||||
|
// AppIcon64 is the 64px icon used by the system tray.
|
||||||
|
//
|
||||||
|
//go:embed teleportfling-64.png
|
||||||
|
var AppIcon64 []byte
|
||||||
|
|
||||||
|
// AppIcon is the full-resolution (512px) application icon.
|
||||||
|
//
|
||||||
|
//go:embed teleportfling.png
|
||||||
|
var AppIcon []byte
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 431 B |
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=TeleportFling
|
||||||
|
GenericName=Screen and audio stream sender
|
||||||
|
Comment=Stream this screen and audio to an OBS Teleport receiver
|
||||||
|
Exec=teleportfling-gui
|
||||||
|
Icon=teleportfling
|
||||||
|
Terminal=false
|
||||||
|
Categories=AudioVideo;Video;Network;
|
||||||
|
StartupNotify=false
|
||||||
|
StartupWMClass=io.teleportfling
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,115 @@
|
|||||||
|
// Command teleportfling-icon regenerates the application icon assets in
|
||||||
|
// assets/ from scratch. It draws a rounded green square with two lighter
|
||||||
|
// "fling" dots suggesting motion.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// teleportfling-icon
|
||||||
|
//
|
||||||
|
// The generated PNGs are committed to the repo and embedded into the binary
|
||||||
|
// via the assets package; this tool exists so the icon can be tweaked and
|
||||||
|
// regenerated without a design tool.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sizes are the icon sizes to emit. Hosts scale from these; 512 is used for
|
||||||
|
// the app icon, 256 and 64 for smaller contexts.
|
||||||
|
var sizes = []int{512, 256, 64}
|
||||||
|
|
||||||
|
// outputDir is where the PNGs are written (the repo assets directory).
|
||||||
|
const outputDir = "assets"
|
||||||
|
|
||||||
|
// inRoundedRect reports whether (x,y) lies inside a square with rounded
|
||||||
|
// corners of the given radius.
|
||||||
|
func inRoundedRect(x, y, radius, size int) bool {
|
||||||
|
m := size - 1
|
||||||
|
switch {
|
||||||
|
case x >= radius && x <= m-radius:
|
||||||
|
return true
|
||||||
|
case y >= radius && y <= m-radius:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var cx, cy int
|
||||||
|
if x < radius {
|
||||||
|
cx = radius
|
||||||
|
} else {
|
||||||
|
cx = m - radius
|
||||||
|
}
|
||||||
|
if y < radius {
|
||||||
|
cy = radius
|
||||||
|
} else {
|
||||||
|
cy = m - radius
|
||||||
|
}
|
||||||
|
dx, dy := x-cx, y-cy
|
||||||
|
return dx*dx+dy*dy <= radius*radius
|
||||||
|
}
|
||||||
|
|
||||||
|
// render draws the icon at the given size into a PNG.
|
||||||
|
func render(size int, bg, dot color.NRGBA) []byte {
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||||
|
radius := size / 5
|
||||||
|
for y := 0; y < size; y++ {
|
||||||
|
for x := 0; x < size; x++ {
|
||||||
|
if inRoundedRect(x, y, radius, size) {
|
||||||
|
img.SetNRGBA(x, y, bg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Two dots suggesting a flung stream, offset diagonally.
|
||||||
|
for i, off := range []int{3, 9} {
|
||||||
|
cx, cy, r := size/2, size/2+off*size/32, size/8-i
|
||||||
|
for y := cy - r; y <= cy+r; y++ {
|
||||||
|
for x := cx - r; x <= cx+r; x++ {
|
||||||
|
if (x-cx)*(x-cx)+(y-cy)*(y-cy) <= r*r {
|
||||||
|
img.SetNRGBA(x, y, dot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = png.Encode(&buf, img)
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
bg := color.NRGBA{R: 46, G: 125, B: 50, A: 255} // teleport green
|
||||||
|
dot := color.NRGBA{R: 150, G: 220, B: 140, A: 255}
|
||||||
|
|
||||||
|
//nolint:gosec // writing generated PNG assets into the repo, not secrets
|
||||||
|
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, size := range sizes {
|
||||||
|
name := "teleportfling"
|
||||||
|
if size != 512 {
|
||||||
|
name += "-" + itoa(size)
|
||||||
|
}
|
||||||
|
path := outputDir + "/" + name + ".png"
|
||||||
|
//nolint:gosec // generated PNG assets, world-readable for installers
|
||||||
|
if err := os.WriteFile(path, render(size, bg, dot), 0o644); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(v int) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [10]byte
|
||||||
|
i := len(buf)
|
||||||
|
for v > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + v%10)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ package gui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"image/color"
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,7 +19,9 @@ import (
|
|||||||
"fyne.io/fyne/v2/dialog"
|
"fyne.io/fyne/v2/dialog"
|
||||||
"fyne.io/fyne/v2/driver/desktop"
|
"fyne.io/fyne/v2/driver/desktop"
|
||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
|
"fyne.io/systray"
|
||||||
|
|
||||||
|
"teleportfling/assets"
|
||||||
"teleportfling/internal/config"
|
"teleportfling/internal/config"
|
||||||
"teleportfling/internal/flinger"
|
"teleportfling/internal/flinger"
|
||||||
)
|
)
|
||||||
@@ -38,11 +41,15 @@ const appID = "io.teleportfling"
|
|||||||
type App struct {
|
type App struct {
|
||||||
fyneApp fyne.App
|
fyneApp fyne.App
|
||||||
win fyne.Window
|
win fyne.Window
|
||||||
|
desk desktop.App
|
||||||
|
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
eng *flinger.Engine
|
eng *flinger.Engine
|
||||||
lock bool // serialises start/stop against UI actions
|
lock bool // serialises start/stop against UI actions
|
||||||
|
|
||||||
|
// Tray state icons, built once at startup.
|
||||||
|
iconIdle, iconActive *staticResource
|
||||||
|
|
||||||
// UI state.
|
// UI state.
|
||||||
startBtn *widget.Button
|
startBtn *widget.Button
|
||||||
statusLab *widget.Label
|
statusLab *widget.Label
|
||||||
@@ -62,6 +69,9 @@ func Run() {
|
|||||||
|
|
||||||
g.fyneApp = app.NewWithID(appID)
|
g.fyneApp = app.NewWithID(appID)
|
||||||
g.win = g.fyneApp.NewWindow("TeleportFling")
|
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).
|
// Load persisted settings (falling back to defaults).
|
||||||
g.cfg = mustLoadConfig()
|
g.cfg = mustLoadConfig()
|
||||||
@@ -207,6 +217,7 @@ func (g *App) start() {
|
|||||||
g.startBtn.Importance = widget.DangerImportance
|
g.startBtn.Importance = widget.DangerImportance
|
||||||
g.statusLab.SetText("Streaming (port " + itoa(g.cfg.Port) + ")")
|
g.statusLab.SetText("Streaming (port " + itoa(g.cfg.Port) + ")")
|
||||||
g.statusLab.Importance = widget.SuccessImportance
|
g.statusLab.Importance = widget.SuccessImportance
|
||||||
|
g.setTrayState(true, "TeleportFling · Streaming")
|
||||||
g.refresh()
|
g.refresh()
|
||||||
g.watchStats()
|
g.watchStats()
|
||||||
}
|
}
|
||||||
@@ -225,7 +236,13 @@ func (g *App) watchStats() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
st := g.eng.Status()
|
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.statusLab.SetText(formatStatus(st))
|
||||||
|
g.setTrayState(true, "TeleportFling · "+frames+" frames, "+dropped+" dropped")
|
||||||
|
})
|
||||||
case <-done:
|
case <-done:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -256,6 +273,7 @@ func (g *App) stop() {
|
|||||||
g.startBtn.Importance = widget.HighImportance
|
g.startBtn.Importance = widget.HighImportance
|
||||||
g.statusLab.SetText("Stopped")
|
g.statusLab.SetText("Stopped")
|
||||||
g.statusLab.Importance = widget.MediumImportance
|
g.statusLab.Importance = widget.MediumImportance
|
||||||
|
g.setTrayState(false, "TeleportFling")
|
||||||
g.refresh()
|
g.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +292,7 @@ func (g *App) setupTray() {
|
|||||||
log.Printf("gui: system tray not supported on this desktop")
|
log.Printf("gui: system tray not supported on this desktop")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
g.desk = desk
|
||||||
|
|
||||||
m := fyne.NewMenu("TeleportFling",
|
m := fyne.NewMenu("TeleportFling",
|
||||||
fyne.NewMenuItem("Show", func() {
|
fyne.NewMenuItem("Show", func() {
|
||||||
@@ -301,4 +320,21 @@ func (g *App) setupTray() {
|
|||||||
// Left-clicking the tray icon shows the settings window.
|
// Left-clicking the tray icon shows the settings window.
|
||||||
desk.SetSystemTrayWindow(g.win)
|
desk.SetSystemTrayWindow(g.win)
|
||||||
g.win.SetOnClosed(func() { g.fyneApp.Quit() })
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// 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, the tray menu and the
|
||||||
|
// tray status icon.
|
||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
// traySize is the icon size in pixels. Tray hosts typically request 22px;
|
||||||
|
// we render larger and let the host scale down for crispness.
|
||||||
|
const traySize = 64
|
||||||
|
|
||||||
|
// newTrayResource renders a rounded square with a status dot into a
|
||||||
|
// fyne.Resource that SetSystemTrayIcon accepts.
|
||||||
|
func newTrayResource(bg, dot color.NRGBA) *staticResource {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, traySize, traySize))
|
||||||
|
|
||||||
|
// Rounded-square background.
|
||||||
|
radius := 14
|
||||||
|
for y := 0; y < traySize; y++ {
|
||||||
|
for x := 0; x < traySize; x++ {
|
||||||
|
if inRoundedRect(x, y, radius) {
|
||||||
|
img.Set(x, y, bg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center status dot.
|
||||||
|
cx, cy, r := traySize/2, traySize/2, 16
|
||||||
|
for y := cy - r; y <= cy+r; y++ {
|
||||||
|
for x := cx - r; x <= cx+r; x++ {
|
||||||
|
if (x-cx)*(x-cx)+(y-cy)*(y-cy) <= r*r {
|
||||||
|
img.Set(x, y, dot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = png.Encode(&buf, img)
|
||||||
|
return &staticResource{name: "teleportfling-tray", data: buf.Bytes()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inRoundedRect reports whether (x,y) falls inside the rounded square.
|
||||||
|
func inRoundedRect(x, y, radius int) bool {
|
||||||
|
const m = traySize - 1
|
||||||
|
switch {
|
||||||
|
case x >= radius && x <= m-radius:
|
||||||
|
return true
|
||||||
|
case y >= radius && y <= m-radius:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Corner: within radius of the nearest corner centre.
|
||||||
|
var cx, cy int
|
||||||
|
if x < radius {
|
||||||
|
cx = radius
|
||||||
|
} else {
|
||||||
|
cx = m - radius
|
||||||
|
}
|
||||||
|
if y < radius {
|
||||||
|
cy = radius
|
||||||
|
} else {
|
||||||
|
cy = m - radius
|
||||||
|
}
|
||||||
|
dx, dy := x-cx, y-cy
|
||||||
|
return dx*dx+dy*dy <= radius*radius
|
||||||
|
}
|
||||||
|
|
||||||
|
// staticResource is a simple in-memory fyne.Resource.
|
||||||
|
type staticResource struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *staticResource) Name() string { return r.name }
|
||||||
|
func (r *staticResource) Content() []byte { return r.data }
|
||||||
Reference in New Issue
Block a user