Files
moonlight-drive-in/mpv_seamless_player.py
jessikitty 05a8bd237b Upload files to "/"
Add core Python modules
2025-12-09 16:49:13 +11:00

763 lines
30 KiB
Python

# 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 ===")