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

@@ -1 +0,0 @@
User requested exit via password

View File

@@ -30,6 +30,8 @@ class DrawingLayer(Widget):
self.current_width = 3 # Default thickness self.current_width = 3 # Default thickness
self.drawing_enabled = True # Drawing always enabled in edit mode self.drawing_enabled = True # Drawing always enabled in edit mode
self.reset_callback = reset_callback # Callback to reset countdown timer self.reset_callback = reset_callback # Callback to reset countdown timer
self._last_draw_time = 0 # For throttling touch updates
self._draw_throttle_interval = 0.016 # ~60fps (16ms between updates)
def on_touch_down(self, touch): def on_touch_down(self, touch):
if not self.drawing_enabled or not self.collide_point(*touch.pos): if not self.drawing_enabled or not self.collide_point(*touch.pos):
@@ -48,7 +50,11 @@ class DrawingLayer(Widget):
def on_touch_move(self, touch): def on_touch_move(self, touch):
if 'line' in touch.ud and self.drawing_enabled: if 'line' in touch.ud and self.drawing_enabled:
# Throttle updates to ~60fps for better performance
current_time = time.time()
if current_time - self._last_draw_time >= self._draw_throttle_interval:
touch.ud['line'].points += [touch.x, touch.y] touch.ud['line'].points += [touch.x, touch.y]
self._last_draw_time = current_time
return True return True
def undo(self): def undo(self):
@@ -68,12 +74,10 @@ class DrawingLayer(Widget):
def set_color(self, color_tuple): def set_color(self, color_tuple):
"""Set drawing color (RGBA)""" """Set drawing color (RGBA)"""
self.current_color = color_tuple self.current_color = color_tuple
Logger.debug(f"DrawingLayer: Color set to {color_tuple}")
def set_thickness(self, value): def set_thickness(self, value):
"""Set line thickness""" """Set line thickness"""
self.current_width = value self.current_width = value
Logger.debug(f"DrawingLayer: Thickness set to {value}")
class EditPopup(Popup): class EditPopup(Popup):

View File

@@ -188,10 +188,9 @@ def fetch_server_playlist(config):
return {'playlist': [], 'version': 0} return {'playlist': [], 'version': 0}
def save_playlist_with_version(playlist_data, playlist_dir): def save_playlist(playlist_data, playlist_dir):
"""Save playlist to file with version number.""" """Save playlist to a single file (no versioning)."""
version = playlist_data.get('version', 0) playlist_file = os.path.join(playlist_dir, 'server_playlist.json')
playlist_file = os.path.join(playlist_dir, f'server_playlist_v{version}.json')
# Ensure directory exists # Ensure directory exists
os.makedirs(playlist_dir, exist_ok=True) os.makedirs(playlist_dir, exist_ok=True)
@@ -222,6 +221,9 @@ def download_media_files(playlist, media_dir):
logger.info(f"✓ File {file_name} already exists. Skipping download.") logger.info(f"✓ File {file_name} already exists. Skipping download.")
else: else:
try: try:
# Create parent directories if they don't exist (for nested paths like edited_media/5/)
os.makedirs(os.path.dirname(local_path), exist_ok=True)
response = requests.get(file_url, timeout=30) response = requests.get(file_url, timeout=30)
if response.status_code == 200: if response.status_code == 200:
with open(local_path, 'wb') as file: with open(local_path, 'wb') as file:
@@ -245,69 +247,49 @@ def download_media_files(playlist, media_dir):
return updated_playlist return updated_playlist
def delete_old_playlists_and_media(current_version, playlist_dir, media_dir, keep_versions=1): def delete_unused_media(playlist_data, media_dir):
"""Delete old playlist files and media files not referenced by the latest playlist version.""" """Delete media files not referenced in the current playlist."""
try: try:
# Find all playlist files
playlist_files = [f for f in os.listdir(playlist_dir)
if f.startswith('server_playlist_v') and f.endswith('.json')]
# Extract versions and sort
versions = []
for f in playlist_files:
try:
version = int(f.replace('server_playlist_v', '').replace('.json', ''))
versions.append((version, f))
except ValueError:
continue
versions.sort(reverse=True)
# Keep only the latest N versions
files_to_delete = [f for v, f in versions[keep_versions:]]
for f in files_to_delete:
filepath = os.path.join(playlist_dir, f)
os.remove(filepath)
logger.info(f"🗑️ Deleted old playlist: {f}")
# Clean up unused media files
logger.info("🔍 Checking for unused media files...")
# Get list of media files referenced in current playlist # Get list of media files referenced in current playlist
current_playlist_file = os.path.join(playlist_dir, f'server_playlist_v{current_version}.json')
referenced_files = set() referenced_files = set()
for media in playlist_data.get('playlist', []):
if os.path.exists(current_playlist_file): file_name = media.get('file_name', '')
try:
with open(current_playlist_file, 'r') as f:
playlist_data = json.load(f)
for item in playlist_data.get('playlist', []):
file_name = item.get('file_name', '')
if file_name: if file_name:
referenced_files.add(file_name) referenced_files.add(file_name)
logger.info(f"📋 Current playlist references {len(referenced_files)} media files") logger.info(f"📋 Current playlist references {len(referenced_files)} files")
# Get all files in media directory (excluding edited_media subfolder)
if os.path.exists(media_dir): if os.path.exists(media_dir):
media_files = [f for f in os.listdir(media_dir) # Recursively get all media files
if os.path.isfile(os.path.join(media_dir, f))]
deleted_count = 0 deleted_count = 0
for media_file in media_files: for root, dirs, files in os.walk(media_dir):
for media_file in files:
# Get relative path from media_dir
full_path = os.path.join(root, media_file)
rel_path = os.path.relpath(full_path, media_dir)
# Skip if file is in current playlist # Skip if file is in current playlist
if media_file in referenced_files: if rel_path in referenced_files:
continue continue
# Delete unreferenced file # Delete unreferenced file
media_path = os.path.join(media_dir, media_file)
try: try:
os.remove(media_path) os.remove(full_path)
logger.info(f"🗑️ Deleted unused media: {media_file}") logger.info(f"🗑️ Deleted unused media: {rel_path}")
deleted_count += 1 deleted_count += 1
except Exception as e: except Exception as e:
logger.warning(f"⚠️ Could not delete {media_file}: {e}") logger.warning(f"⚠️ Could not delete {rel_path}: {e}")
# Clean up empty directories
for root, dirs, files in os.walk(media_dir, topdown=False):
for dir_name in dirs:
dir_path = os.path.join(root, dir_name)
try:
if not os.listdir(dir_path): # If directory is empty
os.rmdir(dir_path)
logger.debug(f"🗑️ Removed empty directory: {os.path.relpath(dir_path, media_dir)}")
except Exception:
pass
if deleted_count > 0: if deleted_count > 0:
logger.info(f"✅ Deleted {deleted_count} unused media files") logger.info(f"✅ Deleted {deleted_count} unused media files")
@@ -315,12 +297,9 @@ def delete_old_playlists_and_media(current_version, playlist_dir, media_dir, kee
logger.info("✅ No unused media files to delete") logger.info("✅ No unused media files to delete")
except Exception as e: except Exception as e:
logger.error(f"❌ Error reading playlist for media cleanup: {e}") logger.error(f"❌ Error during media cleanup: {e}")
logger.info(f"✅ Cleanup complete (kept {keep_versions} latest playlist versions)")
except Exception as e:
logger.error(f"❌ Error during cleanup: {e}")
def update_playlist_if_needed(config, playlist_dir, media_dir): def update_playlist_if_needed(config, playlist_dir, media_dir):
@@ -334,22 +313,17 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
logger.warning("⚠️ No valid playlist received from server") logger.warning("⚠️ No valid playlist received from server")
return None return None
# Check local version # Check local version from single playlist file
local_version = 0 local_version = 0
local_playlist_file = None playlist_file = os.path.join(playlist_dir, 'server_playlist.json')
if os.path.exists(playlist_dir): if os.path.exists(playlist_file):
playlist_files = [f for f in os.listdir(playlist_dir)
if f.startswith('server_playlist_v') and f.endswith('.json')]
for f in playlist_files:
try: try:
version = int(f.replace('server_playlist_v', '').replace('.json', '')) with open(playlist_file, 'r') as f:
if version > local_version: local_data = json.load(f)
local_version = version local_version = local_data.get('version', 0)
local_playlist_file = os.path.join(playlist_dir, f) except Exception as e:
except ValueError: logger.warning(f"⚠️ Could not read local playlist: {e}")
continue
logger.info(f"📊 Playlist versions - Server: v{server_version}, Local: v{local_version}") logger.info(f"📊 Playlist versions - Server: v{server_version}, Local: v{local_version}")
@@ -361,17 +335,17 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
updated_playlist = download_media_files(server_data['playlist'], media_dir) updated_playlist = download_media_files(server_data['playlist'], media_dir)
server_data['playlist'] = updated_playlist server_data['playlist'] = updated_playlist
# Save new playlist # Save new playlist (single file, no versioning)
playlist_file = save_playlist_with_version(server_data, playlist_dir) playlist_file = save_playlist(server_data, playlist_dir)
# Clean up old versions # Delete unused media files
delete_old_playlists_and_media(server_version, playlist_dir, media_dir) delete_unused_media(server_data, media_dir)
logger.info(f"✅ Playlist updated successfully to v{server_version}") logger.info(f"✅ Playlist updated successfully to v{server_version}")
return playlist_file return playlist_file
else: else:
logger.info("✓ Playlist is up to date") logger.info("✓ Playlist is up to date")
return local_playlist_file return playlist_file
except Exception as e: except Exception as e:
logger.error(f"❌ Error updating playlist: {e}") logger.error(f"❌ Error updating playlist: {e}")

View File

@@ -32,6 +32,7 @@ from kivy.uix.popup import Popup
from kivy.uix.textinput import TextInput from kivy.uix.textinput import TextInput
from kivy.uix.vkeyboard import VKeyboard from kivy.uix.vkeyboard import VKeyboard
from kivy.clock import Clock from kivy.clock import Clock
from kivy.loader import Loader
from kivy.core.window import Window from kivy.core.window import Window
from kivy.properties import BooleanProperty from kivy.properties import BooleanProperty
from kivy.logger import Logger from kivy.logger import Logger
@@ -768,27 +769,20 @@ class SettingsPopup(Popup):
"""Reset playlist version to 0 and rename playlist file""" """Reset playlist version to 0 and rename playlist file"""
try: try:
playlists_dir = os.path.join(self.player.base_dir, 'playlists') playlists_dir = os.path.join(self.player.base_dir, 'playlists')
playlist_file = os.path.join(playlists_dir, 'server_playlist.json')
# Find current playlist file if os.path.exists(playlist_file):
import glob # Delete the playlist file to force re-download
playlist_files = glob.glob(os.path.join(playlists_dir, 'server_playlist_v*.json')) os.remove(playlist_file)
Logger.info(f"SettingsPopup: Deleted playlist file - will resync from server")
if playlist_files: # Reset player's playlist version
# 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')
# 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
self.player.playlist_version = 0 self.player.playlist_version = 0
# Update display # Update display
self.ids.playlist_info.text = f'Playlist: v{self.player.playlist_version}' 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: else:
Logger.info("SettingsPopup: No playlist file found") Logger.info("SettingsPopup: No playlist file found")
self._show_temp_message('No playlist file found', (1, 0.7, 0, 1)) 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}") Logger.debug(f"SignagePlayer: Error sending async feedback: {e}")
def get_latest_playlist_file(self): def get_latest_playlist_file(self):
"""Get the path to the latest playlist file""" """Get the path to the playlist file"""
try: return os.path.join(self.playlists_dir, 'server_playlist.json')
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')
def load_playlist(self, dt=None): def load_playlist(self, dt=None):
"""Load playlist from file""" """Load playlist from file"""
@@ -1121,14 +1101,15 @@ class SignagePlayer(Widget):
def on_intro_end(instance, value): def on_intro_end(instance, value):
if value == 'stop': if value == 'stop':
Logger.info("SignagePlayer: Intro video finished") Logger.info("SignagePlayer: Intro video finished")
# Mark intro as played before removing video
self.intro_played = True
# Remove intro video # Remove intro video
if intro_video in self.ids.content_area.children: if intro_video in self.ids.content_area.children:
self.ids.content_area.remove_widget(intro_video) self.ids.content_area.remove_widget(intro_video)
# Mark intro as played # Start normal playlist immediately to reduce white screen
self.intro_played = True
# Start normal playlist
self.check_playlist_and_play(None) self.check_playlist_and_play(None)
intro_video.bind(state=on_intro_end) intro_video.bind(state=on_intro_end)
@@ -1186,23 +1167,22 @@ class SignagePlayer(Widget):
file_name = media_item.get('file_name', '') file_name = media_item.get('file_name', '')
duration = media_item.get('duration', 10) duration = media_item.get('duration', 10)
Logger.info(f"SignagePlayer: ===== Playing item {self.current_index + 1}/{len(self.playlist)} =====") Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)")
Logger.info(f"SignagePlayer: File: {file_name}")
Logger.info(f"SignagePlayer: Duration: {duration}s")
# Construct full path to media file # Construct full path to media file
media_path = os.path.join(self.media_dir, file_name) 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: ❌ Media file not found: {media_path}")
Logger.error(f"SignagePlayer: Skipping to next media...") Logger.error(f"SignagePlayer: Skipping to next media...")
self.consecutive_errors += 1 self.consecutive_errors += 1
self.next_media() self.next_media()
return return
Logger.info(f"SignagePlayer: ✓ File exists (size: {os.path.getsize(media_path):,} bytes)")
# Remove status label if showing # Remove status label if showing
self.ids.status_label.opacity = 0 self.ids.status_label.opacity = 0
@@ -1211,7 +1191,7 @@ class SignagePlayer(Widget):
# Properly stop video if it's playing to prevent resource leaks # Properly stop video if it's playing to prevent resource leaks
if isinstance(self.current_widget, Video): if isinstance(self.current_widget, Video):
try: 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.state = 'stop'
self.current_widget.unload() self.current_widget.unload()
except Exception as e: except Exception as e:
@@ -1219,19 +1199,18 @@ class SignagePlayer(Widget):
self.ids.content_area.remove_widget(self.current_widget) self.ids.content_area.remove_widget(self.current_widget)
self.current_widget = None 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 # Determine media type and create appropriate widget
file_extension = os.path.splitext(file_name)[1].lower() 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']: if file_extension in ['.mp4', '.avi', '.mkv', '.mov', '.webm']:
# Video file # Video file
Logger.info(f"SignagePlayer: Media type: VIDEO") Logger.debug(f"SignagePlayer: Media type: VIDEO")
self.play_video(media_path, duration) self.play_video(media_path, duration)
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']: elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
# Image file # 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) self.play_image(media_path, duration, force_reload=force_reload)
else: else:
Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}") Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}")
@@ -1254,7 +1233,7 @@ class SignagePlayer(Widget):
# Reset error counter on successful playback # Reset error counter on successful playback
self.consecutive_errors = 0 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: except Exception as e:
Logger.error(f"SignagePlayer: Error playing media: {e}") Logger.error(f"SignagePlayer: Error playing media: {e}")
@@ -1281,11 +1260,9 @@ class SignagePlayer(Widget):
self.next_media() self.next_media()
return return
Logger.info(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s") Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s")
Logger.info(f"SignagePlayer: Video provider: {os.environ.get('KIVY_VIDEO', 'default')}")
# Create Video widget with optimized settings # Create Video widget with optimized settings
Logger.info(f"SignagePlayer: Creating Video widget...")
self.current_widget = Video( self.current_widget = Video(
source=video_path, source=video_path,
state='play', # Start playing immediately state='play', # Start playing immediately
@@ -1301,14 +1278,16 @@ class SignagePlayer(Widget):
self.current_widget.bind(on_eos=self._on_video_eos) self.current_widget.bind(on_eos=self._on_video_eos)
# Add to content area # Add to content area
Logger.info(f"SignagePlayer: Adding video widget to content area...")
self.ids.content_area.add_widget(self.current_widget) self.ids.content_area.add_widget(self.current_widget)
# Schedule next media after duration (unschedule first to prevent overlaps) # 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.unschedule(self.next_media)
Clock.schedule_once(self.next_media, duration) Clock.schedule_once(self.next_media, duration)
# Preload next media asynchronously for smoother transitions
self.preload_next_media()
except Exception as e: except Exception as e:
Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}") Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}")
self.consecutive_errors += 1 self.consecutive_errors += 1
@@ -1317,30 +1296,24 @@ class SignagePlayer(Widget):
def _on_video_eos(self, instance): def _on_video_eos(self, instance):
"""Callback when video reaches end of stream""" """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): def _on_video_loaded(self, instance, value):
"""Callback when video is loaded - log video information""" """Callback when video is loaded - log video information"""
if value: if value:
try: try:
Logger.info(f"SignagePlayer: Video loaded successfully") Logger.debug(f"SignagePlayer: Video loaded: {instance.texture.size if instance.texture else 'No texture'}, {instance.duration}s")
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}")
except Exception as e: 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): def play_image(self, image_path, duration, force_reload=False):
"""Play an image file""" """Play an image file"""
try: try:
# Log file info before loading # Log file info before loading
file_size = os.path.getsize(image_path) Logger.debug(f"SignagePlayer: Loading image: {os.path.basename(image_path)} ({os.path.getsize(image_path):,} bytes)")
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}")
if force_reload: 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 # Use regular Image widget instead of AsyncImage to bypass all caching
from kivy.uix.image import Image from kivy.uix.image import Image
self.current_widget = Image( self.current_widget = Image(
@@ -1353,7 +1326,6 @@ class SignagePlayer(Widget):
# Force reload the texture # Force reload the texture
self.current_widget.reload() self.current_widget.reload()
else: else:
Logger.info(f"SignagePlayer: Creating AsyncImage widget...")
self.current_widget = AsyncImage( self.current_widget = AsyncImage(
source=image_path, source=image_path,
allow_stretch=True, allow_stretch=True,
@@ -1361,13 +1333,14 @@ class SignagePlayer(Widget):
size_hint=(1, 1), size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5} 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) self.ids.content_area.add_widget(self.current_widget)
# Schedule next media after duration (unschedule first to prevent overlaps) # 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.unschedule(self.next_media)
Clock.schedule_once(self.next_media, duration) 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: except Exception as e:
Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}") Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}")
self.consecutive_errors += 1 self.consecutive_errors += 1
@@ -1396,6 +1369,48 @@ class SignagePlayer(Widget):
Clock.unschedule(self.next_media) Clock.unschedule(self.next_media)
self.play_current_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): def toggle_pause(self, instance=None):
"""Toggle pause/play with auto-resume after 5 minutes""" """Toggle pause/play with auto-resume after 5 minutes"""
self.is_paused = not self.is_paused self.is_paused = not self.is_paused