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