Add discoverable rooms to desktop lobby
This commit is contained in:
@@ -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
|
for both creating and joining rooms start in the remembered folder. The server
|
||||||
address remains fixed. Detached chat opens docked on the next launch.
|
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
|
||||||
|
|
||||||
Chat follows new messages while you are near the bottom. Scroll up to read older
|
Chat follows new messages while you are near the bottom. Scroll up to read older
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
|
||||||
QFileDialog, QFrame, QApplication
|
QFileDialog, QFrame, QApplication, QCheckBox, QListWidget, QListWidgetItem
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ class LobbyWidget(QWidget):
|
|||||||
create_requested = pyqtSignal(str, str, str, object) # username, path, filename, size
|
create_requested = pyqtSignal(str, str, str, object) # username, path, filename, size
|
||||||
join_requested = pyqtSignal(str, str) # username, room_code
|
join_requested = pyqtSignal(str, str) # username, room_code
|
||||||
file_selected = pyqtSignal(str)
|
file_selected = pyqtSignal(str)
|
||||||
|
refresh_requested = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -44,7 +45,8 @@ class LobbyWidget(QWidget):
|
|||||||
# Container for centering
|
# Container for centering
|
||||||
container = QFrame()
|
container = QFrame()
|
||||||
container.setObjectName("lobbyCard")
|
container.setObjectName("lobbyCard")
|
||||||
container.setFixedWidth(500)
|
container.setMinimumWidth(420)
|
||||||
|
container.setMaximumWidth(500)
|
||||||
container_layout = QVBoxLayout(container)
|
container_layout = QVBoxLayout(container)
|
||||||
container_layout.setContentsMargins(30, 30, 30, 30)
|
container_layout.setContentsMargins(30, 30, 30, 30)
|
||||||
container_layout.setSpacing(15)
|
container_layout.setSpacing(15)
|
||||||
@@ -91,7 +93,11 @@ class LobbyWidget(QWidget):
|
|||||||
self.create_room_btn.clicked.connect(self.create_room)
|
self.create_room_btn.clicked.connect(self.create_room)
|
||||||
|
|
||||||
create_panel.addWidget(self.create_file_btn)
|
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)
|
create_panel.addWidget(self.create_room_btn)
|
||||||
|
|
||||||
# Join Room Panel
|
# Join Room Panel
|
||||||
@@ -118,7 +124,30 @@ class LobbyWidget(QWidget):
|
|||||||
actions_layout.addLayout(join_panel)
|
actions_layout.addLayout(join_panel)
|
||||||
|
|
||||||
container_layout.addLayout(actions_layout)
|
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
|
# Signals to enable/disable buttons
|
||||||
self.username_input.textChanged.connect(self.check_inputs)
|
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
|
has_code = len(self.room_code_input.text().strip()) >= 4
|
||||||
|
|
||||||
self.create_room_btn.setEnabled(has_name and has_file)
|
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):
|
def create_room(self):
|
||||||
username = self.username_input.text().strip()
|
username = self.username_input.text().strip()
|
||||||
|
|||||||
+43
-1
@@ -1,10 +1,12 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import json
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication, QMainWindow, QStackedWidget, QFileDialog, QMessageBox
|
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 PyQt6.QtGui import QIcon
|
||||||
|
|
||||||
from sync_client import SyncClientThread
|
from sync_client import SyncClientThread
|
||||||
@@ -79,6 +81,40 @@ class VlcSyncApp(QMainWindow):
|
|||||||
self.lobby_widget.file_selected.connect(self._remember_media_folder)
|
self.lobby_widget.file_selected.connect(self._remember_media_folder)
|
||||||
self.room_widget.volume_slider.valueChanged.connect(
|
self.room_widget.volume_slider.valueChanged.connect(
|
||||||
lambda volume: self.settings.setValue("volume", volume))
|
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):
|
def _restore_preferences(self):
|
||||||
self.lobby_widget.username_input.setText(self.settings.value("username", "", type=str))
|
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)
|
self.settings.setValue("media_folder", folder)
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
|
self.discovery_timer.stop()
|
||||||
|
if self.discovery_reply is not None:
|
||||||
|
self.discovery_reply.abort()
|
||||||
room = self.room_widget
|
room = self.room_widget
|
||||||
self.settings.setValue("window_geometry", self.saveGeometry())
|
self.settings.setValue("window_geometry", self.saveGeometry())
|
||||||
width = (room.chat_container.width() if not room.popout_window and
|
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({
|
self.pending_connect_action = lambda: self.sync_client.send_message({
|
||||||
"type": "create_room",
|
"type": "create_room",
|
||||||
|
"listed": self.lobby_widget.listed_checkbox.isChecked(),
|
||||||
"username": self.username,
|
"username": self.username,
|
||||||
"fileInfo": {
|
"fileInfo": {
|
||||||
"name": self.local_file_name,
|
"name": self.local_file_name,
|
||||||
@@ -159,6 +199,8 @@ class VlcSyncApp(QMainWindow):
|
|||||||
self.lobby_widget.clear_file()
|
self.lobby_widget.clear_file()
|
||||||
self.lobby_widget.reset_ui()
|
self.lobby_widget.reset_ui()
|
||||||
|
|
||||||
|
self.refresh_sessions()
|
||||||
|
|
||||||
def _on_room_sync_action(self, action, position_s, req_id):
|
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})
|
self.sync_client.send_message({"type": "sync", "action": action, "position": position_s, "req_id": req_id})
|
||||||
|
|
||||||
|
|||||||
@@ -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() == []
|
||||||
@@ -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()
|
||||||
@@ -8,6 +8,7 @@ from test_chat import PlayerSignals
|
|||||||
|
|
||||||
|
|
||||||
def test_preferences_survive_restart(qtbot, monkeypatch, tmp_path):
|
def test_preferences_survive_restart(qtbot, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(VlcSyncApp, "refresh_sessions", lambda self: None)
|
||||||
monkeypatch.setattr(room_widget, "VLCSyncPlayer",
|
monkeypatch.setattr(room_widget, "VLCSyncPlayer",
|
||||||
lambda frame: Mock(signals=PlayerSignals()))
|
lambda frame: Mock(signals=PlayerSignals()))
|
||||||
path = str(tmp_path / "preferences.ini")
|
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):
|
def test_missing_folder_and_volume_bounds(qtbot, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(VlcSyncApp, "refresh_sessions", lambda self: None)
|
||||||
monkeypatch.setattr(room_widget, "VLCSyncPlayer",
|
monkeypatch.setattr(room_widget, "VLCSyncPlayer",
|
||||||
lambda frame: Mock(signals=PlayerSignals()))
|
lambda frame: Mock(signals=PlayerSignals()))
|
||||||
settings = QSettings(str(tmp_path / "preferences.ini"), QSettings.Format.IniFormat)
|
settings = QSettings(str(tmp_path / "preferences.ini"), QSettings.Format.IniFormat)
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ interface FileInfo {
|
|||||||
duration: number;
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoomState {
|
interface RoomState {
|
||||||
code: string;
|
code: string;
|
||||||
|
listed: boolean;
|
||||||
fileInfo: FileInfo;
|
fileInfo: FileInfo;
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
@@ -133,7 +134,17 @@ const server = Bun.serve<WSData>({
|
|||||||
port: Number(process.env.PORT) || 3000,
|
port: Number(process.env.PORT) || 3000,
|
||||||
|
|
||||||
fetch(req, server) {
|
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
|
// WebSocket upgrade
|
||||||
if (url.pathname === "/ws") {
|
if (url.pathname === "/ws") {
|
||||||
@@ -179,8 +190,9 @@ const server = Bun.serve<WSData>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const code = generateRoomCode();
|
const code = generateRoomCode();
|
||||||
const room: RoomState = {
|
const room: RoomState = {
|
||||||
code,
|
code,
|
||||||
|
listed: msg.listed !== false,
|
||||||
fileInfo: {
|
fileInfo: {
|
||||||
name: msg.fileInfo.name,
|
name: msg.fileInfo.name,
|
||||||
size: msg.fileInfo.size,
|
size: msg.fileInfo.size,
|
||||||
|
|||||||
Reference in New Issue
Block a user