Fix Windows player: kiosk lockdown, robust video transitions, keep-awake
- Production/kiosk mode: exit_on_escape=0, window-close guard, Ctrl+C ignore, Alt+F4/Alt+Tab/Win/Ctrl+Esc keyboard hook (Windows) - Robust video playback: async (non-blocking) ffpyplayer teardown, video progress watchdog (advance at true clip end), EOS re-entrancy guard, stale-advance guard, focus keeper for foreground retention - Resume playback timer after Settings/exit popups close - Windows keep-awake: SetThreadExecutionState + disable screensaver/ lock screen (restored on exit) - Always-on playback_trace.log for diagnosing transitions - exe metadata: app_icon.ico + version_info.txt (publisher identity)
This commit is contained in:
+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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user