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:
149
src/main.py
149
src/main.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user