diff --git a/.github/workflows/repo-guards.yml b/.github/workflows/repo-guards.yml index 53ef4f9..f2fcff7 100644 --- a/.github/workflows/repo-guards.yml +++ b/.github/workflows/repo-guards.yml @@ -32,7 +32,7 @@ jobs: - name: Install build deps run: python -m pip install pyqt6 requests flask pyinstaller - name: Run test suites on Windows - run: python tests/run_all.py + run: python -u tests/run_all.py - name: Test stale-binary detector (check_stale.ps1) shell: pwsh run: ./tests/test_check_stale.ps1 diff --git a/README.md b/README.md index 2c527d8..30d66ef 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ hardware-accelerated video cells powered by libmpv. - **Multi-monitor** — each monitor gets its own fullscreen window with a configurable grid of video cells (1x1 to 6x6) +- **Per-monitor layout** — the setup wizard independently assigns each + selected display a Wall/Preview role, physical rotation (Auto/0°/90°/180°/ + 270°), and rows × columns video grid - **libmpv backend** — hardware-accelerated decode via nvdec/d3d11 (NVIDIA Blackwell), 240 Hz G-Sync compatible, HDR hinting - **Emby integration** — streams directly from your Emby server with @@ -114,7 +117,7 @@ All endpoints under `/api/`: ``` hyperwall.py → hyperwall/app.py → WallController - ├── SetupWizard (monitor + library picker) + ├── SetupWizard (per-monitor role/rotation/grid + library picker) ├── Per-monitor QMainWindow (fullscreen) │ └── Grid of VideoCell widgets │ └── mpv.MPV embedded via wid= @@ -134,6 +137,11 @@ username = your_username password = your_password [Settings] +# These are fallback defaults; the wizard stores per-monitor overrides. +last_grid_rows = 2 +last_grid_cols = 2 +last_display_roles = +last_display_layouts = cleanup_on_startup = false ``` diff --git a/config.example.ini b/config.example.ini index 6ae5542..522cb66 100644 --- a/config.example.ini +++ b/config.example.ini @@ -16,7 +16,14 @@ last_screens = # Last used libraries (auto-saved, comma-separated library names) last_libraries = # Grid layout: rows x cols per monitor +# These remain the defaults for displays without a saved per-display layout. last_grid_rows = 2 last_grid_cols = 2 +# Preview grid defaults (also overridden per display in the wizard) +last_preview_rows = 3 +last_preview_cols = 4 +# Per-monitor role and layout state (auto-saved JSON) +last_display_roles = +last_display_layouts = # Run tagged-item cleanup on startup cleanup_on_startup = false diff --git a/hyperwall/__init__.py b/hyperwall/__init__.py index f30bd0f..da4ed73 100644 --- a/hyperwall/__init__.py +++ b/hyperwall/__init__.py @@ -7,7 +7,7 @@ from __future__ import annotations -__version__ = "10.14.0" +__version__ = "10.15.0" # Short "major.minor" form, derived — used for User-Agent / Emby auth Version / # window titles so a version bump touches exactly ONE line (this file). VERSION_SHORT = ".".join(__version__.split(".")[:2]) diff --git a/hyperwall/app.py b/hyperwall/app.py index cfd64ad..7ed3b8c 100644 --- a/hyperwall/app.py +++ b/hyperwall/app.py @@ -7,9 +7,12 @@ from __future__ import annotations +import argparse import ctypes +import json import logging import os +import socket import sys from logging.handlers import RotatingFileHandler @@ -41,6 +44,7 @@ from . import theme from .wizard import SetupWizard from .wall import WallController, MouseIdleHider +from .sync import SyncServer, SyncClient, DEFAULT_SYNC_PORT logger = logging.getLogger("HyperWall") @@ -166,6 +170,31 @@ def _show_error_dialog(title: str, msg: str) -> None: def main() -> None: sys.excepthook = _handle_exception + # 0. Optional headless sync-relay mode (no Qt, no mpv). + parser = argparse.ArgumentParser(prog="hyperwall") + parser.add_argument( + "--sync-relay", + action="store_true", + help="Run only the headless sync relay (no GUI).", + ) + parser.add_argument( + "--sync-host", + default="0.0.0.0", + help="Host to bind the sync relay to (default: 0.0.0.0).", + ) + parser.add_argument( + "--sync-port", + type=int, + default=DEFAULT_SYNC_PORT, + help=f"Port for the sync relay (default: {DEFAULT_SYNC_PORT}).", + ) + args = parser.parse_args() + + if args.sync_relay: + from .sync import run_sync_relay + run_sync_relay(host=args.sync_host, port=args.sync_port) + return + # 1. NVIDIA isolation: re-exec into bundled exe if needed maybe_relaunch_in_isolation() @@ -321,6 +350,10 @@ def main() -> None: last_libraries=cfg.last_libraries, last_rows=cfg.last_grid_rows, last_cols=cfg.last_grid_cols, + last_preview_rows=cfg.last_preview_rows, + last_preview_cols=cfg.last_preview_cols, + last_display_roles=cfg.display_roles(), + last_display_layouts=cfg.display_layouts(), ) if wiz.exec() != QDialog.DialogCode.Accepted: client.close() @@ -346,6 +379,12 @@ def main() -> None: last_libraries=",".join(settings["libraries"]), last_grid_rows=settings["grid_rows"], last_grid_cols=settings["grid_cols"], + last_preview_rows=settings["preview_rows"], + last_preview_cols=settings["preview_cols"], + last_display_roles=json.dumps(settings["display_roles"]), + last_display_layouts=json.dumps( + settings.get("display_layouts", {}), sort_keys=True + ), cleanup_on_startup=cfg.cleanup_on_startup, scenes=cfg.scenes, # preserve saved scene presets across the rewrite ) @@ -370,8 +409,35 @@ def main() -> None: grid_rows=settings["grid_rows"], grid_cols=settings["grid_cols"], client=client, + display_roles=settings.get("display_roles"), + display_layouts=settings.get("display_layouts"), + preview_rows=settings.get("preview_rows", 3), + preview_cols=settings.get("preview_cols", 4), ) + # Optional network sync layer for multi-machine walls. + if cfg.sync_enabled: + if cfg.sync_server: + sync = SyncServer(wall, host=cfg.sync_host, port=cfg.sync_port) + else: + # For clients, connect to the server's IP (not 0.0.0.0). + host = cfg.sync_host if cfg.sync_host != "0.0.0.0" else "127.0.0.1" + sync = SyncClient( + wall, + host=host, + port=cfg.sync_port, + display_name=cfg.sync_display_name or socket.gethostname(), + ) + sync.start() + wall.set_sync_adapter(sync) + logger.info( + "Sync %s started (%s:%d) as %s", + "server" if cfg.sync_server else "client", + cfg.sync_host, + cfg.sync_port, + cfg.sync_display_name or socket.gethostname(), + ) + if _WEB_AVAILABLE: _web.start(wall) elif "_web" in globals(): diff --git a/hyperwall/cell.py b/hyperwall/cell.py index 9aa0895..13369d6 100644 --- a/hyperwall/cell.py +++ b/hyperwall/cell.py @@ -200,6 +200,8 @@ class VideoCell(QWidget): request_next = pyqtSignal(object, bool) request_prev = pyqtSignal(object) + request_solo = pyqtSignal(object) + request_remote_solo = pyqtSignal(object) _sig_eof = pyqtSignal(int, str) _sig_track_done = pyqtSignal(int) _sig_buffering = pyqtSignal(int, bool) @@ -235,11 +237,11 @@ def __init__(self, controller: Any): self._buffering_card = False self._retry_count = 0 self._force_transcode = False - # True while this cell's CURRENT stream is a server transcode (HLS). - # Read by the controller's transcode-concurrency gate. Kept drift-free - # by re-deriving it from the URL at every stream commit (play + - # advance_to_prefetched); a transcode URL carries an .m3u8 playlist. - self._is_transcoding = False + # URL state used by the controller's transcode-concurrency gate. + # Prefetched URLs are kept separate from the currently playing URL so + # a warm future HLS playlist is not counted as active server work. + self._stream_url: str | None = None + self._prefetched_stream_url: str | None = None self._played_anything = False self._paused = False # main-thread cache; safe to read cross-thread self._last_next_request_ts = 0.0 @@ -700,7 +702,8 @@ def play(self, item: dict[str, Any], url: str) -> None: # loadfile (replace) clears the mpv playlist tail, so any queued # prefetch entry is gone with it (probed live 2026-07-13). self._prefetched = None - self._is_transcoding = ".m3u8" in url # transcode = HLS playlist URL + self._stream_url = url + self._prefetched_stream_url = None self._begin_track(item) # Determine if we need to recreate mpv @@ -805,7 +808,8 @@ def advance_to_prefetched(self) -> bool: return False item, _url, sid = self._prefetched self._prefetched = None - self._is_transcoding = ".m3u8" in _url # transcode = HLS playlist URL + self._stream_url = _url + self._prefetched_stream_url = None if STATS_ENABLED: self._flush_stats() if self.muted and self._audio_started: @@ -1575,3 +1579,19 @@ def _on_error(self) -> None: logger.error("Max retries reached — skipping.") self._force_transcode = False self._request_next_throttled(False) + + # ── input handling ────────────────────────────────────────────────────────────────── + + def mouseDoubleClickEvent(self, event: Any) -> None: + """Double-click a cell to toggle full-screen solo in its window. + + Ctrl+double-click requests a remote solo on other synced displays. + """ + if event.button() == Qt.MouseButton.LeftButton: + if event.modifiers() == Qt.KeyboardModifier.ControlModifier: + self.request_remote_solo.emit(self) + else: + self.request_solo.emit(self) + event.accept() + return + super().mouseDoubleClickEvent(event) diff --git a/hyperwall/config.py b/hyperwall/config.py index 0ca5976..61f8ec8 100644 --- a/hyperwall/config.py +++ b/hyperwall/config.py @@ -8,10 +8,11 @@ from __future__ import annotations import configparser +import json import os from dataclasses import dataclass, field -from .constants import CONFIG_FILE +from .constants import CONFIG_FILE, normalize_display_layout def effective_server_url(configured: str, override: str | None = None) -> str: @@ -40,8 +41,19 @@ class HyperwallConfig: last_libraries: str = "" last_grid_rows: int = 2 last_grid_cols: int = 2 + last_preview_rows: int = 3 + last_preview_cols: int = 4 + last_display_roles: str = "" + last_display_layouts: str = "" cleanup_on_startup: bool = False + # ── Network sync ── + sync_enabled: bool = False + sync_server: bool = False + sync_host: str = "0.0.0.0" + sync_port: int = 9876 + sync_display_name: str = "" + # ── Scenes ── # Named wall presets persisted in a [Scenes] section as name=JSON. Stored # as a tuple of (name, json_str) pairs to keep the dataclass hashable/frozen. @@ -79,9 +91,32 @@ def load(cls, path: str | None = None) -> HyperwallConfig: last_libraries=cfg.get("Settings", "last_libraries", fallback=""), last_grid_rows=cfg.getint("Settings", "last_grid_rows", fallback=2), last_grid_cols=cfg.getint("Settings", "last_grid_cols", fallback=2), + last_preview_rows=cfg.getint( + "Settings", "last_preview_rows", fallback=3 + ), + last_preview_cols=cfg.getint( + "Settings", "last_preview_cols", fallback=4 + ), + last_display_roles=cfg.get( + "Settings", "last_display_roles", fallback="" + ), + last_display_layouts=cfg.get( + "Settings", "last_display_layouts", fallback="" + ), cleanup_on_startup=cfg.getboolean( "Settings", "cleanup_on_startup", fallback=False ), + sync_enabled=cfg.getboolean( + "Settings", "sync_enabled", fallback=False + ), + sync_server=cfg.getboolean( + "Settings", "sync_server", fallback=False + ), + sync_host=cfg.get("Settings", "sync_host", fallback="0.0.0.0"), + sync_port=cfg.getint("Settings", "sync_port", fallback=9876), + sync_display_name=cfg.get( + "Settings", "sync_display_name", fallback="" + ), scenes=scenes, ) @@ -101,7 +136,16 @@ def _create_template(cls, path: str) -> None: "last_libraries": "", "last_grid_rows": "2", "last_grid_cols": "2", + "last_preview_rows": "3", + "last_preview_cols": "4", + "last_display_roles": "", + "last_display_layouts": "", "cleanup_on_startup": "false", + "sync_enabled": "false", + "sync_server": "false", + "sync_host": "0.0.0.0", + "sync_port": "9876", + "sync_display_name": "", } os.makedirs(os.path.dirname(path) or ".", exist_ok=True) with open(path, "w") as f: @@ -124,13 +168,47 @@ def save(self, path: str | None = None) -> None: "last_libraries": self.last_libraries, "last_grid_rows": str(self.last_grid_rows), "last_grid_cols": str(self.last_grid_cols), + "last_preview_rows": str(self.last_preview_rows), + "last_preview_cols": str(self.last_preview_cols), + "last_display_roles": self.last_display_roles, + "last_display_layouts": self.last_display_layouts, "cleanup_on_startup": str(self.cleanup_on_startup), + "sync_enabled": str(self.sync_enabled), + "sync_server": str(self.sync_server), + "sync_host": self.sync_host, + "sync_port": str(self.sync_port), + "sync_display_name": self.sync_display_name, } if self.scenes: cfg["Scenes"] = {name: val for name, val in self.scenes} with open(path, "w") as f: cfg.write(f) + def display_roles(self) -> dict[str, str]: + """Parse the JSON last_display_roles map; return {} if malformed.""" + if not self.last_display_roles: + return {} + try: + return json.loads(self.last_display_roles) + except json.JSONDecodeError: + return {} + + def display_layouts(self) -> dict[str, dict[str, object]]: + """Parse and normalize the JSON per-display layout map.""" + if not self.last_display_layouts: + return {} + try: + raw = json.loads(self.last_display_layouts) + except (json.JSONDecodeError, TypeError): + return {} + if not isinstance(raw, dict): + return {} + return { + str(name): normalize_display_layout(layout) + for name, layout in raw.items() + if isinstance(layout, dict) + } + class ConfigMissingError(Exception): """Raised when config.ini does not exist and a template was created.""" diff --git a/hyperwall/constants.py b/hyperwall/constants.py index 722178a..7963c3e 100644 --- a/hyperwall/constants.py +++ b/hyperwall/constants.py @@ -167,6 +167,9 @@ def effective_bitrate_budget_mbps(n_cells: int) -> int: linear_downscaling="yes", scale="ewa_lanczossharp", deband="yes", + # Cover each cell edge-to-edge. Keep the source aspect ratio, but crop + # overflow rather than leaving black bars in portrait/narrow grids. + panscan=1.0, video_sync="audio", video_sync_max_video_change=5, interpolation="no", @@ -205,6 +208,75 @@ def effective_bitrate_budget_mbps(n_cells: int) -> int: IS_MACOS = sys.platform == "darwin" +# ── Display roles ──────────────────────────────────────────────────────────── +class DisplayRole: + """Role assigned to each selected monitor at launch. + + WALL — the public video wall (e.g. 2×2 on an external display). + PREVIEW — a larger operator grid (e.g. 3×4) for browsing; any cell can + be double-clicked to go full-screen on that laptop while the + wall grid keeps playing. + """ + + WALL = "wall" + PREVIEW = "preview" + + _ALL = (WALL, PREVIEW) + + @classmethod + def is_valid(cls, value: str | None) -> bool: + return value in cls._ALL + + +class DisplayRotation: + """Per-display orientation preference captured by the setup wizard. + + ``AUTO`` follows the physical orientation reported by the operating + system. The explicit degree values describe the monitor's intended + clockwise rotation and are persisted as strings so the config remains + stable across JSON/config-parser round trips. + """ + + AUTO = "auto" + DEG_0 = "0" + DEG_90 = "90" + DEG_180 = "180" + DEG_270 = "270" + + _ALL = (AUTO, DEG_0, DEG_90, DEG_180, DEG_270) + + @classmethod + def is_valid(cls, value: str | None) -> bool: + return value in cls._ALL + + +def normalize_display_layout(raw: object | None) -> dict[str, object]: + """Return a safe, typed per-display rotation + grid configuration. + + Config files are user-editable, so malformed entries must not prevent the + wall from starting. Grid dimensions are constrained to the same 1..6 + range exposed by the wizard; an invalid rotation falls back to AUTO. + """ + data = raw if isinstance(raw, dict) else {} + + rotation = str(data.get("rotation", DisplayRotation.AUTO)).strip().lower() + if not DisplayRotation.is_valid(rotation): + rotation = DisplayRotation.AUTO + + def _dimension(key: str) -> int: + try: + value = int(data.get(key, 2)) + except (TypeError, ValueError): + value = 2 + return max(1, min(6, value)) + + return { + "rotation": rotation, + "rows": _dimension("rows"), + "cols": _dimension("cols"), + } + + def mpv_opts_for_platform(platform: str | None = None) -> dict[str, object]: """Return the base MPV options adjusted for the given platform. diff --git a/hyperwall/playlist.py b/hyperwall/playlist.py index 5c9c26f..4d3880d 100644 --- a/hyperwall/playlist.py +++ b/hyperwall/playlist.py @@ -78,3 +78,10 @@ def next(self, group: str = DEFAULT_GROUP) -> Item | None: self._refill(group) q = self._queues[group] return q.popleft() + + def push_front(self, group: str, item: Item) -> None: + """Return a reserved item to the front of a group's live queue.""" + if item not in self._pools.get(group, []): + return + q = self._queues.setdefault(group, deque()) + q.appendleft(item) diff --git a/hyperwall/reliability.py b/hyperwall/reliability.py index 74b25b0..b835a4b 100644 --- a/hyperwall/reliability.py +++ b/hyperwall/reliability.py @@ -136,6 +136,26 @@ def gate_auto_transcode( return active_transcodes < max_concurrent +def is_transcode_stream(url: str | None) -> bool: + """Return whether ``url`` is an Emby server-transcode HLS master.""" + return bool(url) and "/master.m3u8" in url.lower() + + +def active_transcode_count( + streams: Iterable[tuple[str | None, bool]], +) -> int: + """Count currently playing HLS transcodes, excluding queued prefetches.""" + return sum( + 1 for url, is_prefetch in streams + if not is_prefetch and is_transcode_stream(url) + ) + + +def allow_transcode_prefetch(active_transcodes: int, max_concurrent: int) -> bool: + """Whether warming another server-transcode playlist is safe.""" + return max_concurrent <= 0 or active_transcodes < max_concurrent + + def apply_jitter(delay_s: float, rand: float) -> float: """Spread a retry delay over [0.75x, 1.25x] to desynchronize cells. diff --git a/hyperwall/sync.py b/hyperwall/sync.py new file mode 100644 index 0000000..78f99b9 --- /dev/null +++ b/hyperwall/sync.py @@ -0,0 +1,544 @@ +""" +Hyperwall — network sync layer. + +Allows multiple Hyperwall instances (e.g. two laptops + a wall driver) to +share playlist state and remote-control solo fullscreen. + +Protocol: newline-delimited JSON over TCP. +One instance runs as the sync server; the others connect as clients. +The server is authoritative for the shared playlist state. + +Message types: + hello client -> server (identify self on connect) + full_state server -> client (snapshot of playlist + cell state) + cell_update bidirectional (a cell changed to a new item) + solo bidirectional (solo a cell on a target display) + exit_solo bidirectional (exit solo on a display) + filter bidirectional (filter mode changed) + ping / pong bidirectional (keepalive) +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import socket +import threading +import time +import uuid +from typing import Any + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +logger = logging.getLogger("HyperWall") + +DEFAULT_SYNC_PORT = 9876 + + +class SyncMsg: + HELLO = "hello" + FULL_STATE = "full_state" + CELL_UPDATE = "cell_update" + SOLO = "solo" + EXIT_SOLO = "exit_solo" + FILTER = "filter" + REMOTE_SOLO = "remote_solo" + PING = "ping" + PONG = "pong" + + +class SyncPeer: + """Wraps a asyncio StreamReader/Writer pair with JSON line framing.""" + + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + self.reader = reader + self.writer = writer + self.display_name: str = "" + self.peer_id: str = uuid.uuid4().hex[:8] + self.displays: list[str] = [] + self._closed = False + + async def send(self, msg: dict[str, Any]) -> None: + if self._closed: + return + try: + line = json.dumps(msg, separators=(",", ":"), default=str).encode("utf-8") + self.writer.write(line + b"\n") + await self.writer.drain() + except Exception as e: + logger.debug("Sync send to %s failed: %s", self.display_name or self.peer_id, e) + self._closed = True + + async def recv(self) -> dict[str, Any] | None: + while True: + try: + line = await self.reader.readline() + except Exception as e: + logger.debug("Sync recv from %s failed: %s", self.display_name or self.peer_id, e) + return None + if not line: + return None + line = line.strip() + if not line: + continue + try: + return json.loads(line.decode("utf-8")) + except json.JSONDecodeError: + logger.warning("Sync malformed JSON from %s", self.display_name or self.peer_id) + continue + + def close(self) -> None: + if not self._closed: + self._closed = True + try: + self.writer.close() + except Exception: + pass + + +class SyncServer: + """TCP sync server. Accepts clients and broadcasts state changes.""" + + def __init__( + self, + controller: Any, + host: str = "0.0.0.0", + port: int = DEFAULT_SYNC_PORT, + ): + self.controller = controller + self.host = host + self.port = port + self.peers: list[SyncPeer] = [] + self._server: asyncio.Server | None = None + self._task: asyncio.Task | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._lock = asyncio.Lock() + + def start(self) -> None: + def _run_loop(): + loop = asyncio.new_event_loop() + self._loop = loop + self._task = loop.create_task(self._run()) + loop.run_forever() + + threading.Thread(target=_run_loop, daemon=True, name="hyperwall-sync-srv").start() + logger.info("Sync server starting on %s:%d", self.host, self.port) + + async def _run(self) -> None: + self._server = await asyncio.start_server( + self._on_client, self.host, self.port + ) + async with self._server: + await self._server.serve_forever() + + async def _on_client( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + peer = SyncPeer(reader, writer) + addr = writer.get_extra_info("peername") + logger.info("Sync client connected: %s", addr) + async with self._lock: + self.peers.append(peer) + try: + while True: + msg = await peer.recv() + if msg is None: + break + await self._handle(peer, msg) + finally: + async with self._lock: + if peer in self.peers: + self.peers.remove(peer) + peer.close() + logger.info("Sync client disconnected: %s", peer.display_name or addr) + + async def _handle(self, peer: SyncPeer, msg: dict[str, Any]) -> None: + mtype = msg.get("type") + if mtype == SyncMsg.HELLO: + peer.display_name = msg.get("display_name", "unknown") + peer.displays = msg.get("displays", []) or [] + logger.info("Sync hello from %s", peer.display_name) + full = self._build_full_state() + await peer.send(full) + return + + if mtype == SyncMsg.PING: + await peer.send({"type": SyncMsg.PONG}) + return + + # State-changing messages are applied locally and rebroadcast. + if mtype in (SyncMsg.CELL_UPDATE, SyncMsg.SOLO, SyncMsg.EXIT_SOLO, SyncMsg.FILTER): + self._apply_local(msg) + await self._broadcast(msg, exclude=peer) + return + + if mtype == SyncMsg.REMOTE_SOLO: + await self._route_remote_solo(peer, msg) + return + + logger.debug("Sync unknown message type: %s", mtype) + + async def _route_remote_solo( + self, sender: SyncPeer, msg: dict[str, Any] + ) -> None: + """Forward a remote-solo request to every other peer's first display.""" + item_id = msg.get("item_id") + if not item_id: + return + async with self._lock: + peers = list(self.peers) + for peer in peers: + if peer is sender or not peer.displays: + continue + target_display = peer.displays[0] + await peer.send({ + "type": SyncMsg.SOLO, + "display_id": target_display, + "item_id": item_id, + }) + + def _apply_local(self, msg: dict[str, Any]) -> None: + """Apply a remote message on the GUI thread via the controller.""" + if self.controller is None: + return + try: + self.controller.run_on_main(lambda: self.controller.sync_apply(msg)) + except Exception as e: + logger.warning("Sync local apply failed: %s", e) + + def _build_full_state(self) -> dict[str, Any]: + """Snapshot current playlist + cell state for new clients.""" + if self.controller is None: + return {"type": SyncMsg.FULL_STATE} + + # Headless relay stores state directly; GUI controller stores it on cells. + if hasattr(self.controller, "_cell_states"): + cells = dict(self.controller._cell_states) + solo = dict(getattr(self.controller, "_solo_state", {})) + else: + cells = {} + for cell in getattr(self.controller, "cells", []): + cid = getattr(cell, "cell_id", None) + item = getattr(cell, "current_item", None) or {} + if cid: + cells[cid] = item.get("Id") + solo = {} + if getattr(self.controller, "_solo_cell", None) is not None: + sc = self.controller._solo_cell + sw = self.controller._solo_window + sid = getattr(sc, "cell_id", None) + did = None + if sw is not None: + did = self.controller._window_meta.get(id(sw), {}).get("display_id") + item_id = (sc.current_item or {}).get("Id") + solo = {"display_id": did, "cell_id": sid, "item_id": item_id} + return { + "type": SyncMsg.FULL_STATE, + "cells": cells, + "solo": solo, + "filter": getattr(self.controller, "filter_mode", "all"), + } + + async def _broadcast( + self, msg: dict[str, Any], exclude: SyncPeer | None = None + ) -> None: + async with self._lock: + peers = list(self.peers) + for peer in peers: + if peer is exclude: + continue + await peer.send(msg) + + def broadcast(self, msg: dict[str, Any]) -> None: + """Fire-and-forget broadcast from the GUI thread.""" + if self._loop is None: + return + try: + asyncio.run_coroutine_threadsafe(self._broadcast(msg), self._loop) + except Exception as e: + logger.debug("Sync broadcast scheduling failed: %s", e) + + def stop(self) -> None: + if self._server is not None: + self._server.close() + for peer in list(self.peers): + peer.close() + self.peers.clear() + if self._loop is not None: + try: + self._loop.call_soon_threadsafe(self._loop.stop) + except Exception as e: + logger.debug("Sync server loop stop failed: %s", e) + + +class SyncClient: + """TCP sync client. Connects to a sync server and forwards local events.""" + + def __init__( + self, + controller: Any, + host: str, + port: int = DEFAULT_SYNC_PORT, + display_name: str = "", + ): + self.controller = controller + self.host = host + self.port = port + self.display_name = display_name or socket.gethostname() + self.peer: SyncPeer | None = None + self._task: asyncio.Task | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._closed = False + + def start(self) -> None: + def _run_loop(): + loop = asyncio.new_event_loop() + self._loop = loop + self._task = loop.create_task(self._run()) + loop.run_forever() + + threading.Thread(target=_run_loop, daemon=True, name="hyperwall-sync-cli").start() + logger.info("Sync client starting; server %s:%d", self.host, self.port) + + async def _run(self) -> None: + while not self._closed: + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(self.host, self.port), + timeout=5.0, + ) + except Exception as e: + logger.warning("Sync connect to %s:%d failed: %s", self.host, self.port, e) + await asyncio.sleep(5.0) + continue + + self.peer = SyncPeer(reader, writer) + logger.info("Sync connected to server") + await self._send_hello() + try: + while not self._closed: + msg = await self.peer.recv() + if msg is None: + break + await self._handle(msg) + except Exception as e: + logger.warning("Sync client loop error: %s", e) + finally: + if self.peer is not None: + self.peer.close() + self.peer = None + logger.info("Sync disconnected; retrying in 5s") + await asyncio.sleep(5.0) + + async def _send_hello(self) -> None: + cells = [] + for cell in getattr(self.controller, "cells", []): + cid = getattr(cell, "cell_id", None) + if cid: + cells.append(cid) + displays = [] + for win in getattr(self.controller, "windows", []): + did = self.controller._window_meta.get(id(win), {}).get("display_id") + if did: + displays.append(did) + await self._send({ + "type": SyncMsg.HELLO, + "display_name": self.display_name, + "cells": cells, + "displays": displays, + }) + + async def _handle(self, msg: dict[str, Any]) -> None: + mtype = msg.get("type") + if mtype == SyncMsg.PING: + await self._send({"type": SyncMsg.PONG}) + return + if mtype == SyncMsg.PONG: + return + self._apply_local(msg) + + def _apply_local(self, msg: dict[str, Any]) -> None: + if self.controller is None: + return + try: + self.controller.run_on_main(lambda: self.controller.sync_apply(msg)) + except Exception as e: + logger.warning("Sync local apply failed: %s", e) + + async def _send(self, msg: dict[str, Any]) -> None: + if self.peer is not None: + await self.peer.send(msg) + + def send(self, msg: dict[str, Any]) -> None: + """Fire-and-forget send from the GUI thread.""" + if self._closed or self.peer is None or self._loop is None: + return + try: + asyncio.run_coroutine_threadsafe(self._send(msg), self._loop) + except Exception as e: + logger.debug("Sync send scheduling failed: %s", e) + + def broadcast(self, msg: dict[str, Any]) -> None: + """Clients have only one peer (the server), so broadcast == send.""" + self.send(msg) + + def stop(self) -> None: + self._closed = True + if self.peer is not None: + self.peer.close() + if self._task is not None: + self._task.cancel() + if self._loop is not None: + try: + self._loop.call_soon_threadsafe(self._loop.stop) + except Exception as e: + logger.debug("Sync client loop stop failed: %s", e) + + +# ── headless relay controller ────────────────────────────────────────────────────────────────── + +class RelayController: + """Minimal controller for running the sync server headlessly on a relay host. + + The relay does not play video; it just remembers the last authoritative + state and forwards messages between Hyperwall peers. + """ + + def __init__(self): + self.cells: list[Any] = [] + self.windows: list[Any] = [] + self._window_meta: dict[int, dict] = {} + self.filter_mode = "all" + self._solo_cell = None + self._solo_window = None + self._cell_states: dict[str, str] = {} + self._solo_state: dict[str, Any] = {} + + def run_on_main(self, fn: Any) -> None: + fn() + + def sync_apply(self, msg: dict[str, Any]) -> None: + mtype = msg.get("type") + if mtype == SyncMsg.CELL_UPDATE: + cid = msg.get("cell_id") + iid = msg.get("item_id") + if cid and iid: + self._cell_states[cid] = iid + elif mtype == SyncMsg.SOLO: + self._solo_state = { + "display_id": msg.get("display_id"), + "cell_id": msg.get("cell_id"), + "item_id": msg.get("item_id"), + } + elif mtype == SyncMsg.EXIT_SOLO: + self._solo_state = {} + elif mtype == SyncMsg.FILTER: + mode = msg.get("mode") + if mode in ("all", "favorites"): + self.filter_mode = mode + + +def run_sync_relay(host: str = "0.0.0.0", port: int = DEFAULT_SYNC_PORT) -> None: + """Run a headless sync relay. Blocks until interrupted.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + controller = RelayController() + server = SyncServer(controller, host=host, port=port) + server.start() + logger.info("Hyperwall sync relay listening on %s:%d", host, port) + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + logger.info("Relay shutting down.") + finally: + server.stop() diff --git a/hyperwall/wall.py b/hyperwall/wall.py index d71ba72..c6b5fe1 100644 --- a/hyperwall/wall.py +++ b/hyperwall/wall.py @@ -39,7 +39,7 @@ from .constants import ( MAX_DIRECT_FPS, MAX_CONCURRENT_TRANSCODES, - effective_bitrate_budget_mbps, + DisplayRole, OUTAGE_MIN_CELLS, OUTAGE_WINDOW_S, STREAM_START_STAGGER_MS, @@ -48,30 +48,67 @@ STATS_INFO_PROPS, apply_cache_budget, apply_env_overrides, + effective_bitrate_budget_mbps, MPV_OPTS, + normalize_display_layout, SCRIPT_DIR, ) from .emby import EmbyClient, ContentLoader from .urls import needs_transcode as _needs_transcode_pure -from .reliability import is_systemic_outage, gate_auto_transcode +from .reliability import ( + active_transcode_count, + allow_transcode_prefetch, + gate_auto_transcode, + is_systemic_outage, +) from .urls import build_stream_url, tag_names from .playlist import PlaylistManager, DEFAULT_GROUP logger = logging.getLogger("HyperWall") +class WallWindow(QMainWindow): + """Fullscreen window for one display; notifies controller on resize so a + soloed cell continues to fill the central widget.""" + + def __init__(self, controller: "WallController"): + super().__init__() + self._controller = controller + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + if self._controller._solo_window is self: + cell = self._controller._solo_cell + if cell is not None: + cell.setGeometry(self.centralWidget().rect()) + + class EmergencyKeyFilter(QObject): - """App-level escape handler — works even when mpv children steal focus.""" + """App-level escape handler — works even when mpv children steal focus. + + If a preview/wall cell is currently in solo full-screen mode, Escape + exits solo first; a second Escape shuts the wall down. + """ - def __init__(self, shutdown_callback: callable): + def __init__( + self, + shutdown_callback: callable, + solo_active_callback: callable | None = None, + exit_solo_callback: callable | None = None, + ): super().__init__() self._shutdown_callback = shutdown_callback + self._solo_active = solo_active_callback or (lambda: False) + self._exit_solo = exit_solo_callback or (lambda: None) def eventFilter(self, obj: QObject, event: QEvent) -> bool: if ( event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Escape ): + if self._solo_active(): + self._exit_solo() + return True self._shutdown_callback() return True return False @@ -136,15 +173,29 @@ def __init__( grid_rows: int, grid_cols: int, client: EmbyClient, + display_roles: dict[str, str] | None = None, + display_layouts: dict[str, dict[str, Any]] | None = None, + preview_rows: int = 3, + preview_cols: int = 4, ): self.client = client self.screens = screens self.libraries = libraries self.grid_rows = grid_rows self.grid_cols = grid_cols + self.preview_rows = preview_rows + self.preview_cols = preview_cols + self.display_roles = display_roles or {} + self.display_layouts = display_layouts or {} self.cells: list[VideoCell] = [] self.windows: list[QMainWindow] = [] + # Per-window metadata: role, grid cells, solo state, layout, etc. + self._window_meta: dict[int, dict[str, Any]] = {} + self._solo_cell: VideoCell | None = None + self._solo_window: QMainWindow | None = None + self._sync: Any | None = None + self._sync_enabled = False self._shortcuts: list[QShortcut] = [] self.all_items: list[dict[str, Any]] = [] self.filtered: list[dict[str, Any]] = [] @@ -177,7 +228,11 @@ def __init__( self._last_outage_log_ts = 0.0 # Emergency escape - self._escape_filter = EmergencyKeyFilter(self._shutdown) + self._escape_filter = EmergencyKeyFilter( + self._shutdown, + solo_active_callback=lambda: self._solo_cell is not None, + exit_solo_callback=self._exit_solo, + ) QApplication.instance().installEventFilter(self._escape_filter) self._build_displays() @@ -207,13 +262,33 @@ def __init__( self._start_async_load() - # ── display construction ────────────────────────────────────────────── + # ── display construction ─────────────────────────────────────────────────────── def _build_displays(self) -> None: - rows, cols = self.grid_rows, self.grid_cols for screen in self.screens: - win = QMainWindow() - win.setWindowTitle(f"HyperWall — {screen.name()}") + role = self.display_roles.get( + screen.name(), DisplayRole.WALL + ) + if role not in DisplayRole._ALL: + role = DisplayRole.WALL + is_preview = role == DisplayRole.PREVIEW + default_rows = self.preview_rows if is_preview else self.grid_rows + default_cols = self.preview_cols if is_preview else self.grid_cols + raw_layout = self.display_layouts.get(screen.name(), {}) + if not isinstance(raw_layout, dict): + raw_layout = {} + layout = normalize_display_layout({ + "rotation": raw_layout.get("rotation", "auto"), + "rows": raw_layout.get("rows", default_rows), + "cols": raw_layout.get("cols", default_cols), + }) + rotation = str(layout["rotation"]) + rows = int(layout["rows"]) + cols = int(layout["cols"]) + + win = WallWindow(self) + role_name = "Preview" if is_preview else "Wall" + win.setWindowTitle(f"HyperWall — {role_name} — {screen.name()}") win.setStyleSheet("background: black;") cw = QWidget() @@ -222,13 +297,21 @@ def _build_displays(self) -> None: grid.setContentsMargins(0, 0, 0, 0) grid.setSpacing(0) + window_cells: list[VideoCell] = [] + cell_positions: dict[int, tuple[int, int]] = {} + display_id = uuid.uuid4().hex for r in range(rows): for c in range(cols): cell = VideoCell(self) + cell.cell_id = uuid.uuid4().hex cell.request_next.connect(self.next_video) cell.request_prev.connect(self.prev_video) + cell.request_solo.connect(self._toggle_solo) + cell.request_remote_solo.connect(self._remote_solo) grid.addWidget(cell, r, c) self.cells.append(cell) + window_cells.append(cell) + cell_positions[id(cell)] = (r, c) # Keyboard shortcuts per window for key, fn in ( @@ -245,9 +328,272 @@ def _build_displays(self) -> None: win.setGeometry(screen.geometry()) self.windows.append(win) - logger.info("Display built: %s", screen.name()) + self._window_meta[id(win)] = { + "screen": screen, + "role": role, + "rotation": rotation, + "rows": rows, + "cols": cols, + "display_id": display_id, + "grid": grid, + "cells": window_cells, + "positions": cell_positions, + "solo": False, + } + logger.info( + "Display built: %s (%s, rotation=%s, %dx%d)", + screen.name(), role_name, rotation, rows, cols, + ) - # ── content loading ─────────────────────────────────────────────────── + def _window_for_cell(self, cell: VideoCell) -> QMainWindow | None: + """Return the QMainWindow that contains the given cell.""" + parent = cell.parentWidget() + while parent is not None: + if isinstance(parent, QMainWindow): + return parent + parent = parent.parentWidget() + return None + + def _toggle_solo(self, cell: VideoCell) -> None: + """Double-click handler: enter or exit full-screen solo for a cell. + + Solo is only enabled on PREVIEW displays so the public wall grid + stays intact; double-clicking a wall cell is ignored. + """ + win = self._window_for_cell(cell) + if win is None: + return + meta = self._window_meta.get(id(win)) + if meta is None or meta["role"] != DisplayRole.PREVIEW: + return + if self._solo_cell is cell: + self._exit_solo() + return + if self._solo_cell is not None: + self._exit_solo() + self._enter_solo(cell) + + def _remote_solo(self, cell: VideoCell) -> None: + """Ctrl+double-click handler: ask the sync server to solo this item + on all other peers' displays.""" + if not self._sync_enabled or self._sync is None: + return + item_id = (cell.current_item or {}).get("Id") + if not item_id: + return + self.sync_broadcast({"type": "remote_solo", "item_id": item_id}) + logger.info("Remote solo requested for item %s", item_id) + + def _enter_solo(self, cell: VideoCell) -> None: + win = self._window_for_cell(cell) + if win is None: + return + meta = self._window_meta.get(id(win)) + if meta is None or meta["solo"]: + return + + grid: QGridLayout = meta["grid"] + # Remove the cell from the grid (keeps parent) and hide the rest. + grid.removeWidget(cell) + for other in meta["cells"]: + if other is not cell: + other.hide() + cell.setParent(win.centralWidget()) + cell.setGeometry(win.centralWidget().rect()) + cell.show() + cell.raise_() + + meta["solo"] = True + self._solo_cell = cell + self._solo_window = win + did = meta.get("display_id") + cid = getattr(cell, "cell_id", None) + if did and cid: + self.sync_broadcast_solo(did, cid) + logger.info("Solo: cell %d full-screen on %s", self.cells.index(cell), win.windowTitle()) + + def _exit_solo(self) -> None: + """Restore the soloed cell into its grid position.""" + cell = self._solo_cell + win = self._solo_window + if cell is None or win is None: + return + meta = self._window_meta.get(id(win)) + if meta is None: + return + + grid: QGridLayout = meta["grid"] + pos = meta["positions"].get(id(cell)) + if pos is None: + return + + cell.setParent(win.centralWidget()) + grid.addWidget(cell, pos[0], pos[1]) + for other in meta["cells"]: + other.show() + + did = meta.get("display_id") + meta["solo"] = False + self._solo_cell = None + self._solo_window = None + if did: + self.sync_broadcast_exit_solo(did) + logger.info("Solo: exited") + + # ── network sync ────────────────────────────────────────────────────────────────── + + def set_sync_adapter(self, sync: Any) -> None: + """Attach a SyncServer or SyncClient after construction.""" + self._sync = sync + self._sync_enabled = sync is not None + + def sync_broadcast(self, msg: dict[str, Any]) -> None: + """Send a state change to peers if sync is active.""" + if not self._sync_enabled or self._sync is None: + return + try: + self._sync.broadcast(msg) + except Exception as e: + logger.debug("Sync broadcast failed: %s", e) + + def sync_broadcast_cell_update(self, cell: VideoCell) -> None: + if not self._sync_enabled: + return + cid = getattr(cell, "cell_id", None) + iid = (cell.current_item or {}).get("Id") + if cid and iid: + self.sync_broadcast({ + "type": "cell_update", + "cell_id": cid, + "item_id": iid, + }) + + def sync_broadcast_solo(self, display_id: str, cell_id: str) -> None: + if not self._sync_enabled: + return + self.sync_broadcast({ + "type": "solo", + "display_id": display_id, + "cell_id": cell_id, + }) + + def sync_broadcast_exit_solo(self, display_id: str) -> None: + if not self._sync_enabled: + return + self.sync_broadcast({ + "type": "exit_solo", + "display_id": display_id, + }) + + def sync_broadcast_filter(self) -> None: + if not self._sync_enabled: + return + self.sync_broadcast({ + "type": "filter", + "mode": self.filter_mode, + }) + + def sync_apply(self, msg: dict[str, Any]) -> None: + """Apply a remote sync message on the GUI thread.""" + mtype = msg.get("type") + if mtype == "cell_update": + self._sync_apply_cell_update(msg) + elif mtype == "solo": + self._sync_apply_solo(msg) + elif mtype == "exit_solo": + self._sync_apply_exit_solo(msg) + elif mtype == "filter": + self._sync_apply_filter(msg) + elif mtype == "full_state": + self._sync_apply_full_state(msg) + + def _sync_find_cell(self, cell_id: str) -> VideoCell | None: + for cell in self.cells: + if getattr(cell, "cell_id", None) == cell_id: + return cell + return None + + def _sync_find_display(self, display_id: str) -> QMainWindow | None: + for win in self.windows: + meta = self._window_meta.get(id(win)) + if meta and meta.get("display_id") == display_id: + return win + return None + + def _sync_apply_cell_update(self, msg: dict[str, Any]) -> None: + cid = msg.get("cell_id") + iid = msg.get("item_id") + if not cid or not iid: + return + cell = self._sync_find_cell(cid) + if cell is None: + return + # Avoid re-applying our own broadcasts. + current_iid = (cell.current_item or {}).get("Id") + if current_iid == iid: + return + item = next((i for i in self.all_items if i.get("Id") == iid), None) + if item is None: + # Item not in our local library yet; load may still be in progress. + logger.debug("Sync cell_update for unknown item %s", iid) + return + self._hand_off(cell, item) + + def _sync_apply_solo(self, msg: dict[str, Any]) -> None: + did = msg.get("display_id") + cid = msg.get("cell_id") + iid = msg.get("item_id") + if not did: + return + win = self._sync_find_display(did) + if win is None: + return + cell = None + if cid: + cell = self._sync_find_cell(cid) + # If the message carried an item_id instead of (or in addition to) a + # cell_id, load that item into the target display's first cell. + if cell is None and iid: + meta = self._window_meta.get(id(win)) + if meta and meta["cells"]: + target = meta["cells"][0] + item = next((i for i in self.all_items if i.get("Id") == iid), None) + if item is not None: + self._hand_off(target, item) + cell = target + if cell is None: + return + if self._solo_cell is not None: + self._exit_solo() + self._enter_solo(cell) + + def _sync_apply_exit_solo(self, msg: dict[str, Any]) -> None: + did = msg.get("display_id") + if not did: + return + if self._solo_window is None: + return + meta = self._window_meta.get(id(self._solo_window)) + if meta and meta.get("display_id") == did: + self._exit_solo() + + def _sync_apply_filter(self, msg: dict[str, Any]) -> None: + mode = msg.get("mode") + if mode in ("all", "favorites") and mode != self.filter_mode: + self._set_filter(mode) + + def _sync_apply_full_state(self, msg: dict[str, Any]) -> None: + cells = msg.get("cells", {}) + for cid, iid in cells.items(): + self._sync_apply_cell_update({"cell_id": cid, "item_id": iid}) + solo = msg.get("solo", {}) + if solo.get("display_id"): + self._sync_apply_solo(solo) + mode = msg.get("filter") + if mode: + self._sync_apply_filter({"mode": mode}) + + # ── content loading ───────────────────────────────────────────────────────────── def _start_async_load(self) -> None: self.loader = ContentLoader(self.client, self.libraries) @@ -308,9 +654,13 @@ def _build_url( if force_transcode: transcode = True else: - active = sum( - 1 for c in self.cells - if c is not cell and getattr(c, "_is_transcoding", False) + active = active_transcode_count( + ( + getattr(c, "_stream_url", None), + False, + ) + for c in self.cells + if c is not cell ) transcode = gate_auto_transcode( auto_transcode, active, MAX_CONCURRENT_TRANSCODES, @@ -407,8 +757,31 @@ def _queue() -> None: if item is None: return url, sid = self._build_url(item, prefetch=True, cell=cell) + if "/master.m3u8" in url: + active = active_transcode_count( + ( + getattr(other, "_stream_url", None), + False, + ) + for other in self.cells + if other is not cell + ) + if not allow_transcode_prefetch( + active, MAX_CONCURRENT_TRANSCODES, + ): + logger.info( + "Skipping transcoded prefetch while %d/%d " + "transcode slots are active.", + active, MAX_CONCURRENT_TRANSCODES, + ) + self.playlists.push_front( + self._cell_group(cell), item, + ) + return if not cell.prefetch(item, url, sid): logger.debug("Prefetch declined for %s.", item.get("Name", "?")) + else: + cell._prefetched_stream_url = url QTimer.singleShot(0, _queue) @@ -461,6 +834,7 @@ def next_video(self, cell: VideoCell, is_retry: bool = False) -> None: "[PREFETCH→] %s", (cell.current_item or {}).get("Name"), ) self._arm_prefetch(cell) + self.sync_broadcast_cell_update(cell) return item = self.playlists.next(self._cell_group(cell)) if item is None: @@ -468,14 +842,16 @@ def next_video(self, cell: VideoCell, is_retry: bool = False) -> None: if prev: cell.history.append(prev) self._hand_off(cell, item) + self.sync_broadcast_cell_update(cell) @traced("wall.prev_video") def prev_video(self, cell: VideoCell) -> None: if cell.history: item = cell.history.pop() self._hand_off(cell, item) + self.sync_broadcast_cell_update(cell) - # ── global controls ─────────────────────────────────────────────────── + # ── global controls ─────────────────────────────────────────────────────────────────── def _global_toggle_controls(self) -> None: self.controls_visible = not self.controls_visible @@ -543,6 +919,7 @@ def _set_filter(self, mode: str) -> None: i * STREAM_START_STAGGER_MS, lambda cell=c: self.next_video(cell, False), ) + self.sync_broadcast_filter() # ── tag / favorite mutations ────────────────────────────────────────── @@ -771,5 +1148,11 @@ def _cleanup(self) -> None: except Exception as e: logger.debug("removeEventFilter failed: %s", e) + if self._sync is not None: + try: + self._sync.stop() + except Exception as e: + logger.debug("Sync stop failed: %s", e) + self.client.close() logger.info("Cleanup complete.") diff --git a/hyperwall/wizard.py b/hyperwall/wizard.py index 5078a72..52e0deb 100644 --- a/hyperwall/wizard.py +++ b/hyperwall/wizard.py @@ -8,9 +8,11 @@ from typing import Any -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QColor, QPainter from PyQt6.QtWidgets import ( + QAbstractItemView, + QComboBox, QDialog, QGroupBox, QHBoxLayout, @@ -25,7 +27,12 @@ from . import VERSION_SHORT from . import theme -from .constants import _s +from .constants import ( + DisplayRole, + DisplayRotation, + _s, + normalize_display_layout, +) class _GridPreview(QWidget): @@ -63,6 +70,22 @@ def paintEvent(self, _event: Any) -> None: p.end() +class _DisplayRow(QWidget): + """Interactive row used inside the display list. + + The row itself selects the monitor, while child combo boxes retain normal + mouse handling. Making the whole row transparent would also make those + child controls transparent in Qt, so selection is explicit instead. + """ + + clicked = pyqtSignal() + + def mousePressEvent(self, event: Any) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit() + super().mousePressEvent(event) + + class SetupWizard(QDialog): """Pre-launch configuration dialog: select monitors, libraries, grid.""" @@ -74,13 +97,23 @@ def __init__( last_libraries: str = "", last_rows: int = 2, last_cols: int = 2, + last_preview_rows: int = 3, + last_preview_cols: int = 4, + last_display_roles: dict[str, str] | None = None, + last_display_layouts: dict[str, dict[str, object]] | None = None, ): super().__init__() self.setWindowTitle(f"HyperWall {VERSION_SHORT}") - self.resize(_s(760), _s(560)) + self.resize(_s(1_020), _s(620)) self.setStyleSheet(theme.dialog_qss()) self._screen_map: dict[str, Any] = {} + self._screen_items: dict[str, QListWidgetItem] = {} + self._role_boxes: dict[str, QComboBox] = {} + self._rotation_boxes: dict[str, QComboBox] = {} + self._grid_boxes: dict[str, QComboBox] = {} + last_display_roles = last_display_roles or {} + last_display_layouts = last_display_layouts or {} layout = QVBoxLayout(self) layout.setContentsMargins(_s(26), _s(22), _s(26), _s(22)) @@ -107,22 +140,105 @@ def __init__( panels.setSpacing(_s(14)) # ── Displays ── - grp_disp = QGroupBox("DISPLAYS") + grp_disp = QGroupBox("DISPLAYS · ROLE / ROTATION / VIDEO GRID") ld = QVBoxLayout(grp_disp) self.list_disp = QListWidget() - self.list_disp.setSelectionMode(QListWidget.SelectionMode.MultiSelection) + self.list_disp.setSelectionMode( + QAbstractItemView.SelectionMode.MultiSelection + ) prev_screens = last_screens.split(",") if last_screens else [] for idx, s in enumerate(screens, 1): - label = ( + label_text = ( f"Monitor {idx} — {s.name()} " f"[{s.geometry().width()}x{s.geometry().height()}]" ) - item = QListWidgetItem(label) + item = QListWidgetItem() self.list_disp.addItem(item) - self._screen_map[label] = s + self._screen_map[label_text] = s + self._screen_items[label_text] = item + + row = _DisplayRow() + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(_s(6), _s(2), _s(6), _s(2)) + row_layout.setSpacing(_s(8)) + row.clicked.connect(lambda item=item: item.setSelected(True)) + lbl = QLabel(label_text) + lbl.setStyleSheet( + f"color: {theme.TEXT}; font-size: {_s(11)}px; background: transparent;" + ) + lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + row_layout.addWidget(lbl, 1) + + role_box = QComboBox() + role_box.addItem("Wall", DisplayRole.WALL) + role_box.addItem("Preview", DisplayRole.PREVIEW) + role_box.setMinimumWidth(_s(78)) + role = last_display_roles.get(s.name(), DisplayRole.WALL) + role_box.setCurrentIndex( + 0 if role == DisplayRole.WALL else 1 + ) + self._role_boxes[label_text] = role_box + role_box.currentIndexChanged.connect( + lambda _index, item=item: item.setSelected(True) + ) + row_layout.addWidget(role_box) + + rotation_box = QComboBox() + for label, value in ( + ("Auto", DisplayRotation.AUTO), + ("0°", DisplayRotation.DEG_0), + ("90°", DisplayRotation.DEG_90), + ("180°", DisplayRotation.DEG_180), + ("270°", DisplayRotation.DEG_270), + ): + rotation_box.addItem(label, value) + rotation_box.setToolTip( + "Physical monitor rotation; Auto follows the OS display orientation." + ) + rotation_box.setMinimumWidth(_s(72)) + + default_rows = last_preview_rows if role == DisplayRole.PREVIEW else last_rows + default_cols = last_preview_cols if role == DisplayRole.PREVIEW else last_cols + saved_layout = last_display_layouts.get(s.name(), {}) + display_layout = normalize_display_layout({ + "rotation": saved_layout.get("rotation", DisplayRotation.AUTO), + "rows": saved_layout.get("rows", default_rows), + "cols": saved_layout.get("cols", default_cols), + }) + rotation_index = rotation_box.findData(display_layout["rotation"]) + rotation_box.setCurrentIndex(max(0, rotation_index)) + self._rotation_boxes[label_text] = rotation_box + rotation_box.currentIndexChanged.connect( + lambda _index, item=item: item.setSelected(True) + ) + row_layout.addWidget(rotation_box) + + grid_box = QComboBox() + for grid_rows in range(1, 7): + for grid_cols in range(1, 7): + grid_box.addItem( + f"{grid_rows} × {grid_cols}", + (grid_rows, grid_cols), + ) + grid_box.setToolTip("Videos per display: rows × columns.") + grid_box.setMinimumWidth(_s(74)) + grid_index = grid_box.findData( + (display_layout["rows"], display_layout["cols"]) + ) + grid_box.setCurrentIndex(max(0, grid_index)) + self._grid_boxes[label_text] = grid_box + grid_box.currentIndexChanged.connect( + lambda _index, item=item: item.setSelected(True) + ) + row_layout.addWidget(grid_box) + self.list_disp.setItemWidget(item, row) + + # Match selection state to the underlying item if s.name() in prev_screens: item.setSelected(True) + # Size the row to its contents + item.setSizeHint(row.sizeHint()) ld.addWidget(self.list_disp) panels.addWidget(grp_disp) @@ -131,7 +247,9 @@ def __init__( grp_lib = QGroupBox("SOURCES") ll = QVBoxLayout(grp_lib) self.list_lib = QListWidget() - self.list_lib.setSelectionMode(QListWidget.SelectionMode.MultiSelection) + self.list_lib.setSelectionMode( + QAbstractItemView.SelectionMode.MultiSelection + ) prev_libs = last_libraries.split(",") if last_libraries else [] for lib in libraries: @@ -145,9 +263,9 @@ def __init__( layout.addLayout(panels) - # ── Grid + live preview ── - grp_grid = QGroupBox("LAYOUT") - lg = QHBoxLayout(grp_grid) + # ── Wall grid + live preview ── + grp_wall = QGroupBox("FALLBACK WALL GRID (new displays)") + lg = QHBoxLayout(grp_wall) lg.setSpacing(_s(12)) self.rows = QSpinBox() self.rows.setRange(1, 6) @@ -170,33 +288,81 @@ def __init__( ) lg.addWidget(self.lbl_cells) lg.addStretch() + layout.addWidget(grp_wall) + + # ── Preview grid ── + grp_preview = QGroupBox("FALLBACK PREVIEW GRID (new displays)") + pg = QHBoxLayout(grp_preview) + pg.setSpacing(_s(12)) + self.preview_rows = QSpinBox() + self.preview_rows.setRange(1, 6) + self.preview_rows.setValue(last_preview_rows) + self.preview_cols = QSpinBox() + self.preview_cols.setRange(1, 6) + self.preview_cols.setValue(last_preview_cols) + pg.addWidget(QLabel("ROWS")) + pg.addWidget(self.preview_rows) + pg.addSpacing(_s(12)) + pg.addWidget(QLabel("COLS")) + pg.addWidget(self.preview_cols) + pg.addSpacing(_s(16)) + + self.preview_preview = _GridPreview(last_preview_rows, last_preview_cols) + pg.addWidget(self.preview_preview) + self.lbl_preview_cells = QLabel() + self.lbl_preview_cells.setStyleSheet( + f"color: {theme.TEXT_DIM}; font-size: {_s(11)}px; background: transparent;" + ) + pg.addWidget(self.lbl_preview_cells) + pg.addStretch() + layout.addWidget(grp_preview) btn = QPushButton("▶ INITIALIZE SYSTEM") btn.clicked.connect(self.accept) btn.setDefault(True) # Enter starts the wall btn.setAutoDefault(True) - lg.addWidget(btn) - layout.addWidget(grp_grid) + layout.addWidget(btn, alignment=Qt.AlignmentFlag.AlignRight) self.rows.valueChanged.connect(self._sync_preview) self.cols.valueChanged.connect(self._sync_preview) + self.preview_rows.valueChanged.connect(self._sync_preview) + self.preview_cols.valueChanged.connect(self._sync_preview) self._sync_preview() def _sync_preview(self) -> None: r, c = self.rows.value(), self.cols.value() self.preview.set_grid(r, c) self.lbl_cells.setText(f"{r * c} cells / display") + pr, pc = self.preview_rows.value(), self.preview_cols.value() + self.preview_preview.set_grid(pr, pc) + self.lbl_preview_cells.setText(f"{pr * pc} cells / display") def get_settings(self) -> dict[str, Any]: """Return the selected configuration.""" + selected_labels = [ + label + for label, item in self._screen_items.items() + if item.isSelected() + ] return { - "screens": [ - self._screen_map[i.text()] - for i in self.list_disp.selectedItems() - ], + "screens": [self._screen_map[l] for l in selected_labels], "libraries": [ i.text() for i in self.list_lib.selectedItems() ], "grid_rows": self.rows.value(), "grid_cols": self.cols.value(), + "preview_rows": self.preview_rows.value(), + "preview_cols": self.preview_cols.value(), + "display_roles": { + self._screen_map[l].name(): self._role_boxes[l].currentData() + for l in selected_labels + }, + "display_layouts": { + self._screen_map[l].name(): { + "rotation": self._rotation_boxes[l].currentData(), + "rows": self._grid_boxes[l].currentData()[0], + "cols": self._grid_boxes[l].currentData()[1], + } + for l in self._screen_map + }, } diff --git a/scripts/config.client.ini.template b/scripts/config.client.ini.template new file mode 100644 index 0000000..17f325b --- /dev/null +++ b/scripts/config.client.ini.template @@ -0,0 +1,25 @@ +; Copy this file to config.ini and fill in your Emby credentials. +; Use this on every Hyperwall client (including the wall-driving laptop). +; The sync relay runs separately on mb.perseus.observer. + +[Settings] +server_url = https://mb.perseus.observer +username = YOUR_EMBY_USERNAME +password = YOUR_EMBY_PASSWORD + +; --- sync client settings --- +sync_enabled = true +sync_server = false +sync_host = mb.perseus.observer +sync_port = 9876 + +; Change this per machine (e.g. thomas-laptop, mark-laptop) +sync_display_name = YOUR_NAME-laptop + +; --- preview grid on the laptop screen --- +last_preview_rows = 3 +last_preview_cols = 4 + +; Display roles are set in the Setup Wizard on first run: +; External/wall display -> Wall +; Built-in laptop display -> Preview diff --git a/scripts/run-hyperwall-client-macos.sh b/scripts/run-hyperwall-client-macos.sh new file mode 100755 index 0000000..798211c --- /dev/null +++ b/scripts/run-hyperwall-client-macos.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# HyperWall — macOS client launcher for Mark (or any macOS peer) +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_DIR" + +CONFIG="$REPO_DIR/config.ini" + +if [ ! -f "$CONFIG" ]; then + echo "Missing $CONFIG" >&2 + echo "Run the app once to create it, or copy a prepared config.ini into the repo." >&2 + exit 1 +fi + +# Quick sanity check that sync is pointed at the relay +if ! grep -qE '^sync_enabled\s*=\s*true' "$CONFIG"; then + echo "WARNING: sync_enabled is not true in $CONFIG" >&2 +fi +if ! grep -qE '^sync_server\s*=\s*false' "$CONFIG"; then + echo "WARNING: sync_server should be false when connecting to mb.perseus.observer relay" >&2 +fi + +exec ./launch.sh "$@" diff --git a/scripts/run-hyperwall-relay.sh b/scripts/run-hyperwall-relay.sh new file mode 100755 index 0000000..3821f8f --- /dev/null +++ b/scripts/run-hyperwall-relay.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# HyperWall — headless sync relay for mb.perseus.observer +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_DIR" + +# Prefer the repo venv, fall back to system python3 +PY="./.venv/bin/python" +[ -x "$PY" ] || PY="python3" + +HOST="${HYPERWALL_SYNC_HOST:-0.0.0.0}" +PORT="${HYPERWALL_SYNC_PORT:-9876}" + +exec "$PY" hyperwall.py --sync-relay --sync-host "$HOST" --sync-port "$PORT" diff --git a/tests/run_all.py b/tests/run_all.py index 8b07453..5505210 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -23,6 +23,8 @@ "test_reliability", "test_urls", "test_config", + "test_preview_displays", + "test_sync", "test_playlist", "test_scenes", "test_backends", diff --git a/tests/run_repo_guards.py b/tests/run_repo_guards.py index 44898b4..f74b49a 100644 --- a/tests/run_repo_guards.py +++ b/tests/run_repo_guards.py @@ -46,10 +46,10 @@ def test_01_entry_point_imports(): def test_02_package_identity(): """Package has version and banner.""" from hyperwall import __version__, runtime_banner - assert __version__ == "10.14.0" + assert __version__ == "10.15.0" banner = runtime_banner() assert "Hyperwall" in banner - assert "10.14.0" in banner + assert "10.15.0" in banner def test_03_config_loads(): diff --git a/tests/test_config.py b/tests/test_config.py index 2b5f959..4fc61a0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,6 +7,7 @@ from __future__ import annotations import os +import json import sys import tempfile @@ -98,6 +99,39 @@ def test_defaults_applied_for_absent_settings(): assert loaded.scenes == () # no [Scenes] section → empty +def test_display_layouts_round_trip(): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "config.ini") + layouts = { + "External": {"rotation": "90", "rows": 3, "cols": 2}, + "Laptop": {"rotation": "auto", "rows": 3, "cols": 4}, + } + cfg = HyperwallConfig( + server_url="http://h", username="u", password="p", + last_display_layouts=json.dumps(layouts), + ) + cfg.save(path) + loaded = HyperwallConfig.load(path) + assert loaded.display_layouts() == layouts + + +def test_display_layouts_reject_malformed_entries(): + cfg = HyperwallConfig( + server_url="http://h", username="u", password="p", + last_display_layouts=json.dumps({ + "Good": {"rotation": "270", "rows": 6, "cols": 1}, + "BadShape": "not-a-layout", + "BadRotation": {"rotation": "diagonal", "rows": 2, "cols": 2}, + "BadGrid": {"rotation": "0", "rows": 99, "cols": 0}, + }), + ) + assert cfg.display_layouts() == { + "Good": {"rotation": "270", "rows": 6, "cols": 1}, + "BadRotation": {"rotation": "auto", "rows": 2, "cols": 2}, + "BadGrid": {"rotation": "0", "rows": 6, "cols": 1}, + } + + def test_scenes_round_trip(): from hyperwall.scenes import scene_to_str, normalize_scene, scenes_from_mapping with tempfile.TemporaryDirectory() as d: diff --git a/tests/test_macos_playback_performance.py b/tests/test_macos_playback_performance.py index 47f0053..22ecc9c 100644 --- a/tests/test_macos_playback_performance.py +++ b/tests/test_macos_playback_performance.py @@ -36,6 +36,15 @@ def test_prefetch_is_deferred_after_transition(): assert "def _queue" in body +def test_prefetch_hls_is_not_used_for_playback_concurrency_accounting(): + reliability = _source("hyperwall/reliability.py") + wall = _source("hyperwall/wall.py") + assert "active_transcode_count" in reliability + assert "active_transcode_count(" in wall + assert "allow_transcode_prefetch" in wall + assert "_prefetched_stream_url" in _source("hyperwall/cell.py") + + def run_all() -> int: tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] passed = failed = 0 diff --git a/tests/test_platform.py b/tests/test_platform.py index 3e8283b..ee5d93a 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -23,6 +23,9 @@ def test_01_macos_opts_use_render_api(): assert opts["hwdec"] == "videotoolbox", opts["hwdec"] assert "gpu_api" not in opts, "d3d11 gpu_api leaked into macOS opts" assert str(opts["ao"]).startswith("coreaudio"), opts["ao"] + # Fill every cell edge-to-edge. panscan preserves aspect ratio and crops + # overflow instead of introducing black bars in portrait/narrow grids. + assert opts["panscan"] == 1.0 # The render call must never block the single GUI thread on the audio # clock (8 cells x 50ms would serialize into wall-wide jank). assert opts["video_timing_offset"] == 0 @@ -36,6 +39,7 @@ def test_02_windows_opts_unchanged(): assert opts["gpu_api"] == "d3d11" assert opts["hwdec"] == "d3d11va" assert str(opts["ao"]).startswith("wasapi") + assert opts["panscan"] == 1.0 # HQ downscaling is load-bearing on every platform. assert opts["dscale"] == "mitchell" assert opts["correct_downscaling"] == "yes" diff --git a/tests/test_playlist.py b/tests/test_playlist.py index 11caf8b..d86c33e 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -75,6 +75,17 @@ def test_unknown_group_returns_none(): assert pm.next("does-not-exist") is None +def test_push_front_returns_reserved_item_without_dropping_it(): + pm = PlaylistManager(shuffle=_noshuffle) + pm.set_source(_items(2)) + item = pm.next() + assert item is not None + pm.push_front(DEFAULT_GROUP, item) + returned = pm.next() + assert returned is not None + assert returned["Id"] == item["Id"] + + def test_clear_group_preserves_pool(): pm = PlaylistManager(shuffle=_noshuffle) pm.set_source(_items(3), group="a") diff --git a/tests/test_preview_displays.py b/tests/test_preview_displays.py new file mode 100644 index 0000000..c2f6109 --- /dev/null +++ b/tests/test_preview_displays.py @@ -0,0 +1,299 @@ +"""Tests for the preview-display / solo-fullscreen feature. + +Pure-logic tests run everywhere. Qt construction tests follow the project +convention and only run on Windows (where PyQt6 + offscreen are available). +""" +import json +import os +from pathlib import Path +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +try: + from PyQt6.QtWidgets import QApplication + + _app = QApplication.instance() or QApplication([]) + _PYQT = os.name == "nt" +except ImportError: + _PYQT = False + +from hyperwall.config import HyperwallConfig +from hyperwall.constants import DisplayRole + + +# ── constants / config ── + +def test_wizard_uses_qt_item_view_selection_enum(): + wizard_source = ( + Path(__file__).resolve().parents[1] / "hyperwall" / "wizard.py" + ).read_text(encoding="utf-8") + assert "QAbstractItemView.SelectionMode.MultiSelection" in wizard_source + assert "QListWidgetItem.SelectionMode" not in wizard_source + +def test_display_role_values(): + assert DisplayRole.WALL == "wall" + assert DisplayRole.PREVIEW == "preview" + assert DisplayRole.is_valid("wall") is True + assert DisplayRole.is_valid("preview") is True + assert DisplayRole.is_valid("bogus") is False + assert DisplayRole.is_valid(None) is False + + +def test_config_preview_fields_round_trip(): + cfg = HyperwallConfig( + server_url="http://localhost:8096", + username="u", + password="p", + last_grid_rows=2, + last_grid_cols=2, + last_preview_rows=3, + last_preview_cols=4, + last_display_roles=json.dumps({"HDMI-1": "preview"}), + ) + assert cfg.last_preview_rows == 3 + assert cfg.last_preview_cols == 4 + assert cfg.display_roles() == {"HDMI-1": "preview"} + + +def test_config_malformed_display_roles_returns_empty(): + cfg = HyperwallConfig( + server_url="http://localhost:8096", + username="u", + password="p", + last_display_roles="not-json", + ) + assert cfg.display_roles() == {} + + +def test_display_layout_defaults_and_rotation_values(): + from hyperwall.constants import DisplayRotation, normalize_display_layout + + assert DisplayRotation.AUTO == "auto" + assert DisplayRotation.DEG_90 == "90" + assert DisplayRotation.DEG_270 == "270" + assert normalize_display_layout({}) == { + "rotation": "auto", "rows": 2, "cols": 2, + } + assert normalize_display_layout( + {"rotation": "90", "rows": 4, "cols": 3} + ) == {"rotation": "90", "rows": 4, "cols": 3} + + +def test_config_save_load_preview_fields(): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "config.ini") + cfg = HyperwallConfig( + server_url="http://localhost:8096", + username="u", + password="p", + last_preview_rows=4, + last_preview_cols=5, + last_display_roles=json.dumps({"DP-1": "wall", "eDP-1": "preview"}), + ) + cfg.save(path) + loaded = HyperwallConfig.load(path) + assert loaded.last_preview_rows == 4 + assert loaded.last_preview_cols == 5 + assert loaded.display_roles() == {"DP-1": "wall", "eDP-1": "preview"} + + +# ── WallController construction (Windows/Qt only) ── + +def _build_bare_wall( + screens, client, display_roles, display_layouts=None, + preview_rows=3, preview_cols=4, +): + """Build display widgets without async loading or fullscreen side effects.""" + from hyperwall.wall import WallController + + wall = WallController.__new__(WallController) + wall.client = client + wall.screens = screens + wall.libraries = ["Movies"] + wall.grid_rows = 2 + wall.grid_cols = 2 + wall.preview_rows = preview_rows + wall.preview_cols = preview_cols + wall.display_roles = display_roles + wall.display_layouts = display_layouts or {} + wall.cells = [] + wall.windows = [] + wall._window_meta = {} + wall._solo_cell = None + wall._solo_window = None + wall._sync = None + wall._sync_enabled = False + wall._shortcuts = [] + wall.controls_visible = False + wall._build_displays() + return wall + +def test_wall_controller_builds_wall_and_preview_windows(): + if not _PYQT: + raise AssertionError("SKIP") + + from PyQt6.QtCore import QRect + from hyperwall.emby import EmbyClient + + class _FakeClient: + def __init__(self): + self.access_token = "token" + self.server_url = "http://localhost" + self.user_id = "uid" + self.backend = type("B", (), {"requires_static_true": True})() + + def test_connection(self): + return True + + def authenticate(self): + return True + + def fetch_libraries(self): + return [] + + def close(self): + pass + + def get(self, *a, **k): + return type("R", (), {"json": lambda: {}})() + + def post(self, *a, **k): + return type("R", (), {"status_code": 200})() + + class _FakeScreen: + def __init__(self, name, x, y, w, h): + self._name = name + self._geo = QRect(x, y, w, h) + + def name(self): + return self._name + + def geometry(self): + return self._geo + + screens = [ + _FakeScreen("External", 0, 0, 2560, 1440), + _FakeScreen("Laptop", 2560, 0, 1920, 1080), + ] + display_roles = {"External": DisplayRole.WALL, "Laptop": DisplayRole.PREVIEW} + display_layouts = { + "External": {"rotation": "90", "rows": 3, "cols": 2}, + "Laptop": {"rotation": "0", "rows": 2, "cols": 3}, + } + + wall = _build_bare_wall( + screens, + _FakeClient(), + display_roles, + display_layouts, + preview_rows=3, + preview_cols=4, + ) + + # One wall window (3x2 = 6 cells) + one preview window (2x3 = 6 cells) + assert len(wall.windows) == 2 + assert len(wall.cells) == 12 + meta = wall._window_meta + roles = {m["role"] for m in meta.values()} + assert roles == {DisplayRole.WALL, DisplayRole.PREVIEW} + by_name = { + meta["screen"].name(): meta for meta in meta.values() + } + assert (by_name["External"]["rows"], by_name["External"]["cols"]) == (3, 2) + assert by_name["External"]["rotation"] == "90" + assert (by_name["Laptop"]["rows"], by_name["Laptop"]["cols"]) == (2, 3) + + for win in wall.windows: + win.close() + + +def test_solo_mode_round_trip(): + if not _PYQT: + raise AssertionError("SKIP") + + from PyQt6.QtCore import QRect + + class _FakeClient: + access_token = "token" + server_url = "http://localhost" + user_id = "uid" + backend = type("B", (), {"requires_static_true": True})() + + def test_connection(self): + return True + + def authenticate(self): + return True + + def fetch_libraries(self): + return [] + + def close(self): + pass + + def get(self, *a, **k): + return type("R", (), {"json": lambda: {}})() + + def post(self, *a, **k): + return type("R", (), {"status_code": 200})() + + class _FakeScreen: + def __init__(self, name, x, y, w, h): + self._name = name + self._geo = QRect(x, y, w, h) + + def name(self): + return self._name + + def geometry(self): + return self._geo + + wall = _build_bare_wall( + [_FakeScreen("Preview", 0, 0, 1920, 1080)], + _FakeClient(), + {"Preview": DisplayRole.PREVIEW}, + ) + + cell = wall.cells[0] + assert wall._solo_cell is None + from unittest.mock import patch + with patch("PyQt6.QtWidgets.QWidget.show"): + wall._enter_solo(cell) + assert wall._solo_cell is cell + assert wall._window_meta[id(wall.windows[0])]["solo"] is True + wall._exit_solo() + assert wall._solo_cell is None + assert wall._window_meta[id(wall.windows[0])]["solo"] is False + + for win in wall.windows: + win.close() + + +# ── runner ── + +def run_all() -> int: + failures = 0 + for name, fn in globals().items(): + if not name.startswith("test_"): + continue + try: + fn() + print(f" PASS {name}") + except AssertionError as e: + if str(e) == "SKIP": + print(f" SKIP {name}") + else: + print(f" FAIL {name}: {e}") + failures += 1 + except Exception as e: + print(f" FAIL {name}: {e}") + failures += 1 + print(f"\n{failures} failed out of {sum(1 for n in globals() if n.startswith('test_'))} tests.") + return failures + + +if __name__ == "__main__": + sys.exit(run_all()) diff --git a/tests/test_reliability.py b/tests/test_reliability.py index 17dc013..c2b2142 100644 --- a/tests/test_reliability.py +++ b/tests/test_reliability.py @@ -374,6 +374,41 @@ def test_gate_auto_transcode_passthrough_and_disable(): assert gate_auto_transcode(True, active_transcodes=99, max_concurrent=0) +def test_prefetch_transcode_is_not_counted_as_active_playback(): + from hyperwall.reliability import active_transcode_count + + streams = [ + ("https://emby/Videos/a/stream?static=true", False), + ("https://emby/Videos/b/master.m3u8?PlaySessionId=b", True), + ("https://emby/Videos/c/master.m3u8?PlaySessionId=c", False), + ] + assert active_transcode_count(streams) == 1 + + +def test_prefetch_transcode_detection_requires_hls_playlist(): + from hyperwall.reliability import is_transcode_stream + + assert is_transcode_stream("https://emby/Videos/a/master.m3u8?x=1") + assert not is_transcode_stream("https://emby/Videos/a/stream.m3u8?static=true") + assert not is_transcode_stream("https://emby/Videos/a/stream?static=true") + + +def test_active_transcode_count_ignores_stale_or_empty_streams(): + from hyperwall.reliability import active_transcode_count + + assert active_transcode_count( + [("", False), (None, False), ("/Videos/a/master.m3u8", False)] + ) == 1 + + +def test_transcode_prefetch_is_disabled_when_server_budget_is_exhausted(): + from hyperwall.reliability import allow_transcode_prefetch + + assert allow_transcode_prefetch(active_transcodes=0, max_concurrent=4) + assert not allow_transcode_prefetch(active_transcodes=4, max_concurrent=4) + assert allow_transcode_prefetch(active_transcodes=4, max_concurrent=0) + + def test_max_concurrent_transcodes_constant(): from hyperwall import constants as c assert c.MAX_CONCURRENT_TRANSCODES == 4 diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..6f777d0 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,216 @@ +"""Tests for the network sync layer. + +These tests exercise the asyncio TCP protocol with mock controllers. +No Qt dependency. +""" +import asyncio +import json +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from hyperwall.sync import SyncMsg, SyncPeer, SyncServer, RelayController + + +class _FakeController: + def __init__(self): + self.cells = [] + self.windows = [] + self._window_meta = {} + self.filter_mode = "all" + self.applied: list[dict] = [] + self._solo_cell = None + self._solo_window = None + + def run_on_main(self, fn): + fn() + + def sync_apply(self, msg): + self.applied.append(msg) + + +def _run(coro): + return asyncio.run(coro) + + +async def _make_peer(): + reader = asyncio.StreamReader() + writer = _FakeWriter() + return reader, writer, SyncPeer(reader, writer) + + +def test_sync_msg_constants(): + assert SyncMsg.HELLO == "hello" + assert SyncMsg.CELL_UPDATE == "cell_update" + assert SyncMsg.SOLO == "solo" + assert SyncMsg.EXIT_SOLO == "exit_solo" + assert SyncMsg.FILTER == "filter" + assert SyncMsg.REMOTE_SOLO == "remote_solo" + assert SyncMsg.PING == "ping" + assert SyncMsg.PONG == "pong" + + +def test_sync_peer_framing(): + async def _test(): + _, writer, peer = await _make_peer() + await peer.send({"type": "test", "value": 42}) + assert len(writer.written) == 1 + line = writer.written[0].decode("utf-8").strip() + assert json.loads(line) == {"type": "test", "value": 42} + _run(_test()) + + +def test_sync_peer_recv(): + async def _test(): + reader, _, peer = await _make_peer() + reader.feed_data(b'{"type":"ping"}\n') + reader.feed_data(b'\n') # empty line ignored + reader.feed_data(b'bad json\n') + reader.feed_data(b'{"type":"pong"}\n') + reader.feed_eof() + assert await peer.recv() == {"type": "ping"} + assert await peer.recv() == {"type": "pong"} + assert await peer.recv() is None + _run(_test()) + + +def test_sync_server_hello_replies_with_full_state(): + async def _test(): + ctrl = _FakeController() + server = SyncServer(ctrl, host="127.0.0.1", port=0) + + _, client_writer, client_peer = await _make_peer() + server.peers.append(client_peer) + + await server._handle(client_peer, { + "type": "hello", + "display_name": "test", + "displays": ["d1"], + }) + + assert client_peer.display_name == "test" + assert len(client_writer.written) == 1 + msg = json.loads(client_writer.written[0].decode("utf-8").strip()) + assert msg["type"] == "full_state" + _run(_test()) + + +def test_sync_server_broadcasts_state_change(): + async def _test(): + ctrl = _FakeController() + server = SyncServer(ctrl, host="127.0.0.1", port=0) + server._loop = asyncio.get_running_loop() + + _, peer_writer, peer = await _make_peer() + server.peers.append(peer) + server.broadcast({"type": "cell_update", "cell_id": "c1", "item_id": "i1"}) + await asyncio.sleep(0.05) + assert any(b"cell_update" in data for data in peer_writer.written) + _run(_test()) + + +def test_sync_server_routes_remote_solo_to_other_peers(): + async def _test(): + ctrl = _FakeController() + server = SyncServer(ctrl, host="127.0.0.1", port=0) + + _, sender_writer, sender = await _make_peer() + sender.display_name = "sender" + sender.displays = ["d1"] + + _, target_writer, target = await _make_peer() + target.display_name = "target" + target.displays = ["d2"] + + server.peers = [sender, target] + await server._route_remote_solo(sender, {"type": "remote_solo", "item_id": "xyz"}) + + assert not any(b"xyz" in data for data in sender_writer.written) + assert any(b"xyz" in data for data in target_writer.written) + routed = json.loads(target_writer.written[0].decode("utf-8").strip()) + assert routed == {"type": "solo", "display_id": "d2", "item_id": "xyz"} + _run(_test()) + + +def test_sync_server_applies_local_and_broadcasts_filter(): + async def _test(): + ctrl = _FakeController() + server = SyncServer(ctrl, host="127.0.0.1", port=0) + _, sender_writer, sender = await _make_peer() + _, peer_writer, peer = await _make_peer() + server.peers = [sender, peer] + + await server._handle(sender, {"type": "filter", "mode": "favorites"}) + + assert any(m.get("mode") == "favorites" for m in ctrl.applied) + assert not any(b"favorites" in data for data in sender_writer.written) + assert any(b"favorites" in data for data in peer_writer.written) + _run(_test()) + + +def test_relay_controller_tracks_state(): + ctrl = RelayController() + ctrl.sync_apply({"type": "cell_update", "cell_id": "c1", "item_id": "i1"}) + ctrl.sync_apply({"type": "filter", "mode": "favorites"}) + ctrl.sync_apply({"type": "solo", "display_id": "d1", "cell_id": "c1", "item_id": "i1"}) + + assert ctrl._cell_states == {"c1": "i1"} + assert ctrl.filter_mode == "favorites" + assert ctrl._solo_state["display_id"] == "d1" + + full = SyncServer(ctrl, host="127.0.0.1", port=0)._build_full_state() + assert full["type"] == "full_state" + assert full["cells"] == {"c1": "i1"} + assert full["filter"] == "favorites" + assert full["solo"]["item_id"] == "i1" + + +def test_relay_controller_exit_solo_clears_state(): + ctrl = RelayController() + ctrl.sync_apply({"type": "solo", "display_id": "d1", "cell_id": "c1", "item_id": "i1"}) + ctrl.sync_apply({"type": "exit_solo"}) + assert ctrl._solo_state == {} + + +# ── helpers ── + +class _FakeWriter: + def __init__(self): + self.written: list[bytes] = [] + self._closed = False + + def write(self, data: bytes) -> None: + if not self._closed: + self.written.append(data) + + async def drain(self) -> None: + pass + + def close(self) -> None: + self._closed = True + + def get_extra_info(self, name: str): + return ("127.0.0.1", 12345) + + +# ── runner ── + +def run_all() -> int: + tests = [n for n in globals() if n.startswith("test_")] + failures = 0 + for name in tests: + fn = globals()[name] + try: + fn() + print(f" PASS {name}") + except Exception as e: + print(f" FAIL {name}: {e}") + failures += 1 + print(f"\n{failures} failed out of {len(tests)} tests.") + return failures + + +if __name__ == "__main__": + sys.exit(run_all())