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:
136
README.md
Normal file
136
README.md
Normal 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
6
config/app_config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"server_ip": "digiserver",
|
||||||
|
"port": "80",
|
||||||
|
"screen_name": "rpi-tvholba1",
|
||||||
|
"quickconnect_key": "8887779"
|
||||||
|
}
|
||||||
22
install.sh
Normal file
22
install.sh
Normal 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"
|
||||||
BIN
media/HARTING_Safety_day_informare_2_page_005.jpg
Normal file
BIN
media/HARTING_Safety_day_informare_2_page_005.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
BIN
media/call-of-duty-black-3840x2160-23674.jpg
Normal file
BIN
media/call-of-duty-black-3840x2160-23674.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
15
playlists/server_playlist_v5.json
Normal file
15
playlists/server_playlist_v5.json
Normal 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
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
kivy==2.1.0
|
||||||
|
requests==2.32.4
|
||||||
|
bcrypt==4.2.1
|
||||||
6
run_player.sh
Normal file
6
run_player.sh
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Start Kivy Signage Player
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/src"
|
||||||
|
python3 main.py
|
||||||
BIN
src/__pycache__/get_playlists.cpython-311.pyc
Normal file
BIN
src/__pycache__/get_playlists.cpython-311.pyc
Normal file
Binary file not shown.
334
src/get_playlists.py
Normal file
334
src/get_playlists.py
Normal 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
441
src/main.py
Normal 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
229
src/signage_player.kv
Normal 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()
|
||||||
Reference in New Issue
Block a user