feat: fix looping
This commit is contained in:
315
vlchan/player.py
315
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 <video_path> [synchan_url]")
|
||||
print("Usage: python -m vlchan.player <video_path> [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:
|
||||
|
||||
Reference in New Issue
Block a user