Add desktop ready checks, smarter chat scrolling, and fight-night reactions

This commit is contained in:
Peter Stockings
2026-10-05 15:46:55 +11:00
parent 19576de166
commit f3eeda71e6
13 changed files with 1228 additions and 10 deletions
+56
View File
@@ -0,0 +1,56 @@
# VideoSync desktop client
## 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` — K.O.: That's a wrap.
- `/bell` — DING!: Back to your corners.
- `/tap` — TAP!: Someone's had enough.
- `/robbery` — a reaction plus a 30-second room vote on the decision.
Reactions slide into the bottom of the video for 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, minimized, or another application is active.
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.
+11
View File
@@ -6,6 +6,9 @@ HELP_TEXT = """
• <b>/seek [time]</b> - Seek (e.g., 1:23, +30s, -1m)<br> • <b>/seek [time]</b> - Seek (e.g., 1:23, +30s, -1m)<br>
• <b>/tag [now|time] [name]</b> - Create a highlight<br> • <b>/tag [now|time] [name]</b> - Create a highlight<br>
• <b>/time</b> - Show current timestamp<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>/help</b> - Show this message<br><br>
<b>Keyboard Shortcuts:</b><br> <b>Keyboard Shortcuts:</b><br>
• <b>Space</b>: Play/Pause<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): def handle_chat_command(cmd: str, parts: list, widget):
"""Handle a slash command. Returns True if the command was handled.""" """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 cmd == "/play":
if not widget.vlc_player.is_playing: if not widget.vlc_player.is_playing:
widget.toggle_playback() widget.toggle_playback()
+146
View File
@@ -0,0 +1,146 @@
"""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, QPoint, QEasingCurve, QEvent
from PyQt6.QtWidgets import QApplication, 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 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)
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()
# Avoid displaying an always-on-top effect over another application.
active = QApplication.activeWindow()
while active is not None and active is not owner:
active = active.parentWidget()
if active is None or 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.slide.stop()
self._place()
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):
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.WindowDeactivate, QEvent.Type.Close):
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.slide.stop()
self.dismiss_timer.stop()
self.hide()
+12
View File
@@ -40,6 +40,8 @@ class VlcSyncApp(QMainWindow):
self.room_widget.leave_requested.connect(self._on_room_leave) 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.sync_action_requested.connect(self._on_room_sync_action)
self.room_widget.chat_message_ready.connect(self._on_room_chat) 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 # App State
self.username = "" self.username = ""
@@ -64,6 +66,9 @@ class VlcSyncApp(QMainWindow):
self.sync_client.chat_message.connect(self.room_widget.add_chat_message) 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.system_message.connect(self.room_widget.add_system_message)
self.sync_client.sync_event.connect(self.room_widget.handle_sync_event) 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.sync_client.latency_updated.connect(self.room_widget.update_latency)
self.apply_stylesheet() self.apply_stylesheet()
@@ -212,6 +217,9 @@ class VlcSyncApp(QMainWindow):
self.room_widget.handle_sync_event(state) self.room_widget.handle_sync_event(state)
self.room_widget.add_system_message("Welcome to the room! 👋") 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): def on_room_rejoined(self, msg: dict):
self.room_widget.set_room_code_display(self.room_code) self.room_widget.set_room_code_display(self.room_code)
@@ -232,6 +240,10 @@ class VlcSyncApp(QMainWindow):
if state: if state:
self.room_widget.handle_sync_event(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): def apply_stylesheet(self):
self.setStyleSheet(""" self.setStyleSheet("""
QWidget { QWidget {
+264 -9
View File
@@ -2,13 +2,15 @@ import os
import datetime import datetime
import html import html
import re import re
import time
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QFrame, QTextEdit, QTextBrowser, QApplication, QSplitter, QPushButton, QFrame, QTextEdit, QTextBrowser, QApplication, QSplitter,
QScrollArea 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.QtGui import QIcon, QCursor
from PyQt6.QtMultimedia import QSoundEffect
import uuid import uuid
import urllib.parse import urllib.parse
@@ -18,6 +20,7 @@ from utils import format_time_hms, format_time_short
from chat_widgets import ChatBubble, SystemMessageWidget, ChatPopoutWindow from chat_widgets import ChatBubble, SystemMessageWidget, ChatPopoutWindow
from custom_widgets import ClickableSlider from custom_widgets import ClickableSlider
from commands import parse_time_arg, handle_chat_command from commands import parse_time_arg, handle_chat_command
from fight_reactions import REACTIONS, RingsideStrip, write_reaction_sound
class ExpectedVlcEvent: class ExpectedVlcEvent:
@@ -31,6 +34,8 @@ class RoomWidget(QWidget):
leave_requested = pyqtSignal() leave_requested = pyqtSignal()
sync_action_requested = pyqtSignal(str, float, str) # action, position_s, req_id sync_action_requested = pyqtSignal(str, float, str) # action, position_s, req_id
chat_message_ready = pyqtSignal(str) # raw message text chat_message_ready = pyqtSignal(str) # raw message text
ready_action_requested = pyqtSignal(dict)
fight_action_requested = pyqtSignal(dict)
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -43,6 +48,19 @@ class RoomWidget(QWidget):
self._is_first_user_update = True self._is_first_user_update = True
self.popout_window = None 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._setup_ui()
self._unread_messages = 0 self._unread_messages = 0
@@ -110,6 +128,7 @@ class RoomWidget(QWidget):
self.video_frame = QFrame() self.video_frame = QFrame()
self.video_frame.setStyleSheet("background-color: black;") self.video_frame.setStyleSheet("background-color: black;")
self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True) self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
self.ringside_strip = RingsideStrip(self.video_frame)
# Controls Bar # Controls Bar
self.controls_bar = QFrame() self.controls_bar = QFrame()
@@ -192,6 +211,30 @@ class RoomWidget(QWidget):
self.users_lbl = QLabel("0 watching") self.users_lbl = QLabel("0 watching")
self.users_lbl.setObjectName("usersLbl") 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) # Tags Container (Hidden by default)
self.tags_container = QFrame() self.tags_container = QFrame()
@@ -252,7 +295,44 @@ class RoomWidget(QWidget):
self.chat_content_layout.setSpacing(0) self.chat_content_layout.setSpacing(0)
self.chat_messages.setWidget(self.chat_content) 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_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() chat_input_layout = QHBoxLayout()
@@ -269,8 +349,11 @@ class RoomWidget(QWidget):
chat_layout.addLayout(header_layout) chat_layout.addLayout(header_layout)
chat_layout.addWidget(self.users_lbl) chat_layout.addWidget(self.users_lbl)
chat_layout.addWidget(self.ready_panel)
chat_layout.addWidget(self.tags_container) chat_layout.addWidget(self.tags_container)
chat_layout.addWidget(self.chat_messages, 1) 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) chat_layout.addLayout(chat_input_layout)
self.splitter.addWidget(video_container) self.splitter.addWidget(video_container)
@@ -286,6 +369,8 @@ class RoomWidget(QWidget):
self.chat_send_btn, self.seekbar, self.volume_slider, self.tags_header_btn, self.chat_send_btn, self.seekbar, self.volume_slider, self.tags_header_btn,
self.chat_toggle_btn, self.popout_btn]: self.chat_toggle_btn, self.popout_btn]:
w.setFocusPolicy(Qt.FocusPolicy.NoFocus) 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 # scroll area handles focus properly
self.chat_messages.setFocusPolicy(Qt.FocusPolicy.ClickFocus) self.chat_messages.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
@@ -326,8 +411,9 @@ class RoomWidget(QWidget):
# Clear chat # Clear chat
self._clear_chat_alert() self._clear_chat_alert()
for i in reversed(range(self.chat_content_layout.count())): self.clear_chat()
self.chat_content_layout.itemAt(i).widget().setParent(None) self.update_ready_check({})
self.reset_fight_reactions()
self.current_users = [] self.current_users = []
@@ -339,6 +425,9 @@ class RoomWidget(QWidget):
def cleanup(self): def cleanup(self):
self._clear_chat_alert() self._clear_chat_alert()
self.clear_chat()
self.update_ready_check({})
self.reset_fight_reactions()
self.vlc_player.stop() self.vlc_player.stop()
self.current_users = [] self.current_users = []
self._is_first_user_update = True self._is_first_user_update = True
@@ -347,6 +436,13 @@ class RoomWidget(QWidget):
self.room_code_display.setText(f"Room: {text}") self.room_code_display.setText(f"Room: {text}")
def update_connection_status(self, connected: bool): 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: if connected:
self.status_dot.setStyleSheet("color: #4BB543; font-size: 14px; background: transparent;") self.status_dot.setStyleSheet("color: #4BB543; font-size: 14px; background: transparent;")
self.status_dot.setToolTip("Connected") self.status_dot.setToolTip("Connected")
@@ -751,6 +847,19 @@ class RoomWidget(QWidget):
return # Don't show this as a chat message bubble return # Don't show this as a chat message bubble
bubble = ChatBubble(author, linked_text, time_str, is_self, self._handle_link_from_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.chat_content_layout.addWidget(bubble)
self._scroll_to_bottom() self._scroll_to_bottom()
@@ -762,7 +871,7 @@ class RoomWidget(QWidget):
and (focus is self.chat_container or self.chat_container.isAncestorOf(focus))) and (focus is self.chat_container or self.chat_container.isAncestorOf(focus)))
def _on_chat_focus_changed(self, old, new): def _on_chat_focus_changed(self, old, new):
if self._chat_has_focus(): if self._chat_has_focus() and self._follow_chat:
self._clear_chat_alert() self._clear_chat_alert()
def _clear_chat_alert(self): def _clear_chat_alert(self):
@@ -778,10 +887,150 @@ class RoomWidget(QWidget):
self._handle_seek_command(time_str) self._handle_seek_command(time_str)
def _scroll_to_bottom(self): def _scroll_to_bottom(self):
# Allow layout to finish before scrolling if self._follow_chat:
QTimer.singleShot(10, lambda: self.chat_messages.verticalScrollBar().setValue( self._chat_scroll_timer.start(10)
self.chat_messages.verticalScrollBar().maximum()
)) 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): def toPlainText(self):
# For compatibility with existing tests # For compatibility with existing tests
@@ -812,10 +1061,16 @@ class RoomWidget(QWidget):
def clear_chat(self): def clear_chat(self):
self._clear_chat_alert() 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())): for i in reversed(range(self.chat_content_layout.count())):
item = self.chat_content_layout.itemAt(i) item = self.chat_content_layout.itemAt(i)
if item.widget(): if item.widget():
item.widget().setParent(None) item.widget().deleteLater()
self.chat_content_layout.removeWidget(item.widget())
def send_chat(self): def send_chat(self):
text = self.chat_input.text().strip() 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)
+16 -1
View File
@@ -21,6 +21,9 @@ class SyncClientThread(QThread):
chat_message = pyqtSignal(str, str, int) # author, text, timestamp chat_message = pyqtSignal(str, str, int) # author, text, timestamp
system_message = pyqtSignal(str) system_message = pyqtSignal(str)
sync_event = pyqtSignal(dict) sync_event = pyqtSignal(dict)
ready_check_updated = pyqtSignal(dict)
fight_reaction = pyqtSignal(dict)
fight_poll_updated = pyqtSignal(dict)
latency_updated = pyqtSignal(int) # latency in ms latency_updated = pyqtSignal(int) # latency in ms
def __init__(self, url="ws://localhost:3000/ws"): def __init__(self, url="ws://localhost:3000/ws"):
@@ -150,7 +153,7 @@ class SyncClientThread(QThread):
elif t == "room_file_check": elif t == "room_file_check":
self.file_check_needed.emit(msg) 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", [])) self.users_updated.emit(msg.get("users", []))
elif t == "chat": elif t == "chat":
@@ -162,3 +165,15 @@ class SyncClientThread(QThread):
elif t == "sync": elif t == "sync":
self.sync_event.emit(msg) 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"))
+116
View File
@@ -17,6 +17,7 @@ class PlayerSignals(QObject):
def room(qtbot, monkeypatch): def room(qtbot, monkeypatch):
player = Mock(signals=PlayerSignals()) player = Mock(signals=PlayerSignals())
monkeypatch.setattr(room_widget, 'VLCSyncPlayer', lambda frame: player) monkeypatch.setattr(room_widget, 'VLCSyncPlayer', lambda frame: player)
monkeypatch.setattr(room_widget.RoomWidget, '_play_reaction_sound', Mock())
widget = room_widget.RoomWidget() widget = room_widget.RoomWidget()
widget.username = 'Me' widget.username = 'Me'
qtbot.addWidget(widget) qtbot.addWidget(widget)
@@ -101,3 +102,118 @@ def test_popout_focus_and_wrapping(room, qtbot, monkeypatch):
room.add_chat_message('Other', 'Background popout', 0) room.add_chat_message('Other', 'Background popout', 0)
assert room._unread_messages == 1 assert room._unread_messages == 1
popout.close() 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()
+149
View File
@@ -0,0 +1,149 @@
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.WindowDeactivate))
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_sound_is_enabled_by_default_and_background_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', 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'] == {}
+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']
+20
View File
@@ -22,3 +22,23 @@ def test_certificate_error_finds_nested_verification_error():
def test_non_certificate_error_is_ignored(): def test_non_certificate_error_is_ignored():
assert SyncClientThread._certificate_error(ConnectionError("offline")) is None 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']
+133
View File
@@ -16,6 +16,9 @@ interface RoomState {
lastUpdate: number; // timestamp when position was last set lastUpdate: number; // timestamp when position was last set
users: Map<string, WebSocket>; users: Map<string, WebSocket>;
chatHistory: ChatMessage[]; chatHistory: ChatMessage[];
readyCheck?: { id: string; requestedBy: string; ready: Set<string> };
lastReactionAt?: number;
fightPoll?: { id: string; expiresAt: number; votes: Map<string, "yes" | "no"> };
} }
interface ChatMessage { interface ChatMessage {
@@ -56,6 +59,29 @@ function getUserList(room: RoomState): string[] {
return Array.from(room.users.keys()); 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 --- // --- MIME Types ---
const mimeTypes: Record<string, string> = { const mimeTypes: Record<string, string> = {
".html": "text/html", ".html": "text/html",
@@ -253,6 +279,8 @@ const server = Bun.serve<WSData>({
position: getCurrentPosition(room), position: getCurrentPosition(room),
}, },
chatHistory: room.chatHistory.slice(-50), chatHistory: room.chatHistory.slice(-50),
readyCheck: readyCheckState(room),
fightPoll: fightPollState(room),
}) })
); );
@@ -267,12 +295,17 @@ const server = Bun.serve<WSData>({
ws as unknown as WebSocket ws as unknown as WebSocket
); );
console.log(`[Room ${code}] ${username} joined`); console.log(`[Room ${code}] ${username} joined`);
if (room.readyCheck) broadcastReadyCheck(room);
break; break;
} }
case "sync": { case "sync": {
const room = rooms.get(ws.data.roomCode); const room = rooms.get(ws.data.roomCode);
if (!room) return; if (!room) return;
if (msg.action === "play" && room.readyCheck) {
room.readyCheck = undefined;
broadcastReadyCheck(room);
}
// Update authoritative state // Update authoritative state
if (msg.action === "play") { if (msg.action === "play") {
@@ -305,6 +338,89 @@ const server = Bun.serve<WSData>({
break; 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": { case "chat": {
const room = rooms.get(ws.data.roomCode); const room = rooms.get(ws.data.roomCode);
if (!room) return; if (!room) return;
@@ -386,6 +502,8 @@ const server = Bun.serve<WSData>({
position: getCurrentPosition(room), position: getCurrentPosition(room),
}, },
chatHistory: room.chatHistory.slice(-50), chatHistory: room.chatHistory.slice(-50),
readyCheck: readyCheckState(room),
fightPoll: fightPollState(room),
}) })
); );
@@ -400,6 +518,7 @@ const server = Bun.serve<WSData>({
ws as unknown as WebSocket ws as unknown as WebSocket
); );
console.log(`[Room ${code}] ${username} rejoined`); console.log(`[Room ${code}] ${username} rejoined`);
if (room.readyCheck) broadcastReadyCheck(room);
break; break;
} }
} }
@@ -411,6 +530,11 @@ const server = Bun.serve<WSData>({
const room = rooms.get(roomCode); const room = rooms.get(roomCode);
if (!room) return; 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 // Don't remove immediately — give a 90s grace period for reconnection
const disconnectKey = `${roomCode}:${username}`; const disconnectKey = `${roomCode}:${username}`;
@@ -425,6 +549,15 @@ const server = Bun.serve<WSData>({
const currentWs = currentRoom.users.get(username); const currentWs = currentRoom.users.get(username);
if (currentWs === (ws as unknown as WebSocket)) { if (currentWs === (ws as unknown as WebSocket)) {
currentRoom.users.delete(username); 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)`); console.log(`[Room ${roomCode}] ${username} removed (grace period expired)`);
if (currentRoom.users.size === 0) { if (currentRoom.users.size === 0) {