Files
alan 1ed57291f9 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.
2026-08-29 19:57:54 +01:00

248 lines
9.9 KiB
Python

"""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))