Fix Windows player: kiosk lockdown, robust video transitions, keep-awake

- Production/kiosk mode: exit_on_escape=0, window-close guard, Ctrl+C
  ignore, Alt+F4/Alt+Tab/Win/Ctrl+Esc keyboard hook (Windows)
- Robust video playback: async (non-blocking) ffpyplayer teardown,
  video progress watchdog (advance at true clip end), EOS re-entrancy
  guard, stale-advance guard, focus keeper for foreground retention
- Resume playback timer after Settings/exit popups close
- Windows keep-awake: SetThreadExecutionState + disable screensaver/
  lock screen (restored on exit)
- Always-on playback_trace.log for diagnosing transitions
- exe metadata: app_icon.ico + version_info.txt (publisher identity)
This commit is contained in:
ske087
2026-08-04 15:57:33 +03:00
parent 5c2b3f545f
commit 31ad592e98
8 changed files with 1048 additions and 71 deletions
+424 -34
View File
@@ -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()
+64
View File
@@ -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
+18 -7
View File
@@ -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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+2 -1
View File
@@ -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 -------------------------
+15 -1
View File
@@ -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**
+482 -28
View File
@@ -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
+43
View File
@@ -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])])
]
)