Compare commits

...
48 Commits
Author SHA1 Message Date
Peter Stockings 21a35d81f0 Add discoverable rooms to desktop lobby 2026-10-05 16:13:17 +11:00
Peter Stockings 836034533e Persist desktop client preferences 2026-10-05 16:06:59 +11:00
Peter Stockings 75362bfcba Show incoming fight reactions on unfocused visible players 2026-10-05 15:57:17 +11:00
Peter Stockings b5f09a9e9e Give KO reactions an animated arcade overlay 2026-10-05 15:52:30 +11:00
Peter Stockings f3eeda71e6 Add desktop ready checks, smarter chat scrolling, and fight-night reactions 2026-10-05 15:46:55 +11:00
Peter Stockings 19576de166 Fix desktop chat wrapping, character rendering, and unread alerts 2026-10-05 13:56:12 +11:00
Peter Stockings ce22de754a Fix desktop WebSocket certificate verification 2026-07-13 23:26:07 +10:00
Peter Stockings aee78d5392 Fix cropping of message bubble cropping on linux 2026-04-01 22:43:35 +11:00
Peter Stockings f1b7a65767 fix: use file extension in QFileDialog filter to prevent space corruption 2026-03-24 10:56:00 +11:00
Peter Stockings 9d2d1995bd Add bash script to pull the latest version of desktop client and run 2026-03-09 21:04:58 +11:00
Peter Stockings 66342706db Split out components into new files 2026-03-09 20:58:14 +11:00
Peter Stockings 386aa18ba1 Consolidate format time methods 2026-03-09 20:48:23 +11:00
Peter Stockings e2edd296fc When joining a room show already sent chat messages 2026-03-09 20:43:46 +11:00
Peter Stockings 42f7ee7f12 Update help command to include keyboard shortcuts 2026-03-09 20:24:22 +11:00
Peter Stockings 5b857bf878 Improve look of chat messages 2026-03-09 20:21:51 +11:00
Peter Stockings 99a0694830 Add ability to popout chat into seperate window 2026-03-09 16:02:59 +11:00
Peter Stockings 887648bf85 Add network status/latency info 2026-03-05 14:09:58 +11:00
Peter Stockings a301d1521b Add support for ping messages in server for determining latency 2026-03-05 13:54:08 +11:00
Peter Stockings dd00011b77 Add tooltip to seekbar on hover 2026-03-05 13:45:49 +11:00
Peter Stockings 25ea1694f1 Add ability to toggle and resize chat bar 2026-03-05 13:40:17 +11:00
Peter Stockings 367da66c0b Auto fill room ID from clip board upon interacting with input 2026-03-05 13:11:31 +11:00
Peter Stockings b59d08d098 Auto show/hide controls in fullscreen based on activity 2026-03-05 13:06:50 +11:00
Peter Stockings ce87a817ea Improve look of seek/volume sliders 2026-03-05 12:57:53 +11:00
Peter Stockings dae4af9ab8 Change volume icon based on volume 2026-03-05 12:48:48 +11:00
Peter Stockings 02092bab69 Make tags/highlights panel collapsible and add support for relative time for tags 2026-03-05 12:38:39 +11:00
Peter Stockings 777e08ff85 Add /tag & /time command to allow users to sending clickable timestamps in chat 2026-03-04 23:25:57 +11:00
Peter Stockings d1a87e004e Add keyboard shortcuts
* spacebar: play/pause
* f: toggle full screen
* enter: focus chat
* horizontal arrows: +- 5s seek
* vertical arrows: +- volume
* m: mute
2026-03-04 23:06:01 +11:00
Peter Stockings 6a8dc7e5e6 Update build script to include copy and check images 2026-03-04 23:03:19 +11:00
Peter Stockings c4bef281f7 Add video scrubbing and click to seek/change volume on sliders 2026-03-03 23:53:27 +11:00
Peter Stockings e120813a01 Update readme 2026-03-03 23:31:16 +11:00
Peter Stockings 2e31eab0ca Show system messages when users join/leave room 2026-03-03 23:26:58 +11:00
Peter Stockings b29b540331 Fix issue where canceling join left button disabled 2026-03-03 23:22:12 +11:00
Peter Stockings 43929ea94d Use state machine for syncing playback 2026-03-03 23:15:19 +11:00
Peter Stockings 0e7f80ff1f Add request id to server response 2026-03-03 23:10:04 +11:00
Peter Stockings bd43cd10f6 Refactor desktop client codebase 2026-03-03 22:50:02 +11:00
Peter Stockings de0a7172e7 Allow file drops 2026-03-03 20:25:36 +11:00
Peter Stockings 3eb4b671c6 Add reconnect logic for desktop app 2026-03-03 20:10:05 +11:00
Peter Stockings 92aa926cf0 When joining a room sync video playback if playing 2026-03-03 19:55:18 +11:00
Peter Stockings 4665cf700e Add support for /seek, /play, /pause commands in chat 2026-03-03 19:38:10 +11:00
Peter Stockings a5b7e08a6a Improve look of start screen and add toast to copy room ID button 2026-03-03 19:18:57 +11:00
Peter Stockings 31c79e794e Make chat window scrollable 2026-03-03 19:04:27 +11:00
Peter Stockings 4cf4c153cd Format video time as hh:mm:ss 2026-03-03 19:00:36 +11:00
Peter Stockings 9d22860f0d Add script to build desktop client 2026-03-02 22:24:49 +11:00
Peter Stockings 3e1ea32383 Upate desktop app to point from local to production server 2026-03-02 22:13:19 +11:00
Peter Stockings 36d6aeaf51 Set initial volume in desktop app 2026-03-02 22:07:42 +11:00
Peter Stockings b587b5e87d When joining a room only allow clients to select a video with the same name as the one already selected by room creator 2026-03-02 22:00:51 +11:00
Peter Stockings 475fdbb2b8 Create desktop app due to mkv issue with chrome 2026-03-02 21:54:22 +11:00
Peter Stockings 2020b59259 Remove speed control 2026-03-02 21:28:24 +11:00
36 changed files with 4478 additions and 132 deletions
+23
View File
@@ -3,3 +3,26 @@ bun.lockb
.env
.DS_Store
Thumbs.db
# Python
__pycache__/
*.py[cod]
*$py.class
**/.pytest_cache/
# Environments (uv / venv)
.venv/
env/
venv/
ENV/
# Local media testing files
*.mkv
*.mp4
*.avi
# PyInstaller / Build
build/
dist/
*.spec
*.exe
+59 -15
View File
@@ -2,17 +2,28 @@
Watch videos together in real-time. Each user loads a local video file — only sync commands and chat messages travel over the network.
## Architecture
This project consists of three main components:
- **Server**: A fast, lightweight WebSocket server built with Bun.
- **Web Frontend**: A vanilla HTML/CSS/JS single-page application for joining rooms and syncing video playback.
- **Desktop App**: A Python/PyQt6 application with embedded VLC (`python-vlc`). **Note:** The desktop client was specifically created because web browsers (like Chrome) have issues handling and playing MKV files natively.
## Features
- **Room system** — create a room, share the 6-character code with friends
- **File verification** — joiners must have the exact same file (matched by name + size)
- **Playback sync** — play, pause, seek, and speed changes broadcast to all clients instantly
- **Drift correction** — automatic re-sync every 5 seconds to keep everyone aligned
- **Live chat** — YouTube-style chat sidebar with colored usernames
- **Local playback** — multi-gigabyte files work fine since nothing is uploaded
- **Room system** — create a room, share the 6-character code with friends.
- **File verification** — joiners must have the exact same file (matched by name + size).
- **Playback sync** — play, pause, seek, and speed changes broadcast to all clients instantly.
- **Drift correction** — automatic re-sync every 5 seconds to keep everyone aligned.
- **Live chat** — YouTube-style chat sidebar with colored usernames.
- **Local playback** — multi-gigabyte files work fine since nothing is uploaded.
- **Broad format support** — the desktop app leverages VLC to play MKV and other formats that browsers struggle with.
## Quick Start
### 1. Start the Server
```bash
# Install Bun (if not already installed)
curl -fsSL https://bun.sh/install | bash
@@ -21,15 +32,44 @@ curl -fsSL https://bun.sh/install | bash
bun run server.ts
```
Open **http://localhost:3000** in your browser.
### 2. Connect via Web Frontend
## How It Works
Once the server is running, open **http://localhost:3000** in your browser to access the web app.
1. Enter your name and select a video file → **Create Room**
2. Share the room code with a friend
3. Friend enters the code → **Join Room** → selects their copy of the same file
4. Play/pause/seek in either browser — the other stays in sync
### 3. Connect via Desktop App (Recommended for MKV files)
If you are playing MKV files or formats not supported by your browser, use the desktop client:
```bash
cd desktop-client
# Install dependencies using uv
uv sync
# Run the app
uv run main.py
```
You can also build a standalone executable for the desktop app using the provided `build.ps1` script (requires PyInstaller).
### Quick Run on Linux
If you just want to run the desktop client without cloning the repo manually, use the launcher script. Prerequisites: `git` and [`uv`](https://docs.astral.sh/uv/).
```bash
# Download the script
curl -fsSL https://gitea.peterstockings.com/peterstockings/video-sync/raw/branch/master/desktop-client/run-videosync.sh -o run-videosync.sh
chmod +x run-videosync.sh
# Run (clones on first launch, pulls updates on subsequent runs)
./run-videosync.sh
```
## Deployment
```bash
@@ -53,17 +93,21 @@ location / {
| Component | Technology |
|-----------|-----------|
| Server | Bun (native HTTP + WebSocket) |
| Frontend | Vanilla HTML / CSS / JS |
| Dependencies | None |
| **Server** | Bun (native HTTP + WebSocket) |
| **Web Frontend** | Vanilla HTML / CSS / JS |
| **Desktop App**| Python, PyQt6, python-vlc, websockets |
## Project Structure
```
├── server.ts # Bun WebSocket server
├── package.json
└── public/
├── index.html # Single-page app
├── style.css # Dark theme
└── app.js # Client sync + chat logic
├── public/
│ ├── index.html # Single-page web app
│ ├── style.css # Dark theme
│ └── app.js # Client sync + chat logic
└── desktop-client/
├── main.py # Desktop app entry point
├── pyproject.toml # Python dependencies (uv)
└── vlc_player.py # Embedded VLC player
```
+1
View File
@@ -0,0 +1 @@
3.14
+78
View File
@@ -0,0 +1,78 @@
# 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 lobby checks for listed rooms at startup and every 15 seconds while open.
Select a session and click **Choose file & join**, or double-click it, to use the
existing file verification flow. Enter your display name first. **Refresh** checks
immediately; joining by code remains available when discovery fails.
New rooms are listed by default. Clear **List room in active sessions** before
creating a room to keep it out of the list; anyone with its code can still join.
Only rooms with connected participants appear. Discovery requires the updated
server's `/api/rooms` endpoint; older clients also create listed rooms by default.
## Chat
Chat follows new messages while you are near the bottom. Scroll up to read older
messages without losing your place. Incoming messages appear beneath a **New
messages** divider, and **Jump to latest** shows how many have arrived. Clicking
it, or scrolling back to the bottom, resumes following messages.
## Ready checks
Send `/ready` in chat to start a check for everyone in the room. Playback pauses
and a ready panel appears above chat. Each person clicks **I'm ready**; they can
change their answer with **Not ready**. When everyone is connected and ready,
the person who started the check can click **Start together**. They can also
cancel the check. Repeating `/ready` leaves an existing check and its votes intact.
New participants must confirm readiness. Disconnecting clears that person's
vote; reconnecting restores the panel so they can confirm again. Ordinary play
controls dismiss an active check and continue playback.
Ready checks require the updated server and desktop clients. The web client
does not yet have ready-check controls.
## Fight-night commands
- `/ko` — a large arcade-style K.O. with gold lettering, red outline, and an impact animation.
- `/bell` — DING!: Back to your corners.
- `/tap` — TAP!: Someone's had enough.
- `/robbery` — a reaction plus a 30-second room vote on the decision.
The K.O. appears across the center of the video; other reactions slide into the
bottom. Effects clear after 2.2 seconds and keep playback
running. A shared 3-second cooldown limits room-wide effects. Reaction events
stay in chat history; joining or reconnecting does not replay animations.
Quiet sound effects play automatically with visible reactions. Sounds are
original synthesized effects cached locally, with no external downloads. Overlays and sound are
suppressed while the player is hidden or minimized. Visible players receive
reactions even without keyboard focus, without activating the player window.
Reactions also work while the detached chat window is focused.
Robbery votes allow one answer per person, which you can change before the vote
closes. The result remains visible after closing and can be dismissed. Joining
or reconnecting restores the current vote. Fight commands require the updated
server and desktop clients; web clients currently see the chat reaction only.
## Tests
With `pytest`, `pytest-qt`, and Bun installed, run from this directory:
```powershell
$env:QT_QPA_PLATFORM = 'offscreen'
.\.venv\Scripts\python.exe -m pytest test_chat.py test_sync_client.py test_ready_server.py test_fight_reactions.py test_fight_server.py -q
```
The ready-check integration test starts an isolated local server; it does not
contact the production service. VLC playback is mocked in the chat UI tests.
+2
View File
@@ -0,0 +1,2 @@
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
+108
View File
@@ -0,0 +1,108 @@
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame,
QMainWindow, QSizePolicy
)
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)
# Container for the actual bubble
self.bubble = QFrame()
self.bubble.setObjectName("bubble")
# Preferred/Preferred: bubble sizes to its content but doesn't
# expand to fill all available horizontal space (fixes Linux layout).
self.bubble.setSizePolicy(
QSizePolicy.Policy.Preferred,
QSizePolicy.Policy.Preferred,
)
self.bubble.setMaximumWidth(420)
bg_color = "#0084FF" if is_self else "#2a2a2e"
text_color = "#FFFFFF"
author_color = "#E1E1E1" if is_self else "#3ea6ff"
self.bubble.setStyleSheet(f"""
#bubble {{
background-color: {bg_color};
border-radius: 12px;
}}
""")
bubble_layout = QVBoxLayout(self.bubble)
bubble_layout.setContentsMargins(10, 8, 10, 6)
bubble_layout.setSpacing(2)
# 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)
# Text — on Linux, word-wrapping QLabels inside HBoxLayouts can
# overflow their parent's painted area. Setting Preferred + minimum
# 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(
QSizePolicy.Policy.Preferred,
QSizePolicy.Policy.Preferred,
)
self.text_lbl.setStyleSheet(f"color: {text_color}; font-size: 13px; border: none; background: transparent;")
self.text_lbl.setOpenExternalLinks(False)
self.text_lbl.linkActivated.connect(on_link_clicked)
bubble_layout.addWidget(self.text_lbl)
# Time
time_lbl = QLabel(time_str)
time_lbl.setAlignment(Qt.AlignmentFlag.AlignRight)
time_lbl.setStyleSheet(f"color: {text_color}; font-size: 9px; opacity: 0.6; border: none; background: transparent;")
bubble_layout.addWidget(time_lbl)
if is_self:
layout.addStretch()
layout.addWidget(self.bubble)
else:
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):
super().__init__()
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 5, 0, 5)
lbl = QLabel(text)
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl.setWordWrap(True)
lbl.setStyleSheet("color: #888; font-style: italic; font-size: 11px; background: transparent;")
layout.addWidget(lbl)
class ChatPopoutWindow(QMainWindow):
closed = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Live Chat")
self.resize(350, 600)
def closeEvent(self, event):
self.closed.emit()
super().closeEvent(event)
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4BB543" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 237 B

+117
View File
@@ -0,0 +1,117 @@
from utils import format_time_short
HELP_TEXT = """
<b>Available Commands:</b><br>
• <b>/play</b>, <b>/pause</b> - Control playback<br>
• <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>
• <b>F</b>: Fullscreen | <b>M</b>: Mute<br>
• <b>Left/Right</b>: Seek ±5s<br>
• <b>Up/Down</b>: Volume ±5%<br>
• <b>Enter</b>: Focus chat | <b>Esc</b>: Clear focus
"""
def parse_time_arg(arg: str, current_ms: int, length_ms: int):
"""Parses a time string (absolute time, offset like +30s, or 'now') and returns the target time in MS. Returns None on error."""
if arg == "now":
return current_ms
try:
target_ms = 0
if arg.startswith('+') or arg.startswith('-'):
modifier = 1 if arg.startswith('+') else -1
num_str = arg[1:]
if num_str.endswith('s'): val = float(num_str[:-1]) * 1000
elif num_str.endswith('m'): val = float(num_str[:-1]) * 60 * 1000
elif num_str.endswith('h'): val = float(num_str[:-1]) * 3600 * 1000
else: val = float(num_str) * 1000
target_ms = current_ms + (val * modifier)
elif ":" in arg:
parts = arg.split(":")
parts.reverse()
if len(parts) > 0: target_ms += float(parts[0]) * 1000
if len(parts) > 1: target_ms += float(parts[1]) * 60 * 1000
if len(parts) > 2: target_ms += float(parts[2]) * 3600 * 1000
else:
if arg.endswith('s'): target_ms = float(arg[:-1]) * 1000
elif arg.endswith('m'): target_ms = float(arg[:-1]) * 60 * 1000
elif arg.endswith('h'): target_ms = float(arg[:-1]) * 3600 * 1000
else: target_ms = float(arg) * 1000
if length_ms > 0:
return max(0, min(target_ms, length_ms))
return max(0, target_ms)
except ValueError:
return None
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()
widget.add_system_message(" ".join(parts))
return True
elif cmd == "/pause":
if widget.vlc_player.is_playing:
widget.toggle_playback()
widget.add_system_message(" ".join(parts))
return True
elif cmd == "/seek":
if len(parts) > 1:
if widget._handle_seek_command(parts[1]):
widget.add_system_message(" ".join(parts))
else:
widget.add_system_message("Invalid time format. Use: 1:23, +30s, -1m")
else:
widget.add_system_message("Usage: /seek [time]")
return True
elif cmd == "/tag":
if len(parts) > 1:
time_arg = parts[1].lower()
tag_name = " ".join(parts[2:])
current_ms = widget.vlc_player.current_time_ms
length_ms = widget.vlc_player.get_length()
target_ms = parse_time_arg(time_arg, current_ms, length_ms)
if target_ms is not None:
time_str = format_time_short(target_ms)
msg = f"{time_str} {tag_name}".strip()
widget.chat_message_ready.emit(msg)
else:
widget.add_system_message("Invalid time format. Use: now, 1:23, +30s, -1m")
else:
widget.add_system_message("Usage: /tag [now|time] [name]")
return True
elif cmd == "/time":
current_ms = widget.vlc_player.current_time_ms
time_str = format_time_short(current_ms)
widget.add_system_message(f"Current time: <a href='seek:{time_str}' style='color: #66b3ff; text-decoration: none;'>{time_str}</a>")
return True
elif cmd == "/help":
widget.add_system_message(HELP_TEXT)
return True
return False
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#aaaaaa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>

After

Width:  |  Height:  |  Size: 333 B

+48
View File
@@ -0,0 +1,48 @@
from PyQt6.QtWidgets import QSlider, QToolTip
from PyQt6.QtCore import Qt
class ClickableSlider(QSlider):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._get_tooltip_text = None
self.setMouseTracking(True)
def set_tooltip_provider(self, provider_func):
self._get_tooltip_text = provider_func
def mousePressEvent(self, event):
super().mousePressEvent(event)
if event.button() == Qt.MouseButton.LeftButton:
val = 0
if self.orientation() == Qt.Orientation.Horizontal:
if self.width() > 0:
val = self.minimum() + ((self.maximum() - self.minimum()) * event.pos().x()) / self.width()
else:
if self.height() > 0:
val = self.minimum() + ((self.maximum() - self.minimum()) * (self.height() - event.pos().y())) / self.height()
val = max(self.minimum(), min(self.maximum(), int(val)))
self.setValue(val)
self.sliderMoved.emit(val)
if self._get_tooltip_text:
text = self._get_tooltip_text(val)
if text:
QToolTip.showText(event.globalPosition().toPoint(), text, self)
def mouseMoveEvent(self, event):
super().mouseMoveEvent(event)
if self._get_tooltip_text and self.width() > 0:
hover_val = self.minimum() + ((self.maximum() - self.minimum()) * event.pos().x()) / self.width()
hover_val = max(self.minimum(), min(self.maximum(), int(hover_val)))
text = self._get_tooltip_text(hover_val)
if text:
QToolTip.showText(event.globalPosition().toPoint(), text, self)
def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
if event.button() == Qt.MouseButton.LeftButton:
# Explicitly emit sliderReleased on mouse release
# to ensure single clicks on the track also trigger the release logic
self.sliderReleased.emit()
+223
View File
@@ -0,0 +1,223 @@
"""Native ringside overlay and small, original synthesized reaction sounds."""
import math
from pathlib import Path
import struct
import wave
from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QVariantAnimation, QPoint, QEasingCurve, QEvent
from PyQt6.QtGui import QPainter, QPainterPath, QFont, QColor, QPen, QLinearGradient, QTransform
from PyQt6.QtWidgets import QWidget, QFrame, QLabel, QHBoxLayout, QVBoxLayout
REACTIONS = {
"ko": ("K.O.", "That's a wrap.", "#ff5676"),
"bell": ("DING!", "Back to your corners.", "#f5c86c"),
"tap": ("TAP!", "Someone's had enough.", "#e1e1e1"),
"robbery": ("ROBBERY?", "Judges, explain yourselves.", "#f5c86c"),
}
def write_reaction_sound(directory, kind):
"""Generate mono PCM once per cache directory, with no downloaded samples."""
if kind not in REACTIONS:
raise ValueError("Unknown reaction")
path = Path(directory) / f"{kind}.wav"
if path.exists():
return path
path.parent.mkdir(parents=True, exist_ok=True)
rate = 22050
duration = .85 if kind == "bell" else .4
samples = bytearray()
for i in range(int(rate * duration)):
t = i / rate
attack = min(1, t / .008)
if kind == "ko":
value = math.sin(2 * math.pi * (130 * t - 100 * t * t)) * math.exp(-13 * t)
elif kind == "bell":
value = sum(math.sin(2 * math.pi * f * t) * a for f, a in
((880, .55), (1765, .25), (2410, .12))) * math.exp(-5 * t)
elif kind == "tap":
pulse = t if t < .16 else t - .16
value = math.sin(2 * math.pi * 420 * pulse) * math.exp(-45 * pulse)
else:
value = math.sin(2 * math.pi * 145 * t) * math.sin(2 * math.pi * 22 * t) * math.exp(-8 * t)
release = min(1, (duration - t) / .02)
samples.extend(struct.pack("<h", int(16000 * value * attack * release)))
with wave.open(str(path), "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(rate)
output.writeframes(samples)
return path
class ArcadeKnockout(QWidget):
"""Vector-painted arcade lettering scales cleanly with the video surface."""
def __init__(self, parent):
super().__init__(parent)
self.progress = 0.0
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
def set_progress(self, value):
self.progress = value
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
t = self.progress
opacity = min(1.0, t / .035, max(0.0, (1.0 - t) / .18))
painter.setOpacity(opacity)
painter.translate(self.width() / 2, self.height() / 2)
entry = min(1.0, t / .18)
scale = 1.0 + .65 * (1 - entry) ** 3 - .06 * math.sin(entry * math.pi)
painter.scale(scale, scale)
font = QFont("Impact")
font.setPixelSize(180)
font.setBold(True)
letters = QPainterPath()
letters.addText(0, 0, font, "K.O.")
letters = QTransform().shear(-.12, 0).map(letters)
bounds = letters.boundingRect()
fit = min(self.width() * .63 / bounds.width(), self.height() * .40 / bounds.height())
painter.scale(fit, fit)
painter.translate(-bounds.center())
shadow = QTransform().translate(7, 10).map(letters)
painter.setPen(QPen(QColor("#131017"), 18, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
painter.setBrush(QColor("#131017"))
painter.drawPath(shadow)
painter.setPen(QPen(QColor("#8b1028"), 14, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
painter.setBrush(QColor("#f33c35"))
painter.drawPath(letters)
gradient = QLinearGradient(0, bounds.top(), 0, bounds.bottom())
gradient.setColorAt(0, QColor("#fff7b0"))
gradient.setColorAt(.40, QColor("#ffe252"))
gradient.setColorAt(1, QColor("#ff9c22"))
painter.setPen(QPen(QColor("#ff583e"), 5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
painter.setBrush(gradient)
painter.drawPath(letters)
if t < .32:
painter.setOpacity(opacity * (1 - t / .32) * .55)
painter.setClipPath(letters)
sweep = bounds.left() + bounds.width() * min(1.0, t / .32)
painter.fillRect(int(sweep), int(bounds.top() - 10), 16, int(bounds.height() + 20), QColor("#fffef0"))
class RingsideStrip(QWidget):
"""A separate native window stays above VLC's native video surface."""
def __init__(self, video_frame):
super().__init__(video_frame, Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowDoesNotAcceptFocus | Qt.WindowType.WindowTransparentForInput)
self.video_frame = video_frame
self.owner_window = None
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
self.panel = QFrame()
outer.addWidget(self.panel)
self.ko_canvas = ArcadeKnockout(self)
outer.addWidget(self.ko_canvas)
self.ko_canvas.hide()
self.kind = None
self.ko_animation = QVariantAnimation(self)
self.ko_animation.setDuration(2200)
self.ko_animation.setStartValue(0.0)
self.ko_animation.setEndValue(1.0)
self.ko_animation.valueChanged.connect(self.ko_canvas.set_progress)
row = QHBoxLayout(self.panel)
row.setContentsMargins(14, 10, 14, 10)
row.setSpacing(14)
self.headline = QLabel()
self.headline.setTextFormat(Qt.TextFormat.PlainText)
self.caption = QLabel()
self.caption.setTextFormat(Qt.TextFormat.PlainText)
self.caption.setWordWrap(True)
self.caption.setMinimumWidth(0)
self.caption.setStyleSheet("color: #f2f2f5; background: transparent; font-size: 13px;")
self.metadata = QLabel()
self.metadata.setStyleSheet("color: #a6a9b5; background: transparent; font-size: 11px;")
details = QVBoxLayout()
details.setSpacing(3)
details.addWidget(self.caption)
details.addWidget(self.metadata)
row.addWidget(self.headline)
row.addLayout(details, 1)
self.dismiss_timer = QTimer(self)
self.dismiss_timer.setSingleShot(True)
self.dismiss_timer.timeout.connect(self.dismiss)
self.slide = QPropertyAnimation(self, b"pos", self)
self.slide.setDuration(180)
self.slide.setEasingCurve(QEasingCurve.Type.OutCubic)
video_frame.installEventFilter(self)
def show_reaction(self, kind, author, position):
if kind not in REACTIONS or not self.video_frame.isVisible():
return False
owner = self.video_frame.window()
if owner.isMinimized():
return False
if self.owner_window is not owner:
if self.owner_window is not None:
self.owner_window.removeEventFilter(self)
self.owner_window = owner
owner.installEventFilter(self)
title, caption, color = REACTIONS[kind]
self.headline.setText(title)
self.headline.setStyleSheet(f"color: {color}; background: transparent; font-size: 24px; font-weight: bold;")
self.caption.setText(f"{author}: {caption}")
self.metadata.setText(f"Room reaction · {position}")
self.panel.setStyleSheet(f"QFrame {{ background: #1a1b20; border-left: 3px solid {color}; border-radius: 6px; }} QLabel {{ border: none; }}")
self.kind = kind
self.panel.setVisible(kind != "ko")
self.ko_canvas.setVisible(kind == "ko")
self.ko_animation.stop()
self.slide.stop()
self._place()
if kind == "ko":
self.ko_canvas.set_progress(0.0)
self.show()
self.ko_animation.start()
self.dismiss_timer.start(2200)
return True
end = self.pos()
self.move(end + QPoint(0, 12))
self.show()
self.slide.setStartValue(self.pos())
self.slide.setEndValue(end)
self.slide.start()
self.dismiss_timer.start(2200)
return True
def _place(self):
if self.kind == "ko":
self.setFixedSize(self.video_frame.size())
self.move(self.video_frame.mapToGlobal(QPoint(0, 0)))
return
self.setMinimumSize(0, 0)
self.setMaximumSize(16777215, 16777215)
width = max(1, min(540, self.video_frame.width() - 32))
self.setFixedWidth(width)
height = self.layout().heightForWidth(width)
self.setFixedHeight(max(64, height))
origin = self.video_frame.mapToGlobal(QPoint(16, self.video_frame.height() - self.height() - 16))
self.move(origin)
def eventFilter(self, watched, event):
if self.isVisible():
if event.type() in (QEvent.Type.Hide, QEvent.Type.Close):
self.dismiss()
elif event.type() == QEvent.Type.WindowStateChange and self.owner_window.isMinimized():
self.dismiss()
elif event.type() in (QEvent.Type.Move, QEvent.Type.Resize):
self.slide.stop()
self._place()
return super().eventFilter(watched, event)
def dismiss(self):
self.ko_animation.stop()
self.slide.stop()
self.dismiss_timer.stop()
self.hide()
Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

+249
View File
@@ -0,0 +1,249 @@
import os
import re
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QFileDialog, QFrame, QApplication, QCheckBox, QListWidget, QListWidgetItem
)
from PyQt6.QtCore import Qt, pyqtSignal
class RoomCodeInput(QLineEdit):
"""QLineEdit that auto-fills from clipboard if it looks like a room code."""
def focusInEvent(self, event):
super().focusInEvent(event)
if not self.text().strip():
self._try_paste_room_code()
def _try_paste_room_code(self):
clipboard = QApplication.clipboard()
text = (clipboard.text() or "").strip().upper()
if re.match(r'^[A-Z0-9]{4,8}$', text):
self.setText(text)
self.selectAll()
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__()
self.setAcceptDrops(True)
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):
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Container for centering
container = QFrame()
container.setObjectName("lobbyCard")
container.setMinimumWidth(420)
container.setMaximumWidth(500)
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(30, 30, 30, 30)
container_layout.setSpacing(15)
# 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)
# Username
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.create_file_btn.clicked.connect(self.select_file)
self.create_file_info = QLabel("")
self.create_file_info.setObjectName("fileInfo")
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)
self.create_room_btn.clicked.connect(self.create_room)
create_panel.addWidget(self.create_file_btn)
create_panel.addWidget(self.create_file_info)
self.listed_checkbox = QCheckBox("List room in active sessions")
self.listed_checkbox.setChecked(True)
self.listed_checkbox.setToolTip("Unlisted rooms can still be joined using their code.")
create_panel.addWidget(self.listed_checkbox)
create_panel.addWidget(self.create_room_btn)
# Join Room Panel
join_panel = QVBoxLayout()
join_panel.addWidget(QLabel("Join a Room"))
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.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)
panels = QHBoxLayout()
panels.addWidget(container)
sessions = QVBoxLayout()
heading = QHBoxLayout()
heading.addWidget(QLabel("ALREADY WATCHING"))
self.refresh_btn = QPushButton("Refresh")
self.refresh_btn.clicked.connect(self.refresh_requested.emit)
heading.addWidget(self.refresh_btn)
sessions.addLayout(heading)
self.sessions_status = QLabel("Checking for active sessions…")
self.sessions_status.setWordWrap(True)
sessions.addWidget(self.sessions_status)
self.sessions_list = QListWidget()
self.sessions_list.setWordWrap(True)
self.sessions_list.setMinimumWidth(280)
sessions.addWidget(self.sessions_list)
self.session_join_btn = QPushButton("Choose file & join")
self.session_join_btn.setEnabled(False)
self.session_join_btn.clicked.connect(self.join_selected_session)
self.sessions_list.itemDoubleClicked.connect(lambda _: self.join_selected_session())
self.sessions_list.itemSelectionChanged.connect(self.check_inputs)
sessions.addWidget(self.session_join_btn)
panels.addLayout(sessions)
layout.addLayout(panels)
# Signals to enable/disable buttons
self.username_input.textChanged.connect(self.check_inputs)
self.room_code_input.textChanged.connect(self.check_inputs)
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")
self.create_file_info.show()
self.check_inputs()
def select_file(self):
file_path, _ = QFileDialog.getOpenFileName(
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)
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.accept()
else:
event.ignore()
def dropEvent(self, event):
for url in event.mimeData().urls():
file_path = url.toLocalFile()
if os.path.isfile(file_path):
ext = os.path.splitext(file_path)[1].lower()
if ext in ['.mp4', '.mkv', '.avi', '.mov', '.webm']:
self._set_local_file(file_path)
break
def check_inputs(self):
has_name = len(self.username_input.text().strip()) > 0
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.session_join_btn.setEnabled(has_name and self.sessions_list.currentItem() is not None)
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.setData(Qt.ItemDataRole.UserRole, room['code'])
self.sessions_list.addItem(item)
if room['code'] == code:
self.sessions_list.setCurrentItem(item)
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):
username = self.username_input.text().strip()
self.create_room_btn.setText("Connecting...")
self.create_room_btn.setEnabled(False)
self.join_room_btn.setEnabled(False)
self.create_requested.emit(username, self.local_file_path, self.local_file_name, self.local_file_size)
def join_room(self):
username = self.username_input.text().strip()
room_code = self.room_code_input.text().strip().upper()
self.join_room_btn.setText("Connecting...")
self.join_room_btn.setEnabled(False)
self.create_room_btn.setEnabled(False)
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.check_inputs()
def clear_file(self):
self.local_file_path = None
self.local_file_name = None
self.local_file_size = 0
self.create_file_info.hide()
self.check_inputs()
+601
View File
@@ -0,0 +1,601 @@
import sys
import os
import ctypes
import json
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QStackedWidget, QFileDialog, QMessageBox
)
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
from lobby_widget import LobbyWidget
from room_widget import RoomWidget
class VlcSyncApp(QMainWindow):
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")
if os.path.exists(icon_path):
self.setWindowIcon(QIcon(icon_path))
self.setMinimumSize(900, 600)
self.resize(1100, 700)
# Main stacked widget
self.stacked_widget = QStackedWidget()
self.setCentralWidget(self.stacked_widget)
# Setup Views
self.lobby_widget = LobbyWidget()
self.room_widget = RoomWidget()
self.stacked_widget.addWidget(self.lobby_widget)
self.stacked_widget.addWidget(self.room_widget)
# Connect View Signals
self.lobby_widget.create_requested.connect(self._on_lobby_create)
self.lobby_widget.join_requested.connect(self._on_lobby_join)
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 = ""
self.room_code = ""
self.local_file_path = None
self.local_file_name = None
self.local_file_size = 0
self.pending_connect_action = None
# Network Service
self.sync_client = SyncClientThread("wss://video-sync.peterstockings.com/ws")
self.sync_client.connected.connect(self.on_ws_connected)
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)
self.sync_client.file_check_needed.connect(self.on_file_check_needed)
self.sync_client.users_updated.connect(self.room_widget.update_users)
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.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
self.local_file_path = file_path
self.local_file_name = file_name
self.local_file_size = file_size
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,
"size": self.local_file_size,
"duration": 0
}
})
self._ensure_connection()
def _on_lobby_join(self, username, room_code):
self.username = username
self.room_code = room_code
self.pending_connect_action = lambda: self.sync_client.send_message({
"type": "join_room",
"username": self.username,
"code": self.room_code
})
self._ensure_connection()
def _ensure_connection(self):
if self.sync_client.running and self.sync_client.ws:
self.on_ws_connected()
else:
self.sync_client.start()
def _on_room_leave(self):
self.room_widget.cleanup()
self.sync_client.stop()
self.stacked_widget.setCurrentIndex(0)
self.room_code = ""
self.local_file_path = None
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})
def _on_room_chat(self, text):
self.sync_client.send_message({"type": "chat", "message": text})
# --- WebSocket Callbacks ---
def on_ws_connected(self):
if self.pending_connect_action:
self.pending_connect_action()
self.pending_connect_action = None
elif self.stacked_widget.currentIndex() == 1 and self.room_code and self.username:
self.sync_client.send_message({
"type": "rejoin_room",
"username": self.username,
"code": self.room_code
})
def on_ws_disconnected(self):
if self.stacked_widget.currentIndex() == 1:
self.room_widget.set_room_code_display(f"{self.room_code} (Reconnecting...)")
self.room_widget.add_system_message("⚠️ Connection lost. Trying to reconnect...")
else:
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()
self.sync_client.stop()
def on_file_check_needed(self, msg: dict):
QTimer.singleShot(0, lambda: self._handle_file_check(msg))
def _handle_file_check(self, msg: dict):
req_name = msg["fileInfo"].get("name", "Unknown")
req_size = msg["fileInfo"].get("size", 0)
QMessageBox.information(self, "File Required", f"To join this room, you need to select:\n\nName: {req_name}\nSize: {req_size / (1024*1024):.1f} MB")
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}", 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)
if self.local_file_name.lower() == req_name.lower() and int(self.local_file_size) == int(req_size):
self.sync_client.send_message({
"type": "confirm_join",
"fileInfo": {
"name": self.local_file_name,
"size": self.local_file_size,
"duration": 0
}
})
else:
err_msg = f"Expected: {req_name} ({req_size} bytes)\nGot: {self.local_file_name} ({self.local_file_size} bytes)"
QMessageBox.critical(self, "File Mismatch", f"The selected file does not exactly match the room's required file.\n\n{err_msg}")
self.sync_client.stop()
self.lobby_widget.reset_ui()
else:
self.sync_client.stop()
self.lobby_widget.reset_ui()
def on_room_joined(self, msg: dict):
if "room" in msg:
self.room_code = msg["room"]["code"]
else:
self.room_code = msg.get("code", "")
self.stacked_widget.setCurrentIndex(1)
self.lobby_widget.reset_ui()
state = msg.get("state", {})
start_time_s = state.get("position", 0.0) if state else 0.0
self.room_widget.setup_room(self.room_code, self.username, self.local_file_name, self.local_file_path, start_time_s)
chat_history = msg.get("chatHistory", [])
if chat_history:
for chat in chat_history:
self.room_widget.add_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0))
users = msg.get("users", [])
if users:
self.room_widget.update_users(users)
if state:
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)
chat_history = msg.get("chatHistory", [])
if chat_history:
self.room_widget.clear_chat()
for chat in chat_history:
self.room_widget.add_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0))
self.room_widget.add_system_message("✅ Reconnected to the room.")
users = msg.get("users", [])
if users:
self.room_widget.update_users(users)
state = msg.get("state", {})
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 {
background-color: #0f0f0f;
color: #f1f1f1;
font-family: 'Segoe UI', 'Roboto', sans-serif;
font-size: 14px;
}
#lobbyCard {
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
}
#brandTitle {
font-size: 32px;
font-weight: bold;
color: #3ea6ff;
}
#brandTagline {
font-size: 14px;
color: #aaaaaa;
}
QLabel {
color: #aaaaaa;
font-weight: bold;
font-size: 12px;
background-color: transparent;
}
#fileInfo {
background-color: rgba(62, 166, 255, 0.1);
color: #3ea6ff;
padding: 10px;
border-radius: 6px;
font-weight: normal;
font-size: 13px;
}
QLineEdit {
background-color: #272727;
border: 1px solid #333;
border-radius: 6px;
padding: 10px;
color: white;
font-size: 14px;
}
QLineEdit:focus {
border: 1px solid #3ea6ff;
}
QPushButton {
border-radius: 6px;
padding: 10px 16px;
font-weight: bold;
font-size: 14px;
background-color: #272727;
color: white;
border: 1px solid #444;
}
QPushButton:hover {
background-color: #333;
}
QPushButton:disabled {
border-color: #222;
color: #666;
}
#primaryBtn {
background-color: #3ea6ff;
color: black;
border: none;
}
#primaryBtn:hover {
background-color: #65b8ff;
}
#primaryBtn:disabled {
background-color: #333;
color: #666;
}
#iconBtn {
background-color: transparent;
border: none;
padding: 5px;
}
#iconBtn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
#dangerBtn {
background-color: transparent;
color: #ff4e45;
border: 1px solid #ff4e45;
padding: 6px 12px;
font-size: 12px;
}
#dangerBtn:hover {
background-color: #ff4e45;
color: white;
}
#divider {
color: #555;
}
#topbar {
background-color: #1a1a1a;
border-bottom: 1px solid #333;
}
#fileBadge {
background-color: #272727;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: normal;
}
#controlsBar {
background-color: #1a1a1a;
} #playBtn {
background: transparent;
color: white;
font-size: 20px;
border: none;
} #playBtn:hover {
background: #333;
}
#seekBar {
border: none;
background: transparent;
}
#seekBar::groove:horizontal {
height: 4px;
background: #444;
border: none;
border-radius: 2px;
}
#seekBar::sub-page:horizontal {
background: #ff0000;
height: 4px;
border-radius: 2px;
}
#seekBar::add-page:horizontal {
background: #444;
height: 4px;
border-radius: 2px;
}
#seekBar::handle:horizontal {
background: #ff0000;
width: 12px;
height: 12px;
margin: -4px 0;
border-radius: 6px;
}
#seekBar::handle:horizontal:hover {
background: #ff3333;
width: 14px;
height: 14px;
margin: -5px 0;
border-radius: 7px;
}
#volumeSlider {
border: none;
background: transparent;
}
#volumeSlider::groove:horizontal {
height: 4px;
background: #444;
border: none;
border-radius: 2px;
}
#volumeSlider::sub-page:horizontal {
background: #fff;
height: 4px;
border-radius: 2px;
}
#volumeSlider::add-page:horizontal {
background: #444;
height: 4px;
border-radius: 2px;
}
#volumeSlider::handle:horizontal {
background: #fff;
width: 10px;
height: 10px;
margin: -3px 0;
border-radius: 5px;
}
#volumeSlider::handle:horizontal:hover {
width: 12px;
height: 12px;
margin: -4px 0;
border-radius: 6px;
}
#chatContainer {
background-color: #111;
border-left: 1px solid #333;
}
#chatHeader {
font-size: 16px;
color: white;
padding: 10px 0;
}
#usersLbl {
color: #3ea6ff;
font-size: 12px;
}
#chatMessages {
background-color: transparent;
border: none;
}
#chatMessages QScrollBar:vertical {
border: none;
background: transparent;
width: 8px;
margin: 0px;
}
#chatMessages QScrollBar::handle:vertical {
background: #333;
min-height: 20px;
border-radius: 4px;
}
#chatMessages QScrollBar::handle:vertical:hover {
background: #444;
}
#chatMessages QScrollBar::add-line:vertical, #chatMessages QScrollBar::sub-line:vertical {
height: 0px;
}
#chatMessages QScrollBar::add-page:vertical, #chatMessages QScrollBar::sub-page:vertical {
background: none;
}
#bubble {
border: none;
}
""")
if __name__ == "__main__":
import ctypes
if os.name == 'nt':
myappid = 'vlcsync.desktopclient.app.1'
try:
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except Exception:
pass
app = QApplication(sys.argv)
icon_path = os.path.join(os.path.dirname(__file__), "icon.png")
if os.path.exists(icon_path):
app.setWindowIcon(QIcon(icon_path))
window = VlcSyncApp()
window.show()
sys.exit(app.exec())
+14
View File
@@ -0,0 +1,14 @@
[project]
name = "python-app"
version = "0.1.0"
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",
"python-vlc>=3.0.21203",
"websockets>=16.0",
]
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# VideoSync Desktop Client Launcher
# Fetches the latest version from the repo and runs the desktop client.
# Prerequisites: git, uv
set -e
REPO_URL="https://gitea.peterstockings.com/peterstockings/video-sync.git"
INSTALL_DIR="$HOME/.videosync"
echo "🎬 VideoSync Desktop Client Launcher"
echo "======================================"
# Clone or pull the latest version
if [ -d "$INSTALL_DIR" ]; then
echo "📥 Pulling latest changes..."
git -C "$INSTALL_DIR" pull --ff-only
else
echo "📥 Cloning repository..."
git clone "$REPO_URL" "$INSTALL_DIR"
fi
echo "🚀 Launching VideoSync..."
cd "$INSTALL_DIR/desktop-client"
uv run main.py
+68
View File
@@ -0,0 +1,68 @@
"""Verify a deployed server with two isolated clients: python smoke_commands.py --url wss://host/ws."""
import argparse
import json
import ssl
import time
import certifi
from websockets.sync.client import connect
def verify_commands(url):
options = {'ssl': ssl.create_default_context(cafile=certifi.where())} if url.startswith('wss:') else {}
def send(ws, **message):
ws.send(json.dumps(message))
def receive(ws, kind):
deadline = time.monotonic() + 8
while True:
message = json.loads(ws.recv(timeout=max(.01, deadline - time.monotonic())))
if message['type'] in ('error', 'reaction_error'):
raise RuntimeError(message.get('message'))
if message['type'] == kind:
return message
with connect(url, **options) as host, connect(url, **options) as guest:
file_info = {'name': 'command-smoke-test.mkv', 'size': 1234, 'duration': 60}
send(host, type='create_room', username='CommandSmokeHost', fileInfo=file_info)
code = receive(host, 'room_created')['code']
send(guest, type='join_room', username='CommandSmokeGuest', code=code)
receive(guest, 'room_file_check')
send(guest, type='confirm_join', fileInfo=file_info)
receive(guest, 'room_joined')
receive(host, 'user_joined')
send(host, type='ready_check', action='begin')
check = receive(host, 'ready_check')
assert check['active'] and receive(guest, 'ready_check')['id'] == check['id']
for ws in (host, guest):
send(ws, type='ready_check', action='vote', id=check['id'], ready=True)
for peer in (host, guest):
receive(peer, 'ready_check')
send(host, type='ready_check', action='start', id=check['id'])
for peer in (host, guest):
assert receive(peer, 'sync')['action'] == 'play'
assert not receive(peer, 'ready_check')['active']
print('PASS /ready: shared panel state, votes, synchronized start', flush=True)
for i, kind in enumerate(('ko', 'bell', 'tap', 'robbery')):
if i:
time.sleep(3.2)
send(host, type='reaction', kind=kind)
for peer in (host, guest):
assert receive(peer, 'reaction')['kind'] == kind
assert receive(peer, 'chat')['message'] == '🥊 /' + kind
print(f'PASS /{kind}: delivered to both clients', flush=True)
poll = receive(host, 'fight_poll')
assert receive(guest, 'fight_poll')['id'] == poll['id']
send(guest, type='fight_poll_vote', id=poll['id'], choice='yes')
for peer in (host, guest):
assert receive(peer, 'fight_poll')['votes']['CommandSmokeGuest'] == 'yes'
send(host, type='request_state')
assert receive(host, 'state')['state']['playing']
print('PASS /robbery vote and uninterrupted playback', flush=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--url', required=True)
verify_commands(parser.parse_args().url)
+179
View File
@@ -0,0 +1,179 @@
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
users_updated = pyqtSignal(list)
chat_message = pyqtSignal(str, str, int) # author, text, timestamp
system_message = pyqtSignal(str)
sync_event = pyqtSignal(dict)
ready_check_updated = pyqtSignal(dict)
fight_reaction = pyqtSignal(dict)
fight_poll_updated = pyqtSignal(dict)
latency_updated = pyqtSignal(int) # latency in ms
def __init__(self, url="ws://localhost:3000/ws"):
super().__init__()
self.url = url
self.ws = None
self.loop = None
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)
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
if self.ws and self.loop:
asyncio.run_coroutine_threadsafe(self.ws.close(), self.loop)
self.quit()
self.wait()
def send_message(self, message: dict):
"""Called safely from the main PyQt thread to send data out"""
if self.ws and self.loop:
json_str = json.dumps(message)
asyncio.run_coroutine_threadsafe(self.ws.send(json_str), self.loop)
async def _ping_loop(self, ws):
"""Sends WebSocket protocol-level pings every 5s to measure latency."""
while self.running:
try:
pong = await ws.ping()
sent_at = time.time()
await asyncio.wait_for(pong, timeout=5)
latency_ms = int((time.time() - sent_at) * 1000)
self.latency_updated.emit(latency_ms)
except Exception:
break
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:
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:
if not self.running:
break
self._handle_message(json.loads(message))
except websockets.ConnectionClosed:
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}")
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", "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),
)
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"))
+60
View File
@@ -0,0 +1,60 @@
import pytest
from PyQt6.QtCore import Qt
from main import VlcSyncApp
def test_app_ui_flow(qtbot):
# Initialize the main application
app = VlcSyncApp()
qtbot.addWidget(app)
# 1. Test Lobby View
assert app.stacked_widget.currentIndex() == 0
assert not app.lobby_widget.create_room_btn.isEnabled()
# Fill out the Lobby Form
app.lobby_widget.username_input.setText("PyTestUser")
# Mocking file selection instead of opening the native dialog
app.lobby_widget.local_file_path = "test_pytest.mkv"
app.lobby_widget.local_file_name = "test_pytest.mkv"
app.lobby_widget.local_file_size = 1048576
app.lobby_widget.check_inputs()
# Button should now be active
assert app.lobby_widget.create_room_btn.isEnabled()
# 2. Test Creating Room matches integration pipeline
qtbot.mouseClick(app.lobby_widget.create_room_btn, Qt.MouseButton.LeftButton)
# Wait for the WebSocket connected signal, the room_created server response, and UI transition
def check_room_joined():
assert app.stacked_widget.currentIndex() == 1
assert len(app.room_code) > 2
qtbot.waitUntil(check_room_joined, timeout=5000)
# 3. Test Chat flow End-to-End
# Type a message
qtbot.keyClicks(app.room_widget.chat_input, "Automated UI Test Message")
# Click Send
qtbot.mouseClick(app.room_widget.chat_send_btn, Qt.MouseButton.LeftButton)
# Wait until the Bun server grabs the websocket payload, stores it, and broadcasts it back to the UI!
def check_chat_received():
assert "Automated UI Test Message" in app.room_widget.chat_messages.toPlainText()
qtbot.waitUntil(check_chat_received, timeout=3000)
# 4. Test Playback Sync (UI updates and internal flags)
assert app.room_widget.play_btn.text() == "▶"
# Click Play
qtbot.mouseClick(app.room_widget.play_btn, Qt.MouseButton.LeftButton)
def check_playback_started():
assert app.room_widget.play_btn.text() == "⏸"
qtbot.waitUntil(check_playback_started, timeout=2000)
# Clean up background threads
app._on_room_leave()
+219
View File
@@ -0,0 +1,219 @@
from unittest.mock import Mock
import pytest
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtGui import QTextDocument
from PyQt6.QtWidgets import QApplication
import room_widget
class PlayerSignals(QObject):
time_changed = pyqtSignal(int)
state_changed = pyqtSignal(bool, int)
@pytest.fixture
def room(qtbot, monkeypatch):
player = Mock(signals=PlayerSignals())
monkeypatch.setattr(room_widget, 'VLCSyncPlayer', lambda frame: player)
monkeypatch.setattr(room_widget.RoomWidget, '_play_reaction_sound', Mock())
widget = room_widget.RoomWidget()
widget.username = 'Me'
qtbot.addWidget(widget)
widget.resize(1050, 500)
widget.show()
return widget
@pytest.mark.parametrize('text', ["It's someone's & my <message>",
"It's at 1:23 & <b>literal</b>",
'line one\nline two', 'literal &#39; entity'])
def test_message_rendering(room, text):
room.add_chat_message("<b>O'Brien</b>", text, 0)
bubble = room.chat_content_layout.itemAt(0).widget()
doc = QTextDocument()
doc.setHtml(bubble.text_lbl.text())
assert doc.toPlainText() == text
if '1:23' in text:
assert 'href="seek:1:23"' in bubble.text_lbl.text()
def test_bubbles_do_not_shrink_or_clip_when_overflowing(room, qtbot):
text = 'A wrapped message with several words. ' * 8
room.add_chat_message('Other', text, 0)
qtbot.wait(100)
first = room.chat_content_layout.itemAt(0).widget()
initial_height = first.height()
for i in range(35):
room.add_chat_message('Me' if i % 2 else 'Other', text, 0)
qtbot.wait(200)
assert room.chat_messages.verticalScrollBar().maximum() > 0
assert first.height() >= initial_height
for width in (220, 450, 280):
room.splitter.setSizes([room.width() - width, width])
qtbot.wait(100)
for i in range(room.chat_content_layout.count()):
bubble = room.chat_content_layout.itemAt(i).widget()
label = bubble.text_lbl
assert label.height() >= label.heightForWidth(label.width())
assert bubble.bubble.rect().contains(label.geometry())
def test_unread_alert_and_focus_clear(room, qtbot, monkeypatch):
alert = Mock()
monkeypatch.setattr(QApplication, 'alert', alert)
room.activateWindow()
room.setFocus()
qtbot.wait(50)
room.add_chat_message('Other', 'hello', 0)
assert room._unread_messages == 1
alert.assert_called_once()
room.add_chat_message('Me', 'reply', 0)
assert room._unread_messages == 1
room.chat_input.setFocus()
qtbot.wait(50)
assert room._unread_messages == 0
room.add_chat_message('Other', 'focused', 0)
assert room._unread_messages == 0
room.chat_container.hide()
room.add_chat_message('Other', 'hidden', 0)
assert room._unread_messages == 1
room.clear_chat()
assert room._unread_messages == 0
def test_popout_focus_and_wrapping(room, qtbot, monkeypatch):
monkeypatch.setattr(QApplication, 'alert', Mock())
room.popout_chat()
popout = room.popout_window
qtbot.addWidget(popout)
popout.activateWindow()
room.chat_input.setFocus()
qtbot.wait(100)
room.add_chat_message('Other', 'Wrapped popout text ' * 40, 0)
qtbot.wait(100)
assert room._unread_messages == 0
bubble = room.chat_content_layout.itemAt(0).widget()
assert bubble.text_lbl.height() >= bubble.text_lbl.heightForWidth(bubble.text_lbl.width())
room.activateWindow()
room.setFocus()
qtbot.wait(100)
room.add_chat_message('Other', 'Background popout', 0)
assert room._unread_messages == 1
popout.close()
def test_scrolling_preserves_position_and_counts_unread(room, qtbot):
for i in range(30):
room.add_chat_message('Other', f'Message {i}: ' + 'wrapped words ' * 10, 0)
qtbot.wait(150)
bar = room.chat_messages.verticalScrollBar()
assert bar.value() == bar.maximum()
bar.setValue(100)
qtbot.wait(30)
position = bar.value()
room.add_chat_message('Other', 'First unread message', 0)
room.add_chat_message('Other', 'Second unread message', 0)
room.add_system_message('Someone paused')
qtbot.wait(150)
assert bar.value() == position
assert room._pending_chat_messages == 2
assert not room.chat_jump_btn.isHidden()
assert room._unread_divider is not None
room.chat_input.setFocus()
assert room._pending_chat_messages == 2
room.jump_to_latest()
qtbot.wait(150)
assert bar.value() == bar.maximum()
assert room.chat_jump_btn.isHidden()
room.add_chat_message('Other', 'Follow latest', 0)
qtbot.wait(150)
assert bar.value() == bar.maximum()
assert room._pending_chat_messages == 0
def test_scroll_back_to_bottom_clears_pending(room, qtbot):
for i in range(20):
room.add_chat_message('Other', 'Words ' * 30, 0)
qtbot.wait(150)
bar = room.chat_messages.verticalScrollBar()
bar.setValue(0)
room.add_chat_message('Other', 'Unread', 0)
qtbot.wait(100)
bar.setValue(bar.maximum())
assert room._pending_chat_messages == 0
assert room._follow_chat
assert room.chat_jump_btn.isHidden()
room.clear_chat()
assert room._unread_divider is None
assert room.chat_content_layout.count() == 0
def test_ready_command_and_controls(room, qtbot):
with qtbot.waitSignal(room.ready_action_requested) as signal:
room.chat_input.setText('/ready')
room.send_chat()
assert signal.args == [{'type': 'ready_check', 'action': 'begin'}]
assert room.chat_input.text() == ''
state = {'active': True, 'id': 'check-1', 'requestedBy': 'Me', 'participants': [
{'username': 'Me', 'ready': False, 'connected': True},
{'username': "<b>O'Brien</b>", 'ready': True, 'connected': True}]}
room.update_ready_check(state)
assert not room.ready_panel.isHidden()
assert not room.ready_start_btn.isEnabled()
assert "<b>O'Brien</b>" in room.ready_users.text()
with qtbot.waitSignal(room.ready_action_requested) as signal:
room.ready_btn.click()
assert signal.args[0] == {'type': 'ready_check', 'action': 'vote', 'id': 'check-1', 'ready': True}
state['participants'][0]['ready'] = True
room.update_ready_check(state)
assert room.ready_start_btn.isEnabled()
with qtbot.waitSignal(room.ready_action_requested) as signal:
room.ready_start_btn.click()
assert signal.args[0]['action'] == 'start'
state['participants'][1]['connected'] = False
room.update_ready_check(state)
assert not room.ready_start_btn.isEnabled()
assert 'Reconnecting' in room.ready_users.text()
state['requestedBy'] = 'Other'
room.update_ready_check(state)
assert room.ready_start_btn.isHidden()
room.update_ready_check({'active': False})
assert room.ready_panel.isHidden()
def test_ready_panel_buttons_fit_styled_chat(room, qtbot):
from main import VlcSyncApp
VlcSyncApp.apply_stylesheet(room)
room.splitter.setSizes([750, 300])
room.update_ready_check({'active': True, 'id': 'layout', 'requestedBy': 'Me', 'participants': [
{'username': 'Me', 'ready': True, 'connected': True}]})
qtbot.wait(100)
for button in (room.ready_btn, room.ready_start_btn, room.ready_cancel_btn):
assert button.width() >= button.fontMetrics().horizontalAdvance(button.text()) + 20
room.update_connection_status(False)
room.update_ready_check(room._ready_check)
assert not room.ready_start_btn.isEnabled()
assert not room.ready_btn.isEnabled()
def test_app_wires_commands_to_websocket_and_incoming_panels(room, qtbot, monkeypatch):
import main
from sync_client import SyncClientThread
client = SyncClientThread()
client.send_message = Mock()
monkeypatch.setattr(main, 'SyncClientThread', lambda url: client)
app = main.VlcSyncApp()
qtbot.addWidget(app)
for command, expected in [('/ready', {'type': 'ready_check', 'action': 'begin'}),
('/ko', {'type': 'reaction', 'kind': 'ko'}),
('/bell', {'type': 'reaction', 'kind': 'bell'})]:
app.room_widget.chat_input.setText(command)
app.room_widget.send_chat()
client.send_message.assert_called_with(expected)
state = {'type': 'ready_check', 'active': True, 'id': 'wired', 'requestedBy': 'Me',
'participants': [{'username': 'Me', 'ready': False, 'connected': True}]}
client._handle_message(state)
assert app.room_widget._ready_check['id'] == 'wired'
assert not app.room_widget.ready_panel.isHidden()
+33
View File
@@ -0,0 +1,33 @@
import json
from urllib.request import urlopen
from test_ready_server import server_url, send, receive
from websockets.sync.client import connect
def test_discovery_visibility_and_connected_users(server_url):
endpoint = server_url.replace('ws://', 'http://').removesuffix('/ws') + '/api/rooms'
def listing():
with urlopen(endpoint, timeout=3) as response:
assert response.headers['Cache-Control'] == 'no-store'
return json.load(response)['rooms']
assert listing() == []
with connect(server_url) as public, connect(server_url) as private:
file_info = {'name': 'test.mkv', 'size': 1234, 'duration': 60}
send(public, type='create_room', username='Public', fileInfo=file_info)
public_code = receive(public, 'room_created')['code']
send(private, type='create_room', username='Private', fileInfo=file_info, listed=False)
private_code = receive(private, 'room_created')['code']
rooms = listing()
assert len(rooms) == 1
assert rooms[0]['code'] == public_code
assert rooms[0]['users'] == ['Public']
assert rooms[0]['playing'] is False
with connect(server_url) as guest:
send(guest, type='join_room', username='Guest', code=private_code)
receive(guest, 'room_file_check')
send(guest, type='confirm_join', fileInfo=file_info)
receive(guest, 'room_joined')
assert len(listing()) == 1
assert listing() == []
+190
View File
@@ -0,0 +1,190 @@
import time
import wave
from unittest.mock import Mock
import pytest
from PyQt6.QtCore import QEvent, QUrl
from PyQt6.QtMultimedia import QSoundEffect
from PyQt6.QtWidgets import QApplication
from fight_reactions import REACTIONS, write_reaction_sound
from test_chat import room
@pytest.mark.parametrize('kind', REACTIONS)
def test_command_dispatch(room, qtbot, kind):
with qtbot.waitSignal(room.fight_action_requested) as event:
room.chat_input.setText('/' + kind)
room.send_chat()
assert event.args == [{'type': 'reaction', 'kind': kind}]
assert room.chat_input.text() == ''
assert room.chat_content_layout.count() == 0 # Wait for the server echo.
@pytest.mark.parametrize('kind', REACTIONS)
def test_strip_is_native_positioned_and_does_not_steal_focus(room, qtbot, kind):
room.activateWindow()
room.chat_input.setFocus()
qtbot.wait(30)
room.handle_fight_reaction({'kind': kind, 'username': "<b>O'Brien</b>", 'position': 83})
strip = room.ringside_strip
assert strip.isWindow()
assert strip.isVisible()
assert strip.headline.text() == REACTIONS[kind][0]
assert "<b>O'Brien</b>" in strip.caption.text()
assert '01:23' in strip.metadata.text()
qtbot.wait(210)
assert QApplication.focusWidget() is room.chat_input
video = room.video_frame
rect = strip.geometry()
from PyQt6.QtCore import QRect, QPoint
bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size())
assert bounds.contains(rect)
room.resize(1150, 650)
qtbot.wait(50)
bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size())
assert bounds.contains(strip.geometry())
QApplication.sendEvent(room, QEvent(QEvent.Type.Hide))
assert not strip.isVisible()
def test_strip_auto_dismiss_and_cleanup(room, qtbot):
room.activateWindow()
qtbot.wait(30)
room.handle_fight_reaction({'kind': 'ko', 'username': 'Alex', 'position': 0})
assert room.ringside_strip.isVisible()
room.ringside_strip.dismiss_timer.start(30)
qtbot.waitUntil(lambda: not room.ringside_strip.isVisible())
room.handle_fight_reaction({'kind': 'bell', 'username': 'Alex', 'position': 0})
room.cleanup()
assert not room.ringside_strip.isVisible()
assert not room.ringside_strip.dismiss_timer.isActive()
assert not room._fight_poll_timer.isActive()
def test_reactions_work_with_chat_popout_focused(room, qtbot):
room.popout_chat()
popout = room.popout_window
qtbot.addWidget(popout)
popout.activateWindow()
room.chat_input.setFocus()
qtbot.wait(50)
room.handle_fight_reaction({'kind': 'ko', 'username': 'Other', 'position': 0})
assert room.ringside_strip.isVisible()
assert QApplication.focusWidget() is room.chat_input
popout.close()
def test_arcade_ko_covers_video_then_restores_strip(room, qtbot):
room.activateWindow()
qtbot.wait(30)
room.handle_fight_reaction({'kind': 'ko', 'position': 0})
overlay = room.ringside_strip
assert overlay.size() == room.video_frame.size()
assert overlay.panel.isHidden()
assert not overlay.ko_canvas.isHidden()
assert overlay.ko_animation.state() == overlay.ko_animation.State.Running
room.handle_fight_reaction({'kind': 'bell', 'position': 0})
assert overlay.ko_canvas.isHidden()
assert not overlay.panel.isHidden()
assert overlay.width() <= 540
assert overlay.height() < room.video_frame.height()
room.cleanup()
assert overlay.ko_animation.state() == overlay.ko_animation.State.Stopped
def test_sound_is_enabled_by_default_and_hidden_effects_are_suppressed(room, qtbot, monkeypatch):
play = Mock()
monkeypatch.setattr(room, '_play_reaction_sound', play)
room.activateWindow()
qtbot.wait(30)
room.handle_fight_reaction({'kind': 'ko', 'position': 0})
play.assert_called_once_with('ko')
play.reset_mock()
room.handle_fight_reaction({'kind': 'bell', 'position': 0})
play.assert_called_once_with('bell')
room.hide()
room.handle_fight_reaction({'kind': 'tap', 'position': 0})
play.assert_called_once_with('bell')
assert not room.ringside_strip.isVisible()
@pytest.mark.parametrize('kind', ['ko', 'bell'])
def test_receiving_client_shows_reaction_without_focus(room, qtbot, monkeypatch, kind):
from PyQt6.QtWidgets import QLineEdit
other_client = QLineEdit()
qtbot.addWidget(other_client)
other_client.show()
other_client.activateWindow()
other_client.setFocus()
qtbot.wait(30)
assert not room.isActiveWindow()
play = Mock()
monkeypatch.setattr(room, '_play_reaction_sound', play)
room.handle_fight_reaction({'kind': kind, 'username': 'Other client', 'position': 0})
assert room.ringside_strip.isVisible()
play.assert_called_once_with(kind)
assert QApplication.focusWidget() is other_client
QApplication.sendEvent(room, QEvent(QEvent.Type.WindowDeactivate))
assert room.ringside_strip.isVisible()
room.showMinimized()
qtbot.wait(30)
assert not room.ringside_strip.isVisible()
@pytest.mark.parametrize('kind', REACTIONS)
def test_original_sound_files_are_valid_and_loadable(qtbot, tmp_path, kind):
path = write_reaction_sound(tmp_path, kind)
assert write_reaction_sound(tmp_path, kind) == path
with wave.open(str(path)) as audio:
assert audio.getnchannels() == 1
assert audio.getsampwidth() == 2
assert audio.getframerate() == 22050
assert .35 <= audio.getnframes() / 22050 <= .9
assert any(audio.readframes(audio.getnframes()))
sound = QSoundEffect()
sound.setSource(QUrl.fromLocalFile(str(path)))
qtbot.waitUntil(lambda: sound.status() in (QSoundEffect.Status.Ready, QSoundEffect.Status.Error))
assert sound.status() == QSoundEffect.Status.Ready
def test_robbery_vote_controls_expiry_and_reconnect(room, qtbot):
poll = {'id': 'vote-1', 'active': True, 'expiresAt': time.time() * 1000 + 30000,
'votes': {'Other': 'yes'}}
room.update_fight_poll(poll)
assert not room.fight_poll_panel.isHidden()
assert 'Absolutely 1' in room.fight_poll_result.text()
with qtbot.waitSignal(room.fight_action_requested) as event:
room.fight_no_btn.click()
assert event.args == [{'type': 'fight_poll_vote', 'id': 'vote-1', 'choice': 'no'}]
poll['votes']['Me'] = 'no'
room.update_fight_poll(poll)
assert 'Fair decision 1' in room.fight_poll_result.text()
assert 'Your vote is in' in room.fight_poll_result.text()
room.update_connection_status(False)
assert not room.fight_yes_btn.isEnabled()
room.update_connection_status(True)
assert room.fight_yes_btn.isEnabled()
poll['expiresAt'] = time.time() * 1000 - 1000
room.update_fight_poll(poll)
assert 'Vote closed' in room.fight_poll_title.text()
assert not room.fight_yes_btn.isEnabled()
assert not room._fight_poll_timer.isActive()
room.reset_fight_reactions()
assert room.fight_poll_panel.isHidden()
def test_styled_vote_controls_fit_and_dismiss_stays_dismissed(room, qtbot):
from main import VlcSyncApp
VlcSyncApp.apply_stylesheet(room)
room.splitter.setSizes([750, 350])
poll = {'id': 'styled', 'active': True, 'expiresAt': time.time() * 1000 + 30000, 'votes': {}}
room.update_fight_poll(poll)
qtbot.wait(50)
for button in (room.fight_yes_btn, room.fight_no_btn, room.fight_poll_dismiss_btn):
assert button.width() >= button.fontMetrics().horizontalAdvance(button.text()) + 10
room.fight_poll_dismiss_btn.click()
room.update_fight_poll(poll)
assert room.fight_poll_panel.isHidden()
room.update_fight_poll({**poll, 'id': 'new-poll'})
assert not room.fight_poll_panel.isHidden()
+104
View File
@@ -0,0 +1,104 @@
import json
import time
import pytest
from websockets.sync.client import connect
from test_ready_server import server_url, send, receive
def join_pair(host, guest):
file_info = {'name': 'fight.mkv', 'size': 1234, 'duration': 60}
send(host, type='create_room', username='Host', fileInfo=file_info)
code = receive(host, 'room_created')['code']
send(guest, type='join_room', username='Guest', code=code)
receive(guest, 'room_file_check')
send(guest, type='confirm_join', fileInfo=file_info)
receive(guest, 'room_joined')
receive(host, 'user_joined')
return code, file_info
@pytest.mark.parametrize('kind', ['ko', 'bell', 'tap', 'robbery'])
def test_reactions_are_room_wide_and_preserve_playback(server_url, kind):
with connect(server_url) as host, connect(server_url) as guest:
code, file_info = join_pair(host, guest)
# An unconfirmed client cannot broadcast or consume the cooldown.
with connect(server_url) as pending:
send(pending, type='join_room', username='Pending', code=code)
receive(pending, 'room_file_check')
send(pending, type='reaction', kind=kind)
send(pending, type='ping', timestamp=1)
assert receive(pending, 'pong')['timestamp'] == 1
send(host, type='reaction', kind='invalid')
assert 'Unknown' in receive(host, 'reaction_error')['message']
send(host, type='sync', action='play', position=18)
receive(guest, 'sync')
send(host, type='reaction', kind=kind, username='Spoofed')
reaction = receive(host, 'reaction')
assert reaction['kind'] == kind and reaction['username'] == 'Host'
assert reaction['position'] >= 18
assert receive(guest, 'reaction') == reaction
assert receive(host, 'chat')['message'] == '🥊 /' + kind
receive(guest, 'chat')
if kind == 'robbery':
assert receive(host, 'fight_poll')['active']
receive(guest, 'fight_poll')
send(guest, type='reaction', kind='bell')
assert 'cooldown' in receive(guest, 'reaction_error')['message']
send(host, type='request_state')
state = receive(host, 'state')['state']
assert state['playing'] and state['position'] >= 18
with pytest.raises(TimeoutError):
host.recv(timeout=.1)
# Reconnecting restores chat history without replaying transient effects.
guest.close()
with connect(server_url) as restored:
send(restored, type='rejoin_room', username='Guest', code=code)
snapshot = receive(restored, 'room_rejoined')
assert snapshot['chatHistory'][-1]['message'] == '🥊 /' + kind
with pytest.raises(TimeoutError):
restored.recv(timeout=.1)
def test_robbery_votes_are_unique_restored_and_expire(server_url):
with connect(server_url) as host, connect(server_url) as guest:
code, file_info = join_pair(host, guest)
send(host, type='reaction', kind='robbery')
poll = receive(host, 'fight_poll')
poll_id = poll['id']
assert poll['votes'] == {} and poll['active']
receive(guest, 'fight_poll')
send(host, type='fight_poll_vote', id=poll_id, choice='yes')
assert receive(host, 'fight_poll')['votes'] == {'Host': 'yes'}
receive(guest, 'fight_poll')
send(host, type='fight_poll_vote', id=poll_id, choice='no')
assert receive(host, 'fight_poll')['votes'] == {'Host': 'no'}
receive(guest, 'fight_poll')
send(guest, type='fight_poll_vote', id=poll_id, choice='yes', username='Host')
assert receive(host, 'fight_poll')['votes'] == {'Host': 'no', 'Guest': 'yes'}
receive(guest, 'fight_poll')
for vote_id, choice in [('stale', 'yes'), (poll_id, 'invalid')]:
send(guest, type='fight_poll_vote', id=vote_id, choice=choice)
send(guest, type='ping', timestamp=2)
assert json.loads(guest.recv(timeout=3))['type'] == 'pong'
guest.close()
with connect(server_url) as restored:
send(restored, type='rejoin_room', username='Guest', code=code)
snapshot = receive(restored, 'room_rejoined')['fightPoll']
assert snapshot['id'] == poll_id and snapshot['votes']['Guest'] == 'yes'
receive(host, 'user_rejoined')
time.sleep(3.1)
send(restored, type='reaction', kind='robbery')
assert 'already open' in receive(restored, 'reaction_error')['message']
assert receive(restored, 'fight_poll')['id'] == poll_id
closed = json.loads(host.recv(timeout=35))
assert closed['type'] == 'fight_poll' and not closed['active']
assert closed['votes'] == {'Host': 'no', 'Guest': 'yes'}
assert not receive(restored, 'fight_poll')['active']
send(restored, type='fight_poll_vote', id=poll_id, choice='no')
send(restored, type='ping', timestamp=3)
assert json.loads(restored.recv(timeout=3))['type'] == 'pong'
send(host, type='reaction', kind='robbery')
replacement = receive(host, 'fight_poll')
assert replacement['id'] != poll_id and replacement['votes'] == {}
+145
View File
@@ -0,0 +1,145 @@
import asyncio
import json
import websockets
import sys
async def run_integration_test():
url = "wss://video-sync.peterstockings.com/ws"
print("🤖 Test: Starting Integration Test Suite...")
# Client 1: Creator
try:
creator_ws = await websockets.connect(url)
print("✅ Creator connected to WebSocket")
except Exception as e:
print(f"❌ Failed to connect to server: {e}")
sys.exit(1)
creator_msg = {
"type": "create_room",
"username": "TestCreator",
"fileInfo": {
"name": "test_video.mkv",
"size": 1048576, # 1MB
"duration": 0
}
}
await creator_ws.send(json.dumps(creator_msg))
print("📤 Creator sent 'create_room'")
room_code = None
# Wait for room_created
try:
response_str = await asyncio.wait_for(creator_ws.recv(), timeout=2.0)
response = json.loads(response_str)
if response.get("type") == "room_created":
room_code = response.get("code")
print(f"✅ Creator received room_created with code: {room_code}")
else:
print(f"❌ Unexpected response for creator: {response}")
sys.exit(1)
except asyncio.TimeoutError:
print("❌ Timeout waiting for 'room_created'")
sys.exit(1)
# Client 2: Joiner
try:
joiner_ws = await websockets.connect(url)
print("✅ Joiner connected to WebSocket")
except Exception as e:
print(f"❌ Joiner failed to connect to server: {e}")
sys.exit(1)
joiner_msg = {
"type": "join_room",
"username": "TestJoiner",
"code": room_code
}
await joiner_ws.send(json.dumps(joiner_msg))
print(f"📤 Joiner sent 'join_room' for code: {room_code}")
# Wait for file_check needed
try:
response_str = await asyncio.wait_for(joiner_ws.recv(), timeout=2.0)
response = json.loads(response_str)
if response.get("type") == "room_file_check":
print(f"✅ Joiner received room_file_check from server. Payload: {response}")
# Send file confirmation
confirm_msg = {
"type": "confirm_join",
"fileInfo": {
"name": "test_video.mkv",
"size": 1048576,
"duration": 0
}
}
await joiner_ws.send(json.dumps(confirm_msg))
print("📤 Joiner sent 'confirm_join'")
# Wait for successful room_joined
response_str = await asyncio.wait_for(joiner_ws.recv(), timeout=2.0)
response = json.loads(response_str)
if response.get("type") == "room_joined":
print(f"✅ Joiner successfully received room_joined: {list(response.keys())}")
# Check users payload from the join response itself
users = response.get("users", [])
print(f"✅ Joiner received user list from room_joined payload: {users}")
assert len(users) == 2, "Expected 2 users in the room!"
# Setup wait task for the Joiner to receive a Play sync event
print("⏳ Creator is sending a Play sync event...")
await creator_ws.send(json.dumps({
"type": "sync",
"action": "play",
"position": 5.0
}))
# Creator will get an echo, Joiner will get the broadcast
creator_echo = json.loads(await asyncio.wait_for(creator_ws.recv(), timeout=2.0))
joiner_sync = json.loads(await asyncio.wait_for(joiner_ws.recv(), timeout=2.0))
if joiner_sync.get("type") == "sync":
print(f"✅ Joiner received sync event: {joiner_sync}")
assert joiner_sync.get("action") == "play", "Expected 'play' sync event"
assert joiner_sync.get("position") == 5.0, "Expected position 5.0"
else:
print(f"❌ Joiner expected sync, got: {joiner_sync}")
sys.exit(1)
# Setup wait task for the Joiner to send a Chat message
print("⏳ Joiner is sending a Chat message...")
await joiner_ws.send(json.dumps({
"type": "chat",
"message": "Hello from integration test!"
}))
creator_chat = json.loads(await asyncio.wait_for(creator_ws.recv(), timeout=2.0))
joiner_chat = json.loads(await asyncio.wait_for(joiner_ws.recv(), timeout=2.0))
if creator_chat.get("type") == "chat":
print(f"✅ Creator received chat event: {creator_chat}")
assert creator_chat.get("username") == "TestJoiner", "Expected TestJoiner author"
assert creator_chat.get("message") == "Hello from integration test!", "Expected correct message"
else:
print(f"❌ Creator expected chat, got: {creator_chat}")
sys.exit(1)
else:
print(f"❌ Joiner expected room_joined, got: {response}")
else:
print(f"❌ Joiner expected room_file_check, got: {response}")
except asyncio.TimeoutError:
print("❌ Timeout waiting for server response to 'join_room'")
sys.exit(1)
await creator_ws.close()
await joiner_ws.close()
print("🎉 Integration test completed successfully!")
if __name__ == "__main__":
asyncio.run(run_integration_test())
+23
View File
@@ -0,0 +1,23 @@
from lobby_widget import LobbyWidget
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()
+48
View File
@@ -0,0 +1,48 @@
from unittest.mock import Mock
from PyQt6.QtCore import QSettings
import room_widget
from main import VlcSyncApp
from test_chat import PlayerSignals
def test_preferences_survive_restart(qtbot, monkeypatch, tmp_path):
monkeypatch.setattr(VlcSyncApp, "refresh_sessions", lambda self: None)
monkeypatch.setattr(room_widget, "VLCSyncPlayer",
lambda frame: Mock(signals=PlayerSignals()))
path = str(tmp_path / "preferences.ini")
settings = QSettings(path, QSettings.Format.IniFormat)
first = VlcSyncApp(settings)
qtbot.addWidget(first)
first.show()
first.lobby_widget.username_input.setText("Alex")
first.room_widget.volume_slider.setValue(37)
first._remember_media_folder(str(tmp_path / "movie.mkv"))
first.room_widget.chat_container.hide()
first.room_widget.tags_list.hide()
first.resize(900, 650)
first.close()
second = VlcSyncApp(QSettings(path, QSettings.Format.IniFormat))
qtbot.addWidget(second)
assert second.lobby_widget.username_input.text() == "Alex"
assert second.room_widget.volume_slider.value() == 37
assert second.lobby_widget.media_folder == str(tmp_path)
assert second.room_widget.chat_container.isHidden()
assert second.room_widget.tags_list.isHidden()
assert second.size() == first.size()
assert second.sync_client.url == "wss://video-sync.peterstockings.com/ws"
def test_missing_folder_and_volume_bounds(qtbot, monkeypatch, tmp_path):
monkeypatch.setattr(VlcSyncApp, "refresh_sessions", lambda self: None)
monkeypatch.setattr(room_widget, "VLCSyncPlayer",
lambda frame: Mock(signals=PlayerSignals()))
settings = QSettings(str(tmp_path / "preferences.ini"), QSettings.Format.IniFormat)
settings.setValue("media_folder", str(tmp_path / "missing"))
settings.setValue("volume", 200)
window = VlcSyncApp(settings)
qtbot.addWidget(window)
assert window.lobby_widget.media_folder == ""
assert window.room_widget.volume_slider.value() == 100
+133
View File
@@ -0,0 +1,133 @@
"""Exercise ready checks through real WebSockets against an isolated local server."""
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import time
import pytest
from websockets.sync.client import connect
@pytest.fixture
def server_url():
with socket.socket() as reservation:
reservation.bind(('127.0.0.1', 0))
port = reservation.getsockname()[1]
bun = shutil.which('bun')
assert bun, 'Bun is required to test the local server'
process = subprocess.Popen(
[bun, 'run', 'server.ts'], cwd=Path(__file__).resolve().parent.parent,
env={**os.environ, 'PORT': str(port)}, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
try:
deadline = time.monotonic() + 5
while True:
if process.poll() is not None:
pytest.fail(process.stderr.read().decode())
try:
with socket.create_connection(('127.0.0.1', port), timeout=.1):
break
except OSError:
if time.monotonic() >= deadline:
pytest.fail('Local server did not start')
time.sleep(.05)
yield f'ws://127.0.0.1:{port}/ws'
finally:
process.terminate()
process.wait(timeout=5)
process.stderr.close()
def send(ws, **message):
ws.send(json.dumps(message))
def receive(ws, kind, predicate=lambda message: True):
deadline = time.monotonic() + 3
while True:
message = json.loads(ws.recv(timeout=max(.01, deadline - time.monotonic())))
if message['type'] == kind and predicate(message):
return message
def test_room_ready_check_lifecycle(server_url):
file_info = {'name': 'test.mkv', 'size': 1234, 'duration': 60}
with connect(server_url) as host, connect(server_url) as guest:
send(host, type='create_room', username='Host', fileInfo=file_info)
code = receive(host, 'room_created')['code']
send(guest, type='join_room', username='Guest', code=code)
receive(guest, 'room_file_check')
send(guest, type='confirm_join', fileInfo=file_info)
receive(guest, 'room_joined')
receive(host, 'user_joined')
send(host, type='sync', action='play', position=12)
receive(guest, 'sync')
send(host, type='ready_check', action='begin')
pause = receive(host, 'sync')
assert pause['action'] == 'pause' and pause['position'] >= 12
check = receive(host, 'ready_check')
check_id = check['id']
assert check['requestedBy'] == 'Host'
assert not any(p['ready'] for p in check['participants'])
assert receive(guest, 'ready_check')['id'] == check_id
# Unconfirmed peers cannot create or vote in a check.
with connect(server_url) as pending:
send(pending, type='join_room', username='Pending', code=code)
receive(pending, 'room_file_check')
send(pending, type='ready_check', action='begin')
send(pending, type='ready_check', action='vote', id=check_id, ready=True)
send(host, type='ready_check', action='start', id=check_id)
send(host, type='ready_check', action='vote', id=check_id, ready=True)
check = receive(host, 'ready_check')
assert check['active'] and sum(p['ready'] for p in check['participants']) == 1
receive(guest, 'ready_check')
send(host, type='ready_check', action='begin')
repeated = receive(host, 'ready_check')
assert repeated['id'] == check_id
assert sum(p['ready'] for p in repeated['participants']) == 1
receive(guest, 'ready_check')
send(guest, type='ready_check', action='vote', id=check_id, ready=True)
assert all(p['ready'] for p in receive(host, 'ready_check')['participants'])
receive(guest, 'ready_check')
# Non-initiators cannot start. Stale IDs cannot change votes.
send(guest, type='ready_check', action='start', id=check_id)
send(guest, type='ready_check', action='vote', id='stale', ready=False)
send(guest, type='ready_check', action='begin')
assert all(p['ready'] for p in receive(host, 'ready_check')['participants'])
receive(guest, 'ready_check')
# Dropping a connection revokes readiness and blocks starting.
guest.close()
disconnected = receive(host, 'ready_check')
guest_state = next(p for p in disconnected['participants'] if p['username'] == 'Guest')
assert not guest_state['connected'] and not guest_state['ready']
send(host, type='ready_check', action='start', id=check_id)
with connect(server_url) as rejoined:
send(rejoined, type='rejoin_room', username='Guest', code=code)
restored = receive(rejoined, 'room_rejoined')['readyCheck']
assert restored['id'] == check_id
assert not next(p for p in restored['participants'] if p['username'] == 'Guest')['ready']
receive(host, 'ready_check')
receive(rejoined, 'ready_check')
send(rejoined, type='ready_check', action='vote', id=check_id, ready=True)
receive(host, 'ready_check')
receive(rejoined, 'ready_check')
send(host, type='ready_check', action='start', id=check_id)
assert receive(host, 'sync')['action'] == 'play'
assert receive(rejoined, 'sync')['action'] == 'play'
assert not receive(host, 'ready_check')['active']
assert not receive(rejoined, 'ready_check')['active']
send(host, type='ready_check', action='begin')
new_check = receive(host, 'ready_check')
assert new_check['id'] != check_id
assert not any(p['ready'] for p in new_check['participants'])
receive(rejoined, 'ready_check')
send(host, type='ready_check', action='cancel', id=new_check['id'])
assert not receive(host, 'ready_check')['active']
assert not receive(rejoined, 'ready_check')['active']
+44
View File
@@ -0,0 +1,44 @@
import ssl
from sync_client import SyncClientThread
def test_ssl_context_uses_bundled_ca_certificates():
context = SyncClientThread._create_ssl_context()
assert context.verify_mode == ssl.CERT_REQUIRED
assert context.check_hostname is True
assert context.cert_store_stats()["x509_ca"] > 0
def test_certificate_error_finds_nested_verification_error():
certificate_error = ssl.SSLCertVerificationError(1, "certificate has expired")
try:
raise RuntimeError("connection failed") from certificate_error
except RuntimeError as error:
assert SyncClientThread._certificate_error(error) is certificate_error
def test_non_certificate_error_is_ignored():
assert SyncClientThread._certificate_error(ConnectionError("offline")) is None
def test_ready_check_event_dispatch(qtbot):
client = SyncClientThread()
message = {'type': 'ready_check', 'active': True, 'id': 'check'}
with qtbot.waitSignal(client.ready_check_updated) as signal:
client._handle_message(message)
assert signal.args == [message]
def test_fight_event_dispatch_and_nonfatal_feedback(qtbot):
client = SyncClientThread()
for event_type, signal in (('reaction', client.fight_reaction), ('fight_poll', client.fight_poll_updated)):
message = {'type': event_type, 'kind': 'ko'}
with qtbot.waitSignal(signal) as event:
client._handle_message(message)
assert event.args == [message]
with qtbot.waitSignal(client.system_message) as event:
client._handle_message({'type': 'reaction_error', 'message': 'Cooldown'})
assert event.args == ['Cooldown']
+11
View File
@@ -0,0 +1,11 @@
def format_time_hms(ms: int) -> str:
"""Format milliseconds as HH:MM:SS."""
s = max(0, ms) // 1000
return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}"
def format_time_short(ms: int) -> str:
"""Format milliseconds as MM:SS or HH:MM:SS if >= 1 hour."""
s = int(max(0, ms) // 1000)
if s >= 3600:
return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}"
return f"{(s % 3600) // 60:02d}:{s % 60:02d}"
+245
View File
@@ -0,0 +1,245 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "altgraph"
version = "0.17.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pefile"
version = "2024.8.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
]
[[package]]
name = "pillow"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
]
[[package]]
name = "pyinstaller"
version = "6.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
{ name = "macholib", marker = "sys_platform == 'darwin'" },
{ name = "packaging" },
{ name = "pefile", marker = "sys_platform == 'win32'" },
{ name = "pyinstaller-hooks-contrib" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c8/63/fd62472b6371d89dc138d40c36d87a50dc2de18a035803bbdc376b4ffac4/pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865", size = 4036072, upload-time = "2026-02-14T18:06:28.718Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/eb/23374721fecfa72677e79800921cb6aceefa6ba48574dc404f3f6c6c3be7/pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134", size = 1040563, upload-time = "2026-02-14T18:05:22.436Z" },
{ url = "https://files.pythonhosted.org/packages/cd/7e/dfd724b0b533f5aaec0ee5df406fe2319987ed6964480a706f85478b12ea/pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11", size = 735477, upload-time = "2026-02-14T18:05:27.143Z" },
{ url = "https://files.pythonhosted.org/packages/88/c9/ee3a4101c31f26344e66896c73c1fd6ed8282bf871473365b7f8674af406/pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf", size = 747143, upload-time = "2026-02-14T18:05:31.488Z" },
{ url = "https://files.pythonhosted.org/packages/da/0a/fc77e9f861be8cf300ac37155f59cc92aff99b29f2ddd78546f563a5b5a6/pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122", size = 744849, upload-time = "2026-02-14T18:05:35.492Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e3/6872e020ee758afe0b821663858492c10745608b07150e5e2c824a5b3e1c/pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63", size = 741590, upload-time = "2026-02-14T18:05:39.59Z" },
{ url = "https://files.pythonhosted.org/packages/53/60/b8db5f1a4b0fb228175f2ea0aa33f949adcc097fbe981cc524f9faf85777/pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe", size = 741448, upload-time = "2026-02-14T18:05:45.636Z" },
{ url = "https://files.pythonhosted.org/packages/6f/4d/63b0600f2694e9141b83129fbc1c488ec84d5a0770b1448ec154dcd0fee9/pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83", size = 740613, upload-time = "2026-02-14T18:05:49.726Z" },
{ url = "https://files.pythonhosted.org/packages/01/d4/e812ad36178093a0e9fd4b8127577748dd85b0cb71de912229dca21fd741/pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6", size = 740350, upload-time = "2026-02-14T18:05:54.093Z" },
{ url = "https://files.pythonhosted.org/packages/52/03/b2c2ee41fb8e10fd2a45d21f5ec2ef25852cfb978dbf762972eed59e3d63/pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2", size = 1324317, upload-time = "2026-02-14T18:06:00.085Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d3/6d5e62b8270e2b53a6065e281b3a7785079b00e9019c8019952828dd1669/pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33", size = 1384894, upload-time = "2026-02-14T18:06:06.425Z" },
{ url = "https://files.pythonhosted.org/packages/81/65/458cd523308a101a22fd2742893405030cc24994cc74b1b767cecf137160/pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea", size = 1325374, upload-time = "2026-02-14T18:06:12.804Z" },
]
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2026.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/eb/e1dd9a5348e4cf348471c0e5fd617d948779bc3199cf4edb134d8fceca91/pyinstaller_hooks_contrib-2026.1.tar.gz", hash = "sha256:a5f0891a1e81e92406ab917d9e76adfd7a2b68415ee2e35c950a7b3910bc361b", size = 171504, upload-time = "2026-02-18T13:01:15.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/69/12bafee3cc485d977f596e0d803d7c6fb147430fc35dfe505730aa3a28dd/pyinstaller_hooks_contrib-2026.1-py3-none-any.whl", hash = "sha256:66ad4888ba67de6f3cfd7ef554f9dd1a4389e2eb19f84d7129a5a6818e3f2180", size = 452841, upload-time = "2026-02-18T13:01:14.471Z" },
]
[[package]]
name = "pyqt6"
version = "6.10.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyqt6-qt6" },
{ name = "pyqt6-sip" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/03/e756f52e8b0d7bb5527baf8c46d59af0746391943bdb8655acba22ee4168/pyqt6-6.10.2.tar.gz", hash = "sha256:6c0db5d8cbb9a3e7e2b5b51d0ff3f283121fa27b864db6d2f35b663c9be5cc83", size = 1085573, upload-time = "2026-01-08T16:40:00.244Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/3f/f073a980969aa485ef288eb2e3b94c223ba9c7ac9941543f19b51659b98d/pyqt6-6.10.2-cp39-abi3-macosx_10_14_universal2.whl", hash = "sha256:37ae7c1183fe4dd0c6aefd2006a35731245de1cb6f817bb9e414a3e4848dfd6d", size = 60244482, upload-time = "2026-01-08T16:38:50.837Z" },
{ url = "https://files.pythonhosted.org/packages/ec/3e/9a015651ec71cea2e2f960c37edeb21623ba96a74956c0827def837f7c6b/pyqt6-6.10.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:78e1b3d5763e4cbc84485aef600e0aba5e1932fd263b716f92cd1a40dfa5e924", size = 37899440, upload-time = "2026-01-08T16:39:09.027Z" },
{ url = "https://files.pythonhosted.org/packages/51/74/a88fec2b99700270ca5d7dc7d650236a4990ed6fc88e055ca0fc8a339ee3/pyqt6-6.10.2-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bbc3af541bbecd27301bfe69fe445aa1611a9b490bd3de77306b12df632f7ec6", size = 40748467, upload-time = "2026-01-08T16:39:29.551Z" },
{ url = "https://files.pythonhosted.org/packages/75/34/be7a55529607b21db00a49ca53cb07c3092d2a5a95ea19bb95cfa0346904/pyqt6-6.10.2-cp39-abi3-win_amd64.whl", hash = "sha256:bd328cb70bc382c48861cd5f0a11b2b8ae6f5692d5a2d6679ba52785dced327b", size = 26015391, upload-time = "2026-01-08T16:39:42.946Z" },
{ url = "https://files.pythonhosted.org/packages/af/de/d9c88f976602b7884fec4ad54a4575d48e23e4f390e5357ea83917358846/pyqt6-6.10.2-cp39-abi3-win_arm64.whl", hash = "sha256:7901ba1df024b7ee9fdacfb2b7661aeb3749ae8b0bef65428077de3e0450eabb", size = 26208415, upload-time = "2026-01-08T16:39:57.751Z" },
]
[[package]]
name = "pyqt6-qt6"
version = "6.10.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/eb/f04d547d8ed9f20c7b246db4ef5d93b49cab4692009a10652ed0a8b9d2aa/pyqt6_qt6-6.10.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:5761cfccc721da2311c3f1213577f0ff1df07bbbbe3fa3a209a256b82cf057e3", size = 68688870, upload-time = "2026-01-29T12:26:48.619Z" },
{ url = "https://files.pythonhosted.org/packages/ce/c8/d99e65ab01c2402fb6bc4f77abef7244f7d5fb2f2e6d5b0abdf71bb2e4fc/pyqt6_qt6-6.10.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6dda853a8db1b8d1a2ddbbe76cc6c3aa86614cad14056bd3c0435d8feea73b2d", size = 62512013, upload-time = "2026-01-29T12:27:24.642Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fe/01fd9b9d2ca139ef61582f2e2da249fa169229144294c1bb27db59ad8420/pyqt6_qt6-6.10.2-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:19c10b5f0806e9f9bac2c9759bd5d7d19a78967f330fd60a2db409177fa76e49", size = 84028760, upload-time = "2026-01-29T12:28:03.267Z" },
{ url = "https://files.pythonhosted.org/packages/f4/20/a0d027ebb267d3afaf319d94efe1ff4d667004ee83b96701329a4d11fb95/pyqt6_qt6-6.10.2-py3-none-manylinux_2_39_aarch64.whl", hash = "sha256:2e60d616861ca4565cd295418d605975aa2dc407ba4b94c1586a70c92e9cb052", size = 83063975, upload-time = "2026-01-29T12:28:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/06/8e/595f215876d507417cc8565e05519916d3b0b76baedea6a1e4e5105633fc/pyqt6_qt6-6.10.2-py3-none-win_amd64.whl", hash = "sha256:c4b7f7d66cc58bddf1bc1ca28dfcf7a45f58cfcb11d81d13a0510409dd4957ac", size = 78433821, upload-time = "2026-01-29T12:29:35.493Z" },
{ url = "https://files.pythonhosted.org/packages/50/5f/2196e2b536217b87cb3d2ce13ef8f7607d08b02f1990a4bd84a88d293a3c/pyqt6_qt6-6.10.2-py3-none-win_arm64.whl", hash = "sha256:7164a6f0c1335358a3026df9865c8f75395b01f60f0dcd2f66c029ec16fc83d2", size = 58354426, upload-time = "2026-01-29T12:30:02.95Z" },
]
[[package]]
name = "pyqt6-sip"
version = "13.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/7d/d2916048e2e3960f68cb4e93907639844f7b8ff95897dcc98553776ccdfc/pyqt6_sip-13.11.0.tar.gz", hash = "sha256:d463af37738bda1856c9ef513e5620a37b7a005e9d589c986c3304db4a8a14d3", size = 92509, upload-time = "2026-01-13T16:01:32.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/28/a5178c8e005bafbf9c0fd507f45a3eef619ab582811414a0a461ee75994f/pyqt6_sip-13.11.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4dc9c4df24af0571423c3e85b5c008bad42ed48558eef80fbc3e5d30274c5abb", size = 112431, upload-time = "2026-01-13T16:01:23.832Z" },
{ url = "https://files.pythonhosted.org/packages/13/3c/02770b02b5a05779e26bd02c202c2fd32aa38e225d01f14c06908e33738c/pyqt6_sip-13.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c974d5a193f32e55e746e9b63138503163ac63500dbb1fd67233d8a8d71369bd", size = 301236, upload-time = "2026-01-13T16:01:28.733Z" },
{ url = "https://files.pythonhosted.org/packages/40/47/5af493a698cc520581ca1000b4ab09b8182992053ffe2478062dde5e4671/pyqt6_sip-13.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4284540ffccd8349763ddce3518264dde62f20556720d4061b9c895e09011ca0", size = 323919, upload-time = "2026-01-13T16:01:25.122Z" },
{ url = "https://files.pythonhosted.org/packages/b7/2d/64b26e21183a7ff180105871dd5983a8da539d8768921728268dc6d0a73d/pyqt6_sip-13.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:9bd81cb351640abc803ea2fe7262b5adea28615c9b96fd103d1b6f3459937211", size = 55078, upload-time = "2026-01-13T16:01:29.853Z" },
{ url = "https://files.pythonhosted.org/packages/7e/36/23f699fa8b1c3fcc312ecd12661a1df6057d92e16d4def2399b59cf7bf22/pyqt6_sip-13.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:cd95ec98f8edb15bcea832b8657809f69d758bc4151cc6fd7790c0181949e45f", size = 49465, upload-time = "2026-01-13T16:01:31.174Z" },
]
[[package]]
name = "python-app"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "certifi" },
{ name = "pillow" },
{ name = "pyinstaller" },
{ name = "pyqt6" },
{ name = "python-vlc" },
{ name = "websockets" },
]
[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" },
{ name = "python-vlc", specifier = ">=3.0.21203" },
{ name = "websockets", specifier = ">=16.0" },
]
[[package]]
name = "python-vlc"
version = "3.0.21203"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4b/5b/f9ce6f0c9877b6fe5eafbade55e0dcb6b2b30f1c2c95837aef40e390d63b/python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec", size = 162211, upload-time = "2024-10-07T14:39:54.755Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/ee/7d76eb3b50ccb1397621f32ede0fb4d17aa55a9aa2251bc34e6b9929fdce/python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320", size = 87651, upload-time = "2024-10-07T14:39:50.021Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "setuptools"
version = "82.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
+110
View File
@@ -0,0 +1,110 @@
import vlc
import sys
from PyQt6.QtWidgets import QFrame
from PyQt6.QtCore import Qt, pyqtSignal, QObject
import threading
class VLCSignals(QObject):
state_changed = pyqtSignal(bool, int) # is_playing, time_ms
time_changed = pyqtSignal(int) # time_ms
class VLCSyncPlayer:
def __init__(self, frame: QFrame):
self.frame = frame
self.signals = VLCSignals()
# Initialize VLC instance
# --no-xlib prevents crashes on Linux
# --drop-late-frames improves sync by not delaying playback when CPU is slow
# --no-keyboard stops VLC from capturing and swallowing keyboard events
self.instance = vlc.Instance("--no-xlib", "--drop-late-frames", "--no-keyboard")
self.media_player = self.instance.media_player_new()
# Embed the VLC player into the provided PyQt QFrame
# On Windows, PyQt6 widgets don't have a native handle by default
self.frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow)
if sys.platform.startswith('linux'):
self.media_player.set_xwindow(int(self.frame.winId()))
elif sys.platform == "win32":
# For Windows, we must explicitly cast winId() to int
self.media_player.set_hwnd(int(self.frame.winId()))
elif sys.platform == "darwin":
self.media_player.set_nsobject(int(self.frame.winId()))
# Register Event Callbacks
self.events = self.media_player.event_manager()
self.events.event_attach(vlc.EventType.MediaPlayerPlaying, self._on_playing)
self.events.event_attach(vlc.EventType.MediaPlayerPaused, self._on_paused)
self.events.event_attach(vlc.EventType.MediaPlayerTimeChanged, self._on_time_changed)
# Local State
self.is_playing = False
self.current_time_ms = 0
self.ignore_next_event = False
self.lock = threading.Lock()
def load_media(self, path: str, start_time_s: float = 0.0):
media = self.instance.media_new(path)
if start_time_s > 0:
media.add_option(f"start-time={start_time_s}")
self.media_player.set_media(media)
def play(self):
with self.lock:
self.ignore_next_event = True
self.media_player.play()
def pause(self):
with self.lock:
self.ignore_next_event = True
self.media_player.set_pause(1)
def seek(self, position_ms: int):
with self.lock:
self.ignore_next_event = True
self.media_player.set_time(position_ms)
def set_volume(self, volume: int):
self.media_player.audio_set_volume(volume)
def get_volume(self) -> int:
return self.media_player.audio_get_volume()
# --- Internal VLC Callbacks ---
@vlc.callbackmethod
def _on_playing(self, event):
self.is_playing = True
with self.lock:
if self.ignore_next_event:
self.ignore_next_event = False
return
time_ms = self.media_player.get_time()
# Fire signal to PyQt thread
self.signals.state_changed.emit(True, time_ms)
@vlc.callbackmethod
def _on_paused(self, event):
self.is_playing = False
with self.lock:
if self.ignore_next_event:
self.ignore_next_event = False
return
time_ms = self.media_player.get_time()
self.signals.state_changed.emit(False, time_ms)
@vlc.callbackmethod
def _on_time_changed(self, event):
# Emitted constantly during playback
self.current_time_ms = event.u.new_time
# We also want to fire this signal so the UI scrubber/time label can update
self.signals.time_changed.emit(self.current_time_ms)
def get_length(self):
return self.media_player.get_length()
def stop(self):
self.media_player.stop()
+25 -89
View File
@@ -10,8 +10,6 @@
let joinFile = null; // File object for the joiner
let ignoreSync = false; // flag to avoid feedback loops
let syncTimeout = null;
let driftInterval = null;
let serverState = { playing: false, position: 0, speed: 1 };
// Reconnection state
let reconnectAttempts = 0;
@@ -73,7 +71,6 @@
const volumeSlider = $("volume-slider");
const currentTimeEl = $("current-time");
const durationEl = $("duration");
const speedSelect = $("speed-select");
const fullscreenBtn = $("fullscreen-btn");
const userCountEl = $("user-count");
const usersList = $("users-list");
@@ -238,12 +235,15 @@
// Seamless reconnection — sync to server state
isReconnecting = false;
showConnectionStatus("reconnected");
if (msg.chatHistory) {
chatMessages.innerHTML = "";
msg.chatHistory.forEach((m) => addChatMessage(m.username, m.message, m.timestamp));
}
updateUsers(msg.users);
if (msg.state) {
applySync(msg.state);
}
addSystemMessage("Reconnected");
startDriftCorrection();
flushMessageQueue();
break;
@@ -298,82 +298,22 @@
// Load local file into video player
const file = localFile || joinFile;
if (file) {
loadVideoSource(file);
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = URL.createObjectURL(file);
videoPlayer.src = currentBlobUrl;
}
// --- Handle false "ended" events (Chromium MKV blob bug) ---
// Chrome can't properly index MKV containers loaded via blob URLs.
// After ~30s it may jump to the end and fire "ended" even though
// the video isn't actually over. Recovery: reload the blob and seek.
let recoveryAttempts = 0;
const MAX_RECOVERY_ATTEMPTS = 5;
videoPlayer.addEventListener("ended", () => {
const duration = videoPlayer.duration || 0;
// Only attempt recovery if we have server state showing we're not near the end
if (duration > 0 && lastServerState.position < duration - 5 && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
recoveryAttempts++;
console.log(`[MKV-RECOVERY] False ended detected. Server pos=${lastServerState.position.toFixed(1)}, duration=${duration.toFixed(1)}. Reloading source (attempt ${recoveryAttempts}/${MAX_RECOVERY_ATTEMPTS})`);
const targetPos = lastServerState.position;
const wasPlaying = lastServerState.playing;
const currentFile = localFile || joinFile;
if (currentFile) {
// Reload the video with a fresh blob URL
loadVideoSource(currentFile, targetPos, wasPlaying);
}
}
});
// Reset recovery counter when video plays successfully for a while
let recoveryResetTimer = null;
videoPlayer.addEventListener("timeupdate", () => {
if (recoveryAttempts > 0) {
clearTimeout(recoveryResetTimer);
recoveryResetTimer = setTimeout(() => {
recoveryAttempts = 0;
}, 10000); // Reset after 10s of successful playback
}
});
// --- Resync on tab focus (handles background tab throttling) ---
document.addEventListener("visibilitychange", () => {
if (!document.hidden && roomCode && ws && ws.readyState === WebSocket.OPEN) {
console.log("[SYNC] Tab became visible, requesting state resync");
send({ type: "request_state" });
}
});
// Start drift correction
startDriftCorrection();
}
// --- Video Source Loading ---
// --- State tracking ---
let currentBlobUrl = null;
let lastServerState = { playing: false, position: 0, speed: 1 };
function loadVideoSource(file, seekTo, shouldPlay) {
// Revoke old blob URL to free memory
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
}
currentBlobUrl = URL.createObjectURL(file);
videoPlayer.src = currentBlobUrl;
if (seekTo !== undefined) {
videoPlayer.addEventListener("loadedmetadata", function onMeta() {
videoPlayer.removeEventListener("loadedmetadata", onMeta);
videoPlayer.currentTime = seekTo;
if (shouldPlay) {
videoPlayer.play().then(() => {
videoWrapper.classList.add("playing");
updatePlayPauseIcon();
}).catch(() => {});
}
});
}
}
let lastServerState = { playing: false, position: 0 };
// --- File Check Modal ---
let pendingRoomFileInfo = null;
@@ -459,12 +399,7 @@
// Track latest server state for recovery
if (data.position !== undefined) lastServerState.position = data.position;
if (data.playing !== undefined) lastServerState.playing = data.playing;
if (data.speed !== undefined) lastServerState.speed = data.speed;
if (data.speed !== undefined && videoPlayer.playbackRate !== data.speed) {
videoPlayer.playbackRate = data.speed;
speedSelect.value = String(data.speed);
}
if (data.position !== undefined) {
const diff = Math.abs(videoPlayer.currentTime - data.position);
@@ -510,14 +445,7 @@
}
}
function startDriftCorrection() {
if (driftInterval) clearInterval(driftInterval);
driftInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
send({ type: "request_state" });
}
}, 5000);
}
// --- Get video duration from a file (used for file info) ---
function getVideoDuration(file) {
@@ -544,6 +472,17 @@
container.classList.remove("hidden");
}
function isMkvFile(file) {
return file.name.toLowerCase().endsWith(".mkv");
}
function showFormatWarning(container) {
const warning = document.createElement("div");
warning.className = "format-warning";
warning.innerHTML = `⚠️ <strong>MKV files may not play correctly</strong> in browsers. Convert to MP4 for best results:<br><code>ffmpeg -i file.mkv -c copy file.mp4</code>`;
container.parentNode.insertBefore(warning, container.nextSibling);
}
// ===== EVENT LISTENERS =====
// --- Lobby: Username ---
@@ -560,7 +499,11 @@
localFile = file;
const duration = await getVideoDuration(file);
localFile._duration = duration;
// Remove any previous format warning
const oldWarning = createFileInfo.parentNode.querySelector(".format-warning");
if (oldWarning) oldWarning.remove();
renderFileInfo(createFileInfo, file, duration);
if (isMkvFile(file)) showFormatWarning(createFileInfo);
createRoomBtn.disabled = !usernameInput.value.trim();
});
@@ -679,7 +622,6 @@
joinFile = null;
roomFileInfo = null;
messageQueue = [];
if (driftInterval) clearInterval(driftInterval);
videoPlayer.src = "";
chatMessages.innerHTML = '<div class="chat-welcome" id="chat-welcome"><p>Welcome to the room! 👋</p></div>';
createFileInfo.classList.add("hidden");
@@ -766,12 +708,6 @@
volOffIcon.classList.toggle("hidden", !videoPlayer.muted);
});
// Speed
speedSelect.addEventListener("change", () => {
const speed = parseFloat(speedSelect.value);
videoPlayer.playbackRate = speed;
send({ type: "sync", action: "speed", speed });
});
// Fullscreen
fullscreenBtn.addEventListener("click", () => {
-9
View File
@@ -158,15 +158,6 @@
</span>
</div>
<div class="controls-right">
<select id="speed-select" class="speed-select" title="Playback speed">
<option value="0.25">0.25x</option>
<option value="0.5">0.5x</option>
<option value="0.75">0.75x</option>
<option value="1" selected>1x</option>
<option value="1.25">1.25x</option>
<option value="1.5">1.5x</option>
<option value="2">2x</option>
</select>
<button id="fullscreen-btn" class="ctrl-btn" title="Fullscreen">
<svg viewBox="0 0 24 24" width="22" height="22" fill="white">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
+24
View File
@@ -282,6 +282,30 @@ select {
font-size: 0.75rem;
}
/* Format warning (MKV etc.) */
.format-warning {
width: 100%;
margin-top: 8px;
padding: 10px 14px;
background: rgba(243, 156, 18, 0.1);
border: 1px solid rgba(243, 156, 18, 0.3);
border-radius: var(--radius);
color: var(--warning);
font-size: 0.78rem;
line-height: 1.5;
}
.format-warning code {
display: inline-block;
margin-top: 4px;
padding: 2px 8px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
color: var(--text-primary);
}
/* Buttons */
.btn-primary {
width: 100%;
+159 -19
View File
@@ -8,15 +8,18 @@ interface FileInfo {
duration: number;
}
interface RoomState {
code: string;
interface RoomState {
code: string;
listed: boolean;
fileInfo: FileInfo;
playing: boolean;
position: number;
speed: 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 {
@@ -41,7 +44,7 @@ function generateRoomCode(): string {
function getCurrentPosition(room: RoomState): number {
if (!room.playing) return room.position;
const elapsed = (Date.now() - room.lastUpdate) / 1000;
return room.position + elapsed * room.speed;
return room.position + elapsed; // assume 1x speed
}
function broadcastToRoom(room: RoomState, message: object, excludeWs?: WebSocket) {
@@ -57,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",
@@ -108,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") {
@@ -138,6 +174,10 @@ const server = Bun.serve<WSData>({
}
switch (msg.type) {
case "ping": {
ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp }));
break;
}
case "create_room": {
const username = (msg.username || "").trim();
if (!username) {
@@ -150,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,
@@ -159,7 +200,6 @@ const server = Bun.serve<WSData>({
},
playing: false,
position: 0,
speed: 1,
lastUpdate: Date.now(),
users: new Map(),
chatHistory: [],
@@ -249,9 +289,10 @@ const server = Bun.serve<WSData>({
state: {
playing: room.playing,
position: getCurrentPosition(room),
speed: room.speed,
},
chatHistory: room.chatHistory.slice(-50),
readyCheck: readyCheckState(room),
fightPoll: fightPollState(room),
})
);
@@ -266,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") {
@@ -285,10 +331,6 @@ const server = Bun.serve<WSData>({
} else if (msg.action === "seek") {
room.position = msg.position;
room.lastUpdate = Date.now();
} else if (msg.action === "speed") {
room.position = getCurrentPosition(room);
room.speed = msg.speed;
room.lastUpdate = Date.now();
}
// Broadcast to others
@@ -299,15 +341,98 @@ const server = Bun.serve<WSData>({
action: msg.action,
position: room.position,
playing: room.playing,
speed: room.speed,
username: ws.data.username,
timestamp: Date.now(),
req_id: msg.req_id,
},
ws as unknown as WebSocket
);
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;
@@ -343,7 +468,6 @@ const server = Bun.serve<WSData>({
state: {
playing: room.playing,
position: getCurrentPosition(room),
speed: room.speed,
},
})
);
@@ -388,9 +512,10 @@ const server = Bun.serve<WSData>({
state: {
playing: room.playing,
position: getCurrentPosition(room),
speed: room.speed,
},
chatHistory: room.chatHistory.slice(-50),
readyCheck: readyCheckState(room),
fightPoll: fightPollState(room),
})
);
@@ -405,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;
}
}
@@ -416,10 +542,15 @@ 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 30s grace period for reconnection
// Don't remove immediately — give a 90s grace period for reconnection
const disconnectKey = `${roomCode}:${username}`;
console.log(`[Room ${roomCode}] ${username} disconnected (waiting 30s for reconnect)`);
console.log(`[Room ${roomCode}] ${username} disconnected (waiting 90s for reconnect)`);
const timer = setTimeout(() => {
pendingDisconnects.delete(disconnectKey);
@@ -430,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) {
@@ -443,7 +583,7 @@ const server = Bun.serve<WSData>({
});
}
}
}, 30_000);
}, 90_000);
pendingDisconnects.set(disconnectKey, timer);
},