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:
@@ -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))
|
||||
Reference in New Issue
Block a user