Improve mac startup splash UX and logo rendering

This commit is contained in:
2026-05-22 15:36:38 +00:00
parent 8d3de512eb
commit 33b59bbace
4 changed files with 336 additions and 12 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+330 -12
View File
@@ -5,6 +5,7 @@ Or from PyInstaller: MediaHive.exe [media_folder]
""" """
import argparse import argparse
import base64
import ctypes import ctypes
import html import html
import json import json
@@ -18,6 +19,7 @@ import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from collections.abc import Callable
from concurrent.futures import Future, ThreadPoolExecutor from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path from pathlib import Path
@@ -32,7 +34,9 @@ logger = logging.getLogger("mediahive.winmain")
BACKEND_HOST = "127.0.0.1" BACKEND_HOST = "127.0.0.1"
BACKEND_PORT = 8420 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" MPC_BE_URL = "http://127.0.0.1:13579"
GAMEPAD_REPEAT_SECONDS = 0.008 GAMEPAD_REPEAT_SECONDS = 0.008
GAMEPAD_POLL_SECONDS = 0.008 GAMEPAD_POLL_SECONDS = 0.008
@@ -628,11 +632,194 @@ _SETUP_HTML = """<!DOCTYPE html>
</body></html>""" </body></html>"""
_STARTUP_HTML_TEMPLATE = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>MediaHive</title><style>
:root {
--bg: #090909;
--bg-alt: #151515;
--fg: #eef2f7;
--muted: #b0b0b0;
--accent: #f2513b;
--border: #2b2b2b;
}
* { box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
font-family: Avenir, "Segoe UI", system-ui, sans-serif;
color: var(--fg);
background:
radial-gradient(1200px 680px at 12% -5%, #1c1c1c 0%, transparent 58%),
radial-gradient(980px 540px at 110% 105%, #2a1b19 0%, transparent 58%),
linear-gradient(150deg, var(--bg) 0%, var(--bg-alt) 100%);
}
.shell {
width: 100%;
height: 100%;
display: grid;
grid-template-rows: 1fr auto;
padding: 4vh 4vw;
gap: 3vh;
}
.hero-wrap {
display: grid;
place-items: start center;
text-align: center;
padding-top: clamp(30vh, 33vh, 36vh);
}
.logo {
width: min(630px, 54.6vw);
height: min(238px, 23.8vh);
max-width: 63vw;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
user-select: none;
}
.panel {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 28vh;
max-height: 36vh;
border: 1px solid var(--border);
border-radius: 14px;
background: rgba(8, 8, 8, 0.76);
backdrop-filter: blur(3px);
overflow: hidden;
}
.status {
padding: 14px 16px;
color: var(--muted);
font-size: 18px;
}
.log {
margin: 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: rgba(8, 12, 16, 0.86);
color: #d4dae1;
font: 12px/1.45 ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow: auto;
padding: 12px 16px;
}
.actions {
display: flex;
gap: 10px;
justify-content: flex-end;
padding: 12px 16px;
}
button {
border: 1px solid var(--border);
border-radius: 9px;
background: #151b22;
color: var(--fg);
padding: 10px 14px;
font-size: 14px;
cursor: pointer;
}
button:hover { filter: brightness(1.07); }
.primary {
border-color: color-mix(in srgb, var(--accent) 45%, #fff 0%);
background: linear-gradient(180deg, var(--accent) 0%, #cf412f 100%);
color: #fff;
}
.quiet {
background: #151b22;
color: #c4ccd6;
}
.pulse::after {
content: "";
display: inline-block;
width: 8px;
height: 8px;
margin-left: 10px;
border-radius: 50%;
background: var(--accent);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scale(0.9); }
50% { opacity: 1; transform: scale(1.1); }
}
</style></head><body>
<main class="shell">
<section class="hero-wrap">
<div
class="logo"
role="img"
aria-label="MediaHive"
style="background-image: url('__MEDIAHIVE_LOGO_URI__');"
></div>
</section>
<section class="panel">
<div id="status" class="status pulse">Starting MediaHive</div>
<pre id="log" class="log">Waiting for startup logs...</pre>
<div class="actions">
<button id="quit" class="primary" type="button">Quit</button>
</div>
</section>
</main>
<script>
const statusEl = document.getElementById('status');
const logEl = document.getElementById('log');
const quitBtn = document.getElementById('quit');
let navigating = false;
async function refresh() {
if (!window.pywebview || !window.pywebview.api || navigating) {
return;
}
try {
const status = await window.pywebview.api.startup_status();
if (status && typeof status.message === 'string') {
statusEl.textContent = status.message;
if (status.ready) {
statusEl.classList.remove('pulse');
}
}
const logText = await window.pywebview.api.startup_log_tail();
if (typeof logText === 'string' && logText.length > 0) {
logEl.textContent = logText;
logEl.scrollTop = logEl.scrollHeight;
}
if (status && status.ready && status.backend_url) {
navigating = true;
window.location.href = status.backend_url;
}
} catch (err) {
statusEl.textContent = 'Waiting for startup bridge...';
}
}
quitBtn.addEventListener('click', async () => {
if (!window.pywebview || !window.pywebview.api) {
return;
}
await window.pywebview.api.quit_app();
});
setInterval(refresh, 500);
refresh();
</script>
</body></html>"""
class JsApi: class JsApi:
"""Python methods exposed to the frontend via window.pywebview.api.""" """Python methods exposed to the frontend via window.pywebview.api."""
def __init__(self) -> None: def __init__(self) -> None:
self._window: webview.Window | None = 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: def pick_folder(self) -> str | None:
"""Open a native OS folder picker and return the chosen path (or 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) result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
return result[0] if result else None 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: def _prepend_meipass_to_path() -> None:
"""When frozen, ensure bundled binaries (ffmpeg) are found first on PATH.""" """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", "") 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" url = os.environ["MEDIAHIVE_BACKEND_URL"] + "/api/health"
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout if timeout is not None else None
while time.monotonic() < deadline: while True:
if deadline is not None and time.monotonic() >= deadline:
return False
try: try:
with urllib.request.urlopen(url, timeout=2): with urllib.request.urlopen(url, timeout=BACKEND_HEALTH_REQUEST_TIMEOUT):
return True return True
except Exception: except Exception:
time.sleep(0.25) time.sleep(BACKEND_HEALTH_POLL_SECONDS)
return False
def _icon_path() -> str | None: def _icon_path() -> str | None:
"""Locate the application icon at runtime (frozen or development).""" """Locate the application icon at runtime (frozen or development)."""
if getattr(sys, "frozen", False): 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: else:
base = Path(__file__).parent base = Path(__file__).parent
ico = base / "assets" / "mediahive.ico" ico = base / "assets" / "mediahive.ico"
return str(ico) if ico.exists() else None 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]: def _webview_start_kwargs() -> dict[str, str]:
"""Return platform-specific pywebview startup kwargs.""" """Return platform-specific pywebview startup kwargs."""
# On macOS, force Qt backend so pywebview uses Chromium/WebEngine instead of WKWebView. # On macOS, force Qt backend so pywebview uses Chromium/WebEngine instead of WKWebView.
@@ -734,8 +1010,9 @@ def winmain() -> None:
_prepend_meipass_to_path() _prepend_meipass_to_path()
# In a frozen (windowed) build there is no console — redirect output to a log file # 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): if getattr(sys, "frozen", False):
_setup_logging() startup_log_path = _setup_logging()
# Resolution order: CLI arg → MEDIAHIVE_PATH env → saved config → ask user # Resolution order: CLI arg → MEDIAHIVE_PATH env → saved config → ask user
folder = ( folder = (
@@ -776,15 +1053,56 @@ def winmain() -> None:
) )
backend_thread.start() 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 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 = 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()) logger.info("Configured pywebview backend: %s", _selected_webview_backend())
window = webview.create_window( window = webview.create_window(
title="MediaHive", title="MediaHive",
url=backend_url, html=_startup_html(),
fullscreen=True, fullscreen=True,
js_api=api, js_api=api,
) )
+6
View File
@@ -16,6 +16,8 @@ block_cipher = None
_pkg = Path(mediahive.server.__file__).parent _pkg = Path(mediahive.server.__file__).parent
_frontend_build = _pkg / "frontend-build" _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_win = _pkg / "assets" / "mediahive.ico"
_icon_mac = _pkg / "assets" / "mediahive.icns" _icon_mac = _pkg / "assets" / "mediahive.icns"
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg" _tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
@@ -35,6 +37,10 @@ if _icon_win.exists():
_datas.append((str(_icon_win), "mediahive/assets")) _datas.append((str(_icon_win), "mediahive/assets"))
if _icon_mac.exists(): if _icon_mac.exists():
_datas.append((str(_icon_mac), "mediahive/assets")) _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 = [ _hiddenimports = [
# uvicorn dynamic imports # uvicorn dynamic imports