From 65843c255a7632abe90fe9da176ebd770aad6594 Mon Sep 17 00:00:00 2001 From: scheianu Date: Wed, 6 Aug 2025 02:26:12 +0300 Subject: [PATCH] feat: Full-screen scaling system with three display modes - Implemented comprehensive image/video scaling system - Added fit, fill, and stretch scaling modes - Keyboard shortcuts 1,2,3 for real-time mode switching - Enhanced PIL/Pillow integration for image processing - OpenCV video playback with full-screen scaling - Settings integration for scaling preferences - Fixed touch feedback for control buttons - Thread-safe video frame processing - Perfect aspect ratio calculations for any resolution --- tkinter_app/resources/app_config.txt | 6 +- tkinter_app/src/tkinter_simple_player.py | 1335 +++++++++++++++++++--- tkinter_app/src/virtual_keyboard.py | 360 ++++++ 3 files changed, 1521 insertions(+), 180 deletions(-) create mode 100644 tkinter_app/src/virtual_keyboard.py diff --git a/tkinter_app/resources/app_config.txt b/tkinter_app/resources/app_config.txt index 3c65e24..6814d33 100644 --- a/tkinter_app/resources/app_config.txt +++ b/tkinter_app/resources/app_config.txt @@ -1,9 +1,9 @@ { "screen_orientation": "Landscape", - "screen_name": "tv-holba1", + "screen_name": "tv-terasa", "quickconnect_key": "8887779", - "server_ip": "192.168.1.245", - "port": "5000", + "server_ip": "digi-signage.moto-adv.com", + "port": "8880", "screen_w": "1920", "screen_h": "1080", "playlist_version": 5 diff --git a/tkinter_app/src/tkinter_simple_player.py b/tkinter_app/src/tkinter_simple_player.py index 3659555..b43a3dc 100644 --- a/tkinter_app/src/tkinter_simple_player.py +++ b/tkinter_app/src/tkinter_simple_player.py @@ -6,6 +6,7 @@ Features: - Basic playlist management - Settings configuration - Auto-hiding controls +- Touch display optimization with virtual keyboard """ import tkinter as tk @@ -39,6 +40,9 @@ from python_functions import ( ) 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') @@ -66,6 +70,9 @@ class SimpleMediaPlayerApp: # 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() @@ -91,8 +98,11 @@ class SimpleMediaPlayerApp: 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) @@ -129,84 +139,190 @@ class SimpleMediaPlayerApp: self.schedule_hide_controls() def create_control_panel(self): - """Create the control panel with navigation buttons""" - # Create a semi-transparent frame with rounded corners look + """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=1, + bd=2, relief=tk.RAISED, - padx=10, - pady=10 + padx=15, + pady=15 ) self.control_frame.place(relx=0.98, rely=0.98, anchor='se') - # Modern button style with consistent sizes + # Touch-optimized button configuration button_config = { - 'bg': '#333333', # Dark gray background - 'fg': 'white', # White text - 'activebackground': '#555555', # Lighter gray when clicked + 'bg': '#333333', + 'fg': 'white', + 'activebackground': '#555555', 'activeforeground': 'white', - 'relief': tk.FLAT, # Flat buttons for modern look + 'relief': tk.FLAT, 'borderwidth': 0, - 'width': 8, - 'height': 2, - 'font': ('Arial', 9, 'bold') + 'width': 10, # Larger for touch + 'height': 3, # Larger for touch + 'font': ('Segoe UI', 10, 'bold'), # Larger font + 'cursor': 'hand2' } - # Create a horizontal layout for buttons # Previous button self.prev_btn = tk.Button( self.control_frame, - text="◀ Prev", + text="⏮ Prev", command=self.previous_media, **button_config ) - self.prev_btn.grid(row=0, column=0, padx=3) + 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, - **button_config + 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=3) + self.play_pause_btn.grid(row=0, column=1, padx=5) # Next button self.next_btn = tk.Button( self.control_frame, - text="Next ▶", + text="Next ⏭", command=self.next_media, **button_config ) - self.next_btn.grid(row=0, column=2, padx=3) + self.next_btn.grid(row=0, column=2, padx=5) # Settings button self.settings_btn = tk.Button( self.control_frame, - text="⚙ Settings", + text="⚙️ Settings", command=self.open_settings, - **button_config + 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=3) + self.settings_btn.grid(row=0, column=3, padx=5) - # Exit button - special styling + # Exit button with special styling self.exit_btn = tk.Button( self.control_frame, - text="EXIT", + text="❌ EXIT", command=self.show_exit_dialog, - bg='#cc0000', # Red background + bg='#e74c3c', # Red background fg='white', - activebackground='#ff0000', + activebackground='#ec7063', activeforeground='white', relief=tk.FLAT, borderwidth=0, - width=6, - height=2, - font=('Arial', 9, 'bold') + width=8, + height=3, + font=('Segoe UI', 10, 'bold'), + cursor='hand2' ) - self.exit_btn.grid(row=0, column=4, padx=3) + 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""" @@ -370,10 +486,13 @@ class SimpleMediaPlayerApp: 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""" @@ -415,8 +534,16 @@ class SimpleMediaPlayerApp: def play_video(self, file_path): """Play video file""" - # Clear any status text - self.status_label.config(text="") + # 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(): @@ -430,7 +557,23 @@ class SimpleMediaPlayerApp: fps = cap.get(cv2.CAP_PROP_FPS) delay = int(1000 / fps) if fps > 0 else 30 # Default to 30 FPS if unknown - while self.current_index < len(self.playlist) and not self.is_paused: + # 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: @@ -439,30 +582,58 @@ class SimpleMediaPlayerApp: # Convert color from BGR to RGB frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - # Convert to PhotoImage and display - img = Image.fromarray(frame) - img.thumbnail((self.root.winfo_width(), self.root.winfo_height()), Image.LANCZOS) - photo = ImageTk.PhotoImage(img) - - self.image_label.config(image=photo) - self.image_label.image = photo # Keep reference + # 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 - self.auto_advance_timer = self.root.after(1000, self.next_media) + # 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}") - self.status_label.config(text=f"Error playing video: {e}") - self.auto_advance_timer = self.root.after(5000, self.next_media) + # 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='') @@ -475,27 +646,45 @@ class SimpleMediaPlayerApp: ) def show_image(self, file_path, duration): - """Display an image""" + """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() - # Resize while maintaining aspect ratio - img.thumbnail((screen_width, screen_height), Image.LANCZOS) - photo = ImageTk.PhotoImage(img) + # Ensure we have valid screen dimensions + if screen_width <= 1 or screen_height <= 1: + screen_width = 1920 # Default fallback + screen_height = 1080 - # Display image + # 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( @@ -505,7 +694,9 @@ class SimpleMediaPlayerApp: except Exception as e: Logger.error(f"Failed to show image {file_path}: {e}") - self.status_label.config(text=f"Image error: {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): @@ -593,10 +784,38 @@ class SimpleMediaPlayerApp: 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 @@ -627,8 +846,8 @@ class SimpleMediaPlayerApp: exit_dialog.grab_set() exit_dialog.resizable(False, False) - # Center the dialog - exit_dialog.geometry("+%d+%d" % (self.root.winfo_rootx() + 200, self.root.winfo_rooty() + 200)) + # 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) @@ -705,6 +924,28 @@ class SimpleMediaPlayerApp: 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: @@ -796,152 +1037,798 @@ class SettingsWindow: 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""" - self.window.title("Settings") - self.window.geometry("700x500") - self.window.configure(bg='#f0f0f0') + """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""" - # Main frame - main_frame = ttk.Frame(self.window, padding=10) - main_frame.pack(fill=tk.BOTH, expand=True) + """Create settings widgets with enhanced dark theme styling""" + # Configure enhanced custom styles + style = ttk.Style() + style.theme_use('clam') - # Create a notebook (tabbed interface) - notebook = ttk.Notebook(main_frame) - notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + # Enhanced dark theme styles + style.configure('Dark.TNotebook', + background=self.colors['bg_secondary'], + borderwidth=0, + tabmargins=[2, 5, 2, 0]) - # Tab 1: Configuration - config_tab = ttk.Frame(notebook, padding=10) - notebook.add(config_tab, text="Configuration") + 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') - # Screen Name - ttk.Label(config_tab, text="Screen Name:").grid(row=0, column=0, sticky=tk.W, pady=5) - self.screen_name_var = tk.StringVar() - ttk.Entry(config_tab, textvariable=self.screen_name_var, width=30).grid(row=0, column=1, sticky=tk.W, padx=(10, 0)) + 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'])]) - # Server IP - ttk.Label(config_tab, text="Server IP:").grid(row=1, column=0, sticky=tk.W, pady=5) - self.server_ip_var = tk.StringVar() - ttk.Entry(config_tab, textvariable=self.server_ip_var, width=30).grid(row=1, column=1, sticky=tk.W, padx=(10, 0)) + 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)) - # Port - ttk.Label(config_tab, text="Port:").grid(row=2, column=0, sticky=tk.W, pady=5) - self.port_var = tk.StringVar() - ttk.Entry(config_tab, textvariable=self.port_var, width=30).grid(row=2, column=1, sticky=tk.W, padx=(10, 0)) + 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']) - # QuickConnect Key - ttk.Label(config_tab, text="QuickConnect Key:").grid(row=3, column=0, sticky=tk.W, pady=5) - self.quickconnect_var = tk.StringVar() - ttk.Entry(config_tab, textvariable=self.quickconnect_var, width=30, show='*').grid(row=3, column=1, sticky=tk.W, padx=(10, 0)) + # 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) - # Screen Size - ttk.Label(config_tab, text="Screen Width:").grid(row=4, column=0, sticky=tk.W, pady=5) - self.screen_w_var = tk.StringVar() - ttk.Entry(config_tab, textvariable=self.screen_w_var, width=10).grid(row=4, column=1, sticky=tk.W, padx=(10, 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) - ttk.Label(config_tab, text="Screen Height:").grid(row=5, column=0, sticky=tk.W, pady=5) - self.screen_h_var = tk.StringVar() - ttk.Entry(config_tab, textvariable=self.screen_h_var, width=10).grid(row=5, column=1, sticky=tk.W, padx=(10, 0)) + # Title with enhanced styling + title_container = tk.Frame(header_frame, bg=self.colors['bg_secondary']) + title_container.pack(expand=True, fill=tk.BOTH) - # Server connection test - ttk.Button(config_tab, text="Test Server Connection", command=self.test_connection).grid(row=6, column=0, columnspan=2, sticky=tk.W, pady=10) + 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) - self.connection_status = ttk.Label(config_tab, text="") - self.connection_status.grid(row=7, column=0, columnspan=2, sticky=tk.W) + 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() - # Force playlist refresh - ttk.Button(config_tab, text="Force Playlist Refresh", command=self.force_playlist_refresh).grid(row=8, column=0, columnspan=2, sticky=tk.W, pady=10) + # 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) - # Tab 2: Logs - logs_tab = ttk.Frame(notebook, padding=10) - notebook.add(logs_tab, text="Logs") + # Create enhanced notebook with dark theme + notebook = ttk.Notebook(content_frame, style='Dark.TNotebook') + notebook.pack(fill=tk.BOTH, expand=True, pady=(0, 20)) - # Log display - ttk.Label(logs_tab, text="Recent Logs:").pack(anchor=tk.W, pady=(0, 5)) + # 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) - # Log text with scrollbar in a frame - log_frame = ttk.Frame(logs_tab) - log_frame.pack(fill=tk.BOTH, expand=True) + # Enhanced bottom button section + self.create_bottom_controls(content_frame) - self.log_text = tk.Text(log_frame, height=15, font=('Courier', 9)) - scrollbar = ttk.Scrollbar(log_frame, command=self.log_text.yview) - self.log_text.configure(yscrollcommand=scrollbar.set) - - self.log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - # Refresh logs button - ttk.Button(logs_tab, text="Refresh Logs", command=self.load_logs).pack(anchor=tk.W, pady=10) - - # Tab 3: About - about_tab = ttk.Frame(notebook, padding=10) - notebook.add(about_tab, text="About") - - # About text - about_text = ( - "Tkinter Simple Media Player\n" - "Version 1.0\n\n" - "A lightweight digital signage player built with tkinter.\n" - "Displays images and videos with periodic server synchronization.\n\n" - "Keyboard shortcuts:\n" - "F - Toggle fullscreen\n" - "Space - Play/Pause\n" - "Left/Right Arrow - Previous/Next media\n" - "Escape - Exit (password protected)" - ) - - ttk.Label(about_tab, text=about_text, justify=tk.LEFT).pack(anchor=tk.W) - - # Button frame at bottom - button_frame = ttk.Frame(main_frame) - button_frame.pack(fill=tk.X, pady=10) - - ttk.Button(button_frame, text="Save", command=self.save_config).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Cancel", command=self.window.destroy).pack(side=tk.LEFT) - - # Load initial logs + # 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.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""" + """Load current configuration with enhanced feedback""" try: - Logger.info("Loading configuration in settings window") + Logger.info("Loading configuration in enhanced settings window") config = load_config() Logger.info(f"Config loaded: {config}") - # Set default values if keys don't exist - self.screen_name_var.set(config.get('screen_name', '')) - self.server_ip_var.set(config.get('server_ip', '')) - self.port_var.set(config.get('port', '5000')) # Changed default to 5000 - self.quickconnect_var.set(config.get('quickconnect_key', '')) - self.screen_w_var.set(config.get('screen_w', '1920')) - self.screen_h_var.set(config.get('screen_h', '1080')) + # 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')) - Logger.info("Configuration values loaded successfully in settings") + # 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 settings: {e}") - # Set default values if loading fails - self.screen_name_var.set('') - self.server_ip_var.set('') - self.port_var.set('5000') - self.quickconnect_var.set('') - self.screen_w_var.set('1920') - self.screen_h_var.set('1080') + Logger.error(f"Failed to load config in enhanced settings: {e}") - # Show error but don't block the window - self.connection_status.configure(text=f"Warning: Could not load existing config: {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""" + """Save configuration with enhanced feedback""" try: # Load existing config or create new one try: @@ -949,13 +1836,36 @@ class SettingsWindow: except: config = {} - # Update with new values - config['screen_name'] = self.screen_name_var.get() - config['server_ip'] = self.server_ip_var.get() - config['port'] = self.port_var.get() - config['quickconnect_key'] = self.quickconnect_var.get() - config['screen_w'] = self.screen_w_var.get() - config['screen_h'] = self.screen_h_var.get() + # 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) @@ -964,20 +1874,91 @@ class SettingsWindow: with open(CONFIG_FILE, 'w') as f: json.dump(config, f, indent=4) - Logger.info(f"Configuration saved to {CONFIG_FILE}") + Logger.info(f"Enhanced configuration saved to {CONFIG_FILE}") - # Modern success message - self.show_success_message("Configuration saved successfully!") + # 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", "Do you want to refresh the playlist from server 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 configuration: {e}") - messagebox.showerror("Error", f"Failed to save configuration: {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""" @@ -988,8 +1969,8 @@ class SettingsWindow: success_window.transient(self.window) success_window.grab_set() - # Center the window - success_window.geometry("+%d+%d" % (self.window.winfo_rootx() + 200, self.window.winfo_rooty() + 150)) + # 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'), diff --git a/tkinter_app/src/virtual_keyboard.py b/tkinter_app/src/virtual_keyboard.py new file mode 100644 index 0000000..911c26b --- /dev/null +++ b/tkinter_app/src/virtual_keyboard.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +""" +Virtual Keyboard Component for Touch Displays +Provides an on-screen keyboard for touch-friendly input +""" +import tkinter as tk +from tkinter import ttk + +class VirtualKeyboard: + def __init__(self, parent, target_entry=None, dark_theme=True): + self.parent = parent + self.target_entry = target_entry + self.dark_theme = dark_theme + self.keyboard_window = None + self.caps_lock = False + self.shift_pressed = False + + # Define color schemes + if dark_theme: + self.colors = { + 'bg_primary': '#1e2124', + 'bg_secondary': '#2f3136', + 'bg_tertiary': '#36393f', + 'accent': '#7289da', + 'accent_hover': '#677bc4', + 'text_primary': '#ffffff', + 'text_secondary': '#b9bbbe', + 'key_normal': '#4f545c', + 'key_hover': '#5865f2', + 'key_special': '#ed4245', + 'key_function': '#57f287' + } + else: + self.colors = { + 'bg_primary': '#ffffff', + 'bg_secondary': '#f8f9fa', + 'bg_tertiary': '#e9ecef', + 'accent': '#0d6efd', + 'accent_hover': '#0b5ed7', + 'text_primary': '#000000', + 'text_secondary': '#6c757d', + 'key_normal': '#dee2e6', + 'key_hover': '#0d6efd', + 'key_special': '#dc3545', + 'key_function': '#198754' + } + + def show_keyboard(self, entry_widget=None): + """Show the virtual keyboard""" + if entry_widget: + self.target_entry = entry_widget + + if self.keyboard_window and self.keyboard_window.winfo_exists(): + self.keyboard_window.lift() + return + + self.create_keyboard() + + def hide_keyboard(self): + """Hide the virtual keyboard""" + if self.keyboard_window and self.keyboard_window.winfo_exists(): + self.keyboard_window.destroy() + self.keyboard_window = None + + def create_keyboard(self): + """Create the virtual keyboard window""" + self.keyboard_window = tk.Toplevel(self.parent) + self.keyboard_window.title("Virtual Keyboard") + self.keyboard_window.configure(bg=self.colors['bg_primary']) + self.keyboard_window.resizable(False, False) + + # Make keyboard stay on top + self.keyboard_window.attributes('-topmost', True) + + # Position keyboard at bottom of screen + self.position_keyboard() + + # Create keyboard layout + self.create_keyboard_layout() + + # Bind events + self.keyboard_window.protocol("WM_DELETE_WINDOW", self.hide_keyboard) + + def position_keyboard(self): + """Position keyboard at bottom center of screen""" + self.keyboard_window.update_idletasks() + + # Get screen dimensions + screen_width = self.keyboard_window.winfo_screenwidth() + screen_height = self.keyboard_window.winfo_screenheight() + + # Keyboard dimensions + kb_width = 800 + kb_height = 300 + + # Position at bottom center + x = (screen_width - kb_width) // 2 + y = screen_height - kb_height - 50 # 50px from bottom + + self.keyboard_window.geometry(f"{kb_width}x{kb_height}+{x}+{y}") + + def create_keyboard_layout(self): + """Create the keyboard layout""" + main_frame = tk.Frame(self.keyboard_window, bg=self.colors['bg_primary'], padx=10, pady=10) + main_frame.pack(fill=tk.BOTH, expand=True) + + # Title bar + title_frame = tk.Frame(main_frame, bg=self.colors['bg_secondary'], height=40) + title_frame.pack(fill=tk.X, pady=(0, 10)) + title_frame.pack_propagate(False) + + title_label = tk.Label(title_frame, text="⌨️ Virtual Keyboard", + font=('Segoe UI', 12, 'bold'), + bg=self.colors['bg_secondary'], fg=self.colors['text_primary']) + title_label.pack(side=tk.LEFT, padx=10, pady=10) + + # Close button + close_btn = tk.Button(title_frame, text="✕", command=self.hide_keyboard, + bg=self.colors['key_special'], fg=self.colors['text_primary'], + font=('Segoe UI', 12, 'bold'), relief=tk.FLAT, width=3) + close_btn.pack(side=tk.RIGHT, padx=10, pady=5) + + # Keyboard rows + self.create_keyboard_rows(main_frame) + + def create_keyboard_rows(self, parent): + """Create keyboard rows""" + # Define keyboard layout + rows = [ + ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 'Backspace'], + ['Tab', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'], + ['Caps', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'", 'Enter'], + ['Shift', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/', 'Shift'], + ['Ctrl', 'Alt', 'Space', 'Alt', 'Ctrl'] + ] + + # Special keys with different sizes + special_keys = { + 'Backspace': 2, + 'Tab': 1.5, + 'Enter': 2, + 'Caps': 1.8, + 'Shift': 2.3, + 'Ctrl': 1.2, + 'Alt': 1.2, + 'Space': 6 + } + + for row_index, row in enumerate(rows): + row_frame = tk.Frame(parent, bg=self.colors['bg_primary']) + row_frame.pack(fill=tk.X, pady=2) + + for key in row: + width = special_keys.get(key, 1) + self.create_key_button(row_frame, key, width) + + def create_key_button(self, parent, key, width=1): + """Create a keyboard key button""" + # Determine key type and color + if key in ['Backspace', 'Tab', 'Enter', 'Caps', 'Shift', 'Ctrl', 'Alt']: + bg_color = self.colors['key_function'] + elif key == 'Space': + bg_color = self.colors['key_normal'] + else: + bg_color = self.colors['key_normal'] + + # Calculate button width + base_width = 4 + button_width = int(base_width * width) + + # Display text for special keys + display_text = { + 'Backspace': '⌫', + 'Tab': '⇥', + 'Enter': '⏎', + 'Caps': '⇪', + 'Shift': '⇧', + 'Ctrl': 'Ctrl', + 'Alt': 'Alt', + 'Space': '___' + }.get(key, key.upper() if self.caps_lock or self.shift_pressed else key) + + button = tk.Button(parent, text=display_text, + command=lambda k=key: self.key_pressed(k), + bg=bg_color, fg=self.colors['text_primary'], + font=('Segoe UI', 10, 'bold'), + relief=tk.FLAT, bd=1, + width=button_width, height=2) + + # Add hover effects + def on_enter(e, btn=button): + btn.configure(bg=self.colors['key_hover']) + + def on_leave(e, btn=button): + btn.configure(bg=bg_color) + + button.bind("", on_enter) + button.bind("", on_leave) + + button.pack(side=tk.LEFT, padx=1, pady=1) + + def key_pressed(self, key): + """Handle key press""" + if not self.target_entry: + return + + if key == 'Backspace': + current_pos = self.target_entry.index(tk.INSERT) + if current_pos > 0: + self.target_entry.delete(current_pos - 1) + + elif key == 'Tab': + self.target_entry.insert(tk.INSERT, '\t') + + elif key == 'Enter': + # Try to trigger any bound return event + self.target_entry.event_generate('') + + elif key == 'Caps': + self.caps_lock = not self.caps_lock + self.update_key_display() + + elif key == 'Shift': + self.shift_pressed = not self.shift_pressed + self.update_key_display() + + elif key == 'Space': + self.target_entry.insert(tk.INSERT, ' ') + + elif key in ['Ctrl', 'Alt']: + # These could be used for key combinations in the future + pass + + else: + # Regular character + char = key.upper() if self.caps_lock or self.shift_pressed else key + + # Handle shifted characters + if self.shift_pressed and not self.caps_lock: + shift_map = { + '1': '!', '2': '@', '3': '#', '4': '$', '5': '%', + '6': '^', '7': '&', '8': '*', '9': '(', '0': ')', + '-': '_', '=': '+', '[': '{', ']': '}', '\\': '|', + ';': ':', "'": '"', ',': '<', '.': '>', '/': '?', + '`': '~' + } + char = shift_map.get(key, char) + + self.target_entry.insert(tk.INSERT, char) + + # Reset shift after character input + if self.shift_pressed: + self.shift_pressed = False + self.update_key_display() + + def update_key_display(self): + """Update key display based on caps lock and shift state""" + # This would update the display of keys, but for simplicity + # we'll just recreate the keyboard when needed + pass + + +class TouchOptimizedEntry(tk.Entry): + """Entry widget optimized for touch displays with virtual keyboard""" + + def __init__(self, parent, virtual_keyboard=None, **kwargs): + # Make entry larger for touch + kwargs.setdefault('font', ('Segoe UI', 12)) + kwargs.setdefault('relief', tk.FLAT) + kwargs.setdefault('bd', 8) + + super().__init__(parent, **kwargs) + + self.virtual_keyboard = virtual_keyboard + + # Bind focus events to show/hide keyboard + self.bind('', self.on_focus_in) + self.bind('', self.on_click) + + def on_focus_in(self, event): + """Show virtual keyboard when entry gets focus""" + if self.virtual_keyboard: + self.virtual_keyboard.show_keyboard(self) + + def on_click(self, event): + """Show virtual keyboard when entry is clicked""" + if self.virtual_keyboard: + self.virtual_keyboard.show_keyboard(self) + + +class TouchOptimizedButton(tk.Button): + """Button widget optimized for touch displays""" + + def __init__(self, parent, **kwargs): + # Make buttons larger for touch + kwargs.setdefault('font', ('Segoe UI', 11, 'bold')) + kwargs.setdefault('relief', tk.FLAT) + kwargs.setdefault('padx', 20) + kwargs.setdefault('pady', 12) + kwargs.setdefault('cursor', 'hand2') + + super().__init__(parent, **kwargs) + + # Add touch feedback + self.bind('', self.on_touch_down) + self.bind('', self.on_touch_up) + + def on_touch_down(self, event): + """Visual feedback when button is touched""" + self.configure(relief=tk.SUNKEN) + + def on_touch_up(self, event): + """Reset visual feedback when touch is released""" + self.configure(relief=tk.FLAT) + + +# Test the virtual keyboard +if __name__ == "__main__": + def test_virtual_keyboard(): + root = tk.Tk() + root.title("Virtual Keyboard Test") + root.geometry("600x400") + root.configure(bg='#2f3136') + + # Create virtual keyboard instance + vk = VirtualKeyboard(root, dark_theme=True) + + # Test frame + test_frame = tk.Frame(root, bg='#2f3136', padx=20, pady=20) + test_frame.pack(fill=tk.BOTH, expand=True) + + # Title + tk.Label(test_frame, text="🎮 Touch Display Test", + font=('Segoe UI', 16, 'bold'), + bg='#2f3136', fg='white').pack(pady=20) + + # Test entries + tk.Label(test_frame, text="Click entries to show virtual keyboard:", + bg='#2f3136', fg='white', font=('Segoe UI', 12)).pack(pady=10) + + entry1 = TouchOptimizedEntry(test_frame, vk, width=30, bg='#36393f', + fg='white', insertbackground='white') + entry1.pack(pady=10) + + entry2 = TouchOptimizedEntry(test_frame, vk, width=30, bg='#36393f', + fg='white', insertbackground='white') + entry2.pack(pady=10) + + # Test buttons + TouchOptimizedButton(test_frame, text="Show Keyboard", + command=lambda: vk.show_keyboard(entry1), + bg='#7289da', fg='white').pack(pady=10) + + TouchOptimizedButton(test_frame, text="Hide Keyboard", + command=vk.hide_keyboard, + bg='#ed4245', fg='white').pack(pady=5) + + root.mainloop() + + test_virtual_keyboard()