commit 555fc1c3b45337810c8a90250bcc6e9adfcaa5f5 Author: ducoterra Date: Sun Nov 1 16:54:03 2026 -0500 feat: browser-only screen mirroring prototype A FastAPI server with two pages and one WebSocket. /mirror captures a window, screen or tab through the OS-native getDisplayMedia picker; /watch shows every live mirror as a wall and takes one fullscreen on click. Any number of sharers and watchers, at the same time. Media never touches the server: each watcher gets its own RTCPeerConnection to the sharer, and the Python process only relays JSON. The roster lives in registry.py, which is transport-free by design -- every operation returns the envelopes to deliver, so admission, fan-out and teardown policy are all unit-testable without a socket. Registry rules worth stating: a signal frame is relayed only along an existing subscription in either direction; viewer-to-peer traffic is refused; and teardown is broadcast to every viewer rather than only the subscribers, because a tile is drawn on stream_added and a viewer that has not yet subscribed still has a stale tile to clear. Frontend is hand-written ES modules with no build step and no external asset. Chromium scales a monitor capture to the capturing window and requires a secure context, so /mirror detects an insecure origin and prints the three ways out rather than failing on a button click. LLM-Generated: true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d61e5d8 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Local configuration for `mirror serve`. Copy to .env — never commit real values. + +# Bind address. 0.0.0.0 (the default) makes the server reachable from other devices. +MIRROR_HOST=0.0.0.0 +MIRROR_PORT=8055 + +# Per-viewer outbound cap, in bits/second. With N watchers the sharer uploads N x this. +MIRROR_MAX_BITRATE=12000000 + +# Cap on concurrent mirrors. 0 = unlimited. +MIRROR_MAX_MIRRORS=0 + +# WebSocket ping interval in seconds; also the disconnect detection window. +MIRROR_PING_INTERVAL=20 + +# JSON array of RTCIceServer objects. Empty is correct on a LAN: peers reach each +# other with host candidates, and adding STUN announces internal addresses. +# Only set this when mirroring across subnets/VLANs that cannot route directly. +MIRROR_ICE_SERVERS= +# MIRROR_ICE_SERVERS=[{"urls":"stun://192.168.1.1:3478"}] + +# Developer only: attach a debugpy listener before serving. +DEBUGPY=0 +DEBUGPY_PORT=5678 +DEBUGPY_WAIT=0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffe0319 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ +.python-version + +# Tooling caches +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +coverage.xml +htmlcov/ +pyright-output.json + +# Editors / OS +.vscode/ +.idea/ +.DS_Store +*.swp + +# Local environment — never commit real config values +.env +.env.* +!.env.example + +# Secrets / TLS material generated for local HTTPS +*.key +*.pem +*.p12 +certs/ + +# Playwright +test-results/ +playwright-report/ +.artifacts/ + +# Phase pipeline runtime state (the roadmap itself stays tracked) +.agents/phase-sessions/ +.agents/pipeline.log +.agents/remediation_plan.md diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..471cbbc --- /dev/null +++ b/Containerfile @@ -0,0 +1,50 @@ +# mirror — browser-only screen mirroring. +# +# podman build -t mirror . +# podman run --rm -p 8055:8055 mirror +# +# The image serves the app; it does not capture anything. Capture happens in the +# viewer's and sharer's own browsers, so there is no display, D-Bus, or PipeWire +# socket to pass in. + +FROM docker.io/library/python:3.13-slim AS builder + +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 UV_LINK_MODE=copy +RUN pip install --no-cache-dir uv + +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY src ./src +RUN uv venv /opt/venv \ + && uv pip install --python /opt/venv/bin/python --no-cache . + + +FROM docker.io/library/python:3.13-slim + +LABEL org.opencontainers.image.title="mirror" \ + org.opencontainers.image.description="Browser-only screen mirroring for the local network" \ + org.opencontainers.image.source="https://example.invalid/mirror" + +# curl is for the HEALTHCHECK only; nothing else in the runtime image reaches out. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:${PATH}" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + MIRROR_HOST=0.0.0.0 \ + MIRROR_PORT=8055 + +# Never run the web process as root. +RUN useradd --create-home --shell /usr/sbin/nologin mirror +USER mirror +WORKDIR /home/mirror + +EXPOSE 8055 +HEALTHCHECK --interval=20s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8055/healthz || exit 1 + +ENTRYPOINT ["mirror"] +CMD ["serve"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bde5f5c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 mirror contributors + +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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2e150b3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,68 @@ +[project] +name = "mirror" +version = "0.1.0" +description = "Device-agnostic, browser-only screen mirroring for the local network." +readme = "README.md" +requires-python = ">=3.13" +license = { text = "MIT" } +authors = [{ name = "Reese Apps" }] +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "python-dotenv>=1.0", +] + +[project.optional-dependencies] +dev = [ + "debugpy>=1.8", + "pytest>=8.3", + "pytest-asyncio>=0.24", + "pytest-cov>=5.0", + "httpx>=0.27", + "ruff>=0.6", + "pyright>=1.1.380", + "playwright>=1.47", + "pytest-timeout>=2.3", +] + +[project.scripts] +mirror = "mirror.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/mirror"] + +[tool.ruff] +line-length = 100 +target-version = "py313" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM", "ARG", "RUF"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra --strict-markers --cov=mirror --cov-report=term-missing" +timeout = 60 +asyncio_default_fixture_loop_scope = "function" +asyncio_mode = "auto" +markers = [ + "e2e: browser end-to-end tests (require Playwright browsers)", + "realdisplay: end-to-end tests that capture a real X11 display", +] + +[tool.coverage.run] +source = ["src/mirror"] + +[tool.pyright] +include = ["src"] +pythonVersion = "3.13" +typeCheckingMode = "standard" + +[dependency-groups] +dev = [ + "pytest-timeout>=2.4.0", +] diff --git a/src/mirror/__init__.py b/src/mirror/__init__.py new file mode 100644 index 0000000..ec252ea --- /dev/null +++ b/src/mirror/__init__.py @@ -0,0 +1,7 @@ +"""mirror — device-agnostic, browser-only screen mirroring for the local network.""" + +from __future__ import annotations + +__version__ = "0.1.0" + +__all__ = ["__version__"] diff --git a/src/mirror/__main__.py b/src/mirror/__main__.py new file mode 100644 index 0000000..d69dd39 --- /dev/null +++ b/src/mirror/__main__.py @@ -0,0 +1,111 @@ +"""``mirror`` command line. + +mirror serve [--host H] [--port P] +""" + +from __future__ import annotations + +import argparse +import sys +from dataclasses import replace +from typing import Any, get_args + +from . import __version__, protocol + + +def _positive_int(raw: str) -> int: + try: + value = int(raw) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"{raw!r} is not an integer") from exc + if value < 0 or value > 65535: + raise argparse.ArgumentTypeError("port must be between 0 and 65535") + return value + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="mirror", + description="Device-agnostic screen mirroring, served entirely from the browser.", + epilog="Environment: MIRROR_HOST, MIRROR_PORT, MIRROR_ICE_SERVERS, " + "MIRROR_MAX_BITRATE, MIRROR_MAX_MIRRORS, DEBUGPY.", + ) + parser.add_argument("--version", action="version", version=f"mirror {__version__}") + sub = parser.add_subparsers(dest="command") + + serve = sub.add_parser("serve", help="Start the mirroring server (the default).") + serve.add_argument( + "--host", + default=None, + metavar="ADDR", + help="Bind address. Defaults to $MIRROR_HOST, or 0.0.0.0, which reaches other devices.", + ) + serve.add_argument("--port", type=_positive_int, default=None, help="Bind port.") + serve.add_argument( + "--role-hint", + choices=list(get_args(protocol.Role)), + default=None, + help=argparse.SUPPRESS, + ) + serve.add_argument( + "--log-level", + default="info", + choices=["critical", "error", "warning", "info", "debug", "trace"], + help="Uvicorn log level (default: %(default)s).", + ) + serve.set_defaults(func=cmd_serve) + return parser + + +def cmd_serve(args: argparse.Namespace) -> int: + import uvicorn + + from .config import Settings, lan_addresses + from .debug import maybe_enable_debugpy + + settings = Settings.from_env() + overrides: dict[str, Any] = {} + if args.host is not None: + overrides["host"] = args.host + if args.port is not None: + overrides["port"] = args.port + if overrides: + settings = replace(settings, **overrides) + + maybe_enable_debugpy() + + from .app import create_app + + app = create_app(settings) + + printable = "localhost" if settings.host in ("0.0.0.0", "::") else settings.host + print(f"mirror {__version__} — serving on http://{printable}:{settings.port}", flush=True) + for addr in lan_addresses(): + print(f" from another device: http://{addr}:{settings.port}", flush=True) + print(" share a screen -> /mirror", flush=True) + print(" watch everything -> /watch", flush=True) + + uvicorn.run( + app, + host=settings.host, + port=settings.port, + log_level=args.log_level, + ws_ping_interval=settings.ping_interval, + ws_ping_timeout=settings.ping_interval, + access_log=False, + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0].startswith("-"): + # `mirror`, `mirror --host x` etc. all mean `serve`. + argv = ["serve", *argv] + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/mirror/app.py b/src/mirror/app.py new file mode 100644 index 0000000..43c78c8 --- /dev/null +++ b/src/mirror/app.py @@ -0,0 +1,268 @@ +"""The HTTP + signaling server. + +Two pages and one WebSocket. Media flows browser-to-browser; the server only +shuffles JSON describing who is mirroring what. +""" + +from __future__ import annotations + +import json +from contextlib import suppress +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from pydantic import ValidationError +from starlette.websockets import WebSocketDisconnected + +from . import __version__, protocol +from .config import Settings +from .registry import Envelope, Registry, RegistryError + +STATIC_DIR = Path(__file__).parent / "static" + +#: Hard ceiling on a single signaling frame. SDP blobs are a few kilobytes; +#: anything bigger is noise or an attack, and this server has no authentication. +MAX_MESSAGE_BYTES = 64 * 1024 + + +def _page(name: str) -> FileResponse: + return FileResponse(STATIC_DIR / name, media_type="text/html") + + +class Hub: + """Owns the sockets and delivers the roster's outbound envelopes.""" + + def __init__(self, registry: Registry) -> None: + self.registry = registry + self.sockets: dict[str, WebSocket] = {} + + @property + def connected(self) -> int: + return len(self.sockets) + + async def send(self, peer_id: str, message: dict[str, Any]) -> None: + socket = self.sockets.get(peer_id) + if socket is None: + return + try: + await socket.send_json(message) + except Exception: + await self.drop(peer_id) + + async def emit(self, envelopes: list[Envelope]) -> None: + for peer_id, message in envelopes: + await self.send(peer_id, message) + + async def drop(self, peer_id: str) -> None: + """Forget a socket and clean the roster behind it (idempotent).""" + self.sockets.pop(peer_id, None) + await self.emit(self.registry.leave(peer_id)) + + async def serve(self, websocket: WebSocket) -> None: + """Run one connection for its whole lifetime. + + Two flavours of "the other end is gone" reach us: ``WebSocketDisconnect`` + when the peer goes away mid-read, and ``WebSocketDisconnected`` when we + are asked to read a socket this side has already closed. Both end the + loop and tear the roster down behind us. + """ + await websocket.accept() + peer_id: str | None = None + try: + while True: + try: + raw = await websocket.receive_text() + except (WebSocketDisconnect, WebSocketDisconnected): + break + if len(raw) > MAX_MESSAGE_BYTES: + await self._reject( + peer_id, websocket, "too_large", "Signaling frame exceeds the size limit." + ) + continue + try: + decoded = json.loads(raw) + except json.JSONDecodeError: + await self._reject(peer_id, websocket, "bad_json", "Expected a JSON object.") + continue + if not isinstance(decoded, dict): + await self._reject(peer_id, websocket, "bad_json", "Expected a JSON object.") + continue + + if peer_id is None: + # The very first frame must be `hello`; `_introduce` has + # already closed the socket if it was not. + peer_id = await self._introduce(websocket, decoded) + if peer_id is None: + return + continue + + if await self._dispatch(peer_id, decoded): + break + finally: + if peer_id is not None: + await self.drop(peer_id) + with suppress(Exception): + await websocket.close(code=1000) + + # ------------------------------------------------------------------ inner + + async def _reject( + self, peer_id: str | None, websocket: WebSocket, code: str, message: str + ) -> None: + payload = protocol.error(code=code, message=message, fatal=peer_id is None) + try: + await websocket.send_json(payload) + except Exception: + return + + async def _introduce(self, websocket: WebSocket, decoded: dict[str, Any]) -> str | None: + """Handle the first frame; return the assigned peer id (None = refused).""" + if decoded.get("type") != "hello": + await self._reject( + None, + websocket, + "hello_required", + 'The first message must be {"type":"hello","role":"sharer"|"viewer"}.', + ) + with suppress(Exception): + await websocket.close(code=1002) + return None + try: + hello = protocol.parse_client_message(decoded) + peer_id, outbox = self.registry.join( + role=hello.role, name=hello.name, device=hello.device + ) + except ValidationError as exc: + code = "bad_hello" + # The discriminated union prefixes the variant name, so look for the + # field anywhere in the error path. + if any("role" in tuple(err.get("loc", ())) for err in exc.errors()): + code = "bad_role" + await self._reject(None, websocket, code, "That hello was not usable.") + with suppress(Exception): + await websocket.close(code=1002) + return None + except RegistryError as exc: + await self._reject(None, websocket, exc.code, exc.message) + with suppress(Exception): + await websocket.close(code=1002) + return None + # Register before emitting: the welcome itself is addressed to us. + self.sockets[peer_id] = websocket + await self.emit(outbox) + return peer_id + + async def _dispatch(self, peer_id: str, decoded: dict[str, Any]) -> bool: + """Handle one frame. Returns True when the connection must close.""" + try: + message = protocol.parse_client_message(decoded) + except ValidationError as exc: + await self.emit([(peer_id, protocol.error(code="bad_message", message=str(exc)))]) + return False + + try: + if isinstance(message, protocol.Publish): + outbox = self.registry.publish( + peer_id, + label=message.label, + display_surface=message.display_surface, + width=message.width, + height=message.height, + audio=message.audio, + ) + elif isinstance(message, protocol.Unpublish): + outbox = self.registry.unpublish(peer_id) + elif isinstance(message, protocol.Subscribe): + outbox = self.registry.subscribe(peer_id, message.stream_id) + elif isinstance(message, protocol.Unsubscribe): + outbox = self.registry.unsubscribe(peer_id, message.stream_id) + elif isinstance(message, protocol.Signal): + outbox = self.registry.signal(peer_id, message.to, message.payload) + elif isinstance(message, protocol.Ping): + outbox = [(peer_id, protocol.pong())] + elif isinstance(message, protocol.Hello): + # Re-declaring is harmless: it keeps the roster's labels fresh + # (e.g. the sharer edited their display name mid-session). + outbox = self.registry.rename(peer_id, name=message.name, device=message.device) + else: # pragma: no cover - exhaustive union + outbox = [] + except RegistryError as exc: + await self.emit([(peer_id, protocol.error(code=exc.code, message=exc.message))]) + return exc.fatal + await self.emit(outbox) + return False + + +def create_app(settings: Settings | None = None) -> FastAPI: + """Build the application. A fresh roster per app instance.""" + settings = settings or Settings.from_env() + registry = Registry(max_streams=settings.max_streams) + hub = Hub(registry) + + app = FastAPI( + title="mirror", + version=__version__, + summary="Browser-only screen mirroring for the local network.", + docs_url=None, + redoc_url=None, + ) + app.state.settings = settings + app.state.registry = registry + app.state.hub = hub + + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + @app.get("/", response_class=HTMLResponse, include_in_schema=False) + async def index() -> FileResponse: + return _page("index.html") + + @app.get("/mirror", response_class=HTMLResponse, include_in_schema=False) + async def mirror_page() -> FileResponse: + """The page that picks a window/screen/tab and mirrors it.""" + return _page("mirror.html") + + @app.get("/share", response_class=HTMLResponse, include_in_schema=False) + async def share_alias() -> FileResponse: + return _page("mirror.html") + + @app.get("/watch", response_class=HTMLResponse, include_in_schema=False) + async def watch_page() -> FileResponse: + """The page that shows every live mirror and can stage one fullscreen.""" + return _page("watch.html") + + @app.get("/api/config") + async def client_config() -> JSONResponse: + return JSONResponse( + {"version": __version__, **settings.client_config()}, + headers={"Cache-Control": "no-store"}, + ) + + @app.get("/api/streams") + async def streams() -> JSONResponse: + return JSONResponse( + {"mirrors": registry.roster(), "stats": registry.stats()}, + headers={"Cache-Control": "no-store"}, + ) + + @app.get("/healthz") + async def healthz() -> JSONResponse: + return JSONResponse({"status": "ok", "version": __version__, **registry.stats()}) + + @app.websocket("/ws") + async def signaling(websocket: WebSocket) -> None: + await hub.serve(websocket) + + PAGES = ("/", "/mirror", "/share", "/watch") + + @app.middleware("http") + async def no_store_for_pages(request: Request, call_next): # type: ignore[no-untyped-def] + """A mirror roster changes second to second; never let a browser cache it.""" + response = await call_next(request) + if request.url.path in PAGES: + response.headers["Cache-Control"] = "no-store" + return response + + return app diff --git a/src/mirror/config.py b/src/mirror/config.py new file mode 100644 index 0000000..aae5268 --- /dev/null +++ b/src/mirror/config.py @@ -0,0 +1,105 @@ +"""Runtime configuration, sourced from the environment (and ``.env`` if present).""" + +from __future__ import annotations + +import json +import os +import socket +from dataclasses import dataclass, field +from typing import Any + +DEFAULT_HOST = "0.0.0.0" +DEFAULT_PORT = 8055 +DEFAULT_MAX_BITRATE_BPS = 12_000_000 + + +def _env(name: str, default: str) -> str: + value = os.environ.get(name) + return default if value is None or value == "" else value + + +def _env_int(name: str, default: int) -> int: + raw = _env(name, str(default)) + try: + return int(raw) + except ValueError: + return default + + +def parse_ice_servers(raw: str) -> list[dict[str, Any]]: + """Parse ``MIRROR_ICE_SERVERS`` (a JSON array of RTCIceServer objects). + + Empty / invalid input means "no STUN/TURN", which is the correct default on + a local network: peers reach each other with host candidates alone and we + would rather fail fast than leak a hint of the LAN to an external STUN + server. + """ + raw = (raw or "").strip() + if not raw or raw in ("[]", "none", "null"): + return [] + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(parsed, list): + return [] + return [item for item in parsed if isinstance(item, dict)] + + +@dataclass(frozen=True, slots=True) +class Settings: + """Everything the server needs to start.""" + + host: str = DEFAULT_HOST + port: int = DEFAULT_PORT + ice_servers: list[dict[str, Any]] = field(default_factory=list) + max_bitrate_bps: int = DEFAULT_MAX_BITRATE_BPS + max_streams: int = 0 + ping_interval: float = 20.0 + + @classmethod + def from_env(cls) -> Settings: + try: + from dotenv import load_dotenv + + load_dotenv() + except ImportError: # pragma: no cover - dotenv is a hard dependency + pass + return cls( + host=_env("MIRROR_HOST", DEFAULT_HOST), + port=_env_int("MIRROR_PORT", DEFAULT_PORT), + ice_servers=parse_ice_servers(os.environ.get("MIRROR_ICE_SERVERS", "")), + max_bitrate_bps=_env_int("MIRROR_MAX_BITRATE", DEFAULT_MAX_BITRATE_BPS), + max_streams=_env_int("MIRROR_MAX_MIRRORS", 0), + ping_interval=float(_env_int("MIRROR_PING_INTERVAL", 20)), + ) + + def client_config(self) -> dict[str, Any]: + """The bit of configuration the browser is allowed to know about.""" + return { + "iceServers": self.ice_servers, + "maxBitrateBps": self.max_bitrate_bps, + "pingIntervalMs": int(self.ping_interval * 1000), + } + + +def lan_addresses() -> list[str]: + """Best-effort list of addresses other devices on the LAN can reach us on.""" + found: list[str] = [] + try: + for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): + address = str(info[4][0]) + if address not in found and not address.startswith("127."): + found.append(address) + except socket.gaierror: + pass + # Last resort: the default-route source address, which is usually the LAN IP. + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: + probe.connect(("10.255.255.255", 1)) + default_route = str(probe.getsockname()[0]) + if default_route not in found: + found.append(default_route) + except OSError: + pass + return found diff --git a/src/mirror/debug.py b/src/mirror/debug.py new file mode 100644 index 0000000..3e99f65 --- /dev/null +++ b/src/mirror/debug.py @@ -0,0 +1,28 @@ +"""Optional debugpy attach point. + +Off unless ``DEBUGPY=1``. When on, the process listens (non-blocking by +default) so an editor can attach to a running server. +""" + +from __future__ import annotations + +import os + + +def maybe_enable_debugpy(default_port: int = 5678) -> bool: + """Start listening for a debugpy client if the environment asks for it. + + Returns ``True`` when a listener was opened. + """ + if os.environ.get("DEBUGPY", "").lower() not in {"1", "true", "yes", "on"}: + return False + try: + import debugpy + except ImportError: + return False + port = int(os.environ.get("DEBUGPY_PORT", str(default_port))) + wait = os.environ.get("DEBUGPY_WAIT", "").lower() in {"1", "true", "yes", "on"} + debugpy.listen(("0.0.0.0", port)) + if wait: + debugpy.wait_for_client() + return True diff --git a/src/mirror/protocol.py b/src/mirror/protocol.py new file mode 100644 index 0000000..b05f282 --- /dev/null +++ b/src/mirror/protocol.py @@ -0,0 +1,167 @@ +"""Signaling protocol for mirror. + +The wire format is JSON over a single WebSocket endpoint (``/ws``). This module +owns the *shape* of every message that can cross that socket. The media itself +never touches the server — it travels over WebRTC between peers. + +Naming convention on the wire: ``snake_case`` message ``type`` for server -> +client, and verbs for client -> server. +""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter +from pydantic.alias_generators import to_camel + +Role = Literal["sharer", "viewer"] + +MAX_NAME_LEN = 48 +MAX_LABEL_LEN = 96 + + +class _Inbound(BaseModel): + """Base for every client -> server message. + + The wire is camelCase (it is JS on the other end) while Python is + snake_case. Deriving the aliases — rather than ignoring unknown keys — means + a misspelled or mis-cased field is *accepted under the alias* instead of + being silently dropped into its default. + """ + + model_config = ConfigDict( + extra="ignore", + populate_by_name=True, + alias_generator=to_camel, + ) + + +class Hello(_Inbound): + """First message on a connection; declares what this peer is doing.""" + + type: Literal["hello"] + role: Role = "viewer" + name: str = "" + device: str = "" + + +class Publish(_Inbound): + """A sharer announces (or updates) the surface it is currently capturing.""" + + type: Literal["publish"] + label: str = "" + display_surface: str = Field(default="", max_length=32) + width: int | None = Field(default=None, ge=0, le=16384) + height: int | None = Field(default=None, ge=0, le=16384) + audio: bool = False + + +class Unpublish(_Inbound): + """A sharer stopped capturing; the mirror goes away.""" + + type: Literal["unpublish"] + + +class Subscribe(_Inbound): + type: Literal["subscribe"] + stream_id: str + + +class Unsubscribe(_Inbound): + type: Literal["unsubscribe"] + stream_id: str + + +class Signal(_Inbound): + """An opaque WebRTC blob (SDP or ICE candidate) addressed to one peer.""" + + type: Literal["signal"] + to: str + payload: dict[str, Any] + + +class Ping(_Inbound): + type: Literal["ping"] + + +ClientMessage = Annotated[ + Hello | Publish | Unpublish | Subscribe | Unsubscribe | Signal | Ping, + Field(discriminator="type"), +] + +CLIENT_ADAPTER: TypeAdapter[Any] = TypeAdapter(ClientMessage) + + +def parse_client_message(raw: dict[str, Any]) -> Any: + """Validate a decoded JSON object as a client message. + + Raises :class:`pydantic.ValidationError` if it is not a valid message. + """ + return CLIENT_ADAPTER.validate_python(raw) + + +# --------------------------------------------------------------------------- # +# Outbound builders (server -> client) +# --------------------------------------------------------------------------- # + + +def stream_dto( + *, + stream_id: str, + sharer_id: str, + name: str, + label: str, + display_surface: str, + width: int | None, + height: int | None, + audio: bool, + viewers: int, +) -> dict[str, Any]: + return { + "id": stream_id, + "sharerId": sharer_id, + "name": name, + "label": label, + "displaySurface": display_surface, + "width": width, + "height": height, + "audio": audio, + "viewers": viewers, + } + + +def welcome(*, peer_id: str, role: Role, streams: list[dict[str, Any]]) -> dict[str, Any]: + return {"type": "welcome", "peerId": peer_id, "role": role, "streams": streams} + + +def stream_added(*, stream: dict[str, Any]) -> dict[str, Any]: + return {"type": "stream_added", "stream": stream} + + +def stream_updated(*, stream: dict[str, Any]) -> dict[str, Any]: + return {"type": "stream_updated", "stream": stream} + + +def stream_removed(*, stream_id: str) -> dict[str, Any]: + return {"type": "stream_removed", "streamId": stream_id} + + +def peer_joined(*, stream_id: str, viewer_id: str, name: str) -> dict[str, Any]: + return {"type": "peer_joined", "streamId": stream_id, "viewerId": viewer_id, "name": name} + + +def peer_left(*, stream_id: str, viewer_id: str) -> dict[str, Any]: + return {"type": "peer_left", "streamId": stream_id, "viewerId": viewer_id} + + +def relayed_signal(*, frm: str, to: str, payload: dict[str, Any]) -> dict[str, Any]: + return {"type": "signal", "from": frm, "to": to, "payload": payload} + + +def error(*, code: str, message: str, fatal: bool = False) -> dict[str, Any]: + return {"type": "error", "code": code, "message": message, "fatal": fatal} + + +def pong() -> dict[str, Any]: + return {"type": "pong"} diff --git a/src/mirror/registry.py b/src/mirror/registry.py new file mode 100644 index 0000000..d8a6fde --- /dev/null +++ b/src/mirror/registry.py @@ -0,0 +1,359 @@ +"""The roster: who is mirroring, who is watching, and what may talk to whom. + +This module is deliberately free of transport and I/O. Every operation returns +a list of :class:`Envelope` items — ``(peer_id, message)`` pairs — that the +caller is responsible for delivering. That keeps the whole admission/teardown +policy unit-testable without a single socket. + +Invariants enforced here: + +* A stream belongs to exactly one sharer and shares its fate: no sharer, no stream. +* A viewer only ever receives ``signal`` traffic for streams it subscribed to. +* Every teardown path (explicit, disconnect, replaced stream) emits the same + ``peer_left`` / ``stream_removed`` notifications, so browsers never leak a + ``RTCPeerConnection``. +""" + +from __future__ import annotations + +import secrets +import time +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field +from typing import Any + +from .protocol import ( + MAX_LABEL_LEN, + MAX_NAME_LEN, + peer_joined, + peer_left, + relayed_signal, + stream_added, + stream_removed, + stream_updated, + welcome, +) + +Envelope = tuple[str, dict[str, Any]] + + +class RegistryError(Exception): + """A policy violation. Carries a stable ``code`` for the client.""" + + def __init__(self, code: str, message: str, *, fatal: bool = False) -> None: + super().__init__(message) + self.code = code + self.message = message + self.fatal = fatal + + +@dataclass(slots=True) +class Peer: + id: str + role: str + name: str + device: str = "" + joined_at: float = field(default_factory=time.time) + + +@dataclass(slots=True) +class Stream: + """One live capture, published by exactly one sharer peer.""" + + id: str + sharer_id: str + label: str = "" + display_surface: str = "" + width: int | None = None + height: int | None = None + audio: bool = False + viewers: set[str] = field(default_factory=set) + created_at: float = field(default_factory=time.time) + + def dto(self, name: str) -> dict[str, Any]: + from .protocol import stream_dto + + return stream_dto( + stream_id=self.id, + sharer_id=self.sharer_id, + name=name, + label=self.label, + display_surface=self.display_surface, + width=self.width, + height=self.height, + audio=self.audio, + viewers=len(self.viewers), + ) + + +def _clean(value: str, limit: int) -> str: + return " ".join(str(value).split())[:limit] + + +class Registry: + """In-memory roster of peers and streams.""" + + def __init__( + self, + *, + id_factory: Callable[[], str] | None = None, + max_streams: int = 0, + ) -> None: + self._id_factory = id_factory or (lambda: f"p{secrets.token_hex(4)}") + self._max_streams = max_streams + self.peers: dict[str, Peer] = {} + self.streams: dict[str, Stream] = {} + #: stream_id -> set of viewer peer ids that currently receive it + self._subscriptions_by_viewer: dict[str, set[str]] = {} + + # ---------------------------------------------------------------- helpers + + def _peer(self, peer_id: str) -> Peer: + try: + return self.peers[peer_id] + except KeyError as exc: + raise RegistryError("unknown_peer", f"Unknown peer {peer_id!r}.") from exc + + def _stream(self, stream_id: str) -> Stream: + try: + return self.streams[stream_id] + except KeyError as exc: + raise RegistryError("unknown_stream", f"No mirror with id {stream_id!r}.") from exc + + def _viewers(self) -> Iterable[Peer]: + return (p for p in self.peers.values() if p.role == "viewer") + + def _next_id(self) -> str: + for _ in range(64): + candidate = self._id_factory() + if candidate not in self.peers: + return candidate + raise RegistryError("no_id", "Ran out of peer ids.", fatal=True) + + # ------------------------------------------------------------ operations + + def join(self, *, role: str, name: str = "", device: str = "") -> tuple[str, list[Envelope]]: + """Register a freshly connected socket. Returns ``(peer_id, outbox)``.""" + if role not in ("sharer", "viewer"): + raise RegistryError("bad_role", f"Unknown role {role!r}.", fatal=True) + peer_id = self._next_id() + peer = Peer( + id=peer_id, + role=role, + name=_clean(name, MAX_NAME_LEN) or ("This device" if role == "sharer" else "Viewer"), + device=_clean(device, MAX_NAME_LEN), + ) + self.peers[peer_id] = peer + self._subscriptions_by_viewer[peer_id] = set() + outbox: list[Envelope] = [ + (peer_id, welcome(peer_id=peer_id, role=role, streams=self.roster())) # type: ignore[arg-type] + ] + return peer_id, outbox + + def leave(self, peer_id: str) -> list[Envelope]: + """Tear down everything associated with a peer that is going away.""" + peer = self.peers.pop(peer_id, None) + if peer is None: + return [] + outbox: list[Envelope] = [] + # Its mirrors (if any) disappear for everyone watching them. + for stream in [s for s in self.streams.values() if s.sharer_id == peer_id]: + outbox.extend(self._drop_stream(stream)) + # Its watches (if a viewer) are released so the sharer can close the PC. + for stream_id in self._subscriptions_by_viewer.pop(peer_id, set()): + stream = self.streams.get(stream_id) + if stream is None or peer_id not in stream.viewers: + continue + stream.viewers.discard(peer_id) + outbox.append((stream.sharer_id, peer_left(stream_id=stream.id, viewer_id=peer_id))) + outbox.extend(self._broadcast_update(stream)) + return outbox + + def rename(self, peer_id: str, *, name: str = "", device: str = "") -> list[Envelope]: + """Refresh the labels a peer advertises, and re-publish what shows them.""" + peer = self._peer(peer_id) + cleaned = _clean(name, MAX_NAME_LEN) + changed = bool(cleaned) and cleaned != peer.name + if cleaned: + peer.name = cleaned + if device: + peer.device = _clean(device, MAX_NAME_LEN) + if not changed: + return [] + outbox: list[Envelope] = [] + for stream in self.streams.values(): + if stream.sharer_id == peer_id: + outbox.extend(self._broadcast_update(stream)) + return outbox + + def publish( + self, + peer_id: str, + *, + label: str = "", + display_surface: str = "", + width: int | None = None, + height: int | None = None, + audio: bool = False, + ) -> list[Envelope]: + """Create or refresh the caller's mirror.""" + peer = self._peer(peer_id) + if peer.role != "sharer": + raise RegistryError("not_sharer", "Only sharers can publish.", fatal=True) + at_capacity = ( + bool(self._max_streams) + and peer_id not in self.streams + and len(self.streams) >= self._max_streams + ) + if at_capacity: + raise RegistryError( + "capacity", + f"This server is at its limit of {self._max_streams} mirrors.", + fatal=True, + ) + stream = self.streams.get(peer_id) + brand_new = stream is None + if stream is None: + # One stream per sharer connection: the ids coincide, which keeps + # teardown trivial and is all a single browser tab can offer. + stream = Stream(id=peer_id, sharer_id=peer_id) + self.streams[peer_id] = stream + existing_viewers = set(stream.viewers) + before = (stream.width, stream.height, stream.audio, stream.display_surface) + stream.label = _clean(label, MAX_LABEL_LEN) + stream.display_surface = _clean(display_surface, 32) + stream.width = width + stream.height = height + stream.audio = audio + changed = before != (width, height, audio, stream.display_surface) + + if not brand_new and existing_viewers: + if not changed: + # Same capture, refreshed metadata (e.g. a socket reconnect). + return self._broadcast_update(stream) + # The capture itself changed shape: everyone already holds a PC, so + # ask the sharer to re-offer to each of them. + outbox = [ + ( + viewer_id, + peer_joined(stream_id=stream.id, viewer_id=viewer_id, name=peer.name), + ) + for viewer_id in sorted(existing_viewers) + ] + outbox.extend(self._broadcast_update(stream)) + return outbox + return self._broadcast_add(stream) + + def unpublish(self, peer_id: str) -> list[Envelope]: + """The sharer stopped capturing (native Stop button, tab close, ...).""" + stream = self.streams.get(peer_id) + if stream is None: + return [] + return self._drop_stream(stream) + + def subscribe(self, viewer_id: str, stream_id: str) -> list[Envelope]: + """Ask to receive a mirror. The sharer is told to negotiate with us.""" + viewer = self._peer(viewer_id) + if viewer.role != "viewer": + raise RegistryError("not_viewer", "Only viewers can subscribe.") + stream = self._stream(stream_id) + already = viewer_id in stream.viewers + stream.viewers.add(viewer_id) + self._subscriptions_by_viewer.setdefault(viewer_id, set()).add(stream.id) + if already: + return [] + outbox: list[Envelope] = [ + ( + stream.sharer_id, + peer_joined(stream_id=stream.id, viewer_id=viewer_id, name=viewer.name), + ) + ] + outbox.extend(self._broadcast_update(stream)) + return outbox + + def unsubscribe(self, viewer_id: str, stream_id: str) -> list[Envelope]: + stream = self.streams.get(stream_id) + self._subscriptions_by_viewer.get(viewer_id, set()).discard(stream_id) + if stream is None or viewer_id not in stream.viewers: + return [] + stream.viewers.discard(viewer_id) + outbox: list[Envelope] = [ + (stream.sharer_id, peer_left(stream_id=stream.id, viewer_id=viewer_id)) + ] + outbox.extend(self._broadcast_update(stream)) + return outbox + + def signal(self, frm: str, to: str, payload: dict[str, Any]) -> list[Envelope]: + """Relay an SDP/ICE blob, but only along an authorised subscription. + + Traffic is only allowed between a viewer and the sharer of a mirror that + viewer has subscribed to — in either direction. Peer-to-peer traffic + that the roster does not sanction is refused. + """ + source = self._peer(frm) + target = self._peer(to) + if source.role == "viewer" and target.role == "sharer": + viewer_id, sharer_id = frm, to + elif source.role == "sharer" and target.role == "viewer": + viewer_id, sharer_id = to, frm + else: + raise RegistryError( + "peer_to_peer", + "Peers may only signal the sharer of a mirror they watch.", + ) + if sharer_id not in self._subscriptions_by_viewer.get(viewer_id, frozenset()): + raise RegistryError("not_subscribed", f"{viewer_id!r} is not watching {sharer_id!r}.") + return [(to, relayed_signal(frm=frm, to=to, payload=payload))] + + def watching(self, viewer_id: str) -> set[str]: + """The stream ids this viewer is currently receiving.""" + return set(self._subscriptions_by_viewer.get(viewer_id, set())) + + # -------------------------------------------------------------- read-only + + def roster(self) -> list[dict[str, Any]]: + """Every live mirror, oldest first.""" + return [ + s.dto(self.peers[s.sharer_id].name) + for s in sorted(self.streams.values(), key=lambda s: s.created_at) + if s.sharer_id in self.peers + ] + + def stats(self) -> dict[str, Any]: + viewers = sum(1 for p in self.peers.values() if p.role == "viewer") + sharers = sum(1 for p in self.peers.values() if p.role == "sharer") + return { + "mirrors": len(self.streams), + "sharers": sharers, + "viewers": viewers, + "connections": len(self.peers), + "watching": sum(len(s.viewers) for s in self.streams.values()), + } + + # --------------------------------------------------------------- internal + + def _drop_stream(self, stream: Stream) -> list[Envelope]: + """Retire a mirror. + + Every viewer is told, not only the ones with a live subscription: a tile + is drawn as soon as ``stream_added`` lands, so a viewer that has not got + round to subscribing still has something on screen to clear. + """ + self.streams.pop(stream.id, None) + for subs in self._subscriptions_by_viewer.values(): + subs.discard(stream.id) + message = stream_removed(stream_id=stream.id) + return [(viewer.id, message) for viewer in sorted(self._viewers(), key=lambda p: p.id)] + + def _broadcast_add(self, stream: Stream) -> list[Envelope]: + name = self.peers[stream.sharer_id].name + dto = stream.dto(name) + return [(p.id, stream_added(stream=dto)) for p in self._viewers()] + + def _broadcast_update(self, stream: Stream) -> list[Envelope]: + if stream.sharer_id not in self.peers: + return [] + name = self.peers[stream.sharer_id].name + dto = stream.dto(name) + targets = {p.id for p in self._viewers()} | {stream.sharer_id} + return [(peer_id, stream_updated(stream=dto)) for peer_id in sorted(targets)] diff --git a/src/mirror/static/app.css b/src/mirror/static/app.css new file mode 100644 index 0000000..3f3b0d7 --- /dev/null +++ b/src/mirror/static/app.css @@ -0,0 +1,403 @@ +:root { + --bg: #0b0d10; + --bg-2: #12161b; + --bg-3: #1a2027; + --line: #252d36; + --fg: #e8edf2; + --fg-dim: #94a3b1; + --accent: #4fd1c5; + --accent-ink: #04231f; + --live: #ff4d4f; + --warn: #f5a524; + --radius: 14px; + --shadow: 0 10px 30px rgba(0, 0, 0, 0.45); + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; +} + +* { box-sizing: border-box; } + +/* Any component that sets its own `display` (.btn, .net, .livetag, .icon-btn, + .toast…) outranks the UA's `[hidden] { display: none }`, so a `hidden` + attribute alone would leave it on screen. One rule, applied everywhere: + `hidden` always means gone. */ +[hidden] { + display: none !important; +} + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--fg); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + -webkit-text-size-adjust: 100%; +} + +a { color: var(--accent); } + +/* ---------------------------------------------------------------- chrome */ + +.bar { + position: sticky; + top: 0; + z-index: 30; + display: flex; + align-items: center; + gap: 14px; + padding: 10px 16px; + background: rgba(11, 13, 16, 0.88); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); +} + +.brand { + display: flex; + align-items: center; + gap: 9px; + font-weight: 650; + letter-spacing: 0.2px; +} + +.brand .glyph { + width: 22px; + height: 22px; + border-radius: 50%; + background: radial-gradient(circle at 35% 35%, #7ef0e4, var(--accent) 55%, #157e74); + box-shadow: 0 0 0 1px #2b4b48, 0 0 14px rgba(79, 209, 197, 0.4); +} + +.bar .spacer { flex: 1; } + +.bar .meta { color: var(--fg-dim); font-size: 13px; white-space: nowrap; } + +.net { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--fg-dim); +} + +.net i { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--warn); +} + +.net[data-state="online"] i { background: #37d67a; box-shadow: 0 0 8px rgba(55, 214, 122, 0.7); } +.net[data-state="connecting"] i { animation: pulse 1.1s ease-in-out infinite; } +.net[data-state="offline"] i { background: var(--live); } + +@keyframes pulse { 50% { opacity: 0.25; } } + +.btn { + appearance: none; + border: 1px solid var(--line); + background: var(--bg-3); + color: var(--fg); + padding: 9px 14px; + border-radius: 10px; + font: inherit; + font-weight: 550; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 8px; + transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease; +} + +.btn:hover { background: #222a33; } +.btn:active { transform: translateY(1px); } +.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; } +.btn.primary:hover { background: #6ee0d5; } +.btn.danger { background: var(--live); color: #fff; border-color: transparent; } +.btn.big { padding: 13px 20px; font-size: 16px; border-radius: 12px; } +.btn.ghost { background: transparent; } +.btn:disabled { opacity: 0.45; cursor: not-allowed; } + +.icon-btn { + border: 1px solid var(--line); + background: rgba(18, 22, 27, 0.85); + color: var(--fg); + width: 32px; + height: 32px; + border-radius: 9px; + cursor: pointer; + font-size: 14px; + line-height: 1; + display: inline-grid; + place-items: center; +} + +.icon-btn:hover { background: var(--bg-3); } +.icon-btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } + +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* ------------------------------------------------------------------ notes */ + +.notice { + margin: 14px 16px 0; + padding: 12px 14px; + border: 1px solid var(--warn); + border-radius: var(--radius); + background: rgba(245, 165, 36, 0.09); + font-size: 14px; +} + +.notice code { + font-family: var(--mono); + font-size: 12.5px; + background: rgba(255, 255, 255, 0.08); + padding: 1px 5px; + border-radius: 5px; +} + + +.toast { + position: fixed; + left: 50%; + bottom: 22px; + transform: translateX(-50%); + background: var(--bg-3); + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px 16px; + box-shadow: var(--shadow); + z-index: 60; + font-size: 14px; +} + + +/* ------------------------------------------------------------------- home */ + +.hero { max-width: 980px; margin: 0 auto; padding: 40px 20px 60px; } +.hero h1 { font-size: clamp(28px, 5vw, 44px); margin: 0 0 10px; letter-spacing: -0.5px; } +.hero p.lead { color: var(--fg-dim); font-size: 17px; max-width: 62ch; margin: 0 0 28px; } + +.cards { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } + +.card { + display: block; + padding: 20px; + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: var(--radius); + text-decoration: none; + color: inherit; + transition: border-color 0.15s ease, transform 0.12s ease; +} + +.card:hover { border-color: var(--accent); transform: translateY(-2px); } +.card h2 { margin: 0 0 6px; font-size: 19px; } +.card p { margin: 0; color: var(--fg-dim); font-size: 14px; } +.card .kicker { font-family: var(--mono); font-size: 12px; color: var(--accent); } + +.livebar { + margin-top: 26px; + padding: 14px 16px; + border: 1px dashed var(--line); + border-radius: var(--radius); + color: var(--fg-dim); + font-size: 14px; +} + +/* ------------------------------------------------------------------ share */ + +.share { + display: grid; + grid-template-columns: minmax(0, 1fr) 330px; + gap: 16px; + padding: 16px; + align-items: start; +} + +@media (max-width: 900px) { + .share { grid-template-columns: 1fr; } +} + +.stage { + position: relative; + aspect-ratio: 16 / 10; + background: #05070a; + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + display: grid; + place-items: center; +} + +.stage video { width: 100%; height: 100%; object-fit: contain; display: block; } + +.stage .placeholder { text-align: center; padding: 24px; color: var(--fg-dim); } +.stage .placeholder h2 { color: var(--fg); margin: 14px 0 6px; font-size: 20px; } +.stage .placeholder p { margin: 0 0 18px; font-size: 14px; } + +.livetag { + position: absolute; + top: 12px; + left: 12px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 10px; + border-radius: 999px; + background: rgba(5, 7, 10, 0.72); + border: 1px solid rgba(255, 255, 255, 0.12); + font-size: 12.5px; + font-family: var(--mono); +} + +.livetag i { width: 8px; height: 8px; border-radius: 50%; background: var(--live); animation: pulse 1.4s infinite; } + +.panel { + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 16px; + display: grid; + gap: 14px; +} + +.panel h2 { margin: 0; font-size: 13px; text-transform: uppercase; letter-spacing: 0.09em; color: var(--fg-dim); } +.panel .row { display: flex; gap: 8px; flex-wrap: wrap; } +.panel label { display: grid; gap: 6px; font-size: 13px; color: var(--fg-dim); } +.panel input[type="text"] { + background: var(--bg); + border: 1px solid var(--line); + color: var(--fg); + border-radius: 9px; + padding: 9px 11px; + font: inherit; +} + +dl.stats { display: grid; grid-template-columns: auto 1fr; gap: 5px 12px; margin: 0; font-size: 13.5px; } +dl.stats dt { color: var(--fg-dim); } +dl.stats dd { margin: 0; font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; } + +ul.audience { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 13.5px; } +ul.audience li { + display: flex; justify-content: space-between; gap: 10px; + padding: 7px 10px; background: var(--bg); border: 1px solid var(--line); border-radius: 9px; +} +ul.audience li span.state { font-family: var(--mono); font-size: 11.5px; color: var(--fg-dim); } +ul.audience .empty { border: 1px dashed var(--line); color: var(--fg-dim); justify-content: center; } + +/* ------------------------------------------------------------------ watch */ + +.grid { + display: grid; + gap: 14px; + padding: 16px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); + align-items: start; +} + +.tile { + position: relative; + background: #05070a; + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + cursor: pointer; + transition: border-color 0.15s ease; +} + +.tile:hover { border-color: #3a4753; } +.tile[data-state="live"] { border-color: rgba(79, 209, 197, 0.55); } + +.tile video { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; display: block; background: #05070a; } + +.tile .cap { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 11px; + background: var(--bg-2); + border-top: 1px solid var(--line); + font-size: 13.5px; + min-height: 44px; +} + +.tile .cap .title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tile .cap .sub { color: var(--fg-dim); font-family: var(--mono); font-size: 11.5px; white-space: nowrap; } +.tile .cap .grow { flex: 1; min-width: 0; } + +.tile .badge { + position: absolute; + top: 10px; + left: 10px; + padding: 4px 9px; + border-radius: 999px; + font-family: var(--mono); + font-size: 11.5px; + background: rgba(5, 7, 10, 0.72); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.tile .actions { position: absolute; top: 8px; right: 8px; display: flex; gap: 6px; opacity: 0; transition: opacity 0.15s ease; } +.tile:hover .actions, .tile:focus-within .actions { opacity: 1; } + +@media (hover: none) { .tile .actions { opacity: 1; } } + +.empty { + padding: 70px 20px; + text-align: center; + color: var(--fg-dim); +} + +.empty h2 { color: var(--fg); margin: 0 0 8px; } + +/* Staging: one mirror fills the viewport; the grid is hidden behind it. */ +body.staged .grid, body.staged .empty { display: none; } + +#watch-stage { + position: fixed; + inset: 0; + z-index: 50; + background: #04060a; + display: none; +} + +body.staged #watch-stage { display: block; } + +/* The staged tile *is* the stage: the element itself moves here, so its + RTCPeerConnection and