Compare commits

..
8 Commits
21 changed files with 2166 additions and 121 deletions
+82
View File
@@ -0,0 +1,82 @@
# 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
messages without losing your place. Incoming messages appear beneath a **New
messages** divider, and **Jump to latest** shows how many have arrived. Clicking
it, or scrolling back to the bottom, resumes following messages.
## Ready checks
Send `/ready` in chat to start a check for everyone in the room. Playback pauses
and a ready panel appears above chat. Each person clicks **I'm ready**; they can
change their answer with **Not ready**. When everyone is connected and ready,
the person who started the check can click **Start together**. They can also
cancel the check. Repeating `/ready` leaves an existing check and its votes intact.
New participants must confirm readiness. Disconnecting clears that person's
vote; reconnecting restores the panel so they can confirm again. Ordinary play
controls dismiss an active check and continue playback.
Ready checks require the updated server and desktop clients. The web client
does not yet have ready-check controls.
## Fight-night commands
- `/ko` — a large arcade-style K.O. with gold lettering, red outline, and an impact animation.
- `/bell` — DING!: Back to your corners.
- `/tap` — TAP!: Someone's had enough.
- `/robbery` — a reaction plus a 30-second room vote on the decision.
The K.O. appears across the center of the video; other reactions slide into the
bottom. Effects clear after 2.2 seconds and keep playback
running. A shared 3-second cooldown limits room-wide effects. Reaction events
stay in chat history; joining or reconnecting does not replay animations.
Quiet sound effects play automatically with visible reactions. Sounds are
original synthesized effects cached locally, with no external downloads. Overlays and sound are
suppressed while the player is hidden or minimized. Visible players receive
reactions even without keyboard focus, without activating the player window.
Reactions also work while the detached chat window is focused.
Robbery votes allow one answer per person, which you can change before the vote
closes. The result remains visible after closing and can be dismissed. Joining
or reconnecting restores the current vote. Fight commands require the updated
server and desktop clients; web clients currently see the chat reaction only.
## Tests
With `pytest`, `pytest-qt`, and Bun installed, run from this directory:
```powershell
$env:QT_QPA_PLATFORM = 'offscreen'
.\.venv\Scripts\python.exe -m pytest test_chat.py test_sync_client.py test_ready_server.py test_fight_reactions.py test_fight_server.py -q
```
The ready-check integration test starts an isolated local server; it does not
contact the production service. VLC playback is mocked in the chat UI tests.
+2 -2
View File
@@ -1,2 +1,2 @@
uv add pyinstaller pillow
uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --add-data "icon.png;." --add-data "copy.svg;." --add-data "check.svg;." main.py
uv sync
uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --collect-data "certifi" --add-data "icon.png;." --add-data "copy.svg;." --add-data "check.svg;." main.py
+11
View File
@@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt, pyqtSignal
class ChatBubble(QWidget):
def __init__(self, author, text, time_str, is_self, on_link_clicked):
super().__init__()
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
layout = QHBoxLayout(self)
layout.setContentsMargins(5, 2, 5, 2)
@@ -39,6 +40,7 @@ class ChatBubble(QWidget):
# Author
author_lbl = QLabel("You" if is_self else author)
author_lbl.setTextFormat(Qt.TextFormat.PlainText)
author_lbl.setStyleSheet(f"color: {author_color}; font-weight: bold; font-size: 11px; background: transparent;")
bubble_layout.addWidget(author_lbl)
@@ -47,6 +49,7 @@ class ChatBubble(QWidget):
# width of 0 lets the label wrap within whatever space the bubble
# actually receives rather than expanding the bubble outward.
self.text_lbl = QLabel(text)
self.text_lbl.setTextFormat(Qt.TextFormat.RichText)
self.text_lbl.setWordWrap(True)
self.text_lbl.setMinimumWidth(0)
self.text_lbl.setSizePolicy(
@@ -71,6 +74,14 @@ class ChatBubble(QWidget):
layout.addWidget(self.bubble)
layout.addStretch()
def resizeEvent(self, event):
super().resizeEvent(event)
# Recalculate after the scrollbar or splitter changes the available width.
# A fixed row height prevents the scroll layout squeezing wrapped text.
height = self.layout().heightForWidth(event.size().width())
if height >= 0:
self.setFixedHeight(height)
class SystemMessageWidget(QWidget):
def __init__(self, text):
+11
View File
@@ -6,6 +6,9 @@ HELP_TEXT = """
• <b>/seek [time]</b> - Seek (e.g., 1:23, +30s, -1m)<br>
• <b>/tag [now|time] [name]</b> - Create a highlight<br>
• <b>/time</b> - Show current timestamp<br>
• <b>/ready</b> - Ask everyone if they are ready to watch<br>
• <b>/ko</b>, <b>/bell</b>, <b>/tap</b> - Fight-night reactions<br>
• <b>/robbery</b> - React and open a room vote on the decision<br>
• <b>/help</b> - Show this message<br><br>
<b>Keyboard Shortcuts:</b><br>
• <b>Space</b>: Play/Pause<br>
@@ -52,6 +55,14 @@ def parse_time_arg(arg: str, current_ms: int, length_ms: int):
def handle_chat_command(cmd: str, parts: list, widget):
"""Handle a slash command. Returns True if the command was handled."""
if cmd in ("/ko", "/bell", "/tap", "/robbery"):
widget.fight_action_requested.emit({"type": "reaction", "kind": cmd[1:]})
return True
if cmd == "/ready":
widget.ready_action_requested.emit({"type": "ready_check", "action": "begin"})
return True
if cmd == "/play":
if not widget.vlc_player.is_playing:
widget.toggle_playback()
+223
View File
@@ -0,0 +1,223 @@
"""Native ringside overlay and small, original synthesized reaction sounds."""
import math
from pathlib import Path
import struct
import wave
from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QVariantAnimation, QPoint, QEasingCurve, QEvent
from PyQt6.QtGui import QPainter, QPainterPath, QFont, QColor, QPen, QLinearGradient, QTransform
from PyQt6.QtWidgets import QWidget, QFrame, QLabel, QHBoxLayout, QVBoxLayout
REACTIONS = {
"ko": ("K.O.", "That's a wrap.", "#ff5676"),
"bell": ("DING!", "Back to your corners.", "#f5c86c"),
"tap": ("TAP!", "Someone's had enough.", "#e1e1e1"),
"robbery": ("ROBBERY?", "Judges, explain yourselves.", "#f5c86c"),
}
def write_reaction_sound(directory, kind):
"""Generate mono PCM once per cache directory, with no downloaded samples."""
if kind not in REACTIONS:
raise ValueError("Unknown reaction")
path = Path(directory) / f"{kind}.wav"
if path.exists():
return path
path.parent.mkdir(parents=True, exist_ok=True)
rate = 22050
duration = .85 if kind == "bell" else .4
samples = bytearray()
for i in range(int(rate * duration)):
t = i / rate
attack = min(1, t / .008)
if kind == "ko":
value = math.sin(2 * math.pi * (130 * t - 100 * t * t)) * math.exp(-13 * t)
elif kind == "bell":
value = sum(math.sin(2 * math.pi * f * t) * a for f, a in
((880, .55), (1765, .25), (2410, .12))) * math.exp(-5 * t)
elif kind == "tap":
pulse = t if t < .16 else t - .16
value = math.sin(2 * math.pi * 420 * pulse) * math.exp(-45 * pulse)
else:
value = math.sin(2 * math.pi * 145 * t) * math.sin(2 * math.pi * 22 * t) * math.exp(-8 * t)
release = min(1, (duration - t) / .02)
samples.extend(struct.pack("<h", int(16000 * value * attack * release)))
with wave.open(str(path), "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(rate)
output.writeframes(samples)
return path
class ArcadeKnockout(QWidget):
"""Vector-painted arcade lettering scales cleanly with the video surface."""
def __init__(self, parent):
super().__init__(parent)
self.progress = 0.0
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
def set_progress(self, value):
self.progress = value
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
t = self.progress
opacity = min(1.0, t / .035, max(0.0, (1.0 - t) / .18))
painter.setOpacity(opacity)
painter.translate(self.width() / 2, self.height() / 2)
entry = min(1.0, t / .18)
scale = 1.0 + .65 * (1 - entry) ** 3 - .06 * math.sin(entry * math.pi)
painter.scale(scale, scale)
font = QFont("Impact")
font.setPixelSize(180)
font.setBold(True)
letters = QPainterPath()
letters.addText(0, 0, font, "K.O.")
letters = QTransform().shear(-.12, 0).map(letters)
bounds = letters.boundingRect()
fit = min(self.width() * .63 / bounds.width(), self.height() * .40 / bounds.height())
painter.scale(fit, fit)
painter.translate(-bounds.center())
shadow = QTransform().translate(7, 10).map(letters)
painter.setPen(QPen(QColor("#131017"), 18, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
painter.setBrush(QColor("#131017"))
painter.drawPath(shadow)
painter.setPen(QPen(QColor("#8b1028"), 14, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
painter.setBrush(QColor("#f33c35"))
painter.drawPath(letters)
gradient = QLinearGradient(0, bounds.top(), 0, bounds.bottom())
gradient.setColorAt(0, QColor("#fff7b0"))
gradient.setColorAt(.40, QColor("#ffe252"))
gradient.setColorAt(1, QColor("#ff9c22"))
painter.setPen(QPen(QColor("#ff583e"), 5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
painter.setBrush(gradient)
painter.drawPath(letters)
if t < .32:
painter.setOpacity(opacity * (1 - t / .32) * .55)
painter.setClipPath(letters)
sweep = bounds.left() + bounds.width() * min(1.0, t / .32)
painter.fillRect(int(sweep), int(bounds.top() - 10), 16, int(bounds.height() + 20), QColor("#fffef0"))
class RingsideStrip(QWidget):
"""A separate native window stays above VLC's native video surface."""
def __init__(self, video_frame):
super().__init__(video_frame, Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowDoesNotAcceptFocus | Qt.WindowType.WindowTransparentForInput)
self.video_frame = video_frame
self.owner_window = None
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
self.panel = QFrame()
outer.addWidget(self.panel)
self.ko_canvas = ArcadeKnockout(self)
outer.addWidget(self.ko_canvas)
self.ko_canvas.hide()
self.kind = None
self.ko_animation = QVariantAnimation(self)
self.ko_animation.setDuration(2200)
self.ko_animation.setStartValue(0.0)
self.ko_animation.setEndValue(1.0)
self.ko_animation.valueChanged.connect(self.ko_canvas.set_progress)
row = QHBoxLayout(self.panel)
row.setContentsMargins(14, 10, 14, 10)
row.setSpacing(14)
self.headline = QLabel()
self.headline.setTextFormat(Qt.TextFormat.PlainText)
self.caption = QLabel()
self.caption.setTextFormat(Qt.TextFormat.PlainText)
self.caption.setWordWrap(True)
self.caption.setMinimumWidth(0)
self.caption.setStyleSheet("color: #f2f2f5; background: transparent; font-size: 13px;")
self.metadata = QLabel()
self.metadata.setStyleSheet("color: #a6a9b5; background: transparent; font-size: 11px;")
details = QVBoxLayout()
details.setSpacing(3)
details.addWidget(self.caption)
details.addWidget(self.metadata)
row.addWidget(self.headline)
row.addLayout(details, 1)
self.dismiss_timer = QTimer(self)
self.dismiss_timer.setSingleShot(True)
self.dismiss_timer.timeout.connect(self.dismiss)
self.slide = QPropertyAnimation(self, b"pos", self)
self.slide.setDuration(180)
self.slide.setEasingCurve(QEasingCurve.Type.OutCubic)
video_frame.installEventFilter(self)
def show_reaction(self, kind, author, position):
if kind not in REACTIONS or not self.video_frame.isVisible():
return False
owner = self.video_frame.window()
if owner.isMinimized():
return False
if self.owner_window is not owner:
if self.owner_window is not None:
self.owner_window.removeEventFilter(self)
self.owner_window = owner
owner.installEventFilter(self)
title, caption, color = REACTIONS[kind]
self.headline.setText(title)
self.headline.setStyleSheet(f"color: {color}; background: transparent; font-size: 24px; font-weight: bold;")
self.caption.setText(f"{author}: {caption}")
self.metadata.setText(f"Room reaction · {position}")
self.panel.setStyleSheet(f"QFrame {{ background: #1a1b20; border-left: 3px solid {color}; border-radius: 6px; }} QLabel {{ border: none; }}")
self.kind = kind
self.panel.setVisible(kind != "ko")
self.ko_canvas.setVisible(kind == "ko")
self.ko_animation.stop()
self.slide.stop()
self._place()
if kind == "ko":
self.ko_canvas.set_progress(0.0)
self.show()
self.ko_animation.start()
self.dismiss_timer.start(2200)
return True
end = self.pos()
self.move(end + QPoint(0, 12))
self.show()
self.slide.setStartValue(self.pos())
self.slide.setEndValue(end)
self.slide.start()
self.dismiss_timer.start(2200)
return True
def _place(self):
if self.kind == "ko":
self.setFixedSize(self.video_frame.size())
self.move(self.video_frame.mapToGlobal(QPoint(0, 0)))
return
self.setMinimumSize(0, 0)
self.setMaximumSize(16777215, 16777215)
width = max(1, min(540, self.video_frame.width() - 32))
self.setFixedWidth(width)
height = self.layout().heightForWidth(width)
self.setFixedHeight(max(64, height))
origin = self.video_frame.mapToGlobal(QPoint(16, self.video_frame.height() - self.height() - 16))
self.move(origin)
def eventFilter(self, watched, event):
if self.isVisible():
if event.type() in (QEvent.Type.Hide, QEvent.Type.Close):
self.dismiss()
elif event.type() == QEvent.Type.WindowStateChange and self.owner_window.isMinimized():
self.dismiss()
elif event.type() in (QEvent.Type.Move, QEvent.Type.Resize):
self.slide.stop()
self._place()
return super().eventFilter(watched, event)
def dismiss(self):
self.ko_animation.stop()
self.slide.stop()
self.dismiss_timer.stop()
self.hide()
+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):
+111 -4
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")
@@ -40,6 +43,8 @@ class VlcSyncApp(QMainWindow):
self.room_widget.leave_requested.connect(self._on_room_leave)
self.room_widget.sync_action_requested.connect(self._on_room_sync_action)
self.room_widget.chat_message_ready.connect(self._on_room_chat)
self.room_widget.ready_action_requested.connect(lambda message: self.sync_client.send_message(message))
self.room_widget.fight_action_requested.connect(lambda message: self.sync_client.send_message(message))
# App State
self.username = ""
@@ -55,6 +60,7 @@ class VlcSyncApp(QMainWindow):
self.sync_client.connected.connect(lambda: self.room_widget.update_connection_status(True))
self.sync_client.disconnected.connect(self.on_ws_disconnected)
self.sync_client.disconnected.connect(lambda: self.room_widget.update_connection_status(False))
self.sync_client.connection_error.connect(self.on_connection_error)
self.sync_client.room_joined.connect(self.on_room_joined)
self.sync_client.room_rejoined.connect(self.on_room_rejoined)
self.sync_client.room_error.connect(self.on_room_error)
@@ -63,9 +69,94 @@ class VlcSyncApp(QMainWindow):
self.sync_client.chat_message.connect(self.room_widget.add_chat_message)
self.sync_client.system_message.connect(self.room_widget.add_system_message)
self.sync_client.sync_event.connect(self.room_widget.handle_sync_event)
self.sync_client.ready_check_updated.connect(self.room_widget.update_ready_check)
self.sync_client.fight_reaction.connect(self.room_widget.handle_fight_reaction)
self.sync_client.fight_poll_updated.connect(self.room_widget.update_fight_poll)
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
@@ -75,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,
@@ -109,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})
@@ -135,6 +229,12 @@ class VlcSyncApp(QMainWindow):
self.lobby_widget.reset_ui()
self.pending_connect_action = None
def on_connection_error(self, msg: str):
QMessageBox.critical(self, "Secure Connection Error", msg)
self.pending_connect_action = None
self.lobby_widget.reset_ui()
self.sync_client.stop()
def on_room_error(self, msg: str):
QMessageBox.critical(self, "Room Error", msg)
self.lobby_widget.reset_ui()
@@ -152,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)
@@ -205,6 +306,9 @@ class VlcSyncApp(QMainWindow):
self.room_widget.handle_sync_event(state)
self.room_widget.add_system_message("Welcome to the room! 👋")
self.room_widget.finish_chat_history()
self.room_widget.update_ready_check(msg.get("readyCheck") or {})
self.room_widget.update_fight_poll(msg.get("fightPoll") or {})
def on_room_rejoined(self, msg: dict):
self.room_widget.set_room_code_display(self.room_code)
@@ -225,6 +329,10 @@ class VlcSyncApp(QMainWindow):
if state:
self.room_widget.handle_sync_event(state)
self.room_widget.finish_chat_history()
self.room_widget.update_ready_check(msg.get("readyCheck") or {})
self.room_widget.update_fight_poll(msg.get("fightPoll") or {})
def apply_stylesheet(self):
self.setStyleSheet("""
QWidget {
@@ -493,4 +601,3 @@ if __name__ == "__main__":
window = VlcSyncApp()
window.show()
sys.exit(app.exec())
+1
View File
@@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"certifi>=2026.6.17",
"pillow>=12.1.1",
"pyinstaller>=6.19.0",
"pyqt6>=6.10.2",
+296 -11
View File
@@ -2,13 +2,15 @@ import os
import datetime
import html
import re
import time
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QFrame, QTextEdit, QTextBrowser, QApplication, QSplitter,
QScrollArea
)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QEvent
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QEvent, QStandardPaths, QUrl
from PyQt6.QtGui import QIcon, QCursor
from PyQt6.QtMultimedia import QSoundEffect
import uuid
import urllib.parse
@@ -18,6 +20,7 @@ from utils import format_time_hms, format_time_short
from chat_widgets import ChatBubble, SystemMessageWidget, ChatPopoutWindow
from custom_widgets import ClickableSlider
from commands import parse_time_arg, handle_chat_command
from fight_reactions import REACTIONS, RingsideStrip, write_reaction_sound
class ExpectedVlcEvent:
@@ -31,6 +34,8 @@ class RoomWidget(QWidget):
leave_requested = pyqtSignal()
sync_action_requested = pyqtSignal(str, float, str) # action, position_s, req_id
chat_message_ready = pyqtSignal(str) # raw message text
ready_action_requested = pyqtSignal(dict)
fight_action_requested = pyqtSignal(dict)
def __init__(self):
super().__init__()
@@ -43,8 +48,23 @@ class RoomWidget(QWidget):
self._is_first_user_update = True
self.popout_window = None
self._follow_chat = True
self._pending_chat_messages = 0
self._unread_divider = None
self._ready_check = {}
self._connected = True
self._fight_poll = {}
self._reaction_sounds = {}
self._fight_poll_timer = QTimer(self)
self._fight_poll_timer.setInterval(1000)
self._fight_poll_timer.timeout.connect(self._refresh_fight_poll)
self._chat_scroll_timer = QTimer(self)
self._chat_scroll_timer.setSingleShot(True)
self._chat_scroll_timer.timeout.connect(self._apply_chat_scroll)
self._setup_ui()
self._unread_messages = 0
QApplication.instance().focusChanged.connect(self._on_chat_focus_changed)
def _setup_ui(self):
layout = QVBoxLayout(self)
@@ -108,6 +128,7 @@ class RoomWidget(QWidget):
self.video_frame = QFrame()
self.video_frame.setStyleSheet("background-color: black;")
self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
self.ringside_strip = RingsideStrip(self.video_frame)
# Controls Bar
self.controls_bar = QFrame()
@@ -174,7 +195,7 @@ class RoomWidget(QWidget):
self.chat_container.setMaximumWidth(500)
chat_layout = QVBoxLayout(self.chat_container)
chat_header = QLabel("Live Chat")
chat_header = self.chat_header = QLabel("Live Chat")
chat_header.setObjectName("chatHeader")
self.popout_btn = QPushButton("↗")
@@ -190,6 +211,30 @@ class RoomWidget(QWidget):
self.users_lbl = QLabel("0 watching")
self.users_lbl.setObjectName("usersLbl")
self.ready_panel = QFrame()
ready_layout = QVBoxLayout(self.ready_panel)
ready_layout.setContentsMargins(0, 6, 0, 6)
self.ready_title = QLabel()
self.ready_title.setTextFormat(Qt.TextFormat.PlainText)
self.ready_title.setWordWrap(True)
self.ready_users = QLabel()
self.ready_users.setTextFormat(Qt.TextFormat.PlainText)
self.ready_users.setWordWrap(True)
ready_layout.addWidget(self.ready_title)
ready_layout.addWidget(self.ready_users)
ready_buttons = QHBoxLayout()
self.ready_btn = QPushButton("I'm ready")
self.ready_start_btn = QPushButton("Start together")
self.ready_cancel_btn = QPushButton("Cancel")
for button in (self.ready_btn, self.ready_cancel_btn):
ready_buttons.addWidget(button)
ready_layout.addLayout(ready_buttons)
ready_layout.addWidget(self.ready_start_btn)
self.ready_btn.clicked.connect(self._vote_ready)
self.ready_start_btn.clicked.connect(lambda: self._send_ready_action("start"))
self.ready_cancel_btn.clicked.connect(lambda: self._send_ready_action("cancel"))
self.ready_panel.hide()
# Tags Container (Hidden by default)
self.tags_container = QFrame()
@@ -250,7 +295,44 @@ class RoomWidget(QWidget):
self.chat_content_layout.setSpacing(0)
self.chat_messages.setWidget(self.chat_content)
self.chat_messages.verticalScrollBar().valueChanged.connect(self._on_chat_scroll)
self.chat_messages.verticalScrollBar().rangeChanged.connect(self._on_chat_range_changed)
self.chat_messages.viewport().installEventFilter(self)
self.chat_messages.verticalScrollBar().installEventFilter(self)
self.chat_messages.toPlainText = self.toPlainText # For test compatibility
self.chat_jump_btn = QPushButton()
self.chat_jump_btn.hide()
self.chat_jump_btn.clicked.connect(self.jump_to_latest)
self.fight_poll_panel = QFrame()
poll_layout = QVBoxLayout(self.fight_poll_panel)
poll_layout.setContentsMargins(0, 6, 0, 6)
poll_header = QHBoxLayout()
self.fight_poll_title = QLabel("Was that a robbery?")
self.fight_poll_title.setWordWrap(True)
poll_header.addWidget(self.fight_poll_title, 1)
poll_dismiss = self.fight_poll_dismiss_btn = QPushButton("×")
poll_dismiss.setObjectName("iconBtn")
poll_dismiss.setToolTip("Dismiss this vote")
poll_dismiss.setFixedSize(28, 28)
poll_dismiss.setFocusPolicy(Qt.FocusPolicy.NoFocus)
poll_dismiss.clicked.connect(self.fight_poll_panel.hide)
poll_header.addWidget(poll_dismiss)
poll_layout.addLayout(poll_header)
self.fight_poll_result = QLabel()
self.fight_poll_result.setWordWrap(True)
poll_layout.addWidget(self.fight_poll_result)
poll_buttons = QVBoxLayout()
self.fight_yes_btn = QPushButton("Absolutely")
self.fight_no_btn = QPushButton("Fair decision")
for button in (self.fight_yes_btn, self.fight_no_btn):
button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
poll_buttons.addWidget(button)
poll_layout.addLayout(poll_buttons)
self.fight_yes_btn.clicked.connect(lambda: self._vote_fight_poll("yes"))
self.fight_no_btn.clicked.connect(lambda: self._vote_fight_poll("no"))
self.fight_poll_panel.hide()
chat_input_layout = QHBoxLayout()
@@ -267,8 +349,11 @@ class RoomWidget(QWidget):
chat_layout.addLayout(header_layout)
chat_layout.addWidget(self.users_lbl)
chat_layout.addWidget(self.ready_panel)
chat_layout.addWidget(self.tags_container)
chat_layout.addWidget(self.chat_messages, 1)
chat_layout.addWidget(self.chat_jump_btn)
chat_layout.addWidget(self.fight_poll_panel)
chat_layout.addLayout(chat_input_layout)
self.splitter.addWidget(video_container)
@@ -284,6 +369,8 @@ class RoomWidget(QWidget):
self.chat_send_btn, self.seekbar, self.volume_slider, self.tags_header_btn,
self.chat_toggle_btn, self.popout_btn]:
w.setFocusPolicy(Qt.FocusPolicy.NoFocus)
for button in (self.ready_btn, self.ready_start_btn, self.ready_cancel_btn, self.chat_jump_btn):
button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
# scroll area handles focus properly
self.chat_messages.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
@@ -323,8 +410,10 @@ class RoomWidget(QWidget):
self.room_file_badge.setText(f"📄 {file_name}")
# Clear chat
for i in reversed(range(self.chat_content_layout.count())):
self.chat_content_layout.itemAt(i).widget().setParent(None)
self._clear_chat_alert()
self.clear_chat()
self.update_ready_check({})
self.reset_fight_reactions()
self.current_users = []
@@ -335,6 +424,10 @@ class RoomWidget(QWidget):
self.vlc_player.set_volume(self.volume_slider.value())
def cleanup(self):
self._clear_chat_alert()
self.clear_chat()
self.update_ready_check({})
self.reset_fight_reactions()
self.vlc_player.stop()
self.current_users = []
self._is_first_user_update = True
@@ -343,6 +436,13 @@ class RoomWidget(QWidget):
self.room_code_display.setText(f"Room: {text}")
def update_connection_status(self, connected: bool):
self._connected = connected
self.ready_btn.setEnabled(connected)
self.ready_start_btn.setEnabled(connected and self._can_start_ready())
self.ready_cancel_btn.setEnabled(connected)
self._refresh_fight_poll()
if not connected:
self.ringside_strip.dismiss()
if connected:
self.status_dot.setStyleSheet("color: #4BB543; font-size: 14px; background: transparent;")
self.status_dot.setToolTip("Connected")
@@ -723,13 +823,20 @@ class RoomWidget(QWidget):
time_str = dt.strftime("%I:%M %p")
safe_text = html.escape(text)
safe_author = html.escape(author)
# Regex to find timestamps like 1:23 or 01:23:45
pattern = r'\b(\d{1,2}:\d{2}(?::\d{2})?)\b'
linked_text = re.sub(pattern, r'<a href="seek:\1" style="color: #66b3ff; text-decoration: none;">\1</a>', safe_text)
linked_text = linked_text.replace('\n', '<br>')
is_self = author == self.username
if not is_self and not self._chat_has_focus():
self._unread_messages += 1
self.chat_header.setText(f"Live Chat • {self._unread_messages} new")
self.chat_toggle_btn.setText("💬•")
self.chat_toggle_btn.setToolTip(f"{self._unread_messages} new chat messages")
self.chat_toggle_btn.setStyleSheet("color: #66b3ff; border: 1px solid #66b3ff; border-radius: 6px;")
QApplication.alert(self.chat_container.window(), 3000)
# Auto-extract tags added by users directly via /tag command
# We look for the pattern generated by /tag: "MM:SS tag text"
@@ -740,8 +847,39 @@ class RoomWidget(QWidget):
return # Don't show this as a chat message bubble
bubble = ChatBubble(author, linked_text, time_str, is_self, self._handle_link_from_bubble)
if not self._follow_chat and not is_self:
if self._pending_chat_messages == 0:
if self._unread_divider is not None:
self.chat_content_layout.removeWidget(self._unread_divider)
self._unread_divider.deleteLater()
self._unread_divider = QLabel("──── New messages ────")
self._unread_divider.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._unread_divider.setFixedHeight(28)
self._unread_divider.setStyleSheet("color: #66b3ff; background: transparent;")
self.chat_content_layout.addWidget(self._unread_divider)
self._pending_chat_messages += 1
self.chat_jump_btn.setText(f"↓ {self._pending_chat_messages} new messages · Jump to latest")
self.chat_jump_btn.show()
self.chat_content_layout.addWidget(bubble)
self._scroll_to_bottom()
def _chat_has_focus(self):
focus = QApplication.focusWidget()
return (self.chat_container.isVisible()
and self.chat_container.window().isActiveWindow()
and focus is not None
and (focus is self.chat_container or self.chat_container.isAncestorOf(focus)))
def _on_chat_focus_changed(self, old, new):
if self._chat_has_focus() and self._follow_chat:
self._clear_chat_alert()
def _clear_chat_alert(self):
self._unread_messages = 0
self.chat_header.setText("Live Chat")
self.chat_toggle_btn.setText("💬")
self.chat_toggle_btn.setToolTip("Toggle Chat")
self.chat_toggle_btn.setStyleSheet("")
def _handle_link_from_bubble(self, url_str):
if url_str.startswith("seek:"):
@@ -749,10 +887,150 @@ class RoomWidget(QWidget):
self._handle_seek_command(time_str)
def _scroll_to_bottom(self):
# Allow layout to finish before scrolling
QTimer.singleShot(10, lambda: self.chat_messages.verticalScrollBar().setValue(
self.chat_messages.verticalScrollBar().maximum()
))
if self._follow_chat:
self._chat_scroll_timer.start(10)
def eventFilter(self, watched, event):
if event.type() in (QEvent.Type.Wheel, QEvent.Type.MouseButtonPress, QEvent.Type.KeyPress):
# User scrolling takes precedence over an already queued tail update.
self._chat_scroll_timer.stop()
return super().eventFilter(watched, event)
def _apply_chat_scroll(self):
if self._follow_chat:
bar = self.chat_messages.verticalScrollBar()
bar.setValue(bar.maximum())
def _on_chat_range_changed(self, minimum, maximum):
self._scroll_to_bottom()
def _on_chat_scroll(self, value):
if self._chat_scroll_timer.isActive():
return
self._follow_chat = self.chat_messages.verticalScrollBar().maximum() - value <= 24
if self._follow_chat:
self._pending_chat_messages = 0
self.chat_jump_btn.hide()
if self._chat_has_focus():
self._clear_chat_alert()
def jump_to_latest(self):
self._follow_chat = True
self._pending_chat_messages = 0
self.chat_jump_btn.hide()
self._clear_chat_alert()
self._scroll_to_bottom()
def finish_chat_history(self):
self.jump_to_latest()
def _can_start_ready(self):
participants = self._ready_check.get("participants", [])
return bool(participants) and all(p.get("ready") and p.get("connected") for p in participants)
def update_ready_check(self, state):
new_check = state.get("active") and state.get("id") != self._ready_check.get("id")
self._ready_check = state if state.get("active") else {}
if not self._ready_check:
self.ready_panel.hide()
return
participants = state.get("participants", [])
ready_count = sum(bool(p.get("ready") and p.get("connected")) for p in participants)
self.ready_title.setText(f"Ready check · {ready_count} of {len(participants)} ready\nStarted by {state.get('requestedBy', '')}")
self.ready_users.setText("\n".join(
f"{p['username']}: " + ("Reconnecting…" if not p.get('connected') else "✓ Ready" if p.get('ready') else "Not ready")
for p in participants))
mine = next((p for p in participants if p['username'] == self.username), {})
self.ready_btn.setText("Not ready" if mine.get("ready") else "I'm ready")
initiator = state.get("requestedBy") == self.username
self.ready_start_btn.setVisible(initiator)
self.ready_cancel_btn.setVisible(initiator)
self.ready_btn.setEnabled(self._connected)
self.ready_cancel_btn.setEnabled(self._connected)
self.ready_start_btn.setEnabled(self._connected and self._can_start_ready())
self.ready_panel.show()
if new_check:
self.add_system_message(f"<span>{html.escape(state.get('requestedBy', 'Someone'))} started a ready check.</span>")
if not self._chat_has_focus():
self.chat_toggle_btn.setText("💬•")
self.chat_toggle_btn.setToolTip("A ready check is waiting")
self.chat_toggle_btn.setStyleSheet("color: #66b3ff; border: 1px solid #66b3ff; border-radius: 6px;")
QApplication.alert(self.chat_container.window(), 3000)
def _send_ready_action(self, action, **extra):
if self._ready_check:
self.ready_action_requested.emit({"type": "ready_check", "action": action,
"id": self._ready_check['id'], **extra})
def _vote_ready(self):
mine = next((p for p in self._ready_check.get("participants", []) if p['username'] == self.username), {})
self._send_ready_action("vote", ready=not mine.get("ready", False))
def handle_fight_reaction(self, message):
kind = message.get("kind")
if kind not in REACTIONS:
return
position = format_time_short(int(message.get("position", 0) * 1000))
shown = self.ringside_strip.show_reaction(kind, message.get("username", "Someone"), position)
if shown:
self._play_reaction_sound(kind)
def _play_reaction_sound(self, kind):
try:
if kind not in self._reaction_sounds:
cache = os.path.join(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.GenericCacheLocation),
"VideoSync", "reaction-sounds-v1")
path = write_reaction_sound(cache, kind)
sound = QSoundEffect(self)
sound.setVolume(.18)
sound.setSource(QUrl.fromLocalFile(str(path)))
self._reaction_sounds[kind] = sound
self._reaction_sounds[kind].play()
except OSError:
# A read-only cache must not prevent the visual reaction.
pass
def update_fight_poll(self, state):
previous_id = self._fight_poll.get("id")
self._fight_poll = state if state.get("id") else {}
if not self._fight_poll:
self._fight_poll_timer.stop()
self.fight_poll_panel.hide()
return
if previous_id != state.get("id"):
self.fight_poll_panel.show()
self._refresh_fight_poll()
if state.get("active") and state.get("expiresAt", 0) > time.time() * 1000:
self._fight_poll_timer.start()
def _refresh_fight_poll(self):
if not self._fight_poll:
return
remaining = max(0, int((self._fight_poll.get("expiresAt", 0) - time.time() * 1000 + 999) / 1000))
active = self._fight_poll.get("active", False) and remaining > 0
if not active:
self._fight_poll_timer.stop()
votes = self._fight_poll.get("votes", {})
yes = sum(choice == "yes" for choice in votes.values())
no = sum(choice == "no" for choice in votes.values())
status = f"{remaining}s left" if active else "Vote closed"
mine = votes.get(self.username)
self.fight_poll_title.setText(f"Was that a robbery? · {status}")
self.fight_poll_result.setText(f"Absolutely {yes} · Fair decision {no}" +
(" · Your vote is in" if mine else ""))
for button, choice in ((self.fight_yes_btn, "yes"), (self.fight_no_btn, "no")):
button.setEnabled(bool(active and self._connected))
button.setStyleSheet("border: 1px solid #66b3ff;" if mine == choice else "")
def _vote_fight_poll(self, choice):
if self._fight_poll:
self.fight_action_requested.emit({"type": "fight_poll_vote", "id": self._fight_poll['id'], "choice": choice})
def reset_fight_reactions(self):
self.ringside_strip.dismiss()
self.update_fight_poll({})
for sound in self._reaction_sounds.values():
sound.stop()
def toPlainText(self):
# For compatibility with existing tests
@@ -782,10 +1060,17 @@ class RoomWidget(QWidget):
self._scroll_to_bottom()
def clear_chat(self):
self._clear_chat_alert()
self._chat_scroll_timer.stop()
self._follow_chat = True
self._pending_chat_messages = 0
self._unread_divider = None
self.chat_jump_btn.hide()
for i in reversed(range(self.chat_content_layout.count())):
item = self.chat_content_layout.itemAt(i)
if item.widget():
item.widget().setParent(None)
item.widget().deleteLater()
self.chat_content_layout.removeWidget(item.widget())
def send_chat(self):
text = self.chat_input.text().strip()
+68
View File
@@ -0,0 +1,68 @@
"""Verify a deployed server with two isolated clients: python smoke_commands.py --url wss://host/ws."""
import argparse
import json
import ssl
import time
import certifi
from websockets.sync.client import connect
def verify_commands(url):
options = {'ssl': ssl.create_default_context(cafile=certifi.where())} if url.startswith('wss:') else {}
def send(ws, **message):
ws.send(json.dumps(message))
def receive(ws, kind):
deadline = time.monotonic() + 8
while True:
message = json.loads(ws.recv(timeout=max(.01, deadline - time.monotonic())))
if message['type'] in ('error', 'reaction_error'):
raise RuntimeError(message.get('message'))
if message['type'] == kind:
return message
with connect(url, **options) as host, connect(url, **options) as guest:
file_info = {'name': 'command-smoke-test.mkv', 'size': 1234, 'duration': 60}
send(host, type='create_room', username='CommandSmokeHost', fileInfo=file_info)
code = receive(host, 'room_created')['code']
send(guest, type='join_room', username='CommandSmokeGuest', code=code)
receive(guest, 'room_file_check')
send(guest, type='confirm_join', fileInfo=file_info)
receive(guest, 'room_joined')
receive(host, 'user_joined')
send(host, type='ready_check', action='begin')
check = receive(host, 'ready_check')
assert check['active'] and receive(guest, 'ready_check')['id'] == check['id']
for ws in (host, guest):
send(ws, type='ready_check', action='vote', id=check['id'], ready=True)
for peer in (host, guest):
receive(peer, 'ready_check')
send(host, type='ready_check', action='start', id=check['id'])
for peer in (host, guest):
assert receive(peer, 'sync')['action'] == 'play'
assert not receive(peer, 'ready_check')['active']
print('PASS /ready: shared panel state, votes, synchronized start', flush=True)
for i, kind in enumerate(('ko', 'bell', 'tap', 'robbery')):
if i:
time.sleep(3.2)
send(host, type='reaction', kind=kind)
for peer in (host, guest):
assert receive(peer, 'reaction')['kind'] == kind
assert receive(peer, 'chat')['message'] == '🥊 /' + kind
print(f'PASS /{kind}: delivered to both clients', flush=True)
poll = receive(host, 'fight_poll')
assert receive(guest, 'fight_poll')['id'] == poll['id']
send(guest, type='fight_poll_vote', id=poll['id'], choice='yes')
for peer in (host, guest):
assert receive(peer, 'fight_poll')['votes']['CommandSmokeGuest'] == 'yes'
send(host, type='request_state')
assert receive(host, 'state')['state']['playing']
print('PASS /robbery vote and uninterrupted playback', flush=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--url', required=True)
verify_commands(parser.parse_args().url)
+85 -20
View File
@@ -1,22 +1,30 @@
import asyncio
import json
import ssl
import time
import certifi
import websockets
from PyQt6.QtCore import QThread, pyqtSignal
class SyncClientThread(QThread):
# Signals from WebSocket -> PyQt UI
connected = pyqtSignal()
disconnected = pyqtSignal()
connection_error = pyqtSignal(str)
room_joined = pyqtSignal(dict)
room_rejoined = pyqtSignal(dict)
room_error = pyqtSignal(str)
file_check_needed = pyqtSignal(dict) # msg
file_check_needed = pyqtSignal(dict) # msg
users_updated = pyqtSignal(list)
chat_message = pyqtSignal(str, str, int) # author, text, timestamp
chat_message = pyqtSignal(str, str, int) # author, text, timestamp
system_message = pyqtSignal(str)
sync_event = pyqtSignal(dict)
latency_updated = pyqtSignal(int) # latency in ms
ready_check_updated = pyqtSignal(dict)
fight_reaction = pyqtSignal(dict)
fight_poll_updated = pyqtSignal(dict)
latency_updated = pyqtSignal(int) # latency in ms
def __init__(self, url="ws://localhost:3000/ws"):
super().__init__()
@@ -26,13 +34,31 @@ class SyncClientThread(QThread):
self.running = False
self._ping_sent_at = 0
@staticmethod
def _create_ssl_context():
"""Build a verified TLS context from the CA bundle shipped with the app."""
return ssl.create_default_context(cafile=certifi.where())
@staticmethod
def _certificate_error(exc):
"""Return a nested certificate verification error, if present."""
while exc is not None:
if isinstance(exc, ssl.SSLCertVerificationError):
return exc
exc = exc.__cause__ or exc.__context__
return None
def run(self):
"""Runs strictly within the newly created QThread"""
self.running = True
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.loop.run_until_complete(self._connect_and_listen())
self.loop.close()
try:
self.loop.run_until_complete(self._connect_and_listen())
finally:
self.ws = None
self.running = False
self.loop.close()
def stop(self):
self.running = False
@@ -61,12 +87,17 @@ class SyncClientThread(QThread):
await asyncio.sleep(5)
async def _connect_and_listen(self):
ssl_context = (
self._create_ssl_context() if self.url.startswith("wss://") else None
)
while self.running:
try:
async with websockets.connect(self.url) as ws:
connect_options = {"ssl": ssl_context} if ssl_context else {}
async with websockets.connect(self.url, **connect_options) as ws:
self.ws = ws
self.connected.emit()
ping_task = asyncio.create_task(self._ping_loop(ws))
try:
async for message in ws:
@@ -77,38 +108,72 @@ class SyncClientThread(QThread):
pass
finally:
ping_task.cancel()
except Exception as e:
certificate_error = self._certificate_error(e)
if certificate_error:
verification_reason = getattr(
certificate_error, "verify_message", str(certificate_error)
)
message = (
"Secure connection failed because the server certificate could not be "
f"verified ({verification_reason}). "
"Check that your computer's date and time are correct, then update or "
"restart VideoSync."
)
print(f"WebSocket TLS Error: {certificate_error}")
self.connection_error.emit(message)
self.running = False
break
print(f"WebSocket Error: {e}")
self.disconnected.emit()
finally:
self.ws = None
self.disconnected.emit()
if self.running:
# Reconnect backoff
await asyncio.sleep(2)
def _handle_message(self, msg: dict):
t = msg.get("type")
if t == "room_created":
self.room_joined.emit(msg)
elif t == "room_joined":
self.room_joined.emit(msg)
elif t == "room_rejoined":
self.room_rejoined.emit(msg)
elif t == "error":
self.room_error.emit(msg.get("message", "Unknown error"))
elif t == "room_file_check":
self.file_check_needed.emit(msg)
elif t in ["user_joined", "user_left"]:
elif t in ["user_joined", "user_left", "user_rejoined"]:
self.users_updated.emit(msg.get("users", []))
elif t == "chat":
self.chat_message.emit(msg.get("username", "Unknown"), msg.get("message", ""), msg.get("timestamp", 0))
self.chat_message.emit(
msg.get("username", "Unknown"),
msg.get("message", ""),
msg.get("timestamp", 0),
)
elif t == "sync":
self.sync_event.emit(msg)
elif t == "ready_check":
self.ready_check_updated.emit(msg)
elif t == "reaction":
self.fight_reaction.emit(msg)
elif t == "fight_poll":
self.fight_poll_updated.emit(msg)
elif t == "reaction_error":
self.system_message.emit(msg.get("message", "Reaction unavailable"))
+219
View File
@@ -0,0 +1,219 @@
from unittest.mock import Mock
import pytest
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtGui import QTextDocument
from PyQt6.QtWidgets import QApplication
import room_widget
class PlayerSignals(QObject):
time_changed = pyqtSignal(int)
state_changed = pyqtSignal(bool, int)
@pytest.fixture
def room(qtbot, monkeypatch):
player = Mock(signals=PlayerSignals())
monkeypatch.setattr(room_widget, 'VLCSyncPlayer', lambda frame: player)
monkeypatch.setattr(room_widget.RoomWidget, '_play_reaction_sound', Mock())
widget = room_widget.RoomWidget()
widget.username = 'Me'
qtbot.addWidget(widget)
widget.resize(1050, 500)
widget.show()
return widget
@pytest.mark.parametrize('text', ["It's someone's & my <message>",
"It's at 1:23 & <b>literal</b>",
'line one\nline two', 'literal &#39; entity'])
def test_message_rendering(room, text):
room.add_chat_message("<b>O'Brien</b>", text, 0)
bubble = room.chat_content_layout.itemAt(0).widget()
doc = QTextDocument()
doc.setHtml(bubble.text_lbl.text())
assert doc.toPlainText() == text
if '1:23' in text:
assert 'href="seek:1:23"' in bubble.text_lbl.text()
def test_bubbles_do_not_shrink_or_clip_when_overflowing(room, qtbot):
text = 'A wrapped message with several words. ' * 8
room.add_chat_message('Other', text, 0)
qtbot.wait(100)
first = room.chat_content_layout.itemAt(0).widget()
initial_height = first.height()
for i in range(35):
room.add_chat_message('Me' if i % 2 else 'Other', text, 0)
qtbot.wait(200)
assert room.chat_messages.verticalScrollBar().maximum() > 0
assert first.height() >= initial_height
for width in (220, 450, 280):
room.splitter.setSizes([room.width() - width, width])
qtbot.wait(100)
for i in range(room.chat_content_layout.count()):
bubble = room.chat_content_layout.itemAt(i).widget()
label = bubble.text_lbl
assert label.height() >= label.heightForWidth(label.width())
assert bubble.bubble.rect().contains(label.geometry())
def test_unread_alert_and_focus_clear(room, qtbot, monkeypatch):
alert = Mock()
monkeypatch.setattr(QApplication, 'alert', alert)
room.activateWindow()
room.setFocus()
qtbot.wait(50)
room.add_chat_message('Other', 'hello', 0)
assert room._unread_messages == 1
alert.assert_called_once()
room.add_chat_message('Me', 'reply', 0)
assert room._unread_messages == 1
room.chat_input.setFocus()
qtbot.wait(50)
assert room._unread_messages == 0
room.add_chat_message('Other', 'focused', 0)
assert room._unread_messages == 0
room.chat_container.hide()
room.add_chat_message('Other', 'hidden', 0)
assert room._unread_messages == 1
room.clear_chat()
assert room._unread_messages == 0
def test_popout_focus_and_wrapping(room, qtbot, monkeypatch):
monkeypatch.setattr(QApplication, 'alert', Mock())
room.popout_chat()
popout = room.popout_window
qtbot.addWidget(popout)
popout.activateWindow()
room.chat_input.setFocus()
qtbot.wait(100)
room.add_chat_message('Other', 'Wrapped popout text ' * 40, 0)
qtbot.wait(100)
assert room._unread_messages == 0
bubble = room.chat_content_layout.itemAt(0).widget()
assert bubble.text_lbl.height() >= bubble.text_lbl.heightForWidth(bubble.text_lbl.width())
room.activateWindow()
room.setFocus()
qtbot.wait(100)
room.add_chat_message('Other', 'Background popout', 0)
assert room._unread_messages == 1
popout.close()
def test_scrolling_preserves_position_and_counts_unread(room, qtbot):
for i in range(30):
room.add_chat_message('Other', f'Message {i}: ' + 'wrapped words ' * 10, 0)
qtbot.wait(150)
bar = room.chat_messages.verticalScrollBar()
assert bar.value() == bar.maximum()
bar.setValue(100)
qtbot.wait(30)
position = bar.value()
room.add_chat_message('Other', 'First unread message', 0)
room.add_chat_message('Other', 'Second unread message', 0)
room.add_system_message('Someone paused')
qtbot.wait(150)
assert bar.value() == position
assert room._pending_chat_messages == 2
assert not room.chat_jump_btn.isHidden()
assert room._unread_divider is not None
room.chat_input.setFocus()
assert room._pending_chat_messages == 2
room.jump_to_latest()
qtbot.wait(150)
assert bar.value() == bar.maximum()
assert room.chat_jump_btn.isHidden()
room.add_chat_message('Other', 'Follow latest', 0)
qtbot.wait(150)
assert bar.value() == bar.maximum()
assert room._pending_chat_messages == 0
def test_scroll_back_to_bottom_clears_pending(room, qtbot):
for i in range(20):
room.add_chat_message('Other', 'Words ' * 30, 0)
qtbot.wait(150)
bar = room.chat_messages.verticalScrollBar()
bar.setValue(0)
room.add_chat_message('Other', 'Unread', 0)
qtbot.wait(100)
bar.setValue(bar.maximum())
assert room._pending_chat_messages == 0
assert room._follow_chat
assert room.chat_jump_btn.isHidden()
room.clear_chat()
assert room._unread_divider is None
assert room.chat_content_layout.count() == 0
def test_ready_command_and_controls(room, qtbot):
with qtbot.waitSignal(room.ready_action_requested) as signal:
room.chat_input.setText('/ready')
room.send_chat()
assert signal.args == [{'type': 'ready_check', 'action': 'begin'}]
assert room.chat_input.text() == ''
state = {'active': True, 'id': 'check-1', 'requestedBy': 'Me', 'participants': [
{'username': 'Me', 'ready': False, 'connected': True},
{'username': "<b>O'Brien</b>", 'ready': True, 'connected': True}]}
room.update_ready_check(state)
assert not room.ready_panel.isHidden()
assert not room.ready_start_btn.isEnabled()
assert "<b>O'Brien</b>" in room.ready_users.text()
with qtbot.waitSignal(room.ready_action_requested) as signal:
room.ready_btn.click()
assert signal.args[0] == {'type': 'ready_check', 'action': 'vote', 'id': 'check-1', 'ready': True}
state['participants'][0]['ready'] = True
room.update_ready_check(state)
assert room.ready_start_btn.isEnabled()
with qtbot.waitSignal(room.ready_action_requested) as signal:
room.ready_start_btn.click()
assert signal.args[0]['action'] == 'start'
state['participants'][1]['connected'] = False
room.update_ready_check(state)
assert not room.ready_start_btn.isEnabled()
assert 'Reconnecting' in room.ready_users.text()
state['requestedBy'] = 'Other'
room.update_ready_check(state)
assert room.ready_start_btn.isHidden()
room.update_ready_check({'active': False})
assert room.ready_panel.isHidden()
def test_ready_panel_buttons_fit_styled_chat(room, qtbot):
from main import VlcSyncApp
VlcSyncApp.apply_stylesheet(room)
room.splitter.setSizes([750, 300])
room.update_ready_check({'active': True, 'id': 'layout', 'requestedBy': 'Me', 'participants': [
{'username': 'Me', 'ready': True, 'connected': True}]})
qtbot.wait(100)
for button in (room.ready_btn, room.ready_start_btn, room.ready_cancel_btn):
assert button.width() >= button.fontMetrics().horizontalAdvance(button.text()) + 20
room.update_connection_status(False)
room.update_ready_check(room._ready_check)
assert not room.ready_start_btn.isEnabled()
assert not room.ready_btn.isEnabled()
def test_app_wires_commands_to_websocket_and_incoming_panels(room, qtbot, monkeypatch):
import main
from sync_client import SyncClientThread
client = SyncClientThread()
client.send_message = Mock()
monkeypatch.setattr(main, 'SyncClientThread', lambda url: client)
app = main.VlcSyncApp()
qtbot.addWidget(app)
for command, expected in [('/ready', {'type': 'ready_check', 'action': 'begin'}),
('/ko', {'type': 'reaction', 'kind': 'ko'}),
('/bell', {'type': 'reaction', 'kind': 'bell'})]:
app.room_widget.chat_input.setText(command)
app.room_widget.send_chat()
client.send_message.assert_called_with(expected)
state = {'type': 'ready_check', 'active': True, 'id': 'wired', 'requestedBy': 'Me',
'participants': [{'username': 'Me', 'ready': False, 'connected': True}]}
client._handle_message(state)
assert app.room_widget._ready_check['id'] == 'wired'
assert not app.room_widget.ready_panel.isHidden()
+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() == []
+190
View File
@@ -0,0 +1,190 @@
import time
import wave
from unittest.mock import Mock
import pytest
from PyQt6.QtCore import QEvent, QUrl
from PyQt6.QtMultimedia import QSoundEffect
from PyQt6.QtWidgets import QApplication
from fight_reactions import REACTIONS, write_reaction_sound
from test_chat import room
@pytest.mark.parametrize('kind', REACTIONS)
def test_command_dispatch(room, qtbot, kind):
with qtbot.waitSignal(room.fight_action_requested) as event:
room.chat_input.setText('/' + kind)
room.send_chat()
assert event.args == [{'type': 'reaction', 'kind': kind}]
assert room.chat_input.text() == ''
assert room.chat_content_layout.count() == 0 # Wait for the server echo.
@pytest.mark.parametrize('kind', REACTIONS)
def test_strip_is_native_positioned_and_does_not_steal_focus(room, qtbot, kind):
room.activateWindow()
room.chat_input.setFocus()
qtbot.wait(30)
room.handle_fight_reaction({'kind': kind, 'username': "<b>O'Brien</b>", 'position': 83})
strip = room.ringside_strip
assert strip.isWindow()
assert strip.isVisible()
assert strip.headline.text() == REACTIONS[kind][0]
assert "<b>O'Brien</b>" in strip.caption.text()
assert '01:23' in strip.metadata.text()
qtbot.wait(210)
assert QApplication.focusWidget() is room.chat_input
video = room.video_frame
rect = strip.geometry()
from PyQt6.QtCore import QRect, QPoint
bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size())
assert bounds.contains(rect)
room.resize(1150, 650)
qtbot.wait(50)
bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size())
assert bounds.contains(strip.geometry())
QApplication.sendEvent(room, QEvent(QEvent.Type.Hide))
assert not strip.isVisible()
def test_strip_auto_dismiss_and_cleanup(room, qtbot):
room.activateWindow()
qtbot.wait(30)
room.handle_fight_reaction({'kind': 'ko', 'username': 'Alex', 'position': 0})
assert room.ringside_strip.isVisible()
room.ringside_strip.dismiss_timer.start(30)
qtbot.waitUntil(lambda: not room.ringside_strip.isVisible())
room.handle_fight_reaction({'kind': 'bell', 'username': 'Alex', 'position': 0})
room.cleanup()
assert not room.ringside_strip.isVisible()
assert not room.ringside_strip.dismiss_timer.isActive()
assert not room._fight_poll_timer.isActive()
def test_reactions_work_with_chat_popout_focused(room, qtbot):
room.popout_chat()
popout = room.popout_window
qtbot.addWidget(popout)
popout.activateWindow()
room.chat_input.setFocus()
qtbot.wait(50)
room.handle_fight_reaction({'kind': 'ko', 'username': 'Other', 'position': 0})
assert room.ringside_strip.isVisible()
assert QApplication.focusWidget() is room.chat_input
popout.close()
def test_arcade_ko_covers_video_then_restores_strip(room, qtbot):
room.activateWindow()
qtbot.wait(30)
room.handle_fight_reaction({'kind': 'ko', 'position': 0})
overlay = room.ringside_strip
assert overlay.size() == room.video_frame.size()
assert overlay.panel.isHidden()
assert not overlay.ko_canvas.isHidden()
assert overlay.ko_animation.state() == overlay.ko_animation.State.Running
room.handle_fight_reaction({'kind': 'bell', 'position': 0})
assert overlay.ko_canvas.isHidden()
assert not overlay.panel.isHidden()
assert overlay.width() <= 540
assert overlay.height() < room.video_frame.height()
room.cleanup()
assert overlay.ko_animation.state() == overlay.ko_animation.State.Stopped
def test_sound_is_enabled_by_default_and_hidden_effects_are_suppressed(room, qtbot, monkeypatch):
play = Mock()
monkeypatch.setattr(room, '_play_reaction_sound', play)
room.activateWindow()
qtbot.wait(30)
room.handle_fight_reaction({'kind': 'ko', 'position': 0})
play.assert_called_once_with('ko')
play.reset_mock()
room.handle_fight_reaction({'kind': 'bell', 'position': 0})
play.assert_called_once_with('bell')
room.hide()
room.handle_fight_reaction({'kind': 'tap', 'position': 0})
play.assert_called_once_with('bell')
assert not room.ringside_strip.isVisible()
@pytest.mark.parametrize('kind', ['ko', 'bell'])
def test_receiving_client_shows_reaction_without_focus(room, qtbot, monkeypatch, kind):
from PyQt6.QtWidgets import QLineEdit
other_client = QLineEdit()
qtbot.addWidget(other_client)
other_client.show()
other_client.activateWindow()
other_client.setFocus()
qtbot.wait(30)
assert not room.isActiveWindow()
play = Mock()
monkeypatch.setattr(room, '_play_reaction_sound', play)
room.handle_fight_reaction({'kind': kind, 'username': 'Other client', 'position': 0})
assert room.ringside_strip.isVisible()
play.assert_called_once_with(kind)
assert QApplication.focusWidget() is other_client
QApplication.sendEvent(room, QEvent(QEvent.Type.WindowDeactivate))
assert room.ringside_strip.isVisible()
room.showMinimized()
qtbot.wait(30)
assert not room.ringside_strip.isVisible()
@pytest.mark.parametrize('kind', REACTIONS)
def test_original_sound_files_are_valid_and_loadable(qtbot, tmp_path, kind):
path = write_reaction_sound(tmp_path, kind)
assert write_reaction_sound(tmp_path, kind) == path
with wave.open(str(path)) as audio:
assert audio.getnchannels() == 1
assert audio.getsampwidth() == 2
assert audio.getframerate() == 22050
assert .35 <= audio.getnframes() / 22050 <= .9
assert any(audio.readframes(audio.getnframes()))
sound = QSoundEffect()
sound.setSource(QUrl.fromLocalFile(str(path)))
qtbot.waitUntil(lambda: sound.status() in (QSoundEffect.Status.Ready, QSoundEffect.Status.Error))
assert sound.status() == QSoundEffect.Status.Ready
def test_robbery_vote_controls_expiry_and_reconnect(room, qtbot):
poll = {'id': 'vote-1', 'active': True, 'expiresAt': time.time() * 1000 + 30000,
'votes': {'Other': 'yes'}}
room.update_fight_poll(poll)
assert not room.fight_poll_panel.isHidden()
assert 'Absolutely 1' in room.fight_poll_result.text()
with qtbot.waitSignal(room.fight_action_requested) as event:
room.fight_no_btn.click()
assert event.args == [{'type': 'fight_poll_vote', 'id': 'vote-1', 'choice': 'no'}]
poll['votes']['Me'] = 'no'
room.update_fight_poll(poll)
assert 'Fair decision 1' in room.fight_poll_result.text()
assert 'Your vote is in' in room.fight_poll_result.text()
room.update_connection_status(False)
assert not room.fight_yes_btn.isEnabled()
room.update_connection_status(True)
assert room.fight_yes_btn.isEnabled()
poll['expiresAt'] = time.time() * 1000 - 1000
room.update_fight_poll(poll)
assert 'Vote closed' in room.fight_poll_title.text()
assert not room.fight_yes_btn.isEnabled()
assert not room._fight_poll_timer.isActive()
room.reset_fight_reactions()
assert room.fight_poll_panel.isHidden()
def test_styled_vote_controls_fit_and_dismiss_stays_dismissed(room, qtbot):
from main import VlcSyncApp
VlcSyncApp.apply_stylesheet(room)
room.splitter.setSizes([750, 350])
poll = {'id': 'styled', 'active': True, 'expiresAt': time.time() * 1000 + 30000, 'votes': {}}
room.update_fight_poll(poll)
qtbot.wait(50)
for button in (room.fight_yes_btn, room.fight_no_btn, room.fight_poll_dismiss_btn):
assert button.width() >= button.fontMetrics().horizontalAdvance(button.text()) + 10
room.fight_poll_dismiss_btn.click()
room.update_fight_poll(poll)
assert room.fight_poll_panel.isHidden()
room.update_fight_poll({**poll, 'id': 'new-poll'})
assert not room.fight_poll_panel.isHidden()
+104
View File
@@ -0,0 +1,104 @@
import json
import time
import pytest
from websockets.sync.client import connect
from test_ready_server import server_url, send, receive
def join_pair(host, guest):
file_info = {'name': 'fight.mkv', 'size': 1234, 'duration': 60}
send(host, type='create_room', username='Host', fileInfo=file_info)
code = receive(host, 'room_created')['code']
send(guest, type='join_room', username='Guest', code=code)
receive(guest, 'room_file_check')
send(guest, type='confirm_join', fileInfo=file_info)
receive(guest, 'room_joined')
receive(host, 'user_joined')
return code, file_info
@pytest.mark.parametrize('kind', ['ko', 'bell', 'tap', 'robbery'])
def test_reactions_are_room_wide_and_preserve_playback(server_url, kind):
with connect(server_url) as host, connect(server_url) as guest:
code, file_info = join_pair(host, guest)
# An unconfirmed client cannot broadcast or consume the cooldown.
with connect(server_url) as pending:
send(pending, type='join_room', username='Pending', code=code)
receive(pending, 'room_file_check')
send(pending, type='reaction', kind=kind)
send(pending, type='ping', timestamp=1)
assert receive(pending, 'pong')['timestamp'] == 1
send(host, type='reaction', kind='invalid')
assert 'Unknown' in receive(host, 'reaction_error')['message']
send(host, type='sync', action='play', position=18)
receive(guest, 'sync')
send(host, type='reaction', kind=kind, username='Spoofed')
reaction = receive(host, 'reaction')
assert reaction['kind'] == kind and reaction['username'] == 'Host'
assert reaction['position'] >= 18
assert receive(guest, 'reaction') == reaction
assert receive(host, 'chat')['message'] == '🥊 /' + kind
receive(guest, 'chat')
if kind == 'robbery':
assert receive(host, 'fight_poll')['active']
receive(guest, 'fight_poll')
send(guest, type='reaction', kind='bell')
assert 'cooldown' in receive(guest, 'reaction_error')['message']
send(host, type='request_state')
state = receive(host, 'state')['state']
assert state['playing'] and state['position'] >= 18
with pytest.raises(TimeoutError):
host.recv(timeout=.1)
# Reconnecting restores chat history without replaying transient effects.
guest.close()
with connect(server_url) as restored:
send(restored, type='rejoin_room', username='Guest', code=code)
snapshot = receive(restored, 'room_rejoined')
assert snapshot['chatHistory'][-1]['message'] == '🥊 /' + kind
with pytest.raises(TimeoutError):
restored.recv(timeout=.1)
def test_robbery_votes_are_unique_restored_and_expire(server_url):
with connect(server_url) as host, connect(server_url) as guest:
code, file_info = join_pair(host, guest)
send(host, type='reaction', kind='robbery')
poll = receive(host, 'fight_poll')
poll_id = poll['id']
assert poll['votes'] == {} and poll['active']
receive(guest, 'fight_poll')
send(host, type='fight_poll_vote', id=poll_id, choice='yes')
assert receive(host, 'fight_poll')['votes'] == {'Host': 'yes'}
receive(guest, 'fight_poll')
send(host, type='fight_poll_vote', id=poll_id, choice='no')
assert receive(host, 'fight_poll')['votes'] == {'Host': 'no'}
receive(guest, 'fight_poll')
send(guest, type='fight_poll_vote', id=poll_id, choice='yes', username='Host')
assert receive(host, 'fight_poll')['votes'] == {'Host': 'no', 'Guest': 'yes'}
receive(guest, 'fight_poll')
for vote_id, choice in [('stale', 'yes'), (poll_id, 'invalid')]:
send(guest, type='fight_poll_vote', id=vote_id, choice=choice)
send(guest, type='ping', timestamp=2)
assert json.loads(guest.recv(timeout=3))['type'] == 'pong'
guest.close()
with connect(server_url) as restored:
send(restored, type='rejoin_room', username='Guest', code=code)
snapshot = receive(restored, 'room_rejoined')['fightPoll']
assert snapshot['id'] == poll_id and snapshot['votes']['Guest'] == 'yes'
receive(host, 'user_rejoined')
time.sleep(3.1)
send(restored, type='reaction', kind='robbery')
assert 'already open' in receive(restored, 'reaction_error')['message']
assert receive(restored, 'fight_poll')['id'] == poll_id
closed = json.loads(host.recv(timeout=35))
assert closed['type'] == 'fight_poll' and not closed['active']
assert closed['votes'] == {'Host': 'no', 'Guest': 'yes'}
assert not receive(restored, 'fight_poll')['active']
send(restored, type='fight_poll_vote', id=poll_id, choice='no')
send(restored, type='ping', timestamp=3)
assert json.loads(restored.recv(timeout=3))['type'] == 'pong'
send(host, type='reaction', kind='robbery')
replacement = receive(host, 'fight_poll')
assert replacement['id'] != poll_id and replacement['votes'] == {}
+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
+133
View File
@@ -0,0 +1,133 @@
"""Exercise ready checks through real WebSockets against an isolated local server."""
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import time
import pytest
from websockets.sync.client import connect
@pytest.fixture
def server_url():
with socket.socket() as reservation:
reservation.bind(('127.0.0.1', 0))
port = reservation.getsockname()[1]
bun = shutil.which('bun')
assert bun, 'Bun is required to test the local server'
process = subprocess.Popen(
[bun, 'run', 'server.ts'], cwd=Path(__file__).resolve().parent.parent,
env={**os.environ, 'PORT': str(port)}, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
try:
deadline = time.monotonic() + 5
while True:
if process.poll() is not None:
pytest.fail(process.stderr.read().decode())
try:
with socket.create_connection(('127.0.0.1', port), timeout=.1):
break
except OSError:
if time.monotonic() >= deadline:
pytest.fail('Local server did not start')
time.sleep(.05)
yield f'ws://127.0.0.1:{port}/ws'
finally:
process.terminate()
process.wait(timeout=5)
process.stderr.close()
def send(ws, **message):
ws.send(json.dumps(message))
def receive(ws, kind, predicate=lambda message: True):
deadline = time.monotonic() + 3
while True:
message = json.loads(ws.recv(timeout=max(.01, deadline - time.monotonic())))
if message['type'] == kind and predicate(message):
return message
def test_room_ready_check_lifecycle(server_url):
file_info = {'name': 'test.mkv', 'size': 1234, 'duration': 60}
with connect(server_url) as host, connect(server_url) as guest:
send(host, type='create_room', username='Host', fileInfo=file_info)
code = receive(host, 'room_created')['code']
send(guest, type='join_room', username='Guest', code=code)
receive(guest, 'room_file_check')
send(guest, type='confirm_join', fileInfo=file_info)
receive(guest, 'room_joined')
receive(host, 'user_joined')
send(host, type='sync', action='play', position=12)
receive(guest, 'sync')
send(host, type='ready_check', action='begin')
pause = receive(host, 'sync')
assert pause['action'] == 'pause' and pause['position'] >= 12
check = receive(host, 'ready_check')
check_id = check['id']
assert check['requestedBy'] == 'Host'
assert not any(p['ready'] for p in check['participants'])
assert receive(guest, 'ready_check')['id'] == check_id
# Unconfirmed peers cannot create or vote in a check.
with connect(server_url) as pending:
send(pending, type='join_room', username='Pending', code=code)
receive(pending, 'room_file_check')
send(pending, type='ready_check', action='begin')
send(pending, type='ready_check', action='vote', id=check_id, ready=True)
send(host, type='ready_check', action='start', id=check_id)
send(host, type='ready_check', action='vote', id=check_id, ready=True)
check = receive(host, 'ready_check')
assert check['active'] and sum(p['ready'] for p in check['participants']) == 1
receive(guest, 'ready_check')
send(host, type='ready_check', action='begin')
repeated = receive(host, 'ready_check')
assert repeated['id'] == check_id
assert sum(p['ready'] for p in repeated['participants']) == 1
receive(guest, 'ready_check')
send(guest, type='ready_check', action='vote', id=check_id, ready=True)
assert all(p['ready'] for p in receive(host, 'ready_check')['participants'])
receive(guest, 'ready_check')
# Non-initiators cannot start. Stale IDs cannot change votes.
send(guest, type='ready_check', action='start', id=check_id)
send(guest, type='ready_check', action='vote', id='stale', ready=False)
send(guest, type='ready_check', action='begin')
assert all(p['ready'] for p in receive(host, 'ready_check')['participants'])
receive(guest, 'ready_check')
# Dropping a connection revokes readiness and blocks starting.
guest.close()
disconnected = receive(host, 'ready_check')
guest_state = next(p for p in disconnected['participants'] if p['username'] == 'Guest')
assert not guest_state['connected'] and not guest_state['ready']
send(host, type='ready_check', action='start', id=check_id)
with connect(server_url) as rejoined:
send(rejoined, type='rejoin_room', username='Guest', code=code)
restored = receive(rejoined, 'room_rejoined')['readyCheck']
assert restored['id'] == check_id
assert not next(p for p in restored['participants'] if p['username'] == 'Guest')['ready']
receive(host, 'ready_check')
receive(rejoined, 'ready_check')
send(rejoined, type='ready_check', action='vote', id=check_id, ready=True)
receive(host, 'ready_check')
receive(rejoined, 'ready_check')
send(host, type='ready_check', action='start', id=check_id)
assert receive(host, 'sync')['action'] == 'play'
assert receive(rejoined, 'sync')['action'] == 'play'
assert not receive(host, 'ready_check')['active']
assert not receive(rejoined, 'ready_check')['active']
send(host, type='ready_check', action='begin')
new_check = receive(host, 'ready_check')
assert new_check['id'] != check_id
assert not any(p['ready'] for p in new_check['participants'])
receive(rejoined, 'ready_check')
send(host, type='ready_check', action='cancel', id=new_check['id'])
assert not receive(host, 'ready_check')['active']
assert not receive(rejoined, 'ready_check')['active']
+44
View File
@@ -0,0 +1,44 @@
import ssl
from sync_client import SyncClientThread
def test_ssl_context_uses_bundled_ca_certificates():
context = SyncClientThread._create_ssl_context()
assert context.verify_mode == ssl.CERT_REQUIRED
assert context.check_hostname is True
assert context.cert_store_stats()["x509_ca"] > 0
def test_certificate_error_finds_nested_verification_error():
certificate_error = ssl.SSLCertVerificationError(1, "certificate has expired")
try:
raise RuntimeError("connection failed") from certificate_error
except RuntimeError as error:
assert SyncClientThread._certificate_error(error) is certificate_error
def test_non_certificate_error_is_ignored():
assert SyncClientThread._certificate_error(ConnectionError("offline")) is None
def test_ready_check_event_dispatch(qtbot):
client = SyncClientThread()
message = {'type': 'ready_check', 'active': True, 'id': 'check'}
with qtbot.waitSignal(client.ready_check_updated) as signal:
client._handle_message(message)
assert signal.args == [message]
def test_fight_event_dispatch_and_nonfatal_feedback(qtbot):
client = SyncClientThread()
for event_type, signal in (('reaction', client.fight_reaction), ('fight_poll', client.fight_poll_updated)):
message = {'type': event_type, 'kind': 'ko'}
with qtbot.waitSignal(signal) as event:
client._handle_message(message)
assert event.args == [message]
with qtbot.waitSignal(client.system_message) as event:
client._handle_message({'type': 'reaction_error', 'message': 'Cooldown'})
assert event.args == ['Cooldown']
+11
View File
@@ -11,6 +11,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "macholib"
version = "1.16.4"
@@ -163,6 +172,7 @@ name = "python-app"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "certifi" },
{ name = "pillow" },
{ name = "pyinstaller" },
{ name = "pyqt6" },
@@ -172,6 +182,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "certifi", specifier = ">=2026.6.17" },
{ name = "pillow", specifier = ">=12.1.1" },
{ name = "pyinstaller", specifier = ">=6.19.0" },
{ name = "pyqt6", specifier = ">=6.10.2" },
+150 -5
View File
@@ -8,14 +8,18 @@ interface FileInfo {
duration: number;
}
interface RoomState {
code: string;
interface RoomState {
code: string;
listed: boolean;
fileInfo: FileInfo;
playing: boolean;
position: number;
lastUpdate: number; // timestamp when position was last set
users: Map<string, WebSocket>;
chatHistory: ChatMessage[];
readyCheck?: { id: string; requestedBy: string; ready: Set<string> };
lastReactionAt?: number;
fightPoll?: { id: string; expiresAt: number; votes: Map<string, "yes" | "no"> };
}
interface ChatMessage {
@@ -56,6 +60,29 @@ function getUserList(room: RoomState): string[] {
return Array.from(room.users.keys());
}
function readyCheckState(room: RoomState) {
if (!room.readyCheck) return { type: "ready_check", active: false };
return {
type: "ready_check", active: true,
id: room.readyCheck.id, requestedBy: room.readyCheck.requestedBy,
participants: Array.from(room.users, ([username, ws]) => ({
username, ready: room.readyCheck!.ready.has(username), connected: ws.readyState === 1,
})),
};
}
function broadcastReadyCheck(room: RoomState) {
broadcastToRoom(room, readyCheckState(room));
}
function fightPollState(room: RoomState) {
const poll = room.fightPoll;
if (!poll) return { type: "fight_poll", active: false };
return { type: "fight_poll", id: poll.id, active: Date.now() < poll.expiresAt,
expiresAt: poll.expiresAt,
votes: Object.fromEntries(Array.from(poll.votes).filter(([username]) => room.users.has(username))) };
}
// --- MIME Types ---
const mimeTypes: Record<string, string> = {
".html": "text/html",
@@ -107,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") {
@@ -153,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,
@@ -253,6 +291,8 @@ const server = Bun.serve<WSData>({
position: getCurrentPosition(room),
},
chatHistory: room.chatHistory.slice(-50),
readyCheck: readyCheckState(room),
fightPoll: fightPollState(room),
})
);
@@ -267,12 +307,17 @@ const server = Bun.serve<WSData>({
ws as unknown as WebSocket
);
console.log(`[Room ${code}] ${username} joined`);
if (room.readyCheck) broadcastReadyCheck(room);
break;
}
case "sync": {
const room = rooms.get(ws.data.roomCode);
if (!room) return;
if (msg.action === "play" && room.readyCheck) {
room.readyCheck = undefined;
broadcastReadyCheck(room);
}
// Update authoritative state
if (msg.action === "play") {
@@ -305,6 +350,89 @@ const server = Bun.serve<WSData>({
break;
}
case "ready_check": {
const room = rooms.get(ws.data.roomCode);
const username = ws.data.username;
if (!room || room.users.get(username) !== (ws as unknown as WebSocket)) return;
if (msg.action === "begin") {
// Repeating /ready shows the existing check without resetting votes.
if (!room.readyCheck) {
room.readyCheck = { id: crypto.randomUUID(), requestedBy: username, ready: new Set() };
room.position = getCurrentPosition(room);
room.playing = false;
room.lastUpdate = Date.now();
broadcastToRoom(room, { type: "sync", action: "pause", position: room.position, playing: false });
}
} else {
const check = room.readyCheck;
if (!check || msg.id !== check.id) return;
if (msg.action === "vote" && typeof msg.ready === "boolean") {
if (msg.ready) check.ready.add(username);
else check.ready.delete(username);
} else if (msg.action === "cancel" && check.requestedBy === username) {
room.readyCheck = undefined;
} else if (msg.action === "start" && check.requestedBy === username) {
const everyoneReady = Array.from(room.users).every(([name, peer]) =>
peer.readyState === 1 && check.ready.has(name));
if (!everyoneReady) return;
room.readyCheck = undefined;
room.playing = true;
room.lastUpdate = Date.now();
broadcastToRoom(room, { type: "sync", action: "play", position: room.position, playing: true });
} else return;
}
broadcastReadyCheck(room);
break;
}
case "reaction": {
const room = rooms.get(ws.data.roomCode);
if (!room || room.users.get(ws.data.username) !== (ws as unknown as WebSocket)) return;
if (!["ko", "bell", "tap", "robbery"].includes(msg.kind)) {
ws.send(JSON.stringify({ type: "reaction_error", message: "Unknown fight reaction." }));
return;
}
const now = Date.now();
if (room.lastReactionAt !== undefined && now - room.lastReactionAt < 3000) {
ws.send(JSON.stringify({ type: "reaction_error", message: "Fight reactions have a 3-second room cooldown." }));
return;
}
if (msg.kind === "robbery" && room.fightPoll && now < room.fightPoll.expiresAt) {
ws.send(JSON.stringify({ type: "reaction_error", message: "A robbery vote is already open." }));
ws.send(JSON.stringify(fightPollState(room)));
return;
}
room.lastReactionAt = now;
broadcastToRoom(room, { type: "reaction", kind: msg.kind,
username: ws.data.username, position: getCurrentPosition(room), timestamp: now });
const chatMsg = { username: ws.data.username, message: `🥊 /${msg.kind}`, timestamp: now };
room.chatHistory.push(chatMsg);
if (room.chatHistory.length > 200) room.chatHistory = room.chatHistory.slice(-100);
broadcastToRoom(room, { type: "chat", ...chatMsg });
if (msg.kind === "robbery") {
const poll = { id: crypto.randomUUID(), expiresAt: now + 30_000, votes: new Map<string, "yes" | "no">() };
room.fightPoll = poll;
broadcastToRoom(room, fightPollState(room));
setTimeout(() => {
if (rooms.get(room.code) === room && room.fightPoll?.id === poll.id) {
broadcastToRoom(room, fightPollState(room));
}
}, 30_000);
}
break;
}
case "fight_poll_vote": {
const room = rooms.get(ws.data.roomCode);
if (!room || room.users.get(ws.data.username) !== (ws as unknown as WebSocket)) return;
const poll = room.fightPoll;
if (!poll || poll.id !== msg.id || Date.now() >= poll.expiresAt) return;
if (msg.choice !== "yes" && msg.choice !== "no") return;
poll.votes.set(ws.data.username, msg.choice);
broadcastToRoom(room, fightPollState(room));
break;
}
case "chat": {
const room = rooms.get(ws.data.roomCode);
if (!room) return;
@@ -386,6 +514,8 @@ const server = Bun.serve<WSData>({
position: getCurrentPosition(room),
},
chatHistory: room.chatHistory.slice(-50),
readyCheck: readyCheckState(room),
fightPoll: fightPollState(room),
})
);
@@ -400,6 +530,7 @@ const server = Bun.serve<WSData>({
ws as unknown as WebSocket
);
console.log(`[Room ${code}] ${username} rejoined`);
if (room.readyCheck) broadcastReadyCheck(room);
break;
}
}
@@ -411,6 +542,11 @@ const server = Bun.serve<WSData>({
const room = rooms.get(roomCode);
if (!room) return;
if (room.users.get(username) !== (ws as unknown as WebSocket)) return;
if (room.readyCheck) {
room.readyCheck.ready.delete(username);
broadcastReadyCheck(room);
}
// Don't remove immediately — give a 90s grace period for reconnection
const disconnectKey = `${roomCode}:${username}`;
@@ -425,6 +561,15 @@ const server = Bun.serve<WSData>({
const currentWs = currentRoom.users.get(username);
if (currentWs === (ws as unknown as WebSocket)) {
currentRoom.users.delete(username);
if (currentRoom.fightPoll) {
currentRoom.fightPoll.votes.delete(username);
broadcastToRoom(currentRoom, fightPollState(currentRoom));
}
if (currentRoom.readyCheck) {
currentRoom.readyCheck.ready.delete(username);
if (currentRoom.readyCheck.requestedBy === username) currentRoom.readyCheck = undefined;
broadcastReadyCheck(currentRoom);
}
console.log(`[Room ${roomCode}] ${username} removed (grace period expired)`);
if (currentRoom.users.size === 0) {