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:
2026-08-29 19:57:54 +01:00
commit 1ed57291f9
24 changed files with 5086 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
"""User settings, persisted to ~/.config/screencast/config.json."""
from __future__ import annotations
from .util import config_dir, read_json, write_json
DEFAULTS = {
"fps": 30,
"maxHeight": 1080, # the capture is scaled down to this before encoding
"bitrate": 8000, # kbit/s
"encoder": "auto", # auto | nvenc | vaapi | x264 | vp8 | vp9
"audio": True, # mix desktop audio into the stream
"cursor": True, # draw the pointer into the capture
"port": 8011, # local HTTP server the receiver pulls the stream from
"lastDevice": "", # id of the device cast to last, for the "cast again" default
"autoReconnect": True, # restart the pipeline if the receiver drops the stream
"portVerified": False, # set once a receiver has actually fetched the stream
}
# Settings that only take effect on the next session (changing them mid-cast
# restarts the pipeline instead of being ignored).
PIPELINE_KEYS = {"fps", "maxHeight", "bitrate", "encoder", "audio", "cursor"}
class Config:
def __init__(self) -> None:
self.path = config_dir() / "config.json"
self.values = dict(DEFAULTS)
self.values.update(read_json(self.path, {}) or {})
def __getitem__(self, key):
return self.values.get(key, DEFAULTS.get(key))
def get(self, key, default=None):
return self.values.get(key, DEFAULTS.get(key, default))
def update(self, patch: dict) -> set[str]:
"""Apply a patch, returning the keys that actually changed."""
changed = set()
for key, value in (patch or {}).items():
if key not in DEFAULTS:
continue
want = _coerce(key, value)
if want is None or self.values.get(key) == want:
continue
self.values[key] = want
changed.add(key)
if changed:
self.save()
return changed
def save(self) -> None:
write_json(self.path, self.values)
def _coerce(key, value):
ref = DEFAULTS[key]
try:
if isinstance(ref, bool):
return bool(value)
if isinstance(ref, int):
v = int(value)
if key == "fps":
return max(5, min(60, v))
if key == "maxHeight":
return max(360, min(2160, v))
if key == "bitrate":
return max(500, min(50000, v))
if key == "port":
return max(1024, min(65535, v))
return v
return str(value)
except (TypeError, ValueError):
return None