Compare commits

..
7 Commits
Author SHA1 Message Date
petere 3b3355e002 feat: selectable audio source
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.
2026-09-19 21:44:10 +01:00
petere f1f2bfe1a3 feat: portal-based monitor enumeration fallback
Add a xdg-desktop-portal ScreenCast fallback to monitor enumeration so
the GUI picker works on compositors without hyprctl (GNOME/KDE etc.).
ListMonitors now tries hyprctl first, then creates a portal session,
selects monitor sources, starts capture and parses the stream list.

Verified on Hyprland: the portal path returns the 1920x1200 monitor,
confirming the compositor-agnostic mechanism works.
2026-09-19 20:40:56 +01:00
petere aab2006e78 feat: screen capture scaling / downsampling
Add a Scale option (1.0 native, 0.5 half, etc.) that downsamples the
captured BGRA frame with bilinear interpolation before JPEG encoding.
Config carries the scale factor; the GUI exposes a Scale dropdown
(100%/75%/50%/25%) that applies live via SetConfig.

Verified: OBS displays the image at the reduced resolution matching the
selected scale, without restarting the stream.
2026-09-19 20:33:40 +01:00
petere 583274d9e6 feat: add bitrate measurement and quality presets
- sender tracks payload bytes; engine measures stream bitrate over a
  sliding window and exposes it in Status
- GUI status label and tray tooltip show the live bitrate (e.g. 36 Mbps)
- add a Preset dropdown (Low/Medium/High/Ultra) that sets quality+fps
  together and applies live via SetConfig

Verified: 36 Mbps shown for Medium (quality 70 @ 30fps) matches the
wire measurement.
2026-09-19 17:53:49 +01:00
petere 95f47abadb feat: live settings changes mid-stream
Add flinger.SetConfig to update FPS/quality/name/audio/announce without
restarting the stream. Fields that require a restart (source, port,
stream-index) are preserved. The video loop reads the live config each
frame so changes apply immediately, and the GUI Quality/Frame-rate
dropdowns push changes to a running engine.

Verified live: fps 12->33 and JPEG size 210KB->1.3MB on quality change.
2026-09-19 17:37:47 +01:00
petere f25b498fc1 docs: move backlog to Gitea issue tracker
Replace todo.md with a pointer to the issue tracker. Feature requests
created as issues #1-#6 on gitea.edley.me.
2026-09-19 15:02:47 +01:00
petere 2b85a78c22 chore: move to public gitea instance and drop project-local gitea MCP
- Remove the project-local gitea MCP (opencode.json) and gitea-mcp-server
  from the dev shell — the gitea MCP is now installed globally and points
  at https://gitea.edley.me
- Update README/project.md links and the flake package homepage to the
  new public instance
- The GITEA_ACCESS_TOKEN for the old homeserver is removed from
  .secrets.env (gitignored); the global token from /run/secrets is used
2026-09-19 14:31:08 +01:00
17 changed files with 1123 additions and 90 deletions
+2 -2
View File
@@ -84,8 +84,8 @@ Three distribution formats are provided. Prebuilt artifacts are tracked in
the repo under `dist/` (click to view on the remote; raw downloads require the repo under `dist/` (click to view on the remote; raw downloads require
access to the Gitea server): access to the Gitea server):
- [AppImage](http://homeserver:3050/pedley/TeleportFling/src/branch/main/dist/TeleportFling-0.1.0-x86_64.AppImage) - [AppImage](https://gitea.edley.me/petere/TeleportFling/src/branch/main/dist/TeleportFling-0.1.0-x86_64.AppImage)
- [Debian/Ubuntu .deb](http://homeserver:3050/pedley/TeleportFling/src/branch/main/dist/teleportfling_0.1.0_amd64.deb) - [Debian/Ubuntu .deb](https://gitea.edley.me/petere/TeleportFling/src/branch/main/dist/teleportfling_0.1.0_amd64.deb)
### Nix / NixOS ### Nix / NixOS
+2 -3
View File
@@ -71,7 +71,7 @@
meta = with pkgs.lib; { meta = with pkgs.lib; {
description = "Stream a screen and audio to OBS Teleport receivers"; description = "Stream a screen and audio to OBS Teleport receivers";
homepage = "http://homeserver:3050/pedley/TeleportFling"; homepage = "https://gitea.edley.me/petere/TeleportFling";
license = licenses.gpl2; license = licenses.gpl2;
mainProgram = "teleportfling"; mainProgram = "teleportfling";
platforms = platforms.linux; platforms = platforms.linux;
@@ -96,7 +96,7 @@
meta = with pkgs.lib; { meta = with pkgs.lib; {
description = "TeleportFling desktop GUI and system tray"; description = "TeleportFling desktop GUI and system tray";
homepage = "http://homeserver:3050/pedley/TeleportFling"; homepage = "https://gitea.edley.me/petere/TeleportFling";
license = licenses.gpl2; license = licenses.gpl2;
mainProgram = "teleportfling-gui"; mainProgram = "teleportfling-gui";
platforms = platforms.linux; platforms = platforms.linux;
@@ -124,7 +124,6 @@
gopls gopls
nixd nixd
nodejs nodejs
gitea-mcp-server
]; ];
buildInputs = with pkgs; [ buildInputs = with pkgs; [
git git
+2
View File
@@ -44,3 +44,5 @@ require (
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.42.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
replace go2tv.app/screencast => ./third_party/screencast
+97
View File
@@ -0,0 +1,97 @@
// Audio source enumeration for the settings UI.
package capture
import (
"encoding/json"
"os/exec"
)
// AudioDevice describes a capturable PipeWire audio node.
type AudioDevice struct {
Serial uint64 // PipeWire object.serial, passed to the capture backend
ID uint32 // PipeWire node id (informational)
Name string // node name, e.g. "alsa_output...analog-stereo"
Desc string // human-readable description
IsOutput bool // true = a sink (system output); false = a source (mic)
}
// pwDumpNode is the subset of `pw-dump` output we parse.
type pwDumpNode struct {
ID uint32 `json:"id"`
Info struct {
Props map[string]any `json:"props"`
} `json:"info"`
}
// ListAudioSources enumerates PipeWire audio sinks and sources via `pw-dump`.
// To capture system audio we attach to a sink's monitor; to capture a
// microphone we attach to an Audio/Source node. This lists the capturable
// audio nodes so the GUI can present a picker instead of always using the
// system default output.
func ListAudioSources() ([]AudioDevice, error) {
out, err := exec.Command("pw-dump").Output()
if err != nil {
return nil, err
}
var nodes []pwDumpNode
if err := json.Unmarshal(out, &nodes); err != nil {
return nil, err
}
var sources []AudioDevice
for _, n := range nodes {
props := n.Info.Props
mediaClass, _ := props["media.class"].(string)
if !audioNodeClass(mediaClass) {
continue
}
desc, _ := props["node.description"].(string)
name, _ := props["node.name"].(string)
serial := toUint64(props["object.serial"])
if serial == 0 {
continue
}
sources = append(sources, AudioDevice{
Serial: serial,
ID: n.ID,
Name: name,
Desc: desc,
IsOutput: mediaClass == "Audio/Sink",
})
}
return sources, nil
}
// audioNodeClass reports whether a media.class is a capturable audio node.
func audioNodeClass(mediaClass string) bool {
switch mediaClass {
case "Audio/Sink", "Audio/Source":
return true
default:
return false
}
}
// toUint64 best-effort converts a pw-dump property value to uint64.
// PipeWire serials are small, so the conversions cannot overflow in practice.
//
//nolint:gosec // safe: JSON numbers from pw-dump are small node serials
func toUint64(v any) uint64 {
switch t := v.(type) {
case float64:
return uint64(t)
case int:
return uint64(t)
case int64:
return uint64(t)
case uint64:
return t
case json.Number:
if n, err := t.Int64(); err == nil {
return uint64(n)
}
}
return 0
}
+64
View File
@@ -0,0 +1,64 @@
package capture
import (
"encoding/json"
"testing"
)
// TestPWParseAudioNodes parses a representative pw-dump payload into the
// node shape used by ListAudioSources.
func TestPWParseAudioNodes(t *testing.T) {
payload := `[
{"id": 51, "info": {"props": {"media.class": "Audio/Sink", "node.name": "alsa_out_speaker", "node.description": "Speaker", "object.serial": 1179}}},
{"id": 56, "info": {"props": {"media.class": "Audio/Source", "node.name": "alsa_in_mic", "node.description": "Stereo Mic", "object.serial": 1183}}},
{"id": 64, "info": {"props": {"media.class": "Audio/Device", "node.description": "Not capturable", "object.serial": 1172}}}
]`
var nodes []pwDumpNode
if err := json.Unmarshal([]byte(payload), &nodes); err != nil {
t.Fatalf("unmarshal: %v", err)
}
var devs []AudioDevice
for _, n := range nodes {
props := n.Info.Props
mc, _ := props["media.class"].(string)
if !audioNodeClass(mc) {
continue
}
devs = append(devs, AudioDevice{
Serial: toUint64(props["object.serial"]),
ID: n.ID,
Name: props["node.name"].(string),
Desc: props["node.description"].(string),
IsOutput: mc == "Audio/Sink",
})
}
if len(devs) != 2 {
t.Fatalf("got %d capturable devices, want 2", len(devs))
}
// Sink serial parsed and flagged as output.
if devs[0].Serial != 1179 || !devs[0].IsOutput {
t.Errorf("sink wrong: %+v", devs[0])
}
// Source serial parsed and flagged as input.
if devs[1].Serial != 1183 || devs[1].IsOutput {
t.Errorf("source wrong: %+v", devs[1])
}
}
// TestAudioNodeClass verifies which media classes are capturable.
func TestAudioNodeClass(t *testing.T) {
cases := map[string]bool{
"Audio/Sink": true,
"Audio/Source": true,
"Audio/Device": false,
"Video/Source": false,
}
for cls, want := range cases {
if got := audioNodeClass(cls); got != want {
t.Errorf("audioNodeClass(%q) = %v, want %v", cls, got, want)
}
}
}
+7 -3
View File
@@ -29,12 +29,16 @@ type Monitor struct {
// ErrNoMonitors is returned when monitor enumeration is unsupported or fails. // ErrNoMonitors is returned when monitor enumeration is unsupported or fails.
var ErrNoMonitors = errors.New("capture: monitor enumeration unavailable on this compositor") var ErrNoMonitors = errors.New("capture: monitor enumeration unavailable on this compositor")
// ListMonitors returns the available monitors for the GUI picker. // ListMonitors returns the available monitors for the GUI picker. It prefers
// the Hyprland IPC when available, falling back to the xdg-desktop-portal
// ScreenCast API for other compositors.
func ListMonitors() ([]Monitor, error) { func ListMonitors() ([]Monitor, error) {
if hyprctlAvailable() { if hyprctlAvailable() {
return hyprctlMonitors() if mons, err := hyprctlMonitors(); err == nil {
return mons, nil
}
} }
return nil, ErrNoMonitors return portalMonitors()
} }
// hyprctlAvailable reports whether the Hyprland monitor command exists. // hyprctlAvailable reports whether the Hyprland monitor command exists.
+7 -5
View File
@@ -24,12 +24,14 @@ type PipeWire struct {
} }
// OpenPipeWire opens a PipeWire capture session. streamIndex selects which // OpenPipeWire opens a PipeWire capture session. streamIndex selects which
// monitor to capture when multiple are present. Triggering the portal // monitor to capture when multiple are present; audioSourceSerial optionally
// consent dialog is expected; the compositor decides whether to show it. // selects a specific PipeWire audio node (0 = system default). Triggering the
func OpenPipeWire(streamIndex int, audio bool) (*PipeWire, error) { // portal consent dialog is expected; the compositor decides whether to show it.
func OpenPipeWire(streamIndex int, audio bool, audioSourceSerial uint64) (*PipeWire, error) {
s, err := capture.Open(&capture.Options{ s, err := capture.Open(&capture.Options{
StreamIndex: streamIndex, StreamIndex: streamIndex,
IncludeAudio: audio, IncludeAudio: audio,
AudioSourceSerial: audioSourceSerial,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
+251
View File
@@ -0,0 +1,251 @@
// Portal-based monitor enumeration (fallback for compositors without
// `hyprctl`, e.g. GNOME/KDE).
//
// The xdg-desktop-portal ScreenCast API is compositor-agnostic: we create a
// session, select monitor sources, call Start, and parse the returned stream
// list into Monitor entries.
//
// NOTE: Start() may present the compositor's screen-sharing consent dialog,
// so this path is only used as a fallback when hyprctl is unavailable.
package capture
import (
"errors"
"fmt"
"time"
"github.com/godbus/dbus/v5"
)
// xdg-desktop-portal D-Bus names and interfaces.
const (
portalBusName = "org.freedesktop.portal.Desktop"
portalPath = "/org/freedesktop/portal/desktop"
screenCastIFace = "org.freedesktop.portal.ScreenCast"
requestIFace = "org.freedesktop.portal.Request"
sessionIFace = "org.freedesktop.portal.Session"
)
// portalTimeout is how long we wait for a portal request to complete.
const portalTimeout = 10 * time.Second
// portalMonitors enumerates monitors via the xdg-desktop-portal ScreenCast
// interface. Returns ErrNoMonitors if the portal is unavailable or the user
// cancels the selection.
func portalMonitors() ([]Monitor, error) {
conn, err := dbus.SessionBus()
if err != nil {
return nil, ErrNoMonitors
}
defer func() { _ = conn.Close() }()
obj := conn.Object(portalBusName, portalPath)
handle := fmt.Sprintf("teleportfling%d", time.Now().UnixNano())
sess, err := portalNewSession(conn, obj, handle)
if err != nil {
return nil, ErrNoMonitors
}
defer sess.close()
if err := sess.selectSources(); err != nil {
return nil, ErrNoMonitors
}
streams, err := sess.start()
if err != nil {
return nil, ErrNoMonitors
}
monitors := make([]Monitor, 0, len(streams))
for i, s := range streams {
monitors = append(monitors, Monitor{
Index: i,
Name: s.Name,
Width: s.Size[0],
Height: s.Size[1],
})
}
return monitors, nil
}
// portalStream is a parsed ScreenCast stream.
type portalStream struct {
Name string
Size [2]int
}
// portalNewSession creates a ScreenCast session and subscribes to portal
// Request signals on the connection.
func portalNewSession(conn *dbus.Conn, obj dbus.BusObject, handle string) (*portalSession, error) {
// Subscribe to portal Request::Response signals once for this connection.
sigCh := make(chan *dbus.Signal, 16)
conn.Signal(sigCh)
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(requestIFace),
dbus.WithMatchOption("member", "Response"),
); err != nil {
return nil, err
}
s := &portalSession{conn: conn, obj: obj}
// The Request signals arrive on paths we learn from each call; store the
// channel for the wait helper.
s.sig = sigCh
data := map[string]dbus.Variant{
"session_handle_token": dbus.MakeVariant(handle),
"handle_token": dbus.MakeVariant(handle + "_create"),
}
call := obj.Call(screenCastIFace+".CreateSession", 0, data)
if call.Err != nil {
return nil, call.Err
}
var reqPath dbus.ObjectPath
if err := call.Store(&reqPath); err != nil {
return nil, err
}
resp, err := s.waitResponse(reqPath)
if err != nil {
return nil, err
}
sv, ok := resp["session_handle"]
if !ok {
return nil, errors.New("portal: CreateSession response missing session_handle")
}
str, ok := sv.Value().(string)
if !ok {
return nil, errors.New("portal: session_handle has unexpected type")
}
s.path = dbus.ObjectPath(str)
return s, nil
}
// portalSession carries the signal channel used to await portal responses.
type portalSession struct {
conn *dbus.Conn
obj dbus.BusObject
path dbus.ObjectPath
sig chan *dbus.Signal
}
// selectSources configures the session to capture all monitors.
func (s *portalSession) selectSources() error {
data := map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(fmt.Sprintf("sel%d", time.Now().UnixNano())),
"types": dbus.MakeVariant(uint32(1)), // MONITOR
"multiple": dbus.MakeVariant(true),
}
call := s.obj.Call(screenCastIFace+".SelectSources", 0, s.path, data)
if call.Err != nil {
return call.Err
}
var reqPath dbus.ObjectPath
if err := call.Store(&reqPath); err != nil {
return err
}
_, err := s.waitResponse(reqPath)
return err
}
// start calls ScreenCast.Start and parses the stream list.
func (s *portalSession) start() ([]portalStream, error) {
data := map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(fmt.Sprintf("start%d", time.Now().UnixNano())),
}
call := s.obj.Call(screenCastIFace+".Start", 0, s.path, "", data)
if call.Err != nil {
return nil, call.Err
}
var reqPath dbus.ObjectPath
if err := call.Store(&reqPath); err != nil {
return nil, err
}
resp, err := s.waitResponse(reqPath)
if err != nil {
return nil, err
}
sv, ok := resp["streams"]
if !ok {
return nil, errors.New("portal: Start response missing streams")
}
var raw [][]any
switch v := sv.Value().(type) {
case [][]any:
raw = v
case []any:
for _, item := range v {
if sub, ok := item.([]any); ok {
raw = append(raw, sub)
}
}
default:
return nil, fmt.Errorf("portal: streams has unexpected type %T", sv.Value())
}
streams := make([]portalStream, 0, len(raw))
for i, s := range raw {
if len(s) < 2 {
continue
}
ps := portalStream{Name: fmt.Sprintf("Monitor %d", i)}
props, ok := s[1].(map[string]dbus.Variant)
if !ok {
continue
}
if v, ok := props["size"]; ok {
if a, ok := v.Value().([]any); ok && len(a) >= 2 {
if w, ok := a[0].(int32); ok {
ps.Size[0] = int(w)
}
if h, ok := a[1].(int32); ok {
ps.Size[1] = int(h)
}
}
}
if v, ok := props["id"]; ok {
if id, ok := v.Value().(string); ok && id != "" {
ps.Name = id
}
}
streams = append(streams, ps)
}
return streams, nil
}
// close best-effort closes the portal session.
func (s *portalSession) close() {
if s.path != "" {
_ = s.obj.Call(sessionIFace+".Close", 0, s.path).Err
}
}
// waitResponse waits for the Request::Response signal for the given request
// path and returns the response dict.
func (s *portalSession) waitResponse(reqPath dbus.ObjectPath) (map[string]dbus.Variant, error) {
deadline := time.NewTimer(portalTimeout)
defer deadline.Stop()
for {
select {
case sig := <-s.sig:
if sig.Path != reqPath {
continue
}
if len(sig.Body) < 2 {
return nil, errors.New("portal: malformed Response signal")
}
dict, ok := sig.Body[1].(map[string]dbus.Variant)
if !ok {
return nil, errors.New("portal: Response payload is not a dict")
}
return dict, nil
case <-deadline.C:
return nil, errors.New("portal: request timed out")
}
}
}
+16 -7
View File
@@ -16,13 +16,15 @@ import (
// Config mirrors flinger.Config with JSON tags for persistence. // Config mirrors flinger.Config with JSON tags for persistence.
type Config struct { type Config struct {
Name string `json:"name"` Name string `json:"name"`
Port int `json:"port"` Port int `json:"port"`
Quality int `json:"quality"` Quality int `json:"quality"`
FPS int `json:"fps"` FPS int `json:"fps"`
Source string `json:"source"` Source string `json:"source"`
Audio bool `json:"audio"` Audio bool `json:"audio"`
StreamIndex int `json:"stream_index"` AudioSource uint64 `json:"audio_source"`
StreamIndex int `json:"stream_index"`
Scale float64 `json:"scale"`
// Announce is a *bool so an absent JSON key (older config files) keeps // Announce is a *bool so an absent JSON key (older config files) keeps
// the default instead of silently disabling announcements. // the default instead of silently disabling announcements.
Announce *bool `json:"announce"` Announce *bool `json:"announce"`
@@ -39,7 +41,9 @@ func Default() Config {
FPS: c.FPS, FPS: c.FPS,
Source: c.Source, Source: c.Source,
Audio: c.Audio, Audio: c.Audio,
AudioSource: c.AudioSource,
StreamIndex: c.StreamIndex, StreamIndex: c.StreamIndex,
Scale: c.Scale,
Announce: &announce, Announce: &announce,
} }
} }
@@ -57,7 +61,9 @@ func (c Config) ToFlinger() flinger.Config {
FPS: c.FPS, FPS: c.FPS,
Source: c.Source, Source: c.Source,
Audio: c.Audio, Audio: c.Audio,
AudioSource: c.AudioSource,
StreamIndex: c.StreamIndex, StreamIndex: c.StreamIndex,
Scale: c.Scale,
Announce: announce, Announce: announce,
} }
} }
@@ -111,6 +117,9 @@ func LoadFrom(path string) (Config, error) {
if c.Source == "" { if c.Source == "" {
c.Source = d.Source c.Source = d.Source
} }
if c.Scale <= 0 || c.Scale > 1 {
c.Scale = d.Scale
}
if c.Announce == nil { if c.Announce == nil {
announce := true announce := true
c.Announce = &announce c.Announce = &announce
+271 -21
View File
@@ -10,6 +10,7 @@ package flinger
import ( import (
"errors" "errors"
"fmt"
"image" "image"
"image/color" "image/color"
"io" "io"
@@ -47,8 +48,14 @@ type Config struct {
Source string Source string
// Audio enables system audio capture and streaming. // Audio enables system audio capture and streaming.
Audio bool Audio bool
// AudioSource is the PipeWire node serial to capture audio from (0 = system
// default output). Only applies when Audio is true and Source is "screen".
AudioSource uint64
// StreamIndex selects which monitor to capture (screen source only). // StreamIndex selects which monitor to capture (screen source only).
StreamIndex int StreamIndex int
// Scale downscales the captured frame before encoding. 1.0 = native
// resolution, 0.5 = half width/height, etc. Must be in (0, 1].
Scale float64
// Announce controls whether the stream is advertised via UDP multicast. // Announce controls whether the stream is advertised via UDP multicast.
// When disabled, receivers must connect by IP manually. // When disabled, receivers must connect by IP manually.
Announce bool Announce bool
@@ -62,6 +69,7 @@ func DefaultConfig() Config {
FPS: 30, FPS: 30,
Source: "screen", Source: "screen",
Audio: true, Audio: true,
Scale: 1.0,
Announce: true, Announce: true,
} }
} }
@@ -83,6 +91,9 @@ func (c Config) Validate() error {
if c.StreamIndex < 0 { if c.StreamIndex < 0 {
return errors.New("stream index must be >= 0") return errors.New("stream index must be >= 0")
} }
if c.Scale <= 0 || c.Scale > 1 {
return errors.New("scale must be in (0, 1]")
}
return nil return nil
} }
@@ -92,6 +103,9 @@ type Status struct {
Frames int64 Frames int64
Dropped int64 Dropped int64
Conns int Conns int
// Bitrate is the measured stream bandwidth in bits per second, averaged
// over the previous measurement window.
Bitrate int64
// Err is the most recent runtime error encountered (capture, encode, // Err is the most recent runtime error encountered (capture, encode,
// packet or audio), or nil if the stream is healthy. // packet or audio), or nil if the stream is healthy.
Err error Err error
@@ -99,7 +113,8 @@ type Status struct {
// Engine owns the capture, encode and send pipeline. // Engine owns the capture, encode and send pipeline.
type Engine struct { type Engine struct {
cfg Config cfgMu sync.RWMutex
cfg Config
sender *output.Sender sender *output.Sender
announcer *discovery.Announcer announcer *discovery.Announcer
@@ -110,9 +125,30 @@ type Engine struct {
start time.Time start time.Time
stop chan struct{} stop chan struct{}
frames atomic.Int64 // running gates packet sending: when false the loops consume capture
// frames but do not transmit. This lets the GUI "stop" without tearing
// down the portal/PipeWire session (which the portal cannot reliably
// recreate in-process), so a subsequent Start just flips running on.
running atomic.Bool
frames atomic.Int64
// wg tracks the audio/video/stats goroutines so Stop can wait for them to
// finish before destroying shared resources (encoder, sender, capture).
wg sync.WaitGroup
// scaleBuf is the cached downscaled frame, reallocated only when the
// configured scale or source dimensions change.
scaleBuf *capture.VideoFrame
errMu sync.RWMutex errMu sync.RWMutex
lastErr error lastErr error
// bitrate tracking
bitMu sync.Mutex
bitLast time.Time
bitBytes int64
bitrate int64
} }
// setErr records the most recent runtime error. Pass nil to clear it. // setErr records the most recent runtime error. Pass nil to clear it.
@@ -122,6 +158,36 @@ func (e *Engine) setErr(err error) {
e.errMu.Unlock() e.errMu.Unlock()
} }
// SetConfig updates engine settings live (FPS, quality, name, etc.) without
// restarting the stream. It validates the new config first; on error the
// engine keeps its current settings.
//
// Not every field is live-applicable mid-stream: source, port and
// stream-index still require a restart (they are ignored if changed).
func (e *Engine) SetConfig(cfg Config) error {
if err := cfg.Validate(); err != nil {
return err
}
e.cfgMu.Lock()
defer e.cfgMu.Unlock()
// Fields that cannot change live keep their current values.
cfg.Source = e.cfg.Source
cfg.Port = e.cfg.Port
cfg.StreamIndex = e.cfg.StreamIndex
cfg.AudioSource = e.cfg.AudioSource
e.cfg = cfg
return nil
}
// getCfg returns a snapshot of the current config.
func (e *Engine) getCfg() Config {
e.cfgMu.RLock()
defer e.cfgMu.RUnlock()
return e.cfg
}
// New creates an engine from cfg. Capture is opened eagerly so that // New creates an engine from cfg. Capture is opened eagerly so that
// misconfiguration (e.g. no screen-share permission) surfaces before Start. // misconfiguration (e.g. no screen-share permission) surfaces before Start.
func New(cfg Config) (*Engine, error) { func New(cfg Config) (*Engine, error) {
@@ -147,7 +213,7 @@ func New(cfg Config) (*Engine, error) {
switch cfg.Source { switch cfg.Source {
case "screen": case "screen":
cam, err := capture.OpenPipeWire(cfg.StreamIndex, cfg.Audio) cam, err := capture.OpenPipeWire(cfg.StreamIndex, cfg.Audio, cfg.AudioSource)
if err != nil { if err != nil {
e.encoder.Close() e.encoder.Close()
sender.Close() sender.Close()
@@ -170,11 +236,13 @@ func New(cfg Config) (*Engine, error) {
// config) starts announcing the stream. It is idempotent. // config) starts announcing the stream. It is idempotent.
func (e *Engine) Start() { func (e *Engine) Start() {
if e.stop != nil { if e.stop != nil {
e.Resume()
return return
} }
e.start = time.Now() e.start = time.Now()
e.stop = make(chan struct{}) e.stop = make(chan struct{})
e.running.Store(true)
if e.cfg.Announce { if e.cfg.Announce {
e.announcer = discovery.Start(e.cfg.Name, e.sender.Port()) e.announcer = discovery.Start(e.cfg.Name, e.sender.Port())
@@ -191,32 +259,68 @@ func (e *Engine) Start() {
src = capture.NewSilenceSource() src = capture.NewSilenceSource()
} }
go e.audioLoop(src) e.wg.Add(3)
go e.videoLoop() go func() {
go e.statsLoop() defer e.wg.Done()
e.audioLoop(src)
}()
go func() {
defer e.wg.Done()
e.videoLoop()
}()
go func() {
defer e.wg.Done()
e.statsLoop()
}()
} }
// Stop halts the loops, stops announcing and closes all resources. It is // Stop pauses the stream: loops keep consuming capture frames but stop
// idempotent. After Stop the engine must not be restarted. // transmitting, and the portal/PipeWire session stays open so the engine can
// be resumed with Start without re-opening the portal (which the portal
// cannot reliably do in-process). Idempotent.
func (e *Engine) Stop() { func (e *Engine) Stop() {
e.running.Store(false)
}
// Pause is an alias for Stop; kept for clarity at call sites.
func (e *Engine) Pause() { e.Stop() }
// Resume restarts transmission on a paused engine. It is a no-op if the
// engine was never started.
func (e *Engine) Resume() {
if e.stop == nil {
return
}
e.running.Store(true)
}
// Close fully tears down the engine: stops the loops, closes the portal
// session and frees the encoder/sender. After Close the engine must not be
// reused.
func (e *Engine) Close() {
if e.stop == nil { if e.stop == nil {
return return
} }
close(e.stop) close(e.stop)
// Give the loops a moment to observe the stop signal.
time.Sleep(50 * time.Millisecond) // Closing the capture source unblocks the audio and video loops that may
// be stuck in a read. Do this before waiting so they can observe stop.
if e.cam != nil {
if err := e.cam.Close(); err != nil {
log.Printf("flinger: capture close: %v", err)
}
}
// Wait for the audio/video/stats goroutines to finish so they no longer
// touch the encoder or sender before we destroy them.
e.wg.Wait()
if e.announcer != nil { if e.announcer != nil {
e.announcer.Stop() e.announcer.Stop()
} }
e.sender.Close() e.sender.Close()
e.encoder.Close() e.encoder.Close()
if e.cam != nil {
if err := e.cam.Close(); err != nil {
log.Printf("flinger: capture close: %v", err)
}
}
} }
// Status returns a snapshot of the running engine. // Status returns a snapshot of the running engine.
@@ -224,15 +328,48 @@ func (e *Engine) Status() Status {
e.errMu.RLock() e.errMu.RLock()
err := e.lastErr err := e.lastErr
e.errMu.RUnlock() e.errMu.RUnlock()
return Status{ return Status{
Running: e.stop != nil, Running: e.running.Load() && e.stop != nil,
Frames: e.frames.Load(), Frames: e.frames.Load(),
Dropped: e.sender.Dropped(), Dropped: e.sender.Dropped(),
Conns: e.sender.NumConns(), Conns: e.sender.NumConns(),
Bitrate: e.measureBitrate(),
Err: err, Err: err,
} }
} }
// bitrateWindow is the sliding window over which bitrate is averaged.
const bitrateWindow = 2 * time.Second
// measureBitrate computes the current stream bitrate (bits/sec) over a
// sliding window. It is called from Status.
func (e *Engine) measureBitrate() int64 {
e.bitMu.Lock()
defer e.bitMu.Unlock()
now := time.Now()
bytes := e.sender.BytesSent()
if e.bitLast.IsZero() {
e.bitLast = now
e.bitBytes = bytes
return 0
}
elapsed := now.Sub(e.bitLast)
if elapsed < bitrateWindow {
return e.bitrate
}
// Bytes accumulated since the previous sample.
delta := bytes - e.bitBytes
e.bitrate = int64(float64(delta*8) / elapsed.Seconds())
e.bitLast = now
e.bitBytes = bytes
return e.bitrate
}
// audioLoop reads raw PCM and emits WAVE packets. start is the shared // audioLoop reads raw PCM and emits WAVE packets. start is the shared
// reference clock used by the video loop so audio and video timestamps stay // reference clock used by the video loop so audio and video timestamps stay
// aligned on the receiver. // aligned on the receiver.
@@ -248,8 +385,17 @@ func (e *Engine) audioLoop(src io.ReadCloser) {
buf := make([]byte, chunkBytes) buf := make([]byte, chunkBytes)
for { for {
select {
case <-e.stop:
return
default:
}
n, err := io.ReadFull(src, buf) n, err := io.ReadFull(src, buf)
if n > 0 { if n > 0 {
if !e.running.Load() {
continue
}
frames := n / (speakers * 2) frames := n / (speakers * 2)
ts := uint64(time.Since(e.start)) ts := uint64(time.Since(e.start))
packet, perr := protocol.BuildWavePacket(ts, protocol.AudioFormatS16, sampleRate, speakers, int32(frames), buf[:n]) packet, perr := protocol.BuildWavePacket(ts, protocol.AudioFormatS16, sampleRate, speakers, int32(frames), buf[:n])
@@ -274,12 +420,86 @@ func (e *Engine) audioLoop(src io.ReadCloser) {
} }
} }
// videoLoop pulls frames and sends them at the configured fps. // scaleFrame downscales frame by the configured factor, returning the original
// when scale is 1.0 (native). The result is a cached buffer reused across
// frames, reallocated only when the dimensions or scale change.
func (e *Engine) scaleFrame(frame *capture.VideoFrame, scale float64) *capture.VideoFrame {
if scale >= 1.0 || frame == nil {
return frame
}
sw := int(float64(frame.Width) * scale)
sh := int(float64(frame.Height) * scale)
if sw < 1 {
sw = 1
}
if sh < 1 {
sh = 1
}
// Reuse the cached buffer if it matches the target size.
if e.scaleBuf == nil || e.scaleBuf.Width != sw || e.scaleBuf.Height != sh {
e.scaleBuf = &capture.VideoFrame{
Pix: make([]byte, sw*sh*4),
Width: sw,
Height: sh,
Stride: sw * 4,
}
}
scaleBGRA(frame, e.scaleBuf)
return e.scaleBuf
}
// scaleBGRA bilinearly downsamples an interleaved BGRA frame into dst.
func scaleBGRA(src, dst *capture.VideoFrame) {
sw, sh := float64(src.Width), float64(src.Height)
for y := 0; y < dst.Height; y++ {
srcY := (float64(y) + 0.5) * sh / float64(dst.Height)
y0 := int(srcY)
if y0 >= src.Height-1 {
y0 = src.Height - 2
}
yFrac := srcY - float64(y0)
row0 := y0 * src.Stride
row1 := (y0 + 1) * src.Stride
di := y * dst.Stride
for x := 0; x < dst.Width; x++ {
srcX := (float64(x) + 0.5) * sw / float64(dst.Width)
x0 := int(srcX)
if x0 >= src.Width-1 {
x0 = src.Width - 2
}
xFrac := srcX - float64(x0)
p00 := row0 + x0*4
p01 := row0 + (x0+1)*4
p10 := row1 + x0*4
p11 := row1 + (x0+1)*4
for c := 0; c < 4; c++ {
top := float64(src.Pix[p00+c])*(1-xFrac) + float64(src.Pix[p01+c])*xFrac
bot := float64(src.Pix[p10+c])*(1-xFrac) + float64(src.Pix[p11+c])*xFrac
dst.Pix[di+x*4+c] = uint8(top*(1-yFrac) + bot*yFrac)
}
}
}
}
// videoLoop pulls frames and sends them at the configured fps. The frame rate
// and JPEG quality are read from the live config so SetConfig takes effect
// without restarting.
func (e *Engine) videoLoop() { func (e *Engine) videoLoop() {
frameInterval := time.Second / time.Duration(e.cfg.FPS) frameInterval := time.Second / time.Duration(e.getCfg().FPS)
next := e.start next := e.start
for { for {
select {
case <-e.stop:
return
default:
}
frame, err := e.loop.Next() frame, err := e.loop.Next()
if err != nil { if err != nil {
select { select {
@@ -294,6 +514,15 @@ func (e *Engine) videoLoop() {
continue continue
} }
// Re-read the live config each frame so FPS/quality changes apply
// immediately. When the interval changes, resync `next` to now.
cfg := e.getCfg()
interval := time.Second / time.Duration(cfg.FPS)
if interval != frameInterval {
frameInterval = interval
next = time.Now()
}
// Drop frames when running ahead of the target fps to keep // Drop frames when running ahead of the target fps to keep
// timestamps monotonic (e.g. a 60 Hz monitor captured at 30 fps). // timestamps monotonic (e.g. a 60 Hz monitor captured at 30 fps).
now := time.Now() now := time.Now()
@@ -302,8 +531,16 @@ func (e *Engine) videoLoop() {
} }
next = now.Add(frameInterval) next = now.Add(frameInterval)
// When paused (Stop), keep consuming frames to stay alive but do not
// encode or transmit. The portal session is left open so a subsequent
// Start can resume without re-opening the portal.
if !e.running.Load() {
continue
}
ts := uint64(now.Sub(e.start)) ts := uint64(now.Sub(e.start))
buf, err := e.encoder.EncodeBGRA(frame.Pix, frame.Width, frame.Height, e.cfg.Quality) enc := e.scaleFrame(frame, cfg.Scale)
buf, err := e.encoder.EncodeBGRA(enc.Pix, enc.Width, enc.Height, cfg.Quality)
if err != nil { if err != nil {
e.setErr(err) e.setErr(err)
log.Printf("flinger: jpeg: %v", err) log.Printf("flinger: jpeg: %v", err)
@@ -334,10 +571,11 @@ func (e *Engine) statsLoop() {
select { select {
case <-tick.C: case <-tick.C:
st := e.Status() st := e.Status()
rate := formatBitrate(st.Bitrate)
if st.Dropped > 0 { if st.Dropped > 0 {
log.Printf("flinger: %d frames, %d dropped, %d conns", st.Frames, st.Dropped, st.Conns) log.Printf("flinger: %d frames, %d dropped, %d conns, %s", st.Frames, st.Dropped, st.Conns, rate)
} else { } else {
log.Printf("flinger: %d frames, %d conns", st.Frames, st.Conns) log.Printf("flinger: %d frames, %d conns, %s", st.Frames, st.Conns, rate)
} }
case <-e.stop: case <-e.stop:
return return
@@ -345,6 +583,18 @@ func (e *Engine) statsLoop() {
} }
} }
// 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)
}
}
// frameSource abstracts the frame source: real capture or the test pattern. // frameSource abstracts the frame source: real capture or the test pattern.
type frameSource interface { type frameSource interface {
Next() (*capture.VideoFrame, error) Next() (*capture.VideoFrame, error)
+160 -1
View File
@@ -5,6 +5,8 @@ import (
"net" "net"
"testing" "testing"
"time" "time"
"teleportfling/internal/capture"
) )
// TestEnginePatternStartStop runs the engine with the synthetic pattern source // TestEnginePatternStartStop runs the engine with the synthetic pattern source
@@ -20,7 +22,7 @@ func TestEnginePatternStartStop(t *testing.T) {
} }
eng.Start() eng.Start()
defer eng.Stop() defer eng.Close()
// Connect a raw receiver and read until the engine reports frames sent. // Connect a raw receiver and read until the engine reports frames sent.
conn, err := net.Dial("tcp", "127.0.0.1:"+itoa(eng.sender.Port())) conn, err := net.Dial("tcp", "127.0.0.1:"+itoa(eng.sender.Port()))
@@ -129,6 +131,116 @@ func TestValidate(t *testing.T) {
} }
} }
// TestBitrateMeasurement verifies the engine reports a non-zero bitrate once
// it has been streaming for a bit.
func TestBitrateMeasurement(t *testing.T) {
cfg := DefaultConfig()
cfg.Source = "pattern"
cfg.Port = 19759
eng, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
eng.Start()
defer eng.Close()
// Connect a receiver so packets actually flow, and wait for the bitrate
// window to produce a measurement.
conn, err := net.Dial("tcp", "127.0.0.1:"+itoa(eng.sender.Port()))
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
go func() {
buf := make([]byte, 64*1024)
for {
if _, err := conn.Read(buf); err != nil {
return
}
}
}()
deadline := time.Now().Add(4 * time.Second)
for time.Now().Before(deadline) {
if eng.Status().Bitrate > 0 {
return
}
time.Sleep(200 * time.Millisecond)
}
t.Error("bitrate stayed 0 after 4s of streaming")
}
// TestFormatBitrate checks the human-readable bitrate formatting.
func TestFormatBitrate(t *testing.T) {
cases := []struct {
bps int64
want string
}{
{500, "500 bps"},
{5_000, "5 kbps"},
{5_000_000, "5.0 Mbps"},
}
for _, c := range cases {
if got := formatBitrate(c.bps); got != c.want {
t.Errorf("formatBitrate(%d) = %q, want %q", c.bps, got, c.want)
}
}
}
// TestScaleBGRA verifies downsampling produces the expected dimensions and
// preserves the dominant colour of a solid frame.
func TestScaleBGRA(t *testing.T) {
src := &capture.VideoFrame{
Pix: make([]byte, 100*80*4),
Width: 100,
Height: 80,
Stride: 100 * 4,
}
// Fill with solid red (BGRA: B=0, G=0, R=255).
for i := 0; i+4 <= len(src.Pix); i += 4 {
src.Pix[i], src.Pix[i+1], src.Pix[i+2], src.Pix[i+3] = 0, 0, 255, 255
}
dst := &capture.VideoFrame{
Pix: make([]byte, 50*40*4),
Width: 50,
Height: 40,
Stride: 50 * 4,
}
scaleBGRA(src, dst)
if dst.Width != 50 || dst.Height != 40 {
t.Errorf("dst dims = %dx%d, want 50x40", dst.Width, dst.Height)
}
// Check a few pixels are solid red.
for _, idx := range []int{0, 4, 100, 200} {
if dst.Pix[idx] != 0 || dst.Pix[idx+1] != 0 || dst.Pix[idx+2] != 255 {
t.Errorf("pixel %d not red: B=%d G=%d R=%d", idx, dst.Pix[idx], dst.Pix[idx+1], dst.Pix[idx+2])
}
}
}
// TestEngineScalePreservesNative verifies scaleFrame returns the original
// frame at scale 1.0.
func TestEngineScalePreservesNative(t *testing.T) {
cfg := DefaultConfig()
cfg.Source = "pattern"
cfg.Port = 19760
eng, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
frame := &capture.VideoFrame{Pix: make([]byte, 4*4*4), Width: 4, Height: 4, Stride: 16}
if got := eng.scaleFrame(frame, 1.0); got != frame {
t.Error("scale 1.0 should return the original frame")
}
if got := eng.scaleFrame(frame, 0.5); got == frame {
t.Error("scale 0.5 should return a new frame")
}
}
func itoa(v int) string { func itoa(v int) string {
if v == 0 { if v == 0 {
return "0" return "0"
@@ -142,3 +254,50 @@ func itoa(v int) string {
} }
return string(buf[i:]) return string(buf[i:])
} }
// TestSetConfig verifies live config updates apply FPS/quality and reject
// invalid values, while preserving fields that can't change live.
func TestSetConfig(t *testing.T) {
cfg := DefaultConfig()
cfg.Source = "pattern"
cfg.Port = 19758
eng, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
// Update FPS/quality live.
newCfg := DefaultConfig()
newCfg.Source = "screen" // must be ignored (requires restart)
newCfg.Port = 9999 // must be ignored
newCfg.Quality = 95
newCfg.FPS = 60
if err := eng.SetConfig(newCfg); err != nil {
t.Fatalf("SetConfig: %v", err)
}
got := eng.getCfg()
if got.Quality != 95 {
t.Errorf("quality = %d, want 95", got.Quality)
}
if got.FPS != 60 {
t.Errorf("fps = %d, want 60", got.FPS)
}
// Source/port preserved.
if got.Source != "pattern" {
t.Errorf("source = %q, want pattern (unchanged live)", got.Source)
}
if got.Port != 19758 {
t.Errorf("port = %d, want 19758 (unchanged live)", got.Port)
}
// Invalid config is rejected and current settings kept.
if err := eng.SetConfig(Config{Quality: 200}); err == nil {
t.Error("expected error for invalid quality")
}
got = eng.getCfg()
if got.Quality != 95 {
t.Errorf("quality changed after rejected update: %d", got.Quality)
}
}
+220 -10
View File
@@ -36,6 +36,34 @@ func itoa(v int) string { return strconv.Itoa(v) }
func atoi(s string) (int, error) { return strconv.Atoi(s) } 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. // appID is the Fyne application ID used for preferences/settings storage.
const appID = "io.teleportfling" const appID = "io.teleportfling"
@@ -61,13 +89,21 @@ type App struct {
portEnt *widget.Entry portEnt *widget.Entry
qualitySel *widget.Select qualitySel *widget.Select
fpsSel *widget.Select fpsSel *widget.Select
presetSel *widget.Select
scaleSel *widget.Select
nameEnt *widget.Entry nameEnt *widget.Entry
audioChk *widget.Check audioChk *widget.Check
audioSel *widget.Select
audioDevs []capture.AudioDevice
announceChk *widget.Check announceChk *widget.Check
srcSel *widget.Select srcSel *widget.Select
monSel *widget.Select monSel *widget.Select
monitors []capture.Monitor monitors []capture.Monitor
statsDone chan struct{} 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 // Run starts the GUI and blocks until the app exits. configPath selects a
@@ -140,16 +176,27 @@ func (g *App) buildUI() {
g.setupMonitorPicker() g.setupMonitorPicker()
// Quality. // Quality.
g.qualitySel = widget.NewSelect([]string{"50", "60", "70", "80", "90", "100"}, func(string) {}) g.qualitySel = widget.NewSelect([]string{"50", "60", "70", "80", "90", "100"}, g.applyLiveSettings)
g.qualitySel.SetSelected(itoa(g.cfg.Quality)) g.qualitySel.SetSelected(itoa(g.cfg.Quality))
// FPS. // FPS.
g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, func(string) {}) g.fpsSel = widget.NewSelect([]string{"15", "30", "60"}, g.applyLiveSettings)
g.fpsSel.SetSelected(itoa(g.cfg.FPS)) 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. // Audio.
g.audioChk = widget.NewCheck("Capture system audio", nil) g.audioChk = widget.NewCheck("Capture system audio", func(bool) { g.applyLiveSettings("") })
g.audioChk.SetChecked(g.cfg.Audio) g.audioChk.SetChecked(g.cfg.Audio)
g.setupAudioPicker()
// Announce over multicast. // Announce over multicast.
g.announceChk = widget.NewCheck("Announce on LAN", nil) g.announceChk = widget.NewCheck("Announce on LAN", nil)
@@ -173,9 +220,12 @@ func (g *App) buildUI() {
{Text: "Port", Widget: g.portEnt}, {Text: "Port", Widget: g.portEnt},
{Text: "Source", Widget: g.srcSel}, {Text: "Source", Widget: g.srcSel},
{Text: "Monitor", Widget: g.monSel}, {Text: "Monitor", Widget: g.monSel},
{Text: "Preset", Widget: g.presetSel},
{Text: "Quality", Widget: g.qualitySel}, {Text: "Quality", Widget: g.qualitySel},
{Text: "Frame rate", Widget: g.fpsSel}, {Text: "Frame rate", Widget: g.fpsSel},
{Text: "", Widget: g.audioChk}, {Text: "Scale", Widget: g.scaleSel},
{Text: "Audio", Widget: g.audioChk},
{Text: "Audio source", Widget: g.audioSel},
{Text: "", Widget: g.announceChk}, {Text: "", Widget: g.announceChk},
}, },
} }
@@ -241,35 +291,147 @@ func (g *App) selectedMonitorIndex() int {
return g.cfg.StreamIndex 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. // toggleStream starts or stops the engine based on current UI state.
func (g *App) toggleStream() { func (g *App) toggleStream() {
if g.lock { if g.lock {
return return
} }
if g.eng != nil { // Engine exists and is currently running → pause it.
if g.eng != nil && g.eng.Status().Running {
g.stop() g.stop()
return return
} }
g.start() g.start()
} }
// start reads the form into cfg, saves it, and boots the engine. // 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() { func (g *App) start() {
port, _ := atoi(g.portEnt.Text) port, _ := atoi(g.portEnt.Text)
quality, _ := atoi(g.qualitySel.Selected) quality, _ := atoi(g.qualitySel.Selected)
fps, _ := atoi(g.fpsSel.Selected) fps, _ := atoi(g.fpsSel.Selected)
announce := g.announceChk.Checked announce := g.announceChk.Checked
g.cfg = config.Config{ newCfg := config.Config{
Name: g.nameEnt.Text, Name: g.nameEnt.Text,
Port: port, Port: port,
Quality: quality, Quality: quality,
FPS: fps, FPS: fps,
Source: g.srcSel.Selected, Source: g.srcSel.Selected,
Audio: g.audioChk.Checked, Audio: g.audioChk.Checked,
AudioSource: g.selectedAudioSerial(),
StreamIndex: g.selectedMonitorIndex(), StreamIndex: g.selectedMonitorIndex(),
Scale: parseScale(g.scaleSel.Selected),
Announce: &announce, Announce: &announce,
} }
g.cfg = newCfg
if g.configPath != "" { if g.configPath != "" {
if err := config.SaveTo(g.configPath, g.cfg); err != nil { if err := config.SaveTo(g.configPath, g.cfg); err != nil {
log.Printf("gui: config save: %v", err) log.Printf("gui: config save: %v", err)
@@ -278,17 +440,47 @@ func (g *App) start() {
log.Printf("gui: config save: %v", err) 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()) eng, err := flinger.New(g.cfg.ToFlinger())
if err != nil { if err != nil {
dialog.ShowError(err, g.win) dialog.ShowError(err, g.win)
return return
} }
g.eng = eng g.eng = eng
g.lastStart = newCfg
g.eng.Start() 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.SetText("Stop")
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(cfg.Port) + ")")
g.statusLab.Importance = widget.SuccessImportance g.statusLab.Importance = widget.SuccessImportance
g.setTrayState(true, "TeleportFling · Streaming") g.setTrayState(true, "TeleportFling · Streaming")
g.refresh() g.refresh()
@@ -320,6 +512,9 @@ func (g *App) watchStats() {
g.statusLab.Importance = widget.SuccessImportance g.statusLab.Importance = widget.SuccessImportance
} }
tip := "TeleportFling · " + frames + " frames, " + dropped + " dropped" tip := "TeleportFling · " + frames + " frames, " + dropped + " dropped"
if st.Bitrate > 0 {
tip += " · " + formatBitrate(st.Bitrate)
}
if st.Err != nil { if st.Err != nil {
tip += " · error" tip += " · error"
} }
@@ -338,13 +533,29 @@ func formatStatus(st flinger.Status) string {
if st.Dropped > 0 { if st.Dropped > 0 {
base += " · " + itoa(int(st.Dropped)) + " dropped" base += " · " + itoa(int(st.Dropped)) + " dropped"
} }
if st.Bitrate > 0 {
base += " · " + formatBitrate(st.Bitrate)
}
if st.Err != nil { if st.Err != nil {
base += "\nError: " + st.Err.Error() base += "\nError: " + st.Err.Error()
} }
return base return base
} }
// stop halts the engine and returns the UI to the stopped state. // 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() { func (g *App) stop() {
if g.statsDone != nil { if g.statsDone != nil {
close(g.statsDone) close(g.statsDone)
@@ -352,7 +563,6 @@ func (g *App) stop() {
} }
if g.eng != nil { if g.eng != nil {
g.eng.Stop() g.eng.Stop()
g.eng = nil
} }
g.startBtn.SetText("Start") g.startBtn.SetText("Start")
g.startBtn.Importance = widget.HighImportance g.startBtn.Importance = widget.HighImportance
+10
View File
@@ -37,6 +37,7 @@ type Sender struct {
port int port int
dropped atomic.Int64 dropped atomic.Int64
bytes atomic.Int64
} }
// New creates an unconnected Sender. // New creates an unconnected Sender.
@@ -95,6 +96,10 @@ func (s *Sender) Send(b []byte) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
// Count the packet once as produced bandwidth (independent of how many
// receivers are attached).
s.bytes.Add(int64(len(b)))
for c, ch := range s.conns { for c, ch := range s.conns {
switch { switch {
case len(ch) > dropAt: case len(ch) > dropAt:
@@ -125,6 +130,11 @@ func (s *Sender) Dropped() int64 {
return s.dropped.Load() return s.dropped.Load()
} }
// BytesSent returns the total number of payload bytes handed to Send.
func (s *Sender) BytesSent() int64 {
return s.bytes.Load()
}
// Close shuts down the listener and waits for all writer goroutines to // Close shuts down the listener and waits for all writer goroutines to
// drain. After Close returns the Sender must not be reused. // drain. After Close returns the Sender must not be reused.
func (s *Sender) Close() { func (s *Sender) Close() {
-8
View File
@@ -7,14 +7,6 @@
} }
}, },
"mcp": { "mcp": {
"gitea": {
"type": "local",
"command": ["gitea-mcp", "-t", "stdio"],
"environment": {
"GITEA_HOST": "http://homeserver:3050",
"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}"
}
},
"obs": { "obs": {
"type": "local", "type": "local",
"command": ["npx", "-y", "obs-mcp"], "command": ["npx", "-y", "obs-mcp"],
+1 -1
View File
@@ -42,7 +42,7 @@ and streams it. No NDI compatibility of any form.
| Discovery | UDP multicast peer discovery (identical to `obs-teleport`) | | Discovery | UDP multicast peer discovery (identical to `obs-teleport`) |
| Target quality | Initial release: 1920x1080 @ 30 fps, balanced JPEG quality | | Target quality | Initial release: 1920x1080 @ 30 fps, balanced JPEG quality |
| Protocol target | Latest `obs-teleport` release (protocol contract below) | | Protocol target | Latest `obs-teleport` release (protocol contract below) |
| Remote git | Self-hosted Gitea at `http://homeserver:3050/` | | Remote git | Public self-hosted Gitea at `https://gitea.edley.me/` |
## Teleport Protocol Contract ## Teleport Protocol Contract
Vendored Submodule
+1
Submodule third_party/screencast added at 4cba613625
+12 -29
View File
@@ -1,35 +1,18 @@
# TeleportFling TODO # TeleportFling TODO
Backlog of deferred / planned work, tracked outside of milestone milestones. Backlog items have moved to the issue tracker on the Gitea instance:
## Planned https://gitea.edley.me/petere/TeleportFling/issues
- [x] **Monitor picker in the GUI** — monitors are enumerated via hyprctl Open feature requests:
(Hyprland) and shown as a dropdown in the settings window; the selected
monitor persists as `stream_index`. Falls back to a single indexed
option on other compositors.
- [x] **Surface runtime errors in the GUI** — the engine records the most
recent capture/encode/packet/audio error and the GUI shows it in the
status label and tray tooltip (highlighted as a danger).
- [x] **`--config` flag** — the CLI and GUI accept a custom config path
(`teleportfling --config /path/config.json`); explicit CLI flags
override file values. Used by the systemd daemon unit in contrib/.
- [x] **Run as a daemon**`contrib/install-daemon.sh` installs a per-user
systemd unit (`contrib/teleportfling.service`) that starts the headless
CLI with its own config at `~/.config/teleportfling/daemon.json`.
## Future / Ideas (not yet scoped) 1. Live settings changes mid-stream
2. Bitrate / bandwidth tuning
3. Screen capture scaling / downsampling
4. Multi-monitor verification
5. Non-Hyprland monitor enumeration
6. Packaging verification on real machines
- [ ] **Live settings changes mid-stream**`flinger.SetConfig` for FPS/quality Completed items (monitor picker, runtime errors in GUI, `--config` flag,
without a restart. daemon mode, packaging scripts) are closed in git history — see the README
- [ ] **Bitrate / bandwidth tuning** — options beyond the balanced 1080p30 and project.md for current capabilities.
default.
- [ ] **Screen capture scaling / downsampling** — capture a scaled region rather
than the monitor's native resolution.
- [ ] **Multi-monitor verification** — the GUI monitor picker enumerates via
hyprctl; confirm behaviour on a real multi-monitor setup.
- [ ] **Non-Hyprland monitor enumeration** — portal-based fallback so the picker
works on GNOME/KDE etc. (currently falls back to a single indexed option).
- [ ] **Packaging** — Nix flake package, AppImage, and `.deb` for Debian/Ubuntu
(in progress: flake package, AppImage and .deb scripts added under
packaging/; verify on a real Debian/Ubuntu machine).