diff --git a/src/main.py b/src/main.py index 0f72fb6..92274d8 100644 --- a/src/main.py +++ b/src/main.py @@ -91,6 +91,11 @@ from kivy.graphics import Color, Line, Ellipse from kivy.uix.floatlayout import FloatLayout from kivy.uix.slider import Slider from playback_trace import trace # always-on playback transition logger +from weblink_session import ( + WeblinkSession, + WeblinkSettings, + validate_weblink_url, +) # unified web-link controller (single owner of launch/watchdog/teardown) # Load the KV file - resolve relative to this file's directory _kv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'signage_player.kv') @@ -955,10 +960,16 @@ class SignagePlayer(Widget): self.playlist = [] self.current_index = 0 self.current_widget = None - self._weblink_proc = None # Handle to the Chromium kiosk process for weblink items - self._weblink_preload_proc = None # Hidden Chromium pre-warming the next weblink URL - self._watchdog_stop = None # threading.Event to stop the inactivity watchdog - self._weblink_watchdog_thread = None # Background thread monitoring touch inactivity + self._weblink_proc = None # Mirror of the live weblink browser process + self._weblink_preload_proc = None # Deprecated: pre-warm handled by WeblinkSession + self._watchdog_stop = None # Deprecated: idle watcher owned by WeblinkSession + self._weblink_watchdog_thread = None # Deprecated: idle watcher owned by WeblinkSession + # Unified web-link controller. Owns launch, verified start, idle/max-dwell + # watching and teardown for one weblink item at a time. Platform wrappers + # (e.g. windows/run_win.py) inject their adapters via + # `weblink_adapter_factory` before playback starts. + self._weblink_session = None + self.weblink_adapter_factory = None self.is_playing = False self.is_paused = False self.auto_resume_event = None # Track scheduled auto-resume @@ -968,10 +979,20 @@ class SignagePlayer(Widget): self._media_started_at = None # monotonic time the current media started self._media_duration = 10 # scheduled duration of the current media self._video_eos_pending = False # guards against duplicate EOS callbacks on one video - self._last_advance_at = 0.0 # monotonic time of the last next_media advance (dedupe guard) + self._last_advance_at = 0.0 # monotonic time of the last next_media advance + self._advance_generation = 0 # bumped on every advance; stamps scheduled callbacks self._focus_keeper_event = None # Clock interval that keeps the window focused during video self._focus_keeper_elapsed = 0.0 self._focus_keeper_duration = 0.0 + self._focus_keeper_misses = 0 # consecutive keeper ticks without foreground + # Continuous foreground guardian — re-asserts the Kivy window to the + # foreground while media is playing (images AND videos), so focus that + # is lost overnight (screensaver, update, dialog, restart) is recovered + # even when an image is on screen. Skipped while paused or a weblink + # browser is showing (focus legitimately belongs to the browser). + self._focus_guardian_event = None + self._focus_guardian_interval = 5.0 + self._focus_guardian_misses = 0 self._video_watchdog_event = None # Clock interval that watches video progress self._video_watchdog_stopped = False # self.should_refresh_playlist = False # Flag to reload playlist after edit upload (DISABLED - causing crashes) @@ -1014,9 +1035,26 @@ class SignagePlayer(Widget): Clock.schedule_interval(self.update_heartbeat, 10) # Update every 10 seconds # Start screen activity signaler (keep display awake) Clock.schedule_interval(self.signal_screen_activity, 20) # Signal every 20 seconds + # Start the continuous focus guardian. It re-asserts the Kivy window + # to the foreground whenever focus is lost while media is playing, so + # the overnight scenario (focus stolen by a dialog/screensaver/update + # while an image was on screen) self-heals. It skips itself while the + # player is paused or a weblink browser is showing. + self._start_focus_guardian() def _update_size(self, instance, value): + # Keep the screen-size properties in sync with the real window size. + # On Windows, Window.size is the TRUE (DPI-aware) resolution; without + # this the content_area could be sized from a stale/pre-fullscreen + # value, leaving a black strip and wrong image/video scaling. self.size = value + try: + w, h = value + if w and h: + self.screen_width = w + self.screen_height = h + except Exception: + pass if hasattr(self, 'ids') and 'content_area' in self.ids: self.ids.content_area.size = value @@ -1061,8 +1099,9 @@ class SignagePlayer(Widget): except Exception: pass - # Weblinks are advanced by their own watchdog thread — don't touch. - if getattr(self, '_weblink_proc', None) is not None: + # Web links are interaction-driven: the WeblinkSession owns their + # countdown, so there is nothing to re-arm here. + if self._weblink_is_active(): return # Re-arm the advance timer with the remaining time. @@ -1076,7 +1115,7 @@ class SignagePlayer(Widget): f"SignagePlayer: Re-arming next_media in {remaining:.1f}s after popup" ) Clock.unschedule(self.next_media) - Clock.schedule_once(self.next_media, remaining) + self._schedule_advance(remaining) def apply_kiosk_mode(self, enabled): """Enable/disable production (kiosk) lockdown. @@ -1467,49 +1506,27 @@ class SignagePlayer(Widget): after_weblink=_after_weblink, weblink_proc=bool(getattr(self, '_weblink_proc', None))) - # ── Weblink → media transition (desktop-flash safe) ────────────── - # For web→media transitions, render the next Kivy widget first, - # then close Chromium on the next frame. This prevents a brief - # desktop exposure while the compositor removes Chromium. - if not _after_weblink: - proc = getattr(self, '_weblink_proc', None) - if proc is not None: - next_is_weblink = media_item.get('type') == 'weblink' - if next_is_weblink: - # Weblink -> weblink: close current Chromium first. - self._stop_inactivity_watchdog() - self._kill_weblink_preload() - self._weblink_proc = None - - if proc.poll() is None: - try: - proc.terminate() - except Exception as exc: - Logger.warning(f"SignagePlayer: weblink terminate: {exc}") - - def _resume(dt): - if proc.poll() is None: - try: - proc.kill() - except Exception: - pass - try: - Window.show() - Window.raise_window() - except Exception: - pass - self.play_current_media( - force_reload=force_reload, _after_weblink=True - ) - - Clock.schedule_once(_resume, 0.2) - return - - # Weblink -> non-weblink: keep Chromium visible while we - # prepare next Kivy frame, then close Chromium deferred. - self._stop_inactivity_watchdog() - self._kill_weblink_preload() - self._weblink_proc = proc + # ── Leaving a weblink: close the browser safely ────────────────── + # Weblinks are torn down by the WeblinkSession. `close()` is + # idempotent, so calling it here is safe whether the previous item + # ended by idle/max-dwell (already closed) or was interrupted. + # + # Ordering matters for a flash-free transition: + # weblink -> weblink : close the browser BEFORE launching the next + # weblink -> media : render the next Kivy widget FIRST, then + # close the browser on the following frame, + # so player content is already underneath. + session = self._weblink_session + if not _after_weblink and session is not None and session.active: + next_is_weblink = self._item_is_weblink(media_item) + if next_is_weblink: + session.close() + try: + Window.show() + Window.raise_window() + except Exception: + pass + else: try: self.ids.content_area.opacity = 1 except Exception: @@ -1519,16 +1536,19 @@ class SignagePlayer(Widget): Window.raise_window() except Exception: pass - Logger.debug("SignagePlayer: Deferred Chromium close after next frame render") + Logger.debug( + "SignagePlayer: Deferred browser close after next frame render" + ) # ──────────────────────────────────────────────────────────────── # Handle web links before any file/path handling (no local file exists) - if media_item.get('type') == 'weblink': + if self._item_is_weblink(media_item): Logger.debug("SignagePlayer: Media type: WEBLINK") trace("enter_weblink_branch", url=media_item.get('url', '')[:80]) self.ids.status_label.opacity = 0 self._remove_current_widget() - # Hide content_area — Chromium will cover it; avoids stale frame + # Hide content_area — the browser (or embedded CEF) covers it; + # this avoids a stale frame behind a translucent page. try: self.ids.content_area.opacity = 0 except Exception: @@ -1536,6 +1556,7 @@ class SignagePlayer(Widget): started = self.play_weblink(media_item.get('url', ''), duration) trace("weblink_started", ok=bool(started)) if started: + # Verified launch only — see WeblinkSession.start(). self.consecutive_errors = 0 if self.config: asyncio.ensure_future( @@ -1546,6 +1567,13 @@ class SignagePlayer(Widget): file_name ) ) + else: + # The session could not show anything (bad URL, no browser, + # page never became visible). Skip instead of sitting on a + # black screen for the whole duration. + Logger.warning("SignagePlayer: Web link could not be displayed - skipping") + self.consecutive_errors += 1 + self._skip_to_next_media() return # Construct full path to media file @@ -1575,7 +1603,15 @@ class SignagePlayer(Widget): # Video file Logger.debug(f"SignagePlayer: Media type: VIDEO") trace("starting_video", path=media_path) - self.play_video(media_path, duration) + # Read the server's audio/mute flags for this item (default: sound on) + item_muted = bool(media_item.get('muted', False)) + item_audio = str(media_item.get('audio', 'on')).lower() + # Explicit 'off' overrides muted; muted flag wins if present + if item_audio == 'off': + item_muted = True + if item_muted: + Logger.info(f"SignagePlayer: Video muted per playlist (audio={item_audio}, muted={item_muted})") + self.play_video(media_path, duration, muted=item_muted) elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']: # Image file Logger.debug(f"SignagePlayer: Media type: IMAGE") @@ -1604,9 +1640,10 @@ class SignagePlayer(Widget): self.consecutive_errors = 0 Logger.debug(f"SignagePlayer: Media started successfully") - # If we arrived here from a weblink item, close Chromium after the + # If we arrived here from a weblink item, close the browser after the # next Kivy frame so the new widget is already visible underneath. - if media_item.get('type') != 'weblink' and getattr(self, '_weblink_proc', None) is not None: + session = self._weblink_session + if session is not None and session.active: trace("closing_weblink_after_frame", name=file_name) self._kill_weblink_after_frame() @@ -1626,8 +1663,18 @@ class SignagePlayer(Widget): self.show_error(f"Error playing media: {e}") self._skip_to_next_media() - def play_video(self, video_path, duration): - """Play a video file using Kivy's Video widget with optimizations""" + def play_video(self, video_path, duration, muted=False): + """Play a video file using Kivy's Video widget with optimizations. + + Args: + video_path: Path to the video file + duration: Playlist duration for this item (safety-net advance timer) + muted: True to play without sound (server 'audio': 'off' / 'muted': true) + + NOTE: Kivy's Video widget has NO 'muted' property — the constructor + raises TypeError for unknown kwargs. The correct way to mute is the + 'volume' property (0.0 = muted, 1.0 = full volume). + """ try: # Verify file exists if not os.path.exists(video_path): @@ -1638,11 +1685,13 @@ class SignagePlayer(Widget): Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s") - # Create Video widget with optimized settings for smooth playback + # Create Video widget with optimized settings for smooth playback. + # Apply the server's audio:off/muted flag via 'volume' (0.0=mute). self._video_source = video_path self.current_widget = Video( source=video_path, state='play', # Start playing immediately + volume=0.0 if muted else 1.0, # server audio:off/muted -> mute options={ 'eos': 'stop', # Stop at end of stream 'ff_opts': { @@ -1686,7 +1735,7 @@ class SignagePlayer(Widget): # (unschedule first to prevent overlaps). Logger.debug(f"SignagePlayer: Scheduled next media in {duration}s") Clock.unschedule(self.next_media) - Clock.schedule_once(self.next_media, duration) + self._schedule_advance(duration) # Preload next media asynchronously for smoother transitions self.preload_next_media() @@ -1742,12 +1791,36 @@ class SignagePlayer(Widget): The heavy Win32 bring-to-front (EnumWindows + AttachThreadInput + SetForegroundWindow) can take ~1.5s and was blocking the main thread at every video start (see playback_trace.log: video_loaded then +1.5s - before video_focus_reasserted). Running it on a background thread - keeps playback smooth. + before video_focus_reasserted). We therefore run it as a scheduled + Kivy Clock callback. + + IMPORTANT (why this fixes the overnight focus loss): the previous + implementation spawned a `threading.Thread` and called + `Window.raise_window()` (an SDL call — NOT thread-safe) plus Win32 + `SetForegroundWindow` from that random worker thread. Windows applies + its "foreground lock" against background processes and is + particularly hostile to SetForegroundWindow called from a non-input + thread, so once the app ran in the background the keeper could detect + the lost focus forever but could never win it back (17,681 + focus_keeper_focus_lost ticks overnight, window never raised). + + The Win32 bring-to-front helpers we use here are safe to call from the + main/Kivy thread (they use ShowWindowAsync + SetWindowPos Z-order + flash, which work even when the process is backgrounded), and running + them on the SDL thread avoids touching SDL from a foreign thread. """ + # Skip entirely if the window is already foreground — cheap path. + try: + check = getattr(self, '_is_foreground_win', None) + if check is not None and check(): + return + except Exception: + pass + def _do(): try: from kivy.core.window import Window as _KivyWindow + _KivyWindow.show() _KivyWindow.raise_window() except Exception: pass @@ -1757,19 +1830,22 @@ class SignagePlayer(Widget): _bring() except Exception: pass - threading.Thread(target=_do, daemon=True, name='focus-bring-front').start() + # Run on the Kivy main thread (SDL thread) so we never touch SDL from + # a foreign thread. Non-blocking: scheduled, not blocking the frame. + Clock.schedule_once(lambda dt: _do(), 0) def _start_focus_keeper(self, duration): """Periodically keep the Kivy window in the foreground. Runs for up to `duration` seconds while a video is on screen. Each tick first does a CHEAP foreground check; the expensive bring-to-front only - runs when focus was actually lost, and even then on a background - thread so the UI never stalls. + runs when focus was actually lost. Re-asserts focus on the SDL thread + (via Clock) rather than a worker thread. """ self._stop_focus_keeper() self._focus_keeper_elapsed = 0.0 self._focus_keeper_duration = float(duration) + self._focus_keeper_misses = 0 self._focus_keeper_event = Clock.schedule_interval( self._focus_keeper_tick, 0.5 ) @@ -1786,11 +1862,26 @@ class SignagePlayer(Widget): try: check = getattr(self, '_is_foreground_win', None) if check is not None and check(): + self._focus_keeper_misses = 0 return # already focused, nothing to do except Exception: pass + + self._focus_keeper_misses += 1 trace("focus_keeper_focus_lost") - self._bring_window_to_front_nonblocking() + + # Lightweight SDL raise every tick (cheap, safe on the SDL thread). + try: + from kivy.core.window import Window as _KivyWindow + _KivyWindow.raise_window() + except Exception: + pass + + # The heavy Win32 bring-to-front (AttachThreadInput + + # SetForegroundWindow + Z-order flash) only runs on a throttled cadence + # (once per second) so a persistently-lost focus can't stutter video. + if self._focus_keeper_misses % 2 == 1: + self._bring_window_to_front_nonblocking() def _stop_focus_keeper(self): """Cancel any active focus keeper interval.""" @@ -1802,6 +1893,61 @@ class SignagePlayer(Widget): pass self._focus_keeper_event = None self._focus_keeper_elapsed = 0.0 + self._focus_keeper_misses = 0 + + # ── Continuous focus guardian (Windows foreground re-assertion) ────── + # The video-only focus keeper stops as soon as the playlist moves to an + # image, so if focus is lost while an image is on screen (which is exactly + # what happened overnight) nothing ever pulls the window back to the front. + # The guardian runs for the whole playback session and only skips itself + # while the player is paused or a weblink browser is showing. + + def _start_focus_guardian(self): + """Start the continuous foreground guardian (idempotent).""" + if getattr(self, '_focus_guardian_event', None) is not None: + return + self._focus_guardian_misses = 0 + self._focus_guardian_event = Clock.schedule_interval( + self._focus_guardian_tick, self._focus_guardian_interval + ) + Logger.debug("SignagePlayer: Focus guardian started") + + def _focus_guardian_tick(self, dt): + """Re-assert the Kivy window to the foreground if focus was lost.""" + # Don't fight the weblink browser — it legitimately owns foreground. + if getattr(self, '_weblink_proc', None) is not None: + return + # Don't steal focus while the user is interacting with the app + # (settings/exit popups, paused playback). + if self.is_paused: + return + try: + check = getattr(self, '_is_foreground_win', None) + if check is not None and check(): + self._focus_guardian_misses = 0 + return # already foreground + except Exception: + pass + + self._focus_guardian_misses += 1 + # Only raise after the foreground has actually been lost a couple of + # ticks in a row (avoids fighting transient focus such as a click on + # a notification that the user immediately closes). + if self._focus_guardian_misses >= 2: + trace("focus_guardian_reassert", + misses=self._focus_guardian_misses) + self._bring_window_to_front_nonblocking() + + def _stop_focus_guardian(self): + """Stop the continuous foreground guardian.""" + ev = getattr(self, '_focus_guardian_event', None) + if ev is not None: + try: + Clock.unschedule(ev) + except Exception: + pass + self._focus_guardian_event = None + self._focus_guardian_misses = 0 def _on_video_eos(self, instance): """Callback when video reaches end of stream. @@ -1877,7 +2023,7 @@ class SignagePlayer(Widget): # Schedule next media after duration (unschedule first to prevent overlaps) Logger.debug(f"SignagePlayer: Scheduled next media in {duration}s") Clock.unschedule(self.next_media) - Clock.schedule_once(self.next_media, duration) + self._schedule_advance(duration) # Preload next media asynchronously for smoother transitions self.preload_next_media() @@ -1960,265 +2106,214 @@ class SignagePlayer(Widget): Logger.debug("SignagePlayer: Previous widget removed") trace("widget_removed") - def play_weblink(self, url, duration): - """Display a live web page fullscreen using a Chromium kiosk overlay. + # ── Web-link playback (delegated to WeblinkSession) ────────────── + @staticmethod + def _item_is_weblink(media_item): + """True when a playlist item should be rendered as a live web page. - Kivy has no production-grade embedded web view on Raspberry Pi, so we - launch Chromium in kiosk mode over the Kivy window for the item's - duration, then close it and advance to the next item. - - Returns True if the browser was launched, False otherwise. + The server marks these with ``type == "weblink"``, but we also accept + common aliases and the "no file + http(s) url" shape so a slightly + different server payload is not rendered as a (missing) media file. """ - import shutil - import subprocess - from urllib.parse import urlparse + if not isinstance(media_item, dict): + return False + item_type = str(media_item.get('type', '') or '').strip().lower() + if item_type in ('weblink', 'web_link', 'web-link', 'link', 'url', 'webpage'): + return True + file_name = str(media_item.get('file_name', '') or '') + if file_name and os.path.splitext(file_name)[1]: + return False # has a file extension -> real media file + ok, _ = validate_weblink_url(str(media_item.get('url', '') or '')) + return ok - # Defence in depth: only ever open http/https links. - 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() + def _weblink_is_active(self): + """True while a web link is on screen and owns the interaction model.""" + session = self._weblink_session + if session is None: + return False + try: + return bool(session.active) + except Exception: return False - browser = shutil.which('chromium-browser') or shutil.which('chromium') - if not browser: - Logger.error("SignagePlayer: Chromium not installed; cannot display weblink") - self.consecutive_errors += 1 - self._skip_to_next_media() - return False + def get_weblink_session(self): + """Return the lazily created :class:`WeblinkSession` for this player. - target_width, target_height = self._get_browser_target_size() + The session is the single owner of weblink launch, verified start-up, + idle/max-dwell watching and teardown. Platform wrappers inject their + adapters by assigning ``player.weblink_adapter_factory``; the default + is a plain Chromium subprocess (Linux / Raspberry Pi). + """ + if self._weblink_session is None: + adapters = None + factory = getattr(self, 'weblink_adapter_factory', None) + if callable(factory): + try: + adapters = factory(self) + except Exception as exc: + Logger.error(f"SignagePlayer: weblink_adapter_factory failed: {exc}") + adapters = None + self._weblink_session = WeblinkSession( + self, + settings=WeblinkSettings(self.config), + adapters=adapters, + on_finished=self._on_weblink_finished, + on_failed=self._on_weblink_failed, + ) + return self._weblink_session + + def play_weblink(self, url, duration): + """Display a live web page fullscreen for ``duration`` seconds. + + Web links are **interaction-driven**, not passive media: + + * the page is shown for ``duration`` seconds, + * every interaction with it (touch, tap, scroll, mouse movement) + postpones the advance by 10 seconds measured from that interaction, + * so a viewer navigating the link during the last seconds of the slot + keeps the page on screen instead of being cut off, + * and ``max_dwell`` (duration x factor) is an absolute backstop so a + wedged browser can never park the playlist. + + Pause/play deliberately does not apply to web links — see + :meth:`toggle_pause`. + + Returns True when a browser was launched, False when the item must be + skipped. A failed start-up is reported asynchronously via + `_on_weblink_failed` once the visibility wait times out. + """ + ok, reason = validate_weblink_url(url) + if not ok: + Logger.warning(f"SignagePlayer: Refusing weblink ({reason}): {url!r}") + trace("weblink_refused", reason=reason) + return False try: - Logger.info(f"SignagePlayer: Opening weblink in kiosk browser: {url}") - Logger.info(f"SignagePlayer: Inactivity timeout set to {duration}s (touch resets the countdown)") - Logger.info(f"SignagePlayer: Chromium target launch size: {target_width}x{target_height}") - # Kill the hidden pre-warm instance first; its work (binary in RAM, - # page in disk cache) makes the kiosk relaunch below near-instant. - self._kill_weblink_preload() - self._weblink_proc = subprocess.Popen([ - browser, - # Use --start-fullscreen instead of --kiosk. - # --kiosk requests Wayland's exclusive-fullscreen protocol which - # prevents Labwc from restoring the previous window on close. - # --start-fullscreen is a normal maximised window that the - # compositor can un-stack without issues. - '--start-fullscreen', - '--start-maximized', - '--app=' + url, - '--noerrdialogs', - '--disable-infobars', - '--incognito', - '--no-first-run', - '--disable-session-crashed-bubble', - '--check-for-update-interval=31536000', - # Suppress the GNOME keyring / wallet unlock popup - '--password-store=basic', - '--use-mock-keychain', - # Prevent any other credential / sync dialogs - '--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', - ]) + duration = float(duration) + except (TypeError, ValueError): + duration = 10.0 + duration = max(1.0, min(duration, 86400.0)) - # Unschedule any previous fixed timer — the watchdog thread takes - # over and only advances after 'duration' seconds of NO touch activity. - Clock.unschedule(self.next_media) - self._start_inactivity_watchdog(duration) + # The browser (or embedded CEF) covers the content area. Hiding it + # avoids a stale frame showing through a translucent page; it is + # restored by the transition code and on any failure. + try: + self.ids.content_area.opacity = 0 + except Exception: + pass + # A fixed next_media timer would fire while the browser is up. + Clock.unschedule(self.next_media) - # Preload the next image so the transition after the weblink is smooth. - self.preload_next_media() - return True - except Exception as e: - Logger.error(f"SignagePlayer: Error opening weblink {url}: {e}") - # Restore content area if launch failed + session = self.get_weblink_session() + started = session.start(url, duration) + trace("weblink_session_started", ok=bool(started), engine=session.describe()) + + if not started: try: self.ids.content_area.opacity = 1 except Exception: pass - self.consecutive_errors += 1 - self._weblink_proc = None - self._skip_to_next_media() return False + # Pre-load the item after this one while the browser is on screen. + self.preload_next_media() + return True + + def _on_weblink_finished(self): + """Advance to the next item once a web link has completed. + + Called when the viewer stopped interacting for the postponement window + (or the max-dwell backstop was reached). The platform layer may raise + the Kivy window as part of the transition; here we only restore the + player surface and move on. + """ + try: + self.ids.content_area.opacity = 1 + except Exception: + pass + self.next_media() + + def _on_weblink_failed(self): + """Skip a web link that never became visible (blank-screen guard).""" + try: + self.ids.content_area.opacity = 1 + except Exception: + pass + self.consecutive_errors += 1 + self._skip_to_next_media() + def _get_browser_target_size(self): - """Return a stable browser launch size to avoid 800x600 first-paint flash.""" - default_width, default_height = 1920, 1080 + """Return a stable browser launch size to avoid 800x600 first-paint flash. + + Falls back to the *real* Kivy window size (a full-HD default is only a + last resort) so non-1080p panels are not launched at the wrong size. + """ try: width, height = Window.size - width = int(width) - height = int(height) + width, height = int(width), int(height) + if width >= 320 and height >= 240: + return width, height except Exception: - return default_width, default_height - - # Chromium sometimes first-paints at 800x600 before fullscreen; - # force at least Full HD when reported dimensions are too small. - if width < 1280 or height < 720: - return default_width, default_height - - return width, height + pass + return 1920, 1080 def _start_inactivity_watchdog(self, duration): - """Start a background thread that monitors /dev/input/* for touch/key - activity. The thread resets the idle counter on every event; when the - screen has been idle for *duration* seconds it fires next_media(). - Works even while Chromium owns the display (raw device reads bypass the - window-manager focus). - Also fires immediately if Chromium exits on its own (e.g. user closes it). + """Deprecated — the idle watcher is owned by WeblinkSession. + + Kept as a no-op shim so external/platform code that still calls it does + not break; the session starts its own watcher as part of `start()`. """ - import glob - import select - import threading - import time - - # Stop any previous watchdog cleanly before starting a new one. - self._stop_inactivity_watchdog() - - stop_event = threading.Event() - self._watchdog_stop = stop_event - weblink_proc = self._weblink_proc # snapshot so thread sees the right process - - def watchdog(): - # Open every available input event device (touchscreen, mouse, kbd). - devices = [] - for path in sorted(glob.glob('/dev/input/event*')): - try: - devices.append(open(path, 'rb')) # noqa: WPS515 - except (PermissionError, OSError) as exc: - Logger.debug(f"SignagePlayer: Watchdog cannot open {path}: {exc}") - - if not devices: - # No input devices accessible → fall back to a plain fixed timer. - Logger.warning( - "SignagePlayer: Watchdog — no /dev/input devices accessible; " - "falling back to fixed timer" - ) - stop_event.wait(timeout=duration) - if not stop_event.is_set(): - Clock.schedule_once(self.next_media, 0) - return - - Logger.info( - f"SignagePlayer: Watchdog watching {len(devices)} input device(s), " - f"idle threshold = {duration}s" - ) - last_activity = time.monotonic() - - try: - while not stop_event.is_set(): - # If Chromium exited on its own (user closed it), advance immediately. - if weblink_proc is not None and weblink_proc.poll() is not None: - Logger.info( - "SignagePlayer: Chromium exited — advancing to next media" - ) - Clock.schedule_once(self.next_media, 0) - break - - idle = time.monotonic() - last_activity - if idle >= duration: - Logger.info( - f"SignagePlayer: No touch for {duration}s — advancing to next media" - ) - Clock.schedule_once(self.next_media, 0) - break - - # Wait up to 0.5 s for any raw input event. - timeout = min(0.5, duration - idle) - readable, _, _ = select.select(devices, [], [], timeout) - - if readable: - # Drain the data so the buffer doesn't fill up. - for fd in readable: - try: - fd.read(24) # struct input_event = 24 bytes on 64-bit Linux - except OSError: - pass - last_activity = time.monotonic() - Logger.debug( - "SignagePlayer: Touch/key detected — inactivity timer reset" - ) - finally: - for fd in devices: - try: - fd.close() - except OSError: - pass - - self._weblink_watchdog_thread = threading.Thread( - target=watchdog, daemon=True, name='weblink-watchdog' + Logger.debug( + "SignagePlayer: _start_inactivity_watchdog is deprecated " + "(WeblinkSession owns the idle watcher)" ) - self._weblink_watchdog_thread.start() def _stop_inactivity_watchdog(self): - """Signal the watchdog thread to exit without firing next_media.""" - stop_event = getattr(self, '_watchdog_stop', None) - if stop_event is not None: - stop_event.set() - self._watchdog_stop = None - self._weblink_watchdog_thread = None + """Deprecated — the idle watcher is owned by WeblinkSession.""" + session = self._weblink_session + if session is not None: + try: + session.close() + except Exception as exc: + Logger.debug(f"SignagePlayer: weblink session close failed: {exc}") def _kill_weblink_after_frame(self): """Gracefully transition away from a weblink item. - Stops the watchdog and preload immediately (so nothing fires a spurious - next_media), clears self._weblink_proc so the slot is free for the next - item, then schedules the actual Chromium termination for the *next Kivy - frame*. By that time Kivy has already rendered the new media widget - underneath Chromium, so when the browser window disappears the player - content is instantly visible — no black flash. + The browser is closed by the :class:`WeblinkSession`, so this only + restores the player surface and raises the Kivy window. The actual + teardown is deferred to the next frame so Kivy has already rendered the + new media widget underneath the browser — removing the black flash. """ - self._stop_inactivity_watchdog() - self._kill_weblink_preload() + session = self._weblink_session + if session is not None and session.active: + session.cancel_prewarm() - proc = self._weblink_proc # snapshot - self._weblink_proc = None # free the slot right away + def _deferred(): + session.close() + try: + Window.raise_window() + except Exception as exc: + Logger.debug(f"SignagePlayer: raise_window failed (non-fatal): {exc}") - if proc is None or proc.poll() is not None: + Clock.schedule_once(lambda dt: _deferred(), 0) + else: # Nothing to kill — still raise the window in case it got buried. try: Window.raise_window() except Exception: pass - return - - def _do_kill(dt): - if proc.poll() is None: - try: - proc.terminate() - try: - proc.wait(timeout=3) - except Exception: - proc.kill() - Logger.debug("SignagePlayer: Closed weblink kiosk browser (deferred)") - except Exception as exc: - Logger.warning(f"SignagePlayer: Error in deferred weblink kill: {exc}") - # Bring Kivy window to front now that Chromium is gone. - try: - Window.raise_window() - except Exception as exc: - Logger.debug(f"SignagePlayer: raise_window failed (non-fatal): {exc}") - - Clock.schedule_once(_do_kill, 0) def _kill_weblink_process(self): - """Terminate the kiosk browser, inactivity watchdog, and any pre-warm process.""" - # Stop watchdog first so it cannot fire next_media after we've moved on. - self._stop_inactivity_watchdog() - self._kill_weblink_preload() + """Terminate the kiosk browser, idle watcher and any pre-warm process. - proc = getattr(self, '_weblink_proc', None) - if proc is not None and proc.poll() is None: + Idempotent: safe to call from pause, stop, restart and shutdown paths + even when no weblink is active. + """ + session = self._weblink_session + if session is not None: try: - proc.terminate() - try: - proc.wait(timeout=5) - except Exception: - proc.kill() - Logger.debug("SignagePlayer: Closed weblink kiosk browser") + session.close() except Exception as e: Logger.warning(f"SignagePlayer: Error closing weblink browser: {e}") self._weblink_proc = None @@ -2231,6 +2326,14 @@ class SignagePlayer(Widget): except Exception as exc: Logger.debug(f"SignagePlayer: raise_window failed (non-fatal): {exc}") + # Raise the Kivy window back to the front — when Chromium ran in kiosk + # mode it covered the Kivy window entirely; the window manager won't + # automatically bring it back on all compositors/WMs. + try: + Window.raise_window() + except Exception as exc: + Logger.debug(f"SignagePlayer: raise_window failed (non-fatal): {exc}") + def _skip_to_next_media(self): """Advance past a failed item WITHOUT recursing. @@ -2253,16 +2356,39 @@ class SignagePlayer(Widget): pass # Reset and retry slowly instead of stopping forever self.consecutive_errors = 0 - Clock.schedule_once(self.next_media, 30) + self._schedule_advance(30) else: - Clock.schedule_once(self.next_media, 1) + self._schedule_advance(1) - def next_media(self, dt=None): + def _schedule_advance(self, delay): + """Schedule the next playlist advance, stamped with the generation. + + The stamp lets :meth:`next_media` discard callbacks queued for an item + that has already been superseded (a stale watchdog, a duplicated timer, + an aborted teardown) without needing a wall-clock throttling window. + """ + token = self._advance_generation + + def _advance(dt): + self.next_media(_token=token) + + Clock.schedule_once(_advance, delay) + + def next_media(self, dt=None, _token=None): """Move to next media item. A stale-advance guard prevents duplicate/queued next_media calls from firing right after a long (blocked) teardown and skipping the media that was just shown. + + The guard is a *generation token*: every advance bumps + ``_advance_generation``. Callbacks scheduled by :meth:`_schedule_advance` + carry the generation that was current when they were queued, so when two + callbacks are queued for the same item only the first is honoured — the + second sees a stale token and is dropped. Direct calls (e.g. the weblink + session reporting that its item finished) pass no token and are always + honoured. Unlike a wall-clock throttling window this never swallows a + deliberate fast transition such as weblink -> weblink. """ trace("next_media_called", was_index=self.current_index, @@ -2272,11 +2398,13 @@ class SignagePlayer(Widget): trace("next_media_BLOCKED_paused") return - now = time.monotonic() - if now - self._last_advance_at < 1.0: - trace("next_media_IGNORED_stale", - since_last=round(now - self._last_advance_at, 3)) + if _token is not None and _token != self._advance_generation: + trace("next_media_IGNORED_stale_callback", + callback_gen=_token, current_gen=self._advance_generation) return + + now = time.monotonic() + self._advance_generation += 1 self._last_advance_at = now Logger.info(f"SignagePlayer: Transitioning to next media (was index {self.current_index})") @@ -2296,71 +2424,24 @@ class SignagePlayer(Widget): self.play_current_media() def _prewarm_weblink(self, url): - """Launch Chromium off-screen to warm up the binary and page cache. + """Pre-warm the browser for an upcoming web-link item. - The window is placed far outside the visible area so the user never - sees it. When play_weblink() fires for real it kills this hidden - instance first, then relaunches in kiosk mode. Because the Chromium - binary is already resident in RAM and the page is in the disk cache, - the visible kiosk window appears almost immediately. + Delegated to :class:`WeblinkSession`, which asks the preferred engine to + warm its binary and page cache. Platform layers may override this (the + Windows wrapper disables pre-warm because an off-screen browser there + interferes with audio and GPU resources). """ - import shutil - import subprocess - from urllib.parse import urlparse - - if not url: - return - scheme = urlparse(url).scheme.lower() - if scheme not in ('http', 'https'): - return - - browser = shutil.which('chromium-browser') or shutil.which('chromium') - if not browser: - return - - target_width, target_height = self._get_browser_target_size() - - # Kill any stale preload first. - self._kill_weblink_preload() - - try: - Logger.debug(f"SignagePlayer: Pre-warming weblink off-screen: {url}") - self._weblink_preload_proc = subprocess.Popen([ - browser, - '--app=' + url, - # Place window completely outside the visible display area. - '--window-position=-9999,-9999', - f'--window-size={target_width},{target_height}', # pre-render at target resolution - '--noerrdialogs', - '--disable-infobars', - '--incognito', - '--no-first-run', - '--disable-session-crashed-bubble', - '--check-for-update-interval=31536000', - '--password-store=basic', - '--use-mock-keychain', - '--disable-sync', - '--disable-background-networking', - '--no-default-browser-check', - ]) - except Exception as exc: - Logger.debug(f"SignagePlayer: Pre-warm launch failed (non-fatal): {exc}") - self._weblink_preload_proc = None + session = self.get_weblink_session() + session.prewarm(url) def _kill_weblink_preload(self): - """Terminate the hidden pre-warm Chromium process if running.""" - proc = getattr(self, '_weblink_preload_proc', None) - if proc is not None and proc.poll() is None: + """Deprecated — pre-warm is owned by WeblinkSession.""" + session = self._weblink_session + if session is not None: try: - proc.terminate() - try: - proc.wait(timeout=3) - except Exception: - proc.kill() - Logger.debug("SignagePlayer: Killed weblink pre-warm process") + session.cancel_prewarm() except Exception as exc: - Logger.debug(f"SignagePlayer: Error killing pre-warm process: {exc}") - self._weblink_preload_proc = None + Logger.debug(f"SignagePlayer: weblink cancel_prewarm failed: {exc}") def preload_next_media(self): """Preload the next media item asynchronously to improve transition smoothness @@ -2377,13 +2458,21 @@ class SignagePlayer(Widget): try: next_media_item = self.playlist[next_index] file_name = next_media_item.get('file_name', '') + + # Pre-warm the next web link (browser binary + page cache) so the + # transition into it is fast. Handled before the file checks below + # because a weblink has no local file. + if self._item_is_weblink(next_media_item): + self._prewarm_weblink(next_media_item.get('url', '')) + return + media_path = os.path.join(self.media_dir, file_name) - + # Check if file exists before attempting preload if not os.path.exists(media_path): Logger.debug(f"SignagePlayer: Preload skipped - file not found: {file_name}") return - + # Only preload images (videos are handled differently) file_extension = os.path.splitext(file_name)[1].lower() if file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']: @@ -2400,17 +2489,27 @@ class SignagePlayer(Widget): # Use Kivy's Loader to preload asynchronously Logger.debug(f"SignagePlayer: Preloading next image: {file_name}") Loader.image(media_path) - - elif next_media_item.get('type') == 'weblink': - # Pre-warm Chromium in a hidden off-screen window so the binary - # and page content are in OS cache before the slot arrives. - self._prewarm_weblink(next_media_item.get('url', '')) except Exception as e: Logger.debug(f"SignagePlayer: Error preloading next media: {e}") def toggle_pause(self, instance=None): - """Toggle pause/play with auto-resume after 5 minutes""" + """Toggle pause/play with auto-resume after 5 minutes. + + Pause/play applies to *passive* media (images and videos). It is + deliberately ignored while a web link is on screen: a web link is an + interactive surface, so its progress is controlled by viewer interaction + (each touch postpones the advance) rather than by the pause button. + Tapping pause during a web link is a no-op so it cannot cut a viewer's + session short. + """ + if self._weblink_is_active(): + Logger.info( + "SignagePlayer: Pause/play ignored — web link is interaction-driven" + ) + trace("toggle_pause_ignored_weblink") + return + self.is_paused = not self.is_paused if self.is_paused: @@ -2419,8 +2518,6 @@ class SignagePlayer(Widget): self.ids.play_pause_btn.background_normal = self.resources_path + '/play.png' self.ids.play_pause_btn.background_down = self.resources_path + '/play.png' Clock.unschedule(self.next_media) - # Close any kiosk browser so the player controls are visible while paused - self._kill_weblink_process() # Cancel any existing auto-resume if self.auto_resume_event: @@ -2666,8 +2763,26 @@ class SignagePlayer(Widget): Logger.info(f"SignagePlayer: Card data captured: {user_card_data}") Logger.info(f"SignagePlayer: Opening edit interface for {file_name}") + # Capture the media context (server-side file name + media id) so the + # edited image is uploaded under the same naming the server expects + # (edited_media//...). + media_id = None + original_filename = None + try: + if self.playlist and 0 <= self.current_index < len(self.playlist): + media_item = self.playlist[self.current_index] + media_id = media_item.get('id') + original_filename = media_item.get('file_name') + if not original_filename: + original_filename = file_name + except Exception as e: + Logger.warning(f"SignagePlayer: Could not read media context for edit: {e}") + # Open edit popup with card data (will be used when saving) - popup = EditPopup(player_instance=self, image_path=image_path, user_card_data=user_card_data) + popup = EditPopup(player_instance=self, image_path=image_path, + user_card_data=user_card_data, + media_id=media_id, + original_filename=original_filename) popup.open() def show_exit_popup(self, instance=None): @@ -2811,6 +2926,14 @@ class SignagePlayerApp(App): def on_stop(self): Logger.info("SignagePlayerApp: Application stopped") + # Stop the continuous focus guardian + try: + if self.root and hasattr(self.root, '_stop_focus_guardian'): + self.root._stop_focus_guardian() + Logger.info("SignagePlayerApp: Focus guardian stopped") + except Exception as e: + Logger.debug(f"SignagePlayerApp: Error stopping focus guardian: {e}") + # Close any kiosk browser opened for a weblink item try: if self.root and hasattr(self.root, '_kill_weblink_process'): diff --git a/windows/run_win.py b/windows/run_win.py index 7b6ecce..b4c671b 100644 --- a/windows/run_win.py +++ b/windows/run_win.py @@ -20,6 +20,51 @@ import logging from pathlib import Path +def _set_process_dpi_awareness(): + """Declare per-monitor DPI awareness so Kivy/SDL2 see the TRUE resolution. + + Without this, on a display scaled above 100% (e.g. 1920x1080 @ 125%), + Windows virtualizes the app to the scaled-down size (1536x864). Kivy then + sizes the content area to the virtualized resolution, leaving a black strip + on one side and making images/videos render at the wrong size. + + Prefer PROCESS_PER_MONITOR_DPI_AWARE_V2 (2); fall back to + PROCESS_PER_MONITOR_DPI_AWARE (1) and PROCESS_SYSTEM_DPI_AWARE (0). + """ + try: + try: + # Windows 10 1703+ + aware = ctypes.c_int(2) # PROCESS_PER_MONITOR_DPI_AWARE_V2 + ctypes.windll.shcore.SetProcessDpiAwareness(aware) + return + except Exception: + pass + try: + # Windows 8.1 / fallback + ctypes.windll.user32.SetProcessDPIAware() + return + except Exception: + pass + except Exception: + pass + + +# ── Declare DPI awareness BEFORE any Kivy/SDL import ──────────────── +_set_process_dpi_awareness() + +# Make SDL2 use the native (physical) pixel size instead of the DPI-scaled +# virtual size. Without this, on a 125%-scaled display the window and the +# Kivy content area are sized to the virtualized resolution (1536x864) even +# when the monitor is 1920x1080, leaving a black strip and seeing the +# desktop through the gap. +os.environ.setdefault('SDL_VIDEO_HIGHDPI', '1') + +# ── Windows-native card reader (Raw Input API + LL-hook fallback) ─── +# Imported here (not lazily) so PyInstaller bundles it via run_win.py's +# module graph. It replaces the Linux-only evdev-based CardReader. +from win_card_reader import WindowsCardReader + + def _show_error_box(title, message): """Show a Windows message box with the error (visible even without console).""" try: @@ -242,9 +287,10 @@ class _Win32Overlay: and there is a brief moment where the desktop is visible. This overlay covers that flash with a pure-black borderless always-on-top Win32 window. - For weblink open, the overlay is kept up until Chrome's window is detected - (`_hide_overlay_when_chrome_ready`) so the desktop is never exposed while - the browser is still starting. + For weblink open, the overlay is kept up by the adapter's + `on_before_launch` and only hidden once the browser window is confirmed + visible (see `_WinChromeAdapter.wait_visible`), so the desktop is never + exposed while the browser is still starting. """ _hwnd = None @@ -605,24 +651,168 @@ def _base_apply_kiosk_mode(self, enabled): pass -def _bring_hwnd_to_front(hwnd): +def _force_foreground_sendinput(hwnd): + """Bypass the Windows foreground lock using the SendInput trick. + + Windows only lets a process call SetForegroundWindow() if it is the "last + input process" — i.e. it processed the most recent keyboard/mouse input. + A background signage player that never gets user input can therefore be + denied foreground forever once another window (like a cycling kiosk + Chrome) owns the input queue. That is exactly the "focus lost after many + weblink cycles" bug. + + The classic workaround: synthesize a real input event (an invisible + Alt-key press) via SendInput. This makes the *current* process the last + input process, so the subsequent SetForegroundWindow() is allowed. + + NOTE: This is the same technique used by AutoHotkey and countless kiosk + apps. It briefly fakes a keypress, but since we send only a modifier key + (Alt) that is immediately released, the user never sees it. + """ + try: + # 1) Send a harmless Alt keydown + keyup so THIS process becomes the + # last-input process. + user32 = ctypes.windll.user32 + + # NOTE: The Win32 INPUT struct is a 32-byte union (mouse/keyboard/ + # hardware) preceded by a 4-byte type and 4 bytes of padding on x64, + # for a total of 40 bytes. The previous implementation defined INPUT + # as just type+KEYBDINPUT (32 bytes) — SendInput() rejected the + # undersized buffer (cbSize mismatch) so the fake Alt keypress was + # NEVER delivered, and the foreground lock was never defeated. That is + # why focus was permanently lost after enough weblink cycles. + if ctypes.sizeof(ctypes.c_void_p) == 8: # 64-bit + class KEYBDINPUT(ctypes.Structure): + _fields_ = [ + ('wVk', ctypes.c_ushort), + ('wScan', ctypes.c_ushort), + ('dwFlags', ctypes.c_ulong), + ('time', ctypes.c_ulong), + ('dwExtraInfo', ctypes.c_ulonglong), # ULONG_PTR + ] + + class MOUSEINPUT(ctypes.Structure): + _fields_ = [ + ('dx', ctypes.c_long), + ('dy', ctypes.c_long), + ('mouseData', ctypes.c_ulong), + ('dwFlags', ctypes.c_ulong), + ('time', ctypes.c_ulong), + ('dwExtraInfo', ctypes.c_ulonglong), # ULONG_PTR + ] + + class HARDWAREINPUT(ctypes.Structure): + _fields_ = [ + ('uMsg', ctypes.c_ulong), + ('wParamL', ctypes.c_ushort), + ('wParamH', ctypes.c_ushort), + ] + + class INPUTUNION(ctypes.Union): + _fields_ = [ + ('mi', MOUSEINPUT), + ('ki', KEYBDINPUT), + ('hi', HARDWAREINPUT), + ] + + class INPUT(ctypes.Structure): + _fields_ = [ + ('type', ctypes.c_ulong), + ('u', INPUTUNION), + ] + else: # 32-bit fallback + class KEYBDINPUT(ctypes.Structure): + _fields_ = [ + ('wVk', ctypes.c_ushort), + ('wScan', ctypes.c_ushort), + ('dwFlags', ctypes.c_ulong), + ('time', ctypes.c_ulong), + ('dwExtraInfo', ctypes.c_ulong), # ULONG_PTR + ] + + class MOUSEINPUT(ctypes.Structure): + _fields_ = [ + ('dx', ctypes.c_long), + ('dy', ctypes.c_long), + ('mouseData', ctypes.c_ulong), + ('dwFlags', ctypes.c_ulong), + ('time', ctypes.c_ulong), + ('dwExtraInfo', ctypes.c_ulong), # ULONG_PTR + ] + + class HARDWAREINPUT(ctypes.Structure): + _fields_ = [ + ('uMsg', ctypes.c_ulong), + ('wParamL', ctypes.c_ushort), + ('wParamH', ctypes.c_ushort), + ] + + class INPUTUNION(ctypes.Union): + _fields_ = [ + ('mi', MOUSEINPUT), + ('ki', KEYBDINPUT), + ('hi', HARDWAREINPUT), + ] + + class INPUT(ctypes.Structure): + _fields_ = [ + ('type', ctypes.c_ulong), + ('u', INPUTUNION), + ] + + INPUT_KEYBOARD = 1 + KEYEVENTF_KEYUP = 0x0002 + VK_MENU = 0x12 # Alt + + # Send Alt down + inp_down = INPUT() + inp_down.type = INPUT_KEYBOARD + inp_down.u.ki.wVk = VK_MENU + # Send Alt up + inp_up = INPUT() + inp_up.type = INPUT_KEYBOARD + inp_up.u.ki.wVk = VK_MENU + inp_up.u.ki.dwFlags = KEYEVENTF_KEYUP + + arr = (INPUT * 2)(inp_down, inp_up) + user32.SendInput(2, ctypes.byref(arr), ctypes.sizeof(INPUT)) + except Exception: + pass + + +def _bring_hwnd_to_front(hwnd, use_topmost_flash=True): """Force a Win32 window to the foreground using only ctypes. IMPORTANT: Windows restricts SetForegroundWindow() — a process can only set the foreground window if it was the *last input process* or the current foreground window is the same thread. To work around this, we - attach our calling thread (and the target window's thread) to the current - foreground window's input thread before calling SetForegroundWindow. + escalate through several methods: + + Method 1 — AttachThreadInput bypass: attach our calling thread (and the + target window's thread) to the current foreground window's + input thread before calling SetForegroundWindow. + Method 2 — SendInput unlock: fake an Alt keypress so our process + becomes the last-input process, then SetForegroundWindow. + This defeats the foreground lock even when another process + (e.g. a repeatedly cycling kiosk Chrome) owns the input. + Method 3 — Z-order flash: BringWindowToTop + SetWindowPos(TOPMOST then + NOTOPMOST) which reorders Z-order and works even when the + process is backgrounded. + + Returns True if the window is the foreground window afterwards, False + otherwise (so callers can retry). """ if not hwnd: - return + return False user32 = ctypes.windll.user32 kernel32 = ctypes.windll.kernel32 # If minimized, restore first so the window can actually be shown. if user32.IsIconic(hwnd): + user32.ShowWindowAsync(hwnd, _SW_RESTORE) user32.ShowWindow(hwnd, _SW_RESTORE) + # ── Method 1: SetForegroundWindow with the input-thread bypass ── try: fore_hwnd = user32.GetForegroundWindow() if fore_hwnd and fore_hwnd != hwnd: @@ -642,19 +832,167 @@ def _bring_hwnd_to_front(hwnd): except Exception: pass + # ── Method 2: SendInput unlock (beats the foreground lock) ── + # Only bother if we still don't have foreground after Method 1. + try: + if user32.GetForegroundWindow() != hwnd: + _force_foreground_sendinput(hwnd) + user32.SetForegroundWindow(hwnd) + user32.BringWindowToTop(hwnd) + except Exception: + pass + + # ── Method 3: Z-order + restore (reliable from a background process) ── + user32.ShowWindowAsync(hwnd, _SW_SHOWNORMAL) user32.ShowWindow(hwnd, _SW_SHOWNORMAL) user32.BringWindowToTop(hwnd) - user32.SetWindowPos(hwnd, _HWND_TOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE) - user32.SetWindowPos(hwnd, _HWND_NOTOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE) + if use_topmost_flash: + user32.SetWindowPos(hwnd, _HWND_TOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE) + user32.SetWindowPos(hwnd, _HWND_NOTOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE) + + # ── Verify ── + try: + return user32.GetForegroundWindow() == hwnd + except Exception: + return False + + +def _force_kivy_fullscreen_bounds(): + """Force the Kivy/SDL window to cover the ENTIRE physical monitor. + + Kivy's 'fullscreen' config can leave the SDL window at the DPI-virtualized + size (e.g. 1536x864 on a 1920x1080 display at 125% scaling), which shows a + black strip and lets the desktop bleed through. This resizes + repositions + the SDL window to the physical monitor bounds using Win32 directly, which + works regardless of how SDL interpreted the DPI config. + + Returns True on success, False otherwise (so callers can retry after the + window is created). + """ + try: + user32 = ctypes.windll.user32 + # SM_CXSCREEN/SM_CYSCREEN return PHYSICAL pixels once the process is + # DPI-aware (we set that at startup), so these are the true bounds. + w = user32.GetSystemMetrics(0) # SM_CXSCREEN + h = user32.GetSystemMetrics(1) # SM_CYSCREEN + if w <= 0 or h <= 0: + return False + + hwnd = _find_kivy_hwnd() + if hwnd is None: + return False + + # Cheap check: skip SetWindowPos if the window already covers the full + # monitor at 0,0 (the 2s sizing guardian calls this repeatedly, so we + # must not churn the window when the size is already correct). + try: + import win32gui + cur = win32gui.GetWindowRect(hwnd) # (left, top, right, bottom) + if (cur[0] == 0 and cur[1] == 0 + and (cur[2] - cur[0]) == w and (cur[3] - cur[1]) == h): + return True + except Exception: + pass + + # Remove any maximized flag first, then size + position at 0,0 to the + # full monitor size. SWP_NOZORDER keeps z-order unchanged. + SWP_NOZORDER = 0x0004 + SWP_FRAMECHANGED = 0x0020 + user32.SetWindowPos( + hwnd, 0, 0, 0, int(w), int(h), + SWP_NOZORDER | SWP_FRAMECHANGED, + ) + # Ensure it's visible + restored (not minimized). + user32.ShowWindow(hwnd, _SW_RESTORE) + user32.ShowWindow(hwnd, _SW_SHOWNORMAL) + return True + except Exception: + return False + + +def _reassert_kivy_fullscreen(self=None): + """Restore the Kivy window to full physical-monitor bounds AND re-sync the + Kivy content area after a weblink browser closes. + + WHY: Chrome/Edge opens its own fullscreen kiosk window over Kivy. When that + browser window is destroyed, SDL can leave the Kivy window at a wrong / + DPI-virtualized size (e.g. 1536x864 on a 1920x1080 display), and the Kivy + content_area + screen_width/height stay at the stale size -> black strip + + desktop bleed-through + wrong image/video scaling. This forcibly resizes + the SDL window to the monitor bounds and re-syncs Kivy's layout. + + self: the SignagePlayer instance (optional; used to re-sync its ids). + """ + ok = False + try: + ok = _force_kivy_fullscreen_bounds() + except Exception: + ok = False + + # Re-sync the Kivy content layout to the true monitor bounds. The native + # SetWindowPos above drives SDL's WM_SIZE -> Kivy Window.size -> _update_size + # automatically; we also set the properties directly here as immediate + # insurance so the content_area never renders at a stale size in the frame + # right after a weblink closes. + try: + user32 = ctypes.windll.user32 + w = user32.GetSystemMetrics(0) + h = user32.GetSystemMetrics(1) + if w > 0 and h > 0: + if self is not None: + try: + self.screen_width = w + self.screen_height = h + except Exception: + pass + try: + self.size = (w, h) + except Exception: + pass + try: + self.ids.content_area.size = (w, h) + except Exception: + pass + ok = True + except Exception: + pass + return ok def _find_kivy_hwnd(): - """Return the HWND of the Kivy/SDL window, or None.""" + """Return the HWND of the Kivy/SDL window, or None. + + IMPORTANT: The previous implementation matched ANY window whose class is + 'SDL_app' OR whose title contains 'Kiwy'/'Signage'. Because this build runs + with console=True, the exe's own console window has the title + '...\\KiwySignagePlayer\\KiwySignagePlayer.exe' — which CONTAINS both + 'Kiwy' and 'Signage'. EnumWindows lists top-level windows in Z-order, so + the console window could be returned as hwnd_list[-1], and + _bring_hwnd_to_front() would then raise the CONSOLE window instead of the + media window. That is the "app focuses the console instead of the widget" + symptom. + + Fix: only ever return a real SDL window (class 'SDL_app' or + 'SDL_app_arm'/'SDL_app_x11' variants). Never match on the title, and + explicitly exclude the console window class ('ConsoleWindowClass'). + """ try: import win32gui except Exception: return None - hwnd_list = [] + + # Class names of Kivy's SDL2 window on Windows. + SDL_CLASSES = ('SDL_app', 'SDL_app_x11', 'SDL_app_arm') + + sdl_windows = [] + # Fallback: in case the SDL class name differs, remember any non-console + # window owned by this process whose title mentions the app. + our_pid = None + try: + import os as _os + our_pid = _os.getpid() + except Exception: + our_pid = None def _enum_cb(hwnd, _): try: @@ -662,14 +1000,32 @@ def _find_kivy_hwnd(): title = win32gui.GetWindowText(hwnd) except Exception: return - if cls == "SDL_app" or "Kiwy" in title or "Signage" in title: - hwnd_list.append(hwnd) + # Skip the console host window outright — it must never be the target. + if cls in ('ConsoleWindowClass', 'CASCADIA_HOSTING_WINDOW_CLASS'): + return + if cls.startswith('SDL_app') or cls in SDL_CLASSES: + sdl_windows.append(hwnd) + return + # Last-resort fallback: a visible, non-tool window of OUR process whose + # title contains the app name. This catches renamed/ALT-styled SDL + # windows without ever matching the console. + if our_pid is not None: + try: + if win32gui.GetWindowThreadProcessId(hwnd, None)[1] != our_pid: + return + except Exception: + return + if "Kiwy" in title or "Signage" in title: + sdl_windows.append(hwnd) try: win32gui.EnumWindows(_enum_cb, None) except Exception: pass - return hwnd_list[-1] if hwnd_list else None + + # Prefer the LAST enumerated SDL window (Kivy's window is typically the + # newest/topmost SDL window); the console is already excluded above. + return sdl_windows[-1] if sdl_windows else None def _is_kivy_foreground(): @@ -690,26 +1046,105 @@ def _is_kivy_foreground(): return False -def _bring_kivy_to_front(): +_BRING_FRONT_LOCK = None # guards concurrent worker-thread bring-to-front calls + + +def _bring_kivy_to_front(async_ok=True): """Bring the Kivy/SDL window to the foreground. Uses win32gui.EnumWindows to find the SDL_app window, then _bring_hwnd_to_front (ctypes-only) to force it forward — no dependency on the un-bundled `win32con` module. Falls back to Kivy's built-in raise_window(). + + IMPORTANT (freeze fix): the heavy Win32 work (EnumWindows + + AttachThreadInput + SetForegroundWindow + SendInput) can block for a long + time when leaked Chrome/Edge windows fight back, and running it on the + Kivy main thread wedged the event loop overnight (video stuck, heartbeat + frozen). When async_ok=True (default, used from the focus keeper), the + heavy work runs on a background thread so the main thread is never + blocked; only the cheap fallback raise runs inline. + + Returns True if the Kivy window is (or is now) the foreground window, + False otherwise. """ - try: - hwnd = _find_kivy_hwnd() - if hwnd is None: - return - _bring_hwnd_to_front(hwnd) - except Exception: - # Fallback to Kivy's built-in raise + global _BRING_FRONT_LOCK + + if not async_ok: + # Synchronous path: used by explicit transitions (weblink -> media) + # where the caller has already hidden the overlay and really needs the + # result now. Still guarded by a lock + timeout-safe call. + hwnd = None + try: + hwnd = _find_kivy_hwnd() + except Exception: + hwnd = None + if hwnd is not None: + try: + ok = _bring_hwnd_to_front(hwnd) + if ok: + return True + except Exception: + pass try: from kivy.core.window import Window Window.show() Window.raise_window() except Exception: pass + try: + return _is_kivy_foreground() + except Exception: + return False + + # ── Async path (never blocks the Kivy thread) ────────────────── + try: + if _BRING_FRONT_LOCK is None: + _BRING_FRONT_LOCK = __import__('threading').Lock() + except Exception: + _BRING_FRONT_LOCK = None + + if _BRING_FRONT_LOCK is not None and not _BRING_FRONT_LOCK.acquire(blocking=False): + # A previous bring-to-front is still running on a worker thread — + # don't pile up more work on the main thread. + return False + + def _work(): + try: + hwnd = None + try: + hwnd = _find_kivy_hwnd() + except Exception: + hwnd = None + if hwnd is not None: + try: + _bring_hwnd_to_front(hwnd) + except Exception: + pass + else: + # No SDL window found yet — cheap Kivy raise instead. + try: + from kivy.core.window import Window + Window.raise_window() + except Exception: + pass + finally: + if _BRING_FRONT_LOCK is not None: + try: + _BRING_FRONT_LOCK.release() + except Exception: + pass + + try: + t = __import__('threading').Thread(target=_work, daemon=True, + name='bring-kivy-front-win') + t.start() + except Exception: + if _BRING_FRONT_LOCK is not None: + try: + _BRING_FRONT_LOCK.release() + except Exception: + pass + return True # optimistically report; the worker does the work def _find_chrome_hwnd(proc): @@ -755,64 +1190,6 @@ def _find_chrome_hwnd(proc): return chrome_hwnd -def _bring_chrome_to_front(proc): - """Find the top-level window of a launched Chrome/Edge process and bring - it to the foreground (so the weblink is actually visible over Kivy).""" - chrome_hwnd = _find_chrome_hwnd(proc) - if chrome_hwnd is not None: - _bring_hwnd_to_front(chrome_hwnd) - else: - # Give the browser a moment to create its window, then retry once. - import time - time.sleep(0.3) - chrome_hwnd = _find_chrome_hwnd(proc) - if chrome_hwnd is not None: - _bring_hwnd_to_front(chrome_hwnd) - - -def _hide_overlay_when_chrome_ready(proc, timeout=5.0, poll_interval=0.1): - """Hide the black overlay only once the weblink browser is on screen. - - Polls for the Chrome/Edge window (main thread via Kivy Clock). The overlay - stays up until the browser window is detected and brought to the front — - this guarantees the host desktop is never exposed while Chrome is still - starting (cold start / slow disk / GPU). If Chrome never appears within - `timeout` seconds, the overlay is hidden anyway and Kivy is raised. - """ - from kivy.clock import Clock - from kivy.logger import Logger - - _elapsed = [0.0] - - def _poll(dt): - _elapsed[0] += dt - hwnd = _find_chrome_hwnd(proc) - if hwnd is not None: - _Win32Overlay.hide() - _bring_hwnd_to_front(hwnd) - Logger.info( - f"SignagePlayer: Browser window detected ({hwnd}) — overlay hidden" - ) - return False # stop polling - if _elapsed[0] >= timeout: - Logger.warning( - "SignagePlayer: Chrome window not detected in " - f"{timeout:.0f}s — hiding overlay and raising Kivy" - ) - _Win32Overlay.hide() - _bring_kivy_to_front() - return False # stop polling - return True # keep polling - - # First check immediately (Chrome may already be up), then poll. - hwnd = _find_chrome_hwnd(proc) - if hwnd is not None: - _Win32Overlay.hide() - _bring_hwnd_to_front(hwnd) - return - Clock.schedule_interval(_poll, poll_interval) - - def _windows_kill_process_tree(proc): """Kill a process AND all its children using taskkill. @@ -839,8 +1216,123 @@ def _windows_kill_process_tree(proc): pass +def _log(msg): + """Module-level logger helper (Kivy Logger when available, else print).""" + try: + from kivy.logger import Logger + Logger.info(f"run_win: {msg}") + except Exception: + try: + print(f"[run_win] {msg}") + except Exception: + pass + + +def _windows_kill_browsers_on_profile(profile_dir): + """Kill every Chrome/Edge process using the given kiosk profile dir. + + WHY: Chrome/Edge hands off to an existing process when the same + --user-data-dir is already in use. If a previous weblink leaked a browser + (e.g. the app was killed while Chrome was up, or the process tree kill + missed a child), that leaked process keeps the profile lock AND owns the + visible URL window. The next weblink launch then: + 1. hands the URL to the leaked process, + 2. exits immediately -> the watchdog advances instantly, + 3. and the real browser window is never closed -> windows accumulate + in the background (observed: 7 leaked msedge.exe processes). + + This scans running browser processes, matches their command line against + the profile dir, and taskkills the whole tree so a fresh launch always + creates (and owns) its own window. + """ + if not profile_dir: + return + try: + profile_norm = os.path.normcase(os.path.normpath(profile_dir)) + # Enumerate processes with command lines via WMIC (Windows 8.1+). + # WMIC is deprecated on Win11 24H2+ but still works; fall back to + # PowerShell if it is missing. + rows = [] + try: + out = subprocess.run( + ['wmic', 'process', 'where', + "name='chrome.exe' or name='msedge.exe' or name='chromium.exe'", + 'get', 'ProcessId,CommandLine', '/format:csv'], + capture_output=True, text=True, timeout=15 + ) + for line in out.stdout.splitlines(): + if line.strip() and ',' in line: + rows.append(line) + except Exception: + rows = [] + if not rows: + # Fallback: PowerShell Get-CimInstance (Win11 24H2+ / no WMIC) + try: + ps = ( + "Get-CimInstance Win32_Process -Filter " + "\"Name='chrome.exe' or Name='msedge.exe' or Name='chromium.exe'\" | " + "ForEach-Object { \"$($_.ProcessId),$($_.CommandLine)\" }" + ) + out = subprocess.run( + ['powershell', '-NoProfile', '-Command', ps], + capture_output=True, text=True, timeout=20 + ) + for line in out.stdout.splitlines(): + if line.strip(): + rows.append(line) + except Exception: + rows = [] + + killed = 0 + for row in rows: + try: + # CSV: "Node,ProcessId,CommandLine" + parts = row.split(',', 2) + if len(parts) < 2: + continue + pid_str = parts[1].strip() + cmd = parts[2] if len(parts) > 2 else '' + if not pid_str.isdigit(): + continue + pid = int(pid_str) + if pid <= 0 or pid == os.getpid(): + continue + if profile_norm in os.path.normcase(cmd or ''): + # This browser is using our kiosk profile -> kill its tree. + try: + subprocess.run( + ['taskkill', '/F', '/T', '/PID', str(pid)], + capture_output=True, timeout=5 + ) + killed += 1 + except Exception: + pass + except Exception: + continue + if killed: + _log(f"Killed {killed} leaked browser process(es) using {profile_dir}") + return killed + except Exception as e: + _log(f"_windows_kill_browsers_on_profile error: {e}") + return 0 + + def _patch_main(): """Patch the main module after import for Windows compatibility.""" + # Logger is only imported lazily inside individual functions; make it + # available for the patch code at this scope as well. If it cannot be + # imported (very early startup), fall back to a no-op so the patch logic + # never crashes on a logging call. + try: + from kivy.logger import Logger # noqa: F401 + except Exception: + class _NullLogger: + @staticmethod + def _noop(*args, **kwargs): + pass + info = debug = warning = error = critical = exception = staticmethod(_noop) + Logger = _NullLogger() + # ── CRITICAL: Override Linux env vars BEFORE importing main ───── # main.py's top-level code sets SDL_VIDEODRIVER=wayland,x11,dummy # and other Linux values. We MUST override these before main.py @@ -857,6 +1349,24 @@ def _patch_main(): # Now safe to import main.py — env vars are already Windows-correct import main as signage_main + # ── Re-apply the fullscreen/window config AFTER main.py is imported ── + # main.py's top-level code calls Config.set('graphics','fullscreen','0') + # and window_state='maximized', which OVERRIDES the values run_win.py set + # before the import. On a DPI-scaled display that left the window at the + # virtualized size (e.g. 1536x864 on a 1920x1080 monitor), so the Kivy + # content only covered part of the screen and the desktop showed through + # the black strip. Re-asserting the config here (after main.py ran, but + # before App.run() creates the window) makes the SDL window come up at the + # true fullscreen resolution. + try: + from kivy.config import Config as _Config + _Config.set('graphics', 'fullscreen', '1') + _Config.set('graphics', 'window_state', 'maximized') + _Config.set('graphics', 'borderless', '1') + _Config.set('graphics', 'resizable', '0') + except Exception: + pass + # Replace signal_screen_activity # IMPORTANT: Assign under BOTH the attribute name AND the function's own name. # Kivy's WeakMethod stores self.__func__.__name__ (= '_windows_screen_activity') @@ -866,155 +1376,200 @@ def _patch_main(): signage_main.SignagePlayer.signal_screen_activity = _windows_screen_activity signage_main.SignagePlayer._windows_screen_activity = _windows_screen_activity - # Store a reference to the original play_weblink so we can wrap it - _original_play_weblink = signage_main.SignagePlayer.play_weblink + # ── Windows web-link engines ──────────────────────────────────── + # The player's play_weblink() delegates to WeblinkSession, which owns + # launch, verified visibility, the interaction watcher and teardown. + # Windows therefore injects *adapters* (one per browser flavour) instead of + # overriding play_weblink — that is what removed the old z-order/focus + # fighting and the leaked-browser accumulation. + from weblink_session import ChromiumSubprocessAdapter - def _windows_play_weblink(self, url, duration): - """Windows-compatible weblink handler. + class _WinCefAdapter(ChromiumSubprocessAdapter): + """Embedded CEF: renders inside Kivy's window, so no subprocess and + no z-order battles. Visibility cannot be 'not found' — it either shows + or raises — so the window check is skipped.""" - 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 + name = 'cef-embedded' + embedded = True - from playback_trace import trace - scheme = urlparse(url).scheme.lower() - if scheme not in ('http', 'https'): - Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}") - trace("win_weblink_REFUSED_scheme", scheme=scheme) - self.consecutive_errors += 1 - self._skip_to_next_media() - return False + def __init__(self): + super().__init__(kiosk=False) + self._browser = None + self._resize_bound = 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 in Kivy): {url}") - trace("win_weblink_CEF", url=url[:80]) + @property + def process(self): + return None # nothing to kill: CEF lives in-process + + def launch(self, url, width, height): + self._browser = _get_cef_browser() + if self._browser is None: + return False + self._bind_resize_once() + # The page is pumped through the Kivy Clock, so this is safe to + # call from the main thread. + return bool(self._browser.show(url)) + + def is_alive(self): + return self._browser is not None and self._browser.is_showing() + + def wait_visible(self, timeout): + """CEF is embedded: treat 'showing' as visible after a short settle.""" + import time + deadline = time.monotonic() + min(2.0, max(0.2, timeout)) + while time.monotonic() < deadline: + if self._browser is not None and self._browser.is_showing(): + # Give the compositor a moment to paint the first frame. + time.sleep(0.3) + return True, 'cef-showing' + time.sleep(0.1) + return False, 'cef did not show' + + def on_visible(self): + trace('win_weblink_CEF_shown') + + def _bind_resize_once(self): + """Bind the resize handler exactly once. + + The previous implementation rebound a NEW closure on every weblink + cycle, so Kivy's callback list grew without bound until the app + slowed down. Binding once removes that leak. + """ + if self._resize_bound: + return try: - self.ids.content_area.opacity = 0 - except Exception: - pass - # Attach resize handler so CEF follows Kivy window resizes - from kivy.core.window import Window as KivyWindow - _orig_on_resize = getattr(KivyWindow, '_on_resize', None) - def _cef_resize(*args): - try: - w, h = KivyWindow.size - cef_browser.resize(int(w), int(h)) - except Exception: - pass - if _orig_on_resize: + from kivy.core.window import Window as KivyWindow + + def _cef_resize(*args): try: - return _orig_on_resize(*args) + w, h = KivyWindow.size + _orig_on_resize = getattr(KivyWindow, '_on_resize', None) + if _orig_on_resize and getattr(_orig_on_resize, '__name__', '') != '_cef_resize': + _orig_on_resize(*args) except Exception: pass - KivyWindow._on_resize = _cef_resize - # Bind to size event as well - try: + try: + browser = _get_cef_browser() + if browser is not None: + browser.resize(int(KivyWindow.size[0]), int(KivyWindow.size[1])) + except Exception: + pass + KivyWindow.bind(size=_cef_resize) - except Exception: - pass + self._resize_bound = True + except Exception as exc: + _log(f"CEF resize bind failed (non-fatal): {exc}") - cef_browser.show(url) - Clock.unschedule(self.next_media) - self._start_inactivity_watchdog(duration) - self.preload_next_media() - Logger.info("SignagePlayer: CEF embedded browser visible (inside Kivy window)") - trace("win_weblink_CEF_shown") - return True - - # ── Strategy 2: Subprocess Chrome/Edge (fallback) ──────────── - browser = _windows_find_browser() - if not browser: - Logger.error( - "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 - - target_width, target_height = self._get_browser_target_size() - - try: - Logger.info(f"SignagePlayer: Opening weblink via subprocess: {url} ({browser})") - trace("win_weblink_subprocess", browser=os.path.basename(browser), url=url[:80]) - self._kill_weblink_preload() - - # Hide Kivy content (do NOT minimize — that makes it impossible - # to reliably bring Kivy back to foreground after Chrome closes). - from kivy.core.window import Window as KivyWindow + def teardown(self): + self.cancel_prewarm() try: - self.ids.content_area.opacity = 0 - except Exception: - pass + if self._browser is not None: + self._browser.hide() + except Exception as exc: + _log(f"CEF hide failed (non-fatal): {exc}") + def prewarm(self, url): + # CEF keeps one browser instance alive; there is nothing to warm. + pass + + class _WinChromeAdapter(ChromiumSubprocessAdapter): + """Chrome/Edge kiosk subprocess with the Windows visibility check. + + Verifying the real HWND (rather than just the process) is what stops a + blank screen when the page never paints. + """ + + name = 'chrome-subprocess' + embedded = False + + def __init__(self, browser_path): + super().__init__(browser_path=browser_path, kiosk=True) + self._profile_dir = None + + def on_before_launch(self, url, width, height): _Win32Overlay.show() - trace("win_overlay_shown") + trace('win_overlay_shown') - # CRITICAL: use a dedicated --user-data-dir. Without it, Chrome - # hands the URL to the existing browser process and this launched - # process exits immediately (poll() != None), so the watchdog - # advances instantly and the weblink never displays. A private - # profile also guarantees a brand-new top-level window we can - # track, bring to front, and taskkill without touching the user's - # normal browser session. - profile_dir = os.path.join( - os.environ.get('KIWY_DATA_DIR', os.getcwd()), - '.kiosk-profile' + def on_launch_failed(self): + _Win32Overlay.hide() + + def launch(self, url, width, height): + # A dedicated --user-data-dir is mandatory: without it Chrome hands + # the URL to an existing process, the launched process exits + # immediately and the weblink never displays. + self._profile_dir = os.path.join( + os.environ.get('KIWY_DATA_DIR', os.getcwd()), '.kiosk-profile' ) try: - os.makedirs(profile_dir, exist_ok=True) + os.makedirs(self._profile_dir, exist_ok=True) except Exception: pass - self._weblink_proc = subprocess.Popen([ - browser, - '--user-data-dir=' + profile_dir, - '--kiosk', - '--new-window', - '--start-maximized', - '--start-fullscreen', - '--app=' + url, - '--no-first-run', - '--noerrdialogs', - '--disable-infobars', - '--incognito', - '--no-default-browser-check', - '--disable-session-crashed-bubble', - '--disable-features=TranslateUI', - '--disable-sync', - '--disable-background-networking', - '--window-position=0,0', - f'--window-size={target_width},{target_height}', - '--force-device-scale-factor=1', - url, - ], shell=False) + # Kill any leaked browser still holding this profile's lock BEFORE + # launching, otherwise the new launch hands off and exits. + _windows_kill_browsers_on_profile(self._profile_dir) - # Hide the black overlay ONLY once Chrome's window is actually on - # screen. A fixed timer lets the desktop flash if Chrome is still - # starting (cold start / slow disk / GPU init). Adaptive polling - # keeps the screen black until the browser covers it. - weblink_proc = self._weblink_proc - _hide_overlay_when_chrome_ready(weblink_proc, timeout=6.0) + return super().launch(url, width, height) - Clock.unschedule(self.next_media) - self._start_inactivity_watchdog(duration) - self.preload_next_media() - trace("win_weblink_subprocess_started", pid=weblink_proc.pid if weblink_proc else None) - return True + def wait_visible(self, timeout): + """Poll for the real Chrome/Edge window, then hide the overlay.""" + import time + proc = self.process + if proc is None: + return False, 'no process' + deadline = time.monotonic() + max(1.0, float(timeout)) + while time.monotonic() < deadline: + if proc.poll() is not None: + # Exited early: either a hand-off or a crash. The process + # tree kill on the next cycle cleans up any leaked window. + return False, f'browser exited early (rc={proc.returncode})' + hwnd = _find_chrome_hwnd(proc) + if hwnd is not None: + _Win32Overlay.hide() + _bring_hwnd_to_front(hwnd) + return True, f'hwnd={hwnd}' + time.sleep(0.1) + return False, 'browser window never appeared' - except Exception as e: - Logger.error(f"SignagePlayer: Error opening weblink: {e}") - trace("win_weblink_EXCEPTION", error=str(e)) + def teardown(self): + """Kill the process tree, then restore Kivy — in the safe order.""" + proc, self._proc = self._proc, None + if proc is not None and proc.poll() is None: + trace('win_killing_chrome', pid=proc.pid) + _windows_kill_process_tree(proc) + # ORDER MATTERS: hide the fullscreen overlay BEFORE raising Kivy. + # If the topmost overlay is destroyed after Kivy is raised, Windows + # hands foreground to Explorer instead of our window — the + # "player runs but stays in the background" bug. _Win32Overlay.hide() - self.consecutive_errors += 1 - self._skip_to_next_media() - return False + _bring_kivy_to_front() + try: + _reassert_kivy_fullscreen(signage_main.SignagePlayer) + except Exception: + pass + + def prewarm(self, url): + # Disabled on Windows: an off-screen Chrome claims the audio device, + # spawns GPU processes and adds a duplicate taskbar entry. + pass + + def _windows_weblink_adapter_factory(player): + """Choose the Windows web-link engines, best first. + + CEF (embedded) is preferred when available because it cannot fight for + z-order or foreground; the Chrome/Edge subprocess is the fallback. + """ + adapters = [] + if _get_cef_browser() is not None: + adapters.append(_WinCefAdapter()) + browser = _windows_find_browser() + if browser: + adapters.append(_WinChromeAdapter(browser)) + return adapters + + signage_main.SignagePlayer.weblink_adapter_factory = staticmethod( + _windows_weblink_adapter_factory + ) # Replace weblink handling # ── Give play_video a hook to re-assert the Kivy window to the front ── @@ -1030,164 +1585,13 @@ def _patch_main(): lambda: _is_kivy_foreground() ) - signage_main.SignagePlayer.play_weblink = _windows_play_weblink + # NOTE: the old Windows overrides of play_weblink / _start_inactivity_watchdog + # / _kill_weblink_after_frame / play_current_media / _prewarm_weblink have + # been REMOVED. WeblinkSession (src/weblink_session.py) is now the single + # owner of launch, verified visibility, the interaction watcher and + # teardown; the Windows-specific behaviour lives in the adapters injected + # by `_windows_weblink_adapter_factory` above. - # Patch the _get_browser_target_size to always return a reasonable size on Windows - def _windows_get_browser_target_size(self): - try: - from kivy.core.window import Window - width, height = Window.size - if width >= 1280 and height >= 720: - return int(width), int(height) - except Exception: - pass - return 1920, 1080 - - signage_main.SignagePlayer._get_browser_target_size = _windows_get_browser_target_size - - # Patch _start_inactivity_watchdog for Windows — /dev/input does not exist - _original_watchdog = signage_main.SignagePlayer._start_inactivity_watchdog - - def _windows_watchdog(self, duration): - """Windows watchdog: uses a simple timer since /dev/input is not available. - - Falls back to a fixed timer that advances after 'duration' seconds. - """ - from kivy.clock import Clock - import threading - - self._stop_inactivity_watchdog() - stop_event = threading.Event() - self._watchdog_stop = stop_event - weblink_proc = self._weblink_proc - - def watchdog(): - import time - # Simply wait for the duration, checking if Chromium exited early - elapsed = 0.0 - step = 0.5 - while elapsed < duration and not stop_event.is_set(): - if weblink_proc is not None and weblink_proc.poll() is not None: - Clock.schedule_once(self.next_media, 0) - return - time.sleep(step) - elapsed += step - if not stop_event.is_set(): - Clock.schedule_once(self.next_media, 0) - - self._weblink_watchdog_thread = threading.Thread( - target=watchdog, daemon=True, name='weblink-watchdog-win' - ) - self._weblink_watchdog_thread.start() - - signage_main.SignagePlayer._start_inactivity_watchdog = _windows_watchdog - - # ── Patch kill_weblink_after_frame for both CEF and subprocess ── - def _windows_kill_weblink_after_frame(self): - """Close the weblink (CEF or subprocess) immediately before next media. - - Restores Kivy content visibility and brings the Kivy window to front - in all cases. - """ - import time - from kivy.logger import Logger - from kivy.clock import Clock - from kivy.core.window import Window as _KivyWindow - from playback_trace import trace - self._stop_inactivity_watchdog() - self._kill_weblink_preload() - - # Restore Kivy content visibility - try: - self.ids.content_area.opacity = 1 - except Exception: - pass - - # 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") - trace("win_kill_weblink_CEF_hide") - cef_browser.hide() - self._weblink_proc = None - _bring_kivy_to_front() - trace("win_kivy_brought_front") - return - - # Fallback: subprocess Chrome - proc = self._weblink_proc - self._weblink_proc = None - - if proc is None or proc.poll() is not None: - _bring_kivy_to_front() - trace("win_kill_weblink_noop", proc_none=(proc is None)) - return - - _Win32Overlay.show() - Logger.info("SignagePlayer: Killing Chromium subprocess immediately") - trace("win_killing_chrome", pid=proc.pid) - _windows_kill_process_tree(proc) - time.sleep(0.1) - _bring_kivy_to_front() - _Win32Overlay.hide() - trace("win_chrome_killed_kivy_front") - signage_main.SignagePlayer._kill_weblink_after_frame = _windows_kill_weblink_after_frame - - # ── 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. - - CRITICAL: Must restore content_area.opacity=1 BEFORE killing the browser, - because the original play_current_media() skips the weblink→media transition - block once self._weblink_proc is None. If opacity stays 0, the next widget - renders but is invisible. - """ - if not _after_weblink: - # Restore Kivy content visibility BEFORE killing the browser so the - # original play_current_media() doesn't need to handle the transition. - try: - self.ids.content_area.opacity = 1 - except Exception: - pass - try: - from kivy.core.window import Window as _KivyWindow - _KivyWindow.show() - except Exception: - pass - - # 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 - _bring_kivy_to_front() - - # 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): - 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/) _original_connection_test = signage_main.SettingsPopup.test_connection @@ -1274,6 +1678,64 @@ def _patch_main(): signage_main.SignagePlayer.apply_kiosk_mode = _windows_apply_kiosk_mode_patch + # ── Patch CardReader for Windows ──────────────────────────────── + # The Linux CardReader uses evdev (/dev/input/event*), which does not + # exist on Windows. Replace the class reference so that + # SignagePlayer.show_edit_interface() uses the Windows implementation + # (Raw Input API + LL-hook fallback) instead. + signage_main.CardReader = WindowsCardReader + Logger.info( + "SignagePlayer: CardReader patched -> WindowsCardReader " + "(Raw Input API)" + ) + + # ── Shut the card reader pump down on app exit ───────────────── + _original_on_stop = signage_main.SignagePlayerApp.on_stop + + def _windows_on_stop(self): + try: + root = getattr(self, 'root', None) + if root is not None: + cr = getattr(root, 'card_reader', None) + if cr is not None and hasattr(cr, 'shutdown'): + cr.shutdown() + Logger.info("SignagePlayer: Windows card reader shut down") + except Exception as e: + Logger.debug(f"SignagePlayer: card reader shutdown error: {e}") + _original_on_stop(self) + + signage_main.SignagePlayerApp.on_stop = _windows_on_stop + + # ── Force the Kivy window to cover the full physical monitor ── + # Kivy's fullscreen config can leave the SDL window at the DPI-virtualized + # size, showing a black strip + desktop bleed-through. Once the app starts + # we resize the window to the true monitor bounds and keep re-asserting it + # for the first few seconds (the SDL window may not exist immediately). + _original_on_start = signage_main.SignagePlayerApp.on_start + + def _windows_on_start(self): + _original_on_start(self) + try: + from kivy.clock import Clock + + # Continuous sizing guardian: every 2s, if the SDL window is not at + # the true monitor bounds (e.g. Chrome left it DPI-virtualized after + # a weblink), resize it back and re-sync the Kivy layout. This + # self-heals the "black strip + wrong size after a weblink" bug + # without a heavy constant SetWindowPos loop (the helper no-ops + # when the size already matches). + def _guard(dt): + try: + _reassert_kivy_fullscreen(self.root) + except Exception: + pass + + Clock.schedule_interval(_guard, 2.0) + except Exception as e: + _log(f"fullscreen guard start error: {e}") + + signage_main.SignagePlayerApp.on_start = _windows_on_start + return signage_main