Compare commits

..
3 Commits
7 changed files with 558 additions and 87 deletions
+24
View File
@@ -1,5 +1,29 @@
# 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 desktop workspace has a sidebar for your saved display name, **Create room**,
and **Join by code**. Creation and code entry open in separate dialogs. Drag a
video onto the lobby to prepare a new room. Enter your name to enable room actions.
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
+292 -79
View File
@@ -2,9 +2,38 @@ import os
import re
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QFileDialog, QFrame, QApplication
)
from PyQt6.QtCore import Qt, pyqtSignal
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."""
@@ -24,6 +53,8 @@ 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__()
@@ -32,100 +63,238 @@ class LobbyWidget(QWidget):
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.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Container for centering
container = QFrame()
container.setObjectName("lobbyCard")
container.setFixedWidth(500)
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(30, 30, 30, 30)
container_layout.setSpacing(15)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Brand
title = QLabel("VideoSync")
title.setObjectName("brandTitle")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
tagline = QLabel("Watch together, anywhere")
tagline.setObjectName("brandTagline")
tagline.setAlignment(Qt.AlignmentFlag.AlignCenter)
container_layout.addWidget(title)
container_layout.addWidget(tagline)
container_layout.addSpacing(20)
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)
# Username
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 a display name")
container_layout.addWidget(QLabel("YOUR NAME"))
container_layout.addWidget(self.username_input)
container_layout.addSpacing(20)
# Actions Layout (Create vs Join)
actions_layout = QHBoxLayout()
actions_layout.setSpacing(20)
# Create Room Panel
create_panel = QVBoxLayout()
create_panel.addWidget(QLabel("Create a Room"))
self.create_file_btn = QPushButton("Choose Video File")
self.create_file_btn.setObjectName("secondaryBtn")
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("fileInfo")
self.create_file_info.setObjectName("lobbyFileInfo")
self.create_file_info.setWordWrap(True)
self.create_file_info.hide()
self.create_room_btn = QPushButton("Create Room")
self.create_room_btn.setObjectName("primaryBtn")
self.create_room_btn.setEnabled(False)
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)
create_panel.addWidget(self.create_file_btn)
create_panel.addWidget(self.create_file_info)
create_panel.addWidget(self.create_room_btn)
# Join Room Panel
join_panel = QVBoxLayout()
join_panel.addWidget(QLabel("Join a 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.join_room_btn = QPushButton("Join Room")
self.join_room_btn.setObjectName("secondaryBtn")
self.join_room_btn.setEnabled(False)
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)
join_panel.addWidget(self.room_code_input)
join_panel.addWidget(self.join_room_btn)
actions_layout.addLayout(create_panel)
# Divider
divider = QLabel("OR")
divider.setAlignment(Qt.AlignmentFlag.AlignCenter)
divider.setObjectName("divider")
actions_layout.addWidget(divider)
actions_layout.addLayout(join_panel)
container_layout.addLayout(actions_layout)
layout.addWidget(container)
# Signals to enable/disable buttons
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")
@@ -134,7 +303,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)
@@ -152,6 +321,7 @@ class LobbyWidget(QWidget):
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):
@@ -159,10 +329,47 @@ class LobbyWidget(QWidget):
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)
self.join_room_btn.setEnabled(has_name and has_code)
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)
@@ -170,6 +377,11 @@ class LobbyWidget(QWidget):
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...")
@@ -178,8 +390,9 @@ class LobbyWidget(QWidget):
self.join_requested.emit(username, room_code)
def reset_ui(self):
self.create_room_btn.setText("Create Room")
self.join_room_btn.setText("Join Room")
self._connecting = False
self.create_room_btn.setText("Create room")
self.join_room_btn.setText("Join room")
self.check_inputs()
def clear_file(self):
+92 -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,88 @@ 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.sessions_list.hide()
self.lobby_widget.session_count.setText("Join by code is still available.")
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 +166,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 +201,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 +252,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() == []
+52
View File
@@ -0,0 +1,52 @@
from lobby_widget import LobbyWidget
from PyQt6.QtCore import Qt
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()
def test_workspace_dialogs_and_connection_lock(qtbot, tmp_path):
lobby = LobbyWidget()
qtbot.addWidget(lobby)
lobby.show()
assert not lobby.create_action_btn.isEnabled()
lobby.username_input.setText("Alex")
qtbot.mouseClick(lobby.create_action_btn, Qt.MouseButton.LeftButton)
assert lobby.create_dialog.isVisible()
assert not lobby.create_room_btn.isEnabled()
video = tmp_path / "movie.mkv"
video.write_bytes(b"local video")
lobby._set_local_file(str(video))
lobby.listed_checkbox.setChecked(False)
with qtbot.waitSignal(lobby.create_requested) as signal:
qtbot.mouseClick(lobby.create_room_btn, Qt.MouseButton.LeftButton)
assert signal.args == ["Alex", str(video), "movie.mkv", 11]
assert not lobby.create_dialog.isVisible()
assert not lobby.create_action_btn.isEnabled()
assert not lobby.join_action_btn.isEnabled()
lobby.reset_ui()
qtbot.mouseClick(lobby.join_action_btn, Qt.MouseButton.LeftButton)
lobby.room_code_input.setText("abc123")
with qtbot.waitSignal(lobby.join_requested) as signal:
qtbot.keyClick(lobby.room_code_input, Qt.Key.Key_Return)
assert signal.args == ["Alex", "ABC123"]
assert not lobby.join_dialog.isVisible()
+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,