Compare commits
3
Commits
75362bfcba
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c5d4ee546 | ||
|
|
21a35d81f0 | ||
|
|
836034533e |
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user