# mpv_seamless_player.py - Enhanced with folder sequence support """ MPV-based seamless video player with folder sequence playback """ import os import sys import random import time import threading import logging import queue from pathlib import Path import tkinter as tk try: import mpv MPV_AVAILABLE = True except ImportError: MPV_AVAILABLE = False print("ERROR: python-mpv not installed!") class MPVSeamlessPlayer: def __init__(self, config, debug_console): self.config = config self.debug_console = debug_console self.logger = logging.getLogger(__name__) self.running = False if not MPV_AVAILABLE: raise Exception("MPV not available") # Video state self.current_mode = "trailers" self.current_video_path = None self.specific_video_playing = False self.specific_video_start_time = None # MPV setup self.player_a = None self.player_b = None self.active_player = 'a' # Display settings self.screen_width = 1920 self.screen_height = 1080 self.is_fullscreen = True # Content - Enhanced with folder support self.trailer_videos = [] self.key_mapping = {} # Single file mappings self.folder_mapping = {} # Folder sequence mappings self.folder_state = {} # Track current position in each folder self.video_queue = queue.Queue() self.lock = threading.Lock() # Enhanced trailer randomness - avoid recent repeats self.recently_played_trailers = [] # Track last N trailers self.max_recent_trailers = 36 # Don't repeat within last 25 plays self.trailer_history_size = 70 # Keep history of 50 for better randomness # Transition state self.transition_lock = threading.Lock() self.last_transition_time = 0 # Control flags self.force_next = False self.pending_specific_video = False # Minimum play time protection self.video_start_time = None self.minimum_play_time = 3.0 self.minimum_specific_play_time = 5.0 # Performance tracking self.stats = { 'transitions': 0, 'failed_loads': 0, 'queue_processed': 0, 'folder_videos_played': 0 } # Initialize self.logger.info("=== MPV PLAYER INITIALIZATION ===") self.detect_display_settings() if not self.setup_mpv(): raise Exception("MPV setup failed") self.load_content() self.logger.info("=== MPV PLAYER READY ===") def detect_display_settings(self): """Detect optimal display configuration""" try: temp_root = tk.Tk() temp_root.withdraw() self.screen_width = temp_root.winfo_screenwidth() self.screen_height = temp_root.winfo_screenheight() self.logger.info(f"Screen: {self.screen_width}x{self.screen_height}") temp_root.destroy() except Exception as e: self.logger.error(f"Display detection failed: {e}") self.screen_width = 1920 self.screen_height = 1080 def setup_mpv(self): """Initialize MPV players""" try: self.logger.info("Setting up MPV players...") mpv_config = { 'vo': 'gpu', 'hwdec': 'auto', 'fullscreen': True, 'scale': 'lanczos', 'keepaspect': True, 'keepaspect-window': True, 'volume': 70, 'volume-max': 100, 'keep-open': 'no', 'loop-file': 'no', 'pause': False, 'osd-level': 0, 'quiet': True, } self.player_a = mpv.MPV(**mpv_config) self.player_b = mpv.MPV(**mpv_config) self.player_a.register_event_callback(lambda event: self._mpv_event_handler('a', event)) self.player_b.register_event_callback(lambda event: self._mpv_event_handler('b', event)) self.logger.info("MPV setup complete") return True except Exception as e: self.logger.error(f"MPV setup failed: {e}", exc_info=True) return False def _mpv_event_handler(self, player_id, event): """Handle MPV events for seamless playback - FIXED""" try: # MPV events don't have .get() - use direct attribute access event_id = None # Try to get event_id if hasattr(event, 'event_id'): event_id = event.event_id elif isinstance(event, dict) and 'event_id' in event: event_id = event['event_id'] # Event ID 7 = END_FILE if event_id == 7: if self.active_player == player_id and self.running: self.logger.info(f"Video ended on player {player_id}") self._handle_video_end() except Exception as e: # Silently ignore common event attribute errors error_msg = str(e) if "get" not in error_msg and "event_id" not in error_msg: self.logger.debug(f"Event error for player {player_id}: {e}") def _handle_video_end(self): """Handle natural video end""" try: if self.specific_video_playing: self.logger.info("Specific video completed - returning to trailer mode") self.debug_console.log("Video completed - switching back to trailer mode") self.specific_video_playing = False self.specific_video_start_time = None self.current_mode = "trailers" def queue_next_trailer(): time.sleep(self.config.TRANSITION_DELAY) next_trailer = self.get_random_trailer() if next_trailer and self.running: self.video_queue.put(("trailers", next_trailer)) threading.Thread(target=queue_next_trailer, daemon=True).start() except Exception as e: self.logger.error(f"Error handling video end: {e}") def load_content(self): """Load video content and mappings - Enhanced with folder support""" try: self.logger.info("Loading video content...") # Load trailers trailers_path = Path(self.config.TRAILERS_DIR) if trailers_path.exists(): for f in trailers_path.iterdir(): if f.is_file() and f.suffix.lower() in self.config.SUPPORTED_FORMATS: self.trailer_videos.append(str(f)) self.logger.info(f"Loaded {len(self.trailer_videos)} trailer videos") # Load single file mappings self.key_mapping = self.config.load_key_mapping() self.logger.info(f"Loaded {len(self.key_mapping)} single-file NFC mappings") # Load folder mappings self.folder_mapping = self.config.load_folder_mapping() self.logger.info(f"Loaded {len(self.folder_mapping)} folder NFC mappings") # Load folder state (playback positions) self.folder_state = self.config.load_folder_state() self.logger.info(f"Loaded playback state for {len(self.folder_state)} folders") # Initialize state for new folders for nfc_id, folder_data in self.folder_mapping.items(): folder_name = folder_data['folder_name'] if folder_name not in self.folder_state: self.folder_state[folder_name] = 0 self.logger.info(f"Initialized folder '{folder_name}' at position 0") # Save initial state self.config.save_folder_state(self.folder_state) except Exception as e: self.logger.error(f"Content loading error: {e}", exc_info=True) def reset_all_folder_positions(self): """Reset all folder sequences back to the first video""" try: self.logger.info("Resetting all folder positions to start") # Reset all positions to 0 for folder_name in self.folder_state: self.folder_state[folder_name] = 0 # Save the reset state self.config.save_folder_state(self.folder_state) self.logger.info(f"Reset complete - {len(self.folder_state)} folders reset") self.debug_console.log(f"All folder sequences reset to beginning ({len(self.folder_state)} folders)") return True except Exception as e: self.logger.error(f"Failed to reset folder positions: {e}") self.debug_console.log_error("Failed to reset folder positions") return False def get_next_video_from_folder(self, nfc_id): """Get the next video in sequence from a folder""" try: if nfc_id not in self.folder_mapping: return None folder_data = self.folder_mapping[nfc_id] folder_name = folder_data['folder_name'] videos = folder_data['videos'] if not videos: self.logger.warning(f"No videos in folder '{folder_name}'") return None # Get current position current_pos = self.folder_state.get(folder_name, 0) # Ensure position is valid if current_pos >= len(videos): current_pos = 0 # Get the video at current position video_path = videos[current_pos] # Advance position for next time next_pos = (current_pos + 1) % len(videos) self.folder_state[folder_name] = next_pos # Save state self.config.save_folder_state(self.folder_state) self.logger.info(f"Folder '{folder_name}': Playing video {current_pos + 1}/{len(videos)}") self.logger.info(f"Next scan will play video {next_pos + 1}/{len(videos)}") self.debug_console.log(f"Folder '{folder_name}': Video {current_pos + 1}/{len(videos)}") return video_path except Exception as e: self.logger.error(f"Error getting next folder video: {e}") return None def get_active_player(self): """Get currently active MPV player""" return self.player_a if self.active_player == 'a' else self.player_b def get_inactive_player(self): """Get currently inactive MPV player""" return self.player_b if self.active_player == 'a' else self.player_a def can_skip_video(self): """Check if video has played long enough to be skipped""" if not self.video_start_time: return True current_time = time.time() play_duration = current_time - self.video_start_time min_time = self.minimum_specific_play_time if self.specific_video_playing else self.minimum_play_time can_skip = play_duration >= min_time if not can_skip: remaining = min_time - play_duration self.logger.debug(f"Skip blocked: needs {remaining:.1f}s more") return can_skip def seamless_transition(self, video_path, is_specific=False): """Perform seamless video transition""" transition_start = time.time() with self.transition_lock: try: video_name = Path(video_path).name self.logger.info(f"Starting transition to: {video_name}") # Rate limiting time_since_last = time.time() - self.last_transition_time if time_since_last < 0.3: time.sleep(0.3 - time_since_last) inactive_player = self.get_inactive_player() active_player = self.get_active_player() inactive_player.play(str(video_path)) # Wait for video to start start_wait = time.time() while time.time() - start_wait < 2.0: try: if not inactive_player.idle_active: break except: pass time.sleep(0.05) time.sleep(0.15) try: if active_player: active_player.stop() except: pass self.active_player = 'b' if self.active_player == 'a' else 'a' self.current_video_path = video_path self.specific_video_playing = is_specific self.last_transition_time = time.time() self.stats['transitions'] += 1 self.video_start_time = time.time() if is_specific: self.specific_video_start_time = time.time() self.stats['folder_videos_played'] += 1 self.logger.info(f"SPECIFIC VIDEO NOW PLAYING: {video_name}") else: self.specific_video_start_time = None transition_time = time.time() - transition_start self.logger.info(f"Transition complete in {transition_time:.3f}s") self.debug_console.log_video_played(video_name) return True except Exception as e: self.stats['failed_loads'] += 1 self.logger.error(f"Transition failed: {e}", exc_info=True) return False def skip_current_video(self): """Skip current video""" try: if not self.can_skip_video(): self.logger.info("Skip request ignored - minimum play time not reached") return self.logger.info("Skipping current video") active_player = self.get_active_player() try: active_player.stop() except: pass self.specific_video_playing = False self.specific_video_start_time = None self.pending_specific_video = False self.force_next = True self.video_start_time = None self.debug_console.log("Video skipped by user") except Exception as e: self.logger.error(f"Skip failed: {e}", exc_info=True) def force_next_video(self): """Force next video""" try: if not self.can_skip_video(): return self.logger.info("Forcing next video") if not self.pending_specific_video: self.force_next = True self.specific_video_playing = False self.specific_video_start_time = None self.video_start_time = None if self.current_mode == "specific": self.current_mode = "trailers" self.logger.info("Force returning to trailer mode") except Exception as e: self.logger.error(f"Force next failed: {e}", exc_info=True) def set_volume(self, volume): """Set volume for both MPV players""" try: vol = int(volume * 100) vol = max(0, min(100, vol)) if self.player_a: self.player_a.volume = vol if self.player_b: self.player_b.volume = vol self.logger.debug(f"Volume set to {vol}%") except Exception as e: self.logger.error(f"Volume setting failed: {e}") def toggle_fullscreen(self): """Toggle fullscreen mode""" try: active_player = self.get_active_player() if active_player: try: current_state = active_player.fullscreen new_state = not current_state active_player.fullscreen = new_state self.is_fullscreen = new_state self.logger.info(f"Fullscreen toggled: {new_state}") self.debug_console.log(f"Fullscreen: {new_state}") except Exception as e: self.logger.warning(f"Fullscreen toggle failed: {e}") except Exception as e: self.logger.error(f"Fullscreen toggle failed: {e}") def is_video_playing(self): """Check if video is currently playing""" try: active_player = self.get_active_player() if not active_player: return False return not active_player.idle_active except: return False def get_video_duration(self): """Get current video duration in seconds""" try: active_player = self.get_active_player() if not active_player: return 0 duration = active_player.duration return duration if duration and duration > 0 else 0 except: return 0 def get_video_position(self): """Get current video position in seconds""" try: active_player = self.get_active_player() if not active_player: return 0 position = active_player.playback_time return position if position and position > 0 else 0 except: return 0 def get_random_trailer(self): """Get random trailer video with anti-repeat logic""" if not self.trailer_videos: return None # If we have very few trailers, just pick randomly if len(self.trailer_videos) <= 3: trailer = random.choice(self.trailer_videos) self.logger.debug(f"Selected trailer (few available): {Path(trailer).name}") return trailer # Get trailers that haven't been played recently available_trailers = [ t for t in self.trailer_videos if t not in self.recently_played_trailers[-self.max_recent_trailers:] ] # If all trailers were played recently, use the oldest ones if not available_trailers: self.logger.debug("All trailers recently played - clearing recent history") # Keep only the most recent 5 to avoid immediate repeats self.recently_played_trailers = self.recently_played_trailers[-5:] available_trailers = [ t for t in self.trailer_videos if t not in self.recently_played_trailers ] # If still none available, just use all if not available_trailers: available_trailers = self.trailer_videos # Select random trailer from available pool trailer = random.choice(available_trailers) # Add to history self.recently_played_trailers.append(trailer) # Trim history to max size if len(self.recently_played_trailers) > self.trailer_history_size: self.recently_played_trailers = self.recently_played_trailers[-self.trailer_history_size:] recent_count = len([t for t in self.recently_played_trailers[-self.max_recent_trailers:] if t == trailer]) self.logger.info(f"Selected trailer: {Path(trailer).name}") self.logger.debug(f"Available pool: {len(available_trailers)}/{len(self.trailer_videos)} trailers") self.logger.debug(f"Recent history size: {len(self.recently_played_trailers)}") return trailer def process_video_queue(self): """Process video queue""" processed = 0 try: while not self.video_queue.empty(): try: mode, video_path = self.video_queue.get_nowait() processed += 1 video_name = Path(video_path).name self.logger.info(f"Processing queued video: {video_name}") is_specific = (mode == "specific") if is_specific: self.pending_specific_video = True if self.seamless_transition(video_path, is_specific=is_specific): self.current_mode = mode self.stats['queue_processed'] += 1 if is_specific: time.sleep(0.3) duration = self.get_video_duration() self.logger.info(f"Video duration: {duration:.1f}s") self.pending_specific_video = False # Clear remaining queue while not self.video_queue.empty(): try: self.video_queue.get_nowait() except queue.Empty: break break else: if is_specific: self.pending_specific_video = False except queue.Empty: break except Exception as e: self.logger.error(f"Queue processing error: {e}", exc_info=True) self.pending_specific_video = False if processed > 0: self.debug_console.update_queue_depth(self.video_queue.qsize()) def play_specific_video(self, nfc_id): """Handle NFC input - Enhanced with folder support""" with self.lock: try: self.logger.info(f"Processing NFC input: {nfc_id}") # Check if this is a folder mapping first if nfc_id in self.folder_mapping: self.logger.info(f"NFC {nfc_id} mapped to folder sequence") video_path = self.get_next_video_from_folder(nfc_id) if not video_path: self.logger.error(f"Could not get video from folder for NFC {nfc_id}") self.debug_console.log_error(f"No video available for NFC {nfc_id}") return if not Path(video_path).exists(): self.logger.error(f"Video file not found: {video_path}") self.debug_console.log_error(f"File missing: {video_path}") return # Stop current video try: active_player = self.get_active_player() if active_player: active_player.stop() except: pass # Clear queue while not self.video_queue.empty(): try: self.video_queue.get_nowait() except queue.Empty: break # Queue the folder video self.video_queue.put(("specific", video_path)) self.logger.info(f"Queued folder video: {Path(video_path).name}") self.pending_specific_video = True self.debug_console.update_queue_depth(self.video_queue.qsize()) return # Check single file mapping if nfc_id in self.key_mapping: self.logger.info(f"NFC {nfc_id} mapped to single file") video_path = self.key_mapping[nfc_id] if video_path.startswith("Missing:"): self.logger.error(f"Video marked as missing for NFC {nfc_id}") self.debug_console.log_error(f"Missing video for NFC {nfc_id}") return if not Path(video_path).exists(): self.logger.error(f"Video file not found: {video_path}") self.debug_console.log_error(f"File missing: {video_path}") return # Stop current video try: active_player = self.get_active_player() if active_player: active_player.stop() except: pass # Clear queue while not self.video_queue.empty(): try: self.video_queue.get_nowait() except queue.Empty: break # Queue the video self.video_queue.put(("specific", video_path)) self.logger.info(f"Queued single file video: {Path(video_path).name}") self.pending_specific_video = True self.debug_console.update_queue_depth(self.video_queue.qsize()) return # No mapping found self.logger.warning(f"No mapping found for NFC ID: {nfc_id}") self.debug_console.log(f"Unknown NFC ID: {nfc_id}") except Exception as e: self.logger.error(f"NFC processing error: {e}", exc_info=True) def run(self): """Main video player loop""" self.running = True self.start_time = time.time() self.logger.info("=== MPV VIDEO PLAYER MAIN LOOP START ===") # Start with random trailer initial_trailer = self.get_random_trailer() if initial_trailer: self.seamless_transition(initial_trailer) else: self.logger.error("No initial trailer available!") while self.running: try: # Process video queue if not self.video_queue.empty(): self.process_video_queue() # Force next video elif self.force_next and not self.pending_specific_video: self.force_next = False if self.video_queue.empty(): if self.current_mode == "specific" or self.specific_video_playing: self.current_mode = "trailers" self.specific_video_playing = False self.specific_video_start_time = None next_trailer = self.get_random_trailer() if next_trailer: self.seamless_transition(next_trailer) # Handle natural video end elif not self.is_video_playing() and self.current_video_path: self.logger.info("Video ended naturally") if self.specific_video_playing or self.current_mode == "specific": self.current_mode = "trailers" self.specific_video_playing = False self.specific_video_start_time = None next_trailer = self.get_random_trailer() if next_trailer: time.sleep(self.config.TRANSITION_DELAY) self.seamless_transition(next_trailer) time.sleep(0.1) except Exception as e: self.logger.error(f"Main loop error: {e}", exc_info=True) time.sleep(1) self.logger.info("=== MPV VIDEO PLAYER MAIN LOOP END ===") def stop(self): """Clean up MPV resources""" self.logger.info("=== MPV VIDEO PLAYER STOP ===") self.running = False try: if self.player_a: try: self.player_a.stop() self.player_a.terminate() except: pass if self.player_b: try: self.player_b.stop() self.player_b.terminate() except: pass except Exception as e: self.logger.error(f"Cleanup error: {e}") self.logger.info("=== MPV CLEANUP COMPLETE ===")