Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/repo-guards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=
Expand All @@ -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
```

Expand Down
7 changes: 7 additions & 0 deletions config.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion hyperwall/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
66 changes: 66 additions & 0 deletions hyperwall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Expand All @@ -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
)
Expand All @@ -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():
Expand Down
34 changes: 27 additions & 7 deletions hyperwall/cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
80 changes: 79 additions & 1 deletion hyperwall/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
)

Expand All @@ -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:
Expand All @@ -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."""
Loading
Loading