Use state machine for syncing playback

This commit is contained in:
Peter Stockings
2026-03-03 23:15:19 +11:00
parent 0e7f80ff1f
commit 43929ea94d
2 changed files with 76 additions and 39 deletions

View File

@@ -105,8 +105,8 @@ class VlcSyncApp(QMainWindow):
self.local_file_path = None self.local_file_path = None
self.lobby_widget.clear_file() self.lobby_widget.clear_file()
def _on_room_sync_action(self, action, position_s): def _on_room_sync_action(self, action, position_s, req_id):
self.sync_client.send_message({"type": "sync", "action": action, "position": position_s}) self.sync_client.send_message({"type": "sync", "action": action, "position": position_s, "req_id": req_id})
def _on_room_chat(self, text): def _on_room_chat(self, text):
self.sync_client.send_message({"type": "chat", "message": text}) self.sync_client.send_message({"type": "chat", "message": text})

View File

@@ -6,12 +6,20 @@ from PyQt6.QtWidgets import (
) )
from PyQt6.QtCore import Qt, pyqtSignal, QTimer from PyQt6.QtCore import Qt, pyqtSignal, QTimer
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
import uuid
from vlc_player import VLCSyncPlayer from vlc_player import VLCSyncPlayer
class ExpectedVlcEvent:
def __init__(self, action: str, req_id: str, target_val=None):
self.action = action
self.req_id = req_id
self.target_val = target_val
self.timestamp = datetime.datetime.now()
class RoomWidget(QWidget): class RoomWidget(QWidget):
leave_requested = pyqtSignal() leave_requested = pyqtSignal()
sync_action_requested = pyqtSignal(str, float) # action, position_s sync_action_requested = pyqtSignal(str, float, str) # action, position_s, req_id
chat_message_ready = pyqtSignal(str) # raw message text chat_message_ready = pyqtSignal(str) # raw message text
def __init__(self): def __init__(self):
@@ -19,7 +27,7 @@ class RoomWidget(QWidget):
self.username = "" self.username = ""
self.room_code = "" self.room_code = ""
self.ignore_vlc_events = False self.expected_vlc_events = []
self.last_reported_time_ms = 0 self.last_reported_time_ms = 0
self._setup_ui() self._setup_ui()
@@ -210,16 +218,38 @@ class RoomWidget(QWidget):
toast.deleteLater() toast.deleteLater()
QTimer.singleShot(1500, reset) QTimer.singleShot(1500, reset)
def _tell_vlc_and_expect(self, action: str, position_s: float):
req_id = str(uuid.uuid4())[:8]
target_ms = int(position_s * 1000)
# Clean up old expectations (e.g. VLC dropped the event or we missed it)
now = datetime.datetime.now()
self.expected_vlc_events = [e for e in self.expected_vlc_events
if (now - e.timestamp).total_seconds() < 3.0]
self.expected_vlc_events.append(ExpectedVlcEvent(action, req_id, target_ms))
if action == "play":
self.vlc_player.seek(target_ms)
self.vlc_player.play()
self.play_btn.setText("")
elif action == "pause":
self.vlc_player.seek(target_ms)
self.vlc_player.pause()
self.play_btn.setText("")
elif action == "seek":
self.vlc_player.seek(target_ms)
return req_id
def toggle_playback(self): def toggle_playback(self):
position_s = self.vlc_player.current_time_ms / 1000.0 position_s = self.vlc_player.current_time_ms / 1000.0
if self.vlc_player.is_playing: if self.vlc_player.is_playing:
self.vlc_player.pause() req = self._tell_vlc_and_expect("pause", position_s)
self.play_btn.setText("") self.sync_action_requested.emit("pause", position_s, req)
self.sync_action_requested.emit("pause", position_s)
else: else:
self.vlc_player.play() req = self._tell_vlc_and_expect("play", position_s)
self.play_btn.setText("") self.sync_action_requested.emit("play", position_s, req)
self.sync_action_requested.emit("play", position_s)
def on_vlc_time(self, time_ms: int): def on_vlc_time(self, time_ms: int):
length_ms = self.vlc_player.get_length() length_ms = self.vlc_player.get_length()
@@ -235,17 +265,39 @@ class RoomWidget(QWidget):
self.seekbar.setValue(progress) self.seekbar.setValue(progress)
self.seekbar.blockSignals(False) self.seekbar.blockSignals(False)
if self.last_reported_time_ms is not None: if self.last_reported_time_ms is not None:
diff = abs(time_ms - self.last_reported_time_ms) diff = abs(time_ms - self.last_reported_time_ms)
if diff > 2500 and not self.ignore_vlc_events:
self.sync_action_requested.emit("seek", time_ms / 1000.0) if diff > 1500:
self.last_reported_time_ms = time_ms # Look for a pending seek expectation
matched = False
for i, expected in enumerate(self.expected_vlc_events):
if expected.action == "seek" and (expected.target_val is None or abs(expected.target_val - time_ms) < 2000):
matched = True
self.expected_vlc_events.pop(i)
break
if not matched:
# Genuine user scrub!
req = str(uuid.uuid4())[:8]
self.sync_action_requested.emit("seek", time_ms / 1000.0, req)
self.last_reported_time_ms = time_ms
def on_vlc_state(self, playing: bool, time_ms: int): def on_vlc_state(self, playing: bool, time_ms: int):
if self.ignore_vlc_events:
return
action = "play" if playing else "pause" action = "play" if playing else "pause"
self.sync_action_requested.emit(action, time_ms / 1000.0)
# Look for a pending state change expectation
matched = False
for i, expected in enumerate(self.expected_vlc_events):
if expected.action == action:
matched = True
self.expected_vlc_events.pop(i)
break
if not matched:
req = str(uuid.uuid4())[:8]
self.sync_action_requested.emit(action, time_ms / 1000.0, req)
def on_seekbar_dragged(self, value): def on_seekbar_dragged(self, value):
length_ms = self.vlc_player.get_length() length_ms = self.vlc_player.get_length()
@@ -260,38 +312,23 @@ class RoomWidget(QWidget):
length_ms = self.vlc_player.get_length() length_ms = self.vlc_player.get_length()
if length_ms > 0: if length_ms > 0:
target_ms = int((self.seekbar.value() / 1000.0) * length_ms) target_ms = int((self.seekbar.value() / 1000.0) * length_ms)
self.vlc_player.seek(target_ms) req = self._tell_vlc_and_expect("seek", target_ms / 1000.0)
self.sync_action_requested.emit("seek", target_ms / 1000.0) self.sync_action_requested.emit("seek", target_ms / 1000.0, req)
def on_volume_changed(self, value): def on_volume_changed(self, value):
self.vlc_player.set_volume(value) self.vlc_player.set_volume(value)
# --- Incoming Sync Logic --- # --- Incoming Sync Logic ---
def handle_sync_event(self, msg: dict): def handle_sync_event(self, msg: dict):
self.ignore_vlc_events = True
action = msg.get("action") action = msg.get("action")
if not action: if not action:
if msg.get("playing", False): action = "play" if msg.get("playing", False): action = "play"
elif msg.get("playing") is False: action = "pause" elif msg.get("playing") is False: action = "pause"
position_s = msg.get("position", 0) position_s = msg.get("position", 0)
position_ms = int(position_s * 1000)
if action == "play": if action in ["play", "pause", "seek"]:
self.vlc_player.seek(position_ms) self._tell_vlc_and_expect(action, position_s)
self.vlc_player.play()
self.play_btn.setText("")
elif action == "pause":
self.vlc_player.seek(position_ms)
self.vlc_player.pause()
self.play_btn.setText("")
elif action == "seek":
self.vlc_player.seek(position_ms)
def clear_ignore():
self.ignore_vlc_events = False
QTimer.singleShot(1500, clear_ignore)
username = msg.get("username") username = msg.get("username")
if username and username != self.username: if username and username != self.username:
@@ -385,8 +422,8 @@ class RoomWidget(QWidget):
else: target_ms = float(arg) * 1000 else: target_ms = float(arg) * 1000
target_ms = max(0, min(target_ms, length_ms)) target_ms = max(0, min(target_ms, length_ms))
self.vlc_player.seek(int(target_ms)) req = self._tell_vlc_and_expect("seek", target_ms / 1000.0)
self.sync_action_requested.emit("seek", target_ms / 1000.0) self.sync_action_requested.emit("seek", target_ms / 1000.0, req)
return True return True
except ValueError: except ValueError:
return False return False