import sys import os import ctypes from PyQt6.QtWidgets import ( QApplication, QMainWindow, QStackedWidget, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox, QFrame, QSlider ) from PyQt6.QtCore import Qt, pyqtSignal, QObject, QTimer from PyQt6.QtGui import QFont, QIcon, QColor from vlc_player import VLCSyncPlayer from sync_client import SyncClientThread import datetime class VlcSyncApp(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("VideoSync — Watch Together") # Set window icon icon_path = os.path.join(os.path.dirname(__file__), "icon.png") if os.path.exists(icon_path): self.setWindowIcon(QIcon(icon_path)) self.setMinimumSize(900, 600) self.resize(1100, 700) # Main stacked widget to switch between Lobby and Room self.stacked_widget = QStackedWidget() self.setCentralWidget(self.stacked_widget) # Setup Views self.lobby_view = self.create_lobby_view() self.room_view = self.create_room_view() self.stacked_widget.addWidget(self.lobby_view) self.stacked_widget.addWidget(self.room_view) # State self.username = "" self.room_code = "" self.local_file_path = None self.local_file_name = None self.local_file_size = 0 self.pending_connect_action = None self.ignore_vlc_events = False self.last_reported_time_ms = 0 self.sync_client = SyncClientThread("ws://localhost:3000/ws") self.sync_client.connected.connect(self.on_ws_connected) self.sync_client.room_joined.connect(self.on_room_joined) self.sync_client.room_error.connect(self.on_room_error) self.sync_client.file_check_needed.connect(self.on_file_check_needed) self.sync_client.users_updated.connect(self.on_users_updated) self.sync_client.chat_message.connect(self.on_chat_message) self.sync_client.system_message.connect(self.on_system_message) self.sync_client.sync_event.connect(self.on_sync_event) self.apply_stylesheet() def create_lobby_view(self): widget = QWidget() layout = QVBoxLayout(widget) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) # Container for centering container = QFrame() container.setObjectName("lobbyCard") container.setFixedWidth(500) container_layout = QVBoxLayout(container) container_layout.setContentsMargins(30, 30, 30, 30) container_layout.setSpacing(15) # Brand title = QLabel("VideoSync") title.setObjectName("brandTitle") title.setAlignment(Qt.AlignmentFlag.AlignCenter) tagline = QLabel("Watch together, anywhere") tagline.setObjectName("brandTagline") tagline.setAlignment(Qt.AlignmentFlag.AlignCenter) container_layout.addWidget(title) container_layout.addWidget(tagline) container_layout.addSpacing(20) # Username self.username_input = QLineEdit() self.username_input.setPlaceholderText("Enter a display name") container_layout.addWidget(QLabel("YOUR NAME")) container_layout.addWidget(self.username_input) container_layout.addSpacing(20) # Actions Layout (Create vs Join) actions_layout = QHBoxLayout() actions_layout.setSpacing(20) # Create Room Panel create_panel = QVBoxLayout() create_panel.addWidget(QLabel("Create a Room")) self.create_file_btn = QPushButton("Choose Video File") self.create_file_btn.setObjectName("secondaryBtn") self.create_file_btn.clicked.connect(self.select_file) self.create_file_info = QLabel("") self.create_file_info.setObjectName("fileInfo") self.create_file_info.setWordWrap(True) self.create_file_info.hide() self.create_room_btn = QPushButton("Create Room") self.create_room_btn.setObjectName("primaryBtn") self.create_room_btn.setEnabled(False) self.create_room_btn.clicked.connect(self.create_room) create_panel.addWidget(self.create_file_btn) create_panel.addWidget(self.create_file_info) create_panel.addWidget(self.create_room_btn) # Join Room Panel join_panel = QVBoxLayout() join_panel.addWidget(QLabel("Join a Room")) self.room_code_input = QLineEdit() self.room_code_input.setPlaceholderText("e.g. ABC123") self.join_room_btn = QPushButton("Join Room") self.join_room_btn.setObjectName("secondaryBtn") self.join_room_btn.setEnabled(False) self.join_room_btn.clicked.connect(self.join_room) join_panel.addWidget(self.room_code_input) join_panel.addWidget(self.join_room_btn) actions_layout.addLayout(create_panel) # Divider divider = QLabel("OR") divider.setAlignment(Qt.AlignmentFlag.AlignCenter) divider.setObjectName("divider") actions_layout.addWidget(divider) actions_layout.addLayout(join_panel) container_layout.addLayout(actions_layout) layout.addWidget(container) # Signals to enable/disable buttons self.username_input.textChanged.connect(self.check_inputs) self.room_code_input.textChanged.connect(self.check_inputs) return widget def create_room_view(self): widget = QWidget() layout = QHBoxLayout(widget) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # --- Left Side: Video --- video_container = QWidget() video_layout = QVBoxLayout(video_container) video_layout.setContentsMargins(0, 0, 0, 0) video_layout.setSpacing(0) # Topbar self.topbar = QFrame() self.topbar.setObjectName("topbar") self.topbar.setFixedHeight(50) topbar_layout = QHBoxLayout(self.topbar) self.room_code_display = QLabel("Room: XXXX") self.copy_code_btn = QPushButton("📋") self.copy_code_btn.setFixedSize(30, 30) self.copy_code_btn.setToolTip("Copy Room Code") self.copy_code_btn.clicked.connect(self.copy_room_code) self.room_file_badge = QLabel("📄 No file") self.room_file_badge.setObjectName("fileBadge") self.leave_btn = QPushButton("Leave Room") self.leave_btn.setObjectName("dangerBtn") self.leave_btn.clicked.connect(self.leave_room) topbar_layout.addWidget(self.room_code_display) topbar_layout.addWidget(self.copy_code_btn) topbar_layout.addStretch() topbar_layout.addWidget(self.room_file_badge) topbar_layout.addWidget(self.leave_btn) # Video Frame Placeholder self.video_frame = QFrame() self.video_frame.setStyleSheet("background-color: black;") # Fix for Windows QWidgetWindow Error: # Force the frame to have its own native HWND so VLC can attach to it without complaining it's not a top-level window. self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True) # Controls Bar controls = QFrame() controls.setObjectName("controlsBar") controls.setFixedHeight(60) controls_layout = QHBoxLayout(controls) self.play_btn = QPushButton("▶") self.play_btn.setFixedSize(40, 40) self.play_btn.setObjectName("playBtn") # Time and SeekBar self.seekbar = QSlider(Qt.Orientation.Horizontal) self.seekbar.setRange(0, 1000) self.seekbar.setObjectName("seekBar") self.seekbar.sliderMoved.connect(self.on_seekbar_dragged) self.seekbar.sliderReleased.connect(self.on_seekbar_released) self.time_lbl = QLabel("0:00 / 0:00") # Volume self.vol_icon = QLabel("🔊") self.vol_icon.setObjectName("volIcon") self.volume_slider = QSlider(Qt.Orientation.Horizontal) self.volume_slider.setRange(0, 100) self.volume_slider.setValue(100) self.volume_slider.setFixedWidth(100) self.volume_slider.setObjectName("volumeSlider") self.volume_slider.valueChanged.connect(self.on_volume_changed) # Fullscreen self.fullscreen_btn = QPushButton("⛶") self.fullscreen_btn.setFixedSize(40, 40) self.fullscreen_btn.setObjectName("fullscreenBtn") self.fullscreen_btn.clicked.connect(self.toggle_fullscreen) controls_layout.addWidget(self.play_btn) controls_layout.addWidget(self.seekbar, 1) # SeekBar gets the stretch controls_layout.addWidget(self.time_lbl) controls_layout.addSpacing(15) controls_layout.addWidget(self.vol_icon) controls_layout.addWidget(self.volume_slider) controls_layout.addSpacing(10) controls_layout.addWidget(self.fullscreen_btn) video_layout.addWidget(self.topbar) video_layout.addWidget(self.video_frame) video_layout.addWidget(controls) # --- Right Side: Chat --- self.chat_container = QFrame() self.chat_container.setObjectName("chatContainer") self.chat_container.setFixedWidth(320) chat_layout = QVBoxLayout(self.chat_container) chat_header = QLabel("Live Chat") chat_header.setObjectName("chatHeader") self.users_lbl = QLabel("0 watching") self.users_lbl.setObjectName("usersLbl") self.chat_messages = QLabel("Welcome to the room! 👋\n") self.chat_messages.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) self.chat_messages.setWordWrap(True) # We should really use a QScrollArea or QTextEdit here eventually chat_input_layout = QHBoxLayout() self.chat_input = QLineEdit() self.chat_input.setPlaceholderText("Send a message...") self.chat_send_btn = QPushButton("Send") chat_input_layout.addWidget(self.chat_input) chat_input_layout.addWidget(self.chat_send_btn) # Chat actions self.chat_send_btn.clicked.connect(self.send_chat) self.chat_input.returnPressed.connect(self.send_chat) chat_layout.addWidget(chat_header) chat_layout.addWidget(self.users_lbl) chat_layout.addWidget(self.chat_messages, 1) # stretch chat_layout.addLayout(chat_input_layout) layout.addWidget(video_container, 1) # stretch layout.addWidget(self.chat_container) # Instantiate the VLC Player Wrapper self.vlc_player = VLCSyncPlayer(self.video_frame) self.vlc_player.signals.time_changed.connect(self.on_vlc_time) self.vlc_player.signals.state_changed.connect(self.on_vlc_state) self.play_btn.clicked.connect(self.toggle_playback) return widget def toggle_fullscreen(self): if self.isFullScreen(): self.showNormal() self.fullscreen_btn.setText("⛶") self.chat_container.show() self.topbar.show() else: self.showFullScreen() self.fullscreen_btn.setText("🗗") self.chat_container.hide() self.topbar.hide() def toggle_playback(self): position_s = self.vlc_player.current_time_ms / 1000.0 if self.vlc_player.is_playing: self.vlc_player.pause() self.play_btn.setText("▶") self.sync_client.send_message({"type": "sync", "action": "pause", "position": position_s}) else: self.vlc_player.play() self.play_btn.setText("⏸") self.sync_client.send_message({"type": "sync", "action": "play", "position": position_s}) def on_vlc_time(self, time_ms: int): length_ms = self.vlc_player.get_length() if length_ms > 0: def fmt(ms): s = max(0, ms) // 1000 return f"{s//60}:{s%60:02d}" if not self.seekbar.isSliderDown(): self.time_lbl.setText(f"{fmt(time_ms)} / {fmt(length_ms)}") progress = int((time_ms / length_ms) * 1000) self.seekbar.blockSignals(True) self.seekbar.setValue(progress) self.seekbar.blockSignals(False) if self.last_reported_time_ms is not None: diff = abs(time_ms - self.last_reported_time_ms) if diff > 2500 and not self.ignore_vlc_events: self.sync_client.send_message({"type": "sync", "action": "seek", "position": time_ms / 1000.0}) self.last_reported_time_ms = time_ms def on_vlc_state(self, playing: bool, time_ms: int): if self.ignore_vlc_events: return action = "play" if playing else "pause" self.sync_client.send_message({"type": "sync", "action": action, "position": time_ms / 1000.0}) def on_seekbar_dragged(self, value): length_ms = self.vlc_player.get_length() if length_ms > 0: target_ms = int((value / 1000.0) * length_ms) def fmt(ms): s = max(0, ms) // 1000 return f"{s//60}:{s%60:02d}" self.time_lbl.setText(f"{fmt(target_ms)} / {fmt(length_ms)}") def on_seekbar_released(self): length_ms = self.vlc_player.get_length() if length_ms > 0: target_ms = int((self.seekbar.value() / 1000.0) * length_ms) self.vlc_player.seek(target_ms) self.sync_client.send_message({"type": "sync", "action": "seek", "position": target_ms / 1000.0}) def on_volume_changed(self, value): self.vlc_player.set_volume(value) def select_file(self): file_path, _ = QFileDialog.getOpenFileName( self, "Select Video File", "", "Video Files (*.mp4 *.mkv *.avi *.mov *.webm);;All Files (*)" ) if file_path: self.local_file_path = file_path self.local_file_name = os.path.basename(file_path) self.local_file_size = os.path.getsize(file_path) size_mb = self.local_file_size / (1024 * 1024) self.create_file_info.setText(f"{self.local_file_name}\n{size_mb:.1f} MB") self.create_file_info.show() self.check_inputs() def copy_room_code(self): if self.room_code: QApplication.clipboard().setText(self.room_code) self.copy_code_btn.setText("✓") QTimer.singleShot(2000, lambda: self.copy_code_btn.setText("📋")) def check_inputs(self): has_name = len(self.username_input.text().strip()) > 0 has_file = self.local_file_path is not None has_code = len(self.room_code_input.text().strip()) >= 4 self.create_room_btn.setEnabled(has_name and has_file) self.join_room_btn.setEnabled(has_name and has_code) def create_room(self): self.username = self.username_input.text().strip() self.create_room_btn.setText("Connecting...") self.create_room_btn.setEnabled(False) self.join_room_btn.setEnabled(False) self.pending_connect_action = lambda: self.sync_client.send_message({ "type": "create_room", "username": self.username, "fileInfo": { "name": self.local_file_name, "size": self.local_file_size, "duration": 0 } }) if self.sync_client.running and self.sync_client.ws: self.on_ws_connected() else: self.sync_client.start() def join_room(self): self.username = self.username_input.text().strip() self.room_code = self.room_code_input.text().strip().upper() self.join_room_btn.setText("Connecting...") self.join_room_btn.setEnabled(False) self.create_room_btn.setEnabled(False) self.pending_connect_action = lambda: self.sync_client.send_message({ "type": "join_room", "username": self.username, "code": self.room_code }) if self.sync_client.running and self.sync_client.ws: self.on_ws_connected() else: self.sync_client.start() def leave_room(self): self.vlc_player.stop() self.sync_client.stop() self.stacked_widget.setCurrentIndex(0) self.local_file_path = None self.create_file_info.hide() self.room_code = "" self.check_inputs() self.create_room_btn.setText("Create Room") self.join_room_btn.setText("Join Room") # --- WebSocket Callbacks --- def on_ws_connected(self): if self.pending_connect_action: self.pending_connect_action() self.pending_connect_action = None def on_room_error(self, msg: str): QMessageBox.critical(self, "Room Error", msg) self.create_room_btn.setText("Create Room") self.join_room_btn.setText("Join Room") self.check_inputs() self.sync_client.stop() def on_file_check_needed(self, msg: dict): # Defer execution to the PyQt Main Thread to avoid deadlocking the WebSocket thread QTimer.singleShot(0, lambda: self._handle_file_check(msg)) def _handle_file_check(self, msg: dict): req_name = msg["fileInfo"].get("name", "Unknown") req_size = msg["fileInfo"].get("size", 0) QMessageBox.information(self, "File Required", f"To join this room, you need to select:\\n\\nName: {req_name}\\nSize: {req_size / (1024*1024):.1f} MB") file_path, _ = QFileDialog.getOpenFileName( self, f"Select {req_name}", "", "Video Files (*.mp4 *.mkv *.avi *.mov *.webm);;All Files (*)" ) if file_path: self.local_file_path = file_path self.local_file_name = os.path.basename(file_path) self.local_file_size = os.path.getsize(file_path) if self.local_file_name == req_name and self.local_file_size == req_size: self.sync_client.send_message({ "type": "confirm_join", "fileInfo": { "name": self.local_file_name, "size": self.local_file_size, "duration": 0 } }) else: QMessageBox.critical(self, "File Mismatch", "The selected file does not exactly match the room's required file.") self.sync_client.stop() self.join_room_btn.setText("Join Room") self.check_inputs() else: self.sync_client.stop() self.join_room_btn.setText("Join Room") self.check_inputs() def on_room_joined(self, msg: dict): if "room" in msg: self.room_code = msg["room"]["code"] else: self.room_code = msg.get("code", "") self.stacked_widget.setCurrentIndex(1) self.room_code_display.setText(f"Room: {self.room_code}") self.room_file_badge.setText(f"📄 {self.local_file_name}") self.create_room_btn.setText("Create Room") self.join_room_btn.setText("Join Room") self.chat_messages.setText("Welcome to the room! 👋
") if self.local_file_path: self.vlc_player.load_media(self.local_file_path) users = msg.get("users", []) if users: self.on_users_updated(users) state = msg.get("state", {}) if state: self.on_sync_event(state) chat_history = msg.get("chatHistory", []) if chat_history: self.chat_messages.setText("Welcome to the room! 👋
") for chat in chat_history: self.on_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0)) def on_chat_message(self, author: str, text: str, timestamp: int): try: dt = datetime.datetime.fromtimestamp(timestamp / 1000.0) except (ValueError, OSError, TypeError): dt = datetime.datetime.now() time_str = dt.strftime("%I:%M %p") current = self.chat_messages.text() new_msg = f"{author}: {text} {time_str}
" self.chat_messages.setText(current + new_msg) def on_system_message(self, text: str): current = self.chat_messages.text() new_msg = f"{text}
" self.chat_messages.setText(current + new_msg) def on_users_updated(self, users: list): self.users_lbl.setText(f"{len(users)} watching: {', '.join(users)}") def on_sync_event(self, msg: dict): self.ignore_vlc_events = True action = msg.get("action") # Handle full state sync vs event sync if not action: if msg.get("playing", False): action = "play" elif msg.get("playing") is False: action = "pause" position_s = msg.get("position", 0) position_ms = int(position_s * 1000) if action == "play": self.vlc_player.seek(position_ms) self.vlc_player.play() self.play_btn.setText("⏸") elif action == "pause": self.vlc_player.seek(position_ms) self.vlc_player.pause() self.play_btn.setText("▶") elif action == "seek": self.vlc_player.seek(position_ms) def clear_ignore(): self.ignore_vlc_events = False QTimer.singleShot(1500, clear_ignore) # System notification username = msg.get("username") if username and username != self.username: if action == "play": self.on_system_message(f"{username} pressed play") elif action == "pause": self.on_system_message(f"{username} paused") elif action == "seek": def fmt(s): return f"{int(s)//60}:{int(s)%60:02d}" self.on_system_message(f"{username} seeked to {fmt(position_s)}") def send_chat(self): text = self.chat_input.text().strip() if text: self.sync_client.send_message({"type": "chat", "message": text}) self.chat_input.setText("") def apply_stylesheet(self): self.setStyleSheet(""" QWidget { background-color: #0f0f0f; color: #f1f1f1; font-family: 'Segoe UI', 'Roboto', sans-serif; font-size: 14px; } #lobbyCard { background-color: #1a1a1a; border: 1px solid #333; border-radius: 12px; } #brandTitle { font-size: 32px; font-weight: bold; color: #3ea6ff; } #brandTagline { font-size: 14px; color: #aaaaaa; } QLabel { color: #aaaaaa; font-weight: bold; font-size: 12px; } #fileInfo { background-color: rgba(62, 166, 255, 0.1); color: #3ea6ff; padding: 10px; border-radius: 6px; font-weight: normal; font-size: 13px; } QLineEdit { background-color: #272727; border: 1px solid #333; border-radius: 6px; padding: 10px; color: white; font-size: 14px; } QLineEdit:focus { border: 1px solid #3ea6ff; } QPushButton { border-radius: 6px; padding: 10px 16px; font-weight: bold; font-size: 14px; } #primaryBtn { background-color: #3ea6ff; color: black; border: none; } #primaryBtn:hover { background-color: #65b8ff; } #primaryBtn:disabled { background-color: #333; color: #666; } #secondaryBtn { background-color: #272727; color: white; border: 1px solid #444; } #secondaryBtn:hover { background-color: #333; } #secondaryBtn:disabled { border-color: #222; color: #666; } #dangerBtn { background-color: transparent; color: #ff4e45; border: 1px solid #ff4e45; padding: 6px 12px; font-size: 12px; } #dangerBtn:hover { background-color: #ff4e45; color: white; } #divider { color: #555; } #topbar { background-color: #1a1a1a; border-bottom: 1px solid #333; } #fileBadge { background-color: #272727; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: normal; } #controlsBar { background-color: #1a1a1a; } #playBtn { background: transparent; color: white; font-size: 20px; border: none; } #playBtn:hover { background: #333; } #chatContainer { background-color: #111; border-left: 1px solid #333; } #chatHeader { font-size: 16px; color: white; padding: 10px 0; } #usersLbl { color: #3ea6ff; font-size: 12px; } """) if __name__ == "__main__": # Tell Windows this is a distinct app so the taskbar icon updates correctly if os.name == 'nt': myappid = 'vlcsync.desktopclient.app.1' try: ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) except Exception: pass app = QApplication(sys.argv) # Set app-level icon icon_path = os.path.join(os.path.dirname(__file__), "icon.png") if os.path.exists(icon_path): app.setWindowIcon(QIcon(icon_path)) window = VlcSyncApp() window.show() sys.exit(app.exec())