Screencast: cast the desktop to Cast, DLNA and AirPlay receivers

An Omarchy shell plugin: a Python/asyncio daemon that discovers network
displays and serves an encoded screen capture for them to pull, plus a
Quickshell bar widget and panel to drive it.

The screen comes from the xdg-desktop-portal ScreenCast interface (asked
for every time, never remembered), goes through GStreamer, and is served
from a local HTTP port. Desktop audio is mixed in from the default
output's monitor. The container follows the receiver: WebM/VP8 for Cast,
MPEG-TS/H.264 for DLNA, HLS for AirPlay video.

The stream port has to be reachable from the LAN, and none of these
protocols can carry a credential, so the capability is the URL: a fresh
random path per session, refused to anything off the LAN, capped at four
concurrent readers.
This commit is contained in:
2026-08-29 19:57:54 +01:00
commit 1ed57291f9
24 changed files with 5086 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
from .base import Backend, BackendError
from .cast import CastBackend
from .dlna import DlnaBackend
from .airplay import AirPlayBackend
__all__ = ["Backend", "BackendError", "CastBackend", "DlnaBackend", "AirPlayBackend", "for_kind"]
def for_kind(kind: str):
return {"cast": CastBackend, "dlna": DlnaBackend, "airplay": AirPlayBackend}.get(kind)
+247
View File
@@ -0,0 +1,247 @@
"""AirPlay, through pyatv.
Caveat worth knowing: AirPlay *mirroring* (the low-latency screen protocol Macs
and iPhones use) is closed and has no open sender implementation on Linux. What
does work is AirPlay video playback — the receiver is handed a URL and plays it
— so the screen goes out as a live HLS stream. That means several seconds of
delay, and a receiver that refuses live HLS refuses the cast.
pyatv is optional: without it this backend reports itself unavailable instead of
taking the whole daemon down.
"""
from __future__ import annotations
import asyncio
import contextlib
from ..devices import Device
from ..util import log, port_open, state_dir
from .base import Backend, BackendError
INSTALL_HINT = "AirPlay needs pyatv: ~/.local/lib/screencast/venv/bin/pip install pyatv"
async def _pingable(host: str) -> bool:
proc = await asyncio.create_subprocess_exec(
"ping", "-c1", "-W1", host,
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
return await proc.wait() == 0
class AirPlayBackend(Backend):
kind = "airplay"
containers = ("hls",)
progressive = False
def __init__(self, daemon) -> None:
super().__init__(daemon)
self._atv = None
self._atv_id = ""
self._play_task: asyncio.Task | None = None
self._storage = None
self._pairing = None
self._pairing_device: Device | None = None
# ---- pyatv plumbing ----
@staticmethod
def available() -> bool:
try:
import pyatv # noqa: F401
except Exception: # noqa: BLE001
return False
return True
async def _storage_obj(self):
if self._storage is None:
from pyatv.storage.file_storage import FileStorage
path = state_dir() / "airplay.json"
self._storage = FileStorage(str(path), asyncio.get_running_loop())
await self._storage.load()
# pyatv writes this with a plain open(); it holds the credentials
# that let anyone play to the user's receivers.
with contextlib.suppress(OSError):
path.touch()
path.chmod(0o600)
return self._storage
async def _config(self, device: Device):
"""Find the receiver again, and be specific about why when we cannot.
Televisions keep announcing `_airplay._tcp` over mDNS while the receiver
itself is switched off, so "it is in the list" says nothing about whether
it will talk to us. Check the port first: a refused connection is a
setting the user can change, and no amount of scanning will fix it.
"""
import pyatv
loop = asyncio.get_running_loop()
storage = await self._storage_obj()
port = device.port or 7000
if not await port_open(device.host, port):
reachable = await port_open(device.host, 80, 1.0) or await _pingable(device.host)
log.warning("airplay: %s has nothing listening on %d", device.name, port)
raise BackendError(
f"{device.name} is on the network but its AirPlay receiver is off — "
"turn AirPlay on in the TV's settings"
if reachable else
f"{device.name} is not answering — switch it on and try again"
)
# Unicast first (fast, and exact); some receivers only answer multicast.
for attempt, timeout in enumerate((5, 8), start=1):
confs = await pyatv.scan(loop, hosts=[device.host], timeout=timeout, storage=storage)
if confs:
if attempt > 1:
log.info("airplay: %s answered on attempt %d", device.name, attempt)
return confs[0]
log.warning("airplay: %s did not answer a unicast scan (%ds)", device.name, timeout)
confs = [c for c in await pyatv.scan(loop, timeout=8, storage=storage)
if str(c.address) == device.host]
if confs:
log.info("airplay: %s only answered the multicast scan", device.name)
return confs[0]
raise BackendError(
f"{device.name} accepts connections but did not identify itself — "
"check that AirPlay is on and the TV is awake"
)
async def _connect(self, device: Device):
if self._atv is not None and self._atv_id == device.id:
return self._atv
if self._atv is not None:
await self.stop(device) # a connection to a different receiver
if not self.available():
raise BackendError(INSTALL_HINT)
import pyatv
conf = await self._config(device)
try:
self._atv = await pyatv.connect(
conf, asyncio.get_running_loop(), storage=await self._storage_obj()
)
except Exception as exc: # noqa: BLE001
log.warning("airplay connect to %s failed: %s", device.name, exc)
if any(w in str(exc).lower() for w in ("auth", "credential", "pair")):
raise BackendError(f"{device.name} needs pairing first") from exc
raise BackendError(f"could not connect to {device.name}: {exc}") from exc
self._atv_id = device.id
log.info("airplay: connected to %s", device.name)
return self._atv
# ---- casting ----
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
atv = await self._connect(device)
async def run():
try:
await atv.stream.play_url(url)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
log.warning("airplay playback ended: %s", exc)
self.daemon.note_backend_error(f"{device.name}: {exc}")
# play_url only returns when playback ends, so it cannot be awaited here.
self._play_task = asyncio.create_task(run())
await asyncio.sleep(0.5)
if self._play_task.done():
exc = self._play_task.exception()
if exc is not None:
raise BackendError(f"{device.name} refused the stream: {exc}")
log.info("airplay: %s playing %s", device.name, url)
async def stop(self, device: Device) -> None:
if self._play_task is not None:
self._play_task.cancel()
self._play_task = None
atv, self._atv, self._atv_id = self._atv, None, ""
if atv is not None:
try:
await atv.remote_control.stop()
except Exception: # noqa: BLE001
pass
atv.close()
async def set_volume(self, device: Device, volume: float) -> None:
atv = await self._connect(device)
await atv.audio.set_volume(max(0.0, min(1.0, volume)) * 100.0)
async def transport(self, device: Device, action: str) -> None:
atv = await self._connect(device)
rc = atv.remote_control
fn = {"play": rc.play, "pause": rc.pause, "stop": rc.stop}.get(action)
if fn is None:
raise BackendError(f"unknown transport action {action}")
await fn()
async def poll(self, device: Device) -> dict:
atv = self._atv
if atv is None:
return {}
out: dict = {"connected": True}
try:
playing = await atv.metadata.playing()
out["playerState"] = str(playing.device_state).split(".")[-1].lower()
out["app"] = playing.title or ""
except Exception: # noqa: BLE001
pass
try:
out["volume"] = float(atv.audio.volume) / 100.0
except Exception: # noqa: BLE001
pass
return out
# ---- pairing (PIN shown on the TV, typed into the panel) ----
async def pair_begin(self, device: Device) -> None:
if not self.available():
raise BackendError(INSTALL_HINT)
import pyatv
from pyatv.const import Protocol
await self.pair_cancel()
conf = await self._config(device)
self._pairing = await pyatv.pair(
conf, Protocol.AirPlay, asyncio.get_running_loop(), storage=await self._storage_obj()
)
await self._pairing.begin()
self._pairing_device = device
log.info("airplay: pairing with %s (%s)", device.name,
"PIN on the TV" if self._pairing.device_provides_pin else "no PIN needed")
if not self._pairing.device_provides_pin:
# Some receivers want *us* to show a PIN; nothing to type in that case.
await self._pairing.finish()
async def pair_pin(self, pin: str) -> bool:
if self._pairing is None:
raise BackendError("no pairing in progress")
name = self._pairing_device.name if self._pairing_device else "the receiver"
self._pairing.pin(pin)
try:
await self._pairing.finish()
except Exception as exc: # noqa: BLE001
log.warning("airplay pairing with %s failed: %s", name, exc)
await self.pair_cancel()
raise BackendError(f"{name} rejected the code: {exc}") from exc
ok = bool(self._pairing.has_paired)
log.info("airplay: pairing with %s %s", name, "succeeded" if ok else "was refused")
if ok and self._storage is not None:
await self._storage.save()
# Pairing leaves its own connection behind; drop ours so the next cast
# reconnects with the credentials we just stored.
self._atv, self._atv_id = None, ""
await self.pair_cancel()
return ok
async def pair_cancel(self) -> None:
pairing, self._pairing = self._pairing, None
self._pairing_device = None
if pairing is not None:
try:
await pairing.close()
except Exception: # noqa: BLE001
pass
async def close(self) -> None:
await self.pair_cancel()
if self._atv is not None or self._play_task is not None:
await self.stop(Device(id="", kind="airplay", name="", host="", port=0))
+41
View File
@@ -0,0 +1,41 @@
"""What every receiver protocol has to provide."""
from __future__ import annotations
from ..devices import Device
class BackendError(Exception):
pass
class Backend:
kind = ""
#: Containers this protocol can be handed, best first.
containers: tuple[str, ...] = ("webm",)
#: Does it pull a plain HTTP stream (True) or an HLS playlist (False)?
progressive = True
def __init__(self, daemon) -> None:
self.daemon = daemon
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
raise NotImplementedError
async def stop(self, device: Device) -> None:
raise NotImplementedError
async def set_volume(self, device: Device, volume: float) -> None:
raise BackendError("this receiver has no volume control")
async def set_muted(self, device: Device, muted: bool) -> None:
raise BackendError("this receiver has no mute control")
async def transport(self, device: Device, action: str) -> None:
raise BackendError("this receiver has no transport control")
async def poll(self, device: Device) -> dict:
"""Live status of the session: {state, app, volume, muted, error}."""
return {}
async def close(self) -> None:
return None
+136
View File
@@ -0,0 +1,136 @@
"""Google Cast.
pychromecast is a threaded, blocking library; every call into it runs in a
worker thread and its status callbacks are marshalled back onto the loop.
"""
from __future__ import annotations
import asyncio
import functools
from ..devices import Device
from ..util import log
from .base import Backend, BackendError
CONTENT_TYPES = {"webm": "video/webm", "mp4": "video/mp4", "matroska": "video/x-matroska",
"mpegts": "video/mp2t"}
class CastBackend(Backend):
kind = "cast"
# WebM/VP8 only. The receiver plays a WebM live stream from any cluster,
# with no index and no seeking. GStreamer's mp4mux "streamable" output is
# not that: a client joining after the first fragment cannot start it (the
# TV sits on a spinner, and ffprobe hangs the same way), and the CMAF muxer
# that would fix it lives in gst-plugins-rs, which is not a dependency worth
# taking for a second-best path.
containers = ("webm",)
def __init__(self, daemon) -> None:
super().__init__(daemon)
self._casts: dict[str, object] = {}
async def _cast(self, device: Device):
cast = self._casts.get(device.id)
if cast is not None and getattr(cast, "socket_client", None) is not None:
if not cast.socket_client.stop.is_set():
return cast
self._casts.pop(device.id, None)
info = device.extra.get("cast_info")
if info is None:
raise BackendError("the device is no longer on the network")
zconf = self.daemon.discovery.zeroconf
def connect():
import pychromecast
cast = pychromecast.get_chromecast_from_cast_info(info, zconf, tries=1, timeout=5)
cast.wait(timeout=12)
return cast
try:
cast = await asyncio.to_thread(connect)
except Exception as exc: # noqa: BLE001 - anything from the socket layer
raise BackendError(f"could not connect to {device.name}: {exc}") from exc
self._casts[device.id] = cast
return cast
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
cast = await self._cast(device)
ctype = CONTENT_TYPES.get(container, "video/webm")
def start():
mc = cast.media_controller
mc.play_media(
url, ctype, title=title, stream_type="LIVE", autoplay=True,
metadata={"metadataType": 0, "title": title},
)
mc.block_until_active(timeout=20)
try:
await asyncio.to_thread(start)
except Exception as exc: # noqa: BLE001
raise BackendError(f"{device.name} refused the stream: {exc}") from exc
log.info("cast: %s playing %s (%s)", device.name, url, ctype)
async def stop(self, device: Device) -> None:
cast = self._casts.get(device.id)
if cast is None:
return
def finish():
try:
cast.media_controller.stop()
except Exception: # noqa: BLE001
pass
try:
cast.quit_app()
except Exception: # noqa: BLE001
pass
cast.disconnect()
await asyncio.to_thread(finish)
self._casts.pop(device.id, None)
async def set_volume(self, device: Device, volume: float) -> None:
cast = await self._cast(device)
await asyncio.to_thread(
functools.partial(cast.socket_client.receiver_controller.set_volume, max(0.0, min(1.0, volume)))
)
async def set_muted(self, device: Device, muted: bool) -> None:
cast = await self._cast(device)
await asyncio.to_thread(
functools.partial(cast.socket_client.receiver_controller.set_volume_muted, bool(muted))
)
async def transport(self, device: Device, action: str) -> None:
cast = await self._cast(device)
mc = cast.media_controller
fn = {"play": mc.play, "pause": mc.pause, "stop": mc.stop}.get(action)
if fn is None:
raise BackendError(f"unknown transport action {action}")
await asyncio.to_thread(fn)
async def poll(self, device: Device) -> dict:
cast = self._casts.get(device.id)
if cast is None:
return {}
status = cast.socket_client.receiver_controller.status
media = cast.media_controller.status
state = (media.player_state or "").lower() if media else ""
return {
"app": (status.display_name if status else "") or "",
"volume": float(status.volume_level) if status else -1.0,
"muted": bool(status.volume_muted) if status else False,
"playerState": state,
"connected": not cast.socket_client.stop.is_set(),
}
async def close(self) -> None:
for cast in list(self._casts.values()):
try:
await asyncio.to_thread(cast.disconnect)
except Exception: # noqa: BLE001
pass
self._casts.clear()
+163
View File
@@ -0,0 +1,163 @@
"""DLNA / UPnP MediaRenderer.
A renderer is told to fetch a URL (SetAVTransportURI + Play) and polled for what
it is doing. Most TVs handle a live MPEG-TS stream this way; some refuse
anything without a duration, which shows up as a Play error.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
from html import escape
import aiohttp
from ..devices import Device
from ..util import log
from .base import Backend, BackendError
SOAP_ENV = (
'<?xml version="1.0" encoding="utf-8"?>'
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"'
' s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>{body}</s:Body></s:Envelope>'
)
PROTOCOL_INFO = {
"mpegts": "http-get:*:video/mp2t:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=8D500000000000000000000000000000",
"mp4": "http-get:*:video/mp4:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=8D500000000000000000000000000000",
"webm": "http-get:*:video/webm:*",
}
def didl(url: str, title: str, container: str) -> str:
return (
'<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"'
' xmlns:dc="http://purl.org/dc/elements/1.1/"'
' xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">'
'<item id="0" parentID="-1" restricted="1">'
f"<dc:title>{escape(title)}</dc:title>"
"<upnp:class>object.item.videoItem.movie</upnp:class>"
f'<res protocolInfo="{PROTOCOL_INFO.get(container, PROTOCOL_INFO["mpegts"])}">{escape(url)}</res>'
"</item></DIDL-Lite>"
)
class DlnaBackend(Backend):
kind = "dlna"
# MPEG-TS: a renderer can start on any packet, which is what a live stream
# needs (see the note in cast.py about fragmented MP4).
containers = ("mpegts",)
def __init__(self, daemon) -> None:
super().__init__(daemon)
self._session: aiohttp.ClientSession | None = None
async def _http(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
return self._session
async def _soap(self, device: Device, service: str, action: str, args: dict) -> dict:
control = device.extra.get("control") or {}
url = control.get(service)
stype = control.get(f"{service}_type")
if not url or not stype:
raise BackendError(f"{device.name} has no {service} service")
# stype comes out of the device's own description XML.
body = f'<u:{action} xmlns:u="{escape(stype, quote=True)}">' + "".join(
f"<{k}>{escape(str(v))}</{k}>" for k, v in args.items()
) + f"</u:{action}>"
payload = SOAP_ENV.format(body=body).encode()
headers = {
"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": f'"{stype}#{action}"',
"Connection": "close",
}
session = await self._http()
try:
async with session.post(url, data=payload, headers=headers) as resp:
text = (await resp.content.read(256 << 10)).decode("utf-8", "replace")
if resp.status >= 400:
raise BackendError(f"{action} failed: {_fault(text) or resp.status}")
except aiohttp.ClientError as exc:
raise BackendError(f"{device.name} did not answer: {exc}") from exc
return _soap_values(text)
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
await self._soap(device, "avtransport", "SetAVTransportURI",
{"InstanceID": 0, "CurrentURI": url,
"CurrentURIMetaData": didl(url, title, container)})
await self._soap(device, "avtransport", "Play", {"InstanceID": 0, "Speed": "1"})
log.info("dlna: %s playing %s", device.name, url)
async def stop(self, device: Device) -> None:
try:
await self._soap(device, "avtransport", "Stop", {"InstanceID": 0})
except BackendError as exc:
log.debug("dlna stop: %s", exc)
async def transport(self, device: Device, action: str) -> None:
if action == "pause":
await self._soap(device, "avtransport", "Pause", {"InstanceID": 0})
elif action == "play":
await self._soap(device, "avtransport", "Play", {"InstanceID": 0, "Speed": "1"})
elif action == "stop":
await self.stop(device)
else:
raise BackendError(f"unknown transport action {action}")
async def set_volume(self, device: Device, volume: float) -> None:
await self._soap(device, "rendering", "SetVolume",
{"InstanceID": 0, "Channel": "Master",
"DesiredVolume": int(max(0.0, min(1.0, volume)) * 100)})
async def set_muted(self, device: Device, muted: bool) -> None:
await self._soap(device, "rendering", "SetMute",
{"InstanceID": 0, "Channel": "Master", "DesiredMute": 1 if muted else 0})
async def poll(self, device: Device) -> dict:
out: dict = {}
try:
info = await self._soap(device, "avtransport", "GetTransportInfo", {"InstanceID": 0})
state = (info.get("CurrentTransportState") or "").lower()
out["playerState"] = {"playing": "playing", "paused_playback": "paused",
"stopped": "idle", "transitioning": "buffering"}.get(state, state)
out["connected"] = True
except BackendError:
out["connected"] = False
if (device.extra.get("control") or {}).get("rendering"):
try:
vol = await self._soap(device, "rendering", "GetVolume",
{"InstanceID": 0, "Channel": "Master"})
out["volume"] = int(vol.get("CurrentVolume", 0)) / 100.0
except BackendError:
pass
return out
async def close(self) -> None:
if self._session is not None and not self._session.closed:
await self._session.close()
def _soap_values(text: str) -> dict:
try:
root = ET.fromstring(text)
except ET.ParseError:
return {}
values = {}
for body in root.iter():
if body.tag.endswith("Body"):
for resp in body:
for child in resp:
values[child.tag.split("}")[-1]] = child.text or ""
return values
def _fault(text: str) -> str:
try:
root = ET.fromstring(text)
except ET.ParseError:
return ""
for el in root.iter():
if el.tag.endswith("errorDescription"):
return el.text or ""
return ""