feat: fix looping

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

View File

@@ -10,64 +10,140 @@ 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:
synchan_url: URL of the synchan server synchan_url: URL of the synchan server
video_path: Path to video file to play video_path: Path to video file to play
""" """
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
self.synchan_subscription = None self.synchan_subscription = None
# State tracking # State tracking
self._is_playing = False self._is_playing = False
self.last_sync_time = 0 self.last_sync_time = 0
self.sync_threshold = 0.1 # 100ms threshold for sync self.sync_threshold = 0.1 # 100ms threshold for sync
# Playback rate adjustment constants (from VideoPlayer.tsx) # Playback rate adjustment constants (from VideoPlayer.tsx)
self.rate_k = 0.5 # K constant self.rate_k = 0.5 # K constant
self.rate_range = 0.05 # Range limit self.rate_range = 0.05 # Range limit
# 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
media = self.instance.media_new(video_path) media = self.instance.media_new(video_path)
self.player.set_media(media) self.player.set_media(media)
print(f"Loaded video: {video_path}") print(f"Loaded video: {video_path}")
def start_sync(self): def start_sync(self):
"""Start synchronization with synchan server.""" """Start synchronization with synchan server."""
if self.synchan_observable is not None: if self.synchan_observable is not None:
print("Sync already started") print("Sync already started")
return return
self.synchan_observable = create_synchan(self.synchan_url) self.synchan_observable = create_synchan(self.synchan_url)
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")
def stop_sync(self): def stop_sync(self):
"""Stop synchronization with synchan server.""" """Stop synchronization with synchan server."""
if self.synchan_subscription: if self.synchan_subscription:
@@ -75,38 +151,42 @@ class VLChanPlayer:
self.synchan_subscription = None self.synchan_subscription = None
self.synchan_observable = None self.synchan_observable = None
print("Stopped synchan synchronization") print("Stopped synchan synchronization")
def _handle_synchan_state(self, state: SynchanState): def _handle_synchan_state(self, state: SynchanState):
"""Handle state updates from synchan server.""" """Handle state updates from synchan server."""
if self.on_state_change: if self.on_state_change:
self.on_state_change(state) self.on_state_change(state)
# Get current local time # Get current local time
local_time = self.player.get_time() / 1000.0 # Convert to seconds local_time = self.player.get_time() / 1000.0 # Convert to seconds
# Apply latency compensation (similar to VideoPlayer.tsx) # Apply latency compensation (similar to VideoPlayer.tsx)
# 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
diff = compensated_time - local_time diff = compensated_time - local_time
# 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
diff = 0 # Reset diff after seek diff = 0 # Reset diff after seek
# Calculate playback rate adjustment (similar to getPlaybackRate) # Calculate playback rate adjustment (similar to getPlaybackRate)
new_rate = self._get_playback_rate(diff) new_rate = self._get_playback_rate(diff)
# Apply playback rate and play # Apply playback rate and play
self.player.set_rate(new_rate) self.player.set_rate(new_rate)
if not self._is_playing: if not self._is_playing:
@@ -121,31 +201,31 @@ class VLChanPlayer:
self.player.pause() self.player.pause()
self._is_playing = False self._is_playing = False
print("Paused playback") print("Paused playback")
# Set exact time when paused # Set exact time when paused
seek_time = int(state.currentTime * 1000) seek_time = int(state.currentTime * 1000)
self.player.set_time(seek_time) self.player.set_time(seek_time)
print(f"Set paused time to {state.currentTime:.2f}s") print(f"Set paused time to {state.currentTime:.2f}s")
def _handle_synchan_error(self, error): def _handle_synchan_error(self, error):
"""Handle synchan connection errors.""" """Handle synchan connection errors."""
print(f"Synchan error: {error}") print(f"Synchan error: {error}")
# Optionally attempt to reconnect # Optionally attempt to reconnect
threading.Timer(5.0, self.start_sync).start() threading.Timer(5.0, self.start_sync).start()
def _handle_synchan_completed(self): def _handle_synchan_completed(self):
"""Handle synchan connection completion.""" """Handle synchan connection completion."""
print("Synchan connection completed") print("Synchan connection completed")
def _get_playback_rate(self, diff: float) -> float: def _get_playback_rate(self, diff: float) -> float:
"""Calculate playback rate adjustment based on time difference. """Calculate playback rate adjustment based on time difference.
This implements the same logic as getPlaybackRate in VideoPlayer.tsx: This implements the same logic as getPlaybackRate in VideoPlayer.tsx:
return 1 + Math.min(Math.max(diff * 0.5, -range), range); return 1 + Math.min(Math.max(diff * 0.5, -range), range);
Args: Args:
diff: Time difference in seconds (positive = ahead, negative = behind) diff: Time difference in seconds (positive = ahead, negative = behind)
Returns: Returns:
Adjusted playback rate (1.0 = normal speed) Adjusted playback rate (1.0 = normal speed)
""" """
@@ -154,81 +234,186 @@ class VLChanPlayer:
rate_adjustment = max(rate_adjustment, -self.rate_range) rate_adjustment = max(rate_adjustment, -self.rate_range)
rate_adjustment = min(rate_adjustment, self.rate_range) rate_adjustment = min(rate_adjustment, self.rate_range)
return 1.0 + rate_adjustment return 1.0 + rate_adjustment
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")
def pause(self): def pause(self):
"""Pause video playback.""" """Pause video playback."""
self.player.pause() self.player.pause()
self._is_playing = False self._is_playing = False
print("Paused playback") print("Paused playback")
def stop(self): def stop(self):
"""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):
"""Seek to a specific time position.""" """Seek to a specific time position."""
time_ms = int(time_seconds * 1000) time_ms = int(time_seconds * 1000)
self.player.set_time(time_ms) self.player.set_time(time_ms)
print(f"Seeked to {time_seconds:.2f}s") print(f"Seeked to {time_seconds:.2f}s")
def get_position(self) -> float: def get_position(self) -> float:
"""Get current playback position in seconds.""" """Get current playback position in seconds."""
return self.player.get_time() / 1000.0 return self.player.get_time() / 1000.0
def get_compensated_position(self, latency: float) -> float: def get_compensated_position(self, latency: float) -> float:
"""Get latency-compensated position for synchronization. """Get latency-compensated position for synchronization.
Args: Args:
latency: Network latency in milliseconds latency: Network latency in milliseconds
Returns: Returns:
Position adjusted for latency Position adjusted for latency
""" """
return self.get_position() + latency / 1000.0 return self.get_position() + latency / 1000.0
def get_duration(self) -> float: def get_duration(self) -> float:
"""Get video duration in seconds.""" """Get video duration in seconds."""
return self.player.get_length() / 1000.0 return self.player.get_length() / 1000.0
def is_playing(self) -> bool: def is_playing(self) -> bool:
"""Check if video is currently playing.""" """Check if video is currently playing."""
return self.player.is_playing() return self.player.is_playing()
def set_volume(self, volume: int): def set_volume(self, volume: int):
"""Set volume (0-100).""" """Set volume (0-100)."""
self.player.audio_set_volume(int(volume)) self.player.audio_set_volume(int(volume))
def get_volume(self) -> int: def get_volume(self) -> int:
"""Get current volume (0-100).""" """Get current volume (0-100)."""
return self.player.audio_get_volume() return self.player.audio_get_volume()
def get_rate(self) -> float: def get_rate(self) -> float:
"""Get current playback rate.""" """Get current playback rate."""
return self.player.get_rate() return self.player.get_rate()
def set_rate(self, rate: float): def set_rate(self, rate: float):
"""Set playback rate (1.0 = normal speed).""" """Set playback rate (1.0 = normal speed)."""
self.player.set_rate(rate) self.player.set_rate(rate)
def set_fullscreen(self, fullscreen: bool): def set_fullscreen(self, fullscreen: bool):
"""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")
@@ -237,34 +422,40 @@ class VLChanPlayer:
def main(): def main():
"""Example usage of VLChanPlayer.""" """Example usage of VLChanPlayer."""
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]
# Create player 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) 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):
print(f"Synchan state: playing={state.playing}, time={state.currentTime:.2f}s") print(f"Synchan state: playing={state.playing}, time={state.currentTime:.2f}s")
player.on_state_change = on_state_change player.on_state_change = on_state_change
# Start synchronization # Start synchronization
player.start_sync() player.start_sync()
try: try:
# Start playback # Start playback
player.play() player.play()
# Keep running until interrupted # Keep running until interrupted
while True: while True:
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nShutting down...") print("\nShutting down...")
finally: finally: