Improve mac startup splash UX and logo rendering
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
+330
-12
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user