Add discoverable rooms to desktop lobby

This commit is contained in:
Peter Stockings
2026-10-05 16:13:17 +11:00
parent 836034533e
commit 21a35d81f0
7 changed files with 190 additions and 11 deletions
+12
View File
@@ -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
+60 -5
View File
@@ -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()
+43 -1
View File
@@ -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})
+33
View File
@@ -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() == []
+23
View File
@@ -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()
+2
View File
@@ -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)