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

137 lines
5.0 KiB
Python

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