Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31ad592e98 | |||
| 5c2b3f545f |
+424
-34
@@ -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()
|
||||
|
||||
|
||||
|
||||
+166
-97
@@ -6,11 +6,16 @@ Checks server connectivity and manages WiFi restart on connection failure
|
||||
import subprocess
|
||||
import time
|
||||
import random
|
||||
import platform
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from kivy.logger import Logger
|
||||
from kivy.clock import Clock
|
||||
|
||||
# Detect platform once so the ping / WiFi-restart commands below can
|
||||
# pick the correct syntax (Linux vs Windows).
|
||||
IS_WINDOWS = platform.system() == 'Windows'
|
||||
|
||||
|
||||
class NetworkMonitor:
|
||||
"""Monitor network connectivity and manage WiFi restart"""
|
||||
@@ -99,9 +104,15 @@ class NetworkMonitor:
|
||||
|
||||
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
||||
|
||||
# Ping the server hostname with 3 attempts
|
||||
# Ping the server hostname with 3 attempts.
|
||||
# Windows ping uses -n for count and -w for timeout (ms),
|
||||
# while Linux uses -c and -W.
|
||||
if IS_WINDOWS:
|
||||
cmd = ['ping', '-n', '3', '-w', '3000', hostname]
|
||||
else:
|
||||
cmd = ['ping', '-c', '3', '-W', '3', hostname]
|
||||
result = subprocess.run(
|
||||
['ping', '-c', '3', '-W', '3', hostname],
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
@@ -123,113 +134,171 @@ class NetworkMonitor:
|
||||
|
||||
def _restart_wifi(self):
|
||||
"""
|
||||
Restart WiFi by turning it off for a specified duration then back on
|
||||
This runs in a separate thread to not block the main application
|
||||
Restart WiFi by turning it off for a specified duration then back on.
|
||||
Uses the platform-appropriate commands:
|
||||
- Linux (Raspberry Pi): sudo rfkill / ifconfig / dhclient
|
||||
- Windows: netsh wlan disconnect / connect
|
||||
This runs in a separate thread to not block the main application.
|
||||
"""
|
||||
def wifi_restart_thread():
|
||||
try:
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
|
||||
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
||||
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
||||
result = subprocess.run(
|
||||
['sudo', 'rfkill', 'block', 'wifi'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
||||
Logger.info("NetworkMonitor: WiFi is now DISABLED and will remain OFF")
|
||||
|
||||
if IS_WINDOWS:
|
||||
self._restart_wifi_windows()
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
||||
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
||||
|
||||
# Fallback to ifconfig
|
||||
result2 = subprocess.run(
|
||||
['sudo', 'ifconfig', 'wlan0', 'down'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result2.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
||||
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
||||
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
|
||||
return
|
||||
|
||||
# Wait for the specified duration with WiFi OFF
|
||||
wait_minutes = self.wifi_restart_duration / 60
|
||||
Logger.info(f"NetworkMonitor: ====================================")
|
||||
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
|
||||
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
|
||||
Logger.info(f"NetworkMonitor: ====================================")
|
||||
|
||||
# Sleep while WiFi is OFF
|
||||
time.sleep(self.wifi_restart_duration)
|
||||
|
||||
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
|
||||
|
||||
# Turn WiFi back on after the wait period
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
|
||||
# Unblock WiFi using rfkill
|
||||
result = subprocess.run(
|
||||
['sudo', 'rfkill', 'unblock', 'wifi'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
|
||||
|
||||
# Also bring interface up
|
||||
result2 = subprocess.run(
|
||||
['sudo', 'ifconfig', 'wlan0', 'up'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result2.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
|
||||
|
||||
# Wait a bit for connection to establish
|
||||
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
|
||||
time.sleep(10)
|
||||
|
||||
# Try to restart DHCP
|
||||
Logger.info("NetworkMonitor: Requesting IP address...")
|
||||
subprocess.run(
|
||||
['sudo', 'dhclient', 'wlan0'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
||||
|
||||
self._restart_wifi_linux()
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
||||
except Exception as e:
|
||||
Logger.error(f"NetworkMonitor: Error during WiFi restart: {e}")
|
||||
|
||||
|
||||
# Run in separate thread to not block the application
|
||||
import threading
|
||||
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _restart_wifi_windows(self):
|
||||
"""Windows WiFi restart using netsh. Turn off for the wait period,
|
||||
then turn back on so Windows reconnects to the preferred network."""
|
||||
wait_minutes = self.wifi_restart_duration / 60
|
||||
Logger.info(
|
||||
f"NetworkMonitor: Windows WiFi restart — off for {wait_minutes:.0f} min"
|
||||
)
|
||||
|
||||
# Turn WiFi OFF
|
||||
off = subprocess.run(
|
||||
['netsh', 'wlan', 'disconnect'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if off.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF (netsh wlan disconnect)")
|
||||
else:
|
||||
Logger.warning(
|
||||
f"NetworkMonitor: netsh wlan disconnect failed: {off.stderr.strip()}"
|
||||
)
|
||||
|
||||
# Wait with WiFi OFF
|
||||
Logger.info(
|
||||
f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes "
|
||||
f"(started {datetime.now().strftime('%H:%M:%S')})"
|
||||
)
|
||||
time.sleep(self.wifi_restart_duration)
|
||||
Logger.info(
|
||||
f"NetworkMonitor: Wait period completed at {datetime.now().strftime('%H:%M:%S')}"
|
||||
)
|
||||
|
||||
# Turn WiFi back ON — Windows reconnects to the preferred network
|
||||
on = subprocess.run(
|
||||
['netsh', 'wlan', 'connect'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if on.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi re-enabled (netsh wlan connect)")
|
||||
else:
|
||||
# 'netsh wlan connect' without a profile may return non-zero even
|
||||
# though the radio comes back on; log it but don't fail hard.
|
||||
Logger.warning(
|
||||
f"NetworkMonitor: netsh wlan connect returned {on.returncode}: "
|
||||
f"{on.stderr.strip()} (may reconnect automatically)"
|
||||
)
|
||||
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
|
||||
def _restart_wifi_linux(self):
|
||||
"""Linux (Raspberry Pi) WiFi restart using rfkill/ifconfig/dhclient."""
|
||||
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
||||
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
||||
result = subprocess.run(
|
||||
['sudo', 'rfkill', 'block', 'wifi'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
||||
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
||||
|
||||
# Fallback to ifconfig
|
||||
result2 = subprocess.run(
|
||||
['sudo', 'ifconfig', 'wlan0', 'down'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result2.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
||||
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
||||
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
|
||||
return
|
||||
|
||||
# Wait for the specified duration with WiFi OFF
|
||||
wait_minutes = self.wifi_restart_duration / 60
|
||||
Logger.info(f"NetworkMonitor: ====================================")
|
||||
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
|
||||
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
|
||||
Logger.info(f"NetworkMonitor: ====================================")
|
||||
|
||||
# Sleep while WiFi is OFF
|
||||
time.sleep(self.wifi_restart_duration)
|
||||
|
||||
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
|
||||
|
||||
# Turn WiFi back on after the wait period
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
|
||||
# Unblock WiFi using rfkill
|
||||
result = subprocess.run(
|
||||
['sudo', 'rfkill', 'unblock', 'wifi'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
|
||||
|
||||
# Also bring interface up
|
||||
result2 = subprocess.run(
|
||||
['sudo', 'ifconfig', 'wlan0', 'up'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result2.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
|
||||
|
||||
# Wait a bit for connection to establish
|
||||
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
|
||||
time.sleep(10)
|
||||
|
||||
# Try to restart DHCP
|
||||
Logger.info("NetworkMonitor: Requesting IP address...")
|
||||
subprocess.run(
|
||||
['sudo', 'dhclient', 'wlan0'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
||||
|
||||
@@ -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
@@ -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 |
+72
-26
@@ -153,37 +153,78 @@ source_tree = Tree(str(SRC_DIR), prefix='', excludes=['*.pyc', '__pycache__', '*
|
||||
import importlib.util
|
||||
from pathlib import Path as _Path
|
||||
|
||||
def _find_share_dlls(package_path, subdir='bin'):
|
||||
"""Find .dll files under a package's share/ directory."""
|
||||
if not package_path:
|
||||
|
||||
def _site_packages_dir(package_path):
|
||||
"""Climb up from a package's __init__.py to its site-packages dir."""
|
||||
d = _Path(package_path).parent
|
||||
while d.name != 'site-packages' and d.parent != d:
|
||||
d = d.parent
|
||||
return d
|
||||
|
||||
|
||||
def _find_share_dlls(package_name, share_name=None):
|
||||
"""Find .dll files under venv_root/share/<share_name>/.
|
||||
|
||||
kivy_deps.sdl2/angle/glew and ffpyplayer install their DLLs into
|
||||
<venv>/share/<pkg>/... NOT inside the package dir. The share folder is
|
||||
named after the *short* dep name (e.g. 'sdl2', 'angle', 'glew'), not the
|
||||
dotted package name ('kivy_deps.sdl2'), so pass share_name explicitly.
|
||||
"""
|
||||
if share_name is None:
|
||||
share_name = package_name
|
||||
spec = importlib.util.find_spec(package_name)
|
||||
if spec is None or not spec.origin:
|
||||
return []
|
||||
base = _Path(package_path).parent
|
||||
# Check: share/<pkg>/bin/ relative to parent
|
||||
share = base / 'share'
|
||||
if share.is_dir():
|
||||
sp = _site_packages_dir(spec.origin)
|
||||
# Climb from site-packages up until we find a sibling 'share' dir
|
||||
# (site-packages -> Lib -> venv, where venv/share lives).
|
||||
d = sp
|
||||
while d.parent != d:
|
||||
if (d.parent / 'share').is_dir():
|
||||
share = d.parent / 'share' / share_name
|
||||
break
|
||||
d = d.parent
|
||||
else:
|
||||
return []
|
||||
if not share.is_dir():
|
||||
return []
|
||||
results = []
|
||||
for root, dirs, files in os.walk(share):
|
||||
for f in files:
|
||||
if f.endswith('.dll'):
|
||||
results.append((os.path.join(root, f), '.'))
|
||||
return results
|
||||
|
||||
|
||||
def _find_ffpyplayer_bins():
|
||||
"""Return ffpyplayer's own dependency DLL dirs (FFmpeg + bundled SDL).
|
||||
|
||||
ffpyplayer ships a `dep_bins` list that already points at the correct
|
||||
share/ffpyplayer/ffmpeg/bin and share/ffpyplayer/sdl/bin directories.
|
||||
"""
|
||||
try:
|
||||
import ffpyplayer
|
||||
bins = getattr(ffpyplayer, 'dep_bins', None)
|
||||
if not bins:
|
||||
return []
|
||||
results = []
|
||||
for root, dirs, files in os.walk(share):
|
||||
for f in files:
|
||||
if f.endswith('.dll'):
|
||||
results.append((os.path.join(root, f), '.'))
|
||||
for b in bins:
|
||||
bpath = _Path(b)
|
||||
if bpath.is_dir():
|
||||
for f in bpath.glob('*.dll'):
|
||||
results.append((str(f), '.'))
|
||||
return results
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# SDL2 DLLs
|
||||
_sdl2_spec = importlib.util.find_spec('kivy_deps.sdl2')
|
||||
_sdl2_dlls = _find_share_dlls(_sdl2_spec.origin if _sdl2_spec else None)
|
||||
|
||||
# ANGLE DLLs
|
||||
_angle_spec = importlib.util.find_spec('kivy_deps.angle')
|
||||
_angle_dlls = _find_share_dlls(_angle_spec.origin if _angle_spec else None)
|
||||
# SDL2 / ANGLE / GLEW DLLs (kivy_deps share dirs)
|
||||
_sdl2_dlls = _find_share_dlls('kivy_deps.sdl2', 'sdl2')
|
||||
_angle_dlls = _find_share_dlls('kivy_deps.angle', 'angle')
|
||||
_glew_dlls = _find_share_dlls('kivy_deps.glew', 'glew')
|
||||
|
||||
# GLEW DLLs
|
||||
_glew_spec = importlib.util.find_spec('kivy_deps.glew')
|
||||
_glew_dlls = _find_share_dlls(_glew_spec.origin if _glew_spec else None)
|
||||
|
||||
# ffpyplayer FFmpeg DLLs
|
||||
_ffpy_spec = importlib.util.find_spec('ffpyplayer')
|
||||
_ffpy_dlls = _find_share_dlls(_ffpy_spec.origin if _ffpy_spec else None)
|
||||
# ffpyplayer FFmpeg + bundled SDL DLLs (via dep_bins)
|
||||
_ffpy_dlls = _find_ffpyplayer_bins()
|
||||
|
||||
_all_binaries = _sdl2_dlls + _angle_dlls + _glew_dlls + _ffpy_dlls
|
||||
|
||||
@@ -194,6 +235,10 @@ if not _all_binaries:
|
||||
print("fails with 'SDL2.dll not found' or similar, you will need")
|
||||
print("to manually add the DLL paths to the spec file.")
|
||||
print("=" * 70)
|
||||
else:
|
||||
print(f"[spec] Bundling {len(_all_binaries)} DLLs:")
|
||||
for _p, _t in sorted(_all_binaries):
|
||||
print(f" {_Path(_p).name} <- {_p}")
|
||||
|
||||
# --- Build the .exe --------------------------------------------------
|
||||
a = Analysis(
|
||||
@@ -240,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 -------------------------
|
||||
|
||||
+135
-32
@@ -6,29 +6,117 @@
|
||||
|
||||
---
|
||||
|
||||
## 📅 Current Session — 2026-07-24
|
||||
## 📅 Current Session — 2026-07-31
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Branch** | `Windows-Player` |
|
||||
| **Python** | 3.12.9 — `C:\Users\Dell-PC\AppData\Local\Programs\Python\Python312\python.exe` |
|
||||
| **Venv** | `windows\venv312\` (pre-built, all deps installed) |
|
||||
| **Python** | 3.12.9 — `windows\venv\` (250 MB, all deps installed) |
|
||||
| **Kivy** | 2.3.1 |
|
||||
| **PyInstaller** | 6.21.0 |
|
||||
| **Libraries added** | `cefpython3` (embedded Chromium), `pywin32` 312 (win32gui for window mgmt) |
|
||||
| **Last .exe build** | 2026-07-24 13:47 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) |
|
||||
| **Build command** | `Set-Location windows; venv312\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` |
|
||||
| **Last .exe build** | 2026-07-26 16:53 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) |
|
||||
| **Build command** | `.\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` |
|
||||
|
||||
### ⚠️ Python Version Constraints
|
||||
- **Python 3.12.9** — ✅ Confirmed working. Has pre-built Kivy 2.3.1 wheels.
|
||||
- **Python 3.13** — ❌ Kivy wheels NOT available for Windows.
|
||||
- **Python 3.14** — ❌ Tested 2026-07-24. `kivy_deps.sdl2_dev~=0.8.0` has no cp314 wheel.
|
||||
→ Solution: removed Python 3.14 from system, keeping only 3.12.9.
|
||||
### 📋 Cross-platform audit — Linux commands → Windows handling
|
||||
|
||||
Every Linux-only command in `src/` was cross-referenced against the patches
|
||||
in `windows/run_win.py`. All are covered except the one listed below:
|
||||
|
||||
| # | File / method | Linux commands | Windows handling |
|
||||
|---|---------------|----------------|------------------|
|
||||
| 1 | `main.py` `signal_screen_activity()` | `xset`, `xdotool`, `xrandr`, `tvservice`, `wlopm`, `wlr-randr`, `ydotool` | ✅ patched → `SetThreadExecutionState` (ctypes) in `run_win.py` |
|
||||
| 2 | `main.py` `play_weblink()` | `chromium-browser` / `chromium` | ✅ patched → CEF embedded, then Chrome/Edge subprocess |
|
||||
| 3 | `main.py` `_start_inactivity_watchdog()` | `/dev/input/event*`, `select` | ✅ patched → fixed timer watchdog |
|
||||
| 4 | `main.py` `CardReader` | `evdev`, `/dev/input/event*` | ✅ fake `evdev` injected → falls back |
|
||||
| 5 | `main.py` `SettingsPopup.test_connection` | `/tmp/temp_auth_test.json` | ✅ patched → `tempfile.gettempdir()` |
|
||||
| 6 | `main.py` weblink kill/prewarm wrappers | `proc.terminate()` only | ✅ patched → `taskkill /F /T` + `_Win32Overlay` |
|
||||
| 7 | `network_monitor.py` `_test_server_connection()` | `ping -c 3 -W 3` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** |
|
||||
| 8 | `network_monitor.py` `_restart_wifi()` | `sudo rfkill`, `sudo ifconfig`, `sudo dhclient` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** |
|
||||
| 9 | `get_playlists_v2.py`, `player_auth.py`, `ssl_utils.py`, `edit_popup.py`, `keyboard_widget.py` | none | ✅ no Linux commands |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Tracker
|
||||
|
||||
### [BUG-010] NetworkMonitor uses Linux-only ping + rfkill commands
|
||||
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||
- **Symptom:** `network_monitor.py` ran `ping -c 3 -W 3` (Linux flags) and on
|
||||
connection failure invoked `sudo rfkill` / `sudo ifconfig wlan0` /
|
||||
`sudo dhclient` — all fail or hang on Windows (`sudo` isn't even present).
|
||||
- **Root cause:** This module was missed when the other Linux paths were
|
||||
patched in `run_win.py`.
|
||||
- **Fix:** Made `network_monitor.py` self-contained cross-platform:
|
||||
1. Added `IS_WINDOWS = platform.system() == 'Windows'`
|
||||
2. `_test_server_connection()` uses `ping -n 3 -w 3000` on Windows
|
||||
3. `_restart_wifi()` dispatches to `_restart_wifi_windows()`
|
||||
(`netsh wlan disconnect` → wait → `netsh wlan connect`) or
|
||||
`_restart_wifi_linux()` (original rfkill/ifconfig/dhclient path kept intact)
|
||||
- **Files:** `src/network_monitor.py`
|
||||
- **Test:** Windows `ping -n 3 -w 3000 localhost` returns 0; AST parse OK.
|
||||
|
||||
---
|
||||
|
||||
### [BUG-011] Weblink never displays on Windows (opens behind Kivy / exits instantly)
|
||||
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||
- **Symptom:** Web link items don't show. In the console log the weblink item
|
||||
is reached but no browser appears, then playback moves on.
|
||||
- **Root causes (two compounding):**
|
||||
1. **Chrome re-used an existing instance.** `subprocess.Popen([chrome, '--new-window', url])`
|
||||
delegates the URL to the already-running Chrome process and this launched
|
||||
process **exits immediately** (`poll() != None`) → the watchdog fired
|
||||
instantly and advanced to the next item, so the weblink never displayed.
|
||||
2. **Overlay-hide raised Kivy over Chrome.** `_hide_overlay()` called
|
||||
`_bring_kivy_to_front()`, so even when Chrome did open it sat *behind*
|
||||
the borderless-fullscreen Kivy window.
|
||||
- **Fix (in `windows/run_win.py`):**
|
||||
1. Launch Chrome/Edge with a **dedicated `--user-data-dir`** (`<data>/.kiosk-profile`)
|
||||
so a brand-new, trackable browser instance is created instead of
|
||||
delegating to an existing one. Also guarantees a top-level window we can
|
||||
enumerate, raise, and `taskkill` without touching the user's profile.
|
||||
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.
|
||||
- **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**
|
||||
- **Symptom:** After a weblink finishes, the next media/widget renders but the
|
||||
Kivy window stays behind (or the window focus is lost) — user sees the wrong
|
||||
window / frozen view.
|
||||
- **Root cause:** `_bring_kivy_to_front()` did `import win32con`, but
|
||||
`win32con` is a pure-Python module in `win32\lib\` that is **only importable
|
||||
via the `pywin32.pth` file**. `.pth` files are ignored in frozen PyInstaller
|
||||
apps, so `win32con` was never bundled (confirmed via `pyi-archive_viewer` —
|
||||
only `win32gui.pyd` / `win32api.pyd` / `win32process.pyd` present). The
|
||||
`import win32con` threw, the whole function silently fell back to
|
||||
`Window.raise_window()`, and the Kivy window was never reliably raised.
|
||||
- **Fix (in `windows/run_win.py`):**
|
||||
1. Replaced the `win32con` dependency with **raw ctypes + numeric constants**
|
||||
(`_SW_SHOWNORMAL`, `_SWP_*`, `_HWND_TOPMOST`, …).
|
||||
2. New `_bring_hwnd_to_front(hwnd)` — ctypes-only `SetForegroundWindow` with
|
||||
`AttachThreadInput` foreground-lock bypass + `IsIconic` restore + topmost
|
||||
flash.
|
||||
3. `_bring_kivy_to_front()` now uses `_find_kivy_hwnd()` (win32gui.EnumWindows
|
||||
for `SDL_app`) + `_bring_hwnd_to_front()`, with Kivy `raise_window()` as
|
||||
last-resort fallback.
|
||||
- **Test:** exe rebuilt; no `win32con` import remains in `run_win.py`.
|
||||
|
||||
---
|
||||
|
||||
### [BUG-001] RecursionError: play_current_media ↔ restart_playlist
|
||||
- **Status:** ✅ Fixed 2026-07-24
|
||||
- **Symptom:** Pressing "Restart Player" in settings with empty playlist causes
|
||||
@@ -83,20 +171,31 @@
|
||||
- **Files:** `windows/run_win.py` — `_windows_kill_process_tree()`
|
||||
|
||||
### [BUG-007] Video plays behind Chromium on weblink→media transition
|
||||
- **Status:** 🔧 **Fix in progress** 2026-07-24
|
||||
- **Status:** ✅ **Fixed — 2026-07-26 (final)**
|
||||
- **Symptom:** When a weblink ends and the next media starts, the media plays
|
||||
*behind* Chromium. Audio is heard but user sees Chrome.
|
||||
- **Root cause (Windows):** Linux renders Kivy widget UNDER Chromium → closes
|
||||
Chrome → widget visible. On Windows Chrome stays ON TOP.
|
||||
`Window.raise_window()` is unreliable.
|
||||
- **Fix applied (2026-07-24):**
|
||||
1. **`_bring_kivy_to_front()`** — uses `win32gui.SetForegroundWindow(hwnd)`
|
||||
to reliably bring Kivy/SDL window to front (replaces `raise_window`)
|
||||
2. **`_windows_kill_weblink_after_frame()`** — kills Chrome IMMEDIATELY
|
||||
(not deferred one frame later) before next media starts
|
||||
3. **CEF browser** (`cefpython3`) — embedded Chromium widget replaces
|
||||
subprocess entirely. No process management, no z-order fights.
|
||||
- **Files:** `windows/run_win.py`, `windows/cef_browser.py`
|
||||
`Window.raise_window()` is unreliable. Three compounding issues:
|
||||
1. `KivyWindow.minimize()` made Kivy impossible to bring back reliably
|
||||
2. `_windows_play_current_media` killed the browser but never restored
|
||||
`content_area.opacity = 1`, so next widget rendered invisible
|
||||
3. `_bring_kivy_to_front()` failed because Windows `SetForegroundWindow`
|
||||
refuses to let a background process steal focus
|
||||
- **Fix applied (2026-07-26):**
|
||||
1. **Removed `KivyWindow.minimize()`** in `_windows_play_weblink()` — Kivy
|
||||
stays visible behind the overlay instead of being hidden
|
||||
2. **Restored `content_area.opacity = 1`** in `_windows_play_current_media`
|
||||
and `_windows_kill_weblink_after_frame()` — ensures next widget is visible
|
||||
3. **`_bring_kivy_to_front()`** — added `AttachThreadInput()` to bypass
|
||||
Windows foreground lock so Kivy can steal focus from Chrome
|
||||
4. **Overlay hide** now calls `_bring_kivy_to_front()` instead of
|
||||
`Window.raise_window()`
|
||||
5. **CEF path** (`_windows_kill_weblink_after_frame`) now also calls
|
||||
`_bring_kivy_to_front()` after hiding
|
||||
- **Note:** `cefpython3` requires Python 3.10 — falls back to subprocess
|
||||
Chrome/Edge on 3.12.9. Transition now works reliably with subprocess path.
|
||||
- **Files:** `windows/run_win.py`
|
||||
|
||||
### [BUG-008] Intro video and media files not found at runtime
|
||||
- **Status:** ✅ **Fixed** 2026-07-24
|
||||
@@ -159,15 +258,15 @@ When the .exe runs:
|
||||
## 🔧 Build Cheatsheet
|
||||
|
||||
```powershell
|
||||
# Build the .exe (from project root or windows/)
|
||||
# Build the .exe (from windows/ directory)
|
||||
Set-Location windows
|
||||
& .\venv312\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
||||
& .\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
||||
|
||||
# Run in dev mode (no build needed)
|
||||
& .\venv312\Scripts\python.exe run_win.py
|
||||
& .\venv\Scripts\python.exe run_win.py
|
||||
|
||||
# Test imports only
|
||||
& .\venv312\Scripts\python.exe test_import_fix.py
|
||||
& .\venv\Scripts\python.exe test_import_fix.py
|
||||
```
|
||||
|
||||
---
|
||||
@@ -177,10 +276,14 @@ Set-Location windows
|
||||
- [x] ~~Investigate [BUG-006]~~ → merged into [BUG-007], fixed with CEF + win32gui
|
||||
- [x] ~~Test `SetForegroundWindow`~~ → `_bring_kivy_to_front()` uses `win32gui`
|
||||
- [x] Install `cefpython3` — embedded Chromium, no more subprocess
|
||||
- [ ] Verify CEF embedded browser actually works at runtime
|
||||
- [ ] Test the subprocess fallback path when CEF is unavailable
|
||||
- [ ] Check why `AsyncImage` error shows for intro1.mp4 (path issue)
|
||||
- [ ] Ensure media files are downloaded before playback
|
||||
- [ ] Consider adding a startup `.bat` file that users can double-click
|
||||
- [ ] Test card reader fallback behaviour (evdev not available)
|
||||
- [ ] Add `cef_browser.py` to PyInstaller hidden imports in `build.spec`
|
||||
- [x] ~~Verify CEF embedded browser actually works at runtime~~ → CEF needs Python 3.10, falls back to Chrome/Edge
|
||||
- [x] ~~Test the subprocess fallback path when CEF is unavailable~~ → Tested and working with `_bring_kivy_to_front()`
|
||||
- [x] ~~Check why `AsyncImage` error shows for intro1.mp4 (path issue)~~ → Runtime hook copies resources to exe dir
|
||||
- [x] ~~Ensure media files are downloaded before playback~~ → `pyi_runtime_hook.py` copies config/resources on first run
|
||||
- [x] ~~Add `cef_browser.py` to PyInstaller hidden imports~~ → Already in `build.spec`
|
||||
- [x] ~~Make `network_monitor.py` Windows-compatible~~ → [BUG-010] fixed 2026-07-31 (`ping -n` / `netsh wlan` on Windows, rfkill path preserved on Linux)
|
||||
- [ ] Rebuild the .exe to pick up the `network_monitor.py` fix
|
||||
- [ ] Clean `cefpython3` from `venv/` (Python 3.12 won't use it anyway)
|
||||
- [ ] Verify the .exe works on a fresh Windows machine (no Python installed)
|
||||
- [ ] Test the `taskkill` fallback path on a machine without Chrome/Edge installed
|
||||
- [ ] Add a standalone `.bat` launcher for development mode
|
||||
|
||||
+667
-41
@@ -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,33 +333,375 @@ class _Win32Overlay:
|
||||
cls._hwnd = None
|
||||
|
||||
|
||||
def _bring_kivy_to_front():
|
||||
"""Bring the Kivy/SDL window to foreground using win32gui.
|
||||
class _Win32Backdrop:
|
||||
"""Persistent fullscreen black window shown at player startup.
|
||||
|
||||
Unlike Window.raise_window(), win32gui.SetForegroundWindow
|
||||
actually works reliably on Windows — it uses the same Win32
|
||||
API that the Task Manager uses.
|
||||
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).
|
||||
_SW_SHOWNORMAL = 1
|
||||
_SW_MINIMIZE = 6
|
||||
_SW_RESTORE = 9
|
||||
_SWP_NOSIZE = 0x0001
|
||||
_SWP_NOMOVE = 0x0002
|
||||
_SWP_NOACTIVATE = 0x0010
|
||||
_SWP_SHOWWINDOW = 0x0040
|
||||
_HWND_TOPMOST = -1
|
||||
_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.
|
||||
|
||||
IMPORTANT: Windows restricts SetForegroundWindow() — a process can only
|
||||
set the foreground window if it was the *last input process* or the
|
||||
current foreground window is the same thread. To work around this, we
|
||||
attach our calling thread (and the target window's thread) to the current
|
||||
foreground window's input thread before calling SetForegroundWindow.
|
||||
"""
|
||||
if not hwnd:
|
||||
return
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
# If minimized, restore first so the window can actually be shown.
|
||||
if user32.IsIconic(hwnd):
|
||||
user32.ShowWindow(hwnd, _SW_RESTORE)
|
||||
|
||||
try:
|
||||
fore_hwnd = user32.GetForegroundWindow()
|
||||
if fore_hwnd and fore_hwnd != hwnd:
|
||||
fore_tid = user32.GetWindowThreadProcessId(fore_hwnd, None)
|
||||
target_tid = user32.GetWindowThreadProcessId(hwnd, None)
|
||||
our_tid = kernel32.GetCurrentThreadId()
|
||||
if fore_tid != our_tid:
|
||||
user32.AttachThreadInput(our_tid, fore_tid, True)
|
||||
user32.AttachThreadInput(target_tid, fore_tid, True)
|
||||
user32.SetForegroundWindow(hwnd)
|
||||
user32.AttachThreadInput(target_tid, fore_tid, False)
|
||||
user32.AttachThreadInput(our_tid, fore_tid, False)
|
||||
else:
|
||||
user32.SetForegroundWindow(hwnd)
|
||||
else:
|
||||
user32.SetForegroundWindow(hwnd)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
user32.ShowWindow(hwnd, _SW_SHOWNORMAL)
|
||||
user32.BringWindowToTop(hwnd)
|
||||
user32.SetWindowPos(hwnd, _HWND_TOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE)
|
||||
user32.SetWindowPos(hwnd, _HWND_NOTOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE)
|
||||
|
||||
|
||||
def _find_kivy_hwnd():
|
||||
"""Return the HWND of the Kivy/SDL window, or None."""
|
||||
try:
|
||||
import win32gui
|
||||
except Exception:
|
||||
return None
|
||||
hwnd_list = []
|
||||
|
||||
def _enum_cb(hwnd, _):
|
||||
try:
|
||||
cls = win32gui.GetClassName(hwnd)
|
||||
title = win32gui.GetWindowText(hwnd)
|
||||
except Exception:
|
||||
return
|
||||
if cls == "SDL_app" or "Kiwy" in title or "Signage" in title:
|
||||
hwnd_list.append(hwnd)
|
||||
|
||||
try:
|
||||
win32gui.EnumWindows(_enum_cb, None)
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
import win32con
|
||||
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 _enum_cb(hwnd, hwnd_list):
|
||||
cls = win32gui.GetClassName(hwnd)
|
||||
title = win32gui.GetWindowText(hwnd)
|
||||
if cls == "SDL_app":
|
||||
hwnd_list.append(hwnd)
|
||||
elif "Kiwy" in title or "Signage" in title:
|
||||
hwnd_list.append(hwnd)
|
||||
|
||||
hwnd_list = []
|
||||
win32gui.EnumWindows(_enum_cb, hwnd_list)
|
||||
def _bring_kivy_to_front():
|
||||
"""Bring the Kivy/SDL window to the foreground.
|
||||
|
||||
if hwnd_list:
|
||||
kivy_hwnd = hwnd_list[-1] # most recent
|
||||
win32gui.ShowWindow(kivy_hwnd, win32con.SW_SHOWNORMAL)
|
||||
win32gui.SetForegroundWindow(kivy_hwnd)
|
||||
win32gui.BringWindowToTop(kivy_hwnd)
|
||||
Uses win32gui.EnumWindows to find the SDL_app window, then _bring_hwnd_to_front
|
||||
(ctypes-only) to force it forward — no dependency on the un-bundled
|
||||
`win32con` module. Falls back to Kivy's built-in raise_window().
|
||||
"""
|
||||
try:
|
||||
hwnd = _find_kivy_hwnd()
|
||||
if hwnd is None:
|
||||
return
|
||||
_bring_hwnd_to_front(hwnd)
|
||||
except Exception:
|
||||
# Fallback to Kivy's built-in raise
|
||||
try:
|
||||
@@ -295,6 +712,107 @@ def _bring_kivy_to_front():
|
||||
pass
|
||||
|
||||
|
||||
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 None
|
||||
try:
|
||||
import win32gui
|
||||
import win32process
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
target_pid = proc.pid
|
||||
chrome_hwnd = None
|
||||
|
||||
def _enum_cb(hwnd, _):
|
||||
nonlocal chrome_hwnd
|
||||
if chrome_hwnd is not None:
|
||||
return
|
||||
try:
|
||||
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||||
except Exception:
|
||||
return
|
||||
if pid != target_pid:
|
||||
return
|
||||
try:
|
||||
cls = win32gui.GetClassName(hwnd)
|
||||
except Exception:
|
||||
return
|
||||
# Chrome/Edge top-level window classes
|
||||
if cls in ('Chrome_WidgetWin_1', 'Chrome_WidgetWin_0', 'ApplicationFrameWindow'):
|
||||
if win32gui.IsWindowVisible(hwnd):
|
||||
chrome_hwnd = hwnd
|
||||
|
||||
try:
|
||||
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)
|
||||
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.
|
||||
|
||||
@@ -362,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
|
||||
@@ -373,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:
|
||||
@@ -403,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) ────────────
|
||||
@@ -420,20 +942,40 @@ 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
|
||||
# Hide Kivy content (do NOT minimize — that makes it impossible
|
||||
# to reliably bring Kivy back to foreground after Chrome closes).
|
||||
from kivy.core.window import Window as KivyWindow
|
||||
try:
|
||||
self.ids.content_area.opacity = 0
|
||||
KivyWindow.minimize()
|
||||
except Exception:
|
||||
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
|
||||
# process exits immediately (poll() != None), so the watchdog
|
||||
# advances instantly and the weblink never displays. A private
|
||||
# profile also guarantees a brand-new top-level window we can
|
||||
# track, bring to front, and taskkill without touching the user's
|
||||
# normal browser session.
|
||||
profile_dir = os.path.join(
|
||||
os.environ.get('KIWY_DATA_DIR', os.getcwd()),
|
||||
'.kiosk-profile'
|
||||
)
|
||||
try:
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._weblink_proc = subprocess.Popen([
|
||||
browser,
|
||||
'--user-data-dir=' + profile_dir,
|
||||
'--kiosk',
|
||||
'--new-window',
|
||||
'--start-maximized',
|
||||
'--start-fullscreen',
|
||||
@@ -453,27 +995,41 @@ def _patch_main():
|
||||
url,
|
||||
], shell=False)
|
||||
|
||||
def _hide_overlay(dt):
|
||||
_Win32Overlay.hide()
|
||||
try:
|
||||
KivyWindow.raise_window()
|
||||
except Exception:
|
||||
pass
|
||||
Clock.schedule_once(_hide_overlay, 1.5)
|
||||
# Hide the black overlay ONLY once Chrome's window is actually on
|
||||
# screen. A fixed timer lets the desktop flash if Chrome is still
|
||||
# starting (cold start / slow disk / GPU init). Adaptive polling
|
||||
# keeps the screen black until the browser covers it.
|
||||
weblink_proc = self._weblink_proc
|
||||
_hide_overlay_when_chrome_ready(weblink_proc, timeout=6.0)
|
||||
|
||||
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
|
||||
@@ -528,19 +1084,34 @@ def _patch_main():
|
||||
|
||||
# ── Patch kill_weblink_after_frame for both CEF and subprocess ──
|
||||
def _windows_kill_weblink_after_frame(self):
|
||||
"""Close the weblink (CEF or subprocess) immediately before next media."""
|
||||
"""Close the weblink (CEF or subprocess) immediately before next media.
|
||||
|
||||
Restores Kivy content visibility and brings the Kivy window to front
|
||||
in all cases.
|
||||
"""
|
||||
import time
|
||||
from kivy.logger import Logger
|
||||
from kivy.clock import Clock
|
||||
from kivy.core.window import Window as _KivyWindow
|
||||
from playback_trace import trace
|
||||
self._stop_inactivity_watchdog()
|
||||
self._kill_weblink_preload()
|
||||
|
||||
# Restore Kivy content visibility
|
||||
try:
|
||||
self.ids.content_area.opacity = 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try CEF first
|
||||
cef_browser = _get_cef_browser()
|
||||
if cef_browser is not None and cef_browser.is_showing():
|
||||
Logger.info("SignagePlayer: Hiding CEF embedded browser")
|
||||
trace("win_kill_weblink_CEF_hide")
|
||||
cef_browser.hide()
|
||||
self._weblink_proc = None
|
||||
_bring_kivy_to_front()
|
||||
trace("win_kivy_brought_front")
|
||||
return
|
||||
|
||||
# Fallback: subprocess Chrome
|
||||
@@ -548,27 +1119,50 @@ def _patch_main():
|
||||
self._weblink_proc = None
|
||||
|
||||
if proc is None or proc.poll() is not None:
|
||||
_bring_kivy_to_front()
|
||||
trace("win_kill_weblink_noop", proc_none=(proc is None))
|
||||
return
|
||||
|
||||
_Win32Overlay.show()
|
||||
Logger.info("SignagePlayer: Killing Chromium subprocess immediately")
|
||||
trace("win_killing_chrome", pid=proc.pid)
|
||||
_windows_kill_process_tree(proc)
|
||||
time.sleep(0.1)
|
||||
_bring_kivy_to_front()
|
||||
_Win32Overlay.hide()
|
||||
trace("win_chrome_killed_kivy_front")
|
||||
signage_main.SignagePlayer._kill_weblink_after_frame = _windows_kill_weblink_after_frame
|
||||
|
||||
# ── Patch play_current_media — same immediate-kill logic ────────
|
||||
_original_play_current = signage_main.SignagePlayer.play_current_media
|
||||
|
||||
def _windows_play_current_media(self, force_reload=False, _after_weblink=False):
|
||||
"""Wrapped play_current_media — closes weblink immediately on transition."""
|
||||
"""Wrapped play_current_media — closes weblink immediately on transition.
|
||||
|
||||
CRITICAL: Must restore content_area.opacity=1 BEFORE killing the browser,
|
||||
because the original play_current_media() skips the weblink→media transition
|
||||
block once self._weblink_proc is None. If opacity stays 0, the next widget
|
||||
renders but is invisible.
|
||||
"""
|
||||
if not _after_weblink:
|
||||
# Restore Kivy content visibility BEFORE killing the browser so the
|
||||
# original play_current_media() doesn't need to handle the transition.
|
||||
try:
|
||||
self.ids.content_area.opacity = 1
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from kivy.core.window import Window as _KivyWindow
|
||||
_KivyWindow.show()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Kill CEF browser if showing
|
||||
cef_browser = _get_cef_browser()
|
||||
if cef_browser is not None and cef_browser.is_showing():
|
||||
cef_browser.hide()
|
||||
self._weblink_proc = None
|
||||
_bring_kivy_to_front()
|
||||
|
||||
# Kill subprocess Chrome if running
|
||||
proc = self._weblink_proc
|
||||
@@ -659,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
|
||||
|
||||
|
||||
@@ -720,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()
|
||||
@@ -825,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
|
||||
|
||||
@@ -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])])
|
||||
]
|
||||
)
|
||||
Reference in New Issue
Block a user