Compare commits
48
Commits
1.0
...
21a35d81f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a35d81f0 | ||
|
|
836034533e | ||
|
|
75362bfcba | ||
|
|
b5f09a9e9e | ||
|
|
f3eeda71e6 | ||
|
|
19576de166 | ||
|
|
ce22de754a | ||
|
|
aee78d5392 | ||
|
|
f1b7a65767 | ||
|
|
9d2d1995bd | ||
|
|
66342706db | ||
|
|
386aa18ba1 | ||
|
|
e2edd296fc | ||
|
|
42f7ee7f12 | ||
|
|
5b857bf878 | ||
|
|
99a0694830 | ||
|
|
887648bf85 | ||
|
|
a301d1521b | ||
|
|
dd00011b77 | ||
|
|
25ea1694f1 | ||
|
|
367da66c0b | ||
|
|
b59d08d098 | ||
|
|
ce87a817ea | ||
|
|
dae4af9ab8 | ||
|
|
02092bab69 | ||
|
|
777e08ff85 | ||
|
|
d1a87e004e | ||
|
|
6a8dc7e5e6 | ||
|
|
c4bef281f7 | ||
|
|
e120813a01 | ||
|
|
2e31eab0ca | ||
|
|
b29b540331 | ||
|
|
43929ea94d | ||
|
|
0e7f80ff1f | ||
|
|
bd43cd10f6 | ||
|
|
de0a7172e7 | ||
|
|
3eb4b671c6 | ||
|
|
92aa926cf0 | ||
|
|
4665cf700e | ||
|
|
a5b7e08a6a | ||
|
|
31c79e794e | ||
|
|
4cf4c153cd | ||
|
|
9d22860f0d | ||
|
|
3e1ea32383 | ||
|
|
36d6aeaf51 | ||
|
|
b587b5e87d | ||
|
|
475fdbb2b8 | ||
|
|
2020b59259 |
+23
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.14
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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 |
@@ -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()
|
||||
@@ -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 |
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
@@ -0,0 +1,219 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
from PyQt6.QtGui import QTextDocument
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
import room_widget
|
||||
|
||||
|
||||
class PlayerSignals(QObject):
|
||||
time_changed = pyqtSignal(int)
|
||||
state_changed = pyqtSignal(bool, int)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def room(qtbot, monkeypatch):
|
||||
player = Mock(signals=PlayerSignals())
|
||||
monkeypatch.setattr(room_widget, 'VLCSyncPlayer', lambda frame: player)
|
||||
monkeypatch.setattr(room_widget.RoomWidget, '_play_reaction_sound', Mock())
|
||||
widget = room_widget.RoomWidget()
|
||||
widget.username = 'Me'
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1050, 500)
|
||||
widget.show()
|
||||
return widget
|
||||
|
||||
|
||||
@pytest.mark.parametrize('text', ["It's someone's & my <message>",
|
||||
"It's at 1:23 & <b>literal</b>",
|
||||
'line one\nline two', 'literal ' entity'])
|
||||
def test_message_rendering(room, text):
|
||||
room.add_chat_message("<b>O'Brien</b>", text, 0)
|
||||
bubble = room.chat_content_layout.itemAt(0).widget()
|
||||
doc = QTextDocument()
|
||||
doc.setHtml(bubble.text_lbl.text())
|
||||
assert doc.toPlainText() == text
|
||||
if '1:23' in text:
|
||||
assert 'href="seek:1:23"' in bubble.text_lbl.text()
|
||||
|
||||
|
||||
def test_bubbles_do_not_shrink_or_clip_when_overflowing(room, qtbot):
|
||||
text = 'A wrapped message with several words. ' * 8
|
||||
room.add_chat_message('Other', text, 0)
|
||||
qtbot.wait(100)
|
||||
first = room.chat_content_layout.itemAt(0).widget()
|
||||
initial_height = first.height()
|
||||
for i in range(35):
|
||||
room.add_chat_message('Me' if i % 2 else 'Other', text, 0)
|
||||
qtbot.wait(200)
|
||||
assert room.chat_messages.verticalScrollBar().maximum() > 0
|
||||
assert first.height() >= initial_height
|
||||
for width in (220, 450, 280):
|
||||
room.splitter.setSizes([room.width() - width, width])
|
||||
qtbot.wait(100)
|
||||
for i in range(room.chat_content_layout.count()):
|
||||
bubble = room.chat_content_layout.itemAt(i).widget()
|
||||
label = bubble.text_lbl
|
||||
assert label.height() >= label.heightForWidth(label.width())
|
||||
assert bubble.bubble.rect().contains(label.geometry())
|
||||
|
||||
|
||||
def test_unread_alert_and_focus_clear(room, qtbot, monkeypatch):
|
||||
alert = Mock()
|
||||
monkeypatch.setattr(QApplication, 'alert', alert)
|
||||
room.activateWindow()
|
||||
room.setFocus()
|
||||
qtbot.wait(50)
|
||||
room.add_chat_message('Other', 'hello', 0)
|
||||
assert room._unread_messages == 1
|
||||
alert.assert_called_once()
|
||||
room.add_chat_message('Me', 'reply', 0)
|
||||
assert room._unread_messages == 1
|
||||
room.chat_input.setFocus()
|
||||
qtbot.wait(50)
|
||||
assert room._unread_messages == 0
|
||||
room.add_chat_message('Other', 'focused', 0)
|
||||
assert room._unread_messages == 0
|
||||
room.chat_container.hide()
|
||||
room.add_chat_message('Other', 'hidden', 0)
|
||||
assert room._unread_messages == 1
|
||||
room.clear_chat()
|
||||
assert room._unread_messages == 0
|
||||
|
||||
|
||||
def test_popout_focus_and_wrapping(room, qtbot, monkeypatch):
|
||||
monkeypatch.setattr(QApplication, 'alert', Mock())
|
||||
room.popout_chat()
|
||||
popout = room.popout_window
|
||||
qtbot.addWidget(popout)
|
||||
popout.activateWindow()
|
||||
room.chat_input.setFocus()
|
||||
qtbot.wait(100)
|
||||
room.add_chat_message('Other', 'Wrapped popout text ' * 40, 0)
|
||||
qtbot.wait(100)
|
||||
assert room._unread_messages == 0
|
||||
bubble = room.chat_content_layout.itemAt(0).widget()
|
||||
assert bubble.text_lbl.height() >= bubble.text_lbl.heightForWidth(bubble.text_lbl.width())
|
||||
room.activateWindow()
|
||||
room.setFocus()
|
||||
qtbot.wait(100)
|
||||
room.add_chat_message('Other', 'Background popout', 0)
|
||||
assert room._unread_messages == 1
|
||||
popout.close()
|
||||
|
||||
|
||||
def test_scrolling_preserves_position_and_counts_unread(room, qtbot):
|
||||
for i in range(30):
|
||||
room.add_chat_message('Other', f'Message {i}: ' + 'wrapped words ' * 10, 0)
|
||||
qtbot.wait(150)
|
||||
bar = room.chat_messages.verticalScrollBar()
|
||||
assert bar.value() == bar.maximum()
|
||||
bar.setValue(100)
|
||||
qtbot.wait(30)
|
||||
position = bar.value()
|
||||
room.add_chat_message('Other', 'First unread message', 0)
|
||||
room.add_chat_message('Other', 'Second unread message', 0)
|
||||
room.add_system_message('Someone paused')
|
||||
qtbot.wait(150)
|
||||
assert bar.value() == position
|
||||
assert room._pending_chat_messages == 2
|
||||
assert not room.chat_jump_btn.isHidden()
|
||||
assert room._unread_divider is not None
|
||||
room.chat_input.setFocus()
|
||||
assert room._pending_chat_messages == 2
|
||||
room.jump_to_latest()
|
||||
qtbot.wait(150)
|
||||
assert bar.value() == bar.maximum()
|
||||
assert room.chat_jump_btn.isHidden()
|
||||
room.add_chat_message('Other', 'Follow latest', 0)
|
||||
qtbot.wait(150)
|
||||
assert bar.value() == bar.maximum()
|
||||
assert room._pending_chat_messages == 0
|
||||
|
||||
|
||||
def test_scroll_back_to_bottom_clears_pending(room, qtbot):
|
||||
for i in range(20):
|
||||
room.add_chat_message('Other', 'Words ' * 30, 0)
|
||||
qtbot.wait(150)
|
||||
bar = room.chat_messages.verticalScrollBar()
|
||||
bar.setValue(0)
|
||||
room.add_chat_message('Other', 'Unread', 0)
|
||||
qtbot.wait(100)
|
||||
bar.setValue(bar.maximum())
|
||||
assert room._pending_chat_messages == 0
|
||||
assert room._follow_chat
|
||||
assert room.chat_jump_btn.isHidden()
|
||||
room.clear_chat()
|
||||
assert room._unread_divider is None
|
||||
assert room.chat_content_layout.count() == 0
|
||||
|
||||
|
||||
def test_ready_command_and_controls(room, qtbot):
|
||||
with qtbot.waitSignal(room.ready_action_requested) as signal:
|
||||
room.chat_input.setText('/ready')
|
||||
room.send_chat()
|
||||
assert signal.args == [{'type': 'ready_check', 'action': 'begin'}]
|
||||
assert room.chat_input.text() == ''
|
||||
state = {'active': True, 'id': 'check-1', 'requestedBy': 'Me', 'participants': [
|
||||
{'username': 'Me', 'ready': False, 'connected': True},
|
||||
{'username': "<b>O'Brien</b>", 'ready': True, 'connected': True}]}
|
||||
room.update_ready_check(state)
|
||||
assert not room.ready_panel.isHidden()
|
||||
assert not room.ready_start_btn.isEnabled()
|
||||
assert "<b>O'Brien</b>" in room.ready_users.text()
|
||||
with qtbot.waitSignal(room.ready_action_requested) as signal:
|
||||
room.ready_btn.click()
|
||||
assert signal.args[0] == {'type': 'ready_check', 'action': 'vote', 'id': 'check-1', 'ready': True}
|
||||
state['participants'][0]['ready'] = True
|
||||
room.update_ready_check(state)
|
||||
assert room.ready_start_btn.isEnabled()
|
||||
with qtbot.waitSignal(room.ready_action_requested) as signal:
|
||||
room.ready_start_btn.click()
|
||||
assert signal.args[0]['action'] == 'start'
|
||||
state['participants'][1]['connected'] = False
|
||||
room.update_ready_check(state)
|
||||
assert not room.ready_start_btn.isEnabled()
|
||||
assert 'Reconnecting' in room.ready_users.text()
|
||||
state['requestedBy'] = 'Other'
|
||||
room.update_ready_check(state)
|
||||
assert room.ready_start_btn.isHidden()
|
||||
room.update_ready_check({'active': False})
|
||||
assert room.ready_panel.isHidden()
|
||||
|
||||
|
||||
def test_ready_panel_buttons_fit_styled_chat(room, qtbot):
|
||||
from main import VlcSyncApp
|
||||
VlcSyncApp.apply_stylesheet(room)
|
||||
room.splitter.setSizes([750, 300])
|
||||
room.update_ready_check({'active': True, 'id': 'layout', 'requestedBy': 'Me', 'participants': [
|
||||
{'username': 'Me', 'ready': True, 'connected': True}]})
|
||||
qtbot.wait(100)
|
||||
for button in (room.ready_btn, room.ready_start_btn, room.ready_cancel_btn):
|
||||
assert button.width() >= button.fontMetrics().horizontalAdvance(button.text()) + 20
|
||||
room.update_connection_status(False)
|
||||
room.update_ready_check(room._ready_check)
|
||||
assert not room.ready_start_btn.isEnabled()
|
||||
assert not room.ready_btn.isEnabled()
|
||||
|
||||
|
||||
def test_app_wires_commands_to_websocket_and_incoming_panels(room, qtbot, monkeypatch):
|
||||
import main
|
||||
from sync_client import SyncClientThread
|
||||
client = SyncClientThread()
|
||||
client.send_message = Mock()
|
||||
monkeypatch.setattr(main, 'SyncClientThread', lambda url: client)
|
||||
app = main.VlcSyncApp()
|
||||
qtbot.addWidget(app)
|
||||
for command, expected in [('/ready', {'type': 'ready_check', 'action': 'begin'}),
|
||||
('/ko', {'type': 'reaction', 'kind': 'ko'}),
|
||||
('/bell', {'type': 'reaction', 'kind': 'bell'})]:
|
||||
app.room_widget.chat_input.setText(command)
|
||||
app.room_widget.send_chat()
|
||||
client.send_message.assert_called_with(expected)
|
||||
state = {'type': 'ready_check', 'active': True, 'id': 'wired', 'requestedBy': 'Me',
|
||||
'participants': [{'username': 'Me', 'ready': False, 'connected': True}]}
|
||||
client._handle_message(state)
|
||||
assert app.room_widget._ready_check['id'] == 'wired'
|
||||
assert not app.room_widget.ready_panel.isHidden()
|
||||
@@ -0,0 +1,33 @@
|
||||
import json
|
||||
from urllib.request import urlopen
|
||||
|
||||
from test_ready_server import server_url, send, receive
|
||||
from websockets.sync.client import connect
|
||||
|
||||
|
||||
def test_discovery_visibility_and_connected_users(server_url):
|
||||
endpoint = server_url.replace('ws://', 'http://').removesuffix('/ws') + '/api/rooms'
|
||||
def listing():
|
||||
with urlopen(endpoint, timeout=3) as response:
|
||||
assert response.headers['Cache-Control'] == 'no-store'
|
||||
return json.load(response)['rooms']
|
||||
|
||||
assert listing() == []
|
||||
with connect(server_url) as public, connect(server_url) as private:
|
||||
file_info = {'name': 'test.mkv', 'size': 1234, 'duration': 60}
|
||||
send(public, type='create_room', username='Public', fileInfo=file_info)
|
||||
public_code = receive(public, 'room_created')['code']
|
||||
send(private, type='create_room', username='Private', fileInfo=file_info, listed=False)
|
||||
private_code = receive(private, 'room_created')['code']
|
||||
rooms = listing()
|
||||
assert len(rooms) == 1
|
||||
assert rooms[0]['code'] == public_code
|
||||
assert rooms[0]['users'] == ['Public']
|
||||
assert rooms[0]['playing'] is False
|
||||
with connect(server_url) as guest:
|
||||
send(guest, type='join_room', username='Guest', code=private_code)
|
||||
receive(guest, 'room_file_check')
|
||||
send(guest, type='confirm_join', fileInfo=file_info)
|
||||
receive(guest, 'room_joined')
|
||||
assert len(listing()) == 1
|
||||
assert listing() == []
|
||||
@@ -0,0 +1,190 @@
|
||||
import time
|
||||
import wave
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from PyQt6.QtCore import QEvent, QUrl
|
||||
from PyQt6.QtMultimedia import QSoundEffect
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
from fight_reactions import REACTIONS, write_reaction_sound
|
||||
from test_chat import room
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', REACTIONS)
|
||||
def test_command_dispatch(room, qtbot, kind):
|
||||
with qtbot.waitSignal(room.fight_action_requested) as event:
|
||||
room.chat_input.setText('/' + kind)
|
||||
room.send_chat()
|
||||
assert event.args == [{'type': 'reaction', 'kind': kind}]
|
||||
assert room.chat_input.text() == ''
|
||||
assert room.chat_content_layout.count() == 0 # Wait for the server echo.
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', REACTIONS)
|
||||
def test_strip_is_native_positioned_and_does_not_steal_focus(room, qtbot, kind):
|
||||
room.activateWindow()
|
||||
room.chat_input.setFocus()
|
||||
qtbot.wait(30)
|
||||
room.handle_fight_reaction({'kind': kind, 'username': "<b>O'Brien</b>", 'position': 83})
|
||||
strip = room.ringside_strip
|
||||
assert strip.isWindow()
|
||||
assert strip.isVisible()
|
||||
assert strip.headline.text() == REACTIONS[kind][0]
|
||||
assert "<b>O'Brien</b>" in strip.caption.text()
|
||||
assert '01:23' in strip.metadata.text()
|
||||
qtbot.wait(210)
|
||||
assert QApplication.focusWidget() is room.chat_input
|
||||
video = room.video_frame
|
||||
rect = strip.geometry()
|
||||
from PyQt6.QtCore import QRect, QPoint
|
||||
bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size())
|
||||
assert bounds.contains(rect)
|
||||
room.resize(1150, 650)
|
||||
qtbot.wait(50)
|
||||
bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size())
|
||||
assert bounds.contains(strip.geometry())
|
||||
QApplication.sendEvent(room, QEvent(QEvent.Type.Hide))
|
||||
assert not strip.isVisible()
|
||||
|
||||
|
||||
def test_strip_auto_dismiss_and_cleanup(room, qtbot):
|
||||
room.activateWindow()
|
||||
qtbot.wait(30)
|
||||
room.handle_fight_reaction({'kind': 'ko', 'username': 'Alex', 'position': 0})
|
||||
assert room.ringside_strip.isVisible()
|
||||
room.ringside_strip.dismiss_timer.start(30)
|
||||
qtbot.waitUntil(lambda: not room.ringside_strip.isVisible())
|
||||
room.handle_fight_reaction({'kind': 'bell', 'username': 'Alex', 'position': 0})
|
||||
room.cleanup()
|
||||
assert not room.ringside_strip.isVisible()
|
||||
assert not room.ringside_strip.dismiss_timer.isActive()
|
||||
assert not room._fight_poll_timer.isActive()
|
||||
|
||||
|
||||
def test_reactions_work_with_chat_popout_focused(room, qtbot):
|
||||
room.popout_chat()
|
||||
popout = room.popout_window
|
||||
qtbot.addWidget(popout)
|
||||
popout.activateWindow()
|
||||
room.chat_input.setFocus()
|
||||
qtbot.wait(50)
|
||||
room.handle_fight_reaction({'kind': 'ko', 'username': 'Other', 'position': 0})
|
||||
assert room.ringside_strip.isVisible()
|
||||
assert QApplication.focusWidget() is room.chat_input
|
||||
popout.close()
|
||||
|
||||
|
||||
def test_arcade_ko_covers_video_then_restores_strip(room, qtbot):
|
||||
room.activateWindow()
|
||||
qtbot.wait(30)
|
||||
room.handle_fight_reaction({'kind': 'ko', 'position': 0})
|
||||
overlay = room.ringside_strip
|
||||
assert overlay.size() == room.video_frame.size()
|
||||
assert overlay.panel.isHidden()
|
||||
assert not overlay.ko_canvas.isHidden()
|
||||
assert overlay.ko_animation.state() == overlay.ko_animation.State.Running
|
||||
room.handle_fight_reaction({'kind': 'bell', 'position': 0})
|
||||
assert overlay.ko_canvas.isHidden()
|
||||
assert not overlay.panel.isHidden()
|
||||
assert overlay.width() <= 540
|
||||
assert overlay.height() < room.video_frame.height()
|
||||
room.cleanup()
|
||||
assert overlay.ko_animation.state() == overlay.ko_animation.State.Stopped
|
||||
|
||||
|
||||
def test_sound_is_enabled_by_default_and_hidden_effects_are_suppressed(room, qtbot, monkeypatch):
|
||||
play = Mock()
|
||||
monkeypatch.setattr(room, '_play_reaction_sound', play)
|
||||
room.activateWindow()
|
||||
qtbot.wait(30)
|
||||
room.handle_fight_reaction({'kind': 'ko', 'position': 0})
|
||||
play.assert_called_once_with('ko')
|
||||
play.reset_mock()
|
||||
room.handle_fight_reaction({'kind': 'bell', 'position': 0})
|
||||
play.assert_called_once_with('bell')
|
||||
room.hide()
|
||||
room.handle_fight_reaction({'kind': 'tap', 'position': 0})
|
||||
play.assert_called_once_with('bell')
|
||||
assert not room.ringside_strip.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['ko', 'bell'])
|
||||
def test_receiving_client_shows_reaction_without_focus(room, qtbot, monkeypatch, kind):
|
||||
from PyQt6.QtWidgets import QLineEdit
|
||||
other_client = QLineEdit()
|
||||
qtbot.addWidget(other_client)
|
||||
other_client.show()
|
||||
other_client.activateWindow()
|
||||
other_client.setFocus()
|
||||
qtbot.wait(30)
|
||||
assert not room.isActiveWindow()
|
||||
play = Mock()
|
||||
monkeypatch.setattr(room, '_play_reaction_sound', play)
|
||||
room.handle_fight_reaction({'kind': kind, 'username': 'Other client', 'position': 0})
|
||||
assert room.ringside_strip.isVisible()
|
||||
play.assert_called_once_with(kind)
|
||||
assert QApplication.focusWidget() is other_client
|
||||
QApplication.sendEvent(room, QEvent(QEvent.Type.WindowDeactivate))
|
||||
assert room.ringside_strip.isVisible()
|
||||
room.showMinimized()
|
||||
qtbot.wait(30)
|
||||
assert not room.ringside_strip.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', REACTIONS)
|
||||
def test_original_sound_files_are_valid_and_loadable(qtbot, tmp_path, kind):
|
||||
path = write_reaction_sound(tmp_path, kind)
|
||||
assert write_reaction_sound(tmp_path, kind) == path
|
||||
with wave.open(str(path)) as audio:
|
||||
assert audio.getnchannels() == 1
|
||||
assert audio.getsampwidth() == 2
|
||||
assert audio.getframerate() == 22050
|
||||
assert .35 <= audio.getnframes() / 22050 <= .9
|
||||
assert any(audio.readframes(audio.getnframes()))
|
||||
sound = QSoundEffect()
|
||||
sound.setSource(QUrl.fromLocalFile(str(path)))
|
||||
qtbot.waitUntil(lambda: sound.status() in (QSoundEffect.Status.Ready, QSoundEffect.Status.Error))
|
||||
assert sound.status() == QSoundEffect.Status.Ready
|
||||
|
||||
|
||||
def test_robbery_vote_controls_expiry_and_reconnect(room, qtbot):
|
||||
poll = {'id': 'vote-1', 'active': True, 'expiresAt': time.time() * 1000 + 30000,
|
||||
'votes': {'Other': 'yes'}}
|
||||
room.update_fight_poll(poll)
|
||||
assert not room.fight_poll_panel.isHidden()
|
||||
assert 'Absolutely 1' in room.fight_poll_result.text()
|
||||
with qtbot.waitSignal(room.fight_action_requested) as event:
|
||||
room.fight_no_btn.click()
|
||||
assert event.args == [{'type': 'fight_poll_vote', 'id': 'vote-1', 'choice': 'no'}]
|
||||
poll['votes']['Me'] = 'no'
|
||||
room.update_fight_poll(poll)
|
||||
assert 'Fair decision 1' in room.fight_poll_result.text()
|
||||
assert 'Your vote is in' in room.fight_poll_result.text()
|
||||
room.update_connection_status(False)
|
||||
assert not room.fight_yes_btn.isEnabled()
|
||||
room.update_connection_status(True)
|
||||
assert room.fight_yes_btn.isEnabled()
|
||||
poll['expiresAt'] = time.time() * 1000 - 1000
|
||||
room.update_fight_poll(poll)
|
||||
assert 'Vote closed' in room.fight_poll_title.text()
|
||||
assert not room.fight_yes_btn.isEnabled()
|
||||
assert not room._fight_poll_timer.isActive()
|
||||
room.reset_fight_reactions()
|
||||
assert room.fight_poll_panel.isHidden()
|
||||
|
||||
|
||||
def test_styled_vote_controls_fit_and_dismiss_stays_dismissed(room, qtbot):
|
||||
from main import VlcSyncApp
|
||||
VlcSyncApp.apply_stylesheet(room)
|
||||
room.splitter.setSizes([750, 350])
|
||||
poll = {'id': 'styled', 'active': True, 'expiresAt': time.time() * 1000 + 30000, 'votes': {}}
|
||||
room.update_fight_poll(poll)
|
||||
qtbot.wait(50)
|
||||
for button in (room.fight_yes_btn, room.fight_no_btn, room.fight_poll_dismiss_btn):
|
||||
assert button.width() >= button.fontMetrics().horizontalAdvance(button.text()) + 10
|
||||
room.fight_poll_dismiss_btn.click()
|
||||
room.update_fight_poll(poll)
|
||||
assert room.fight_poll_panel.isHidden()
|
||||
room.update_fight_poll({**poll, 'id': 'new-poll'})
|
||||
assert not room.fight_poll_panel.isHidden()
|
||||
@@ -0,0 +1,104 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from websockets.sync.client import connect
|
||||
|
||||
from test_ready_server import server_url, send, receive
|
||||
|
||||
|
||||
def join_pair(host, guest):
|
||||
file_info = {'name': 'fight.mkv', 'size': 1234, 'duration': 60}
|
||||
send(host, type='create_room', username='Host', fileInfo=file_info)
|
||||
code = receive(host, 'room_created')['code']
|
||||
send(guest, type='join_room', username='Guest', code=code)
|
||||
receive(guest, 'room_file_check')
|
||||
send(guest, type='confirm_join', fileInfo=file_info)
|
||||
receive(guest, 'room_joined')
|
||||
receive(host, 'user_joined')
|
||||
return code, file_info
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['ko', 'bell', 'tap', 'robbery'])
|
||||
def test_reactions_are_room_wide_and_preserve_playback(server_url, kind):
|
||||
with connect(server_url) as host, connect(server_url) as guest:
|
||||
code, file_info = join_pair(host, guest)
|
||||
# An unconfirmed client cannot broadcast or consume the cooldown.
|
||||
with connect(server_url) as pending:
|
||||
send(pending, type='join_room', username='Pending', code=code)
|
||||
receive(pending, 'room_file_check')
|
||||
send(pending, type='reaction', kind=kind)
|
||||
send(pending, type='ping', timestamp=1)
|
||||
assert receive(pending, 'pong')['timestamp'] == 1
|
||||
send(host, type='reaction', kind='invalid')
|
||||
assert 'Unknown' in receive(host, 'reaction_error')['message']
|
||||
send(host, type='sync', action='play', position=18)
|
||||
receive(guest, 'sync')
|
||||
send(host, type='reaction', kind=kind, username='Spoofed')
|
||||
reaction = receive(host, 'reaction')
|
||||
assert reaction['kind'] == kind and reaction['username'] == 'Host'
|
||||
assert reaction['position'] >= 18
|
||||
assert receive(guest, 'reaction') == reaction
|
||||
assert receive(host, 'chat')['message'] == '🥊 /' + kind
|
||||
receive(guest, 'chat')
|
||||
if kind == 'robbery':
|
||||
assert receive(host, 'fight_poll')['active']
|
||||
receive(guest, 'fight_poll')
|
||||
send(guest, type='reaction', kind='bell')
|
||||
assert 'cooldown' in receive(guest, 'reaction_error')['message']
|
||||
send(host, type='request_state')
|
||||
state = receive(host, 'state')['state']
|
||||
assert state['playing'] and state['position'] >= 18
|
||||
with pytest.raises(TimeoutError):
|
||||
host.recv(timeout=.1)
|
||||
# Reconnecting restores chat history without replaying transient effects.
|
||||
guest.close()
|
||||
with connect(server_url) as restored:
|
||||
send(restored, type='rejoin_room', username='Guest', code=code)
|
||||
snapshot = receive(restored, 'room_rejoined')
|
||||
assert snapshot['chatHistory'][-1]['message'] == '🥊 /' + kind
|
||||
with pytest.raises(TimeoutError):
|
||||
restored.recv(timeout=.1)
|
||||
|
||||
|
||||
def test_robbery_votes_are_unique_restored_and_expire(server_url):
|
||||
with connect(server_url) as host, connect(server_url) as guest:
|
||||
code, file_info = join_pair(host, guest)
|
||||
send(host, type='reaction', kind='robbery')
|
||||
poll = receive(host, 'fight_poll')
|
||||
poll_id = poll['id']
|
||||
assert poll['votes'] == {} and poll['active']
|
||||
receive(guest, 'fight_poll')
|
||||
send(host, type='fight_poll_vote', id=poll_id, choice='yes')
|
||||
assert receive(host, 'fight_poll')['votes'] == {'Host': 'yes'}
|
||||
receive(guest, 'fight_poll')
|
||||
send(host, type='fight_poll_vote', id=poll_id, choice='no')
|
||||
assert receive(host, 'fight_poll')['votes'] == {'Host': 'no'}
|
||||
receive(guest, 'fight_poll')
|
||||
send(guest, type='fight_poll_vote', id=poll_id, choice='yes', username='Host')
|
||||
assert receive(host, 'fight_poll')['votes'] == {'Host': 'no', 'Guest': 'yes'}
|
||||
receive(guest, 'fight_poll')
|
||||
for vote_id, choice in [('stale', 'yes'), (poll_id, 'invalid')]:
|
||||
send(guest, type='fight_poll_vote', id=vote_id, choice=choice)
|
||||
send(guest, type='ping', timestamp=2)
|
||||
assert json.loads(guest.recv(timeout=3))['type'] == 'pong'
|
||||
guest.close()
|
||||
with connect(server_url) as restored:
|
||||
send(restored, type='rejoin_room', username='Guest', code=code)
|
||||
snapshot = receive(restored, 'room_rejoined')['fightPoll']
|
||||
assert snapshot['id'] == poll_id and snapshot['votes']['Guest'] == 'yes'
|
||||
receive(host, 'user_rejoined')
|
||||
time.sleep(3.1)
|
||||
send(restored, type='reaction', kind='robbery')
|
||||
assert 'already open' in receive(restored, 'reaction_error')['message']
|
||||
assert receive(restored, 'fight_poll')['id'] == poll_id
|
||||
closed = json.loads(host.recv(timeout=35))
|
||||
assert closed['type'] == 'fight_poll' and not closed['active']
|
||||
assert closed['votes'] == {'Host': 'no', 'Guest': 'yes'}
|
||||
assert not receive(restored, 'fight_poll')['active']
|
||||
send(restored, type='fight_poll_vote', id=poll_id, choice='no')
|
||||
send(restored, type='ping', timestamp=3)
|
||||
assert json.loads(restored.recv(timeout=3))['type'] == 'pong'
|
||||
send(host, type='reaction', kind='robbery')
|
||||
replacement = receive(host, 'fight_poll')
|
||||
assert replacement['id'] != poll_id and replacement['votes'] == {}
|
||||
@@ -0,0 +1,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())
|
||||
@@ -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()
|
||||
@@ -0,0 +1,48 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from PyQt6.QtCore import QSettings
|
||||
|
||||
import room_widget
|
||||
from main import VlcSyncApp
|
||||
from test_chat import PlayerSignals
|
||||
|
||||
|
||||
def test_preferences_survive_restart(qtbot, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(VlcSyncApp, "refresh_sessions", lambda self: None)
|
||||
monkeypatch.setattr(room_widget, "VLCSyncPlayer",
|
||||
lambda frame: Mock(signals=PlayerSignals()))
|
||||
path = str(tmp_path / "preferences.ini")
|
||||
settings = QSettings(path, QSettings.Format.IniFormat)
|
||||
first = VlcSyncApp(settings)
|
||||
qtbot.addWidget(first)
|
||||
first.show()
|
||||
first.lobby_widget.username_input.setText("Alex")
|
||||
first.room_widget.volume_slider.setValue(37)
|
||||
first._remember_media_folder(str(tmp_path / "movie.mkv"))
|
||||
first.room_widget.chat_container.hide()
|
||||
first.room_widget.tags_list.hide()
|
||||
first.resize(900, 650)
|
||||
first.close()
|
||||
|
||||
second = VlcSyncApp(QSettings(path, QSettings.Format.IniFormat))
|
||||
qtbot.addWidget(second)
|
||||
assert second.lobby_widget.username_input.text() == "Alex"
|
||||
assert second.room_widget.volume_slider.value() == 37
|
||||
assert second.lobby_widget.media_folder == str(tmp_path)
|
||||
assert second.room_widget.chat_container.isHidden()
|
||||
assert second.room_widget.tags_list.isHidden()
|
||||
assert second.size() == first.size()
|
||||
assert second.sync_client.url == "wss://video-sync.peterstockings.com/ws"
|
||||
|
||||
|
||||
def test_missing_folder_and_volume_bounds(qtbot, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(VlcSyncApp, "refresh_sessions", lambda self: None)
|
||||
monkeypatch.setattr(room_widget, "VLCSyncPlayer",
|
||||
lambda frame: Mock(signals=PlayerSignals()))
|
||||
settings = QSettings(str(tmp_path / "preferences.ini"), QSettings.Format.IniFormat)
|
||||
settings.setValue("media_folder", str(tmp_path / "missing"))
|
||||
settings.setValue("volume", 200)
|
||||
window = VlcSyncApp(settings)
|
||||
qtbot.addWidget(window)
|
||||
assert window.lobby_widget.media_folder == ""
|
||||
assert window.room_widget.volume_slider.value() == 100
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Exercise ready checks through real WebSockets against an isolated local server."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from websockets.sync.client import connect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server_url():
|
||||
with socket.socket() as reservation:
|
||||
reservation.bind(('127.0.0.1', 0))
|
||||
port = reservation.getsockname()[1]
|
||||
bun = shutil.which('bun')
|
||||
assert bun, 'Bun is required to test the local server'
|
||||
process = subprocess.Popen(
|
||||
[bun, 'run', 'server.ts'], cwd=Path(__file__).resolve().parent.parent,
|
||||
env={**os.environ, 'PORT': str(port)}, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
pytest.fail(process.stderr.read().decode())
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() >= deadline:
|
||||
pytest.fail('Local server did not start')
|
||||
time.sleep(.05)
|
||||
yield f'ws://127.0.0.1:{port}/ws'
|
||||
finally:
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
process.stderr.close()
|
||||
|
||||
|
||||
def send(ws, **message):
|
||||
ws.send(json.dumps(message))
|
||||
|
||||
|
||||
def receive(ws, kind, predicate=lambda message: True):
|
||||
deadline = time.monotonic() + 3
|
||||
while True:
|
||||
message = json.loads(ws.recv(timeout=max(.01, deadline - time.monotonic())))
|
||||
if message['type'] == kind and predicate(message):
|
||||
return message
|
||||
|
||||
|
||||
def test_room_ready_check_lifecycle(server_url):
|
||||
file_info = {'name': 'test.mkv', 'size': 1234, 'duration': 60}
|
||||
with connect(server_url) as host, connect(server_url) as guest:
|
||||
send(host, type='create_room', username='Host', fileInfo=file_info)
|
||||
code = receive(host, 'room_created')['code']
|
||||
send(guest, type='join_room', username='Guest', code=code)
|
||||
receive(guest, 'room_file_check')
|
||||
send(guest, type='confirm_join', fileInfo=file_info)
|
||||
receive(guest, 'room_joined')
|
||||
receive(host, 'user_joined')
|
||||
send(host, type='sync', action='play', position=12)
|
||||
receive(guest, 'sync')
|
||||
send(host, type='ready_check', action='begin')
|
||||
pause = receive(host, 'sync')
|
||||
assert pause['action'] == 'pause' and pause['position'] >= 12
|
||||
check = receive(host, 'ready_check')
|
||||
check_id = check['id']
|
||||
assert check['requestedBy'] == 'Host'
|
||||
assert not any(p['ready'] for p in check['participants'])
|
||||
assert receive(guest, 'ready_check')['id'] == check_id
|
||||
|
||||
# Unconfirmed peers cannot create or vote in a check.
|
||||
with connect(server_url) as pending:
|
||||
send(pending, type='join_room', username='Pending', code=code)
|
||||
receive(pending, 'room_file_check')
|
||||
send(pending, type='ready_check', action='begin')
|
||||
send(pending, type='ready_check', action='vote', id=check_id, ready=True)
|
||||
send(host, type='ready_check', action='start', id=check_id)
|
||||
send(host, type='ready_check', action='vote', id=check_id, ready=True)
|
||||
check = receive(host, 'ready_check')
|
||||
assert check['active'] and sum(p['ready'] for p in check['participants']) == 1
|
||||
receive(guest, 'ready_check')
|
||||
send(host, type='ready_check', action='begin')
|
||||
repeated = receive(host, 'ready_check')
|
||||
assert repeated['id'] == check_id
|
||||
assert sum(p['ready'] for p in repeated['participants']) == 1
|
||||
receive(guest, 'ready_check')
|
||||
send(guest, type='ready_check', action='vote', id=check_id, ready=True)
|
||||
assert all(p['ready'] for p in receive(host, 'ready_check')['participants'])
|
||||
receive(guest, 'ready_check')
|
||||
|
||||
# Non-initiators cannot start. Stale IDs cannot change votes.
|
||||
send(guest, type='ready_check', action='start', id=check_id)
|
||||
send(guest, type='ready_check', action='vote', id='stale', ready=False)
|
||||
send(guest, type='ready_check', action='begin')
|
||||
assert all(p['ready'] for p in receive(host, 'ready_check')['participants'])
|
||||
receive(guest, 'ready_check')
|
||||
|
||||
# Dropping a connection revokes readiness and blocks starting.
|
||||
guest.close()
|
||||
disconnected = receive(host, 'ready_check')
|
||||
guest_state = next(p for p in disconnected['participants'] if p['username'] == 'Guest')
|
||||
assert not guest_state['connected'] and not guest_state['ready']
|
||||
send(host, type='ready_check', action='start', id=check_id)
|
||||
with connect(server_url) as rejoined:
|
||||
send(rejoined, type='rejoin_room', username='Guest', code=code)
|
||||
restored = receive(rejoined, 'room_rejoined')['readyCheck']
|
||||
assert restored['id'] == check_id
|
||||
assert not next(p for p in restored['participants'] if p['username'] == 'Guest')['ready']
|
||||
receive(host, 'ready_check')
|
||||
receive(rejoined, 'ready_check')
|
||||
send(rejoined, type='ready_check', action='vote', id=check_id, ready=True)
|
||||
receive(host, 'ready_check')
|
||||
receive(rejoined, 'ready_check')
|
||||
send(host, type='ready_check', action='start', id=check_id)
|
||||
assert receive(host, 'sync')['action'] == 'play'
|
||||
assert receive(rejoined, 'sync')['action'] == 'play'
|
||||
assert not receive(host, 'ready_check')['active']
|
||||
assert not receive(rejoined, 'ready_check')['active']
|
||||
|
||||
send(host, type='ready_check', action='begin')
|
||||
new_check = receive(host, 'ready_check')
|
||||
assert new_check['id'] != check_id
|
||||
assert not any(p['ready'] for p in new_check['participants'])
|
||||
receive(rejoined, 'ready_check')
|
||||
send(host, type='ready_check', action='cancel', id=new_check['id'])
|
||||
assert not receive(host, 'ready_check')['active']
|
||||
assert not receive(rejoined, 'ready_check')['active']
|
||||
@@ -0,0 +1,44 @@
|
||||
import ssl
|
||||
|
||||
from sync_client import SyncClientThread
|
||||
|
||||
|
||||
def test_ssl_context_uses_bundled_ca_certificates():
|
||||
context = SyncClientThread._create_ssl_context()
|
||||
|
||||
assert context.verify_mode == ssl.CERT_REQUIRED
|
||||
assert context.check_hostname is True
|
||||
assert context.cert_store_stats()["x509_ca"] > 0
|
||||
|
||||
|
||||
def test_certificate_error_finds_nested_verification_error():
|
||||
certificate_error = ssl.SSLCertVerificationError(1, "certificate has expired")
|
||||
|
||||
try:
|
||||
raise RuntimeError("connection failed") from certificate_error
|
||||
except RuntimeError as error:
|
||||
assert SyncClientThread._certificate_error(error) is certificate_error
|
||||
|
||||
|
||||
def test_non_certificate_error_is_ignored():
|
||||
assert SyncClientThread._certificate_error(ConnectionError("offline")) is None
|
||||
|
||||
|
||||
def test_ready_check_event_dispatch(qtbot):
|
||||
client = SyncClientThread()
|
||||
message = {'type': 'ready_check', 'active': True, 'id': 'check'}
|
||||
with qtbot.waitSignal(client.ready_check_updated) as signal:
|
||||
client._handle_message(message)
|
||||
assert signal.args == [message]
|
||||
|
||||
|
||||
def test_fight_event_dispatch_and_nonfatal_feedback(qtbot):
|
||||
client = SyncClientThread()
|
||||
for event_type, signal in (('reaction', client.fight_reaction), ('fight_poll', client.fight_poll_updated)):
|
||||
message = {'type': event_type, 'kind': 'ko'}
|
||||
with qtbot.waitSignal(signal) as event:
|
||||
client._handle_message(message)
|
||||
assert event.args == [message]
|
||||
with qtbot.waitSignal(client.system_message) as event:
|
||||
client._handle_message({'type': 'reaction_error', 'message': 'Cooldown'})
|
||||
assert event.args == ['Cooldown']
|
||||
@@ -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}"
|
||||
Generated
+245
@@ -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" },
|
||||
]
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user