Add desktop ready checks, smarter chat scrolling, and fight-night reactions
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -40,6 +40,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 = ""
|
||||
@@ -64,6 +66,9 @@ 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()
|
||||
@@ -212,6 +217,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)
|
||||
@@ -232,6 +240,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 {
|
||||
|
||||
@@ -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,6 +48,19 @@ 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
|
||||
@@ -110,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()
|
||||
@@ -192,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()
|
||||
@@ -252,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()
|
||||
@@ -269,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)
|
||||
@@ -286,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)
|
||||
@@ -326,8 +411,9 @@ class RoomWidget(QWidget):
|
||||
|
||||
# Clear chat
|
||||
self._clear_chat_alert()
|
||||
for i in reversed(range(self.chat_content_layout.count())):
|
||||
self.chat_content_layout.itemAt(i).widget().setParent(None)
|
||||
self.clear_chat()
|
||||
self.update_ready_check({})
|
||||
self.reset_fight_reactions()
|
||||
|
||||
|
||||
self.current_users = []
|
||||
@@ -339,6 +425,9 @@ class RoomWidget(QWidget):
|
||||
|
||||
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
|
||||
@@ -347,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")
|
||||
@@ -751,6 +847,19 @@ 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()
|
||||
|
||||
@@ -762,7 +871,7 @@ class RoomWidget(QWidget):
|
||||
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():
|
||||
if self._chat_has_focus() and self._follow_chat:
|
||||
self._clear_chat_alert()
|
||||
|
||||
def _clear_chat_alert(self):
|
||||
@@ -778,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
|
||||
@@ -812,10 +1061,16 @@ class RoomWidget(QWidget):
|
||||
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
@@ -21,6 +21,9 @@ class SyncClientThread(QThread):
|
||||
chat_message = pyqtSignal(str, str, int) # author, text, timestamp
|
||||
system_message = pyqtSignal(str)
|
||||
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
|
||||
|
||||
def __init__(self, url="ws://localhost:3000/ws"):
|
||||
@@ -150,7 +153,7 @@ class SyncClientThread(QThread):
|
||||
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":
|
||||
@@ -162,3 +165,15 @@ class SyncClientThread(QThread):
|
||||
|
||||
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"))
|
||||
|
||||
@@ -17,6 +17,7 @@ class PlayerSignals(QObject):
|
||||
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)
|
||||
@@ -101,3 +102,118 @@ def test_popout_focus_and_wrapping(room, qtbot, monkeypatch):
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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'] == {}
|
||||
@@ -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']
|
||||
@@ -22,3 +22,23 @@ def test_certificate_error_finds_nested_verification_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']
|
||||
|
||||
@@ -16,6 +16,9 @@ interface RoomState {
|
||||
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 +59,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",
|
||||
@@ -253,6 +279,8 @@ const server = Bun.serve<WSData>({
|
||||
position: getCurrentPosition(room),
|
||||
},
|
||||
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
|
||||
);
|
||||
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 +338,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 +502,8 @@ const server = Bun.serve<WSData>({
|
||||
position: getCurrentPosition(room),
|
||||
},
|
||||
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
|
||||
);
|
||||
console.log(`[Room ${code}] ${username} rejoined`);
|
||||
if (room.readyCheck) broadcastReadyCheck(room);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -411,6 +530,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 +549,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) {
|
||||
|
||||
Reference in New Issue
Block a user