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