import sys import os import ctypes import json from PyQt6.QtWidgets import ( QApplication, QMainWindow, QStackedWidget, QFileDialog, QMessageBox ) from PyQt6.QtCore import Qt, QTimer, QSettings, QUrl from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply from PyQt6.QtGui import QIcon from sync_client import SyncClientThread from lobby_widget import LobbyWidget from room_widget import RoomWidget class VlcSyncApp(QMainWindow): def __init__(self, settings=None): super().__init__() self.settings = settings if settings is not None else QSettings("VideoSync", "DesktopClient") self.setWindowTitle("VideoSync — Watch Together") 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 self.stacked_widget = QStackedWidget() self.setCentralWidget(self.stacked_widget) # Setup Views self.lobby_widget = LobbyWidget() self.room_widget = RoomWidget() self.stacked_widget.addWidget(self.lobby_widget) self.stacked_widget.addWidget(self.room_widget) # 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) self.room_widget.ready_action_requested.connect(lambda message: self.sync_client.send_message(message)) self.room_widget.fight_action_requested.connect(lambda message: self.sync_client.send_message(message)) # 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 # Network Service self.sync_client = SyncClientThread("wss://video-sync.peterstockings.com/ws") self.sync_client.connected.connect(self.on_ws_connected) self.sync_client.connected.connect(lambda: self.room_widget.update_connection_status(True)) self.sync_client.disconnected.connect(self.on_ws_disconnected) self.sync_client.disconnected.connect(lambda: self.room_widget.update_connection_status(False)) self.sync_client.connection_error.connect(self.on_connection_error) self.sync_client.room_joined.connect(self.on_room_joined) 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.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.sync_client.ready_check_updated.connect(self.room_widget.update_ready_check) self.sync_client.fight_reaction.connect(self.room_widget.handle_fight_reaction) self.sync_client.fight_poll_updated.connect(self.room_widget.update_fight_poll) self.sync_client.latency_updated.connect(self.room_widget.update_latency) self.apply_stylesheet() self._restore_preferences() self.lobby_widget.username_input.textChanged.connect( lambda name: self.settings.setValue("username", name)) self.lobby_widget.file_selected.connect(self._remember_media_folder) self.room_widget.volume_slider.valueChanged.connect( lambda volume: self.settings.setValue("volume", volume)) self.discovery_network = QNetworkAccessManager(self) self.discovery_reply = None self.discovery_timer = QTimer(self) self.discovery_timer.setInterval(15000) self.discovery_timer.timeout.connect(self.refresh_sessions) self.lobby_widget.refresh_requested.connect(self.refresh_sessions) self.discovery_timer.start() QTimer.singleShot(0, self.refresh_sessions) def refresh_sessions(self): if self.stacked_widget.currentIndex() != 0 or self.discovery_reply is not None: return url = self.sync_client.url.replace("wss://", "https://").replace("ws://", "http://") request = QNetworkRequest(QUrl(url.rsplit("/", 1)[0] + "/api/rooms")) request.setTransferTimeout(8000) self.lobby_widget.refresh_btn.setEnabled(False) reply = self.discovery_network.get(request) self.discovery_reply = reply reply.finished.connect(lambda: self._finish_discovery(reply)) def _finish_discovery(self, reply): try: if reply.error() != QNetworkReply.NetworkError.NoError: raise ValueError("Discovery unavailable") rooms = json.loads(bytes(reply.readAll()))["rooms"] self.lobby_widget.update_sessions(rooms) except (ValueError, KeyError, TypeError, OverflowError): self.lobby_widget.sessions_list.clear() self.lobby_widget.check_inputs() self.lobby_widget.sessions_status.setText("Couldn’t check for sessions. You can retry or join by code.") finally: self.discovery_reply = None self.lobby_widget.refresh_btn.setEnabled(True) reply.deleteLater() def _restore_preferences(self): self.lobby_widget.username_input.setText(self.settings.value("username", "", type=str)) folder = self.settings.value("media_folder", "", type=str) self.lobby_widget.media_folder = folder if os.path.isdir(folder) else "" volume = self.settings.value("volume", 100, type=int) self.room_widget.volume_slider.setValue(max(0, min(100, volume))) geometry = self.settings.value("window_geometry") if geometry is not None: self.restoreGeometry(geometry) width = max(200, min(800, self.settings.value("chat_width", 320, type=int))) self.room_widget._saved_chat_width = width self.room_widget.splitter.setSizes([max(100, self.width() - width), width]) self.room_widget.chat_container.setVisible(self.settings.value("chat_visible", True, type=bool)) if not self.settings.value("highlights_visible", True, type=bool): self.room_widget.tags_list.hide() self.room_widget.tags_header_btn.setText("▶ Highlights") def _remember_media_folder(self, path): folder = os.path.dirname(path) self.lobby_widget.media_folder = folder self.settings.setValue("media_folder", folder) def closeEvent(self, event): self.discovery_timer.stop() if self.discovery_reply is not None: self.discovery_reply.abort() room = self.room_widget self.settings.setValue("window_geometry", self.saveGeometry()) width = (room.chat_container.width() if not room.popout_window and not room.chat_container.isHidden() else getattr(room, "_saved_chat_width", 320)) self.settings.setValue("chat_width", width) self.settings.setValue("chat_visible", not room.chat_container.isHidden()) self.settings.setValue("highlights_visible", not room.tags_list.isHidden()) self.settings.sync() if room.popout_window: room.popout_window.close() self.sync_client.stop() room.cleanup() super().closeEvent(event) 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 = file_name self.local_file_size = file_size self.pending_connect_action = lambda: self.sync_client.send_message({ "type": "create_room", "listed": self.lobby_widget.listed_checkbox.isChecked(), "username": self.username, "fileInfo": { "name": self.local_file_name, "size": self.local_file_size, "duration": 0 } }) self._ensure_connection() 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 _on_room_leave(self): self.room_widget.cleanup() self.sync_client.stop() self.stacked_widget.setCurrentIndex(0) self.room_code = "" self.local_file_path = None self.lobby_widget.clear_file() self.lobby_widget.reset_ui() self.refresh_sessions() def _on_room_sync_action(self, action, position_s, req_id): self.sync_client.send_message({"type": "sync", "action": action, "position": position_s, "req_id": req_id}) def _on_room_chat(self, text): self.sync_client.send_message({"type": "chat", "message": text}) # --- WebSocket Callbacks --- def on_ws_connected(self): if self.pending_connect_action: self.pending_connect_action() self.pending_connect_action = None elif self.stacked_widget.currentIndex() == 1 and self.room_code and self.username: self.sync_client.send_message({ "type": "rejoin_room", "username": self.username, "code": self.room_code }) def on_ws_disconnected(self): if self.stacked_widget.currentIndex() == 1: self.room_widget.set_room_code_display(f"{self.room_code} (Reconnecting...)") self.room_widget.add_system_message("⚠️ Connection lost. Trying to reconnect...") else: self.lobby_widget.reset_ui() self.pending_connect_action = None def on_connection_error(self, msg: str): QMessageBox.critical(self, "Secure Connection Error", msg) self.pending_connect_action = None self.lobby_widget.reset_ui() self.sync_client.stop() def on_room_error(self, msg: str): QMessageBox.critical(self, "Room Error", msg) self.lobby_widget.reset_ui() self.sync_client.stop() def on_file_check_needed(self, msg: dict): 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") ext = os.path.splitext(req_name)[1].lower() file_filter = f"Video Files (*{ext});;All Files (*)" if ext else "All Files (*)" file_path, _ = QFileDialog.getOpenFileName( self, f"Select {req_name}", self.lobby_widget.media_folder, file_filter ) if file_path: self._remember_media_folder(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.lower() == req_name.lower() and int(self.local_file_size) == int(req_size): self.sync_client.send_message({ "type": "confirm_join", "fileInfo": { "name": self.local_file_name, "size": self.local_file_size, "duration": 0 } }) else: 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.lobby_widget.reset_ui() else: self.sync_client.stop() self.lobby_widget.reset_ui() 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.lobby_widget.reset_ui() state = msg.get("state", {}) start_time_s = state.get("position", 0.0) if state else 0.0 self.room_widget.setup_room(self.room_code, self.username, self.local_file_name, self.local_file_path, start_time_s) chat_history = msg.get("chatHistory", []) if chat_history: for chat in chat_history: self.room_widget.add_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0)) users = msg.get("users", []) if users: self.room_widget.update_users(users) if state: self.room_widget.handle_sync_event(state) self.room_widget.add_system_message("Welcome to the room! 👋") self.room_widget.finish_chat_history() self.room_widget.update_ready_check(msg.get("readyCheck") or {}) self.room_widget.update_fight_poll(msg.get("fightPoll") or {}) def on_room_rejoined(self, msg: dict): self.room_widget.set_room_code_display(self.room_code) chat_history = msg.get("chatHistory", []) if chat_history: self.room_widget.clear_chat() for chat in chat_history: self.room_widget.add_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0)) self.room_widget.add_system_message("✅ Reconnected to the room.") users = msg.get("users", []) if users: self.room_widget.update_users(users) state = msg.get("state", {}) if state: self.room_widget.handle_sync_event(state) self.room_widget.finish_chat_history() self.room_widget.update_ready_check(msg.get("readyCheck") or {}) self.room_widget.update_fight_poll(msg.get("fightPoll") or {}) 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; background-color: transparent; } #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; background-color: #272727; color: white; border: 1px solid #444; } QPushButton:hover { background-color: #333; } QPushButton:disabled { border-color: #222; color: #666; } #primaryBtn { background-color: #3ea6ff; color: black; border: none; } #primaryBtn:hover { background-color: #65b8ff; } #primaryBtn:disabled { background-color: #333; color: #666; } #iconBtn { background-color: transparent; border: none; padding: 5px; } #iconBtn:hover { background-color: rgba(255, 255, 255, 0.1); } #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; } #seekBar { border: none; background: transparent; } #seekBar::groove:horizontal { height: 4px; background: #444; border: none; border-radius: 2px; } #seekBar::sub-page:horizontal { background: #ff0000; height: 4px; border-radius: 2px; } #seekBar::add-page:horizontal { background: #444; height: 4px; border-radius: 2px; } #seekBar::handle:horizontal { background: #ff0000; width: 12px; height: 12px; margin: -4px 0; border-radius: 6px; } #seekBar::handle:horizontal:hover { background: #ff3333; width: 14px; height: 14px; margin: -5px 0; border-radius: 7px; } #volumeSlider { border: none; background: transparent; } #volumeSlider::groove:horizontal { height: 4px; background: #444; border: none; border-radius: 2px; } #volumeSlider::sub-page:horizontal { background: #fff; height: 4px; border-radius: 2px; } #volumeSlider::add-page:horizontal { background: #444; height: 4px; border-radius: 2px; } #volumeSlider::handle:horizontal { background: #fff; width: 10px; height: 10px; margin: -3px 0; border-radius: 5px; } #volumeSlider::handle:horizontal:hover { width: 12px; height: 12px; margin: -4px 0; border-radius: 6px; } #chatContainer { background-color: #111; border-left: 1px solid #333; } #chatHeader { font-size: 16px; color: white; padding: 10px 0; } #usersLbl { color: #3ea6ff; font-size: 12px; } #chatMessages { background-color: transparent; border: none; } #chatMessages QScrollBar:vertical { border: none; background: transparent; width: 8px; margin: 0px; } #chatMessages QScrollBar::handle:vertical { background: #333; min-height: 20px; border-radius: 4px; } #chatMessages QScrollBar::handle:vertical:hover { background: #444; } #chatMessages QScrollBar::add-line:vertical, #chatMessages QScrollBar::sub-line:vertical { height: 0px; } #chatMessages QScrollBar::add-page:vertical, #chatMessages QScrollBar::sub-page:vertical { background: none; } #bubble { border: none; } """) if __name__ == "__main__": import ctypes if os.name == 'nt': myappid = 'vlcsync.desktopclient.app.1' try: ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) except Exception: pass app = QApplication(sys.argv) 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())