31ad592e98
- 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)
2851 lines
124 KiB
Python
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") |