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

- Production/kiosk mode: exit_on_escape=0, window-close guard, Ctrl+C
  ignore, Alt+F4/Alt+Tab/Win/Ctrl+Esc keyboard hook (Windows)
- Robust video playback: async (non-blocking) ffpyplayer teardown,
  video progress watchdog (advance at true clip end), EOS re-entrancy
  guard, stale-advance guard, focus keeper for foreground retention
- Resume playback timer after Settings/exit popups close
- Windows keep-awake: SetThreadExecutionState + disable screensaver/
  lock screen (restored on exit)
- Always-on playback_trace.log for diagnosing transitions
- exe metadata: app_icon.ico + version_info.txt (publisher identity)
This commit is contained in:
ske087
2026-08-04 15:57:33 +03:00
parent 5c2b3f545f
commit 31ad592e98
8 changed files with 1048 additions and 71 deletions
+424 -34
View File
@@ -8,6 +8,7 @@ PLAYER_VERSION = "1.2.0"
import os
import json
import platform
import signal
import threading
import time
import asyncio
@@ -35,6 +36,10 @@ from kivy.config import Config
# Performance optimizations for video playback
Config.set('kivy', 'keyboard_mode', '') # Disable default virtual keyboard
# Kiosk requirement: Escape must NEVER close the player. The only exit path
# is the password-protected exit button. This stays disabled even during
# development so exit behavior always mirrors production.
Config.set('kivy', 'exit_on_escape', '0')
Config.set('graphics', 'fullscreen', '0') # Will be set to 1 later
Config.set('graphics', 'window_state', 'maximized') # Maximize window
@@ -85,6 +90,7 @@ from edit_popup import DrawingLayer, EditPopup
from kivy.graphics import Color, Line, Ellipse
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.slider import Slider
from playback_trace import trace # always-on playback transition logger
# Load the KV file - resolve relative to this file's directory
_kv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'signage_player.kv')
@@ -581,16 +587,8 @@ class ExitPasswordPopup(Popup):
"""Handle popup dismissal - resume playback and restart cursor hide timer"""
# Hide and remove keyboard
self.hide_keyboard()
# Resume playback if it wasn't paused before
if not self.was_paused:
self.player.is_paused = False
# Resume video if it was playing
if self.player.current_widget and isinstance(self.player.current_widget, Video):
self.player.current_widget.state = 'play'
# Restart the control hide timer
self.player.schedule_hide_controls()
# Resume playback and re-arm the media advance timer
self.player.resume_after_popup(self.was_paused)
def check_password(self):
"""Check if entered password matches quickconnect key"""
@@ -613,6 +611,8 @@ class ExitPasswordPopup(Popup):
except Exception as e:
Logger.warning(f"ExitPasswordPopup: Could not create stop flag: {e}")
# Allow the app to close (releases the production-mode close guard)
self.player.set_allow_exit(True)
self.dismiss()
App.get_running_app().stop()
else:
@@ -658,7 +658,10 @@ class SettingsPopup(Popup):
self.ids.playlist_info.text = f'Playlist: v{self.player.playlist_version}'
self.ids.media_count_info.text = f'Media: {len(self.player.playlist)}'
self.ids.status_info.text = f'Status: {"Playing" if self.player.is_playing else "Paused" if self.player.is_paused else "Idle"}'
# Refresh the production-mode button to reflect the current state
self.update_production_button()
# Bind to dismiss event to manage cursor visibility and resume playback
self.bind(on_dismiss=self.on_popup_dismiss)
@@ -703,17 +706,45 @@ class SettingsPopup(Popup):
"""Handle popup dismissal - resume playback and restart cursor hide timer"""
# Hide and remove keyboard
self.hide_keyboard()
# Resume playback if it wasn't paused before
if not self.was_paused:
self.player.is_paused = False
# Resume video if it was playing
if self.player.current_widget and isinstance(self.player.current_widget, Video):
self.player.current_widget.state = 'play'
# Restart the control hide timer
self.player.schedule_hide_controls()
# Resume playback and re-arm the media advance timer
self.player.resume_after_popup(self.was_paused)
def update_production_button(self):
"""Refresh the production-mode button to reflect the current state.
Green when production (kiosk) mode is enabled, grey when disabled.
"""
production = bool(self.player.config.get('production_mode', False))
btn = self.ids.production_mode_btn
if production:
btn.background_color = (0.2, 0.7, 0.2, 1) # green
btn.text = 'Production ON'
else:
btn.background_color = (0.4, 0.4, 0.4, 1) # grey
btn.text = 'Enable Production'
def toggle_production_mode(self):
"""Toggle production (kiosk) mode and apply the lockdown immediately."""
production = not bool(self.player.config.get('production_mode', False))
Logger.info(
f"SettingsPopup: {'Enabling' if production else 'Disabling'} "
"production mode"
)
# Apply lockdown (exit_on_escape, close guard, Ctrl+C, platform hooks)
self.player.apply_kiosk_mode(production)
self.player.save_config()
self.update_production_button()
if production:
self._show_temp_message(
'✓ Production mode ENABLED — kiosk lockdown active',
(0, 1, 0, 1)
)
else:
self._show_temp_message(
'✓ Production mode DISABLED — development mode',
(0.8, 0.8, 0.8, 1)
)
def test_connection(self):
"""Test connection to server with current credentials"""
# Update status label to show testing
@@ -933,6 +964,16 @@ class SignagePlayer(Widget):
self.auto_resume_event = None # Track scheduled auto-resume
self.config = {}
self.playlist_version = None
self._allow_exit = False # Set True by the password exit flow to allow the app to close
self._media_started_at = None # monotonic time the current media started
self._media_duration = 10 # scheduled duration of the current media
self._video_eos_pending = False # guards against duplicate EOS callbacks on one video
self._last_advance_at = 0.0 # monotonic time of the last next_media advance (dedupe guard)
self._focus_keeper_event = None # Clock interval that keeps the window focused during video
self._focus_keeper_elapsed = 0.0
self._focus_keeper_duration = 0.0
self._video_watchdog_event = None # Clock interval that watches video progress
self._video_watchdog_stopped = False
# self.should_refresh_playlist = False # Flag to reload playlist after edit upload (DISABLED - causing crashes)
self.consecutive_errors = 0 # Track consecutive playback errors
self.max_consecutive_errors = 10 # Maximum errors before stopping
@@ -958,6 +999,11 @@ class SignagePlayer(Widget):
# Bind to window size for fullscreen
Window.bind(size=self._update_size)
self._update_size(Window, Window.size)
# Bind the window-close guard. In production mode it returns True
# (blocking X / Alt+F4 / any WM close) until the password exit flow
# calls set_allow_exit(True). In development mode it returns False so
# the window closes normally.
Window.bind(on_request_close=self._guard_window_close)
# Initialize player
Clock.schedule_once(self.initialize_player, 0.1)
# Hide controls timer
@@ -973,7 +1019,97 @@ class SignagePlayer(Widget):
self.size = value
if hasattr(self, 'ids') and 'content_area' in self.ids:
self.ids.content_area.size = value
def _guard_window_close(self, *args, **kwargs):
"""Block window-close attempts in production mode unless exit allowed.
Bound to Window's `on_request_close` event. Kivy's SDL2 provider
dispatches this for the window close button / Alt+F4 / WM close.
Returning True cancels the close. The password exit flow calls
set_allow_exit(True) just before App.stop(), which lets the app
terminate even while production mode is active.
"""
if not self.config.get('production_mode', False):
return False # development mode — allow normal window close
if getattr(self, '_allow_exit', False):
return False # legitimate password-protected exit
Logger.info("SignagePlayer: Window close blocked (production mode)")
return True
def set_allow_exit(self, allow=True):
"""Allow the app to actually close (called by the password exit flow)."""
self._allow_exit = bool(allow)
def resume_after_popup(self, was_paused):
"""Resume playback and re-arm the media advance timer after a modal
popup (settings / exit-password) is dismissed.
`show_settings()` / `show_exit_popup()` unschedule next_media while
the popup is open. Without re-arming here the current media would sit
forever once the popup closes, so the playlist stops advancing.
"""
# Always restart the control-hide timer.
self.schedule_hide_controls()
if was_paused:
return # user had already paused playback — stay paused
self.is_paused = False
# Resume a paused video.
if self.current_widget and isinstance(self.current_widget, Video):
try:
self.current_widget.state = 'play'
except Exception:
pass
# Weblinks are advanced by their own watchdog thread — don't touch.
if getattr(self, '_weblink_proc', None) is not None:
return
# Re-arm the advance timer with the remaining time.
started = getattr(self, '_media_started_at', None)
duration = getattr(self, '_media_duration', 10)
remaining = duration
if started is not None:
remaining = duration - (time.monotonic() - started)
remaining = max(0.5, remaining)
Logger.debug(
f"SignagePlayer: Re-arming next_media in {remaining:.1f}s after popup"
)
Clock.unschedule(self.next_media)
Clock.schedule_once(self.next_media, remaining)
def apply_kiosk_mode(self, enabled):
"""Enable/disable production (kiosk) lockdown.
When enabled:
- Escape never exits the app (exit_on_escape stays 0).
- Window close (X / Alt+F4) is blocked by _guard_window_close until
the password exit flow calls set_allow_exit(True).
- Ctrl+C is ignored so the console cannot kill the player.
Platform wrappers (e.g. run_win.py) may extend this to also swallow
Alt+Tab / Win / Ctrl+Esc via a low-level keyboard hook.
"""
self.config['production_mode'] = bool(enabled)
if enabled:
# Defense in depth: ensure Escape can never close the player.
try:
Config.set('kivy', 'exit_on_escape', '0')
except Exception:
pass
# Ignore Ctrl+C in production — the console must not kill the app.
try:
signal.signal(signal.SIGINT, signal.SIG_IGN)
except Exception:
pass
Logger.info("SignagePlayer: PRODUCTION MODE ENABLED — kiosk lockdown active")
else:
# Development mode: restore the default Ctrl+C handler.
try:
signal.signal(signal.SIGINT, signal.default_int_handler)
except Exception:
pass
Logger.info("SignagePlayer: Production mode disabled — development mode")
def update_heartbeat(self, dt):
"""Update heartbeat file to indicate player is alive"""
try:
@@ -1046,7 +1182,10 @@ class SignagePlayer(Widget):
# Load configuration
self.load_config()
# Apply persisted production/kiosk mode (exit lockdown) if enabled
self.apply_kiosk_mode(self.config.get('production_mode', False))
# Initialize network monitor
self.start_network_monitoring()
@@ -1077,7 +1216,8 @@ class SignagePlayer(Widget):
"quickconnect_key": "1234567",
"max_resolution": "auto",
"use_https": True,
"verify_ssl": True
"verify_ssl": True,
"production_mode": False
}
self.save_config()
Logger.info("SignagePlayer: Created default configuration with HTTPS enabled")
@@ -1312,8 +1452,20 @@ class SignagePlayer(Widget):
media_item = self.playlist[self.current_index]
file_name = media_item.get('file_name', '')
duration = media_item.get('duration', 10)
# Track start time + duration so a popup can re-arm the advance
# timer with the correct remaining time after dismissal.
self._media_started_at = time.monotonic()
self._media_duration = duration
Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)")
trace("play_current_media",
index=self.current_index + 1,
total=len(self.playlist),
name=file_name,
type=media_item.get('type', '?'),
duration=duration,
after_weblink=_after_weblink,
weblink_proc=bool(getattr(self, '_weblink_proc', None)))
# ── Weblink → media transition (desktop-flash safe) ──────────────
# For web→media transitions, render the next Kivy widget first,
@@ -1373,6 +1525,7 @@ class SignagePlayer(Widget):
# Handle web links before any file/path handling (no local file exists)
if media_item.get('type') == 'weblink':
Logger.debug("SignagePlayer: Media type: WEBLINK")
trace("enter_weblink_branch", url=media_item.get('url', '')[:80])
self.ids.status_label.opacity = 0
self._remove_current_widget()
# Hide content_area — Chromium will cover it; avoids stale frame
@@ -1381,6 +1534,7 @@ class SignagePlayer(Widget):
except Exception:
pass
started = self.play_weblink(media_item.get('url', ''), duration)
trace("weblink_started", ok=bool(started))
if started:
self.consecutive_errors = 0
if self.config:
@@ -1420,10 +1574,12 @@ class SignagePlayer(Widget):
if file_extension in ['.mp4', '.avi', '.mkv', '.mov', '.webm']:
# Video file
Logger.debug(f"SignagePlayer: Media type: VIDEO")
trace("starting_video", path=media_path)
self.play_video(media_path, duration)
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
# Image file
Logger.debug(f"SignagePlayer: Media type: IMAGE")
trace("starting_image", path=media_path)
self.play_image(media_path, duration, force_reload=force_reload)
else:
Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}")
@@ -1451,10 +1607,12 @@ class SignagePlayer(Widget):
# If we arrived here from a weblink item, close Chromium after the
# next Kivy frame so the new widget is already visible underneath.
if media_item.get('type') != 'weblink' and getattr(self, '_weblink_proc', None) is not None:
trace("closing_weblink_after_frame", name=file_name)
self._kill_weblink_after_frame()
except Exception as e:
Logger.error(f"SignagePlayer: Error playing media: {e}")
trace("play_current_media_EXCEPTION", error=str(e))
self.consecutive_errors += 1
# Check if we've exceeded max errors
@@ -1481,6 +1639,7 @@ class SignagePlayer(Widget):
Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s")
# Create Video widget with optimized settings for smooth playback
self._video_source = video_path
self.current_widget = Video(
source=video_path,
state='play', # Start playing immediately
@@ -1506,7 +1665,25 @@ class SignagePlayer(Widget):
# Add to content area
self.ids.content_area.add_widget(self.current_widget)
# Schedule next media after duration (unschedule first to prevent overlaps)
# Start the focus keeper so the window stays foreground for the
# whole video duration (handles SDL surface swaps at load and any
# later re-swaps). The keeper is non-blocking.
self._start_focus_keeper(duration + 1)
trace("video_focus_keeper_started")
# Start a progress watchdog. ffpyplayer does NOT reliably dispatch
# EOS — the trace shows the video looping its tail for the gap
# between its real end (~30s for sample-30s.mp4) and the fixed
# timer (31s). The watchdog polls the actual position and
# force-advances as soon as the video really ends.
self._video_watchdog_stopped = False
self._video_watchdog_event = Clock.schedule_interval(
self._video_watchdog_tick, 0.5
)
trace("video_watchdog_started", duration=duration)
# Schedule a safety-net advance after the playlist duration
# (unschedule first to prevent overlaps).
Logger.debug(f"SignagePlayer: Scheduled next media in {duration}s")
Clock.unschedule(self.next_media)
Clock.schedule_once(self.next_media, duration)
@@ -1519,20 +1696,155 @@ class SignagePlayer(Widget):
self.consecutive_errors += 1
self._skip_to_next_media()
def _video_watchdog_tick(self, dt):
"""Watch the current video's progress and force-advance at its end.
ffpyplayer sometimes fails to dispatch EOS, leaving the video looping
its last frames for the gap between the true clip end and the playlist
timer. This polls position vs duration and advances as soon as the
video actually finishes, so the tail-loop never shows.
"""
if self._video_watchdog_stopped:
return
w = getattr(self, 'current_widget', None)
if not isinstance(w, Video) or w.source != getattr(self, '_video_source', None):
self._stop_video_watchdog()
return
try:
duration = getattr(w, 'duration', 0.0) or 0.0
position = getattr(w, 'position', -1) or 0.0
if duration > 0 and position >= duration - 0.4:
trace(
"video_watchdog_advance",
pos=round(position, 2),
dur=round(duration, 2),
)
self._stop_video_watchdog()
Clock.unschedule(self.next_media)
Clock.schedule_once(self._advance_after_video_eos, 0.1)
except Exception:
pass
def _stop_video_watchdog(self):
"""Cancel the video progress watchdog."""
self._video_watchdog_stopped = True
ev = getattr(self, '_video_watchdog_event', None)
if ev is not None:
try:
Clock.unschedule(ev)
except Exception:
pass
self._video_watchdog_event = None
def _bring_window_to_front_nonblocking(self):
"""Bring the Kivy window forward WITHOUT stalling the UI.
The heavy Win32 bring-to-front (EnumWindows + AttachThreadInput +
SetForegroundWindow) can take ~1.5s and was blocking the main thread
at every video start (see playback_trace.log: video_loaded then +1.5s
before video_focus_reasserted). Running it on a background thread
keeps playback smooth.
"""
def _do():
try:
from kivy.core.window import Window as _KivyWindow
_KivyWindow.raise_window()
except Exception:
pass
try:
_bring = getattr(self, '_bring_kivy_to_front_win', None)
if _bring is not None:
_bring()
except Exception:
pass
threading.Thread(target=_do, daemon=True, name='focus-bring-front').start()
def _start_focus_keeper(self, duration):
"""Periodically keep the Kivy window in the foreground.
Runs for up to `duration` seconds while a video is on screen. Each tick
first does a CHEAP foreground check; the expensive bring-to-front only
runs when focus was actually lost, and even then on a background
thread so the UI never stalls.
"""
self._stop_focus_keeper()
self._focus_keeper_elapsed = 0.0
self._focus_keeper_duration = float(duration)
self._focus_keeper_event = Clock.schedule_interval(
self._focus_keeper_tick, 0.5
)
trace("focus_keeper_started", duration=duration)
def _focus_keeper_tick(self, dt):
"""One focus-keeper tick: cheap check, heavy action only if needed."""
self._focus_keeper_elapsed += dt
if self._focus_keeper_elapsed > self._focus_keeper_duration:
self._stop_focus_keeper()
return
# Cheap foreground check first — avoids the 1.5s Win32 work entirely
# when the window already has focus.
try:
check = getattr(self, '_is_foreground_win', None)
if check is not None and check():
return # already focused, nothing to do
except Exception:
pass
trace("focus_keeper_focus_lost")
self._bring_window_to_front_nonblocking()
def _stop_focus_keeper(self):
"""Cancel any active focus keeper interval."""
ev = getattr(self, '_focus_keeper_event', None)
if ev is not None:
try:
Clock.unschedule(ev)
except Exception:
pass
self._focus_keeper_event = None
self._focus_keeper_elapsed = 0.0
def _on_video_eos(self, instance):
"""Callback when video reaches end of stream"""
"""Callback when video reaches end of stream.
Guarded against re-entrancy: ffpyplayer can dispatch EOS more than once
for a single video (thread + main-thread paths), and duplicate advance
schedules caused skipped items / crashes on later playlist loops.
"""
if self._video_eos_pending:
trace("video_eos_IGNORED_duplicate", state=getattr(instance, 'state', '?'))
return # already handled for this video
self._video_eos_pending = True
self._stop_video_watchdog()
Logger.debug("SignagePlayer: Video finished playing (EOS)")
# Unschedule any pending timer and advance to next media
trace("video_eos", state=getattr(instance, 'state', '?'))
# NOTE: do NOT set instance.state = 'stop' here on the main thread —
# in Kivy that triggers VideoFFPy.stop() -> unload() -> thread.join(),
# a blocking call that freezes the UI. The teardown worker thread in
# _remove_current_widget does the stop+unload off the main thread.
# Unschedule any pending timer and advance to next media exactly once
Clock.unschedule(self.next_media)
Clock.schedule_once(self.next_media, 0.5)
Clock.schedule_once(self._advance_after_video_eos, 0.5)
def _advance_after_video_eos(self, dt):
"""Advance to the next media after a video ended (single-fire)."""
self._video_eos_pending = False
self._stop_video_watchdog()
trace("advance_after_video_eos", index=self.current_index)
self.next_media()
def _on_video_loaded(self, instance, value):
"""Callback when video is loaded - log video information"""
"""Callback when the video's first frame is decoded and loaded.
The SDL surface swap at this moment can drop the window from the
foreground. The periodic focus keeper (started in play_video) handles
keeping it focused; here we just log it.
"""
if value:
try:
Logger.debug(f"SignagePlayer: Video loaded: {instance.texture.size if instance.texture else 'No texture'}, {instance.duration}s")
except Exception as e:
Logger.debug(f"SignagePlayer: Could not log video info: {e}")
trace("video_loaded")
def play_image(self, image_path, duration, force_reload=False):
"""Play an image file"""
@@ -1574,20 +1886,79 @@ class SignagePlayer(Widget):
self.consecutive_errors += 1
self._skip_to_next_media()
def _teardown_video_async(self, widget):
"""Stop and unload a finished video OFF the main thread.
CRITICAL: Kivy's `Video.state = 'stop'` calls VideoFFPy.stop() ->
unload() -> self._thread.join(), which BLOCKS until the ffpyplayer
decode thread exits. That join is variable (0.4s up to 100s+) and was
freezing the whole UI thread at every video->next transition (see
playback_trace.log gaps between remove_current_widget and
widget_removed_async). Doing the full stop+unload on a worker thread
keeps the UI responsive.
"""
try:
# Rebind the same widget's EOS is not needed; just stop+unload.
widget.state = 'stop'
except Exception:
pass
try:
widget.unload()
except Exception:
pass
def _remove_current_widget(self):
"""Stop and remove the current Kivy media widget if one is present."""
trace("remove_current_widget",
has=bool(self.current_widget),
is_video=isinstance(self.current_widget, Video))
if self.current_widget:
# Properly stop video if it's playing to prevent resource leaks
if isinstance(self.current_widget, Video):
try:
Logger.debug("SignagePlayer: Stopping previous video widget...")
self.current_widget.state = 'stop'
self.current_widget.unload()
# Unbind EOS first so stopping/unloading cannot re-trigger
# the transition callback (caused double-advance / crashes
# at the video -> next-media boundary).
try:
self.current_widget.unbind(on_eos=self._on_video_eos)
except Exception:
pass
# Do NOT set state='stop' on the main thread — in Kivy that
# triggers VideoFFPy.stop() -> unload() -> thread.join(), a
# blocking call that froze the UI for up to 100s+ (see
# playback_trace.log gaps between remove_current_widget and
# widget_removed_async). Detach immediately and let the
# worker thread do the full stop+unload.
try:
self.ids.content_area.remove_widget(self.current_widget)
except Exception:
pass
widget = self.current_widget
self.current_widget = None
self._video_eos_pending = False
self._stop_focus_keeper()
self._stop_video_watchdog()
threading.Thread(
target=self._teardown_video_async,
args=(widget,), daemon=True,
name='video-teardown'
).start()
trace("widget_removed_async")
return
except Exception as e:
Logger.warning(f"SignagePlayer: Error stopping video: {e}")
self.ids.content_area.remove_widget(self.current_widget)
try:
self.ids.content_area.remove_widget(self.current_widget)
except Exception:
pass
self.current_widget = None
# Reset the EOS guard so the next video can advance normally.
self._video_eos_pending = False
self._stop_focus_keeper()
self._stop_video_watchdog()
Logger.debug("SignagePlayer: Previous widget removed")
trace("widget_removed")
def play_weblink(self, url, duration):
"""Display a live web page fullscreen using a Chromium kiosk overlay.
@@ -1887,11 +2258,27 @@ class SignagePlayer(Widget):
Clock.schedule_once(self.next_media, 1)
def next_media(self, dt=None):
"""Move to next media item"""
"""Move to next media item.
A stale-advance guard prevents duplicate/queued next_media calls from
firing right after a long (blocked) teardown and skipping the media
that was just shown.
"""
trace("next_media_called",
was_index=self.current_index,
paused=self.is_paused)
if self.is_paused:
Logger.info(f"SignagePlayer: ⏸ Blocked next_media - player is paused")
trace("next_media_BLOCKED_paused")
return
now = time.monotonic()
if now - self._last_advance_at < 1.0:
trace("next_media_IGNORED_stale",
since_last=round(now - self._last_advance_at, 3))
return
self._last_advance_at = now
Logger.info(f"SignagePlayer: Transitioning to next media (was index {self.current_index})")
self.current_index += 1
@@ -2075,8 +2462,10 @@ class SignagePlayer(Widget):
def restart_playlist(self):
"""Restart playlist from beginning"""
trace("restart_playlist", count=len(self.playlist))
if not self.playlist:
Logger.warning("SignagePlayer: Cannot restart - playlist is empty")
trace("restart_playlist_EMPTY")
return
Logger.info("SignagePlayer: Restarting playlist")
@@ -2299,6 +2688,7 @@ class SignagePlayer(Widget):
def exit_app(self, instance=None):
"""Exit the application"""
Logger.info("SignagePlayer: Exiting application")
self.set_allow_exit(True)
App.get_running_app().stop()