From bd43cd10f6c088c2711641ebb7deee146cdd31ca Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Tue, 3 Mar 2026 22:50:02 +1100 Subject: [PATCH] Refactor desktop client codebase --- desktop-client/lobby_widget.py | 175 +++++++++ desktop-client/main.py | 652 ++++----------------------------- desktop-client/room_widget.py | 392 ++++++++++++++++++++ desktop-client/test_app.py | 30 +- 4 files changed, 647 insertions(+), 602 deletions(-) create mode 100644 desktop-client/lobby_widget.py create mode 100644 desktop-client/room_widget.py diff --git a/desktop-client/lobby_widget.py b/desktop-client/lobby_widget.py new file mode 100644 index 0000000..71ef74a --- /dev/null +++ b/desktop-client/lobby_widget.py @@ -0,0 +1,175 @@ +import os +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QFileDialog, QFrame +) +from PyQt6.QtCore import Qt, pyqtSignal + +class LobbyWidget(QWidget): + # Signals to communicate to VlcSyncApp + create_requested = pyqtSignal(str, str, str, object) # username, path, filename, size + join_requested = pyqtSignal(str, str) # username, room_code + + def __init__(self): + super().__init__() + self.setAcceptDrops(True) + + self.local_file_path = None + self.local_file_name = None + self.local_file_size = 0 + + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + 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) + + def _set_local_file(self, file_path: str): + 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 select_file(self): + file_path, _ = QFileDialog.getOpenFileName( + self, "Select Video File", "", "Video Files (*.mp4 *.mkv *.avi *.mov *.webm);;All Files (*)" + ) + if file_path: + self._set_local_file(file_path) + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.accept() + else: + event.ignore() + + def dropEvent(self, event): + for url in event.mimeData().urls(): + file_path = url.toLocalFile() + if os.path.isfile(file_path): + ext = os.path.splitext(file_path)[1].lower() + if ext in ['.mp4', '.mkv', '.avi', '.mov', '.webm']: + self._set_local_file(file_path) + break + + 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): + 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.create_requested.emit(username, self.local_file_path, self.local_file_name, self.local_file_size) + + def join_room(self): + username = self.username_input.text().strip() + 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.join_requested.emit(username, room_code) + + def reset_ui(self): + self.create_room_btn.setText("Create Room") + self.join_room_btn.setText("Join Room") + self.check_inputs() + + def clear_file(self): + self.local_file_path = None + self.local_file_name = None + self.local_file_size = 0 + self.create_file_info.hide() + self.check_inputs() diff --git a/desktop-client/main.py b/desktop-client/main.py index 0e18c90..cb9414a 100644 --- a/desktop-client/main.py +++ b/desktop-client/main.py @@ -2,52 +2,54 @@ import sys import os import ctypes from PyQt6.QtWidgets import ( - QApplication, QMainWindow, QStackedWidget, QWidget, QVBoxLayout, - QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox, - QFrame, QSlider, QTextEdit + QApplication, QMainWindow, QStackedWidget, QFileDialog, QMessageBox ) -from PyQt6.QtCore import Qt, pyqtSignal, QObject, QTimer -from PyQt6.QtGui import QFont, QIcon, QColor -from vlc_player import VLCSyncPlayer +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QIcon + from sync_client import SyncClientThread -import datetime +from lobby_widget import LobbyWidget +from room_widget import RoomWidget 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) - self.setAcceptDrops(True) - # Main stacked widget to switch between Lobby and Room + # Main stacked widget 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.lobby_widget = LobbyWidget() + self.room_widget = RoomWidget() - self.stacked_widget.addWidget(self.lobby_view) - self.stacked_widget.addWidget(self.room_view) + self.stacked_widget.addWidget(self.lobby_widget) + self.stacked_widget.addWidget(self.room_widget) - # State + # Connect View Signals + self.lobby_widget.create_requested.connect(self._on_lobby_create) + self.lobby_widget.join_requested.connect(self._on_lobby_join) + self.room_widget.leave_requested.connect(self._on_room_leave) + self.room_widget.sync_action_requested.connect(self._on_room_sync_action) + self.room_widget.chat_message_ready.connect(self._on_room_chat) + + # App 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 + # Network Service self.sync_client = SyncClientThread("wss://video-sync.peterstockings.com/ws") self.sync_client.connected.connect(self.on_ws_connected) self.sync_client.disconnected.connect(self.on_ws_disconnected) @@ -55,381 +57,18 @@ class VlcSyncApp(QMainWindow): self.sync_client.room_rejoined.connect(self.on_room_rejoined) 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.sync_client.users_updated.connect(self.room_widget.update_users) + self.sync_client.chat_message.connect(self.room_widget.add_chat_message) + self.sync_client.system_message.connect(self.room_widget.add_system_message) + self.sync_client.sync_event.connect(self.room_widget.handle_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.setObjectName("iconBtn") - self.copy_icon = QIcon(os.path.join(os.path.dirname(__file__), "copy.svg")) - self.check_icon = QIcon(os.path.join(os.path.dirname(__file__), "check.svg")) - self.copy_code_btn.setIcon(self.copy_icon) - 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("00:00:00 / 00:00: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("iconBtn") - 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 = QTextEdit() - self.chat_messages.setObjectName("chatMessages") - self.chat_messages.setReadOnly(True) - self.chat_messages.setHtml("Welcome to the room! 👋") - - 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.vlc_player.set_volume(self.volume_slider.value()) - - 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//3600:02d}:{(s%3600)//60:02d}:{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//3600:02d}:{(s%3600)//60:02d}:{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 _set_local_file(self, file_path: str): + def _on_lobby_create(self, username, file_path, file_name, file_size): + self.username = username 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 select_file(self): - file_path, _ = QFileDialog.getOpenFileName( - self, "Select Video File", "", "Video Files (*.mp4 *.mkv *.avi *.mov *.webm);;All Files (*)" - ) - if file_path: - self._set_local_file(file_path) - - def dragEnterEvent(self, event): - if event.mimeData().hasUrls(): - event.accept() - else: - event.ignore() - - def dropEvent(self, event): - if self.stacked_widget.currentIndex() == 0: - for url in event.mimeData().urls(): - file_path = url.toLocalFile() - if os.path.isfile(file_path): - ext = os.path.splitext(file_path)[1].lower() - if ext in ['.mp4', '.mkv', '.avi', '.mov', '.webm']: - self._set_local_file(file_path) - break - - def copy_room_code(self): - if self.room_code: - QApplication.clipboard().setText(self.room_code) - self.copy_code_btn.setIcon(self.check_icon) - - # Show floating tooltip for feedback - toast = QLabel("Copied!", self) - toast.setStyleSheet("background-color: #4BB543; color: white; padding: 4px 8px; border-radius: 4px; font-weight: bold;") - toast.setWindowFlags(Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint) - - # Position the toast slightly below the button - pos = self.copy_code_btn.mapToGlobal(self.copy_code_btn.rect().bottomLeft()) - toast.move(pos.x(), pos.y() + 5) - toast.show() - - def reset(): - self.copy_code_btn.setIcon(self.copy_icon) - toast.deleteLater() - - QTimer.singleShot(1500, reset) - - 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.local_file_name = file_name + self.local_file_size = file_size self.pending_connect_action = lambda: self.sync_client.send_message({ "type": "create_room", @@ -440,40 +79,37 @@ class VlcSyncApp(QMainWindow): "duration": 0 } }) - - if self.sync_client.running and self.sync_client.ws: - self.on_ws_connected() - else: - self.sync_client.start() + self._ensure_connection() - 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) - + def _on_lobby_join(self, username, room_code): + self.username = username + self.room_code = room_code self.pending_connect_action = lambda: self.sync_client.send_message({ "type": "join_room", "username": self.username, "code": self.room_code }) - + self._ensure_connection() + + def _ensure_connection(self): 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() + def _on_room_leave(self): + self.room_widget.cleanup() 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") + self.local_file_path = None + self.lobby_widget.clear_file() + + def _on_room_sync_action(self, action, position_s): + self.sync_client.send_message({"type": "sync", "action": action, "position": position_s}) + + def _on_room_chat(self, text): + self.sync_client.send_message({"type": "chat", "message": text}) # --- WebSocket Callbacks --- def on_ws_connected(self): @@ -481,7 +117,6 @@ class VlcSyncApp(QMainWindow): self.pending_connect_action() self.pending_connect_action = None elif self.stacked_widget.currentIndex() == 1 and self.room_code and self.username: - # We are already in a room and just reconnected self.sync_client.send_message({ "type": "rejoin_room", "username": self.username, @@ -490,25 +125,22 @@ class VlcSyncApp(QMainWindow): def on_ws_disconnected(self): if self.stacked_widget.currentIndex() == 1: - self.room_code_display.setText(f"Room: {self.room_code} (Reconnecting...)") - self.on_system_message("⚠️ Connection lost. Trying to reconnect...") + self.room_widget.set_room_code_display(f"{self.room_code} (Reconnecting...)") + self.room_widget.add_system_message("⚠️ Connection lost. Trying to reconnect...") 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.lobby_widget.reset_ui() 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") + 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}", "", f"Required File ({req_name})" @@ -519,7 +151,7 @@ class VlcSyncApp(QMainWindow): 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: + if self.local_file_name.lower() == req_name.lower() and int(self.local_file_size) == int(req_size): self.sync_client.send_message({ "type": "confirm_join", "fileInfo": { @@ -529,14 +161,13 @@ class VlcSyncApp(QMainWindow): } }) else: - QMessageBox.critical(self, "File Mismatch", "The selected file does not exactly match the room's required file.") + err_msg = f"Expected: {req_name} ({req_size} bytes)\nGot: {self.local_file_name} ({self.local_file_size} bytes)" + QMessageBox.critical(self, "File Mismatch", f"The selected file does not exactly match the room's required file.\n\n{err_msg}") self.sync_client.stop() - self.join_room_btn.setText("Join Room") - self.check_inputs() + self.lobby_widget.reset_ui() else: self.sync_client.stop() - self.join_room_btn.setText("Join Room") - self.check_inputs() + self.lobby_widget.reset_ui() def on_room_joined(self, msg: dict): if "room" in msg: @@ -545,189 +176,36 @@ class VlcSyncApp(QMainWindow): 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.setHtml("Welcome to the room! 👋") + self.lobby_widget.reset_ui() state = msg.get("state", {}) start_time_s = state.get("position", 0.0) if state else 0.0 - if self.local_file_path: - self.vlc_player.load_media(self.local_file_path, start_time_s) - self.vlc_player.set_volume(self.volume_slider.value()) + self.room_widget.setup_room(self.room_code, self.username, self.local_file_name, self.local_file_path, start_time_s) users = msg.get("users", []) if users: - self.on_users_updated(users) + self.room_widget.update_users(users) if state: - self.on_sync_event(state) + self.room_widget.handle_sync_event(state) chat_history = msg.get("chatHistory", []) if chat_history: - self.chat_messages.setHtml("Welcome to the room! 👋") for chat in chat_history: - self.on_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0)) + self.room_widget.add_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0)) def on_room_rejoined(self, msg: dict): - self.room_code_display.setText(f"Room: {self.room_code}") - self.on_system_message("✅ Reconnected to the room.") + self.room_widget.set_room_code_display(self.room_code) + self.room_widget.add_system_message("✅ Reconnected to the room.") users = msg.get("users", []) if users: - self.on_users_updated(users) + self.room_widget.update_users(users) state = msg.get("state", {}) if state: - # Don't reload media, just resync playback state - self.on_sync_event(state) - - 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") - - new_msg = f"{author}: {text} {time_str}" - self.chat_messages.append(new_msg) - - def on_system_message(self, text: str): - new_msg = f"{text}" - self.chat_messages.append(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)//3600:02d}:{(int(s)%3600)//60:02d}:{int(s)%60:02d}" - self.on_system_message(f"{username} seeked to {fmt(position_s)}") - - def _handle_seek_command(self, arg: str) -> bool: - current_ms = self.vlc_player.current_time_ms - length_ms = self.vlc_player.get_length() - if length_ms <= 0: - return False - - try: - target_ms = 0 - if arg.startswith('+') or arg.startswith('-'): - # relative - modifier = 1 if arg.startswith('+') else -1 - num_str = arg[1:] - - if num_str.endswith('s'): - val = float(num_str[:-1]) * 1000 - elif num_str.endswith('m'): - val = float(num_str[:-1]) * 60 * 1000 - elif num_str.endswith('h'): - val = float(num_str[:-1]) * 3600 * 1000 - else: - val = float(num_str) * 1000 # Default to seconds - - target_ms = current_ms + (val * modifier) - elif ":" in arg: - # absolute time like HH:MM:SS or MM:SS - parts = arg.split(":") - parts.reverse() # seconds, minutes, hours - if len(parts) > 0: target_ms += float(parts[0]) * 1000 - if len(parts) > 1: target_ms += float(parts[1]) * 60 * 1000 - if len(parts) > 2: target_ms += float(parts[2]) * 3600 * 1000 - else: - # absolute seconds or something with a suffix but no + or - - if arg.endswith('s'): - target_ms = float(arg[:-1]) * 1000 - elif arg.endswith('m'): - target_ms = float(arg[:-1]) * 60 * 1000 - elif arg.endswith('h'): - target_ms = float(arg[:-1]) * 3600 * 1000 - else: - target_ms = float(arg) * 1000 - - target_ms = max(0, min(target_ms, length_ms)) - self.vlc_player.seek(int(target_ms)) - self.sync_client.send_message({"type": "sync", "action": "seek", "position": target_ms / 1000.0}) - return True - except ValueError: - return False - - def send_chat(self): - text = self.chat_input.text().strip() - if not text: - return - - if text.startswith("/"): - parts = text.split() - cmd = parts[0].lower() - - if cmd == "/play": - self.chat_input.setText("") - if not self.vlc_player.is_playing: - self.toggle_playback() - self.on_system_message(text) - return - elif cmd == "/pause": - self.chat_input.setText("") - if self.vlc_player.is_playing: - self.toggle_playback() - self.on_system_message(text) - return - elif cmd == "/seek": - self.chat_input.setText("") - if len(parts) > 1: - if self._handle_seek_command(parts[1]): - self.on_system_message(text) - else: - self.on_system_message("Invalid time format. Use: 1:23, +30s, -1m") - else: - self.on_system_message("Usage: /seek [time]") - return - elif cmd == "/help": - self.chat_input.setText("") - self.on_system_message("Available commands:
/play - Resume playback
/pause - Pause playback
/seek [time] - Seek to specific time (e.g., 1:23) or offset (e.g., +30s, -1m)
/help - Show this message") - return - - self.sync_client.send_message({"type": "chat", "message": text}) - self.chat_input.setText("") + self.room_widget.handle_sync_event(state) def apply_stylesheet(self): self.setStyleSheet(""" @@ -908,7 +386,7 @@ class VlcSyncApp(QMainWindow): """) if __name__ == "__main__": - # Tell Windows this is a distinct app so the taskbar icon updates correctly + import ctypes if os.name == 'nt': myappid = 'vlcsync.desktopclient.app.1' try: @@ -918,7 +396,6 @@ if __name__ == "__main__": 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)) @@ -926,3 +403,4 @@ if __name__ == "__main__": window = VlcSyncApp() window.show() sys.exit(app.exec()) + diff --git a/desktop-client/room_widget.py b/desktop-client/room_widget.py new file mode 100644 index 0000000..724a59e --- /dev/null +++ b/desktop-client/room_widget.py @@ -0,0 +1,392 @@ +import os +import datetime +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QFrame, QSlider, QTextEdit, QApplication +) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer +from PyQt6.QtGui import QIcon + +from vlc_player import VLCSyncPlayer + +class RoomWidget(QWidget): + leave_requested = pyqtSignal() + sync_action_requested = pyqtSignal(str, float) # action, position_s + chat_message_ready = pyqtSignal(str) # raw message text + + def __init__(self): + super().__init__() + + self.username = "" + self.room_code = "" + self.ignore_vlc_events = False + self.last_reported_time_ms = 0 + + self._setup_ui() + + def _setup_ui(self): + layout = QHBoxLayout(self) + 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.setObjectName("iconBtn") + self.copy_icon = QIcon(os.path.join(os.path.dirname(__file__), "copy.svg")) + self.check_icon = QIcon(os.path.join(os.path.dirname(__file__), "check.svg")) + self.copy_code_btn.setIcon(self.copy_icon) + 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_requested.emit) + + 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;") + 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("00:00:00 / 00:00: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("iconBtn") + self.fullscreen_btn.clicked.connect(self.toggle_fullscreen) + + controls_layout.addWidget(self.play_btn) + controls_layout.addWidget(self.seekbar, 1) + 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 = QTextEdit() + self.chat_messages.setObjectName("chatMessages") + self.chat_messages.setReadOnly(True) + self.chat_messages.setHtml("Welcome to the room! 👋") + + 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) + chat_layout.addLayout(chat_input_layout) + + layout.addWidget(video_container, 1) + 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.vlc_player.set_volume(self.volume_slider.value()) + + self.play_btn.clicked.connect(self.toggle_playback) + + def setup_room(self, room_code: str, username: str, file_name: str, file_path: str, start_time_s: float = 0.0): + self.room_code = room_code + self.username = username + self.set_room_code_display(room_code) + self.room_file_badge.setText(f"📄 {file_name}") + self.chat_messages.setHtml("Welcome to the room! 👋") + + if file_path: + self.vlc_player.load_media(file_path, start_time_s) + self.vlc_player.set_volume(self.volume_slider.value()) + + def cleanup(self): + self.vlc_player.stop() + + def set_room_code_display(self, text: str): + self.room_code_display.setText(f"Room: {text}") + + def toggle_fullscreen(self): + top_window = self.window() + if top_window.isFullScreen(): + top_window.showNormal() + self.fullscreen_btn.setText("⛶") + self.chat_container.show() + self.topbar.show() + else: + top_window.showFullScreen() + self.fullscreen_btn.setText("🗗") + self.chat_container.hide() + self.topbar.hide() + + def copy_room_code(self): + if self.room_code: + QApplication.clipboard().setText(self.room_code) + self.copy_code_btn.setIcon(self.check_icon) + + toast = QLabel("Copied!", self) + toast.setStyleSheet("background-color: #4BB543; color: white; padding: 4px 8px; border-radius: 4px; font-weight: bold;") + toast.setWindowFlags(Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint) + + pos = self.copy_code_btn.mapToGlobal(self.copy_code_btn.rect().bottomLeft()) + toast.move(pos.x(), pos.y() + 5) + toast.show() + + def reset(): + self.copy_code_btn.setIcon(self.copy_icon) + toast.deleteLater() + QTimer.singleShot(1500, reset) + + 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_action_requested.emit("pause", position_s) + else: + self.vlc_player.play() + self.play_btn.setText("⏸") + self.sync_action_requested.emit("play", 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//3600:02d}:{(s%3600)//60:02d}:{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_action_requested.emit("seek", 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_action_requested.emit(action, 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//3600:02d}:{(s%3600)//60:02d}:{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_action_requested.emit("seek", target_ms / 1000.0) + + def on_volume_changed(self, value): + self.vlc_player.set_volume(value) + + # --- Incoming Sync Logic --- + def handle_sync_event(self, msg: dict): + self.ignore_vlc_events = True + + action = msg.get("action") + 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) + + username = msg.get("username") + if username and username != self.username: + if action == "play": self.add_system_message(f"{username} pressed play") + elif action == "pause": self.add_system_message(f"{username} paused") + elif action == "seek": + def fmt(s): return f"{int(s)//3600:02d}:{(int(s)%3600)//60:02d}:{int(s)%60:02d}" + self.add_system_message(f"{username} seeked to {fmt(position_s)}") + + def update_users(self, users: list): + self.users_lbl.setText(f"{len(users)} watching: {', '.join(users)}") + + def add_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") + new_msg = f"{author}: {text} {time_str}" + self.chat_messages.append(new_msg) + + def add_system_message(self, text: str): + new_msg = f"{text}" + self.chat_messages.append(new_msg) + + def send_chat(self): + text = self.chat_input.text().strip() + if not text: + return + + if text.startswith("/"): + parts = text.split() + cmd = parts[0].lower() + + if cmd == "/play": + self.chat_input.setText("") + if not self.vlc_player.is_playing: + self.toggle_playback() + self.add_system_message(text) + return + elif cmd == "/pause": + self.chat_input.setText("") + if self.vlc_player.is_playing: + self.toggle_playback() + self.add_system_message(text) + return + elif cmd == "/seek": + self.chat_input.setText("") + if len(parts) > 1: + if self._handle_seek_command(parts[1]): + self.add_system_message(text) + else: + self.add_system_message("Invalid time format. Use: 1:23, +30s, -1m") + else: + self.add_system_message("Usage: /seek [time]") + return + elif cmd == "/help": + self.chat_input.setText("") + self.add_system_message("Available commands:
/play - Resume playback
/pause - Pause playback
/seek [time] - Seek to specific time (e.g., 1:23) or offset (e.g., +30s, -1m)
/help - Show this message") + return + + self.chat_message_ready.emit(text) + self.chat_input.setText("") + + def _handle_seek_command(self, arg: str) -> bool: + current_ms = self.vlc_player.current_time_ms + length_ms = self.vlc_player.get_length() + if length_ms <= 0: + return False + + try: + target_ms = 0 + if arg.startswith('+') or arg.startswith('-'): + modifier = 1 if arg.startswith('+') else -1 + num_str = arg[1:] + if num_str.endswith('s'): val = float(num_str[:-1]) * 1000 + elif num_str.endswith('m'): val = float(num_str[:-1]) * 60 * 1000 + elif num_str.endswith('h'): val = float(num_str[:-1]) * 3600 * 1000 + else: val = float(num_str) * 1000 + target_ms = current_ms + (val * modifier) + elif ":" in arg: + parts = arg.split(":") + parts.reverse() + if len(parts) > 0: target_ms += float(parts[0]) * 1000 + if len(parts) > 1: target_ms += float(parts[1]) * 60 * 1000 + if len(parts) > 2: target_ms += float(parts[2]) * 3600 * 1000 + else: + if arg.endswith('s'): target_ms = float(arg[:-1]) * 1000 + elif arg.endswith('m'): target_ms = float(arg[:-1]) * 60 * 1000 + elif arg.endswith('h'): target_ms = float(arg[:-1]) * 3600 * 1000 + else: target_ms = float(arg) * 1000 + + target_ms = max(0, min(target_ms, length_ms)) + self.vlc_player.seek(int(target_ms)) + self.sync_action_requested.emit("seek", target_ms / 1000.0) + return True + except ValueError: + return False diff --git a/desktop-client/test_app.py b/desktop-client/test_app.py index 506c7ba..5907d64 100644 --- a/desktop-client/test_app.py +++ b/desktop-client/test_app.py @@ -9,22 +9,22 @@ def test_app_ui_flow(qtbot): # 1. Test Lobby View assert app.stacked_widget.currentIndex() == 0 - assert not app.create_room_btn.isEnabled() + assert not app.lobby_widget.create_room_btn.isEnabled() # Fill out the Lobby Form - app.username_input.setText("PyTestUser") + app.lobby_widget.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() + app.lobby_widget.local_file_path = "test_pytest.mkv" + app.lobby_widget.local_file_name = "test_pytest.mkv" + app.lobby_widget.local_file_size = 1048576 + app.lobby_widget.check_inputs() # Button should now be active - assert app.create_room_btn.isEnabled() + assert app.lobby_widget.create_room_btn.isEnabled() # 2. Test Creating Room matches integration pipeline - qtbot.mouseClick(app.create_room_btn, Qt.MouseButton.LeftButton) + qtbot.mouseClick(app.lobby_widget.create_room_btn, Qt.MouseButton.LeftButton) # Wait for the WebSocket connected signal, the room_created server response, and UI transition def check_room_joined(): @@ -35,26 +35,26 @@ def test_app_ui_flow(qtbot): # 3. Test Chat flow End-to-End # Type a message - qtbot.keyClicks(app.chat_input, "Automated UI Test Message") + qtbot.keyClicks(app.room_widget.chat_input, "Automated UI Test Message") # Click Send - qtbot.mouseClick(app.chat_send_btn, Qt.MouseButton.LeftButton) + qtbot.mouseClick(app.room_widget.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() + assert "Automated UI Test Message" in app.room_widget.chat_messages.toPlainText() qtbot.waitUntil(check_chat_received, timeout=3000) # 4. Test Playback Sync (UI updates and internal flags) - assert app.play_btn.text() == "▶" + assert app.room_widget.play_btn.text() == "▶" # Click Play - qtbot.mouseClick(app.play_btn, Qt.MouseButton.LeftButton) + qtbot.mouseClick(app.room_widget.play_btn, Qt.MouseButton.LeftButton) def check_playback_started(): - assert app.play_btn.text() == "⏸" + assert app.room_widget.play_btn.text() == "⏸" qtbot.waitUntil(check_playback_started, timeout=2000) # Clean up background threads - app.leave_room() + app._on_room_leave()