diff --git a/Makefile b/Makefile
index f8c64cbe4..ccfbc37c4 100644
--- a/Makefile
+++ b/Makefile
@@ -18,7 +18,7 @@ SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
ASSETS_DIR=assets
APPLICATIONS_DIR=$(DATA_DIR)/applications
-.PHONY: all build clean lint-qml install install-bin install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
+.PHONY: all build cast-helper clean lint-qml install install-bin install-cast-helper install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
all: build
@@ -27,6 +27,11 @@ build:
@$(MAKE) -C $(CORE_DIR) build
@echo "Build complete"
+# Opt-in: builds the go-gst screen-capture helper for the cast screen-mirror
+# feature (needs CGO + GStreamer dev packages). The main dms build stays CGO-free.
+cast-helper:
+ @$(MAKE) -C $(CORE_DIR) cast-helper
+
clean:
@echo "Cleaning build artifacts..."
@$(MAKE) -C $(CORE_DIR) clean
@@ -41,6 +46,10 @@ install-bin:
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Binary installed"
+# Install the cast helper alongside dms (dms locates it in the same directory).
+install-cast-helper:
+ @$(MAKE) -C $(CORE_DIR) install-cast-helper PREFIX=$(PREFIX)
+
install-shell:
@echo "Installing shell files to $(SHELL_INSTALL_DIR)..."
@mkdir -p $(SHELL_INSTALL_DIR)
diff --git a/core/Makefile b/core/Makefile
index cad9b95ec..d4cba4a36 100644
--- a/core/Makefile
+++ b/core/Makefile
@@ -1,7 +1,9 @@
BINARY_NAME=dms
BINARY_NAME_INSTALL=dankinstall
+CAST_HELPER=dms-cast-helper
SOURCE_DIR=cmd/dms
SOURCE_DIR_INSTALL=cmd/dankinstall
+SOURCE_DIR_CAST=cmd/dms-cast-helper
BUILD_DIR=bin
PREFIX ?= /usr/local
INSTALL_DIR=$(PREFIX)/bin
@@ -22,7 +24,7 @@ BUILD_LDFLAGS=-ldflags='-s -w -X main.Version=$(VERSION) -X main.buildTime=$(BUI
# Architecture to build for dist target (amd64, arm64, or all)
ARCH ?= all
-.PHONY: all build dankinstall dist clean install install-all install-dankinstall uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps print-version help
+.PHONY: all build dankinstall cast-helper dist clean install install-all install-dankinstall install-cast-helper uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps print-version help
# Default target
all: build
@@ -40,6 +42,16 @@ dankinstall:
CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME_INSTALL)"
+# Build the go-gst screen-capture helper used by the cast screen-mirror feature.
+# Opt-in (not part of `make build`) because it needs CGO + GStreamer dev packages
+# (gstreamer-1.0, gst-plugins-base, plus plugins-good/bad/ugly + pipewire at
+# runtime). The main dms binary stays CGO-free and spawns this when present.
+cast-helper:
+ @echo "Building $(CAST_HELPER) (requires CGO + GStreamer dev)..."
+ @mkdir -p $(BUILD_DIR)
+ CGO_ENABLED=1 $(GO) build -tags casthelper -ldflags='-s -w' -o $(BUILD_DIR)/$(CAST_HELPER) ./$(SOURCE_DIR_CAST)
+ @echo "Build complete: $(BUILD_DIR)/$(CAST_HELPER)"
+
# Build distro binaries for amd64 and arm64 (Linux only, no update/greeter support)
dist:
ifeq ($(ARCH),all)
@@ -68,6 +80,12 @@ install:
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Installation complete"
+# Install the cast helper next to dms (dms finds it via the same directory).
+install-cast-helper:
+ @echo "Installing $(CAST_HELPER) to $(INSTALL_DIR)..."
+ @install -D -m 755 $(BUILD_DIR)/$(CAST_HELPER) $(INSTALL_DIR)/$(CAST_HELPER)
+ @echo "Installation complete"
+
install-all:
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
diff --git a/core/cmd/dms-cast-helper/main.go b/core/cmd/dms-cast-helper/main.go
new file mode 100644
index 000000000..6904ca3a7
--- /dev/null
+++ b/core/cmd/dms-cast-helper/main.go
@@ -0,0 +1,167 @@
+//go:build casthelper
+
+// Command dms-cast-helper captures the screen with GStreamer (the go-gst
+// library — no gst-launch CLI) and either writes an HLS stream (for Chromecast)
+// or an H.264 byte-stream to stdout (for AirPlay/doubletake).
+//
+// The screen source is the xdg-desktop-portal PipeWire remote: the parent
+// negotiates the portal and passes the remote fd as fd 3 plus the node id via
+// -node. With -test the source is a synthetic pattern (no portal needed), which
+// makes the pipeline verifiable offline.
+//
+// Built only with CGO (it links GStreamer); the package has a no-cgo stub so
+// `CGO_ENABLED=0 go build ./...` over the core stays green.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/go-gst/go-gst/gst"
+ "github.com/go-gst/go-gst/gst/app"
+)
+
+const portalFD = 3 // the PipeWire remote fd the parent passes via ExtraFiles
+
+func main() {
+ mode := flag.String("mode", "h264", "output mode: h264 (stdout) | hls")
+ out := flag.String("out", "", "HLS output directory (mode=hls)")
+ node := flag.Int("node", 0, "PipeWire node id from the portal")
+ fps := flag.Int("fps", 30, "framerate")
+ bitrate := flag.Int("bitrate", 4147, "x264 bitrate kbps")
+ width := flag.Int("width", 1920, "scaled width")
+ height := flag.Int("height", 1080, "scaled height")
+ test := flag.Bool("test", false, "use a synthetic source instead of the portal (debug)")
+ flag.Parse()
+
+ gst.Init(nil)
+
+ desc, err := buildPipeline(*mode, *out, *node, *fps, *bitrate, *width, *height, *test)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "dms-cast-helper:", err)
+ os.Exit(2)
+ }
+
+ pipeline, err := gst.NewPipelineFromString(desc)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "pipeline:", err)
+ os.Exit(1)
+ }
+
+ // Rewrite PTS to a monotonic timeline on the encoder input: the portal
+ // delivers pts=0 on every buffer, which breaks HLS segmentation (hlssink
+ // cuts segments by timestamp) and gives the encoder no temporal reference.
+ if err := restampEncoderInput(pipeline, *fps); err != nil {
+ fmt.Fprintln(os.Stderr, "restamp:", err)
+ os.Exit(1)
+ }
+
+ if *mode == "h264" {
+ if err := wireH264Stdout(pipeline); err != nil {
+ fmt.Fprintln(os.Stderr, "appsink:", err)
+ os.Exit(1)
+ }
+ }
+
+ if err := pipeline.SetState(gst.StatePlaying); err != nil {
+ fmt.Fprintln(os.Stderr, "set playing:", err)
+ os.Exit(1)
+ }
+ runUntilDone(pipeline)
+}
+
+// buildPipeline assembles the GStreamer pipeline description.
+func buildPipeline(mode, out string, node, fps, bitrate, width, height int, test bool) (string, error) {
+ var src string
+ if test {
+ // Synthetic source — already system-memory, no DMA-BUF import needed.
+ src = fmt.Sprintf("videotestsrc is-live=true pattern=18 ! video/x-raw,width=%d,height=%d,framerate=%d/1,format=I420 ! videoconvert", width, height, fps)
+ } else {
+ // Portal PipeWire source: VA-import the DMA-BUF, scale, and force 4:2:0
+ // (I420). RGB screens would otherwise yield High 4:4:4 Predictive, which
+ // consumer TV/Cast decoders can't decode -> black.
+ src = fmt.Sprintf("pipewiresrc fd=%d path=%d do-timestamp=true ! vapostproc ! video/x-raw,width=%d,height=%d,format=I420 ! videoconvert", portalFD, node, width, height)
+ }
+
+ enc := fmt.Sprintf("x264enc name=enc tune=zerolatency speed-preset=superfast bitrate=%d key-int-max=%d byte-stream=true ! h264parse config-interval=-1", bitrate, fps)
+
+ switch mode {
+ case "h264":
+ return src + " ! " + enc + " ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=sink sync=false max-buffers=8 drop=false", nil
+ case "hls":
+ if out == "" {
+ return "", fmt.Errorf("mode=hls requires -out
")
+ }
+ hls := fmt.Sprintf("mpegtsmux ! hlssink location=%s/segment%%05d.ts playlist-location=%s/stream.m3u8 target-duration=1 max-files=10 playlist-length=5", out, out)
+ return src + " ! " + enc + " ! " + hls, nil
+ default:
+ return "", fmt.Errorf("unknown -mode %q (want h264|hls)", mode)
+ }
+}
+
+// restampEncoderInput adds a pad probe on the encoder's sink that rewrites each
+// buffer's PTS/duration to a monotonic fps timeline.
+func restampEncoderInput(pipeline *gst.Pipeline, fps int) error {
+ enc, err := pipeline.GetElementByName("enc")
+ if err != nil {
+ return err
+ }
+ sinkPad := enc.GetStaticPad("sink")
+ frameDur := uint64(time.Second) / uint64(fps)
+ var frame uint64
+ sinkPad.AddProbe(gst.PadProbeTypeBuffer, func(self *gst.Pad, info *gst.PadProbeInfo) gst.PadProbeReturn {
+ buf := info.GetBuffer()
+ if buf == nil {
+ return gst.PadProbeOK
+ }
+ buf.SetPresentationTimestamp(gst.ClockTime(frame * frameDur))
+ buf.SetDuration(gst.ClockTime(frameDur))
+ frame++
+ return gst.PadProbeOK
+ })
+ return nil
+}
+
+// wireH264Stdout streams appsink buffers (raw H.264 byte-stream) to stdout.
+func wireH264Stdout(pipeline *gst.Pipeline) error {
+ elem, err := pipeline.GetElementByName("sink")
+ if err != nil {
+ return err
+ }
+ sink := app.SinkFromElement(elem)
+ sink.SetCallbacks(&app.SinkCallbacks{
+ NewSampleFunc: func(s *app.Sink) gst.FlowReturn {
+ sample := s.PullSample()
+ if sample == nil {
+ return gst.FlowEOS
+ }
+ if _, err := os.Stdout.Write(sample.GetBuffer().Bytes()); err != nil {
+ return gst.FlowError
+ }
+ return gst.FlowOK
+ },
+ })
+ return nil
+}
+
+// runUntilDone blocks on the bus until EOS or error.
+func runUntilDone(pipeline *gst.Pipeline) {
+ bus := pipeline.GetBus()
+ for {
+ msg := bus.TimedPop(gst.ClockTime(uint64(gst.ClockTimeNone)))
+ if msg == nil {
+ continue
+ }
+ switch msg.Type() {
+ case gst.MessageEOS:
+ pipeline.SetState(gst.StateNull)
+ return
+ case gst.MessageError:
+ fmt.Fprintln(os.Stderr, "gst error:", msg.ParseError().Error())
+ pipeline.SetState(gst.StateNull)
+ os.Exit(1)
+ }
+ }
+}
diff --git a/core/cmd/dms-cast-helper/main_nocgo.go b/core/cmd/dms-cast-helper/main_nocgo.go
new file mode 100644
index 000000000..a67afe477
--- /dev/null
+++ b/core/cmd/dms-cast-helper/main_nocgo.go
@@ -0,0 +1,17 @@
+//go:build !casthelper
+
+// Stub built unless the `casthelper` tag is set, so a plain `go build ./...` /
+// `go test ./...` over the core stays green without GStreamer dev packages (and
+// without enabling CGO). The real GStreamer helper is built by `make
+// cast-helper`, which passes `-tags casthelper` with CGO_ENABLED=1.
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+func main() {
+ fmt.Fprintln(os.Stderr, "dms-cast-helper was built without GStreamer support; build it with `make cast-helper` (needs CGO + GStreamer dev packages)")
+ os.Exit(2)
+}
diff --git a/core/go.mod b/core/go.mod
index 6b16688ee..412a47964 100644
--- a/core/go.mod
+++ b/core/go.mod
@@ -10,12 +10,15 @@ require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v1.0.0
github.com/fsnotify/fsnotify v1.10.1
+ github.com/go-gst/go-gst v1.4.0
github.com/godbus/dbus/v5 v5.2.2
+ github.com/grandcat/zeroconf v1.0.0
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
+ github.com/vishen/go-chromecast v0.3.4
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0
github.com/yuin/goldmark v1.8.2
@@ -32,6 +35,8 @@ require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/akutz/memconn v0.1.0 // indirect
+ github.com/buger/jsonparser v1.1.1 // indirect
+ github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
@@ -44,29 +49,40 @@ require (
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect
+ github.com/go-gst/go-glib v1.4.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
+ github.com/h2non/filetype v1.1.3 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/mattn/go-pointer v0.0.1 // indirect
github.com/mdlayher/netlink v1.11.1 // indirect
github.com/mdlayher/socket v0.6.0 // indirect
+ github.com/miekg/dns v1.1.62 // indirect
+ github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.50.0 // indirect
+ golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/tools v0.44.0 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
)
require (
diff --git a/core/go.sum b/core/go.sum
index e1bb4ebd9..fdadd7aeb 100644
--- a/core/go.sum
+++ b/core/go.sum
@@ -24,6 +24,10 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
+github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -91,6 +95,10 @@ github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsd
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
+github.com/go-gst/go-glib v1.4.0 h1:FB2uVfB0uqz7/M6EaDdWWlBZRQpvFAbWfL7drdw8lAE=
+github.com/go-gst/go-glib v1.4.0/go.mod h1:GUIpWmkxQ1/eL+FYSjKpLDyTZx6Vgd9nNXt8dA31d5M=
+github.com/go-gst/go-gst v1.4.0 h1:EikB43u4c3wc8d2RzlFRSfIGIXYzDy6Zls2vJqrG2BU=
+github.com/go-gst/go-gst v1.4.0/go.mod h1:p8TLGtOxJLcrp6PCkTPdnanwWBxPZvYiHDbuSuwgO3c=
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
@@ -98,6 +106,8 @@ github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
@@ -105,6 +115,10 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
+github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
+github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
+github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -117,9 +131,12 @@ github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5G
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -132,12 +149,19 @@ github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
+github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mdlayher/netlink v1.11.1 h1:T136gDS6Gkt+hLncaBwKdW5GpEC8Z0ykqimOebVoal0=
github.com/mdlayher/netlink v1.11.1/go.mod h1:ao4LjamyK4Uq9L8+fQzqFYpAncbeCdwbvd9Edv/pYnc=
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
+github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
+github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
+github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -164,6 +188,8 @@ github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4Xl
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
@@ -182,6 +208,8 @@ github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
+github.com/vishen/go-chromecast v0.3.4 h1:ELRwOoNaxwIsKCXuxCXXku92+H/qKLj3a6ZN6LDD+H8=
+github.com/vishen/go-chromecast v0.3.4/go.mod h1:9ht6970KP5YmO0WpJJPMfInai2HA5w+q+UWId3QLxBc=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@@ -192,6 +220,8 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx
github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
@@ -204,32 +234,68 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/core/internal/server/chromecast/airplay.go b/core/internal/server/chromecast/airplay.go
new file mode 100644
index 000000000..ce8bd9d89
--- /dev/null
+++ b/core/internal/server/chromecast/airplay.go
@@ -0,0 +1,119 @@
+package chromecast
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/grandcat/zeroconf"
+)
+
+// airplayDiscoverFunc browses for AirPlay devices. Overridable in tests.
+var airplayDiscoverFunc = discoverAirplay
+
+// discoverAirplay browses _airplay._tcp and returns a channel of normalized
+// AirPlay devices. The channel is closed when ctx is cancelled.
+func discoverAirplay(ctx context.Context) (<-chan Device, error) {
+ resolver, err := zeroconf.NewResolver(nil)
+ if err != nil {
+ return nil, fmt.Errorf("airplay resolver: %w", err)
+ }
+
+ entries := make(chan *zeroconf.ServiceEntry, 8)
+ if err := resolver.Browse(ctx, "_airplay._tcp", "local.", entries); err != nil {
+ return nil, fmt.Errorf("airplay browse: %w", err)
+ }
+
+ out := make(chan Device, 8)
+ go func() {
+ defer close(out)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case entry, ok := <-entries:
+ if !ok {
+ return
+ }
+ dev := airplayEntryToDevice(entry)
+ if dev.Host == "" {
+ continue
+ }
+ select {
+ case out <- dev:
+ case <-ctx.Done():
+ return
+ }
+ }
+ }
+ }()
+ return out, nil
+}
+
+// unescapeDNS decodes DNS presentation-format escapes (\DDD decimal byte
+// escapes and \X literal escapes) that zeroconf leaves in instance names, e.g.
+// "Geoffrey\226\128\153s\ MacBook" -> "Geoffrey’s MacBook".
+func unescapeDNS(s string) string {
+ if !strings.Contains(s, "\\") {
+ return s
+ }
+ var b strings.Builder
+ for i := 0; i < len(s); i++ {
+ if s[i] != '\\' || i+1 >= len(s) {
+ b.WriteByte(s[i])
+ continue
+ }
+ if i+3 < len(s) && isDigit(s[i+1]) && isDigit(s[i+2]) && isDigit(s[i+3]) {
+ b.WriteByte(byte((int(s[i+1]-'0')*100 + int(s[i+2]-'0')*10 + int(s[i+3]-'0'))))
+ i += 3
+ continue
+ }
+ b.WriteByte(s[i+1])
+ i++
+ }
+ return b.String()
+}
+
+func isDigit(c byte) bool { return c >= '0' && c <= '9' }
+
+// airplayEntryToDevice normalizes a zeroconf _airplay._tcp entry into a Device.
+func airplayEntryToDevice(entry *zeroconf.ServiceEntry) Device {
+ dev := Device{
+ Name: unescapeDNS(entry.Instance),
+ Port: entry.Port,
+ Protocol: ProtocolAirplay,
+ }
+ if len(entry.AddrIPv4) > 0 {
+ dev.Host = entry.AddrIPv4[0].String()
+ }
+ // AirPlay TXT records carry model (and a deviceid we deliberately do NOT key
+ // on — see below).
+ for _, txt := range entry.Text {
+ k, v, ok := strings.Cut(txt, "=")
+ if !ok {
+ continue
+ }
+ if strings.EqualFold(k, "model") {
+ dev.Model = v
+ }
+ }
+
+ // Key on the mDNS instance name, not the deviceid TXT or host:port. The
+ // instance comes from the PTR/SRV records and is present in every resolved
+ // browse response; the deviceid TXT is frequently dropped under mDNS port
+ // contention (avahi + other listeners share 5353), which previously made the
+ // same device flip between its MAC id and a host:port fallback across
+ // browses — breaking favorite/auto-reconnect matching and de-duplication.
+ switch {
+ case entry.Instance != "":
+ dev.ID = "airplay:" + unescapeDNS(entry.Instance)
+ case entry.HostName != "":
+ dev.ID = "airplay:" + strings.TrimSuffix(entry.HostName, ".")
+ default:
+ dev.ID = fmt.Sprintf("airplay:%s:%d", dev.Host, dev.Port)
+ }
+ if dev.Name == "" {
+ dev.Name = strings.TrimPrefix(dev.ID, "airplay:")
+ }
+ return dev
+}
diff --git a/core/internal/server/chromecast/airplay_mirror.go b/core/internal/server/chromecast/airplay_mirror.go
new file mode 100644
index 000000000..d06676219
--- /dev/null
+++ b/core/internal/server/chromecast/airplay_mirror.go
@@ -0,0 +1,110 @@
+package chromecast
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+// defaultPortRange is the fixed UDP/TCP window doubletake confines its AirPlay
+// back-channel ports to, so users can open exactly this range in their firewall.
+const defaultPortRange = "60000-60010"
+
+// doubletakePath resolves the doubletake binary (the AirPlay 2 protocol sender).
+// doubletake is a separate GPLv3 process, never linked into the MIT core.
+func doubletakePath() string {
+ if p := os.Getenv("DMS_DOUBLETAKE"); p != "" {
+ return p
+ }
+ return "doubletake"
+}
+
+func portRange() string {
+ if r := os.Getenv("DMS_CAST_PORT_RANGE"); r != "" {
+ return r
+ }
+ return defaultPortRange
+}
+
+// buildDoubletakeCmd builds the doubletake mirror invocation for host.
+// doubletake drives gst-launch-1.0 for capture itself (see the fork at
+// github.com/domenkozar/doubletake), so no capture hook is needed here.
+// Overridable in tests.
+var buildDoubletakeCmd = func(host string) *exec.Cmd {
+ return exec.Command(doubletakePath(),
+ "-target", host,
+ "-port-range", portRange(),
+ "-no-audio",
+ )
+}
+
+// airplayMirror manages the doubletake subprocess that mirrors the screen to an
+// AirPlay 2 receiver.
+type airplayMirror struct {
+ mu sync.Mutex
+ cmd *exec.Cmd
+ running bool
+}
+
+// start launches doubletake mirroring to host. onExit fires if the process ends
+// on its own (not via stop), so the manager can clear connection state.
+func (a *airplayMirror) start(host string, onExit func()) error {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.running {
+ return fmt.Errorf("airplay mirror already running")
+ }
+
+ cmd := buildDoubletakeCmd(host)
+ cmd.Stderr = os.Stderr
+ if err := cmd.Start(); err != nil {
+ if errors.Is(err, exec.ErrNotFound) {
+ return fmt.Errorf("AirPlay mirroring requires 'doubletake' — install it (or set DMS_DOUBLETAKE)")
+ }
+ return fmt.Errorf("start doubletake: %w", err)
+ }
+ a.cmd = cmd
+ a.running = true
+ log.Infof("[Cast] AirPlay mirror started -> %s", host)
+
+ go func() {
+ _ = cmd.Wait()
+ a.mu.Lock()
+ unexpected := a.running // still true => we didn't stop() it
+ a.running = false
+ a.cmd = nil
+ a.mu.Unlock()
+ if unexpected {
+ log.Warn("[Cast] AirPlay mirror exited unexpectedly")
+ if onExit != nil {
+ onExit()
+ }
+ }
+ }()
+ return nil
+}
+
+// stop terminates the doubletake subprocess.
+func (a *airplayMirror) stop() {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if !a.running {
+ return
+ }
+ a.running = false
+ if a.cmd != nil && a.cmd.Process != nil {
+ _ = a.cmd.Process.Kill()
+ }
+ a.cmd = nil
+ log.Info("[Cast] AirPlay mirror stopped")
+}
+
+func (a *airplayMirror) isRunning() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.running
+}
diff --git a/core/internal/server/chromecast/avahi_dbus.go b/core/internal/server/chromecast/avahi_dbus.go
new file mode 100644
index 000000000..d61865992
--- /dev/null
+++ b/core/internal/server/chromecast/avahi_dbus.go
@@ -0,0 +1,191 @@
+package chromecast
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// Avahi D-Bus constants (see avahi-common/defs.h).
+const (
+ avahiIfaceUnspec = int32(-1) // AVAHI_IF_UNSPEC
+ avahiProtoInet = int32(0) // AVAHI_PROTO_INET (IPv4)
+ avahiServerRunning = int32(2) // AVAHI_SERVER_RUNNING
+)
+
+const (
+ avahiDest = "org.freedesktop.Avahi"
+ avahiServerIface = "org.freedesktop.Avahi.Server"
+ avahiBrowserIface = "org.freedesktop.Avahi.ServiceBrowser"
+ avahiItemNewSignal = "org.freedesktop.Avahi.ServiceBrowser.ItemNew"
+)
+
+// avahiAvailableFunc reports whether the Avahi daemon is usable. Overridable in
+// tests so they never touch the system bus.
+var avahiAvailableFunc = avahiAvailable
+
+// avahiBrowseFunc browses one service type via Avahi. Overridable in tests.
+var avahiBrowseFunc = browseAvahi
+
+// avahiAvailable reports whether avahi-daemon is running and reachable on the
+// system bus. When it is, we browse through it instead of running our own mDNS
+// stack, which avoids contending for UDP port 5353 (avahi already owns it).
+func avahiAvailable() bool {
+ conn, err := dbus.SystemBus()
+ if err != nil {
+ return false
+ }
+ var state int32
+ err = conn.Object(avahiDest, "/").Call(avahiServerIface+".GetState", 0).Store(&state)
+ return err == nil && state == avahiServerRunning
+}
+
+// browseAvahi asks Avahi to browse serviceType and returns a channel of
+// normalized Devices (tagged with proto). It uses a private system-bus
+// connection so its signal stream is isolated; the connection, the browser, and
+// the channel are all torn down when ctx is cancelled.
+func browseAvahi(ctx context.Context, serviceType string, proto string) (<-chan Device, error) {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return nil, fmt.Errorf("avahi system bus: %w", err)
+ }
+
+ server := conn.Object(avahiDest, "/")
+ var browserPath dbus.ObjectPath
+ if err := server.Call(avahiServerIface+".ServiceBrowserNew", 0,
+ avahiIfaceUnspec, avahiProtoInet, serviceType, "", uint32(0)).Store(&browserPath); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("avahi ServiceBrowserNew(%s): %w", serviceType, err)
+ }
+
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(browserPath),
+ dbus.WithMatchInterface(avahiBrowserIface),
+ ); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("avahi match: %w", err)
+ }
+
+ sig := make(chan *dbus.Signal, 32)
+ conn.Signal(sig)
+
+ out := make(chan Device, 8)
+ var resolves sync.WaitGroup
+ go func() {
+ // Defers run LIFO: wait for in-flight resolves, then free the browser and
+ // close the bus while they no longer use it, then close the channel.
+ defer close(out)
+ defer conn.Close()
+ defer conn.Object(avahiDest, browserPath).Call(avahiBrowserIface+".Free", 0)
+ defer resolves.Wait()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case s, ok := <-sig:
+ if !ok {
+ return
+ }
+ if s.Path != browserPath || s.Name != avahiItemNewSignal {
+ continue
+ }
+ // ItemNew(i iface, i proto, s name, s type, s domain, u flags)
+ if len(s.Body) < 5 {
+ continue
+ }
+ iface, _ := s.Body[0].(int32)
+ protocol, _ := s.Body[1].(int32)
+ name, _ := s.Body[2].(string)
+ stype, _ := s.Body[3].(string)
+ domain, _ := s.Body[4].(string)
+
+ // Resolve off the browse loop so one slow/failed resolve doesn't
+ // stall the others, and retry: under mDNS port contention (e.g.
+ // Chrome's 5353 socket) a single resolve often loses its response.
+ resolves.Add(1)
+ go func() {
+ defer resolves.Done()
+ dev, ok := resolveAvahiRetry(ctx, server, iface, protocol, name, stype, domain, proto)
+ if !ok {
+ return
+ }
+ select {
+ case out <- dev:
+ case <-ctx.Done():
+ }
+ }()
+ }
+ }
+ }()
+ return out, nil
+}
+
+// avahiResolveAttempts bounds how many times a browsed item is re-resolved
+// before giving up for this announcement (it is retried on the next re-announce).
+const avahiResolveAttempts = 3
+
+// resolveAvahiRetry resolves an item, retrying a few times because a single
+// mDNS resolve response is easily lost when other listeners share port 5353.
+func resolveAvahiRetry(ctx context.Context, server dbus.BusObject, iface, protocol int32, name, stype, domain, proto string) (Device, bool) {
+ for attempt := 0; attempt < avahiResolveAttempts; attempt++ {
+ if dev, ok := resolveAvahi(server, iface, protocol, name, stype, domain, proto); ok {
+ return dev, true
+ }
+ if ctx.Err() != nil {
+ return Device{}, false
+ }
+ }
+ return Device{}, false
+}
+
+// resolveAvahi resolves a browsed item to an address + TXT and maps it to a
+// Device. Avahi returns names already unescaped, so no DNS-escape decoding is
+// needed here (unlike the raw zeroconf path).
+func resolveAvahi(server dbus.BusObject, iface, protocol int32, name, stype, domain string, proto string) (Device, bool) {
+ var (
+ rIface, rProto int32
+ rName, rType, rDomain string
+ rHost string
+ rAproto int32
+ rAddress string
+ rPort uint16
+ rTxt [][]byte
+ rFlags uint32
+ )
+ err := server.Call(avahiServerIface+".ResolveService", 0,
+ iface, protocol, name, stype, domain, avahiProtoInet, uint32(0)).Store(
+ &rIface, &rProto, &rName, &rType, &rDomain, &rHost, &rAproto, &rAddress, &rPort, &rTxt, &rFlags)
+ if err != nil {
+ return Device{}, false // device vanished between browse and resolve
+ }
+
+ txt := make(map[string]string, len(rTxt))
+ for _, b := range rTxt {
+ k, v, ok := strings.Cut(string(b), "=")
+ if ok {
+ txt[strings.ToLower(k)] = v
+ }
+ }
+
+ dev := Device{Host: rAddress, Port: int(rPort), Protocol: proto}
+ switch proto {
+ case ProtocolChromecast:
+ // Cast TXT carries the device UUID (id), friendly name (fn), model (md).
+ dev.ID = txt["id"]
+ dev.Name = txt["fn"]
+ dev.Model = txt["md"]
+ case ProtocolAirplay:
+ // Key on the instance name, consistent with the zeroconf path.
+ dev.ID = "airplay:" + rName
+ dev.Name = rName
+ dev.Model = txt["model"]
+ }
+ if dev.Name == "" {
+ dev.Name = rName
+ }
+ return dev, true
+}
diff --git a/core/internal/server/chromecast/config.go b/core/internal/server/chromecast/config.go
new file mode 100644
index 000000000..89931d1b7
--- /dev/null
+++ b/core/internal/server/chromecast/config.go
@@ -0,0 +1,54 @@
+package chromecast
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
+)
+
+// Config persists user preferences for the chromecast service.
+type Config struct {
+ // PreferredID is the stable ID of the device to auto-reconnect to. Empty
+ // means no preferred device.
+ PreferredID string `json:"preferredId"`
+ // PreferredName is kept for display only (the ID is authoritative).
+ PreferredName string `json:"preferredName"`
+}
+
+// configPathFunc resolves the config file path. Overridable in tests.
+var configPathFunc = func() (string, error) {
+ return filepath.Join(utils.XDGConfigHome(), "DankMaterialShell", "castsettings.json"), nil
+}
+
+func loadConfig() Config {
+ var cfg Config
+ path, err := configPathFunc()
+ if err != nil {
+ return cfg
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return cfg
+ }
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return Config{}
+ }
+ return cfg
+}
+
+func saveConfig(cfg Config) error {
+ path, err := configPathFunc()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, data, 0o644)
+}
diff --git a/core/internal/server/chromecast/handlers.go b/core/internal/server/chromecast/handlers.go
new file mode 100644
index 000000000..629428604
--- /dev/null
+++ b/core/internal/server/chromecast/handlers.go
@@ -0,0 +1,157 @@
+package chromecast
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+)
+
+// HandleRequest routes an IPC request to the appropriate handler.
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ switch req.Method {
+ case "chromecast.getState":
+ handleGetState(conn, req, manager)
+ case "chromecast.startDiscovery":
+ handleStartDiscovery(conn, req, manager)
+ case "chromecast.stopDiscovery":
+ handleStopDiscovery(conn, req, manager)
+ case "chromecast.connect":
+ handleConnect(conn, req, manager)
+ case "chromecast.disconnect":
+ handleDisconnect(conn, req, manager)
+ case "chromecast.cast":
+ handleCast(conn, req, manager)
+ case "chromecast.play":
+ handleControl(conn, req, manager.Play, "playing")
+ case "chromecast.pause":
+ handleControl(conn, req, manager.Pause, "paused")
+ case "chromecast.stop":
+ handleControl(conn, req, manager.StopPlayback, "stopped")
+ case "chromecast.seek":
+ handleSeek(conn, req, manager)
+ case "chromecast.setVolume":
+ handleSetVolume(conn, req, manager)
+ case "chromecast.setMuted":
+ handleSetMuted(conn, req, manager)
+ case "chromecast.castScreen":
+ handleCastScreen(conn, req, manager)
+ case "chromecast.stopScreen":
+ handleStopScreen(conn, req, manager)
+ case "chromecast.setPreferred":
+ handleSetPreferred(conn, req, manager)
+ case "chromecast.clearPreferred":
+ manager.ClearPreferred()
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "preference cleared"})
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleStartDiscovery(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.StartDiscovery(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "discovery started"})
+}
+
+func handleStopDiscovery(conn net.Conn, req models.Request, manager *Manager) {
+ manager.StopDiscovery()
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "discovery stopped"})
+}
+
+func handleConnect(conn net.Conn, req models.Request, manager *Manager) {
+ id := models.GetOr(req, "id", "")
+ if id == "" {
+ models.RespondError(conn, req.ID, "missing device id")
+ return
+ }
+ if err := manager.Connect(id); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connected"})
+}
+
+func handleDisconnect(conn net.Conn, req models.Request, manager *Manager) {
+ manager.Disconnect()
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"})
+}
+
+func handleCast(conn net.Conn, req models.Request, manager *Manager) {
+ url := models.GetOr(req, "url", "")
+ if url == "" {
+ models.RespondError(conn, req.ID, "missing url")
+ return
+ }
+ contentType := models.GetOr(req, "contentType", "")
+ if err := manager.Cast(url, contentType); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "casting"})
+}
+
+// handleControl wraps a no-argument transport action.
+func handleControl(conn net.Conn, req models.Request, action func() error, okMsg string) {
+ if err := action(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: okMsg})
+}
+
+func handleSeek(conn net.Conn, req models.Request, manager *Manager) {
+ pos := models.GetOr(req, "position", float64(0))
+ if err := manager.Seek(pos); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "seeked"})
+}
+
+func handleSetVolume(conn net.Conn, req models.Request, manager *Manager) {
+ level := models.GetOr(req, "level", float64(0))
+ if err := manager.SetVolume(level); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "volume set"})
+}
+
+func handleSetMuted(conn net.Conn, req models.Request, manager *Manager) {
+ muted := models.GetOr(req, "muted", false)
+ if err := manager.SetMuted(muted); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "mute set"})
+}
+
+func handleCastScreen(conn net.Conn, req models.Request, manager *Manager) {
+ if err := manager.CastScreen(); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "screen casting"})
+}
+
+func handleStopScreen(conn net.Conn, req models.Request, manager *Manager) {
+ manager.StopScreen()
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "screen stopped"})
+}
+
+func handleSetPreferred(conn net.Conn, req models.Request, manager *Manager) {
+ id := models.GetOr(req, "id", "")
+ if id == "" {
+ models.RespondError(conn, req.ID, "missing device id")
+ return
+ }
+ manager.SetPreferred(id)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "preference saved"})
+}
diff --git a/core/internal/server/chromecast/manager.go b/core/internal/server/chromecast/manager.go
new file mode 100644
index 000000000..339835c07
--- /dev/null
+++ b/core/internal/server/chromecast/manager.go
@@ -0,0 +1,814 @@
+package chromecast
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "sort"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+ castapp "github.com/vishen/go-chromecast/application"
+ "github.com/vishen/go-chromecast/cast"
+ castdns "github.com/vishen/go-chromecast/dns"
+)
+
+// pollInterval is how often the connected device is polled for playback status.
+// The Cast protocol does not push media progress, so we refresh on a timer and
+// broadcast only when something actually changes.
+const pollInterval = time.Second
+
+// discoverFunc abstracts mDNS discovery so the manager can be unit-tested
+// without a real LAN. Defaults to the go-chromecast implementation.
+var discoverFunc = func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) {
+ return castdns.DiscoverCastDNSEntries(ctx, iface)
+}
+
+// castApp is the slice of the go-chromecast Application API the manager uses.
+// It exists so connection logic can be unit-tested with a fake.
+type castApp interface {
+ Start(addr string, port int) error
+ Update() error
+ Status() (*cast.Application, *cast.Media, *cast.Volume)
+ Load(filenameOrURL string, startTime int, contentType string, transcode, detach, forceDetach bool) error
+ Unpause() error
+ Pause() error
+ StopMedia() error
+ SeekToTime(value float32) error
+ SetVolume(value float32) error
+ SetMuted(value bool) error
+ Close(stopMedia bool) error
+}
+
+// errNotConnected is returned by control actions when no device is connected.
+var errNotConnected = errors.New("not connected to a device")
+
+// newAppFunc constructs a cast application. Overridable in tests.
+var newAppFunc = func() castApp {
+ return castapp.NewApplication()
+}
+
+// Manager discovers Cast devices via mDNS and broadcasts device-list changes
+// to subscribers. Discovery is explicitly started/stopped by clients so the
+// mDNS browser only runs while a UI is interested in the results.
+type Manager struct {
+ mu sync.RWMutex
+ devices map[string]Device
+ discovering bool
+ discoverCancel context.CancelFunc
+ discoverGen uint64 // bumped on every (re)start/stop so a stale wg-drain can't clear a newer scan
+
+ // connMu serializes connection lifecycle transitions (Connect/Disconnect)
+ // so two overlapping attempts can't both install an app or undo each other.
+ connMu sync.Mutex
+ // appMu serializes every call into the go-chromecast Application, which is
+ // not concurrent-safe; the poll loop and IPC control actions both call it.
+ appMu sync.Mutex
+
+ // Connection state (guarded by mu; blocking network calls are never made
+ // while the lock is held).
+ app castApp
+ connCancel context.CancelFunc
+ connected bool
+ autoConnecting bool // guards against overlapping auto-reconnect attempts
+ suppressAutoReconnect bool // set when the user explicitly disconnects, so a re-announce doesn't auto-reconnect
+ activeDevice Device
+ playback *Playback
+ screencasting bool
+ preferredID string
+
+ screen *screenStreamer
+ airplay *airplayMirror
+ subscribers syncmap.Map[string, chan State]
+ sendMu sync.Mutex // serializes broadcast sends vs. subscriber-channel closes
+ closed atomic.Bool
+}
+
+// callApp runs a single Application call while holding appMu. The go-chromecast
+// Application mutates a shared requestID/resultChanMap without locking, so the
+// poll loop and concurrent IPC control actions must not call into it at once
+// (doing so races and can crash the process with "concurrent map writes").
+func (m *Manager) callApp(app castApp, fn func(castApp) error) error {
+ m.appMu.Lock()
+ defer m.appMu.Unlock()
+ return fn(app)
+}
+
+// statusOf reads the Application's cached status under appMu (Status() reads
+// fields Update() writes, so it must be serialized with the other app calls).
+func (m *Manager) statusOf(app castApp) (*cast.Application, *cast.Media, *cast.Volume) {
+ m.appMu.Lock()
+ defer m.appMu.Unlock()
+ return app.Status()
+}
+
+// NewManager creates a chromecast manager. Discovery does not start until
+// StartDiscovery is called.
+func NewManager() *Manager {
+ cfg := loadConfig()
+ return &Manager{
+ devices: make(map[string]Device),
+ screen: &screenStreamer{},
+ airplay: &airplayMirror{},
+ preferredID: cfg.PreferredID,
+ }
+}
+
+// rebrowseInterval paces the self-healing re-browse on the built-in mDNS
+// fallback: every interval the resolver is recreated so a browser that missed
+// responses (port 5353 is shared with other mDNS listeners) gets another chance.
+const rebrowseInterval = 10 * time.Second
+
+// StartDiscovery begins discovery for both Chromecast (_googlecast._tcp) and
+// AirPlay (_airplay._tcp) devices. It is a no-op if discovery is already
+// running. Newly seen devices from either protocol are merged into one list and
+// broadcast to subscribers. When avahi-daemon is available, browsing goes
+// through it (no parallel mDNS stack, no port contention); otherwise the
+// built-in zeroconf/go-chromecast browsers run with periodic self-healing.
+func (m *Manager) StartDiscovery() error {
+ m.mu.Lock()
+ if m.discovering {
+ m.mu.Unlock()
+ return nil
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ m.discoverCancel = cancel
+ m.discovering = true
+ m.discoverGen++
+ gen := m.discoverGen
+ m.devices = make(map[string]Device) // fresh list per scan
+ m.mu.Unlock()
+
+ sources := m.discoverySources(ctx)
+
+ log.Info("[Cast] Discovery started")
+ m.broadcast()
+
+ var wg sync.WaitGroup
+ for _, src := range sources {
+ wg.Add(1)
+ go func(ch <-chan Device) {
+ defer wg.Done()
+ for dev := range ch {
+ m.upsertDevice(dev)
+ }
+ }(src)
+ }
+
+ // Mark discovery stopped once every source has ended — but only if this is
+ // still the active scan. A StopDiscovery + StartDiscovery can replace this
+ // session while its sources are still draining; the gen check stops that
+ // stale drain from clearing the newer scan's discovering flag.
+ go func() {
+ wg.Wait()
+ m.mu.Lock()
+ stale := m.discoverGen != gen
+ if !stale {
+ m.discovering = false
+ m.discoverCancel = nil
+ }
+ m.mu.Unlock()
+ if !stale {
+ log.Info("[Cast] Discovery stopped")
+ m.broadcast()
+ }
+ }()
+
+ return nil
+}
+
+// discoverySources picks the discovery backend and returns one Device channel
+// per protocol. Prefers avahi; falls back to the built-in self-healing mDNS
+// browsers. Errors only if no source could be started at all.
+func (m *Manager) discoverySources(ctx context.Context) []<-chan Device {
+ useAvahi := avahiAvailableFunc()
+ if useAvahi {
+ log.Info("[Cast] Using avahi for mDNS discovery")
+ }
+ // One source per protocol, each choosing avahi or its built-in browser
+ // independently — so a single avahi browse failure degrades only that
+ // protocol to its fallback instead of silently dropping it.
+ cc := m.protocolSource(ctx, useAvahi, "_googlecast._tcp", ProtocolChromecast, func(c context.Context) (<-chan Device, error) {
+ return castEntriesToDevices(discoverFunc(c, nil))
+ })
+ ap := m.protocolSource(ctx, useAvahi, "_airplay._tcp", ProtocolAirplay, func(c context.Context) (<-chan Device, error) {
+ return airplayDiscoverFunc(c)
+ })
+ return []<-chan Device{cc, ap}
+}
+
+// protocolSource returns the device channel for one protocol: avahi when it is
+// available and its browse starts, otherwise the built-in self-healing browser.
+func (m *Manager) protocolSource(ctx context.Context, useAvahi bool, serviceType, proto string, builtin func(context.Context) (<-chan Device, error)) <-chan Device {
+ if useAvahi {
+ ch, err := avahiBrowseFunc(ctx, serviceType, proto)
+ if err == nil {
+ return ch
+ }
+ log.Warnf("[Cast] avahi browse %s failed (%v); falling back to built-in mDNS", serviceType, err)
+ }
+ return m.selfHealBrowse(ctx, builtin)
+}
+
+// selfHealBrowse repeatedly runs mk with a fresh per-cycle context so a browser
+// that missed mDNS responses re-queries on the next cycle. Devices are forwarded
+// as they arrive; the loop ends when ctx is cancelled.
+func (m *Manager) selfHealBrowse(ctx context.Context, mk func(context.Context) (<-chan Device, error)) <-chan Device {
+ out := make(chan Device, 8)
+ go func() {
+ defer close(out)
+ for ctx.Err() == nil {
+ cycleCtx, cancel := context.WithTimeout(ctx, rebrowseInterval)
+ if ch, err := mk(cycleCtx); err == nil {
+ for dev := range ch {
+ select {
+ case out <- dev:
+ case <-ctx.Done():
+ cancel()
+ return
+ }
+ }
+ }
+ <-cycleCtx.Done() // pace re-browses and honor cancellation
+ cancel()
+ }
+ }()
+ return out
+}
+
+// castEntriesToDevices adapts the go-chromecast discovery channel to Devices.
+func castEntriesToDevices(in <-chan castdns.CastEntry, err error) (<-chan Device, error) {
+ if err != nil {
+ return nil, err
+ }
+ out := make(chan Device, 8)
+ go func() {
+ defer close(out)
+ for e := range in {
+ out <- Device{
+ ID: e.UUID,
+ Name: e.DeviceName,
+ Model: e.Device,
+ Host: e.GetAddr(),
+ Port: e.Port,
+ Protocol: ProtocolChromecast,
+ }
+ }
+ }()
+ return out, nil
+}
+
+// upsertDevice merges a discovered device into the list, assigns a stable key,
+// broadcasts, and auto-reconnects to the preferred device when it appears.
+func (m *Manager) upsertDevice(dev Device) {
+ key := dev.ID
+ if key == "" {
+ key = fmt.Sprintf("%s:%d", dev.Host, dev.Port)
+ }
+ dev.ID = key
+ if dev.Name == "" {
+ dev.Name = key
+ }
+
+ m.mu.Lock()
+ existing, known := m.devices[key]
+ changed := !known || existing != dev
+ m.devices[key] = dev
+ // Auto-reconnect to the preferred device when it (re)appears, for either
+ // protocol — Connect dispatches Chromecast vs AirPlay (which starts the
+ // mirror). For AirPlay this auto-starts a screen mirror; the screen-share
+ // portal only prompts on the first grant (the restore token auto-grants
+ // after), so it isn't intrusive on later reconnects. autoConnecting prevents
+ // overlapping attempts from rapid/duplicate discovery events.
+ shouldReconnect := m.preferredID == key && !m.connected && !m.autoConnecting && !m.suppressAutoReconnect
+ if shouldReconnect {
+ m.autoConnecting = true
+ }
+ m.mu.Unlock()
+ // Discovery re-announces and the self-heal re-browse re-emit unchanged
+ // devices on a loop; only snapshot/sort/broadcast when something actually
+ // changed to avoid re-pushing an identical list to every subscriber.
+ if changed {
+ log.Debugf("[Cast] Discovered %s %s (%s) at %s:%d", dev.Protocol, dev.Name, dev.Model, dev.Host, dev.Port)
+ m.broadcast()
+ }
+
+ if shouldReconnect {
+ log.Infof("[Cast] Auto-reconnecting to preferred device %s", dev.Name)
+ go func(id string) {
+ if err := m.Connect(id); err != nil {
+ log.Warnf("[Cast] Auto-reconnect failed: %v", err)
+ }
+ m.mu.Lock()
+ m.autoConnecting = false
+ m.mu.Unlock()
+ }(key)
+ }
+}
+
+// StopDiscovery cancels an in-flight mDNS browse. Safe to call when not
+// discovering.
+func (m *Manager) StopDiscovery() {
+ m.mu.Lock()
+ cancel := m.discoverCancel
+ m.discoverCancel = nil
+ // Clear synchronously (don't wait for the async source drain) so an
+ // immediate StartDiscovery isn't a no-op, and bump the generation so the
+ // in-flight drain goroutine won't touch the next scan's state.
+ wasDiscovering := m.discovering
+ m.discovering = false
+ m.discoverGen++
+ m.mu.Unlock()
+
+ if cancel != nil {
+ cancel()
+ }
+ // The stale drain goroutine won't broadcast (its gen no longer matches), so
+ // publish the stopped state here.
+ if wasDiscovering {
+ m.broadcast()
+ }
+}
+
+// Connect opens a control connection to the device with the given ID and
+// begins polling it for playback status. Any existing connection is dropped
+// first. The device must have been seen by discovery.
+func (m *Manager) Connect(id string) error {
+ m.connMu.Lock()
+ defer m.connMu.Unlock()
+ return m.connectLocked(id)
+}
+
+// connectLocked performs the connection. Callers hold connMu so connect and
+// disconnect transitions never overlap (which previously let two concurrent
+// connects both install an app, leaking the first connection and its poll loop).
+func (m *Manager) connectLocked(id string) error {
+ m.mu.Lock()
+ // An explicit connect re-engages auto-reconnect for future re-announces.
+ m.suppressAutoReconnect = false
+ dev, ok := m.devices[id]
+ hasSession := m.connected || m.app != nil
+ m.mu.Unlock()
+
+ if !ok {
+ return fmt.Errorf("unknown device: %s", id)
+ }
+
+ // AirPlay devices use the mirroring path (doubletake), not the Cast protocol.
+ if dev.Protocol == ProtocolAirplay {
+ return m.connectAirplayLocked(dev)
+ }
+
+ // Drop any existing session first — a Chromecast app OR an AirPlay mirror.
+ // m.app is nil for AirPlay, so the connected check is what catches it.
+ if hasSession {
+ m.disconnectLocked()
+ }
+
+ app := newAppFunc()
+ if err := m.callApp(app, func(a castApp) error { return a.Start(dev.Host, dev.Port) }); err != nil {
+ return fmt.Errorf("connect to %s: %w", dev.Name, err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ m.mu.Lock()
+ m.app = app
+ m.connCancel = cancel
+ m.connected = true
+ m.activeDevice = dev
+ m.playback = nil
+ m.mu.Unlock()
+
+ log.Infof("[Chromecast] Connected to %s (%s:%d)", dev.Name, dev.Host, dev.Port)
+ m.broadcast()
+
+ // Prime playback state, then poll for changes until disconnected.
+ if err := m.callApp(app, func(a castApp) error { return a.Update() }); err != nil {
+ log.Warnf("[Chromecast] Initial status update failed: %v", err)
+ }
+ m.refreshPlayback(app)
+
+ go m.pollLoop(ctx, app)
+ return nil
+}
+
+// connectAirplayLocked starts mirroring the screen to an AirPlay 2 device via
+// doubletake. For AirPlay, connecting and mirroring are the same action.
+// Callers hold connMu.
+func (m *Manager) connectAirplayLocked(dev Device) error {
+ m.disconnectLocked() // drop any existing connection first
+
+ onExit := func() {
+ m.mu.Lock()
+ m.connected = false
+ m.activeDevice = Device{}
+ m.screencasting = false
+ m.mu.Unlock()
+ m.broadcast()
+ }
+ if err := m.airplay.start(dev.Host, onExit); err != nil {
+ return err
+ }
+
+ m.mu.Lock()
+ m.connected = true
+ m.activeDevice = dev
+ m.screencasting = true // AirPlay connect == screen mirroring
+ m.mu.Unlock()
+ log.Infof("[Cast] Connected to AirPlay device %s (%s)", dev.Name, dev.Host)
+ m.broadcast()
+ return nil
+}
+
+// Disconnect closes the active control connection, if any, and stops any
+// in-progress screen mirroring. This is the user-initiated path, so it suppresses
+// auto-reconnect until the next explicit connect/preference change.
+func (m *Manager) Disconnect() {
+ m.connMu.Lock()
+ defer m.connMu.Unlock()
+ m.mu.Lock()
+ m.suppressAutoReconnect = true
+ m.mu.Unlock()
+ m.disconnectLocked()
+}
+
+// disconnectLocked tears down the active session (Cast app and/or AirPlay
+// mirror) and clears connection state. Callers hold connMu.
+func (m *Manager) disconnectLocked() {
+ m.screen.stop()
+ m.airplay.stop()
+
+ m.mu.Lock()
+ cancel := m.connCancel
+ app := m.app
+ m.app = nil
+ m.connCancel = nil
+ m.connected = false
+ m.activeDevice = Device{}
+ m.playback = nil
+ m.screencasting = false
+ m.mu.Unlock()
+
+ if cancel != nil {
+ cancel()
+ }
+ if app != nil {
+ if err := m.callApp(app, func(a castApp) error { return a.Close(false) }); err != nil {
+ log.Debugf("[Chromecast] Error closing connection: %v", err)
+ }
+ log.Info("[Chromecast] Disconnected")
+ }
+ m.broadcast()
+}
+
+// SetPreferred records a device as the auto-reconnect target and persists it.
+// An empty id clears the preference.
+func (m *Manager) SetPreferred(id string) {
+ m.mu.Lock()
+ m.preferredID = id
+ // Choosing a preferred device is an opt-in to auto-connect, so lift any
+ // suppression left by an earlier explicit disconnect.
+ m.suppressAutoReconnect = false
+ name := ""
+ if dev, ok := m.devices[id]; ok {
+ name = dev.Name
+ }
+ m.mu.Unlock()
+
+ if err := saveConfig(Config{PreferredID: id, PreferredName: name}); err != nil {
+ log.Warnf("[Chromecast] Failed to persist preferred device: %v", err)
+ }
+ m.broadcast()
+}
+
+// ClearPreferred removes the auto-reconnect preference.
+func (m *Manager) ClearPreferred() {
+ m.SetPreferred("")
+}
+
+// startupScanWindow bounds how long the boot-time reconnect scan runs.
+const startupScanWindow = 30 * time.Second
+
+// StartupReconnect runs a bounded discovery scan if a preferred device is set,
+// so the shell can reconnect to it without any UI being open. The scan stops
+// itself after startupScanWindow; the discovery loop auto-connects if the
+// preferred device shows up in the meantime.
+func (m *Manager) StartupReconnect() {
+ m.mu.RLock()
+ preferred := m.preferredID
+ m.mu.RUnlock()
+ if preferred == "" {
+ return
+ }
+
+ log.Infof("[Chromecast] Scanning for preferred device for up to %s", startupScanWindow)
+ if err := m.StartDiscovery(); err != nil {
+ log.Warnf("[Chromecast] Startup scan failed to start: %v", err)
+ return
+ }
+ time.AfterFunc(startupScanWindow, func() {
+ // Only stop if we never connected; an active connection means a UI or
+ // the auto-reconnect already took over.
+ m.mu.RLock()
+ connected := m.connected
+ m.mu.RUnlock()
+ if !connected {
+ m.StopDiscovery()
+ }
+ })
+}
+
+// currentApp returns the active connection or errNotConnected.
+func (m *Manager) currentApp() (castApp, error) {
+ m.mu.RLock()
+ app := m.app
+ m.mu.RUnlock()
+ if app == nil {
+ return nil, errNotConnected
+ }
+ return app, nil
+}
+
+// Cast loads a media URL (or local file path) on the connected device. An empty
+// contentType lets the library infer it from the extension.
+func (m *Manager) Cast(url, contentType string) error {
+ app, err := m.currentApp()
+ if err != nil {
+ return err
+ }
+ if err := m.callApp(app, func(a castApp) error { return a.Load(url, 0, contentType, false, false, false) }); err != nil {
+ return fmt.Errorf("load media: %w", err)
+ }
+ m.afterControl(app)
+ return nil
+}
+
+// CastScreen mirrors the local screen to the connected device by capturing it
+// to an HLS stream and casting that stream's URL. This is the buffered "laggy
+// mirror" path — expect multi-second latency, not real-time mirroring.
+func (m *Manager) CastScreen() error {
+ m.mu.RLock()
+ app := m.app
+ host := m.activeDevice.Host
+ m.mu.RUnlock()
+ if app == nil {
+ return errNotConnected
+ }
+
+ ip, err := outboundIPFunc(host)
+ if err != nil {
+ return fmt.Errorf("determine local address: %w", err)
+ }
+
+ // onExit fires if the capture helper dies on its own, so the UI doesn't get
+ // stuck showing "Mirroring" with a dead/zombie helper.
+ onExit := func() {
+ m.mu.Lock()
+ wasCasting := m.screencasting
+ m.screencasting = false
+ m.mu.Unlock()
+ if wasCasting {
+ log.Warn("[Cast] Screen capture helper exited unexpectedly")
+ m.broadcast()
+ }
+ }
+
+ url, err := m.screen.start(ip, onExit)
+ if err != nil {
+ return err
+ }
+
+ if err := m.callApp(app, func(a castApp) error { return a.Load(url, 0, hlsContentType, false, false, false) }); err != nil {
+ m.screen.stop()
+ return fmt.Errorf("cast screen stream: %w", err)
+ }
+
+ m.mu.Lock()
+ m.screencasting = true
+ m.mu.Unlock()
+
+ m.broadcast()
+ m.afterControl(app)
+ return nil
+}
+
+// StopScreen stops screen mirroring (the capture pipeline and HLS server). It
+// leaves the device connection intact.
+func (m *Manager) StopScreen() {
+ m.screen.stop()
+ m.mu.Lock()
+ wasCasting := m.screencasting
+ m.screencasting = false
+ app := m.app
+ m.mu.Unlock()
+
+ if app != nil {
+ _ = m.callApp(app, func(a castApp) error { return a.StopMedia() })
+ }
+ if wasCasting {
+ m.broadcast()
+ }
+}
+
+// control runs a transport action against the connected app and refreshes state.
+func (m *Manager) control(fn func(castApp) error) error {
+ app, err := m.currentApp()
+ if err != nil {
+ return err
+ }
+ if err := m.callApp(app, fn); err != nil {
+ return err
+ }
+ m.afterControl(app)
+ return nil
+}
+
+// afterControl refreshes status after a state-changing action so subscribers
+// see the result without waiting for the next poll tick.
+func (m *Manager) afterControl(app castApp) {
+ if err := m.callApp(app, func(a castApp) error { return a.Update() }); err != nil {
+ log.Debugf("[Chromecast] Post-action status update failed: %v", err)
+ }
+ m.refreshPlayback(app)
+}
+
+func (m *Manager) Play() error { return m.control(func(a castApp) error { return a.Unpause() }) }
+func (m *Manager) Pause() error { return m.control(func(a castApp) error { return a.Pause() }) }
+func (m *Manager) StopPlayback() error {
+ return m.control(func(a castApp) error { return a.StopMedia() })
+}
+func (m *Manager) Seek(seconds float64) error {
+ return m.control(func(a castApp) error { return a.SeekToTime(float32(seconds)) })
+}
+func (m *Manager) SetVolume(level float64) error {
+ return m.control(func(a castApp) error { return a.SetVolume(float32(level)) })
+}
+func (m *Manager) SetMuted(muted bool) error {
+ return m.control(func(a castApp) error { return a.SetMuted(muted) })
+}
+
+// pollLoop refreshes playback status on a timer until the context is cancelled.
+func (m *Manager) pollLoop(ctx context.Context, app castApp) {
+ ticker := time.NewTicker(pollInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if err := m.callApp(app, func(a castApp) error { return a.Update() }); err != nil {
+ log.Debugf("[Chromecast] Status update failed: %v", err)
+ continue
+ }
+ m.refreshPlayback(app)
+ }
+ }
+}
+
+// refreshPlayback reads the app's cached status and broadcasts if it changed.
+func (m *Manager) refreshPlayback(app castApp) {
+ pb := buildPlayback(m.statusOf(app))
+
+ m.mu.Lock()
+ // Ignore late refreshes from a connection we've already dropped.
+ if m.app != app {
+ m.mu.Unlock()
+ return
+ }
+ changed := !playbackEqual(m.playback, pb)
+ m.playback = pb
+ m.mu.Unlock()
+
+ if changed {
+ m.broadcast()
+ }
+}
+
+// buildPlayback maps the go-chromecast status into our Playback model, or nil
+// when nothing is loaded.
+func buildPlayback(app *cast.Application, media *cast.Media, vol *cast.Volume) *Playback {
+ if app == nil && media == nil {
+ return nil
+ }
+ pb := &Playback{}
+ if app != nil {
+ pb.AppName = app.DisplayName
+ if pb.AppName == "" {
+ pb.AppName = app.StatusText
+ }
+ }
+ if media != nil {
+ pb.State = media.PlayerState
+ pb.CurrentTime = float64(media.CurrentTime)
+ pb.Duration = float64(media.Media.Duration)
+ pb.Title = media.Media.Metadata.Title
+ pb.Subtitle = media.Media.Metadata.Subtitle
+ pb.Artist = media.Media.Metadata.Artist
+ }
+ if vol != nil {
+ pb.Volume = float64(vol.Level)
+ pb.Muted = vol.Muted
+ }
+ return pb
+}
+
+func playbackEqual(a, b *Playback) bool {
+ if a == nil || b == nil {
+ return a == b
+ }
+ return *a == *b
+}
+
+// GetState returns a snapshot of the current discovery state and device list.
+func (m *Manager) GetState() State {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.snapshot()
+}
+
+// snapshot builds a State value. Caller must hold at least a read lock.
+func (m *Manager) snapshot() State {
+ devices := make([]Device, 0, len(m.devices))
+ for _, d := range m.devices {
+ devices = append(devices, d)
+ }
+ // Stable ordering so the UI list doesn't reshuffle between updates.
+ sort.Slice(devices, func(i, j int) bool {
+ if devices[i].Name != devices[j].Name {
+ return devices[i].Name < devices[j].Name
+ }
+ return devices[i].ID < devices[j].ID
+ })
+
+ state := State{
+ Discovering: m.discovering,
+ Devices: devices,
+ Connected: m.connected,
+ Playback: m.playback,
+ Screencasting: m.screencasting,
+ PreferredID: m.preferredID,
+ }
+ if m.connected {
+ dev := m.activeDevice
+ state.ActiveDevice = &dev
+ }
+ return state
+}
+
+// Subscribe creates a buffered channel for the given client ID.
+func (m *Manager) Subscribe(clientID string) chan State {
+ ch := make(chan State, 64)
+ m.subscribers.Store(clientID, ch)
+ return ch
+}
+
+// Unsubscribe removes and closes the subscriber channel.
+func (m *Manager) Unsubscribe(clientID string) {
+ if val, ok := m.subscribers.LoadAndDelete(clientID); ok {
+ // Serialize with broadcast so a concurrent send can't hit a closed channel.
+ m.sendMu.Lock()
+ close(val)
+ m.sendMu.Unlock()
+ }
+}
+
+func (m *Manager) broadcast() {
+ if m.closed.Load() {
+ return
+ }
+ m.mu.RLock()
+ state := m.snapshot()
+ m.mu.RUnlock()
+
+ m.sendMu.Lock()
+ defer m.sendMu.Unlock()
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ select {
+ case ch <- state:
+ default:
+ }
+ return true
+ })
+}
+
+// Close stops discovery, drops any active connection, and closes all
+// subscriber channels.
+func (m *Manager) Close() {
+ m.closed.Store(true)
+ m.StopDiscovery()
+ m.Disconnect()
+
+ m.sendMu.Lock()
+ defer m.sendMu.Unlock()
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+}
diff --git a/core/internal/server/chromecast/manager_test.go b/core/internal/server/chromecast/manager_test.go
new file mode 100644
index 000000000..9fe538695
--- /dev/null
+++ b/core/internal/server/chromecast/manager_test.go
@@ -0,0 +1,756 @@
+package chromecast
+
+import (
+ "context"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/grandcat/zeroconf"
+ "github.com/vishen/go-chromecast/cast"
+ castdns "github.com/vishen/go-chromecast/dns"
+)
+
+// blockingCatCmd returns a `cat` command that genuinely blocks (its stdin is a
+// pipe whose write end stays open until test cleanup), so the subprocess stays
+// alive for the test instead of reading EOF from /dev/null and exiting at once.
+// Stand-in for the cast helper / doubletake; no sleeps, no timing.
+func blockingCatCmd(t *testing.T) *exec.Cmd {
+ t.Helper()
+ pr, pw, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("pipe: %v", err)
+ }
+ t.Cleanup(func() {
+ pw.Close()
+ pr.Close()
+ })
+ c := exec.Command("cat")
+ c.Stdin = pr
+ return c
+}
+
+// withDiscoverFunc swaps the package discovery function for the duration of a
+// test and restores it afterwards.
+func withDiscoverFunc(t *testing.T, fn func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error)) {
+ t.Helper()
+ prev := discoverFunc
+ discoverFunc = fn
+ t.Cleanup(func() { discoverFunc = prev })
+}
+
+// TestMain disables real AirPlay (zeroconf) discovery for all tests by default;
+// airplay-specific tests override airplayDiscoverFunc.
+func TestMain(m *testing.M) {
+ // Never touch the real system bus / mDNS in tests; drive discovery through
+ // the overridable fakes only.
+ avahiAvailableFunc = func() bool { return false }
+ airplayDiscoverFunc = func(ctx context.Context) (<-chan Device, error) {
+ ch := make(chan Device)
+ go func() { <-ctx.Done(); close(ch) }()
+ return ch, nil
+ }
+ discoverFunc = func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) {
+ ch := make(chan castdns.CastEntry)
+ go func() { <-ctx.Done(); close(ch) }()
+ return ch, nil
+ }
+ os.Exit(m.Run())
+}
+
+func withAirplayDiscoverFunc(t *testing.T, fn func(ctx context.Context) (<-chan Device, error)) {
+ t.Helper()
+ prev := airplayDiscoverFunc
+ airplayDiscoverFunc = fn
+ t.Cleanup(func() { airplayDiscoverFunc = prev })
+}
+
+// emitOnce returns a discovery func that emits the given devices then stays open
+// until ctx is cancelled.
+func emitOnceAirplay(devs ...Device) func(ctx context.Context) (<-chan Device, error) {
+ return func(ctx context.Context) (<-chan Device, error) {
+ out := make(chan Device, len(devs))
+ for _, d := range devs {
+ out <- d
+ }
+ go func() { <-ctx.Done(); close(out) }()
+ return out, nil
+ }
+}
+
+func emitOnceChromecast(entries ...castdns.CastEntry) func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) {
+ return func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) {
+ out := make(chan castdns.CastEntry, len(entries))
+ for _, e := range entries {
+ out <- e
+ }
+ go func() { <-ctx.Done(); close(out) }()
+ return out, nil
+ }
+}
+
+func TestDiscoversBothProtocols(t *testing.T) {
+ withDiscoverFunc(t, emitOnceChromecast(castdns.CastEntry{
+ UUID: "cc1", DeviceName: "Living Room", Device: "Chromecast", Port: 8009, AddrV4: net.IPv4(192, 168, 1, 5),
+ }))
+ withAirplayDiscoverFunc(t, emitOnceAirplay(Device{
+ ID: "ap1", Name: "TV de la sala", Model: "Hisense", Host: "192.168.1.6", Port: 7000, Protocol: ProtocolAirplay,
+ }))
+
+ m := NewManager()
+ defer m.Close()
+ sub := m.Subscribe("test")
+ if err := m.StartDiscovery(); err != nil {
+ t.Fatalf("StartDiscovery: %v", err)
+ }
+
+ got := waitForState(t, sub, func(s State) bool { return len(s.Devices) == 2 })
+
+ byProto := map[string]Device{}
+ for _, d := range got.Devices {
+ byProto[d.Protocol] = d
+ }
+ if byProto[ProtocolChromecast].ID != "cc1" {
+ t.Errorf("missing/incorrect chromecast device: %+v", byProto)
+ }
+ if byProto[ProtocolAirplay].ID != "ap1" || byProto[ProtocolAirplay].Port != 7000 {
+ t.Errorf("missing/incorrect airplay device: %+v", byProto)
+ }
+}
+
+func TestConnectAirplayStartsMirror(t *testing.T) {
+ prev := buildDoubletakeCmd
+ buildDoubletakeCmd = func(host string) *exec.Cmd { return blockingCatCmd(t) }
+ defer func() { buildDoubletakeCmd = prev }()
+
+ withDiscoverFunc(t, emitOnceChromecast())
+ withAirplayDiscoverFunc(t, emitOnceAirplay(Device{
+ ID: "ap1", Name: "TV de la sala", Host: "192.168.4.33", Port: 7000, Protocol: ProtocolAirplay,
+ }))
+
+ m := NewManager()
+ defer m.Close()
+ sub := m.Subscribe("test")
+ if err := m.StartDiscovery(); err != nil {
+ t.Fatalf("StartDiscovery: %v", err)
+ }
+ waitForState(t, sub, func(s State) bool { return len(s.Devices) == 1 })
+
+ if err := m.Connect("ap1"); err != nil {
+ t.Fatalf("Connect(airplay): %v", err)
+ }
+ got := waitForState(t, sub, func(s State) bool { return s.Connected && s.Screencasting })
+ if got.ActiveDevice == nil || got.ActiveDevice.Protocol != ProtocolAirplay {
+ t.Fatalf("expected active airplay device, got %+v", got.ActiveDevice)
+ }
+ if !m.airplay.isRunning() {
+ t.Fatal("expected airplay mirror running")
+ }
+
+ m.Disconnect()
+ got = waitForState(t, sub, func(s State) bool { return !s.Connected })
+ if m.airplay.isRunning() {
+ t.Fatal("expected airplay mirror stopped")
+ }
+ if got.Screencasting {
+ t.Fatal("expected screencasting cleared on disconnect")
+ }
+}
+
+func TestAirplayEntryNormalization(t *testing.T) {
+ dev := airplayEntryToDevice(&zeroconf.ServiceEntry{
+ ServiceRecord: zeroconf.ServiceRecord{Instance: "TV de la sala"},
+ HostName: "LinuxTV.local.",
+ Port: 7000,
+ AddrIPv4: []net.IP{net.IPv4(192, 168, 4, 33)},
+ Text: []string{"deviceid=AA:BB:CC:DD:EE:FF", "model=55A6QU"},
+ })
+ if dev.Protocol != ProtocolAirplay || dev.ID != "airplay:TV de la sala" || dev.Model != "55A6QU" || dev.Host != "192.168.4.33" {
+ t.Fatalf("unexpected normalization: %+v", dev)
+ }
+}
+
+// The same device must keep the same ID across browses even when the deviceid
+// TXT and/or the A record are dropped under mDNS port contention — the id is
+// derived from the always-present instance name, not the flaky TXT or host:port.
+func TestAirplayEntryStableIDWithoutTXT(t *testing.T) {
+ full := airplayEntryToDevice(&zeroconf.ServiceEntry{
+ ServiceRecord: zeroconf.ServiceRecord{Instance: "TV de la sala"},
+ HostName: "LinuxTV.local.",
+ Port: 7000,
+ AddrIPv4: []net.IP{net.IPv4(192, 168, 4, 33)},
+ Text: []string{"deviceid=AA:BB:CC:DD:EE:FF", "model=55A6QU"},
+ })
+ // A later browse that lost the TXT record and the resolved address.
+ partial := airplayEntryToDevice(&zeroconf.ServiceEntry{
+ ServiceRecord: zeroconf.ServiceRecord{Instance: "TV de la sala"},
+ HostName: "LinuxTV.local.",
+ Port: 7000,
+ })
+ if partial.ID != full.ID {
+ t.Fatalf("id flipped across browses: full=%q partial=%q", full.ID, partial.ID)
+ }
+}
+
+func TestUnescapeDNS(t *testing.T) {
+ cases := map[string]string{
+ `TV de la sala`: "TV de la sala",
+ `Geoffrey\226\128\153s\ MacBook`: "Geoffrey’s MacBook",
+ `Mac\ Pro\ \(2\)`: "Mac Pro (2)",
+ }
+ for in, want := range cases {
+ if got := unescapeDNS(in); got != want {
+ t.Errorf("unescapeDNS(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// withNewAppFunc swaps the cast-application constructor for the test duration.
+func withNewAppFunc(t *testing.T, fn func() castApp) {
+ t.Helper()
+ prev := newAppFunc
+ newAppFunc = fn
+ t.Cleanup(func() { newAppFunc = prev })
+}
+
+// fakeApp is a test double for the go-chromecast Application.
+type fakeApp struct {
+ mu sync.Mutex
+ startErr error
+ started bool
+ closed bool
+ app *cast.Application
+ media *cast.Media
+ vol *cast.Volume
+
+ loadedURL string
+ loadedType string
+ unpaused bool
+ paused bool
+ mediaStopped bool
+ seekedTo float32
+ volumeSet float32
+ muteSet bool
+}
+
+func (f *fakeApp) Start(addr string, port int) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.started = true
+ return f.startErr
+}
+
+func (f *fakeApp) Update() error { return nil }
+
+func (f *fakeApp) Status() (*cast.Application, *cast.Media, *cast.Volume) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.app, f.media, f.vol
+}
+
+func (f *fakeApp) Load(url string, startTime int, contentType string, transcode, detach, forceDetach bool) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.loadedURL = url
+ f.loadedType = contentType
+ return nil
+}
+
+func (f *fakeApp) Unpause() error { f.mu.Lock(); defer f.mu.Unlock(); f.unpaused = true; return nil }
+func (f *fakeApp) Pause() error { f.mu.Lock(); defer f.mu.Unlock(); f.paused = true; return nil }
+func (f *fakeApp) StopMedia() error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.mediaStopped = true
+ return nil
+}
+func (f *fakeApp) SeekToTime(v float32) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.seekedTo = v
+ return nil
+}
+func (f *fakeApp) SetVolume(v float32) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.volumeSet = v
+ return nil
+}
+func (f *fakeApp) SetMuted(v bool) error { f.mu.Lock(); defer f.mu.Unlock(); f.muteSet = v; return nil }
+
+func (f *fakeApp) Close(stopMedia bool) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.closed = true
+ return nil
+}
+
+// discoverOneDevice installs a discovery func that emits a single device and
+// then blocks until cancelled, and starts discovery on the manager.
+func discoverOneDevice(t *testing.T, m *Manager, entry castdns.CastEntry) {
+ t.Helper()
+ emit := make(chan castdns.CastEntry, 1)
+ emit <- entry
+ withDiscoverFunc(t, func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) {
+ out := make(chan castdns.CastEntry)
+ go func() {
+ defer close(out)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case e := <-emit:
+ out <- e
+ }
+ }
+ }()
+ return out, nil
+ })
+ sub := m.Subscribe("discover-helper")
+ defer m.Unsubscribe("discover-helper")
+ if err := m.StartDiscovery(); err != nil {
+ t.Fatalf("StartDiscovery: %v", err)
+ }
+ waitForState(t, sub, func(s State) bool { return len(s.Devices) == 1 })
+}
+
+// waitForState reads states from ch until pred is satisfied or it times out.
+func waitForState(t *testing.T, ch chan State, pred func(State) bool) State {
+ t.Helper()
+ timeout := time.After(2 * time.Second)
+ for {
+ select {
+ case s := <-ch:
+ if pred(s) {
+ return s
+ }
+ case <-timeout:
+ t.Fatal("timed out waiting for expected state")
+ }
+ }
+}
+
+func TestStartDiscoveryBroadcastsDevices(t *testing.T) {
+ entries := make(chan castdns.CastEntry, 2)
+ withDiscoverFunc(t, func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) {
+ out := make(chan castdns.CastEntry)
+ go func() {
+ defer close(out)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case e, ok := <-entries:
+ if !ok {
+ return
+ }
+ out <- e
+ }
+ }
+ }()
+ return out, nil
+ })
+
+ m := NewManager()
+ defer m.Close()
+
+ sub := m.Subscribe("test")
+
+ if err := m.StartDiscovery(); err != nil {
+ t.Fatalf("StartDiscovery: %v", err)
+ }
+
+ // First broadcast is the empty in-progress state.
+ waitForState(t, sub, func(s State) bool { return s.Discovering && len(s.Devices) == 0 })
+
+ entries <- castdns.CastEntry{UUID: "uuid-b", DeviceName: "Bedroom", Device: "Chromecast", Port: 8009}
+ entries <- castdns.CastEntry{UUID: "uuid-a", DeviceName: "Attic", Device: "Google Nest", Port: 8009}
+
+ got := waitForState(t, sub, func(s State) bool { return len(s.Devices) == 2 })
+
+ // snapshot must be sorted by Name for stable UI ordering.
+ if got.Devices[0].Name != "Attic" || got.Devices[1].Name != "Bedroom" {
+ t.Fatalf("devices not sorted by name: %+v", got.Devices)
+ }
+ if !got.Discovering {
+ t.Fatal("expected Discovering=true while scan is active")
+ }
+}
+
+func TestStartDiscoveryIsIdempotent(t *testing.T) {
+ withDiscoverFunc(t, func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) {
+ out := make(chan castdns.CastEntry)
+ go func() {
+ <-ctx.Done()
+ close(out)
+ }()
+ return out, nil
+ })
+
+ m := NewManager()
+ defer m.Close()
+
+ if err := m.StartDiscovery(); err != nil {
+ t.Fatalf("StartDiscovery: %v", err)
+ }
+ if err := m.StartDiscovery(); err != nil {
+ t.Fatalf("second StartDiscovery should be a no-op, got: %v", err)
+ }
+ if !m.GetState().Discovering {
+ t.Fatal("expected Discovering=true")
+ }
+}
+
+func TestStopDiscoveryEndsScan(t *testing.T) {
+ withDiscoverFunc(t, func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) {
+ out := make(chan castdns.CastEntry)
+ go func() {
+ <-ctx.Done()
+ close(out)
+ }()
+ return out, nil
+ })
+
+ m := NewManager()
+ defer m.Close()
+
+ sub := m.Subscribe("test")
+ if err := m.StartDiscovery(); err != nil {
+ t.Fatalf("StartDiscovery: %v", err)
+ }
+ waitForState(t, sub, func(s State) bool { return s.Discovering })
+
+ m.StopDiscovery()
+ waitForState(t, sub, func(s State) bool { return !s.Discovering })
+}
+
+// A device without a UUID must still be tracked (keyed by host:port) rather
+// than dropped or collapsed with other UUID-less devices.
+func TestDeviceWithoutUUIDIsKeyedByHostPort(t *testing.T) {
+ entries := make(chan castdns.CastEntry, 2)
+ withDiscoverFunc(t, func(ctx context.Context, iface *net.Interface) (<-chan castdns.CastEntry, error) {
+ out := make(chan castdns.CastEntry)
+ go func() {
+ defer close(out)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case e, ok := <-entries:
+ if !ok {
+ return
+ }
+ out <- e
+ }
+ }
+ }()
+ return out, nil
+ })
+
+ m := NewManager()
+ defer m.Close()
+ sub := m.Subscribe("test")
+ if err := m.StartDiscovery(); err != nil {
+ t.Fatalf("StartDiscovery: %v", err)
+ }
+
+ entries <- castdns.CastEntry{AddrV4: net.IPv4(192, 168, 1, 10), Port: 8009}
+ entries <- castdns.CastEntry{AddrV4: net.IPv4(192, 168, 1, 11), Port: 8009}
+
+ waitForState(t, sub, func(s State) bool { return len(s.Devices) == 2 })
+}
+
+func TestConnectBroadcastsPlayback(t *testing.T) {
+ fake := &fakeApp{
+ app: &cast.Application{DisplayName: "Default Media Receiver"},
+ media: &cast.Media{PlayerState: "PLAYING", CurrentTime: 12, Media: cast.MediaItem{Duration: 200, Metadata: cast.MediaMetadata{Title: "Song"}}},
+ vol: &cast.Volume{Level: 0.5, Muted: false},
+ }
+ withNewAppFunc(t, func() castApp { return fake })
+
+ m := NewManager()
+ defer m.Close()
+ discoverOneDevice(t, m, castdns.CastEntry{UUID: "dev1", DeviceName: "Living Room", Port: 8009, AddrV4: net.IPv4(192, 168, 1, 5)})
+
+ sub := m.Subscribe("test")
+ if err := m.Connect("dev1"); err != nil {
+ t.Fatalf("Connect: %v", err)
+ }
+
+ got := waitForState(t, sub, func(s State) bool {
+ return s.Connected && s.Playback != nil && s.Playback.State == "PLAYING"
+ })
+ if !fake.started {
+ t.Fatal("expected Start to be called on the app")
+ }
+ if got.ActiveDevice == nil || got.ActiveDevice.ID != "dev1" {
+ t.Fatalf("expected active device dev1, got %+v", got.ActiveDevice)
+ }
+ if got.Playback.Title != "Song" || got.Playback.Duration != 200 || got.Playback.Volume != 0.5 {
+ t.Fatalf("unexpected playback: %+v", got.Playback)
+ }
+}
+
+// connectFake wires a discovered device + fake app and connects to it,
+// returning the manager and fake for assertions.
+func connectFake(t *testing.T, fake *fakeApp) *Manager {
+ t.Helper()
+ withNewAppFunc(t, func() castApp { return fake })
+ m := NewManager()
+ t.Cleanup(m.Close)
+ discoverOneDevice(t, m, castdns.CastEntry{UUID: "dev1", DeviceName: "TV", Port: 8009, AddrV4: net.IPv4(192, 168, 1, 5)})
+ if err := m.Connect("dev1"); err != nil {
+ t.Fatalf("Connect: %v", err)
+ }
+ return m
+}
+
+func TestCastLoadsMedia(t *testing.T) {
+ fake := &fakeApp{app: &cast.Application{DisplayName: "Receiver"}}
+ m := connectFake(t, fake)
+
+ if err := m.Cast("http://example.com/v.mp4", "video/mp4"); err != nil {
+ t.Fatalf("Cast: %v", err)
+ }
+ if fake.loadedURL != "http://example.com/v.mp4" || fake.loadedType != "video/mp4" {
+ t.Fatalf("unexpected load: url=%q type=%q", fake.loadedURL, fake.loadedType)
+ }
+}
+
+func TestTransportControlsCallThrough(t *testing.T) {
+ fake := &fakeApp{app: &cast.Application{DisplayName: "Receiver"}}
+ m := connectFake(t, fake)
+
+ if err := m.Play(); err != nil || !fake.unpaused {
+ t.Fatalf("Play: err=%v unpaused=%v", err, fake.unpaused)
+ }
+ if err := m.Pause(); err != nil || !fake.paused {
+ t.Fatalf("Pause: err=%v paused=%v", err, fake.paused)
+ }
+ if err := m.Seek(42.5); err != nil || fake.seekedTo != 42.5 {
+ t.Fatalf("Seek: err=%v seekedTo=%v", err, fake.seekedTo)
+ }
+ if err := m.SetVolume(0.25); err != nil || fake.volumeSet != 0.25 {
+ t.Fatalf("SetVolume: err=%v volumeSet=%v", err, fake.volumeSet)
+ }
+ if err := m.SetMuted(true); err != nil || !fake.muteSet {
+ t.Fatalf("SetMuted: err=%v muteSet=%v", err, fake.muteSet)
+ }
+ if err := m.StopPlayback(); err != nil || !fake.mediaStopped {
+ t.Fatalf("StopPlayback: err=%v mediaStopped=%v", err, fake.mediaStopped)
+ }
+}
+
+func TestControlsErrorWhenNotConnected(t *testing.T) {
+ m := NewManager()
+ defer m.Close()
+ if err := m.Cast("http://x/y.mp4", ""); err != errNotConnected {
+ t.Fatalf("expected errNotConnected, got %v", err)
+ }
+ if err := m.Play(); err != errNotConnected {
+ t.Fatalf("expected errNotConnected from Play, got %v", err)
+ }
+}
+
+// withStubCapture stubs the capture helper with `cat` (blocks on stdin, no
+// timing), a fixed local IP, and a fake portal session so tests never touch the
+// network, GStreamer, or the real screen-share dialog.
+func withStubCapture(t *testing.T) {
+ t.Helper()
+ prevCmd := buildHelperCmd
+ prevIP := outboundIPFunc
+ prevPortal := requestScreencast
+ buildHelperCmd = func(outDir string, nodeID uint32) *exec.Cmd { return blockingCatCmd(t) }
+ outboundIPFunc = func(host string) (string, error) { return "127.0.0.1", nil }
+ requestScreencast = func(ctx context.Context) (*PortalSession, error) {
+ r, w, err := os.Pipe()
+ if err != nil {
+ return nil, err
+ }
+ w.Close()
+ return &PortalSession{Fd: r, NodeID: 7}, nil
+ }
+ t.Cleanup(func() {
+ buildHelperCmd = prevCmd
+ outboundIPFunc = prevIP
+ requestScreencast = prevPortal
+ })
+}
+
+func TestCastScreenStartsCaptureAndCasts(t *testing.T) {
+ withStubCapture(t)
+ fake := &fakeApp{app: &cast.Application{DisplayName: "Receiver"}}
+ m := connectFake(t, fake)
+
+ sub := m.Subscribe("test")
+ if err := m.CastScreen(); err != nil {
+ t.Fatalf("CastScreen: %v", err)
+ }
+
+ if !strings.HasSuffix(fake.loadedURL, "/"+hlsPlaylist) {
+ t.Fatalf("expected HLS playlist URL, got %q", fake.loadedURL)
+ }
+ if fake.loadedType != hlsContentType {
+ t.Fatalf("expected HLS content type, got %q", fake.loadedType)
+ }
+ waitForState(t, sub, func(s State) bool { return s.Screencasting })
+ if !m.screen.isRunning() {
+ t.Fatal("expected screen streamer to be running")
+ }
+
+ m.StopScreen()
+ waitForState(t, sub, func(s State) bool { return !s.Screencasting })
+ if m.screen.isRunning() {
+ t.Fatal("expected screen streamer to be stopped")
+ }
+}
+
+func TestCastScreenRequiresConnection(t *testing.T) {
+ withStubCapture(t)
+ m := NewManager()
+ defer m.Close()
+ if err := m.CastScreen(); err != errNotConnected {
+ t.Fatalf("expected errNotConnected, got %v", err)
+ }
+}
+
+func TestScreenStreamerServesHLSDir(t *testing.T) {
+ withStubCapture(t)
+
+ s := &screenStreamer{}
+ url, err := s.start("127.0.0.1", nil)
+ if err != nil {
+ t.Fatalf("start: %v", err)
+ }
+ defer s.stop()
+
+ // Drop a file into the served directory and fetch it back.
+ if err := os.WriteFile(filepath.Join(s.dir, hlsPlaylist), []byte("#EXTM3U\n"), 0o644); err != nil {
+ t.Fatalf("write playlist: %v", err)
+ }
+ resp, err := http.Get(url)
+ if err != nil {
+ t.Fatalf("GET %s: %v", url, err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "#EXTM3U") {
+ t.Fatalf("unexpected response: status=%d body=%q", resp.StatusCode, string(body))
+ }
+}
+
+// withTempConfig points config persistence at a temp file for the test.
+func withTempConfig(t *testing.T) {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "castsettings.json")
+ prev := configPathFunc
+ configPathFunc = func() (string, error) { return path, nil }
+ t.Cleanup(func() { configPathFunc = prev })
+}
+
+func TestSetPreferredPersistsAndReloads(t *testing.T) {
+ withTempConfig(t)
+
+ m := NewManager()
+ m.SetPreferred("dev-42")
+ if m.GetState().PreferredID != "dev-42" {
+ t.Fatalf("expected preferred dev-42, got %q", m.GetState().PreferredID)
+ }
+
+ // A fresh manager must load the persisted preference.
+ m2 := NewManager()
+ if m2.GetState().PreferredID != "dev-42" {
+ t.Fatalf("preference not reloaded, got %q", m2.GetState().PreferredID)
+ }
+
+ m2.ClearPreferred()
+ m3 := NewManager()
+ if m3.GetState().PreferredID != "" {
+ t.Fatalf("expected cleared preference, got %q", m3.GetState().PreferredID)
+ }
+}
+
+func TestAutoReconnectsToPreferredDevice(t *testing.T) {
+ withTempConfig(t)
+ fake := &fakeApp{app: &cast.Application{DisplayName: "Receiver"}}
+ withNewAppFunc(t, func() castApp { return fake })
+
+ m := NewManager()
+ defer m.Close()
+ m.SetPreferred("dev1")
+
+ sub := m.Subscribe("test")
+ // Discover the preferred device; the manager should auto-connect.
+ discoverOneDevice(t, m, castdns.CastEntry{UUID: "dev1", DeviceName: "Living Room", Port: 8009, AddrV4: net.IPv4(192, 168, 1, 5)})
+
+ got := waitForState(t, sub, func(s State) bool { return s.Connected })
+ if got.ActiveDevice == nil || got.ActiveDevice.ID != "dev1" {
+ t.Fatalf("expected auto-connect to dev1, got %+v", got.ActiveDevice)
+ }
+ if !fake.started {
+ t.Fatal("expected Start to be called during auto-reconnect")
+ }
+}
+
+func TestAutoConnectsToPreferredAirplayDevice(t *testing.T) {
+ withTempConfig(t)
+ prev := buildDoubletakeCmd
+ buildDoubletakeCmd = func(host string) *exec.Cmd { return blockingCatCmd(t) }
+ defer func() { buildDoubletakeCmd = prev }()
+
+ m := NewManager()
+ defer m.Close()
+ m.SetPreferred("airplay:Living Room TV")
+
+ sub := m.Subscribe("test")
+ // The preferred AirPlay device appears: auto-connect must start the mirror.
+ m.upsertDevice(Device{ID: "airplay:Living Room TV", Name: "Living Room TV", Host: "192.168.4.40", Port: 7000, Protocol: ProtocolAirplay})
+
+ got := waitForState(t, sub, func(s State) bool { return s.Connected && s.Screencasting })
+ if got.ActiveDevice == nil || got.ActiveDevice.ID != "airplay:Living Room TV" {
+ t.Fatalf("expected auto-connect to preferred airplay device, got %+v", got.ActiveDevice)
+ }
+ if !m.airplay.isRunning() {
+ t.Fatal("expected airplay mirror running after auto-connect")
+ }
+}
+
+func TestConnectUnknownDeviceErrors(t *testing.T) {
+ withNewAppFunc(t, func() castApp { return &fakeApp{} })
+ m := NewManager()
+ defer m.Close()
+ if err := m.Connect("nope"); err == nil {
+ t.Fatal("expected error connecting to unknown device")
+ }
+}
+
+func TestDisconnectClearsStateAndClosesApp(t *testing.T) {
+ fake := &fakeApp{app: &cast.Application{DisplayName: "Receiver"}}
+ withNewAppFunc(t, func() castApp { return fake })
+
+ m := NewManager()
+ defer m.Close()
+ discoverOneDevice(t, m, castdns.CastEntry{UUID: "dev1", DeviceName: "Kitchen", Port: 8009, AddrV4: net.IPv4(192, 168, 1, 6)})
+
+ sub := m.Subscribe("test")
+ if err := m.Connect("dev1"); err != nil {
+ t.Fatalf("Connect: %v", err)
+ }
+ waitForState(t, sub, func(s State) bool { return s.Connected })
+
+ m.Disconnect()
+ got := waitForState(t, sub, func(s State) bool { return !s.Connected })
+ if got.ActiveDevice != nil || got.Playback != nil {
+ t.Fatalf("expected cleared state, got %+v", got)
+ }
+ if !fake.closed {
+ t.Fatal("expected Close to be called on the app")
+ }
+}
diff --git a/core/internal/server/chromecast/screencast.go b/core/internal/server/chromecast/screencast.go
new file mode 100644
index 000000000..fed541d7a
--- /dev/null
+++ b/core/internal/server/chromecast/screencast.go
@@ -0,0 +1,239 @@
+package chromecast
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+// hlsPlaylist is the HLS playlist filename served to the Cast device.
+const hlsPlaylist = "stream.m3u8"
+
+// hlsContentType is the MIME type for an HLS playlist.
+const hlsContentType = "application/x-mpegURL"
+
+// helperPath resolves the dms-cast-helper binary (the go-gst capture program).
+// Order: DMS_CAST_HELPER env, then next to the dms executable, then PATH.
+func helperPath() string {
+ if p := os.Getenv("DMS_CAST_HELPER"); p != "" {
+ return p
+ }
+ if exe, err := os.Executable(); err == nil {
+ cand := filepath.Join(filepath.Dir(exe), "dms-cast-helper")
+ if _, err := os.Stat(cand); err == nil {
+ return cand
+ }
+ }
+ return "dms-cast-helper"
+}
+
+// buildHelperCmd builds the go-gst capture helper invocation for HLS output.
+// A package var so tests can stub it.
+var buildHelperCmd = func(outDir string, nodeID uint32) *exec.Cmd {
+ return exec.Command(helperPath(),
+ "-mode", "hls",
+ "-out", outDir,
+ "-node", fmt.Sprintf("%d", nodeID),
+ )
+}
+
+// outboundIPFunc resolves the local interface address that targetHost would
+// connect back to. Overridable in tests.
+var outboundIPFunc = outboundIP
+
+func outboundIP(targetHost string) (string, error) {
+ // A UDP "connection" sends no packets; it just selects the route/interface
+ // the kernel would use to reach targetHost, exposing the local address the
+ // Cast device can reach us on.
+ conn, err := net.Dial("udp", net.JoinHostPort(targetHost, "8009"))
+ if err != nil {
+ return "", err
+ }
+ defer conn.Close()
+ return conn.LocalAddr().(*net.UDPAddr).IP.String(), nil
+}
+
+// screenStreamer captures the screen to HLS and serves it over HTTP for a Cast
+// device to pull. This is the buffered-player "laggy mirror" path: the Cast
+// media receiver pre-buffers segments, so expect multi-second latency. It is
+// not real-time mirroring (that needs Google's closed protocol).
+// portalTimeout bounds how long we wait for the user to approve the screen-share
+// dialog before giving up.
+const portalTimeout = 90 * time.Second
+
+type screenStreamer struct {
+ mu sync.Mutex
+ dir string
+ cmd *exec.Cmd
+ server *http.Server
+ portal *PortalSession
+ running bool
+}
+
+// noListFS wraps an http.FileSystem to disable directory listings, so the HLS
+// segment filenames can't be enumerated by an unauthenticated client.
+type noListFS struct{ fs http.FileSystem }
+
+func (n noListFS) Open(name string) (http.File, error) {
+ f, err := n.fs.Open(name)
+ if err != nil {
+ return nil, err
+ }
+ return noListFile{f}, nil
+}
+
+type noListFile struct{ http.File }
+
+func (noListFile) Readdir(int) ([]os.FileInfo, error) { return nil, nil }
+
+// randToken returns an unguessable URL path segment for the HLS server, so a
+// LAN host can't reach the screen capture just by scanning the port.
+func randToken() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b), nil
+}
+
+// start negotiates the screen-share portal, runs the go-gst capture helper to
+// produce HLS, serves it over HTTP, and returns the playlist URL reachable from
+// reachableIP. The HTTP server is bound to reachableIP only, serves under an
+// unguessable path token with directory listing off, and onExit fires if the
+// capture helper exits on its own. The portal step pops the screen-share dialog.
+func (s *screenStreamer) start(reachableIP string, onExit func()) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.running {
+ return "", fmt.Errorf("screencast already running")
+ }
+
+ token, err := randToken()
+ if err != nil {
+ return "", err
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), portalTimeout)
+ defer cancel()
+ portal, err := requestScreencast(ctx)
+ if err != nil {
+ return "", fmt.Errorf("screencast portal: %w", err)
+ }
+
+ dir, err := os.MkdirTemp("", "dms-cast-screen-")
+ if err != nil {
+ portal.Close()
+ return "", err
+ }
+
+ // Bind to the single LAN-facing interface the cast device reaches us on,
+ // not 0.0.0.0, so the capture isn't offered on every interface.
+ listener, err := net.Listen("tcp", net.JoinHostPort(reachableIP, "0"))
+ if err != nil {
+ os.RemoveAll(dir)
+ portal.Close()
+ return "", err
+ }
+ port := listener.Addr().(*net.TCPAddr).Port
+
+ // Serve under a random path token (so a port scan can't guess the URL) with
+ // directory listing disabled. Still unauthenticated, but unguessable.
+ prefix := "/" + token + "/"
+ mux := http.NewServeMux()
+ mux.Handle(prefix, http.StripPrefix(prefix, http.FileServer(noListFS{http.Dir(dir)})))
+ srv := &http.Server{Handler: mux}
+ go func() {
+ if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
+ log.Warnf("[Cast] HLS server error: %v", err)
+ }
+ }()
+
+ cmd := buildHelperCmd(dir, portal.NodeID)
+ cmd.ExtraFiles = []*os.File{portal.Fd} // PipeWire remote fd -> child fd 3
+ cmd.Stderr = os.Stderr
+ if err := cmd.Start(); err != nil {
+ srv.Close()
+ os.RemoveAll(dir)
+ portal.Close()
+ return "", fmt.Errorf("start capture helper: %w", err)
+ }
+
+ s.dir = dir
+ s.cmd = cmd
+ s.server = srv
+ s.portal = portal
+ s.running = true
+
+ // Supervise the helper: if it exits on its own (crash, portal revoked),
+ // tear down and notify so the manager clears screencasting instead of being
+ // stuck on a dead/zombie helper. Mirrors airplayMirror's exit watcher.
+ go func() {
+ _ = cmd.Wait()
+ s.mu.Lock()
+ unexpected := s.running && s.cmd == cmd
+ if unexpected {
+ s.teardownLocked() // process already reaped by Wait above
+ }
+ s.mu.Unlock()
+ if unexpected && onExit != nil {
+ onExit()
+ }
+ }()
+
+ url := fmt.Sprintf("http://%s:%d%s%s", reachableIP, port, prefix, hlsPlaylist)
+ log.Infof("[Cast] Screencast HLS at %s (dir %s)", url, dir)
+ return url, nil
+}
+
+// stop kills the capture helper and tears down the HTTP server, temp directory,
+// and portal.
+func (s *screenStreamer) stop() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if !s.running {
+ return
+ }
+ // Only Kill here; the supervisor goroutine started in start() is the sole
+ // caller of cmd.Wait() and will reap the process (calling Wait from two
+ // goroutines races). It sees running=false below and skips its own teardown.
+ if s.cmd != nil && s.cmd.Process != nil {
+ _ = s.cmd.Process.Kill()
+ }
+ s.teardownLocked()
+}
+
+// teardownLocked releases the HTTP server, temp directory, and portal and
+// resets state. The caller holds s.mu and must already have reaped the helper.
+func (s *screenStreamer) teardownLocked() {
+ if s.server != nil {
+ _ = s.server.Close()
+ }
+ if s.portal != nil {
+ s.portal.Close()
+ }
+ if s.dir != "" {
+ _ = os.RemoveAll(s.dir)
+ }
+ s.cmd = nil
+ s.server = nil
+ s.portal = nil
+ s.dir = ""
+ s.running = false
+ log.Info("[Cast] Screencast stopped")
+}
+
+func (s *screenStreamer) isRunning() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.running
+}
diff --git a/core/internal/server/chromecast/screencast_portal.go b/core/internal/server/chromecast/screencast_portal.go
new file mode 100644
index 000000000..33cbfce45
--- /dev/null
+++ b/core/internal/server/chromecast/screencast_portal.go
@@ -0,0 +1,187 @@
+package chromecast
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "sync/atomic"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
+ "github.com/godbus/dbus/v5"
+)
+
+const (
+ portalBus = "org.freedesktop.portal.Desktop"
+ portalObjPath = "/org/freedesktop/portal/desktop"
+ scIface = "org.freedesktop.portal.ScreenCast"
+ reqIface = "org.freedesktop.portal.Request"
+)
+
+var portalTokenSeq atomic.Uint64
+
+// PortalSession is an open xdg-desktop-portal ScreenCast session plus the
+// PipeWire remote it produced.
+type PortalSession struct {
+ conn *dbus.Conn
+ session dbus.ObjectPath
+ NodeID uint32
+ Fd *os.File
+}
+
+// Close releases the PipeWire fd, closes the portal session, and the bus conn.
+func (p *PortalSession) Close() {
+ if p == nil {
+ return
+ }
+ if p.Fd != nil {
+ p.Fd.Close()
+ }
+ if p.conn != nil {
+ if p.session != "" {
+ p.conn.Object(portalBus, p.session).Call("org.freedesktop.portal.Session.Close", 0)
+ }
+ p.conn.Close()
+ }
+}
+
+// requestScreencast negotiates a ScreenCast session via xdg-desktop-portal and
+// returns the granted PipeWire node id + remote fd. It pops the system
+// screen-share dialog. Pure Go (godbus) — no cgo, no CLI.
+//
+// Overridable in tests.
+var requestScreencast = func(ctx context.Context) (*PortalSession, error) {
+ conn, err := dbus.ConnectSessionBus()
+ if err != nil {
+ return nil, fmt.Errorf("session bus: %w", err)
+ }
+ ps := &PortalSession{conn: conn}
+
+ // SENDER token for request/session object paths: unique name minus the
+ // leading ':' with '.' -> '_'.
+ sender := strings.ReplaceAll(strings.TrimPrefix(conn.Names()[0], ":"), ".", "_")
+ portal := conn.Object(portalBus, portalObjPath)
+
+ // 1) CreateSession
+ sessTok := nextToken("ses")
+ res, err := portalRequest(ctx, conn, sender, portal, scIface+".CreateSession",
+ map[string]dbus.Variant{
+ "handle_token": dbus.MakeVariant(nextToken("req")),
+ "session_handle_token": dbus.MakeVariant(sessTok),
+ })
+ if err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("CreateSession: %w", err)
+ }
+ sh, ok := dbusutil.Get[string](res, "session_handle")
+ if !ok {
+ conn.Close()
+ return nil, fmt.Errorf("CreateSession: no session_handle")
+ }
+ ps.session = dbus.ObjectPath(sh)
+
+ // 2) SelectSources: monitor, single, embedded cursor.
+ if _, err := portalRequest(ctx, conn, sender, portal, scIface+".SelectSources",
+ ps.session,
+ map[string]dbus.Variant{
+ "handle_token": dbus.MakeVariant(nextToken("req")),
+ "types": dbus.MakeVariant(uint32(1)), // 1 = MONITOR
+ "multiple": dbus.MakeVariant(false),
+ "cursor_mode": dbus.MakeVariant(uint32(2)), // 2 = EMBEDDED
+ }); err != nil {
+ ps.Close()
+ return nil, fmt.Errorf("SelectSources: %w", err)
+ }
+
+ // 3) Start (pops the dialog).
+ startRes, err := portalRequest(ctx, conn, sender, portal, scIface+".Start",
+ ps.session, "",
+ map[string]dbus.Variant{"handle_token": dbus.MakeVariant(nextToken("req"))})
+ if err != nil {
+ ps.Close()
+ return nil, fmt.Errorf("Start: %w", err)
+ }
+ nodeID, err := firstStreamNode(startRes)
+ if err != nil {
+ ps.Close()
+ return nil, err
+ }
+ ps.NodeID = nodeID
+
+ // 4) OpenPipeWireRemote — synchronous, returns the fd directly (not a Request).
+ var fd dbus.UnixFD
+ if err := portal.CallWithContext(ctx, scIface+".OpenPipeWireRemote", 0,
+ ps.session, map[string]dbus.Variant{}).Store(&fd); err != nil {
+ ps.Close()
+ return nil, fmt.Errorf("OpenPipeWireRemote: %w", err)
+ }
+ ps.Fd = os.NewFile(uintptr(fd), "pipewire-remote")
+ return ps, nil
+}
+
+func nextToken(prefix string) string {
+ return fmt.Sprintf("dms_%s_%d", prefix, portalTokenSeq.Add(1))
+}
+
+// portalRequest invokes a portal method whose last argument is the options map
+// (carrying handle_token) and which returns a Request object path, then waits
+// for that Request's Response signal and returns its results.
+func portalRequest(ctx context.Context, conn *dbus.Conn, sender string, portal dbus.BusObject, method string, args ...any) (map[string]dbus.Variant, error) {
+ // The handle_token is in the options map (last arg); derive the request path.
+ opts, _ := args[len(args)-1].(map[string]dbus.Variant)
+ token := opts["handle_token"].Value().(string)
+ reqPath := dbus.ObjectPath(fmt.Sprintf("%s/request/%s/%s", portalObjPath, sender, token))
+
+ // Subscribe BEFORE calling to avoid missing a fast response.
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchObjectPath(reqPath),
+ dbus.WithMatchInterface(reqIface),
+ dbus.WithMatchMember("Response"),
+ ); err != nil {
+ return nil, err
+ }
+ defer conn.RemoveMatchSignal(
+ dbus.WithMatchObjectPath(reqPath),
+ dbus.WithMatchInterface(reqIface),
+ dbus.WithMatchMember("Response"),
+ )
+ sigCh := make(chan *dbus.Signal, 4)
+ conn.Signal(sigCh)
+ defer conn.RemoveSignal(sigCh)
+
+ var returned dbus.ObjectPath
+ if err := portal.CallWithContext(ctx, method, 0, args...).Store(&returned); err != nil {
+ return nil, err
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case sig := <-sigCh:
+ if sig.Path != reqPath || len(sig.Body) < 2 {
+ continue
+ }
+ code, _ := sig.Body[0].(uint32)
+ results, _ := sig.Body[1].(map[string]dbus.Variant)
+ if code != 0 {
+ return nil, fmt.Errorf("portal request rejected (response=%d)", code)
+ }
+ return results, nil
+ }
+ }
+}
+
+// firstStreamNode extracts the PipeWire node id from a Start response. The
+// "streams" result is a()(ua{sv}): array of (node_id uint32, props).
+func firstStreamNode(res map[string]dbus.Variant) (uint32, error) {
+ streams, ok := dbusutil.Get[[][]any](res, "streams")
+ if !ok || len(streams) == 0 {
+ return 0, fmt.Errorf("Start: empty/invalid streams")
+ }
+ nodeID, ok := streams[0][0].(uint32)
+ if !ok {
+ return 0, fmt.Errorf("Start: stream node id not a uint32")
+ }
+ return nodeID, nil
+}
diff --git a/core/internal/server/chromecast/types.go b/core/internal/server/chromecast/types.go
new file mode 100644
index 000000000..3b2ed698a
--- /dev/null
+++ b/core/internal/server/chromecast/types.go
@@ -0,0 +1,42 @@
+package chromecast
+
+// Protocol identifies how a device is reached.
+const (
+ ProtocolChromecast = "chromecast" // Google Cast (_googlecast._tcp)
+ ProtocolAirplay = "airplay" // AirPlay 2 (_airplay._tcp)
+)
+
+// Device is a Cast-compatible device discovered on the LAN (Chromecast or
+// AirPlay).
+type Device struct {
+ ID string `json:"id"` // stable identifier: device UUID/id, or host:port when none is advertised
+ Name string `json:"name"` // friendly name
+ Model string `json:"model"` // device model, e.g. "Chromecast Ultra" or "Hisense ..."
+ Host string `json:"host"` // IPv4 address used to connect
+ Port int `json:"port"`
+ Protocol string `json:"protocol"` // ProtocolChromecast | ProtocolAirplay
+}
+
+// Playback describes what the connected device is currently playing.
+type Playback struct {
+ State string `json:"state"` // PLAYING, PAUSED, BUFFERING, IDLE
+ Title string `json:"title"`
+ Subtitle string `json:"subtitle"`
+ Artist string `json:"artist"`
+ AppName string `json:"appName"` // receiver app, e.g. "Default Media Receiver"
+ CurrentTime float64 `json:"currentTime"`
+ Duration float64 `json:"duration"`
+ Volume float64 `json:"volume"` // 0.0–1.0
+ Muted bool `json:"muted"`
+}
+
+// State is the full chromecast service state pushed to subscribers.
+type State struct {
+ Discovering bool `json:"discovering"`
+ Devices []Device `json:"devices"`
+ Connected bool `json:"connected"`
+ ActiveDevice *Device `json:"activeDevice,omitempty"`
+ Playback *Playback `json:"playback,omitempty"`
+ Screencasting bool `json:"screencasting"`
+ PreferredID string `json:"preferredId"`
+}
diff --git a/core/internal/server/router.go b/core/internal/server/router.go
index df7fee2ef..6dbcdc878 100644
--- a/core/internal/server/router.go
+++ b/core/internal/server/router.go
@@ -8,6 +8,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/chromecast"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
@@ -124,6 +125,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return
}
+ if strings.HasPrefix(req.Method, "chromecast.") {
+ if chromecastManager == nil {
+ models.RespondError(conn, req.ID, "chromecast manager not initialized")
+ return
+ }
+ chromecast.HandleRequest(conn, req, chromecastManager)
+ return
+ }
+
if strings.HasPrefix(req.Method, "brightness.") {
if brightnessManager == nil {
models.RespondError(conn, req.ID, "brightness manager not initialized")
diff --git a/core/internal/server/server.go b/core/internal/server/server.go
index c239f24be..f95ea9793 100644
--- a/core/internal/server/server.go
+++ b/core/internal/server/server.go
@@ -20,6 +20,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/chromecast"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
@@ -66,6 +67,7 @@ var bluezManager *bluez.Manager
var appPickerManager *apppicker.Manager
var cupsManager *cups.Manager
var tailscaleManager *tailscale.Manager
+var chromecastManager *chromecast.Manager
var brightnessManager *brightness.Manager
var wlrOutputManager *wlroutput.Manager
var evdevManager *evdev.Manager
@@ -385,6 +387,12 @@ func InitializeSysUpdateManager() error {
return nil
}
+func InitializeChromecastManager() error {
+ chromecastManager = chromecast.NewManager()
+ log.Info("Chromecast manager initialized")
+ return nil
+}
+
func handleConnection(conn net.Conn) {
defer conn.Close()
@@ -443,6 +451,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "tailscale")
}
+ if chromecastManager != nil {
+ caps = append(caps, "chromecast")
+ }
+
if brightnessManager != nil {
caps = append(caps, "brightness")
}
@@ -509,6 +521,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "tailscale")
}
+ if chromecastManager != nil {
+ caps = append(caps, "chromecast")
+ }
+
if brightnessManager != nil {
caps = append(caps, "brightness")
}
@@ -1013,6 +1029,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}()
}
+ if shouldSubscribe("chromecast") && chromecastManager != nil {
+ wg.Add(1)
+ chromecastChan := chromecastManager.Subscribe(clientID + "-chromecast")
+ go func() {
+ defer wg.Done()
+ defer chromecastManager.Unsubscribe(clientID + "-chromecast")
+
+ initialState := chromecastManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "chromecast", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-chromecastChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "chromecast", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
if shouldSubscribe("brightness") && brightnessManager != nil {
wg.Add(2)
brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state")
@@ -1304,6 +1352,9 @@ func cleanupManagers() {
if tailscaleManager != nil {
tailscaleManager.Close()
}
+ if chromecastManager != nil {
+ chromecastManager.Close()
+ }
}
func Start(printDocs bool) error {
@@ -1616,6 +1667,13 @@ func Start(printDocs bool) error {
}
}()
+ if err := InitializeChromecastManager(); err != nil {
+ log.Warnf("Chromecast manager unavailable: %v", err)
+ } else {
+ notifyCapabilityChange()
+ chromecastManager.StartupReconnect()
+ }
+
if err := InitializeAppPickerManager(); err != nil {
log.Debugf("AppPicker manager unavailable: %v", err)
}
diff --git a/docs/casting.md b/docs/casting.md
new file mode 100644
index 000000000..47ba4198d
--- /dev/null
+++ b/docs/casting.md
@@ -0,0 +1,87 @@
+# Casting (Chromecast + AirPlay)
+
+The cast feature discovers both **Chromecast** (`_googlecast._tcp`) and **AirPlay 2**
+(`_airplay._tcp`) devices and shows them in one Control Center widget.
+
+- **Chromecast**: media casting + screen mirroring (HLS) handled in-process by the
+ `dms` core (Go Cast protocol).
+- **AirPlay 2**: screen mirroring via **doubletake** (a separate process).
+
+Screen capture/encode is done by **`dms-cast-helper`**, a small program that uses
+the GStreamer **library** (go-gst) — not the `gst-launch`/`ffmpeg`/`wf-recorder`
+CLIs. The capture pipeline imports the portal's DMA-BUF via VA-API, forces 4:2:0
+(`I420`) chroma (4:4:4 is rejected by most TV decoders → black screen), and
+re-stamps timestamps (the portal delivers `pts=0`).
+
+## Build
+
+The main `dms` binary stays CGO-free. The cast helper is opt-in and needs CGO +
+GStreamer development packages:
+
+```sh
+make build # dms (no extra deps)
+make cast-helper # dms-cast-helper (needs gstreamer-1.0 + gst-plugins-base dev, pkg-config, glib dev)
+sudo make install install-cast-helper
+```
+
+Runtime needs GStreamer plugins (base/good/bad/ugly + pipewire) and, for hardware
+DMA-BUF import on Wayland, a VA-API driver.
+
+## AirPlay: external doubletake dependency
+
+AirPlay mirroring shells out to **doubletake** (GPLv3) — a separate binary, never
+linked into the MIT `dms`. We use the fork
+[`domenkozar/doubletake` (`go-gst-capture`)](https://github.com/domenkozar/doubletake/tree/go-gst-capture),
+which drives `gst-launch-1.0` for capture and fixes the black-screen + pts-timing
+issues there (DMA-BUF import + 4:2:0 chroma; pending upstream in omarroth/doubletake).
+
+doubletake builds **CGO-free** — no GStreamer dev packages needed; it only needs the
+`gst-launch-1.0` CLI and plugins at runtime. Build it and put `bin/doubletake` on
+`PATH`, or point `DMS_DOUBLETAKE` at it:
+
+```sh
+git clone -b go-gst-capture https://github.com/domenkozar/doubletake
+cd doubletake && make # builds bin/doubletake (CGO-free)
+```
+
+Without doubletake, AirPlay devices still appear in discovery but connecting reports
+that doubletake is required.
+
+## Discovery
+
+Both protocols are found over mDNS. When **avahi-daemon** is running, `dms`
+browses through it (Avahi D-Bus API) and does **not** open its own port-5353
+socket — this avoids contending with avahi for the port. Where avahi is absent,
+`dms` falls back to a built-in browser (go-chromecast + zeroconf) that
+**re-browses periodically** so a resolver that missed responses recovers on the
+next cycle.
+
+AirPlay devices are keyed by their mDNS **instance name** (stable and always
+present), not the `deviceid` TXT record — the TXT is frequently dropped under
+port contention, which would otherwise make the same device flip identity
+between scans and break the favorite/auto-reconnect match.
+
+> **Note:** multiple processes sharing UDP port 5353 degrades mDNS for everyone.
+> Google Chrome in particular keeps a `224.0.0.251:5353` socket open for its own
+> Cast discovery and can intermittently swallow resolve responses (so does any
+> second mDNS stack). If discovery shows a device but never resolves its
+> address, that contention is the usual cause. Preferring avahi and retrying
+> resolves mitigates it, but a misbehaving co-resident mDNS listener can still
+> cause flakiness.
+
+## Firewall
+
+AirPlay receivers connect *back* to the sender on negotiated ports. `dms` confines
+them to a fixed range (default `60000-60010`, override `DMS_CAST_PORT_RANGE`); open
+that range inbound (UDP+TCP). On NixOS:
+
+```nix
+networking.firewall.allowedUDPPortRanges = [ { from = 60000; to = 60010; } ];
+networking.firewall.allowedTCPPortRanges = [ { from = 60000; to = 60010; } ];
+```
+
+## Environment overrides
+
+- `DMS_CAST_HELPER` — path to `dms-cast-helper` (default: next to `dms`, then PATH).
+- `DMS_DOUBLETAKE` — path to `doubletake` (default: PATH).
+- `DMS_CAST_PORT_RANGE` — AirPlay back-channel port range (default `60000-60010`).
diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml
index 356ded1b6..ed78a4835 100644
--- a/quickshell/Common/SettingsData.qml
+++ b/quickshell/Common/SettingsData.qml
@@ -395,6 +395,11 @@ Singleton {
"id": "darkMode",
"enabled": true,
"width": 50
+ },
+ {
+ "id": "builtin_chromecast",
+ "enabled": true,
+ "width": 50
}
]
diff --git a/quickshell/Modules/ControlCenter/BuiltinPlugins/ChromecastWidget.qml b/quickshell/Modules/ControlCenter/BuiltinPlugins/ChromecastWidget.qml
new file mode 100644
index 000000000..cbff6d40e
--- /dev/null
+++ b/quickshell/Modules/ControlCenter/BuiltinPlugins/ChromecastWidget.qml
@@ -0,0 +1,52 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+import qs.Modules.Plugins
+
+PluginComponent {
+ id: root
+
+ // Keeps the subscription alive while the widget exists so the collapsed tile
+ // can show connection/playback status. Discovery is driven separately by the
+ // detail panel (only while it is open).
+ Ref {
+ service: ChromecastService
+ }
+
+ ccWidgetIcon: ChromecastService.connected ? "cast_connected" : "cast"
+ ccWidgetPrimaryText: I18n.tr("Cast", "Chromecast widget title")
+ ccWidgetSecondaryText: {
+ if (!ChromecastService.available)
+ return I18n.tr("Not available", "Chromecast service not available");
+ if (ChromecastService.connected) {
+ const dev = ChromecastService.activeDevice;
+ const name = dev ? dev.name : I18n.tr("Connected", "Chromecast connected status");
+ if (ChromecastService.screencasting)
+ return I18n.tr("Mirroring · %1", "Chromecast mirroring a screen to a device").arg(name);
+ const pb = ChromecastService.playback;
+ if (pb && pb.title)
+ return I18n.tr("%1 · %2", "Chromecast now-playing title on a device").arg(pb.title).arg(name);
+ return name;
+ }
+ if (ChromecastService.discovering)
+ return I18n.tr("Searching…", "Chromecast searching for devices");
+ const count = ChromecastService.deviceCount;
+ if (count > 0)
+ return I18n.tr("%1 devices", "Number of Chromecast devices found").arg(count);
+ return I18n.tr("No devices", "No Chromecast devices found");
+ }
+ ccWidgetIsActive: ChromecastService.connected
+
+ // When connected, the tile toggle disconnects. When not connected, there is
+ // no single target, so clicking expands the detail to pick a device.
+ ccWidgetIsToggle: ChromecastService.connected
+ onCcWidgetToggled: {
+ if (ChromecastService.connected)
+ ChromecastService.disconnect();
+ }
+
+ ccDetailContent: Component {
+ ChromecastDetailContent {}
+ }
+}
diff --git a/quickshell/Modules/ControlCenter/Components/DetailHost.qml b/quickshell/Modules/ControlCenter/Components/DetailHost.qml
index 5013d110f..aa6a0750b 100644
--- a/quickshell/Modules/ControlCenter/Components/DetailHost.qml
+++ b/quickshell/Modules/ControlCenter/Components/DetailHost.qml
@@ -114,6 +114,12 @@ Item {
}
builtinInstance = widgetModel.tailscaleBuiltinInstance;
}
+ if (builtinId === "builtin_chromecast") {
+ if (widgetModel?.chromecastLoader) {
+ widgetModel.chromecastLoader.active = true;
+ }
+ builtinInstance = widgetModel.chromecastBuiltinInstance;
+ }
if (builtinId === "builtin_display_profiles") {
if (widgetModel?.displayProfilesLoader) {
widgetModel.displayProfilesLoader.active = true;
diff --git a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml
index 907f0f761..8262afd94 100644
--- a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml
+++ b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml
@@ -941,6 +941,12 @@ Column {
}
builtinInstance = Qt.binding(() => root.model?.tailscaleBuiltinInstance);
}
+ if (id === "builtin_chromecast") {
+ if (root.model?.chromecastLoader) {
+ root.model.chromecastLoader.active = true;
+ }
+ builtinInstance = Qt.binding(() => root.model?.chromecastBuiltinInstance);
+ }
if (id === "builtin_display_profiles") {
if (root.model?.displayProfilesLoader) {
root.model.displayProfilesLoader.active = true;
diff --git a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml
index c96939fb1..772dd767e 100644
--- a/quickshell/Modules/ControlCenter/Models/WidgetModel.qml
+++ b/quickshell/Modules/ControlCenter/Models/WidgetModel.qml
@@ -11,6 +11,7 @@ QtObject {
property var vpnBuiltinInstance: null
property var cupsBuiltinInstance: null
property var tailscaleBuiltinInstance: null
+ property var chromecastBuiltinInstance: null
property var displayProfilesBuiltinInstance: null
property var vpnLoader: Loader {
@@ -94,6 +95,35 @@ QtObject {
}
}
+ property var chromecastLoader: Loader {
+ active: false
+ sourceComponent: Component {
+ ChromecastWidget {}
+ }
+
+ onItemChanged: {
+ root.chromecastBuiltinInstance = item;
+ }
+
+ onActiveChanged: {
+ if (!active) {
+ root.chromecastBuiltinInstance = null;
+ }
+ }
+
+ Connections {
+ target: SettingsData
+ function onControlCenterWidgetsChanged() {
+ const widgets = SettingsData.controlCenterWidgets || [];
+ const hasChromecastWidget = widgets.some(w => w.id === "builtin_chromecast");
+ if (!hasChromecastWidget && chromecastLoader.active) {
+ root.log.debug("No Chromecast widget in control center, deactivating loader");
+ chromecastLoader.active = false;
+ }
+ }
+ }
+ }
+
property var displayProfilesLoader: Loader {
active: false
sourceComponent: Component {
@@ -272,6 +302,16 @@ QtObject {
"warning": !TailscaleService.available ? I18n.tr("Tailscale not available", "Warning when Tailscale service is not running") : undefined,
"isBuiltinPlugin": true
},
+ {
+ "id": "builtin_chromecast",
+ "text": I18n.tr("Cast", "Chromecast widget title"),
+ "description": I18n.tr("Cast media and mirror your screen to Cast devices", "Chromecast control center widget description"),
+ "icon": "cast",
+ "type": "builtin_plugin",
+ "enabled": ChromecastService.available,
+ "warning": !ChromecastService.available ? I18n.tr("Casting not available", "Warning when Chromecast service is not running") : undefined,
+ "isBuiltinPlugin": true
+ },
{
"id": "builtin_display_profiles",
"text": I18n.tr("Display Profiles"),
diff --git a/quickshell/Modules/ControlCenter/utils/widgets.js b/quickshell/Modules/ControlCenter/utils/widgets.js
index 4a6c578b9..38c01bcf3 100644
--- a/quickshell/Modules/ControlCenter/utils/widgets.js
+++ b/quickshell/Modules/ControlCenter/utils/widgets.js
@@ -81,7 +81,8 @@ function resetToDefault() {
{"id": "audioOutput", "enabled": true, "width": 50},
{"id": "audioInput", "enabled": true, "width": 50},
{"id": "nightMode", "enabled": true, "width": 50},
- {"id": "darkMode", "enabled": true, "width": 50}
+ {"id": "darkMode", "enabled": true, "width": 50},
+ {"id": "builtin_chromecast", "enabled": true, "width": 50}
]
SettingsData.set("controlCenterWidgets", defaultWidgets)
}
diff --git a/quickshell/Services/ChromecastService.qml b/quickshell/Services/ChromecastService.qml
new file mode 100644
index 000000000..baeca0aed
--- /dev/null
+++ b/quickshell/Services/ChromecastService.qml
@@ -0,0 +1,271 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import qs.Common
+
+Singleton {
+ id: root
+ readonly property var log: Log.scoped("ChromecastService")
+
+ // refCount tracks interest in the service's *state* (e.g. a collapsed widget
+ // showing connection status) and drives the subscription. discoveryRefCount
+ // tracks interest in the *device list* and additionally drives the mDNS
+ // browse, which is more expensive — so it only runs while a detail UI is open.
+ property int refCount: 0
+ property int discoveryRefCount: 0
+
+ property bool available: false
+ property bool stateInitialized: false
+
+ property bool discovering: false
+ property var devices: []
+
+ // Connection / playback state, mirrored from the core service.
+ property bool connected: false
+ property var activeDevice: null
+ property var playback: null
+ property bool screencasting: false
+ property string preferredId: ""
+
+ readonly property int deviceCount: devices.length
+ readonly property bool isPlaying: playback && playback.state === "PLAYING"
+
+ readonly property string socketPath: Quickshell.env("DMS_SOCKET")
+
+ readonly property bool wantSubscription: refCount > 0 || discoveryRefCount > 0
+
+ onRefCountChanged: updateSubscription()
+
+ onDiscoveryRefCountChanged: {
+ updateSubscription();
+ if (discoveryRefCount > 0)
+ startDiscovery();
+ else if (discoveryRefCount === 0)
+ stopDiscovery();
+ }
+
+ function updateSubscription() {
+ if (wantSubscription) {
+ ensureSubscription();
+ } else if (DMSService.activeSubscriptions.includes("chromecast")) {
+ DMSService.removeSubscription("chromecast");
+ }
+ }
+
+ function ensureSubscription() {
+ if (!wantSubscription)
+ return;
+ if (!DMSService.isConnected)
+ return;
+ if (DMSService.activeSubscriptions.includes("chromecast"))
+ return;
+ if (DMSService.activeSubscriptions.includes("all"))
+ return;
+ DMSService.addSubscription("chromecast");
+ if (available)
+ getState();
+ }
+
+ Component.onCompleted: {
+ if (socketPath && socketPath.length > 0)
+ checkDMSCapabilities();
+ }
+
+ Connections {
+ target: DMSService
+
+ function onConnectionStateChanged() {
+ if (DMSService.isConnected) {
+ checkDMSCapabilities();
+ ensureSubscription();
+ if (root.discoveryRefCount > 0)
+ root.startDiscovery();
+ }
+ }
+ }
+
+ Connections {
+ target: DMSService
+ enabled: DMSService.isConnected
+
+ function onChromecastStateUpdate(data) {
+ root.log.debug("Subscription update received");
+ root.updateState(data);
+ }
+
+ function onCapabilitiesReceived() {
+ root.checkDMSCapabilities();
+ }
+ }
+
+ function checkDMSCapabilities() {
+ if (!DMSService.isConnected)
+ return;
+ if (DMSService.capabilities.length === 0)
+ return;
+ const wasAvailable = available;
+ available = DMSService.capabilities.includes("chromecast");
+
+ if (!available)
+ return;
+ if (!stateInitialized) {
+ stateInitialized = true;
+ getState();
+ }
+ if (!wasAvailable)
+ ensureSubscription();
+ }
+
+ function getState() {
+ if (!available)
+ return;
+ DMSService.sendRequest("chromecast.getState", null, response => {
+ if (response.result)
+ updateState(response.result);
+ });
+ }
+
+ function devicesEqual(a, b) {
+ if (a.length !== b.length)
+ return false;
+ for (var i = 0; i < a.length; i++) {
+ const x = a[i], y = b[i];
+ if (x.id !== y.id || x.name !== y.name || x.model !== y.model || x.host !== y.host || x.port !== y.port || x.protocol !== y.protocol)
+ return false;
+ }
+ return true;
+ }
+
+ function updateState(data) {
+ if (!data)
+ return;
+ discovering = data.discovering || false;
+ // The core pushes the full state on every playback tick; only reassign
+ // the devices array (which re-evaluates list bindings and rebuilds
+ // delegates) when it actually changed.
+ const newDevices = data.devices || [];
+ if (!devicesEqual(devices, newDevices))
+ devices = newDevices;
+ connected = data.connected || false;
+ activeDevice = data.activeDevice || null;
+ playback = data.playback || null;
+ screencasting = data.screencasting || false;
+ preferredId = data.preferredId || "";
+ }
+
+ // sendAction issues a state-changing request; the core refreshes and
+ // broadcasts on success, so subscribers update without an extra getState.
+ function sendAction(method, params) {
+ if (!available)
+ return;
+ DMSService.sendRequest(method, params, response => {
+ if (response.error) {
+ root.log.warn(method + " failed: " + response.error);
+ ToastService.showError(I18n.tr("Cast action failed", "Toast shown when a Cast control action is rejected"), response.error);
+ }
+ });
+ }
+
+ // cast loads a media URL or local file path on the connected device.
+ function cast(url, contentType) {
+ if (!url)
+ return;
+ sendAction("chromecast.cast", {
+ "url": url,
+ "contentType": contentType || ""
+ });
+ }
+
+ function play() {
+ sendAction("chromecast.play", null);
+ }
+
+ function pause() {
+ sendAction("chromecast.pause", null);
+ }
+
+ function stop() {
+ sendAction("chromecast.stop", null);
+ }
+
+ function seek(position) {
+ sendAction("chromecast.seek", {
+ "position": position
+ });
+ }
+
+ function setVolume(level) {
+ sendAction("chromecast.setVolume", {
+ "level": level
+ });
+ }
+
+ function setMuted(muted) {
+ sendAction("chromecast.setMuted", {
+ "muted": muted
+ });
+ }
+
+ // castScreen mirrors the local screen to the connected device (buffered HLS
+ // path — expect multi-second latency, not real-time mirroring).
+ function castScreen() {
+ sendAction("chromecast.castScreen", null);
+ }
+
+ function stopScreen() {
+ sendAction("chromecast.stopScreen", null);
+ }
+
+ // setPreferred marks a device as the auto-reconnect target. Passing the
+ // already-preferred id (or empty) clears the preference.
+ function setPreferred(id) {
+ if (!id || id === preferredId)
+ sendAction("chromecast.clearPreferred", null);
+ else
+ sendAction("chromecast.setPreferred", {
+ "id": id
+ });
+ }
+
+ function connect(id) {
+ if (!available || !id)
+ return;
+ DMSService.sendRequest("chromecast.connect", {
+ "id": id
+ }, response => {
+ if (response.error) {
+ root.log.warn("connect failed: " + response.error);
+ ToastService.showError(I18n.tr("Cast failed", "Toast shown when connecting to a Cast device fails"), response.error);
+ }
+ });
+ }
+
+ function disconnect() {
+ if (!available)
+ return;
+ DMSService.sendRequest("chromecast.disconnect", null, response => {
+ if (response.error)
+ root.log.warn("disconnect failed: " + response.error);
+ });
+ }
+
+ function startDiscovery() {
+ if (!available)
+ return;
+ DMSService.sendRequest("chromecast.startDiscovery", null, response => {
+ if (response.error)
+ root.log.warn("startDiscovery failed: " + response.error);
+ });
+ }
+
+ function stopDiscovery() {
+ if (!available)
+ return;
+ DMSService.sendRequest("chromecast.stopDiscovery", null, response => {
+ if (response.error)
+ root.log.warn("stopDiscovery failed: " + response.error);
+ });
+ }
+}
diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml
index 9a75b34ff..982decafa 100644
--- a/quickshell/Services/DMSService.qml
+++ b/quickshell/Services/DMSService.qml
@@ -63,6 +63,7 @@ Singleton {
signal locationStateUpdate(var data)
signal sysupdateStateUpdate(var data)
signal tailscaleStateUpdate(var data)
+ signal chromecastStateUpdate(var data)
property bool capsLockState: false
property bool screensaverInhibited: false
@@ -397,6 +398,8 @@ Singleton {
sysupdateStateUpdate(data);
} else if (service === "tailscale") {
tailscaleStateUpdate(data);
+ } else if (service === "chromecast") {
+ chromecastStateUpdate(data);
}
}
diff --git a/quickshell/Widgets/ChromecastDetailContent.qml b/quickshell/Widgets/ChromecastDetailContent.qml
new file mode 100644
index 000000000..18d83dae5
--- /dev/null
+++ b/quickshell/Widgets/ChromecastDetailContent.qml
@@ -0,0 +1,373 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ LayoutMirroring.enabled: I18n.isRtl
+ LayoutMirroring.childrenInherit: true
+
+ implicitHeight: contentColumn.implicitHeight + Theme.spacingM * 2
+ radius: Theme.cornerRadius
+ color: Theme.nestedSurface
+ border.color: Theme.outlineMedium
+ border.width: Theme.layerOutlineWidth
+
+ // While this detail is shown, keep an mDNS browse running. Released on close.
+ Component.onCompleted: ChromecastService.discoveryRefCount++
+ Component.onDestruction: ChromecastService.discoveryRefCount--
+
+ function formatTime(seconds) {
+ if (!seconds || seconds < 0)
+ return "0:00";
+ const s = Math.floor(seconds);
+ const m = Math.floor(s / 60);
+ const sec = s % 60;
+ return m + ":" + (sec < 10 ? "0" + sec : sec);
+ }
+
+ // Devices with the connected one pinned to the top (and injected if a scan
+ // dropped it), so the active device shows once — at the top of the list —
+ // instead of in a separate card above it.
+ readonly property var sortedDevices: {
+ // The core already returns devices in a stable order, so we only need to
+ // pull the connected one to the top (injecting it if a scan dropped it).
+ const devs = ChromecastService.devices ? ChromecastService.devices.slice() : [];
+ const active = ChromecastService.connected ? ChromecastService.activeDevice : null;
+ const activeId = active ? active.id : "";
+ if (active && activeId) {
+ const i = devs.findIndex(d => d.id === activeId);
+ if (i >= 0)
+ devs.splice(i, 1);
+ devs.unshift(active);
+ }
+ return devs;
+ }
+
+ Column {
+ id: contentColumn
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingS
+
+ // Not-available state
+ Item {
+ width: parent.width
+ height: 80
+ visible: !ChromecastService.available
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingS
+
+ DankIcon {
+ name: "cast"
+ size: 36
+ color: Theme.surfaceVariantText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: I18n.tr("Casting not available", "Chromecast service unavailable message")
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceVariantText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
+
+ // ---- Playback controls for the active Chromecast ----
+ // AirPlay has no media controls (connecting just mirrors), so this card
+ // is Chromecast-only; the connected device itself is shown at the top of
+ // the device list below rather than duplicated here.
+ Rectangle {
+ width: parent.width
+ visible: ChromecastService.available && ChromecastService.connected && ChromecastService.activeDevice && ChromecastService.activeDevice.protocol === "chromecast"
+ implicitHeight: nowPlayingColumn.implicitHeight + Theme.spacingM * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
+
+ Column {
+ id: nowPlayingColumn
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingS
+
+ // Progress bar (only when media with a known duration is playing)
+ RowLayout {
+ width: parent.width
+ spacing: Theme.spacingS
+ visible: !ChromecastService.screencasting && ChromecastService.playback && ChromecastService.playback.duration > 0
+
+ StyledText {
+ text: root.formatTime(ChromecastService.playback ? ChromecastService.playback.currentTime : 0)
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ DankSlider {
+ id: seekSlider
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ minimum: 0
+ maximum: Math.max(1, Math.round(ChromecastService.playback ? ChromecastService.playback.duration : 1))
+ value: Math.round(ChromecastService.playback ? ChromecastService.playback.currentTime : 0)
+ unit: ""
+ showValue: false
+ onSliderDragFinished: finalValue => ChromecastService.seek(finalValue)
+ }
+
+ StyledText {
+ text: root.formatTime(ChromecastService.playback ? ChromecastService.playback.duration : 0)
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ Layout.alignment: Qt.AlignVCenter
+ }
+ }
+
+ // Transport controls
+ RowLayout {
+ width: parent.width
+ spacing: Theme.spacingS
+ visible: !ChromecastService.screencasting
+
+ DankActionButton {
+ iconName: ChromecastService.isPlaying ? "pause" : "play_arrow"
+ buttonSize: 32
+ iconSize: 20
+ iconColor: Theme.primary
+ tooltipText: ChromecastService.isPlaying ? I18n.tr("Pause") : I18n.tr("Play")
+ onClicked: {
+ if (ChromecastService.isPlaying)
+ ChromecastService.pause();
+ else
+ ChromecastService.play();
+ }
+ }
+
+ DankActionButton {
+ iconName: "stop"
+ buttonSize: 32
+ iconSize: 20
+ iconColor: Theme.surfaceVariantText
+ tooltipText: I18n.tr("Stop")
+ onClicked: ChromecastService.stop()
+ }
+
+ DankActionButton {
+ readonly property bool muted: ChromecastService.playback && ChromecastService.playback.muted
+ iconName: muted ? "volume_off" : "volume_up"
+ buttonSize: 32
+ iconSize: 20
+ iconColor: Theme.surfaceVariantText
+ tooltipText: muted ? I18n.tr("Unmute") : I18n.tr("Mute")
+ onClicked: ChromecastService.setMuted(!muted)
+ }
+
+ DankSlider {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ leftIcon: "volume_down"
+ minimum: 0
+ maximum: 100
+ value: Math.round((ChromecastService.playback ? ChromecastService.playback.volume : 0) * 100)
+ onSliderDragFinished: finalValue => ChromecastService.setVolume(finalValue / 100)
+ }
+ }
+
+ // Screen-mirror toggle — Chromecast only. For AirPlay, connecting
+ // already mirrors the screen, so the Disconnect button stops it.
+ DankToggle {
+ width: parent.width
+ text: I18n.tr("Mirror screen", "Chromecast screen mirroring toggle")
+ description: I18n.tr("Experimental — expect a few seconds of lag", "Chromecast screen mirroring latency warning")
+ checked: ChromecastService.screencasting
+ onToggled: value => {
+ if (value)
+ ChromecastService.castScreen();
+ else
+ ChromecastService.stopScreen();
+ }
+ }
+ }
+ }
+
+ // ---- Devices header ----
+ RowLayout {
+ width: parent.width
+ visible: ChromecastService.available
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: I18n.tr("Devices", "Chromecast devices list header")
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ Layout.fillWidth: true
+ }
+
+ DankIcon {
+ name: "sync"
+ size: 14
+ color: Theme.surfaceVariantText
+ visible: ChromecastService.discovering
+ Layout.alignment: Qt.AlignVCenter
+
+ RotationAnimation on rotation {
+ running: ChromecastService.discovering
+ from: 0
+ to: 360
+ duration: 1200
+ loops: Animation.Infinite
+ }
+ }
+ }
+
+ // ---- Device list ----
+ DankFlickable {
+ width: parent.width
+ height: 160
+ visible: ChromecastService.available
+ contentHeight: deviceColumn.implicitHeight
+ clip: true
+
+ Column {
+ id: deviceColumn
+ width: parent.width
+ spacing: Theme.spacingXS
+
+ // Empty state
+ Item {
+ width: parent.width
+ height: 60
+ visible: root.sortedDevices.length === 0
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: "tv"
+ size: 28
+ color: Theme.surfaceVariantText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ text: ChromecastService.discovering ? I18n.tr("Searching for devices…", "Chromecast discovery in progress") : I18n.tr("No devices found", "No Chromecast devices on the network")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
+
+ Repeater {
+ model: root.sortedDevices
+
+ delegate: Rectangle {
+ id: deviceCard
+ required property var modelData
+
+ readonly property bool isActive: ChromecastService.connected && ChromecastService.activeDevice && ChromecastService.activeDevice.id === modelData.id
+
+ width: deviceColumn.width
+ height: deviceRow.implicitHeight + Theme.spacingS * 2
+ radius: Theme.cornerRadius
+ color: isActive ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : (deviceArea.containsMouse ? Theme.surfaceContainerHigh : Theme.surfaceContainerHighest)
+
+ RowLayout {
+ id: deviceRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Theme.spacingS
+ spacing: Theme.spacingS
+
+ DankIcon {
+ readonly property bool isAirplay: deviceCard.modelData.protocol === "airplay"
+ name: isAirplay ? (deviceCard.isActive ? "connected_tv" : "tv") : (deviceCard.isActive ? "cast_connected" : "cast")
+ size: 18
+ color: deviceCard.isActive ? Theme.primary : Theme.surfaceVariantText
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ Column {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ spacing: 1
+
+ StyledText {
+ text: deviceCard.modelData.name || deviceCard.modelData.id
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ width: parent.width
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ // Protocol label (+ model), so Chromecast vs AirPlay is clear.
+ text: {
+ const proto = deviceCard.modelData.protocol === "airplay" ? I18n.tr("AirPlay", "AirPlay protocol label") : I18n.tr("Chromecast", "Chromecast protocol label");
+ const model = deviceCard.modelData.model || "";
+ return model.length > 0 ? (proto + " · " + model) : proto;
+ }
+ font.pixelSize: 10
+ color: Theme.surfaceVariantText
+ width: parent.width
+ elide: Text.ElideRight
+ }
+ }
+
+ DankActionButton {
+ readonly property bool isPreferred: ChromecastService.preferredId === deviceCard.modelData.id
+ iconName: isPreferred ? "star" : "star_outline"
+ buttonSize: 28
+ iconSize: 16
+ iconColor: isPreferred ? Theme.primary : Theme.surfaceVariantText
+ tooltipText: isPreferred ? I18n.tr("Auto-connects — click to unset", "Chromecast preferred device hint") : I18n.tr("Auto-connect to this device", "Chromecast set preferred device")
+ Layout.alignment: Qt.AlignVCenter
+ onClicked: ChromecastService.setPreferred(deviceCard.modelData.id)
+ }
+
+ DankActionButton {
+ iconName: deviceCard.isActive ? (ChromecastService.screencasting ? "stop_screen_share" : "cancel_presentation") : "cast"
+ buttonSize: 28
+ iconSize: 16
+ iconColor: deviceCard.isActive ? Theme.surfaceText : Theme.primary
+ tooltipText: deviceCard.isActive ? I18n.tr("Disconnect") : I18n.tr("Cast to this device")
+ Layout.alignment: Qt.AlignVCenter
+ onClicked: {
+ if (deviceCard.isActive)
+ ChromecastService.disconnect();
+ else
+ ChromecastService.connect(deviceCard.modelData.id);
+ }
+ }
+ }
+
+ MouseArea {
+ id: deviceArea
+ z: -1
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (!deviceCard.isActive)
+ ChromecastService.connect(deviceCard.modelData.id);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}