Files
Kiwy-Signage/src/main.py
Kiwy Signage Player 72d382b96b Add image editing feature with drawing tools
- Added pencil edit button to player controls
- Created EditPopup with drawing layer for image annotation
- Drawing tools: color selection (red/blue/green/black), thickness control
- Features: undo last stroke, clear all strokes, save edited image
- Playback automatically pauses during editing
- Only images (.jpg, .jpeg, .png, .bmp) can be edited
- Edited images saved with '_edited' suffix in same directory
- Drawing layer with touch support for annotations
- Full toolbar with color, thickness, and action controls
2025-12-04 22:40:05 +02:00

1496 lines
60 KiB
Python

"""
Kivy Signage Player - Main Application
Displays content from DigiServer playlists using Kivy framework
"""
import os
import json
import platform
import threading
import time
import asyncio
from concurrent.futures import ThreadPoolExecutor
# 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
# Configure Kivy BEFORE importing any Kivy modules
from kivy.config import Config
# Disable default virtual keyboard - we'll use our custom one
Config.set('kivy', 'keyboard_mode', '')
from kivy.app import App
from kivy.uix.widget import Widget
from kivy.uix.label import Label
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
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.core.window import Window
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,
send_playlist_restart_feedback,
send_player_error_feedback
)
from keyboard_widget import KeyboardWidget
from kivy.graphics import Color, Line, Ellipse
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.slider import Slider
# Load the KV file
Builder.load_file('signage_player.kv')
# Removed VLCVideoWidget - using Kivy's built-in Video widget instead
class DrawingLayer(Widget):
"""Layer for drawing on top of images"""
def __init__(self, **kwargs):
super(DrawingLayer, self).__init__(**kwargs)
self.strokes = [] # Store all drawn lines
self.current_color = (1, 0, 0, 1) # Default red
self.current_width = 3 # Default thickness
self.drawing_enabled = True # Drawing always enabled in edit mode
def on_touch_down(self, touch):
if not self.drawing_enabled or not self.collide_point(*touch.pos):
return False
with self.canvas:
Color(*self.current_color)
new_line = Line(points=[touch.x, touch.y], width=self.current_width)
self.strokes.append({'line': new_line, 'color': self.current_color, 'width': self.current_width})
touch.ud['line'] = new_line
return True
def on_touch_move(self, touch):
if 'line' in touch.ud and self.drawing_enabled:
touch.ud['line'].points += [touch.x, touch.y]
return True
def undo(self):
"""Remove the last stroke"""
if self.strokes:
last_stroke = self.strokes.pop()
self.canvas.remove(last_stroke['line'])
Logger.info("DrawingLayer: Undid last stroke")
def clear_all(self):
"""Clear all strokes"""
for stroke in self.strokes:
self.canvas.remove(stroke['line'])
self.strokes = []
Logger.info("DrawingLayer: Cleared all strokes")
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):
"""Popup for editing/annotating images"""
def __init__(self, player_instance, image_path, **kwargs):
super(EditPopup, self).__init__(**kwargs)
self.player = player_instance
self.image_path = image_path
self.drawing_layer = None
# Pause playback
self.was_paused = self.player.is_paused
if not self.was_paused:
self.player.is_paused = True
Clock.unschedule(self.player.next_media)
if self.player.current_widget and isinstance(self.player.current_widget, Video):
self.player.current_widget.state = 'pause'
# Show cursor
try:
Window.show_cursor = True
except:
pass
# Build UI
self.title = 'Edit Image'
self.size_hint = (0.95, 0.95)
self.auto_dismiss = False
# Main layout
main_layout = BoxLayout(orientation='vertical', padding=10, spacing=10)
# Image and drawing area
image_container = FloatLayout()
# Background image
self.image_widget = Image(
source=image_path,
allow_stretch=True,
keep_ratio=True,
size_hint=(1, 1)
)
image_container.add_widget(self.image_widget)
# Drawing layer on top
self.drawing_layer = DrawingLayer(size_hint=(1, 1))
image_container.add_widget(self.drawing_layer)
main_layout.add_widget(image_container)
# Toolbar
toolbar = BoxLayout(size_hint_y=None, height=80, spacing=10, padding=[10, 5])
# Color buttons
color_layout = BoxLayout(orientation='vertical', size_hint_x=0.15, spacing=5)
color_layout.add_widget(Label(text='Color:', size_hint_y=0.3, font_size='12sp'))
color_btns = BoxLayout(spacing=5)
color_btns.add_widget(Button(
text='Red',
background_color=(1, 0, 0, 1),
on_press=lambda x: self.drawing_layer.set_color((1, 0, 0, 1))
))
color_btns.add_widget(Button(
text='Blue',
background_color=(0, 0, 1, 1),
on_press=lambda x: self.drawing_layer.set_color((0, 0, 1, 1))
))
color_btns.add_widget(Button(
text='Green',
background_color=(0, 1, 0, 1),
on_press=lambda x: self.drawing_layer.set_color((0, 1, 0, 1))
))
color_btns.add_widget(Button(
text='Black',
background_color=(0, 0, 0, 1),
on_press=lambda x: self.drawing_layer.set_color((0, 0, 0, 1))
))
color_layout.add_widget(color_btns)
toolbar.add_widget(color_layout)
# Thickness controls
thickness_layout = BoxLayout(orientation='vertical', size_hint_x=0.15, spacing=5)
thickness_layout.add_widget(Label(text='Thickness:', size_hint_y=0.3, font_size='12sp'))
thickness_btns = BoxLayout(spacing=5)
thickness_btns.add_widget(Button(
text='Thin',
on_press=lambda x: self.drawing_layer.set_thickness(2)
))
thickness_btns.add_widget(Button(
text='Medium',
on_press=lambda x: self.drawing_layer.set_thickness(5)
))
thickness_btns.add_widget(Button(
text='Thick',
on_press=lambda x: self.drawing_layer.set_thickness(10)
))
thickness_layout.add_widget(thickness_btns)
toolbar.add_widget(thickness_layout)
# Action buttons
actions_layout = BoxLayout(orientation='vertical', size_hint_x=0.2, spacing=5)
actions_layout.add_widget(Label(text='Actions:', size_hint_y=0.3, font_size='12sp'))
action_btns = BoxLayout(spacing=5)
action_btns.add_widget(Button(
text='Undo',
background_color=(0.9, 0.6, 0.2, 1),
on_press=lambda x: self.drawing_layer.undo()
))
action_btns.add_widget(Button(
text='Clear',
background_color=(0.9, 0.3, 0.2, 1),
on_press=lambda x: self.drawing_layer.clear_all()
))
actions_layout.add_widget(action_btns)
toolbar.add_widget(actions_layout)
# Save and close buttons
final_btns = BoxLayout(orientation='vertical', size_hint_x=0.2, spacing=5)
final_btns.add_widget(Button(
text='Save',
background_color=(0.2, 0.8, 0.2, 1),
font_size='16sp',
on_press=self.save_image
))
final_btns.add_widget(Button(
text='Cancel',
background_color=(0.6, 0.2, 0.2, 1),
font_size='16sp',
on_press=self.close_without_saving
))
toolbar.add_widget(final_btns)
main_layout.add_widget(toolbar)
self.content = main_layout
# Bind to dismiss
self.bind(on_dismiss=self.on_popup_dismiss)
Logger.info(f"EditPopup: Opened for image {os.path.basename(image_path)}")
def save_image(self, instance):
"""Save the edited image"""
try:
# Generate output filename
base_name = os.path.basename(self.image_path)
name, ext = os.path.splitext(base_name)
output_path = os.path.join(
os.path.dirname(self.image_path),
f"{name}_edited{ext}"
)
# Export the entire widget (image + drawings) as PNG
self.content.export_to_png(output_path)
Logger.info(f"EditPopup: Saved edited image to {output_path}")
# Show confirmation
self.title = f'Saved as {os.path.basename(output_path)}'
Clock.schedule_once(lambda dt: self.dismiss(), 1)
except Exception as e:
Logger.error(f"EditPopup: Error saving image: {e}")
self.title = f'Error saving: {e}'
def close_without_saving(self, instance):
"""Close without saving"""
Logger.info("EditPopup: Closed without saving")
self.dismiss()
def on_popup_dismiss(self, *args):
"""Resume playback when popup closes"""
# Resume playback if it wasn't paused before
if not self.was_paused:
self.player.is_paused = False
self.player.play_current_media()
# Restart control hide timer
self.player.schedule_hide_controls()
Logger.info("EditPopup: Dismissed, playback resumed")
# Custom keyboard container with close button
class KeyboardContainer(BoxLayout):
def __init__(self, vkeyboard, **kwargs):
super(KeyboardContainer, self).__init__(**kwargs)
self.orientation = 'vertical'
self.vkeyboard = vkeyboard
# Calculate dimensions - half screen width
container_width = Window.width * 0.5
# Height proportional to width (maintain aspect ratio)
# Standard keyboard aspect ratio is ~3:1 (width:height)
keyboard_height = container_width / 3
close_bar_height = 50
total_height = keyboard_height + close_bar_height
# Set exact size (not size_hint)
self.size_hint = (None, None)
self.size = (container_width, total_height)
# Center horizontally at bottom
self.x = (Window.width - container_width) / 2
self.y = 0
# Bind to window resize
Window.bind(on_resize=self._on_window_resize)
# Create close button bar
close_bar = BoxLayout(
orientation='horizontal',
size_hint=(1, None),
height=close_bar_height,
padding=[10, 5, 10, 5]
)
# Add a spacer
close_bar.add_widget(Widget())
# Create close button
close_btn = Button(
text='',
size_hint=(None, 1),
width=50,
background_color=(0.8, 0.2, 0.2, 0.9),
font_size='24sp',
bold=True
)
close_btn.bind(on_press=self.close_keyboard)
close_bar.add_widget(close_btn)
# Add close button bar at top
self.add_widget(close_bar)
# Set keyboard exact size to match container width
self.vkeyboard.size_hint = (None, None)
self.vkeyboard.width = container_width
self.vkeyboard.height = keyboard_height
# Force keyboard to respect our dimensions
self.vkeyboard.scale_min = container_width / Window.width
self.vkeyboard.scale_max = container_width / Window.width
# Add keyboard
self.add_widget(self.vkeyboard)
# Background
with self.canvas.before:
from kivy.graphics import Color, RoundedRectangle
Color(0.1, 0.1, 0.1, 0.95)
self.bg_rect = RoundedRectangle(pos=self.pos, size=self.size, radius=[15, 15, 0, 0])
self.bind(pos=self._update_bg, size=self._update_bg)
Logger.info(f"KeyboardContainer: Created at ({self.x}, {self.y}) with size {self.size}")
def _on_window_resize(self, window, width, height):
"""Reposition and resize keyboard on window resize"""
container_width = width * 0.5
keyboard_height = container_width / 3 # Maintain aspect ratio
close_bar_height = 50
total_height = keyboard_height + close_bar_height
self.size = (container_width, total_height)
self.x = (width - container_width) / 2
self.y = 0
if self.vkeyboard:
self.vkeyboard.width = container_width
self.vkeyboard.height = keyboard_height
self.vkeyboard.scale_min = container_width / width
self.vkeyboard.scale_max = container_width / width
def _update_bg(self, *args):
"""Update background rectangle"""
self.bg_rect.pos = self.pos
self.bg_rect.size = self.size
def close_keyboard(self, *args):
"""Close the keyboard"""
Logger.info("KeyboardContainer: Closing keyboard")
if self.vkeyboard.target:
self.vkeyboard.target.focus = False
# Custom VKeyboard that uses container
class CustomVKeyboard(VKeyboard):
def __init__(self, **kwargs):
super(CustomVKeyboard, self).__init__(**kwargs)
self.container = None
Clock.schedule_once(self._setup_container, 0.1)
def _setup_container(self, dt):
"""Wrap keyboard in a container with close button"""
if self.parent and not self.container:
# Remove keyboard from parent
parent = self.parent
parent.remove_widget(self)
# Create container with keyboard
self.container = KeyboardContainer(self)
# Add container to parent
parent.add_widget(self.container)
Logger.info("CustomVKeyboard: Wrapped in container with close button")
# Set the custom keyboard factory
Window.set_vkeyboard_class(CustomVKeyboard)
class ExitPasswordPopup(Popup):
def __init__(self, player_instance, was_paused=False, **kwargs):
super(ExitPasswordPopup, self).__init__(**kwargs)
self.player = player_instance
self.was_paused = was_paused
self.keyboard_widget = None
# 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)
# Show keyboard after popup opens
Clock.schedule_once(self._show_keyboard, 0.2)
def _show_keyboard(self, dt):
"""Show the custom keyboard"""
try:
# Create keyboard widget and add to window
self.keyboard_widget = KeyboardWidget()
Window.add_widget(self.keyboard_widget)
self.keyboard_widget.show_keyboard(self.ids.password_input)
Logger.info("ExitPasswordPopup: Keyboard added to window")
except Exception as e:
Logger.warning(f"ExitPasswordPopup: Could not show keyboard: {e}")
def on_input_focus(self, instance, value):
"""Handle input field focus"""
if value and self.keyboard_widget: # Got focus
try:
self.keyboard_widget.show_keyboard(instance)
except Exception as e:
Logger.debug(f"ExitPasswordPopup: Could not show keyboard on focus: {e}")
def key_pressed(self, key):
"""Handle key press from keyboard"""
if self.keyboard_widget:
self.keyboard_widget.key_pressed(key)
def hide_keyboard(self):
"""Hide the custom keyboard"""
if self.keyboard_widget:
self.keyboard_widget.hide_keyboard()
Window.remove_widget(self.keyboard_widget)
self.keyboard_widget = None
def on_popup_dismiss(self, *args):
"""Handle popup dismissal - resume playback and restart cursor hide timer"""
# Hide and remove keyboard
self.hide_keyboard()
# 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"""
entered_password = self.ids.password_input.text
correct_password = self.player.config.get('quickconnect_key', '1234567')
if entered_password == correct_password:
# Password correct, exit app
Logger.info("ExitPasswordPopup: Correct password, exiting app")
# Create stop flag to prevent watchdog restart
stop_flag = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'.player_stop_requested'
)
try:
with open(stop_flag, 'w') as f:
f.write('User requested exit via password')
Logger.info("ExitPasswordPopup: Stop flag created - watchdog will not restart")
except Exception as e:
Logger.warning(f"ExitPasswordPopup: Could not create stop flag: {e}")
self.dismiss()
App.get_running_app().stop()
else:
# Password incorrect, show error and close popup
Logger.warning("ExitPasswordPopup: Incorrect password")
self.ids.error_label.text = 'Incorrect password!'
Clock.schedule_once(lambda dt: self.dismiss(), 1)
class SettingsPopup(Popup):
def __init__(self, player_instance, was_paused=False, **kwargs):
super(SettingsPopup, self).__init__(**kwargs)
self.player = player_instance
self.was_paused = was_paused
self.keyboard_widget = None
# 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')
self.ids.screen_input.text = self.player.config.get('screen_name', 'kivy-player')
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_input_touch(self, instance, touch):
"""Handle touch on input field to show keyboard"""
Logger.info(f"SettingsPopup: Input touched: {instance}")
self._show_keyboard(instance)
return False # Don't consume the touch event
def _show_keyboard(self, target_input):
"""Show the custom keyboard"""
try:
if not self.keyboard_widget:
# Create keyboard widget and add to window
self.keyboard_widget = KeyboardWidget()
Window.add_widget(self.keyboard_widget)
Logger.info("SettingsPopup: Keyboard added to window")
self.keyboard_widget.show_keyboard(target_input)
except Exception as e:
Logger.warning(f"SettingsPopup: Could not show keyboard: {e}")
def key_pressed(self, key):
"""Handle key press from keyboard"""
if self.keyboard_widget:
self.keyboard_widget.key_pressed(key)
def hide_keyboard(self):
"""Hide the custom keyboard"""
if self.keyboard_widget:
self.keyboard_widget.hide_keyboard()
Window.remove_widget(self.keyboard_widget)
self.keyboard_widget = None
def on_popup_dismiss(self, *args):
"""Handle popup dismissal - resume playback and restart cursor hide timer"""
# Hide and remove keyboard
self.hide_keyboard()
# 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 test_connection(self):
"""Test connection to server with current credentials"""
# Update status label to show testing
self.ids.connection_status.text = 'Testing connection...'
self.ids.connection_status.color = (1, 0.7, 0, 1) # Orange
# Run test in background thread to avoid blocking UI
def run_test():
try:
from player_auth import PlayerAuth
import re
# Get current values from inputs
server_ip = self.ids.server_input.text.strip()
screen_name = self.ids.screen_input.text.strip()
quickconnect = self.ids.quickconnect_input.text.strip()
port = self.player.config.get('port', '443')
if not all([server_ip, screen_name, quickconnect]):
Clock.schedule_once(lambda dt: self.update_connection_status(
'Error: Fill all fields', False
))
return
# Build server URL
if server_ip.startswith('http://') or server_ip.startswith('https://'):
server_url = server_ip
if not ':' in server_ip.replace('https://', '').replace('http://', ''):
if port and port != '443' and port != '80':
server_url = f"{server_ip}:{port}"
else:
protocol = "https" if port == "443" else "http"
server_url = f"{protocol}://{server_ip}:{port}"
Logger.info(f"SettingsPopup: Testing connection to {server_url}")
# Create temporary auth instance (don't save)
auth = PlayerAuth('/tmp/temp_auth_test.json')
# Try to authenticate
success, error = auth.authenticate(
server_url=server_url,
hostname=screen_name,
quickconnect_code=quickconnect
)
# Clean up temp file
try:
import os
if os.path.exists('/tmp/temp_auth_test.json'):
os.remove('/tmp/temp_auth_test.json')
except:
pass
# Update UI on main thread
if success:
player_name = auth.get_player_name()
Clock.schedule_once(lambda dt: self.update_connection_status(
f'✓ Connected: {player_name}', True
))
else:
Clock.schedule_once(lambda dt: self.update_connection_status(
f'✗ Failed: {error}', False
))
except Exception as e:
Logger.error(f"SettingsPopup: Connection test error: {e}")
Clock.schedule_once(lambda dt: self.update_connection_status(
f'✗ Error: {str(e)}', False
))
# Run in thread
threading.Thread(target=run_test, daemon=True).start()
def update_connection_status(self, message, success):
"""Update connection status label"""
self.ids.connection_status.text = message
if success:
self.ids.connection_status.color = (0, 1, 0, 1) # Green
else:
self.ids.connection_status.color = (1, 0, 0, 1) # Red
def save_and_close(self):
"""Save configuration and close popup"""
# Update config
self.player.config['server_ip'] = self.ids.server_input.text
self.player.config['screen_name'] = self.ids.screen_input.text
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()
class SignagePlayer(Widget):
from kivy.properties import StringProperty
resources_path = StringProperty()
from kivy.properties import NumericProperty
screen_width = NumericProperty(0)
screen_height = NumericProperty(0)
def set_screen_size(self):
"""Get the current screen size and set as properties."""
self.screen_width, self.screen_height = Window.size
Logger.info(f"Screen size detected: {self.screen_width}x{self.screen_height}")
is_paused = BooleanProperty(False)
def __init__(self, **kwargs):
super(SignagePlayer, self).__init__(**kwargs)
# Initialize variables
self.playlist = []
self.current_index = 0
self.current_widget = None
self.is_playing = False
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
self.intro_played = False # Track if intro has been played
# Paths
self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.config_dir = os.path.join(self.base_dir, 'config')
self.media_dir = os.path.join(self.base_dir, 'media')
self.playlists_dir = os.path.join(self.base_dir, 'playlists')
self.config_file = os.path.join(self.config_dir, 'app_config.json')
self.resources_path = os.path.join(self.config_dir, 'resources')
self.heartbeat_file = os.path.join(self.base_dir, '.player_heartbeat')
# Create directories if they don't exist
for directory in [self.config_dir, self.media_dir, self.playlists_dir]:
os.makedirs(directory, exist_ok=True)
# Get and set screen size
self.set_screen_size()
# Bind to window size for fullscreen
Window.bind(size=self._update_size)
self._update_size(Window, Window.size)
# Initialize player
Clock.schedule_once(self.initialize_player, 0.1)
# Hide controls timer
self.controls_timer = None
# Auto-hide controls
self.schedule_hide_controls()
# Start heartbeat monitoring
Clock.schedule_interval(self.update_heartbeat, 10) # Update every 10 seconds
def _update_size(self, instance, value):
self.size = value
if hasattr(self, 'ids') and 'content_area' in self.ids:
self.ids.content_area.size = value
def update_heartbeat(self, dt):
"""Update heartbeat file to indicate player is alive"""
try:
# Touch the heartbeat file to update its modification time
with open(self.heartbeat_file, 'w') as f:
f.write(str(time.time()))
except Exception as e:
Logger.warning(f"SignagePlayer: Failed to update heartbeat: {e}")
def initialize_player(self, dt):
"""Initialize the player - load config and start playlist checking"""
Logger.info("SignagePlayer: Initializing player...")
# Load configuration
self.load_config()
# Play intro video first
self.play_intro_video()
# Start async server tasks (non-blocking)
asyncio.ensure_future(self.async_playlist_update_loop())
Logger.info("SignagePlayer: Async server tasks started")
# Start media playback
Clock.schedule_interval(self.check_playlist_and_play, 30) # Check every 30 seconds
def load_config(self):
"""Load configuration from file"""
try:
if os.path.exists(self.config_file):
with open(self.config_file, 'r') as f:
self.config = json.load(f)
Logger.info(f"SignagePlayer: Configuration loaded from {self.config_file}")
else:
# Create default configuration
self.config = {
"server_ip": "localhost",
"port": "5000",
"screen_name": "kivy-player",
"quickconnect_key": "1234567",
"max_resolution": "auto"
}
self.save_config()
Logger.info("SignagePlayer: Created default configuration")
except Exception as e:
Logger.error(f"SignagePlayer: Error loading config: {e}")
self.show_error(f"Failed to load configuration: {e}")
def save_config(self):
"""Save configuration to file"""
try:
with open(self.config_file, 'w') as f:
json.dump(self.config, f, indent=2)
Logger.info("SignagePlayer: Configuration saved")
except Exception as e:
Logger.error(f"SignagePlayer: Error saving config: {e}")
async def async_playlist_update_loop(self):
"""Async coroutine to check for playlist updates without blocking UI"""
Logger.info("SignagePlayer: Starting async playlist update loop")
while True:
try:
if self.config:
# Run blocking network I/O in thread pool
loop = asyncio.get_event_loop()
# Find latest playlist file
latest_playlist = await loop.run_in_executor(
None,
self.get_latest_playlist_file
)
# Check for updates (network I/O in thread pool)
updated = await loop.run_in_executor(
None,
update_playlist_if_needed,
latest_playlist,
self.config,
self.media_dir,
self.playlists_dir
)
if updated:
Logger.info("SignagePlayer: Playlist updated, reloading...")
# Schedule UI update on main thread
Clock.schedule_once(self.load_playlist, 0)
# Wait 60 seconds before next check
await asyncio.sleep(60)
except Exception as e:
Logger.error(f"SignagePlayer: Error in async playlist update loop: {e}")
await asyncio.sleep(30) # Wait 30 seconds on error
async def async_send_feedback(self, feedback_func, *args):
"""Send feedback to server asynchronously without blocking UI"""
try:
loop = asyncio.get_event_loop()
# Run feedback in thread pool to avoid blocking
await loop.run_in_executor(None, feedback_func, *args)
except Exception as e:
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')
def load_playlist(self, dt=None):
"""Load playlist from file"""
try:
playlist_file = self.get_latest_playlist_file()
if os.path.exists(playlist_file):
with open(playlist_file, 'r') as f:
data = json.load(f)
self.playlist = data.get('playlist', [])
self.playlist_version = data.get('version', 0)
Logger.info(f"SignagePlayer: Loaded playlist v{self.playlist_version} with {len(self.playlist)} items")
if self.playlist:
self.ids.status_label.text = f"Playlist loaded: {len(self.playlist)} items"
if not self.is_playing:
Clock.schedule_once(self.start_playback, 1)
else:
self.ids.status_label.text = "No media in playlist"
else:
Logger.warning(f"SignagePlayer: Playlist file not found: {playlist_file}")
self.ids.status_label.text = "No playlist found"
except Exception as e:
Logger.error(f"SignagePlayer: Error loading playlist: {e}")
self.show_error(f"Failed to load playlist: {e}")
def play_intro_video(self):
"""Play intro video on startup"""
intro_path = os.path.join(self.resources_path, 'intro1.mp4')
if not os.path.exists(intro_path):
Logger.warning(f"SignagePlayer: Intro video not found at {intro_path}")
# Skip intro and load playlist
self.intro_played = True
self.check_playlist_and_play(None)
return
try:
Logger.info("SignagePlayer: Playing intro video...")
self.ids.status_label.opacity = 0 # Hide status label
# Create video widget for intro
intro_video = Video(
source=intro_path,
state='play',
options={'eos': 'stop'},
allow_stretch=True,
keep_ratio=True,
size=self.size,
pos=(0, 0)
)
# Bind to video end event
def on_intro_end(instance, value):
if value == 'stop':
Logger.info("SignagePlayer: Intro video finished")
# 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
self.check_playlist_and_play(None)
intro_video.bind(state=on_intro_end)
# Add intro video to content area
self.ids.content_area.add_widget(intro_video)
except Exception as e:
Logger.error(f"SignagePlayer: Error playing intro video: {e}")
# Skip intro and load playlist
self.intro_played = True
self.check_playlist_and_play(None)
def check_playlist_and_play(self, dt):
"""Check for playlist updates and ensure playback is running"""
# Don't start playlist until intro is done
if not self.intro_played:
return
if not self.playlist:
self.load_playlist()
if self.playlist and not self.is_playing and not self.is_paused:
self.start_playback()
def start_playback(self, dt=None):
"""Start media playback"""
if not self.playlist:
Logger.warning("SignagePlayer: No playlist to play")
return
Logger.info("SignagePlayer: Starting playback")
self.is_playing = True
self.current_index = 0
self.play_current_media()
def play_current_media(self):
"""Play the current media item"""
if not self.playlist or self.current_index >= len(self.playlist):
# End of playlist, restart
self.restart_playlist()
return
try:
media_item = self.playlist[self.current_index]
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")
# 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):
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
# Remove previous media widget
if self.current_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...")
self.current_widget.state = 'stop'
self.current_widget.unload()
except Exception as e:
Logger.warning(f"SignagePlayer: Error stopping video: {e}")
self.ids.content_area.remove_widget(self.current_widget)
self.current_widget = None
Logger.info(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")
self.play_video(media_path, duration)
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif']:
# Image file
Logger.info(f"SignagePlayer: Media type: IMAGE")
self.play_image(media_path, duration)
else:
Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}")
Logger.warning(f"SignagePlayer: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif")
Logger.warning(f"SignagePlayer: Skipping to next media...")
self.consecutive_errors += 1
self.next_media()
return
# Send feedback to server asynchronously (non-blocking)
if self.config:
asyncio.ensure_future(
self.async_send_feedback(
send_playing_status_feedback,
self.config,
self.playlist_version,
file_name
)
)
# Reset error counter on successful playback
self.consecutive_errors = 0
Logger.info(f"SignagePlayer: ✓ Media started successfully (consecutive_errors reset to 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 using Kivy's Video widget"""
try:
# Verify file exists
if not os.path.exists(video_path):
Logger.error(f"SignagePlayer: ❌ Video file not found: {video_path}")
self.consecutive_errors += 1
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')}")
# 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
options={
'eos': 'stop', # Stop at end of stream
},
allow_stretch=True,
keep_ratio=True, # Maintain aspect ratio
size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5}
)
# Bind to loaded and error events
self.current_widget.bind(loaded=self._on_video_loaded)
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")
Clock.unschedule(self.next_media)
Clock.schedule_once(self.next_media, duration)
except Exception as e:
Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}")
self.consecutive_errors += 1
if self.consecutive_errors < self.max_consecutive_errors:
self.next_media()
def _on_video_eos(self, instance):
"""Callback when video reaches end of stream"""
Logger.info("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}")
except Exception as e:
Logger.warning(f"SignagePlayer: Could not log video info: {e}")
def play_image(self, image_path, duration):
"""Play an image file"""
try:
Logger.info(f"SignagePlayer: Creating AsyncImage widget...")
self.current_widget = AsyncImage(
source=image_path,
allow_stretch=True,
keep_ratio=True, # Maintain aspect ratio
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")
Clock.unschedule(self.next_media)
Clock.schedule_once(self.next_media, duration)
Logger.info(f"SignagePlayer: ✓ Image displayed successfully")
except Exception as e:
Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}")
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"""
if self.is_paused:
Logger.debug(f"SignagePlayer: Skipping next_media - player is paused")
return
Logger.info(f"SignagePlayer: Transitioning to next media (was index {self.current_index})")
self.current_index += 1
# Unschedule any pending media transitions
Clock.unschedule(self.next_media)
# Play next media or restart playlist
self.play_current_media()
def previous_media(self, instance=None):
"""Move to previous media item"""
if self.playlist:
self.current_index = max(0, self.current_index - 1)
Clock.unschedule(self.next_media)
self.play_current_media()
def toggle_pause(self, instance=None):
"""Toggle pause/play"""
self.is_paused = not self.is_paused
if self.is_paused:
self.ids.play_pause_btn.text = ''
Clock.unschedule(self.next_media)
else:
self.ids.play_pause_btn.text = ''
# Resume by playing current media
self.play_current_media()
def restart_playlist(self):
"""Restart playlist from beginning"""
Logger.info("SignagePlayer: Restarting playlist")
# Send restart feedback asynchronously (non-blocking)
if self.config:
asyncio.ensure_future(
self.async_send_feedback(
send_playlist_restart_feedback,
self.config,
self.playlist_version
)
)
self.current_index = 0
self.play_current_media()
def show_error(self, message):
"""Show error message"""
Logger.error(f"SignagePlayer: {message}")
# Send error feedback to server asynchronously (non-blocking)
if self.config:
asyncio.ensure_future(
self.async_send_feedback(
send_player_error_feedback,
self.config,
message,
self.playlist_version
)
)
# Show error on screen
self.ids.status_label.text = f"Error: {message}"
self.ids.status_label.opacity = 1
def on_touch_down(self, touch):
"""Handle touch - show controls"""
self.show_controls()
return super(SignagePlayer, self).on_touch_down(touch)
def on_touch_move(self, touch):
"""Handle touch move - show controls"""
self.show_controls()
return super(SignagePlayer, self).on_touch_move(touch)
def show_controls(self):
"""Show control buttons and cursor"""
if self.controls_timer:
self.controls_timer.cancel()
# Show cursor
try:
Window.show_cursor = True
except:
pass
# Fade in controls
Animation(opacity=1, duration=0.3).start(self.ids.controls_layout)
# Schedule hide after 3 seconds
self.schedule_hide_controls()
def schedule_hide_controls(self):
"""Schedule hiding of controls"""
if self.controls_timer:
self.controls_timer.cancel()
self.controls_timer = Clock.schedule_once(self.hide_controls, 3)
def hide_controls(self, dt=None):
"""Hide control buttons and cursor"""
Animation(opacity=0, duration=0.5).start(self.ids.controls_layout)
# Hide cursor after controls are hidden
try:
Window.show_cursor = False
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"""
# 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_edit_interface(self, instance=None):
"""Show edit interface for current image"""
# Check if current media is an image
if not self.playlist or self.current_index >= len(self.playlist):
Logger.warning("SignagePlayer: No media to edit")
return
media_item = self.playlist[self.current_index]
file_name = media_item.get('file_name', '')
file_extension = os.path.splitext(file_name)[1].lower()
# Only allow editing images
if file_extension not in ['.jpg', '.jpeg', '.png', '.bmp']:
Logger.warning(f"SignagePlayer: Cannot edit {file_extension} files, only images")
# Show error message briefly
self.ids.status_label.text = 'Can only edit image files'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Get full path to current image
image_path = os.path.join(self.media_dir, file_name)
if not os.path.exists(image_path):
Logger.error(f"SignagePlayer: Image not found: {image_path}")
return
Logger.info(f"SignagePlayer: Opening edit interface for {file_name}")
# Open edit popup
popup = EditPopup(player_instance=self, image_path=image_path)
popup.open()
def show_exit_popup(self, instance=None):
"""Show exit password popup"""
# 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):
"""Exit the application"""
Logger.info("SignagePlayer: Exiting application")
App.get_running_app().stop()
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
Logger.info(f"SignagePlayerApp: Screen size: {Window.size}")
Logger.info(f"SignagePlayerApp: Available screen size: {Window.system_size if hasattr(Window, 'system_size') else 'N/A'}")
# Hide cursor after 3 seconds of inactivity
Clock.schedule_once(self.hide_cursor, 3)
return SignagePlayer()
def hide_cursor(self, dt):
"""Hide the mouse cursor"""
try:
Window.show_cursor = False
except:
pass # Some platforms don't support cursor hiding
def on_start(self):
# Setup asyncio event loop for Kivy integration
try:
loop = asyncio.get_event_loop()
Logger.info("SignagePlayerApp: Asyncio event loop integrated with Kivy")
except RuntimeError:
# Create new event loop if none exists
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
Logger.info("SignagePlayerApp: New asyncio event loop created")
# Schedule periodic async task processing
Clock.schedule_interval(self._process_async_tasks, 0.1) # Process every 100ms
# Log final window info
Logger.info(f"SignagePlayerApp: Final window size: {Window.size}")
Logger.info(f"SignagePlayerApp: Fullscreen: {Window.fullscreen}")
Logger.info("SignagePlayerApp: Application started")
Logger.info("SignagePlayerApp: Server communications running asynchronously")
def _process_async_tasks(self, dt):
"""Process pending asyncio tasks without blocking Kivy"""
try:
loop = asyncio.get_event_loop()
# Process pending callbacks without blocking
loop.call_soon(loop.stop)
loop.run_forever()
except Exception as e:
pass # Silently handle - loop may not have tasks
def on_stop(self):
Logger.info("SignagePlayerApp: Application stopped")
# Cancel all async tasks
try:
pending = asyncio.all_tasks()
for task in pending:
task.cancel()
Logger.info(f"SignagePlayerApp: Cancelled {len(pending)} async tasks")
except Exception as e:
Logger.debug(f"SignagePlayerApp: Error cancelling tasks: {e}")
if __name__ == '__main__':
try:
Logger.info("=" * 80)
Logger.info("Starting Kivy Signage Player Application")
Logger.info("=" * 80)
SignagePlayerApp().run()
except KeyboardInterrupt:
Logger.info("Application stopped by user (Ctrl+C)")
except Exception as e:
Logger.critical(f"Fatal error in application: {e}")
Logger.exception("Full traceback:")
import sys
sys.exit(1)
finally:
Logger.info("Application shutdown complete")