From 21a35d81f0996e4f40ed0bb69807661a14757c5d Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 5 Oct 2026 16:13:17 +1100 Subject: [PATCH] Add discoverable rooms to desktop lobby --- desktop-client/README.md | 12 +++++ desktop-client/lobby_widget.py | 65 ++++++++++++++++++++++++--- desktop-client/main.py | 44 +++++++++++++++++- desktop-client/test_discovery.py | 33 ++++++++++++++ desktop-client/test_lobby_sessions.py | 23 ++++++++++ desktop-client/test_preferences.py | 2 + server.ts | 22 ++++++--- 7 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 desktop-client/test_discovery.py create mode 100644 desktop-client/test_lobby_sessions.py diff --git a/desktop-client/README.md b/desktop-client/README.md index c5abe00..1de56ef 100644 --- a/desktop-client/README.md +++ b/desktop-client/README.md @@ -8,6 +8,18 @@ Preferences are stored locally using Qt's platform settings store. File dialogs for both creating and joining rooms start in the remembered folder. The server address remains fixed. Detached chat opens docked on the next launch. +## Active sessions + +The lobby checks for listed rooms at startup and every 15 seconds while open. +Select a session and click **Choose file & join**, or double-click it, to use the +existing file verification flow. Enter your display name first. **Refresh** checks +immediately; joining by code remains available when discovery fails. + +New rooms are listed by default. Clear **List room in active sessions** before +creating a room to keep it out of the list; anyone with its code can still join. +Only rooms with connected participants appear. Discovery requires the updated +server's `/api/rooms` endpoint; older clients also create listed rooms by default. + ## Chat Chat follows new messages while you are near the bottom. Scroll up to read older diff --git a/desktop-client/lobby_widget.py b/desktop-client/lobby_widget.py index 9c04ca9..8c332da 100644 --- a/desktop-client/lobby_widget.py +++ b/desktop-client/lobby_widget.py @@ -2,7 +2,7 @@ import os import re from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, - QFileDialog, QFrame, QApplication + QFileDialog, QFrame, QApplication, QCheckBox, QListWidget, QListWidgetItem ) from PyQt6.QtCore import Qt, pyqtSignal @@ -25,6 +25,7 @@ class LobbyWidget(QWidget): 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__() @@ -44,7 +45,8 @@ class LobbyWidget(QWidget): # Container for centering container = QFrame() container.setObjectName("lobbyCard") - container.setFixedWidth(500) + container.setMinimumWidth(420) + container.setMaximumWidth(500) container_layout = QVBoxLayout(container) container_layout.setContentsMargins(30, 30, 30, 30) container_layout.setSpacing(15) @@ -91,7 +93,11 @@ class LobbyWidget(QWidget): 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_file_info) + self.listed_checkbox = QCheckBox("List room in active sessions") + self.listed_checkbox.setChecked(True) + self.listed_checkbox.setToolTip("Unlisted rooms can still be joined using their code.") + create_panel.addWidget(self.listed_checkbox) create_panel.addWidget(self.create_room_btn) # Join Room Panel @@ -118,7 +124,30 @@ class LobbyWidget(QWidget): actions_layout.addLayout(join_panel) container_layout.addLayout(actions_layout) - layout.addWidget(container) + panels = QHBoxLayout() + panels.addWidget(container) + sessions = QVBoxLayout() + heading = QHBoxLayout() + heading.addWidget(QLabel("ALREADY WATCHING")) + self.refresh_btn = QPushButton("Refresh") + self.refresh_btn.clicked.connect(self.refresh_requested.emit) + heading.addWidget(self.refresh_btn) + sessions.addLayout(heading) + self.sessions_status = QLabel("Checking for active sessions…") + self.sessions_status.setWordWrap(True) + sessions.addWidget(self.sessions_status) + self.sessions_list = QListWidget() + self.sessions_list.setWordWrap(True) + self.sessions_list.setMinimumWidth(280) + sessions.addWidget(self.sessions_list) + self.session_join_btn = QPushButton("Choose file & join") + self.session_join_btn.setEnabled(False) + self.session_join_btn.clicked.connect(self.join_selected_session) + self.sessions_list.itemDoubleClicked.connect(lambda _: self.join_selected_session()) + self.sessions_list.itemSelectionChanged.connect(self.check_inputs) + sessions.addWidget(self.session_join_btn) + panels.addLayout(sessions) + layout.addLayout(panels) # Signals to enable/disable buttons self.username_input.textChanged.connect(self.check_inputs) @@ -164,7 +193,33 @@ class LobbyWidget(QWidget): 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) + self.join_room_btn.setEnabled(has_name and has_code) + self.session_join_btn.setEnabled(has_name and self.sessions_list.currentItem() is not None) + + 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.setData(Qt.ItemDataRole.UserRole, room['code']) + self.sessions_list.addItem(item) + if room['code'] == code: + self.sessions_list.setCurrentItem(item) + 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): username = self.username_input.text().strip() diff --git a/desktop-client/main.py b/desktop-client/main.py index b0409dd..b81bdf1 100644 --- a/desktop-client/main.py +++ b/desktop-client/main.py @@ -1,10 +1,12 @@ import sys import os import ctypes +import json from PyQt6.QtWidgets import ( QApplication, QMainWindow, QStackedWidget, QFileDialog, QMessageBox ) -from PyQt6.QtCore import Qt, QTimer, QSettings +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 @@ -79,6 +81,40 @@ class VlcSyncApp(QMainWindow): 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)) @@ -103,6 +139,9 @@ class VlcSyncApp(QMainWindow): 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 @@ -125,6 +164,7 @@ class VlcSyncApp(QMainWindow): 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, @@ -159,6 +199,8 @@ class VlcSyncApp(QMainWindow): 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}) diff --git a/desktop-client/test_discovery.py b/desktop-client/test_discovery.py new file mode 100644 index 0000000..4b34f82 --- /dev/null +++ b/desktop-client/test_discovery.py @@ -0,0 +1,33 @@ +import json +from urllib.request import urlopen + +from test_ready_server import server_url, send, receive +from websockets.sync.client import connect + + +def test_discovery_visibility_and_connected_users(server_url): + endpoint = server_url.replace('ws://', 'http://').removesuffix('/ws') + '/api/rooms' + def listing(): + with urlopen(endpoint, timeout=3) as response: + assert response.headers['Cache-Control'] == 'no-store' + return json.load(response)['rooms'] + + assert listing() == [] + with connect(server_url) as public, connect(server_url) as private: + file_info = {'name': 'test.mkv', 'size': 1234, 'duration': 60} + send(public, type='create_room', username='Public', fileInfo=file_info) + public_code = receive(public, 'room_created')['code'] + send(private, type='create_room', username='Private', fileInfo=file_info, listed=False) + private_code = receive(private, 'room_created')['code'] + rooms = listing() + assert len(rooms) == 1 + assert rooms[0]['code'] == public_code + assert rooms[0]['users'] == ['Public'] + assert rooms[0]['playing'] is False + with connect(server_url) as guest: + send(guest, type='join_room', username='Guest', code=private_code) + receive(guest, 'room_file_check') + send(guest, type='confirm_join', fileInfo=file_info) + receive(guest, 'room_joined') + assert len(listing()) == 1 + assert listing() == [] diff --git a/desktop-client/test_lobby_sessions.py b/desktop-client/test_lobby_sessions.py new file mode 100644 index 0000000..39499ec --- /dev/null +++ b/desktop-client/test_lobby_sessions.py @@ -0,0 +1,23 @@ +from lobby_widget import LobbyWidget + + +def test_session_selection_uses_existing_join_flow(qtbot): + lobby = LobbyWidget() + qtbot.addWidget(lobby) + assert lobby.listed_checkbox.isChecked() + rooms = [{"code": "ABC123", "fileInfo": {"name": "movie.mkv"}, + "users": ["Host"], "playing": True, "position": 75}] + lobby.update_sessions(rooms) + lobby.sessions_list.setCurrentRow(0) + assert not lobby.session_join_btn.isEnabled() + lobby.username_input.setText("Alex") + assert lobby.session_join_btn.isEnabled() + with qtbot.waitSignal(lobby.join_requested) as signal: + lobby.join_selected_session() + assert signal.args == ["Alex", "ABC123"] + lobby.reset_ui() + lobby.update_sessions(rooms) + assert lobby.sessions_list.currentItem().data(256) == "ABC123" + lobby.update_sessions([]) + assert not lobby.session_join_btn.isEnabled() + assert "No active sessions" in lobby.sessions_status.text() diff --git a/desktop-client/test_preferences.py b/desktop-client/test_preferences.py index a3889dc..39c78cf 100644 --- a/desktop-client/test_preferences.py +++ b/desktop-client/test_preferences.py @@ -8,6 +8,7 @@ from test_chat import PlayerSignals def test_preferences_survive_restart(qtbot, monkeypatch, tmp_path): + monkeypatch.setattr(VlcSyncApp, "refresh_sessions", lambda self: None) monkeypatch.setattr(room_widget, "VLCSyncPlayer", lambda frame: Mock(signals=PlayerSignals())) path = str(tmp_path / "preferences.ini") @@ -35,6 +36,7 @@ def test_preferences_survive_restart(qtbot, monkeypatch, tmp_path): def test_missing_folder_and_volume_bounds(qtbot, monkeypatch, tmp_path): + monkeypatch.setattr(VlcSyncApp, "refresh_sessions", lambda self: None) monkeypatch.setattr(room_widget, "VLCSyncPlayer", lambda frame: Mock(signals=PlayerSignals())) settings = QSettings(str(tmp_path / "preferences.ini"), QSettings.Format.IniFormat) diff --git a/server.ts b/server.ts index 762efdc..e2b262f 100644 --- a/server.ts +++ b/server.ts @@ -8,8 +8,9 @@ interface FileInfo { duration: number; } -interface RoomState { - code: string; +interface RoomState { + code: string; + listed: boolean; fileInfo: FileInfo; playing: boolean; position: number; @@ -133,7 +134,17 @@ const server = Bun.serve({ port: Number(process.env.PORT) || 3000, fetch(req, server) { - const url = new URL(req.url); + const url = new URL(req.url); + + if (url.pathname === "/api/rooms" && req.method === "GET") { + const activeRooms = Array.from(rooms.values()).flatMap(room => { + const users = Array.from(room.users).filter(([, ws]) => ws.readyState === 1) + .map(([name]) => name); + return room.listed && users.length ? [{ code: room.code, fileInfo: room.fileInfo, + users, playing: room.playing, position: getCurrentPosition(room) }] : []; + }); + return Response.json({ rooms: activeRooms }, { headers: { "Cache-Control": "no-store" } }); + } // WebSocket upgrade if (url.pathname === "/ws") { @@ -179,8 +190,9 @@ const server = Bun.serve({ } const code = generateRoomCode(); - const room: RoomState = { - code, + const room: RoomState = { + code, + listed: msg.listed !== false, fileInfo: { name: msg.fileInfo.name, size: msg.fileInfo.size,