- Fixed start.sh environment variable loading from systemctl - Use here-document (<<<) instead of pipe for subshell to preserve exports - Added better error handling for evdev device enumeration - Added exception handling in intro video playback with detailed logging - App now properly initializes with DISPLAY=:0 and WAYLAND_DISPLAY=wayland-0
1968 lines
84 KiB
Python
1968 lines
84 KiB
Python
"""
|
|
Kivy Signage Player - Main Application
|
|
Displays content from DigiServer playlists using Kivy framework
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import platform
|
|
import threading
|
|
import time
|
|
import asyncio
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
# Set environment variables for better video performance
|
|
os.environ['KIVY_VIDEO'] = 'ffpyplayer' # Use ffpyplayer as video provider
|
|
os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8' # Support common codecs
|
|
os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0' # Prevent screen saver
|
|
os.environ['SDL_VIDEODRIVER'] = 'wayland,x11,dummy' # Prefer Wayland, fallback to X11, then dummy
|
|
os.environ['SDL_AUDIODRIVER'] = 'alsa,pulse,dummy' # Prefer ALSA, fallback to pulse, then dummy
|
|
|
|
# Video playback optimizations
|
|
# Note: pygame backend requires X11/Wayland context; let Kivy auto-detect for better compatibility
|
|
# os.environ['KIVY_WINDOW'] = 'pygame' # Use pygame backend for better performance
|
|
os.environ['KIVY_AUDIO'] = 'ffpyplayer' # Use ffpyplayer for audio
|
|
os.environ['KIVY_GL_BACKEND'] = 'gl' # Use OpenGL backend
|
|
os.environ['KIVY_INPUTPROVIDERS'] = 'wayland,x11' # Only use Wayland and X11 input providers, skip problematic ones
|
|
os.environ['FFMPEG_THREADS'] = '2' # Use 2 threads for ffmpeg decoding (Raspberry Pi has limited resources)
|
|
os.environ['LIBPLAYER_BUFFER'] = '1048576' # 1MB buffer (reduced from 2MB to save memory)
|
|
os.environ['SDL_AUDIODRIVER'] = 'alsa' # Use ALSA for better audio on Pi
|
|
|
|
# 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
|
|
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
|
|
|
|
# Load the KV file
|
|
Builder.load_file('signage_player.kv')
|
|
|
|
|
|
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 if it wasn't paused before
|
|
if not self.was_paused:
|
|
self.player.is_paused = False
|
|
# Resume video if it was playing
|
|
if self.player.current_widget and isinstance(self.player.current_widget, Video):
|
|
self.player.current_widget.state = 'play'
|
|
|
|
# Restart the control hide timer
|
|
self.player.schedule_hide_controls()
|
|
|
|
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}")
|
|
|
|
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.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"}'
|
|
|
|
# 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 if it wasn't paused before
|
|
if not self.was_paused:
|
|
self.player.is_paused = False
|
|
# Resume video if it was playing
|
|
if self.player.current_widget and isinstance(self.player.current_widget, Video):
|
|
self.player.current_widget.state = 'play'
|
|
|
|
# Restart the control hide timer
|
|
self.player.schedule_hide_controls()
|
|
|
|
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.player.config.get('port', '443')
|
|
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"
|
|
server_url = f"{protocol}://{server_ip}:{port}"
|
|
|
|
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['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.is_playing = False
|
|
self.is_paused = False
|
|
self.auto_resume_event = None # Track scheduled auto-resume
|
|
self.config = {}
|
|
self.playlist_version = None
|
|
# 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)
|
|
# 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 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()
|
|
|
|
# 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
|
|
}
|
|
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):
|
|
"""Play the current media item
|
|
|
|
Args:
|
|
force_reload: If True, clears image cache before loading (for edited images)
|
|
"""
|
|
# 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 or 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)
|
|
|
|
Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)")
|
|
|
|
# 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.next_media()
|
|
return
|
|
|
|
# Remove status label if showing
|
|
self.ids.status_label.opacity = 0
|
|
|
|
# Remove previous media widget
|
|
if self.current_widget:
|
|
# Properly stop video if it's playing to prevent resource leaks
|
|
if isinstance(self.current_widget, Video):
|
|
try:
|
|
Logger.debug(f"SignagePlayer: Stopping previous video widget...")
|
|
self.current_widget.state = 'stop'
|
|
self.current_widget.unload()
|
|
except Exception as e:
|
|
Logger.warning(f"SignagePlayer: Error stopping video: {e}")
|
|
|
|
self.ids.content_area.remove_widget(self.current_widget)
|
|
self.current_widget = None
|
|
Logger.debug(f"SignagePlayer: Previous widget removed")
|
|
|
|
# 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")
|
|
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")
|
|
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.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")
|
|
|
|
except Exception as e:
|
|
Logger.error(f"SignagePlayer: Error playing media: {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.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.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.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)
|
|
|
|
# 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 video {video_path}: {e}")
|
|
self.consecutive_errors += 1
|
|
if self.consecutive_errors < self.max_consecutive_errors:
|
|
self.next_media()
|
|
|
|
def _on_video_eos(self, instance):
|
|
"""Callback when video reaches end of stream"""
|
|
Logger.debug("SignagePlayer: Video finished playing (EOS)")
|
|
|
|
def _on_video_loaded(self, instance, value):
|
|
"""Callback when video is loaded - log video information"""
|
|
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}")
|
|
|
|
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
|
|
if self.consecutive_errors < self.max_consecutive_errors:
|
|
self.next_media()
|
|
|
|
def next_media(self, dt=None):
|
|
"""Move to next media item"""
|
|
if self.is_paused:
|
|
Logger.info(f"SignagePlayer: ⏸ Blocked next_media - player is paused")
|
|
return
|
|
|
|
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 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)
|
|
|
|
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)
|
|
|
|
# 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"""
|
|
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")
|
|
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")
|
|
|
|
# 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") |