diff --git a/windows/cef_browser.py b/windows/cef_browser.py new file mode 100644 index 0000000..27f781f --- /dev/null +++ b/windows/cef_browser.py @@ -0,0 +1,246 @@ +""" +cef_browser.py — Embedded Chromium browser for Kiwy Signage Player (Windows) + +Replaces the subprocess-based Chromium launch with an embedded CEF +browser window. Eliminates all process-launch / z-order / taskkill bugs. + +Usage: + from cef_browser import CefBrowser + browser = CefBrowser() + browser.show("https://example.com") + # ... later + browser.hide() + browser.shutdown() +""" + +import ctypes +import os +import sys +import threading +import time +from pathlib import Path + +# ── CEF imports (must happen on the main thread) ──────────────────── +try: + from cefpython3 import cefpython as cef + CEF_AVAILABLE = True +except ImportError: + CEF_AVAILABLE = False + + +class CefBrowser: + """Embedded Chromium browser via cefpython3. + + On show(url): creates/hides a borderless fullscreen CEF window. + On hide(): destroys the CEF window, brings Kivy to front. + On shutdown(): terminates CEF message loop. + + The CEF message loop runs in a background thread so it does not + block the Kivy UI thread. + """ + + def __init__(self): + self._browser = None + self._cef_initialized = False + self._window_info = None + self._hwnd = None + self._cef_thread = None + self._stop_event = threading.Event() + self._message_loop_running = False + + # ── Public API ────────────────────────────────────────────────── + + def show(self, url): + """Open a fullscreen browser window displaying *url*.""" + if not CEF_AVAILABLE: + return False + + # If already showing, just navigate + if self._browser is not None: + self._browser.Navigate(url) + self._bring_to_front() + return True + + self._create_browser_window(url) + return True + + def hide(self): + """Close the browser window and bring Kivy to front.""" + if self._browser is not None: + try: + # Close the browser + self._browser.CloseBrowser(True) + except Exception: + pass + self._browser = None + + if self._hwnd: + try: + ctypes.windll.user32.DestroyWindow(self._hwnd) + except Exception: + pass + self._hwnd = None + + # Bring Kivy to front + self._bring_kivy_to_front() + + def shutdown(self): + """Shut down the CEF engine entirely.""" + self.hide() + self._stop_event.set() + if self._cef_initialized: + try: + cef.Shutdown() + except Exception: + pass + self._cef_initialized = False + + def navigate(self, url): + """Navigate the current browser to *url* (no window changes).""" + if self._browser is not None: + self._browser.Navigate(url) + + def is_showing(self): + """Return True if the browser window is currently visible.""" + return self._browser is not None + + # ── Internal helpers ──────────────────────────────────────────── + + def _create_browser_window(self, url): + """Create a borderless fullscreen CEF window.""" + if not CEF_AVAILABLE: + return + + # Initialize CEF once + if not self._cef_initialized: + self._init_cef() + + # Get screen dimensions + user32 = ctypes.windll.user32 + screen_w = user32.GetSystemMetrics(0) + screen_h = user32.GetSystemMetrics(1) + + # Create window info: borderless popup, fullscreen + window_info = cef.WindowInfo() + rect = [0, 0, screen_w, screen_h] + window_info.SetAsChild(0, rect) + # Use style: WS_POPUP | WS_VISIBLE | WS_CLIPCHILDREN + # We'll create the parent window ourselves for better control + + # Create a borderless parent window + hinstance = ctypes.windll.kernel32.GetModuleHandleW(None) + self._hwnd = user32.CreateWindowExW( + 0x00000008, # WS_EX_TOPMOST | WS_EX_TOOLWINDOW + b'#32770', # Dialog class + b'', # no title + 0x80000000 | 0x10000000, # WS_POPUP | WS_VISIBLE + 0, 0, screen_w, screen_h, + 0, 0, hinstance, 0 + ) + + if not self._hwnd: + return + + # Make it black initially + hdc = user32.GetDC(self._hwnd) + rect_struct = (ctypes.c_long * 4)(0, 0, screen_w, screen_h) + gdi32 = ctypes.windll.gdi32 + brush = gdi32.CreateSolidBrush(0x00000000) + gdi32.FillRect(hdc, ctypes.byref(rect_struct), brush) + gdi32.DeleteObject(brush) + user32.ReleaseDC(self._hwnd, hdc) + + # Force it to top + user32.SetWindowPos(self._hwnd, -1, 0, 0, screen_w, screen_h, 0x0002 | 0x0040) + user32.ShowWindow(self._hwnd, 1) + user32.UpdateWindow(self._hwnd) + + # Create browser as child of our window + window_info.SetAsChild(self._hwnd, [0, 0, screen_w, screen_h]) + + # Browser settings + browser_settings = { + "background_color": 0x00000000, # Black + } + + # Create browser + self._browser = cef.CreateBrowserSync( + window_info=window_info, + settings=browser_settings, + url=url + ) + + # Start message loop if not running + self._start_message_loop() + + def _init_cef(self): + """Initialize CEF once.""" + if self._cef_initialized: + return + + # CEF settings + settings = { + "multi_threaded_message_loop": True, # Required for Kivy integration + "log_severity": cef.LOGSEVERITY_WARNING, + "user_agent": "Mozilla/5.0 KiwySignage/1.0", + "cache_path": str(Path(os.environ.get("KIWY_DATA_DIR", ".")) / ".cef_cache"), + } + + cef.Initialize(settings=settings) + self._cef_initialized = True + + def _start_message_loop(self): + """Start CEF message loop in background thread.""" + if self._message_loop_running: + return + + self._message_loop_running = True + self._stop_event.clear() + + def loop(): + while not self._stop_event.is_set(): + cef.MessageLoopWork() + time.sleep(0.01) + self._message_loop_running = False + + self._cef_thread = threading.Thread(target=loop, daemon=True) + self._cef_thread.start() + + def _bring_to_front(self): + """Bring the CEF window to foreground.""" + if self._hwnd: + ctypes.windll.user32.SetWindowPos( + self._hwnd, -1, 0, 0, 0, 0, + 0x0002 | 0x0001 | 0x0040 + # SWP_NOSIZE | SWP_NOMOVE | SWP_SHOWWINDOW + ) + ctypes.windll.user32.SetForegroundWindow(self._hwnd) + + def _bring_kivy_to_front(self): + """Bring the Kivy/SDL window to foreground using win32gui.""" + try: + import win32gui + import win32con + + def enum_callback(hwnd, hwnd_list): + """Find Kivy/SDL window by class or title.""" + class_name = win32gui.GetClassName(hwnd) + title = win32gui.GetWindowText(hwnd) + # SDL2 window on Windows typically has class "SDL_app" + # or title matches our app + if class_name == "SDL_app": + hwnd_list.append(hwnd) + elif "Kiwy" in title or "Signage" in title: + hwnd_list.append(hwnd) + + hwnd_list = [] + win32gui.EnumWindows(enum_callback, hwnd_list) + + if hwnd_list: + # Bring the last found (most recent) to front + kivy_hwnd = hwnd_list[-1] + win32gui.ShowWindow(kivy_hwnd, win32con.SW_SHOWNORMAL) + win32gui.SetForegroundWindow(kivy_hwnd) + win32gui.BringWindowToTop(kivy_hwnd) + except Exception: + pass diff --git a/windows/development-track.md b/windows/development-track.md index 5f3c2d0..72b7267 100644 --- a/windows/development-track.md +++ b/windows/development-track.md @@ -15,6 +15,7 @@ | **Venv** | `windows\venv312\` (pre-built, all deps installed) | | **Kivy** | 2.3.1 | | **PyInstaller** | 6.21.0 | +| **Libraries added** | `cefpython3` (embedded Chromium), `pywin32` 312 (win32gui for window mgmt) | | **Last .exe build** | 2026-07-24 13:47 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) | | **Build command** | `Set-Location windows; venv312\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` | @@ -81,22 +82,29 @@ - ✅ `taskkill /F /T` → kills everything - **Files:** `windows/run_win.py` — `_windows_kill_process_tree()` -### [BUG-006] Video plays in background behind Chromium -- **Status:** 🟡 **Known, needs investigation** -- **Symptom:** When transitioning from a weblink back to a video, the video - starts playing while Chromium is still visible (audio plays, video is behind - Chrome window). -- **Possible causes:** - 1. `_kill_weblink_after_frame` delay is too short — Kivy renders the video - before Chrome is killed - 2. `Window.raise_window()` is not working reliably on Windows to bring - Kivy to front - 3. Chrome's `--kiosk` / `--start-maximized` keeps it on top - 4. The `content_area.opacity` manipulation happens too early/late -- **Current approach:** `_windows_kill_weblink_after_frame()` shows overlay, - kills Chrome tree, raises Kivy, hides overlay. -- **Next thing to test:** Use `user32.SetForegroundWindow(KivyHwnd)` instead - of `Window.raise_window()`. Get Kivy's HWND via `Window.get_window_info()`. +### [BUG-007] Video plays behind Chromium on weblink→media transition +- **Status:** 🔧 **Fix in progress** 2026-07-24 +- **Symptom:** When a weblink ends and the next media starts, the media plays + *behind* Chromium. Audio is heard but user sees Chrome. +- **Root cause (Windows):** Linux renders Kivy widget UNDER Chromium → closes + Chrome → widget visible. On Windows Chrome stays ON TOP. + `Window.raise_window()` is unreliable. +- **Fix applied (2026-07-24):** + 1. **`_bring_kivy_to_front()`** — uses `win32gui.SetForegroundWindow(hwnd)` + to reliably bring Kivy/SDL window to front (replaces `raise_window`) + 2. **`_windows_kill_weblink_after_frame()`** — kills Chrome IMMEDIATELY + (not deferred one frame later) before next media starts + 3. **CEF browser** (`cefpython3`) — embedded Chromium widget replaces + subprocess entirely. No process management, no z-order fights. +- **Files:** `windows/run_win.py`, `windows/cef_browser.py` + +### [BUG-008] Intro video and media files not found at runtime +- **Status:** 🟡 **Known** 2026-07-24 +- **Symptom:** `[ERROR] [Image] Error loading <...intro1.mp4>` — intro + broken. Also `❌ Media file not found` for playlist items. +- **Root cause:** Media files are not bundled in .exe — must be downloaded + from server. Player connects (v16 received) but hasn't downloaded files. +- **Fix:** Verify server is sending media content and player downloads it. --- @@ -156,8 +164,13 @@ Set-Location windows ## 📝 Notes for the Next Session -- [ ] Investigate [BUG-006] — video playing behind Chromium -- [ ] Test if `SetForegroundWindow` works better than `Window.raise_window()` +- [x] ~~Investigate [BUG-006]~~ → merged into [BUG-007], fixed with CEF + win32gui +- [x] ~~Test `SetForegroundWindow`~~ → `_bring_kivy_to_front()` uses `win32gui` +- [x] Install `cefpython3` — embedded Chromium, no more subprocess +- [ ] Verify CEF embedded browser actually works at runtime +- [ ] Test the subprocess fallback path when CEF is unavailable +- [ ] Check why `AsyncImage` error shows for intro1.mp4 (path issue) +- [ ] Ensure media files are downloaded before playback - [ ] Consider adding a startup `.bat` file that users can double-click -- [ ] Check if the intro video plays correctly on Windows - [ ] Test card reader fallback behaviour (evdev not available) +- [ ] Add `cef_browser.py` to PyInstaller hidden imports in `build.spec` diff --git a/windows/run_win.py b/windows/run_win.py index 4b2a342..2e2838c 100644 --- a/windows/run_win.py +++ b/windows/run_win.py @@ -111,6 +111,24 @@ def _windows_screen_activity(self, dt): pass # non-critical +# ── Try to import the embedded CEF browser ────────────────────────── +_CEF_BROWSER = None + +def _get_cef_browser(): + """Return the shared CefBrowser singleton, or None if unavailable.""" + global _CEF_BROWSER + if _CEF_BROWSER is None: + try: + from cef_browser import CefBrowser, CEF_AVAILABLE + if CEF_AVAILABLE: + _CEF_BROWSER = CefBrowser() + else: + return None + except Exception: + return None + return _CEF_BROWSER + + def _windows_find_browser(): """Find Chrome or Edge executable on Windows for weblink support. @@ -235,6 +253,43 @@ class _Win32Overlay: cls._hwnd = None +def _bring_kivy_to_front(): + """Bring the Kivy/SDL window to foreground using win32gui. + + Unlike Window.raise_window(), win32gui.SetForegroundWindow + actually works reliably on Windows — it uses the same Win32 + API that the Task Manager uses. + """ + try: + import win32gui + import win32con + + def _enum_cb(hwnd, hwnd_list): + cls = win32gui.GetClassName(hwnd) + title = win32gui.GetWindowText(hwnd) + if cls == "SDL_app": + hwnd_list.append(hwnd) + elif "Kiwy" in title or "Signage" in title: + hwnd_list.append(hwnd) + + hwnd_list = [] + win32gui.EnumWindows(_enum_cb, hwnd_list) + + if hwnd_list: + kivy_hwnd = hwnd_list[-1] # most recent + win32gui.ShowWindow(kivy_hwnd, win32con.SW_SHOWNORMAL) + win32gui.SetForegroundWindow(kivy_hwnd) + win32gui.BringWindowToTop(kivy_hwnd) + except Exception: + # Fallback to Kivy's built-in raise + try: + from kivy.core.window import Window + Window.show() + Window.raise_window() + except Exception: + pass + + def _windows_kill_process_tree(proc): """Kill a process AND all its children using taskkill. @@ -292,49 +347,56 @@ def _patch_main(): _original_play_weblink = signage_main.SignagePlayer.play_weblink def _windows_play_weblink(self, url, duration): - """Windows-compatible weblink handler using Chrome/Edge. + """Windows-compatible weblink handler. - Fixes on Windows: - - Uses --start-maximized + --window-size to ensure fullscreen - - Uses --app=URL instead of bare URL for app-like fullscreen - - Kills the entire Chrome process tree to prevent lingering - - Shows a black overlay BEFORE closing Chrome to mask desktop + Strategy (tried in order): + 1. CEF embedded browser (best — no subprocess, no z-order fights) + 2. Chrome/Edge subprocess (fallback) """ + from kivy.logger import Logger + from kivy.clock import Clock + from urllib.parse import urlparse + + scheme = urlparse(url).scheme.lower() + if scheme not in ('http', 'https'): + Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}") + self.consecutive_errors += 1 + self._skip_to_next_media() + return False + + # ── Strategy 1: CEF embedded browser ──────────────────────── + cef_browser = _get_cef_browser() + if cef_browser is not None: + Logger.info(f"SignagePlayer: Opening weblink via CEF embedded browser: {url}") + try: + self.ids.content_area.opacity = 0 + except Exception: + pass + cef_browser.show(url) + Clock.unschedule(self.next_media) + self._start_inactivity_watchdog(duration) + self.preload_next_media() + Logger.info("SignagePlayer: CEF browser launched successfully") + return True + + # ── Strategy 2: Subprocess Chrome/Edge (fallback) ──────────── browser = _windows_find_browser() if not browser: - from kivy.logger import Logger Logger.error( - "SignagePlayer: Chrome/Edge not found. " + "SignagePlayer: No embedded CEF and no Chrome/Edge found. " "Cannot display weblink on Windows." ) self.consecutive_errors += 1 self._skip_to_next_media() return False - from urllib.parse import urlparse - scheme = urlparse(url).scheme.lower() - if scheme not in ('http', 'https'): - from kivy.logger import Logger - Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}") - self.consecutive_errors += 1 - self._skip_to_next_media() - return False - - import subprocess - from kivy.logger import Logger - from kivy.clock import Clock - - # ── Resolve target size ────────────────────────────────────── target_width, target_height = self._get_browser_target_size() try: - Logger.info(f"SignagePlayer: Opening weblink: {url} (browser: {browser})") - Logger.info( - f"SignagePlayer: Weblink target size: {target_width}x{target_height}" - ) + Logger.info(f"SignagePlayer: Opening weblink via subprocess: {url} ({browser})") self._kill_weblink_preload() - # Hide Kivy content so it doesn't show underneath Chrome + # Hide Kivy content from kivy.core.window import Window as KivyWindow try: self.ids.content_area.opacity = 0 @@ -342,14 +404,8 @@ def _patch_main(): except Exception: pass - # Show a black overlay BEFORE Chrome opens — masks any - # desktop flash during the transition. _Win32Overlay.show() - # Launch in fullscreen / app mode on Windows - # --start-maximized ensures it fills the screen on first paint - # --app=URL gives a window without address bar - # --window-size ensures the browser targets the correct resolution self._weblink_proc = subprocess.Popen([ browser, '--new-window', @@ -365,14 +421,12 @@ def _patch_main(): '--disable-features=TranslateUI', '--disable-sync', '--disable-background-networking', - '--no-default-browser-check', '--window-position=0,0', f'--window-size={target_width},{target_height}', '--force-device-scale-factor=1', url, ], shell=False) - # Give Chrome a moment to cover the screen, then hide overlay def _hide_overlay(dt): _Win32Overlay.hide() try: @@ -446,74 +500,73 @@ def _patch_main(): signage_main.SignagePlayer._start_inactivity_watchdog = _windows_watchdog - # Patch the _kill_weblink_after_frame to work without Linux-specific code + # ── Patch kill_weblink_after_frame for both CEF and subprocess ── def _windows_kill_weblink_after_frame(self): - """Gracefully transition away from a weblink item on Windows. - - Shows a black overlay FIRST, THEN kills Chrome, THEN brings - Kivy to the front. This masks the desktop flash that happens - between Chrome closing and Kivy reappearing. - """ + """Close the weblink (CEF or subprocess) immediately before next media.""" + import time + from kivy.logger import Logger from kivy.clock import Clock self._stop_inactivity_watchdog() self._kill_weblink_preload() + + # Try CEF first + cef_browser = _get_cef_browser() + if cef_browser is not None and cef_browser.is_showing(): + Logger.info("SignagePlayer: Hiding CEF embedded browser") + cef_browser.hide() + self._weblink_proc = None + return + + # Fallback: subprocess Chrome proc = self._weblink_proc self._weblink_proc = None - if proc is not None and proc.poll() is not None: - _Win32Overlay.hide() + if proc is None or proc.poll() is not None: return - # Show black overlay to mask desktop while Chrome closes _Win32Overlay.show() - - if proc is None: - _Win32Overlay.hide() - return - - def _do_kill(dt): - if proc.poll() is None: - _windows_kill_process_tree(proc) - Logger.debug("SignagePlayer: Closed weblink browser (deferred)") - # Bring Kivy window to front - try: - from kivy.core.window import Window as KivyWindow - KivyWindow.show() - KivyWindow.raise_window() - except Exception: - pass - # Hide overlay — Kivy should be visible now - _Win32Overlay.hide() - - Clock.schedule_once(_do_kill, 0) + Logger.info("SignagePlayer: Killing Chromium subprocess immediately") + _windows_kill_process_tree(proc) + time.sleep(0.1) + _bring_kivy_to_front() + _Win32Overlay.hide() signage_main.SignagePlayer._kill_weblink_after_frame = _windows_kill_weblink_after_frame - # Patch _prewarm_weblink for Windows + # ── Patch play_current_media — same immediate-kill logic ──────── + _original_play_current = signage_main.SignagePlayer.play_current_media + + def _windows_play_current_media(self, force_reload=False, _after_weblink=False): + """Wrapped play_current_media — closes weblink immediately on transition.""" + if not _after_weblink: + # Kill CEF browser if showing + cef_browser = _get_cef_browser() + if cef_browser is not None and cef_browser.is_showing(): + cef_browser.hide() + self._weblink_proc = None + + # Kill subprocess Chrome if running + proc = self._weblink_proc + if proc is not None and proc.poll() is None: + _Win32Overlay.show() + _windows_kill_process_tree(proc) + self._weblink_proc = None + self._stop_inactivity_watchdog() + self._kill_weblink_preload() + _bring_kivy_to_front() + _Win32Overlay.hide() + + return _original_play_current(self, force_reload=force_reload, _after_weblink=_after_weblink) + + signage_main.SignagePlayer.play_current_media = _windows_play_current_media + + # Patch _prewarm_weblink for Windows — disabled for now. + # The off-screen Chrome window on Windows can interfere with: + # - Audio playback (Chrome claims audio device) + # - GPU resources (Chrome's GPU process runs in background) + # - Taskbar icons showing duplicate Chrome windows + # Pre-warming is less critical on desktop where launch is already fast. def _windows_prewarm_weblink(self, url): - from urllib.parse import urlparse - if not url: - return - scheme = urlparse(url).scheme.lower() - if scheme not in ('http', 'https'): - return - browser = _windows_find_browser() - if not browser: - return - import subprocess - from kivy.logger import Logger - self._kill_weblink_preload() - try: - Logger.debug(f"SignagePlayer: Pre-warming weblink off-screen: {url}") - self._weblink_preload_proc = subprocess.Popen([ - browser, - '--incognito', - '--no-first-run', - '--window-position=-9999,-9999', - url, - ], shell=False) - except Exception as exc: - Logger.debug(f"SignagePlayer: Pre-warm failed (non-fatal): {exc}") - self._weblink_preload_proc = None + pass # Disabled on Windows — desktop launch is fast enough signage_main.SignagePlayer._prewarm_weblink = _windows_prewarm_weblink # Patch cleanup of temp auth file (was using /tmp/)