Compare commits

...
2 Commits
Author SHA1 Message Date
Peter Stockings 21a35d81f0 Add discoverable rooms to desktop lobby 2026-10-05 16:13:17 +11:00
Peter Stockings 836034533e Persist desktop client preferences 2026-10-05 16:06:59 +11:00
7 changed files with 299 additions and 17 deletions
+20
View File
@@ -1,5 +1,25 @@
# VideoSync desktop client
## Saved preferences
The client remembers your display name, volume, window geometry, chat sidebar
visibility and width, highlight panel visibility, and last selected media folder.
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
+68 -9
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
@@ -23,7 +23,9 @@ class RoomCodeInput(QLineEdit):
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
join_requested = pyqtSignal(str, str) # username, room_code
file_selected = pyqtSignal(str)
refresh_requested = pyqtSignal()
def __init__(self):
super().__init__()
@@ -31,7 +33,8 @@ class LobbyWidget(QWidget):
self.local_file_path = None
self.local_file_name = None
self.local_file_size = 0
self.local_file_size = 0
self.media_folder = ""
self._setup_ui()
@@ -42,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)
@@ -89,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
@@ -116,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)
@@ -125,7 +156,9 @@ class LobbyWidget(QWidget):
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.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")
@@ -134,7 +167,7 @@ class LobbyWidget(QWidget):
def select_file(self):
file_path, _ = QFileDialog.getOpenFileName(
self, "Select Video File", "", "Video Files (*.mp4 *.mkv *.avi *.mov *.webm);;All Files (*)"
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)
@@ -160,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()
+90 -3
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
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
@@ -12,8 +14,9 @@ from lobby_widget import LobbyWidget
from room_widget import RoomWidget
class VlcSyncApp(QMainWindow):
def __init__(self):
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")
@@ -72,6 +75,86 @@ class VlcSyncApp(QMainWindow):
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
@@ -81,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,
@@ -115,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})
@@ -164,10 +250,11 @@ class VlcSyncApp(QMainWindow):
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}", "", file_filter
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)
+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()
+48
View File
@@ -0,0 +1,48 @@
from unittest.mock import Mock
from PyQt6.QtCore import QSettings
import room_widget
from main import VlcSyncApp
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")
settings = QSettings(path, QSettings.Format.IniFormat)
first = VlcSyncApp(settings)
qtbot.addWidget(first)
first.show()
first.lobby_widget.username_input.setText("Alex")
first.room_widget.volume_slider.setValue(37)
first._remember_media_folder(str(tmp_path / "movie.mkv"))
first.room_widget.chat_container.hide()
first.room_widget.tags_list.hide()
first.resize(900, 650)
first.close()
second = VlcSyncApp(QSettings(path, QSettings.Format.IniFormat))
qtbot.addWidget(second)
assert second.lobby_widget.username_input.text() == "Alex"
assert second.room_widget.volume_slider.value() == 37
assert second.lobby_widget.media_folder == str(tmp_path)
assert second.room_widget.chat_container.isHidden()
assert second.room_widget.tags_list.isHidden()
assert second.size() == first.size()
assert second.sync_client.url == "wss://video-sync.peterstockings.com/ws"
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)
settings.setValue("media_folder", str(tmp_path / "missing"))
settings.setValue("volume", 200)
window = VlcSyncApp(settings)
qtbot.addWidget(window)
assert window.lobby_widget.media_folder == ""
assert window.room_widget.volume_slider.value() == 100
+17 -5
View File
@@ -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<WSData>({
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<WSData>({
}
const code = generateRoomCode();
const room: RoomState = {
code,
const room: RoomState = {
code,
listed: msg.listed !== false,
fileInfo: {
name: msg.fileInfo.name,
size: msg.fileInfo.size,