feat: fix looping

This commit is contained in:
Justin Lin
2025-10-11 13:30:36 +11:00
parent d335245eb0
commit eb536f385a

View File

@@ -11,8 +11,11 @@ from .synchan import SynchanState, SynchanController, create_synchan
class VLChanPlayer: class VLChanPlayer:
"""VLC video player synchronized with synchan server.""" """VLC video player synchronized with synchan server."""
def __init__(self, synchan_url: str = "http://localhost:3000", def __init__(
video_path: Optional[str] = None): self,
synchan_url: str = "http://localhost:3000",
video_path: Optional[str] = None,
):
"""Initialize the VLC player with synchan synchronization. """Initialize the VLC player with synchan synchronization.
Args: Args:
@@ -21,11 +24,28 @@ class VLChanPlayer:
""" """
self.synchan_url = synchan_url self.synchan_url = synchan_url
self.video_path = video_path self.video_path = video_path
self.loop = True
# Initialize VLC # Initialize VLC
self.instance = vlc.Instance() self.instance = vlc.Instance()
self.player = self.instance.media_player_new() 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 # Initialize synchan
self.synchan_controller = SynchanController(synchan_url) self.synchan_controller = SynchanController(synchan_url)
self.synchan_observable: Optional[Observable[SynchanState]] = None self.synchan_observable: Optional[Observable[SynchanState]] = None
@@ -43,10 +63,66 @@ class VLChanPlayer:
# Callbacks # Callbacks
self.on_state_change: Optional[Callable[[SynchanState], None]] = None 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 # Load video if provided
if video_path: if video_path:
self.load_video(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): def load_video(self, video_path: str):
"""Load a video file.""" """Load a video file."""
self.video_path = video_path self.video_path = video_path
@@ -64,7 +140,7 @@ class VLChanPlayer:
self.synchan_subscription = self.synchan_observable.subscribe( self.synchan_subscription = self.synchan_observable.subscribe(
on_next=self._handle_synchan_state, on_next=self._handle_synchan_state,
on_error=self._handle_synchan_error, on_error=self._handle_synchan_error,
on_completed=self._handle_synchan_completed on_completed=self._handle_synchan_completed,
) )
print("Started synchan synchronization") print("Started synchan synchronization")
@@ -88,9 +164,11 @@ class VLChanPlayer:
# currentTime = newState.currentTime + newState.latency / 1000 # currentTime = newState.currentTime + newState.latency / 1000
compensated_time = state.currentTime + state.latency / 1000 compensated_time = state.currentTime + state.latency / 1000
print(f"Control: playing={state.playing}, server_time={state.currentTime:.2f}s, " print(
f"latency={state.latency:.0f}ms, compensated={compensated_time:.2f}s, " f"Control: playing={state.playing}, server_time={state.currentTime:.2f}s, "
f"local={local_time:.2f}s") f"latency={state.latency:.0f}ms, compensated={compensated_time:.2f}s, "
f"local={local_time:.2f}s"
)
if state.playing: if state.playing:
# Calculate time difference # Calculate time difference
@@ -98,7 +176,9 @@ class VLChanPlayer:
# If difference is large (>1s), seek to correct position # If difference is large (>1s), seek to correct position
if diff > 1.0: 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 seek_time = int(compensated_time * 1000) # Convert to milliseconds
self.player.set_time(seek_time) self.player.set_time(seek_time)
local_time = compensated_time # Update local time after seek local_time = compensated_time # Update local time after seek
@@ -158,8 +238,12 @@ class VLChanPlayer:
def play(self): def play(self):
"""Start video playback.""" """Start video playback."""
if self.player.get_media(): if self.player.get_media():
print("Starting playback...")
self.player.play() self.player.play()
self._is_playing = True self._is_playing = True
# Start position monitoring for loop detection
if self.loop:
self._start_position_monitoring()
print("Started playback") print("Started playback")
else: else:
print("No media loaded") print("No media loaded")
@@ -174,6 +258,7 @@ class VLChanPlayer:
"""Stop video playback.""" """Stop video playback."""
self.player.stop() self.player.stop()
self._is_playing = False self._is_playing = False
self._stop_position_monitoring()
print("Stopped playback") print("Stopped playback")
def seek(self, time_seconds: float): def seek(self, time_seconds: float):
@@ -225,10 +310,110 @@ class VLChanPlayer:
"""Set fullscreen mode.""" """Set fullscreen mode."""
self.player.set_fullscreen(fullscreen) 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): def cleanup(self):
"""Clean up resources.""" """Clean up resources."""
self.stop_sync() self.stop_sync()
self.stop() 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.player.release()
self.instance.release() self.instance.release()
print("Cleaned up VLC player") print("Cleaned up VLC player")
@@ -239,14 +424,20 @@ def main():
import sys import sys
if len(sys.argv) < 2: if len(sys.argv) < 2:
print("Usage: python -m vlchan.player <video_path> [synchan_url]") print("Usage: python -m vlchan.player <video_path> [synchan_url] [--loop]")
sys.exit(1) sys.exit(1)
video_path = sys.argv[1] video_path = sys.argv[1]
synchan_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:3000" 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 # Create player with looping if specified
player = VLChanPlayer(synchan_url, video_path) player = VLChanPlayer(synchan_url, video_path)
print(f"Created player with looping {'enabled' if loop else 'disabled'}")
# Set up state change callback # Set up state change callback
def on_state_change(state: SynchanState): def on_state_change(state: SynchanState):