diff --git a/mediahive/assets/mediahive-logo.png b/mediahive/assets/mediahive-logo.png new file mode 100644 index 0000000..d7fdfa0 Binary files /dev/null and b/mediahive/assets/mediahive-logo.png differ diff --git a/mediahive/assets/mediahive.webp b/mediahive/assets/mediahive.webp new file mode 100644 index 0000000..88249aa Binary files /dev/null and b/mediahive/assets/mediahive.webp differ diff --git a/mediahive/winmain.py b/mediahive/winmain.py index b48c6e5..135ff0c 100644 --- a/mediahive/winmain.py +++ b/mediahive/winmain.py @@ -5,6 +5,7 @@ Or from PyInstaller: MediaHive.exe [media_folder] """ import argparse +import base64 import ctypes import html import json @@ -18,6 +19,7 @@ import time import urllib.error import urllib.parse import urllib.request +from collections.abc import Callable from concurrent.futures import Future, ThreadPoolExecutor from pathlib import Path @@ -32,7 +34,9 @@ logger = logging.getLogger("mediahive.winmain") BACKEND_HOST = "127.0.0.1" BACKEND_PORT = 8420 -HEALTH_TIMEOUT = 2 # seconds +BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds +BACKEND_HEALTH_POLL_SECONDS = 0.25 +STARTUP_LOG_TAIL_LINES = 120 MPC_BE_URL = "http://127.0.0.1:13579" GAMEPAD_REPEAT_SECONDS = 0.008 GAMEPAD_POLL_SECONDS = 0.008 @@ -628,11 +632,194 @@ _SETUP_HTML = """ """ +_STARTUP_HTML_TEMPLATE = """ +MediaHive +
+
+ +
+
+
Starting MediaHive
+
Waiting for startup logs...
+
+ +
+
+
+ +""" + + class JsApi: """Python methods exposed to the frontend via window.pywebview.api.""" def __init__(self) -> None: self._window: webview.Window | None = None + self._startup_state: dict[str, object] | None = None + self._startup_state_lock: threading.Lock | None = None + self._startup_log_path: Path | None = None + self._request_quit_callback: Callable[[], None] | None = None def pick_folder(self) -> str | None: """Open a native OS folder picker and return the chosen path (or None).""" @@ -641,6 +828,46 @@ class JsApi: result = self._window.create_file_dialog(webview.FOLDER_DIALOG) return result[0] if result else None + def configure_startup_bridge( + self, + startup_state: dict[str, object], + startup_state_lock: threading.Lock, + startup_log_path: Path | None, + request_quit_callback: Callable[[], None], + ) -> None: + self._startup_state = startup_state + self._startup_state_lock = startup_state_lock + self._startup_log_path = startup_log_path + self._request_quit_callback = request_quit_callback + + def startup_status(self) -> dict[str, object]: + if self._startup_state is None or self._startup_state_lock is None: + return { + "ready": False, + "failed": False, + "message": "Initializing startup bridge...", + "backend_url": "", + } + with self._startup_state_lock: + return dict(self._startup_state) + + def startup_log_tail(self) -> str: + if self._startup_log_path is None: + return "Log file unavailable in development mode." + try: + lines = self._startup_log_path.read_text(encoding="utf-8").splitlines() + except Exception as exc: + return f"Could not read startup log: {exc}" + if not lines: + return "No startup log entries yet." + return "\n".join(lines[-STARTUP_LOG_TAIL_LINES:]) + + def quit_app(self) -> None: + if self._request_quit_callback is not None: + self._request_quit_callback() + if self._window is not None: + self._window.destroy() + def _prepend_meipass_to_path() -> None: """When frozen, ensure bundled binaries (ffmpeg) are found first on PATH.""" @@ -649,28 +876,77 @@ def _prepend_meipass_to_path() -> None: os.environ["PATH"] = meipass + os.pathsep + os.environ.get("PATH", "") -def _wait_for_backend(timeout: int = HEALTH_TIMEOUT) -> bool: +def _wait_for_backend(timeout: int | None = None) -> bool: url = os.environ["MEDIAHIVE_BACKEND_URL"] + "/api/health" - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: + deadline = time.monotonic() + timeout if timeout is not None else None + while True: + if deadline is not None and time.monotonic() >= deadline: + return False try: - with urllib.request.urlopen(url, timeout=2): + with urllib.request.urlopen(url, timeout=BACKEND_HEALTH_REQUEST_TIMEOUT): return True except Exception: - time.sleep(0.25) - return False + time.sleep(BACKEND_HEALTH_POLL_SECONDS) def _icon_path() -> str | None: """Locate the application icon at runtime (frozen or development).""" if getattr(sys, "frozen", False): - base = Path(sys._MEIPASS) # type: ignore[attr-defined] + meipass = Path(sys._MEIPASS) # type: ignore[attr-defined] + base = meipass / "mediahive" if (meipass / "mediahive").exists() else meipass else: base = Path(__file__).parent ico = base / "assets" / "mediahive.ico" return str(ico) if ico.exists() else None +def _runtime_package_base() -> Path: + """Return runtime package base for frozen and development layouts.""" + if getattr(sys, "frozen", False): + meipass = Path(sys._MEIPASS) # type: ignore[attr-defined] + packaged = meipass / "mediahive" + return packaged if packaged.exists() else meipass + return Path(__file__).parent + + +def _startup_logo_path() -> Path | None: + """Locate the startup logo asset for embedding in the splash page.""" + base = _runtime_package_base() + png = base / "assets" / "mediahive-logo.png" + if png.exists(): + return png + webp = base / "assets" / "mediahive.webp" + if webp.exists(): + return webp + ico = base / "assets" / "mediahive.ico" + if ico.exists(): + return ico + icns = base / "assets" / "mediahive.icns" + if icns.exists(): + return icns + return None + + +def _startup_html() -> str: + logo_uri = "" + logo_path = _startup_logo_path() + if logo_path is not None: + try: + data = logo_path.read_bytes() + ext = logo_path.suffix.lower() + mime = { + ".png": "image/png", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".icns": "image/icns", + }.get(ext, "application/octet-stream") + encoded = base64.b64encode(data).decode("ascii") + logo_uri = f"data:{mime};base64,{encoded}" + except Exception: + logo_uri = "" + return _STARTUP_HTML_TEMPLATE.replace("__MEDIAHIVE_LOGO_URI__", logo_uri) + + def _webview_start_kwargs() -> dict[str, str]: """Return platform-specific pywebview startup kwargs.""" # On macOS, force Qt backend so pywebview uses Chromium/WebEngine instead of WKWebView. @@ -734,8 +1010,9 @@ def winmain() -> None: _prepend_meipass_to_path() # In a frozen (windowed) build there is no console — redirect output to a log file + startup_log_path: Path | None = None if getattr(sys, "frozen", False): - _setup_logging() + startup_log_path = _setup_logging() # Resolution order: CLI arg → MEDIAHIVE_PATH env → saved config → ask user folder = ( @@ -776,15 +1053,56 @@ def winmain() -> None: ) backend_thread.start() - if not _wait_for_backend(): + startup_state_lock = threading.Lock() + startup_state: dict[str, object] = { + "ready": False, + "failed": False, + "message": "Starting MediaHive", + "backend_url": backend_url, + } + + def _request_quit() -> None: server.should_exit = True - raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s") + + def _set_startup_state(**updates: object) -> None: + with startup_state_lock: + startup_state.update(updates) + + def _monitor_backend_ready() -> None: + _set_startup_state(message="Starting MediaHive") + while not server.should_exit: + if _wait_for_backend(timeout=5): + _set_startup_state( + ready=True, + failed=False, + message="Backend ready. Opening MediaHive...", + ) + return + if not backend_thread.is_alive(): + _set_startup_state( + ready=False, + failed=True, + message="Backend stopped unexpectedly. Check logs or quit.", + ) + return + + threading.Thread( + target=_monitor_backend_ready, + daemon=True, + name="mediahive-startup-monitor", + ).start() api = JsApi() + api.configure_startup_bridge( + startup_state=startup_state, + startup_state_lock=startup_state_lock, + startup_log_path=startup_log_path, + request_quit_callback=_request_quit, + ) logger.info("Configured pywebview backend: %s", _selected_webview_backend()) window = webview.create_window( title="MediaHive", - url=backend_url, + html=_startup_html(), fullscreen=True, js_api=api, ) diff --git a/scripts/MediaHive.spec b/scripts/MediaHive.spec index 962ee5a..d3d4807 100644 --- a/scripts/MediaHive.spec +++ b/scripts/MediaHive.spec @@ -16,6 +16,8 @@ block_cipher = None _pkg = Path(mediahive.server.__file__).parent _frontend_build = _pkg / "frontend-build" +_logo_webp = _pkg / "assets" / "mediahive.webp" +_logo_png = _pkg / "assets" / "mediahive-logo.png" _icon_win = _pkg / "assets" / "mediahive.ico" _icon_mac = _pkg / "assets" / "mediahive.icns" _tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg" @@ -35,6 +37,10 @@ if _icon_win.exists(): _datas.append((str(_icon_win), "mediahive/assets")) if _icon_mac.exists(): _datas.append((str(_icon_mac), "mediahive/assets")) +if _logo_webp.exists(): + _datas.append((str(_logo_webp), "mediahive/assets")) +if _logo_png.exists(): + _datas.append((str(_logo_png), "mediahive/assets")) _hiddenimports = [ # uvicorn dynamic imports