Initial commit: Kivy Signage Player

- Complete Kivy-based signage player application
- Fetches playlists from DigiServer via REST API
- Supports images (JPG, PNG, GIF, BMP) and videos (MP4, AVI, MKV, MOV, WEBM)
- Modern UI with touch controls and settings popup
- Separated UI (KV file) from business logic (Python)
- Fullscreen media display with proper scaling
- Server feedback system for player status reporting
- Auto-hide controls with mouse/touch activation
- Virtual environment setup with requirements.txt
- Installation and run scripts included

Features:
 Cross-platform Kivy framework
 Server integration with DigiServer
 Media playback with duration control
 Player feedback and status reporting
 Settings management with persistent config
 Error handling and recovery
 Clean architecture with KV file separation
This commit is contained in:
Kivy Signage Player
2025-09-26 16:48:26 +03:00
commit 04d4ea5916
12 changed files with 1192 additions and 0 deletions

136
README.md Normal file
View File

@@ -0,0 +1,136 @@
# Kivy Signage Player
A modern digital signage player built with Kivy framework that displays content from DigiServer playlists.
## Features
- **Cross-platform**: Runs on Linux, Windows, and macOS
- **Modern UI**: Built with Kivy framework for smooth graphics and animations
- **Multiple Media Types**: Supports images (JPG, PNG, GIF, BMP) and videos (MP4, AVI, MKV, MOV, WEBM)
- **Server Integration**: Fetches playlists from DigiServer with automatic updates
- **Player Feedback**: Reports status and playback information to server
- **Fullscreen Display**: Optimized for digital signage displays
- **Touch Controls**: Mouse/touch-activated control panel
- **Auto-restart**: Continuous playlist looping
- **Error Handling**: Robust error handling with server feedback
## Installation
1. **Install system dependencies:**
```bash
chmod +x install.sh
./install.sh
```
2. **Configure the player:**
Edit `config/app_config.json` with your server details:
```json
{
"server_ip": "your-server-ip",
"port": "5000",
"screen_name": "your-player-name",
"quickconnect_key": "your-quickconnect-code"
}
```
## Usage
### Manual Start
```bash
cd src
python3 main.py
```
### Using Run Script
```bash
chmod +x run_player.sh
./run_player.sh
```
## Controls
- **Mouse/Touch Movement**: Shows control panel for 3 seconds
- **Previous (⏮)**: Go to previous media item
- **Pause/Play (⏸/▶)**: Toggle playback
- **Next (⏭)**: Skip to next media item
- **Settings (⚙)**: View player configuration and status
- **Exit (⏻)**: Close the application
## Directory Structure
```
Kiwi-signage/
├── src/
│ ├── main.py # Main Kivy application
│ └── get_playlists.py # Playlist management and server communication
├── config/
│ └── app_config.json # Player configuration
├── media/ # Downloaded media files (auto-generated)
├── playlists/ # Playlist cache (auto-generated)
├── requirements.txt # Python dependencies
├── install.sh # Installation script
├── run_player.sh # Run script
└── README.md # This file
```
## Configuration Options
### app_config.json
- `server_ip`: IP address or domain of DigiServer
- `port`: Port number of DigiServer (default: 5000)
- `screen_name`: Unique identifier for this player
- `quickconnect_key`: Authentication key for server access
## Features Comparison with Tkinter Player
| Feature | Kivy Player | Tkinter Player |
|---------|-------------|----------------|
| Cross-platform | ✅ Better | ✅ Good |
| Modern UI | ✅ Excellent | ❌ Basic |
| Touch Support | ✅ Native | ❌ Limited |
| Video Playback | ✅ Built-in | ✅ VLC Required |
| Performance | ✅ GPU Accelerated | ❌ CPU Only |
| Animations | ✅ Smooth | ❌ None |
| Mobile Ready | ✅ Yes | ❌ No |
## Server Integration
The player communicates with DigiServer via REST API:
- **Playlist Fetch**: `GET /api/playlists`
- **Player Feedback**: `POST /api/player-feedback`
Status updates sent to server:
- Playlist check and update notifications
- Current playback status
- Error reports
- Playlist restart notifications
## Troubleshooting
### Installation Issues
- Make sure system dependencies are installed: `./install.sh`
- For ARM devices (Raspberry Pi), ensure proper SDL2 libraries
### Playback Issues
- Check media file formats are supported
- Verify network connection to DigiServer
- Check player configuration in settings
### Server Connection
- Verify server IP and port in configuration
- Check quickconnect key is correct
- Ensure DigiServer is running and accessible
## Development
Based on the proven architecture of the tkinter signage player with modern Kivy enhancements:
- **Playlist Management**: Inherited from `get_playlists.py`
- **Media Playback**: Kivy's built-in Video and AsyncImage widgets
- **Server Communication**: REST API calls with feedback system
- **Error Handling**: Comprehensive exception handling with server reporting
## License
This project is part of the DigiServer digital signage system.

6
config/app_config.json Normal file
View File

@@ -0,0 +1,6 @@
{
"server_ip": "digiserver",
"port": "80",
"screen_name": "rpi-tvholba1",
"quickconnect_key": "8887779"
}

22
install.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Kivy Signage Player Installation Script
echo "Installing Kivy Signage Player dependencies..."
# Update system
sudo apt update
# Install system dependencies for Kivy
sudo apt install -y python3-pip python3-setuptools
sudo apt install -y python3-dev
sudo apt install -y libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev
sudo apt install -y libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev
sudo apt install -y zlib1g-dev
# Install Python dependencies
pip3 install -r requirements.txt
echo "Installation completed!"
echo "To run the player:"
echo "cd src && python3 main.py"

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,15 @@
{
"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
}

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
kivy==2.1.0
requests==2.32.4
bcrypt==4.2.1

6
run_player.sh Normal file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
# Start Kivy Signage Player
cd "$(dirname "$0")/src"
python3 main.py

Binary file not shown.

334
src/get_playlists.py Normal file
View File

@@ -0,0 +1,334 @@
import os
import json
import requests
import bcrypt
import re
import datetime
import logging
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def send_player_feedback(config, message, status="active", playlist_version=None, error_details=None):
"""
Send feedback to the server about player status.
Args:
config (dict): Configuration containing server details
message (str): Main feedback message
status (str): Player status - "active", "playing", "error", "restarting"
playlist_version (int, optional): Current playlist version being played
error_details (str, optional): Error details if status is "error"
Returns:
bool: True if feedback sent successfully, False otherwise
"""
try:
server = config.get("server_ip", "")
host = config.get("screen_name", "")
quick = config.get("quickconnect_key", "")
port = config.get("port", "")
# Construct server URL
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
if re.match(ip_pattern, server):
feedback_url = f'http://{server}:{port}/api/player-feedback'
else:
feedback_url = f'http://{server}/api/player-feedback'
# Prepare feedback data
feedback_data = {
'player_name': host,
'quickconnect_code': quick,
'message': message,
'status': status,
'timestamp': datetime.datetime.now().isoformat(),
'playlist_version': playlist_version,
'error_details': error_details
}
logger.info(f"Sending feedback to {feedback_url}: {feedback_data}")
# Send POST request
response = requests.post(feedback_url, json=feedback_data, timeout=10)
if response.status_code == 200:
logger.info(f"Feedback sent successfully: {message}")
return True
else:
logger.warning(f"Feedback failed with status {response.status_code}: {response.text}")
return False
except requests.exceptions.RequestException as e:
logger.error(f"Failed to send feedback: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error sending feedback: {e}")
return False
def send_playlist_check_feedback(config, playlist_version=None):
"""
Send feedback when playlist is checked for updates.
Args:
config (dict): Configuration containing server details
playlist_version (int, optional): Current playlist version
Returns:
bool: True if feedback sent successfully, False otherwise
"""
player_name = config.get("screen_name", "unknown")
version_info = f"v{playlist_version}" if playlist_version else "unknown"
message = f"player {player_name}, is active, Playing {version_info}"
return send_player_feedback(
config=config,
message=message,
status="active",
playlist_version=playlist_version
)
def send_playlist_restart_feedback(config, playlist_version=None):
"""
Send feedback when playlist loop ends and restarts.
Args:
config (dict): Configuration containing server details
playlist_version (int, optional): Current playlist version
Returns:
bool: True if feedback sent successfully, False otherwise
"""
player_name = config.get("screen_name", "unknown")
version_info = f"v{playlist_version}" if playlist_version else "unknown"
message = f"player {player_name}, playlist loop completed, restarting {version_info}"
return send_player_feedback(
config=config,
message=message,
status="restarting",
playlist_version=playlist_version
)
def send_player_error_feedback(config, error_message, playlist_version=None):
"""
Send feedback when an error occurs in the player.
Args:
config (dict): Configuration containing server details
error_message (str): Description of the error
playlist_version (int, optional): Current playlist version
Returns:
bool: True if feedback sent successfully, False otherwise
"""
player_name = config.get("screen_name", "unknown")
message = f"player {player_name}, error occurred"
return send_player_feedback(
config=config,
message=message,
status="error",
playlist_version=playlist_version,
error_details=error_message
)
def send_playing_status_feedback(config, playlist_version=None, current_media=None):
"""
Send feedback about current playing status.
Args:
config (dict): Configuration containing server details
playlist_version (int, optional): Current playlist version
current_media (str, optional): Currently playing media file
Returns:
bool: True if feedback sent successfully, False otherwise
"""
player_name = config.get("screen_name", "unknown")
version_info = f"v{playlist_version}" if playlist_version else "unknown"
media_info = f" - {current_media}" if current_media else ""
message = f"player {player_name}, is active, Playing {version_info}{media_info}"
return send_player_feedback(
config=config,
message=message,
status="playing",
playlist_version=playlist_version
)
def fetch_server_playlist(config):
"""Fetch the updated playlist from the server using a config dict."""
server = config.get("server_ip", "")
host = config.get("screen_name", "")
quick = config.get("quickconnect_key", "")
port = config.get("port", "")
try:
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
if re.match(ip_pattern, server):
server_url = f'http://{server}:{port}/api/playlists'
else:
server_url = f'http://{server}/api/playlists'
params = {
'hostname': host,
'quickconnect_code': quick
}
logger.info(f"Fetching playlist from URL: {server_url} with params: {params}")
response = requests.get(server_url, params=params)
if response.status_code == 200:
response_data = response.json()
logger.info(f"Server response: {response_data}")
playlist = response_data.get('playlist', [])
version = response_data.get('playlist_version', None)
hashed_quickconnect = response_data.get('hashed_quickconnect', None)
if version is not None and hashed_quickconnect is not None:
if bcrypt.checkpw(quick.encode('utf-8'), hashed_quickconnect.encode('utf-8')):
logger.info("Fetched updated playlist from server.")
return {'playlist': playlist, 'version': version}
else:
logger.error("Quickconnect code validation failed.")
else:
logger.error("Failed to retrieve playlist or hashed quickconnect from the response.")
else:
logger.error(f"Failed to fetch playlist. Status Code: {response.status_code}")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to fetch playlist: {e}")
return {'playlist': [], 'version': 0}
def save_playlist_with_version(playlist_data, playlist_dir):
version = playlist_data.get('version', 0)
playlist_file = os.path.join(playlist_dir, f'server_playlist_v{version}.json')
with open(playlist_file, 'w') as f:
json.dump(playlist_data, f, indent=2)
logger.info(f"Playlist saved to {playlist_file}")
return playlist_file
def download_media_files(playlist, media_dir):
"""Download media files from the server and save them to media_dir."""
if not os.path.exists(media_dir):
os.makedirs(media_dir)
logger.info(f"Created directory {media_dir} for media files.")
updated_playlist = []
for media in playlist:
file_name = media.get('file_name', '')
file_url = media.get('url', '')
duration = media.get('duration', 10)
local_path = os.path.join(media_dir, file_name)
logger.info(f"Preparing to download {file_name} from {file_url}...")
if os.path.exists(local_path):
logger.info(f"File {file_name} already exists. Skipping download.")
else:
try:
response = requests.get(file_url, timeout=10)
if response.status_code == 200:
with open(local_path, 'wb') as file:
file.write(response.content)
logger.info(f"Successfully downloaded {file_name} to {local_path}")
else:
logger.error(f"Failed to download {file_name}. Status Code: {response.status_code}")
continue
except requests.exceptions.RequestException as e:
logger.error(f"Error downloading {file_name}: {e}")
continue
updated_media = {
'file_name': file_name,
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
'duration': duration
}
updated_playlist.append(updated_media)
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.
keep_versions: number of latest versions to keep (default 1)
"""
# Find all playlist files
playlist_files = [f for f in os.listdir(playlist_dir) if f.startswith('server_playlist_v') and f.endswith('.json')]
# Keep only the latest N versions
versions = sorted([int(f.split('_v')[-1].split('.json')[0]) for f in playlist_files], reverse=True)
keep = set(versions[:keep_versions])
# Delete old playlist files
for f in playlist_files:
v = int(f.split('_v')[-1].split('.json')[0])
if v not in keep:
os.remove(os.path.join(playlist_dir, f))
# Collect all media files referenced by the kept playlists
referenced = set()
for v in keep:
path = os.path.join(playlist_dir, f'server_playlist_v{v}.json')
if os.path.exists(path):
with open(path, 'r') as f:
data = json.load(f)
for item in data.get('playlist', []):
referenced.add(item.get('file_name'))
# Delete media files not referenced
for f in os.listdir(media_dir):
if f not in referenced:
try:
os.remove(os.path.join(media_dir, f))
except Exception as e:
logger.warning(f"Failed to delete media file {f}: {e}")
def update_playlist_if_needed(local_playlist_path, config, media_dir, playlist_dir):
"""
Fetch the server playlist once, compare versions, and update if needed.
Returns True if updated, False if already up to date.
Also sends feedback to server about playlist check.
"""
server_data = fetch_server_playlist(config)
server_version = server_data.get('version', 0)
if not os.path.exists(local_playlist_path):
local_version = 0
else:
with open(local_playlist_path, 'r') as f:
local_data = json.load(f)
local_version = local_data.get('version', 0)
logger.info(f"Local playlist version: {local_version}, Server playlist version: {server_version}")
# Send feedback about playlist check
send_playlist_check_feedback(config, server_version if server_version > 0 else local_version)
if local_version != server_version:
if server_data and server_data.get('playlist'):
updated_playlist = download_media_files(server_data['playlist'], media_dir)
server_data['playlist'] = updated_playlist
save_playlist_with_version(server_data, playlist_dir)
# Delete old playlists and unreferenced media
delete_old_playlists_and_media(server_version, playlist_dir, media_dir)
# Send feedback about playlist update
player_name = config.get("screen_name", "unknown")
update_message = f"player {player_name}, playlist updated to v{server_version}"
send_player_feedback(config, update_message, "active", server_version)
return True
else:
logger.warning("No playlist data fetched from server or playlist is empty.")
# Send error feedback
send_player_error_feedback(config, "No playlist data fetched from server or playlist is empty", local_version)
return False
else:
logger.info("Local playlist is already up to date.")
return False
def is_playlist_up_to_date(local_playlist_path, config):
"""
Compare the version of the local playlist with the server playlist.
Returns True if up-to-date, False otherwise.
"""
if not os.path.exists(local_playlist_path):
logger.info(f"Local playlist file not found: {local_playlist_path}")
return False
with open(local_playlist_path, 'r') as f:
local_data = json.load(f)
local_version = local_data.get('version', 0)
server_data = fetch_server_playlist(config)
server_version = server_data.get('version', 0)
logger.info(f"Local playlist version: {local_version}, Server playlist version: {server_version}")
return local_version == server_version

441
src/main.py Normal file
View File

@@ -0,0 +1,441 @@
"""
Kivy Signage Player - Main Application
Displays content from DigiServer playlists using Kivy framework
"""
import os
import json
import threading
import time
from kivy.app import App
from kivy.uix.widget import Widget
from kivy.uix.label import Label
from kivy.uix.image import AsyncImage
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.clock import Clock
from kivy.core.window import Window
from kivy.logger import Logger
from kivy.animation import Animation
from kivy.lang import Builder
from get_playlists import (
update_playlist_if_needed,
send_playing_status_feedback,
send_playlist_restart_feedback,
send_player_error_feedback
)
# Load the KV file
Builder.load_file('signage_player.kv')
class SettingsPopup(Popup):
def __init__(self, player_instance, **kwargs):
super(SettingsPopup, self).__init__(**kwargs)
self.player = player_instance
# 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')
# 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"}'
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
# Save to file
self.player.save_config()
# Close popup
self.dismiss()
class SignagePlayer(Widget):
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
# 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')
# 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)
# Initialize player
Clock.schedule_once(self.initialize_player, 0.1)
# Hide controls timer
self.controls_timer = None
# Auto-hide controls
self.schedule_hide_controls()
def initialize_player(self, dt):
"""Initialize the player - load config and start playlist checking"""
Logger.info("SignagePlayer: Initializing player...")
# Load configuration
self.load_config()
# Start playlist update thread
threading.Thread(target=self.playlist_update_loop, daemon=True).start()
# Start media playback
Clock.schedule_interval(self.check_playlist_and_play, 30) # Check every 30 seconds
# Initial playlist check
self.check_playlist_and_play(None)
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"
}
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}")
def playlist_update_loop(self):
"""Background thread to check for playlist updates"""
while True:
try:
if self.config:
# Find latest playlist file
latest_playlist = self.get_latest_playlist_file()
# Check for updates
updated = update_playlist_if_needed(
latest_playlist,
self.config,
self.media_dir,
self.playlists_dir
)
if updated:
Logger.info("SignagePlayer: Playlist updated, reloading...")
Clock.schedule_once(self.load_playlist, 0)
time.sleep(60) # Check every minute
except Exception as e:
Logger.error(f"SignagePlayer: Error in playlist update loop: {e}")
time.sleep(30) # Wait 30 seconds on error
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 check_playlist_and_play(self, dt):
"""Check for playlist updates and ensure playback is running"""
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)
# Construct full path to media file
media_path = os.path.join(self.media_dir, file_name)
if not os.path.exists(media_path):
Logger.error(f"SignagePlayer: Media file not found: {media_path}")
self.next_media()
return
Logger.info(f"SignagePlayer: Playing {file_name} for {duration}s")
# Remove status label if showing
self.ids.status_label.opacity = 0
# Remove previous media widget
if self.current_widget:
self.ids.content_area.remove_widget(self.current_widget)
self.current_widget = None
# Determine media type and create appropriate widget
file_extension = os.path.splitext(file_name)[1].lower()
if file_extension in ['.mp4', '.avi', '.mkv', '.mov', '.webm']:
# Video file
self.play_video(media_path, duration)
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif']:
# Image file
self.play_image(media_path, duration)
else:
Logger.warning(f"SignagePlayer: Unsupported media type: {file_extension}")
self.next_media()
return
# Send feedback to server
if self.config:
send_playing_status_feedback(self.config, self.playlist_version, file_name)
except Exception as e:
Logger.error(f"SignagePlayer: Error playing media: {e}")
self.show_error(f"Error playing media: {e}")
self.next_media()
def play_video(self, video_path, duration):
"""Play a video file"""
try:
self.current_widget = Video(
source=video_path,
state='play',
options={'allow_stretch': True},
size_hint=(1, 1),
pos_hint={'x': 0, 'y': 0}
)
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 video {video_path}: {e}")
self.next_media()
def play_image(self, image_path, duration):
"""Play an image file"""
try:
self.current_widget = AsyncImage(
source=image_path,
allow_stretch=True,
keep_ratio=False,
size_hint=(1, 1),
pos_hint={'x': 0, 'y': 0}
)
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()
def next_media(self, dt=None):
"""Move to next media item"""
if self.is_paused:
return
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
if self.config:
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
if self.config:
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"""
if self.controls_timer:
self.controls_timer.cancel()
# 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"""
Animation(opacity=0, duration=0.5).start(self.ids.controls_layout)
def show_settings(self, instance=None):
"""Show settings popup"""
popup = SettingsPopup(player_instance=self)
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(self):
# Set window to fullscreen
Window.fullscreen = 'auto'
Window.borderless = True
return SignagePlayer()
def on_start(self):
Logger.info("SignagePlayerApp: Application started")
def on_stop(self):
Logger.info("SignagePlayerApp: Application stopped")
if __name__ == '__main__':
SignagePlayerApp().run()

229
src/signage_player.kv Normal file
View File

@@ -0,0 +1,229 @@
#:kivy 2.1.0
<SignagePlayer>:
canvas.before:
Color:
rgba: 0, 0, 0, 1
Rectangle:
size: self.size
pos: self.pos
# Main content area
BoxLayout:
id: main_layout
orientation: 'vertical'
# Content display area (will be dynamically populated)
Widget:
id: content_area
size_hint: 1, 1
# Status label (shown when no content or errors)
Label:
id: status_label
text: 'Loading...'
size_hint: 1, None
height: dp(40)
pos_hint: {'center_x': 0.5}
color: 1, 1, 1, 1
font_size: sp(16)
text_size: self.size
halign: 'center'
valign: 'middle'
# Control panel overlay
FloatLayout:
id: overlay_layout
size_hint: 1, 1
# Controls container
BoxLayout:
id: controls_layout
orientation: 'horizontal'
size_hint: None, None
size: dp(450), dp(60)
pos_hint: {'right': 0.98, 'top': 0.98}
opacity: 0
spacing: dp(5)
padding: dp(10)
canvas.before:
Color:
rgba: 0.1, 0.1, 0.1, 0.8
RoundedRectangle:
size: self.size
pos: self.pos
radius: [dp(10)]
# Control buttons
Button:
id: prev_btn
text: '⏮'
size_hint: None, None
size: dp(60), dp(50)
font_size: sp(18)
background_color: 0.2, 0.2, 0.2, 1
color: 1, 1, 1, 1
on_press: root.previous_media()
Button:
id: play_pause_btn
text: '⏸'
size_hint: None, None
size: dp(60), dp(50)
font_size: sp(18)
background_color: 0.2, 0.6, 0.2, 1
color: 1, 1, 1, 1
on_press: root.toggle_pause()
Button:
id: next_btn
text: '⏭'
size_hint: None, None
size: dp(60), dp(50)
font_size: sp(18)
background_color: 0.2, 0.2, 0.2, 1
color: 1, 1, 1, 1
on_press: root.next_media()
Button:
id: settings_btn
text: '⚙'
size_hint: None, None
size: dp(60), dp(50)
font_size: sp(18)
background_color: 0.4, 0.4, 0.2, 1
color: 1, 1, 1, 1
on_press: root.show_settings()
Button:
id: exit_btn
text: '⏻'
size_hint: None, None
size: dp(60), dp(50)
font_size: sp(18)
background_color: 0.6, 0.2, 0.2, 1
color: 1, 1, 1, 1
on_press: root.exit_app()
# Settings popup content
<SettingsPopup@Popup>:
title: 'Player Settings'
size_hint: 0.8, 0.8
auto_dismiss: True
BoxLayout:
orientation: 'vertical'
padding: dp(20)
spacing: dp(15)
# Server configuration
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Server IP:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: server_input
size_hint_x: 0.7
multiline: False
font_size: sp(14)
# Screen name
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Screen Name:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: screen_input
size_hint_x: 0.7
multiline: False
font_size: sp(14)
# Quickconnect key
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Quickconnect:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: quickconnect_input
size_hint_x: 0.7
multiline: False
font_size: sp(14)
Widget:
size_hint_y: 0.1
# Status information
Label:
id: playlist_info
text: 'Playlist Version: N/A'
size_hint_y: None
height: dp(30)
text_size: self.size
halign: 'left'
valign: 'middle'
Label:
id: media_count_info
text: 'Media Count: 0'
size_hint_y: None
height: dp(30)
text_size: self.size
halign: 'left'
valign: 'middle'
Label:
id: status_info
text: 'Status: Idle'
size_hint_y: None
height: dp(30)
text_size: self.size
halign: 'left'
valign: 'middle'
Widget:
size_hint_y: 0.2
# Action buttons
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(50)
spacing: dp(20)
Button:
text: 'Save & Close'
background_color: 0.2, 0.6, 0.2, 1
on_press: root.save_and_close()
Button:
text: 'Cancel'
background_color: 0.6, 0.2, 0.2, 1
on_press: root.dismiss()