diff --git a/src/main.py b/src/main.py index 1449a94..0f72fb6 100644 --- a/src/main.py +++ b/src/main.py @@ -8,6 +8,7 @@ PLAYER_VERSION = "1.2.0" import os import json import platform +import signal import threading import time import asyncio @@ -35,6 +36,10 @@ from kivy.config import Config # Performance optimizations for video playback Config.set('kivy', 'keyboard_mode', '') # Disable default virtual keyboard +# Kiosk requirement: Escape must NEVER close the player. The only exit path +# is the password-protected exit button. This stays disabled even during +# development so exit behavior always mirrors production. +Config.set('kivy', 'exit_on_escape', '0') Config.set('graphics', 'fullscreen', '0') # Will be set to 1 later Config.set('graphics', 'window_state', 'maximized') # Maximize window @@ -85,6 +90,7 @@ from edit_popup import DrawingLayer, EditPopup 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 # 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') @@ -581,16 +587,8 @@ class ExitPasswordPopup(Popup): """Handle popup dismissal - resume playback and restart cursor hide timer""" # Hide and remove keyboard self.hide_keyboard() - - # Resume playback if it wasn't paused before - if not self.was_paused: - self.player.is_paused = False - # Resume video if it was playing - if self.player.current_widget and isinstance(self.player.current_widget, Video): - self.player.current_widget.state = 'play' - - # Restart the control hide timer - self.player.schedule_hide_controls() + # Resume playback and re-arm the media advance timer + self.player.resume_after_popup(self.was_paused) def check_password(self): """Check if entered password matches quickconnect key""" @@ -613,6 +611,8 @@ class ExitPasswordPopup(Popup): except Exception as e: Logger.warning(f"ExitPasswordPopup: Could not create stop flag: {e}") + # Allow the app to close (releases the production-mode close guard) + self.player.set_allow_exit(True) self.dismiss() App.get_running_app().stop() else: @@ -658,7 +658,10 @@ class SettingsPopup(Popup): self.ids.playlist_info.text = f'Playlist: v{self.player.playlist_version}' self.ids.media_count_info.text = f'Media: {len(self.player.playlist)}' self.ids.status_info.text = f'Status: {"Playing" if self.player.is_playing else "Paused" if self.player.is_paused else "Idle"}' - + + # Refresh the production-mode button to reflect the current state + self.update_production_button() + # Bind to dismiss event to manage cursor visibility and resume playback self.bind(on_dismiss=self.on_popup_dismiss) @@ -703,17 +706,45 @@ class SettingsPopup(Popup): """Handle popup dismissal - resume playback and restart cursor hide timer""" # Hide and remove keyboard self.hide_keyboard() - - # Resume playback if it wasn't paused before - if not self.was_paused: - self.player.is_paused = False - # Resume video if it was playing - if self.player.current_widget and isinstance(self.player.current_widget, Video): - self.player.current_widget.state = 'play' - - # Restart the control hide timer - self.player.schedule_hide_controls() + # Resume playback and re-arm the media advance timer + self.player.resume_after_popup(self.was_paused) + def update_production_button(self): + """Refresh the production-mode button to reflect the current state. + + Green when production (kiosk) mode is enabled, grey when disabled. + """ + production = bool(self.player.config.get('production_mode', False)) + btn = self.ids.production_mode_btn + if production: + btn.background_color = (0.2, 0.7, 0.2, 1) # green + btn.text = 'Production ON' + else: + btn.background_color = (0.4, 0.4, 0.4, 1) # grey + btn.text = 'Enable Production' + + def toggle_production_mode(self): + """Toggle production (kiosk) mode and apply the lockdown immediately.""" + production = not bool(self.player.config.get('production_mode', False)) + Logger.info( + f"SettingsPopup: {'Enabling' if production else 'Disabling'} " + "production mode" + ) + # Apply lockdown (exit_on_escape, close guard, Ctrl+C, platform hooks) + self.player.apply_kiosk_mode(production) + self.player.save_config() + self.update_production_button() + if production: + self._show_temp_message( + '✓ Production mode ENABLED — kiosk lockdown active', + (0, 1, 0, 1) + ) + else: + self._show_temp_message( + '✓ Production mode DISABLED — development mode', + (0.8, 0.8, 0.8, 1) + ) + def test_connection(self): """Test connection to server with current credentials""" # Update status label to show testing @@ -933,6 +964,16 @@ class SignagePlayer(Widget): self.auto_resume_event = None # Track scheduled auto-resume self.config = {} self.playlist_version = None + self._allow_exit = False # Set True by the password exit flow to allow the app to close + 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._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._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) self.consecutive_errors = 0 # Track consecutive playback errors self.max_consecutive_errors = 10 # Maximum errors before stopping @@ -958,6 +999,11 @@ class SignagePlayer(Widget): # Bind to window size for fullscreen Window.bind(size=self._update_size) self._update_size(Window, Window.size) + # Bind the window-close guard. In production mode it returns True + # (blocking X / Alt+F4 / any WM close) until the password exit flow + # calls set_allow_exit(True). In development mode it returns False so + # the window closes normally. + Window.bind(on_request_close=self._guard_window_close) # Initialize player Clock.schedule_once(self.initialize_player, 0.1) # Hide controls timer @@ -973,7 +1019,97 @@ class SignagePlayer(Widget): self.size = value if hasattr(self, 'ids') and 'content_area' in self.ids: self.ids.content_area.size = value - + + def _guard_window_close(self, *args, **kwargs): + """Block window-close attempts in production mode unless exit allowed. + + Bound to Window's `on_request_close` event. Kivy's SDL2 provider + dispatches this for the window close button / Alt+F4 / WM close. + Returning True cancels the close. The password exit flow calls + set_allow_exit(True) just before App.stop(), which lets the app + terminate even while production mode is active. + """ + if not self.config.get('production_mode', False): + return False # development mode — allow normal window close + if getattr(self, '_allow_exit', False): + return False # legitimate password-protected exit + Logger.info("SignagePlayer: Window close blocked (production mode)") + return True + + def set_allow_exit(self, allow=True): + """Allow the app to actually close (called by the password exit flow).""" + self._allow_exit = bool(allow) + + def resume_after_popup(self, was_paused): + """Resume playback and re-arm the media advance timer after a modal + popup (settings / exit-password) is dismissed. + + `show_settings()` / `show_exit_popup()` unschedule next_media while + the popup is open. Without re-arming here the current media would sit + forever once the popup closes, so the playlist stops advancing. + """ + # Always restart the control-hide timer. + self.schedule_hide_controls() + if was_paused: + return # user had already paused playback — stay paused + + self.is_paused = False + # Resume a paused video. + if self.current_widget and isinstance(self.current_widget, Video): + try: + self.current_widget.state = 'play' + except Exception: + pass + + # Weblinks are advanced by their own watchdog thread — don't touch. + if getattr(self, '_weblink_proc', None) is not None: + return + + # Re-arm the advance timer with the remaining time. + started = getattr(self, '_media_started_at', None) + duration = getattr(self, '_media_duration', 10) + remaining = duration + if started is not None: + remaining = duration - (time.monotonic() - started) + remaining = max(0.5, remaining) + Logger.debug( + f"SignagePlayer: Re-arming next_media in {remaining:.1f}s after popup" + ) + Clock.unschedule(self.next_media) + Clock.schedule_once(self.next_media, remaining) + + def apply_kiosk_mode(self, enabled): + """Enable/disable production (kiosk) lockdown. + + When enabled: + - Escape never exits the app (exit_on_escape stays 0). + - Window close (X / Alt+F4) is blocked by _guard_window_close until + the password exit flow calls set_allow_exit(True). + - Ctrl+C is ignored so the console cannot kill the player. + Platform wrappers (e.g. run_win.py) may extend this to also swallow + Alt+Tab / Win / Ctrl+Esc via a low-level keyboard hook. + """ + self.config['production_mode'] = bool(enabled) + if enabled: + # Defense in depth: ensure Escape can never close the player. + try: + Config.set('kivy', 'exit_on_escape', '0') + except Exception: + pass + # Ignore Ctrl+C in production — the console must not kill the app. + try: + signal.signal(signal.SIGINT, signal.SIG_IGN) + except Exception: + pass + Logger.info("SignagePlayer: PRODUCTION MODE ENABLED — kiosk lockdown active") + else: + # Development mode: restore the default Ctrl+C handler. + try: + signal.signal(signal.SIGINT, signal.default_int_handler) + except Exception: + pass + Logger.info("SignagePlayer: Production mode disabled — development mode") + def update_heartbeat(self, dt): """Update heartbeat file to indicate player is alive""" try: @@ -1046,7 +1182,10 @@ class SignagePlayer(Widget): # Load configuration self.load_config() - + + # Apply persisted production/kiosk mode (exit lockdown) if enabled + self.apply_kiosk_mode(self.config.get('production_mode', False)) + # Initialize network monitor self.start_network_monitoring() @@ -1077,7 +1216,8 @@ class SignagePlayer(Widget): "quickconnect_key": "1234567", "max_resolution": "auto", "use_https": True, - "verify_ssl": True + "verify_ssl": True, + "production_mode": False } self.save_config() Logger.info("SignagePlayer: Created default configuration with HTTPS enabled") @@ -1312,8 +1452,20 @@ class SignagePlayer(Widget): media_item = self.playlist[self.current_index] file_name = media_item.get('file_name', '') duration = media_item.get('duration', 10) + # Track start time + duration so a popup can re-arm the advance + # timer with the correct remaining time after dismissal. + self._media_started_at = time.monotonic() + self._media_duration = duration Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)") + trace("play_current_media", + index=self.current_index + 1, + total=len(self.playlist), + name=file_name, + type=media_item.get('type', '?'), + duration=duration, + 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, @@ -1373,6 +1525,7 @@ class SignagePlayer(Widget): # Handle web links before any file/path handling (no local file exists) if media_item.get('type') == 'weblink': 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 @@ -1381,6 +1534,7 @@ class SignagePlayer(Widget): except Exception: pass started = self.play_weblink(media_item.get('url', ''), duration) + trace("weblink_started", ok=bool(started)) if started: self.consecutive_errors = 0 if self.config: @@ -1420,10 +1574,12 @@ class SignagePlayer(Widget): if file_extension in ['.mp4', '.avi', '.mkv', '.mov', '.webm']: # Video file Logger.debug(f"SignagePlayer: Media type: VIDEO") + trace("starting_video", path=media_path) self.play_video(media_path, duration) elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']: # Image file Logger.debug(f"SignagePlayer: Media type: IMAGE") + trace("starting_image", path=media_path) self.play_image(media_path, duration, force_reload=force_reload) else: Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}") @@ -1451,10 +1607,12 @@ class SignagePlayer(Widget): # If we arrived here from a weblink item, close Chromium 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: + trace("closing_weblink_after_frame", name=file_name) self._kill_weblink_after_frame() except Exception as e: Logger.error(f"SignagePlayer: Error playing media: {e}") + trace("play_current_media_EXCEPTION", error=str(e)) self.consecutive_errors += 1 # Check if we've exceeded max errors @@ -1481,6 +1639,7 @@ 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 + self._video_source = video_path self.current_widget = Video( source=video_path, state='play', # Start playing immediately @@ -1506,7 +1665,25 @@ class SignagePlayer(Widget): # Add to content area self.ids.content_area.add_widget(self.current_widget) - # Schedule next media after duration (unschedule first to prevent overlaps) + # Start the focus keeper so the window stays foreground for the + # whole video duration (handles SDL surface swaps at load and any + # later re-swaps). The keeper is non-blocking. + self._start_focus_keeper(duration + 1) + trace("video_focus_keeper_started") + + # Start a progress watchdog. ffpyplayer does NOT reliably dispatch + # EOS — the trace shows the video looping its tail for the gap + # between its real end (~30s for sample-30s.mp4) and the fixed + # timer (31s). The watchdog polls the actual position and + # force-advances as soon as the video really ends. + self._video_watchdog_stopped = False + self._video_watchdog_event = Clock.schedule_interval( + self._video_watchdog_tick, 0.5 + ) + trace("video_watchdog_started", duration=duration) + + # Schedule a safety-net advance after the playlist 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) @@ -1519,20 +1696,155 @@ class SignagePlayer(Widget): self.consecutive_errors += 1 self._skip_to_next_media() + def _video_watchdog_tick(self, dt): + """Watch the current video's progress and force-advance at its end. + + ffpyplayer sometimes fails to dispatch EOS, leaving the video looping + its last frames for the gap between the true clip end and the playlist + timer. This polls position vs duration and advances as soon as the + video actually finishes, so the tail-loop never shows. + """ + if self._video_watchdog_stopped: + return + w = getattr(self, 'current_widget', None) + if not isinstance(w, Video) or w.source != getattr(self, '_video_source', None): + self._stop_video_watchdog() + return + try: + duration = getattr(w, 'duration', 0.0) or 0.0 + position = getattr(w, 'position', -1) or 0.0 + if duration > 0 and position >= duration - 0.4: + trace( + "video_watchdog_advance", + pos=round(position, 2), + dur=round(duration, 2), + ) + self._stop_video_watchdog() + Clock.unschedule(self.next_media) + Clock.schedule_once(self._advance_after_video_eos, 0.1) + except Exception: + pass + + def _stop_video_watchdog(self): + """Cancel the video progress watchdog.""" + self._video_watchdog_stopped = True + ev = getattr(self, '_video_watchdog_event', None) + if ev is not None: + try: + Clock.unschedule(ev) + except Exception: + pass + self._video_watchdog_event = None + + def _bring_window_to_front_nonblocking(self): + """Bring the Kivy window forward WITHOUT stalling the UI. + + 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. + """ + def _do(): + try: + from kivy.core.window import Window as _KivyWindow + _KivyWindow.raise_window() + except Exception: + pass + try: + _bring = getattr(self, '_bring_kivy_to_front_win', None) + if _bring is not None: + _bring() + except Exception: + pass + threading.Thread(target=_do, daemon=True, name='focus-bring-front').start() + + 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. + """ + self._stop_focus_keeper() + self._focus_keeper_elapsed = 0.0 + self._focus_keeper_duration = float(duration) + self._focus_keeper_event = Clock.schedule_interval( + self._focus_keeper_tick, 0.5 + ) + trace("focus_keeper_started", duration=duration) + + def _focus_keeper_tick(self, dt): + """One focus-keeper tick: cheap check, heavy action only if needed.""" + self._focus_keeper_elapsed += dt + if self._focus_keeper_elapsed > self._focus_keeper_duration: + self._stop_focus_keeper() + return + # Cheap foreground check first — avoids the 1.5s Win32 work entirely + # when the window already has focus. + try: + check = getattr(self, '_is_foreground_win', None) + if check is not None and check(): + return # already focused, nothing to do + except Exception: + pass + trace("focus_keeper_focus_lost") + self._bring_window_to_front_nonblocking() + + def _stop_focus_keeper(self): + """Cancel any active focus keeper interval.""" + ev = getattr(self, '_focus_keeper_event', None) + if ev is not None: + try: + Clock.unschedule(ev) + except Exception: + pass + self._focus_keeper_event = None + self._focus_keeper_elapsed = 0.0 + def _on_video_eos(self, instance): - """Callback when video reaches end of stream""" + """Callback when video reaches end of stream. + + Guarded against re-entrancy: ffpyplayer can dispatch EOS more than once + for a single video (thread + main-thread paths), and duplicate advance + schedules caused skipped items / crashes on later playlist loops. + """ + if self._video_eos_pending: + trace("video_eos_IGNORED_duplicate", state=getattr(instance, 'state', '?')) + return # already handled for this video + self._video_eos_pending = True + self._stop_video_watchdog() Logger.debug("SignagePlayer: Video finished playing (EOS)") - # Unschedule any pending timer and advance to next media + trace("video_eos", state=getattr(instance, 'state', '?')) + # NOTE: do NOT set instance.state = 'stop' here on the main thread — + # in Kivy that triggers VideoFFPy.stop() -> unload() -> thread.join(), + # a blocking call that freezes the UI. The teardown worker thread in + # _remove_current_widget does the stop+unload off the main thread. + # Unschedule any pending timer and advance to next media exactly once Clock.unschedule(self.next_media) - Clock.schedule_once(self.next_media, 0.5) + Clock.schedule_once(self._advance_after_video_eos, 0.5) + + def _advance_after_video_eos(self, dt): + """Advance to the next media after a video ended (single-fire).""" + self._video_eos_pending = False + self._stop_video_watchdog() + trace("advance_after_video_eos", index=self.current_index) + self.next_media() def _on_video_loaded(self, instance, value): - """Callback when video is loaded - log video information""" + """Callback when the video's first frame is decoded and loaded. + + The SDL surface swap at this moment can drop the window from the + foreground. The periodic focus keeper (started in play_video) handles + keeping it focused; here we just log it. + """ if value: try: Logger.debug(f"SignagePlayer: Video loaded: {instance.texture.size if instance.texture else 'No texture'}, {instance.duration}s") except Exception as e: Logger.debug(f"SignagePlayer: Could not log video info: {e}") + trace("video_loaded") def play_image(self, image_path, duration, force_reload=False): """Play an image file""" @@ -1574,20 +1886,79 @@ class SignagePlayer(Widget): self.consecutive_errors += 1 self._skip_to_next_media() + def _teardown_video_async(self, widget): + """Stop and unload a finished video OFF the main thread. + + CRITICAL: Kivy's `Video.state = 'stop'` calls VideoFFPy.stop() -> + unload() -> self._thread.join(), which BLOCKS until the ffpyplayer + decode thread exits. That join is variable (0.4s up to 100s+) and was + freezing the whole UI thread at every video->next transition (see + playback_trace.log gaps between remove_current_widget and + widget_removed_async). Doing the full stop+unload on a worker thread + keeps the UI responsive. + """ + try: + # Rebind the same widget's EOS is not needed; just stop+unload. + widget.state = 'stop' + except Exception: + pass + try: + widget.unload() + except Exception: + pass + def _remove_current_widget(self): """Stop and remove the current Kivy media widget if one is present.""" + trace("remove_current_widget", + has=bool(self.current_widget), + is_video=isinstance(self.current_widget, Video)) if self.current_widget: # Properly stop video if it's playing to prevent resource leaks if isinstance(self.current_widget, Video): try: Logger.debug("SignagePlayer: Stopping previous video widget...") - self.current_widget.state = 'stop' - self.current_widget.unload() + # Unbind EOS first so stopping/unloading cannot re-trigger + # the transition callback (caused double-advance / crashes + # at the video -> next-media boundary). + try: + self.current_widget.unbind(on_eos=self._on_video_eos) + except Exception: + pass + # Do NOT set state='stop' on the main thread — in Kivy that + # triggers VideoFFPy.stop() -> unload() -> thread.join(), a + # blocking call that froze the UI for up to 100s+ (see + # playback_trace.log gaps between remove_current_widget and + # widget_removed_async). Detach immediately and let the + # worker thread do the full stop+unload. + try: + self.ids.content_area.remove_widget(self.current_widget) + except Exception: + pass + widget = self.current_widget + self.current_widget = None + self._video_eos_pending = False + self._stop_focus_keeper() + self._stop_video_watchdog() + threading.Thread( + target=self._teardown_video_async, + args=(widget,), daemon=True, + name='video-teardown' + ).start() + trace("widget_removed_async") + return except Exception as e: Logger.warning(f"SignagePlayer: Error stopping video: {e}") - self.ids.content_area.remove_widget(self.current_widget) + try: + self.ids.content_area.remove_widget(self.current_widget) + except Exception: + pass self.current_widget = None + # Reset the EOS guard so the next video can advance normally. + self._video_eos_pending = False + self._stop_focus_keeper() + self._stop_video_watchdog() 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. @@ -1887,11 +2258,27 @@ class SignagePlayer(Widget): Clock.schedule_once(self.next_media, 1) def next_media(self, dt=None): - """Move to next media item""" + """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. + """ + trace("next_media_called", + was_index=self.current_index, + paused=self.is_paused) if self.is_paused: Logger.info(f"SignagePlayer: ⏸ Blocked next_media - player is paused") + 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)) + return + self._last_advance_at = now + Logger.info(f"SignagePlayer: Transitioning to next media (was index {self.current_index})") self.current_index += 1 @@ -2075,8 +2462,10 @@ class SignagePlayer(Widget): def restart_playlist(self): """Restart playlist from beginning""" + trace("restart_playlist", count=len(self.playlist)) if not self.playlist: Logger.warning("SignagePlayer: Cannot restart - playlist is empty") + trace("restart_playlist_EMPTY") return Logger.info("SignagePlayer: Restarting playlist") @@ -2299,6 +2688,7 @@ class SignagePlayer(Widget): def exit_app(self, instance=None): """Exit the application""" Logger.info("SignagePlayer: Exiting application") + self.set_allow_exit(True) App.get_running_app().stop() diff --git a/src/playback_trace.py b/src/playback_trace.py new file mode 100644 index 0000000..9f7397d --- /dev/null +++ b/src/playback_trace.py @@ -0,0 +1,64 @@ +""" +playback_trace.py — Always-on playback transition logger. + +Kivy's log level is forced to 'warning' in main.py / run_win.py, which +suppresses every Logger.info()/Logger.debug() line. That made it impossible +to see why the player skips/crashes at the weblink->image and video->next +transitions. + +This module writes a plain-text trace file (logs/playback_trace.log) with +timestamps, INDEPENDENT of Kivy's log level, so we can always see exactly +what the player is doing. It is thread-safe (a lock guards the append) and +never throws (all failures are swallowed) so it can never break playback. + +Usage: + from playback_trace import trace + trace("play_current_media", index=3, name="foo.jpg", type="image") +""" + +import os +import threading +import time + +_LOCK = threading.Lock() +_LOG_PATH = None +_OPENED = False + + +def _ensure_path(): + global _LOG_PATH, _OPENED + if _OPENED: + return _LOG_PATH + _OPENED = True + try: + # Respect the local data dir the launcher set (same place as logs/). + base = os.environ.get('KIWY_DATA_DIR') or os.getcwd() + log_dir = os.path.join(base, 'logs') + os.makedirs(log_dir, exist_ok=True) + _LOG_PATH = os.path.join(log_dir, 'playback_trace.log') + except Exception: + _LOG_PATH = None + return _LOG_PATH + + +def trace(event, **kwargs): + """Append one line to the playback trace log. + + Args: + event: short event name, e.g. 'next_media', 'eos', 'web_open'. + **kwargs: key=value context, e.g. index=3, name='foo.jpg'. + """ + try: + path = _ensure_path() + if not path: + return + t = time.strftime('%H:%M:%S') + ms = int((time.time() % 1) * 1000) + parts = [f"{t}.{ms:03d}", event] + for k, v in kwargs.items(): + parts.append(f"{k}={v}") + with _LOCK: + with open(path, 'a', encoding='utf-8') as f: + f.write(" ".join(parts) + "\n") + except Exception: + pass # tracing must never break the player diff --git a/src/signage_player.kv b/src/signage_player.kv index 2492c4b..878feba 100644 --- a/src/signage_player.kv +++ b/src/signage_player.kv @@ -598,15 +598,26 @@ font_size: sp(12) on_press: root.restart_player() - # Test Connection Button - Button: - id: test_connection_btn - text: 'Test Server Connection' + # Test Connection + Production Mode Buttons + BoxLayout: + orientation: 'horizontal' size_hint_y: None height: dp(44) - background_color: 0.2, 0.4, 0.8, 1 - font_size: sp(13) - on_press: root.test_connection() + spacing: dp(8) + + Button: + id: test_connection_btn + text: 'Test Server Connection' + background_color: 0.2, 0.4, 0.8, 1 + font_size: sp(13) + on_press: root.test_connection() + + Button: + id: production_mode_btn + text: 'Enable Production' + background_color: 0.4, 0.4, 0.4, 1 # grey = disabled + font_size: sp(13) + on_press: root.toggle_production_mode() # Connection Status Label Label: diff --git a/windows/app_icon.ico b/windows/app_icon.ico new file mode 100644 index 0000000..9a95f3b Binary files /dev/null and b/windows/app_icon.ico differ diff --git a/windows/build.spec b/windows/build.spec index 8f73310..4269539 100644 --- a/windows/build.spec +++ b/windows/build.spec @@ -285,7 +285,8 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, - icon=str(RESOURCES_DIR / 'app_icon.ico') if (RESOURCES_DIR / 'app_icon.ico').exists() else None, + icon=str(BUILD_DIR / 'app_icon.ico') if (BUILD_DIR / 'app_icon.ico').exists() else None, + version=str(BUILD_DIR / 'version_info.txt') if (BUILD_DIR / 'version_info.txt').exists() else None, ) # --- COLLECT everything into a single folder ------------------------- diff --git a/windows/development-track.md b/windows/development-track.md index 06dceb6..5064014 100644 --- a/windows/development-track.md +++ b/windows/development-track.md @@ -76,7 +76,21 @@ in `windows/run_win.py`. All are covered except the one listed below: 2. `_hide_overlay()` now calls **`_bring_chrome_to_front(proc)`** (new helper that enumerates `Chrome_WidgetWin_1/0` windows owned by the launched PID) instead of raising Kivy. -- **Test:** exe rebuilt 2026-07-31 13:53; DLL set intact (28 DLLs incl. FFmpeg). +- **Update (2026-07-31 15:42):** added **`--kiosk`** flag to the weblink launch + args so the browser opens in true kiosk mode (no UI/chrome, locks to screen). + Safe with the dedicated `--user-data-dir` — does not affect the user's normal + browser session. +- **Update (2026-07-31 16:04):** replaced the fixed 1.0s overlay-hide timer with + **adaptive polling** (`_hide_overlay_when_chrome_ready`). The black overlay now + stays up until Chrome's window is actually detected on screen + (`_find_chrome_hwnd`), so the host desktop is never exposed during cold + starts / slow disk / GPU init. Falls back to Kivy after a 6s timeout. +- **Update (2026-07-31 16:19):** added a **persistent `_Win32Backdrop`** — a + fullscreen black window created at player startup (`_Win32Backdrop.show()`) + placed at `HWND_BOTTOM` (below Kivy & the kiosk browser, above the desktop), + destroyed only on clean exit. Any browser load/unload gap now reveals clean + black instead of the host desktop. +- **Test:** exe rebuilt 2026-07-31 16:19; DLL set intact (28 DLLs incl. FFmpeg). ### [BUG-012] Next widget never comes to foreground after weblink ends - **Status:** ✅ **Fixed — 2026-07-31** diff --git a/windows/run_win.py b/windows/run_win.py index 7852881..7b6ecce 100644 --- a/windows/run_win.py +++ b/windows/run_win.py @@ -92,23 +92,95 @@ sys.modules['evdev.InputDevice'] = _FakeEvdevInputDevice # We'll store a reference to the original module's signal_screen_activity # so we can replace it after import. This is done inside _patch_main(). -def _windows_screen_activity(self, dt): - """Windows alternative to Linux screen-keep-awake commands. +# Keep-awake state so we can restore the screensaver on exit. +_SAVED_SCREENSAVER_ACTIVE = None # True/False once read; None = unknown - Uses SetThreadExecutionState via ctypes to tell Windows to keep - the display and system awake. + +def _disable_windows_screensaver(): + """Disable the Windows screensaver so the lock screen never appears. + + On Windows the lock screen is tied to the screensaver: when the screen + 'turns off' or the screensaver runs with 'On resume, display logon + screen', Windows shows the lock. Disabling the screensaver and keeping + the display awake (SetThreadExecutionState ES_DISPLAY_REQUIRED) prevents + both the blank screen and the lock screen. + """ + global _SAVED_SCREENSAVER_ACTIVE + try: + user32 = ctypes.windll.user32 + SPI_GETSCREENSAVEACTIVE = 0x0010 + SPI_SETSCREENSAVEACTIVE = 0x0011 + SPI_SETSCREENSAVERUNSAFE = 0x0013 + SPIF_SENDCHANGE = 0x2 + + user32.SystemParametersInfoW.argtypes = [ + ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_uint + ] + user32.SystemParametersInfoW.restype = ctypes.c_int + + # Remember the original screensaver state once, so we can restore it + # when the app exits. + if _SAVED_SCREENSAVER_ACTIVE is None: + pval = ctypes.c_int(0) + if user32.SystemParametersInfoW(SPI_GETSCREENSAVEACTIVE, 0, + ctypes.byref(pval), 0): + _SAVED_SCREENSAVER_ACTIVE = bool(pval.value) + + # Disable the screensaver (uiParam=0) and mark it safe to toggle + # without a password prompt (SPI_SETSCREENSAVERUNSAFE). + user32.SystemParametersInfoW(SPI_SETSCREENSAVEACTIVE, 0, 0, + SPIF_SENDCHANGE) + user32.SystemParametersInfoW(SPI_SETSCREENSAVERUNSAFE, 0, 0, + SPIF_SENDCHANGE) + except Exception: + pass # non-critical + + +def _restore_windows_screensaver(): + """Restore the screensaver state the app found at startup.""" + global _SAVED_SCREENSAVER_ACTIVE + if _SAVED_SCREENSAVER_ACTIVE is None: + return + try: + user32 = ctypes.windll.user32 + SPI_SETSCREENSAVEACTIVE = 0x0011 + SPIF_SENDCHANGE = 0x2 + user32.SystemParametersInfoW.argtypes = [ + ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_uint + ] + user32.SystemParametersInfoW( + SPI_SETSCREENSAVEACTIVE, + 1 if _SAVED_SCREENSAVER_ACTIVE else 0, 0, SPIF_SENDCHANGE) + _SAVED_SCREENSAVER_ACTIVE = None + except Exception: + pass + + +def _windows_screen_activity(self, dt): + """Windows keep-awake: prevent display-off, sleep AND lock screen. + + SetThreadExecutionState(ES_CONTINUOUS|ES_SYSTEM_REQUIRED|ES_DISPLAY_REQUIRED) + tells Windows the system and display must stay on. Combined with disabling + the screensaver (SystemParametersInfo), this prevents: + - the display turning off, + - the machine sleeping, + - the lock screen (which appears when the screen 'turns off' or the + screensaver runs with logon-on-resume). + Called every ~20s by the existing Clock.schedule_interval. """ try: - # ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED | ES_CONTINUOUS + # ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED ES_CONTINUOUS = 0x80000000 ES_SYSTEM_REQUIRED = 0x00000001 ES_DISPLAY_REQUIRED = 0x00000002 - ctypes.windll.kernel32.SetThreadExecutionState( ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED ) except Exception: pass # non-critical + # Disable the screensaver / lock screen (re-asserted every tick in case + # the OS or another process re-enabled it). + _disable_windows_screensaver() # ── Try to import the embedded CEF browser ────────────────────────── @@ -166,10 +238,13 @@ def _windows_find_browser(): class _Win32Overlay: """Fullscreen black overlay window to mask desktop during transitions. - When switching away from Chromium, the browser window disappears and - there is a brief moment where the desktop is visible before Kivy - manages to bring its window to the front. This overlay covers that - flash with a pure-black borderless always-on-top Win32 window. + When switching to/away from Chromium, the browser window appears/disappears + 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. """ _hwnd = None @@ -258,6 +333,74 @@ class _Win32Overlay: cls._hwnd = None +class _Win32Backdrop: + """Persistent fullscreen black window shown at player startup. + + Sits just above the host desktop but BELOW the Kivy window and the kiosk + browser (placed at HWND_BOTTOM). Because it stays up for the whole session, + any gap while the weblink browser loads or unloads reveals this clean black + screen instead of the host desktop — no more desktop flash during the + browser load/unload transitions. + """ + + _hwnd = None + + @classmethod + def show(cls): + """Create (once) the fullscreen black backdrop above the desktop.""" + if cls._hwnd is not None: + return # already showing + try: + user32 = ctypes.windll.user32 + kernel32 = ctypes.windll.kernel32 + hinstance = kernel32.GetModuleHandleW(None) + screen_w = user32.GetSystemMetrics(0) # SM_CXSCREEN + screen_h = user32.GetSystemMetrics(1) # SM_CYSCREEN + + hwnd = user32.CreateWindowExW( + 0x00000080, # WS_EX_TOOLWINDOW (no taskbar entry) + b'#32770', # dialog class (always available) + b'KiwyBackdrop', + 0x80000000 | 0x10000000, # WS_POPUP | WS_VISIBLE + 0, 0, screen_w, screen_h, + 0, 0, hinstance, 0, + ) + if not hwnd: + return + + # Paint it black + gdi32 = ctypes.windll.gdi32 + hdc = user32.GetDC(hwnd) + rect = (ctypes.c_long * 4)(0, 0, screen_w, screen_h) + brush = gdi32.CreateSolidBrush(0x00000000) # black brush + gdi32.FillRect(hdc, ctypes.byref(rect), brush) + gdi32.DeleteObject(brush) + user32.ReleaseDC(hwnd, hdc) + + # Keep it BELOW the app windows (HWND_BOTTOM = 1) so Kivy and the + # kiosk browser draw on top, but still above the desktop. + user32.SetWindowPos( + hwnd, 1, 0, 0, screen_w, screen_h, + 0x0002 | 0x0040, # SWP_NOMOVE | SWP_SHOWWINDOW + ) + user32.ShowWindow(hwnd, 1) + user32.UpdateWindow(hwnd) + cls._hwnd = hwnd + except Exception: + cls._hwnd = None # failed gracefully + + @classmethod + def hide(cls): + """Destroy the backdrop (only at application exit).""" + if cls._hwnd is None: + return + try: + ctypes.windll.user32.DestroyWindow(cls._hwnd) + except Exception: + pass + cls._hwnd = None + + # Win32 constants used directly (avoid `import win32con` — win32con is a # pure-Python module in win32\\lib\\ that PyInstaller does NOT bundle because # it is only reachable through the pywin32.pth file, which frozen apps ignore). @@ -273,6 +416,194 @@ _HWND_NOTOPMOST = -2 _GWL_EXSTYLE = -20 _WS_EX_TOPMOST = 0x00000008 +# ── Low-level keyboard lockdown (production / kiosk mode) ─────────── +# WH_KEYBOARD_LL constants and virtual-key codes used to swallow host +# shortcuts (Alt+F4, Alt+Tab, Win, Ctrl+Esc) while the player is the +# only thing the operator should interact with. +_WH_KEYBOARD_LL = 13 +_WM_KEYDOWN = 0x0100 +_WM_KEYUP = 0x0101 +_WM_SYSKEYDOWN = 0x0104 +_WM_SYSKEYUP = 0x0105 +_HC_ACTION = 0 +_VK_TAB = 0x09 +_VK_ESCAPE = 0x1B +_VK_LWIN = 0x5B +_VK_RWIN = 0x5C +_VK_F4 = 0x73 +_VK_LCONTROL = 0xA2 +_VK_RCONTROL = 0xA3 +_VK_LMENU = 0xA4 # left Alt +_VK_RMENU = 0xA5 # right Alt + +# Holds the Win32 state for the active keyboard hook (installed while +# production mode is ON). Kept at module scope so the hook proc can be +# referenced without being garbage collected. +_KB_HOOK = { + 'proc': None, + 'handle': None, + 'active': False, +} + + +def _kb_hook_callback(nCode, wParam, lParam): + """Low-level keyboard hook callback. + + Called on the thread that installed the hook for every keyboard event. + We swallow the host-level shortcuts that would let the operator escape + the kiosk player: + - Alt+F4 (close the player / focus-steal) + - Alt+Tab (switch to another app) + - Ctrl+Esc (open Start menu) + - Windows key (open Start menu) + - Alt+Escape (cycle windows) + Returns 1 (consume) for those keys, otherwise passes the event through. + + NOTE: this runs inside a ctypes callback. If it raises, the exception + crosses the native boundary and can crash the process, so every path is + guarded and the hook always forwards with CallNextHookEx. + """ + try: + if nCode == _HC_ACTION: + vk_code = ctypes.cast( + lParam, ctypes.POINTER(ctypes.c_ulong) + ).contents.value & 0xFFFF + # Full key state so we can detect modifier combos reliably. + keys = { + 'lctrl': _is_key_down(_VK_LCONTROL), + 'rctrl': _is_key_down(_VK_RCONTROL), + 'lalt': _is_key_down(_VK_LMENU), + 'ralt': _is_key_down(_VK_RMENU), + 'lwin': _is_key_down(_VK_LWIN), + 'rwin': _is_key_down(_VK_RWIN), + } + ctrl = keys['lctrl'] or keys['rctrl'] + alt = keys['lalt'] or keys['ralt'] + win = keys['lwin'] or keys['rwin'] + + # Block the dangerous host shortcuts. + if vk_code == _VK_F4 and alt: + return 1 # Alt+F4 + if vk_code == _VK_TAB and alt: + return 1 # Alt+Tab + if vk_code == _VK_ESCAPE and ctrl: + return 1 # Ctrl+Esc + if vk_code == _VK_ESCAPE and alt: + return 1 # Alt+Esc + if win: + return 1 # Windows key (left or right) + + # NOTE: Ctrl+Alt+Delete (SAS) is handled by the OS before any + # user-mode hook can see it — it cannot be blocked from here. + except Exception: + # Never let a callback exception cross the native boundary. + pass + try: + return ctypes.windll.user32.CallNextHookEx( + _KB_HOOK['handle'], nCode, wParam, lParam + ) + except Exception: + return 1 # last resort: consume rather than crash + + +def _is_key_down(vk): + """Return True if the given virtual-key is currently pressed.""" + try: + state = ctypes.windll.user32.GetAsyncKeyState(vk) + # 0x8000 = most significant bit set (key is down) + return bool(state & 0x8000) + except Exception: + return False + + +def _install_kb_lockdown(): + """Install the low-level keyboard hook for kiosk mode.""" + global _KB_HOOK + if _KB_HOOK['active']: + return + try: + user32 = ctypes.windll.user32 + HOOKPROC = ctypes.WINFUNCTYPE( + ctypes.c_long, ctypes.c_int, ctypes.c_uint, ctypes.c_ulong + ) + proc = HOOKPROC(_kb_hook_callback) + hmodule = ctypes.windll.kernel32.GetModuleHandleW(None) + handle = user32.SetWindowsHookExW( + _WH_KEYBOARD_LL, proc, hmodule, 0 + ) + if not handle: + return False + _KB_HOOK['proc'] = proc + _KB_HOOK['handle'] = handle + _KB_HOOK['active'] = True + return True + except Exception: + return False + + +def _uninstall_kb_lockdown(): + """Remove the low-level keyboard hook (dev mode).""" + global _KB_HOOK + if not _KB_HOOK['active']: + return + try: + if _KB_HOOK['handle']: + ctypes.windll.user32.UnhookWindowsHookEx(_KB_HOOK['handle']) + except Exception: + pass + _KB_HOOK['handle'] = None + _KB_HOOK['proc'] = None + _KB_HOOK['active'] = False + + +def _windows_apply_kiosk_mode(self, enabled): + """Windows-specific kiosk lockdown in addition to the base logic. + + Installs/uninstalls the low-level keyboard hook that swallows + Alt+F4, Alt+Tab, Win, Ctrl+Esc while the player is in production + mode. Also calls the base implementation for the cross-platform + pieces (exit_on_escape, on_request_close guard, Ctrl+C ignore). + """ + # Call the base (cross-platform) kiosk logic first. + base_apply = getattr( + _patch_main, '_base_apply_kiosk_mode', None + ) or _base_apply_kiosk_mode + base_apply(self, enabled) + + if enabled: + _install_kb_lockdown() + Logger.info( + "run_win: Windows keyboard lockdown ACTIVE " + "(Alt+F4/Alt+Tab/Win/Ctrl+Esc swallowed)" + ) + else: + _uninstall_kb_lockdown() + Logger.info("run_win: Windows keyboard lockdown DISABLED") + + +# Default cross-platform kiosk applier (kept here so the main-module patch +# can reference it; the real implementation lives in main.py, and we simply +# forward to it when the patched method is not available). +def _base_apply_kiosk_mode(self, enabled): + self.config['production_mode'] = bool(enabled) + if enabled: + try: + from kivy.config import Config + Config.set('kivy', 'exit_on_escape', '0') + except Exception: + pass + try: + import signal + signal.signal(signal.SIGINT, signal.SIG_IGN) + except Exception: + pass + else: + try: + import signal + signal.signal(signal.SIGINT, signal.default_int_handler) + except Exception: + pass + def _bring_hwnd_to_front(hwnd): """Force a Win32 window to the foreground using only ctypes. @@ -341,6 +672,24 @@ def _find_kivy_hwnd(): return hwnd_list[-1] if hwnd_list else None +def _is_kivy_foreground(): + """Return True if the Kivy/SDL window is the foreground window. + + Cheap check (single GetForegroundWindow + class compare) so callers can + skip the expensive bring-to-front work when the window is already focused. + """ + try: + import win32gui + fg = win32gui.GetForegroundWindow() + if not fg: + return False + return win32gui.GetClassName(fg) == 'SDL_app' + except Exception: + # If win32gui is unavailable, conservatively say "not foreground" so + # the keeper will call the fallback raise (harmless). + return False + + def _bring_kivy_to_front(): """Bring the Kivy/SDL window to the foreground. @@ -363,16 +712,19 @@ def _bring_kivy_to_front(): pass -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).""" +def _find_chrome_hwnd(proc): + """Find the visible top-level HWND of a launched Chrome/Edge process. + + Returns the HWND if found, otherwise None. Enumerates top-level windows + owned by the given process and matches Chrome/Edge window classes. + """ if proc is None: - return + return None try: import win32gui import win32process except Exception: - return + return None target_pid = proc.pid chrome_hwnd = None @@ -391,7 +743,7 @@ def _bring_chrome_to_front(proc): cls = win32gui.GetClassName(hwnd) except Exception: return - # Chrome's top-level window is class 'Chrome_WidgetWin_1' (or 0) + # Chrome/Edge top-level window classes if cls in ('Chrome_WidgetWin_1', 'Chrome_WidgetWin_0', 'ApplicationFrameWindow'): if win32gui.IsWindowVisible(hwnd): chrome_hwnd = hwnd @@ -400,21 +752,67 @@ def _bring_chrome_to_front(proc): win32gui.EnumWindows(_enum_cb, None) except Exception: pass + 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) - try: - win32gui.EnumWindows(_enum_cb, None) - except Exception: - pass + 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. @@ -482,9 +880,11 @@ def _patch_main(): from kivy.clock import Clock from urllib.parse import urlparse + 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 @@ -493,6 +893,7 @@ def _patch_main(): 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]) try: self.ids.content_area.opacity = 0 except Exception: @@ -523,6 +924,7 @@ def _patch_main(): 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) ──────────── @@ -540,6 +942,7 @@ def _patch_main(): 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 @@ -551,6 +954,7 @@ def _patch_main(): pass _Win32Overlay.show() + 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 @@ -591,29 +995,41 @@ def _patch_main(): url, ], shell=False) - # Hide the black overlay, then bring CHROME to the front — NOT - # Kivy. Kivy is a borderless fullscreen window; if we raise Kivy - # here the weblink would open *behind* it and never be visible. + # 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 - - def _hide_overlay(dt): - _Win32Overlay.hide() - _bring_chrome_to_front(weblink_proc) - Clock.schedule_once(_hide_overlay, 1.0) + _hide_overlay_when_chrome_ready(weblink_proc, timeout=6.0) 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 except Exception as e: Logger.error(f"SignagePlayer: Error opening weblink: {e}") + trace("win_weblink_EXCEPTION", error=str(e)) _Win32Overlay.hide() self.consecutive_errors += 1 self._skip_to_next_media() return False # Replace weblink handling + # ── Give play_video a hook to re-assert the Kivy window to the front ── + # The main module calls `self._bring_kivy_to_front_win` (if present) right + # after adding a video widget, so the image -> video transition never lets + # the host desktop steal the foreground. It also exposes a CHEAP foreground + # check so the focus keeper can skip the expensive bring-to-front work + # whenever the window is already focused. + signage_main.SignagePlayer._bring_kivy_to_front_win = staticmethod( + lambda: _bring_kivy_to_front() + ) + signage_main.SignagePlayer._is_foreground_win = staticmethod( + lambda: _is_kivy_foreground() + ) + signage_main.SignagePlayer.play_weblink = _windows_play_weblink # Patch the _get_browser_target_size to always return a reasonable size on Windows @@ -677,6 +1093,7 @@ def _patch_main(): 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() @@ -690,9 +1107,11 @@ def _patch_main(): 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 @@ -701,14 +1120,17 @@ def _patch_main(): 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 ──────── @@ -831,6 +1253,27 @@ def _patch_main(): signage_main.SettingsPopup.test_connection = _windows_test_connection + # ── Patch apply_kiosk_mode for Windows ────────────────────────── + # Wrap the base implementation (exit_on_escape + close guard + Ctrl+C) + # and add the low-level keyboard hook that swallows Alt+F4 / Alt+Tab / + # Win / Ctrl+Esc while production mode is active. + _base_apply = signage_main.SignagePlayer.apply_kiosk_mode + + def _windows_apply_kiosk_mode_patch(self, enabled): + """Windows kiosk lockdown = base logic + keyboard hook.""" + _base_apply(self, enabled) + if enabled: + _install_kb_lockdown() + Logger.info( + "SignagePlayer: Windows keyboard lockdown ACTIVE " + "(Alt+F4/Alt+Tab/Win/Ctrl+Esc swallowed)" + ) + else: + _uninstall_kb_lockdown() + Logger.info("SignagePlayer: Windows keyboard lockdown DISABLED") + + signage_main.SignagePlayer.apply_kiosk_mode = _windows_apply_kiosk_mode_patch + return signage_main @@ -892,6 +1335,15 @@ if __name__ == '__main__': except Exception: pass + # Show the persistent black backdrop BEFORE Kivy initializes so the + # host desktop is never visible during startup or browser transitions. + _Win32Backdrop.show() + + # Keep the display and system awake and disable the screensaver/lock + # screen from the very start (before Kivy even initializes), so the + # host never blanks, sleeps or locks while the player is up. + _disable_windows_screensaver() + # Apply all Windows patches before launching try: patched_main = _patch_main() @@ -997,6 +1449,8 @@ if __name__ == '__main__': sys.exit(1) finally: Logger.info("Application shutdown complete") + _restore_windows_screensaver() # restore original screensaver state + _Win32Backdrop.hide() # remove backdrop on clean exit except BaseException as _top_e: # Catch any error BEFORE Logger is available (including SystemExit) import traceback as _tb diff --git a/windows/version_info.txt b/windows/version_info.txt new file mode 100644 index 0000000..aab1552 --- /dev/null +++ b/windows/version_info.txt @@ -0,0 +1,43 @@ +# UTF-8 +# +# Windows version resource for KiwySignagePlayer.exe +# This file is used by PyInstaller (version=) to embed publisher/product +# metadata into the executable so Windows Smart App Control / SmartScreen +# can identify the app instead of flagging it as "Unknown publisher". +# +# Note: A code-signing certificate is still required for a fully trusted +# publisher name; this metadata at least names the product/company and +# supplies a version number. +# +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(1, 2, 0, 0), + prodvers=(1, 2, 0, 0), + mask=0x3f, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + '040904B0', + [ + StringStruct('CompanyName', 'Kiwy Signage'), + StringStruct('FileDescription', 'Kiwy Signage Player - Digital Signage Player'), + StringStruct('FileVersion', '1.2.0.0'), + StringStruct('InternalName', 'KiwySignagePlayer'), + StringStruct('LegalCopyright', 'Copyright (c) 2026 Kiwy Signage'), + StringStruct('OriginalFilename', 'KiwySignagePlayer.exe'), + StringStruct('ProductName', 'Kiwy Signage Player'), + StringStruct('ProductVersion', '1.2.0.0'), + ] + ) + ] + ), + VarFileInfo([VarStruct('Translation', [1033, 1200])]) + ] +)