diff --git a/.gitignore b/.gitignore index 86ab79e..4fbaa9a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,20 @@ bun.lockb .env .DS_Store Thumbs.db + +# Python +__pycache__/ +*.py[cod] +*$py.class +**/.pytest_cache/ + +# Environments (uv / venv) +.venv/ +env/ +venv/ +ENV/ + +# Local media testing files +*.mkv +*.mp4 +*.avi diff --git a/desktop-client/.python-version b/desktop-client/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/desktop-client/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/desktop-client/README.md b/desktop-client/README.md new file mode 100644 index 0000000..e69de29 diff --git a/desktop-client/icon.png b/desktop-client/icon.png new file mode 100644 index 0000000..c19f549 Binary files /dev/null and b/desktop-client/icon.png differ diff --git a/desktop-client/main.py b/desktop-client/main.py new file mode 100644 index 0000000..aaec386 --- /dev/null +++ b/desktop-client/main.py @@ -0,0 +1,745 @@ +import sys +import os +import ctypes +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QStackedWidget, QWidget, QVBoxLayout, + QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox, + QFrame, QSlider +) +from PyQt6.QtCore import Qt, pyqtSignal, QObject, QTimer +from PyQt6.QtGui import QFont, QIcon, QColor +from vlc_player import VLCSyncPlayer +from sync_client import SyncClientThread +import datetime + +class VlcSyncApp(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("VideoSync — Watch Together") + + # Set window icon + icon_path = os.path.join(os.path.dirname(__file__), "icon.png") + if os.path.exists(icon_path): + self.setWindowIcon(QIcon(icon_path)) + + self.setMinimumSize(900, 600) + self.resize(1100, 700) + + # Main stacked widget to switch between Lobby and Room + self.stacked_widget = QStackedWidget() + self.setCentralWidget(self.stacked_widget) + + # Setup Views + self.lobby_view = self.create_lobby_view() + self.room_view = self.create_room_view() + + self.stacked_widget.addWidget(self.lobby_view) + self.stacked_widget.addWidget(self.room_view) + + # State + self.username = "" + self.room_code = "" + self.local_file_path = None + self.local_file_name = None + self.local_file_size = 0 + + self.pending_connect_action = None + self.ignore_vlc_events = False + self.last_reported_time_ms = 0 + + self.sync_client = SyncClientThread("ws://localhost:3000/ws") + self.sync_client.connected.connect(self.on_ws_connected) + self.sync_client.room_joined.connect(self.on_room_joined) + self.sync_client.room_error.connect(self.on_room_error) + self.sync_client.file_check_needed.connect(self.on_file_check_needed) + self.sync_client.users_updated.connect(self.on_users_updated) + self.sync_client.chat_message.connect(self.on_chat_message) + self.sync_client.system_message.connect(self.on_system_message) + self.sync_client.sync_event.connect(self.on_sync_event) + + self.apply_stylesheet() + + def create_lobby_view(self): + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Container for centering + container = QFrame() + container.setObjectName("lobbyCard") + container.setFixedWidth(500) + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(30, 30, 30, 30) + container_layout.setSpacing(15) + + # Brand + title = QLabel("VideoSync") + title.setObjectName("brandTitle") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + tagline = QLabel("Watch together, anywhere") + tagline.setObjectName("brandTagline") + tagline.setAlignment(Qt.AlignmentFlag.AlignCenter) + + container_layout.addWidget(title) + container_layout.addWidget(tagline) + container_layout.addSpacing(20) + + # Username + self.username_input = QLineEdit() + self.username_input.setPlaceholderText("Enter a display name") + container_layout.addWidget(QLabel("YOUR NAME")) + container_layout.addWidget(self.username_input) + + container_layout.addSpacing(20) + + # Actions Layout (Create vs Join) + actions_layout = QHBoxLayout() + actions_layout.setSpacing(20) + + # Create Room Panel + create_panel = QVBoxLayout() + create_panel.addWidget(QLabel("Create a Room")) + self.create_file_btn = QPushButton("Choose Video File") + self.create_file_btn.setObjectName("secondaryBtn") + self.create_file_btn.clicked.connect(self.select_file) + + self.create_file_info = QLabel("") + self.create_file_info.setObjectName("fileInfo") + self.create_file_info.setWordWrap(True) + self.create_file_info.hide() + + self.create_room_btn = QPushButton("Create Room") + self.create_room_btn.setObjectName("primaryBtn") + self.create_room_btn.setEnabled(False) + self.create_room_btn.clicked.connect(self.create_room) + + create_panel.addWidget(self.create_file_btn) + create_panel.addWidget(self.create_file_info) + create_panel.addWidget(self.create_room_btn) + + # Join Room Panel + join_panel = QVBoxLayout() + join_panel.addWidget(QLabel("Join a Room")) + self.room_code_input = QLineEdit() + self.room_code_input.setPlaceholderText("e.g. ABC123") + self.join_room_btn = QPushButton("Join Room") + self.join_room_btn.setObjectName("secondaryBtn") + self.join_room_btn.setEnabled(False) + self.join_room_btn.clicked.connect(self.join_room) + + join_panel.addWidget(self.room_code_input) + join_panel.addWidget(self.join_room_btn) + + actions_layout.addLayout(create_panel) + + # Divider + divider = QLabel("OR") + divider.setAlignment(Qt.AlignmentFlag.AlignCenter) + divider.setObjectName("divider") + actions_layout.addWidget(divider) + + actions_layout.addLayout(join_panel) + + container_layout.addLayout(actions_layout) + layout.addWidget(container) + + # Signals to enable/disable buttons + self.username_input.textChanged.connect(self.check_inputs) + self.room_code_input.textChanged.connect(self.check_inputs) + + return widget + + def create_room_view(self): + widget = QWidget() + layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # --- Left Side: Video --- + video_container = QWidget() + video_layout = QVBoxLayout(video_container) + video_layout.setContentsMargins(0, 0, 0, 0) + video_layout.setSpacing(0) + + # Topbar + self.topbar = QFrame() + self.topbar.setObjectName("topbar") + self.topbar.setFixedHeight(50) + topbar_layout = QHBoxLayout(self.topbar) + + self.room_code_display = QLabel("Room: XXXX") + self.copy_code_btn = QPushButton("📋") + self.copy_code_btn.setFixedSize(30, 30) + self.copy_code_btn.setToolTip("Copy Room Code") + self.copy_code_btn.clicked.connect(self.copy_room_code) + + self.room_file_badge = QLabel("📄 No file") + self.room_file_badge.setObjectName("fileBadge") + + self.leave_btn = QPushButton("Leave Room") + self.leave_btn.setObjectName("dangerBtn") + self.leave_btn.clicked.connect(self.leave_room) + + topbar_layout.addWidget(self.room_code_display) + topbar_layout.addWidget(self.copy_code_btn) + topbar_layout.addStretch() + topbar_layout.addWidget(self.room_file_badge) + topbar_layout.addWidget(self.leave_btn) + + # Video Frame Placeholder + self.video_frame = QFrame() + self.video_frame.setStyleSheet("background-color: black;") + # Fix for Windows QWidgetWindow Error: + # Force the frame to have its own native HWND so VLC can attach to it without complaining it's not a top-level window. + self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True) + + # Controls Bar + controls = QFrame() + controls.setObjectName("controlsBar") + controls.setFixedHeight(60) + controls_layout = QHBoxLayout(controls) + + self.play_btn = QPushButton("▶") + self.play_btn.setFixedSize(40, 40) + self.play_btn.setObjectName("playBtn") + + # Time and SeekBar + self.seekbar = QSlider(Qt.Orientation.Horizontal) + self.seekbar.setRange(0, 1000) + self.seekbar.setObjectName("seekBar") + self.seekbar.sliderMoved.connect(self.on_seekbar_dragged) + self.seekbar.sliderReleased.connect(self.on_seekbar_released) + + self.time_lbl = QLabel("0:00 / 0:00") + + # Volume + self.vol_icon = QLabel("🔊") + self.vol_icon.setObjectName("volIcon") + self.volume_slider = QSlider(Qt.Orientation.Horizontal) + self.volume_slider.setRange(0, 100) + self.volume_slider.setValue(100) + self.volume_slider.setFixedWidth(100) + self.volume_slider.setObjectName("volumeSlider") + self.volume_slider.valueChanged.connect(self.on_volume_changed) + + # Fullscreen + self.fullscreen_btn = QPushButton("⛶") + self.fullscreen_btn.setFixedSize(40, 40) + self.fullscreen_btn.setObjectName("fullscreenBtn") + self.fullscreen_btn.clicked.connect(self.toggle_fullscreen) + + controls_layout.addWidget(self.play_btn) + controls_layout.addWidget(self.seekbar, 1) # SeekBar gets the stretch + controls_layout.addWidget(self.time_lbl) + controls_layout.addSpacing(15) + controls_layout.addWidget(self.vol_icon) + controls_layout.addWidget(self.volume_slider) + controls_layout.addSpacing(10) + controls_layout.addWidget(self.fullscreen_btn) + + video_layout.addWidget(self.topbar) + video_layout.addWidget(self.video_frame) + video_layout.addWidget(controls) + + # --- Right Side: Chat --- + self.chat_container = QFrame() + self.chat_container.setObjectName("chatContainer") + self.chat_container.setFixedWidth(320) + chat_layout = QVBoxLayout(self.chat_container) + + chat_header = QLabel("Live Chat") + chat_header.setObjectName("chatHeader") + + self.users_lbl = QLabel("0 watching") + self.users_lbl.setObjectName("usersLbl") + + self.chat_messages = QLabel("Welcome to the room! 👋\n") + self.chat_messages.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) + self.chat_messages.setWordWrap(True) + # We should really use a QScrollArea or QTextEdit here eventually + + chat_input_layout = QHBoxLayout() + self.chat_input = QLineEdit() + self.chat_input.setPlaceholderText("Send a message...") + self.chat_send_btn = QPushButton("Send") + + chat_input_layout.addWidget(self.chat_input) + chat_input_layout.addWidget(self.chat_send_btn) + + # Chat actions + self.chat_send_btn.clicked.connect(self.send_chat) + self.chat_input.returnPressed.connect(self.send_chat) + + chat_layout.addWidget(chat_header) + chat_layout.addWidget(self.users_lbl) + chat_layout.addWidget(self.chat_messages, 1) # stretch + chat_layout.addLayout(chat_input_layout) + + layout.addWidget(video_container, 1) # stretch + layout.addWidget(self.chat_container) + + # Instantiate the VLC Player Wrapper + self.vlc_player = VLCSyncPlayer(self.video_frame) + self.vlc_player.signals.time_changed.connect(self.on_vlc_time) + self.vlc_player.signals.state_changed.connect(self.on_vlc_state) + + self.play_btn.clicked.connect(self.toggle_playback) + + return widget + + def toggle_fullscreen(self): + if self.isFullScreen(): + self.showNormal() + self.fullscreen_btn.setText("⛶") + self.chat_container.show() + self.topbar.show() + else: + self.showFullScreen() + self.fullscreen_btn.setText("🗗") + self.chat_container.hide() + self.topbar.hide() + + def toggle_playback(self): + position_s = self.vlc_player.current_time_ms / 1000.0 + if self.vlc_player.is_playing: + self.vlc_player.pause() + self.play_btn.setText("▶") + self.sync_client.send_message({"type": "sync", "action": "pause", "position": position_s}) + else: + self.vlc_player.play() + self.play_btn.setText("⏸") + self.sync_client.send_message({"type": "sync", "action": "play", "position": position_s}) + + def on_vlc_time(self, time_ms: int): + length_ms = self.vlc_player.get_length() + if length_ms > 0: + def fmt(ms): + s = max(0, ms) // 1000 + return f"{s//60}:{s%60:02d}" + + if not self.seekbar.isSliderDown(): + self.time_lbl.setText(f"{fmt(time_ms)} / {fmt(length_ms)}") + progress = int((time_ms / length_ms) * 1000) + self.seekbar.blockSignals(True) + self.seekbar.setValue(progress) + self.seekbar.blockSignals(False) + + if self.last_reported_time_ms is not None: + diff = abs(time_ms - self.last_reported_time_ms) + if diff > 2500 and not self.ignore_vlc_events: + self.sync_client.send_message({"type": "sync", "action": "seek", "position": time_ms / 1000.0}) + self.last_reported_time_ms = time_ms + + def on_vlc_state(self, playing: bool, time_ms: int): + if self.ignore_vlc_events: + return + action = "play" if playing else "pause" + self.sync_client.send_message({"type": "sync", "action": action, "position": time_ms / 1000.0}) + + def on_seekbar_dragged(self, value): + length_ms = self.vlc_player.get_length() + if length_ms > 0: + target_ms = int((value / 1000.0) * length_ms) + def fmt(ms): + s = max(0, ms) // 1000 + return f"{s//60}:{s%60:02d}" + self.time_lbl.setText(f"{fmt(target_ms)} / {fmt(length_ms)}") + + def on_seekbar_released(self): + length_ms = self.vlc_player.get_length() + if length_ms > 0: + target_ms = int((self.seekbar.value() / 1000.0) * length_ms) + self.vlc_player.seek(target_ms) + self.sync_client.send_message({"type": "sync", "action": "seek", "position": target_ms / 1000.0}) + + def on_volume_changed(self, value): + self.vlc_player.set_volume(value) + + def select_file(self): + file_path, _ = QFileDialog.getOpenFileName( + self, "Select Video File", "", "Video Files (*.mp4 *.mkv *.avi *.mov *.webm);;All Files (*)" + ) + if file_path: + self.local_file_path = file_path + self.local_file_name = os.path.basename(file_path) + self.local_file_size = os.path.getsize(file_path) + + size_mb = self.local_file_size / (1024 * 1024) + self.create_file_info.setText(f"{self.local_file_name}\n{size_mb:.1f} MB") + self.create_file_info.show() + self.check_inputs() + + def copy_room_code(self): + if self.room_code: + QApplication.clipboard().setText(self.room_code) + self.copy_code_btn.setText("✓") + QTimer.singleShot(2000, lambda: self.copy_code_btn.setText("📋")) + + def check_inputs(self): + has_name = len(self.username_input.text().strip()) > 0 + has_file = self.local_file_path is not None + has_code = len(self.room_code_input.text().strip()) >= 4 + + self.create_room_btn.setEnabled(has_name and has_file) + self.join_room_btn.setEnabled(has_name and has_code) + + def create_room(self): + self.username = self.username_input.text().strip() + self.create_room_btn.setText("Connecting...") + self.create_room_btn.setEnabled(False) + self.join_room_btn.setEnabled(False) + + self.pending_connect_action = lambda: self.sync_client.send_message({ + "type": "create_room", + "username": self.username, + "fileInfo": { + "name": self.local_file_name, + "size": self.local_file_size, + "duration": 0 + } + }) + + if self.sync_client.running and self.sync_client.ws: + self.on_ws_connected() + else: + self.sync_client.start() + + def join_room(self): + self.username = self.username_input.text().strip() + self.room_code = self.room_code_input.text().strip().upper() + self.join_room_btn.setText("Connecting...") + self.join_room_btn.setEnabled(False) + self.create_room_btn.setEnabled(False) + + self.pending_connect_action = lambda: self.sync_client.send_message({ + "type": "join_room", + "username": self.username, + "code": self.room_code + }) + + if self.sync_client.running and self.sync_client.ws: + self.on_ws_connected() + else: + self.sync_client.start() + + def leave_room(self): + self.vlc_player.stop() + self.sync_client.stop() + self.stacked_widget.setCurrentIndex(0) + self.local_file_path = None + self.create_file_info.hide() + self.room_code = "" + self.check_inputs() + self.create_room_btn.setText("Create Room") + self.join_room_btn.setText("Join Room") + + # --- WebSocket Callbacks --- + def on_ws_connected(self): + if self.pending_connect_action: + self.pending_connect_action() + self.pending_connect_action = None + + def on_room_error(self, msg: str): + QMessageBox.critical(self, "Room Error", msg) + self.create_room_btn.setText("Create Room") + self.join_room_btn.setText("Join Room") + self.check_inputs() + self.sync_client.stop() + + def on_file_check_needed(self, msg: dict): + # Defer execution to the PyQt Main Thread to avoid deadlocking the WebSocket thread + QTimer.singleShot(0, lambda: self._handle_file_check(msg)) + + def _handle_file_check(self, msg: dict): + req_name = msg["fileInfo"].get("name", "Unknown") + req_size = msg["fileInfo"].get("size", 0) + + QMessageBox.information(self, "File Required", f"To join this room, you need to select:\\n\\nName: {req_name}\\nSize: {req_size / (1024*1024):.1f} MB") + + file_path, _ = QFileDialog.getOpenFileName( + self, f"Select {req_name}", "", "Video Files (*.mp4 *.mkv *.avi *.mov *.webm);;All Files (*)" + ) + + if file_path: + self.local_file_path = file_path + self.local_file_name = os.path.basename(file_path) + self.local_file_size = os.path.getsize(file_path) + + if self.local_file_name == req_name and self.local_file_size == req_size: + self.sync_client.send_message({ + "type": "confirm_join", + "fileInfo": { + "name": self.local_file_name, + "size": self.local_file_size, + "duration": 0 + } + }) + else: + QMessageBox.critical(self, "File Mismatch", "The selected file does not exactly match the room's required file.") + self.sync_client.stop() + self.join_room_btn.setText("Join Room") + self.check_inputs() + else: + self.sync_client.stop() + self.join_room_btn.setText("Join Room") + self.check_inputs() + + def on_room_joined(self, msg: dict): + if "room" in msg: + self.room_code = msg["room"]["code"] + else: + self.room_code = msg.get("code", "") + + self.stacked_widget.setCurrentIndex(1) + self.room_code_display.setText(f"Room: {self.room_code}") + self.room_file_badge.setText(f"📄 {self.local_file_name}") + self.create_room_btn.setText("Create Room") + self.join_room_btn.setText("Join Room") + self.chat_messages.setText("Welcome to the room! 👋
") + + if self.local_file_path: + self.vlc_player.load_media(self.local_file_path) + + users = msg.get("users", []) + if users: + self.on_users_updated(users) + + state = msg.get("state", {}) + if state: + self.on_sync_event(state) + + chat_history = msg.get("chatHistory", []) + if chat_history: + self.chat_messages.setText("Welcome to the room! 👋
") + for chat in chat_history: + self.on_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0)) + + def on_chat_message(self, author: str, text: str, timestamp: int): + try: + dt = datetime.datetime.fromtimestamp(timestamp / 1000.0) + except (ValueError, OSError, TypeError): + dt = datetime.datetime.now() + + time_str = dt.strftime("%I:%M %p") + + current = self.chat_messages.text() + new_msg = f"{author}: {text} {time_str}
" + self.chat_messages.setText(current + new_msg) + + def on_system_message(self, text: str): + current = self.chat_messages.text() + new_msg = f"{text}
" + self.chat_messages.setText(current + new_msg) + + def on_users_updated(self, users: list): + self.users_lbl.setText(f"{len(users)} watching: {', '.join(users)}") + + def on_sync_event(self, msg: dict): + self.ignore_vlc_events = True + + action = msg.get("action") + # Handle full state sync vs event sync + if not action: + if msg.get("playing", False): + action = "play" + elif msg.get("playing") is False: + action = "pause" + + position_s = msg.get("position", 0) + position_ms = int(position_s * 1000) + + if action == "play": + self.vlc_player.seek(position_ms) + 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) + + # System notification + username = msg.get("username") + if username and username != self.username: + if action == "play": + self.on_system_message(f"{username} pressed play") + elif action == "pause": + self.on_system_message(f"{username} paused") + elif action == "seek": + def fmt(s): return f"{int(s)//60}:{int(s)%60:02d}" + self.on_system_message(f"{username} seeked to {fmt(position_s)}") + + def send_chat(self): + text = self.chat_input.text().strip() + if text: + self.sync_client.send_message({"type": "chat", "message": text}) + self.chat_input.setText("") + + def apply_stylesheet(self): + self.setStyleSheet(""" + QWidget { + background-color: #0f0f0f; + color: #f1f1f1; + font-family: 'Segoe UI', 'Roboto', sans-serif; + font-size: 14px; + } + + #lobbyCard { + background-color: #1a1a1a; + border: 1px solid #333; + border-radius: 12px; + } + + #brandTitle { + font-size: 32px; + font-weight: bold; + color: #3ea6ff; + } + + #brandTagline { + font-size: 14px; + color: #aaaaaa; + } + + QLabel { + color: #aaaaaa; + font-weight: bold; + font-size: 12px; + } + + #fileInfo { + background-color: rgba(62, 166, 255, 0.1); + color: #3ea6ff; + padding: 10px; + border-radius: 6px; + font-weight: normal; + font-size: 13px; + } + + QLineEdit { + background-color: #272727; + border: 1px solid #333; + border-radius: 6px; + padding: 10px; + color: white; + font-size: 14px; + } + + QLineEdit:focus { + border: 1px solid #3ea6ff; + } + + QPushButton { + border-radius: 6px; + padding: 10px 16px; + font-weight: bold; + font-size: 14px; + } + + #primaryBtn { + background-color: #3ea6ff; + color: black; + border: none; + } + #primaryBtn:hover { + background-color: #65b8ff; + } + #primaryBtn:disabled { + background-color: #333; + color: #666; + } + + #secondaryBtn { + background-color: #272727; + color: white; + border: 1px solid #444; + } + #secondaryBtn:hover { + background-color: #333; + } + #secondaryBtn:disabled { + border-color: #222; + color: #666; + } + + #dangerBtn { + background-color: transparent; + color: #ff4e45; + border: 1px solid #ff4e45; + padding: 6px 12px; + font-size: 12px; + } + #dangerBtn:hover { + background-color: #ff4e45; + color: white; + } + + #divider { + color: #555; + } + + #topbar { + background-color: #1a1a1a; + border-bottom: 1px solid #333; + } + + #fileBadge { + background-color: #272727; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: normal; + } + + #controlsBar { + background-color: #1a1a1a; + } #playBtn { + background: transparent; + color: white; + font-size: 20px; + border: none; + } #playBtn:hover { + background: #333; + } + + #chatContainer { + background-color: #111; + border-left: 1px solid #333; + } + + #chatHeader { + font-size: 16px; + color: white; + padding: 10px 0; + } + + #usersLbl { + color: #3ea6ff; + font-size: 12px; + } + """) + +if __name__ == "__main__": + # Tell Windows this is a distinct app so the taskbar icon updates correctly + if os.name == 'nt': + myappid = 'vlcsync.desktopclient.app.1' + try: + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + except Exception: + pass + + app = QApplication(sys.argv) + + # Set app-level icon + icon_path = os.path.join(os.path.dirname(__file__), "icon.png") + if os.path.exists(icon_path): + app.setWindowIcon(QIcon(icon_path)) + + window = VlcSyncApp() + window.show() + sys.exit(app.exec()) diff --git a/desktop-client/pyproject.toml b/desktop-client/pyproject.toml new file mode 100644 index 0000000..5fb9718 --- /dev/null +++ b/desktop-client/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "python-app" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "pyqt6>=6.10.2", + "python-vlc>=3.0.21203", + "websockets>=16.0", +] diff --git a/desktop-client/sync_client.py b/desktop-client/sync_client.py new file mode 100644 index 0000000..3f6ed8a --- /dev/null +++ b/desktop-client/sync_client.py @@ -0,0 +1,91 @@ +import asyncio +import json +import websockets +from PyQt6.QtCore import QThread, pyqtSignal + +class SyncClientThread(QThread): + # Signals from WebSocket -> PyQt UI + connected = pyqtSignal() + disconnected = pyqtSignal() + room_joined = pyqtSignal(dict) + room_error = pyqtSignal(str) + file_check_needed = pyqtSignal(dict) # msg + users_updated = pyqtSignal(list) + chat_message = pyqtSignal(str, str, int) # author, text, timestamp + system_message = pyqtSignal(str) + sync_event = pyqtSignal(dict) + + def __init__(self, url="ws://localhost:3000/ws"): + super().__init__() + self.url = url + self.ws = None + self.loop = None + self.running = False + + def run(self): + """Runs strictly within the newly created QThread""" + self.running = True + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.loop.run_until_complete(self._connect_and_listen()) + self.loop.close() + + def stop(self): + self.running = False + if self.ws and self.loop: + asyncio.run_coroutine_threadsafe(self.ws.close(), self.loop) + self.quit() + self.wait() + + def send_message(self, message: dict): + """Called safely from the main PyQt thread to send data out""" + if self.ws and self.loop: + json_str = json.dumps(message) + asyncio.run_coroutine_threadsafe(self.ws.send(json_str), self.loop) + + async def _connect_and_listen(self): + while self.running: + try: + async with websockets.connect(self.url) as ws: + self.ws = ws + self.connected.emit() + + try: + async for message in ws: + if not self.running: + break + self._handle_message(json.loads(message)) + except websockets.ConnectionClosed: + pass + + except Exception as e: + print(f"WebSocket Error: {e}") + + self.disconnected.emit() + if self.running: + # Reconnect backoff + await asyncio.sleep(2) + + def _handle_message(self, msg: dict): + t = msg.get("type") + + if t == "room_created": + self.room_joined.emit(msg) + + elif t == "room_joined" or t == "room_rejoined": + self.room_joined.emit(msg) + + elif t == "error": + self.room_error.emit(msg.get("message", "Unknown error")) + + elif t == "room_file_check": + self.file_check_needed.emit(msg) + + elif t in ["user_joined", "user_left"]: + self.users_updated.emit(msg.get("users", [])) + + elif t == "chat": + self.chat_message.emit(msg.get("username", "Unknown"), msg.get("message", ""), msg.get("timestamp", 0)) + + elif t == "sync": + self.sync_event.emit(msg) diff --git a/desktop-client/test_app.py b/desktop-client/test_app.py new file mode 100644 index 0000000..506c7ba --- /dev/null +++ b/desktop-client/test_app.py @@ -0,0 +1,60 @@ +import pytest +from PyQt6.QtCore import Qt +from main import VlcSyncApp + +def test_app_ui_flow(qtbot): + # Initialize the main application + app = VlcSyncApp() + qtbot.addWidget(app) + + # 1. Test Lobby View + assert app.stacked_widget.currentIndex() == 0 + assert not app.create_room_btn.isEnabled() + + # Fill out the Lobby Form + app.username_input.setText("PyTestUser") + + # Mocking file selection instead of opening the native dialog + app.local_file_path = "test_pytest.mkv" + app.local_file_name = "test_pytest.mkv" + app.local_file_size = 1048576 + app.check_inputs() + + # Button should now be active + assert app.create_room_btn.isEnabled() + + # 2. Test Creating Room matches integration pipeline + qtbot.mouseClick(app.create_room_btn, Qt.MouseButton.LeftButton) + + # Wait for the WebSocket connected signal, the room_created server response, and UI transition + def check_room_joined(): + assert app.stacked_widget.currentIndex() == 1 + assert len(app.room_code) > 2 + + qtbot.waitUntil(check_room_joined, timeout=5000) + + # 3. Test Chat flow End-to-End + # Type a message + qtbot.keyClicks(app.chat_input, "Automated UI Test Message") + # Click Send + qtbot.mouseClick(app.chat_send_btn, Qt.MouseButton.LeftButton) + + # Wait until the Bun server grabs the websocket payload, stores it, and broadcasts it back to the UI! + def check_chat_received(): + assert "Automated UI Test Message" in app.chat_messages.text() + + qtbot.waitUntil(check_chat_received, timeout=3000) + + # 4. Test Playback Sync (UI updates and internal flags) + assert app.play_btn.text() == "▶" + + # Click Play + qtbot.mouseClick(app.play_btn, Qt.MouseButton.LeftButton) + + def check_playback_started(): + assert app.play_btn.text() == "⏸" + + qtbot.waitUntil(check_playback_started, timeout=2000) + + # Clean up background threads + app.leave_room() diff --git a/desktop-client/test_integration.py b/desktop-client/test_integration.py new file mode 100644 index 0000000..933cabe --- /dev/null +++ b/desktop-client/test_integration.py @@ -0,0 +1,145 @@ +import asyncio +import json +import websockets +import sys + +async def run_integration_test(): + url = "ws://localhost:3000/ws" + + print("🤖 Test: Starting Integration Test Suite...") + + # Client 1: Creator + try: + creator_ws = await websockets.connect(url) + print("✅ Creator connected to WebSocket") + except Exception as e: + print(f"❌ Failed to connect to server: {e}") + sys.exit(1) + + creator_msg = { + "type": "create_room", + "username": "TestCreator", + "fileInfo": { + "name": "test_video.mkv", + "size": 1048576, # 1MB + "duration": 0 + } + } + await creator_ws.send(json.dumps(creator_msg)) + print("📤 Creator sent 'create_room'") + + room_code = None + + # Wait for room_created + try: + response_str = await asyncio.wait_for(creator_ws.recv(), timeout=2.0) + response = json.loads(response_str) + if response.get("type") == "room_created": + room_code = response.get("code") + print(f"✅ Creator received room_created with code: {room_code}") + else: + print(f"❌ Unexpected response for creator: {response}") + sys.exit(1) + except asyncio.TimeoutError: + print("❌ Timeout waiting for 'room_created'") + sys.exit(1) + + # Client 2: Joiner + try: + joiner_ws = await websockets.connect(url) + print("✅ Joiner connected to WebSocket") + except Exception as e: + print(f"❌ Joiner failed to connect to server: {e}") + sys.exit(1) + + joiner_msg = { + "type": "join_room", + "username": "TestJoiner", + "code": room_code + } + + await joiner_ws.send(json.dumps(joiner_msg)) + print(f"📤 Joiner sent 'join_room' for code: {room_code}") + + # Wait for file_check needed + try: + response_str = await asyncio.wait_for(joiner_ws.recv(), timeout=2.0) + response = json.loads(response_str) + if response.get("type") == "room_file_check": + print(f"✅ Joiner received room_file_check from server. Payload: {response}") + + # Send file confirmation + confirm_msg = { + "type": "confirm_join", + "fileInfo": { + "name": "test_video.mkv", + "size": 1048576, + "duration": 0 + } + } + await joiner_ws.send(json.dumps(confirm_msg)) + print("📤 Joiner sent 'confirm_join'") + + # Wait for successful room_joined + response_str = await asyncio.wait_for(joiner_ws.recv(), timeout=2.0) + response = json.loads(response_str) + if response.get("type") == "room_joined": + print(f"✅ Joiner successfully received room_joined: {list(response.keys())}") + + # Check users payload from the join response itself + users = response.get("users", []) + print(f"✅ Joiner received user list from room_joined payload: {users}") + assert len(users) == 2, "Expected 2 users in the room!" + + # Setup wait task for the Joiner to receive a Play sync event + print("⏳ Creator is sending a Play sync event...") + await creator_ws.send(json.dumps({ + "type": "sync", + "action": "play", + "position": 5.0 + })) + + # Creator will get an echo, Joiner will get the broadcast + creator_echo = json.loads(await asyncio.wait_for(creator_ws.recv(), timeout=2.0)) + joiner_sync = json.loads(await asyncio.wait_for(joiner_ws.recv(), timeout=2.0)) + + if joiner_sync.get("type") == "sync": + print(f"✅ Joiner received sync event: {joiner_sync}") + assert joiner_sync.get("action") == "play", "Expected 'play' sync event" + assert joiner_sync.get("position") == 5.0, "Expected position 5.0" + else: + print(f"❌ Joiner expected sync, got: {joiner_sync}") + sys.exit(1) + + # Setup wait task for the Joiner to send a Chat message + print("⏳ Joiner is sending a Chat message...") + await joiner_ws.send(json.dumps({ + "type": "chat", + "message": "Hello from integration test!" + })) + + creator_chat = json.loads(await asyncio.wait_for(creator_ws.recv(), timeout=2.0)) + joiner_chat = json.loads(await asyncio.wait_for(joiner_ws.recv(), timeout=2.0)) + + if creator_chat.get("type") == "chat": + print(f"✅ Creator received chat event: {creator_chat}") + assert creator_chat.get("username") == "TestJoiner", "Expected TestJoiner author" + assert creator_chat.get("message") == "Hello from integration test!", "Expected correct message" + else: + print(f"❌ Creator expected chat, got: {creator_chat}") + sys.exit(1) + + else: + print(f"❌ Joiner expected room_joined, got: {response}") + else: + print(f"❌ Joiner expected room_file_check, got: {response}") + except asyncio.TimeoutError: + print("❌ Timeout waiting for server response to 'join_room'") + sys.exit(1) + + await creator_ws.close() + await joiner_ws.close() + print("🎉 Integration test completed successfully!") + +if __name__ == "__main__": + asyncio.run(run_integration_test()) diff --git a/desktop-client/uv.lock b/desktop-client/uv.lock new file mode 100644 index 0000000..1ed9a5c --- /dev/null +++ b/desktop-client/uv.lock @@ -0,0 +1,99 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "pyqt6" +version = "6.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyqt6-qt6" }, + { name = "pyqt6-sip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/03/e756f52e8b0d7bb5527baf8c46d59af0746391943bdb8655acba22ee4168/pyqt6-6.10.2.tar.gz", hash = "sha256:6c0db5d8cbb9a3e7e2b5b51d0ff3f283121fa27b864db6d2f35b663c9be5cc83", size = 1085573, upload-time = "2026-01-08T16:40:00.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/3f/f073a980969aa485ef288eb2e3b94c223ba9c7ac9941543f19b51659b98d/pyqt6-6.10.2-cp39-abi3-macosx_10_14_universal2.whl", hash = "sha256:37ae7c1183fe4dd0c6aefd2006a35731245de1cb6f817bb9e414a3e4848dfd6d", size = 60244482, upload-time = "2026-01-08T16:38:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/3e/9a015651ec71cea2e2f960c37edeb21623ba96a74956c0827def837f7c6b/pyqt6-6.10.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:78e1b3d5763e4cbc84485aef600e0aba5e1932fd263b716f92cd1a40dfa5e924", size = 37899440, upload-time = "2026-01-08T16:39:09.027Z" }, + { url = "https://files.pythonhosted.org/packages/51/74/a88fec2b99700270ca5d7dc7d650236a4990ed6fc88e055ca0fc8a339ee3/pyqt6-6.10.2-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bbc3af541bbecd27301bfe69fe445aa1611a9b490bd3de77306b12df632f7ec6", size = 40748467, upload-time = "2026-01-08T16:39:29.551Z" }, + { url = "https://files.pythonhosted.org/packages/75/34/be7a55529607b21db00a49ca53cb07c3092d2a5a95ea19bb95cfa0346904/pyqt6-6.10.2-cp39-abi3-win_amd64.whl", hash = "sha256:bd328cb70bc382c48861cd5f0a11b2b8ae6f5692d5a2d6679ba52785dced327b", size = 26015391, upload-time = "2026-01-08T16:39:42.946Z" }, + { url = "https://files.pythonhosted.org/packages/af/de/d9c88f976602b7884fec4ad54a4575d48e23e4f390e5357ea83917358846/pyqt6-6.10.2-cp39-abi3-win_arm64.whl", hash = "sha256:7901ba1df024b7ee9fdacfb2b7661aeb3749ae8b0bef65428077de3e0450eabb", size = 26208415, upload-time = "2026-01-08T16:39:57.751Z" }, +] + +[[package]] +name = "pyqt6-qt6" +version = "6.10.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/eb/f04d547d8ed9f20c7b246db4ef5d93b49cab4692009a10652ed0a8b9d2aa/pyqt6_qt6-6.10.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:5761cfccc721da2311c3f1213577f0ff1df07bbbbe3fa3a209a256b82cf057e3", size = 68688870, upload-time = "2026-01-29T12:26:48.619Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c8/d99e65ab01c2402fb6bc4f77abef7244f7d5fb2f2e6d5b0abdf71bb2e4fc/pyqt6_qt6-6.10.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6dda853a8db1b8d1a2ddbbe76cc6c3aa86614cad14056bd3c0435d8feea73b2d", size = 62512013, upload-time = "2026-01-29T12:27:24.642Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/01fd9b9d2ca139ef61582f2e2da249fa169229144294c1bb27db59ad8420/pyqt6_qt6-6.10.2-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:19c10b5f0806e9f9bac2c9759bd5d7d19a78967f330fd60a2db409177fa76e49", size = 84028760, upload-time = "2026-01-29T12:28:03.267Z" }, + { url = "https://files.pythonhosted.org/packages/f4/20/a0d027ebb267d3afaf319d94efe1ff4d667004ee83b96701329a4d11fb95/pyqt6_qt6-6.10.2-py3-none-manylinux_2_39_aarch64.whl", hash = "sha256:2e60d616861ca4565cd295418d605975aa2dc407ba4b94c1586a70c92e9cb052", size = 83063975, upload-time = "2026-01-29T12:28:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/06/8e/595f215876d507417cc8565e05519916d3b0b76baedea6a1e4e5105633fc/pyqt6_qt6-6.10.2-py3-none-win_amd64.whl", hash = "sha256:c4b7f7d66cc58bddf1bc1ca28dfcf7a45f58cfcb11d81d13a0510409dd4957ac", size = 78433821, upload-time = "2026-01-29T12:29:35.493Z" }, + { url = "https://files.pythonhosted.org/packages/50/5f/2196e2b536217b87cb3d2ce13ef8f7607d08b02f1990a4bd84a88d293a3c/pyqt6_qt6-6.10.2-py3-none-win_arm64.whl", hash = "sha256:7164a6f0c1335358a3026df9865c8f75395b01f60f0dcd2f66c029ec16fc83d2", size = 58354426, upload-time = "2026-01-29T12:30:02.95Z" }, +] + +[[package]] +name = "pyqt6-sip" +version = "13.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/7d/d2916048e2e3960f68cb4e93907639844f7b8ff95897dcc98553776ccdfc/pyqt6_sip-13.11.0.tar.gz", hash = "sha256:d463af37738bda1856c9ef513e5620a37b7a005e9d589c986c3304db4a8a14d3", size = 92509, upload-time = "2026-01-13T16:01:32.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/28/a5178c8e005bafbf9c0fd507f45a3eef619ab582811414a0a461ee75994f/pyqt6_sip-13.11.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4dc9c4df24af0571423c3e85b5c008bad42ed48558eef80fbc3e5d30274c5abb", size = 112431, upload-time = "2026-01-13T16:01:23.832Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/02770b02b5a05779e26bd02c202c2fd32aa38e225d01f14c06908e33738c/pyqt6_sip-13.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c974d5a193f32e55e746e9b63138503163ac63500dbb1fd67233d8a8d71369bd", size = 301236, upload-time = "2026-01-13T16:01:28.733Z" }, + { url = "https://files.pythonhosted.org/packages/40/47/5af493a698cc520581ca1000b4ab09b8182992053ffe2478062dde5e4671/pyqt6_sip-13.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4284540ffccd8349763ddce3518264dde62f20556720d4061b9c895e09011ca0", size = 323919, upload-time = "2026-01-13T16:01:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2d/64b26e21183a7ff180105871dd5983a8da539d8768921728268dc6d0a73d/pyqt6_sip-13.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:9bd81cb351640abc803ea2fe7262b5adea28615c9b96fd103d1b6f3459937211", size = 55078, upload-time = "2026-01-13T16:01:29.853Z" }, + { url = "https://files.pythonhosted.org/packages/7e/36/23f699fa8b1c3fcc312ecd12661a1df6057d92e16d4def2399b59cf7bf22/pyqt6_sip-13.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:cd95ec98f8edb15bcea832b8657809f69d758bc4151cc6fd7790c0181949e45f", size = 49465, upload-time = "2026-01-13T16:01:31.174Z" }, +] + +[[package]] +name = "python-app" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pyqt6" }, + { name = "python-vlc" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pyqt6", specifier = ">=6.10.2" }, + { name = "python-vlc", specifier = ">=3.0.21203" }, + { name = "websockets", specifier = ">=16.0" }, +] + +[[package]] +name = "python-vlc" +version = "3.0.21203" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/5b/f9ce6f0c9877b6fe5eafbade55e0dcb6b2b30f1c2c95837aef40e390d63b/python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec", size = 162211, upload-time = "2024-10-07T14:39:54.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/ee/7d76eb3b50ccb1397621f32ede0fb4d17aa55a9aa2251bc34e6b9929fdce/python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320", size = 87651, upload-time = "2024-10-07T14:39:50.021Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/desktop-client/vlc_player.py b/desktop-client/vlc_player.py new file mode 100644 index 0000000..809295f --- /dev/null +++ b/desktop-client/vlc_player.py @@ -0,0 +1,107 @@ +import vlc +import sys +from PyQt6.QtWidgets import QFrame +from PyQt6.QtCore import Qt, pyqtSignal, QObject +import threading + +class VLCSignals(QObject): + state_changed = pyqtSignal(bool, int) # is_playing, time_ms + time_changed = pyqtSignal(int) # time_ms + +class VLCSyncPlayer: + def __init__(self, frame: QFrame): + self.frame = frame + self.signals = VLCSignals() + + # Initialize VLC instance + # --no-xlib prevents crashes on Linux + # --drop-late-frames improves sync by not delaying playback when CPU is slow + self.instance = vlc.Instance("--no-xlib", "--drop-late-frames") + self.media_player = self.instance.media_player_new() + + # Embed the VLC player into the provided PyQt QFrame + # On Windows, PyQt6 widgets don't have a native handle by default + self.frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow) + + if sys.platform.startswith('linux'): + self.media_player.set_xwindow(int(self.frame.winId())) + elif sys.platform == "win32": + # For Windows, we must explicitly cast winId() to int + self.media_player.set_hwnd(int(self.frame.winId())) + elif sys.platform == "darwin": + self.media_player.set_nsobject(int(self.frame.winId())) + + # Register Event Callbacks + self.events = self.media_player.event_manager() + self.events.event_attach(vlc.EventType.MediaPlayerPlaying, self._on_playing) + self.events.event_attach(vlc.EventType.MediaPlayerPaused, self._on_paused) + self.events.event_attach(vlc.EventType.MediaPlayerTimeChanged, self._on_time_changed) + + # Local State + self.is_playing = False + self.current_time_ms = 0 + self.ignore_next_event = False + self.lock = threading.Lock() + + def load_media(self, path: str): + media = self.instance.media_new(path) + self.media_player.set_media(media) + + def play(self): + with self.lock: + self.ignore_next_event = True + self.media_player.play() + + def pause(self): + with self.lock: + self.ignore_next_event = True + self.media_player.set_pause(1) + + def seek(self, position_ms: int): + with self.lock: + self.ignore_next_event = True + self.media_player.set_time(position_ms) + + def set_volume(self, volume: int): + self.media_player.audio_set_volume(volume) + + def get_volume(self) -> int: + return self.media_player.audio_get_volume() + + # --- Internal VLC Callbacks --- + + @vlc.callbackmethod + def _on_playing(self, event): + self.is_playing = True + with self.lock: + if self.ignore_next_event: + self.ignore_next_event = False + return + + time_ms = self.media_player.get_time() + # Fire signal to PyQt thread + self.signals.state_changed.emit(True, time_ms) + + @vlc.callbackmethod + def _on_paused(self, event): + self.is_playing = False + with self.lock: + if self.ignore_next_event: + self.ignore_next_event = False + return + + time_ms = self.media_player.get_time() + self.signals.state_changed.emit(False, time_ms) + + @vlc.callbackmethod + def _on_time_changed(self, event): + # Emitted constantly during playback + self.current_time_ms = event.u.new_time + # We also want to fire this signal so the UI scrubber/time label can update + self.signals.time_changed.emit(self.current_time_ms) + + def get_length(self): + return self.media_player.get_length() + + def stop(self): + self.media_player.stop()