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,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
*.log
|
||||||
|
scripts/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Alan Silva
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Screencast
|
||||||
|
|
||||||
|
An [Omarchy](https://omarchy.org) shell plugin that finds the displays on your
|
||||||
|
network and casts this desktop to them, with the controls in the bar.
|
||||||
|
|
||||||
|
Google Cast (Chromecast, Google TV, Nest), DLNA/UPnP TVs, and AirPlay video.
|
||||||
|
The screen is captured through the `xdg-desktop-portal` ScreenCast interface,
|
||||||
|
encoded with GStreamer, and served from a local HTTP port that the receiver
|
||||||
|
pulls from. Desktop audio is mixed in from the default output's monitor.
|
||||||
|
|
||||||
|
> **Status: early. Looking for testers.** It works here on Cast; DLNA and
|
||||||
|
> AirPlay have had far less exercise. Bug reports welcome.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
omarchy plugin add https://github.com/alanfortlink/omarchy-screencast --enable
|
||||||
|
```
|
||||||
|
|
||||||
|
On first run the plugin builds a private virtualenv under
|
||||||
|
`~/.local/lib/screencast` for its daemon. That needs no password. It does need
|
||||||
|
these Arch packages, and it will tell you in the panel if any are missing:
|
||||||
|
|
||||||
|
```
|
||||||
|
gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-pipewire
|
||||||
|
gst-libav xdg-desktop-portal xdg-desktop-portal-hyprland
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove with `omarchy plugin remove io.github.alanfortlink.screencast`.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
Click the cast icon (), click a display. The portal asks which screen to
|
||||||
|
share every time — the choice is deliberately never remembered. While casting
|
||||||
|
you get stop, pause, change-screen, a live preview and the receiver's volume.
|
||||||
|
Quality, bitrate, desktop audio and pointer visibility are at the bottom of the
|
||||||
|
panel.
|
||||||
|
|
||||||
|
Keys while the panel is open: `↑`/`↓` and `Enter` to cast, `r` rescan, `s` stop,
|
||||||
|
`p` pause, `Esc` close.
|
||||||
|
|
||||||
|
For a keybinding:
|
||||||
|
|
||||||
|
```
|
||||||
|
bind = SUPER SHIFT, C, exec, omarchy-shell io.github.alanfortlink.screencast cast "TV name"
|
||||||
|
bind = SUPER SHIFT, X, exec, omarchy-shell io.github.alanfortlink.screencast stop
|
||||||
|
```
|
||||||
|
|
||||||
|
The same from a terminal: `screencast-server devices | cast "TV name" | status |
|
||||||
|
stop | pair "TV name"`.
|
||||||
|
|
||||||
|
Settings live in `~/.config/screencast/config.json` (`fps`, `maxHeight`,
|
||||||
|
`bitrate`, `encoder`, `audio`, `cursor`, `port`, `autoReconnect`) and the panel
|
||||||
|
writes the same file.
|
||||||
|
|
||||||
|
## Two things to know
|
||||||
|
|
||||||
|
**It is not low latency.** Cast, DLNA and AirPlay video are *pull* protocols:
|
||||||
|
you hand the device a URL and it buffers. Expect one to three seconds, same as
|
||||||
|
casting a tab from a phone. Fine for a film, a talk or a dashboard; wrong for
|
||||||
|
playing a game on the TV. Low-latency mirroring means Miracast or real AirPlay
|
||||||
|
mirroring, neither of which has a working Linux sender.
|
||||||
|
|
||||||
|
**The receiver connects to you**, so a default-deny inbound firewall — which
|
||||||
|
Omarchy sets up — silently drops it. The panel notices and offers a button that
|
||||||
|
opens the port for your subnet only, through one polkit password dialog.
|
||||||
|
Nothing to type.
|
||||||
|
|
||||||
|
AirPlay here is AirPlay *video*, not mirroring (mirroring needs Apple's
|
||||||
|
FairPlay handshake). Apple TVs and most AirPlay 2 TVs accept it; some want
|
||||||
|
pairing first, via the **Pair** button on the row. Support comes from `pyatv`,
|
||||||
|
which is installed best-effort — if it fails, Cast and DLNA carry on.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
* **Nothing found.** mDNS and SSDP do not cross subnets, VPNs, or the client
|
||||||
|
isolation some routers apply to Wi-Fi. Compare with
|
||||||
|
`avahi-browse -rt _googlecast._tcp`.
|
||||||
|
* **The TV spins forever.** Almost always the firewall above.
|
||||||
|
* **Stutter.** Lower the bitrate or resolution; Wi-Fi is usually the limit.
|
||||||
|
Cast is the one path that encodes VP8 on the CPU.
|
||||||
|
* **Logs.** `~/.local/state/screencast/daemon.log`, and
|
||||||
|
`~/.cache/screencast/install.log` for the install.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# AirPlay support. Optional: pyatv is heavy and lags new Python releases, so the
|
||||||
|
# installer treats a failure here as "AirPlay unavailable", not a broken install.
|
||||||
|
pyatv>=0.14
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Runtime dependencies of screencast-server (installed into ~/.local/lib/screencast/venv).
|
||||||
|
aiohttp>=3.9
|
||||||
|
zeroconf>=0.130
|
||||||
|
pychromecast>=13
|
||||||
|
dbus-fast>=2.21
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""screencast-server: discover network displays and cast this desktop to them."""
|
||||||
|
|
||||||
|
VERSION = "0.1.0"
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""Command line entry point.
|
||||||
|
|
||||||
|
screencast-server run the daemon the shell plugin starts
|
||||||
|
screencast-server devices list what is on the network
|
||||||
|
screencast-server cast "<name>" start casting the screen
|
||||||
|
screencast-server stop | status
|
||||||
|
screencast-server pair "<name>" AirPlay PIN pairing
|
||||||
|
screencast-server set fps=30 bitrate=8000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import VERSION
|
||||||
|
from .server import Daemon
|
||||||
|
from .util import log, runtime_dir, setup_logging
|
||||||
|
|
||||||
|
|
||||||
|
async def _call(msg: dict, *, wait_state: bool = True, timeout: float = 200.0) -> dict:
|
||||||
|
"""Send one command to the running daemon and return its reply."""
|
||||||
|
path = runtime_dir() / "ctl.sock"
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.open_unix_connection(str(path))
|
||||||
|
except (FileNotFoundError, ConnectionRefusedError):
|
||||||
|
print("screencast-server is not running (start it with: screencast-server run)", file=sys.stderr)
|
||||||
|
raise SystemExit(4)
|
||||||
|
state: dict = {}
|
||||||
|
writer.write((json.dumps(msg) + "\n").encode())
|
||||||
|
await writer.drain()
|
||||||
|
deadline = asyncio.get_running_loop().time() + timeout
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
remaining = deadline - asyncio.get_running_loop().time()
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
line = await asyncio.wait_for(reader.readline(), remaining)
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
data = json.loads(line)
|
||||||
|
if data.get("type") == "state":
|
||||||
|
state = data
|
||||||
|
if msg.get("cmd") == "get" and wait_state:
|
||||||
|
break
|
||||||
|
elif data.get("type") == "reply":
|
||||||
|
data["state"] = state
|
||||||
|
return data
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
return {"ok": True, "state": state}
|
||||||
|
|
||||||
|
|
||||||
|
def _print_devices(state: dict) -> None:
|
||||||
|
devices = state.get("devices") or []
|
||||||
|
if not devices:
|
||||||
|
print("no receivers found yet")
|
||||||
|
return
|
||||||
|
width = max(len(d["name"]) for d in devices)
|
||||||
|
for dev in devices:
|
||||||
|
extra = f" · {dev['app']}" if dev.get("app") else ""
|
||||||
|
print(f"{dev['name']:<{width}} {dev['kindLabel']:<12} {dev['host']:<15} {dev['id']}{extra}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_status(state: dict) -> None:
|
||||||
|
session = state.get("session") or {}
|
||||||
|
if session.get("state") in (None, "", "idle"):
|
||||||
|
print("idle")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"{session['state']}: {session.get('deviceName', '')} "
|
||||||
|
f"({session.get('width')}x{session.get('height')} {session.get('encoder')}/"
|
||||||
|
f"{session.get('container')}, {session.get('clients')} client(s))"
|
||||||
|
)
|
||||||
|
if session.get("url"):
|
||||||
|
print(f" {session['url']}")
|
||||||
|
if session.get("error"):
|
||||||
|
print(f"error: {session['error']}")
|
||||||
|
elif state.get("error"):
|
||||||
|
print(f"error: {state['error']}")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="screencast-server", description=__doc__)
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true")
|
||||||
|
parser.add_argument("--version", action="version", version=VERSION)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
sub.add_parser("run", help="run the daemon")
|
||||||
|
sub.add_parser("devices", help="list receivers")
|
||||||
|
sub.add_parser("status", help="what is being cast")
|
||||||
|
sub.add_parser("stop", help="stop casting")
|
||||||
|
sub.add_parser("rescan", help="scan the network again")
|
||||||
|
sub.add_parser("open-firewall", help="let receivers reach the stream port (asks for a password)")
|
||||||
|
p_cast = sub.add_parser("cast", help="cast the screen to a receiver")
|
||||||
|
p_cast.add_argument("device", help="device id, or part of its name")
|
||||||
|
p_vol = sub.add_parser("volume", help="set the receiver volume (0-100)")
|
||||||
|
p_vol.add_argument("percent", type=float)
|
||||||
|
p_pair = sub.add_parser("pair", help="pair with an AirPlay receiver")
|
||||||
|
p_pair.add_argument("device")
|
||||||
|
p_set = sub.add_parser("set", help="change settings, e.g. fps=30 audio=false")
|
||||||
|
p_set.add_argument("assignments", nargs="+")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
setup_logging(args.verbose)
|
||||||
|
if args.command == "run":
|
||||||
|
try:
|
||||||
|
code = asyncio.run(Daemon().run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
code = 0
|
||||||
|
# Same reason as the timer in Daemon.shutdown: library threads would
|
||||||
|
# otherwise hold the process open after everything is closed.
|
||||||
|
sys.stdout.flush()
|
||||||
|
sys.stderr.flush()
|
||||||
|
os._exit(code)
|
||||||
|
|
||||||
|
if args.command == "devices":
|
||||||
|
state = asyncio.run(_call({"cmd": "rescan"}, timeout=8)).get("state") or {}
|
||||||
|
_print_devices(state)
|
||||||
|
return 0
|
||||||
|
if args.command == "status":
|
||||||
|
_print_status(asyncio.run(_call({"cmd": "get"}, timeout=8)).get("state") or {})
|
||||||
|
return 0
|
||||||
|
if args.command == "stop":
|
||||||
|
asyncio.run(_call({"cmd": "stop"}, timeout=20))
|
||||||
|
return 0
|
||||||
|
if args.command == "rescan":
|
||||||
|
_print_devices(asyncio.run(_call({"cmd": "rescan"}, timeout=10)).get("state") or {})
|
||||||
|
return 0
|
||||||
|
if args.command == "open-firewall":
|
||||||
|
reply = asyncio.run(_call({"cmd": "openFirewall"}, timeout=200))
|
||||||
|
if reply.get("ok"):
|
||||||
|
print(reply.get("note") or "the stream port is open")
|
||||||
|
return 0
|
||||||
|
print(reply.get("error") or "could not open the port", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if args.command == "cast":
|
||||||
|
state = asyncio.run(_cast(args.device))
|
||||||
|
_print_status(state)
|
||||||
|
return 0 if (state.get("session") or {}).get("state") == "streaming" else 1
|
||||||
|
if args.command == "volume":
|
||||||
|
reply = asyncio.run(_call({"cmd": "volume", "value": args.percent / 100.0}, timeout=15))
|
||||||
|
return 0 if reply.get("ok") else 1
|
||||||
|
if args.command == "pair":
|
||||||
|
return _pair(args.device)
|
||||||
|
if args.command == "set":
|
||||||
|
patch: dict = {}
|
||||||
|
for item in args.assignments:
|
||||||
|
if "=" not in item:
|
||||||
|
print(f"expected key=value, got “{item}”", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
key, value = item.split("=", 1)
|
||||||
|
if value.lower() in ("true", "false"):
|
||||||
|
patch[key] = value.lower() == "true"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
patch[key] = int(value)
|
||||||
|
except ValueError:
|
||||||
|
patch[key] = value
|
||||||
|
reply = asyncio.run(_call({"cmd": "set", "settings": patch}, timeout=15))
|
||||||
|
print(json.dumps((reply.get("state") or {}).get("settings", {}), indent=2))
|
||||||
|
return 0
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
async def _cast(device: str) -> dict:
|
||||||
|
"""Start the cast, then follow the state pushes until it settles."""
|
||||||
|
path = runtime_dir() / "ctl.sock"
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.open_unix_connection(str(path))
|
||||||
|
except (FileNotFoundError, ConnectionRefusedError):
|
||||||
|
print("screencast-server is not running", file=sys.stderr)
|
||||||
|
raise SystemExit(4)
|
||||||
|
writer.write((json.dumps({"cmd": "cast", "device": device}) + "\n").encode())
|
||||||
|
await writer.drain()
|
||||||
|
state: dict = {}
|
||||||
|
# The daemon pushes its state on connect, before it has seen the command:
|
||||||
|
# that first one says "idle" and must not end the wait.
|
||||||
|
first = True
|
||||||
|
deadline = asyncio.get_running_loop().time() + 300
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
remaining = deadline - asyncio.get_running_loop().time()
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
line = await asyncio.wait_for(reader.readline(), remaining)
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
data = json.loads(line)
|
||||||
|
if data.get("type") == "reply" and not data.get("ok"):
|
||||||
|
print(f"error: {data.get('error')}", file=sys.stderr)
|
||||||
|
break
|
||||||
|
if data.get("type") != "state":
|
||||||
|
continue
|
||||||
|
state = data
|
||||||
|
if first:
|
||||||
|
first = False
|
||||||
|
continue
|
||||||
|
session_state = (data.get("session") or {}).get("state", "")
|
||||||
|
if session_state in ("streaming", "error", "idle") and state.get("busy", "") == "":
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _pair(device: str) -> int:
|
||||||
|
reply = asyncio.run(_call({"cmd": "pair", "device": device}, timeout=30))
|
||||||
|
if not reply.get("ok"):
|
||||||
|
print(f"error: {reply.get('error')}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
pin = input("PIN shown on the receiver: ").strip()
|
||||||
|
reply = asyncio.run(_call({"cmd": "pairPin", "pin": pin}, timeout=30))
|
||||||
|
if not reply.get("ok"):
|
||||||
|
print(f"error: {reply.get('error') or 'pairing failed'}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print("paired")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except BrokenPipeError:
|
||||||
|
log.debug("stdout closed")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from .base import Backend, BackendError
|
||||||
|
from .cast import CastBackend
|
||||||
|
from .dlna import DlnaBackend
|
||||||
|
from .airplay import AirPlayBackend
|
||||||
|
|
||||||
|
__all__ = ["Backend", "BackendError", "CastBackend", "DlnaBackend", "AirPlayBackend", "for_kind"]
|
||||||
|
|
||||||
|
|
||||||
|
def for_kind(kind: str):
|
||||||
|
return {"cast": CastBackend, "dlna": DlnaBackend, "airplay": AirPlayBackend}.get(kind)
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""AirPlay, through pyatv.
|
||||||
|
|
||||||
|
Caveat worth knowing: AirPlay *mirroring* (the low-latency screen protocol Macs
|
||||||
|
and iPhones use) is closed and has no open sender implementation on Linux. What
|
||||||
|
does work is AirPlay video playback — the receiver is handed a URL and plays it
|
||||||
|
— so the screen goes out as a live HLS stream. That means several seconds of
|
||||||
|
delay, and a receiver that refuses live HLS refuses the cast.
|
||||||
|
|
||||||
|
pyatv is optional: without it this backend reports itself unavailable instead of
|
||||||
|
taking the whole daemon down.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
from ..devices import Device
|
||||||
|
from ..util import log, port_open, state_dir
|
||||||
|
from .base import Backend, BackendError
|
||||||
|
|
||||||
|
INSTALL_HINT = "AirPlay needs pyatv: ~/.local/lib/screencast/venv/bin/pip install pyatv"
|
||||||
|
|
||||||
|
|
||||||
|
async def _pingable(host: str) -> bool:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"ping", "-c1", "-W1", host,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
|
||||||
|
return await proc.wait() == 0
|
||||||
|
|
||||||
|
|
||||||
|
class AirPlayBackend(Backend):
|
||||||
|
kind = "airplay"
|
||||||
|
containers = ("hls",)
|
||||||
|
progressive = False
|
||||||
|
|
||||||
|
def __init__(self, daemon) -> None:
|
||||||
|
super().__init__(daemon)
|
||||||
|
self._atv = None
|
||||||
|
self._atv_id = ""
|
||||||
|
self._play_task: asyncio.Task | None = None
|
||||||
|
self._storage = None
|
||||||
|
self._pairing = None
|
||||||
|
self._pairing_device: Device | None = None
|
||||||
|
|
||||||
|
# ---- pyatv plumbing ----
|
||||||
|
@staticmethod
|
||||||
|
def available() -> bool:
|
||||||
|
try:
|
||||||
|
import pyatv # noqa: F401
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _storage_obj(self):
|
||||||
|
if self._storage is None:
|
||||||
|
from pyatv.storage.file_storage import FileStorage
|
||||||
|
|
||||||
|
path = state_dir() / "airplay.json"
|
||||||
|
self._storage = FileStorage(str(path), asyncio.get_running_loop())
|
||||||
|
await self._storage.load()
|
||||||
|
# pyatv writes this with a plain open(); it holds the credentials
|
||||||
|
# that let anyone play to the user's receivers.
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
path.touch()
|
||||||
|
path.chmod(0o600)
|
||||||
|
return self._storage
|
||||||
|
|
||||||
|
async def _config(self, device: Device):
|
||||||
|
"""Find the receiver again, and be specific about why when we cannot.
|
||||||
|
|
||||||
|
Televisions keep announcing `_airplay._tcp` over mDNS while the receiver
|
||||||
|
itself is switched off, so "it is in the list" says nothing about whether
|
||||||
|
it will talk to us. Check the port first: a refused connection is a
|
||||||
|
setting the user can change, and no amount of scanning will fix it.
|
||||||
|
"""
|
||||||
|
import pyatv
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
storage = await self._storage_obj()
|
||||||
|
port = device.port or 7000
|
||||||
|
if not await port_open(device.host, port):
|
||||||
|
reachable = await port_open(device.host, 80, 1.0) or await _pingable(device.host)
|
||||||
|
log.warning("airplay: %s has nothing listening on %d", device.name, port)
|
||||||
|
raise BackendError(
|
||||||
|
f"{device.name} is on the network but its AirPlay receiver is off — "
|
||||||
|
"turn AirPlay on in the TV's settings"
|
||||||
|
if reachable else
|
||||||
|
f"{device.name} is not answering — switch it on and try again"
|
||||||
|
)
|
||||||
|
# Unicast first (fast, and exact); some receivers only answer multicast.
|
||||||
|
for attempt, timeout in enumerate((5, 8), start=1):
|
||||||
|
confs = await pyatv.scan(loop, hosts=[device.host], timeout=timeout, storage=storage)
|
||||||
|
if confs:
|
||||||
|
if attempt > 1:
|
||||||
|
log.info("airplay: %s answered on attempt %d", device.name, attempt)
|
||||||
|
return confs[0]
|
||||||
|
log.warning("airplay: %s did not answer a unicast scan (%ds)", device.name, timeout)
|
||||||
|
confs = [c for c in await pyatv.scan(loop, timeout=8, storage=storage)
|
||||||
|
if str(c.address) == device.host]
|
||||||
|
if confs:
|
||||||
|
log.info("airplay: %s only answered the multicast scan", device.name)
|
||||||
|
return confs[0]
|
||||||
|
raise BackendError(
|
||||||
|
f"{device.name} accepts connections but did not identify itself — "
|
||||||
|
"check that AirPlay is on and the TV is awake"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _connect(self, device: Device):
|
||||||
|
if self._atv is not None and self._atv_id == device.id:
|
||||||
|
return self._atv
|
||||||
|
if self._atv is not None:
|
||||||
|
await self.stop(device) # a connection to a different receiver
|
||||||
|
if not self.available():
|
||||||
|
raise BackendError(INSTALL_HINT)
|
||||||
|
import pyatv
|
||||||
|
|
||||||
|
conf = await self._config(device)
|
||||||
|
try:
|
||||||
|
self._atv = await pyatv.connect(
|
||||||
|
conf, asyncio.get_running_loop(), storage=await self._storage_obj()
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("airplay connect to %s failed: %s", device.name, exc)
|
||||||
|
if any(w in str(exc).lower() for w in ("auth", "credential", "pair")):
|
||||||
|
raise BackendError(f"{device.name} needs pairing first") from exc
|
||||||
|
raise BackendError(f"could not connect to {device.name}: {exc}") from exc
|
||||||
|
self._atv_id = device.id
|
||||||
|
log.info("airplay: connected to %s", device.name)
|
||||||
|
return self._atv
|
||||||
|
|
||||||
|
# ---- casting ----
|
||||||
|
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
|
||||||
|
atv = await self._connect(device)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
try:
|
||||||
|
await atv.stream.play_url(url)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("airplay playback ended: %s", exc)
|
||||||
|
self.daemon.note_backend_error(f"{device.name}: {exc}")
|
||||||
|
|
||||||
|
# play_url only returns when playback ends, so it cannot be awaited here.
|
||||||
|
self._play_task = asyncio.create_task(run())
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
if self._play_task.done():
|
||||||
|
exc = self._play_task.exception()
|
||||||
|
if exc is not None:
|
||||||
|
raise BackendError(f"{device.name} refused the stream: {exc}")
|
||||||
|
log.info("airplay: %s playing %s", device.name, url)
|
||||||
|
|
||||||
|
async def stop(self, device: Device) -> None:
|
||||||
|
if self._play_task is not None:
|
||||||
|
self._play_task.cancel()
|
||||||
|
self._play_task = None
|
||||||
|
atv, self._atv, self._atv_id = self._atv, None, ""
|
||||||
|
if atv is not None:
|
||||||
|
try:
|
||||||
|
await atv.remote_control.stop()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
atv.close()
|
||||||
|
|
||||||
|
async def set_volume(self, device: Device, volume: float) -> None:
|
||||||
|
atv = await self._connect(device)
|
||||||
|
await atv.audio.set_volume(max(0.0, min(1.0, volume)) * 100.0)
|
||||||
|
|
||||||
|
async def transport(self, device: Device, action: str) -> None:
|
||||||
|
atv = await self._connect(device)
|
||||||
|
rc = atv.remote_control
|
||||||
|
fn = {"play": rc.play, "pause": rc.pause, "stop": rc.stop}.get(action)
|
||||||
|
if fn is None:
|
||||||
|
raise BackendError(f"unknown transport action {action}")
|
||||||
|
await fn()
|
||||||
|
|
||||||
|
async def poll(self, device: Device) -> dict:
|
||||||
|
atv = self._atv
|
||||||
|
if atv is None:
|
||||||
|
return {}
|
||||||
|
out: dict = {"connected": True}
|
||||||
|
try:
|
||||||
|
playing = await atv.metadata.playing()
|
||||||
|
out["playerState"] = str(playing.device_state).split(".")[-1].lower()
|
||||||
|
out["app"] = playing.title or ""
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
out["volume"] = float(atv.audio.volume) / 100.0
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ---- pairing (PIN shown on the TV, typed into the panel) ----
|
||||||
|
async def pair_begin(self, device: Device) -> None:
|
||||||
|
if not self.available():
|
||||||
|
raise BackendError(INSTALL_HINT)
|
||||||
|
import pyatv
|
||||||
|
from pyatv.const import Protocol
|
||||||
|
|
||||||
|
await self.pair_cancel()
|
||||||
|
conf = await self._config(device)
|
||||||
|
self._pairing = await pyatv.pair(
|
||||||
|
conf, Protocol.AirPlay, asyncio.get_running_loop(), storage=await self._storage_obj()
|
||||||
|
)
|
||||||
|
await self._pairing.begin()
|
||||||
|
self._pairing_device = device
|
||||||
|
log.info("airplay: pairing with %s (%s)", device.name,
|
||||||
|
"PIN on the TV" if self._pairing.device_provides_pin else "no PIN needed")
|
||||||
|
if not self._pairing.device_provides_pin:
|
||||||
|
# Some receivers want *us* to show a PIN; nothing to type in that case.
|
||||||
|
await self._pairing.finish()
|
||||||
|
|
||||||
|
async def pair_pin(self, pin: str) -> bool:
|
||||||
|
if self._pairing is None:
|
||||||
|
raise BackendError("no pairing in progress")
|
||||||
|
name = self._pairing_device.name if self._pairing_device else "the receiver"
|
||||||
|
self._pairing.pin(pin)
|
||||||
|
try:
|
||||||
|
await self._pairing.finish()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("airplay pairing with %s failed: %s", name, exc)
|
||||||
|
await self.pair_cancel()
|
||||||
|
raise BackendError(f"{name} rejected the code: {exc}") from exc
|
||||||
|
ok = bool(self._pairing.has_paired)
|
||||||
|
log.info("airplay: pairing with %s %s", name, "succeeded" if ok else "was refused")
|
||||||
|
if ok and self._storage is not None:
|
||||||
|
await self._storage.save()
|
||||||
|
# Pairing leaves its own connection behind; drop ours so the next cast
|
||||||
|
# reconnects with the credentials we just stored.
|
||||||
|
self._atv, self._atv_id = None, ""
|
||||||
|
await self.pair_cancel()
|
||||||
|
return ok
|
||||||
|
|
||||||
|
async def pair_cancel(self) -> None:
|
||||||
|
pairing, self._pairing = self._pairing, None
|
||||||
|
self._pairing_device = None
|
||||||
|
if pairing is not None:
|
||||||
|
try:
|
||||||
|
await pairing.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self.pair_cancel()
|
||||||
|
if self._atv is not None or self._play_task is not None:
|
||||||
|
await self.stop(Device(id="", kind="airplay", name="", host="", port=0))
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""What every receiver protocol has to provide."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..devices import Device
|
||||||
|
|
||||||
|
|
||||||
|
class BackendError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Backend:
|
||||||
|
kind = ""
|
||||||
|
#: Containers this protocol can be handed, best first.
|
||||||
|
containers: tuple[str, ...] = ("webm",)
|
||||||
|
#: Does it pull a plain HTTP stream (True) or an HLS playlist (False)?
|
||||||
|
progressive = True
|
||||||
|
|
||||||
|
def __init__(self, daemon) -> None:
|
||||||
|
self.daemon = daemon
|
||||||
|
|
||||||
|
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def stop(self, device: Device) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def set_volume(self, device: Device, volume: float) -> None:
|
||||||
|
raise BackendError("this receiver has no volume control")
|
||||||
|
|
||||||
|
async def set_muted(self, device: Device, muted: bool) -> None:
|
||||||
|
raise BackendError("this receiver has no mute control")
|
||||||
|
|
||||||
|
async def transport(self, device: Device, action: str) -> None:
|
||||||
|
raise BackendError("this receiver has no transport control")
|
||||||
|
|
||||||
|
async def poll(self, device: Device) -> dict:
|
||||||
|
"""Live status of the session: {state, app, volume, muted, error}."""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
return None
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""DLNA / UPnP MediaRenderer.
|
||||||
|
|
||||||
|
A renderer is told to fetch a URL (SetAVTransportURI + Play) and polled for what
|
||||||
|
it is doing. Most TVs handle a live MPEG-TS stream this way; some refuse
|
||||||
|
anything without a duration, which shows up as a Play error.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from ..devices import Device
|
||||||
|
from ..util import log
|
||||||
|
from .base import Backend, BackendError
|
||||||
|
|
||||||
|
SOAP_ENV = (
|
||||||
|
'<?xml version="1.0" encoding="utf-8"?>'
|
||||||
|
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"'
|
||||||
|
' s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>{body}</s:Body></s:Envelope>'
|
||||||
|
)
|
||||||
|
|
||||||
|
PROTOCOL_INFO = {
|
||||||
|
"mpegts": "http-get:*:video/mp2t:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=8D500000000000000000000000000000",
|
||||||
|
"mp4": "http-get:*:video/mp4:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=8D500000000000000000000000000000",
|
||||||
|
"webm": "http-get:*:video/webm:*",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def didl(url: str, title: str, container: str) -> str:
|
||||||
|
return (
|
||||||
|
'<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"'
|
||||||
|
' xmlns:dc="http://purl.org/dc/elements/1.1/"'
|
||||||
|
' xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">'
|
||||||
|
'<item id="0" parentID="-1" restricted="1">'
|
||||||
|
f"<dc:title>{escape(title)}</dc:title>"
|
||||||
|
"<upnp:class>object.item.videoItem.movie</upnp:class>"
|
||||||
|
f'<res protocolInfo="{PROTOCOL_INFO.get(container, PROTOCOL_INFO["mpegts"])}">{escape(url)}</res>'
|
||||||
|
"</item></DIDL-Lite>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DlnaBackend(Backend):
|
||||||
|
kind = "dlna"
|
||||||
|
# MPEG-TS: a renderer can start on any packet, which is what a live stream
|
||||||
|
# needs (see the note in cast.py about fragmented MP4).
|
||||||
|
containers = ("mpegts",)
|
||||||
|
|
||||||
|
def __init__(self, daemon) -> None:
|
||||||
|
super().__init__(daemon)
|
||||||
|
self._session: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
|
async def _http(self) -> aiohttp.ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def _soap(self, device: Device, service: str, action: str, args: dict) -> dict:
|
||||||
|
control = device.extra.get("control") or {}
|
||||||
|
url = control.get(service)
|
||||||
|
stype = control.get(f"{service}_type")
|
||||||
|
if not url or not stype:
|
||||||
|
raise BackendError(f"{device.name} has no {service} service")
|
||||||
|
# stype comes out of the device's own description XML.
|
||||||
|
body = f'<u:{action} xmlns:u="{escape(stype, quote=True)}">' + "".join(
|
||||||
|
f"<{k}>{escape(str(v))}</{k}>" for k, v in args.items()
|
||||||
|
) + f"</u:{action}>"
|
||||||
|
payload = SOAP_ENV.format(body=body).encode()
|
||||||
|
headers = {
|
||||||
|
"Content-Type": 'text/xml; charset="utf-8"',
|
||||||
|
"SOAPAction": f'"{stype}#{action}"',
|
||||||
|
"Connection": "close",
|
||||||
|
}
|
||||||
|
session = await self._http()
|
||||||
|
try:
|
||||||
|
async with session.post(url, data=payload, headers=headers) as resp:
|
||||||
|
text = (await resp.content.read(256 << 10)).decode("utf-8", "replace")
|
||||||
|
if resp.status >= 400:
|
||||||
|
raise BackendError(f"{action} failed: {_fault(text) or resp.status}")
|
||||||
|
except aiohttp.ClientError as exc:
|
||||||
|
raise BackendError(f"{device.name} did not answer: {exc}") from exc
|
||||||
|
return _soap_values(text)
|
||||||
|
|
||||||
|
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
|
||||||
|
await self._soap(device, "avtransport", "SetAVTransportURI",
|
||||||
|
{"InstanceID": 0, "CurrentURI": url,
|
||||||
|
"CurrentURIMetaData": didl(url, title, container)})
|
||||||
|
await self._soap(device, "avtransport", "Play", {"InstanceID": 0, "Speed": "1"})
|
||||||
|
log.info("dlna: %s playing %s", device.name, url)
|
||||||
|
|
||||||
|
async def stop(self, device: Device) -> None:
|
||||||
|
try:
|
||||||
|
await self._soap(device, "avtransport", "Stop", {"InstanceID": 0})
|
||||||
|
except BackendError as exc:
|
||||||
|
log.debug("dlna stop: %s", exc)
|
||||||
|
|
||||||
|
async def transport(self, device: Device, action: str) -> None:
|
||||||
|
if action == "pause":
|
||||||
|
await self._soap(device, "avtransport", "Pause", {"InstanceID": 0})
|
||||||
|
elif action == "play":
|
||||||
|
await self._soap(device, "avtransport", "Play", {"InstanceID": 0, "Speed": "1"})
|
||||||
|
elif action == "stop":
|
||||||
|
await self.stop(device)
|
||||||
|
else:
|
||||||
|
raise BackendError(f"unknown transport action {action}")
|
||||||
|
|
||||||
|
async def set_volume(self, device: Device, volume: float) -> None:
|
||||||
|
await self._soap(device, "rendering", "SetVolume",
|
||||||
|
{"InstanceID": 0, "Channel": "Master",
|
||||||
|
"DesiredVolume": int(max(0.0, min(1.0, volume)) * 100)})
|
||||||
|
|
||||||
|
async def set_muted(self, device: Device, muted: bool) -> None:
|
||||||
|
await self._soap(device, "rendering", "SetMute",
|
||||||
|
{"InstanceID": 0, "Channel": "Master", "DesiredMute": 1 if muted else 0})
|
||||||
|
|
||||||
|
async def poll(self, device: Device) -> dict:
|
||||||
|
out: dict = {}
|
||||||
|
try:
|
||||||
|
info = await self._soap(device, "avtransport", "GetTransportInfo", {"InstanceID": 0})
|
||||||
|
state = (info.get("CurrentTransportState") or "").lower()
|
||||||
|
out["playerState"] = {"playing": "playing", "paused_playback": "paused",
|
||||||
|
"stopped": "idle", "transitioning": "buffering"}.get(state, state)
|
||||||
|
out["connected"] = True
|
||||||
|
except BackendError:
|
||||||
|
out["connected"] = False
|
||||||
|
if (device.extra.get("control") or {}).get("rendering"):
|
||||||
|
try:
|
||||||
|
vol = await self._soap(device, "rendering", "GetVolume",
|
||||||
|
{"InstanceID": 0, "Channel": "Master"})
|
||||||
|
out["volume"] = int(vol.get("CurrentVolume", 0)) / 100.0
|
||||||
|
except BackendError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._session is not None and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _soap_values(text: str) -> dict:
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(text)
|
||||||
|
except ET.ParseError:
|
||||||
|
return {}
|
||||||
|
values = {}
|
||||||
|
for body in root.iter():
|
||||||
|
if body.tag.endswith("Body"):
|
||||||
|
for resp in body:
|
||||||
|
for child in resp:
|
||||||
|
values[child.tag.split("}")[-1]] = child.text or ""
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _fault(text: str) -> str:
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(text)
|
||||||
|
except ET.ParseError:
|
||||||
|
return ""
|
||||||
|
for el in root.iter():
|
||||||
|
if el.tag.endswith("errorDescription"):
|
||||||
|
return el.text or ""
|
||||||
|
return ""
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
"""Screen capture and encoding.
|
||||||
|
|
||||||
|
Wayland gives no direct screen access, so the picture comes from the
|
||||||
|
xdg-desktop-portal ScreenCast interface: the portal asks which screen to share -
|
||||||
|
every time, deliberately, since nothing here is worth sharing by accident - and
|
||||||
|
hands back a PipeWire node and a fd for it. GStreamer reads that node, encodes,
|
||||||
|
and writes the muxed stream to a pipe the HTTP server fans out to the receiver.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
from dbus_fast import BusType, Message, MessageType, Variant
|
||||||
|
from dbus_fast.aio import MessageBus
|
||||||
|
|
||||||
|
from .util import gst_has, log, runtime_dir, state_dir
|
||||||
|
|
||||||
|
JPEG_START = b"\xff\xd8\xff"
|
||||||
|
JPEG_END = b"\xff\xd9"
|
||||||
|
|
||||||
|
PORTAL = "org.freedesktop.portal.Desktop"
|
||||||
|
PORTAL_PATH = "/org/freedesktop/portal/desktop"
|
||||||
|
SCREENCAST = "org.freedesktop.portal.ScreenCast"
|
||||||
|
|
||||||
|
SOURCE_MONITOR = 1
|
||||||
|
SOURCE_WINDOW = 2
|
||||||
|
CURSOR_HIDDEN, CURSOR_EMBEDDED = 1, 2
|
||||||
|
PERSIST_NONE = 0 # never remember a screen: every cast asks again
|
||||||
|
|
||||||
|
|
||||||
|
class PortalError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PortalStream:
|
||||||
|
def __init__(self, node_id: int, fd: int, props: dict) -> None:
|
||||||
|
self.node_id = node_id
|
||||||
|
self.fd = fd
|
||||||
|
self.props = props
|
||||||
|
size = props.get("size")
|
||||||
|
self.width, self.height = (int(size[0]), int(size[1])) if size else (0, 0)
|
||||||
|
self.source_type = int(props.get("source_type", SOURCE_MONITOR))
|
||||||
|
self.name = str(props.get("id", "")) or ("Window" if self.source_type == SOURCE_WINDOW else "Screen")
|
||||||
|
|
||||||
|
|
||||||
|
class Portal:
|
||||||
|
"""One ScreenCast session. The bus connection must outlive the cast."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.bus: MessageBus | None = None
|
||||||
|
self.session: str | None = None
|
||||||
|
self._pending: dict[str, asyncio.Future] = {}
|
||||||
|
|
||||||
|
async def _connect(self) -> MessageBus:
|
||||||
|
if self.bus is None or self.bus._disconnected: # noqa: SLF001 - no public flag
|
||||||
|
self.bus = await MessageBus(bus_type=BusType.SESSION, negotiate_unix_fd=True).connect()
|
||||||
|
await self.bus.call(
|
||||||
|
Message(
|
||||||
|
destination="org.freedesktop.DBus",
|
||||||
|
path="/org/freedesktop/DBus",
|
||||||
|
interface="org.freedesktop.DBus",
|
||||||
|
member="AddMatch",
|
||||||
|
signature="s",
|
||||||
|
body=[
|
||||||
|
"type='signal',interface='org.freedesktop.portal.Request',member='Response'"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.bus.add_message_handler(self._on_signal)
|
||||||
|
return self.bus
|
||||||
|
|
||||||
|
def _on_signal(self, msg: Message):
|
||||||
|
if msg.message_type is not MessageType.SIGNAL or msg.member != "Response":
|
||||||
|
return
|
||||||
|
fut = self._pending.pop(msg.path, None)
|
||||||
|
if fut and not fut.done():
|
||||||
|
code, results = msg.body[0], msg.body[1]
|
||||||
|
fut.set_result((int(code), {k: v.value for k, v in results.items()}))
|
||||||
|
|
||||||
|
def _request_path(self, token: str) -> str:
|
||||||
|
sender = self.bus.unique_name[1:].replace(".", "_")
|
||||||
|
return f"{PORTAL_PATH}/request/{sender}/{token}"
|
||||||
|
|
||||||
|
async def _call(self, member: str, signature: str, body: list, options: dict, timeout: float):
|
||||||
|
"""Call a portal method and wait for the Request.Response it answers with."""
|
||||||
|
token = "sc" + secrets.token_hex(8)
|
||||||
|
options = dict(options)
|
||||||
|
options["handle_token"] = Variant("s", token)
|
||||||
|
path = self._request_path(token)
|
||||||
|
fut: asyncio.Future = asyncio.get_running_loop().create_future()
|
||||||
|
self._pending[path] = fut
|
||||||
|
reply = await self.bus.call(
|
||||||
|
Message(
|
||||||
|
destination=PORTAL,
|
||||||
|
path=PORTAL_PATH,
|
||||||
|
interface=SCREENCAST,
|
||||||
|
member=member,
|
||||||
|
signature=signature,
|
||||||
|
body=body + [options],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if reply.message_type is MessageType.ERROR:
|
||||||
|
self._pending.pop(path, None)
|
||||||
|
raise PortalError(f"{member}: {reply.body[0] if reply.body else reply.error_name}")
|
||||||
|
try:
|
||||||
|
code, results = await asyncio.wait_for(fut, timeout)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
self._pending.pop(path, None)
|
||||||
|
raise PortalError("no screen was picked (the share dialog was left unanswered)") from exc
|
||||||
|
if code == 1:
|
||||||
|
raise PortalError("no screen was picked (the share dialog was dismissed)")
|
||||||
|
if code != 0:
|
||||||
|
raise PortalError(f"{member}: portal returned {code}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def open(self, *, cursor: bool, allow_windows: bool = True) -> PortalStream:
|
||||||
|
"""Start a capture session. The portal always asks which screen to share."""
|
||||||
|
await self._connect()
|
||||||
|
stoken = "sc" + secrets.token_hex(8)
|
||||||
|
results = await self._call(
|
||||||
|
"CreateSession", "a{sv}", [], {"session_handle_token": Variant("s", stoken)}, 20
|
||||||
|
)
|
||||||
|
self.session = results.get("session_handle") or f"{PORTAL_PATH}/session/{self.bus.unique_name[1:].replace('.', '_')}/{stoken}"
|
||||||
|
|
||||||
|
types = SOURCE_MONITOR | (SOURCE_WINDOW if allow_windows else 0)
|
||||||
|
opts = {
|
||||||
|
"types": Variant("u", types),
|
||||||
|
"multiple": Variant("b", False),
|
||||||
|
"cursor_mode": Variant("u", CURSOR_EMBEDDED if cursor else CURSOR_HIDDEN),
|
||||||
|
"persist_mode": Variant("u", PERSIST_NONE),
|
||||||
|
}
|
||||||
|
# xdg-desktop-portal-hyprland shows its share picker during
|
||||||
|
# SelectSources, not Start, so this is the call that sits waiting for a
|
||||||
|
# human. Other portals prompt on Start; both get room.
|
||||||
|
await self._call("SelectSources", "oa{sv}", [self.session], opts, 180)
|
||||||
|
results = await self._call("Start", "osa{sv}", [self.session, ""], {}, 180)
|
||||||
|
streams = results.get("streams") or []
|
||||||
|
if not streams:
|
||||||
|
raise PortalError("the portal returned no stream")
|
||||||
|
node_id, props = streams[0][0], {k: v.value if isinstance(v, Variant) else v
|
||||||
|
for k, v in streams[0][1].items()}
|
||||||
|
reply = await self.bus.call(
|
||||||
|
Message(
|
||||||
|
destination=PORTAL,
|
||||||
|
path=PORTAL_PATH,
|
||||||
|
interface=SCREENCAST,
|
||||||
|
member="OpenPipeWireRemote",
|
||||||
|
signature="oa{sv}",
|
||||||
|
body=[self.session, {}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if reply.message_type is MessageType.ERROR:
|
||||||
|
raise PortalError(f"OpenPipeWireRemote: {reply.error_name}")
|
||||||
|
fd = reply.unix_fds[reply.body[0]]
|
||||||
|
return PortalStream(int(node_id), fd, props)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self.bus is not None and self.session:
|
||||||
|
try:
|
||||||
|
await self.bus.call(
|
||||||
|
Message(
|
||||||
|
destination=PORTAL,
|
||||||
|
path=self.session,
|
||||||
|
interface="org.freedesktop.portal.Session",
|
||||||
|
member="Close",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - the session may already be gone
|
||||||
|
pass
|
||||||
|
self.session = None
|
||||||
|
if self.bus is not None:
|
||||||
|
self.bus.disconnect()
|
||||||
|
self.bus = None
|
||||||
|
|
||||||
|
|
||||||
|
def drop_legacy_token() -> None:
|
||||||
|
"""Earlier versions remembered the shared screen; delete what they stored."""
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
(state_dir() / "restore-token.json").unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def pick_encoder(preference: str, container: str) -> str:
|
||||||
|
"""Resolve "auto" against what this machine's GStreamer actually has."""
|
||||||
|
if container == "webm":
|
||||||
|
wanted = ["vp8", "vp9"] if preference in ("auto", "vp8", "nvenc", "vaapi", "x264") else [preference]
|
||||||
|
for enc in wanted:
|
||||||
|
if gst_has({"vp8": "vp8enc", "vp9": "vp9enc"}[enc]):
|
||||||
|
return enc
|
||||||
|
raise PortalError("no VP8/VP9 encoder (install gst-plugins-good)")
|
||||||
|
order = {
|
||||||
|
"auto": ["nvenc", "vaapi", "x264", "openh264"],
|
||||||
|
"nvenc": ["nvenc", "vaapi", "x264", "openh264"],
|
||||||
|
"vaapi": ["vaapi", "nvenc", "x264", "openh264"],
|
||||||
|
"x264": ["x264", "openh264", "nvenc", "vaapi"],
|
||||||
|
}.get(preference, ["nvenc", "vaapi", "x264", "openh264"])
|
||||||
|
elements = {"nvenc": "nvh264enc", "vaapi": "vah264enc", "x264": "x264enc", "openh264": "openh264enc"}
|
||||||
|
for enc in order:
|
||||||
|
if gst_has(elements[enc]):
|
||||||
|
return enc
|
||||||
|
raise PortalError("no H.264 encoder (install gst-plugins-ugly or gst-plugin-va)")
|
||||||
|
|
||||||
|
|
||||||
|
def video_encoder_chain(encoder: str, bitrate_kbps: int, fps: int, *,
|
||||||
|
gpu_scale: bool = False) -> list[str]:
|
||||||
|
# Half a second between keyframes. It costs a little bitrate and buys two
|
||||||
|
# things that matter more here: a receiver can start on the next keyframe
|
||||||
|
# instead of waiting a whole second, and the stream can be cut at one when a
|
||||||
|
# slow receiver has to be skipped forward (see Fanout).
|
||||||
|
gop = max(15, fps // 2)
|
||||||
|
if encoder == "nvenc":
|
||||||
|
upload = [] if gpu_scale else [
|
||||||
|
"cudaupload", "!", "cudaconvertscale", "!", "video/x-raw(memory:CUDAMemory),format=NV12", "!",
|
||||||
|
]
|
||||||
|
return upload + [
|
||||||
|
"nvh264enc", "name=venc", f"bitrate={bitrate_kbps}", f"gop-size={gop}",
|
||||||
|
"rc-mode=cbr", "preset=low-latency-hq", "zerolatency=true", "!",
|
||||||
|
"h264parse", "config-interval=-1",
|
||||||
|
]
|
||||||
|
if encoder == "vaapi":
|
||||||
|
return [
|
||||||
|
"vah264enc", "name=venc", f"bitrate={bitrate_kbps}", f"key-int-max={gop}",
|
||||||
|
"rate-control=cbr", "!", "h264parse", "config-interval=-1",
|
||||||
|
]
|
||||||
|
if encoder == "x264":
|
||||||
|
return [
|
||||||
|
"x264enc", "name=venc", f"bitrate={bitrate_kbps}", "tune=zerolatency",
|
||||||
|
"speed-preset=veryfast", f"key-int-max={gop}", "!", "h264parse", "config-interval=-1",
|
||||||
|
]
|
||||||
|
if encoder == "openh264":
|
||||||
|
return [
|
||||||
|
"openh264enc", "name=venc", f"bitrate={bitrate_kbps * 1000}", f"gop-size={gop}", "!",
|
||||||
|
"h264parse", "config-interval=-1",
|
||||||
|
]
|
||||||
|
if encoder == "vp8":
|
||||||
|
return [
|
||||||
|
"vp8enc", "name=venc", "deadline=1", "cpu-used=6", "threads=8", "end-usage=cbr",
|
||||||
|
f"target-bitrate={bitrate_kbps * 1000}", f"keyframe-max-dist={gop}", "error-resilient=1",
|
||||||
|
]
|
||||||
|
if encoder == "vp9":
|
||||||
|
return [
|
||||||
|
"vp9enc", "name=venc", "deadline=1", "cpu-used=8", "threads=8", "end-usage=cbr",
|
||||||
|
f"target-bitrate={bitrate_kbps * 1000}", f"keyframe-max-dist={gop}",
|
||||||
|
]
|
||||||
|
raise PortalError(f"unknown encoder {encoder}")
|
||||||
|
|
||||||
|
|
||||||
|
def audio_chain(container: str) -> list[str]:
|
||||||
|
if container == "webm":
|
||||||
|
return ["opusenc", "bitrate=128000", "!", "queue"]
|
||||||
|
if gst_has("avenc_aac"):
|
||||||
|
return ["avenc_aac", "bitrate=128000", "!", "aacparse", "!", "queue"]
|
||||||
|
if gst_has("fdkaacenc"):
|
||||||
|
return ["fdkaacenc", "bitrate=128000", "!", "aacparse", "!", "queue"]
|
||||||
|
return ["opusenc", "bitrate=128000", "!", "queue"]
|
||||||
|
|
||||||
|
|
||||||
|
def muxer(container: str) -> list[str]:
|
||||||
|
if container == "webm":
|
||||||
|
# Just under the keyframe interval, so matroskamux closes each cluster on
|
||||||
|
# a keyframe: that is what makes a cluster a safe place to cut.
|
||||||
|
return ["webmmux", "name=mux", "streamable=true", "min-cluster-duration=400000000"]
|
||||||
|
if container == "mp4":
|
||||||
|
return ["mp4mux", "name=mux", "streamable=true", "fragment-duration=200",
|
||||||
|
"faststart=false", "presentation-time=0"]
|
||||||
|
if container == "mpegts":
|
||||||
|
return ["mpegtsmux", "name=mux", "alignment=7"]
|
||||||
|
if container == "matroska":
|
||||||
|
return ["matroskamux", "name=mux", "streamable=true"]
|
||||||
|
raise PortalError(f"unknown container {container}")
|
||||||
|
|
||||||
|
|
||||||
|
def preview_size(width: int, height: int, target: int = 480) -> tuple[int, int]:
|
||||||
|
"""Preview dimensions: `target` wide at the source aspect, both even."""
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return target, (target * 9 // 16 // 2) * 2
|
||||||
|
h = max(2, round(height * target / width))
|
||||||
|
return (target // 2) * 2, (h // 2) * 2
|
||||||
|
|
||||||
|
|
||||||
|
def build_pipeline(stream: PortalStream, *, container: str, encoder: str, fps: int,
|
||||||
|
max_height: int, bitrate_kbps: int, audio_node: str | None,
|
||||||
|
hls_dir: str | None = None, gpu_scale: bool = False,
|
||||||
|
preview_fd: int | None = None) -> list[str]:
|
||||||
|
"""The gst-launch argv for one session (fd 1 carries the muxed stream)."""
|
||||||
|
args = ["gst-launch-1.0", "-q"]
|
||||||
|
# Scale first, pace second: the pacing element repeats frames, and repeating
|
||||||
|
# a 1080p frame is far cheaper than repeating a 4K one.
|
||||||
|
args += [
|
||||||
|
"pipewiresrc", f"fd={stream.fd}", f"path={stream.node_id}", "do-timestamp=true",
|
||||||
|
"keepalive-time=1000", "resend-last=true", "!",
|
||||||
|
"queue", "max-size-time=100000000", "leaky=downstream", "!",
|
||||||
|
]
|
||||||
|
out_w, out_h = output_size(stream.width, stream.height, max_height)
|
||||||
|
size_caps = f",width={out_w},height={out_h}" if out_w and out_h else f",height=[16,{max_height}]"
|
||||||
|
if gpu_scale:
|
||||||
|
# Colour conversion and scaling on the GPU: a 4K screen is otherwise the
|
||||||
|
# most expensive thing in the pipeline, ahead of the encoder itself.
|
||||||
|
args += [
|
||||||
|
"cudaupload", "!", "cudaconvertscale", "!",
|
||||||
|
f"video/x-raw(memory:CUDAMemory),format=NV12{size_caps},pixel-aspect-ratio=1/1", "!",
|
||||||
|
]
|
||||||
|
if encoder not in ("nvenc",):
|
||||||
|
args += ["cudadownload", "!", "videoconvert", "!", "video/x-raw,format=I420", "!"]
|
||||||
|
else:
|
||||||
|
args += [
|
||||||
|
"videoconvert", "!", "videoscale", "method=lanczos", "!",
|
||||||
|
f"video/x-raw{size_caps},pixel-aspect-ratio=1/1", "!",
|
||||||
|
]
|
||||||
|
on_gpu = gpu_scale and encoder == "nvenc"
|
||||||
|
# A Wayland screen only produces a frame when something on it changes, and
|
||||||
|
# videorate can only duplicate once the *next* frame arrives: on a desk left
|
||||||
|
# alone the encoder simply stops, the muxer stops, and the receiver sits on a
|
||||||
|
# spinner waiting for data that is not coming. A compositor is clock-driven
|
||||||
|
# like audiomixer - it emits the last frame again on schedule - so the stream
|
||||||
|
# keeps its framerate no matter how still the screen is.
|
||||||
|
mixer = "cudacompositor" if (on_gpu and gst_has("cudacompositor")) else "compositor"
|
||||||
|
if mixer == "compositor" and on_gpu:
|
||||||
|
args += ["cudadownload", "!", "videoconvert", "!", "video/x-raw,format=I420", "!"]
|
||||||
|
on_gpu = False
|
||||||
|
# Pin the size here, not just the framerate. A compositor places its input
|
||||||
|
# at 0,0 and does not scale it, so anything downstream that renegotiates a
|
||||||
|
# smaller size (the preview branch's videoscale will happily propose its
|
||||||
|
# own 480x270 back through the tee) does not shrink the picture - it crops
|
||||||
|
# it to the top-left corner and encodes that.
|
||||||
|
size_pin = f",width={out_w},height={out_h}" if out_w and out_h else ""
|
||||||
|
rate_caps = (("video/x-raw(memory:CUDAMemory)" if on_gpu else "video/x-raw")
|
||||||
|
+ f"{size_pin},framerate={fps}/1")
|
||||||
|
args += [
|
||||||
|
mixer, "name=vmix", "latency=60000000", "start-time-selection=first",
|
||||||
|
# A pad property, not an element one: repeat the last frame for as long
|
||||||
|
# as the screen stays still instead of falling back to black.
|
||||||
|
"sink_0::max-last-buffer-repeat=-1",
|
||||||
|
"!", rate_caps, "!",
|
||||||
|
"queue", "max-size-time=100000000", "leaky=downstream", "!",
|
||||||
|
]
|
||||||
|
if preview_fd is not None:
|
||||||
|
# A second, deliberately cheap branch: a few frames a second, small, and
|
||||||
|
# leaky, so the panel can show what is actually going out without the
|
||||||
|
# preview ever being able to hold the encoder up.
|
||||||
|
pw, ph = preview_size(out_w, out_h)
|
||||||
|
args += ["tee", "name=vt",
|
||||||
|
"vt.", "!", "queue", "max-size-buffers=1", "leaky=downstream", "!"]
|
||||||
|
if on_gpu:
|
||||||
|
args += ["cudadownload", "!"]
|
||||||
|
args += [
|
||||||
|
"videorate", "drop-only=true", "!", "video/x-raw,framerate=2/1", "!",
|
||||||
|
"videoconvert", "!", "videoscale", "!", f"video/x-raw,width={pw},height={ph}", "!",
|
||||||
|
"jpegenc", "quality=65", "!", "fdsink", f"fd={preview_fd}", "sync=false",
|
||||||
|
"vt.", "!", "queue", "max-size-time=100000000", "leaky=downstream", "!",
|
||||||
|
]
|
||||||
|
|
||||||
|
args += video_encoder_chain(encoder, bitrate_kbps, fps, gpu_scale=on_gpu)
|
||||||
|
# hlssink2 does its own muxing and takes the elementary streams on request
|
||||||
|
# pads; everything else goes through one muxer into the pipe.
|
||||||
|
hls = hls_dir is not None
|
||||||
|
if hls and not gst_safe(hls_dir):
|
||||||
|
raise RuntimeError(f"runtime directory {hls_dir!r} cannot go in a pipeline")
|
||||||
|
args += ["!", "queue", "!", "hls.video" if hls else "mux."]
|
||||||
|
|
||||||
|
if audio_node:
|
||||||
|
# The monitor of an idle sink can go quiet for minutes at a time (a
|
||||||
|
# Bluetooth headset suspends outright), and a muxer with a declared but
|
||||||
|
# silent audio track is exactly what leaves a receiver on a spinner:
|
||||||
|
# players wait for that track's first packet. Mixing the monitor with a
|
||||||
|
# live silence source keeps audio flowing whatever the speakers do.
|
||||||
|
rate_caps = "audio/x-raw,format=S16LE,rate=48000,channels=2,layout=interleaved"
|
||||||
|
args += [
|
||||||
|
"audiomixer", "name=amix", "latency=60000000", "start-time-selection=first", "!",
|
||||||
|
rate_caps, "!", "audioconvert", "!",
|
||||||
|
]
|
||||||
|
args += audio_chain(container)
|
||||||
|
args += ["!", "hls.audio" if hls else "mux."]
|
||||||
|
args += [
|
||||||
|
"pipewiresrc", f"target-object={audio_node}",
|
||||||
|
"stream-properties=p,stream.capture.sink=true", "do-timestamp=true", "!",
|
||||||
|
"audio/x-raw", "!", "audioconvert", "!", "audioresample", "!", rate_caps, "!",
|
||||||
|
"queue", "max-size-time=100000000", "leaky=downstream", "!", "amix.",
|
||||||
|
"audiotestsrc", "is-live=true", "wave=silence", "!", rate_caps, "!",
|
||||||
|
"queue", "max-size-time=100000000", "leaky=downstream", "!", "amix.",
|
||||||
|
]
|
||||||
|
|
||||||
|
if hls:
|
||||||
|
args += ["hlssink2", "name=hls", f"location={hls_dir}/segment%05d.ts",
|
||||||
|
f"playlist-location={hls_dir}/index.m3u8", "target-duration=2", "max-files=6",
|
||||||
|
"playlist-length=4"]
|
||||||
|
else:
|
||||||
|
args += muxer(container) + ["!", "fdsink", "fd=1", "sync=false"]
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def output_size(width: int, height: int, max_height: int) -> tuple[int, int]:
|
||||||
|
"""The size to encode at: capped to max_height, aspect kept, both even.
|
||||||
|
|
||||||
|
Encoders want even dimensions, and the scalers want a fixed size — an
|
||||||
|
open-ended caps range makes cudaconvertscale fail to negotiate at all.
|
||||||
|
"""
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return 0, 0
|
||||||
|
if height > max_height:
|
||||||
|
width = round(width * max_height / height)
|
||||||
|
height = max_height
|
||||||
|
return (width // 2) * 2, (height // 2) * 2
|
||||||
|
|
||||||
|
|
||||||
|
class Capture:
|
||||||
|
"""Portal session + gst process, feeding a Fanout."""
|
||||||
|
|
||||||
|
def __init__(self, on_stopped=None) -> None:
|
||||||
|
self.portal: Portal | None = None
|
||||||
|
self.proc: asyncio.subprocess.Process | None = None
|
||||||
|
self.stream: PortalStream | None = None
|
||||||
|
self.encoder = ""
|
||||||
|
self.container = ""
|
||||||
|
self.error = ""
|
||||||
|
self.on_stopped = on_stopped
|
||||||
|
# A small JPEG of what is actually going out, refreshed a couple of
|
||||||
|
# times a second, so the panel can show the screen it is sharing.
|
||||||
|
self.preview_path = runtime_dir() / "preview.jpg"
|
||||||
|
self.preview_at = 0.0
|
||||||
|
self._tasks: list[asyncio.Task] = []
|
||||||
|
self._stderr = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self.proc is not None and self.proc.returncode is None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self) -> tuple[int, int]:
|
||||||
|
return (self.stream.width, self.stream.height) if self.stream else (0, 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_name(self) -> str:
|
||||||
|
return self.stream.name if self.stream else ""
|
||||||
|
|
||||||
|
async def start(self, *, container: str, cfg, fanout,
|
||||||
|
hls_dir: str | None = None, on_phase=None) -> None:
|
||||||
|
await self.stop()
|
||||||
|
self.error = ""
|
||||||
|
self.container = container
|
||||||
|
self.encoder = pick_encoder(str(cfg["encoder"]), container)
|
||||||
|
self.portal = Portal()
|
||||||
|
self.stream = await self.portal.open(cursor=bool(cfg["cursor"]))
|
||||||
|
if on_phase:
|
||||||
|
on_phase("encoder")
|
||||||
|
|
||||||
|
audio_node = default_sink_node() if cfg["audio"] else None
|
||||||
|
preview_r, preview_w = os.pipe()
|
||||||
|
argv = build_pipeline(
|
||||||
|
self.stream, container=container, encoder=self.encoder, fps=int(cfg["fps"]),
|
||||||
|
max_height=int(cfg["maxHeight"]), bitrate_kbps=int(cfg["bitrate"]),
|
||||||
|
audio_node=audio_node, hls_dir=hls_dir, gpu_scale=gst_has("cudaconvertscale"),
|
||||||
|
preview_fd=preview_w,
|
||||||
|
)
|
||||||
|
log.info("capture: %dx%d %s/%s @%sfps%s", self.stream.width, self.stream.height,
|
||||||
|
self.encoder, container, cfg["fps"], " +audio" if audio_node else "")
|
||||||
|
log.debug("gst: %s", " ".join(argv))
|
||||||
|
self.proc = await asyncio.create_subprocess_exec(
|
||||||
|
*argv,
|
||||||
|
stdout=asyncio.subprocess.PIPE if not hls_dir else asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
pass_fds=(self.stream.fd, preview_w),
|
||||||
|
)
|
||||||
|
os.close(self.stream.fd) # the child owns it now
|
||||||
|
self.stream.fd = -1
|
||||||
|
os.close(preview_w) # ...and the write end of the preview pipe
|
||||||
|
self._tasks.append(asyncio.create_task(self._pump_preview(preview_r)))
|
||||||
|
if not hls_dir:
|
||||||
|
self._tasks.append(asyncio.create_task(self._pump(fanout)))
|
||||||
|
self._tasks.append(asyncio.create_task(self._watch_stderr()))
|
||||||
|
self._tasks.append(asyncio.create_task(self._wait()))
|
||||||
|
|
||||||
|
async def _pump(self, fanout) -> None:
|
||||||
|
assert self.proc and self.proc.stdout
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = await self.proc.stdout.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
fanout.feed(chunk)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("capture pump ended: %s", exc)
|
||||||
|
|
||||||
|
async def _pump_preview(self, fd: int) -> None:
|
||||||
|
"""JPEGs off the preview branch, published one whole frame at a time.
|
||||||
|
|
||||||
|
The panel reads the file while gst keeps writing, so each frame is
|
||||||
|
written beside it and renamed over the top - never half a picture.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
reader = asyncio.StreamReader()
|
||||||
|
try:
|
||||||
|
await loop.connect_read_pipe(
|
||||||
|
lambda: asyncio.StreamReaderProtocol(reader), os.fdopen(fd, "rb", 0))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("preview pipe: %s", exc)
|
||||||
|
return
|
||||||
|
buf = b""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = await reader.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
while True:
|
||||||
|
end = buf.find(JPEG_END)
|
||||||
|
if end < 0:
|
||||||
|
break
|
||||||
|
frame, buf = buf[: end + 2], buf[end + 2 :]
|
||||||
|
start = frame.rfind(JPEG_START)
|
||||||
|
if start > 0:
|
||||||
|
frame = frame[start:]
|
||||||
|
if len(frame) > 1024:
|
||||||
|
self._publish_preview(frame)
|
||||||
|
if len(buf) > 1 << 20: # never a whole frame: give up on it
|
||||||
|
buf = b""
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("preview ended: %s", exc)
|
||||||
|
|
||||||
|
def _publish_preview(self, frame: bytes) -> None:
|
||||||
|
tmp = self.preview_path.with_suffix(".tmp")
|
||||||
|
try:
|
||||||
|
tmp.write_bytes(frame)
|
||||||
|
os.replace(tmp, self.preview_path)
|
||||||
|
self.preview_at = time.time()
|
||||||
|
except OSError as exc:
|
||||||
|
log.debug("preview write: %s", exc)
|
||||||
|
|
||||||
|
def clear_preview(self) -> None:
|
||||||
|
self.preview_at = 0.0
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
self.preview_path.unlink()
|
||||||
|
|
||||||
|
async def _watch_stderr(self) -> None:
|
||||||
|
assert self.proc and self.proc.stderr
|
||||||
|
while True:
|
||||||
|
line = await self.proc.stderr.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
text = line.decode(errors="replace").rstrip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
self._stderr = (self._stderr + "\n" + text)[-2000:]
|
||||||
|
log.warning("gst: %s", text)
|
||||||
|
if "ERROR" in text and not self.error:
|
||||||
|
# gst-launch keeps running after a negotiation failure; stop it
|
||||||
|
# so the session reports the problem instead of hanging.
|
||||||
|
self.error = _clean_error(text)
|
||||||
|
proc = self.proc
|
||||||
|
if proc is not None and proc.returncode is None:
|
||||||
|
proc.terminate()
|
||||||
|
|
||||||
|
async def _wait(self) -> None:
|
||||||
|
assert self.proc
|
||||||
|
code = await self.proc.wait()
|
||||||
|
if code not in (0, -15) and not self.error:
|
||||||
|
self.error = _first_error(self._stderr) or f"the encoder exited ({code})"
|
||||||
|
log.info("capture stopped (%s)", code)
|
||||||
|
if self.on_stopped:
|
||||||
|
self.on_stopped(code, self.error)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
proc, self.proc = self.proc, None
|
||||||
|
for t in self._tasks:
|
||||||
|
t.cancel()
|
||||||
|
self._tasks = []
|
||||||
|
if proc is not None and proc.returncode is None:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(proc.wait(), 3)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
if self.stream is not None and self.stream.fd >= 0:
|
||||||
|
try:
|
||||||
|
os.close(self.stream.fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.stream = None
|
||||||
|
self.clear_preview()
|
||||||
|
if self.portal is not None:
|
||||||
|
await self.portal.close()
|
||||||
|
self.portal = None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_error(text: str) -> str:
|
||||||
|
for line in text.splitlines():
|
||||||
|
if "ERROR" in line:
|
||||||
|
return _clean_error(line)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_error(line: str) -> str:
|
||||||
|
""""ERROR: from element /GstPipeline:…/GstFoo:foo0: msg" → "foo0: msg"."""
|
||||||
|
msg = line.split("ERROR", 1)[1].strip(" :")
|
||||||
|
if msg.startswith("from element"):
|
||||||
|
msg = msg[len("from element"):].strip()
|
||||||
|
msg = msg.rsplit("/", 1)[-1]
|
||||||
|
return msg[:200]
|
||||||
|
|
||||||
|
|
||||||
|
# gst-launch-1.0 joins its argv back into one string and re-parses it, so a
|
||||||
|
# value containing a space, '!' or ',' does not stay a value - it becomes more
|
||||||
|
# pipeline. Everything else we pass is a literal or a clamped int; these two are
|
||||||
|
# not, so they get checked.
|
||||||
|
GST_SAFE = re.compile(r"^[A-Za-z0-9._:@/+-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def gst_safe(value: str) -> bool:
|
||||||
|
return bool(GST_SAFE.match(value))
|
||||||
|
|
||||||
|
|
||||||
|
def default_sink_node() -> str | None:
|
||||||
|
"""node.name of the default audio output, captured in monitor mode."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
for cmd in (["pactl", "get-default-sink"],):
|
||||||
|
try:
|
||||||
|
out = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
|
||||||
|
name = out.stdout.strip()
|
||||||
|
if out.returncode == 0 and name and name != "@DEFAULT_SINK@":
|
||||||
|
if not gst_safe(name):
|
||||||
|
log.warning("audio: sink name %r cannot go in a pipeline", name)
|
||||||
|
return None
|
||||||
|
return name
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
return None
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""What the panel sees as a "display": one receiver, whatever protocol it speaks."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
CAST, AIRPLAY, DLNA = "cast", "airplay", "dlna"
|
||||||
|
|
||||||
|
KIND_LABEL = {CAST: "Google Cast", AIRPLAY: "AirPlay", DLNA: "DLNA"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Device:
|
||||||
|
id: str
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
model: str = ""
|
||||||
|
manufacturer: str = ""
|
||||||
|
# Protocol-specific handles: cast_info for Cast, control URLs for DLNA,
|
||||||
|
# the mDNS TXT record for AirPlay.
|
||||||
|
extra: dict = field(default_factory=dict)
|
||||||
|
seen: float = field(default_factory=time.time)
|
||||||
|
# Live status, filled in by the backend that owns the device.
|
||||||
|
status: str = "" # "" | idle | busy | casting | connecting | error
|
||||||
|
app: str = "" # what it is showing now ("Netflix", "Backdrop", …)
|
||||||
|
volume: float = -1.0 # -1 when unknown
|
||||||
|
muted: bool = False
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def video(self) -> bool:
|
||||||
|
"""Can it show a picture? Speaker groups and speakers cannot."""
|
||||||
|
if self.kind == CAST:
|
||||||
|
return self.extra.get("cast_type") in (None, "cast", "group") and not self.audio_only
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audio_only(self) -> bool:
|
||||||
|
if self.kind == CAST:
|
||||||
|
return self.extra.get("cast_type") in ("audio", "group")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def to_json(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"kindLabel": KIND_LABEL.get(self.kind, self.kind),
|
||||||
|
"name": self.name,
|
||||||
|
"model": self.model,
|
||||||
|
"host": self.host,
|
||||||
|
"port": self.port,
|
||||||
|
"status": self.status,
|
||||||
|
"app": self.app,
|
||||||
|
"volume": self.volume,
|
||||||
|
"muted": self.muted,
|
||||||
|
"audioOnly": self.audio_only,
|
||||||
|
"error": self.error,
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"""Finding receivers on the network.
|
||||||
|
|
||||||
|
Three protocols, three mechanisms, one device list:
|
||||||
|
* Google Cast — mDNS `_googlecast._tcp`, browsed by pychromecast.
|
||||||
|
* AirPlay — mDNS `_airplay._tcp`.
|
||||||
|
* DLNA — SSDP `MediaRenderer`, then the device description XML for its
|
||||||
|
control URLs.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from urllib.parse import urljoin, urlsplit
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf
|
||||||
|
|
||||||
|
from .devices import AIRPLAY, CAST, DLNA, Device
|
||||||
|
|
||||||
|
# Caps on things a hostile or broken LAN can hand us. Every one of these is a
|
||||||
|
# number an attacker would otherwise get to choose.
|
||||||
|
MAX_SSDP_REPLIES = 32 # description URLs fetched per scan
|
||||||
|
DESCRIPTION_LIMIT = 256 << 10 # bytes of device XML we are willing to parse
|
||||||
|
MAX_DEVICES = 64 # entries in the device table
|
||||||
|
|
||||||
|
|
||||||
|
def _is_lan(host: str) -> bool:
|
||||||
|
"""True for an address a receiver could plausibly have."""
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return ip.is_private or ip.is_loopback or ip.is_link_local
|
||||||
|
from .util import log
|
||||||
|
|
||||||
|
AIRPLAY_TYPE = "_airplay._tcp.local."
|
||||||
|
GOOGLECAST_TYPE = "_googlecast._tcp.local."
|
||||||
|
SSDP_ADDR, SSDP_PORT = "239.255.255.250", 1900
|
||||||
|
SSDP_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:1"
|
||||||
|
STALE_AFTER = 180.0 # a device unseen this long drops off the list
|
||||||
|
AWAY_GRACE = 60.0 # ...but a withdrawn announcement gets this long to come back
|
||||||
|
|
||||||
|
|
||||||
|
class Discovery:
|
||||||
|
def __init__(self, on_change) -> None:
|
||||||
|
self.devices: dict[str, Device] = {}
|
||||||
|
self.on_change = on_change
|
||||||
|
self.zeroconf: Zeroconf | None = None
|
||||||
|
self.cast_browser = None
|
||||||
|
self._airplay_browser: ServiceBrowser | None = None
|
||||||
|
self._cast_txt_browser: ServiceBrowser | None = None
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._ssdp_task: asyncio.Task | None = None
|
||||||
|
self._reap_task: asyncio.Task | None = None
|
||||||
|
self._session: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
|
# ---- lifecycle ----
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
|
self.zeroconf = Zeroconf()
|
||||||
|
self._session = aiohttp.ClientSession(
|
||||||
|
timeout=aiohttp.ClientTimeout(total=6), headers={"User-Agent": "screencast/1.0"}
|
||||||
|
)
|
||||||
|
self._start_cast()
|
||||||
|
self._airplay_browser = ServiceBrowser(
|
||||||
|
self.zeroconf, [AIRPLAY_TYPE], handlers=[self._airplay_change]
|
||||||
|
)
|
||||||
|
# Cast devices announce what they are showing in their TXT record, so the
|
||||||
|
# list can say "Netflix" or "idle" without opening a connection to each.
|
||||||
|
self._cast_txt_browser = ServiceBrowser(
|
||||||
|
self.zeroconf, [GOOGLECAST_TYPE], handlers=[self._cast_txt_change]
|
||||||
|
)
|
||||||
|
self._ssdp_task = asyncio.create_task(self._ssdp_loop())
|
||||||
|
self._reap_task = asyncio.create_task(self._reap_loop())
|
||||||
|
|
||||||
|
def _start_cast(self) -> None:
|
||||||
|
try:
|
||||||
|
from pychromecast.discovery import CastBrowser, SimpleCastListener
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("Google Cast discovery unavailable: %s", exc)
|
||||||
|
return
|
||||||
|
listener = SimpleCastListener(
|
||||||
|
add_callback=self._cast_seen, update_callback=self._cast_seen,
|
||||||
|
remove_callback=self._cast_gone,
|
||||||
|
)
|
||||||
|
self.cast_browser = CastBrowser(listener, self.zeroconf)
|
||||||
|
self.cast_browser.start_discovery()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
for task in (self._ssdp_task, self._reap_task):
|
||||||
|
if task:
|
||||||
|
task.cancel()
|
||||||
|
if self.cast_browser is not None:
|
||||||
|
try:
|
||||||
|
self.cast_browser.stop_discovery()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
for browser in (self._airplay_browser, self._cast_txt_browser):
|
||||||
|
if browser is not None:
|
||||||
|
browser.cancel()
|
||||||
|
if self.zeroconf is not None:
|
||||||
|
# close() does blocking network I/O; keep it off the event loop.
|
||||||
|
zc, self.zeroconf = self.zeroconf, None
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await asyncio.wait_for(asyncio.to_thread(zc.close), 3)
|
||||||
|
if self._session is not None:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
async def rescan(self) -> None:
|
||||||
|
"""Ask again now instead of waiting for the next announcement.
|
||||||
|
|
||||||
|
The long-lived browsers are left alone (stopping pychromecast's browser
|
||||||
|
tears down the shared Zeroconf instance with it); a throwaway browser
|
||||||
|
over the same types is enough to put fresh queries on the wire.
|
||||||
|
"""
|
||||||
|
if self.zeroconf is not None:
|
||||||
|
probe = ServiceBrowser(self.zeroconf, [GOOGLECAST_TYPE, AIRPLAY_TYPE],
|
||||||
|
handlers=[self._probe_change])
|
||||||
|
asyncio.get_running_loop().call_later(4, probe.cancel)
|
||||||
|
await self._ssdp_search()
|
||||||
|
|
||||||
|
def _probe_change(self, zeroconf: Zeroconf, service_type: str, name: str,
|
||||||
|
state_change: ServiceStateChange) -> None:
|
||||||
|
"""A manual scan only needs to make devices answer; the real browsers record them."""
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---- Google Cast (callbacks arrive on a zeroconf thread) ----
|
||||||
|
def _cast_seen(self, uuid, service) -> None:
|
||||||
|
if self._loop:
|
||||||
|
self._loop.call_soon_threadsafe(self._cast_seen_main, uuid)
|
||||||
|
|
||||||
|
def _cast_gone(self, uuid, service, cast_info) -> None:
|
||||||
|
if self._loop:
|
||||||
|
self._loop.call_soon_threadsafe(self._drop, f"cast:{uuid}")
|
||||||
|
|
||||||
|
def _cast_seen_main(self, uuid) -> None:
|
||||||
|
info = (self.cast_browser.devices or {}).get(uuid) if self.cast_browser else None
|
||||||
|
if info is None:
|
||||||
|
return
|
||||||
|
dev = self._upsert(
|
||||||
|
Device(
|
||||||
|
id=f"cast:{uuid}",
|
||||||
|
kind=CAST,
|
||||||
|
name=info.friendly_name or str(uuid),
|
||||||
|
host=info.host,
|
||||||
|
port=info.port or 8009,
|
||||||
|
model=info.model_name or "",
|
||||||
|
manufacturer=info.manufacturer or "",
|
||||||
|
extra={"uuid": str(uuid), "cast_type": info.cast_type},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dev.extra["cast_info"] = info
|
||||||
|
|
||||||
|
def _cast_txt_change(self, zeroconf: Zeroconf, service_type: str, name: str,
|
||||||
|
state_change: ServiceStateChange) -> None:
|
||||||
|
if self._loop is None or state_change is ServiceStateChange.Removed:
|
||||||
|
return
|
||||||
|
info = zeroconf.get_service_info(service_type, name, timeout=2000)
|
||||||
|
if info is None:
|
||||||
|
return
|
||||||
|
txt = {
|
||||||
|
k.decode(errors="replace"): (v or b"").decode(errors="replace")
|
||||||
|
for k, v in (info.properties or {}).items()
|
||||||
|
}
|
||||||
|
raw = txt.get("id", "")
|
||||||
|
if len(raw) != 32:
|
||||||
|
return
|
||||||
|
uuid = f"{raw[0:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:]}"
|
||||||
|
self._loop.call_soon_threadsafe(self._cast_txt_main, uuid, txt)
|
||||||
|
|
||||||
|
def _cast_txt_main(self, uuid: str, txt: dict) -> None:
|
||||||
|
dev = self.devices.get(f"cast:{uuid}")
|
||||||
|
if dev is None:
|
||||||
|
return
|
||||||
|
app = txt.get("rs", "").strip()
|
||||||
|
# st: 0 = nothing running, anything else = an app has the screen.
|
||||||
|
status = "idle" if txt.get("st", "0") == "0" else "busy"
|
||||||
|
if (dev.app, dev.status) != (app, status):
|
||||||
|
dev.app, dev.status = app, status
|
||||||
|
self.on_change()
|
||||||
|
|
||||||
|
# ---- AirPlay ----
|
||||||
|
def _airplay_change(self, zeroconf: Zeroconf, service_type: str, name: str,
|
||||||
|
state_change: ServiceStateChange) -> None:
|
||||||
|
if self._loop is None:
|
||||||
|
return
|
||||||
|
if state_change is ServiceStateChange.Removed:
|
||||||
|
# TVs withdraw and re-publish their AirPlay record constantly, and a
|
||||||
|
# row that disappears under the pointer is worse than a stale one:
|
||||||
|
# mark it away and let the reaper drop it if it stays gone.
|
||||||
|
self._loop.call_soon_threadsafe(self._mark_away, f"airplay:{name}")
|
||||||
|
return
|
||||||
|
info = zeroconf.get_service_info(service_type, name, timeout=2000)
|
||||||
|
if info is None:
|
||||||
|
return
|
||||||
|
addrs = info.parsed_scoped_addresses() if hasattr(info, "parsed_scoped_addresses") else []
|
||||||
|
ipv4 = next((a for a in addrs if ":" not in a), None) or (addrs[0] if addrs else "")
|
||||||
|
if not ipv4:
|
||||||
|
return
|
||||||
|
txt = {
|
||||||
|
k.decode(errors="replace"): (v or b"").decode(errors="replace")
|
||||||
|
for k, v in (info.properties or {}).items()
|
||||||
|
}
|
||||||
|
friendly = name.split(".")[0].replace("\\032", " ")
|
||||||
|
self._loop.call_soon_threadsafe(
|
||||||
|
self._upsert,
|
||||||
|
Device(
|
||||||
|
id=f"airplay:{name}",
|
||||||
|
kind=AIRPLAY,
|
||||||
|
name=txt.get("name") or friendly,
|
||||||
|
host=ipv4,
|
||||||
|
port=info.port or 7000,
|
||||||
|
model=txt.get("model", ""),
|
||||||
|
manufacturer="Apple" if txt.get("model", "").startswith(("Mac", "Apple")) else "",
|
||||||
|
extra={"txt": txt, "service": name},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- DLNA ----
|
||||||
|
async def _ssdp_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self._ssdp_search()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("ssdp: %s", exc)
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
async def _ssdp_search(self) -> None:
|
||||||
|
"""M-SEARCH from every local address; renderers answer by unicast."""
|
||||||
|
msg = (
|
||||||
|
"M-SEARCH * HTTP/1.1\r\n"
|
||||||
|
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
|
||||||
|
'MAN: "ssdp:discover"\r\n'
|
||||||
|
"MX: 2\r\n"
|
||||||
|
f"ST: {SSDP_TARGET}\r\n\r\n"
|
||||||
|
).encode()
|
||||||
|
locations: dict[str, str] = {} # description URL -> the address that sent it
|
||||||
|
for local_ip in _local_ipv4s():
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||||
|
try:
|
||||||
|
sock.bind((local_ip, 0))
|
||||||
|
sock.setblocking(False)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
await loop.sock_sendto(sock, msg, (SSDP_ADDR, SSDP_PORT))
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
data, src = await asyncio.wait_for(
|
||||||
|
loop.sock_recvfrom(sock, 4096), max(0.2, deadline - time.monotonic())
|
||||||
|
)
|
||||||
|
except (asyncio.TimeoutError, OSError):
|
||||||
|
break
|
||||||
|
loc = _header(data.decode(errors="replace"), "location")
|
||||||
|
if loc and len(locations) < MAX_SSDP_REPLIES:
|
||||||
|
locations.setdefault(loc, src[0])
|
||||||
|
except OSError as exc:
|
||||||
|
log.debug("ssdp on %s: %s", local_ip, exc)
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
for loc, sender in locations.items():
|
||||||
|
await self._add_dlna(loc, sender)
|
||||||
|
|
||||||
|
async def _add_dlna(self, location: str, sender: str) -> None:
|
||||||
|
assert self._session is not None
|
||||||
|
# An SSDP reply is an unauthenticated UDP packet from anyone on the wire,
|
||||||
|
# and we are about to fetch the URL inside it as this user. Only fetch it
|
||||||
|
# back from the host that sent it: otherwise the packet chooses the
|
||||||
|
# target, and "http://127.0.0.1:9091/..." is a request we would make for
|
||||||
|
# a stranger.
|
||||||
|
host = urlsplit(location).hostname or ""
|
||||||
|
if host != sender or not _is_lan(host):
|
||||||
|
log.info("ssdp: ignoring %s announced by %s", location, sender)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
async with self._session.get(location) as resp:
|
||||||
|
# No renderer needs a megabyte to describe itself; an attacker
|
||||||
|
# would happily send us gigabytes, or an entity bomb.
|
||||||
|
raw = await resp.content.read(DESCRIPTION_LIMIT)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("dlna description %s: %s", location, exc)
|
||||||
|
return
|
||||||
|
xml = raw.decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml)
|
||||||
|
except ET.ParseError:
|
||||||
|
return
|
||||||
|
ns = {"u": "urn:schemas-upnp-org:device-1-0"}
|
||||||
|
dev_el = root.find("u:device", ns)
|
||||||
|
if dev_el is None:
|
||||||
|
return
|
||||||
|
udn = (dev_el.findtext("u:UDN", "", ns) or location).strip()
|
||||||
|
name = (dev_el.findtext("u:friendlyName", "", ns) or "DLNA renderer").strip()
|
||||||
|
model = (dev_el.findtext("u:modelName", "", ns) or "").strip()
|
||||||
|
maker = (dev_el.findtext("u:manufacturer", "", ns) or "").strip()
|
||||||
|
base = root.findtext("u:URLBase", "", ns) or location
|
||||||
|
control: dict[str, str] = {}
|
||||||
|
for svc in dev_el.iter("{urn:schemas-upnp-org:device-1-0}service"):
|
||||||
|
stype = svc.findtext("u:serviceType", "", ns)
|
||||||
|
curl = svc.findtext("u:controlURL", "", ns)
|
||||||
|
if not stype or not curl:
|
||||||
|
continue
|
||||||
|
target = urljoin(base, curl)
|
||||||
|
# URLBase and controlURL are the device's own words. An absolute URL
|
||||||
|
# here would point our POSTs anywhere it likes.
|
||||||
|
if urlsplit(target).hostname != host:
|
||||||
|
log.info("dlna: %s points its control URL at %s, ignoring", location, target)
|
||||||
|
continue
|
||||||
|
if "AVTransport" in stype:
|
||||||
|
control["avtransport"] = target
|
||||||
|
control["avtransport_type"] = stype
|
||||||
|
elif "RenderingControl" in stype:
|
||||||
|
control["rendering"] = target
|
||||||
|
control["rendering_type"] = stype
|
||||||
|
if "avtransport" not in control:
|
||||||
|
return # can be discovered but cannot be told to play anything
|
||||||
|
self._upsert(
|
||||||
|
Device(
|
||||||
|
id=f"dlna:{udn}",
|
||||||
|
kind=DLNA,
|
||||||
|
name=name,
|
||||||
|
host=host,
|
||||||
|
port=int(location.split("/")[2].split(":")[1]) if ":" in location.split("/")[2] else 80,
|
||||||
|
model=model,
|
||||||
|
manufacturer=maker,
|
||||||
|
extra={"control": control, "location": location},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- list bookkeeping ----
|
||||||
|
def _upsert(self, dev: Device) -> Device:
|
||||||
|
old = self.devices.get(dev.id)
|
||||||
|
if old is not None:
|
||||||
|
old.seen = time.time()
|
||||||
|
changed = (old.name, old.host, old.port) != (dev.name, dev.host, dev.port)
|
||||||
|
if old.status == "away":
|
||||||
|
old.status, changed = "", True
|
||||||
|
old.name, old.host, old.port = dev.name, dev.host, dev.port
|
||||||
|
old.model = dev.model or old.model
|
||||||
|
old.extra.update(dev.extra)
|
||||||
|
if changed:
|
||||||
|
self.on_change()
|
||||||
|
return old
|
||||||
|
if len(self.devices) >= MAX_DEVICES:
|
||||||
|
# Announcements are free to send and we push the whole list to the
|
||||||
|
# shell on every change; a flood must not become the shell's problem.
|
||||||
|
log.warning("device list full (%d), ignoring %s", MAX_DEVICES, dev.id)
|
||||||
|
return dev
|
||||||
|
self.devices[dev.id] = dev
|
||||||
|
log.info("found %s: %s (%s)", dev.kind, dev.name, dev.host)
|
||||||
|
self.on_change()
|
||||||
|
return dev
|
||||||
|
|
||||||
|
def _mark_away(self, device_id: str) -> None:
|
||||||
|
dev = self.devices.get(device_id)
|
||||||
|
if dev is None or dev.status == "away":
|
||||||
|
return
|
||||||
|
dev.status = "away"
|
||||||
|
dev.seen = time.time() - (STALE_AFTER - AWAY_GRACE)
|
||||||
|
log.info("away %s", device_id)
|
||||||
|
self.on_change()
|
||||||
|
|
||||||
|
def _drop(self, device_id: str) -> None:
|
||||||
|
if self.devices.pop(device_id, None) is not None:
|
||||||
|
log.info("lost %s", device_id)
|
||||||
|
self.on_change()
|
||||||
|
|
||||||
|
async def _reap_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
now = time.time()
|
||||||
|
for dev_id, dev in list(self.devices.items()):
|
||||||
|
# Cast devices are kept by their browser; the rest age out —
|
||||||
|
# AirPlay only once its away grace has run down too.
|
||||||
|
if dev.kind in (DLNA, AIRPLAY) and now - dev.seen > STALE_AFTER:
|
||||||
|
self._drop(dev_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _header(text: str, name: str) -> str:
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.lower().startswith(name + ":"):
|
||||||
|
return line.split(":", 1)[1].strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _local_ipv4s() -> list[str]:
|
||||||
|
"""Every non-loopback IPv4 we can send from (several NICs, VPNs, …)."""
|
||||||
|
out: list[str] = []
|
||||||
|
try:
|
||||||
|
import ifaddr
|
||||||
|
|
||||||
|
for adapter in ifaddr.get_adapters():
|
||||||
|
for ip in adapter.ips:
|
||||||
|
if ip.is_IPv4 and not str(ip.ip).startswith("127."):
|
||||||
|
out.append(str(ip.ip))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.connect(("8.8.8.8", 9))
|
||||||
|
out.append(s.getsockname()[0])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
return out or ["0.0.0.0"]
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
"""The daemon: device list, session, and the JSON control socket the panel talks to.
|
||||||
|
|
||||||
|
Protocol (newline-delimited JSON):
|
||||||
|
in {"cmd": "cast", "device": "cast:<uuid>"} … see handle()
|
||||||
|
out {"type": "state", …} pushed on every change
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
|
||||||
|
from . import VERSION
|
||||||
|
from .backends import AirPlayBackend, for_kind
|
||||||
|
from .backends.base import BackendError
|
||||||
|
from .capture import PortalError, drop_legacy_token
|
||||||
|
from .config import PIPELINE_KEYS, Config
|
||||||
|
from .devices import Device
|
||||||
|
from .discovery import Discovery
|
||||||
|
from .session import Session
|
||||||
|
from .stream import StreamServer
|
||||||
|
from .util import (firewall_argv, firewall_command, firewall_tool, log, runtime_dir,
|
||||||
|
terminal_argv)
|
||||||
|
|
||||||
|
|
||||||
|
NO_AGENT = "no polkit agent" # pkexec had nowhere to ask; fall back to a terminal
|
||||||
|
|
||||||
|
|
||||||
|
class Daemon:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.cfg = Config()
|
||||||
|
self.discovery = Discovery(on_change=self.push_state)
|
||||||
|
self.stream = StreamServer(on_client=self._on_client)
|
||||||
|
self.session = Session(self)
|
||||||
|
self.backends: dict[str, object] = {}
|
||||||
|
self.clients: set[asyncio.StreamWriter] = set()
|
||||||
|
self.error = ""
|
||||||
|
self.busy = ""
|
||||||
|
self.pairing = "" # device id a pairing PIN is expected for
|
||||||
|
self.firewall_blocked = False # a cast failed with nothing on the stream
|
||||||
|
self._push_handle: asyncio.TimerHandle | None = None
|
||||||
|
self._cast_task: asyncio.Task | None = None
|
||||||
|
self._rescan_task: asyncio.Task | None = None
|
||||||
|
self._stopping = False
|
||||||
|
|
||||||
|
# ---- backends ----
|
||||||
|
def backend_for(self, device: Device):
|
||||||
|
cls = for_kind(device.kind)
|
||||||
|
if cls is None:
|
||||||
|
raise BackendError(f"no backend for {device.kind}")
|
||||||
|
backend = self.backends.get(device.kind)
|
||||||
|
if backend is None:
|
||||||
|
backend = cls(self)
|
||||||
|
self.backends[device.kind] = backend
|
||||||
|
return backend
|
||||||
|
|
||||||
|
def note_backend_error(self, message: str) -> None:
|
||||||
|
self.error = message
|
||||||
|
self.push_state()
|
||||||
|
|
||||||
|
# ---- state ----
|
||||||
|
def build_state(self) -> dict:
|
||||||
|
devices = sorted(
|
||||||
|
self.discovery.devices.values(), key=lambda d: (d.kind != "cast", d.name.lower())
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"type": "state",
|
||||||
|
"version": VERSION,
|
||||||
|
"devices": [d.to_json() for d in devices],
|
||||||
|
"session": self.session.to_json(),
|
||||||
|
"settings": dict(self.cfg.values),
|
||||||
|
"error": self.error,
|
||||||
|
"busy": self.busy,
|
||||||
|
"pairing": self.pairing,
|
||||||
|
"capabilities": {
|
||||||
|
"airplay": AirPlayBackend.available(),
|
||||||
|
"port": self.stream.port or int(self.cfg["port"]),
|
||||||
|
},
|
||||||
|
"firewall": self.firewall_state(),
|
||||||
|
"time": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def firewall_state(self) -> dict:
|
||||||
|
"""What the panel needs to offer "open the port" after a failed cast."""
|
||||||
|
tool = firewall_tool()
|
||||||
|
host = self.session.device.host if self.session.device else ""
|
||||||
|
port = self.stream.port or int(self.cfg["port"])
|
||||||
|
return {
|
||||||
|
"tool": tool,
|
||||||
|
# "blocked": a cast just failed with nothing on the stream.
|
||||||
|
# "verified": some receiver has fetched it at least once on this
|
||||||
|
# machine, which is the only proof the port is really open.
|
||||||
|
"blocked": bool(tool) and self.firewall_blocked,
|
||||||
|
"verified": bool(self.cfg["portVerified"]),
|
||||||
|
"port": port,
|
||||||
|
"command": firewall_command(tool, port, host) if tool else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def open_firewall(self, host: str = "") -> dict:
|
||||||
|
"""Let receivers on this network reach the stream port, once.
|
||||||
|
|
||||||
|
Casting is pull-based - the receiver opens the connection to us - so a
|
||||||
|
default-deny firewall has to be told about the port. Installing does not
|
||||||
|
need root, so this is where it is asked for: one password dialog before
|
||||||
|
the first cast, and the rule persists.
|
||||||
|
"""
|
||||||
|
tool = firewall_tool(refresh=True)
|
||||||
|
if not tool:
|
||||||
|
self.firewall_blocked = False
|
||||||
|
self.cfg.update({"portVerified": True})
|
||||||
|
self.push_state()
|
||||||
|
return {"ok": True, "note": "no firewall is running"}
|
||||||
|
if not host:
|
||||||
|
host = self.session.device.host if self.session.device else ""
|
||||||
|
port = self.stream.port or int(self.cfg["port"])
|
||||||
|
argv_list = firewall_argv(tool, port, host)
|
||||||
|
self.busy = f"Opening port {port}…"
|
||||||
|
self.push_state(now=True)
|
||||||
|
try:
|
||||||
|
ok, err = await self._pkexec(argv_list)
|
||||||
|
if not ok and err == NO_AGENT:
|
||||||
|
ok, err = await self._sudo_in_terminal(argv_list, port)
|
||||||
|
finally:
|
||||||
|
self.busy = ""
|
||||||
|
self.push_state()
|
||||||
|
if not ok:
|
||||||
|
log.warning("open firewall: %s", err)
|
||||||
|
# The panel only sees state, so a cancelled or ignored prompt has to
|
||||||
|
# land there too - otherwise the button just looks broken.
|
||||||
|
self.error = (
|
||||||
|
f"the port was not opened: {err}" if err != NO_AGENT
|
||||||
|
else "no way to ask for a password on this machine"
|
||||||
|
)
|
||||||
|
self.push_state(now=True)
|
||||||
|
return {"ok": False, "error": err}
|
||||||
|
log.info("opened port %d in %s", port, tool)
|
||||||
|
self.firewall_blocked = False
|
||||||
|
self.error = ""
|
||||||
|
# The rule is in: stop warning about a port that is now open. A receiver
|
||||||
|
# actually reaching us keeps it that way (see _on_client).
|
||||||
|
self.cfg.update({"portVerified": True})
|
||||||
|
self.push_state(now=True)
|
||||||
|
return {"ok": True, "note": f"port {port} is open"}
|
||||||
|
|
||||||
|
async def _pkexec(self, argv_list: list[list[str]]) -> tuple[bool, str]:
|
||||||
|
if shutil.which("pkexec") is None:
|
||||||
|
return False, NO_AGENT
|
||||||
|
for argv in argv_list:
|
||||||
|
exe = shutil.which(argv[0])
|
||||||
|
if exe is None:
|
||||||
|
return False, f"{argv[0]} is not installed"
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"pkexec", exe, *argv[1:],
|
||||||
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
out, _ = await asyncio.wait_for(proc.communicate(), 180)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
return False, "the password prompt timed out"
|
||||||
|
text = out.decode(errors="replace").strip()
|
||||||
|
# 126 = dismissed or refused, 127 = pkexec had no agent to ask.
|
||||||
|
if proc.returncode == 127 or "authentication agent" in text.lower():
|
||||||
|
return False, NO_AGENT
|
||||||
|
if proc.returncode == 126:
|
||||||
|
return False, "cancelled"
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return False, text.splitlines()[-1] if text else f"failed ({proc.returncode})"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
async def _sudo_in_terminal(self, argv_list: list[list[str]], port: int) -> tuple[bool, str]:
|
||||||
|
"""No polkit agent: run sudo in a terminal window and watch for the marker."""
|
||||||
|
mark = runtime_dir() / "firewall-ok"
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
mark.unlink()
|
||||||
|
script = " && ".join(shlex.join(a) for a in argv_list) + f" && : > {shlex.quote(str(mark))}"
|
||||||
|
inner = (
|
||||||
|
f"echo 'screencast needs port {port} open so your TV can fetch the stream.'; "
|
||||||
|
f"echo; sudo sh -c {shlex.quote(script)} "
|
||||||
|
"|| { echo; echo 'not opened'; read -r _; }"
|
||||||
|
)
|
||||||
|
argv = terminal_argv(["sh", "-c", inner])
|
||||||
|
if argv is None:
|
||||||
|
return False, "no terminal to ask for a password in; run: " + " && ".join(
|
||||||
|
"sudo " + shlex.join(a) for a in argv_list)
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*argv, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(proc.wait(), 300)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
# The terminal may fork, so trust the marker rather than the exit code.
|
||||||
|
for _ in range(20):
|
||||||
|
if mark.exists():
|
||||||
|
mark.unlink()
|
||||||
|
return True, ""
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
return False, "the port was not opened"
|
||||||
|
|
||||||
|
def push_state(self, now: bool = False) -> None:
|
||||||
|
"""Coalesce the bursts discovery and polling produce into one push.
|
||||||
|
|
||||||
|
`now` skips the coalescing: anything a click just changed is feedback,
|
||||||
|
and 80ms of it queued behind the work is 80ms of a dead-looking panel.
|
||||||
|
"""
|
||||||
|
if now:
|
||||||
|
if self._push_handle is not None:
|
||||||
|
self._push_handle.cancel()
|
||||||
|
self._push_handle = None
|
||||||
|
self._push_now()
|
||||||
|
return
|
||||||
|
if self._push_handle is not None:
|
||||||
|
return
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
self._push_handle = loop.call_later(0.08, self._push_now)
|
||||||
|
|
||||||
|
def _push_now(self) -> None:
|
||||||
|
self._push_handle = None
|
||||||
|
if not self.clients:
|
||||||
|
return
|
||||||
|
line = (json.dumps(self.build_state()) + "\n").encode()
|
||||||
|
for writer in list(self.clients):
|
||||||
|
try:
|
||||||
|
writer.write(line)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
self.clients.discard(writer)
|
||||||
|
|
||||||
|
def _on_client(self, peer: str, connected: bool) -> None:
|
||||||
|
if connected:
|
||||||
|
self.firewall_blocked = False
|
||||||
|
if not self.cfg["portVerified"]:
|
||||||
|
self.cfg.update({"portVerified": True})
|
||||||
|
self.session.note_client(peer, connected)
|
||||||
|
|
||||||
|
# ---- commands ----
|
||||||
|
def find_device(self, key: str) -> Device:
|
||||||
|
key = (key or "").strip()
|
||||||
|
devices = self.discovery.devices
|
||||||
|
if key in devices:
|
||||||
|
return devices[key]
|
||||||
|
lowered = key.lower()
|
||||||
|
for dev in devices.values():
|
||||||
|
if dev.name.lower() == lowered or dev.id.lower() == lowered:
|
||||||
|
return dev
|
||||||
|
for dev in devices.values():
|
||||||
|
if lowered and lowered in dev.name.lower():
|
||||||
|
return dev
|
||||||
|
raise BackendError(f"no device matching “{key}”")
|
||||||
|
|
||||||
|
async def handle(self, msg: dict) -> dict:
|
||||||
|
cmd = str(msg.get("cmd", ""))
|
||||||
|
if cmd == "get":
|
||||||
|
return {"ok": True}
|
||||||
|
if cmd == "rescan":
|
||||||
|
if self._rescan_task is None or self._rescan_task.done():
|
||||||
|
self._rescan_task = asyncio.create_task(self._run_rescan())
|
||||||
|
return {"ok": True, "started": True}
|
||||||
|
if cmd == "cast":
|
||||||
|
device = self.find_device(str(msg.get("device", "")))
|
||||||
|
self.error = ""
|
||||||
|
self.busy = f"Connecting to {device.name}…"
|
||||||
|
self.cfg.update({"lastDevice": device.id})
|
||||||
|
self.push_state(now=True)
|
||||||
|
# A firewall we have never been through is a cast that is going to
|
||||||
|
# fail in forty seconds for a reason we already know. Ask for the
|
||||||
|
# port up front instead - one dialog, once, and then it is done.
|
||||||
|
if firewall_tool() and not self.cfg["portVerified"]:
|
||||||
|
await self.open_firewall(device.host)
|
||||||
|
self.busy = f"Connecting to {device.name}…"
|
||||||
|
self.error = ""
|
||||||
|
self.push_state(now=True)
|
||||||
|
# Starting can sit for a minute on the portal's screen picker, so it
|
||||||
|
# runs on its own task: the panel has to stay able to send "stop".
|
||||||
|
await self._cancel_cast()
|
||||||
|
self._cast_task = asyncio.create_task(
|
||||||
|
self._run_cast(device, self.backend_for(device))
|
||||||
|
)
|
||||||
|
return {"ok": True, "started": True}
|
||||||
|
if cmd == "openFirewall":
|
||||||
|
return await self.open_firewall(str(msg.get("host", "")))
|
||||||
|
if cmd == "stop":
|
||||||
|
self.busy = "Stopping…"
|
||||||
|
self.push_state(now=True)
|
||||||
|
try:
|
||||||
|
await self._cancel_cast()
|
||||||
|
await self.session.stop()
|
||||||
|
finally:
|
||||||
|
self.busy = ""
|
||||||
|
self.push_state(now=True)
|
||||||
|
return {"ok": True}
|
||||||
|
if cmd == "set":
|
||||||
|
changed = self.cfg.update(msg.get("settings") or {})
|
||||||
|
if changed & {"port"} and self.session.active:
|
||||||
|
await self.session.stop()
|
||||||
|
elif changed & PIPELINE_KEYS and self.session.active:
|
||||||
|
# Quality settings only exist in the running pipeline: restart it.
|
||||||
|
device, backend = self.session.device, self.session.backend
|
||||||
|
if device is not None and backend is not None:
|
||||||
|
await self.session.start(device, backend)
|
||||||
|
self.push_state()
|
||||||
|
return {"ok": True, "changed": sorted(changed)}
|
||||||
|
if cmd in ("volume", "mute", "transport"):
|
||||||
|
device = (
|
||||||
|
self.find_device(str(msg["device"])) if msg.get("device") else self.session.device
|
||||||
|
)
|
||||||
|
if device is None:
|
||||||
|
raise BackendError("nothing is being cast")
|
||||||
|
backend = self.backend_for(device)
|
||||||
|
if cmd == "volume":
|
||||||
|
await backend.set_volume(device, float(msg.get("value", 0)))
|
||||||
|
device.volume = float(msg.get("value", 0))
|
||||||
|
elif cmd == "mute":
|
||||||
|
await backend.set_muted(device, bool(msg.get("value")))
|
||||||
|
device.muted = bool(msg.get("value"))
|
||||||
|
else:
|
||||||
|
await backend.transport(device, str(msg.get("action", "")))
|
||||||
|
self.push_state()
|
||||||
|
return {"ok": True}
|
||||||
|
if cmd == "repick":
|
||||||
|
await self.session.repick_source()
|
||||||
|
self.push_state()
|
||||||
|
return {"ok": True}
|
||||||
|
if cmd == "pair":
|
||||||
|
device = self.find_device(str(msg.get("device", "")))
|
||||||
|
backend = self.backend_for(device)
|
||||||
|
if not hasattr(backend, "pair_begin"):
|
||||||
|
raise BackendError(f"{device.name} does not need pairing")
|
||||||
|
self.busy = f"Asking {device.name} for a code…"
|
||||||
|
self.push_state(now=True)
|
||||||
|
try:
|
||||||
|
await backend.pair_begin(device)
|
||||||
|
finally:
|
||||||
|
self.busy = ""
|
||||||
|
self.pairing = device.id
|
||||||
|
self.push_state()
|
||||||
|
return {"ok": True}
|
||||||
|
if cmd == "pairPin":
|
||||||
|
if not self.pairing:
|
||||||
|
raise BackendError("no pairing in progress")
|
||||||
|
device = self.find_device(self.pairing)
|
||||||
|
backend = self.backend_for(device)
|
||||||
|
self.busy = f"Pairing with {device.name}…"
|
||||||
|
self.push_state(now=True)
|
||||||
|
try:
|
||||||
|
ok = await backend.pair_pin(str(msg.get("pin", "")))
|
||||||
|
finally:
|
||||||
|
self.busy = ""
|
||||||
|
self.pairing = ""
|
||||||
|
self.error = "" if ok else f"{device.name} rejected that code"
|
||||||
|
self.push_state()
|
||||||
|
return {"ok": ok, "paired": ok}
|
||||||
|
if cmd == "pairCancel":
|
||||||
|
for backend in self.backends.values():
|
||||||
|
if hasattr(backend, "pair_cancel"):
|
||||||
|
await backend.pair_cancel()
|
||||||
|
self.pairing = ""
|
||||||
|
self.push_state()
|
||||||
|
return {"ok": True}
|
||||||
|
if cmd == "quit":
|
||||||
|
asyncio.create_task(self.shutdown())
|
||||||
|
return {"ok": True}
|
||||||
|
raise BackendError(f"unknown command “{cmd}”")
|
||||||
|
|
||||||
|
async def _run_rescan(self) -> None:
|
||||||
|
self.busy = "Looking for displays…"
|
||||||
|
self.push_state(now=True)
|
||||||
|
try:
|
||||||
|
await self.discovery.rescan()
|
||||||
|
finally:
|
||||||
|
self.busy = ""
|
||||||
|
self.push_state(now=True)
|
||||||
|
|
||||||
|
async def _run_cast(self, device: Device, backend) -> None:
|
||||||
|
try:
|
||||||
|
await self.session.start(device, backend)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await self.session.stop(quiet=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self.busy = ""
|
||||||
|
self.push_state()
|
||||||
|
|
||||||
|
async def _cancel_cast(self) -> None:
|
||||||
|
task, self._cast_task = self._cast_task, None
|
||||||
|
if task is not None and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||||
|
await task
|
||||||
|
|
||||||
|
# ---- control socket ----
|
||||||
|
async def _serve_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||||
|
self.clients.add(writer)
|
||||||
|
pending: set[asyncio.Task] = set()
|
||||||
|
try:
|
||||||
|
writer.write((json.dumps(self.build_state()) + "\n").encode())
|
||||||
|
while True:
|
||||||
|
line = await reader.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
# Each command runs on its own task. Reading the next line must
|
||||||
|
# not wait on this one: a rescan takes six seconds and a cast can
|
||||||
|
# sit on the screen picker for a minute, and a panel whose next
|
||||||
|
# click is stuck in the socket looks broken.
|
||||||
|
task = asyncio.create_task(self._run_command(msg, writer))
|
||||||
|
pending.add(task)
|
||||||
|
task.add_done_callback(pending.discard)
|
||||||
|
except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError):
|
||||||
|
pass # a CLI client that read one line and walked away
|
||||||
|
finally:
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
self.clients.discard(writer)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
async def _run_command(self, msg: dict, writer: asyncio.StreamWriter) -> None:
|
||||||
|
try:
|
||||||
|
reply = await self.handle(msg)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except (BackendError, PortalError) as exc:
|
||||||
|
reply = {"ok": False, "error": str(exc)}
|
||||||
|
self.error = str(exc)
|
||||||
|
except Exception as exc: # noqa: BLE001 - one bad command must not kill the daemon
|
||||||
|
log.exception("command failed")
|
||||||
|
reply = {"ok": False, "error": str(exc)}
|
||||||
|
self.error = str(exc)
|
||||||
|
reply["type"] = "reply"
|
||||||
|
reply["cmd"] = msg.get("cmd", "")
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
writer.write((json.dumps(reply) + "\n").encode())
|
||||||
|
self._push_now()
|
||||||
|
|
||||||
|
# ---- lifecycle ----
|
||||||
|
async def run(self) -> int:
|
||||||
|
lock_path = runtime_dir() / "lock"
|
||||||
|
lock = open(lock_path, "w") # noqa: SIM115 - held for the process lifetime
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
log.error("another screencast-server is already running")
|
||||||
|
return 3
|
||||||
|
drop_legacy_token()
|
||||||
|
lock.write(str(os.getpid()))
|
||||||
|
lock.flush()
|
||||||
|
|
||||||
|
sock_path = runtime_dir() / "ctl.sock"
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
os.unlink(sock_path)
|
||||||
|
# Create it private rather than chmod-ing after the bind: in the /tmp
|
||||||
|
# fallback that gap is a window another local user can connect through.
|
||||||
|
old_umask = os.umask(0o077)
|
||||||
|
try:
|
||||||
|
server = await asyncio.start_unix_server(self._serve_client, path=str(sock_path))
|
||||||
|
finally:
|
||||||
|
os.umask(old_umask)
|
||||||
|
os.chmod(sock_path, 0o600)
|
||||||
|
log.info("screencast-server %s listening on %s", VERSION, sock_path)
|
||||||
|
|
||||||
|
await self.discovery.start()
|
||||||
|
self._done = asyncio.get_running_loop().create_future()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
loop.add_signal_handler(sig, lambda: asyncio.create_task(self.shutdown()))
|
||||||
|
try:
|
||||||
|
await self._done
|
||||||
|
finally:
|
||||||
|
server.close()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await server.wait_closed()
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
os.unlink(sock_path)
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_UN)
|
||||||
|
lock.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
if self._stopping:
|
||||||
|
return
|
||||||
|
self._stopping = True
|
||||||
|
log.info("shutting down")
|
||||||
|
# zeroconf and pychromecast both run non-daemon threads that can outlive
|
||||||
|
# a clean shutdown; never let one of them keep the socket (and the lock)
|
||||||
|
# from being handed to the next instance.
|
||||||
|
asyncio.get_running_loop().call_later(5, lambda: os._exit(0))
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await self._cancel_cast()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await self.session.stop(quiet=True)
|
||||||
|
for backend in self.backends.values():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await backend.close()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await self.stream.stop()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await self.discovery.stop()
|
||||||
|
if not self._done.done():
|
||||||
|
self._done.set_result(None)
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""One cast: capture → local HTTP stream → receiver, and the watching of it."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .backends.base import BackendError
|
||||||
|
from .capture import Capture, PortalError, pick_encoder
|
||||||
|
from .devices import Device
|
||||||
|
from .stream import Fanout
|
||||||
|
from .util import firewall_hint, local_ip_for, log, runtime_dir, url_host
|
||||||
|
|
||||||
|
IDLE, STARTING, STREAMING, STOPPING, ERROR = "idle", "starting", "streaming", "stopping", "error"
|
||||||
|
|
||||||
|
|
||||||
|
def choose_container(backend, cfg) -> str:
|
||||||
|
"""The best container this receiver and this machine can agree on."""
|
||||||
|
preference = str(cfg["encoder"])
|
||||||
|
for container in backend.containers:
|
||||||
|
if container == "hls":
|
||||||
|
return "hls"
|
||||||
|
if preference in ("vp8", "vp9") and container != "webm":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
pick_encoder(preference, container)
|
||||||
|
return container
|
||||||
|
except PortalError:
|
||||||
|
continue
|
||||||
|
# Nothing matched the preference; fall back to whatever the machine has.
|
||||||
|
for container in backend.containers:
|
||||||
|
try:
|
||||||
|
pick_encoder("auto", container)
|
||||||
|
return container
|
||||||
|
except PortalError:
|
||||||
|
continue
|
||||||
|
raise PortalError("no usable encoder for this receiver")
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
def __init__(self, daemon) -> None:
|
||||||
|
self.daemon = daemon
|
||||||
|
self.state = IDLE
|
||||||
|
self.phase = "" # what "starting" is waiting on, for the panel
|
||||||
|
self.device: Device | None = None
|
||||||
|
self.backend = None
|
||||||
|
self.container = ""
|
||||||
|
self.encoder = ""
|
||||||
|
self.url = ""
|
||||||
|
self.error = ""
|
||||||
|
self.since = 0.0
|
||||||
|
self.player_state = ""
|
||||||
|
self.clients = 0
|
||||||
|
self.last_client = 0.0
|
||||||
|
self.capture = Capture(on_stopped=self._capture_stopped)
|
||||||
|
self.fanout: Fanout | None = None
|
||||||
|
self.hls_dir = None
|
||||||
|
self._watchdog: asyncio.Task | None = None
|
||||||
|
self._reconnects = 0
|
||||||
|
|
||||||
|
# ---- reporting ----
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
return self.state in (STARTING, STREAMING)
|
||||||
|
|
||||||
|
def to_json(self) -> dict:
|
||||||
|
width, height = self.capture.size
|
||||||
|
return {
|
||||||
|
"state": self.state,
|
||||||
|
"phase": self.phase,
|
||||||
|
"deviceId": self.device.id if self.device else "",
|
||||||
|
"deviceName": self.device.name if self.device else "",
|
||||||
|
"deviceKind": self.device.kind if self.device else "",
|
||||||
|
"since": self.since,
|
||||||
|
"url": self.url,
|
||||||
|
"container": self.container,
|
||||||
|
"encoder": self.encoder,
|
||||||
|
"source": self.capture.source_name,
|
||||||
|
"preview": str(self.capture.preview_path) if self.capture.preview_at else "",
|
||||||
|
"previewAt": self.capture.preview_at,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"clients": self.clients,
|
||||||
|
"playerState": self.player_state,
|
||||||
|
"bytes": self.fanout.bytes_out if self.fanout else 0,
|
||||||
|
# How far behind the receiver is letting itself fall, in bytes.
|
||||||
|
"backlog": self.fanout.backlog if self.fanout else 0,
|
||||||
|
"dropped": self.fanout.dropped if self.fanout else 0,
|
||||||
|
"error": self.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- start / stop ----
|
||||||
|
async def start(self, device: Device, backend) -> None:
|
||||||
|
await self.stop()
|
||||||
|
cfg = self.daemon.cfg
|
||||||
|
self.device, self.backend = device, backend
|
||||||
|
self.state, self.error, self.since = STARTING, "", time.time()
|
||||||
|
self.phase = "screen" # the portal is asking which screen to share
|
||||||
|
self.player_state, self.clients, self._reconnects = "", 0, 0
|
||||||
|
self.daemon.push_state(now=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.container = choose_container(backend, cfg)
|
||||||
|
hls_dir = None
|
||||||
|
if self.container == "hls":
|
||||||
|
hls_dir = runtime_dir() / "hls"
|
||||||
|
shutil.rmtree(hls_dir, ignore_errors=True)
|
||||||
|
hls_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.hls_dir = hls_dir
|
||||||
|
self.daemon.stream.hls_dir = hls_dir
|
||||||
|
self.fanout = None
|
||||||
|
else:
|
||||||
|
self.fanout = Fanout(self.container, int(cfg["bitrate"]))
|
||||||
|
self.daemon.stream.fanout = self.fanout
|
||||||
|
|
||||||
|
await self.daemon.stream.start(int(cfg["port"]))
|
||||||
|
await self.capture.start(
|
||||||
|
container="mpegts" if self.container == "hls" else self.container,
|
||||||
|
cfg=cfg, fanout=self.fanout,
|
||||||
|
hls_dir=str(hls_dir) if hls_dir else None,
|
||||||
|
on_phase=self._set_phase,
|
||||||
|
)
|
||||||
|
self.encoder = self.capture.encoder
|
||||||
|
|
||||||
|
ip = local_ip_for(device.host)
|
||||||
|
# A fresh token per session is both the access control and the reason
|
||||||
|
# a receiver cannot carry its old buffer - and the delay in it - into
|
||||||
|
# the new stream: to it, this is a URL it has never seen.
|
||||||
|
self.daemon.stream.new_token()
|
||||||
|
path = (self.daemon.stream.hls_path() if self.container == "hls"
|
||||||
|
else self.daemon.stream.url_path(self.container))
|
||||||
|
self.url = f"http://{url_host(ip)}:{self.daemon.stream.port}{path}"
|
||||||
|
if self.container == "hls":
|
||||||
|
await self._await_playlist()
|
||||||
|
|
||||||
|
self._set_phase("connect")
|
||||||
|
await backend.play(device, self.url, container=self.container, title="Screencast")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await self._teardown()
|
||||||
|
self.state, self.device, self.backend = IDLE, None, None
|
||||||
|
self.daemon.push_state(now=True)
|
||||||
|
raise
|
||||||
|
except (PortalError, BackendError) as exc:
|
||||||
|
await self.fail(str(exc))
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001 - never leave a half-started cast behind
|
||||||
|
log.exception("cast failed")
|
||||||
|
await self.fail(str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.state = STREAMING
|
||||||
|
# Handed over, but nothing is on screen until the receiver fetches it.
|
||||||
|
self.phase = "live" if self.clients else "waiting"
|
||||||
|
self.daemon.push_state(now=True)
|
||||||
|
self._watchdog = asyncio.create_task(self._watch())
|
||||||
|
|
||||||
|
def _set_phase(self, phase: str) -> None:
|
||||||
|
self.phase = phase
|
||||||
|
self.daemon.push_state(now=True)
|
||||||
|
|
||||||
|
async def _await_playlist(self) -> None:
|
||||||
|
"""AirPlay fetches the playlist immediately; give hlssink2 time to write one."""
|
||||||
|
assert self.hls_dir is not None
|
||||||
|
for _ in range(100):
|
||||||
|
if (self.hls_dir / "index.m3u8").exists():
|
||||||
|
await asyncio.sleep(1.0) # one full segment, so the receiver has something to play
|
||||||
|
return
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
raise PortalError("the encoder produced no HLS playlist")
|
||||||
|
|
||||||
|
async def fail(self, message: str) -> None:
|
||||||
|
log.warning("cast failed: %s", message)
|
||||||
|
device, backend = self.device, self.backend
|
||||||
|
await self._teardown()
|
||||||
|
self.device, self.backend = device, backend
|
||||||
|
self.state, self.error, self.phase = ERROR, message, ""
|
||||||
|
self.daemon.push_state(now=True)
|
||||||
|
|
||||||
|
async def stop(self, *, quiet: bool = False) -> None:
|
||||||
|
if self.state == IDLE and self.device is None:
|
||||||
|
return
|
||||||
|
self.state = STOPPING
|
||||||
|
if not quiet:
|
||||||
|
self.daemon.push_state(now=True)
|
||||||
|
device, backend = self.device, self.backend
|
||||||
|
if backend is not None and device is not None:
|
||||||
|
try:
|
||||||
|
await backend.stop(device)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("backend stop: %s", exc)
|
||||||
|
await self._teardown()
|
||||||
|
self.state, self.error, self.phase = IDLE, "", ""
|
||||||
|
self.device, self.backend = None, None
|
||||||
|
if not quiet:
|
||||||
|
self.daemon.push_state(now=True)
|
||||||
|
|
||||||
|
async def _teardown(self) -> None:
|
||||||
|
# The watchdog is usually the one calling this (through fail()), and a
|
||||||
|
# task that cancels itself never gets to finish the job: the session
|
||||||
|
# would stay "streaming" with a dead encoder behind it, which is exactly
|
||||||
|
# what a receiver stuck on a spinner looks like from here.
|
||||||
|
watchdog, self._watchdog = self._watchdog, None
|
||||||
|
if watchdog is not None and watchdog is not asyncio.current_task():
|
||||||
|
watchdog.cancel()
|
||||||
|
await self.capture.stop()
|
||||||
|
if self.fanout is not None:
|
||||||
|
self.fanout.close()
|
||||||
|
self.fanout = None
|
||||||
|
self.daemon.stream.fanout = None
|
||||||
|
self.daemon.stream.hls_dir = None
|
||||||
|
if self.hls_dir is not None:
|
||||||
|
shutil.rmtree(self.hls_dir, ignore_errors=True)
|
||||||
|
self.hls_dir = None
|
||||||
|
self.url = ""
|
||||||
|
self.clients = 0
|
||||||
|
|
||||||
|
# ---- live ----
|
||||||
|
def note_client(self, peer: str, connected: bool) -> None:
|
||||||
|
if self.fanout is None and self.hls_dir is None:
|
||||||
|
return
|
||||||
|
self.clients = self.fanout.clients if self.fanout else self.clients + (1 if connected else -1)
|
||||||
|
self.clients = max(0, self.clients)
|
||||||
|
if connected:
|
||||||
|
self.last_client = time.time()
|
||||||
|
self._reconnects = 0
|
||||||
|
if self.state == STREAMING:
|
||||||
|
self.phase = "live" if self.clients else "waiting"
|
||||||
|
self.daemon.push_state(now=True)
|
||||||
|
|
||||||
|
def _capture_stopped(self, code: int, error: str) -> None:
|
||||||
|
if not self.active:
|
||||||
|
return
|
||||||
|
asyncio.create_task(self.fail(error or f"screen capture stopped ({code})"))
|
||||||
|
|
||||||
|
async def _watch(self) -> None:
|
||||||
|
"""Poll the receiver, and notice when the picture stops arriving."""
|
||||||
|
last_bytes, last_progress = -1, time.time()
|
||||||
|
ticks = 0
|
||||||
|
while self.active:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
ticks += 1
|
||||||
|
if self.device is None or self.backend is None:
|
||||||
|
return
|
||||||
|
# A client that connected and then went quiet (a half-open socket, a
|
||||||
|
# receiver that gave up without closing) used to keep the session
|
||||||
|
# looking healthy forever, so check the encoder itself rather than
|
||||||
|
# trusting the client count.
|
||||||
|
if not self.capture.running:
|
||||||
|
await self.fail(self.capture.error or "the screen capture stopped")
|
||||||
|
return
|
||||||
|
if self.fanout is not None:
|
||||||
|
if self.fanout.bytes_out != last_bytes:
|
||||||
|
last_bytes, last_progress = self.fanout.bytes_out, time.time()
|
||||||
|
elif time.time() - last_progress > 15:
|
||||||
|
await self.fail("the encoder stopped producing video")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
status = await self.backend.poll(self.device)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.debug("poll: %s", exc)
|
||||||
|
status = {}
|
||||||
|
if status:
|
||||||
|
self.player_state = status.get("playerState", self.player_state)
|
||||||
|
if "volume" in status and status["volume"] is not None:
|
||||||
|
self.device.volume = float(status["volume"])
|
||||||
|
if "muted" in status:
|
||||||
|
self.device.muted = bool(status["muted"])
|
||||||
|
self.device.app = status.get("app", self.device.app)
|
||||||
|
if status.get("connected") is False:
|
||||||
|
await self.fail(f"{self.device.name} dropped the connection")
|
||||||
|
return
|
||||||
|
# A receiver that never fetched the stream, or that let go of it, is
|
||||||
|
# not casting — retry the play once before giving up on it.
|
||||||
|
stale = self.fanout is not None and self.clients == 0 and time.time() - self.since > 12
|
||||||
|
if stale and time.time() - self.last_client > 12:
|
||||||
|
if bool(self.daemon.cfg["autoReconnect"]) and self._reconnects < 2:
|
||||||
|
self._reconnects += 1
|
||||||
|
log.info("no receiver on the stream; retrying play (%d)", self._reconnects)
|
||||||
|
try:
|
||||||
|
await self.backend.play(self.device, self.url,
|
||||||
|
container=self.container, title="Screencast")
|
||||||
|
self.last_client = time.time()
|
||||||
|
except BackendError as exc:
|
||||||
|
await self.fail(str(exc))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
hint = firewall_hint(self.daemon.stream.port, self.device.host)
|
||||||
|
reason = f"{self.device.name} never picked up the stream"
|
||||||
|
if hint:
|
||||||
|
# The panel turns this into an "Open port" button.
|
||||||
|
self.daemon.firewall_blocked = True
|
||||||
|
await self.fail(f"{reason} — {hint}" if hint else reason)
|
||||||
|
return
|
||||||
|
# How much of the delay is ours: bytes the receiver has not taken
|
||||||
|
# yet, and bytes we skipped past because it never would have.
|
||||||
|
if self.fanout is not None and ticks % 8 == 0 and self.clients:
|
||||||
|
rate = self.fanout.bytes_out / max(1e-3, time.time() - self.fanout.started)
|
||||||
|
log.info("stream: %.1f KiB queued (%.1fs behind), %.0f KiB skipped, %.0f KiB/s",
|
||||||
|
self.fanout.backlog / 1024, self.fanout.backlog / max(1.0, rate),
|
||||||
|
self.fanout.dropped / 1024, rate / 1024)
|
||||||
|
self.daemon.push_state(now=True)
|
||||||
|
|
||||||
|
# ---- source ----
|
||||||
|
async def repick_source(self) -> None:
|
||||||
|
"""Restart the cast so the portal asks which screen to share again."""
|
||||||
|
if self.active and self.device is not None and self.backend is not None:
|
||||||
|
await self.start(self.device, self.backend)
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""The local HTTP server the receivers pull the screen stream from.
|
||||||
|
|
||||||
|
Receivers (Cast, DLNA, AirPlay) do not accept a push: they are handed a URL and
|
||||||
|
fetch it themselves. So the encoder writes one live byte stream and this fans it
|
||||||
|
out to whoever connected, plus serves the HLS directory AirPlay needs.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import ipaddress
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from .util import log
|
||||||
|
|
||||||
|
# A late client cannot start mid-container, so every stream keeps the bytes the
|
||||||
|
# encoder wrote before the first media chunk (EBML head + tracks for WebM, the
|
||||||
|
# moov/init for fMP4, PAT/PMT for MPEG-TS) and replays them on connect.
|
||||||
|
CLUSTER_MARK = b"\x1f\x43\xb6\x75" # WebM Cluster id
|
||||||
|
MOOF_MARK = b"moof" # fragmented MP4 fragment box
|
||||||
|
HEADER_LIMIT = 1 << 20 # never hoard more than 1 MiB as "header"
|
||||||
|
MIN_BACKLOG = 192 << 10 # never hold less than this, whatever the bitrate
|
||||||
|
BACKLOG_SECONDS = 1.5 # ...and never much more than this much screen
|
||||||
|
# (a few keyframe-aligned clusters: the drop unit)
|
||||||
|
|
||||||
|
|
||||||
|
class Reader:
|
||||||
|
"""One receiver's view of the stream: a short, self-trimming backlog.
|
||||||
|
|
||||||
|
A receiver pulls at playback speed, so anything we let pile up here is
|
||||||
|
latency we hand it and never get back — and a live stream that falls behind
|
||||||
|
never catches up on its own. Keeping under a second of screen queued, and
|
||||||
|
skipping forward when it does not drain, is what keeps the picture close to
|
||||||
|
now rather than however far behind it drifted an hour ago.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_bytes: int) -> None:
|
||||||
|
self.items: deque[tuple[bytes, bool]] = deque()
|
||||||
|
self.queued = 0
|
||||||
|
self.dropped = 0
|
||||||
|
self.max_bytes = max_bytes
|
||||||
|
self.closed = False
|
||||||
|
self._wake = asyncio.Event()
|
||||||
|
|
||||||
|
def push(self, data: bytes, at_boundary: bool) -> None:
|
||||||
|
self.items.append((data, at_boundary))
|
||||||
|
self.queued += len(data)
|
||||||
|
if self.queued > self.max_bytes:
|
||||||
|
self._skip_forward()
|
||||||
|
self._wake.set()
|
||||||
|
|
||||||
|
def _skip_forward(self) -> None:
|
||||||
|
while self.queued > self.max_bytes and len(self.items) > 1:
|
||||||
|
data, _ = self.items.popleft()
|
||||||
|
self.queued -= len(data)
|
||||||
|
self.dropped += len(data)
|
||||||
|
# Resume on a container boundary: dropping into the middle of a cluster
|
||||||
|
# hands the receiver's demuxer a torn one.
|
||||||
|
while len(self.items) > 1 and not self.items[0][1]:
|
||||||
|
data, _ = self.items.popleft()
|
||||||
|
self.queued -= len(data)
|
||||||
|
self.dropped += len(data)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
self._wake.set()
|
||||||
|
|
||||||
|
async def get(self) -> bytes | None:
|
||||||
|
while not self.items:
|
||||||
|
if self.closed:
|
||||||
|
return None
|
||||||
|
self._wake.clear()
|
||||||
|
await self._wake.wait()
|
||||||
|
data, _ = self.items.popleft()
|
||||||
|
self.queued -= len(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class Fanout:
|
||||||
|
"""One encoder byte stream, many HTTP readers."""
|
||||||
|
|
||||||
|
def __init__(self, container: str, bitrate_kbps: int = 5000) -> None:
|
||||||
|
self.container = container
|
||||||
|
self.header = b""
|
||||||
|
self.header_done = False
|
||||||
|
self.bytes_out = 0
|
||||||
|
self.started = time.time()
|
||||||
|
self.max_bytes = max(MIN_BACKLOG, int(bitrate_kbps * 1000 / 8 * BACKLOG_SECONDS))
|
||||||
|
# Where this container can be cut without tearing a unit in half. MPEG-TS
|
||||||
|
# has no such mark - it resynchronises on its own - so it is cut anywhere.
|
||||||
|
self._mark = (CLUSTER_MARK if container in ("webm", "matroska")
|
||||||
|
else MOOF_MARK if container == "mp4" else None)
|
||||||
|
self._carry = b"" # bytes held back in case a mark straddles a chunk
|
||||||
|
self._at_boundary = True
|
||||||
|
self._readers: set[Reader] = set()
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clients(self) -> int:
|
||||||
|
return len(self._readers)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backlog(self) -> int:
|
||||||
|
return max((r.queued for r in self._readers), default=0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dropped(self) -> int:
|
||||||
|
return max((r.dropped for r in self._readers), default=0)
|
||||||
|
|
||||||
|
def feed(self, chunk: bytes) -> None:
|
||||||
|
if self._closed or not chunk:
|
||||||
|
return
|
||||||
|
self.bytes_out += len(chunk)
|
||||||
|
if not self.header_done:
|
||||||
|
chunk = self._grow_header(chunk)
|
||||||
|
if not self.header_done or not chunk:
|
||||||
|
return
|
||||||
|
for data, at_boundary in self._cut(chunk):
|
||||||
|
for reader in list(self._readers):
|
||||||
|
reader.push(data, at_boundary)
|
||||||
|
|
||||||
|
def _cut(self, chunk: bytes) -> list[tuple[bytes, bool]]:
|
||||||
|
"""Split so that every piece starting a new container unit says so."""
|
||||||
|
if self._mark is None:
|
||||||
|
return [(chunk, True)]
|
||||||
|
buf = self._carry + chunk
|
||||||
|
width = len(self._mark)
|
||||||
|
pieces: list[tuple[bytes, bool]] = []
|
||||||
|
start = i = 0
|
||||||
|
while True:
|
||||||
|
idx = buf.find(self._mark, i)
|
||||||
|
if idx < 0:
|
||||||
|
break
|
||||||
|
if idx > start:
|
||||||
|
pieces.append((buf[start:idx], self._at_boundary))
|
||||||
|
self._at_boundary = False
|
||||||
|
start, i = idx, idx + width
|
||||||
|
self._at_boundary = True
|
||||||
|
tail = buf[start:]
|
||||||
|
hold = min(len(tail), width - 1)
|
||||||
|
body, self._carry = tail[: len(tail) - hold], tail[len(tail) - hold :]
|
||||||
|
if body:
|
||||||
|
pieces.append((body, self._at_boundary))
|
||||||
|
self._at_boundary = False
|
||||||
|
return pieces
|
||||||
|
|
||||||
|
def _grow_header(self, chunk: bytes) -> bytes:
|
||||||
|
"""Collect the container header; return whatever followed it."""
|
||||||
|
buf = self.header + chunk
|
||||||
|
mark = CLUSTER_MARK if self.container in ("webm", "matroska") else MOOF_MARK
|
||||||
|
idx = buf.find(mark, max(0, len(self.header) - 4))
|
||||||
|
if self.container == "mpegts":
|
||||||
|
# TS repeats its tables; the first packets are enough to start on.
|
||||||
|
if len(buf) >= 32768:
|
||||||
|
self.header, self.header_done = buf[:32768], True
|
||||||
|
return buf[32768:]
|
||||||
|
elif idx > 0:
|
||||||
|
self.header, self.header_done = buf[:idx], True
|
||||||
|
return buf[idx:]
|
||||||
|
if len(buf) >= HEADER_LIMIT:
|
||||||
|
self.header, self.header_done = buf[:HEADER_LIMIT], True
|
||||||
|
return buf[HEADER_LIMIT:]
|
||||||
|
self.header = buf
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def subscribe(self) -> Reader:
|
||||||
|
reader = Reader(self.max_bytes)
|
||||||
|
self._readers.add(reader)
|
||||||
|
return reader
|
||||||
|
|
||||||
|
def unsubscribe(self, reader: Reader) -> None:
|
||||||
|
self._readers.discard(reader)
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._closed = True
|
||||||
|
for reader in list(self._readers):
|
||||||
|
reader.close()
|
||||||
|
self._readers.clear()
|
||||||
|
|
||||||
|
|
||||||
|
CONTENT_TYPES = {
|
||||||
|
"webm": "video/webm",
|
||||||
|
"matroska": "video/x-matroska",
|
||||||
|
"mp4": "video/mp4",
|
||||||
|
"mpegts": "video/mp2t",
|
||||||
|
}
|
||||||
|
PATHS = {"webm": "/live.webm", "mp4": "/live.mp4", "mpegts": "/live.ts", "matroska": "/live.mkv"}
|
||||||
|
|
||||||
|
# One receiver pulls one stream; a couple spare for a retry that has not timed
|
||||||
|
# out yet. Anything past this is either a bug or someone else's curiosity, and
|
||||||
|
# each reader costs a socket plus its share of the backlog.
|
||||||
|
MAX_CLIENTS = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _is_lan(peer: str | None) -> bool:
|
||||||
|
"""Receivers live on the LAN. Anything routed in from outside it is not one."""
|
||||||
|
if not peer:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(peer.split("%", 1)[0])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if ip.version == 6 and ip.ipv4_mapped:
|
||||||
|
ip = ip.ipv4_mapped
|
||||||
|
return ip.is_private or ip.is_loopback or ip.is_link_local
|
||||||
|
|
||||||
|
|
||||||
|
class StreamServer:
|
||||||
|
"""aiohttp server exposing whatever the current session is encoding.
|
||||||
|
|
||||||
|
`on_client` lets the session know a receiver actually connected (or that the
|
||||||
|
last one went away) — that is the only reliable signal that casting started.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, on_client=None) -> None:
|
||||||
|
self.port = 0
|
||||||
|
self.fanout: Fanout | None = None
|
||||||
|
self.hls_dir: Path | None = None
|
||||||
|
self.on_client = on_client
|
||||||
|
# The stream port has to be reachable from the LAN or no receiver can
|
||||||
|
# pull it, and none of these protocols can carry a credential. So the
|
||||||
|
# capability lives in the URL: a fresh unguessable path per session,
|
||||||
|
# which is the only thing standing between a shared screen and everyone
|
||||||
|
# else on the network.
|
||||||
|
self.token = secrets.token_urlsafe(16)
|
||||||
|
self._runner: web.AppRunner | None = None
|
||||||
|
self._app = web.Application()
|
||||||
|
self._app.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/", self._index),
|
||||||
|
web.get("/status", self._status),
|
||||||
|
web.options("/{token}/live.{ext}", self._preflight),
|
||||||
|
web.route("*", "/{token}/live.{ext}", self._live),
|
||||||
|
web.route("*", "/{token}/hls/{name}", self._hls),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def new_token(self) -> str:
|
||||||
|
"""Rotate the stream path. Every session gets its own; the old one dies."""
|
||||||
|
self.token = secrets.token_urlsafe(16)
|
||||||
|
return self.token
|
||||||
|
|
||||||
|
def _authorized(self, request: web.Request) -> bool:
|
||||||
|
return (
|
||||||
|
_is_lan(request.remote)
|
||||||
|
and secrets.compare_digest(request.match_info.get("token", ""), self.token)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(self, port: int) -> None:
|
||||||
|
if self._runner is not None and self.port == port:
|
||||||
|
return
|
||||||
|
await self.stop()
|
||||||
|
self._runner = web.AppRunner(self._app, access_log=None)
|
||||||
|
await self._runner.setup()
|
||||||
|
site = web.TCPSite(self._runner, "0.0.0.0", port, reuse_address=True)
|
||||||
|
await site.start()
|
||||||
|
self.port = port
|
||||||
|
log.info("http stream server on 0.0.0.0:%d", port)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._runner is not None:
|
||||||
|
await self._runner.cleanup()
|
||||||
|
self._runner = None
|
||||||
|
self.port = 0
|
||||||
|
|
||||||
|
def url_path(self, container: str) -> str:
|
||||||
|
return f"/{self.token}" + PATHS.get(container, "/live.webm")
|
||||||
|
|
||||||
|
def hls_path(self) -> str:
|
||||||
|
return f"/{self.token}/hls/index.m3u8"
|
||||||
|
|
||||||
|
async def _index(self, request: web.Request) -> web.Response:
|
||||||
|
return web.Response(text="screencast\n")
|
||||||
|
|
||||||
|
async def _status(self, request: web.Request) -> web.Response:
|
||||||
|
# Whether this desktop is casting is nobody else's business.
|
||||||
|
if request.remote not in ("127.0.0.1", "::1"):
|
||||||
|
raise web.HTTPNotFound()
|
||||||
|
f = self.fanout
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"streaming": f is not None,
|
||||||
|
"container": f.container if f else "",
|
||||||
|
"clients": f.clients if f else 0,
|
||||||
|
"bytes": f.bytes_out if f else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _preflight(self, request: web.Request) -> web.Response:
|
||||||
|
return web.Response(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "*",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _hls(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
if not self._authorized(request):
|
||||||
|
raise web.HTTPNotFound()
|
||||||
|
name = request.match_info["name"]
|
||||||
|
if self.hls_dir is None or "/" in name or name.startswith("."):
|
||||||
|
raise web.HTTPNotFound()
|
||||||
|
path = self.hls_dir / name
|
||||||
|
if not path.is_file():
|
||||||
|
raise web.HTTPNotFound()
|
||||||
|
ctype = "application/vnd.apple.mpegurl" if name.endswith(".m3u8") else "video/mp2t"
|
||||||
|
return web.FileResponse(
|
||||||
|
path,
|
||||||
|
headers={"Content-Type": ctype, "Cache-Control": "no-cache",
|
||||||
|
"Access-Control-Allow-Origin": "*"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _live(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
if not self._authorized(request):
|
||||||
|
raise web.HTTPNotFound()
|
||||||
|
f = self.fanout
|
||||||
|
if f is None:
|
||||||
|
raise web.HTTPServiceUnavailable(text="nothing is being cast")
|
||||||
|
if request.method != "HEAD" and f.clients >= MAX_CLIENTS:
|
||||||
|
log.warning("stream: refusing %s, already %d clients", request.remote, f.clients)
|
||||||
|
raise web.HTTPServiceUnavailable(text="too many clients")
|
||||||
|
headers = {
|
||||||
|
"Content-Type": CONTENT_TYPES.get(f.container, "application/octet-stream"),
|
||||||
|
# The Cast receiver is a web page playing our URL through the media
|
||||||
|
# stack: without these it never gets past "loading". Wide-open CORS
|
||||||
|
# is only safe because the path itself is the secret - a page that
|
||||||
|
# cannot guess the token cannot read the stream.
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "*",
|
||||||
|
"Access-Control-Expose-Headers": "*",
|
||||||
|
"Cache-Control": "no-cache, no-store",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
# DLNA renderers refuse a stream that does not say it is one.
|
||||||
|
"transferMode.dlna.org": "Streaming",
|
||||||
|
"contentFeatures.dlna.org": "DLNA.ORG_OP=00;DLNA.ORG_CI=0;"
|
||||||
|
"DLNA.ORG_FLAGS=8D500000000000000000000000000000",
|
||||||
|
"Server": "Linux/1.0 UPnP/1.0 screencast/1.0",
|
||||||
|
"Accept-Ranges": "none",
|
||||||
|
}
|
||||||
|
resp = web.StreamResponse(status=200, headers=headers)
|
||||||
|
resp.enable_chunked_encoding()
|
||||||
|
await resp.prepare(request)
|
||||||
|
if request.method == "HEAD":
|
||||||
|
return resp
|
||||||
|
|
||||||
|
reader = f.subscribe()
|
||||||
|
peer = request.remote or "?"
|
||||||
|
log.info("stream client %s connected (%s)", peer, f.container)
|
||||||
|
if self.on_client:
|
||||||
|
self.on_client(peer, True)
|
||||||
|
# Nagle would hold a small write back waiting for company; on a live
|
||||||
|
# stream that is latency for nothing.
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
request.transport.get_extra_info("socket").setsockopt(
|
||||||
|
socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
request.transport.set_write_buffer_limits(high=128 << 10)
|
||||||
|
try:
|
||||||
|
if f.header:
|
||||||
|
await resp.write(f.header)
|
||||||
|
while True:
|
||||||
|
chunk = await reader.get()
|
||||||
|
if chunk is None:
|
||||||
|
break
|
||||||
|
await resp.write(chunk)
|
||||||
|
except (ConnectionResetError, asyncio.CancelledError):
|
||||||
|
pass
|
||||||
|
except Exception as exc: # noqa: BLE001 - a dropped receiver must not kill the server
|
||||||
|
log.debug("stream client %s error: %s", peer, exc)
|
||||||
|
finally:
|
||||||
|
f.unsubscribe(reader)
|
||||||
|
log.info("stream client %s gone", peer)
|
||||||
|
if self.on_client:
|
||||||
|
self.on_client(peer, False)
|
||||||
|
return resp
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""Small helpers shared by the daemon: logging, paths, local addresses, subprocess."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger("screencast")
|
||||||
|
_firewall_tool: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(verbose: bool = False) -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if verbose else logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname).1s %(name)s: %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
# The shell owns the daemon's stderr and keeps only the last few lines in a
|
||||||
|
# QML property, so also keep a real log on disk: when a cast misbehaves this
|
||||||
|
# file is the only account of what the encoder and the receiver did.
|
||||||
|
try:
|
||||||
|
handler = logging.handlers.RotatingFileHandler(
|
||||||
|
state_dir() / "daemon.log", maxBytes=512_000, backupCount=1
|
||||||
|
)
|
||||||
|
handler.setFormatter(logging.Formatter(
|
||||||
|
"%(asctime)s %(levelname).1s %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
|
||||||
|
logging.getLogger().addHandler(handler)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("no log file: %s", exc)
|
||||||
|
# These three are chatty at DEBUG and none of it is ours.
|
||||||
|
for noisy in ("zeroconf", "pychromecast", "aiohttp.access", "dbus_fast"):
|
||||||
|
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def firewall_tool(refresh: bool = False) -> str:
|
||||||
|
"""Which host firewall is running, if any ("ufw", "firewalld" or "").
|
||||||
|
|
||||||
|
A receiver fetches the stream from us, so the host firewall has to let it
|
||||||
|
in. Omarchy installs ufw with `default deny incoming`, which drops the TV's
|
||||||
|
SYN silently: the TV shows a spinner and we never see a client at all.
|
||||||
|
Reading the rules needs root, so all we can tell is which firewall is up.
|
||||||
|
"""
|
||||||
|
global _firewall_tool
|
||||||
|
if _firewall_tool is not None and not refresh:
|
||||||
|
return _firewall_tool
|
||||||
|
_firewall_tool = ""
|
||||||
|
for unit in ("ufw", "firewalld"):
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["systemctl", "is-active", unit],
|
||||||
|
capture_output=True, text=True, timeout=3)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
if out.stdout.strip() == "active":
|
||||||
|
_firewall_tool = unit
|
||||||
|
break
|
||||||
|
return _firewall_tool
|
||||||
|
|
||||||
|
|
||||||
|
def firewall_argv(tool: str, port: int, host: str = "") -> list[list[str]]:
|
||||||
|
"""The privileged command(s) that open the stream port to the local network."""
|
||||||
|
if tool == "ufw":
|
||||||
|
return [["ufw", "allow", "from", _subnet_of(host), "to", "any",
|
||||||
|
"port", str(port), "proto", "tcp", "comment", "screencast"]]
|
||||||
|
if tool == "firewalld":
|
||||||
|
# Scoped to the LAN like the ufw rule above: --add-port alone would open
|
||||||
|
# this port to every network the machine ever joins, permanently.
|
||||||
|
rule = (f'rule family="ipv{4 if ":" not in _subnet_of(host) else 6}" '
|
||||||
|
f'source address="{_subnet_of(host)}" '
|
||||||
|
f'port port="{port}" protocol="tcp" accept')
|
||||||
|
return [["firewall-cmd", f"--add-rich-rule={rule}", "--permanent"],
|
||||||
|
["firewall-cmd", "--reload"]]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def firewall_command(tool: str, port: int, host: str = "") -> str:
|
||||||
|
"""The same thing as one line a person can paste into a terminal."""
|
||||||
|
parts = [" ".join(["sudo", *argv]) for argv in firewall_argv(tool, port, host)]
|
||||||
|
return " && ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_argv(command: list[str]) -> list[str] | None:
|
||||||
|
"""Run `command` in the user's terminal, for the times pkexec cannot ask.
|
||||||
|
|
||||||
|
Not every desktop runs a polkit authentication agent - Omarchy does not
|
||||||
|
install one - and without an agent pkexec has nowhere to prompt. A terminal
|
||||||
|
window with sudo in it needs nothing installed and shows the user exactly
|
||||||
|
what is being run.
|
||||||
|
"""
|
||||||
|
for candidate in (os.environ.get("TERMINAL"), "xdg-terminal-exec", "ghostty",
|
||||||
|
"alacritty", "kitty", "foot", "wezterm", "xterm"):
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
exe = shutil.which(candidate)
|
||||||
|
if exe is None:
|
||||||
|
continue
|
||||||
|
name = Path(exe).name
|
||||||
|
if name in ("xdg-terminal-exec", "foot", "kitty", "ghostty"):
|
||||||
|
return [exe, *command] # these take the command as plain arguments
|
||||||
|
if name == "wezterm":
|
||||||
|
return [exe, "start", "--", *command]
|
||||||
|
return [exe, "-e", *command] # alacritty, xterm and the rest
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def firewall_hint(port: int, host: str = "") -> str:
|
||||||
|
tool = firewall_tool()
|
||||||
|
if not tool:
|
||||||
|
return ""
|
||||||
|
return (f"{tool} is blocking port {port} — receivers cannot fetch the stream "
|
||||||
|
f"until it is opened")
|
||||||
|
|
||||||
|
|
||||||
|
def _subnet_of(host: str) -> str:
|
||||||
|
"""The /24 the receiver lives on, so the opened port stays on the LAN."""
|
||||||
|
# `host` comes from a discovery announcement, which anyone on the wire can
|
||||||
|
# send: a device claiming a public address would otherwise pick the subnet
|
||||||
|
# in a rule we are about to install as root. Only a private address gets to
|
||||||
|
# choose; otherwise ask the kernel which address the default route leaves
|
||||||
|
# from, which is the LAN the receivers actually live on.
|
||||||
|
try:
|
||||||
|
if host and not ipaddress.ip_address(host).is_private:
|
||||||
|
host = ""
|
||||||
|
except ValueError:
|
||||||
|
host = ""
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(local_ip_for(host or "1.1.1.1"))
|
||||||
|
except ValueError:
|
||||||
|
return "192.168.0.0/16"
|
||||||
|
if not addr.is_private:
|
||||||
|
return "192.168.0.0/16"
|
||||||
|
if isinstance(addr, ipaddress.IPv4Address):
|
||||||
|
return str(ipaddress.ip_network(f"{addr}/24", strict=False))
|
||||||
|
return str(ipaddress.ip_network(f"{addr}/64", strict=False))
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_dir() -> Path:
|
||||||
|
"""Where the control socket, the preview frame and HLS segments live.
|
||||||
|
|
||||||
|
Without XDG_RUNTIME_DIR this falls back to /tmp, which is shared and
|
||||||
|
predictable: another local user could pre-create the directory, or plant a
|
||||||
|
symlink where the preview frame is about to be written. So refuse anything
|
||||||
|
we do not own, and keep it to ourselves.
|
||||||
|
"""
|
||||||
|
base = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
|
||||||
|
p = Path(base) / "screencast"
|
||||||
|
p.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
st = p.lstat()
|
||||||
|
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid():
|
||||||
|
raise RuntimeError(f"{p} is not ours — refusing to use it")
|
||||||
|
if st.st_mode & 0o077:
|
||||||
|
p.chmod(0o700)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def config_dir() -> Path:
|
||||||
|
base = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")
|
||||||
|
p = Path(base) / "screencast"
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def state_dir() -> Path:
|
||||||
|
"""Logs and, for AirPlay, the receiver pairing credentials — so 0700."""
|
||||||
|
base = os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local/state")
|
||||||
|
p = Path(base) / "screencast"
|
||||||
|
p.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
if p.stat().st_mode & 0o077:
|
||||||
|
p.chmod(0o700)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path, default):
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, data) -> None:
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(data, indent=2))
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def local_ip_for(host: str) -> str:
|
||||||
|
"""The address a receiver at `host` would see us as.
|
||||||
|
|
||||||
|
The machine can sit on several networks at once (several NICs, a VPN), so
|
||||||
|
the stream URL must carry the address that routes back to that particular
|
||||||
|
device: let the kernel pick it.
|
||||||
|
"""
|
||||||
|
fam = socket.AF_INET
|
||||||
|
try:
|
||||||
|
if isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address):
|
||||||
|
fam = socket.AF_INET6
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
s = socket.socket(fam, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.connect((host, 9))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
ip = "127.0.0.1"
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
def url_host(ip: str) -> str:
|
||||||
|
return f"[{ip}]" if ":" in ip else ip
|
||||||
|
|
||||||
|
|
||||||
|
async def run_cmd(*args: str, timeout: float = 5.0) -> tuple[int, str, str]:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
out, err = await asyncio.wait_for(proc.communicate(), timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
return 124, "", "timeout"
|
||||||
|
return proc.returncode or 0, out.decode(errors="replace"), err.decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def have(binary: str) -> bool:
|
||||||
|
from shutil import which
|
||||||
|
|
||||||
|
return which(binary) is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def port_open(host: str, port: int, timeout: float = 2.0) -> bool:
|
||||||
|
"""Is anything listening there? Distinguishes "asleep" from "not running"."""
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False
|
||||||
|
writer.close()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await writer.wait_closed()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def gst_has(element: str) -> bool:
|
||||||
|
"""Is this GStreamer element installed? (cached; a plugin set never changes mid-run)"""
|
||||||
|
if element in _gst_cache:
|
||||||
|
return _gst_cache[element]
|
||||||
|
ok = False
|
||||||
|
try:
|
||||||
|
ok = subprocess.run(
|
||||||
|
["gst-inspect-1.0", "--exists", element], timeout=10
|
||||||
|
).returncode == 0
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
_gst_cache[element] = ok
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
_gst_cache: dict[str, bool] = {}
|
||||||
Executable
+114
@@ -0,0 +1,114 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Install the Screencast plugin for the current user.
|
||||||
|
# ./install.sh install the daemon venv, link + enable the shell plugin
|
||||||
|
# ./install.sh --no-root same, but never ask for a password (skips missing packages)
|
||||||
|
# ./install.sh --uninstall
|
||||||
|
#
|
||||||
|
# Nothing here needs root except installing missing Arch packages, and even that
|
||||||
|
# is skipped with --no-root: the daemon runs entirely as you.
|
||||||
|
set -euo pipefail
|
||||||
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
LIB=$HOME/.local/lib/screencast
|
||||||
|
BIN=$HOME/.local/bin
|
||||||
|
CACHE=${XDG_CACHE_HOME:-$HOME/.cache}/screencast
|
||||||
|
STATE=${XDG_STATE_HOME:-$HOME/.local/state}/screencast
|
||||||
|
ID=io.github.alanfortlink.screencast
|
||||||
|
PLUGIN=$HOME/.config/omarchy/plugins/$ID
|
||||||
|
MODE=${1:-}
|
||||||
|
LOG=$CACHE/install.log
|
||||||
|
|
||||||
|
if [[ $MODE == --uninstall ]]; then
|
||||||
|
omarchy-plugin-disable "$ID" >/dev/null 2>&1 || true
|
||||||
|
# The daemon is a plain user process; ask it to quit, then remove its files.
|
||||||
|
"$LIB/screencast-server" stop >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$LIB" "$CACHE" "$STATE" "$BIN/screencast-server"
|
||||||
|
[[ -L $PLUGIN ]] && rm -f "$PLUGIN" # dev symlink only
|
||||||
|
echo "uninstalled (settings left in ~/.config/screencast/config.json). Now run: omarchy plugin remove $ID"
|
||||||
|
echo "note: if you ever let it open the stream port, that firewall rule is still there."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Runtime dependencies, all from the Arch repos.
|
||||||
|
missing=()
|
||||||
|
for p in python gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-pipewire gst-libav libpipewire xdg-desktop-portal; do
|
||||||
|
pacman -Q "$p" >/dev/null 2>&1 || missing+=("$p")
|
||||||
|
done
|
||||||
|
# Something has to answer org.freedesktop.portal.ScreenCast.
|
||||||
|
if ! pacman -Q xdg-desktop-portal-hyprland >/dev/null 2>&1 && ! pacman -Q xdg-desktop-portal-wlr >/dev/null 2>&1; then
|
||||||
|
missing+=(xdg-desktop-portal-hyprland)
|
||||||
|
fi
|
||||||
|
if ((${#missing[@]})); then
|
||||||
|
if [[ $MODE == --no-root ]]; then
|
||||||
|
: # reported at the end, where the panel can see it
|
||||||
|
else
|
||||||
|
echo "› installing missing packages: ${missing[*]} (password prompt)"
|
||||||
|
if sudo -n true 2>/dev/null; then sudo pacman -S --needed --noconfirm "${missing[@]}"
|
||||||
|
else pkexec pacman -S --needed --noconfirm "${missing[@]}"; fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$CACHE" "$LIB" "$BIN"
|
||||||
|
echo "› creating the Python environment in $LIB/venv (log: $LOG)"
|
||||||
|
{
|
||||||
|
python3 -m venv --upgrade-deps "$LIB/venv"
|
||||||
|
"$LIB/venv/bin/pip" install --upgrade -r "$HERE/daemon/requirements.txt"
|
||||||
|
} >"$LOG" 2>&1 || { tail -n 15 "$LOG" >&2; echo "error: could not build the environment; log: $LOG" >&2; exit 1; }
|
||||||
|
|
||||||
|
# AirPlay is optional: pyatv is a big dependency tree and lags new Python
|
||||||
|
# releases, and everything else works without it.
|
||||||
|
if "$LIB/venv/bin/pip" install --upgrade -r "$HERE/daemon/requirements-optional.txt" >>"$LOG" 2>&1; then
|
||||||
|
echo " AirPlay support installed (pyatv)"
|
||||||
|
else
|
||||||
|
echo " note: pyatv did not install — Cast and DLNA still work, AirPlay will not (see $LOG)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "› installing the daemon"
|
||||||
|
rm -rf "$LIB/screencast"
|
||||||
|
cp -r "$HERE/daemon/screencast" "$LIB/screencast"
|
||||||
|
find "$LIB/screencast" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
cat > "$LIB/screencast-server" <<LAUNCH
|
||||||
|
#!/bin/sh
|
||||||
|
# Generated by install.sh — runs the daemon out of its own virtualenv.
|
||||||
|
exec env PYTHONPATH="$LIB" "$LIB/venv/bin/python" -m screencast "\$@"
|
||||||
|
LAUNCH
|
||||||
|
chmod 755 "$LIB/screencast-server"
|
||||||
|
ln -sfn "$LIB/screencast-server" "$BIN/screencast-server"
|
||||||
|
git -C "$HERE" rev-parse HEAD > "$LIB/installed-commit" 2>/dev/null || date +%s > "$LIB/installed-commit"
|
||||||
|
|
||||||
|
echo "› installing the shell plugin"
|
||||||
|
mkdir -p "$(dirname "$PLUGIN")"
|
||||||
|
if [[ $(readlink -f "$PLUGIN" 2>/dev/null) != "$(readlink -f "$HERE")" ]]; then
|
||||||
|
if [[ -L $PLUGIN || ! -e $PLUGIN ]]; then
|
||||||
|
ln -sfn "$HERE" "$PLUGIN" # dev mode; a real checkout comes from `omarchy plugin add`
|
||||||
|
omarchy-shell -q shell rescanPlugins 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if command -v omarchy-shell >/dev/null && omarchy-shell -q shell ping >/dev/null 2>&1; then
|
||||||
|
enabled() { omarchy-plugin-list 2>/dev/null | awk -v id="$ID" '$1 == id && $2 == "enabled" { found = 1 } END { exit !found }'; }
|
||||||
|
for _ in 1 2 3 4 5; do
|
||||||
|
if enabled; then break; fi
|
||||||
|
if omarchy-plugin-enable "$ID" right >/dev/null 2>&1; then echo " enabled $ID"; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
enabled || echo " note: not enabled yet; run: omarchy plugin enable $ID"
|
||||||
|
else
|
||||||
|
echo " note: omarchy-shell is not running; enable later with: omarchy plugin enable $ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The receiver pulls the stream from us, so the host firewall has to let it in.
|
||||||
|
# Installing stays root-free: the panel offers to open the port (via polkit) the
|
||||||
|
# first time a cast is actually blocked by it.
|
||||||
|
if [[ $(systemctl is-active ufw 2>/dev/null) == active || $(systemctl is-active firewalld 2>/dev/null) == active ]]; then
|
||||||
|
echo "› note: this machine runs a firewall. Receivers fetch the stream from us, so"
|
||||||
|
echo " the panel has an \"Open port\" button: one password dialog, once, and the"
|
||||||
|
echo " rule stays. Nothing to type here."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "done. The cast icon appears in the bar (if not: omarchy-shell shell rescanPlugins)."
|
||||||
|
echo "From a terminal: screencast-server devices | cast \"<name>\" | stop"
|
||||||
|
|
||||||
|
# Missing system packages are the one thing this cannot fix without root, so say
|
||||||
|
# so last: the plugin shows the final line of this log verbatim.
|
||||||
|
if ((${#missing[@]})) && [[ $MODE == --no-root ]]; then
|
||||||
|
echo "MISSING: sudo pacman -S --needed ${missing[*]}"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "io.github.alanfortlink.screencast",
|
||||||
|
"name": "Screencast",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"author": "alanfortlink",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Find the displays on your network — Google Cast, AirPlay and DLNA — and cast this desktop to them, with volume, pause and stop from the bar.",
|
||||||
|
"kinds": [
|
||||||
|
"service",
|
||||||
|
"bar-widget"
|
||||||
|
],
|
||||||
|
"keepLoaded": true,
|
||||||
|
"entryPoints": {
|
||||||
|
"service": "plugin/Service.qml",
|
||||||
|
"barWidget": "plugin/Panel.qml"
|
||||||
|
},
|
||||||
|
"barWidget": {
|
||||||
|
"displayName": "Screencast",
|
||||||
|
"description": "Cast the screen to a TV, Chromecast or speaker on the network; the icon lights up while casting.",
|
||||||
|
"category": "Media",
|
||||||
|
"allowMultiple": false,
|
||||||
|
"defaultSection": "right",
|
||||||
|
"defaults": {
|
||||||
|
"alwaysShow": true
|
||||||
|
},
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"key": "alwaysShow",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Show the icon even when nothing is being cast",
|
||||||
|
"defaultValue": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,999 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.Commons
|
||||||
|
import qs.Ui
|
||||||
|
|
||||||
|
// Bar icon + popup panel for Screencast: the receivers found on the network,
|
||||||
|
// what is being cast right now, and the handful of knobs that decide what goes
|
||||||
|
// over the wire. All the state lives in the screencast-server daemon (see
|
||||||
|
// Service.qml); this file renders it and forwards what the user does.
|
||||||
|
Panel {
|
||||||
|
id: root
|
||||||
|
moduleName: "io.github.alanfortlink.screencast"
|
||||||
|
ipcTarget: "io.github.alanfortlink.screencast"
|
||||||
|
// manageIpc: false so this panel can own the single IpcHandler the target
|
||||||
|
// permits — needed for the cast/stop methods below.
|
||||||
|
manageIpc: false
|
||||||
|
|
||||||
|
readonly property var svc: bar && bar.shell ? bar.shell.serviceFor("io.github.alanfortlink.screencast") : null
|
||||||
|
readonly property bool connected: !!svc && svc.connected
|
||||||
|
readonly property bool installed: !!svc && svc.installed
|
||||||
|
readonly property var devices: svc ? svc.devices : []
|
||||||
|
readonly property var session: svc ? svc.session : ({})
|
||||||
|
readonly property var s: svc ? svc.settings : ({})
|
||||||
|
readonly property bool casting: !!svc && svc.casting
|
||||||
|
readonly property bool starting: !!svc && svc.sessionState === "starting"
|
||||||
|
// The click that has not been answered yet, so the row can react to it now.
|
||||||
|
readonly property string pendingId: svc ? svc.pendingId : ""
|
||||||
|
readonly property var castDevice: svc ? svc.castDevice : null
|
||||||
|
readonly property bool alwaysShow: setting("alwaysShow", true)
|
||||||
|
// Anything the daemon is currently doing on our behalf, so every control that
|
||||||
|
// triggers it can show that it was heard.
|
||||||
|
readonly property bool workingBusy: !!svc && svc.acting
|
||||||
|
// "Casting but nothing fetched yet" counts as work in progress: that wait is
|
||||||
|
// where a blocked port shows up, and silence there is what looks broken.
|
||||||
|
readonly property bool working: starting || workingBusy || (!!svc && svc.setupBusy)
|
||||||
|
|| (casting && !!svc && !svc.live)
|
||||||
|
// Only cast work belongs on the bar icon: a routine rescan every time the
|
||||||
|
// panel opens must not make the bar look like something is happening.
|
||||||
|
readonly property bool castWorking: starting || (!!svc && svc.pendingId !== "")
|
||||||
|
|| (casting && !!svc && !svc.live)
|
||||||
|
|
||||||
|
// A start goes through the portal, the encoder, the receiver, and then the
|
||||||
|
// wait for that receiver to actually fetch the stream. Say which one it is.
|
||||||
|
function phaseText() {
|
||||||
|
if (!svc) return ""
|
||||||
|
var to = svc.castingTo || "the receiver"
|
||||||
|
if (!starting && !casting && svc.activity !== "") return svc.activity
|
||||||
|
switch (svc.sessionPhase) {
|
||||||
|
case "screen": return "Choose the screen to share…"
|
||||||
|
case "encoder": return "Starting the encoder…"
|
||||||
|
case "connect": return "Handing the stream to " + to + "…"
|
||||||
|
case "waiting": return "Waiting for " + to + " to start playing…"
|
||||||
|
}
|
||||||
|
if (starting) return "Connecting to " + to + "…"
|
||||||
|
if (casting && !svc.live) return "Waiting for " + to + " to start playing…"
|
||||||
|
return svc.activity
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property color fg: bar ? bar.foreground : Color.foreground
|
||||||
|
readonly property color dim: Qt.darker(fg, 1.45)
|
||||||
|
readonly property color urgent: bar ? bar.urgent : Color.urgent
|
||||||
|
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
||||||
|
|
||||||
|
// md-cast / md-cast_connected: the bar icon says at a glance whether the
|
||||||
|
// screen is going somewhere.
|
||||||
|
readonly property string castGlyph: ""
|
||||||
|
readonly property string castConnectedGlyph: ""
|
||||||
|
readonly property string tvGlyph: ""
|
||||||
|
readonly property string speakerGlyph: ""
|
||||||
|
readonly property string monitorGlyph: ""
|
||||||
|
|
||||||
|
// Frame-based rather than a rotating glyph: it stays legible at caption size
|
||||||
|
// and does not depend on the icon font having a spinner.
|
||||||
|
component Spinner: Text {
|
||||||
|
property bool spinning: true
|
||||||
|
property int frame: 0
|
||||||
|
readonly property string frames: "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||||||
|
text: frames.charAt(frame)
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
opacity: spinning ? 1 : 0
|
||||||
|
Behavior on opacity { NumberAnimation { duration: 120 } }
|
||||||
|
Timer {
|
||||||
|
interval: 90
|
||||||
|
repeat: true
|
||||||
|
running: parent.spinning && root.opened
|
||||||
|
onTriggered: parent.frame = (parent.frame + 1) % parent.frames.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The preview file is overwritten in place, so the Image needs a changing
|
||||||
|
// source to reload it. Ticks only while the panel is actually on screen.
|
||||||
|
property int previewTick: 0
|
||||||
|
Timer {
|
||||||
|
interval: 450
|
||||||
|
repeat: true
|
||||||
|
running: root.opened && root.casting
|
||||||
|
onTriggered: root.previewTick++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticks only while something is in flight; drives the "(12s)" counter.
|
||||||
|
property int elapsedTick: 0
|
||||||
|
Timer {
|
||||||
|
interval: 1000
|
||||||
|
repeat: true
|
||||||
|
running: root.working && root.opened
|
||||||
|
onTriggered: root.elapsedTick++
|
||||||
|
}
|
||||||
|
function elapsedSuffix() {
|
||||||
|
if (!svc || !svc.sessionSince || !(starting || (casting && !svc.live))) return ""
|
||||||
|
var tick = elapsedTick // referenced so this binding re-runs every second
|
||||||
|
var secs = Math.floor(Date.now() / 1000 - svc.sessionSince) + 0 * tick
|
||||||
|
return secs >= 3 ? " " + secs + "s" : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairingThis(dev) {
|
||||||
|
if (!svc || !dev) return false
|
||||||
|
return svc.activity.indexOf("Asking " + dev.name) === 0 || svc.pairing === dev.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceGlyph(dev) {
|
||||||
|
if (!dev) return tvGlyph
|
||||||
|
return dev.audioOnly ? speakerGlyph : tvGlyph
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLine() {
|
||||||
|
if (!svc) return "starting…"
|
||||||
|
if (!installed) return "not installed yet"
|
||||||
|
if (!connected) return svc.daemonError !== "" ? svc.daemonError : "starting the caster…"
|
||||||
|
if (svc.sessionState === "error") return svc.sessionError || "cast failed"
|
||||||
|
if (starting) return "setting up…"
|
||||||
|
if (svc.activity !== "") return svc.activity
|
||||||
|
if (casting) {
|
||||||
|
var res = session.width > 0 ? session.width + "×" + session.height : ""
|
||||||
|
var bits = [res, String(session.encoder || "").toUpperCase(), (s.fps || 30) + " fps"]
|
||||||
|
if (svc.paused) bits.push("paused")
|
||||||
|
return bits.filter(function(b) { return b !== "" }).join(" · ")
|
||||||
|
}
|
||||||
|
var n = devices.length
|
||||||
|
return n === 0 ? "looking for displays…" : n + (n === 1 ? " display found" : " displays found")
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceSubtitle(dev) {
|
||||||
|
if (dev.id === pendingId && !casting) return svc.activity !== "" ? svc.activity : "connecting…"
|
||||||
|
if (dev.id === (session.deviceId || "") && casting)
|
||||||
|
return starting ? "connecting…" : (svc.paused ? "paused" : "casting" + (session.clients > 0 ? "" : " · waiting for the receiver"))
|
||||||
|
if (dev.kind === "airplay" && svc && !svc.airplayAvailable) return "AirPlay · needs pyatv"
|
||||||
|
var bits = [dev.kindLabel]
|
||||||
|
if (dev.app !== "") bits.push(dev.app)
|
||||||
|
else if (dev.status === "busy") bits.push("in use")
|
||||||
|
else if (dev.status === "away") bits.push("not answering")
|
||||||
|
if (dev.error !== "") bits.push(dev.error)
|
||||||
|
return bits.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- actions ----
|
||||||
|
function castTo(dev) {
|
||||||
|
if (!svc || !dev) return
|
||||||
|
if (dev.id === svc.pendingId && !casting) return // already asked, still waiting
|
||||||
|
if (dev.id === (session.deviceId || "") && casting) { svc.stop(); return }
|
||||||
|
svc.cast(dev.id)
|
||||||
|
}
|
||||||
|
function quality() {
|
||||||
|
var h = s.maxHeight || 1080, f = s.fps || 30
|
||||||
|
return h + "p" + f
|
||||||
|
}
|
||||||
|
function setQuality(value) {
|
||||||
|
var m = String(value).match(/^(\d+)p(\d+)$/)
|
||||||
|
if (!m || !svc) return
|
||||||
|
svc.send({ cmd: "set", settings: { maxHeight: parseInt(m[1]), fps: parseInt(m[2]) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: root.svc
|
||||||
|
// A cast killed by the firewall leaves exactly one thing to do. Put the
|
||||||
|
// panel (and its button) on screen rather than leave a notification that
|
||||||
|
// explains the problem but cannot act on it.
|
||||||
|
function onFirewallBlockedChanged() {
|
||||||
|
if (root.svc && root.svc.firewallBlocked && !root.opened) root.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: root.ipcTarget
|
||||||
|
function open() { root.open() }
|
||||||
|
function close() { root.close() }
|
||||||
|
function toggle() { root.toggle() }
|
||||||
|
// omarchy-shell io.github.alanfortlink.screencast cast "TV name"
|
||||||
|
function cast(device: string): string {
|
||||||
|
if (!root.svc) return "no service"
|
||||||
|
if (device === "") {
|
||||||
|
var last = root.s.lastDevice || ""
|
||||||
|
if (last === "") return "no device given"
|
||||||
|
root.svc.cast(last)
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
root.svc.cast(device)
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
function stop(): string { if (root.svc) root.svc.stop(); return "ok" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- keyboard cursor over the device list ----
|
||||||
|
property bool cursorActive: false
|
||||||
|
property int selectedIndex: 0
|
||||||
|
function moveCursor(dx, dy) {
|
||||||
|
cursorActive = true
|
||||||
|
if (dy === 0 || devices.length === 0) return
|
||||||
|
selectedIndex = Math.max(0, Math.min(devices.length - 1, selectedIndex + dy))
|
||||||
|
}
|
||||||
|
function activateCursor() {
|
||||||
|
if (!cursorActive || selectedIndex >= devices.length) return
|
||||||
|
castTo(devices[selectedIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
visible: alwaysShow || casting
|
||||||
|
implicitWidth: button.implicitWidth
|
||||||
|
implicitHeight: button.implicitHeight
|
||||||
|
|
||||||
|
BarIconButton {
|
||||||
|
id: button
|
||||||
|
anchors.fill: parent
|
||||||
|
bar: root.bar
|
||||||
|
text: root.casting ? root.castConnectedGlyph : root.castGlyph
|
||||||
|
active: root.casting
|
||||||
|
// Setting up a cast takes seconds (portal, encoder, receiver): pulse so the
|
||||||
|
// bar shows the click landed, even with the panel closed.
|
||||||
|
opacity: root.castWorking ? barPulse.value : 1
|
||||||
|
QtObject {
|
||||||
|
id: barPulse
|
||||||
|
property real value: 1
|
||||||
|
NumberAnimation on value {
|
||||||
|
running: root.castWorking
|
||||||
|
from: 1; to: 0.35; duration: 700
|
||||||
|
loops: Animation.Infinite
|
||||||
|
easing.type: Easing.InOutSine
|
||||||
|
onRunningChanged: if (!running) barPulse.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useActiveColor: true
|
||||||
|
activeColor: root.svc && root.svc.sessionState === "error" ? Color.urgent : Color.accent
|
||||||
|
tooltipText: root.casting ? ("Casting to " + root.svc.castingTo + " · right-click: stop")
|
||||||
|
: "Cast this screen"
|
||||||
|
onPressed: function(b) {
|
||||||
|
if (b === Qt.RightButton && root.casting && root.svc) root.svc.stop()
|
||||||
|
else root.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenedChanged: if (opened && svc) { svc.refresh(); svc.rescan() }
|
||||||
|
|
||||||
|
KeyboardPanel {
|
||||||
|
id: panel
|
||||||
|
anchorItem: button
|
||||||
|
owner: root
|
||||||
|
bar: root.bar
|
||||||
|
open: root.opened
|
||||||
|
focusTarget: keyCatcher
|
||||||
|
contentWidth: panel.fittedContentWidth(Style.space(400))
|
||||||
|
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(620))
|
||||||
|
|
||||||
|
PanelKeyCatcher {
|
||||||
|
id: keyCatcher
|
||||||
|
anchors.fill: parent
|
||||||
|
onMoveRequested: function(dx, dy) {
|
||||||
|
if (!root.cursorActive) { root.cursorActive = true; return }
|
||||||
|
root.moveCursor(dx, dy)
|
||||||
|
}
|
||||||
|
onActivateRequested: root.activateCursor()
|
||||||
|
onCloseRequested: root.close()
|
||||||
|
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||||||
|
onTextKey: function(t) {
|
||||||
|
if (t === "r" || t === "R") { if (root.svc) root.svc.rescan() }
|
||||||
|
else if (t === "s" || t === "S") { if (root.svc) root.svc.stop() }
|
||||||
|
else if (t === "p" || t === "P") { if (root.svc && root.casting) root.svc.transport(root.svc.paused ? "play" : "pause") }
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: panelFlick
|
||||||
|
anchors.fill: parent
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: column.implicitHeight
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
flickableDirection: Flickable.VerticalFlick
|
||||||
|
interactive: contentHeight > height
|
||||||
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: column
|
||||||
|
width: panelFlick.width
|
||||||
|
spacing: Style.space(12)
|
||||||
|
|
||||||
|
PanelHero {
|
||||||
|
id: hero
|
||||||
|
width: parent.width
|
||||||
|
title: root.casting ? root.svc.castingTo : "Screencast"
|
||||||
|
meta: root.statusLine()
|
||||||
|
foreground: root.fg
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
iconComponent: Component {
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
text: root.casting ? root.castConnectedGlyph : root.castGlyph
|
||||||
|
color: root.casting ? Color.accent : root.fg
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- what the daemon is doing right now ----------
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(8)
|
||||||
|
visible: root.working && root.phaseText() !== ""
|
||||||
|
|
||||||
|
Spinner {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spinning: root.working
|
||||||
|
color: Color.accent
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
width: parent.width - Style.space(30)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.phaseText() + root.elapsedSuffix()
|
||||||
|
color: root.fg
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- the port receivers fetch from ----------
|
||||||
|
// Opening it needs root, which installing deliberately does not, so it
|
||||||
|
// is asked for here: a warning once a cast has proved it necessary, a
|
||||||
|
// quiet note before that.
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(6)
|
||||||
|
visible: !!root.svc && root.svc.firewallTool !== ""
|
||||||
|
&& (root.svc.firewallBlocked || !root.svc.firewallVerified)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: ""
|
||||||
|
color: root.svc && root.svc.firewallBlocked ? root.urgent : root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.icon
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
width: parent.width - Style.space(34)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: !root.svc ? ""
|
||||||
|
: root.svc.firewallBlocked
|
||||||
|
? ("Nothing reached us on port " + root.svc.firewallPort + ". "
|
||||||
|
+ root.svc.firewallTool + " blocks incoming connections, and receivers fetch the stream from this machine.")
|
||||||
|
: ("Receivers fetch the stream from this machine, and "
|
||||||
|
+ root.svc.firewallTool + " blocks incoming connections. Port "
|
||||||
|
+ root.svc.firewallPort + " has to be open for a cast to play.")
|
||||||
|
color: root.svc && root.svc.firewallBlocked ? root.urgent : root.dim
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: root.svc ? "Open port " + root.svc.firewallPort : "Open the port"
|
||||||
|
iconText: ""
|
||||||
|
foreground: root.fg
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
bordered: true
|
||||||
|
enabled: !!root.svc && root.svc.activity.indexOf("Opening port") !== 0
|
||||||
|
onClicked: if (root.svc) root.svc.openFirewall()
|
||||||
|
}
|
||||||
|
Spinner {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: !!root.svc && root.svc.activity.indexOf("Opening port") === 0
|
||||||
|
spinning: visible
|
||||||
|
color: Color.accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
width: parent.width
|
||||||
|
text: "Asks for your password once, allows only this network, and stays."
|
||||||
|
color: root.dim
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- not installed / install failed ----------
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(6)
|
||||||
|
visible: !!root.svc && (!root.installed || root.svc.setupOutput !== "" || root.svc.busyText !== "")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
visible: !!root.svc && !root.installed && !root.svc.setupBusy
|
||||||
|
text: "Install the caster"
|
||||||
|
foreground: root.fg
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
bordered: true
|
||||||
|
onClicked: if (root.svc) root.svc.install()
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
width: parent.width
|
||||||
|
visible: !!root.svc && (root.svc.busyText !== "" || root.svc.setupOutput !== "")
|
||||||
|
text: root.svc ? (root.svc.busyText !== "" ? root.svc.busyText : root.svc.setupOutput) : ""
|
||||||
|
color: root.svc && root.svc.setupOutput !== "" ? root.urgent : root.dim
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- what is being cast ----------
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(10)
|
||||||
|
visible: root.casting || (!!root.svc && root.svc.sessionState === "error")
|
||||||
|
|
||||||
|
PanelSectionHeader {
|
||||||
|
text: root.casting ? "CASTING" : "LAST CAST"
|
||||||
|
foreground: root.fg
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- what the receiver is being sent ----------
|
||||||
|
// Tapped off the encoder itself rather than re-grabbed, so it shows
|
||||||
|
// the screen that is really going out, at the size it goes out.
|
||||||
|
Rectangle {
|
||||||
|
id: preview
|
||||||
|
width: parent.width
|
||||||
|
height: Math.round(width * (root.svc && root.svc.sessionHeight > 0
|
||||||
|
? root.svc.sessionHeight / root.svc.sessionWidth
|
||||||
|
: 9 / 16))
|
||||||
|
visible: root.casting
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
clip: true
|
||||||
|
color: Qt.rgba(root.fg.r, root.fg.g, root.fg.b, 0.05)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Qt.rgba(root.fg.r, root.fg.g, root.fg.b, 0.14)
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: previewImage
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 1
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
cache: false
|
||||||
|
asynchronous: true
|
||||||
|
smooth: true
|
||||||
|
source: root.svc && root.svc.sessionPreview !== ""
|
||||||
|
? "file://" + root.svc.sessionPreview + "?t=" + root.previewTick
|
||||||
|
: ""
|
||||||
|
// Never blank between reloads: hold the last good frame.
|
||||||
|
opacity: status === Image.Ready ? 1 : 0
|
||||||
|
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before the first frame lands: a quiet breathing placeholder
|
||||||
|
// rather than an empty hole.
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Style.space(6)
|
||||||
|
visible: previewImage.status !== Image.Ready
|
||||||
|
opacity: waitPulse.value
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: root.monitorGlyph
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.display
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: root.starting ? "getting the picture…" : "no picture yet"
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
QtObject {
|
||||||
|
id: waitPulse
|
||||||
|
property real value: 1
|
||||||
|
NumberAnimation on value {
|
||||||
|
running: previewImage.status !== Image.Ready && root.opened
|
||||||
|
from: 1; to: 0.4; duration: 1100
|
||||||
|
loops: Animation.Infinite
|
||||||
|
easing.type: Easing.InOutSine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A live badge in the corner: red dot while the receiver is
|
||||||
|
// actually pulling, hollow while it is only being offered.
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: Style.space(8)
|
||||||
|
spacing: Style.space(5)
|
||||||
|
visible: previewImage.status === Image.Ready
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: Style.space(7); height: width; radius: width / 2
|
||||||
|
color: root.svc && root.svc.live ? root.urgent : "transparent"
|
||||||
|
border.width: root.svc && root.svc.live ? 0 : 1
|
||||||
|
border.color: root.fg
|
||||||
|
opacity: root.svc && root.svc.live ? livePulse2.value : 0.7
|
||||||
|
QtObject {
|
||||||
|
id: livePulse2
|
||||||
|
property real value: 1
|
||||||
|
NumberAnimation on value {
|
||||||
|
running: !!root.svc && root.svc.live && root.opened
|
||||||
|
from: 1; to: 0.35; duration: 1200
|
||||||
|
loops: Animation.Infinite
|
||||||
|
easing.type: Easing.InOutSine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.svc && root.svc.live ? "LIVE" : "OFFERED"
|
||||||
|
color: root.fg
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which screen, bottom-left, over a scrim so it stays readable
|
||||||
|
// whatever is on the picture.
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.margins: Style.space(6)
|
||||||
|
visible: previewImage.status === Image.Ready && sourceLabel.text !== ""
|
||||||
|
width: sourceLabel.width + Style.space(12)
|
||||||
|
height: sourceLabel.height + Style.space(6)
|
||||||
|
radius: Style.space(4)
|
||||||
|
color: Qt.rgba(0, 0, 0, 0.45)
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
id: sourceLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.svc ? root.svc.sessionSource : ""
|
||||||
|
color: "#ffffff"
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
width: parent.width
|
||||||
|
visible: !!root.svc && root.svc.sessionState === "error"
|
||||||
|
text: root.svc ? root.svc.sessionError : ""
|
||||||
|
color: root.urgent
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(8)
|
||||||
|
visible: root.casting
|
||||||
|
|
||||||
|
Button {
|
||||||
|
readonly property bool stopping: !!root.svc && root.svc.stopping
|
||||||
|
text: stopping ? "Stopping…" : "Stop"
|
||||||
|
iconText: stopping ? "" : ""
|
||||||
|
iconSpinning: stopping
|
||||||
|
enabled: !stopping
|
||||||
|
foreground: root.fg
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
bordered: true
|
||||||
|
onClicked: if (root.svc) root.svc.stop()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: root.svc && root.svc.paused ? "Resume" : "Pause"
|
||||||
|
iconText: root.svc && root.svc.paused ? "" : "" // play / pause
|
||||||
|
foreground: root.fg
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
bordered: true
|
||||||
|
onClicked: if (root.svc) root.svc.transport(root.svc.paused ? "play" : "pause")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
readonly property bool repicking: !!root.svc
|
||||||
|
&& root.svc.activity.indexOf("Asking which screen") === 0
|
||||||
|
text: repicking ? "Choose a screen…" : "Change screen"
|
||||||
|
iconText: root.monitorGlyph
|
||||||
|
iconSpinning: repicking
|
||||||
|
enabled: !repicking
|
||||||
|
foreground: root.fg
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
bordered: true
|
||||||
|
onClicked: if (root.svc) root.svc.repickSource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receiver volume, when the protocol has one.
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(8)
|
||||||
|
visible: root.casting && !!root.castDevice && root.castDevice.volume >= 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.castDevice && root.castDevice.muted ? "" : ""
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.icon
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: if (root.svc && root.castDevice) root.svc.setMuted(!root.castDevice.muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PanelSlider {
|
||||||
|
width: parent.width - Style.space(40)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
bar: root.bar
|
||||||
|
minimum: 0
|
||||||
|
maximum: 1
|
||||||
|
step: 0.02
|
||||||
|
value: root.castDevice ? root.castDevice.volume : 0
|
||||||
|
onMoved: function(v) { if (root.svc) root.svc.setVolume(v) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
width: parent.width
|
||||||
|
visible: root.casting
|
||||||
|
text: {
|
||||||
|
var src = session.source || ""
|
||||||
|
var bits = []
|
||||||
|
if (src !== "") bits.push("Sharing " + src)
|
||||||
|
bits.push((s.audio ? "with" : "without") + " desktop audio")
|
||||||
|
if (session.clients > 0) bits.push(session.clients + " receiver" + (session.clients > 1 ? "s" : "") + " connected")
|
||||||
|
return bits.join(" · ")
|
||||||
|
}
|
||||||
|
color: root.dim
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSeparator { visible: root.casting; foreground: root.fg }
|
||||||
|
|
||||||
|
// ---------- the network ----------
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: sectionLabel.implicitHeight
|
||||||
|
|
||||||
|
PanelSectionHeader {
|
||||||
|
id: sectionLabel
|
||||||
|
text: "DISPLAYS ON THE NETWORK"
|
||||||
|
foreground: root.fg
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
id: rescanIcon
|
||||||
|
readonly property bool scanning: !!root.svc && root.svc.activity.indexOf("Looking for") === 0
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: sectionLabel.verticalCenter
|
||||||
|
text: "" // md-refresh
|
||||||
|
color: rescanIcon.scanning ? Color.accent : root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
RotationAnimation on rotation {
|
||||||
|
running: rescanIcon.scanning
|
||||||
|
loops: Animation.Infinite
|
||||||
|
from: 0; to: 360; duration: 900
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -Style.space(6)
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: if (root.svc) root.svc.rescan()
|
||||||
|
ToolTip.visible: containsMouse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
width: parent.width
|
||||||
|
visible: root.devices.length === 0
|
||||||
|
text: root.connected ? "Nothing found yet. Cast devices announce themselves over mDNS; make sure this machine is on the same network (and not only on a VPN)."
|
||||||
|
: "Waiting for the caster to start."
|
||||||
|
color: root.dim
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: deviceColumn
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(4)
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.devices
|
||||||
|
DeviceRow {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
width: deviceColumn.width
|
||||||
|
dev: modelData
|
||||||
|
rowIndex: index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelSeparator { foreground: root.fg }
|
||||||
|
|
||||||
|
// ---------- what gets sent ----------
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
|
PanelSectionHeader {
|
||||||
|
text: "QUALITY"
|
||||||
|
foreground: root.fg
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
Dropdown {
|
||||||
|
width: parent.width
|
||||||
|
label: "Resolution"
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
value: root.quality()
|
||||||
|
options: [
|
||||||
|
{ value: "720p30", label: "720p · 30 fps" },
|
||||||
|
{ value: "1080p30", label: "1080p · 30 fps" },
|
||||||
|
{ value: "1080p60", label: "1080p · 60 fps" },
|
||||||
|
{ value: "1440p30", label: "1440p · 30 fps" },
|
||||||
|
{ value: "2160p30", label: "4K · 30 fps" }
|
||||||
|
]
|
||||||
|
onChanged: function(v) { root.setQuality(v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(8)
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: Style.space(70)
|
||||||
|
text: "Bitrate"
|
||||||
|
color: root.fg
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
}
|
||||||
|
PanelSlider {
|
||||||
|
id: bitrateSlider
|
||||||
|
width: parent.width - Style.space(130)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
bar: root.bar
|
||||||
|
minimum: 1
|
||||||
|
maximum: 25
|
||||||
|
step: 1
|
||||||
|
integer: true
|
||||||
|
value: (root.s.bitrate || 8000) / 1000
|
||||||
|
onReleased: function(v) { if (root.svc) root.svc.setSetting("bitrate", Math.round(v) * 1000) }
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: Math.round(bitrateSlider.liveValue) + " Mb/s"
|
||||||
|
color: root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle {
|
||||||
|
width: parent.width
|
||||||
|
label: "Desktop audio"
|
||||||
|
description: "Send what the speakers are playing along with the picture"
|
||||||
|
checked: !!root.s.audio
|
||||||
|
foreground: root.fg
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
onClicked: if (root.svc) root.svc.setSetting("audio", !root.s.audio)
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle {
|
||||||
|
width: parent.width
|
||||||
|
label: "Show the pointer"
|
||||||
|
checked: !!root.s.cursor
|
||||||
|
foreground: root.fg
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
onClicked: if (root.svc) root.svc.setSetting("cursor", !root.s.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
width: parent.width
|
||||||
|
visible: !!root.svc && root.svc.error !== "" && root.svc.sessionState !== "error"
|
||||||
|
text: root.svc ? root.svc.error : ""
|
||||||
|
color: root.urgent
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One receiver: click to cast, or to stop when it is the one being cast to.
|
||||||
|
component DeviceRow: CursorSurface {
|
||||||
|
id: row
|
||||||
|
property var dev: null
|
||||||
|
property int rowIndex: 0
|
||||||
|
readonly property bool isCurrent: !!dev && ((dev.id === (root.session.deviceId || "") && root.casting)
|
||||||
|
|| dev.id === root.pendingId)
|
||||||
|
// Waiting on us, not yet on the receiver: the row spins from the click on.
|
||||||
|
readonly property bool isPending: !!dev && (dev.id === root.pendingId || (isCurrent && root.starting))
|
||||||
|
readonly property bool disabled: !!dev && dev.kind === "airplay" && !!root.svc && !root.svc.airplayAvailable
|
||||||
|
|
||||||
|
hasCursor: root.cursorActive && root.selectedIndex === rowIndex
|
||||||
|
current: isCurrent
|
||||||
|
foreground: root.fg
|
||||||
|
implicitHeight: rowLayout.implicitHeight + Style.spacing.rowPaddingX
|
||||||
|
opacity: disabled ? 0.55 : 1.0
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: row.disabled ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||||
|
onEntered: { root.cursorActive = true; root.selectedIndex = row.rowIndex }
|
||||||
|
onClicked: {
|
||||||
|
if (row.disabled) return
|
||||||
|
root.castTo(row.dev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: rowLayout
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.leftMargin: Style.space(10)
|
||||||
|
anchors.rightMargin: Style.space(10)
|
||||||
|
spacing: Style.space(10)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
text: root.deviceGlyph(row.dev)
|
||||||
|
color: row.isCurrent ? Color.accent : root.fg
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.heading
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 0
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: row.dev ? row.dev.name : ""
|
||||||
|
color: root.fg
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: row.dev ? root.deviceSubtitle(row.dev) : ""
|
||||||
|
color: root.dim
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AirPlay receivers usually want to be paired once before they accept
|
||||||
|
// anything; the button is only in the way until then.
|
||||||
|
Button {
|
||||||
|
visible: !!row.dev && row.dev.kind === "airplay" && !row.disabled && !row.isCurrent
|
||||||
|
&& (row.hasCursor || (!!root.svc && root.svc.pairing === row.dev.id))
|
||||||
|
text: root.pairingThis(row.dev) ? "Asking…" : "Pair"
|
||||||
|
iconText: root.pairingThis(row.dev) ? "" : ""
|
||||||
|
iconSpinning: root.pairingThis(row.dev)
|
||||||
|
enabled: !root.pairingThis(row.dev)
|
||||||
|
foreground: root.dim
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
fontSize: Style.font.caption
|
||||||
|
onClicked: if (root.svc) root.svc.pair(row.dev.id)
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starting: a spinner. Live: a breathing dot. Casting but nothing being
|
||||||
|
// fetched yet: a dim dot. Otherwise nothing — the whole row is the button.
|
||||||
|
Spinner {
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
visible: row.isPending
|
||||||
|
spinning: visible
|
||||||
|
color: Color.accent
|
||||||
|
font.pixelSize: Style.font.icon
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
id: liveDot
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
visible: row.isCurrent && !row.isPending
|
||||||
|
text: root.svc && root.svc.live ? "" : "" // cast_connected / timer-outline
|
||||||
|
color: root.svc && root.svc.live ? Color.accent : root.dim
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.icon
|
||||||
|
opacity: root.svc && root.svc.live ? 1 : livePulse.value
|
||||||
|
QtObject {
|
||||||
|
id: livePulse
|
||||||
|
property real value: 1
|
||||||
|
NumberAnimation on value {
|
||||||
|
running: liveDot.visible && !(root.svc && root.svc.live)
|
||||||
|
from: 1; to: 0.4; duration: 900
|
||||||
|
loops: Animation.Infinite
|
||||||
|
easing.type: Easing.InOutSine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
visible: row.isCurrent && !row.isPending
|
||||||
|
text: "" // md-stop
|
||||||
|
color: root.fg
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AirPlay receivers that want a PIN: the daemon asks, the code is typed here.
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.rightMargin: Style.space(10)
|
||||||
|
spacing: Style.space(6)
|
||||||
|
visible: !!root.svc && root.svc.pairing !== "" && !!row.dev && root.svc.pairing === row.dev.id
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: pinField
|
||||||
|
width: Style.space(90)
|
||||||
|
placeholderText: "PIN"
|
||||||
|
foreground: root.fg
|
||||||
|
onAccepted: if (root.svc) { root.svc.pairPin(text); text = "" }
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
readonly property bool checking: !!root.svc && root.svc.activity.indexOf("Pairing with") === 0
|
||||||
|
text: checking ? "Checking…" : "Pair"
|
||||||
|
iconText: checking ? "" : ""
|
||||||
|
iconSpinning: checking
|
||||||
|
enabled: !checking
|
||||||
|
foreground: root.fg
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
bordered: true
|
||||||
|
onClicked: if (root.svc) { root.svc.pairPin(pinField.text); pinField.text = "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
// Headless service: owns the screencast-server daemon (starts it, restarts it
|
||||||
|
// if it dies) and keeps a control connection to it. Everything the panel shows
|
||||||
|
// comes from the daemon's state pushes; everything the panel does goes through
|
||||||
|
// send().
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var shell: null
|
||||||
|
property var manifest: null
|
||||||
|
|
||||||
|
// ---- daemon state (mirrors the JSON pushed by screencast-server) ----
|
||||||
|
property var state: ({})
|
||||||
|
readonly property bool connected: sockConnected
|
||||||
|
readonly property var devices: state.devices || []
|
||||||
|
readonly property var session: state.session || ({})
|
||||||
|
readonly property var settings: state.settings || ({})
|
||||||
|
readonly property var capabilities: state.capabilities || ({})
|
||||||
|
readonly property string error: state.error || ""
|
||||||
|
readonly property string busy: state.busy || ""
|
||||||
|
readonly property string pairing: state.pairing || ""
|
||||||
|
// Receivers pull the stream from us, so a default-deny firewall silently
|
||||||
|
// stops every cast. The daemon sets `blocked` once a cast fails that way.
|
||||||
|
readonly property var firewall: state.firewall || ({})
|
||||||
|
readonly property bool firewallBlocked: !!firewall.blocked
|
||||||
|
readonly property string firewallCommand: firewall.command || ""
|
||||||
|
readonly property string firewallTool: firewall.tool || ""
|
||||||
|
readonly property bool firewallVerified: firewall.verified !== false
|
||||||
|
readonly property int firewallPort: firewall.port || 8011
|
||||||
|
readonly property int streamPort: capabilities.port || 8011
|
||||||
|
readonly property bool airplayAvailable: capabilities.airplay !== false
|
||||||
|
|
||||||
|
// "idle" | "starting" | "streaming" | "stopping" | "error"
|
||||||
|
readonly property string sessionState: session.state || "idle"
|
||||||
|
readonly property bool casting: sessionState === "starting" || sessionState === "streaming"
|
||||||
|
readonly property bool live: sessionState === "streaming" && (session.clients || 0) > 0
|
||||||
|
readonly property string castingTo: session.deviceName || ""
|
||||||
|
readonly property string castingId: session.deviceId || ""
|
||||||
|
readonly property string sessionError: session.error || ""
|
||||||
|
// What a "starting" session is waiting on, so the panel can say so.
|
||||||
|
readonly property string sessionPhase: session.phase || ""
|
||||||
|
// A JPEG of the screen actually being encoded, refreshed a few times a second
|
||||||
|
// by the daemon. Empty until the first frame has been written.
|
||||||
|
readonly property string sessionPreview: session.preview || ""
|
||||||
|
readonly property real sessionPreviewAt: session.previewAt || 0
|
||||||
|
readonly property string sessionSource: session.source || ""
|
||||||
|
readonly property int sessionWidth: session.width || 0
|
||||||
|
readonly property int sessionHeight: session.height || 0
|
||||||
|
readonly property real sessionSince: session.since || 0
|
||||||
|
readonly property string playerState: session.playerState || ""
|
||||||
|
readonly property bool paused: playerState === "paused"
|
||||||
|
readonly property bool stopping: sessionState === "stopping"
|
||||||
|
|
||||||
|
function deviceById(id) {
|
||||||
|
var list = devices
|
||||||
|
for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
readonly property var castDevice: deviceById(castingId)
|
||||||
|
|
||||||
|
// ---- what we are optimistically claiming, before the daemon answers ----
|
||||||
|
// Every round trip, however short, is a gap where a click looks ignored. The
|
||||||
|
// panel says what it just asked for straight away and the daemon's own `busy`
|
||||||
|
// takes over as soon as the first state arrives.
|
||||||
|
property string pendingLabel: ""
|
||||||
|
property string pendingId: "" // the device the pending action is about
|
||||||
|
property real pendingAt: 0
|
||||||
|
readonly property string activity: pendingLabel !== "" ? pendingLabel : busy
|
||||||
|
readonly property bool acting: activity !== ""
|
||||||
|
|
||||||
|
function expect(label, id) {
|
||||||
|
pendingLabel = label
|
||||||
|
pendingId = id || ""
|
||||||
|
pendingAt = Date.now()
|
||||||
|
pendingTimer.restart()
|
||||||
|
}
|
||||||
|
function clearPending() {
|
||||||
|
pendingLabel = ""
|
||||||
|
pendingId = ""
|
||||||
|
pendingTimer.stop()
|
||||||
|
}
|
||||||
|
// The daemon confirms by putting something in `busy` (it pushes that before
|
||||||
|
// it starts the work). This only has to fire when it never answers at all.
|
||||||
|
Timer { id: pendingTimer; interval: 8000; repeat: false; onTriggered: root.clearPending() }
|
||||||
|
onBusyChanged: if (busy !== "") clearPending()
|
||||||
|
|
||||||
|
// ---- commands ----
|
||||||
|
function send(obj) {
|
||||||
|
if (!sockConnected) return false
|
||||||
|
sock.write(JSON.stringify(obj) + "\n")
|
||||||
|
sock.flush()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
function refresh() { return send({ cmd: "get" }) }
|
||||||
|
function rescan() {
|
||||||
|
expect("Looking for displays…", "")
|
||||||
|
return send({ cmd: "rescan" })
|
||||||
|
}
|
||||||
|
function cast(deviceId) {
|
||||||
|
var dev = deviceById(deviceId)
|
||||||
|
expect("Connecting to " + (dev ? dev.name : "the display") + "…", deviceId)
|
||||||
|
return send({ cmd: "cast", device: deviceId })
|
||||||
|
}
|
||||||
|
function stop() {
|
||||||
|
expect("Stopping…", castingId)
|
||||||
|
return send({ cmd: "stop" })
|
||||||
|
}
|
||||||
|
function setSetting(key, value) { var p = {}; p[key] = value; return send({ cmd: "set", settings: p }) }
|
||||||
|
function setVolume(v) { return send({ cmd: "volume", value: v }) }
|
||||||
|
function setMuted(v) { return send({ cmd: "mute", value: !!v }) }
|
||||||
|
function transport(action) { return send({ cmd: "transport", action: action }) }
|
||||||
|
// Restart the cast so the portal asks which screen to share again.
|
||||||
|
function repickSource() {
|
||||||
|
expect("Asking which screen to share…", castingId)
|
||||||
|
return send({ cmd: "repick" })
|
||||||
|
}
|
||||||
|
function pair(deviceId) {
|
||||||
|
var dev = deviceById(deviceId)
|
||||||
|
expect("Asking " + (dev ? dev.name : "the display") + " for a code…", deviceId)
|
||||||
|
return send({ cmd: "pair", device: deviceId })
|
||||||
|
}
|
||||||
|
function pairPin(pin) {
|
||||||
|
expect("Checking the code…", pairing)
|
||||||
|
return send({ cmd: "pairPin", pin: String(pin) })
|
||||||
|
}
|
||||||
|
function pairCancel() { clearPending(); return send({ cmd: "pairCancel" }) }
|
||||||
|
function openFirewall() {
|
||||||
|
expect("Opening port " + firewallPort + "…", "")
|
||||||
|
return send({ cmd: "openFirewall" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification bodies are parsed as markup by most daemons, and both of these
|
||||||
|
// carry device names and receiver error text off the network.
|
||||||
|
function plain(text) {
|
||||||
|
return String(text).replace(/[<>&]/g, " ").slice(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(title, body) {
|
||||||
|
if (notifyProc.running) return
|
||||||
|
title = plain(title); body = plain(body)
|
||||||
|
notifyProc.command = ["sh", "-c",
|
||||||
|
'if command -v omarchy-notification-send >/dev/null 2>&1; then exec omarchy-notification-send "$1" "$2"; fi; exec notify-send "$1" "$2"',
|
||||||
|
"screencast-notify", title, body]
|
||||||
|
notifyProc.running = true
|
||||||
|
}
|
||||||
|
Process { id: notifyProc }
|
||||||
|
|
||||||
|
// ---- paths ----
|
||||||
|
readonly property string runtimeDir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/screencast"
|
||||||
|
readonly property string socketPath: runtimeDir + "/ctl.sock"
|
||||||
|
readonly property string homeDir: Quickshell.env("HOME") || ""
|
||||||
|
readonly property string libDir: homeDir + "/.local/lib/screencast"
|
||||||
|
readonly property string daemonBinary: libDir + "/screencast-server"
|
||||||
|
readonly property string cacheDir: (Quickshell.env("XDG_CACHE_HOME") || (homeDir + "/.cache")) + "/screencast"
|
||||||
|
readonly property string installLog: cacheDir + "/install.log"
|
||||||
|
// The plugin checkout (this file lives in <repo>/plugin/).
|
||||||
|
readonly property string repoDir: decodeURIComponent(String(Qt.resolvedUrl("..")).replace(/^file:\/\//, "").replace(/\/$/, ""))
|
||||||
|
|
||||||
|
property bool installed: false
|
||||||
|
property string setupOutput: "" // only what the user must act on
|
||||||
|
property string busyText: "" // transient progress, disappears on its own
|
||||||
|
property string daemonLog: ""
|
||||||
|
property string daemonError: ""
|
||||||
|
property int restarts: 0
|
||||||
|
readonly property bool setupBusy: installProc.running
|
||||||
|
|
||||||
|
// ---- install / update ----
|
||||||
|
// First run after `omarchy plugin add` (and "Reinstall" later): build the
|
||||||
|
// virtualenv under ~/.local/lib and install the daemon into it. No password:
|
||||||
|
// nothing here touches the system.
|
||||||
|
function install() {
|
||||||
|
if (installProc.running) return
|
||||||
|
setupOutput = ""
|
||||||
|
if (busyText === "") busyText = "Installing the caster…"
|
||||||
|
daemonError = ""
|
||||||
|
installProc.command = ["sh", "-c",
|
||||||
|
'mkdir -p "$(dirname "$2")"; cd "$1" && ./install.sh --no-root >"$2" 2>&1; rc=$?; tail -n 4 "$2"; exit $rc',
|
||||||
|
"screencast-install", repoDir, installLog]
|
||||||
|
installProc.running = true
|
||||||
|
}
|
||||||
|
Process {
|
||||||
|
id: installProc
|
||||||
|
property string tailText: ""
|
||||||
|
stdout: StdioCollector { onStreamFinished: installProc.tailText = String(text).trim() }
|
||||||
|
onExited: function(code) {
|
||||||
|
root.busyText = ""
|
||||||
|
if (code !== 0) {
|
||||||
|
root.setupOutput = "Install failed (" + code + "). Log: " + root.installLog + "\n" + tailText
|
||||||
|
} else {
|
||||||
|
// Everything the installer can do without a password is done; system
|
||||||
|
// packages are not one of them, so pass that one line straight through.
|
||||||
|
const missing = /^MISSING: (.*)$/m.exec(tailText)
|
||||||
|
root.setupOutput = missing
|
||||||
|
? "Some system packages are missing, so capture will fail. Install them:\n" + missing[1]
|
||||||
|
: ""
|
||||||
|
restartTimer.restart()
|
||||||
|
}
|
||||||
|
tailText = ""
|
||||||
|
probeProc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// After `omarchy plugin update` the shell reloads this plugin: rebuild
|
||||||
|
// silently when the checkout has moved past what install.sh last installed.
|
||||||
|
Process {
|
||||||
|
id: updateCheckProc
|
||||||
|
command: ["sh", "-c",
|
||||||
|
'a=$(git -C "$1" rev-parse HEAD 2>/dev/null) || exit 0; b=$(cat "$2/installed-commit" 2>/dev/null); ' +
|
||||||
|
'[ -x "$2/screencast-server" ] || exit 0; [ -n "$a" ] && [ "$a" != "$b" ] && exit 3; exit 0',
|
||||||
|
"screencast-updatecheck", root.repoDir, root.libDir]
|
||||||
|
onExited: function(code) { if (code === 3) { root.busyText = "Updating…"; root.install() } }
|
||||||
|
}
|
||||||
|
Process {
|
||||||
|
id: probeProc
|
||||||
|
command: ["sh", "-c", 'test -x "$1"', "probe", root.daemonBinary]
|
||||||
|
onExited: function(code) {
|
||||||
|
root.installed = code === 0
|
||||||
|
if (root.installed && !daemon.running) restartTimer.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// While not installed, keep looking: an install interrupted by a shell reload
|
||||||
|
// finishes on its own and we pick the daemon up.
|
||||||
|
Timer {
|
||||||
|
interval: 5000
|
||||||
|
repeat: true
|
||||||
|
running: !root.installed && !installProc.running && !probeProc.running
|
||||||
|
onTriggered: probeProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- daemon lifecycle ----
|
||||||
|
Process {
|
||||||
|
id: daemon
|
||||||
|
command: [root.daemonBinary, "run"]
|
||||||
|
running: false
|
||||||
|
stderr: SplitParser {
|
||||||
|
onRead: function(line) {
|
||||||
|
var l = root.daemonLog + line + "\n"
|
||||||
|
if (l.length > 4000) l = l.slice(l.length - 4000)
|
||||||
|
root.daemonLog = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onExited: function(code, status) {
|
||||||
|
root.state = ({})
|
||||||
|
if (code === 127) { root.installed = false; probeProc.running = true; return }
|
||||||
|
root.restarts += 1
|
||||||
|
if (code !== 3 && root.restarts >= 3)
|
||||||
|
root.daemonError = "the caster keeps exiting (code " + code + ") — reinstall it or check the log"
|
||||||
|
restartTimer.interval = code === 3 ? 5000 : Math.min(10000, 1000 + root.restarts * 1000) // 3 = another instance holds the lock
|
||||||
|
restartTimer.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set while we wait for an orphaned daemon (left by a previous shell) to quit.
|
||||||
|
property bool orphanQuit: false
|
||||||
|
Timer {
|
||||||
|
id: restartTimer
|
||||||
|
interval: 1000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
if (!root.installed) { probeProc.running = true; return }
|
||||||
|
if (daemon.running) { daemon.signal(15); return }
|
||||||
|
if (root.sockConnected) { root.send({ cmd: "quit" }); root.orphanQuit = true; interval = 5000; restart(); return }
|
||||||
|
daemon.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Timer { interval: 60000; running: daemon.running; repeat: false; onTriggered: root.restarts = 0 }
|
||||||
|
|
||||||
|
// ---- control connection ----
|
||||||
|
// Quickshell's Socket cannot recover from a refused connection, so recreate
|
||||||
|
// the object for every attempt.
|
||||||
|
property var sock: null
|
||||||
|
readonly property bool sockConnected: sock ? sock.connected === true : false
|
||||||
|
property string lastSessionState: "idle"
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: sockComp
|
||||||
|
Socket {
|
||||||
|
path: root.socketPath
|
||||||
|
connected: true
|
||||||
|
parser: SplitParser {
|
||||||
|
onRead: function(line) {
|
||||||
|
try {
|
||||||
|
var msg = JSON.parse(line)
|
||||||
|
if (msg && msg.type === "state") {
|
||||||
|
root.state = msg
|
||||||
|
if (root.daemonError !== "") root.daemonError = ""
|
||||||
|
// Any state the daemon produced after the click is its answer to
|
||||||
|
// it; the optimistic label has done its job.
|
||||||
|
if (root.pendingLabel !== "" && Date.now() - root.pendingAt > 250)
|
||||||
|
root.clearPending()
|
||||||
|
root.noteSession()
|
||||||
|
}
|
||||||
|
} catch (e) { /* a reply we do not care about */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onConnectionStateChanged: {
|
||||||
|
root.sockConnectedChanged()
|
||||||
|
if (!connected) {
|
||||||
|
root.state = ({})
|
||||||
|
if (root.orphanQuit) { root.orphanQuit = false; restartTimer.interval = 1000; restartTimer.restart() }
|
||||||
|
reconnectTimer.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onError: function(err) { reconnectTimer.restart() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function connectSocket() {
|
||||||
|
if (sock) { sock.destroy(); sock = null }
|
||||||
|
sock = sockComp.createObject(root)
|
||||||
|
sockConnectedChanged()
|
||||||
|
}
|
||||||
|
Timer { id: reconnectTimer; interval: 800; repeat: false; onTriggered: if (!root.sockConnected) root.connectSocket() }
|
||||||
|
Timer { interval: 3000; running: !root.sockConnected; repeat: true; onTriggered: if (!root.sockConnected) root.connectSocket() }
|
||||||
|
|
||||||
|
// A cast that fails while the panel is closed would otherwise be invisible.
|
||||||
|
function noteSession() {
|
||||||
|
var now = sessionState
|
||||||
|
if (now === lastSessionState) return
|
||||||
|
var was = lastSessionState
|
||||||
|
lastSessionState = now
|
||||||
|
if (now === "error" && sessionError !== "")
|
||||||
|
notify("Screencast", firewallBlocked
|
||||||
|
? "Port " + firewallPort + " is closed — press “Open port " + firewallPort + "” in the panel"
|
||||||
|
: sessionError)
|
||||||
|
else if (now === "streaming" && was !== "streaming") notify("Screencast", "Casting to " + castingTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
probeProc.running = true
|
||||||
|
connectSocket()
|
||||||
|
updateCheckProc.running = true
|
||||||
|
}
|
||||||
|
Component.onDestruction: {
|
||||||
|
if (daemon.running) daemon.signal(15)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user