From a2fddfa312ef12b62c348ffecc4d4417afd32ad6 Mon Sep 17 00:00:00 2001 From: Kivy Signage Player Date: Mon, 27 Oct 2025 16:46:08 +0200 Subject: [PATCH] updated view --- config/app_config.json | 3 +- playlists/server_playlist_v5.json | 15 -- run_player.sh | 2 +- src/main.py | 239 ++++++++++++++++++++++++++++-- src/signage_player.kv | 21 +++ src/signageplayer.ini | 17 +++ 6 files changed, 267 insertions(+), 30 deletions(-) delete mode 100644 playlists/server_playlist_v5.json create mode 100644 src/signageplayer.ini diff --git a/config/app_config.json b/config/app_config.json index 746cd74..d92864c 100644 --- a/config/app_config.json +++ b/config/app_config.json @@ -4,5 +4,6 @@ "screen_name": "rpi-tvholba1", "quickconnect_key": "8887779", "orientation": "Landscape", - "touch": "True" + "touch": "True", + "max_resolution": "1920x1080" } \ No newline at end of file diff --git a/playlists/server_playlist_v5.json b/playlists/server_playlist_v5.json deleted file mode 100644 index c837f0d..0000000 --- a/playlists/server_playlist_v5.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "playlist": [ - { - "file_name": "call-of-duty-black-3840x2160-23674.jpg", - "url": "media/call-of-duty-black-3840x2160-23674.jpg", - "duration": 30 - }, - { - "file_name": "HARTING_Safety_day_informare_2_page_005.jpg", - "url": "media/HARTING_Safety_day_informare_2_page_005.jpg", - "duration": 20 - } - ], - "version": 5 -} \ No newline at end of file diff --git a/run_player.sh b/run_player.sh index 747667e..90e801e 100644 --- a/run_player.sh +++ b/run_player.sh @@ -1,6 +1,6 @@ #!/bin/bash # Start Kivy Signage Player - +cd "$(dirname "$0")" && source .venv/bin/activate cd "$(dirname "$0")/src" python3 main.py \ No newline at end of file diff --git a/src/main.py b/src/main.py index e45e29a..0995460 100644 --- a/src/main.py +++ b/src/main.py @@ -5,12 +5,20 @@ Displays content from DigiServer playlists using Kivy framework import os import json +import platform import threading import time + +# Set environment variables for better video performance +os.environ['KIVY_VIDEO'] = 'ffpyplayer' # Use ffpyplayer as video provider +os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8' # Support common codecs +os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0' # Prevent screen saver + from kivy.app import App +from kivy.config import Config from kivy.uix.widget import Widget from kivy.uix.label import Label -from kivy.uix.image import AsyncImage +from kivy.uix.image import AsyncImage, Image from kivy.uix.video import Video from kivy.uix.boxlayout import BoxLayout from kivy.uix.button import Button @@ -22,6 +30,8 @@ from kivy.properties import BooleanProperty from kivy.logger import Logger from kivy.animation import Animation from kivy.lang import Builder +from kivy.graphics import Rectangle +from kivy.graphics.texture import Texture from get_playlists import ( update_playlist_if_needed, send_playing_status_feedback, @@ -32,10 +42,44 @@ from get_playlists import ( # Load the KV file Builder.load_file('signage_player.kv') +# Removed VLCVideoWidget - using Kivy's built-in Video widget instead + class ExitPasswordPopup(Popup): - def __init__(self, player_instance, **kwargs): + def __init__(self, player_instance, was_paused=False, **kwargs): super(ExitPasswordPopup, self).__init__(**kwargs) self.player = player_instance + self.was_paused = was_paused + + # Cancel all scheduled cursor/control hide events + try: + if self.player.controls_timer: + self.player.controls_timer.cancel() + self.player.controls_timer = None + Clock.unschedule(self.player.hide_controls) + Clock.unschedule(self.player.schedule_cursor_hide) + except Exception as e: + Logger.debug(f"ExitPasswordPopup: Error canceling timers: {e}") + + # Show cursor when password popup opens + try: + Window.show_cursor = True + except: + pass + + # Bind to dismiss event to manage cursor visibility and resume playback + self.bind(on_dismiss=self.on_popup_dismiss) + + def on_popup_dismiss(self, *args): + """Handle popup dismissal - resume playback and restart cursor hide timer""" + # Resume playback if it wasn't paused before + if not self.was_paused: + self.player.is_paused = False + # Resume video if it was playing + if self.player.current_widget and isinstance(self.player.current_widget, Video): + self.player.current_widget.state = 'play' + + # Restart the control hide timer + self.player.schedule_hide_controls() def check_password(self): """Check if entered password matches quickconnect key""" @@ -54,9 +98,26 @@ class ExitPasswordPopup(Popup): Clock.schedule_once(lambda dt: self.dismiss(), 1) class SettingsPopup(Popup): - def __init__(self, player_instance, **kwargs): + def __init__(self, player_instance, was_paused=False, **kwargs): super(SettingsPopup, self).__init__(**kwargs) self.player = player_instance + self.was_paused = was_paused + + # Cancel all scheduled cursor/control hide events + try: + if self.player.controls_timer: + self.player.controls_timer.cancel() + self.player.controls_timer = None + Clock.unschedule(self.player.hide_controls) + Clock.unschedule(self.player.schedule_cursor_hide) + except Exception as e: + Logger.debug(f"SettingsPopup: Error canceling timers: {e}") + + # Show cursor when settings open + try: + Window.show_cursor = True + except: + pass # Populate current values self.ids.server_input.text = self.player.config.get('server_ip', 'localhost') @@ -64,11 +125,27 @@ class SettingsPopup(Popup): self.ids.quickconnect_input.text = self.player.config.get('quickconnect_key', '1234567') self.ids.orientation_input.text = self.player.config.get('orientation', 'Landscape') self.ids.touch_input.text = self.player.config.get('touch', 'True') + self.ids.resolution_input.text = self.player.config.get('max_resolution', 'auto') # Update status info self.ids.playlist_info.text = f'Playlist Version: {self.player.playlist_version}' self.ids.media_count_info.text = f'Media Count: {len(self.player.playlist)}' self.ids.status_info.text = f'Status: {"Playing" if self.player.is_playing else "Paused" if self.player.is_paused else "Idle"}' + + # Bind to dismiss event to manage cursor visibility and resume playback + self.bind(on_dismiss=self.on_popup_dismiss) + + def on_popup_dismiss(self, *args): + """Handle popup dismissal - resume playback and restart cursor hide timer""" + # Resume playback if it wasn't paused before + if not self.was_paused: + self.player.is_paused = False + # Resume video if it was playing + if self.player.current_widget and isinstance(self.player.current_widget, Video): + self.player.current_widget.state = 'play' + + # Restart the control hide timer + self.player.schedule_hide_controls() def save_and_close(self): """Save configuration and close popup""" @@ -78,10 +155,15 @@ class SettingsPopup(Popup): self.player.config['quickconnect_key'] = self.ids.quickconnect_input.text self.player.config['orientation'] = self.ids.orientation_input.text self.player.config['touch'] = self.ids.touch_input.text + self.player.config['max_resolution'] = self.ids.resolution_input.text # Save to file self.player.save_config() + # Notify user that resolution change requires restart + if self.ids.resolution_input.text != self.player.config.get('max_resolution', 'auto'): + Logger.info("SettingsPopup: Resolution changed - restart required") + # Close popup self.dismiss() @@ -109,6 +191,8 @@ class SignagePlayer(Widget): self.is_paused = False self.config = {} self.playlist_version = None + self.consecutive_errors = 0 # Track consecutive playback errors + self.max_consecutive_errors = 10 # Maximum errors before stopping # Paths self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) self.config_dir = os.path.join(self.base_dir, 'config') @@ -165,7 +249,8 @@ class SignagePlayer(Widget): "server_ip": "localhost", "port": "5000", "screen_name": "kivy-player", - "quickconnect_key": "1234567" + "quickconnect_key": "1234567", + "max_resolution": "auto" } self.save_config() Logger.info("SignagePlayer: Created default configuration") @@ -322,30 +407,67 @@ class SignagePlayer(Widget): if self.config: send_playing_status_feedback(self.config, self.playlist_version, file_name) + # Reset error counter on successful playback + self.consecutive_errors = 0 + except Exception as e: Logger.error(f"SignagePlayer: Error playing media: {e}") + self.consecutive_errors += 1 + + # Check if we've exceeded max errors + if self.consecutive_errors >= self.max_consecutive_errors: + error_msg = f"Too many consecutive errors ({self.consecutive_errors}), stopping playback" + Logger.error(f"SignagePlayer: {error_msg}") + self.show_error(error_msg) + self.is_playing = False + return + self.show_error(f"Error playing media: {e}") self.next_media() def play_video(self, video_path, duration): - """Play a video file""" + """Play a video file using Kivy's Video widget""" try: + # Create Video widget with optimized settings for quality self.current_widget = Video( source=video_path, state='play', - options={'allow_stretch': True}, + 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 size_hint=(1, 1), - pos_hint={'x': 0, 'y': 0} + pos_hint={'center_x': 0.5, 'center_y': 0.5} ) + # Bind to loaded event to log video info + 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) except Exception as e: Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}") - self.next_media() + self.consecutive_errors += 1 + if self.consecutive_errors < self.max_consecutive_errors: + self.next_media() + + def _on_video_loaded(self, instance, value): + """Callback when video is loaded - log video information""" + if value: + try: + Logger.info(f"Video loaded: {instance.texture.size if instance.texture else 'No texture'}") + Logger.info(f"Video duration: {instance.duration}s") + except Exception as e: + Logger.debug(f"Could not log video info: {e}") def play_image(self, image_path, duration): """Play an image file""" @@ -353,15 +475,18 @@ class SignagePlayer(Widget): self.current_widget = AsyncImage( source=image_path, allow_stretch=True, - keep_ratio=False, - size_hint=(1, 1) + keep_ratio=True, # Maintain aspect ratio + size_hint=(1, 1), + pos_hint={'center_x': 0.5, 'center_y': 0.5} ) self.ids.content_area.add_widget(self.current_widget) # Schedule next media after duration Clock.schedule_once(self.next_media, duration) except Exception as e: Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}") - self.next_media() + self.consecutive_errors += 1 + if self.consecutive_errors < self.max_consecutive_errors: + self.next_media() def next_media(self, dt=None): """Move to next media item""" @@ -462,14 +587,41 @@ class SignagePlayer(Widget): except: pass + def schedule_cursor_hide(self): + """Schedule cursor to hide after 3 seconds""" + try: + Clock.schedule_once(lambda dt: Window.__setattr__('show_cursor', False), 3) + except: + pass + def show_settings(self, instance=None): """Show settings popup""" - popup = SettingsPopup(player_instance=self) + # Pause playback when settings opens + was_paused = self.is_paused + if not was_paused: + self.is_paused = True + Clock.unschedule(self.next_media) + + # Stop video if playing + if self.current_widget and isinstance(self.current_widget, Video): + self.current_widget.state = 'pause' + + popup = SettingsPopup(player_instance=self, was_paused=was_paused) popup.open() def show_exit_popup(self, instance=None): """Show exit password popup""" - popup = ExitPasswordPopup(player_instance=self) + # Pause playback when exit popup opens + was_paused = self.is_paused + if not was_paused: + self.is_paused = True + Clock.unschedule(self.next_media) + + # Stop video if playing + if self.current_widget and isinstance(self.current_widget, Video): + self.current_widget.state = 'pause' + + popup = ExitPasswordPopup(player_instance=self, was_paused=was_paused) popup.open() def exit_app(self, instance=None): @@ -479,7 +631,68 @@ class SignagePlayer(Widget): class SignagePlayerApp(App): + def build_config(self, config): + """Configure Kivy settings for optimal video playback""" + # Set graphics settings for smooth 30fps video + config.setdefaults('graphics', { + 'multisamples': '2', # Anti-aliasing + 'maxfps': '30', # Limit to 30fps (optimized for RPi and video content) + 'vsync': '1', # Enable vertical sync + 'resizable': '0', # Disable window resizing + 'borderless': '1', # Borderless window + 'fullscreen': 'auto' # Auto fullscreen + }) + + # Disable unnecessary modules to save resources + config.setdefaults('modules', { + 'inspector': '', # Disable inspector + 'monitor': '', # Disable monitor + 'keybinding': '', # Disable keybinding + 'showborder': '' # Disable border highlighting + }) + + # Input settings + config.setdefaults('input', { + 'mouse': 'mouse', # Enable mouse input + }) + + Logger.info("SignagePlayerApp: Kivy config optimized for 30fps video playback") + def build(self): + # Load config to get resolution setting + config_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config', 'app_config.json') + max_resolution = 'auto' + + try: + if os.path.exists(config_file): + with open(config_file, 'r') as f: + config = json.load(f) + max_resolution = config.get('max_resolution', 'auto') + except Exception as e: + Logger.warning(f"SignagePlayerApp: Could not load resolution setting: {e}") + + # Apply resolution constraint + if max_resolution != 'auto' and 'x' in max_resolution: + try: + max_width, max_height = map(int, max_resolution.split('x')) + current_width, current_height = Window.size + + # Check if current resolution exceeds maximum + if current_width > max_width or current_height > max_height: + # Calculate scaling to fit within max resolution + scale = min(max_width / current_width, max_height / current_height) + new_width = int(current_width * scale) + new_height = int(current_height * scale) + + Window.size = (new_width, new_height) + Logger.info(f"SignagePlayerApp: Resolution constrained from {current_width}x{current_height} to {new_width}x{new_height}") + else: + Logger.info(f"SignagePlayerApp: Current resolution {current_width}x{current_height} within max {max_resolution}") + except Exception as e: + Logger.error(f"SignagePlayerApp: Error parsing resolution setting '{max_resolution}': {e}") + else: + Logger.info(f"SignagePlayerApp: Using auto resolution (no constraint)") + # Force fullscreen and borderless Window.fullscreen = True Window.borderless = True diff --git a/src/signage_player.kv b/src/signage_player.kv index 9ac5174..81142fa 100644 --- a/src/signage_player.kv +++ b/src/signage_player.kv @@ -261,6 +261,27 @@ multiline: False font_size: sp(14) + # Resolution + BoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: dp(40) + spacing: dp(10) + + Label: + text: 'Max Resolution:' + size_hint_x: 0.3 + text_size: self.size + halign: 'left' + valign: 'middle' + + TextInput: + id: resolution_input + size_hint_x: 0.7 + multiline: False + font_size: sp(14) + hint_text: '1920x1080 or auto' + Widget: size_hint_y: 0.1 diff --git a/src/signageplayer.ini b/src/signageplayer.ini new file mode 100644 index 0000000..b4603c8 --- /dev/null +++ b/src/signageplayer.ini @@ -0,0 +1,17 @@ +[graphics] +multisamples = 2 +maxfps = 30 +vsync = 1 +resizable = 0 +borderless = 1 +fullscreen = auto + +[modules] +inspector = +monitor = +keybinding = +showborder = + +[input] +mouse = mouse +