Files
tkinter_player/tkinter_app/src/tkinter_simple_player.py

2108 lines
90 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Tkinter Simple Media Player - A lightweight version with minimal dependencies
Features:
- Image display
- Basic playlist management
- Settings configuration
- Auto-hiding controls
- Touch display optimization with virtual keyboard
"""
import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
import threading
import time
import os
import json
import datetime
from pathlib import Path
import subprocess
import sys
import requests # Required for server communication
import queue
import cv2 # For video playback
import pygame # For video audio
# Try importing PIL but provide fallback
try:
from PIL import Image, ImageTk
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
print("WARNING: PIL not available. Image display functionality will be limited.")
# Import existing functions
from python_functions import (
load_local_playlist, download_media_files, clean_unused_files,
save_local_playlist, update_config_playlist_version, fetch_server_playlist,
load_config
)
from logging_config import Logger
# Import virtual keyboard components
from virtual_keyboard import VirtualKeyboard, TouchOptimizedEntry, TouchOptimizedButton
# Update the config file path to use the resources directory
CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'resources', 'app_config.txt')
class SimpleMediaPlayerApp:
def __init__(self):
self.root = tk.Tk()
self.setup_window()
# Media player state
self.playlist = []
self.current_index = 0
self.is_paused = False
self.is_fullscreen = True
self.auto_advance_timer = None
self.hide_controls_timer = None
self.video_thread = None
self.update_queue = queue.Queue()
# UI Elements
self.image_label = None
self.status_label = None
self.control_frame = None
self.settings_window = None
# Running state
self.running = True
# Display scaling mode ('fit', 'fill', 'stretch')
self.scaling_mode = 'fit' # Default to fit (maintain aspect ratio with black bars)
# Initialize pygame for video audio
try:
pygame.init()
pygame.mixer.init()
except Exception as e:
Logger.warning(f"Failed to initialize pygame mixer for audio: {e}")
self.setup_ui()
# Initialize from server
self.initialize_playlist_from_server()
# Start periodic playlist checks
self.start_periodic_checks()
def setup_window(self):
"""Configure the main window"""
self.root.title("Simple Signage Player")
self.root.configure(bg='black')
# Load window size from config
try:
config = load_config()
width = int(config.get('screen_w', 1920))
height = int(config.get('screen_h', 1080))
# Load scaling mode preference
self.scaling_mode = config.get('scaling_mode', 'fit')
except:
width, height = 800, 600 # Fallback size if config fails
self.scaling_mode = 'fit' # Default scaling mode
self.root.geometry(f"{width}x{height}")
self.root.attributes('-fullscreen', True)
# Bind events
self.root.bind('<Key>', self.on_key_press)
self.root.bind('<Button-1>', self.on_mouse_click)
self.root.bind('<Motion>', self.on_mouse_motion)
self.root.focus_set()
def setup_ui(self):
"""Create the user interface"""
# Main content area - make sure it's black and fill the entire window
self.content_frame = tk.Frame(self.root, bg='black')
self.content_frame.pack(fill=tk.BOTH, expand=True)
# Image display - expand to fill the entire window
self.image_label = tk.Label(self.content_frame, bg='black')
self.image_label.pack(fill=tk.BOTH, expand=True)
# Status label - hidden by default
self.status_label = tk.Label(
self.content_frame,
bg='black',
fg='white',
font=('Arial', 24),
text=""
)
# Don't place the status label by default to keep it hidden
# Control panel
self.create_control_panel()
self.show_controls()
self.schedule_hide_controls()
def create_control_panel(self):
"""Create touch-optimized control panel with larger buttons"""
# Create control frame with larger size for touch
self.control_frame = tk.Frame(
self.root,
bg='#1a1a1a', # Dark background
bd=2,
relief=tk.RAISED,
padx=15,
pady=15
)
self.control_frame.place(relx=0.98, rely=0.98, anchor='se')
# Touch-optimized button configuration
button_config = {
'bg': '#333333',
'fg': 'white',
'activebackground': '#555555',
'activeforeground': 'white',
'relief': tk.FLAT,
'borderwidth': 0,
'width': 10, # Larger for touch
'height': 3, # Larger for touch
'font': ('Segoe UI', 10, 'bold'), # Larger font
'cursor': 'hand2'
}
# Previous button
self.prev_btn = tk.Button(
self.control_frame,
text="⏮ Prev",
command=self.previous_media,
**button_config
)
self.prev_btn.grid(row=0, column=0, padx=5)
# Play/Pause button
self.play_pause_btn = tk.Button(
self.control_frame,
text="⏸ Pause" if not self.is_paused else "▶ Play",
command=self.toggle_play_pause,
bg='#27ae60', # Green for play/pause
activebackground='#35d974',
**{k: v for k, v in button_config.items() if k not in ['bg', 'activebackground']}
)
self.play_pause_btn.grid(row=0, column=1, padx=5)
# Next button
self.next_btn = tk.Button(
self.control_frame,
text="Next ⏭",
command=self.next_media,
**button_config
)
self.next_btn.grid(row=0, column=2, padx=5)
# Settings button
self.settings_btn = tk.Button(
self.control_frame,
text="⚙️ Settings",
command=self.open_settings,
bg='#9b59b6', # Purple for settings
activebackground='#bb8fce',
**{k: v for k, v in button_config.items() if k not in ['bg', 'activebackground']}
)
self.settings_btn.grid(row=0, column=3, padx=5)
# Exit button with special styling
self.exit_btn = tk.Button(
self.control_frame,
text="❌ EXIT",
command=self.show_exit_dialog,
bg='#e74c3c', # Red background
fg='white',
activebackground='#ec7063',
activeforeground='white',
relief=tk.FLAT,
borderwidth=0,
width=8,
height=3,
font=('Segoe UI', 10, 'bold'),
cursor='hand2'
)
self.exit_btn.grid(row=0, column=4, padx=5)
# Add touch feedback to all buttons
for button in [self.prev_btn, self.play_pause_btn, self.next_btn,
self.settings_btn, self.exit_btn]:
self.add_touch_feedback_to_control_button(button)
def scale_image_to_screen(self, img, screen_width, screen_height, mode='fit'):
"""
Scale image to screen with different modes:
- 'fit': Maintain aspect ratio, add black bars if needed (letterbox/pillarbox)
- 'fill': Maintain aspect ratio, crop if needed to fill entire screen
- 'stretch': Ignore aspect ratio, stretch to fill entire screen
"""
img_width, img_height = img.size
if mode == 'stretch':
# Stretch to fill entire screen, ignoring aspect ratio
return img.resize((screen_width, screen_height), Image.LANCZOS), (0, 0)
elif mode == 'fill':
# Maintain aspect ratio, crop to fill entire screen
screen_ratio = screen_width / screen_height
img_ratio = img_width / img_height
if img_ratio > screen_ratio:
# Image is wider - scale by height and crop width
new_height = screen_height
new_width = int(screen_height * img_ratio)
x_offset = (screen_width - new_width) // 2
y_offset = 0
else:
# Image is taller - scale by width and crop height
new_width = screen_width
new_height = int(screen_width / img_ratio)
x_offset = 0
y_offset = (screen_height - new_height) // 2
# Resize and crop
img_resized = img.resize((new_width, new_height), Image.LANCZOS)
# Create final image and paste (this will crop automatically)
final_img = Image.new('RGB', (screen_width, screen_height), 'black')
# Calculate crop area if image is larger than screen
if new_width > screen_width:
crop_x = (new_width - screen_width) // 2
img_resized = img_resized.crop((crop_x, 0, crop_x + screen_width, new_height))
x_offset = 0
if new_height > screen_height:
crop_y = (new_height - screen_height) // 2
img_resized = img_resized.crop((0, crop_y, new_width, crop_y + screen_height))
y_offset = 0
final_img.paste(img_resized, (x_offset, y_offset))
return final_img, (x_offset, y_offset)
else: # mode == 'fit' (default)
# Maintain aspect ratio, add black bars if needed
screen_ratio = screen_width / screen_height
img_ratio = img_width / img_height
if img_ratio > screen_ratio:
# Image is wider than screen - fit to width
new_width = screen_width
new_height = int(screen_width / img_ratio)
else:
# Image is taller than screen - fit to height
new_height = screen_height
new_width = int(screen_height * img_ratio)
# Resize image
img_resized = img.resize((new_width, new_height), Image.LANCZOS)
# Create black background and center the image
final_img = Image.new('RGB', (screen_width, screen_height), 'black')
x_offset = (screen_width - new_width) // 2
y_offset = (screen_height - new_height) // 2
final_img.paste(img_resized, (x_offset, y_offset))
return final_img, (x_offset, y_offset)
def add_touch_feedback_to_control_button(self, button):
"""Add touch feedback effects to control panel buttons"""
original_bg = button.cget('bg')
def on_press(e):
button.configure(relief=tk.SUNKEN)
def on_release(e):
button.configure(relief=tk.FLAT)
def on_enter(e):
button.configure(relief=tk.RAISED)
def on_leave(e):
button.configure(relief=tk.FLAT)
button.bind("<Button-1>", on_press)
button.bind("<ButtonRelease-1>", on_release)
button.bind("<Enter>", on_enter)
button.bind("<Leave>", on_leave)
def initialize_playlist_from_server(self):
"""Initialize the playlist from the server on startup with fallback to local playlist"""
# First try to load any existing local playlist as fallback
fallback_playlist = None
try:
local_playlist_data = load_local_playlist()
fallback_playlist = local_playlist_data.get('playlist', [])
if fallback_playlist:
Logger.info(f"Found fallback playlist with {len(fallback_playlist)} items")
except Exception as e:
Logger.warning(f"No fallback playlist available: {e}")
# Show connection status
self.status_label.config(text="Connecting to server...\nPlease wait")
self.status_label.place(relx=0.5, rely=0.5, anchor='center')
self.root.update()
# Load configuration
config = load_config()
server = config.get("server_ip", "")
host = config.get("screen_name", "")
quick = config.get("quickconnect_key", "")
port = config.get("port", "")
Logger.info(f"Initializing with settings: server={server}, host={host}, port={port}")
if not server or not host or not quick or not port:
Logger.warning("Missing server configuration, using fallback playlist")
self.status_label.place_forget()
self.load_fallback_playlist(fallback_playlist)
return
# Attempt to fetch server playlist with timeout
server_connection_successful = False
try:
# Add connection timeout and retry logic
Logger.info("Attempting to connect to server...")
self.status_label.config(text="Connecting to server...\nAttempting connection")
self.root.update()
server_playlist_data = fetch_server_playlist()
server_playlist = server_playlist_data.get('playlist', [])
server_version = server_playlist_data.get('version', 0)
if server_playlist:
Logger.info(f"Server playlist found with {len(server_playlist)} items, version {server_version}")
server_connection_successful = True
# Download media files and update local playlist
self.status_label.config(text="Downloading media files...\nPlease wait")
self.root.update()
download_media_files(server_playlist, server_version)
update_config_playlist_version(server_version)
# Load the updated local playlist
local_playlist_data = load_local_playlist()
self.playlist = local_playlist_data.get('playlist', [])
if self.playlist:
Logger.info(f"Successfully loaded {len(self.playlist)} items from server")
self.status_label.place_forget()
self.play_current_media()
return
else:
Logger.warning("Server playlist was empty, falling back to local playlist")
else:
Logger.warning("Server returned empty playlist, falling back to local playlist")
except requests.exceptions.ConnectTimeout:
Logger.error("Server connection timeout, using fallback playlist")
except requests.exceptions.ConnectionError:
Logger.error("Cannot connect to server, using fallback playlist")
except requests.exceptions.Timeout:
Logger.error("Server request timeout, using fallback playlist")
except Exception as e:
Logger.error(f"Failed to fetch playlist from server: {e}, using fallback playlist")
# If we reach here, server connection failed - use fallback
if not server_connection_successful:
self.status_label.config(text="Server unavailable\nLoading last playlist...")
self.root.update()
time.sleep(1) # Brief pause to show message
self.status_label.place_forget()
self.load_fallback_playlist(fallback_playlist)
def load_fallback_playlist(self, fallback_playlist):
"""Load fallback playlist when server is unavailable"""
if fallback_playlist and len(fallback_playlist) > 0:
self.playlist = fallback_playlist
Logger.info(f"Loaded fallback playlist with {len(self.playlist)} items")
self.play_current_media()
else:
Logger.warning("No fallback playlist available, loading demo content")
self.load_demo_or_local_playlist()
def load_demo_or_local_playlist(self):
"""Load either the existing local playlist or demo content"""
# First try to load the local playlist
local_playlist_data = load_local_playlist()
self.playlist = local_playlist_data.get('playlist', [])
if self.playlist:
Logger.info(f"Loaded existing local playlist with {len(self.playlist)} items")
self.play_current_media()
return
# If no local playlist, try loading demo content
Logger.info("No local playlist found, loading demo content")
self.create_demo_content()
if self.playlist:
self.play_current_media()
else:
self.show_no_content_message()
def create_demo_content(self):
"""Create demo content for testing"""
demo_images = []
# First check static/resurse folder for any media
static_dir = os.path.join(os.path.dirname(__file__), 'static', 'resurse')
if os.path.exists(static_dir):
for file in os.listdir(static_dir):
if file.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')):
full_path = os.path.join(static_dir, file)
demo_images.append({
'file_name': file,
'url': full_path,
'duration': 5
})
# If no files found in static/resurse, look in Resurse folder
if not demo_images:
demo_dir = './Resurse'
if os.path.exists(demo_dir):
for file in os.listdir(demo_dir):
if file.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')):
demo_images.append({
'file_name': file,
'url': os.path.join(demo_dir, file),
'duration': 5
})
if demo_images:
self.playlist = demo_images
Logger.info(f"Created demo playlist with {len(demo_images)} images")
else:
# Create a text-only demo if no images found
self.playlist = [{
'file_name': 'Demo Text',
'url': 'text://Welcome to Tkinter Media Player!\n\nPlease configure server settings',
'duration': 5
}]
def show_no_content_message(self):
"""Show message when no content is available"""
self.image_label.config(image='')
self.status_label.config(
text="No media content available.\nPress Settings to configure server connection."
)
self.status_label.place(relx=0.5, rely=0.5, anchor='center')
def show_error_message(self, message):
"""Show error message"""
self.image_label.config(image='')
self.status_label.config(text=f"Error: {message}")
self.status_label.place(relx=0.5, rely=0.5, anchor='center')
def play_current_media(self):
"""Play the current media item"""
if not self.playlist or self.current_index >= len(self.playlist):
self.show_no_content_message()
return
media = self.playlist[self.current_index]
file_path = media.get('url', '')
file_name = media.get('file_name', '')
duration = media.get('duration', 10)
# Handle relative paths by converting to absolute paths
if file_path.startswith('static/resurse/'):
# Convert relative path to absolute
absolute_path = os.path.join(os.path.dirname(__file__), file_path)
file_path = absolute_path
Logger.info(f"Playing media: {file_name} from {file_path}")
# Log media start
self.log_event(file_name, "STARTED")
# Cancel existing timers
self.cancel_timers()
# Handle different media types
if file_path.startswith('text://'):
self.show_text_content(file_path[7:], duration)
elif file_path.lower().endswith(('.mp4', '.avi', '.mov', '.mkv')):
self.play_video(file_path)
elif os.path.exists(file_path) and file_path.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp')):
self.show_image(file_path, duration)
else:
Logger.error(f"Unsupported or missing media: {file_path}")
self.status_label.config(text=f"Missing or unsupported media:\n{file_name}")
# Schedule next media after short delay
self.auto_advance_timer = self.root.after(5000, self.next_media)
def play_video(self, file_path):
"""Play video file"""
# Clear any status text and hide status label
self.status_label.place_forget()
# Check if PIL is available for video playback
if not PIL_AVAILABLE:
Logger.error("PIL not available - cannot play video")
self.status_label.config(text="PIL/Pillow not available\nCannot play video files")
self.status_label.place(relx=0.5, rely=0.5, anchor='center')
self.auto_advance_timer = self.root.after(5000, self.next_media)
return
# Use OpenCV to play video in a separate thread
def video_player():
try:
cap = cv2.VideoCapture(file_path)
if not cap.isOpened():
raise ValueError(f"Cannot open video file: {file_path}")
# Get video properties
fps = cap.get(cv2.CAP_PROP_FPS)
delay = int(1000 / fps) if fps > 0 else 30 # Default to 30 FPS if unknown
# Get video dimensions
video_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
video_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
# Get screen dimensions
screen_width = self.root.winfo_width()
screen_height = self.root.winfo_height()
# Ensure we have valid screen dimensions
if screen_width <= 1 or screen_height <= 1:
screen_width = 1920 # Default fallback
screen_height = 1080
Logger.info(f"Video dimensions: {video_width}x{video_height}, "
f"Screen dimensions: {screen_width}x{screen_height}")
while self.current_index < len(self.playlist) and not self.is_paused and self.running:
ret, frame = cap.read()
if not ret:
break # End of video
# Convert color from BGR to RGB
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
# Convert to PIL Image for better scaling
try:
img = Image.fromarray(frame)
# Scale video frame using the scaling helper
final_img, offset = self.scale_image_to_screen(img, screen_width, screen_height, self.scaling_mode)
# Convert to PhotoImage
photo = ImageTk.PhotoImage(final_img)
# Update UI from main thread
self.root.after_idle(lambda p=photo: self._update_video_frame(p))
except Exception as img_error:
Logger.error(f"Video frame processing error: {img_error}")
break
# Wait for the next frame
time.sleep(delay / 1000)
cap.release()
# Schedule next media from main thread
self.root.after_idle(lambda: setattr(self, 'auto_advance_timer',
self.root.after(1000, self.next_media)))
except Exception as e:
Logger.error(f"Error playing video {file_path}: {e}")
# Show error from main thread
self.root.after_idle(lambda: self._show_video_error(str(e)))
# Start video player thread
threading.Thread(target=video_player, daemon=True).start()
def _update_video_frame(self, photo):
"""Update video frame from main thread"""
try:
self.image_label.config(image=photo)
self.image_label.image = photo # Keep reference
except Exception as e:
Logger.error(f"Error updating video frame: {e}")
def _show_video_error(self, error_msg):
"""Show video error from main thread"""
try:
self.status_label.config(text=f"Video Error:\n{error_msg}")
self.status_label.place(relx=0.5, rely=0.5, anchor='center')
self.auto_advance_timer = self.root.after(5000, self.next_media)
except Exception as e:
Logger.error(f"Error showing video error: {e}")
self.auto_advance_timer = self.root.after(5000, self.next_media)
def show_text_content(self, text, duration):
"""Display text content"""
self.image_label.config(image='')
self.status_label.config(text=text)
# Schedule next media
self.auto_advance_timer = self.root.after(
int(duration * 1000),
self.next_media
)
def show_image(self, file_path, duration):
"""Display an image in full screen, properly fitted to screen size"""
try:
# Hide status label and clear any previous text
self.status_label.place_forget()
self.status_label.config(text="")
if PIL_AVAILABLE:
# Use PIL for better image handling
img = Image.open(file_path)
original_size = img.size
# Get actual screen dimensions
screen_width = self.root.winfo_width()
screen_height = self.root.winfo_height()
# Ensure we have valid screen dimensions
if screen_width <= 1 or screen_height <= 1:
screen_width = 1920 # Default fallback
screen_height = 1080
# Scale image using the scaling helper
final_img, offset = self.scale_image_to_screen(img, screen_width, screen_height, self.scaling_mode)
# Convert to PhotoImage
photo = ImageTk.PhotoImage(final_img)
# Clear previous image and display new one
self.image_label.config(image=photo)
self.image_label.image = photo # Keep reference
Logger.info(f"Successfully displayed image: {os.path.basename(file_path)} "
f"(Original: {original_size}, Screen: {screen_width}x{screen_height}, "
f"Mode: {self.scaling_mode}, Offset: {offset})")
else:
# Fall back to basic text display if PIL not available
self.image_label.config(image='')
self.status_label.config(text=f"IMAGE: {os.path.basename(file_path)}\n\n(Install PIL for image display)")
self.status_label.place(relx=0.5, rely=0.5, anchor='center')
Logger.warning("PIL not available - showing text placeholder for image")
# Schedule next media
self.auto_advance_timer = self.root.after(
int(duration * 1000),
self.next_media
)
except Exception as e:
Logger.error(f"Failed to show image {file_path}: {e}")
self.image_label.config(image='')
self.status_label.config(text=f"Image Error:\n{os.path.basename(file_path)}\n{str(e)}")
self.status_label.place(relx=0.5, rely=0.5, anchor='center')
self.auto_advance_timer = self.root.after(5000, self.next_media)
def next_media(self):
"""Move to next media"""
self.cancel_timers()
if not self.playlist:
return
self.current_index = (self.current_index + 1) % len(self.playlist)
# Check for playlist updates at end of cycle
if self.current_index == 0:
threading.Thread(target=self.check_playlist_updates, daemon=True).start()
self.play_current_media()
def previous_media(self):
"""Move to previous media"""
self.cancel_timers()
if not self.playlist:
return
self.current_index = (self.current_index - 1) % len(self.playlist)
self.play_current_media()
def toggle_play_pause(self):
"""Toggle play/pause state"""
self.is_paused = not self.is_paused
if self.is_paused:
self.play_pause_btn.config(text="▶ Play")
self.cancel_timers()
else:
self.play_pause_btn.config(text="⏸ Pause")
# Resume current media
self.play_current_media()
Logger.info(f"Media {'paused' if self.is_paused else 'resumed'}")
def cancel_timers(self):
"""Cancel all active timers"""
if self.auto_advance_timer:
self.root.after_cancel(self.auto_advance_timer)
self.auto_advance_timer = None
def show_controls(self):
"""Show control panel"""
if self.control_frame:
self.control_frame.place(relx=0.98, rely=0.98, anchor='se')
def hide_controls(self):
"""Hide control panel"""
if self.control_frame:
self.control_frame.place_forget()
def schedule_hide_controls(self):
"""Schedule hiding controls after delay"""
if self.hide_controls_timer:
self.root.after_cancel(self.hide_controls_timer)
self.hide_controls_timer = self.root.after(10000, self.hide_controls)
def on_mouse_click(self, event):
"""Handle mouse clicks"""
self.show_controls()
self.schedule_hide_controls()
def on_mouse_motion(self, event):
"""Handle mouse motion"""
self.show_controls()
self.schedule_hide_controls()
def on_key_press(self, event):
"""Handle keyboard events"""
key = event.keysym.lower()
if key == 'f':
self.toggle_fullscreen()
elif key == 'space':
self.toggle_play_pause()
elif key == 'left':
self.previous_media()
elif key == 'right':
self.next_media()
elif key == 'escape':
self.show_exit_dialog()
elif key == '1':
self.set_scaling_mode('fit')
elif key == '2':
self.set_scaling_mode('fill')
elif key == '3':
self.set_scaling_mode('stretch')
elif event.state & 0x4: # Ctrl key pressed
if key == 's':
self.open_settings()
self.show_controls()
self.schedule_hide_controls()
def set_scaling_mode(self, mode):
"""Change the scaling mode and refresh current media"""
old_mode = self.scaling_mode
self.scaling_mode = mode
Logger.info(f"Scaling mode changed from '{old_mode}' to '{mode}'")
# Show temporary notification
self.status_label.config(text=f"Scaling Mode: {mode.title()}\n"
f"1=Fit 2=Fill 3=Stretch")
self.status_label.place(relx=0.5, rely=0.05, anchor='center')
# Hide notification after 2 seconds
self.root.after(2000, lambda: self.status_label.place_forget())
# Refresh current media with new scaling
if self.playlist and 0 <= self.current_index < len(self.playlist):
self.cancel_timers()
self.play_current_media()
def toggle_fullscreen(self):
"""Toggle fullscreen mode"""
self.is_fullscreen = not self.is_fullscreen
self.root.attributes('-fullscreen', self.is_fullscreen)
def open_settings(self):
"""Open settings window"""
if hasattr(self, 'settings_window') and self.settings_window and self.settings_window.winfo_exists():
self.settings_window.lift()
return
self.settings_window = SettingsWindow(self.root, self)
def show_exit_dialog(self):
"""Show modern password-protected exit dialog"""
try:
config = load_config()
quickconnect_key = config.get('quickconnect_key', '')
except:
quickconnect_key = ''
# Create modern exit dialog
exit_dialog = tk.Toplevel(self.root)
exit_dialog.title("Exit Application")
exit_dialog.geometry("400x200")
exit_dialog.configure(bg='#2d2d2d')
exit_dialog.transient(self.root)
exit_dialog.grab_set()
exit_dialog.resizable(False, False)
# Center the dialog using helper method
self.center_dialog_on_screen(exit_dialog, 400, 200)
# Header with icon
header_frame = tk.Frame(exit_dialog, bg='#cc0000', height=60)
header_frame.pack(fill=tk.X)
header_frame.pack_propagate(False)
icon_label = tk.Label(header_frame, text="", font=('Arial', 20, 'bold'),
fg='white', bg='#cc0000')
icon_label.pack(side=tk.LEFT, padx=15, pady=15)
title_label = tk.Label(header_frame, text="Exit Application",
font=('Arial', 14, 'bold'), fg='white', bg='#cc0000')
title_label.pack(side=tk.LEFT, pady=15)
# Content frame
content_frame = tk.Frame(exit_dialog, bg='#2d2d2d', padx=20, pady=20)
content_frame.pack(fill=tk.BOTH, expand=True)
# Password prompt
prompt_label = tk.Label(content_frame, text="Enter password to exit:",
font=('Arial', 11), fg='white', bg='#2d2d2d')
prompt_label.pack(pady=(0, 10))
# Password entry
password_var = tk.StringVar()
password_entry = tk.Entry(content_frame, textvariable=password_var,
font=('Arial', 11), show='*', width=25,
bg='#404040', fg='white', insertbackground='white',
relief=tk.FLAT, bd=5)
password_entry.pack(pady=(0, 15))
password_entry.focus_set()
# Button frame
button_frame = tk.Frame(content_frame, bg='#2d2d2d')
button_frame.pack(fill=tk.X)
def check_password():
if password_var.get() == quickconnect_key:
exit_dialog.destroy()
self.exit_application()
elif password_var.get(): # Only show error if password was entered
# Show error in red
error_label.config(text="✗ Incorrect password", fg='#ff4444')
password_entry.delete(0, tk.END)
password_entry.focus_set()
def cancel_exit():
exit_dialog.destroy()
# Error label (hidden initially)
error_label = tk.Label(content_frame, text="", font=('Arial', 9),
bg='#2d2d2d')
error_label.pack()
# Buttons
cancel_btn = tk.Button(button_frame, text="Cancel", command=cancel_exit,
bg='#555555', fg='white', font=('Arial', 10, 'bold'),
relief=tk.FLAT, padx=20, pady=8, width=10)
cancel_btn.pack(side=tk.RIGHT, padx=(10, 0))
exit_btn = tk.Button(button_frame, text="Exit", command=check_password,
bg='#cc0000', fg='white', font=('Arial', 10, 'bold'),
relief=tk.FLAT, padx=20, pady=8, width=10)
exit_btn.pack(side=tk.RIGHT)
# Bind Enter key to check password
password_entry.bind('<Return>', lambda e: check_password())
exit_dialog.bind('<Escape>', lambda e: cancel_exit())
def exit_application(self):
"""Exit the application"""
Logger.info("Application exit requested")
self.running = False
self.root.quit()
self.root.destroy()
def center_dialog_on_screen(self, dialog, width, height):
"""Center a dialog window on screen regardless of screen size"""
dialog.update_idletasks() # Ensure geometry is calculated
screen_width = dialog.winfo_screenwidth()
screen_height = dialog.winfo_screenheight()
# Calculate center position
center_x = int((screen_width - width) / 2)
center_y = int((screen_height - height) / 2)
# Ensure the dialog doesn't go off-screen on smaller displays
center_x = max(0, min(center_x, screen_width - width))
center_y = max(0, min(center_y, screen_height - height))
dialog.geometry(f"{width}x{height}+{center_x}+{center_y}")
# Bring to front and focus
dialog.lift()
dialog.focus_force()
return center_x, center_y
def check_playlist_updates(self):
"""Check for playlist updates from server with fallback protection"""
try:
config = load_config()
local_version = config.get('playlist_version', 0)
server_playlist_data = fetch_server_playlist()
server_version = server_playlist_data.get('version', 0)
if server_version > local_version:
Logger.info(f"Updating playlist: {local_version} -> {server_version}")
# Clean old files
local_playlist_data = load_local_playlist()
clean_unused_files(local_playlist_data.get('playlist', []))
# Download new content
download_media_files(
server_playlist_data.get('playlist', []),
server_version
)
# Update local playlist
local_playlist_data = load_local_playlist()
self.playlist = local_playlist_data.get('playlist', [])
# Reset to beginning of playlist
self.current_index = 0
Logger.info("Playlist updated successfully")
# Continue with current media after update
self.play_current_media()
else:
Logger.info("No playlist updates available")
except requests.exceptions.ConnectTimeout:
Logger.warning("Server connection timeout during update check - continuing with current playlist")
except requests.exceptions.ConnectionError:
Logger.warning("Cannot connect to server during update check - continuing with current playlist")
except requests.exceptions.Timeout:
Logger.warning("Server request timeout during update check - continuing with current playlist")
except Exception as e:
Logger.warning(f"Failed to check playlist updates: {e} - continuing with current playlist")
def log_event(self, file_name, event):
"""Log media events"""
try:
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
log_message = f"{timestamp} - {event}: {file_name}\n"
# Update the log file path to the resources directory
log_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'resources', 'log.txt')
with open(log_file, 'a') as f:
f.write(log_message)
except Exception as e:
Logger.error(f"Failed to log event: {e}")
def start_periodic_checks(self):
"""Start periodic playlist checks"""
def check_loop():
"""Background thread for periodic checks"""
while self.running:
try:
time.sleep(300) # Check every 5 minutes
if self.running:
self.check_playlist_updates()
except Exception as e:
Logger.error(f"Error in periodic check: {e}")
# Start background thread
threading.Thread(target=check_loop, daemon=True).start()
def run(self):
"""Start the application"""
Logger.info("Starting Simple Tkinter Media Player")
try:
self.root.mainloop()
except KeyboardInterrupt:
self.exit_application()
except Exception as e:
Logger.error(f"Application error: {e}")
print(f"Error: {e}")
class SettingsWindow:
def __init__(self, parent, app):
self.parent = parent
self.app = app
self.window = tk.Toplevel(parent)
# Initialize virtual keyboard for touch displays
self.virtual_keyboard = VirtualKeyboard(self.window, dark_theme=True)
self.setup_window()
self.create_widgets()
self.load_config()
# Setup touch optimization
self.setup_touch_optimization()
def setup_window(self):
"""Setup settings window with enhanced dark theme"""
self.window.title("🎬 Signage Player Settings")
self.window.geometry("900x700")
# Enhanced dark theme colors
self.colors = {
'bg_primary': '#1e2124', # Very dark background
'bg_secondary': '#2f3136', # Slightly lighter background
'bg_tertiary': '#36393f', # Card backgrounds
'accent': '#7289da', # Discord-like blue accent
'accent_hover': '#677bc4', # Darker accent for hover
'success': '#43b581', # Green for success
'warning': '#faa61a', # Orange for warnings
'danger': '#f04747', # Red for errors
'text_primary': '#ffffff', # White text
'text_secondary': '#b9bbbe', # Gray text
'text_muted': '#72767d', # Muted text
'border': '#202225' # Border color
}
self.window.configure(bg=self.colors['bg_primary'])
self.window.transient(self.parent)
self.window.grab_set()
# Set window properties
self.window.resizable(True, True)
self.window.minsize(700, 500)
# Set window icon if available
try:
# Try to use a simple emoji icon
self.window.iconname("🎬")
except:
pass
# Center the window on screen using helper method
self.center_window_on_screen(self.window, 900, 700)
# Add subtle window border effect
self.window.configure(highlightbackground=self.colors['border'], highlightthickness=1)
def create_widgets(self):
"""Create settings widgets with enhanced dark theme styling"""
# Configure enhanced custom styles
style = ttk.Style()
style.theme_use('clam')
# Enhanced dark theme styles
style.configure('Dark.TNotebook',
background=self.colors['bg_secondary'],
borderwidth=0,
tabmargins=[2, 5, 2, 0])
style.configure('Dark.TNotebook.Tab',
padding=[20, 12],
font=('Segoe UI', 11, 'bold'),
background=self.colors['bg_tertiary'],
foreground=self.colors['text_secondary'],
borderwidth=1,
focuscolor='none')
style.map('Dark.TNotebook.Tab',
background=[('selected', self.colors['accent']),
('active', self.colors['accent_hover'])],
foreground=[('selected', self.colors['text_primary']),
('active', self.colors['text_primary'])])
style.configure('Dark.TFrame', background=self.colors['bg_secondary'])
style.configure('Dark.TLabel',
background=self.colors['bg_secondary'],
foreground=self.colors['text_primary'],
font=('Segoe UI', 10))
style.configure('Dark.TEntry',
fieldbackground=self.colors['bg_tertiary'],
foreground=self.colors['text_primary'],
bordercolor=self.colors['border'],
lightcolor=self.colors['bg_tertiary'],
darkcolor=self.colors['bg_tertiary'],
font=('Segoe UI', 10),
insertcolor=self.colors['text_primary'])
# Main container frame
main_frame = tk.Frame(self.window, bg=self.colors['bg_primary'])
main_frame.pack(fill=tk.BOTH, expand=True, padx=0, pady=0)
# Header section with gradient-like effect
header_frame = tk.Frame(main_frame, bg=self.colors['bg_secondary'], height=80)
header_frame.pack(fill=tk.X, padx=0, pady=0)
header_frame.pack_propagate(False)
# Title with enhanced styling
title_container = tk.Frame(header_frame, bg=self.colors['bg_secondary'])
title_container.pack(expand=True, fill=tk.BOTH)
title_label = tk.Label(title_container,
text="🎬 Digital Signage Control Center",
font=('Segoe UI', 24, 'bold'),
fg=self.colors['text_primary'],
bg=self.colors['bg_secondary'])
title_label.pack(expand=True)
subtitle_label = tk.Label(title_container,
text="Configure your digital signage display settings",
font=('Segoe UI', 11),
fg=self.colors['text_secondary'],
bg=self.colors['bg_secondary'])
subtitle_label.pack()
# Content area
content_frame = tk.Frame(main_frame, bg=self.colors['bg_primary'])
content_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)
# Create enhanced notebook with dark theme
notebook = ttk.Notebook(content_frame, style='Dark.TNotebook')
notebook.pack(fill=tk.BOTH, expand=True, pady=(0, 20))
# Create tabs with enhanced styling
self.create_connection_tab_enhanced(notebook)
self.create_display_tab_enhanced(notebook)
self.create_advanced_tab_enhanced(notebook)
self.create_logs_tab_enhanced(notebook)
self.create_about_tab_enhanced(notebook)
# Enhanced bottom button section
self.create_bottom_controls(content_frame)
# Load initial data
self.load_logs()
def create_connection_tab_enhanced(self, notebook):
"""Create enhanced connection settings tab"""
tab_frame = tk.Frame(notebook, bg=self.colors['bg_secondary'])
notebook.add(tab_frame, text="🌐 Connection")
# Create scrollable content
canvas = tk.Canvas(tab_frame, bg=self.colors['bg_secondary'], highlightthickness=0)
scrollbar = tk.Scrollbar(tab_frame, orient="vertical", command=canvas.yview,
bg=self.colors['bg_tertiary'], troughcolor=self.colors['bg_primary'])
scrollable_frame = tk.Frame(canvas, bg=self.colors['bg_secondary'])
scrollable_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
# Server connection card
server_card = self.create_settings_card(scrollable_frame, "🖥️ Server Connection",
"Configure your signage server connection details")
# Server IP/Domain
self.create_input_field(server_card, "Server IP/Domain:", "server_ip_var",
placeholder="e.g., digi-server.example.com")
# Port
self.create_input_field(server_card, "Port:", "port_var", width=15,
placeholder="8880")
# Device settings card
device_card = self.create_settings_card(scrollable_frame, "📱 Device Settings",
"Identify this display device")
# Screen/Device name
self.create_input_field(device_card, "Device Name:", "screen_name_var",
placeholder="e.g., lobby-display-01")
# QuickConnect key
self.create_input_field(device_card, "QuickConnect Key:", "quickconnect_var",
password=True, placeholder="Enter your access key")
# Connection testing card
test_card = self.create_settings_card(scrollable_frame, "🔗 Connection Test",
"Test your server connection")
test_btn_frame = tk.Frame(test_card, bg=self.colors['bg_tertiary'])
test_btn_frame.pack(fill=tk.X, pady=10)
test_btn = self.create_action_button(test_btn_frame, "🔗 Test Connection",
self.test_connection, self.colors['accent'])
test_btn.pack(side=tk.LEFT, padx=5)
canvas.pack(side="left", fill="both", expand=True, padx=20, pady=20)
scrollbar.pack(side="right", fill="y")
def create_display_tab_enhanced(self, notebook):
"""Create enhanced display settings tab"""
tab_frame = tk.Frame(notebook, bg=self.colors['bg_secondary'])
notebook.add(tab_frame, text="🖼️ Display")
main_container = tk.Frame(tab_frame, bg=self.colors['bg_secondary'])
main_container.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)
# Resolution settings card
resolution_card = self.create_settings_card(main_container, "🖥️ Screen Resolution",
"Configure display resolution settings")
# Resolution input fields
resolution_inputs = tk.Frame(resolution_card, bg=self.colors['bg_tertiary'])
resolution_inputs.pack(fill=tk.X, pady=10)
self.create_input_field(resolution_inputs, "Width (px):", "screen_w_var",
width=15, placeholder="1920")
self.create_input_field(resolution_inputs, "Height (px):", "screen_h_var",
width=15, placeholder="1080")
# Preset buttons with enhanced styling
presets_frame = tk.Frame(resolution_card, bg=self.colors['bg_tertiary'])
presets_frame.pack(fill=tk.X, pady=15)
tk.Label(presets_frame, text="Quick Presets:",
font=('Segoe UI', 11, 'bold'),
bg=self.colors['bg_tertiary'],
fg=self.colors['text_primary']).pack(anchor=tk.W, pady=(0, 10))
preset_buttons = tk.Frame(presets_frame, bg=self.colors['bg_tertiary'])
preset_buttons.pack(anchor=tk.W)
presets = [("📱 HD", "1366", "768"), ("💻 Full HD", "1920", "1080"),
("🖥️ 4K", "3840", "2160"), ("📺 Classic", "1024", "768")]
for preset_name, width, height in presets:
btn = self.create_preset_button(preset_buttons, preset_name, width, height)
btn.pack(side=tk.LEFT, padx=3, pady=2)
# Scaling mode settings card
scaling_card = self.create_settings_card(main_container, "📐 Image/Video Scaling",
"Configure how images and videos are displayed on screen")
# Scaling mode options
self.scaling_mode_var = tk.StringVar(value=self.app.scaling_mode if hasattr(self.app, 'scaling_mode') else 'fit')
scaling_label = tk.Label(scaling_card, text="Scaling Mode:",
font=('Segoe UI', 12, 'bold'),
bg=self.colors['bg_tertiary'],
fg=self.colors['text_primary'])
scaling_label.pack(anchor=tk.W, pady=(0, 10))
scaling_options = tk.Frame(scaling_card, bg=self.colors['bg_tertiary'])
scaling_options.pack(fill=tk.X, pady=5)
# Radio buttons for scaling modes
modes = [
("fit", "🖼️ Fit", "Maintain aspect ratio, add black bars if needed"),
("fill", "🔍 Fill", "Maintain aspect ratio, crop to fill entire screen"),
("stretch", "↔️ Stretch", "Ignore aspect ratio, stretch to fill screen")
]
for mode_value, mode_label, mode_desc in modes:
mode_frame = tk.Frame(scaling_options, bg=self.colors['bg_tertiary'])
mode_frame.pack(fill=tk.X, pady=2)
radio_btn = tk.Radiobutton(mode_frame,
text=mode_label,
variable=self.scaling_mode_var,
value=mode_value,
bg=self.colors['bg_tertiary'],
fg=self.colors['text_primary'],
font=('Segoe UI', 11, 'bold'),
selectcolor=self.colors['bg_primary'],
activebackground=self.colors['bg_tertiary'],
activeforeground=self.colors['text_primary'],
command=lambda: self.update_scaling_mode())
radio_btn.pack(side=tk.LEFT, anchor=tk.W)
desc_label = tk.Label(mode_frame, text=f" - {mode_desc}",
font=('Segoe UI', 9),
bg=self.colors['bg_tertiary'],
fg=self.colors['text_secondary'])
desc_label.pack(side=tk.LEFT, anchor=tk.W, padx=(10, 0))
# Keyboard shortcuts info
shortcuts_frame = tk.Frame(scaling_card, bg=self.colors['bg_tertiary'])
shortcuts_frame.pack(fill=tk.X, pady=(15, 0))
shortcuts_label = tk.Label(shortcuts_frame,
text="💡 Tip: Use keyboard shortcuts 1, 2, 3 to quickly change scaling mode during playback",
font=('Segoe UI', 9, 'italic'),
bg=self.colors['bg_tertiary'],
fg=self.colors['text_muted'],
wraplength=400)
shortcuts_label.pack(anchor=tk.W)
def update_scaling_mode(self):
"""Update the scaling mode in the main app"""
new_mode = self.scaling_mode_var.get()
if hasattr(self.app, 'set_scaling_mode'):
self.app.set_scaling_mode(new_mode)
else:
self.app.scaling_mode = new_mode
def create_advanced_tab_enhanced(self, notebook):
"""Create enhanced advanced settings tab"""
tab_frame = tk.Frame(notebook, bg=self.colors['bg_secondary'])
notebook.add(tab_frame, text="⚙️ Advanced")
main_container = tk.Frame(tab_frame, bg=self.colors['bg_secondary'])
main_container.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)
# Sync settings card
sync_card = self.create_settings_card(main_container, "🔄 Synchronization",
"Configure automatic content updates")
self.create_input_field(sync_card, "Auto-refresh (minutes):", "refresh_interval_var",
width=15, placeholder="15")
# Performance settings card
perf_card = self.create_settings_card(main_container, "⚡ Performance",
"Optimize playback performance")
self.hardware_accel_var = tk.BooleanVar(value=True)
self.create_checkbox(perf_card, "Enable hardware acceleration", self.hardware_accel_var)
self.cache_media_var = tk.BooleanVar(value=True)
self.create_checkbox(perf_card, "Cache media files locally", self.cache_media_var)
self.auto_retry_var = tk.BooleanVar(value=True)
self.create_checkbox(perf_card, "Auto-retry failed downloads", self.auto_retry_var)
def create_logs_tab_enhanced(self, notebook):
"""Create enhanced logs tab"""
tab_frame = tk.Frame(notebook, bg=self.colors['bg_secondary'])
notebook.add(tab_frame, text="📋 Logs")
main_container = tk.Frame(tab_frame, bg=self.colors['bg_secondary'])
main_container.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)
# Log header
log_header = tk.Frame(main_container, bg=self.colors['bg_secondary'])
log_header.pack(fill=tk.X, pady=(0, 15))
tk.Label(log_header, text="📋 Application Logs",
font=('Segoe UI', 16, 'bold'),
bg=self.colors['bg_secondary'],
fg=self.colors['text_primary']).pack(side=tk.LEFT)
refresh_btn = self.create_action_button(log_header, "🔄 Refresh Logs",
self.load_logs, self.colors['accent'])
refresh_btn.pack(side=tk.RIGHT)
# Log display area
log_container = tk.Frame(main_container, bg=self.colors['bg_tertiary'],
relief=tk.FLAT, bd=2)
log_container.pack(fill=tk.BOTH, expand=True)
self.log_text = tk.Text(log_container,
font=('Consolas', 9),
bg=self.colors['bg_primary'],
fg=self.colors['text_primary'],
insertbackground=self.colors['accent'],
selectbackground=self.colors['accent'],
selectforeground=self.colors['text_primary'],
relief=tk.FLAT,
bd=10,
wrap=tk.WORD)
log_scrollbar = tk.Scrollbar(log_container, command=self.log_text.yview,
bg=self.colors['bg_tertiary'])
self.log_text.configure(yscrollcommand=log_scrollbar.set)
self.log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
log_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
def create_about_tab_enhanced(self, notebook):
"""Create enhanced about tab"""
tab_frame = tk.Frame(notebook, bg=self.colors['bg_secondary'])
notebook.add(tab_frame, text=" About")
main_container = tk.Frame(tab_frame, bg=self.colors['bg_secondary'])
main_container.pack(fill=tk.BOTH, expand=True, padx=40, pady=40)
# App branding section
branding_frame = tk.Frame(main_container, bg=self.colors['bg_secondary'])
branding_frame.pack(fill=tk.X, pady=(0, 40))
# Large app icon
icon_label = tk.Label(branding_frame, text="🎬",
font=('Segoe UI Emoji', 64),
bg=self.colors['bg_secondary'],
fg=self.colors['accent'])
icon_label.pack()
# App title and version
title_label = tk.Label(branding_frame, text="Digital Signage Player",
font=('Segoe UI', 24, 'bold'),
bg=self.colors['bg_secondary'],
fg=self.colors['text_primary'])
title_label.pack(pady=(10, 5))
version_label = tk.Label(branding_frame, text="Version 2.1 - Enhanced Dark Edition",
font=('Segoe UI', 12),
bg=self.colors['bg_secondary'],
fg=self.colors['text_secondary'])
version_label.pack()
# Feature highlights
features_text = (
"🚀 Features:\n"
"• Modern dark theme interface\n"
"• High-resolution media display\n"
"• Remote playlist management\n"
"• Real-time content synchronization\n"
"• Hardware acceleration support\n"
"• Cross-platform compatibility\n"
"• Advanced logging and diagnostics\n\n"
"⌨️ Keyboard Shortcuts:\n"
"F11 - Toggle fullscreen mode\n"
"Space - Play/Pause media\n"
"← → - Navigate between media\n"
"1 - Set scaling mode to Fit (maintain aspect ratio with black bars)\n"
"2 - Set scaling mode to Fill (maintain aspect ratio, crop to fill screen)\n"
"3 - Set scaling mode to Stretch (ignore aspect ratio, stretch to fill)\n"
"Ctrl+S - Open settings panel\n"
"Escape - Exit application (password protected)\n\n"
"Built with ❤️ using Python & Tkinter"
)
features_label = tk.Label(main_container, text=features_text,
justify=tk.LEFT,
font=('Segoe UI', 11),
bg=self.colors['bg_secondary'],
fg=self.colors['text_primary'],
wraplength=600)
features_label.pack(anchor=tk.W)
def create_bottom_controls(self, parent):
"""Create enhanced bottom control buttons"""
controls_frame = tk.Frame(parent, bg=self.colors['bg_secondary'],
relief=tk.FLAT, bd=1)
controls_frame.pack(fill=tk.X, pady=(10, 0))
# Left side buttons
left_buttons = tk.Frame(controls_frame, bg=self.colors['bg_secondary'])
left_buttons.pack(side=tk.LEFT, padx=10, pady=15)
save_btn = self.create_action_button(left_buttons, "💾 Save Configuration",
self.save_config, self.colors['success'])
save_btn.pack(side=tk.LEFT, padx=5)
test_btn = self.create_action_button(left_buttons, "🔗 Test Connection",
self.test_connection, self.colors['accent'])
test_btn.pack(side=tk.LEFT, padx=5)
refresh_btn = self.create_action_button(left_buttons, "🔄 Refresh Playlist",
self.force_playlist_refresh, self.colors['warning'])
refresh_btn.pack(side=tk.LEFT, padx=5)
# Right side buttons
right_buttons = tk.Frame(controls_frame, bg=self.colors['bg_secondary'])
right_buttons.pack(side=tk.RIGHT, padx=10, pady=15)
cancel_btn = self.create_action_button(right_buttons, "❌ Cancel",
self.window.destroy, self.colors['danger'])
cancel_btn.pack(side=tk.RIGHT, padx=5)
# Status display
self.status_frame = tk.Frame(parent, bg=self.colors['bg_primary'])
self.status_frame.pack(fill=tk.X, pady=(10, 0))
self.connection_status = tk.Label(self.status_frame,
text="Ready to configure your digital signage settings",
bg=self.colors['bg_primary'],
fg=self.colors['text_secondary'],
font=('Segoe UI', 9))
self.connection_status.pack(anchor=tk.W, padx=10, pady=5)
def create_settings_card(self, parent, title, description):
"""Create a settings card with enhanced styling"""
card_frame = tk.Frame(parent, bg=self.colors['bg_tertiary'],
relief=tk.FLAT, bd=2)
card_frame.pack(fill=tk.X, pady=(0, 20), padx=5)
# Card header
header_frame = tk.Frame(card_frame, bg=self.colors['bg_tertiary'])
header_frame.pack(fill=tk.X, padx=20, pady=(15, 5))
title_label = tk.Label(header_frame, text=title,
font=('Segoe UI', 14, 'bold'),
bg=self.colors['bg_tertiary'],
fg=self.colors['text_primary'])
title_label.pack(anchor=tk.W)
desc_label = tk.Label(header_frame, text=description,
font=('Segoe UI', 9),
bg=self.colors['bg_tertiary'],
fg=self.colors['text_secondary'])
desc_label.pack(anchor=tk.W, pady=(2, 0))
# Card content area
content_frame = tk.Frame(card_frame, bg=self.colors['bg_tertiary'])
content_frame.pack(fill=tk.X, padx=20, pady=(10, 15))
return content_frame
def create_input_field(self, parent, label_text, var_name, width=35, placeholder="", password=False):
"""Create a touch-optimized input field with virtual keyboard support"""
field_frame = tk.Frame(parent, bg=self.colors['bg_tertiary'])
field_frame.pack(fill=tk.X, pady=12) # Increased padding for touch
label = tk.Label(field_frame, text=label_text,
font=('Segoe UI', 12, 'bold'), # Larger font for touch
bg=self.colors['bg_tertiary'],
fg=self.colors['text_primary'],
width=18, anchor='w')
label.pack(side=tk.LEFT)
# Create StringVar if it doesn't exist
if not hasattr(self, var_name):
setattr(self, var_name, tk.StringVar())
var = getattr(self, var_name)
# Use touch-optimized entry with virtual keyboard
entry = TouchOptimizedEntry(field_frame,
virtual_keyboard=self.virtual_keyboard,
textvariable=var,
width=width,
font=('Segoe UI', 12), # Larger font
bg=self.colors['bg_primary'],
fg=self.colors['text_primary'],
insertbackground=self.colors['accent'],
relief=tk.FLAT,
bd=10) # Larger border for easier touch
if password:
entry.configure(show='*')
entry.pack(side=tk.LEFT, padx=(15, 0), pady=5)
# Add placeholder text effect
if placeholder:
self.add_placeholder(entry, placeholder)
return entry
def create_checkbox(self, parent, text, variable):
"""Create a checkbox with dark theme styling"""
cb_frame = tk.Frame(parent, bg=self.colors['bg_tertiary'])
cb_frame.pack(fill=tk.X, pady=5)
checkbox = tk.Checkbutton(cb_frame, text=text, variable=variable,
bg=self.colors['bg_tertiary'],
fg=self.colors['text_primary'],
font=('Segoe UI', 10),
selectcolor=self.colors['bg_primary'],
activebackground=self.colors['bg_tertiary'],
activeforeground=self.colors['text_primary'])
checkbox.pack(anchor=tk.W, padx=5)
def create_action_button(self, parent, text, command, color):
"""Create a touch-optimized action button with enhanced styling"""
button = TouchOptimizedButton(parent,
text=text,
command=command,
bg=color,
fg=self.colors['text_primary'],
font=('Segoe UI', 12, 'bold'), # Larger font
relief=tk.FLAT,
padx=25, # Larger padding for touch
pady=15, # Larger padding for touch
cursor='hand2',
bd=3)
# Add enhanced hover effects for touch feedback
def on_enter(e):
button.configure(bg=self.lighten_color(color), relief=tk.RAISED)
def on_leave(e):
button.configure(bg=color, relief=tk.FLAT)
def on_press(e):
button.configure(relief=tk.SUNKEN)
def on_release(e):
button.configure(relief=tk.FLAT)
button.bind("<Enter>", on_enter)
button.bind("<Leave>", on_leave)
button.bind("<Button-1>", on_press)
button.bind("<ButtonRelease-1>", on_release)
return button
def create_preset_button(self, parent, text, width, height):
"""Create a touch-optimized preset resolution button"""
button = TouchOptimizedButton(parent,
text=text,
command=lambda: self.set_resolution_preset(width, height),
bg=self.colors['bg_primary'],
fg=self.colors['text_primary'],
font=('Segoe UI', 10, 'bold'),
relief=tk.FLAT,
padx=20, # Larger for touch
pady=10, # Larger for touch
cursor='hand2')
def on_enter(e):
button.configure(bg=self.colors['accent'], relief=tk.RAISED)
def on_leave(e):
button.configure(bg=self.colors['bg_primary'], relief=tk.FLAT)
def on_press(e):
button.configure(relief=tk.SUNKEN)
def on_release(e):
button.configure(relief=tk.FLAT)
button.bind("<Enter>", on_enter)
button.bind("<Leave>", on_leave)
button.bind("<Button-1>", on_press)
button.bind("<ButtonRelease-1>", on_release)
return button
def add_placeholder(self, entry, placeholder_text):
"""Add placeholder text to entry widget"""
entry.insert(0, placeholder_text)
entry.configure(fg=self.colors['text_muted'])
def on_focus_in(event):
if entry.get() == placeholder_text:
entry.delete(0, tk.END)
entry.configure(fg=self.colors['text_primary'])
def on_focus_out(event):
if not entry.get():
entry.insert(0, placeholder_text)
entry.configure(fg=self.colors['text_muted'])
entry.bind('<FocusIn>', on_focus_in)
entry.bind('<FocusOut>', on_focus_out)
def lighten_color(self, color):
"""Lighten a hex color for hover effects"""
color = color.lstrip('#')
rgb = tuple(int(color[i:i+2], 16) for i in (0, 2, 4))
lighter_rgb = tuple(min(255, int(c * 1.2)) for c in rgb)
return f"#{lighter_rgb[0]:02x}{lighter_rgb[1]:02x}{lighter_rgb[2]:02x}"
def center_window_on_screen(self, window, width, height):
"""Center a window on screen regardless of screen size"""
window.update_idletasks() # Ensure geometry is calculated
screen_width = window.winfo_screenwidth()
screen_height = window.winfo_screenheight()
# Calculate center position
center_x = int((screen_width - width) / 2)
center_y = int((screen_height - height) / 2)
# Ensure the window doesn't go off-screen on smaller displays
center_x = max(0, min(center_x, screen_width - width))
center_y = max(0, min(center_y, screen_height - height))
window.geometry(f"{width}x{height}+{center_x}+{center_y}")
# Bring to front and focus
window.lift()
window.focus_force()
return center_x, center_y
def setup_touch_optimization(self):
"""Setup touch-friendly optimizations"""
# Hide virtual keyboard when clicking outside input fields
def hide_keyboard_on_click(event):
# Check if click is not on an entry widget
if not isinstance(event.widget, (tk.Entry, TouchOptimizedEntry)):
self.virtual_keyboard.hide_keyboard()
self.window.bind("<Button-1>", hide_keyboard_on_click, "+")
# Make window touch-friendly by increasing minimum size
self.window.minsize(800, 600)
# Override window close to hide keyboard first
original_destroy = self.window.destroy
def enhanced_destroy():
self.virtual_keyboard.hide_keyboard()
original_destroy()
self.window.destroy = enhanced_destroy
self.window.protocol("WM_DELETE_WINDOW", enhanced_destroy)
def set_resolution_preset(self, width, height):
"""Set resolution to preset values with visual feedback"""
self.screen_w_var.set(width)
self.screen_h_var.set(height)
self.connection_status.configure(
text=f"✅ Resolution preset applied: {width}x{height}",
fg=self.colors['success']
)
# Reset status color after 3 seconds
self.window.after(3000, lambda: self.connection_status.configure(
fg=self.colors['text_secondary']
))
def load_config(self):
"""Load current configuration with enhanced feedback"""
try:
Logger.info("Loading configuration in enhanced settings window")
config = load_config()
Logger.info(f"Config loaded: {config}")
# Set values for connection settings
if hasattr(self, 'screen_name_var'):
self.screen_name_var.set(config.get('screen_name', ''))
if hasattr(self, 'server_ip_var'):
self.server_ip_var.set(config.get('server_ip', ''))
if hasattr(self, 'port_var'):
self.port_var.set(config.get('port', '8880'))
if hasattr(self, 'quickconnect_var'):
self.quickconnect_var.set(config.get('quickconnect_key', ''))
# Set values for display settings
if hasattr(self, 'screen_w_var'):
self.screen_w_var.set(config.get('screen_w', '1920'))
if hasattr(self, 'screen_h_var'):
self.screen_h_var.set(config.get('screen_h', '1080'))
if hasattr(self, 'scaling_mode_var'):
self.scaling_mode_var.set(config.get('scaling_mode', 'fit'))
# Set values for advanced settings
if hasattr(self, 'refresh_interval_var'):
self.refresh_interval_var.set(config.get('refresh_interval', '15'))
if hasattr(self, 'hardware_accel_var'):
self.hardware_accel_var.set(config.get('hardware_acceleration', True))
if hasattr(self, 'cache_media_var'):
self.cache_media_var.set(config.get('cache_media', True))
if hasattr(self, 'auto_retry_var'):
self.auto_retry_var.set(config.get('auto_retry', True))
# Update status with success message
if hasattr(self, 'connection_status'):
self.connection_status.configure(
text="✅ Configuration loaded successfully",
fg=self.colors['success']
)
# Reset status color after 3 seconds
self.window.after(3000, lambda: self.connection_status.configure(
fg=self.colors['text_secondary']
))
Logger.info("Configuration values loaded successfully in enhanced settings")
except Exception as e:
Logger.error(f"Failed to load config in enhanced settings: {e}")
# Set default values if loading fails
if hasattr(self, 'screen_name_var'):
self.screen_name_var.set('')
if hasattr(self, 'server_ip_var'):
self.server_ip_var.set('')
if hasattr(self, 'port_var'):
self.port_var.set('8880')
if hasattr(self, 'quickconnect_var'):
self.quickconnect_var.set('')
if hasattr(self, 'screen_w_var'):
self.screen_w_var.set('1920')
if hasattr(self, 'screen_h_var'):
self.screen_h_var.set('1080')
# Set advanced defaults
if hasattr(self, 'refresh_interval_var'):
self.refresh_interval_var.set('15')
if hasattr(self, 'hardware_accel_var'):
self.hardware_accel_var.set(True)
if hasattr(self, 'cache_media_var'):
self.cache_media_var.set(True)
if hasattr(self, 'auto_retry_var'):
self.auto_retry_var.set(True)
# Show error message with enhanced styling
if hasattr(self, 'connection_status'):
self.connection_status.configure(
text=f"⚠️ Warning: Could not load existing config: {str(e)[:50]}...",
fg=self.colors['warning']
)
def save_config(self):
"""Save configuration with enhanced feedback"""
try:
# Load existing config or create new one
try:
config = load_config()
except:
config = {}
# Update with new values from the enhanced interface
if hasattr(self, 'screen_name_var'):
config['screen_name'] = self.screen_name_var.get()
if hasattr(self, 'server_ip_var'):
config['server_ip'] = self.server_ip_var.get()
if hasattr(self, 'port_var'):
config['port'] = self.port_var.get()
if hasattr(self, 'quickconnect_var'):
config['quickconnect_key'] = self.quickconnect_var.get()
if hasattr(self, 'screen_w_var'):
config['screen_w'] = self.screen_w_var.get()
if hasattr(self, 'screen_h_var'):
config['screen_h'] = self.screen_h_var.get()
# Save advanced settings if they exist
if hasattr(self, 'refresh_interval_var'):
config['refresh_interval'] = self.refresh_interval_var.get()
if hasattr(self, 'hardware_accel_var'):
config['hardware_acceleration'] = self.hardware_accel_var.get()
if hasattr(self, 'cache_media_var'):
config['cache_media'] = self.cache_media_var.get()
if hasattr(self, 'auto_retry_var'):
config['auto_retry'] = self.auto_retry_var.get()
# Save display settings
if hasattr(self, 'scaling_mode_var'):
config['scaling_mode'] = self.scaling_mode_var.get()
# Also update the main app's scaling mode
if hasattr(self.app, 'scaling_mode'):
self.app.scaling_mode = self.scaling_mode_var.get()
# Ensure directory exists
config_dir = os.path.dirname(CONFIG_FILE)
os.makedirs(config_dir, exist_ok=True)
with open(CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=4)
Logger.info(f"Enhanced configuration saved to {CONFIG_FILE}")
# Show enhanced success message
self.show_enhanced_success_message("Configuration saved successfully!")
# Ask if user wants to refresh playlist now
if messagebox.askyesno("Refresh Playlist",
"Configuration saved! Would you like to refresh the playlist from server now?",
icon='question'):
self.force_playlist_refresh()
self.window.destroy()
except Exception as e:
Logger.error(f"Failed to save enhanced configuration: {e}")
self.show_enhanced_error_message(f"Failed to save configuration: {e}")
def show_enhanced_success_message(self, message):
"""Show an enhanced success message with dark theme"""
success_window = tk.Toplevel(self.window)
success_window.title("Success")
success_window.geometry("400x150")
success_window.configure(bg=self.colors['bg_primary'])
success_window.transient(self.window)
success_window.grab_set()
success_window.resizable(False, False)
# Center the window using helper method
self.center_window_on_screen(success_window, 400, 150)
# Main frame
main_frame = tk.Frame(success_window, bg=self.colors['bg_primary'], padx=30, pady=20)
main_frame.pack(fill=tk.BOTH, expand=True)
# Success icon
icon_label = tk.Label(main_frame, text="", font=('Segoe UI Emoji', 32),
fg=self.colors['success'], bg=self.colors['bg_primary'])
icon_label.pack(pady=(0, 10))
# Success message
msg_label = tk.Label(main_frame, text=message, font=('Segoe UI', 12, 'bold'),
fg=self.colors['text_primary'], bg=self.colors['bg_primary'],
wraplength=340, justify=tk.CENTER)
msg_label.pack(pady=(0, 20))
# OK button
ok_btn = self.create_action_button(main_frame, "OK", success_window.destroy,
self.colors['success'])
ok_btn.pack()
# Auto-close after 3 seconds
success_window.after(3000, success_window.destroy)
def show_enhanced_error_message(self, message):
"""Show an enhanced error message with dark theme"""
error_window = tk.Toplevel(self.window)
error_window.title("Error")
error_window.geometry("400x150")
error_window.configure(bg=self.colors['bg_primary'])
error_window.transient(self.window)
error_window.grab_set()
error_window.resizable(False, False)
# Center the window using helper method
self.center_window_on_screen(error_window, 400, 150)
# Main frame
main_frame = tk.Frame(error_window, bg=self.colors['bg_primary'], padx=30, pady=20)
main_frame.pack(fill=tk.BOTH, expand=True)
# Error icon
icon_label = tk.Label(main_frame, text="", font=('Segoe UI Emoji', 32),
fg=self.colors['danger'], bg=self.colors['bg_primary'])
icon_label.pack(pady=(0, 10))
# Error message
msg_label = tk.Label(main_frame, text=message, font=('Segoe UI', 11),
fg=self.colors['text_primary'], bg=self.colors['bg_primary'],
wraplength=340, justify=tk.CENTER)
msg_label.pack(pady=(0, 20))
# OK button
ok_btn = self.create_action_button(main_frame, "OK", error_window.destroy,
self.colors['danger'])
ok_btn.pack()
def show_success_message(self, message):
"""Show a modern success message"""
success_window = tk.Toplevel(self.window)
success_window.title("Success")
success_window.geometry("300x120")
success_window.configure(bg='#2d5a2d')
success_window.transient(self.window)
success_window.grab_set()
# Center the window using helper method
self.center_window_on_screen(success_window, 300, 120)
# Success icon and message
icon_label = tk.Label(success_window, text="", font=('Arial', 24, 'bold'),
fg='white', bg='#2d5a2d')
icon_label.pack(pady=10)
msg_label = tk.Label(success_window, text=message, font=('Arial', 10),
fg='white', bg='#2d5a2d', wraplength=250)
msg_label.pack(pady=5)
ok_btn = tk.Button(success_window, text="OK", command=success_window.destroy,
bg='#4d7d4d', fg='white', font=('Arial', 10, 'bold'),
relief=tk.FLAT, padx=20, pady=5)
ok_btn.pack(pady=10)
def force_playlist_refresh(self):
"""Force refresh of playlist from server"""
try:
# Show connection message
self.connection_status.configure(text="Refreshing playlist from server...")
self.window.update()
# Fetch server playlist
server_playlist_data = fetch_server_playlist()
server_playlist = server_playlist_data.get('playlist', [])
server_version = server_playlist_data.get('version', 0)
if server_playlist:
# Clean old files
local_playlist_data = load_local_playlist()
clean_unused_files(local_playlist_data.get('playlist', []))
# Download new content
download_media_files(server_playlist, server_version)
update_config_playlist_version(server_version)
# Let the app know to reload
self.app.playlist = load_local_playlist().get('playlist', [])
self.app.current_index = 0 # Reset to beginning
self.connection_status.configure(text=f"Playlist refreshed! Downloaded {len(server_playlist)} media files.")
# Force the app to play the current media
if self.app.playlist:
self.app.play_current_media()
else:
self.connection_status.configure(text="Server returned empty playlist. Check server connection settings.")
except Exception as e:
self.connection_status.configure(text=f"Error refreshing playlist: {str(e)[:50]}...")
Logger.error(f"Failed to refresh playlist: {e}")
def test_connection(self):
"""Test server connection"""
try:
self.connection_status.configure(text="Testing connection...")
self.window.update()
server_ip = self.server_ip_var.get()
port = self.port_var.get()
screen_name = self.screen_name_var.get()
quickconnect = self.quickconnect_var.get()
if not all([server_ip, port, screen_name, quickconnect]):
self.connection_status.configure(text="Please fill all connection fields")
return
url = f"http://{server_ip}:{port}/api/playlists"
params = {
'hostname': screen_name,
'quickconnect_code': quickconnect
}
response = requests.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
version = data.get('playlist_version', 'Unknown')
num_items = len(data.get('playlist', []))
self.connection_status.configure(
text=f"✓ Connected! Server playlist version: {version}, {num_items} media items available"
)
else:
self.connection_status.configure(
text=f"✗ Connection failed (Status: {response.status_code})"
)
except Exception as e:
self.connection_status.configure(text=f"✗ Connection error: {str(e)[:50]}...")
def load_logs(self):
"""Load recent log entries"""
try:
# Update to use the resources directory
log_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'resources', 'log.txt')
if os.path.exists(log_file):
with open(log_file, 'r') as f:
lines = f.readlines()
# Show last 20 lines
recent_lines = lines[-20:] if len(lines) > 20 else lines
self.log_text.delete(1.0, tk.END)
self.log_text.insert(tk.END, ''.join(recent_lines))
self.log_text.see(tk.END)
else:
self.log_text.delete(1.0, tk.END)
self.log_text.insert(tk.END, "No log file found")
except Exception as e:
self.log_text.delete(1.0, tk.END)
self.log_text.insert(tk.END, f"Error loading logs: {e}")
def load_playlist_view(self):
"""Load playlist items into treeview"""
try:
# Clear existing items
for item in self.playlist_view.get_children():
self.playlist_view.delete(item)
except Exception as e:
Logger.error(f"Failed to load playlist view: {e}")
messagebox.showerror("Error", f"Failed to load playlist: {e}")
def show_video_placeholder(self, filename, duration):
"""Show placeholder for video files"""
self.image_label.config(image='')
self.status_label.config(text="") # Clear any status text for cleaner display
# Schedule next media
self.auto_advance_timer = self.root.after(
int(duration * 1000),
self.next_media
)