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)
This commit is contained in:
Kivy Signage Player
2025-11-03 16:13:13 +02:00
parent a2fddfa312
commit 342331f9dd
2 changed files with 188 additions and 28 deletions

57
convert_video_for_rpi.sh Executable file
View File

@@ -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 <input_video> [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

View File

@@ -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__':