|
|
|
|
@@ -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
|
|
|
|
|
|