import os import re from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QFrame, QApplication, QCheckBox, QListWidget, QListWidgetItem, QDialog, QStyledItemDelegate, QStyle ) from PyQt6.QtCore import Qt, pyqtSignal, QSize from PyQt6.QtGui import QColor, QFont, QFontMetrics class SessionDelegate(QStyledItemDelegate): """Draw readable session rows with elided filenames and secondary details.""" def paint(self, painter, option, index): painter.save() selected = bool(option.state & QStyle.StateFlag.State_Selected) hovered = bool(option.state & QStyle.StateFlag.State_MouseOver) painter.fillRect(option.rect, QColor("#29243f" if selected else "#222839" if hovered else "#191e29")) rect = option.rect.adjusted(22, 20, -22, -20) lines = str(index.data()).splitlines() for i, text in enumerate(lines[:3]): font = QFont("Segoe UI", 10) font.setWeight(QFont.Weight.DemiBold if i == 0 else QFont.Weight.Normal) painter.setFont(font) painter.setPen(QColor("#edf1fa" if i == 0 else "#9ca9c0")) line = rect.adjusted(0, i * 24, 0, 0) line.setHeight(24) text = QFontMetrics(font).elidedText(text, Qt.TextElideMode.ElideRight, line.width()) painter.drawText(line, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, text) painter.setPen(QColor("#2c3546")) painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight()) if option.state & QStyle.StateFlag.State_HasFocus: painter.setPen(QColor("#9b8bff")) painter.drawRect(option.rect.adjusted(1, 1, -1, -1)) painter.restore() class RoomCodeInput(QLineEdit): """QLineEdit that auto-fills from clipboard if it looks like a room code.""" def focusInEvent(self, event): super().focusInEvent(event) if not self.text().strip(): self._try_paste_room_code() def _try_paste_room_code(self): clipboard = QApplication.clipboard() text = (clipboard.text() or "").strip().upper() if re.match(r'^[A-Z0-9]{4,8}$', text): self.setText(text) self.selectAll() 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 file_selected = pyqtSignal(str) refresh_requested = pyqtSignal() def __init__(self): super().__init__() self.setAcceptDrops(True) self.local_file_path = None self.local_file_name = None self.local_file_size = 0 self.media_folder = "" self._setup_ui() def _setup_ui(self): self.setObjectName("desktopLobby") self._connecting = False layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) header = QFrame() header.setObjectName("lobbyHeader") header_layout = QHBoxLayout(header) header_layout.setContentsMargins(28, 18, 28, 18) brand = QLabel("VideoSync") brand.setObjectName("lobbyBrand") header_layout.addWidget(brand) header_layout.addStretch() self.connection_label = QLabel("Watch together. Stay in sync.") self.connection_label.setObjectName("lobbyMuted") header_layout.addWidget(self.connection_label) layout.addWidget(header) body = QHBoxLayout() body.setSpacing(0) sidebar = QFrame() sidebar.setObjectName("lobbySidebar") sidebar.setFixedWidth(260) side = QVBoxLayout(sidebar) side.setContentsMargins(24, 32, 24, 24) side.setSpacing(12) title = QLabel("Watch together.") title.setObjectName("sidebarTitle") side.addWidget(title) subtitle = QLabel("One room. Everyone in sync.") subtitle.setObjectName("lobbyMuted") side.addWidget(subtitle) side.addSpacing(12) self.create_action_btn = QPushButton("+ Create room") self.create_action_btn.setObjectName("lobbyPrimary") self.create_action_btn.clicked.connect(self.open_create_dialog) side.addWidget(self.create_action_btn) self.join_action_btn = QPushButton("Join by code") self.join_action_btn.clicked.connect(self.open_join_dialog) side.addWidget(self.join_action_btn) side.addSpacing(28) name_label = QLabel("Your display name") side.addWidget(name_label) self.username_input = QLineEdit() self.username_input.setPlaceholderText("Enter your name") self.username_input.setAccessibleName("Your display name") side.addWidget(self.username_input) name_label.setBuddy(self.username_input) hint = QLabel("Remembered on this computer.") hint.setObjectName("lobbyMuted") hint.setWordWrap(True) side.addWidget(hint) side.addStretch() privacy = QLabel("Your video stays on your computer.\nOnly playback and chat are shared.") privacy.setObjectName("lobbyMuted") privacy.setWordWrap(True) side.addWidget(privacy) body.addWidget(sidebar) workspace = QVBoxLayout() workspace.setContentsMargins(32, 32, 32, 24) workspace.setSpacing(12) heading = QHBoxLayout() title_group = QVBoxLayout() title_group.setSpacing(8) title = QLabel("Active sessions") title.setObjectName("workspaceTitle") title_group.addWidget(title) description = QLabel("See who’s watching and join in.") description.setObjectName("lobbyMuted") title_group.addWidget(description) heading.addLayout(title_group) heading.addStretch() self.refresh_btn = QPushButton("Refresh") self.refresh_btn.setObjectName("lobbyQuiet") self.refresh_btn.clicked.connect(self.refresh_requested.emit) heading.addWidget(self.refresh_btn) workspace.addLayout(heading) workspace.addSpacing(12) self.sessions_status = QLabel("Checking for active sessions…") self.sessions_status.setObjectName("lobbyMuted") self.sessions_status.setWordWrap(True) workspace.addWidget(self.sessions_status) self.sessions_list = QListWidget() self.sessions_list.setObjectName("lobbySessions") self.sessions_list.setWordWrap(True) self.sessions_list.setItemDelegate(SessionDelegate(self.sessions_list)) self.sessions_list.setAccessibleName("Active sessions") workspace.addWidget(self.sessions_list, 1) bottom = QHBoxLayout() self.session_count = QLabel("Select a session to get started.") self.session_count.setObjectName("lobbyMuted") bottom.addWidget(self.session_count) bottom.addStretch() self.session_join_btn = QPushButton("Choose file && join") self.session_join_btn.setObjectName("lobbyPrimary") self.session_join_btn.setEnabled(False) self.session_join_btn.clicked.connect(self.join_selected_session) bottom.addWidget(self.session_join_btn) workspace.addLayout(bottom) self.sessions_list.itemDoubleClicked.connect(lambda _: self.join_selected_session()) self.sessions_list.itemSelectionChanged.connect(self.check_inputs) body.addLayout(workspace, 1) layout.addLayout(body, 1) self.create_dialog = self._make_dialog("Create a room", "Choose a local video, then invite your friends.") create = self.create_dialog.layout() self.create_file_btn = QPushButton("Choose video file") self.create_file_btn.clicked.connect(self.select_file) create.addWidget(self.create_file_btn) self.create_file_info = QLabel("") self.create_file_info.setObjectName("lobbyFileInfo") self.create_file_info.setWordWrap(True) self.create_file_info.hide() create.addWidget(self.create_file_info) self.listed_checkbox = QCheckBox("List room in active sessions") self.listed_checkbox.setChecked(True) create.addWidget(self.listed_checkbox) hint = QLabel("Unlisted rooms can still be joined using their code.") hint.setObjectName("lobbyMuted") hint.setWordWrap(True) create.addWidget(hint) self.create_room_btn = QPushButton("Create room") self.create_room_btn.setObjectName("lobbyPrimary") self.create_room_btn.clicked.connect(self.create_room) self._dialog_actions(self.create_dialog, self.create_room_btn) self.join_dialog = self._make_dialog("Join by code", "Enter the room code your friend shared.") self.room_code_input = RoomCodeInput() self.room_code_input.setPlaceholderText("e.g. ABC123") self.room_code_input.setAccessibleName("Room code") self.room_code_input.setMaxLength(8) self.room_code_input.returnPressed.connect(self._join_if_valid) self.join_dialog.layout().addWidget(self.room_code_input) self.join_room_btn = QPushButton("Join room") self.join_room_btn.setObjectName("lobbyPrimary") self.join_room_btn.clicked.connect(self.join_room) self._dialog_actions(self.join_dialog, self.join_room_btn) self.username_input.textChanged.connect(self.check_inputs) self.room_code_input.textChanged.connect(self.check_inputs) self._apply_lobby_style() self.check_inputs() def _make_dialog(self, title, description): dialog = QDialog(self) dialog.setWindowTitle(title) dialog.setObjectName("lobbyDialog") dialog.setModal(True) dialog.setMinimumWidth(420) layout = QVBoxLayout(dialog) layout.setContentsMargins(28, 28, 28, 28) layout.setSpacing(18) label = QLabel(title) label.setObjectName("dialogTitle") layout.addWidget(label) text = QLabel(description) text.setObjectName("lobbyMuted") text.setWordWrap(True) layout.addWidget(text) return dialog def _dialog_actions(self, dialog, action): row = QHBoxLayout() row.addStretch() cancel = QPushButton("Cancel") cancel.clicked.connect(dialog.reject) row.addWidget(cancel) row.addWidget(action) dialog.layout().addLayout(row) def open_create_dialog(self): if not self.username_input.text().strip(): self.username_input.setFocus() return self.create_dialog.show() self.create_dialog.raise_() self.create_file_btn.setFocus() def open_join_dialog(self): if not self.username_input.text().strip(): self.username_input.setFocus() return self.join_dialog.show() self.join_dialog.raise_() self.room_code_input.setFocus() def _join_if_valid(self): if self.join_room_btn.isEnabled(): self.join_room() def _apply_lobby_style(self): self.setStyleSheet(""" QWidget#desktopLobby, QDialog#lobbyDialog { background: #10131b; } QLabel { color: #edf1fa; font-family: 'Segoe UI'; font-size: 13px; font-weight: normal; } QFrame#lobbyHeader { background: #191e29; border-bottom: 1px solid #2c3546; } QFrame#lobbySidebar { background: #191e29; border-right: 1px solid #2c3546; } QLabel#lobbyBrand { font-size: 20px; font-weight: 600; } QLabel#sidebarTitle, QLabel#dialogTitle { font-size: 23px; font-weight: 600; } QLabel#workspaceTitle { font-size: 28px; font-weight: 600; } QLabel#lobbyMuted { color: #9ca9c0; font-size: 12px; } QLabel#lobbyFileInfo { background: #29243f; color: #c6bcff; padding: 14px; border-radius: 7px; } QLineEdit { background: #10131b; color: #edf1fa; border: 1px solid #364157; border-radius: 7px; padding: 11px; font: 14px 'Segoe UI'; } QLineEdit:focus { border-color: #9b8bff; } QPushButton { background: #191e29; color: #edf1fa; border: 1px solid #364157; border-radius: 7px; padding: 11px 15px; font: 13px 'Segoe UI'; } QPushButton:hover { background: #242b3b; border-color: #9b8bff; } QPushButton#lobbyPrimary { background: #9b8bff; color: #141020; border: none; font-weight: 600; } QPushButton#lobbyPrimary:hover { background: #b1a4ff; } QPushButton:disabled, QPushButton#lobbyPrimary:disabled { background: #242b38; color: #738099; border-color: #2c3546; } QPushButton#lobbyQuiet { background: transparent; } QCheckBox { color: #edf1fa; spacing: 9px; font: 13px 'Segoe UI'; } QListWidget#lobbySessions { background: #191e29; color: #edf1fa; border: 1px solid #2c3546; border-radius: 9px; outline: none; font: 14px 'Segoe UI'; } QListWidget#lobbySessions::item { padding: 20px; border-bottom: 1px solid #2c3546; } QListWidget#lobbySessions::item:selected { background: #29243f; color: #edf1fa; } QListWidget#lobbySessions::item:hover { background: #222839; } QListWidget#lobbySessions:focus { border-color: #9b8bff; } """) 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) self.media_folder = os.path.dirname(file_path) self.file_selected.emit(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", self.media_folder, "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) self.open_create_dialog() 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 and not self._connecting) self.create_action_btn.setEnabled(has_name and not self._connecting) self.join_action_btn.setEnabled(has_name and not self._connecting) self.join_room_btn.setEnabled(has_name and has_code and not self._connecting) self.session_join_btn.setEnabled(has_name and self.sessions_list.currentItem() is not None and not self._connecting) def update_sessions(self, rooms): selected = self.sessions_list.currentItem() code = selected.data(Qt.ItemDataRole.UserRole) if selected else None self.sessions_list.clear() for room in rooms: seconds = max(0, int(room.get("position", 0))) state = "Playing" if room.get("playing") else "Paused" users = room.get("users", []) item = QListWidgetItem(f"{room['fileInfo']['name']}\n" f"{state} · {seconds // 60}:{seconds % 60:02d} · {len(users)} connected\n" f"{', '.join(users)}") item.setSizeHint(QSize(0, 116)) item.setToolTip(item.text()) item.setData(Qt.ItemDataRole.UserRole, room['code']) self.sessions_list.addItem(item) if room['code'] == code: self.sessions_list.setCurrentItem(item) self.session_count.setText(f"{len(rooms)} room{'s' if len(rooms) != 1 else ''} available") self.sessions_list.setVisible(bool(rooms)) self.sessions_status.setText("Select a session to join." if rooms else "No active sessions. Create a room to get started.") self.check_inputs() def join_selected_session(self): item = self.sessions_list.currentItem() if item and self.username_input.text().strip(): self.room_code_input.setText(item.data(Qt.ItemDataRole.UserRole)) self.join_room() def create_room(self): if not self.create_room_btn.isEnabled(): return self._connecting = True self.create_dialog.accept() self.check_inputs() 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): if not self.join_room_btn.isEnabled(): return self._connecting = True self.join_dialog.accept() self.check_inputs() 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._connecting = False 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()