feat: fix looping
This commit is contained in:
211
vlchan/player.py
211
vlchan/player.py
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user