feat: add monitor picker to the GUI settings
Enumerate monitors via hyprctl (Hyprland) and present them as a dropdown in the settings window instead of a raw index. The picker is enabled only for the screen source and persists the selected monitor as stream_index. Falls back to a single indexed option when enumeration is unavailable (non-Hyprland).
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
// Monitor enumeration for the settings UI.
|
||||||
|
//
|
||||||
|
// The screen-capture backend itself selects a monitor by index; this file
|
||||||
|
// provides a way to list the available monitors so the GUI can present a
|
||||||
|
// friendly picker instead of a raw index.
|
||||||
|
//
|
||||||
|
// Hyprland exposes monitor info via the `hyprctl monitors` command. Other
|
||||||
|
// Wayland compositors would need a portal-based enumeration; for now we only
|
||||||
|
// implement the Hyprland path (the primary dev environment) and return a
|
||||||
|
// clear error elsewhere.
|
||||||
|
|
||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Monitor describes one capturable output.
|
||||||
|
type Monitor struct {
|
||||||
|
Index int // capture StreamIndex to pass to OpenPipeWire
|
||||||
|
Name string // compositor name, e.g. "eDP-1"
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Primary bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNoMonitors is returned when monitor enumeration is unsupported or fails.
|
||||||
|
var ErrNoMonitors = errors.New("capture: monitor enumeration unavailable on this compositor")
|
||||||
|
|
||||||
|
// ListMonitors returns the available monitors for the GUI picker.
|
||||||
|
func ListMonitors() ([]Monitor, error) {
|
||||||
|
if hyprctlAvailable() {
|
||||||
|
return hyprctlMonitors()
|
||||||
|
}
|
||||||
|
return nil, ErrNoMonitors
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyprctlAvailable reports whether the Hyprland monitor command exists.
|
||||||
|
func hyprctlAvailable() bool {
|
||||||
|
_, err := exec.LookPath("hyprctl")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyprctlMonitor is the JSON shape emitted by `hyprctl monitors -j`.
|
||||||
|
type hyprctlMonitor struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Focused bool `json:"focused"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyprctlMonitors lists monitors via the Hyprland IPC command.
|
||||||
|
func hyprctlMonitors() ([]Monitor, error) {
|
||||||
|
out, err := exec.Command("hyprctl", "monitors", "-j").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrNoMonitors
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw []hyprctlMonitor
|
||||||
|
if err := json.Unmarshal(out, &raw); err != nil {
|
||||||
|
return nil, ErrNoMonitors
|
||||||
|
}
|
||||||
|
|
||||||
|
monitors := make([]Monitor, 0, len(raw))
|
||||||
|
for _, m := range raw {
|
||||||
|
monitors = append(monitors, Monitor{
|
||||||
|
Index: m.ID,
|
||||||
|
Name: m.Name,
|
||||||
|
Width: m.Width,
|
||||||
|
Height: m.Height,
|
||||||
|
Primary: m.Focused,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return monitors, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHyprctlMonitors parses a representative `hyprctl monitors -j` payload.
|
||||||
|
func TestHyprctlMonitors(t *testing.T) {
|
||||||
|
payload := `[
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "eDP-1",
|
||||||
|
"description": "BOE 0x094C",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1200,
|
||||||
|
"focused": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "HDMI-A-1",
|
||||||
|
"description": "Samsung",
|
||||||
|
"width": 2560,
|
||||||
|
"height": 1440,
|
||||||
|
"focused": false
|
||||||
|
}
|
||||||
|
]`
|
||||||
|
|
||||||
|
var raw []hyprctlMonitor
|
||||||
|
if err := json.Unmarshal([]byte(payload), &raw); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(raw) != 2 {
|
||||||
|
t.Fatalf("got %d monitors, want 2", len(raw))
|
||||||
|
}
|
||||||
|
if raw[0].ID != 0 || raw[0].Name != "eDP-1" || raw[0].Width != 1920 {
|
||||||
|
t.Errorf("monitor 0 wrong: %+v", raw[0])
|
||||||
|
}
|
||||||
|
if raw[1].ID != 1 || raw[1].Focused {
|
||||||
|
t.Errorf("monitor 1 wrong: %+v", raw[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
-2
@@ -8,6 +8,7 @@ package gui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
"fyne.io/systray"
|
"fyne.io/systray"
|
||||||
|
|
||||||
"teleportfling/assets"
|
"teleportfling/assets"
|
||||||
|
"teleportfling/internal/capture"
|
||||||
"teleportfling/internal/config"
|
"teleportfling/internal/config"
|
||||||
"teleportfling/internal/flinger"
|
"teleportfling/internal/flinger"
|
||||||
)
|
)
|
||||||
@@ -63,6 +65,8 @@ type App struct {
|
|||||||
audioChk *widget.Check
|
audioChk *widget.Check
|
||||||
announceChk *widget.Check
|
announceChk *widget.Check
|
||||||
srcSel *widget.Select
|
srcSel *widget.Select
|
||||||
|
monSel *widget.Select
|
||||||
|
monitors []capture.Monitor
|
||||||
statsDone chan struct{}
|
statsDone chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +137,7 @@ func (g *App) buildUI() {
|
|||||||
// Source.
|
// Source.
|
||||||
g.srcSel = widget.NewSelect([]string{"screen", "pattern"}, func(string) {})
|
g.srcSel = widget.NewSelect([]string{"screen", "pattern"}, func(string) {})
|
||||||
g.srcSel.SetSelected(g.cfg.Source)
|
g.srcSel.SetSelected(g.cfg.Source)
|
||||||
|
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"}, func(string) {})
|
||||||
@@ -167,6 +172,7 @@ func (g *App) buildUI() {
|
|||||||
{Text: "Name", Widget: g.nameEnt},
|
{Text: "Name", Widget: g.nameEnt},
|
||||||
{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: "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: "", Widget: g.audioChk},
|
||||||
@@ -185,6 +191,56 @@ func (g *App) buildUI() {
|
|||||||
g.win.Resize(fyne.NewSize(380, 0))
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -211,7 +267,7 @@ func (g *App) start() {
|
|||||||
FPS: fps,
|
FPS: fps,
|
||||||
Source: g.srcSel.Selected,
|
Source: g.srcSel.Selected,
|
||||||
Audio: g.audioChk.Checked,
|
Audio: g.audioChk.Checked,
|
||||||
StreamIndex: g.cfg.StreamIndex,
|
StreamIndex: g.selectedMonitorIndex(),
|
||||||
Announce: &announce,
|
Announce: &announce,
|
||||||
}
|
}
|
||||||
if g.configPath != "" {
|
if g.configPath != "" {
|
||||||
@@ -258,7 +314,16 @@ func (g *App) watchStats() {
|
|||||||
// Fyne UI calls must run on the main thread.
|
// Fyne UI calls must run on the main thread.
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
g.statusLab.SetText(formatStatus(st))
|
g.statusLab.SetText(formatStatus(st))
|
||||||
g.setTrayState(true, "TeleportFling · "+frames+" frames, "+dropped+" dropped")
|
if st.Err != nil {
|
||||||
|
g.statusLab.Importance = widget.DangerImportance
|
||||||
|
} else {
|
||||||
|
g.statusLab.Importance = widget.SuccessImportance
|
||||||
|
}
|
||||||
|
tip := "TeleportFling · " + frames + " frames, " + dropped + " dropped"
|
||||||
|
if st.Err != nil {
|
||||||
|
tip += " · error"
|
||||||
|
}
|
||||||
|
g.setTrayState(true, tip)
|
||||||
})
|
})
|
||||||
case <-done:
|
case <-done:
|
||||||
return
|
return
|
||||||
@@ -273,6 +338,9 @@ 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.Err != nil {
|
||||||
|
base += "\nError: " + st.Err.Error()
|
||||||
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user