#!/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 vlc # For video playback with hardware acceleration # 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) # VLC will be used for video/audio playback (no pygame needed) 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('', self.on_key_press) self.root.bind('', self.on_mouse_click) self.root.bind('', 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("", on_press) button.bind("", on_release) button.bind("", on_enter) button.bind("", 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 using system VLC as a subprocess for robust hardware acceleration and stability.""" self.status_label.place_forget() def run_vlc_subprocess(): try: Logger.info(f"Starting system VLC subprocess for video: {file_path}") # Build VLC command vlc_cmd = [ 'cvlc', '--fullscreen', '--no-osd', '--no-video-title-show', '--play-and-exit', '--quiet', file_path ] proc = subprocess.Popen(vlc_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) proc.wait() Logger.info(f"VLC subprocess finished: {file_path}") except Exception as e: Logger.error(f"VLC subprocess error: {e}") finally: self.root.after_idle(lambda: setattr(self, 'auto_advance_timer', self.root.after(1000, self.next_media))) threading.Thread(target=run_vlc_subprocess, 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 # Pause media playback when opening settings if not self.is_paused: self.toggle_play_pause() self.settings_window = SettingsWindow(self.root, self) # Add a callback to resume playback when the settings window is closed def on_settings_close(): if self.is_paused: self.toggle_play_pause() self.settings_window.protocol("WM_DELETE_WINDOW", on_settings_close) 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('', lambda e: check_password()) exit_dialog.bind('', 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("", 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.settings_window = SettingsWindow(self.root, self, dark_theme=True) 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("", on_enter) button.bind("", on_leave) button.bind("", on_press) button.bind("", 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("", on_enter) button.bind("", on_leave) button.bind("", on_press) button.bind("", 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('', on_focus_in) entry.bind('', 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("", 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 )