Perf: Optimize playback and simplify playlist management

- Performance improvements:
  * Throttle drawing updates to 60fps (16ms intervals)
  * Optimize file I/O: use single os.stat() instead of exists+getsize
  * Reduce logger overhead: convert hot-path info logs to debug
  * Preload next media asynchronously for smoother transitions
  * Smart cache invalidation for edited images

- Simplify playlist management:
  * Remove versioning: single server_playlist.json file
  * Create nested directories for edited_media downloads
  * Recursively delete unused media and empty folders
  * Cleaner version tracking without file proliferation

- UI improvements:
  * Smoother intro-to-playlist transition
  * Fix edited media directory creation for nested paths
This commit is contained in:
Kiwy Signage Player
2025-12-14 16:57:47 +02:00
parent b2d380511a
commit 1cc0eae542
4 changed files with 156 additions and 164 deletions

View File

@@ -32,6 +32,7 @@ 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
@@ -768,27 +769,20 @@ class SettingsPopup(Popup):
"""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')
# Find current playlist file
import glob
playlist_files = glob.glob(os.path.join(playlists_dir, 'server_playlist_v*.json'))
if playlist_files:
# Get the first (should be only one) playlist file
current_playlist = playlist_files[0]
new_playlist = os.path.join(playlists_dir, 'server_playlist_v0.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")
# Rename to v0
os.rename(current_playlist, new_playlist)
Logger.info(f"SettingsPopup: Renamed {os.path.basename(current_playlist)} to server_playlist_v0.json")
# Update player's playlist version
# 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 to v0 - will resync from server', (0, 1, 0, 1))
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))
@@ -1046,22 +1040,8 @@ class SignagePlayer(Widget):
Logger.debug(f"SignagePlayer: Error sending async feedback: {e}")
def get_latest_playlist_file(self):
"""Get the path to the latest playlist file"""
try:
if os.path.exists(self.playlists_dir):
playlist_files = [f for f in os.listdir(self.playlists_dir)
if f.startswith('server_playlist_v') and f.endswith('.json')]
if playlist_files:
# Sort by version number and get the latest
versions = [(int(f.split('_v')[-1].split('.json')[0]), f) for f in playlist_files]
versions.sort(reverse=True)
latest_file = versions[0][1]
return os.path.join(self.playlists_dir, latest_file)
return os.path.join(self.playlists_dir, 'server_playlist_v0.json')
except Exception as e:
Logger.error(f"SignagePlayer: Error getting latest playlist: {e}")
return os.path.join(self.playlists_dir, 'server_playlist_v0.json')
"""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"""
@@ -1121,14 +1101,15 @@ class SignagePlayer(Widget):
def on_intro_end(instance, value):
if value == 'stop':
Logger.info("SignagePlayer: Intro video finished")
# Mark intro as played before removing video
self.intro_played = True
# Remove intro video
if intro_video in self.ids.content_area.children:
self.ids.content_area.remove_widget(intro_video)
# Mark intro as played
self.intro_played = True
# Start normal playlist
# Start normal playlist immediately to reduce white screen
self.check_playlist_and_play(None)
intro_video.bind(state=on_intro_end)
@@ -1186,23 +1167,22 @@ class SignagePlayer(Widget):
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)} =====")
Logger.info(f"SignagePlayer: File: {file_name}")
Logger.info(f"SignagePlayer: Duration: {duration}s")
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)
Logger.info(f"SignagePlayer: Full path: {media_path}")
if not os.path.exists(media_path):
# 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
Logger.info(f"SignagePlayer: ✓ File exists (size: {os.path.getsize(media_path):,} bytes)")
# Remove status label if showing
self.ids.status_label.opacity = 0
@@ -1211,7 +1191,7 @@ class SignagePlayer(Widget):
# Properly stop video if it's playing to prevent resource leaks
if isinstance(self.current_widget, Video):
try:
Logger.info(f"SignagePlayer: Stopping previous video widget...")
Logger.debug(f"SignagePlayer: Stopping previous video widget...")
self.current_widget.state = 'stop'
self.current_widget.unload()
except Exception as e:
@@ -1219,19 +1199,18 @@ class SignagePlayer(Widget):
self.ids.content_area.remove_widget(self.current_widget)
self.current_widget = None
Logger.info(f"SignagePlayer: Previous widget removed")
Logger.debug(f"SignagePlayer: Previous widget removed")
# Determine media type and create appropriate widget
file_extension = os.path.splitext(file_name)[1].lower()
Logger.info(f"SignagePlayer: Extension: {file_extension}")
if file_extension in ['.mp4', '.avi', '.mkv', '.mov', '.webm']:
# Video file
Logger.info(f"SignagePlayer: Media type: VIDEO")
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.info(f"SignagePlayer: Media type: IMAGE")
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}")
@@ -1254,7 +1233,7 @@ class SignagePlayer(Widget):
# Reset error counter on successful playback
self.consecutive_errors = 0
Logger.info(f"SignagePlayer: Media started successfully (consecutive_errors reset to 0)")
Logger.debug(f"SignagePlayer: Media started successfully")
except Exception as e:
Logger.error(f"SignagePlayer: Error playing media: {e}")
@@ -1281,11 +1260,9 @@ class SignagePlayer(Widget):
self.next_media()
return
Logger.info(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s")
Logger.info(f"SignagePlayer: Video provider: {os.environ.get('KIVY_VIDEO', 'default')}")
Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s")
# Create Video widget with optimized settings
Logger.info(f"SignagePlayer: Creating Video widget...")
self.current_widget = Video(
source=video_path,
state='play', # Start playing immediately
@@ -1301,14 +1278,16 @@ class SignagePlayer(Widget):
self.current_widget.bind(on_eos=self._on_video_eos)
# Add to content area
Logger.info(f"SignagePlayer: Adding video widget to content area...")
self.ids.content_area.add_widget(self.current_widget)
# Schedule next media after duration (unschedule first to prevent overlaps)
Logger.info(f"SignagePlayer: Scheduled next media in {duration}s")
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
@@ -1317,30 +1296,24 @@ class SignagePlayer(Widget):
def _on_video_eos(self, instance):
"""Callback when video reaches end of stream"""
Logger.info("SignagePlayer: Video finished playing (EOS)")
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.info(f"SignagePlayer: Video loaded successfully")
Logger.info(f"SignagePlayer: Video texture: {instance.texture.size if instance.texture else 'No texture'}")
Logger.info(f"SignagePlayer: Video duration: {instance.duration}s")
Logger.info(f"SignagePlayer: Video state: {instance.state}")
Logger.debug(f"SignagePlayer: Video loaded: {instance.texture.size if instance.texture else 'No texture'}, {instance.duration}s")
except Exception as e:
Logger.warning(f"SignagePlayer: Could not log video info: {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
file_size = os.path.getsize(image_path)
file_mtime = os.path.getmtime(image_path)
Logger.info(f"SignagePlayer: Loading image: {os.path.basename(image_path)}")
Logger.info(f" - Size: {file_size:,} bytes, Modified: {file_mtime}")
Logger.debug(f"SignagePlayer: Loading image: {os.path.basename(image_path)} ({os.path.getsize(image_path):,} bytes)")
if force_reload:
Logger.info(f"SignagePlayer: Force reload - using Image widget (no async cache)")
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(
@@ -1353,7 +1326,6 @@ class SignagePlayer(Widget):
# Force reload the texture
self.current_widget.reload()
else:
Logger.info(f"SignagePlayer: Creating AsyncImage widget...")
self.current_widget = AsyncImage(
source=image_path,
allow_stretch=True,
@@ -1361,13 +1333,14 @@ class SignagePlayer(Widget):
size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5}
)
Logger.info(f"SignagePlayer: Adding image widget to content area...")
self.ids.content_area.add_widget(self.current_widget)
# Schedule next media after duration (unschedule first to prevent overlaps)
Logger.info(f"SignagePlayer: Scheduled next media in {duration}s")
Logger.debug(f"SignagePlayer: Scheduled next media in {duration}s")
Clock.unschedule(self.next_media)
Clock.schedule_once(self.next_media, duration)
Logger.info(f"SignagePlayer: ✓ Image displayed successfully")
# 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
@@ -1396,6 +1369,48 @@ class SignagePlayer(Widget):
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