From 342331f9dd5ec528569ba51f257a0dfee82b86ff Mon Sep 17 00:00:00 2001 From: Kivy Signage Player Date: Mon, 3 Nov 2025 16:13:13 +0200 Subject: [PATCH] Improve video playback performance with async server communication Changes: - Implement async/await pattern for non-blocking server communications - Use asyncio event loop integrated with Kivy for smooth UI - Run playlist updates and feedback sending in thread pool - Prevent UI freezing during network I/O operations - Add video error handling and playback state management - Implement delayed video playback start (wait 0.5s for loading) - Add video error callback for better error recovery - Cancel all async tasks on application shutdown - Process async tasks every 100ms without blocking Kivy event loop New file: - convert_video_for_rpi.sh: FFmpeg script for optimizing videos (1080p @ 30fps, H.264) --- convert_video_for_rpi.sh | 57 ++++++++++++++ src/main.py | 159 ++++++++++++++++++++++++++++++++------- 2 files changed, 188 insertions(+), 28 deletions(-) create mode 100755 convert_video_for_rpi.sh diff --git a/convert_video_for_rpi.sh b/convert_video_for_rpi.sh new file mode 100755 index 0000000..1fd62ce --- /dev/null +++ b/convert_video_for_rpi.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Video Conversion Script for Raspberry Pi Signage Player +# Converts videos to optimal settings: 1080p @ 30fps, H.264 codec + +if [ $# -eq 0 ]; then + echo "Usage: $0 [output_video]" + echo "Example: $0 input.mp4 output.mp4" + echo "" + echo "This script converts videos to Raspberry Pi-friendly settings:" + echo " - Resolution: Max 1920x1080" + echo " - Frame rate: 30 fps" + echo " - Codec: H.264" + echo " - Bitrate: ~5-8 Mbps" + exit 1 +fi + +INPUT_VIDEO="$1" +OUTPUT_VIDEO="${2:-converted_$(basename "$INPUT_VIDEO")}" + +if [ ! -f "$INPUT_VIDEO" ]; then + echo "Error: Input file '$INPUT_VIDEO' not found!" + exit 1 +fi + +echo "Converting video for Raspberry Pi playback..." +echo "Input: $INPUT_VIDEO" +echo "Output: $OUTPUT_VIDEO" +echo "" + +# Convert video with optimal settings for Raspberry Pi +ffmpeg -i "$INPUT_VIDEO" \ + -c:v libx264 \ + -preset medium \ + -crf 23 \ + -maxrate 8M \ + -bufsize 12M \ + -vf "scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease,fps=30" \ + -r 30 \ + -c:a aac \ + -b:a 128k \ + -movflags +faststart \ + -y \ + "$OUTPUT_VIDEO" + +if [ $? -eq 0 ]; then + echo "" + echo "✓ Conversion completed successfully!" + echo "Original: $(du -h "$INPUT_VIDEO" | cut -f1)" + echo "Converted: $(du -h "$OUTPUT_VIDEO" | cut -f1)" + echo "" + echo "You can now use '$OUTPUT_VIDEO' in your signage player." +else + echo "" + echo "✗ Conversion failed! Make sure ffmpeg is installed:" + echo " sudo apt-get install ffmpeg" + exit 1 +fi diff --git a/src/main.py b/src/main.py index 0995460..63111e6 100644 --- a/src/main.py +++ b/src/main.py @@ -8,6 +8,8 @@ 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 @@ -227,8 +229,9 @@ class SignagePlayer(Widget): # Load configuration self.load_config() - # Start playlist update thread - threading.Thread(target=self.playlist_update_loop, daemon=True).start() + # 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 @@ -267,31 +270,52 @@ class SignagePlayer(Widget): except Exception as e: Logger.error(f"SignagePlayer: Error saving config: {e}") - def playlist_update_loop(self): - """Background thread to check for playlist updates""" + 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: - # Find latest playlist file - latest_playlist = self.get_latest_playlist_file() + # Run blocking network I/O in thread pool + loop = asyncio.get_event_loop() - # Check for updates - updated = update_playlist_if_needed( - latest_playlist, - self.config, - self.media_dir, + # Find latest playlist file + latest_playlist = await loop.run_in_executor( + None, + self.get_latest_playlist_file + ) + + # Check for updates (network I/O in thread pool) + updated = await loop.run_in_executor( + None, + update_playlist_if_needed, + latest_playlist, + self.config, + self.media_dir, self.playlists_dir ) if updated: Logger.info("SignagePlayer: Playlist updated, reloading...") + # Schedule UI update on main thread Clock.schedule_once(self.load_playlist, 0) - time.sleep(60) # Check every minute + # Wait 60 seconds before next check + await asyncio.sleep(60) except Exception as e: - Logger.error(f"SignagePlayer: Error in playlist update loop: {e}") - time.sleep(30) # Wait 30 seconds on error + 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 latest playlist file""" @@ -403,9 +427,16 @@ class SignagePlayer(Widget): self.next_media() return - # Send feedback to server + # Send feedback to server asynchronously (non-blocking) if self.config: - send_playing_status_feedback(self.config, self.playlist_version, file_name) + 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 @@ -428,15 +459,18 @@ class SignagePlayer(Widget): def play_video(self, video_path, duration): """Play a video file using Kivy's Video widget""" try: - # Create Video widget with optimized settings for quality + # Verify file exists + if not os.path.exists(video_path): + Logger.error(f"Video file not found: {video_path}") + self.next_media() + return + + # Create Video widget with optimized settings self.current_widget = Video( source=video_path, - state='play', + state='stop', # Start stopped, then play after loaded options={ 'eos': 'stop', # Stop at end of stream - 'autoplay': True, # Auto-play when loaded - 'allow_stretch': True, # Allow stretching to fit - 'sync': 'audio', # Sync video to audio for smooth playback }, allow_stretch=True, keep_ratio=True, # Maintain aspect ratio @@ -444,15 +478,15 @@ class SignagePlayer(Widget): pos_hint={'center_x': 0.5, 'center_y': 0.5} ) - # Bind to loaded event to log video info + # Bind to loaded and error events self.current_widget.bind(loaded=self._on_video_loaded) # Add to content area self.ids.content_area.add_widget(self.current_widget) Logger.info(f"SignagePlayer: Playing {os.path.basename(video_path)} for {duration}s") - # Schedule next media after duration - Clock.schedule_once(self.next_media, duration) + # Wait a bit for video to load, then start playing + Clock.schedule_once(lambda dt: self._start_video_playback(duration), 0.5) except Exception as e: Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}") @@ -460,6 +494,29 @@ class SignagePlayer(Widget): if self.consecutive_errors < self.max_consecutive_errors: self.next_media() + def _start_video_playback(self, duration): + """Start video playback after widget is loaded""" + try: + if self.current_widget and hasattr(self.current_widget, 'state'): + self.current_widget.state = 'play' + Logger.info("SignagePlayer: Video playback started") + # Schedule next media after duration + Clock.schedule_once(self.next_media, duration) + else: + Logger.error("SignagePlayer: Video widget not ready") + self.next_media() + except Exception as e: + Logger.error(f"SignagePlayer: Error starting video playback: {e}") + self.next_media() + + def _on_video_error(self, instance, error): + """Callback when video encounters an error""" + Logger.error(f"Video playback error: {error}") + Logger.error(f"Video source: {instance.source}") + Logger.error(f"Video state: {instance.state}") + # Try to skip to next media on error + Clock.schedule_once(self.next_media, 1) + def _on_video_loaded(self, instance, value): """Callback when video is loaded - log video information""" if value: @@ -524,9 +581,15 @@ class SignagePlayer(Widget): """Restart playlist from beginning""" Logger.info("SignagePlayer: Restarting playlist") - # Send restart feedback + # Send restart feedback asynchronously (non-blocking) if self.config: - send_playlist_restart_feedback(self.config, self.playlist_version) + asyncio.ensure_future( + self.async_send_feedback( + send_playlist_restart_feedback, + self.config, + self.playlist_version + ) + ) self.current_index = 0 self.play_current_media() @@ -535,9 +598,16 @@ class SignagePlayer(Widget): """Show error message""" Logger.error(f"SignagePlayer: {message}") - # Send error feedback to server + # Send error feedback to server asynchronously (non-blocking) if self.config: - send_player_error_feedback(self.config, message, self.playlist_version) + 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}" @@ -710,13 +780,46 @@ class SignagePlayerApp(App): pass # Some platforms don't support cursor hiding def on_start(self): + # Setup asyncio event loop for Kivy integration + try: + loop = asyncio.get_event_loop() + Logger.info("SignagePlayerApp: Asyncio event loop integrated with Kivy") + except RuntimeError: + # Create new event loop if none exists + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + Logger.info("SignagePlayerApp: New asyncio event loop created") + + # 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") + + # 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__':