1ed57291f9
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.
137 lines
5.0 KiB
Python
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()
|