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.drawing_enabled = True # Drawing always enabled in edit mode
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):
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):
if 'line' in touch.ud and self.drawing_enabled:
touch.ud['line'].points += [touch.x, touch.y]
# 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]
self._last_draw_time = current_time
return True
def undo(self):
@@ -68,12 +74,10 @@ class DrawingLayer(Widget):
def set_color(self, color_tuple):
"""Set drawing color (RGBA)"""
self.current_color = color_tuple
Logger.debug(f"DrawingLayer: Color set to {color_tuple}")
def set_thickness(self, value):
"""Set line thickness"""
self.current_width = value
Logger.debug(f"DrawingLayer: Thickness set to {value}")
class EditPopup(Popup):

View File

@@ -188,10 +188,9 @@ def fetch_server_playlist(config):
return {'playlist': [], 'version': 0}
def save_playlist_with_version(playlist_data, playlist_dir):
"""Save playlist to file with version number."""
version = playlist_data.get('version', 0)
playlist_file = os.path.join(playlist_dir, f'server_playlist_v{version}.json')
def save_playlist(playlist_data, playlist_dir):
"""Save playlist to a single file (no versioning)."""
playlist_file = os.path.join(playlist_dir, 'server_playlist.json')
# Ensure directory exists
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.")
else:
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)
if response.status_code == 200:
with open(local_path, 'wb') as file:
@@ -245,82 +247,59 @@ def download_media_files(playlist, media_dir):
return updated_playlist
def delete_old_playlists_and_media(current_version, playlist_dir, media_dir, keep_versions=1):
"""Delete old playlist files and media files not referenced by the latest playlist version."""
def delete_unused_media(playlist_data, media_dir):
"""Delete media files not referenced in the current playlist."""
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
current_playlist_file = os.path.join(playlist_dir, f'server_playlist_v{current_version}.json')
referenced_files = set()
for media in playlist_data.get('playlist', []):
file_name = media.get('file_name', '')
if file_name:
referenced_files.add(file_name)
if os.path.exists(current_playlist_file):
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:
referenced_files.add(file_name)
logger.info(f"📋 Current playlist references {len(referenced_files)} media files")
# Get all files in media directory (excluding edited_media subfolder)
if os.path.exists(media_dir):
media_files = [f for f in os.listdir(media_dir)
if os.path.isfile(os.path.join(media_dir, f))]
deleted_count = 0
for media_file in media_files:
# Skip if file is in current playlist
if media_file in referenced_files:
continue
# Delete unreferenced file
media_path = os.path.join(media_dir, media_file)
try:
os.remove(media_path)
logger.info(f"🗑️ Deleted unused media: {media_file}")
deleted_count += 1
except Exception as e:
logger.warning(f"⚠️ Could not delete {media_file}: {e}")
if deleted_count > 0:
logger.info(f"✅ Deleted {deleted_count} unused media files")
else:
logger.info("✅ No unused media files to delete")
except Exception as e:
logger.error(f"❌ Error reading playlist for media cleanup: {e}")
logger.info(f"📋 Current playlist references {len(referenced_files)} files")
logger.info(f"✅ Cleanup complete (kept {keep_versions} latest playlist versions)")
if os.path.exists(media_dir):
# Recursively get all media files
deleted_count = 0
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
if rel_path in referenced_files:
continue
# Delete unreferenced file
try:
os.remove(full_path)
logger.info(f"🗑️ Deleted unused media: {rel_path}")
deleted_count += 1
except Exception as 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:
logger.info(f"✅ Deleted {deleted_count} unused media files")
else:
logger.info("✅ No unused media files to delete")
except Exception as e:
logger.error(f"❌ Error during cleanup: {e}")
logger.error(f"❌ Error during media cleanup: {e}")
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")
return None
# Check local version
# Check local version from single playlist file
local_version = 0
local_playlist_file = None
playlist_file = os.path.join(playlist_dir, 'server_playlist.json')
if os.path.exists(playlist_dir):
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:
version = int(f.replace('server_playlist_v', '').replace('.json', ''))
if version > local_version:
local_version = version
local_playlist_file = os.path.join(playlist_dir, f)
except ValueError:
continue
if os.path.exists(playlist_file):
try:
with open(playlist_file, 'r') as f:
local_data = json.load(f)
local_version = local_data.get('version', 0)
except Exception as e:
logger.warning(f"⚠️ Could not read local playlist: {e}")
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)
server_data['playlist'] = updated_playlist
# Save new playlist
playlist_file = save_playlist_with_version(server_data, playlist_dir)
# Save new playlist (single file, no versioning)
playlist_file = save_playlist(server_data, playlist_dir)
# Clean up old versions
delete_old_playlists_and_media(server_version, playlist_dir, media_dir)
# Delete unused media files
delete_unused_media(server_data, media_dir)
logger.info(f"✅ Playlist updated successfully to v{server_version}")
return playlist_file
else:
logger.info("✓ Playlist is up to date")
return local_playlist_file
return playlist_file
except Exception as 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.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