diff --git a/.player_heartbeat b/.player_heartbeat index 03f3a0d..0ad10fa 100644 --- a/.player_heartbeat +++ b/.player_heartbeat @@ -1 +1 @@ -1782752385.7015185 \ No newline at end of file +1782752485.750587 \ No newline at end of file diff --git a/src/main.py b/src/main.py index 6f7e22c..84d7580 100644 --- a/src/main.py +++ b/src/main.py @@ -3,6 +3,8 @@ Kivy Signage Player - Main Application Displays content from DigiServer playlists using Kivy framework """ +PLAYER_VERSION = "1.2.0" + import os import json import platform @@ -918,7 +920,10 @@ 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_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.is_playing = False self.is_paused = False self.auto_resume_event = None # Track scheduled auto-resume @@ -1277,11 +1282,13 @@ class SignagePlayer(Widget): self.current_index = 0 self.play_current_media() - def play_current_media(self, force_reload=False): + def play_current_media(self, force_reload=False, _after_weblink=False): """Play the current media item Args: force_reload: If True, clears image cache before loading (for edited images) + _after_weblink: Internal flag — True when called after the weblink + dismiss delay so we skip the delay logic a second time. """ # Don't play if paused (unless we're explicitly resuming) if self.is_paused: @@ -1299,15 +1306,73 @@ class SignagePlayer(Widget): duration = media_item.get('duration', 10) Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)") - - # Close any kiosk browser left over from a previous web-link item - self._kill_weblink_process() + + # ── Weblink → media transition (Wayland-safe) ────────────────── + # On Wayland (Labwc) Window.raise_window() is a no-op, so we must + # ensure Chromium's fullscreen window is fully gone BEFORE we try + # to render Kivy content. We: + # 1. Stop watchdog + preload immediately + # 2. Terminate Chromium (non-blocking) + # 3. Hide the Kivy content_area so nothing stale is visible + # 4. Wait 200 ms for the compositor to remove Chromium's window + # 5. Show content_area + re-call play_current_media to render + if not _after_weblink: + proc = getattr(self, '_weblink_proc', None) + if proc is not None: + self._stop_inactivity_watchdog() + self._kill_weblink_preload() + self._weblink_proc = None + + # Hide content while Chromium is still closing + try: + self.ids.content_area.opacity = 0 + except Exception: + pass + + # Terminate Chromium + if proc.poll() is None: + try: + proc.terminate() + except Exception as exc: + Logger.warning(f"SignagePlayer: weblink terminate: {exc}") + + def _resume(dt): + # Force-kill if still alive after the delay + if proc.poll() is None: + try: + proc.kill() + except Exception: + pass + # Restore content area and show Kivy window + try: + self.ids.content_area.opacity = 1 + except Exception: + pass + try: + Window.show() + Window.raise_window() + except Exception: + pass + # Now render the actual media + self.play_current_media( + force_reload=force_reload, _after_weblink=True + ) + + # 200 ms gives Labwc time to remove the fullscreen surface + Clock.schedule_once(_resume, 0.2) + return + # ──────────────────────────────────────────────────────────────── # Handle web links before any file/path handling (no local file exists) if media_item.get('type') == 'weblink': Logger.debug("SignagePlayer: Media type: WEBLINK") self.ids.status_label.opacity = 0 self._remove_current_widget() + # Hide content_area — Chromium will cover it; avoids stale frame + try: + self.ids.content_area.opacity = 0 + except Exception: + pass started = self.play_weblink(media_item.get('url', ''), duration) if started: self.consecutive_errors = 0 @@ -1538,10 +1603,19 @@ class SignagePlayer(Widget): return False try: - Logger.info(f"SignagePlayer: Opening weblink in kiosk browser for {duration}s: {url}") + Logger.info(f"SignagePlayer: Opening weblink in kiosk browser: {url}") + Logger.info(f"SignagePlayer: Inactivity timeout set to {duration}s (touch resets the countdown)") + # 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, - '--kiosk', + # 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', '--app=' + url, '--noerrdialogs', '--disable-infobars', @@ -1549,25 +1623,184 @@ class SignagePlayer(Widget): '--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', ]) - # Advance after the configured duration. The kiosk browser is closed - # at the start of the next play_current_media() via _kill_weblink_process(). + # 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) - Clock.schedule_once(self.next_media, duration) + self._start_inactivity_watchdog(duration) # 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 + 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 + 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). + """ + 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' + ) + 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 + + 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. + """ + self._stop_inactivity_watchdog() + self._kill_weblink_preload() + + proc = self._weblink_proc # snapshot + self._weblink_proc = None # free the slot right away + + if proc is None or proc.poll() is not None: + # 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 process if one is running.""" + """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() + proc = getattr(self, '_weblink_proc', None) if proc is not None and proc.poll() is None: try: @@ -1581,6 +1814,14 @@ class SignagePlayer(Widget): Logger.warning(f"SignagePlayer: Error closing weblink browser: {e}") self._weblink_proc = None + # 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. @@ -1629,6 +1870,71 @@ class SignagePlayer(Widget): Clock.unschedule(self.next_media) self.play_current_media() + def _prewarm_weblink(self, url): + """Launch Chromium off-screen to warm up the binary and page cache. + + 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. + """ + 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 + + # 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', + '--window-size=1920,1080', # pre-render at full 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 + + 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: + try: + proc.terminate() + try: + proc.wait(timeout=3) + except Exception: + proc.kill() + Logger.debug("SignagePlayer: Killed weblink pre-warm process") + except Exception as exc: + Logger.debug(f"SignagePlayer: Error killing pre-warm process: {exc}") + self._weblink_preload_proc = None + def preload_next_media(self): """Preload the next media item asynchronously to improve transition smoothness @@ -1667,6 +1973,11 @@ 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}")