""" 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")