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()