Files
Kiwy-Signage/src/main.py
T
ske087 31ad592e98 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)
2026-08-04 15:57:33 +03:00

2851 lines
124 KiB
Python

"""
Kivy Signage Player - Main Application
Displays content from DigiServer playlists using Kivy framework
"""
PLAYER_VERSION = "1.2.0"
import os
import json
import platform
import signal
import threading
import time
import asyncio
from concurrent.futures import ThreadPoolExecutor
# Set environment variables for better video performance
# Use setdefault() so that a wrapper script (e.g. run_win.py) can pre-set
# Windows-compatible values before this module is imported.
os.environ.setdefault('KIVY_VIDEO', 'ffpyplayer')
os.environ.setdefault('FFPYPLAYER_CODECS', 'h264,h265,vp9,vp8')
os.environ.setdefault('SDL_VIDEO_ALLOW_SCREENSAVER', '0')
os.environ.setdefault('SDL_VIDEODRIVER', 'wayland,x11,dummy')
os.environ.setdefault('SDL_AUDIODRIVER', 'alsa,pulse,dummy')
# Video playback optimizations
os.environ.setdefault('KIVY_AUDIO', 'ffpyplayer')
os.environ.setdefault('KIVY_GL_BACKEND', 'gl')
os.environ.setdefault('KIVY_INPUTPROVIDERS', 'wayland,x11')
os.environ.setdefault('FFMPEG_THREADS', '2')
os.environ.setdefault('LIBPLAYER_BUFFER', '1048576')
os.environ.setdefault('SDL_AUDIODRIVER', 'alsa')
# Configure Kivy BEFORE importing any Kivy modules
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
# Video and audio performance settings
Config.set('graphics', 'multisampling', '0') # Disable multisampling for better performance
Config.set('graphics', 'fast_rgba', '1') # Enable fast RGBA for better performance
# Note: 'audio' section is not available in default Kivy config - it's handled by ffpyplayer
Config.set('kivy', 'log_level', 'warning') # Reduce logging overhead
from kivy.app import App
from kivy.uix.widget import Widget
from kivy.uix.label import Label
from kivy.uix.image import AsyncImage, Image
from kivy.uix.video import Video
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.popup import Popup
from kivy.uix.textinput import TextInput
from kivy.uix.vkeyboard import VKeyboard
from kivy.clock import Clock
from kivy.loader import Loader
from kivy.core.window import Window
from kivy.properties import BooleanProperty
from kivy.logger import Logger
# Try to import evdev for card reader support (after Logger is available)
try:
import evdev
from evdev import InputDevice, categorize, ecodes
EVDEV_AVAILABLE = True
Logger.info("CardReader: evdev library loaded successfully")
except ImportError:
EVDEV_AVAILABLE = False
Logger.warning("CardReader: evdev not available - card reader functionality will use fallback mode")
from kivy.animation import Animation
from kivy.lang import Builder
from kivy.graphics import Rectangle
from kivy.graphics.texture import Texture
from get_playlists_v2 import (
update_playlist_if_needed,
send_playing_status_feedback,
send_playlist_restart_feedback,
send_player_error_feedback
)
from keyboard_widget import KeyboardWidget
from network_monitor import NetworkMonitor
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')
if not os.path.exists(_kv_path):
# Fallback: relative to cwd (for PyInstaller bundled runs)
_kv_path = 'signage_player.kv'
Builder.load_file(_kv_path)
class CardReader:
"""USB Card Reader Handler for user authentication"""
def __init__(self):
self.device = None
self.card_data = ""
self.reading = False
self.last_read_time = 0
self.read_timeout = 5 # seconds
self.evdev_available = EVDEV_AVAILABLE
def find_card_reader(self):
"""Find USB card reader device"""
if not self.evdev_available:
Logger.error("CardReader: evdev library not available")
return False
try:
try:
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
except Exception as e:
Logger.warning(f"CardReader: Could not enumerate devices: {e}")
Logger.info("CardReader: Trying alternative device enumeration...")
# Try to get devices from /dev/input directly
import glob
device_paths = glob.glob('/dev/input/event*')
devices = []
for path in device_paths:
try:
devices.append(evdev.InputDevice(path))
except Exception as dev_err:
Logger.debug(f"CardReader: Could not open {path}: {dev_err}")
continue
# Log all available devices for debugging
Logger.info("CardReader: Scanning input devices...")
for device in devices:
Logger.info(f" - {device.name} ({device.path})")
# Exclusion list: devices to skip (touchscreens, mice, etc.)
exclusion_keywords = [
'touch', 'touchscreen', 'mouse', 'mice', 'trackpad',
'touchpad', 'pen', 'stylus', 'video', 'button', 'lid'
]
# Priority 1: Look for devices explicitly named as card readers
for device in devices:
device_name_lower = device.name.lower()
# Skip excluded devices
if any(keyword in device_name_lower for keyword in exclusion_keywords):
Logger.info(f"CardReader: Skipping excluded device: {device.name}")
continue
# High priority: explicit card reader or RFID reader
if 'card' in device_name_lower or 'reader' in device_name_lower or 'rfid' in device_name_lower or 'hid' in device_name_lower:
capabilities = device.capabilities()
if ecodes.EV_KEY in capabilities:
Logger.info(f"CardReader: Found card reader: {device.name} at {device.path}")
self.device = device
return True
# Priority 2: Look for USB keyboard devices (but not built-in/PS2 keyboards)
# Card readers usually show as USB HID keyboards
for device in devices:
device_name_lower = device.name.lower()
# Skip excluded devices
if any(keyword in device_name_lower for keyword in exclusion_keywords):
continue
# Look for USB keyboards (card readers often appear as "USB Keyboard" or similar)
if 'usb' in device_name_lower and 'keyboard' in device_name_lower:
capabilities = device.capabilities()
if ecodes.EV_KEY in capabilities:
Logger.info(f"CardReader: Using USB keyboard device as card reader: {device.name} at {device.path}")
self.device = device
return True
# Priority 3: Use any keyboard device that hasn't been excluded
Logger.warning("CardReader: No explicit card reader found, searching for any suitable keyboard device")
for device in devices:
device_name_lower = device.name.lower()
# Skip excluded devices
if any(keyword in device_name_lower for keyword in exclusion_keywords):
continue
capabilities = device.capabilities()
if ecodes.EV_KEY in capabilities:
Logger.info(f"CardReader: Using keyboard device as card reader: {device.name} at {device.path}")
self.device = device
return True
Logger.warning("CardReader: No suitable input device found after checking all devices")
return False
except Exception as e:
Logger.error(f"CardReader: Error finding device: {e}")
return False
def read_card_async(self, callback):
"""Start reading card data asynchronously"""
if not self.evdev_available:
Logger.warning("CardReader: evdev not available - waiting for manual timeout/cancel")
# Fallback: In development mode without evdev, don't auto-authenticate
# Let the popup timeout or user cancel manually
# This allows testing the popup behavior
# To enable auto-authentication for testing, uncomment the next line:
# Clock.schedule_once(lambda dt: callback("DEFAULT_USER_12345"), 0.5)
return
if not self.device:
if not self.find_card_reader():
Logger.error("CardReader: Cannot start reading - no device found")
Clock.schedule_once(lambda dt: callback(None), 0)
return
# Reset state completely before starting new read
self.reading = True
self.card_data = "" # Clear any previous data
self.last_read_time = time.time()
Logger.info(f"CardReader: Starting fresh card read (cleared previous data)")
# Start reading in a separate thread
thread = threading.Thread(target=self._read_card_thread, args=(callback,))
thread.daemon = True
thread.start()
def _read_card_thread(self, callback):
"""Thread function to read card data"""
callback_fired = False # Prevent duplicate callbacks
try:
Logger.info("CardReader: Waiting for card swipe...")
for event in self.device.read_loop():
if not self.reading:
Logger.info("CardReader: Reading stopped externally")
break
# Check for timeout
if time.time() - self.last_read_time > self.read_timeout:
Logger.warning("CardReader: Read timeout")
self.reading = False
if not callback_fired:
callback_fired = True
Clock.schedule_once(lambda dt: callback(None), 0)
break
if event.type == ecodes.EV_KEY:
key_event = categorize(event)
if key_event.keystate == 1: # Key down
key_code = key_event.keycode
# Handle Enter key (card read complete)
if key_code == 'KEY_ENTER':
final_card_data = self.card_data.strip()
Logger.info(f"CardReader: Card read complete: '{final_card_data}' (length: {len(final_card_data)})")
self.reading = False
if not callback_fired:
callback_fired = True
if final_card_data: # Have valid data
# Capture card_data now before it can be modified
Clock.schedule_once(lambda dt, data=final_card_data: callback(data), 0)
else: # Empty data
Logger.warning("CardReader: No data collected, sending None")
Clock.schedule_once(lambda dt: callback(None), 0)
break
# Build card data string
elif key_code.startswith('KEY_'):
char = key_code.replace('KEY_', '')
if len(char) == 1: # Single character
self.card_data += char
self.last_read_time = time.time()
except Exception as e:
Logger.error(f"CardReader: Error reading card: {e}")
self.reading = False
if not callback_fired:
callback_fired = True
Clock.schedule_once(lambda dt: callback(None), 0)
def stop_reading(self):
"""Stop reading card data"""
self.reading = False
class CardSwipePopup(Popup):
"""Popup that shows card swipe prompt with 5 second timeout"""
def __init__(self, callback, resources_path, **kwargs):
super(CardSwipePopup, self).__init__(**kwargs)
self.callback = callback
self.resources_path = resources_path
self.timeout_event = None
self.finished = False # Prevent duplicate finish calls
self.received_card_data = None # Store the card data safely
# Set icon source after KV loads
Clock.schedule_once(lambda dt: self._setup_after_kv(), 0)
def _setup_after_kv(self):
"""Setup widgets after KV file has loaded them"""
# Set card icon source
icon_path = os.path.join(self.resources_path, 'access-card.png')
self.ids.icon_image.source = icon_path
# Start countdown
self.remaining_time = 5
self.countdown_event = Clock.schedule_interval(self.update_countdown, 1)
# Schedule timeout
self.timeout_event = Clock.schedule_once(self.on_timeout, 5)
def update_countdown(self, dt):
"""Update countdown display"""
self.remaining_time -= 1
self.ids.countdown_label.text = str(self.remaining_time)
if self.remaining_time <= 0:
Clock.unschedule(self.countdown_event)
def on_timeout(self, dt):
"""Called when timeout occurs"""
Logger.warning("CardSwipePopup: Timeout - no card swiped")
self.ids.message_label.text = 'Timeout - No card detected'
Clock.schedule_once(lambda dt: self.finish(None), 0.5)
def card_received(self, card_data):
"""Called when card data is received"""
if self.finished:
Logger.warning(f"CardSwipePopup: Ignoring duplicate card_received call (already finished)")
return
Logger.info(f"CardSwipePopup: Card received: '{card_data}' (length: {len(card_data) if card_data else 0})")
# Validate card data
if not card_data or len(card_data) < 3:
Logger.warning(f"CardSwipePopup: Invalid card data received (too short), ignoring")
return
# Store card data to prevent race conditions
self.received_card_data = card_data
# Stop timeout to prevent duplicate finish
if self.timeout_event:
Clock.unschedule(self.timeout_event)
self.timeout_event = None
# Change icon to card-checked.png
checked_icon_path = os.path.join(self.resources_path, 'card-checked.png')
Logger.info(f"CardSwipePopup: Loading checked icon from {checked_icon_path}")
# Verify file exists
if os.path.exists(checked_icon_path):
self.ids.icon_image.source = checked_icon_path
self.ids.icon_image.reload() # Force reload the image
Logger.info("CardSwipePopup: Card-checked icon loaded")
else:
Logger.warning(f"CardSwipePopup: card-checked.png not found at {checked_icon_path}")
self.ids.message_label.text = 'Card detected'
self.ids.countdown_label.opacity = 0 # Hide countdown
# Increase delay to 1 second to give time for image to display
Clock.schedule_once(lambda dt: self.finish(self.received_card_data), 1.0)
def cancel(self, instance):
"""Cancel button pressed"""
Logger.info("CardSwipePopup: Cancelled by user")
self.finish(None)
def finish(self, card_data):
"""Clean up and call callback"""
if self.finished:
Logger.warning("CardSwipePopup: finish() called multiple times, ignoring duplicate")
return
self.finished = True
Logger.info(f"CardSwipePopup: Finishing with card_data: '{card_data}'")
# Cancel scheduled events
if self.timeout_event:
Clock.unschedule(self.timeout_event)
if self.countdown_event:
Clock.unschedule(self.countdown_event)
# Dismiss popup
self.dismiss()
# Call callback with result
if self.callback:
Logger.info(f"CardSwipePopup: Calling callback with card_data: '{card_data}'")
self.callback(card_data)
# Custom keyboard container with close button
class KeyboardContainer(BoxLayout):
def __init__(self, vkeyboard, **kwargs):
super(KeyboardContainer, self).__init__(**kwargs)
self.orientation = 'vertical'
self.vkeyboard = vkeyboard
# Calculate dimensions - half screen width
container_width = Window.width * 0.5
# Height proportional to width (maintain aspect ratio)
# Standard keyboard aspect ratio is ~3:1 (width:height)
keyboard_height = container_width / 3
close_bar_height = 50
total_height = keyboard_height + close_bar_height
# Set exact size (not size_hint)
self.size_hint = (None, None)
self.size = (container_width, total_height)
# Center horizontally at bottom
self.x = (Window.width - container_width) / 2
self.y = 0
# Bind to window resize
Window.bind(on_resize=self._on_window_resize)
# Create close button bar
close_bar = BoxLayout(
orientation='horizontal',
size_hint=(1, None),
height=close_bar_height,
padding=[10, 5, 10, 5]
)
# Add a spacer
close_bar.add_widget(Widget())
# Create close button
close_btn = Button(
text='',
size_hint=(None, 1),
width=50,
background_color=(0.8, 0.2, 0.2, 0.9),
font_size='24sp',
bold=True
)
close_btn.bind(on_press=self.close_keyboard)
close_bar.add_widget(close_btn)
# Add close button bar at top
self.add_widget(close_bar)
# Set keyboard exact size to match container width
self.vkeyboard.size_hint = (None, None)
self.vkeyboard.width = container_width
self.vkeyboard.height = keyboard_height
# Force keyboard to respect our dimensions
self.vkeyboard.scale_min = container_width / Window.width
self.vkeyboard.scale_max = container_width / Window.width
# Add keyboard
self.add_widget(self.vkeyboard)
# Background
with self.canvas.before:
from kivy.graphics import Color, RoundedRectangle
Color(0.1, 0.1, 0.1, 0.95)
self.bg_rect = RoundedRectangle(pos=self.pos, size=self.size, radius=[15, 15, 0, 0])
self.bind(pos=self._update_bg, size=self._update_bg)
Logger.info(f"KeyboardContainer: Created at ({self.x}, {self.y}) with size {self.size}")
def _on_window_resize(self, window, width, height):
"""Reposition and resize keyboard on window resize"""
container_width = width * 0.5
keyboard_height = container_width / 3 # Maintain aspect ratio
close_bar_height = 50
total_height = keyboard_height + close_bar_height
self.size = (container_width, total_height)
self.x = (width - container_width) / 2
self.y = 0
if self.vkeyboard:
self.vkeyboard.width = container_width
self.vkeyboard.height = keyboard_height
self.vkeyboard.scale_min = container_width / width
self.vkeyboard.scale_max = container_width / width
def _update_bg(self, *args):
"""Update background rectangle"""
self.bg_rect.pos = self.pos
self.bg_rect.size = self.size
def close_keyboard(self, *args):
"""Close the keyboard"""
Logger.info("KeyboardContainer: Closing keyboard")
if self.vkeyboard.target:
self.vkeyboard.target.focus = False
# Custom VKeyboard that uses container
class CustomVKeyboard(VKeyboard):
def __init__(self, **kwargs):
super(CustomVKeyboard, self).__init__(**kwargs)
self.container = None
Clock.schedule_once(self._setup_container, 0.1)
def _setup_container(self, dt):
"""Wrap keyboard in a container with close button"""
if self.parent and not self.container:
# Remove keyboard from parent
parent = self.parent
parent.remove_widget(self)
# Create container with keyboard
self.container = KeyboardContainer(self)
# Add container to parent
parent.add_widget(self.container)
Logger.info("CustomVKeyboard: Wrapped in container with close button")
# Set the custom keyboard factory (only if Window is properly initialized)
if Window is not None:
try:
Window.set_vkeyboard_class(CustomVKeyboard)
except Exception as e:
Logger.warning(f"CustomVKeyboard: Could not set custom keyboard: {e}")
class ExitPasswordPopup(Popup):
def __init__(self, player_instance, was_paused=False, **kwargs):
super(ExitPasswordPopup, self).__init__(**kwargs)
self.player = player_instance
self.was_paused = was_paused
self.keyboard_widget = None
# Cancel all scheduled cursor/control hide events
try:
if self.player.controls_timer:
self.player.controls_timer.cancel()
self.player.controls_timer = None
Clock.unschedule(self.player.hide_controls)
Clock.unschedule(self.player.schedule_cursor_hide)
except Exception as e:
Logger.debug(f"ExitPasswordPopup: Error canceling timers: {e}")
# Show cursor when password popup opens
try:
Window.show_cursor = True
except:
pass
# Bind to dismiss event to manage cursor visibility and resume playback
self.bind(on_dismiss=self.on_popup_dismiss)
# Show keyboard after popup opens
Clock.schedule_once(self._show_keyboard, 0.2)
def _show_keyboard(self, dt):
"""Show the custom keyboard"""
try:
# Create keyboard widget and add to window
self.keyboard_widget = KeyboardWidget()
Window.add_widget(self.keyboard_widget)
self.keyboard_widget.show_keyboard(self.ids.password_input)
Logger.info("ExitPasswordPopup: Keyboard added to window")
except Exception as e:
Logger.warning(f"ExitPasswordPopup: Could not show keyboard: {e}")
def on_input_focus(self, instance, value):
"""Handle input field focus"""
if value and self.keyboard_widget: # Got focus
try:
self.keyboard_widget.show_keyboard(instance)
except Exception as e:
Logger.debug(f"ExitPasswordPopup: Could not show keyboard on focus: {e}")
def key_pressed(self, key):
"""Handle key press from keyboard"""
if self.keyboard_widget:
self.keyboard_widget.key_pressed(key)
def hide_keyboard(self):
"""Hide the custom keyboard"""
if self.keyboard_widget:
self.keyboard_widget.hide_keyboard()
Window.remove_widget(self.keyboard_widget)
self.keyboard_widget = None
def on_popup_dismiss(self, *args):
"""Handle popup dismissal - resume playback and restart cursor hide timer"""
# Hide and remove keyboard
self.hide_keyboard()
# 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"""
entered_password = self.ids.password_input.text
correct_password = self.player.config.get('quickconnect_key', '1234567')
if entered_password == correct_password:
# Password correct, exit app
Logger.info("ExitPasswordPopup: Correct password, exiting app")
# Create stop flag to prevent watchdog restart
stop_flag = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'.player_stop_requested'
)
try:
with open(stop_flag, 'w') as f:
f.write('User requested exit via password')
Logger.info("ExitPasswordPopup: Stop flag created - watchdog will not restart")
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:
# Password incorrect, show error and close popup
Logger.warning("ExitPasswordPopup: Incorrect password")
self.ids.error_label.text = 'Incorrect password!'
Clock.schedule_once(lambda dt: self.dismiss(), 1)
class SettingsPopup(Popup):
def __init__(self, player_instance, was_paused=False, **kwargs):
super(SettingsPopup, self).__init__(**kwargs)
self.player = player_instance
self.was_paused = was_paused
self.keyboard_widget = None
# Cancel all scheduled cursor/control hide events
try:
if self.player.controls_timer:
self.player.controls_timer.cancel()
self.player.controls_timer = None
Clock.unschedule(self.player.hide_controls)
Clock.unschedule(self.player.schedule_cursor_hide)
except Exception as e:
Logger.debug(f"SettingsPopup: Error canceling timers: {e}")
# Show cursor when settings open
try:
Window.show_cursor = True
except:
pass
# Populate current values
self.ids.server_input.text = self.player.config.get('server_ip', 'localhost')
self.ids.port_input.text = str(self.player.config.get('port', ''))
self.ids.screen_input.text = self.player.config.get('screen_name', 'kivy-player')
self.ids.quickconnect_input.text = self.player.config.get('quickconnect_key', '1234567')
self.ids.orientation_input.text = self.player.config.get('orientation', 'Landscape')
self.ids.touch_input.text = self.player.config.get('touch', 'True')
self.ids.resolution_input.text = self.player.config.get('max_resolution', 'auto')
self.ids.edit_enabled_checkbox.active = self.player.config.get('edit_feature_enabled', True)
# Update status info
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)
def on_input_touch(self, instance, touch):
"""Handle touch on input field to show keyboard"""
Logger.info(f"SettingsPopup: Input touched: {instance}")
self._show_keyboard(instance)
return False # Don't consume the touch event
def _show_keyboard(self, target_input):
"""Show the custom keyboard"""
try:
if not self.keyboard_widget:
# Create keyboard widget and add to window
self.keyboard_widget = KeyboardWidget()
Window.add_widget(self.keyboard_widget)
Logger.info("SettingsPopup: Keyboard added to window")
self.keyboard_widget.show_keyboard(target_input)
except Exception as e:
Logger.warning(f"SettingsPopup: Could not show keyboard: {e}")
def key_pressed(self, key):
"""Handle key press from keyboard"""
if self.keyboard_widget:
self.keyboard_widget.key_pressed(key)
def hide_keyboard(self):
"""Hide the custom keyboard"""
if self.keyboard_widget:
self.keyboard_widget.hide_keyboard()
Window.remove_widget(self.keyboard_widget)
self.keyboard_widget = None
def on_edit_feature_toggle(self, is_active):
"""Handle edit feature checkbox toggle"""
Logger.info(f"SettingsPopup: Edit feature {'enabled' if is_active else 'disabled'}")
# Update config immediately so it's available
self.player.config['edit_feature_enabled'] = is_active
def on_popup_dismiss(self, *args):
"""Handle popup dismissal - resume playback and restart cursor hide timer"""
# Hide and remove keyboard
self.hide_keyboard()
# 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
self.ids.connection_status.text = 'Testing connection...'
self.ids.connection_status.color = (1, 0.7, 0, 1) # Orange
# Run test in background thread to avoid blocking UI
def run_test():
try:
from player_auth import PlayerAuth
import re
# Get current values from inputs
server_ip = self.ids.server_input.text.strip()
screen_name = self.ids.screen_input.text.strip()
quickconnect = self.ids.quickconnect_input.text.strip()
port = self.ids.port_input.text.strip() or self.player.config.get('port', '')
use_https = self.player.config.get('use_https', True)
verify_ssl = self.player.config.get('verify_ssl', True)
if not all([server_ip, screen_name, quickconnect]):
Clock.schedule_once(lambda dt: self.update_connection_status(
'Error: Fill all fields', False
))
return
# Build server URL
if server_ip.startswith('http://') or server_ip.startswith('https://'):
server_url = server_ip
if not ':' in server_ip.replace('https://', '').replace('http://', ''):
if port and port != '443' and port != '80':
server_url = f"{server_ip}:{port}"
else:
protocol = "https" if use_https else "http"
if ':' in server_ip:
# server_ip already contains a port (e.g. 192.168.0.230:8080)
server_url = f"{protocol}://{server_ip}"
else:
server_url = f"{protocol}://{server_ip}:{port}" if port else f"{protocol}://{server_ip}"
Logger.info(f"SettingsPopup: Testing connection to {server_url} (HTTPS: {use_https}, Verify SSL: {verify_ssl})")
# Create temporary auth instance (don't save)
auth = PlayerAuth('/tmp/temp_auth_test.json', use_https=use_https, verify_ssl=verify_ssl)
# Try to authenticate
success, error = auth.authenticate(
server_url=server_url,
hostname=screen_name,
quickconnect_code=quickconnect
)
# Clean up temp file
try:
import os
if os.path.exists('/tmp/temp_auth_test.json'):
os.remove('/tmp/temp_auth_test.json')
except:
pass
# Update UI on main thread
if success:
player_name = auth.get_player_name()
Clock.schedule_once(lambda dt: self.update_connection_status(
f'✓ Connected: {player_name}', True
))
else:
Clock.schedule_once(lambda dt: self.update_connection_status(
f'✗ Failed: {error}', False
))
except Exception as e:
Logger.error(f"SettingsPopup: Connection test error: {e}")
Clock.schedule_once(lambda dt: self.update_connection_status(
f'✗ Error: {str(e)}', False
))
# Run in thread
threading.Thread(target=run_test, daemon=True).start()
def update_connection_status(self, message, success):
"""Update connection status label"""
self.ids.connection_status.text = message
if success:
self.ids.connection_status.color = (0, 1, 0, 1) # Green
else:
self.ids.connection_status.color = (1, 0, 0, 1) # Red
def reset_player_auth(self):
"""Reset player authentication by deleting player_auth.json"""
try:
auth_file = os.path.join(os.path.dirname(__file__), 'player_auth.json')
if os.path.exists(auth_file):
os.remove(auth_file)
Logger.info(f"SettingsPopup: Deleted authentication file: {auth_file}")
self._show_temp_message('✓ Authentication reset - will reauthenticate on restart', (0, 1, 0, 1))
else:
Logger.info("SettingsPopup: No authentication file found")
self._show_temp_message('No authentication file found', (1, 0.7, 0, 1))
except Exception as e:
Logger.error(f"SettingsPopup: Error resetting authentication: {e}")
self._show_temp_message(f'✗ Error: {str(e)}', (1, 0, 0, 1))
def reset_playlist_version(self):
"""Reset playlist version to 0 and rename playlist file"""
try:
playlists_dir = os.path.join(self.player.base_dir, 'playlists')
playlist_file = os.path.join(playlists_dir, 'server_playlist.json')
if os.path.exists(playlist_file):
# Delete the playlist file to force re-download
os.remove(playlist_file)
Logger.info(f"SettingsPopup: Deleted playlist file - will resync from server")
# Reset player's playlist version
self.player.playlist_version = 0
# Update display
self.ids.playlist_info.text = f'Playlist: v{self.player.playlist_version}'
self._show_temp_message('✓ Playlist reset - will resync from server', (0, 1, 0, 1))
else:
Logger.info("SettingsPopup: No playlist file found")
self._show_temp_message('No playlist file found', (1, 0.7, 0, 1))
except Exception as e:
Logger.error(f"SettingsPopup: Error resetting playlist: {e}")
self._show_temp_message(f'✗ Error: {str(e)}', (1, 0, 0, 1))
def restart_player(self):
"""Restart playlist from the first item"""
try:
Logger.info("SettingsPopup: Restarting player from first item")
# Reset to first item
self.player.current_index = 0
# Show message
self._show_temp_message('✓ Restarting playlist from beginning...', (0, 1, 0, 1))
# Close settings after a short delay
def close_and_restart(dt):
self.dismiss()
# Start playing from first item
self.player.play_current_media()
Clock.schedule_once(close_and_restart, 2)
except Exception as e:
Logger.error(f"SettingsPopup: Error restarting player: {e}")
self._show_temp_message(f'✗ Error: {str(e)}', (1, 0, 0, 1))
def _show_temp_message(self, message, color):
"""Show a temporary popup message for 2 seconds"""
from kivy.uix.popup import Popup
from kivy.uix.label import Label
popup = Popup(
title='',
content=Label(text=message, color=color),
size_hint=(0.6, 0.3),
auto_dismiss=True
)
popup.open()
# Auto-close after 2 seconds
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
def save_and_close(self):
"""Save configuration and close popup"""
# Update config
self.player.config['server_ip'] = self.ids.server_input.text
self.player.config['port'] = self.ids.port_input.text.strip()
self.player.config['screen_name'] = self.ids.screen_input.text
self.player.config['quickconnect_key'] = self.ids.quickconnect_input.text
self.player.config['orientation'] = self.ids.orientation_input.text
self.player.config['touch'] = self.ids.touch_input.text
self.player.config['max_resolution'] = self.ids.resolution_input.text
self.player.config['edit_feature_enabled'] = self.ids.edit_enabled_checkbox.active
# Save to file
self.player.save_config()
# Notify user that resolution change requires restart
if self.ids.resolution_input.text != self.player.config.get('max_resolution', 'auto'):
Logger.info("SettingsPopup: Resolution changed - restart required")
# Close popup
self.dismiss()
class SignagePlayer(Widget):
from kivy.properties import StringProperty
resources_path = StringProperty()
from kivy.properties import NumericProperty
screen_width = NumericProperty(0)
screen_height = NumericProperty(0)
def set_screen_size(self):
"""Get the current screen size and set as properties."""
self.screen_width, self.screen_height = Window.size
Logger.info(f"Screen size detected: {self.screen_width}x{self.screen_height}")
is_paused = BooleanProperty(False)
def __init__(self, **kwargs):
super(SignagePlayer, self).__init__(**kwargs)
# Initialize variables
self.playlist = []
self.current_index = 0
self.current_widget = None
self._weblink_proc = None # Handle to the Chromium kiosk process for weblink items
self._weblink_preload_proc = None # Hidden Chromium pre-warming the next weblink URL
self._watchdog_stop = None # threading.Event to stop the inactivity watchdog
self._weblink_watchdog_thread = None # Background thread monitoring touch inactivity
self.is_playing = False
self.is_paused = False
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
self.intro_played = False # Track if intro has been played
# Card reader for authentication
self.card_reader = None
self._pending_edit_image = None
# Network monitor
self.network_monitor = None
# Paths
self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.config_dir = os.path.join(self.base_dir, 'config')
self.media_dir = os.path.join(self.base_dir, 'media')
self.playlists_dir = os.path.join(self.base_dir, 'playlists')
self.config_file = os.path.join(self.config_dir, 'app_config.json')
self.resources_path = os.path.join(self.config_dir, 'resources')
self.heartbeat_file = os.path.join(self.base_dir, '.player_heartbeat')
# Create directories if they don't exist
for directory in [self.config_dir, self.media_dir, self.playlists_dir]:
os.makedirs(directory, exist_ok=True)
# Get and set screen size
self.set_screen_size()
# 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
self.controls_timer = None
# Auto-hide controls
self.schedule_hide_controls()
# Start heartbeat monitoring
Clock.schedule_interval(self.update_heartbeat, 10) # Update every 10 seconds
# Start screen activity signaler (keep display awake)
Clock.schedule_interval(self.signal_screen_activity, 20) # Signal every 20 seconds
def _update_size(self, instance, value):
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:
# Touch the heartbeat file to update its modification time
with open(self.heartbeat_file, 'w') as f:
f.write(str(time.time()))
except Exception as e:
Logger.warning(f"SignagePlayer: Failed to update heartbeat: {e}")
def signal_screen_activity(self, dt):
"""Signal screen activity to prevent power saving/sleep
Supports both X11 and Wayland display servers.
Uses multiple methods to keep display awake.
"""
try:
# Detect display server type
is_wayland = os.environ.get('WAYLAND_DISPLAY') is not None
if is_wayland:
# Wayland-specific commands
# Method 1: Use wlopm (Wayland output power management)
os.system('wlopm --on \\* 2>/dev/null || true')
# Method 2: Use wlr-randr for wlroots compositors
os.system('wlr-randr --output HDMI-A-1 --on 2>/dev/null || true')
# Method 3: Simulate activity via ydotool (Wayland's xdotool alternative)
if os.system('which ydotool 2>/dev/null') == 0:
os.system('ydotool mousemove -x 1 -y 1 2>/dev/null || true')
os.system('ydotool mousemove -x -1 -y -1 2>/dev/null || true')
# Method 4: Keep HDMI powered on (works on both)
os.system('/usr/bin/tvservice -p 2>/dev/null')
Logger.debug("SignagePlayer: Wayland screen activity signal sent")
else:
# X11-specific commands (original code)
# Method 1: Reset screensaver timer using xset
os.system('DISPLAY=:0 xset s reset 2>/dev/null')
# Method 2: Force display on
os.system('DISPLAY=:0 xset dpms force on 2>/dev/null')
# Method 3: Disable DPMS
os.system('DISPLAY=:0 xset -dpms 2>/dev/null')
# Method 4: Move mouse slightly (subtle, user won't notice)
if os.system('DISPLAY=:0 xdotool mousemove_relative 1 1 2>/dev/null') == 0:
os.system('DISPLAY=:0 xdotool mousemove_relative -1 -1 2>/dev/null')
# Method 5: Keep HDMI powered on (Raspberry Pi specific)
os.system('/usr/bin/tvservice -p 2>/dev/null')
# Method 6: Disable monitor power saving via xrandr
os.system('DISPLAY=:0 xrandr --output HDMI-1 --power-profile performance 2>/dev/null || true')
Logger.debug("SignagePlayer: X11 screen activity signal sent")
except Exception as e:
Logger.debug(f"SignagePlayer: Screen activity signal failed (non-critical): {e}")
# Non-critical - continue if this fails
def initialize_player(self, dt):
"""Initialize the player - load config and start playlist checking"""
Logger.info("SignagePlayer: Initializing player...")
# 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()
# Play intro video first
self.play_intro_video()
# Start async server tasks (non-blocking)
asyncio.ensure_future(self.async_playlist_update_loop())
Logger.info("SignagePlayer: Async server tasks started")
# Start media playback
Clock.schedule_interval(self.check_playlist_and_play, 30) # Check every 30 seconds
def load_config(self):
"""Load configuration from file"""
Logger.debug("SignagePlayer: load_config() starting...")
try:
if os.path.exists(self.config_file):
with open(self.config_file, 'r') as f:
self.config = json.load(f)
Logger.info(f"SignagePlayer: Configuration loaded from {self.config_file}")
else:
# Create default configuration with HTTPS support
self.config = {
"server_ip": "localhost",
"port": "443",
"screen_name": "kivy-player",
"quickconnect_key": "1234567",
"max_resolution": "auto",
"use_https": True,
"verify_ssl": True,
"production_mode": False
}
self.save_config()
Logger.info("SignagePlayer: Created default configuration with HTTPS enabled")
except Exception as e:
Logger.error(f"SignagePlayer: Error loading config: {e}")
self.show_error(f"Failed to load configuration: {e}")
def save_config(self):
"""Save configuration to file"""
try:
with open(self.config_file, 'w') as f:
json.dump(self.config, f, indent=2)
Logger.info("SignagePlayer: Configuration saved")
except Exception as e:
Logger.error(f"SignagePlayer: Error saving config: {e}")
def start_network_monitoring(self):
"""Initialize and start network monitoring"""
try:
if self.config and 'server_ip' in self.config:
server_url = self.config.get('server_ip', '')
# Initialize network monitor with:
# - Check every 30-45 minutes
# - Restart WiFi for 20 minutes on connection failure
self.network_monitor = NetworkMonitor(
server_url=server_url,
check_interval_min=30,
check_interval_max=45,
wifi_restart_duration=20
)
# Start monitoring
self.network_monitor.start_monitoring()
Logger.info("SignagePlayer: Network monitoring started")
else:
Logger.warning("SignagePlayer: Cannot start network monitoring - no server configured")
except Exception as e:
Logger.error(f"SignagePlayer: Error starting network monitoring: {e}")
async def async_playlist_update_loop(self):
"""Async coroutine to check for playlist updates without blocking UI"""
Logger.info("SignagePlayer: Starting async playlist update loop")
while True:
try:
if self.config:
# Run blocking network I/O in thread pool
loop = asyncio.get_event_loop()
# Check for updates (network I/O in thread pool)
# Note: get_playlists_v2 doesn't need latest_playlist parameter
updated = await loop.run_in_executor(
None,
update_playlist_if_needed,
self.config,
self.playlists_dir,
self.media_dir
)
if updated:
Logger.info("SignagePlayer: Playlist updated, reloading...")
# Schedule UI update on main thread
Clock.schedule_once(self.load_playlist, 0)
# Wait 60 seconds before next check
await asyncio.sleep(60)
except Exception as e:
Logger.error(f"SignagePlayer: Error in async playlist update loop: {e}")
await asyncio.sleep(30) # Wait 30 seconds on error
async def async_send_feedback(self, feedback_func, *args):
"""Send feedback to server asynchronously without blocking UI"""
try:
loop = asyncio.get_event_loop()
# Run feedback in thread pool to avoid blocking
await loop.run_in_executor(None, feedback_func, *args)
except Exception as e:
Logger.debug(f"SignagePlayer: Error sending async feedback: {e}")
def get_latest_playlist_file(self):
"""Get the path to the playlist file"""
return os.path.join(self.playlists_dir, 'server_playlist.json')
def load_playlist(self, dt=None):
"""Load playlist from file"""
try:
playlist_file = self.get_latest_playlist_file()
if os.path.exists(playlist_file):
with open(playlist_file, 'r') as f:
data = json.load(f)
self.playlist = data.get('playlist', [])
# Check for both 'version' and 'playlist_version' keys (for backward compatibility)
self.playlist_version = data.get('version', data.get('playlist_version', 0))
Logger.info(f"SignagePlayer: Loaded playlist v{self.playlist_version} with {len(self.playlist)} items")
if self.playlist:
self.ids.status_label.text = f"Playlist loaded: {len(self.playlist)} items"
# Only start playback if intro has finished
if not self.is_playing and self.intro_played:
Clock.schedule_once(self.start_playback, 1)
else:
self.ids.status_label.text = "No media in playlist"
else:
Logger.warning(f"SignagePlayer: Playlist file not found: {playlist_file}")
self.ids.status_label.text = "No playlist found"
except Exception as e:
Logger.error(f"SignagePlayer: Error loading playlist: {e}")
self.show_error(f"Failed to load playlist: {e}")
def play_intro_video(self):
"""Play intro video on startup"""
Logger.info(f"SignagePlayer: play_intro_video() called, intro_played={self.intro_played}")
intro_path = os.path.join(self.resources_path, 'intro1.mp4')
if not os.path.exists(intro_path):
Logger.warning(f"SignagePlayer: Intro video not found at {intro_path}")
# Skip intro and load playlist
self.intro_played = True
Clock.schedule_once(self.check_playlist_and_play, 0.1)
return
try:
Logger.info("SignagePlayer: Playing intro video...")
self.ids.status_label.opacity = 0 # Hide status label
# Create video widget for intro
intro_video = Video(
source=intro_path,
state='play',
options={'eos': 'stop'},
size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5}
)
# Bind to video end event
def on_intro_end(instance, value):
try:
if value == 'stop':
Logger.info("SignagePlayer: Intro video finished")
# Mark intro as played before removing video
self.intro_played = True
# Stop and unload the video properly
try:
instance.state = 'stop'
instance.unload()
except Exception as e:
Logger.debug(f"SignagePlayer: Could not unload intro video: {e}")
# Remove intro video
try:
if intro_video in self.ids.content_area.children:
self.ids.content_area.remove_widget(intro_video)
except Exception as e:
Logger.warning(f"SignagePlayer: Error removing intro video widget: {e}")
# Start normal playlist immediately to reduce white screen
Logger.debug("SignagePlayer: Triggering playlist check after intro")
self.check_playlist_and_play(None)
except Exception as e:
Logger.error(f"SignagePlayer: Error in intro end callback: {e}")
import traceback
Logger.error(f"SignagePlayer: Traceback: {traceback.format_exc()}")
intro_video.bind(state=on_intro_end)
# Add intro video to content area
self.ids.content_area.add_widget(intro_video)
except Exception as e:
Logger.error(f"SignagePlayer: Error playing intro video: {e}")
import traceback
Logger.error(f"SignagePlayer: Traceback: {traceback.format_exc()}")
# Skip intro and load playlist
self.intro_played = True
Clock.schedule_once(self.check_playlist_and_play, 0.1)
def check_playlist_and_play(self, dt):
"""Check for playlist updates and ensure playback is running"""
# Don't start playlist until intro is done
if not self.intro_played:
return
if not self.playlist:
self.load_playlist()
if self.playlist and not self.is_playing and not self.is_paused:
self.start_playback()
def start_playback(self, dt=None):
"""Start media playback"""
if not self.playlist:
Logger.warning("SignagePlayer: No playlist to play")
return
Logger.info("SignagePlayer: Starting playback")
self.is_playing = True
self.current_index = 0
self.play_current_media()
def play_current_media(self, force_reload=False, _after_weblink=False):
"""Play the current media item
Args:
force_reload: If True, clears image cache before loading (for edited images)
_after_weblink: Internal flag — True when called after the weblink
dismiss delay so we skip the delay logic a second time.
"""
# Don't play if paused (unless we're explicitly resuming)
if self.is_paused:
Logger.debug(f"SignagePlayer: Skipping play_current_media - player is paused")
return
if not self.playlist:
Logger.warning("SignagePlayer: Cannot play - playlist is empty")
return
if self.current_index >= len(self.playlist):
# End of playlist, restart
self.restart_playlist()
return
try:
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,
# then close Chromium on the next frame. This prevents a brief
# desktop exposure while the compositor removes Chromium.
if not _after_weblink:
proc = getattr(self, '_weblink_proc', None)
if proc is not None:
next_is_weblink = media_item.get('type') == 'weblink'
if next_is_weblink:
# Weblink -> weblink: close current Chromium first.
self._stop_inactivity_watchdog()
self._kill_weblink_preload()
self._weblink_proc = None
if proc.poll() is None:
try:
proc.terminate()
except Exception as exc:
Logger.warning(f"SignagePlayer: weblink terminate: {exc}")
def _resume(dt):
if proc.poll() is None:
try:
proc.kill()
except Exception:
pass
try:
Window.show()
Window.raise_window()
except Exception:
pass
self.play_current_media(
force_reload=force_reload, _after_weblink=True
)
Clock.schedule_once(_resume, 0.2)
return
# Weblink -> non-weblink: keep Chromium visible while we
# prepare next Kivy frame, then close Chromium deferred.
self._stop_inactivity_watchdog()
self._kill_weblink_preload()
self._weblink_proc = proc
try:
self.ids.content_area.opacity = 1
except Exception:
pass
try:
Window.show()
Window.raise_window()
except Exception:
pass
Logger.debug("SignagePlayer: Deferred Chromium close after next frame render")
# ────────────────────────────────────────────────────────────────
# 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
try:
self.ids.content_area.opacity = 0
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:
asyncio.ensure_future(
self.async_send_feedback(
send_playing_status_feedback,
self.config,
self.playlist_version,
file_name
)
)
return
# Construct full path to media file
media_path = os.path.join(self.media_dir, file_name)
# Use single os.stat() call to check existence and get size (more efficient)
try:
file_stat = os.stat(media_path)
Logger.debug(f"SignagePlayer: File size: {file_stat.st_size:,} bytes")
except FileNotFoundError:
Logger.error(f"SignagePlayer: ❌ Media file not found: {media_path}")
Logger.error(f"SignagePlayer: Skipping to next media...")
self.consecutive_errors += 1
self._skip_to_next_media()
return
# Remove status label if showing
self.ids.status_label.opacity = 0
# Remove previous media widget
self._remove_current_widget()
# Determine media type and create appropriate widget
file_extension = os.path.splitext(file_name)[1].lower()
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}")
Logger.warning(f"SignagePlayer: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif/.webp")
Logger.warning(f"SignagePlayer: Skipping to next media...")
self.consecutive_errors += 1
self._skip_to_next_media()
return
# Send feedback to server asynchronously (non-blocking)
if self.config:
asyncio.ensure_future(
self.async_send_feedback(
send_playing_status_feedback,
self.config,
self.playlist_version,
file_name
)
)
# Reset error counter on successful playback
self.consecutive_errors = 0
Logger.debug(f"SignagePlayer: Media started successfully")
# 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
if self.consecutive_errors >= self.max_consecutive_errors:
error_msg = f"Too many consecutive errors ({self.consecutive_errors}), stopping playback"
Logger.error(f"SignagePlayer: {error_msg}")
self.show_error(error_msg)
self.is_playing = False
return
self.show_error(f"Error playing media: {e}")
self._skip_to_next_media()
def play_video(self, video_path, duration):
"""Play a video file using Kivy's Video widget with optimizations"""
try:
# Verify file exists
if not os.path.exists(video_path):
Logger.error(f"SignagePlayer: ❌ Video file not found: {video_path}")
self.consecutive_errors += 1
self._skip_to_next_media()
return
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
options={
'eos': 'stop', # Stop at end of stream
'ff_opts': {
# FFmpeg options for better playback performance
'buffer_size': '2048000', # 2MB buffer
'threads': '4', # Multi-threaded decoding
'fflags': '+ignidx', # Ignore index for better seeking
},
'allow_fallback': True, # Allow codec fallback
},
size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5},
anim_delay=1.0/60.0 # 60 FPS animation
)
# Bind to loaded and error events
self.current_widget.bind(loaded=self._on_video_loaded)
self.current_widget.bind(on_eos=self._on_video_eos)
# Add to content area
self.ids.content_area.add_widget(self.current_widget)
# 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)
# Preload next media asynchronously for smoother transitions
self.preload_next_media()
except Exception as e:
Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}")
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.
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)")
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._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 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"""
try:
# Log file info before loading
Logger.debug(f"SignagePlayer: Loading image: {os.path.basename(image_path)} ({os.path.getsize(image_path):,} bytes)")
if force_reload:
Logger.debug(f"SignagePlayer: Force reload - bypassing cache")
# Use regular Image widget instead of AsyncImage to bypass all caching
from kivy.uix.image import Image
self.current_widget = Image(
source=image_path,
allow_stretch=True,
keep_ratio=True,
size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5}
)
# Force reload the texture
self.current_widget.reload()
else:
self.current_widget = AsyncImage(
source=image_path,
allow_stretch=True,
keep_ratio=True, # Maintain aspect ratio
size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5}
)
self.ids.content_area.add_widget(self.current_widget)
# Schedule next media after 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)
# Preload next media asynchronously for smoother transitions
self.preload_next_media()
except Exception as e:
Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}")
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...")
# 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}")
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.
Kivy has no production-grade embedded web view on Raspberry Pi, so we
launch Chromium in kiosk mode over the Kivy window for the item's
duration, then close it and advance to the next item.
Returns True if the browser was launched, False otherwise.
"""
import shutil
import subprocess
from urllib.parse import urlparse
# Defence in depth: only ever open http/https links.
scheme = urlparse(url).scheme.lower()
if scheme not in ('http', 'https'):
Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}")
self.consecutive_errors += 1
self._skip_to_next_media()
return False
browser = shutil.which('chromium-browser') or shutil.which('chromium')
if not browser:
Logger.error("SignagePlayer: Chromium not installed; cannot display weblink")
self.consecutive_errors += 1
self._skip_to_next_media()
return False
target_width, target_height = self._get_browser_target_size()
try:
Logger.info(f"SignagePlayer: Opening weblink in kiosk browser: {url}")
Logger.info(f"SignagePlayer: Inactivity timeout set to {duration}s (touch resets the countdown)")
Logger.info(f"SignagePlayer: Chromium target launch size: {target_width}x{target_height}")
# Kill the hidden pre-warm instance first; its work (binary in RAM,
# page in disk cache) makes the kiosk relaunch below near-instant.
self._kill_weblink_preload()
self._weblink_proc = subprocess.Popen([
browser,
# Use --start-fullscreen instead of --kiosk.
# --kiosk requests Wayland's exclusive-fullscreen protocol which
# prevents Labwc from restoring the previous window on close.
# --start-fullscreen is a normal maximised window that the
# compositor can un-stack without issues.
'--start-fullscreen',
'--start-maximized',
'--app=' + url,
'--noerrdialogs',
'--disable-infobars',
'--incognito',
'--no-first-run',
'--disable-session-crashed-bubble',
'--check-for-update-interval=31536000',
# Suppress the GNOME keyring / wallet unlock popup
'--password-store=basic',
'--use-mock-keychain',
# Prevent any other credential / sync dialogs
'--disable-sync',
'--disable-background-networking',
'--no-default-browser-check',
'--window-position=0,0',
f'--window-size={target_width},{target_height}',
'--force-device-scale-factor=1',
])
# Unschedule any previous fixed timer — the watchdog thread takes
# over and only advances after 'duration' seconds of NO touch activity.
Clock.unschedule(self.next_media)
self._start_inactivity_watchdog(duration)
# Preload the next image so the transition after the weblink is smooth.
self.preload_next_media()
return True
except Exception as e:
Logger.error(f"SignagePlayer: Error opening weblink {url}: {e}")
# Restore content area if launch failed
try:
self.ids.content_area.opacity = 1
except Exception:
pass
self.consecutive_errors += 1
self._weblink_proc = None
self._skip_to_next_media()
return False
def _get_browser_target_size(self):
"""Return a stable browser launch size to avoid 800x600 first-paint flash."""
default_width, default_height = 1920, 1080
try:
width, height = Window.size
width = int(width)
height = int(height)
except Exception:
return default_width, default_height
# Chromium sometimes first-paints at 800x600 before fullscreen;
# force at least Full HD when reported dimensions are too small.
if width < 1280 or height < 720:
return default_width, default_height
return width, height
def _start_inactivity_watchdog(self, duration):
"""Start a background thread that monitors /dev/input/* for touch/key
activity. The thread resets the idle counter on every event; when the
screen has been idle for *duration* seconds it fires next_media().
Works even while Chromium owns the display (raw device reads bypass the
window-manager focus).
Also fires immediately if Chromium exits on its own (e.g. user closes it).
"""
import glob
import select
import threading
import time
# Stop any previous watchdog cleanly before starting a new one.
self._stop_inactivity_watchdog()
stop_event = threading.Event()
self._watchdog_stop = stop_event
weblink_proc = self._weblink_proc # snapshot so thread sees the right process
def watchdog():
# Open every available input event device (touchscreen, mouse, kbd).
devices = []
for path in sorted(glob.glob('/dev/input/event*')):
try:
devices.append(open(path, 'rb')) # noqa: WPS515
except (PermissionError, OSError) as exc:
Logger.debug(f"SignagePlayer: Watchdog cannot open {path}: {exc}")
if not devices:
# No input devices accessible → fall back to a plain fixed timer.
Logger.warning(
"SignagePlayer: Watchdog — no /dev/input devices accessible; "
"falling back to fixed timer"
)
stop_event.wait(timeout=duration)
if not stop_event.is_set():
Clock.schedule_once(self.next_media, 0)
return
Logger.info(
f"SignagePlayer: Watchdog watching {len(devices)} input device(s), "
f"idle threshold = {duration}s"
)
last_activity = time.monotonic()
try:
while not stop_event.is_set():
# If Chromium exited on its own (user closed it), advance immediately.
if weblink_proc is not None and weblink_proc.poll() is not None:
Logger.info(
"SignagePlayer: Chromium exited — advancing to next media"
)
Clock.schedule_once(self.next_media, 0)
break
idle = time.monotonic() - last_activity
if idle >= duration:
Logger.info(
f"SignagePlayer: No touch for {duration}s — advancing to next media"
)
Clock.schedule_once(self.next_media, 0)
break
# Wait up to 0.5 s for any raw input event.
timeout = min(0.5, duration - idle)
readable, _, _ = select.select(devices, [], [], timeout)
if readable:
# Drain the data so the buffer doesn't fill up.
for fd in readable:
try:
fd.read(24) # struct input_event = 24 bytes on 64-bit Linux
except OSError:
pass
last_activity = time.monotonic()
Logger.debug(
"SignagePlayer: Touch/key detected — inactivity timer reset"
)
finally:
for fd in devices:
try:
fd.close()
except OSError:
pass
self._weblink_watchdog_thread = threading.Thread(
target=watchdog, daemon=True, name='weblink-watchdog'
)
self._weblink_watchdog_thread.start()
def _stop_inactivity_watchdog(self):
"""Signal the watchdog thread to exit without firing next_media."""
stop_event = getattr(self, '_watchdog_stop', None)
if stop_event is not None:
stop_event.set()
self._watchdog_stop = None
self._weblink_watchdog_thread = None
def _kill_weblink_after_frame(self):
"""Gracefully transition away from a weblink item.
Stops the watchdog and preload immediately (so nothing fires a spurious
next_media), clears self._weblink_proc so the slot is free for the next
item, then schedules the actual Chromium termination for the *next Kivy
frame*. By that time Kivy has already rendered the new media widget
underneath Chromium, so when the browser window disappears the player
content is instantly visible — no black flash.
"""
self._stop_inactivity_watchdog()
self._kill_weblink_preload()
proc = self._weblink_proc # snapshot
self._weblink_proc = None # free the slot right away
if proc is None or proc.poll() is not None:
# Nothing to kill — still raise the window in case it got buried.
try:
Window.raise_window()
except Exception:
pass
return
def _do_kill(dt):
if proc.poll() is None:
try:
proc.terminate()
try:
proc.wait(timeout=3)
except Exception:
proc.kill()
Logger.debug("SignagePlayer: Closed weblink kiosk browser (deferred)")
except Exception as exc:
Logger.warning(f"SignagePlayer: Error in deferred weblink kill: {exc}")
# Bring Kivy window to front now that Chromium is gone.
try:
Window.raise_window()
except Exception as exc:
Logger.debug(f"SignagePlayer: raise_window failed (non-fatal): {exc}")
Clock.schedule_once(_do_kill, 0)
def _kill_weblink_process(self):
"""Terminate the kiosk browser, inactivity watchdog, and any pre-warm process."""
# Stop watchdog first so it cannot fire next_media after we've moved on.
self._stop_inactivity_watchdog()
self._kill_weblink_preload()
proc = getattr(self, '_weblink_proc', None)
if proc is not None and proc.poll() is None:
try:
proc.terminate()
try:
proc.wait(timeout=5)
except Exception:
proc.kill()
Logger.debug("SignagePlayer: Closed weblink kiosk browser")
except Exception as e:
Logger.warning(f"SignagePlayer: Error closing weblink browser: {e}")
self._weblink_proc = None
# Raise the Kivy window back to the front — when Chromium ran in kiosk
# mode it covered the Kivy window entirely; the window manager won't
# automatically bring it back on all compositors/WMs.
try:
Window.raise_window()
except Exception as exc:
Logger.debug(f"SignagePlayer: raise_window failed (non-fatal): {exc}")
def _skip_to_next_media(self):
"""Advance past a failed item WITHOUT recursing.
Error paths used to call next_media() synchronously, which re-entered
play_current_media() immediately. With a playlist whose files are all
missing/invalid (e.g. offline before the first media sync) this recursed
until Python's stack limit ('maximum recursion depth exceeded') and
crashed playback. Scheduling via the Kivy Clock unwinds the stack
between attempts and throttles retries so the standard playlist keeps
cycling and automatically resumes once content is available.
"""
Clock.unschedule(self.next_media)
if self.consecutive_errors >= self.max_consecutive_errors:
msg = "No playable media yet - waiting for content to sync..."
Logger.warning(f"SignagePlayer: {msg} ({self.consecutive_errors} errors)")
try:
self.ids.status_label.text = msg
self.ids.status_label.opacity = 1
except Exception:
pass
# Reset and retry slowly instead of stopping forever
self.consecutive_errors = 0
Clock.schedule_once(self.next_media, 30)
else:
Clock.schedule_once(self.next_media, 1)
def next_media(self, dt=None):
"""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
# Unschedule any pending media transitions
Clock.unschedule(self.next_media)
# Play next media or restart playlist
self.play_current_media()
def previous_media(self, instance=None):
"""Move to previous media item"""
if self.playlist:
self.current_index = max(0, self.current_index - 1)
Clock.unschedule(self.next_media)
self.play_current_media()
def _prewarm_weblink(self, url):
"""Launch Chromium off-screen to warm up the binary and page cache.
The window is placed far outside the visible area so the user never
sees it. When play_weblink() fires for real it kills this hidden
instance first, then relaunches in kiosk mode. Because the Chromium
binary is already resident in RAM and the page is in the disk cache,
the visible kiosk window appears almost immediately.
"""
import shutil
import subprocess
from urllib.parse import urlparse
if not url:
return
scheme = urlparse(url).scheme.lower()
if scheme not in ('http', 'https'):
return
browser = shutil.which('chromium-browser') or shutil.which('chromium')
if not browser:
return
target_width, target_height = self._get_browser_target_size()
# Kill any stale preload first.
self._kill_weblink_preload()
try:
Logger.debug(f"SignagePlayer: Pre-warming weblink off-screen: {url}")
self._weblink_preload_proc = subprocess.Popen([
browser,
'--app=' + url,
# Place window completely outside the visible display area.
'--window-position=-9999,-9999',
f'--window-size={target_width},{target_height}', # pre-render at target resolution
'--noerrdialogs',
'--disable-infobars',
'--incognito',
'--no-first-run',
'--disable-session-crashed-bubble',
'--check-for-update-interval=31536000',
'--password-store=basic',
'--use-mock-keychain',
'--disable-sync',
'--disable-background-networking',
'--no-default-browser-check',
])
except Exception as exc:
Logger.debug(f"SignagePlayer: Pre-warm launch failed (non-fatal): {exc}")
self._weblink_preload_proc = None
def _kill_weblink_preload(self):
"""Terminate the hidden pre-warm Chromium process if running."""
proc = getattr(self, '_weblink_preload_proc', None)
if proc is not None and proc.poll() is None:
try:
proc.terminate()
try:
proc.wait(timeout=3)
except Exception:
proc.kill()
Logger.debug("SignagePlayer: Killed weblink pre-warm process")
except Exception as exc:
Logger.debug(f"SignagePlayer: Error killing pre-warm process: {exc}")
self._weblink_preload_proc = None
def preload_next_media(self):
"""Preload the next media item asynchronously to improve transition smoothness
Only preloads images as videos are loaded on-demand.
Respects force_reload scenarios (e.g., after editing).
"""
if not self.playlist:
return
# Calculate next index (with wraparound)
next_index = (self.current_index + 1) % len(self.playlist)
try:
next_media_item = self.playlist[next_index]
file_name = next_media_item.get('file_name', '')
media_path = os.path.join(self.media_dir, file_name)
# Check if file exists before attempting preload
if not os.path.exists(media_path):
Logger.debug(f"SignagePlayer: Preload skipped - file not found: {file_name}")
return
# Only preload images (videos are handled differently)
file_extension = os.path.splitext(file_name)[1].lower()
if file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
# Check if this is the same file as current (e.g., after editing)
# If so, we need to invalidate cache
current_media = self.playlist[self.current_index]
current_file = current_media.get('file_name', '')
if file_name == current_file:
# Same file - likely edited, don't preload as we'll force reload
Logger.debug(f"SignagePlayer: Preload skipped - same file after edit: {file_name}")
return
# Use Kivy's Loader to preload asynchronously
Logger.debug(f"SignagePlayer: Preloading next image: {file_name}")
Loader.image(media_path)
elif next_media_item.get('type') == 'weblink':
# Pre-warm Chromium in a hidden off-screen window so the binary
# and page content are in OS cache before the slot arrives.
self._prewarm_weblink(next_media_item.get('url', ''))
except Exception as e:
Logger.debug(f"SignagePlayer: Error preloading next media: {e}")
def toggle_pause(self, instance=None):
"""Toggle pause/play with auto-resume after 5 minutes"""
self.is_paused = not self.is_paused
if self.is_paused:
# Paused - change icon to play and schedule auto-resume
Logger.info("SignagePlayer: ⏸ PAUSING - is_paused = True")
self.ids.play_pause_btn.background_normal = self.resources_path + '/play.png'
self.ids.play_pause_btn.background_down = self.resources_path + '/play.png'
Clock.unschedule(self.next_media)
# Close any kiosk browser so the player controls are visible while paused
self._kill_weblink_process()
# Cancel any existing auto-resume
if self.auto_resume_event:
Clock.unschedule(self.auto_resume_event)
# Schedule auto-resume after 5 minutes (300 seconds)
self.auto_resume_event = Clock.schedule_once(self.auto_resume_playback, 300)
Logger.info("SignagePlayer: Auto-resume scheduled in 5 minutes")
else:
# Playing - change icon to pause and cancel auto-resume
# Note: is_paused is already set to False by the toggle above
Logger.info("SignagePlayer: ▶ RESUMING - is_paused = False")
self.ids.play_pause_btn.background_normal = self.resources_path + '/pause.png'
self.ids.play_pause_btn.background_down = self.resources_path + '/pause.png'
# Cancel auto-resume if manually resumed
if self.auto_resume_event:
Clock.unschedule(self.auto_resume_event)
self.auto_resume_event = None
# Resume by playing current media (is_paused is now False)
self.play_current_media()
def auto_resume_playback(self, dt):
"""Automatically resume playback after 5 minutes of pause"""
Logger.info("SignagePlayer: Auto-resuming playback after 5 minutes")
self.auto_resume_event = None
if self.is_paused:
# Reset pause state FIRST (before calling play_current_media)
self.is_paused = False
# Update icon to pause
self.ids.play_pause_btn.background_normal = self.resources_path + '/pause.png'
self.ids.play_pause_btn.background_down = self.resources_path + '/pause.png'
# Resume playback (is_paused is now False so it will work)
self.play_current_media()
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")
# Send restart feedback asynchronously (non-blocking)
if self.config:
asyncio.ensure_future(
self.async_send_feedback(
send_playlist_restart_feedback,
self.config,
self.playlist_version
)
)
self.current_index = 0
self.play_current_media()
def show_error(self, message):
"""Show error message"""
Logger.error(f"SignagePlayer: {message}")
# Send error feedback to server asynchronously (non-blocking)
if self.config:
asyncio.ensure_future(
self.async_send_feedback(
send_player_error_feedback,
self.config,
message,
self.playlist_version
)
)
# Show error on screen
self.ids.status_label.text = f"Error: {message}"
self.ids.status_label.opacity = 1
def on_touch_down(self, touch):
"""Handle touch - show controls and signal screen activity"""
self.signal_screen_activity(0) # Keep display awake on user input
self.show_controls()
return super(SignagePlayer, self).on_touch_down(touch)
def on_touch_move(self, touch):
"""Handle touch move - show controls and signal screen activity"""
self.signal_screen_activity(0) # Keep display awake on user input
self.show_controls()
return super(SignagePlayer, self).on_touch_move(touch)
def show_controls(self):
"""Show control buttons and cursor"""
if self.controls_timer:
self.controls_timer.cancel()
# Show cursor
try:
Window.show_cursor = True
except:
pass
# Fade in controls
Animation(opacity=1, duration=0.3).start(self.ids.controls_layout)
# Schedule hide after 3 seconds
self.schedule_hide_controls()
def schedule_hide_controls(self):
"""Schedule hiding of controls"""
if self.controls_timer:
self.controls_timer.cancel()
self.controls_timer = Clock.schedule_once(self.hide_controls, 3)
def hide_controls(self, dt=None):
"""Hide control buttons and cursor"""
Animation(opacity=0, duration=0.5).start(self.ids.controls_layout)
# Hide cursor after controls are hidden
try:
Window.show_cursor = False
except:
pass
def schedule_cursor_hide(self):
"""Schedule cursor to hide after 3 seconds"""
try:
Clock.schedule_once(lambda dt: Window.__setattr__('show_cursor', False), 3)
except:
pass
def show_settings(self, instance=None):
"""Show settings popup"""
# Pause playback when settings opens
was_paused = self.is_paused
if not was_paused:
self.is_paused = True
Clock.unschedule(self.next_media)
# Stop video if playing
if self.current_widget and isinstance(self.current_widget, Video):
self.current_widget.state = 'pause'
popup = SettingsPopup(player_instance=self, was_paused=was_paused)
popup.open()
def show_edit_interface(self, instance=None):
"""
Show edit interface for current image
Workflow:
1. Check if edit feature is enabled in settings
2. Validate media type (must be image)
3. Check edit_on_player permission from server
4. Prompt for card swipe (5 second timeout)
5. Open edit interface with card data stored
6. When saved, card data is included in metadata and sent to server
"""
# Check 0: Verify edit feature is enabled in player settings
edit_feature_enabled = self.config.get('edit_feature_enabled', True)
if not edit_feature_enabled:
Logger.warning("SignagePlayer: Edit feature is disabled in settings")
# Show error message briefly
self.ids.status_label.text = 'Edit feature disabled - Enable in settings'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Check if current media is an image
if not self.playlist or self.current_index >= len(self.playlist):
Logger.warning("SignagePlayer: No media to edit")
return
media_item = self.playlist[self.current_index]
file_name = media_item.get('file_name', '')
file_extension = os.path.splitext(file_name)[1].lower()
# Check 1: Only allow editing images
if file_extension not in ['.jpg', '.jpeg', '.png', '.bmp', '.webp']:
Logger.warning(f"SignagePlayer: Cannot edit {file_extension} files, only images")
# Show error message briefly
self.ids.status_label.text = 'Can only edit image files'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Check 2: Verify edit_on_player permission from server
edit_allowed = media_item.get('edit_on_player', False)
if not edit_allowed:
Logger.warning(f"SignagePlayer: Edit not allowed for {file_name} (edit_on_player=false)")
# Show error message briefly
self.ids.status_label.text = 'Edit not permitted for this media'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Store image info
self._pending_edit_image = file_name
# Show card swipe popup with 5 second timeout
self._card_swipe_popup = CardSwipePopup(callback=self._on_card_swipe_result, resources_path=self.resources_path)
self._card_swipe_popup.open()
# Initialize card reader if not already created
if not self.card_reader:
self.card_reader = CardReader()
# Start reading card asynchronously
self.card_reader.read_card_async(self._on_card_data_received)
def _on_card_data_received(self, card_data):
"""Called when card reader gets data"""
if hasattr(self, '_card_swipe_popup') and self._card_swipe_popup:
self._card_swipe_popup.card_received(card_data)
def _on_card_swipe_result(self, card_data):
"""Handle result from card swipe popup (card data or None if timeout/cancelled)"""
# Get image path
file_name = self._pending_edit_image
image_path = os.path.join(self.media_dir, file_name)
if not os.path.exists(image_path):
Logger.error(f"SignagePlayer: Image not found: {image_path}")
self.ids.status_label.text = 'Image file not found'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# If no card data (timeout or cancelled), don't open edit interface
if not card_data:
Logger.info("SignagePlayer: Edit cancelled - no card data")
self.ids.status_label.text = 'Edit cancelled - card required'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Store the card data (will be sent to server when save is done)
user_card_data = card_data.strip()
Logger.info(f"SignagePlayer: Card data captured: {user_card_data}")
Logger.info(f"SignagePlayer: Opening edit interface for {file_name}")
# Open edit popup with card data (will be used when saving)
popup = EditPopup(player_instance=self, image_path=image_path, user_card_data=user_card_data)
popup.open()
def show_exit_popup(self, instance=None):
"""Show exit password popup"""
# Pause playback when exit popup opens
was_paused = self.is_paused
if not was_paused:
self.is_paused = True
Clock.unschedule(self.next_media)
# Stop video if playing
if self.current_widget and isinstance(self.current_widget, Video):
self.current_widget.state = 'pause'
popup = ExitPasswordPopup(player_instance=self, was_paused=was_paused)
popup.open()
def exit_app(self, instance=None):
"""Exit the application"""
Logger.info("SignagePlayer: Exiting application")
self.set_allow_exit(True)
App.get_running_app().stop()
class SignagePlayerApp(App):
def build_config(self, config):
"""Configure Kivy settings for optimal video playback"""
# Set graphics settings for smooth 30fps video
config.setdefaults('graphics', {
'multisamples': '2', # Anti-aliasing
'maxfps': '30', # Limit to 30fps (optimized for RPi and video content)
'vsync': '1', # Enable vertical sync
'resizable': '0', # Disable window resizing
'borderless': '1', # Borderless window
'fullscreen': 'auto' # Auto fullscreen
})
# Disable unnecessary modules to save resources
config.setdefaults('modules', {
'inspector': '', # Disable inspector
'monitor': '', # Disable monitor
'keybinding': '', # Disable keybinding
'showborder': '' # Disable border highlighting
})
# Input settings
config.setdefaults('input', {
'mouse': 'mouse', # Enable mouse input
})
Logger.info("SignagePlayerApp: Kivy config optimized for 30fps video playback")
def build(self):
# Load config to get resolution setting
config_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config', 'app_config.json')
max_resolution = 'auto'
try:
if os.path.exists(config_file):
with open(config_file, 'r') as f:
config = json.load(f)
max_resolution = config.get('max_resolution', 'auto')
except Exception as e:
Logger.warning(f"SignagePlayerApp: Could not load resolution setting: {e}")
# Apply resolution constraint
if max_resolution != 'auto' and 'x' in max_resolution:
try:
max_width, max_height = map(int, max_resolution.split('x'))
current_width, current_height = Window.size
# Check if current resolution exceeds maximum
if current_width > max_width or current_height > max_height:
# Calculate scaling to fit within max resolution
scale = min(max_width / current_width, max_height / current_height)
new_width = int(current_width * scale)
new_height = int(current_height * scale)
Window.size = (new_width, new_height)
Logger.info(f"SignagePlayerApp: Resolution constrained from {current_width}x{current_height} to {new_width}x{new_height}")
else:
Logger.info(f"SignagePlayerApp: Current resolution {current_width}x{current_height} within max {max_resolution}")
except Exception as e:
Logger.error(f"SignagePlayerApp: Error parsing resolution setting '{max_resolution}': {e}")
else:
Logger.info(f"SignagePlayerApp: Using auto resolution (no constraint)")
# Force fullscreen and borderless (only if Window is available)
if Window is not None:
Window.fullscreen = True
Window.borderless = True
Logger.info(f"SignagePlayerApp: Screen size: {Window.size}")
Logger.info(f"SignagePlayerApp: Available screen size: {Window.system_size if hasattr(Window, 'system_size') else 'N/A'}")
# Hide cursor after 3 seconds of inactivity
Clock.schedule_once(self.hide_cursor, 3)
else:
Logger.critical("SignagePlayerApp: Window is None - display server not available")
return SignagePlayer()
def hide_cursor(self, dt):
"""Hide the mouse cursor"""
try:
if Window is not None:
Window.show_cursor = False
except:
pass # Some platforms don't support cursor hiding
def on_start(self):
# Setup asyncio event loop for Kivy integration
try:
# Use get_running_loop in Python 3.7+ to avoid deprecation warning
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop, create a new one
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
Logger.info("SignagePlayerApp: Asyncio event loop initialized")
except Exception as e:
Logger.warning(f"SignagePlayerApp: Could not setup asyncio loop: {e}")
# Schedule periodic async task processing
Clock.schedule_interval(self._process_async_tasks, 0.1) # Process every 100ms
# Log final window info
Logger.info(f"SignagePlayerApp: Final window size: {Window.size}")
Logger.info(f"SignagePlayerApp: Fullscreen: {Window.fullscreen}")
Logger.info("SignagePlayerApp: Application started")
Logger.info("SignagePlayerApp: Server communications running asynchronously")
def _process_async_tasks(self, dt):
"""Process pending asyncio tasks without blocking Kivy"""
try:
loop = asyncio.get_event_loop()
# Process pending callbacks without blocking
loop.call_soon(loop.stop)
loop.run_forever()
except Exception as e:
pass # Silently handle - loop may not have tasks
def on_stop(self):
Logger.info("SignagePlayerApp: Application stopped")
# Close any kiosk browser opened for a weblink item
try:
if self.root and hasattr(self.root, '_kill_weblink_process'):
self.root._kill_weblink_process()
Logger.info("SignagePlayerApp: Weblink browser closed")
except Exception as e:
Logger.debug(f"SignagePlayerApp: Error closing weblink browser: {e}")
# Stop network monitoring
if hasattr(self.root, 'network_monitor') and self.root.network_monitor:
self.root.network_monitor.stop_monitoring()
Logger.info("SignagePlayerApp: Network monitoring stopped")
# Cancel all async tasks
try:
pending = asyncio.all_tasks()
for task in pending:
task.cancel()
Logger.info(f"SignagePlayerApp: Cancelled {len(pending)} async tasks")
except Exception as e:
Logger.debug(f"SignagePlayerApp: Error cancelling tasks: {e}")
if __name__ == '__main__':
try:
Logger.info("=" * 80)
Logger.info("Starting Kivy Signage Player Application")
Logger.info("=" * 80)
SignagePlayerApp().run()
except KeyboardInterrupt:
Logger.info("Application stopped by user (Ctrl+C)")
except Exception as e:
Logger.critical(f"Fatal error in application: {e}")
Logger.exception("Full traceback:")
import sys
sys.exit(1)
finally:
Logger.info("Application shutdown complete")