Compare commits
8
Commits
aee78d5392
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c5d4ee546 | ||
|
|
21a35d81f0 | ||
|
|
836034533e | ||
|
|
75362bfcba | ||
|
|
b5f09a9e9e | ||
|
|
f3eeda71e6 | ||
|
|
19576de166 | ||
|
|
ce22de754a |
@@ -0,0 +1,82 @@
|
||||
# VideoSync desktop client
|
||||
|
||||
## Saved preferences
|
||||
|
||||
The client remembers your display name, volume, window geometry, chat sidebar
|
||||
visibility and width, highlight panel visibility, and last selected media folder.
|
||||
Preferences are stored locally using Qt's platform settings store. File dialogs
|
||||
for both creating and joining rooms start in the remembered folder. The server
|
||||
address remains fixed. Detached chat opens docked on the next launch.
|
||||
|
||||
## Active sessions
|
||||
|
||||
The desktop workspace has a sidebar for your saved display name, **Create room**,
|
||||
and **Join by code**. Creation and code entry open in separate dialogs. Drag a
|
||||
video onto the lobby to prepare a new room. Enter your name to enable room actions.
|
||||
|
||||
The lobby checks for listed rooms at startup and every 15 seconds while open.
|
||||
Select a session and click **Choose file & join**, or double-click it, to use the
|
||||
existing file verification flow. Enter your display name first. **Refresh** checks
|
||||
immediately; joining by code remains available when discovery fails.
|
||||
|
||||
New rooms are listed by default. Clear **List room in active sessions** before
|
||||
creating a room to keep it out of the list; anyone with its code can still join.
|
||||
Only rooms with connected participants appear. Discovery requires the updated
|
||||
server's `/api/rooms` endpoint; older clients also create listed rooms by default.
|
||||
|
||||
## Chat
|
||||
|
||||
Chat follows new messages while you are near the bottom. Scroll up to read older
|
||||
messages without losing your place. Incoming messages appear beneath a **New
|
||||
messages** divider, and **Jump to latest** shows how many have arrived. Clicking
|
||||
it, or scrolling back to the bottom, resumes following messages.
|
||||
|
||||
## Ready checks
|
||||
|
||||
Send `/ready` in chat to start a check for everyone in the room. Playback pauses
|
||||
and a ready panel appears above chat. Each person clicks **I'm ready**; they can
|
||||
change their answer with **Not ready**. When everyone is connected and ready,
|
||||
the person who started the check can click **Start together**. They can also
|
||||
cancel the check. Repeating `/ready` leaves an existing check and its votes intact.
|
||||
|
||||
New participants must confirm readiness. Disconnecting clears that person's
|
||||
vote; reconnecting restores the panel so they can confirm again. Ordinary play
|
||||
controls dismiss an active check and continue playback.
|
||||
|
||||
Ready checks require the updated server and desktop clients. The web client
|
||||
does not yet have ready-check controls.
|
||||
|
||||
## Fight-night commands
|
||||
|
||||
- `/ko` — a large arcade-style K.O. with gold lettering, red outline, and an impact animation.
|
||||
- `/bell` — DING!: Back to your corners.
|
||||
- `/tap` — TAP!: Someone's had enough.
|
||||
- `/robbery` — a reaction plus a 30-second room vote on the decision.
|
||||
|
||||
The K.O. appears across the center of the video; other reactions slide into the
|
||||
bottom. Effects clear after 2.2 seconds and keep playback
|
||||
running. A shared 3-second cooldown limits room-wide effects. Reaction events
|
||||
stay in chat history; joining or reconnecting does not replay animations.
|
||||
|
||||
Quiet sound effects play automatically with visible reactions. Sounds are
|
||||
original synthesized effects cached locally, with no external downloads. Overlays and sound are
|
||||
suppressed while the player is hidden or minimized. Visible players receive
|
||||
reactions even without keyboard focus, without activating the player window.
|
||||
Reactions also work while the detached chat window is focused.
|
||||
|
||||
Robbery votes allow one answer per person, which you can change before the vote
|
||||
closes. The result remains visible after closing and can be dismissed. Joining
|
||||
or reconnecting restores the current vote. Fight commands require the updated
|
||||
server and desktop clients; web clients currently see the chat reaction only.
|
||||
|
||||
## Tests
|
||||
|
||||
With `pytest`, `pytest-qt`, and Bun installed, run from this directory:
|
||||
|
||||
```powershell
|
||||
$env:QT_QPA_PLATFORM = 'offscreen'
|
||||
.\.venv\Scripts\python.exe -m pytest test_chat.py test_sync_client.py test_ready_server.py test_fight_reactions.py test_fight_server.py -q
|
||||
```
|
||||
|
||||
The ready-check integration test starts an isolated local server; it does not
|
||||
contact the production service. VLC playback is mocked in the chat UI tests.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
uv add pyinstaller pillow
|
||||
uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --add-data "icon.png;." --add-data "copy.svg;." --add-data "check.svg;." main.py
|
||||
uv sync
|
||||
uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --collect-data "certifi" --add-data "icon.png;." --add-data "copy.svg;." --add-data "check.svg;." main.py
|
||||
|
||||
@@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt, pyqtSignal
|
||||
class ChatBubble(QWidget):
|
||||
def __init__(self, author, text, time_str, is_self, on_link_clicked):
|
||||
super().__init__()
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(5, 2, 5, 2)
|
||||
|
||||
@@ -39,6 +40,7 @@ class ChatBubble(QWidget):
|
||||
|
||||
# Author
|
||||
author_lbl = QLabel("You" if is_self else author)
|
||||
author_lbl.setTextFormat(Qt.TextFormat.PlainText)
|
||||
author_lbl.setStyleSheet(f"color: {author_color}; font-weight: bold; font-size: 11px; background: transparent;")
|
||||
bubble_layout.addWidget(author_lbl)
|
||||
|
||||
@@ -47,6 +49,7 @@ class ChatBubble(QWidget):
|
||||
# width of 0 lets the label wrap within whatever space the bubble
|
||||
# actually receives rather than expanding the bubble outward.
|
||||
self.text_lbl = QLabel(text)
|
||||
self.text_lbl.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.text_lbl.setWordWrap(True)
|
||||
self.text_lbl.setMinimumWidth(0)
|
||||
self.text_lbl.setSizePolicy(
|
||||
@@ -71,6 +74,14 @@ class ChatBubble(QWidget):
|
||||
layout.addWidget(self.bubble)
|
||||
layout.addStretch()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
# Recalculate after the scrollbar or splitter changes the available width.
|
||||
# A fixed row height prevents the scroll layout squeezing wrapped text.
|
||||
height = self.layout().heightForWidth(event.size().width())
|
||||
if height >= 0:
|
||||
self.setFixedHeight(height)
|
||||
|
||||
|
||||
class SystemMessageWidget(QWidget):
|
||||
def __init__(self, text):
|
||||
|
||||
@@ -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,223 @@
|
||||
"""Native ringside overlay and small, original synthesized reaction sounds."""
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import wave
|
||||
|
||||
from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QVariantAnimation, QPoint, QEasingCurve, QEvent
|
||||
from PyQt6.QtGui import QPainter, QPainterPath, QFont, QColor, QPen, QLinearGradient, QTransform
|
||||
from PyQt6.QtWidgets import QWidget, QFrame, QLabel, QHBoxLayout, QVBoxLayout
|
||||
|
||||
|
||||
REACTIONS = {
|
||||
"ko": ("K.O.", "That's a wrap.", "#ff5676"),
|
||||
"bell": ("DING!", "Back to your corners.", "#f5c86c"),
|
||||
"tap": ("TAP!", "Someone's had enough.", "#e1e1e1"),
|
||||
"robbery": ("ROBBERY?", "Judges, explain yourselves.", "#f5c86c"),
|
||||
}
|
||||
|
||||
|
||||
def write_reaction_sound(directory, kind):
|
||||
"""Generate mono PCM once per cache directory, with no downloaded samples."""
|
||||
if kind not in REACTIONS:
|
||||
raise ValueError("Unknown reaction")
|
||||
path = Path(directory) / f"{kind}.wav"
|
||||
if path.exists():
|
||||
return path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rate = 22050
|
||||
duration = .85 if kind == "bell" else .4
|
||||
samples = bytearray()
|
||||
for i in range(int(rate * duration)):
|
||||
t = i / rate
|
||||
attack = min(1, t / .008)
|
||||
if kind == "ko":
|
||||
value = math.sin(2 * math.pi * (130 * t - 100 * t * t)) * math.exp(-13 * t)
|
||||
elif kind == "bell":
|
||||
value = sum(math.sin(2 * math.pi * f * t) * a for f, a in
|
||||
((880, .55), (1765, .25), (2410, .12))) * math.exp(-5 * t)
|
||||
elif kind == "tap":
|
||||
pulse = t if t < .16 else t - .16
|
||||
value = math.sin(2 * math.pi * 420 * pulse) * math.exp(-45 * pulse)
|
||||
else:
|
||||
value = math.sin(2 * math.pi * 145 * t) * math.sin(2 * math.pi * 22 * t) * math.exp(-8 * t)
|
||||
release = min(1, (duration - t) / .02)
|
||||
samples.extend(struct.pack("<h", int(16000 * value * attack * release)))
|
||||
with wave.open(str(path), "wb") as output:
|
||||
output.setnchannels(1)
|
||||
output.setsampwidth(2)
|
||||
output.setframerate(rate)
|
||||
output.writeframes(samples)
|
||||
return path
|
||||
|
||||
|
||||
class ArcadeKnockout(QWidget):
|
||||
"""Vector-painted arcade lettering scales cleanly with the video surface."""
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
self.progress = 0.0
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
def set_progress(self, value):
|
||||
self.progress = value
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
t = self.progress
|
||||
opacity = min(1.0, t / .035, max(0.0, (1.0 - t) / .18))
|
||||
painter.setOpacity(opacity)
|
||||
painter.translate(self.width() / 2, self.height() / 2)
|
||||
entry = min(1.0, t / .18)
|
||||
scale = 1.0 + .65 * (1 - entry) ** 3 - .06 * math.sin(entry * math.pi)
|
||||
painter.scale(scale, scale)
|
||||
font = QFont("Impact")
|
||||
font.setPixelSize(180)
|
||||
font.setBold(True)
|
||||
letters = QPainterPath()
|
||||
letters.addText(0, 0, font, "K.O.")
|
||||
letters = QTransform().shear(-.12, 0).map(letters)
|
||||
bounds = letters.boundingRect()
|
||||
fit = min(self.width() * .63 / bounds.width(), self.height() * .40 / bounds.height())
|
||||
painter.scale(fit, fit)
|
||||
painter.translate(-bounds.center())
|
||||
shadow = QTransform().translate(7, 10).map(letters)
|
||||
painter.setPen(QPen(QColor("#131017"), 18, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
|
||||
painter.setBrush(QColor("#131017"))
|
||||
painter.drawPath(shadow)
|
||||
painter.setPen(QPen(QColor("#8b1028"), 14, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
|
||||
painter.setBrush(QColor("#f33c35"))
|
||||
painter.drawPath(letters)
|
||||
gradient = QLinearGradient(0, bounds.top(), 0, bounds.bottom())
|
||||
gradient.setColorAt(0, QColor("#fff7b0"))
|
||||
gradient.setColorAt(.40, QColor("#ffe252"))
|
||||
gradient.setColorAt(1, QColor("#ff9c22"))
|
||||
painter.setPen(QPen(QColor("#ff583e"), 5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
|
||||
painter.setBrush(gradient)
|
||||
painter.drawPath(letters)
|
||||
if t < .32:
|
||||
painter.setOpacity(opacity * (1 - t / .32) * .55)
|
||||
painter.setClipPath(letters)
|
||||
sweep = bounds.left() + bounds.width() * min(1.0, t / .32)
|
||||
painter.fillRect(int(sweep), int(bounds.top() - 10), 16, int(bounds.height() + 20), QColor("#fffef0"))
|
||||
|
||||
|
||||
class RingsideStrip(QWidget):
|
||||
"""A separate native window stays above VLC's native video surface."""
|
||||
def __init__(self, video_frame):
|
||||
super().__init__(video_frame, Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint
|
||||
| Qt.WindowType.WindowDoesNotAcceptFocus | Qt.WindowType.WindowTransparentForInput)
|
||||
self.video_frame = video_frame
|
||||
self.owner_window = None
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
self.panel = QFrame()
|
||||
outer.addWidget(self.panel)
|
||||
self.ko_canvas = ArcadeKnockout(self)
|
||||
outer.addWidget(self.ko_canvas)
|
||||
self.ko_canvas.hide()
|
||||
self.kind = None
|
||||
self.ko_animation = QVariantAnimation(self)
|
||||
self.ko_animation.setDuration(2200)
|
||||
self.ko_animation.setStartValue(0.0)
|
||||
self.ko_animation.setEndValue(1.0)
|
||||
self.ko_animation.valueChanged.connect(self.ko_canvas.set_progress)
|
||||
row = QHBoxLayout(self.panel)
|
||||
row.setContentsMargins(14, 10, 14, 10)
|
||||
row.setSpacing(14)
|
||||
self.headline = QLabel()
|
||||
self.headline.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.caption = QLabel()
|
||||
self.caption.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.caption.setWordWrap(True)
|
||||
self.caption.setMinimumWidth(0)
|
||||
self.caption.setStyleSheet("color: #f2f2f5; background: transparent; font-size: 13px;")
|
||||
self.metadata = QLabel()
|
||||
self.metadata.setStyleSheet("color: #a6a9b5; background: transparent; font-size: 11px;")
|
||||
details = QVBoxLayout()
|
||||
details.setSpacing(3)
|
||||
details.addWidget(self.caption)
|
||||
details.addWidget(self.metadata)
|
||||
row.addWidget(self.headline)
|
||||
row.addLayout(details, 1)
|
||||
self.dismiss_timer = QTimer(self)
|
||||
self.dismiss_timer.setSingleShot(True)
|
||||
self.dismiss_timer.timeout.connect(self.dismiss)
|
||||
self.slide = QPropertyAnimation(self, b"pos", self)
|
||||
self.slide.setDuration(180)
|
||||
self.slide.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||||
video_frame.installEventFilter(self)
|
||||
|
||||
def show_reaction(self, kind, author, position):
|
||||
if kind not in REACTIONS or not self.video_frame.isVisible():
|
||||
return False
|
||||
owner = self.video_frame.window()
|
||||
if owner.isMinimized():
|
||||
return False
|
||||
if self.owner_window is not owner:
|
||||
if self.owner_window is not None:
|
||||
self.owner_window.removeEventFilter(self)
|
||||
self.owner_window = owner
|
||||
owner.installEventFilter(self)
|
||||
title, caption, color = REACTIONS[kind]
|
||||
self.headline.setText(title)
|
||||
self.headline.setStyleSheet(f"color: {color}; background: transparent; font-size: 24px; font-weight: bold;")
|
||||
self.caption.setText(f"{author}: {caption}")
|
||||
self.metadata.setText(f"Room reaction · {position}")
|
||||
self.panel.setStyleSheet(f"QFrame {{ background: #1a1b20; border-left: 3px solid {color}; border-radius: 6px; }} QLabel {{ border: none; }}")
|
||||
self.kind = kind
|
||||
self.panel.setVisible(kind != "ko")
|
||||
self.ko_canvas.setVisible(kind == "ko")
|
||||
self.ko_animation.stop()
|
||||
self.slide.stop()
|
||||
self._place()
|
||||
if kind == "ko":
|
||||
self.ko_canvas.set_progress(0.0)
|
||||
self.show()
|
||||
self.ko_animation.start()
|
||||
self.dismiss_timer.start(2200)
|
||||
return True
|
||||
end = self.pos()
|
||||
self.move(end + QPoint(0, 12))
|
||||
self.show()
|
||||
self.slide.setStartValue(self.pos())
|
||||
self.slide.setEndValue(end)
|
||||
self.slide.start()
|
||||
self.dismiss_timer.start(2200)
|
||||
return True
|
||||
|
||||
def _place(self):
|
||||
if self.kind == "ko":
|
||||
self.setFixedSize(self.video_frame.size())
|
||||
self.move(self.video_frame.mapToGlobal(QPoint(0, 0)))
|
||||
return
|
||||
self.setMinimumSize(0, 0)
|
||||
self.setMaximumSize(16777215, 16777215)
|
||||
width = max(1, min(540, self.video_frame.width() - 32))
|
||||
self.setFixedWidth(width)
|
||||
height = self.layout().heightForWidth(width)
|
||||
self.setFixedHeight(max(64, height))
|
||||
origin = self.video_frame.mapToGlobal(QPoint(16, self.video_frame.height() - self.height() - 16))
|
||||
self.move(origin)
|
||||
|
||||
def eventFilter(self, watched, event):
|
||||
if self.isVisible():
|
||||
if event.type() in (QEvent.Type.Hide, QEvent.Type.Close):
|
||||
self.dismiss()
|
||||
elif event.type() == QEvent.Type.WindowStateChange and self.owner_window.isMinimized():
|
||||
self.dismiss()
|
||||
elif event.type() in (QEvent.Type.Move, QEvent.Type.Resize):
|
||||
self.slide.stop()
|
||||
self._place()
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def dismiss(self):
|
||||
self.ko_animation.stop()
|
||||
self.slide.stop()
|
||||
self.dismiss_timer.stop()
|
||||
self.hide()
|
||||
+292
-79
@@ -2,9 +2,38 @@ import os
|
||||
import re
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
|
||||
QFileDialog, QFrame, QApplication
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
QFileDialog, QFrame, QApplication, QCheckBox, QListWidget, QListWidgetItem, QDialog,
|
||||
QStyledItemDelegate, QStyle
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QSize
|
||||
from PyQt6.QtGui import QColor, QFont, QFontMetrics
|
||||
|
||||
|
||||
class SessionDelegate(QStyledItemDelegate):
|
||||
"""Draw readable session rows with elided filenames and secondary details."""
|
||||
def paint(self, painter, option, index):
|
||||
painter.save()
|
||||
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
||||
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||
painter.fillRect(option.rect, QColor("#29243f" if selected else
|
||||
"#222839" if hovered else "#191e29"))
|
||||
rect = option.rect.adjusted(22, 20, -22, -20)
|
||||
lines = str(index.data()).splitlines()
|
||||
for i, text in enumerate(lines[:3]):
|
||||
font = QFont("Segoe UI", 10)
|
||||
font.setWeight(QFont.Weight.DemiBold if i == 0 else QFont.Weight.Normal)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor("#edf1fa" if i == 0 else "#9ca9c0"))
|
||||
line = rect.adjusted(0, i * 24, 0, 0)
|
||||
line.setHeight(24)
|
||||
text = QFontMetrics(font).elidedText(text, Qt.TextElideMode.ElideRight, line.width())
|
||||
painter.drawText(line, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, text)
|
||||
painter.setPen(QColor("#2c3546"))
|
||||
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
|
||||
if option.state & QStyle.StateFlag.State_HasFocus:
|
||||
painter.setPen(QColor("#9b8bff"))
|
||||
painter.drawRect(option.rect.adjusted(1, 1, -1, -1))
|
||||
painter.restore()
|
||||
|
||||
class RoomCodeInput(QLineEdit):
|
||||
"""QLineEdit that auto-fills from clipboard if it looks like a room code."""
|
||||
@@ -24,6 +53,8 @@ class LobbyWidget(QWidget):
|
||||
# Signals to communicate to VlcSyncApp
|
||||
create_requested = pyqtSignal(str, str, str, object) # username, path, filename, size
|
||||
join_requested = pyqtSignal(str, str) # username, room_code
|
||||
file_selected = pyqtSignal(str)
|
||||
refresh_requested = pyqtSignal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -32,100 +63,238 @@ class LobbyWidget(QWidget):
|
||||
self.local_file_path = None
|
||||
self.local_file_name = None
|
||||
self.local_file_size = 0
|
||||
self.media_folder = ""
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
self.setObjectName("desktopLobby")
|
||||
self._connecting = False
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Container for centering
|
||||
container = QFrame()
|
||||
container.setObjectName("lobbyCard")
|
||||
container.setFixedWidth(500)
|
||||
container_layout = QVBoxLayout(container)
|
||||
container_layout.setContentsMargins(30, 30, 30, 30)
|
||||
container_layout.setSpacing(15)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Brand
|
||||
title = QLabel("VideoSync")
|
||||
title.setObjectName("brandTitle")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
tagline = QLabel("Watch together, anywhere")
|
||||
tagline.setObjectName("brandTagline")
|
||||
tagline.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
container_layout.addWidget(title)
|
||||
container_layout.addWidget(tagline)
|
||||
container_layout.addSpacing(20)
|
||||
header = QFrame()
|
||||
header.setObjectName("lobbyHeader")
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(28, 18, 28, 18)
|
||||
brand = QLabel("VideoSync")
|
||||
brand.setObjectName("lobbyBrand")
|
||||
header_layout.addWidget(brand)
|
||||
header_layout.addStretch()
|
||||
self.connection_label = QLabel("Watch together. Stay in sync.")
|
||||
self.connection_label.setObjectName("lobbyMuted")
|
||||
header_layout.addWidget(self.connection_label)
|
||||
layout.addWidget(header)
|
||||
|
||||
# Username
|
||||
body = QHBoxLayout()
|
||||
body.setSpacing(0)
|
||||
sidebar = QFrame()
|
||||
sidebar.setObjectName("lobbySidebar")
|
||||
sidebar.setFixedWidth(260)
|
||||
side = QVBoxLayout(sidebar)
|
||||
side.setContentsMargins(24, 32, 24, 24)
|
||||
side.setSpacing(12)
|
||||
title = QLabel("Watch together.")
|
||||
title.setObjectName("sidebarTitle")
|
||||
side.addWidget(title)
|
||||
subtitle = QLabel("One room. Everyone in sync.")
|
||||
subtitle.setObjectName("lobbyMuted")
|
||||
side.addWidget(subtitle)
|
||||
side.addSpacing(12)
|
||||
self.create_action_btn = QPushButton("+ Create room")
|
||||
self.create_action_btn.setObjectName("lobbyPrimary")
|
||||
self.create_action_btn.clicked.connect(self.open_create_dialog)
|
||||
side.addWidget(self.create_action_btn)
|
||||
self.join_action_btn = QPushButton("Join by code")
|
||||
self.join_action_btn.clicked.connect(self.open_join_dialog)
|
||||
side.addWidget(self.join_action_btn)
|
||||
side.addSpacing(28)
|
||||
name_label = QLabel("Your display name")
|
||||
side.addWidget(name_label)
|
||||
self.username_input = QLineEdit()
|
||||
self.username_input.setPlaceholderText("Enter a display name")
|
||||
container_layout.addWidget(QLabel("YOUR NAME"))
|
||||
container_layout.addWidget(self.username_input)
|
||||
|
||||
container_layout.addSpacing(20)
|
||||
|
||||
# Actions Layout (Create vs Join)
|
||||
actions_layout = QHBoxLayout()
|
||||
actions_layout.setSpacing(20)
|
||||
|
||||
# Create Room Panel
|
||||
create_panel = QVBoxLayout()
|
||||
create_panel.addWidget(QLabel("Create a Room"))
|
||||
self.create_file_btn = QPushButton("Choose Video File")
|
||||
self.create_file_btn.setObjectName("secondaryBtn")
|
||||
self.username_input.setPlaceholderText("Enter your name")
|
||||
self.username_input.setAccessibleName("Your display name")
|
||||
side.addWidget(self.username_input)
|
||||
name_label.setBuddy(self.username_input)
|
||||
hint = QLabel("Remembered on this computer.")
|
||||
hint.setObjectName("lobbyMuted")
|
||||
hint.setWordWrap(True)
|
||||
side.addWidget(hint)
|
||||
side.addStretch()
|
||||
privacy = QLabel("Your video stays on your computer.\nOnly playback and chat are shared.")
|
||||
privacy.setObjectName("lobbyMuted")
|
||||
privacy.setWordWrap(True)
|
||||
side.addWidget(privacy)
|
||||
body.addWidget(sidebar)
|
||||
|
||||
workspace = QVBoxLayout()
|
||||
workspace.setContentsMargins(32, 32, 32, 24)
|
||||
workspace.setSpacing(12)
|
||||
heading = QHBoxLayout()
|
||||
title_group = QVBoxLayout()
|
||||
title_group.setSpacing(8)
|
||||
title = QLabel("Active sessions")
|
||||
title.setObjectName("workspaceTitle")
|
||||
title_group.addWidget(title)
|
||||
description = QLabel("See who’s watching and join in.")
|
||||
description.setObjectName("lobbyMuted")
|
||||
title_group.addWidget(description)
|
||||
heading.addLayout(title_group)
|
||||
heading.addStretch()
|
||||
self.refresh_btn = QPushButton("Refresh")
|
||||
self.refresh_btn.setObjectName("lobbyQuiet")
|
||||
self.refresh_btn.clicked.connect(self.refresh_requested.emit)
|
||||
heading.addWidget(self.refresh_btn)
|
||||
workspace.addLayout(heading)
|
||||
workspace.addSpacing(12)
|
||||
self.sessions_status = QLabel("Checking for active sessions…")
|
||||
self.sessions_status.setObjectName("lobbyMuted")
|
||||
self.sessions_status.setWordWrap(True)
|
||||
workspace.addWidget(self.sessions_status)
|
||||
self.sessions_list = QListWidget()
|
||||
self.sessions_list.setObjectName("lobbySessions")
|
||||
self.sessions_list.setWordWrap(True)
|
||||
self.sessions_list.setItemDelegate(SessionDelegate(self.sessions_list))
|
||||
self.sessions_list.setAccessibleName("Active sessions")
|
||||
workspace.addWidget(self.sessions_list, 1)
|
||||
bottom = QHBoxLayout()
|
||||
self.session_count = QLabel("Select a session to get started.")
|
||||
self.session_count.setObjectName("lobbyMuted")
|
||||
bottom.addWidget(self.session_count)
|
||||
bottom.addStretch()
|
||||
self.session_join_btn = QPushButton("Choose file && join")
|
||||
self.session_join_btn.setObjectName("lobbyPrimary")
|
||||
self.session_join_btn.setEnabled(False)
|
||||
self.session_join_btn.clicked.connect(self.join_selected_session)
|
||||
bottom.addWidget(self.session_join_btn)
|
||||
workspace.addLayout(bottom)
|
||||
self.sessions_list.itemDoubleClicked.connect(lambda _: self.join_selected_session())
|
||||
self.sessions_list.itemSelectionChanged.connect(self.check_inputs)
|
||||
body.addLayout(workspace, 1)
|
||||
layout.addLayout(body, 1)
|
||||
|
||||
self.create_dialog = self._make_dialog("Create a room", "Choose a local video, then invite your friends.")
|
||||
create = self.create_dialog.layout()
|
||||
self.create_file_btn = QPushButton("Choose video file")
|
||||
self.create_file_btn.clicked.connect(self.select_file)
|
||||
|
||||
create.addWidget(self.create_file_btn)
|
||||
self.create_file_info = QLabel("")
|
||||
self.create_file_info.setObjectName("fileInfo")
|
||||
self.create_file_info.setObjectName("lobbyFileInfo")
|
||||
self.create_file_info.setWordWrap(True)
|
||||
self.create_file_info.hide()
|
||||
|
||||
self.create_room_btn = QPushButton("Create Room")
|
||||
self.create_room_btn.setObjectName("primaryBtn")
|
||||
self.create_room_btn.setEnabled(False)
|
||||
create.addWidget(self.create_file_info)
|
||||
self.listed_checkbox = QCheckBox("List room in active sessions")
|
||||
self.listed_checkbox.setChecked(True)
|
||||
create.addWidget(self.listed_checkbox)
|
||||
hint = QLabel("Unlisted rooms can still be joined using their code.")
|
||||
hint.setObjectName("lobbyMuted")
|
||||
hint.setWordWrap(True)
|
||||
create.addWidget(hint)
|
||||
self.create_room_btn = QPushButton("Create room")
|
||||
self.create_room_btn.setObjectName("lobbyPrimary")
|
||||
self.create_room_btn.clicked.connect(self.create_room)
|
||||
|
||||
create_panel.addWidget(self.create_file_btn)
|
||||
create_panel.addWidget(self.create_file_info)
|
||||
create_panel.addWidget(self.create_room_btn)
|
||||
|
||||
# Join Room Panel
|
||||
join_panel = QVBoxLayout()
|
||||
join_panel.addWidget(QLabel("Join a Room"))
|
||||
self._dialog_actions(self.create_dialog, self.create_room_btn)
|
||||
|
||||
self.join_dialog = self._make_dialog("Join by code", "Enter the room code your friend shared.")
|
||||
self.room_code_input = RoomCodeInput()
|
||||
self.room_code_input.setPlaceholderText("e.g. ABC123")
|
||||
self.join_room_btn = QPushButton("Join Room")
|
||||
self.join_room_btn.setObjectName("secondaryBtn")
|
||||
self.join_room_btn.setEnabled(False)
|
||||
self.room_code_input.setAccessibleName("Room code")
|
||||
self.room_code_input.setMaxLength(8)
|
||||
self.room_code_input.returnPressed.connect(self._join_if_valid)
|
||||
self.join_dialog.layout().addWidget(self.room_code_input)
|
||||
self.join_room_btn = QPushButton("Join room")
|
||||
self.join_room_btn.setObjectName("lobbyPrimary")
|
||||
self.join_room_btn.clicked.connect(self.join_room)
|
||||
|
||||
join_panel.addWidget(self.room_code_input)
|
||||
join_panel.addWidget(self.join_room_btn)
|
||||
|
||||
actions_layout.addLayout(create_panel)
|
||||
|
||||
# Divider
|
||||
divider = QLabel("OR")
|
||||
divider.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
divider.setObjectName("divider")
|
||||
actions_layout.addWidget(divider)
|
||||
|
||||
actions_layout.addLayout(join_panel)
|
||||
|
||||
container_layout.addLayout(actions_layout)
|
||||
layout.addWidget(container)
|
||||
|
||||
# Signals to enable/disable buttons
|
||||
self._dialog_actions(self.join_dialog, self.join_room_btn)
|
||||
self.username_input.textChanged.connect(self.check_inputs)
|
||||
self.room_code_input.textChanged.connect(self.check_inputs)
|
||||
self._apply_lobby_style()
|
||||
self.check_inputs()
|
||||
|
||||
def _make_dialog(self, title, description):
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle(title)
|
||||
dialog.setObjectName("lobbyDialog")
|
||||
dialog.setModal(True)
|
||||
dialog.setMinimumWidth(420)
|
||||
layout = QVBoxLayout(dialog)
|
||||
layout.setContentsMargins(28, 28, 28, 28)
|
||||
layout.setSpacing(18)
|
||||
label = QLabel(title)
|
||||
label.setObjectName("dialogTitle")
|
||||
layout.addWidget(label)
|
||||
text = QLabel(description)
|
||||
text.setObjectName("lobbyMuted")
|
||||
text.setWordWrap(True)
|
||||
layout.addWidget(text)
|
||||
return dialog
|
||||
|
||||
def _dialog_actions(self, dialog, action):
|
||||
row = QHBoxLayout()
|
||||
row.addStretch()
|
||||
cancel = QPushButton("Cancel")
|
||||
cancel.clicked.connect(dialog.reject)
|
||||
row.addWidget(cancel)
|
||||
row.addWidget(action)
|
||||
dialog.layout().addLayout(row)
|
||||
|
||||
def open_create_dialog(self):
|
||||
if not self.username_input.text().strip():
|
||||
self.username_input.setFocus()
|
||||
return
|
||||
self.create_dialog.show()
|
||||
self.create_dialog.raise_()
|
||||
self.create_file_btn.setFocus()
|
||||
|
||||
def open_join_dialog(self):
|
||||
if not self.username_input.text().strip():
|
||||
self.username_input.setFocus()
|
||||
return
|
||||
self.join_dialog.show()
|
||||
self.join_dialog.raise_()
|
||||
self.room_code_input.setFocus()
|
||||
|
||||
def _join_if_valid(self):
|
||||
if self.join_room_btn.isEnabled():
|
||||
self.join_room()
|
||||
|
||||
def _apply_lobby_style(self):
|
||||
self.setStyleSheet("""
|
||||
QWidget#desktopLobby, QDialog#lobbyDialog { background: #10131b; }
|
||||
QLabel { color: #edf1fa; font-family: 'Segoe UI'; font-size: 13px; font-weight: normal; }
|
||||
QFrame#lobbyHeader { background: #191e29; border-bottom: 1px solid #2c3546; }
|
||||
QFrame#lobbySidebar { background: #191e29; border-right: 1px solid #2c3546; }
|
||||
QLabel#lobbyBrand { font-size: 20px; font-weight: 600; }
|
||||
QLabel#sidebarTitle, QLabel#dialogTitle { font-size: 23px; font-weight: 600; }
|
||||
QLabel#workspaceTitle { font-size: 28px; font-weight: 600; }
|
||||
QLabel#lobbyMuted { color: #9ca9c0; font-size: 12px; }
|
||||
QLabel#lobbyFileInfo { background: #29243f; color: #c6bcff; padding: 14px; border-radius: 7px; }
|
||||
QLineEdit { background: #10131b; color: #edf1fa; border: 1px solid #364157;
|
||||
border-radius: 7px; padding: 11px; font: 14px 'Segoe UI'; }
|
||||
QLineEdit:focus { border-color: #9b8bff; }
|
||||
QPushButton { background: #191e29; color: #edf1fa; border: 1px solid #364157;
|
||||
border-radius: 7px; padding: 11px 15px; font: 13px 'Segoe UI'; }
|
||||
QPushButton:hover { background: #242b3b; border-color: #9b8bff; }
|
||||
QPushButton#lobbyPrimary { background: #9b8bff; color: #141020; border: none; font-weight: 600; }
|
||||
QPushButton#lobbyPrimary:hover { background: #b1a4ff; }
|
||||
QPushButton:disabled, QPushButton#lobbyPrimary:disabled { background: #242b38; color: #738099; border-color: #2c3546; }
|
||||
QPushButton#lobbyQuiet { background: transparent; }
|
||||
QCheckBox { color: #edf1fa; spacing: 9px; font: 13px 'Segoe UI'; }
|
||||
QListWidget#lobbySessions { background: #191e29; color: #edf1fa;
|
||||
border: 1px solid #2c3546; border-radius: 9px; outline: none; font: 14px 'Segoe UI'; }
|
||||
QListWidget#lobbySessions::item { padding: 20px; border-bottom: 1px solid #2c3546; }
|
||||
QListWidget#lobbySessions::item:selected { background: #29243f; color: #edf1fa; }
|
||||
QListWidget#lobbySessions::item:hover { background: #222839; }
|
||||
QListWidget#lobbySessions:focus { border-color: #9b8bff; }
|
||||
""")
|
||||
|
||||
def _set_local_file(self, file_path: str):
|
||||
self.local_file_path = file_path
|
||||
self.local_file_name = os.path.basename(file_path)
|
||||
self.local_file_size = os.path.getsize(file_path)
|
||||
self.media_folder = os.path.dirname(file_path)
|
||||
self.file_selected.emit(file_path)
|
||||
|
||||
size_mb = self.local_file_size / (1024 * 1024)
|
||||
self.create_file_info.setText(f"{self.local_file_name}\n{size_mb:.1f} MB")
|
||||
@@ -134,7 +303,7 @@ class LobbyWidget(QWidget):
|
||||
|
||||
def select_file(self):
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select Video File", "", "Video Files (*.mp4 *.mkv *.avi *.mov *.webm);;All Files (*)"
|
||||
self, "Select Video File", self.media_folder, "Video Files (*.mp4 *.mkv *.avi *.mov *.webm);;All Files (*)"
|
||||
)
|
||||
if file_path:
|
||||
self._set_local_file(file_path)
|
||||
@@ -152,6 +321,7 @@ class LobbyWidget(QWidget):
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in ['.mp4', '.mkv', '.avi', '.mov', '.webm']:
|
||||
self._set_local_file(file_path)
|
||||
self.open_create_dialog()
|
||||
break
|
||||
|
||||
def check_inputs(self):
|
||||
@@ -159,10 +329,47 @@ class LobbyWidget(QWidget):
|
||||
has_file = self.local_file_path is not None
|
||||
has_code = len(self.room_code_input.text().strip()) >= 4
|
||||
|
||||
self.create_room_btn.setEnabled(has_name and has_file)
|
||||
self.join_room_btn.setEnabled(has_name and has_code)
|
||||
self.create_room_btn.setEnabled(has_name and has_file and not self._connecting)
|
||||
self.create_action_btn.setEnabled(has_name and not self._connecting)
|
||||
self.join_action_btn.setEnabled(has_name and not self._connecting)
|
||||
self.join_room_btn.setEnabled(has_name and has_code and not self._connecting)
|
||||
self.session_join_btn.setEnabled(has_name and self.sessions_list.currentItem() is not None and not self._connecting)
|
||||
|
||||
def update_sessions(self, rooms):
|
||||
selected = self.sessions_list.currentItem()
|
||||
code = selected.data(Qt.ItemDataRole.UserRole) if selected else None
|
||||
self.sessions_list.clear()
|
||||
for room in rooms:
|
||||
seconds = max(0, int(room.get("position", 0)))
|
||||
state = "Playing" if room.get("playing") else "Paused"
|
||||
users = room.get("users", [])
|
||||
item = QListWidgetItem(f"{room['fileInfo']['name']}\n"
|
||||
f"{state} · {seconds // 60}:{seconds % 60:02d} · {len(users)} connected\n"
|
||||
f"{', '.join(users)}")
|
||||
item.setSizeHint(QSize(0, 116))
|
||||
item.setToolTip(item.text())
|
||||
item.setData(Qt.ItemDataRole.UserRole, room['code'])
|
||||
self.sessions_list.addItem(item)
|
||||
if room['code'] == code:
|
||||
self.sessions_list.setCurrentItem(item)
|
||||
self.session_count.setText(f"{len(rooms)} room{'s' if len(rooms) != 1 else ''} available")
|
||||
self.sessions_list.setVisible(bool(rooms))
|
||||
self.sessions_status.setText("Select a session to join." if rooms else
|
||||
"No active sessions. Create a room to get started.")
|
||||
self.check_inputs()
|
||||
|
||||
def join_selected_session(self):
|
||||
item = self.sessions_list.currentItem()
|
||||
if item and self.username_input.text().strip():
|
||||
self.room_code_input.setText(item.data(Qt.ItemDataRole.UserRole))
|
||||
self.join_room()
|
||||
|
||||
def create_room(self):
|
||||
if not self.create_room_btn.isEnabled():
|
||||
return
|
||||
self._connecting = True
|
||||
self.create_dialog.accept()
|
||||
self.check_inputs()
|
||||
username = self.username_input.text().strip()
|
||||
self.create_room_btn.setText("Connecting...")
|
||||
self.create_room_btn.setEnabled(False)
|
||||
@@ -170,6 +377,11 @@ class LobbyWidget(QWidget):
|
||||
self.create_requested.emit(username, self.local_file_path, self.local_file_name, self.local_file_size)
|
||||
|
||||
def join_room(self):
|
||||
if not self.join_room_btn.isEnabled():
|
||||
return
|
||||
self._connecting = True
|
||||
self.join_dialog.accept()
|
||||
self.check_inputs()
|
||||
username = self.username_input.text().strip()
|
||||
room_code = self.room_code_input.text().strip().upper()
|
||||
self.join_room_btn.setText("Connecting...")
|
||||
@@ -178,8 +390,9 @@ class LobbyWidget(QWidget):
|
||||
self.join_requested.emit(username, room_code)
|
||||
|
||||
def reset_ui(self):
|
||||
self.create_room_btn.setText("Create Room")
|
||||
self.join_room_btn.setText("Join Room")
|
||||
self._connecting = False
|
||||
self.create_room_btn.setText("Create room")
|
||||
self.join_room_btn.setText("Join room")
|
||||
self.check_inputs()
|
||||
|
||||
def clear_file(self):
|
||||
|
||||
+111
-4
@@ -1,10 +1,12 @@
|
||||
import sys
|
||||
import os
|
||||
import ctypes
|
||||
import json
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QStackedWidget, QFileDialog, QMessageBox
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtCore import Qt, QTimer, QSettings, QUrl
|
||||
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||||
from PyQt6.QtGui import QIcon
|
||||
|
||||
from sync_client import SyncClientThread
|
||||
@@ -12,8 +14,9 @@ from lobby_widget import LobbyWidget
|
||||
from room_widget import RoomWidget
|
||||
|
||||
class VlcSyncApp(QMainWindow):
|
||||
def __init__(self):
|
||||
def __init__(self, settings=None):
|
||||
super().__init__()
|
||||
self.settings = settings if settings is not None else QSettings("VideoSync", "DesktopClient")
|
||||
self.setWindowTitle("VideoSync — Watch Together")
|
||||
|
||||
icon_path = os.path.join(os.path.dirname(__file__), "icon.png")
|
||||
@@ -40,6 +43,8 @@ class VlcSyncApp(QMainWindow):
|
||||
self.room_widget.leave_requested.connect(self._on_room_leave)
|
||||
self.room_widget.sync_action_requested.connect(self._on_room_sync_action)
|
||||
self.room_widget.chat_message_ready.connect(self._on_room_chat)
|
||||
self.room_widget.ready_action_requested.connect(lambda message: self.sync_client.send_message(message))
|
||||
self.room_widget.fight_action_requested.connect(lambda message: self.sync_client.send_message(message))
|
||||
|
||||
# App State
|
||||
self.username = ""
|
||||
@@ -55,6 +60,7 @@ class VlcSyncApp(QMainWindow):
|
||||
self.sync_client.connected.connect(lambda: self.room_widget.update_connection_status(True))
|
||||
self.sync_client.disconnected.connect(self.on_ws_disconnected)
|
||||
self.sync_client.disconnected.connect(lambda: self.room_widget.update_connection_status(False))
|
||||
self.sync_client.connection_error.connect(self.on_connection_error)
|
||||
self.sync_client.room_joined.connect(self.on_room_joined)
|
||||
self.sync_client.room_rejoined.connect(self.on_room_rejoined)
|
||||
self.sync_client.room_error.connect(self.on_room_error)
|
||||
@@ -63,9 +69,94 @@ class VlcSyncApp(QMainWindow):
|
||||
self.sync_client.chat_message.connect(self.room_widget.add_chat_message)
|
||||
self.sync_client.system_message.connect(self.room_widget.add_system_message)
|
||||
self.sync_client.sync_event.connect(self.room_widget.handle_sync_event)
|
||||
self.sync_client.ready_check_updated.connect(self.room_widget.update_ready_check)
|
||||
self.sync_client.fight_reaction.connect(self.room_widget.handle_fight_reaction)
|
||||
self.sync_client.fight_poll_updated.connect(self.room_widget.update_fight_poll)
|
||||
self.sync_client.latency_updated.connect(self.room_widget.update_latency)
|
||||
|
||||
self.apply_stylesheet()
|
||||
self._restore_preferences()
|
||||
self.lobby_widget.username_input.textChanged.connect(
|
||||
lambda name: self.settings.setValue("username", name))
|
||||
self.lobby_widget.file_selected.connect(self._remember_media_folder)
|
||||
self.room_widget.volume_slider.valueChanged.connect(
|
||||
lambda volume: self.settings.setValue("volume", volume))
|
||||
self.discovery_network = QNetworkAccessManager(self)
|
||||
self.discovery_reply = None
|
||||
self.discovery_timer = QTimer(self)
|
||||
self.discovery_timer.setInterval(15000)
|
||||
self.discovery_timer.timeout.connect(self.refresh_sessions)
|
||||
self.lobby_widget.refresh_requested.connect(self.refresh_sessions)
|
||||
self.discovery_timer.start()
|
||||
QTimer.singleShot(0, self.refresh_sessions)
|
||||
|
||||
def refresh_sessions(self):
|
||||
if self.stacked_widget.currentIndex() != 0 or self.discovery_reply is not None:
|
||||
return
|
||||
url = self.sync_client.url.replace("wss://", "https://").replace("ws://", "http://")
|
||||
request = QNetworkRequest(QUrl(url.rsplit("/", 1)[0] + "/api/rooms"))
|
||||
request.setTransferTimeout(8000)
|
||||
self.lobby_widget.refresh_btn.setEnabled(False)
|
||||
reply = self.discovery_network.get(request)
|
||||
self.discovery_reply = reply
|
||||
reply.finished.connect(lambda: self._finish_discovery(reply))
|
||||
|
||||
def _finish_discovery(self, reply):
|
||||
try:
|
||||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
raise ValueError("Discovery unavailable")
|
||||
rooms = json.loads(bytes(reply.readAll()))["rooms"]
|
||||
self.lobby_widget.update_sessions(rooms)
|
||||
except (ValueError, KeyError, TypeError, OverflowError):
|
||||
self.lobby_widget.sessions_list.clear()
|
||||
self.lobby_widget.sessions_list.hide()
|
||||
self.lobby_widget.session_count.setText("Join by code is still available.")
|
||||
self.lobby_widget.check_inputs()
|
||||
self.lobby_widget.sessions_status.setText("Couldn’t check for sessions. You can retry or join by code.")
|
||||
finally:
|
||||
self.discovery_reply = None
|
||||
self.lobby_widget.refresh_btn.setEnabled(True)
|
||||
reply.deleteLater()
|
||||
|
||||
def _restore_preferences(self):
|
||||
self.lobby_widget.username_input.setText(self.settings.value("username", "", type=str))
|
||||
folder = self.settings.value("media_folder", "", type=str)
|
||||
self.lobby_widget.media_folder = folder if os.path.isdir(folder) else ""
|
||||
volume = self.settings.value("volume", 100, type=int)
|
||||
self.room_widget.volume_slider.setValue(max(0, min(100, volume)))
|
||||
geometry = self.settings.value("window_geometry")
|
||||
if geometry is not None:
|
||||
self.restoreGeometry(geometry)
|
||||
width = max(200, min(800, self.settings.value("chat_width", 320, type=int)))
|
||||
self.room_widget._saved_chat_width = width
|
||||
self.room_widget.splitter.setSizes([max(100, self.width() - width), width])
|
||||
self.room_widget.chat_container.setVisible(self.settings.value("chat_visible", True, type=bool))
|
||||
if not self.settings.value("highlights_visible", True, type=bool):
|
||||
self.room_widget.tags_list.hide()
|
||||
self.room_widget.tags_header_btn.setText("▶ Highlights")
|
||||
|
||||
def _remember_media_folder(self, path):
|
||||
folder = os.path.dirname(path)
|
||||
self.lobby_widget.media_folder = folder
|
||||
self.settings.setValue("media_folder", folder)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.discovery_timer.stop()
|
||||
if self.discovery_reply is not None:
|
||||
self.discovery_reply.abort()
|
||||
room = self.room_widget
|
||||
self.settings.setValue("window_geometry", self.saveGeometry())
|
||||
width = (room.chat_container.width() if not room.popout_window and
|
||||
not room.chat_container.isHidden() else getattr(room, "_saved_chat_width", 320))
|
||||
self.settings.setValue("chat_width", width)
|
||||
self.settings.setValue("chat_visible", not room.chat_container.isHidden())
|
||||
self.settings.setValue("highlights_visible", not room.tags_list.isHidden())
|
||||
self.settings.sync()
|
||||
if room.popout_window:
|
||||
room.popout_window.close()
|
||||
self.sync_client.stop()
|
||||
room.cleanup()
|
||||
super().closeEvent(event)
|
||||
|
||||
def _on_lobby_create(self, username, file_path, file_name, file_size):
|
||||
self.username = username
|
||||
@@ -75,6 +166,7 @@ class VlcSyncApp(QMainWindow):
|
||||
|
||||
self.pending_connect_action = lambda: self.sync_client.send_message({
|
||||
"type": "create_room",
|
||||
"listed": self.lobby_widget.listed_checkbox.isChecked(),
|
||||
"username": self.username,
|
||||
"fileInfo": {
|
||||
"name": self.local_file_name,
|
||||
@@ -109,6 +201,8 @@ class VlcSyncApp(QMainWindow):
|
||||
self.lobby_widget.clear_file()
|
||||
self.lobby_widget.reset_ui()
|
||||
|
||||
self.refresh_sessions()
|
||||
|
||||
def _on_room_sync_action(self, action, position_s, req_id):
|
||||
self.sync_client.send_message({"type": "sync", "action": action, "position": position_s, "req_id": req_id})
|
||||
|
||||
@@ -135,6 +229,12 @@ class VlcSyncApp(QMainWindow):
|
||||
self.lobby_widget.reset_ui()
|
||||
self.pending_connect_action = None
|
||||
|
||||
def on_connection_error(self, msg: str):
|
||||
QMessageBox.critical(self, "Secure Connection Error", msg)
|
||||
self.pending_connect_action = None
|
||||
self.lobby_widget.reset_ui()
|
||||
self.sync_client.stop()
|
||||
|
||||
def on_room_error(self, msg: str):
|
||||
QMessageBox.critical(self, "Room Error", msg)
|
||||
self.lobby_widget.reset_ui()
|
||||
@@ -152,10 +252,11 @@ class VlcSyncApp(QMainWindow):
|
||||
ext = os.path.splitext(req_name)[1].lower()
|
||||
file_filter = f"Video Files (*{ext});;All Files (*)" if ext else "All Files (*)"
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, f"Select {req_name}", "", file_filter
|
||||
self, f"Select {req_name}", self.lobby_widget.media_folder, file_filter
|
||||
)
|
||||
|
||||
if file_path:
|
||||
self._remember_media_folder(file_path)
|
||||
self.local_file_path = file_path
|
||||
self.local_file_name = os.path.basename(file_path)
|
||||
self.local_file_size = os.path.getsize(file_path)
|
||||
@@ -205,6 +306,9 @@ class VlcSyncApp(QMainWindow):
|
||||
self.room_widget.handle_sync_event(state)
|
||||
|
||||
self.room_widget.add_system_message("Welcome to the room! 👋")
|
||||
self.room_widget.finish_chat_history()
|
||||
self.room_widget.update_ready_check(msg.get("readyCheck") or {})
|
||||
self.room_widget.update_fight_poll(msg.get("fightPoll") or {})
|
||||
|
||||
def on_room_rejoined(self, msg: dict):
|
||||
self.room_widget.set_room_code_display(self.room_code)
|
||||
@@ -225,6 +329,10 @@ class VlcSyncApp(QMainWindow):
|
||||
if state:
|
||||
self.room_widget.handle_sync_event(state)
|
||||
|
||||
self.room_widget.finish_chat_history()
|
||||
self.room_widget.update_ready_check(msg.get("readyCheck") or {})
|
||||
self.room_widget.update_fight_poll(msg.get("fightPoll") or {})
|
||||
|
||||
def apply_stylesheet(self):
|
||||
self.setStyleSheet("""
|
||||
QWidget {
|
||||
@@ -493,4 +601,3 @@ if __name__ == "__main__":
|
||||
window = VlcSyncApp()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"certifi>=2026.6.17",
|
||||
"pillow>=12.1.1",
|
||||
"pyinstaller>=6.19.0",
|
||||
"pyqt6>=6.10.2",
|
||||
|
||||
+296
-11
@@ -2,13 +2,15 @@ import os
|
||||
import datetime
|
||||
import html
|
||||
import re
|
||||
import time
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QFrame, QTextEdit, QTextBrowser, QApplication, QSplitter,
|
||||
QScrollArea
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QEvent
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QEvent, QStandardPaths, QUrl
|
||||
from PyQt6.QtGui import QIcon, QCursor
|
||||
from PyQt6.QtMultimedia import QSoundEffect
|
||||
|
||||
import uuid
|
||||
import urllib.parse
|
||||
@@ -18,6 +20,7 @@ from utils import format_time_hms, format_time_short
|
||||
from chat_widgets import ChatBubble, SystemMessageWidget, ChatPopoutWindow
|
||||
from custom_widgets import ClickableSlider
|
||||
from commands import parse_time_arg, handle_chat_command
|
||||
from fight_reactions import REACTIONS, RingsideStrip, write_reaction_sound
|
||||
|
||||
|
||||
class ExpectedVlcEvent:
|
||||
@@ -31,6 +34,8 @@ class RoomWidget(QWidget):
|
||||
leave_requested = pyqtSignal()
|
||||
sync_action_requested = pyqtSignal(str, float, str) # action, position_s, req_id
|
||||
chat_message_ready = pyqtSignal(str) # raw message text
|
||||
ready_action_requested = pyqtSignal(dict)
|
||||
fight_action_requested = pyqtSignal(dict)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -43,8 +48,23 @@ class RoomWidget(QWidget):
|
||||
self._is_first_user_update = True
|
||||
|
||||
self.popout_window = None
|
||||
self._follow_chat = True
|
||||
self._pending_chat_messages = 0
|
||||
self._unread_divider = None
|
||||
self._ready_check = {}
|
||||
self._connected = True
|
||||
self._fight_poll = {}
|
||||
self._reaction_sounds = {}
|
||||
self._fight_poll_timer = QTimer(self)
|
||||
self._fight_poll_timer.setInterval(1000)
|
||||
self._fight_poll_timer.timeout.connect(self._refresh_fight_poll)
|
||||
self._chat_scroll_timer = QTimer(self)
|
||||
self._chat_scroll_timer.setSingleShot(True)
|
||||
self._chat_scroll_timer.timeout.connect(self._apply_chat_scroll)
|
||||
|
||||
self._setup_ui()
|
||||
self._unread_messages = 0
|
||||
QApplication.instance().focusChanged.connect(self._on_chat_focus_changed)
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
@@ -108,6 +128,7 @@ class RoomWidget(QWidget):
|
||||
self.video_frame = QFrame()
|
||||
self.video_frame.setStyleSheet("background-color: black;")
|
||||
self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
|
||||
self.ringside_strip = RingsideStrip(self.video_frame)
|
||||
|
||||
# Controls Bar
|
||||
self.controls_bar = QFrame()
|
||||
@@ -174,7 +195,7 @@ class RoomWidget(QWidget):
|
||||
self.chat_container.setMaximumWidth(500)
|
||||
chat_layout = QVBoxLayout(self.chat_container)
|
||||
|
||||
chat_header = QLabel("Live Chat")
|
||||
chat_header = self.chat_header = QLabel("Live Chat")
|
||||
chat_header.setObjectName("chatHeader")
|
||||
|
||||
self.popout_btn = QPushButton("↗")
|
||||
@@ -190,6 +211,30 @@ class RoomWidget(QWidget):
|
||||
|
||||
self.users_lbl = QLabel("0 watching")
|
||||
self.users_lbl.setObjectName("usersLbl")
|
||||
|
||||
self.ready_panel = QFrame()
|
||||
ready_layout = QVBoxLayout(self.ready_panel)
|
||||
ready_layout.setContentsMargins(0, 6, 0, 6)
|
||||
self.ready_title = QLabel()
|
||||
self.ready_title.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.ready_title.setWordWrap(True)
|
||||
self.ready_users = QLabel()
|
||||
self.ready_users.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.ready_users.setWordWrap(True)
|
||||
ready_layout.addWidget(self.ready_title)
|
||||
ready_layout.addWidget(self.ready_users)
|
||||
ready_buttons = QHBoxLayout()
|
||||
self.ready_btn = QPushButton("I'm ready")
|
||||
self.ready_start_btn = QPushButton("Start together")
|
||||
self.ready_cancel_btn = QPushButton("Cancel")
|
||||
for button in (self.ready_btn, self.ready_cancel_btn):
|
||||
ready_buttons.addWidget(button)
|
||||
ready_layout.addLayout(ready_buttons)
|
||||
ready_layout.addWidget(self.ready_start_btn)
|
||||
self.ready_btn.clicked.connect(self._vote_ready)
|
||||
self.ready_start_btn.clicked.connect(lambda: self._send_ready_action("start"))
|
||||
self.ready_cancel_btn.clicked.connect(lambda: self._send_ready_action("cancel"))
|
||||
self.ready_panel.hide()
|
||||
|
||||
# Tags Container (Hidden by default)
|
||||
self.tags_container = QFrame()
|
||||
@@ -250,7 +295,44 @@ class RoomWidget(QWidget):
|
||||
self.chat_content_layout.setSpacing(0)
|
||||
|
||||
self.chat_messages.setWidget(self.chat_content)
|
||||
self.chat_messages.verticalScrollBar().valueChanged.connect(self._on_chat_scroll)
|
||||
self.chat_messages.verticalScrollBar().rangeChanged.connect(self._on_chat_range_changed)
|
||||
self.chat_messages.viewport().installEventFilter(self)
|
||||
self.chat_messages.verticalScrollBar().installEventFilter(self)
|
||||
self.chat_messages.toPlainText = self.toPlainText # For test compatibility
|
||||
|
||||
self.chat_jump_btn = QPushButton()
|
||||
self.chat_jump_btn.hide()
|
||||
self.chat_jump_btn.clicked.connect(self.jump_to_latest)
|
||||
|
||||
self.fight_poll_panel = QFrame()
|
||||
poll_layout = QVBoxLayout(self.fight_poll_panel)
|
||||
poll_layout.setContentsMargins(0, 6, 0, 6)
|
||||
poll_header = QHBoxLayout()
|
||||
self.fight_poll_title = QLabel("Was that a robbery?")
|
||||
self.fight_poll_title.setWordWrap(True)
|
||||
poll_header.addWidget(self.fight_poll_title, 1)
|
||||
poll_dismiss = self.fight_poll_dismiss_btn = QPushButton("×")
|
||||
poll_dismiss.setObjectName("iconBtn")
|
||||
poll_dismiss.setToolTip("Dismiss this vote")
|
||||
poll_dismiss.setFixedSize(28, 28)
|
||||
poll_dismiss.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
poll_dismiss.clicked.connect(self.fight_poll_panel.hide)
|
||||
poll_header.addWidget(poll_dismiss)
|
||||
poll_layout.addLayout(poll_header)
|
||||
self.fight_poll_result = QLabel()
|
||||
self.fight_poll_result.setWordWrap(True)
|
||||
poll_layout.addWidget(self.fight_poll_result)
|
||||
poll_buttons = QVBoxLayout()
|
||||
self.fight_yes_btn = QPushButton("Absolutely")
|
||||
self.fight_no_btn = QPushButton("Fair decision")
|
||||
for button in (self.fight_yes_btn, self.fight_no_btn):
|
||||
button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
poll_buttons.addWidget(button)
|
||||
poll_layout.addLayout(poll_buttons)
|
||||
self.fight_yes_btn.clicked.connect(lambda: self._vote_fight_poll("yes"))
|
||||
self.fight_no_btn.clicked.connect(lambda: self._vote_fight_poll("no"))
|
||||
self.fight_poll_panel.hide()
|
||||
|
||||
|
||||
chat_input_layout = QHBoxLayout()
|
||||
@@ -267,8 +349,11 @@ class RoomWidget(QWidget):
|
||||
|
||||
chat_layout.addLayout(header_layout)
|
||||
chat_layout.addWidget(self.users_lbl)
|
||||
chat_layout.addWidget(self.ready_panel)
|
||||
chat_layout.addWidget(self.tags_container)
|
||||
chat_layout.addWidget(self.chat_messages, 1)
|
||||
chat_layout.addWidget(self.chat_jump_btn)
|
||||
chat_layout.addWidget(self.fight_poll_panel)
|
||||
chat_layout.addLayout(chat_input_layout)
|
||||
|
||||
self.splitter.addWidget(video_container)
|
||||
@@ -284,6 +369,8 @@ class RoomWidget(QWidget):
|
||||
self.chat_send_btn, self.seekbar, self.volume_slider, self.tags_header_btn,
|
||||
self.chat_toggle_btn, self.popout_btn]:
|
||||
w.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
for button in (self.ready_btn, self.ready_start_btn, self.ready_cancel_btn, self.chat_jump_btn):
|
||||
button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
# scroll area handles focus properly
|
||||
self.chat_messages.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||||
@@ -323,8 +410,10 @@ class RoomWidget(QWidget):
|
||||
self.room_file_badge.setText(f"📄 {file_name}")
|
||||
|
||||
# Clear chat
|
||||
for i in reversed(range(self.chat_content_layout.count())):
|
||||
self.chat_content_layout.itemAt(i).widget().setParent(None)
|
||||
self._clear_chat_alert()
|
||||
self.clear_chat()
|
||||
self.update_ready_check({})
|
||||
self.reset_fight_reactions()
|
||||
|
||||
|
||||
self.current_users = []
|
||||
@@ -335,6 +424,10 @@ class RoomWidget(QWidget):
|
||||
self.vlc_player.set_volume(self.volume_slider.value())
|
||||
|
||||
def cleanup(self):
|
||||
self._clear_chat_alert()
|
||||
self.clear_chat()
|
||||
self.update_ready_check({})
|
||||
self.reset_fight_reactions()
|
||||
self.vlc_player.stop()
|
||||
self.current_users = []
|
||||
self._is_first_user_update = True
|
||||
@@ -343,6 +436,13 @@ class RoomWidget(QWidget):
|
||||
self.room_code_display.setText(f"Room: {text}")
|
||||
|
||||
def update_connection_status(self, connected: bool):
|
||||
self._connected = connected
|
||||
self.ready_btn.setEnabled(connected)
|
||||
self.ready_start_btn.setEnabled(connected and self._can_start_ready())
|
||||
self.ready_cancel_btn.setEnabled(connected)
|
||||
self._refresh_fight_poll()
|
||||
if not connected:
|
||||
self.ringside_strip.dismiss()
|
||||
if connected:
|
||||
self.status_dot.setStyleSheet("color: #4BB543; font-size: 14px; background: transparent;")
|
||||
self.status_dot.setToolTip("Connected")
|
||||
@@ -723,13 +823,20 @@ class RoomWidget(QWidget):
|
||||
time_str = dt.strftime("%I:%M %p")
|
||||
|
||||
safe_text = html.escape(text)
|
||||
safe_author = html.escape(author)
|
||||
|
||||
|
||||
# Regex to find timestamps like 1:23 or 01:23:45
|
||||
pattern = r'\b(\d{1,2}:\d{2}(?::\d{2})?)\b'
|
||||
linked_text = re.sub(pattern, r'<a href="seek:\1" style="color: #66b3ff; text-decoration: none;">\1</a>', safe_text)
|
||||
linked_text = linked_text.replace('\n', '<br>')
|
||||
|
||||
is_self = author == self.username
|
||||
if not is_self and not self._chat_has_focus():
|
||||
self._unread_messages += 1
|
||||
self.chat_header.setText(f"Live Chat • {self._unread_messages} new")
|
||||
self.chat_toggle_btn.setText("💬•")
|
||||
self.chat_toggle_btn.setToolTip(f"{self._unread_messages} new chat messages")
|
||||
self.chat_toggle_btn.setStyleSheet("color: #66b3ff; border: 1px solid #66b3ff; border-radius: 6px;")
|
||||
QApplication.alert(self.chat_container.window(), 3000)
|
||||
|
||||
# Auto-extract tags added by users directly via /tag command
|
||||
# We look for the pattern generated by /tag: "MM:SS tag text"
|
||||
@@ -740,8 +847,39 @@ class RoomWidget(QWidget):
|
||||
return # Don't show this as a chat message bubble
|
||||
|
||||
bubble = ChatBubble(author, linked_text, time_str, is_self, self._handle_link_from_bubble)
|
||||
if not self._follow_chat and not is_self:
|
||||
if self._pending_chat_messages == 0:
|
||||
if self._unread_divider is not None:
|
||||
self.chat_content_layout.removeWidget(self._unread_divider)
|
||||
self._unread_divider.deleteLater()
|
||||
self._unread_divider = QLabel("──── New messages ────")
|
||||
self._unread_divider.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._unread_divider.setFixedHeight(28)
|
||||
self._unread_divider.setStyleSheet("color: #66b3ff; background: transparent;")
|
||||
self.chat_content_layout.addWidget(self._unread_divider)
|
||||
self._pending_chat_messages += 1
|
||||
self.chat_jump_btn.setText(f"↓ {self._pending_chat_messages} new messages · Jump to latest")
|
||||
self.chat_jump_btn.show()
|
||||
self.chat_content_layout.addWidget(bubble)
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def _chat_has_focus(self):
|
||||
focus = QApplication.focusWidget()
|
||||
return (self.chat_container.isVisible()
|
||||
and self.chat_container.window().isActiveWindow()
|
||||
and focus is not None
|
||||
and (focus is self.chat_container or self.chat_container.isAncestorOf(focus)))
|
||||
|
||||
def _on_chat_focus_changed(self, old, new):
|
||||
if self._chat_has_focus() and self._follow_chat:
|
||||
self._clear_chat_alert()
|
||||
|
||||
def _clear_chat_alert(self):
|
||||
self._unread_messages = 0
|
||||
self.chat_header.setText("Live Chat")
|
||||
self.chat_toggle_btn.setText("💬")
|
||||
self.chat_toggle_btn.setToolTip("Toggle Chat")
|
||||
self.chat_toggle_btn.setStyleSheet("")
|
||||
|
||||
def _handle_link_from_bubble(self, url_str):
|
||||
if url_str.startswith("seek:"):
|
||||
@@ -749,10 +887,150 @@ class RoomWidget(QWidget):
|
||||
self._handle_seek_command(time_str)
|
||||
|
||||
def _scroll_to_bottom(self):
|
||||
# Allow layout to finish before scrolling
|
||||
QTimer.singleShot(10, lambda: self.chat_messages.verticalScrollBar().setValue(
|
||||
self.chat_messages.verticalScrollBar().maximum()
|
||||
))
|
||||
if self._follow_chat:
|
||||
self._chat_scroll_timer.start(10)
|
||||
|
||||
def eventFilter(self, watched, event):
|
||||
if event.type() in (QEvent.Type.Wheel, QEvent.Type.MouseButtonPress, QEvent.Type.KeyPress):
|
||||
# User scrolling takes precedence over an already queued tail update.
|
||||
self._chat_scroll_timer.stop()
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def _apply_chat_scroll(self):
|
||||
if self._follow_chat:
|
||||
bar = self.chat_messages.verticalScrollBar()
|
||||
bar.setValue(bar.maximum())
|
||||
|
||||
def _on_chat_range_changed(self, minimum, maximum):
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def _on_chat_scroll(self, value):
|
||||
if self._chat_scroll_timer.isActive():
|
||||
return
|
||||
self._follow_chat = self.chat_messages.verticalScrollBar().maximum() - value <= 24
|
||||
if self._follow_chat:
|
||||
self._pending_chat_messages = 0
|
||||
self.chat_jump_btn.hide()
|
||||
if self._chat_has_focus():
|
||||
self._clear_chat_alert()
|
||||
|
||||
def jump_to_latest(self):
|
||||
self._follow_chat = True
|
||||
self._pending_chat_messages = 0
|
||||
self.chat_jump_btn.hide()
|
||||
self._clear_chat_alert()
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def finish_chat_history(self):
|
||||
self.jump_to_latest()
|
||||
|
||||
def _can_start_ready(self):
|
||||
participants = self._ready_check.get("participants", [])
|
||||
return bool(participants) and all(p.get("ready") and p.get("connected") for p in participants)
|
||||
|
||||
def update_ready_check(self, state):
|
||||
new_check = state.get("active") and state.get("id") != self._ready_check.get("id")
|
||||
self._ready_check = state if state.get("active") else {}
|
||||
if not self._ready_check:
|
||||
self.ready_panel.hide()
|
||||
return
|
||||
participants = state.get("participants", [])
|
||||
ready_count = sum(bool(p.get("ready") and p.get("connected")) for p in participants)
|
||||
self.ready_title.setText(f"Ready check · {ready_count} of {len(participants)} ready\nStarted by {state.get('requestedBy', '')}")
|
||||
self.ready_users.setText("\n".join(
|
||||
f"{p['username']}: " + ("Reconnecting…" if not p.get('connected') else "✓ Ready" if p.get('ready') else "Not ready")
|
||||
for p in participants))
|
||||
mine = next((p for p in participants if p['username'] == self.username), {})
|
||||
self.ready_btn.setText("Not ready" if mine.get("ready") else "I'm ready")
|
||||
initiator = state.get("requestedBy") == self.username
|
||||
self.ready_start_btn.setVisible(initiator)
|
||||
self.ready_cancel_btn.setVisible(initiator)
|
||||
self.ready_btn.setEnabled(self._connected)
|
||||
self.ready_cancel_btn.setEnabled(self._connected)
|
||||
self.ready_start_btn.setEnabled(self._connected and self._can_start_ready())
|
||||
self.ready_panel.show()
|
||||
if new_check:
|
||||
self.add_system_message(f"<span>{html.escape(state.get('requestedBy', 'Someone'))} started a ready check.</span>")
|
||||
if not self._chat_has_focus():
|
||||
self.chat_toggle_btn.setText("💬•")
|
||||
self.chat_toggle_btn.setToolTip("A ready check is waiting")
|
||||
self.chat_toggle_btn.setStyleSheet("color: #66b3ff; border: 1px solid #66b3ff; border-radius: 6px;")
|
||||
QApplication.alert(self.chat_container.window(), 3000)
|
||||
|
||||
def _send_ready_action(self, action, **extra):
|
||||
if self._ready_check:
|
||||
self.ready_action_requested.emit({"type": "ready_check", "action": action,
|
||||
"id": self._ready_check['id'], **extra})
|
||||
|
||||
def _vote_ready(self):
|
||||
mine = next((p for p in self._ready_check.get("participants", []) if p['username'] == self.username), {})
|
||||
self._send_ready_action("vote", ready=not mine.get("ready", False))
|
||||
|
||||
def handle_fight_reaction(self, message):
|
||||
kind = message.get("kind")
|
||||
if kind not in REACTIONS:
|
||||
return
|
||||
position = format_time_short(int(message.get("position", 0) * 1000))
|
||||
shown = self.ringside_strip.show_reaction(kind, message.get("username", "Someone"), position)
|
||||
if shown:
|
||||
self._play_reaction_sound(kind)
|
||||
|
||||
def _play_reaction_sound(self, kind):
|
||||
try:
|
||||
if kind not in self._reaction_sounds:
|
||||
cache = os.path.join(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.GenericCacheLocation),
|
||||
"VideoSync", "reaction-sounds-v1")
|
||||
path = write_reaction_sound(cache, kind)
|
||||
sound = QSoundEffect(self)
|
||||
sound.setVolume(.18)
|
||||
sound.setSource(QUrl.fromLocalFile(str(path)))
|
||||
self._reaction_sounds[kind] = sound
|
||||
self._reaction_sounds[kind].play()
|
||||
except OSError:
|
||||
# A read-only cache must not prevent the visual reaction.
|
||||
pass
|
||||
|
||||
def update_fight_poll(self, state):
|
||||
previous_id = self._fight_poll.get("id")
|
||||
self._fight_poll = state if state.get("id") else {}
|
||||
if not self._fight_poll:
|
||||
self._fight_poll_timer.stop()
|
||||
self.fight_poll_panel.hide()
|
||||
return
|
||||
if previous_id != state.get("id"):
|
||||
self.fight_poll_panel.show()
|
||||
self._refresh_fight_poll()
|
||||
if state.get("active") and state.get("expiresAt", 0) > time.time() * 1000:
|
||||
self._fight_poll_timer.start()
|
||||
|
||||
def _refresh_fight_poll(self):
|
||||
if not self._fight_poll:
|
||||
return
|
||||
remaining = max(0, int((self._fight_poll.get("expiresAt", 0) - time.time() * 1000 + 999) / 1000))
|
||||
active = self._fight_poll.get("active", False) and remaining > 0
|
||||
if not active:
|
||||
self._fight_poll_timer.stop()
|
||||
votes = self._fight_poll.get("votes", {})
|
||||
yes = sum(choice == "yes" for choice in votes.values())
|
||||
no = sum(choice == "no" for choice in votes.values())
|
||||
status = f"{remaining}s left" if active else "Vote closed"
|
||||
mine = votes.get(self.username)
|
||||
self.fight_poll_title.setText(f"Was that a robbery? · {status}")
|
||||
self.fight_poll_result.setText(f"Absolutely {yes} · Fair decision {no}" +
|
||||
(" · Your vote is in" if mine else ""))
|
||||
for button, choice in ((self.fight_yes_btn, "yes"), (self.fight_no_btn, "no")):
|
||||
button.setEnabled(bool(active and self._connected))
|
||||
button.setStyleSheet("border: 1px solid #66b3ff;" if mine == choice else "")
|
||||
|
||||
def _vote_fight_poll(self, choice):
|
||||
if self._fight_poll:
|
||||
self.fight_action_requested.emit({"type": "fight_poll_vote", "id": self._fight_poll['id'], "choice": choice})
|
||||
|
||||
def reset_fight_reactions(self):
|
||||
self.ringside_strip.dismiss()
|
||||
self.update_fight_poll({})
|
||||
for sound in self._reaction_sounds.values():
|
||||
sound.stop()
|
||||
|
||||
def toPlainText(self):
|
||||
# For compatibility with existing tests
|
||||
@@ -782,10 +1060,17 @@ class RoomWidget(QWidget):
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def clear_chat(self):
|
||||
self._clear_chat_alert()
|
||||
self._chat_scroll_timer.stop()
|
||||
self._follow_chat = True
|
||||
self._pending_chat_messages = 0
|
||||
self._unread_divider = None
|
||||
self.chat_jump_btn.hide()
|
||||
for i in reversed(range(self.chat_content_layout.count())):
|
||||
item = self.chat_content_layout.itemAt(i)
|
||||
if item.widget():
|
||||
item.widget().setParent(None)
|
||||
item.widget().deleteLater()
|
||||
self.chat_content_layout.removeWidget(item.widget())
|
||||
|
||||
def send_chat(self):
|
||||
text = self.chat_input.text().strip()
|
||||
|
||||
@@ -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)
|
||||
@@ -1,22 +1,30 @@
|
||||
import asyncio
|
||||
import json
|
||||
import ssl
|
||||
import time
|
||||
|
||||
import certifi
|
||||
import websockets
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
|
||||
class SyncClientThread(QThread):
|
||||
# Signals from WebSocket -> PyQt UI
|
||||
connected = pyqtSignal()
|
||||
disconnected = pyqtSignal()
|
||||
connection_error = pyqtSignal(str)
|
||||
room_joined = pyqtSignal(dict)
|
||||
room_rejoined = pyqtSignal(dict)
|
||||
room_error = pyqtSignal(str)
|
||||
file_check_needed = pyqtSignal(dict) # msg
|
||||
file_check_needed = pyqtSignal(dict) # msg
|
||||
users_updated = pyqtSignal(list)
|
||||
chat_message = pyqtSignal(str, str, int) # author, text, timestamp
|
||||
chat_message = pyqtSignal(str, str, int) # author, text, timestamp
|
||||
system_message = pyqtSignal(str)
|
||||
sync_event = pyqtSignal(dict)
|
||||
latency_updated = pyqtSignal(int) # latency in ms
|
||||
ready_check_updated = pyqtSignal(dict)
|
||||
fight_reaction = pyqtSignal(dict)
|
||||
fight_poll_updated = pyqtSignal(dict)
|
||||
latency_updated = pyqtSignal(int) # latency in ms
|
||||
|
||||
def __init__(self, url="ws://localhost:3000/ws"):
|
||||
super().__init__()
|
||||
@@ -26,13 +34,31 @@ class SyncClientThread(QThread):
|
||||
self.running = False
|
||||
self._ping_sent_at = 0
|
||||
|
||||
@staticmethod
|
||||
def _create_ssl_context():
|
||||
"""Build a verified TLS context from the CA bundle shipped with the app."""
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
@staticmethod
|
||||
def _certificate_error(exc):
|
||||
"""Return a nested certificate verification error, if present."""
|
||||
while exc is not None:
|
||||
if isinstance(exc, ssl.SSLCertVerificationError):
|
||||
return exc
|
||||
exc = exc.__cause__ or exc.__context__
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
"""Runs strictly within the newly created QThread"""
|
||||
self.running = True
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.loop.run_until_complete(self._connect_and_listen())
|
||||
self.loop.close()
|
||||
try:
|
||||
self.loop.run_until_complete(self._connect_and_listen())
|
||||
finally:
|
||||
self.ws = None
|
||||
self.running = False
|
||||
self.loop.close()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
@@ -61,12 +87,17 @@ class SyncClientThread(QThread):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _connect_and_listen(self):
|
||||
ssl_context = (
|
||||
self._create_ssl_context() if self.url.startswith("wss://") else None
|
||||
)
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
async with websockets.connect(self.url) as ws:
|
||||
connect_options = {"ssl": ssl_context} if ssl_context else {}
|
||||
async with websockets.connect(self.url, **connect_options) as ws:
|
||||
self.ws = ws
|
||||
self.connected.emit()
|
||||
|
||||
|
||||
ping_task = asyncio.create_task(self._ping_loop(ws))
|
||||
try:
|
||||
async for message in ws:
|
||||
@@ -77,38 +108,72 @@ class SyncClientThread(QThread):
|
||||
pass
|
||||
finally:
|
||||
ping_task.cancel()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
certificate_error = self._certificate_error(e)
|
||||
if certificate_error:
|
||||
verification_reason = getattr(
|
||||
certificate_error, "verify_message", str(certificate_error)
|
||||
)
|
||||
message = (
|
||||
"Secure connection failed because the server certificate could not be "
|
||||
f"verified ({verification_reason}). "
|
||||
"Check that your computer's date and time are correct, then update or "
|
||||
"restart VideoSync."
|
||||
)
|
||||
print(f"WebSocket TLS Error: {certificate_error}")
|
||||
self.connection_error.emit(message)
|
||||
self.running = False
|
||||
break
|
||||
print(f"WebSocket Error: {e}")
|
||||
|
||||
self.disconnected.emit()
|
||||
|
||||
finally:
|
||||
self.ws = None
|
||||
self.disconnected.emit()
|
||||
|
||||
if self.running:
|
||||
# Reconnect backoff
|
||||
await asyncio.sleep(2)
|
||||
|
||||
def _handle_message(self, msg: dict):
|
||||
t = msg.get("type")
|
||||
|
||||
|
||||
if t == "room_created":
|
||||
self.room_joined.emit(msg)
|
||||
|
||||
|
||||
elif t == "room_joined":
|
||||
self.room_joined.emit(msg)
|
||||
|
||||
|
||||
elif t == "room_rejoined":
|
||||
self.room_rejoined.emit(msg)
|
||||
|
||||
|
||||
elif t == "error":
|
||||
self.room_error.emit(msg.get("message", "Unknown error"))
|
||||
|
||||
|
||||
elif t == "room_file_check":
|
||||
self.file_check_needed.emit(msg)
|
||||
|
||||
elif t in ["user_joined", "user_left"]:
|
||||
|
||||
elif t in ["user_joined", "user_left", "user_rejoined"]:
|
||||
self.users_updated.emit(msg.get("users", []))
|
||||
|
||||
|
||||
elif t == "chat":
|
||||
self.chat_message.emit(msg.get("username", "Unknown"), msg.get("message", ""), msg.get("timestamp", 0))
|
||||
|
||||
self.chat_message.emit(
|
||||
msg.get("username", "Unknown"),
|
||||
msg.get("message", ""),
|
||||
msg.get("timestamp", 0),
|
||||
)
|
||||
|
||||
elif t == "sync":
|
||||
self.sync_event.emit(msg)
|
||||
|
||||
elif t == "ready_check":
|
||||
self.ready_check_updated.emit(msg)
|
||||
|
||||
elif t == "reaction":
|
||||
self.fight_reaction.emit(msg)
|
||||
|
||||
elif t == "fight_poll":
|
||||
self.fight_poll_updated.emit(msg)
|
||||
|
||||
elif t == "reaction_error":
|
||||
self.system_message.emit(msg.get("message", "Reaction unavailable"))
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
from PyQt6.QtGui import QTextDocument
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
import room_widget
|
||||
|
||||
|
||||
class PlayerSignals(QObject):
|
||||
time_changed = pyqtSignal(int)
|
||||
state_changed = pyqtSignal(bool, int)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def room(qtbot, monkeypatch):
|
||||
player = Mock(signals=PlayerSignals())
|
||||
monkeypatch.setattr(room_widget, 'VLCSyncPlayer', lambda frame: player)
|
||||
monkeypatch.setattr(room_widget.RoomWidget, '_play_reaction_sound', Mock())
|
||||
widget = room_widget.RoomWidget()
|
||||
widget.username = 'Me'
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1050, 500)
|
||||
widget.show()
|
||||
return widget
|
||||
|
||||
|
||||
@pytest.mark.parametrize('text', ["It's someone's & my <message>",
|
||||
"It's at 1:23 & <b>literal</b>",
|
||||
'line one\nline two', 'literal ' entity'])
|
||||
def test_message_rendering(room, text):
|
||||
room.add_chat_message("<b>O'Brien</b>", text, 0)
|
||||
bubble = room.chat_content_layout.itemAt(0).widget()
|
||||
doc = QTextDocument()
|
||||
doc.setHtml(bubble.text_lbl.text())
|
||||
assert doc.toPlainText() == text
|
||||
if '1:23' in text:
|
||||
assert 'href="seek:1:23"' in bubble.text_lbl.text()
|
||||
|
||||
|
||||
def test_bubbles_do_not_shrink_or_clip_when_overflowing(room, qtbot):
|
||||
text = 'A wrapped message with several words. ' * 8
|
||||
room.add_chat_message('Other', text, 0)
|
||||
qtbot.wait(100)
|
||||
first = room.chat_content_layout.itemAt(0).widget()
|
||||
initial_height = first.height()
|
||||
for i in range(35):
|
||||
room.add_chat_message('Me' if i % 2 else 'Other', text, 0)
|
||||
qtbot.wait(200)
|
||||
assert room.chat_messages.verticalScrollBar().maximum() > 0
|
||||
assert first.height() >= initial_height
|
||||
for width in (220, 450, 280):
|
||||
room.splitter.setSizes([room.width() - width, width])
|
||||
qtbot.wait(100)
|
||||
for i in range(room.chat_content_layout.count()):
|
||||
bubble = room.chat_content_layout.itemAt(i).widget()
|
||||
label = bubble.text_lbl
|
||||
assert label.height() >= label.heightForWidth(label.width())
|
||||
assert bubble.bubble.rect().contains(label.geometry())
|
||||
|
||||
|
||||
def test_unread_alert_and_focus_clear(room, qtbot, monkeypatch):
|
||||
alert = Mock()
|
||||
monkeypatch.setattr(QApplication, 'alert', alert)
|
||||
room.activateWindow()
|
||||
room.setFocus()
|
||||
qtbot.wait(50)
|
||||
room.add_chat_message('Other', 'hello', 0)
|
||||
assert room._unread_messages == 1
|
||||
alert.assert_called_once()
|
||||
room.add_chat_message('Me', 'reply', 0)
|
||||
assert room._unread_messages == 1
|
||||
room.chat_input.setFocus()
|
||||
qtbot.wait(50)
|
||||
assert room._unread_messages == 0
|
||||
room.add_chat_message('Other', 'focused', 0)
|
||||
assert room._unread_messages == 0
|
||||
room.chat_container.hide()
|
||||
room.add_chat_message('Other', 'hidden', 0)
|
||||
assert room._unread_messages == 1
|
||||
room.clear_chat()
|
||||
assert room._unread_messages == 0
|
||||
|
||||
|
||||
def test_popout_focus_and_wrapping(room, qtbot, monkeypatch):
|
||||
monkeypatch.setattr(QApplication, 'alert', Mock())
|
||||
room.popout_chat()
|
||||
popout = room.popout_window
|
||||
qtbot.addWidget(popout)
|
||||
popout.activateWindow()
|
||||
room.chat_input.setFocus()
|
||||
qtbot.wait(100)
|
||||
room.add_chat_message('Other', 'Wrapped popout text ' * 40, 0)
|
||||
qtbot.wait(100)
|
||||
assert room._unread_messages == 0
|
||||
bubble = room.chat_content_layout.itemAt(0).widget()
|
||||
assert bubble.text_lbl.height() >= bubble.text_lbl.heightForWidth(bubble.text_lbl.width())
|
||||
room.activateWindow()
|
||||
room.setFocus()
|
||||
qtbot.wait(100)
|
||||
room.add_chat_message('Other', 'Background popout', 0)
|
||||
assert room._unread_messages == 1
|
||||
popout.close()
|
||||
|
||||
|
||||
def test_scrolling_preserves_position_and_counts_unread(room, qtbot):
|
||||
for i in range(30):
|
||||
room.add_chat_message('Other', f'Message {i}: ' + 'wrapped words ' * 10, 0)
|
||||
qtbot.wait(150)
|
||||
bar = room.chat_messages.verticalScrollBar()
|
||||
assert bar.value() == bar.maximum()
|
||||
bar.setValue(100)
|
||||
qtbot.wait(30)
|
||||
position = bar.value()
|
||||
room.add_chat_message('Other', 'First unread message', 0)
|
||||
room.add_chat_message('Other', 'Second unread message', 0)
|
||||
room.add_system_message('Someone paused')
|
||||
qtbot.wait(150)
|
||||
assert bar.value() == position
|
||||
assert room._pending_chat_messages == 2
|
||||
assert not room.chat_jump_btn.isHidden()
|
||||
assert room._unread_divider is not None
|
||||
room.chat_input.setFocus()
|
||||
assert room._pending_chat_messages == 2
|
||||
room.jump_to_latest()
|
||||
qtbot.wait(150)
|
||||
assert bar.value() == bar.maximum()
|
||||
assert room.chat_jump_btn.isHidden()
|
||||
room.add_chat_message('Other', 'Follow latest', 0)
|
||||
qtbot.wait(150)
|
||||
assert bar.value() == bar.maximum()
|
||||
assert room._pending_chat_messages == 0
|
||||
|
||||
|
||||
def test_scroll_back_to_bottom_clears_pending(room, qtbot):
|
||||
for i in range(20):
|
||||
room.add_chat_message('Other', 'Words ' * 30, 0)
|
||||
qtbot.wait(150)
|
||||
bar = room.chat_messages.verticalScrollBar()
|
||||
bar.setValue(0)
|
||||
room.add_chat_message('Other', 'Unread', 0)
|
||||
qtbot.wait(100)
|
||||
bar.setValue(bar.maximum())
|
||||
assert room._pending_chat_messages == 0
|
||||
assert room._follow_chat
|
||||
assert room.chat_jump_btn.isHidden()
|
||||
room.clear_chat()
|
||||
assert room._unread_divider is None
|
||||
assert room.chat_content_layout.count() == 0
|
||||
|
||||
|
||||
def test_ready_command_and_controls(room, qtbot):
|
||||
with qtbot.waitSignal(room.ready_action_requested) as signal:
|
||||
room.chat_input.setText('/ready')
|
||||
room.send_chat()
|
||||
assert signal.args == [{'type': 'ready_check', 'action': 'begin'}]
|
||||
assert room.chat_input.text() == ''
|
||||
state = {'active': True, 'id': 'check-1', 'requestedBy': 'Me', 'participants': [
|
||||
{'username': 'Me', 'ready': False, 'connected': True},
|
||||
{'username': "<b>O'Brien</b>", 'ready': True, 'connected': True}]}
|
||||
room.update_ready_check(state)
|
||||
assert not room.ready_panel.isHidden()
|
||||
assert not room.ready_start_btn.isEnabled()
|
||||
assert "<b>O'Brien</b>" in room.ready_users.text()
|
||||
with qtbot.waitSignal(room.ready_action_requested) as signal:
|
||||
room.ready_btn.click()
|
||||
assert signal.args[0] == {'type': 'ready_check', 'action': 'vote', 'id': 'check-1', 'ready': True}
|
||||
state['participants'][0]['ready'] = True
|
||||
room.update_ready_check(state)
|
||||
assert room.ready_start_btn.isEnabled()
|
||||
with qtbot.waitSignal(room.ready_action_requested) as signal:
|
||||
room.ready_start_btn.click()
|
||||
assert signal.args[0]['action'] == 'start'
|
||||
state['participants'][1]['connected'] = False
|
||||
room.update_ready_check(state)
|
||||
assert not room.ready_start_btn.isEnabled()
|
||||
assert 'Reconnecting' in room.ready_users.text()
|
||||
state['requestedBy'] = 'Other'
|
||||
room.update_ready_check(state)
|
||||
assert room.ready_start_btn.isHidden()
|
||||
room.update_ready_check({'active': False})
|
||||
assert room.ready_panel.isHidden()
|
||||
|
||||
|
||||
def test_ready_panel_buttons_fit_styled_chat(room, qtbot):
|
||||
from main import VlcSyncApp
|
||||
VlcSyncApp.apply_stylesheet(room)
|
||||
room.splitter.setSizes([750, 300])
|
||||
room.update_ready_check({'active': True, 'id': 'layout', 'requestedBy': 'Me', 'participants': [
|
||||
{'username': 'Me', 'ready': True, 'connected': True}]})
|
||||
qtbot.wait(100)
|
||||
for button in (room.ready_btn, room.ready_start_btn, room.ready_cancel_btn):
|
||||
assert button.width() >= button.fontMetrics().horizontalAdvance(button.text()) + 20
|
||||
room.update_connection_status(False)
|
||||
room.update_ready_check(room._ready_check)
|
||||
assert not room.ready_start_btn.isEnabled()
|
||||
assert not room.ready_btn.isEnabled()
|
||||
|
||||
|
||||
def test_app_wires_commands_to_websocket_and_incoming_panels(room, qtbot, monkeypatch):
|
||||
import main
|
||||
from sync_client import SyncClientThread
|
||||
client = SyncClientThread()
|
||||
client.send_message = Mock()
|
||||
monkeypatch.setattr(main, 'SyncClientThread', lambda url: client)
|
||||
app = main.VlcSyncApp()
|
||||
qtbot.addWidget(app)
|
||||
for command, expected in [('/ready', {'type': 'ready_check', 'action': 'begin'}),
|
||||
('/ko', {'type': 'reaction', 'kind': 'ko'}),
|
||||
('/bell', {'type': 'reaction', 'kind': 'bell'})]:
|
||||
app.room_widget.chat_input.setText(command)
|
||||
app.room_widget.send_chat()
|
||||
client.send_message.assert_called_with(expected)
|
||||
state = {'type': 'ready_check', 'active': True, 'id': 'wired', 'requestedBy': 'Me',
|
||||
'participants': [{'username': 'Me', 'ready': False, 'connected': True}]}
|
||||
client._handle_message(state)
|
||||
assert app.room_widget._ready_check['id'] == 'wired'
|
||||
assert not app.room_widget.ready_panel.isHidden()
|
||||
@@ -0,0 +1,33 @@
|
||||
import json
|
||||
from urllib.request import urlopen
|
||||
|
||||
from test_ready_server import server_url, send, receive
|
||||
from websockets.sync.client import connect
|
||||
|
||||
|
||||
def test_discovery_visibility_and_connected_users(server_url):
|
||||
endpoint = server_url.replace('ws://', 'http://').removesuffix('/ws') + '/api/rooms'
|
||||
def listing():
|
||||
with urlopen(endpoint, timeout=3) as response:
|
||||
assert response.headers['Cache-Control'] == 'no-store'
|
||||
return json.load(response)['rooms']
|
||||
|
||||
assert listing() == []
|
||||
with connect(server_url) as public, connect(server_url) as private:
|
||||
file_info = {'name': 'test.mkv', 'size': 1234, 'duration': 60}
|
||||
send(public, type='create_room', username='Public', fileInfo=file_info)
|
||||
public_code = receive(public, 'room_created')['code']
|
||||
send(private, type='create_room', username='Private', fileInfo=file_info, listed=False)
|
||||
private_code = receive(private, 'room_created')['code']
|
||||
rooms = listing()
|
||||
assert len(rooms) == 1
|
||||
assert rooms[0]['code'] == public_code
|
||||
assert rooms[0]['users'] == ['Public']
|
||||
assert rooms[0]['playing'] is False
|
||||
with connect(server_url) as guest:
|
||||
send(guest, type='join_room', username='Guest', code=private_code)
|
||||
receive(guest, 'room_file_check')
|
||||
send(guest, type='confirm_join', fileInfo=file_info)
|
||||
receive(guest, 'room_joined')
|
||||
assert len(listing()) == 1
|
||||
assert listing() == []
|
||||
@@ -0,0 +1,190 @@
|
||||
import time
|
||||
import wave
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from PyQt6.QtCore import QEvent, QUrl
|
||||
from PyQt6.QtMultimedia import QSoundEffect
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
from fight_reactions import REACTIONS, write_reaction_sound
|
||||
from test_chat import room
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', REACTIONS)
|
||||
def test_command_dispatch(room, qtbot, kind):
|
||||
with qtbot.waitSignal(room.fight_action_requested) as event:
|
||||
room.chat_input.setText('/' + kind)
|
||||
room.send_chat()
|
||||
assert event.args == [{'type': 'reaction', 'kind': kind}]
|
||||
assert room.chat_input.text() == ''
|
||||
assert room.chat_content_layout.count() == 0 # Wait for the server echo.
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', REACTIONS)
|
||||
def test_strip_is_native_positioned_and_does_not_steal_focus(room, qtbot, kind):
|
||||
room.activateWindow()
|
||||
room.chat_input.setFocus()
|
||||
qtbot.wait(30)
|
||||
room.handle_fight_reaction({'kind': kind, 'username': "<b>O'Brien</b>", 'position': 83})
|
||||
strip = room.ringside_strip
|
||||
assert strip.isWindow()
|
||||
assert strip.isVisible()
|
||||
assert strip.headline.text() == REACTIONS[kind][0]
|
||||
assert "<b>O'Brien</b>" in strip.caption.text()
|
||||
assert '01:23' in strip.metadata.text()
|
||||
qtbot.wait(210)
|
||||
assert QApplication.focusWidget() is room.chat_input
|
||||
video = room.video_frame
|
||||
rect = strip.geometry()
|
||||
from PyQt6.QtCore import QRect, QPoint
|
||||
bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size())
|
||||
assert bounds.contains(rect)
|
||||
room.resize(1150, 650)
|
||||
qtbot.wait(50)
|
||||
bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size())
|
||||
assert bounds.contains(strip.geometry())
|
||||
QApplication.sendEvent(room, QEvent(QEvent.Type.Hide))
|
||||
assert not strip.isVisible()
|
||||
|
||||
|
||||
def test_strip_auto_dismiss_and_cleanup(room, qtbot):
|
||||
room.activateWindow()
|
||||
qtbot.wait(30)
|
||||
room.handle_fight_reaction({'kind': 'ko', 'username': 'Alex', 'position': 0})
|
||||
assert room.ringside_strip.isVisible()
|
||||
room.ringside_strip.dismiss_timer.start(30)
|
||||
qtbot.waitUntil(lambda: not room.ringside_strip.isVisible())
|
||||
room.handle_fight_reaction({'kind': 'bell', 'username': 'Alex', 'position': 0})
|
||||
room.cleanup()
|
||||
assert not room.ringside_strip.isVisible()
|
||||
assert not room.ringside_strip.dismiss_timer.isActive()
|
||||
assert not room._fight_poll_timer.isActive()
|
||||
|
||||
|
||||
def test_reactions_work_with_chat_popout_focused(room, qtbot):
|
||||
room.popout_chat()
|
||||
popout = room.popout_window
|
||||
qtbot.addWidget(popout)
|
||||
popout.activateWindow()
|
||||
room.chat_input.setFocus()
|
||||
qtbot.wait(50)
|
||||
room.handle_fight_reaction({'kind': 'ko', 'username': 'Other', 'position': 0})
|
||||
assert room.ringside_strip.isVisible()
|
||||
assert QApplication.focusWidget() is room.chat_input
|
||||
popout.close()
|
||||
|
||||
|
||||
def test_arcade_ko_covers_video_then_restores_strip(room, qtbot):
|
||||
room.activateWindow()
|
||||
qtbot.wait(30)
|
||||
room.handle_fight_reaction({'kind': 'ko', 'position': 0})
|
||||
overlay = room.ringside_strip
|
||||
assert overlay.size() == room.video_frame.size()
|
||||
assert overlay.panel.isHidden()
|
||||
assert not overlay.ko_canvas.isHidden()
|
||||
assert overlay.ko_animation.state() == overlay.ko_animation.State.Running
|
||||
room.handle_fight_reaction({'kind': 'bell', 'position': 0})
|
||||
assert overlay.ko_canvas.isHidden()
|
||||
assert not overlay.panel.isHidden()
|
||||
assert overlay.width() <= 540
|
||||
assert overlay.height() < room.video_frame.height()
|
||||
room.cleanup()
|
||||
assert overlay.ko_animation.state() == overlay.ko_animation.State.Stopped
|
||||
|
||||
|
||||
def test_sound_is_enabled_by_default_and_hidden_effects_are_suppressed(room, qtbot, monkeypatch):
|
||||
play = Mock()
|
||||
monkeypatch.setattr(room, '_play_reaction_sound', play)
|
||||
room.activateWindow()
|
||||
qtbot.wait(30)
|
||||
room.handle_fight_reaction({'kind': 'ko', 'position': 0})
|
||||
play.assert_called_once_with('ko')
|
||||
play.reset_mock()
|
||||
room.handle_fight_reaction({'kind': 'bell', 'position': 0})
|
||||
play.assert_called_once_with('bell')
|
||||
room.hide()
|
||||
room.handle_fight_reaction({'kind': 'tap', 'position': 0})
|
||||
play.assert_called_once_with('bell')
|
||||
assert not room.ringside_strip.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['ko', 'bell'])
|
||||
def test_receiving_client_shows_reaction_without_focus(room, qtbot, monkeypatch, kind):
|
||||
from PyQt6.QtWidgets import QLineEdit
|
||||
other_client = QLineEdit()
|
||||
qtbot.addWidget(other_client)
|
||||
other_client.show()
|
||||
other_client.activateWindow()
|
||||
other_client.setFocus()
|
||||
qtbot.wait(30)
|
||||
assert not room.isActiveWindow()
|
||||
play = Mock()
|
||||
monkeypatch.setattr(room, '_play_reaction_sound', play)
|
||||
room.handle_fight_reaction({'kind': kind, 'username': 'Other client', 'position': 0})
|
||||
assert room.ringside_strip.isVisible()
|
||||
play.assert_called_once_with(kind)
|
||||
assert QApplication.focusWidget() is other_client
|
||||
QApplication.sendEvent(room, QEvent(QEvent.Type.WindowDeactivate))
|
||||
assert room.ringside_strip.isVisible()
|
||||
room.showMinimized()
|
||||
qtbot.wait(30)
|
||||
assert not room.ringside_strip.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', REACTIONS)
|
||||
def test_original_sound_files_are_valid_and_loadable(qtbot, tmp_path, kind):
|
||||
path = write_reaction_sound(tmp_path, kind)
|
||||
assert write_reaction_sound(tmp_path, kind) == path
|
||||
with wave.open(str(path)) as audio:
|
||||
assert audio.getnchannels() == 1
|
||||
assert audio.getsampwidth() == 2
|
||||
assert audio.getframerate() == 22050
|
||||
assert .35 <= audio.getnframes() / 22050 <= .9
|
||||
assert any(audio.readframes(audio.getnframes()))
|
||||
sound = QSoundEffect()
|
||||
sound.setSource(QUrl.fromLocalFile(str(path)))
|
||||
qtbot.waitUntil(lambda: sound.status() in (QSoundEffect.Status.Ready, QSoundEffect.Status.Error))
|
||||
assert sound.status() == QSoundEffect.Status.Ready
|
||||
|
||||
|
||||
def test_robbery_vote_controls_expiry_and_reconnect(room, qtbot):
|
||||
poll = {'id': 'vote-1', 'active': True, 'expiresAt': time.time() * 1000 + 30000,
|
||||
'votes': {'Other': 'yes'}}
|
||||
room.update_fight_poll(poll)
|
||||
assert not room.fight_poll_panel.isHidden()
|
||||
assert 'Absolutely 1' in room.fight_poll_result.text()
|
||||
with qtbot.waitSignal(room.fight_action_requested) as event:
|
||||
room.fight_no_btn.click()
|
||||
assert event.args == [{'type': 'fight_poll_vote', 'id': 'vote-1', 'choice': 'no'}]
|
||||
poll['votes']['Me'] = 'no'
|
||||
room.update_fight_poll(poll)
|
||||
assert 'Fair decision 1' in room.fight_poll_result.text()
|
||||
assert 'Your vote is in' in room.fight_poll_result.text()
|
||||
room.update_connection_status(False)
|
||||
assert not room.fight_yes_btn.isEnabled()
|
||||
room.update_connection_status(True)
|
||||
assert room.fight_yes_btn.isEnabled()
|
||||
poll['expiresAt'] = time.time() * 1000 - 1000
|
||||
room.update_fight_poll(poll)
|
||||
assert 'Vote closed' in room.fight_poll_title.text()
|
||||
assert not room.fight_yes_btn.isEnabled()
|
||||
assert not room._fight_poll_timer.isActive()
|
||||
room.reset_fight_reactions()
|
||||
assert room.fight_poll_panel.isHidden()
|
||||
|
||||
|
||||
def test_styled_vote_controls_fit_and_dismiss_stays_dismissed(room, qtbot):
|
||||
from main import VlcSyncApp
|
||||
VlcSyncApp.apply_stylesheet(room)
|
||||
room.splitter.setSizes([750, 350])
|
||||
poll = {'id': 'styled', 'active': True, 'expiresAt': time.time() * 1000 + 30000, 'votes': {}}
|
||||
room.update_fight_poll(poll)
|
||||
qtbot.wait(50)
|
||||
for button in (room.fight_yes_btn, room.fight_no_btn, room.fight_poll_dismiss_btn):
|
||||
assert button.width() >= button.fontMetrics().horizontalAdvance(button.text()) + 10
|
||||
room.fight_poll_dismiss_btn.click()
|
||||
room.update_fight_poll(poll)
|
||||
assert room.fight_poll_panel.isHidden()
|
||||
room.update_fight_poll({**poll, 'id': 'new-poll'})
|
||||
assert not room.fight_poll_panel.isHidden()
|
||||
@@ -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,52 @@
|
||||
from lobby_widget import LobbyWidget
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
|
||||
def test_session_selection_uses_existing_join_flow(qtbot):
|
||||
lobby = LobbyWidget()
|
||||
qtbot.addWidget(lobby)
|
||||
assert lobby.listed_checkbox.isChecked()
|
||||
rooms = [{"code": "ABC123", "fileInfo": {"name": "movie.mkv"},
|
||||
"users": ["Host"], "playing": True, "position": 75}]
|
||||
lobby.update_sessions(rooms)
|
||||
lobby.sessions_list.setCurrentRow(0)
|
||||
assert not lobby.session_join_btn.isEnabled()
|
||||
lobby.username_input.setText("Alex")
|
||||
assert lobby.session_join_btn.isEnabled()
|
||||
with qtbot.waitSignal(lobby.join_requested) as signal:
|
||||
lobby.join_selected_session()
|
||||
assert signal.args == ["Alex", "ABC123"]
|
||||
lobby.reset_ui()
|
||||
lobby.update_sessions(rooms)
|
||||
assert lobby.sessions_list.currentItem().data(256) == "ABC123"
|
||||
lobby.update_sessions([])
|
||||
assert not lobby.session_join_btn.isEnabled()
|
||||
assert "No active sessions" in lobby.sessions_status.text()
|
||||
|
||||
|
||||
def test_workspace_dialogs_and_connection_lock(qtbot, tmp_path):
|
||||
lobby = LobbyWidget()
|
||||
qtbot.addWidget(lobby)
|
||||
lobby.show()
|
||||
assert not lobby.create_action_btn.isEnabled()
|
||||
lobby.username_input.setText("Alex")
|
||||
qtbot.mouseClick(lobby.create_action_btn, Qt.MouseButton.LeftButton)
|
||||
assert lobby.create_dialog.isVisible()
|
||||
assert not lobby.create_room_btn.isEnabled()
|
||||
video = tmp_path / "movie.mkv"
|
||||
video.write_bytes(b"local video")
|
||||
lobby._set_local_file(str(video))
|
||||
lobby.listed_checkbox.setChecked(False)
|
||||
with qtbot.waitSignal(lobby.create_requested) as signal:
|
||||
qtbot.mouseClick(lobby.create_room_btn, Qt.MouseButton.LeftButton)
|
||||
assert signal.args == ["Alex", str(video), "movie.mkv", 11]
|
||||
assert not lobby.create_dialog.isVisible()
|
||||
assert not lobby.create_action_btn.isEnabled()
|
||||
assert not lobby.join_action_btn.isEnabled()
|
||||
lobby.reset_ui()
|
||||
qtbot.mouseClick(lobby.join_action_btn, Qt.MouseButton.LeftButton)
|
||||
lobby.room_code_input.setText("abc123")
|
||||
with qtbot.waitSignal(lobby.join_requested) as signal:
|
||||
qtbot.keyClick(lobby.room_code_input, Qt.Key.Key_Return)
|
||||
assert signal.args == ["Alex", "ABC123"]
|
||||
assert not lobby.join_dialog.isVisible()
|
||||
@@ -0,0 +1,48 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from PyQt6.QtCore import QSettings
|
||||
|
||||
import room_widget
|
||||
from main import VlcSyncApp
|
||||
from test_chat import PlayerSignals
|
||||
|
||||
|
||||
def test_preferences_survive_restart(qtbot, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(VlcSyncApp, "refresh_sessions", lambda self: None)
|
||||
monkeypatch.setattr(room_widget, "VLCSyncPlayer",
|
||||
lambda frame: Mock(signals=PlayerSignals()))
|
||||
path = str(tmp_path / "preferences.ini")
|
||||
settings = QSettings(path, QSettings.Format.IniFormat)
|
||||
first = VlcSyncApp(settings)
|
||||
qtbot.addWidget(first)
|
||||
first.show()
|
||||
first.lobby_widget.username_input.setText("Alex")
|
||||
first.room_widget.volume_slider.setValue(37)
|
||||
first._remember_media_folder(str(tmp_path / "movie.mkv"))
|
||||
first.room_widget.chat_container.hide()
|
||||
first.room_widget.tags_list.hide()
|
||||
first.resize(900, 650)
|
||||
first.close()
|
||||
|
||||
second = VlcSyncApp(QSettings(path, QSettings.Format.IniFormat))
|
||||
qtbot.addWidget(second)
|
||||
assert second.lobby_widget.username_input.text() == "Alex"
|
||||
assert second.room_widget.volume_slider.value() == 37
|
||||
assert second.lobby_widget.media_folder == str(tmp_path)
|
||||
assert second.room_widget.chat_container.isHidden()
|
||||
assert second.room_widget.tags_list.isHidden()
|
||||
assert second.size() == first.size()
|
||||
assert second.sync_client.url == "wss://video-sync.peterstockings.com/ws"
|
||||
|
||||
|
||||
def test_missing_folder_and_volume_bounds(qtbot, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(VlcSyncApp, "refresh_sessions", lambda self: None)
|
||||
monkeypatch.setattr(room_widget, "VLCSyncPlayer",
|
||||
lambda frame: Mock(signals=PlayerSignals()))
|
||||
settings = QSettings(str(tmp_path / "preferences.ini"), QSettings.Format.IniFormat)
|
||||
settings.setValue("media_folder", str(tmp_path / "missing"))
|
||||
settings.setValue("volume", 200)
|
||||
window = VlcSyncApp(settings)
|
||||
qtbot.addWidget(window)
|
||||
assert window.lobby_widget.media_folder == ""
|
||||
assert window.room_widget.volume_slider.value() == 100
|
||||
@@ -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']
|
||||
@@ -0,0 +1,44 @@
|
||||
import ssl
|
||||
|
||||
from sync_client import SyncClientThread
|
||||
|
||||
|
||||
def test_ssl_context_uses_bundled_ca_certificates():
|
||||
context = SyncClientThread._create_ssl_context()
|
||||
|
||||
assert context.verify_mode == ssl.CERT_REQUIRED
|
||||
assert context.check_hostname is True
|
||||
assert context.cert_store_stats()["x509_ca"] > 0
|
||||
|
||||
|
||||
def test_certificate_error_finds_nested_verification_error():
|
||||
certificate_error = ssl.SSLCertVerificationError(1, "certificate has expired")
|
||||
|
||||
try:
|
||||
raise RuntimeError("connection failed") from certificate_error
|
||||
except RuntimeError as error:
|
||||
assert SyncClientThread._certificate_error(error) is certificate_error
|
||||
|
||||
|
||||
def test_non_certificate_error_is_ignored():
|
||||
assert SyncClientThread._certificate_error(ConnectionError("offline")) is None
|
||||
|
||||
|
||||
def test_ready_check_event_dispatch(qtbot):
|
||||
client = SyncClientThread()
|
||||
message = {'type': 'ready_check', 'active': True, 'id': 'check'}
|
||||
with qtbot.waitSignal(client.ready_check_updated) as signal:
|
||||
client._handle_message(message)
|
||||
assert signal.args == [message]
|
||||
|
||||
|
||||
def test_fight_event_dispatch_and_nonfatal_feedback(qtbot):
|
||||
client = SyncClientThread()
|
||||
for event_type, signal in (('reaction', client.fight_reaction), ('fight_poll', client.fight_poll_updated)):
|
||||
message = {'type': event_type, 'kind': 'ko'}
|
||||
with qtbot.waitSignal(signal) as event:
|
||||
client._handle_message(message)
|
||||
assert event.args == [message]
|
||||
with qtbot.waitSignal(client.system_message) as event:
|
||||
client._handle_message({'type': 'reaction_error', 'message': 'Cooldown'})
|
||||
assert event.args == ['Cooldown']
|
||||
Generated
+11
@@ -11,6 +11,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.6.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macholib"
|
||||
version = "1.16.4"
|
||||
@@ -163,6 +172,7 @@ name = "python-app"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pyinstaller" },
|
||||
{ name = "pyqt6" },
|
||||
@@ -172,6 +182,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "certifi", specifier = ">=2026.6.17" },
|
||||
{ name = "pillow", specifier = ">=12.1.1" },
|
||||
{ name = "pyinstaller", specifier = ">=6.19.0" },
|
||||
{ name = "pyqt6", specifier = ">=6.10.2" },
|
||||
|
||||
@@ -8,14 +8,18 @@ interface FileInfo {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface RoomState {
|
||||
code: string;
|
||||
interface RoomState {
|
||||
code: string;
|
||||
listed: boolean;
|
||||
fileInfo: FileInfo;
|
||||
playing: boolean;
|
||||
position: number;
|
||||
lastUpdate: number; // timestamp when position was last set
|
||||
users: Map<string, WebSocket>;
|
||||
chatHistory: ChatMessage[];
|
||||
readyCheck?: { id: string; requestedBy: string; ready: Set<string> };
|
||||
lastReactionAt?: number;
|
||||
fightPoll?: { id: string; expiresAt: number; votes: Map<string, "yes" | "no"> };
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
@@ -56,6 +60,29 @@ function getUserList(room: RoomState): string[] {
|
||||
return Array.from(room.users.keys());
|
||||
}
|
||||
|
||||
function readyCheckState(room: RoomState) {
|
||||
if (!room.readyCheck) return { type: "ready_check", active: false };
|
||||
return {
|
||||
type: "ready_check", active: true,
|
||||
id: room.readyCheck.id, requestedBy: room.readyCheck.requestedBy,
|
||||
participants: Array.from(room.users, ([username, ws]) => ({
|
||||
username, ready: room.readyCheck!.ready.has(username), connected: ws.readyState === 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastReadyCheck(room: RoomState) {
|
||||
broadcastToRoom(room, readyCheckState(room));
|
||||
}
|
||||
|
||||
function fightPollState(room: RoomState) {
|
||||
const poll = room.fightPoll;
|
||||
if (!poll) return { type: "fight_poll", active: false };
|
||||
return { type: "fight_poll", id: poll.id, active: Date.now() < poll.expiresAt,
|
||||
expiresAt: poll.expiresAt,
|
||||
votes: Object.fromEntries(Array.from(poll.votes).filter(([username]) => room.users.has(username))) };
|
||||
}
|
||||
|
||||
// --- MIME Types ---
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
@@ -107,7 +134,17 @@ const server = Bun.serve<WSData>({
|
||||
port: Number(process.env.PORT) || 3000,
|
||||
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === "/api/rooms" && req.method === "GET") {
|
||||
const activeRooms = Array.from(rooms.values()).flatMap(room => {
|
||||
const users = Array.from(room.users).filter(([, ws]) => ws.readyState === 1)
|
||||
.map(([name]) => name);
|
||||
return room.listed && users.length ? [{ code: room.code, fileInfo: room.fileInfo,
|
||||
users, playing: room.playing, position: getCurrentPosition(room) }] : [];
|
||||
});
|
||||
return Response.json({ rooms: activeRooms }, { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
// WebSocket upgrade
|
||||
if (url.pathname === "/ws") {
|
||||
@@ -153,8 +190,9 @@ const server = Bun.serve<WSData>({
|
||||
}
|
||||
|
||||
const code = generateRoomCode();
|
||||
const room: RoomState = {
|
||||
code,
|
||||
const room: RoomState = {
|
||||
code,
|
||||
listed: msg.listed !== false,
|
||||
fileInfo: {
|
||||
name: msg.fileInfo.name,
|
||||
size: msg.fileInfo.size,
|
||||
@@ -253,6 +291,8 @@ const server = Bun.serve<WSData>({
|
||||
position: getCurrentPosition(room),
|
||||
},
|
||||
chatHistory: room.chatHistory.slice(-50),
|
||||
readyCheck: readyCheckState(room),
|
||||
fightPoll: fightPollState(room),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -267,12 +307,17 @@ const server = Bun.serve<WSData>({
|
||||
ws as unknown as WebSocket
|
||||
);
|
||||
console.log(`[Room ${code}] ${username} joined`);
|
||||
if (room.readyCheck) broadcastReadyCheck(room);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sync": {
|
||||
const room = rooms.get(ws.data.roomCode);
|
||||
if (!room) return;
|
||||
if (msg.action === "play" && room.readyCheck) {
|
||||
room.readyCheck = undefined;
|
||||
broadcastReadyCheck(room);
|
||||
}
|
||||
|
||||
// Update authoritative state
|
||||
if (msg.action === "play") {
|
||||
@@ -305,6 +350,89 @@ const server = Bun.serve<WSData>({
|
||||
break;
|
||||
}
|
||||
|
||||
case "ready_check": {
|
||||
const room = rooms.get(ws.data.roomCode);
|
||||
const username = ws.data.username;
|
||||
if (!room || room.users.get(username) !== (ws as unknown as WebSocket)) return;
|
||||
if (msg.action === "begin") {
|
||||
// Repeating /ready shows the existing check without resetting votes.
|
||||
if (!room.readyCheck) {
|
||||
room.readyCheck = { id: crypto.randomUUID(), requestedBy: username, ready: new Set() };
|
||||
room.position = getCurrentPosition(room);
|
||||
room.playing = false;
|
||||
room.lastUpdate = Date.now();
|
||||
broadcastToRoom(room, { type: "sync", action: "pause", position: room.position, playing: false });
|
||||
}
|
||||
} else {
|
||||
const check = room.readyCheck;
|
||||
if (!check || msg.id !== check.id) return;
|
||||
if (msg.action === "vote" && typeof msg.ready === "boolean") {
|
||||
if (msg.ready) check.ready.add(username);
|
||||
else check.ready.delete(username);
|
||||
} else if (msg.action === "cancel" && check.requestedBy === username) {
|
||||
room.readyCheck = undefined;
|
||||
} else if (msg.action === "start" && check.requestedBy === username) {
|
||||
const everyoneReady = Array.from(room.users).every(([name, peer]) =>
|
||||
peer.readyState === 1 && check.ready.has(name));
|
||||
if (!everyoneReady) return;
|
||||
room.readyCheck = undefined;
|
||||
room.playing = true;
|
||||
room.lastUpdate = Date.now();
|
||||
broadcastToRoom(room, { type: "sync", action: "play", position: room.position, playing: true });
|
||||
} else return;
|
||||
}
|
||||
broadcastReadyCheck(room);
|
||||
break;
|
||||
}
|
||||
|
||||
case "reaction": {
|
||||
const room = rooms.get(ws.data.roomCode);
|
||||
if (!room || room.users.get(ws.data.username) !== (ws as unknown as WebSocket)) return;
|
||||
if (!["ko", "bell", "tap", "robbery"].includes(msg.kind)) {
|
||||
ws.send(JSON.stringify({ type: "reaction_error", message: "Unknown fight reaction." }));
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (room.lastReactionAt !== undefined && now - room.lastReactionAt < 3000) {
|
||||
ws.send(JSON.stringify({ type: "reaction_error", message: "Fight reactions have a 3-second room cooldown." }));
|
||||
return;
|
||||
}
|
||||
if (msg.kind === "robbery" && room.fightPoll && now < room.fightPoll.expiresAt) {
|
||||
ws.send(JSON.stringify({ type: "reaction_error", message: "A robbery vote is already open." }));
|
||||
ws.send(JSON.stringify(fightPollState(room)));
|
||||
return;
|
||||
}
|
||||
room.lastReactionAt = now;
|
||||
broadcastToRoom(room, { type: "reaction", kind: msg.kind,
|
||||
username: ws.data.username, position: getCurrentPosition(room), timestamp: now });
|
||||
const chatMsg = { username: ws.data.username, message: `🥊 /${msg.kind}`, timestamp: now };
|
||||
room.chatHistory.push(chatMsg);
|
||||
if (room.chatHistory.length > 200) room.chatHistory = room.chatHistory.slice(-100);
|
||||
broadcastToRoom(room, { type: "chat", ...chatMsg });
|
||||
if (msg.kind === "robbery") {
|
||||
const poll = { id: crypto.randomUUID(), expiresAt: now + 30_000, votes: new Map<string, "yes" | "no">() };
|
||||
room.fightPoll = poll;
|
||||
broadcastToRoom(room, fightPollState(room));
|
||||
setTimeout(() => {
|
||||
if (rooms.get(room.code) === room && room.fightPoll?.id === poll.id) {
|
||||
broadcastToRoom(room, fightPollState(room));
|
||||
}
|
||||
}, 30_000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "fight_poll_vote": {
|
||||
const room = rooms.get(ws.data.roomCode);
|
||||
if (!room || room.users.get(ws.data.username) !== (ws as unknown as WebSocket)) return;
|
||||
const poll = room.fightPoll;
|
||||
if (!poll || poll.id !== msg.id || Date.now() >= poll.expiresAt) return;
|
||||
if (msg.choice !== "yes" && msg.choice !== "no") return;
|
||||
poll.votes.set(ws.data.username, msg.choice);
|
||||
broadcastToRoom(room, fightPollState(room));
|
||||
break;
|
||||
}
|
||||
|
||||
case "chat": {
|
||||
const room = rooms.get(ws.data.roomCode);
|
||||
if (!room) return;
|
||||
@@ -386,6 +514,8 @@ const server = Bun.serve<WSData>({
|
||||
position: getCurrentPosition(room),
|
||||
},
|
||||
chatHistory: room.chatHistory.slice(-50),
|
||||
readyCheck: readyCheckState(room),
|
||||
fightPoll: fightPollState(room),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -400,6 +530,7 @@ const server = Bun.serve<WSData>({
|
||||
ws as unknown as WebSocket
|
||||
);
|
||||
console.log(`[Room ${code}] ${username} rejoined`);
|
||||
if (room.readyCheck) broadcastReadyCheck(room);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -411,6 +542,11 @@ const server = Bun.serve<WSData>({
|
||||
|
||||
const room = rooms.get(roomCode);
|
||||
if (!room) return;
|
||||
if (room.users.get(username) !== (ws as unknown as WebSocket)) return;
|
||||
if (room.readyCheck) {
|
||||
room.readyCheck.ready.delete(username);
|
||||
broadcastReadyCheck(room);
|
||||
}
|
||||
|
||||
// Don't remove immediately — give a 90s grace period for reconnection
|
||||
const disconnectKey = `${roomCode}:${username}`;
|
||||
@@ -425,6 +561,15 @@ const server = Bun.serve<WSData>({
|
||||
const currentWs = currentRoom.users.get(username);
|
||||
if (currentWs === (ws as unknown as WebSocket)) {
|
||||
currentRoom.users.delete(username);
|
||||
if (currentRoom.fightPoll) {
|
||||
currentRoom.fightPoll.votes.delete(username);
|
||||
broadcastToRoom(currentRoom, fightPollState(currentRoom));
|
||||
}
|
||||
if (currentRoom.readyCheck) {
|
||||
currentRoom.readyCheck.ready.delete(username);
|
||||
if (currentRoom.readyCheck.requestedBy === username) currentRoom.readyCheck = undefined;
|
||||
broadcastReadyCheck(currentRoom);
|
||||
}
|
||||
console.log(`[Room ${roomCode}] ${username} removed (grace period expired)`);
|
||||
|
||||
if (currentRoom.users.size === 0) {
|
||||
|
||||
Reference in New Issue
Block a user