Skip to content

feat: Chromecast + AirPlay 2 casting - #2697

Open
domenkozar wants to merge 5 commits into
AvengeMedia:masterfrom
domenkozar:feat/chromecast-casting
Open

feat: Chromecast + AirPlay 2 casting#2697
domenkozar wants to merge 5 commits into
AvengeMedia:masterfrom
domenkozar:feat/chromecast-casting

Conversation

@domenkozar

@domenkozar domenkozar commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a casting feature that discovers and casts to both Chromecast (_googlecast._tcp) and AirPlay 2 (_airplay._tcp) devices from a single Control Center widget.

  • Chromecast: media casting + screen mirroring (HLS), handled in-process by the dms core via the Go Cast protocol.
  • AirPlay 2: screen mirroring via doubletake (a separate GPLv3 process, never linked into the MIT dms).
  • Screen capture/encode is done by a new dms-cast-helper binary using 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 and forces 4:2:0 (I420) chroma (4:4:4 is rejected by most TV decoders → black screen).
  • mDNS discovery prefers avahi (D-Bus API) when present and falls back to a built-in re-browsing browser otherwise.

The main dms binary stays CGO-free; the cast helper is opt-in and needs CGO + GStreamer dev packages. See docs/casting.md for build, firewall, and environment details.

Dependency

AirPlay mirroring relies on the doubletake fork, which drives gst-launch-1.0 for capture and fixes the black-screen + pts-timing issues on that path (DMA-BUF import + 4:2:0 chroma). doubletake builds CGO-free and only needs the gst-launch-1.0 CLI plus plugins at runtime:

Potential blocker

Status

Slices: discovery, cast, screen-mirror, Control Center widget/service, auto-reconnect. AirPlay 2 mirroring has been verified against a real non-Apple AirPlay 2 receiver; Chromecast paths are built but not yet verified against real Cast hardware.

Screenshot from 2026-06-24 11-42-07

@purian23
purian23 requested a review from bbedward June 25, 2026 01:16
@bbedward

Copy link
Copy Markdown
Collaborator

CGO is a hard no from me, along with the GPL3 lib which isn't a potential blocker it is a blocker because we need to be able to distribute DMS with vendored modules, which makes the license incompatibility a blocker (for example, Fedora/Redhat wouldnt be able to package dms anymore under the current license).

The feature is good and actually very useful (I appreciate the contribution and thoughtfulness of it), but we'd have to find another way to implement it. Anything that depends on C would need to be a binary entirely external from dms or built into quickshell itself basically.

I would be more open to this one being a plugin, unless we could workaround the CGO requirements (by either not needing gstreamer, building it into quickshell, or referencing an external binary/dependency that wasn't vendored into dms at all)

@domenkozar

Copy link
Copy Markdown
Contributor Author

CGO is a hard no from me, along with the GPL3 lib which isn't a potential blocker it is a blocker because we need to be able to distribute DMS with vendored modules, which makes the license incompatibility a blocker (for example, Fedora/Redhat wouldnt be able to package dms anymore under the current license).

I'm talking to upstream to potentially relicense so that we can ship it. Fingers crossed.

Anything that depends on C would need to be a binary entirely external from dms or built into quickshell itself basically.

Maybe I should just apply fixes upstream to use gstreamer cli like it used to be in omarroth/doubletake#9 and we're good to include it by default.

@domenkozar

Copy link
Copy Markdown
Contributor Author

I would be more open to this one being a plugin, unless we could workaround the CGO requirements (by either not needing gstreamer, building it into quickshell, or referencing an external binary/dependency that wasn't vendored into dms at all)

I've reverted the CGO change in upstream PR, still discussing licensing options.

@Spoch-dev

This comment was marked as spam.

@domenkozar

Copy link
Copy Markdown
Contributor Author

CGO is a hard no from me, along with the GPL3 lib which isn't a potential blocker it is a blocker because we need to be able to distribute DMS with vendored modules, which makes the license incompatibility a blocker (for example, Fedora/Redhat wouldnt be able to package dms anymore under the current license).

The feature is good and actually very useful (I appreciate the contribution and thoughtfulness of it), but we'd have to find another way to implement it. Anything that depends on C would need to be a binary entirely external from dms or built into quickshell itself basically.

I would be more open to this one being a plugin, unless we could workaround the CGO requirements (by either not needing gstreamer, building it into quickshell, or referencing an external binary/dependency that wasn't vendored into dms at all)

CGO is removed and license is now LGPL.

@domenkozar
domenkozar force-pushed the feat/chromecast-casting branch from 574f994 to 1bcff0a Compare July 1, 2026 21:06
@domenkozar

Copy link
Copy Markdown
Contributor Author

@bbedward waiting on your review, I believe your original concerns were addressed.

1 similar comment
@domenkozar

Copy link
Copy Markdown
Contributor Author

@bbedward waiting on your review, I believe your original concerns were addressed.

@toods2

toods2 commented Jul 8, 2026

Copy link
Copy Markdown

In my opinion, I think this would be more appropriate for a plugin.

This would introduce several dependencies into the core of DMS for a new feature that will need to be maintained and carries the expectation from users as being robust and performant. The https://github.com/grandcat/zeroconf repo doesn't look like it's had any updates/activity in the last 3/4 years, and the AirPlay https://github.com/omarroth/doubletake repo describes itself as someone's personal LLM project. Has this been thoroughly tested mirroring to an actual Apple device such as an AppleTV for stability and performance?

@domenkozar

Copy link
Copy Markdown
Contributor Author

In my opinion, I think this would be more appropriate for a plugin.

I understand the feeling behind this, but I'd like to say that my inspiration for dms is to become better for macOS and to achieve that we have to aim for more than mediocre.

Having streaming work out of the box is something crucial for a desktop environment these days.

This would introduce several dependencies into the core of DMS for a new feature that will need to be maintained and carries the expectation from users as being robust and performant. The https://github.com/grandcat/zeroconf repo doesn't look like it's had any updates/activity in the last 3/4 years, and the AirPlay https://github.com/omarroth/doubletake repo describes itself as someone's personal LLM project.

For me the beauty of OSS is that we can fix these things as we go. DMS will probably expose bugs that will trickle down and worst case result into maintained forks.

Has this been thoroughly tested mirroring to an actual Apple device such as an AppleTV for stability and performance?

Yes, I've tested on the TV and doubletake is starting to maintain a matrix of devices that were tested on.

@bbedward

Copy link
Copy Markdown
Collaborator

/claude review

Comment on lines +74 to +87
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()
}
}
}()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The waiter doesn't check that it's still the current process, so a restart can be clobbered by the previous one.

stop() only sends Kill() and returns immediately; reaping happens later in this goroutine. On any reconnect (connectAirplayLockeddisconnectLocked()airplay.stop()airplay.start()), the new process is typically installed before the old cmd.Wait() returns. The stale goroutine then sees a.running == true (the new mirror), sets a.running = false / a.cmd = nil, and fires the old onExit — so the UI flips to disconnected while the freshly spawned doubletake keeps running untracked (stop() can no longer kill it → leaked process).

screenStreamer already guards this correctly with s.cmd == cmd (screencast.go:183); do the same here:

Suggested change
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()
}
}
}()
go func() {
_ = cmd.Wait()
a.mu.Lock()
// Only clear state if this is still the active process — stop()+start()
// can install a new cmd before this Wait returns.
unexpected := a.running && a.cmd == cmd
if unexpected {
a.running = false
a.cmd = nil
}
a.mu.Unlock()
if unexpected {
log.Warn("[Cast] AirPlay mirror exited unexpectedly")
if onExit != nil {
onExit()
}
}
}()

Comment on lines +234 to +237
DankFlickable {
width: parent.width
height: 160
visible: ChromecastService.available

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detail panel will be clipped whenever a Chromecast is connected.

DetailHost is sized by DetailHeightUtils.detailHeightForSection() (quickshell/Modules/ControlCenter/utils/detailHeight.js) with clip: true. builtin_chromecast matches none of the special cases there (it isn't plugin_-prefixed, so ccDetailHeight is ignored too), so it falls through to the 250px default. This content is ~212px with no connection, but with the now-playing card visible (progress row + transport row + DankToggle) it's ~370px — roughly half the 160px device list gets cut off with no way to scroll to it.

builtin_vpn / builtin_tailscale handle this by being listed in the 350px branch; add builtin_chromecast there (400 would be safer given the mirror toggle):

if (section === "wifi" || section === "bluetooth"
        || section === "builtin_vpn" || section === "builtin_tailscale"
        || section === "builtin_chromecast")
    return Math.min(400, maxHeight);

buttonSize: 32
iconSize: 20
iconColor: Theme.surfaceVariantText
tooltipText: I18n.tr("Stop")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Stop", "Mute" (line 170) and "Cast to this device" (line 346) are new terms with no translator context — CONTRIBUTING.md requires the context argument for new strings. (Play/Pause/Unmute/Disconnect are fine, they already exist in translations/en.json; these three don't.)

Suggested change
tooltipText: I18n.tr("Stop")
tooltipText: I18n.tr("Stop", "Stops playback on the connected Cast device")

@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Claude review

Solid, well-commented implementation; three issues worth fixing before merge (nothing blocking the architecture).

  • Stale cmd.Wait() goroutine can clobber a restarted mirror → leaked doubletake process + spurious disconnect, core/internal/server/chromecast/airplay_mirror.go:74
  • Detail panel falls through to the 250px default in detailHeight.js, clipping the device list when connected, quickshell/Widgets/ChromecastDetailContent.qml:234
  • New user-facing terms without translator context ("Stop", "Mute", "Cast to this device"), quickshell/Widgets/ChromecastDetailContent.qml:160

Checked: manager/discovery/portal/HLS lifecycle and locking, avahi + zeroconf paths, subprocess & goroutine leaks, IPC/router wiring, QML bindings and Control Center widget/loader plumbing, i18n rules. Model: claude-opus-5.

domenkozar and others added 4 commits August 20, 2026 18:25
New cast manager in the core daemon: mDNS discovery of both Google Cast
(_googlecast._tcp) and AirPlay (_airplay._tcp) devices, connect, live
playback status, media casting with transport controls, and screen
mirroring. Exposed over JSON-RPC and wired into the server/router.

Discovery prefers avahi via D-Bus when it is running (no parallel mDNS
stack, so it avoids UDP 5353 contention) and otherwise falls back to a
self healing zeroconf/go-chromecast browser that re-browses periodically.
AirPlay devices are keyed by their stable mDNS instance name rather than
the flaky deviceid TXT. A preferred device auto connects when discovered.

Screen capture and encode run in dms-cast-helper, a cgo gated go-gst
program (VA-API DMA-BUF import, I420 chroma, PTS restamp) kept separate
so the core stays CGO free. Chromecast mirrors via HLS served from core;
AirPlay mirrors via the external doubletake process.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ChromecastService.qml subscribes to the core cast manager and exposes
discovery, connect/disconnect, transport controls, screen mirroring and
preferred device, with the mDNS browse refcounted to the detail panel.

A new Control Center widget (ChromecastWidget) and detail panel
(ChromecastDetailContent) list both Chromecast and AirPlay devices,
pin the connected device to the top of the list (no duplicate card),
and offer stop mirroring plus an auto connect star per device.
Registered in the widget model and Control Center components, and added
to the default widget set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers Chromecast and AirPlay discovery (avahi preferred, with a self
healing zeroconf fallback), building dms-cast-helper, the external
doubletake dependency for AirPlay, the firewall port range, and the
mDNS port 5353 contention gotcha (notably Chrome).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The doubletake fork was reworked to drop the go-gst (CGO) capture path and
drive gst-launch-1.0 instead, fixing the black-screen + pts-timing issues on
that path. Update casting docs (build no longer needs CGO or GStreamer dev
packages) and the airplay_mirror comment to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@domenkozar
domenkozar force-pushed the feat/chromecast-casting branch from 1bcff0a to 52b2190 Compare August 20, 2026 23:26
@domenkozar

Copy link
Copy Markdown
Contributor Author

Thanks @bbedward rebased and addressed review comments.

The i18n-term-variants hook flagged "Searching…" against the existing
"Searching..." in SpotlightResultsList. Switch both cast strings to the
repo's dot convention (103 uses vs 2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDSfhdP7ufRn4VpwYpc4kY
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants