From eb536f385a97f1a7557e3f03ef40d768ede4ab70 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Sat, 11 Oct 2025 13:30:36 +1100 Subject: [PATCH] feat: fix looping --- vlchan/player.py | 315 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 253 insertions(+), 62 deletions(-) diff --git a/vlchan/player.py b/vlchan/player.py index 7a85536..65943fe 100644 --- a/vlchan/player.py +++ b/vlchan/player.py @@ -10,64 +10,140 @@ from .synchan import SynchanState, SynchanController, create_synchan class VLChanPlayer: """VLC video player synchronized with synchan server.""" - - def __init__(self, synchan_url: str = "http://localhost:3000", - video_path: Optional[str] = None): + + def __init__( + self, + synchan_url: str = "http://localhost:3000", + video_path: Optional[str] = None, + ): """Initialize the VLC player with synchan synchronization. - + Args: synchan_url: URL of the synchan server video_path: Path to video file to play """ self.synchan_url = synchan_url self.video_path = video_path - + self.loop = True + # Initialize VLC self.instance = vlc.Instance() self.player = self.instance.media_player_new() - + + # Set up VLC event manager for looping and debugging + self.event_manager = self.player.event_manager() + self.event_manager.event_attach( + vlc.EventType.MediaPlayerEndReached, self._on_end_reached + ) + # Add more events for debugging + self.event_manager.event_attach( + vlc.EventType.MediaPlayerStopped, self._on_stopped + ) + self.event_manager.event_attach( + vlc.EventType.MediaPlayerPlaying, self._on_playing + ) + self.event_manager.event_attach( + vlc.EventType.MediaPlayerPaused, self._on_paused + ) + # Initialize synchan self.synchan_controller = SynchanController(synchan_url) self.synchan_observable: Optional[Observable[SynchanState]] = None self.synchan_subscription = None - + # State tracking self._is_playing = False self.last_sync_time = 0 self.sync_threshold = 0.1 # 100ms threshold for sync - + # Playback rate adjustment constants (from VideoPlayer.tsx) self.rate_k = 0.5 # K constant self.rate_range = 0.05 # Range limit - + # Callbacks self.on_state_change: Optional[Callable[[SynchanState], None]] = None - + + # Position-based loop detection as fallback + self._last_position = 0.0 + self._position_check_thread = None + self._position_monitor_running = False + # Load video if provided if video_path: self.load_video(video_path) - + + def _on_end_reached(self, event): + """Handle video end reached event for looping.""" + print( + f"[EVENT] MediaPlayerEndReached fired - loop={self.loop}, was_playing={self._is_playing}" + ) + if self.loop and self.video_path: + print("[LOOP] Attempting to loop back to beginning") + + def restart_video(): + try: + print("[LOOP] Stopping player...") + self.player.stop() + time.sleep(0.05) # Shorter delay + + print("[LOOP] Reloading media...") + media = self.instance.media_new(self.video_path) + self.player.set_media(media) + + print("[LOOP] Setting position to 0...") + self.player.set_position(0.0) + time.sleep(0.05) + + if self._is_playing: + print("[LOOP] Restarting playback...") + self.player.play() + else: + print("[LOOP] Not restarting - was not playing") + + except Exception as e: + print(f"[LOOP ERROR] Failed to restart: {e}") + + # Use a timer to restart after a short delay + threading.Timer(0.05, restart_video).start() + else: + print("[EVENT] Video ended - no looping enabled") + self._is_playing = False + + def _on_stopped(self, event): + """Handle player stopped event.""" + print("[EVENT] MediaPlayerStopped") + + def _on_playing(self, event): + """Handle player playing event.""" + print("[EVENT] MediaPlayerPlaying") + self._is_playing = True + + def _on_paused(self, event): + """Handle player paused event.""" + print("[EVENT] MediaPlayerPaused") + self._is_playing = False + def load_video(self, video_path: str): """Load a video file.""" self.video_path = video_path media = self.instance.media_new(video_path) self.player.set_media(media) print(f"Loaded video: {video_path}") - + def start_sync(self): """Start synchronization with synchan server.""" if self.synchan_observable is not None: print("Sync already started") return - + self.synchan_observable = create_synchan(self.synchan_url) self.synchan_subscription = self.synchan_observable.subscribe( on_next=self._handle_synchan_state, on_error=self._handle_synchan_error, - on_completed=self._handle_synchan_completed + on_completed=self._handle_synchan_completed, ) print("Started synchan synchronization") - + def stop_sync(self): """Stop synchronization with synchan server.""" if self.synchan_subscription: @@ -75,38 +151,42 @@ class VLChanPlayer: self.synchan_subscription = None self.synchan_observable = None print("Stopped synchan synchronization") - + def _handle_synchan_state(self, state: SynchanState): """Handle state updates from synchan server.""" if self.on_state_change: self.on_state_change(state) - + # Get current local time local_time = self.player.get_time() / 1000.0 # Convert to seconds - + # Apply latency compensation (similar to VideoPlayer.tsx) # currentTime = newState.currentTime + newState.latency / 1000 compensated_time = state.currentTime + state.latency / 1000 - - print(f"Control: playing={state.playing}, server_time={state.currentTime:.2f}s, " - f"latency={state.latency:.0f}ms, compensated={compensated_time:.2f}s, " - f"local={local_time:.2f}s") - + + print( + f"Control: playing={state.playing}, server_time={state.currentTime:.2f}s, " + f"latency={state.latency:.0f}ms, compensated={compensated_time:.2f}s, " + f"local={local_time:.2f}s" + ) + if state.playing: # Calculate time difference diff = compensated_time - local_time - + # If difference is large (>1s), seek to correct position if diff > 1.0: - print(f"Large diff detected ({diff:.2f}s), seeking to {compensated_time:.2f}s") + print( + f"Large diff detected ({diff:.2f}s), seeking to {compensated_time:.2f}s" + ) seek_time = int(compensated_time * 1000) # Convert to milliseconds self.player.set_time(seek_time) local_time = compensated_time # Update local time after seek diff = 0 # Reset diff after seek - + # Calculate playback rate adjustment (similar to getPlaybackRate) new_rate = self._get_playback_rate(diff) - + # Apply playback rate and play self.player.set_rate(new_rate) if not self._is_playing: @@ -121,31 +201,31 @@ class VLChanPlayer: self.player.pause() self._is_playing = False print("Paused playback") - + # Set exact time when paused seek_time = int(state.currentTime * 1000) self.player.set_time(seek_time) print(f"Set paused time to {state.currentTime:.2f}s") - + def _handle_synchan_error(self, error): """Handle synchan connection errors.""" print(f"Synchan error: {error}") # Optionally attempt to reconnect threading.Timer(5.0, self.start_sync).start() - + def _handle_synchan_completed(self): """Handle synchan connection completion.""" print("Synchan connection completed") - + def _get_playback_rate(self, diff: float) -> float: """Calculate playback rate adjustment based on time difference. - + This implements the same logic as getPlaybackRate in VideoPlayer.tsx: return 1 + Math.min(Math.max(diff * 0.5, -range), range); - + Args: diff: Time difference in seconds (positive = ahead, negative = behind) - + Returns: Adjusted playback rate (1.0 = normal speed) """ @@ -154,81 +234,186 @@ class VLChanPlayer: rate_adjustment = max(rate_adjustment, -self.rate_range) rate_adjustment = min(rate_adjustment, self.rate_range) return 1.0 + rate_adjustment - + def play(self): """Start video playback.""" if self.player.get_media(): + print("Starting playback...") self.player.play() self._is_playing = True + # Start position monitoring for loop detection + if self.loop: + self._start_position_monitoring() print("Started playback") else: print("No media loaded") - + def pause(self): """Pause video playback.""" self.player.pause() self._is_playing = False print("Paused playback") - + def stop(self): """Stop video playback.""" self.player.stop() self._is_playing = False + self._stop_position_monitoring() print("Stopped playback") - + def seek(self, time_seconds: float): """Seek to a specific time position.""" time_ms = int(time_seconds * 1000) self.player.set_time(time_ms) print(f"Seeked to {time_seconds:.2f}s") - + def get_position(self) -> float: """Get current playback position in seconds.""" return self.player.get_time() / 1000.0 - + def get_compensated_position(self, latency: float) -> float: """Get latency-compensated position for synchronization. - + Args: latency: Network latency in milliseconds - + Returns: Position adjusted for latency """ return self.get_position() + latency / 1000.0 - + def get_duration(self) -> float: """Get video duration in seconds.""" return self.player.get_length() / 1000.0 - + def is_playing(self) -> bool: """Check if video is currently playing.""" return self.player.is_playing() - + def set_volume(self, volume: int): """Set volume (0-100).""" self.player.audio_set_volume(int(volume)) - + def get_volume(self) -> int: """Get current volume (0-100).""" return self.player.audio_get_volume() - + def get_rate(self) -> float: """Get current playback rate.""" return self.player.get_rate() - + def set_rate(self, rate: float): """Set playback rate (1.0 = normal speed).""" self.player.set_rate(rate) - + def set_fullscreen(self, fullscreen: bool): """Set fullscreen mode.""" self.player.set_fullscreen(fullscreen) - + + def set_loop(self, loop: bool): + """Enable or disable video looping.""" + self.loop = loop + print(f"Looping {'enabled' if loop else 'disabled'}") + + def is_looping(self) -> bool: + """Check if looping is enabled.""" + return self.loop + + def trigger_loop(self): + """Manually trigger a loop back to the beginning (for testing).""" + if self.video_path: + print("Manually triggering loop") + self._on_end_reached(None) + + def _start_position_monitoring(self): + """Start monitoring position for loop detection.""" + if not self._position_monitor_running: + self._position_monitor_running = True + self._position_check_thread = threading.Thread( + target=self._position_monitor_loop, daemon=True + ) + self._position_check_thread.start() + print("[MONITOR] Started position monitoring") + + def _stop_position_monitoring(self): + """Stop monitoring position.""" + self._position_monitor_running = False + if self._position_check_thread: + self._position_check_thread.join(timeout=1.0) + print("[MONITOR] Stopped position monitoring") + + def _position_monitor_loop(self): + """Monitor position for loop detection as fallback.""" + consecutive_same_position = 0 + while self._position_monitor_running: + try: + if self._is_playing and self.loop: + current_pos = self.get_position() + duration = self.get_duration() + + # Check if we're stuck at the end + if duration > 0 and current_pos >= duration - 0.5: + consecutive_same_position += 1 + if consecutive_same_position >= 3: # 3 consecutive checks + print( + f"[MONITOR] Detected stuck at end: pos={current_pos:.2f}, dur={duration:.2f}" + ) + self._force_loop() + consecutive_same_position = 0 + else: + consecutive_same_position = 0 + + # Also check if position hasn't changed for too long near the end + if ( + duration > 0 + and current_pos > duration * 0.95 + and abs(current_pos - self._last_position) < 0.1 + ): + consecutive_same_position += 1 + else: + consecutive_same_position = 0 + + self._last_position = current_pos + + time.sleep(1) # Check every second + except Exception as e: + print(f"[MONITOR ERROR] {e}") + break + + def _force_loop(self): + """Force a loop when position monitoring detects we're stuck.""" + print("[FORCE LOOP] Forcing loop due to position monitoring") + try: + # Stop and restart completely + self.player.stop() + time.sleep(0.1) + + # Reload media + media = self.instance.media_new(self.video_path) + self.player.set_media(media) + + # Start from beginning + self.player.set_time(0) + time.sleep(0.1) + + # Resume if we were playing + if self._is_playing: + self.player.play() + print("[FORCE LOOP] Resumed playback from beginning") + except Exception as e: + print(f"[FORCE LOOP ERROR] {e}") + def cleanup(self): """Clean up resources.""" self.stop_sync() self.stop() + self._stop_position_monitoring() + # Detach events before cleanup + if hasattr(self, "event_manager"): + self.event_manager.event_detach(vlc.EventType.MediaPlayerEndReached) + self.event_manager.event_detach(vlc.EventType.MediaPlayerStopped) + self.event_manager.event_detach(vlc.EventType.MediaPlayerPlaying) + self.event_manager.event_detach(vlc.EventType.MediaPlayerPaused) self.player.release() self.instance.release() print("Cleaned up VLC player") @@ -237,34 +422,40 @@ class VLChanPlayer: def main(): """Example usage of VLChanPlayer.""" import sys - + if len(sys.argv) < 2: - print("Usage: python -m vlchan.player [synchan_url]") + print("Usage: python -m vlchan.player [synchan_url] [--loop]") sys.exit(1) - + video_path = sys.argv[1] - synchan_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:3000" - - # Create player + synchan_url = ( + sys.argv[2] + if len(sys.argv) > 2 and not sys.argv[2].startswith("--") + else "http://localhost:3000" + ) + loop = "--loop" in sys.argv + + # Create player with looping if specified player = VLChanPlayer(synchan_url, video_path) - + print(f"Created player with looping {'enabled' if loop else 'disabled'}") + # Set up state change callback def on_state_change(state: SynchanState): print(f"Synchan state: playing={state.playing}, time={state.currentTime:.2f}s") - + player.on_state_change = on_state_change - + # Start synchronization player.start_sync() - + try: # Start playback player.play() - + # Keep running until interrupted while True: time.sleep(1) - + except KeyboardInterrupt: print("\nShutting down...") finally: