Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
.gitignore
vendored
23
.gitignore
vendored
@@ -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
|
||||
|
||||
74
README.md
74
README.md
@@ -2,17 +2,28 @@
|
||||
|
||||
Watch videos together in real-time. Each user loads a local video file — only sync commands and chat messages travel over the network.
|
||||
|
||||
## Architecture
|
||||
|
||||
This project consists of three main components:
|
||||
|
||||
- **Server**: A fast, lightweight WebSocket server built with Bun.
|
||||
- **Web Frontend**: A vanilla HTML/CSS/JS single-page application for joining rooms and syncing video playback.
|
||||
- **Desktop App**: A Python/PyQt6 application with embedded VLC (`python-vlc`). **Note:** The desktop client was specifically created because web browsers (like Chrome) have issues handling and playing MKV files natively.
|
||||
|
||||
## Features
|
||||
|
||||
- **Room system** — create a room, share the 6-character code with friends
|
||||
- **File verification** — joiners must have the exact same file (matched by name + size)
|
||||
- **Playback sync** — play, pause, seek, and speed changes broadcast to all clients instantly
|
||||
- **Drift correction** — automatic re-sync every 5 seconds to keep everyone aligned
|
||||
- **Live chat** — YouTube-style chat sidebar with colored usernames
|
||||
- **Local playback** — multi-gigabyte files work fine since nothing is uploaded
|
||||
- **Room system** — create a room, share the 6-character code with friends.
|
||||
- **File verification** — joiners must have the exact same file (matched by name + size).
|
||||
- **Playback sync** — play, pause, seek, and speed changes broadcast to all clients instantly.
|
||||
- **Drift correction** — automatic re-sync every 5 seconds to keep everyone aligned.
|
||||
- **Live chat** — YouTube-style chat sidebar with colored usernames.
|
||||
- **Local playback** — multi-gigabyte files work fine since nothing is uploaded.
|
||||
- **Broad format support** — the desktop app leverages VLC to play MKV and other formats that browsers struggle with.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start the Server
|
||||
|
||||
```bash
|
||||
# Install Bun (if not already installed)
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
@@ -21,15 +32,44 @@ curl -fsSL https://bun.sh/install | bash
|
||||
bun run server.ts
|
||||
```
|
||||
|
||||
Open **http://localhost:3000** in your browser.
|
||||
### 2. Connect via Web Frontend
|
||||
|
||||
## How It Works
|
||||
Once the server is running, open **http://localhost:3000** in your browser to access the web app.
|
||||
|
||||
1. Enter your name and select a video file → **Create Room**
|
||||
2. Share the room code with a friend
|
||||
3. Friend enters the code → **Join Room** → selects their copy of the same file
|
||||
4. Play/pause/seek in either browser — the other stays in sync
|
||||
|
||||
### 3. Connect via Desktop App (Recommended for MKV files)
|
||||
|
||||
If you are playing MKV files or formats not supported by your browser, use the desktop client:
|
||||
|
||||
```bash
|
||||
cd desktop-client
|
||||
|
||||
# Install dependencies using uv
|
||||
uv sync
|
||||
|
||||
# Run the app
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
You can also build a standalone executable for the desktop app using the provided `build.ps1` script (requires PyInstaller).
|
||||
|
||||
### Quick Run on Linux
|
||||
|
||||
If you just want to run the desktop client without cloning the repo manually, use the launcher script. Prerequisites: `git` and [`uv`](https://docs.astral.sh/uv/).
|
||||
|
||||
```bash
|
||||
# Download the script
|
||||
curl -fsSL https://gitea.peterstockings.com/peterstockings/video-sync/raw/branch/master/desktop-client/run-videosync.sh -o run-videosync.sh
|
||||
chmod +x run-videosync.sh
|
||||
|
||||
# Run (clones on first launch, pulls updates on subsequent runs)
|
||||
./run-videosync.sh
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
@@ -53,17 +93,21 @@ location / {
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Server | Bun (native HTTP + WebSocket) |
|
||||
| Frontend | Vanilla HTML / CSS / JS |
|
||||
| Dependencies | None |
|
||||
| **Server** | Bun (native HTTP + WebSocket) |
|
||||
| **Web Frontend** | Vanilla HTML / CSS / JS |
|
||||
| **Desktop App**| Python, PyQt6, python-vlc, websockets |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── server.ts # Bun WebSocket server
|
||||
├── package.json
|
||||
└── public/
|
||||
├── index.html # Single-page app
|
||||
├── style.css # Dark theme
|
||||
└── app.js # Client sync + chat logic
|
||||
├── public/
|
||||
│ ├── index.html # Single-page web app
|
||||
│ ├── style.css # Dark theme
|
||||
│ └── app.js # Client sync + chat logic
|
||||
└── desktop-client/
|
||||
├── main.py # Desktop app entry point
|
||||
├── pyproject.toml # Python dependencies (uv)
|
||||
└── vlc_player.py # Embedded VLC player
|
||||
```
|
||||
|
||||
1
desktop-client/.python-version
Normal file
1
desktop-client/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
0
desktop-client/README.md
Normal file
0
desktop-client/README.md
Normal file
2
desktop-client/build.ps1
Normal file
2
desktop-client/build.ps1
Normal file
@@ -0,0 +1,2 @@
|
||||
uv add pyinstaller pillow
|
||||
uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --add-data "icon.png;." --add-data "copy.svg;." --add-data "check.svg;." main.py
|
||||
82
desktop-client/chat_widgets.py
Normal file
82
desktop-client/chat_widgets.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame,
|
||||
QMainWindow
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
|
||||
|
||||
class ChatBubble(QWidget):
|
||||
def __init__(self, author, text, time_str, is_self, on_link_clicked):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(5, 2, 5, 2)
|
||||
|
||||
# Container for the actual bubble
|
||||
self.bubble = QFrame()
|
||||
self.bubble.setObjectName("bubble")
|
||||
|
||||
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.setStyleSheet(f"color: {author_color}; font-weight: bold; font-size: 11px; background: transparent;")
|
||||
bubble_layout.addWidget(author_lbl)
|
||||
|
||||
# Text
|
||||
self.text_lbl = QLabel(text)
|
||||
self.text_lbl.setWordWrap(True)
|
||||
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()
|
||||
|
||||
|
||||
class SystemMessageWidget(QWidget):
|
||||
def __init__(self, text):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 5, 0, 5)
|
||||
lbl = QLabel(text)
|
||||
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
lbl.setWordWrap(True)
|
||||
lbl.setStyleSheet("color: #888; font-style: italic; font-size: 11px; background: transparent;")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
|
||||
class ChatPopoutWindow(QMainWindow):
|
||||
closed = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Live Chat")
|
||||
self.resize(350, 600)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.closed.emit()
|
||||
super().closeEvent(event)
|
||||
3
desktop-client/check.svg
Normal file
3
desktop-client/check.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4BB543" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
106
desktop-client/commands.py
Normal file
106
desktop-client/commands.py
Normal file
@@ -0,0 +1,106 @@
|
||||
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>/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 == "/play":
|
||||
if not widget.vlc_player.is_playing:
|
||||
widget.toggle_playback()
|
||||
widget.add_system_message(" ".join(parts))
|
||||
return True
|
||||
|
||||
elif cmd == "/pause":
|
||||
if widget.vlc_player.is_playing:
|
||||
widget.toggle_playback()
|
||||
widget.add_system_message(" ".join(parts))
|
||||
return True
|
||||
|
||||
elif cmd == "/seek":
|
||||
if len(parts) > 1:
|
||||
if widget._handle_seek_command(parts[1]):
|
||||
widget.add_system_message(" ".join(parts))
|
||||
else:
|
||||
widget.add_system_message("Invalid time format. Use: 1:23, +30s, -1m")
|
||||
else:
|
||||
widget.add_system_message("Usage: /seek [time]")
|
||||
return True
|
||||
|
||||
elif cmd == "/tag":
|
||||
if len(parts) > 1:
|
||||
time_arg = parts[1].lower()
|
||||
tag_name = " ".join(parts[2:])
|
||||
|
||||
current_ms = widget.vlc_player.current_time_ms
|
||||
length_ms = widget.vlc_player.get_length()
|
||||
target_ms = parse_time_arg(time_arg, current_ms, length_ms)
|
||||
|
||||
if target_ms is not None:
|
||||
time_str = format_time_short(target_ms)
|
||||
msg = f"{time_str} {tag_name}".strip()
|
||||
widget.chat_message_ready.emit(msg)
|
||||
else:
|
||||
widget.add_system_message("Invalid time format. Use: now, 1:23, +30s, -1m")
|
||||
else:
|
||||
widget.add_system_message("Usage: /tag [now|time] [name]")
|
||||
return True
|
||||
|
||||
elif cmd == "/time":
|
||||
current_ms = widget.vlc_player.current_time_ms
|
||||
time_str = format_time_short(current_ms)
|
||||
widget.add_system_message(f"Current time: <a href='seek:{time_str}' style='color: #66b3ff; text-decoration: none;'>{time_str}</a>")
|
||||
return True
|
||||
|
||||
elif cmd == "/help":
|
||||
widget.add_system_message(HELP_TEXT)
|
||||
return True
|
||||
|
||||
return False
|
||||
4
desktop-client/copy.svg
Normal file
4
desktop-client/copy.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#aaaaaa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 333 B |
48
desktop-client/custom_widgets.py
Normal file
48
desktop-client/custom_widgets.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from PyQt6.QtWidgets import QSlider, QToolTip
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
|
||||
class ClickableSlider(QSlider):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._get_tooltip_text = None
|
||||
self.setMouseTracking(True)
|
||||
|
||||
def set_tooltip_provider(self, provider_func):
|
||||
self._get_tooltip_text = provider_func
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
super().mousePressEvent(event)
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
val = 0
|
||||
if self.orientation() == Qt.Orientation.Horizontal:
|
||||
if self.width() > 0:
|
||||
val = self.minimum() + ((self.maximum() - self.minimum()) * event.pos().x()) / self.width()
|
||||
else:
|
||||
if self.height() > 0:
|
||||
val = self.minimum() + ((self.maximum() - self.minimum()) * (self.height() - event.pos().y())) / self.height()
|
||||
|
||||
val = max(self.minimum(), min(self.maximum(), int(val)))
|
||||
self.setValue(val)
|
||||
self.sliderMoved.emit(val)
|
||||
|
||||
if self._get_tooltip_text:
|
||||
text = self._get_tooltip_text(val)
|
||||
if text:
|
||||
QToolTip.showText(event.globalPosition().toPoint(), text, self)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
super().mouseMoveEvent(event)
|
||||
if self._get_tooltip_text and self.width() > 0:
|
||||
hover_val = self.minimum() + ((self.maximum() - self.minimum()) * event.pos().x()) / self.width()
|
||||
hover_val = max(self.minimum(), min(self.maximum(), int(hover_val)))
|
||||
text = self._get_tooltip_text(hover_val)
|
||||
if text:
|
||||
QToolTip.showText(event.globalPosition().toPoint(), text, self)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
super().mouseReleaseEvent(event)
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
# Explicitly emit sliderReleased on mouse release
|
||||
# to ensure single clicks on the track also trigger the release logic
|
||||
self.sliderReleased.emit()
|
||||
BIN
desktop-client/icon.png
Normal file
BIN
desktop-client/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 382 KiB |
190
desktop-client/lobby_widget.py
Normal file
190
desktop-client/lobby_widget.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import os
|
||||
import re
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
|
||||
QFileDialog, QFrame, QApplication
|
||||
)
|
||||
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
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
self.local_file_path = None
|
||||
self.local_file_name = None
|
||||
self.local_file_size = 0
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Container for centering
|
||||
container = QFrame()
|
||||
container.setObjectName("lobbyCard")
|
||||
container.setFixedWidth(500)
|
||||
container_layout = QVBoxLayout(container)
|
||||
container_layout.setContentsMargins(30, 30, 30, 30)
|
||||
container_layout.setSpacing(15)
|
||||
|
||||
# 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)
|
||||
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)
|
||||
layout.addWidget(container)
|
||||
|
||||
# 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)
|
||||
|
||||
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", "", "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)
|
||||
|
||||
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()
|
||||
494
desktop-client/main.py
Normal file
494
desktop-client/main.py
Normal file
@@ -0,0 +1,494 @@
|
||||
import sys
|
||||
import os
|
||||
import ctypes
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QStackedWidget, QFileDialog, QMessageBox
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
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):
|
||||
super().__init__()
|
||||
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)
|
||||
|
||||
# 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.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.latency_updated.connect(self.room_widget.update_latency)
|
||||
|
||||
self.apply_stylesheet()
|
||||
|
||||
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",
|
||||
"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()
|
||||
|
||||
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_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")
|
||||
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, f"Select {req_name}", "", f"Required File ({req_name})"
|
||||
)
|
||||
|
||||
if 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! 👋")
|
||||
|
||||
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)
|
||||
|
||||
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())
|
||||
|
||||
13
desktop-client/pyproject.toml
Normal file
13
desktop-client/pyproject.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "python-app"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"pillow>=12.1.1",
|
||||
"pyinstaller>=6.19.0",
|
||||
"pyqt6>=6.10.2",
|
||||
"python-vlc>=3.0.21203",
|
||||
"websockets>=16.0",
|
||||
]
|
||||
818
desktop-client/room_widget.py
Normal file
818
desktop-client/room_widget.py
Normal file
@@ -0,0 +1,818 @@
|
||||
import os
|
||||
import datetime
|
||||
import html
|
||||
import re
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QFrame, QTextEdit, QTextBrowser, QApplication, QSplitter,
|
||||
QScrollArea
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QEvent
|
||||
from PyQt6.QtGui import QIcon, QCursor
|
||||
|
||||
import uuid
|
||||
import urllib.parse
|
||||
|
||||
from vlc_player import VLCSyncPlayer
|
||||
from utils import format_time_hms, format_time_short
|
||||
from chat_widgets import ChatBubble, SystemMessageWidget, ChatPopoutWindow
|
||||
from custom_widgets import ClickableSlider
|
||||
from commands import parse_time_arg, handle_chat_command
|
||||
|
||||
|
||||
class ExpectedVlcEvent:
|
||||
def __init__(self, action: str, req_id: str, target_val=None):
|
||||
self.action = action
|
||||
self.req_id = req_id
|
||||
self.target_val = target_val
|
||||
self.timestamp = datetime.datetime.now()
|
||||
|
||||
class RoomWidget(QWidget):
|
||||
leave_requested = pyqtSignal()
|
||||
sync_action_requested = pyqtSignal(str, float, str) # action, position_s, req_id
|
||||
chat_message_ready = pyqtSignal(str) # raw message text
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.username = ""
|
||||
self.room_code = ""
|
||||
self.expected_vlc_events = []
|
||||
self.last_reported_time_ms = 0
|
||||
self.current_users = []
|
||||
self._is_first_user_update = True
|
||||
|
||||
self.popout_window = None
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
self.splitter.setHandleWidth(3)
|
||||
self.splitter.setStyleSheet("""
|
||||
QSplitter::handle {
|
||||
background-color: #333;
|
||||
}
|
||||
QSplitter::handle:hover {
|
||||
background-color: #3ea6ff;
|
||||
}
|
||||
""")
|
||||
|
||||
# --- Left Side: Video ---
|
||||
video_container = QWidget()
|
||||
video_layout = QVBoxLayout(video_container)
|
||||
video_layout.setContentsMargins(0, 0, 0, 0)
|
||||
video_layout.setSpacing(0)
|
||||
|
||||
# Topbar
|
||||
self.topbar = QFrame()
|
||||
self.topbar.setObjectName("topbar")
|
||||
self.topbar.setFixedHeight(50)
|
||||
topbar_layout = QHBoxLayout(self.topbar)
|
||||
|
||||
self.room_code_display = QLabel("Room: XXXX")
|
||||
self.copy_code_btn = QPushButton()
|
||||
self.copy_code_btn.setObjectName("iconBtn")
|
||||
self.copy_icon = QIcon(os.path.join(os.path.dirname(__file__), "copy.svg"))
|
||||
self.check_icon = QIcon(os.path.join(os.path.dirname(__file__), "check.svg"))
|
||||
self.copy_code_btn.setIcon(self.copy_icon)
|
||||
self.copy_code_btn.setFixedSize(30, 30)
|
||||
self.copy_code_btn.setToolTip("Copy Room Code")
|
||||
self.copy_code_btn.clicked.connect(self.copy_room_code)
|
||||
|
||||
self.status_dot = QLabel("●")
|
||||
self.status_dot.setFixedWidth(20)
|
||||
self.status_dot.setStyleSheet("color: #888; font-size: 14px; background: transparent;")
|
||||
self.status_dot.setToolTip("Connecting...")
|
||||
self._latency_ms = None
|
||||
|
||||
self.room_file_badge = QLabel("📄 No file")
|
||||
self.room_file_badge.setObjectName("fileBadge")
|
||||
|
||||
self.leave_btn = QPushButton("Leave Room")
|
||||
self.leave_btn.setObjectName("dangerBtn")
|
||||
self.leave_btn.clicked.connect(self.leave_requested.emit)
|
||||
|
||||
topbar_layout.addWidget(self.room_code_display)
|
||||
topbar_layout.addWidget(self.copy_code_btn)
|
||||
topbar_layout.addWidget(self.status_dot)
|
||||
topbar_layout.addStretch()
|
||||
topbar_layout.addWidget(self.room_file_badge)
|
||||
topbar_layout.addWidget(self.leave_btn)
|
||||
|
||||
# Video Frame Placeholder
|
||||
self.video_frame = QFrame()
|
||||
self.video_frame.setStyleSheet("background-color: black;")
|
||||
self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
|
||||
|
||||
# Controls Bar
|
||||
self.controls_bar = QFrame()
|
||||
self.controls_bar.setObjectName("controlsBar")
|
||||
self.controls_bar.setFixedHeight(60)
|
||||
controls_layout = QHBoxLayout(self.controls_bar)
|
||||
|
||||
self.play_btn = QPushButton("▶")
|
||||
self.play_btn.setFixedSize(40, 40)
|
||||
self.play_btn.setObjectName("playBtn")
|
||||
|
||||
# Time and SeekBar
|
||||
self.seekbar = ClickableSlider(Qt.Orientation.Horizontal)
|
||||
self.seekbar.setRange(0, 1000)
|
||||
self.seekbar.setObjectName("seekBar")
|
||||
self.seekbar.sliderMoved.connect(self.on_seekbar_dragged)
|
||||
self.seekbar.sliderReleased.connect(self.on_seekbar_released)
|
||||
self.seekbar.set_tooltip_provider(self.get_seekbar_tooltip)
|
||||
|
||||
self.time_lbl = QLabel("00:00:00 / 00:00:00")
|
||||
|
||||
# Volume
|
||||
self.vol_icon = QLabel("🔊")
|
||||
self.vol_icon.setObjectName("volIcon")
|
||||
self.volume_slider = ClickableSlider(Qt.Orientation.Horizontal)
|
||||
self.volume_slider.setRange(0, 100)
|
||||
self.volume_slider.setValue(100)
|
||||
self.volume_slider.setFixedWidth(100)
|
||||
self.volume_slider.setObjectName("volumeSlider")
|
||||
self.volume_slider.valueChanged.connect(self.on_volume_changed)
|
||||
self.volume_slider.set_tooltip_provider(lambda v: f"{v}%")
|
||||
|
||||
# Fullscreen
|
||||
self.fullscreen_btn = QPushButton("⛶")
|
||||
self.fullscreen_btn.setFixedSize(40, 40)
|
||||
self.fullscreen_btn.setObjectName("iconBtn")
|
||||
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
|
||||
|
||||
controls_layout.addWidget(self.play_btn)
|
||||
controls_layout.addWidget(self.seekbar, 1)
|
||||
controls_layout.addWidget(self.time_lbl)
|
||||
controls_layout.addSpacing(15)
|
||||
controls_layout.addWidget(self.vol_icon)
|
||||
controls_layout.addWidget(self.volume_slider)
|
||||
controls_layout.addSpacing(10)
|
||||
controls_layout.addWidget(self.fullscreen_btn)
|
||||
|
||||
# Chat toggle
|
||||
self.chat_toggle_btn = QPushButton("💬")
|
||||
self.chat_toggle_btn.setFixedSize(40, 40)
|
||||
self.chat_toggle_btn.setObjectName("iconBtn")
|
||||
self.chat_toggle_btn.setToolTip("Toggle Chat")
|
||||
self.chat_toggle_btn.clicked.connect(self.toggle_chat)
|
||||
controls_layout.addWidget(self.chat_toggle_btn)
|
||||
|
||||
video_layout.addWidget(self.topbar)
|
||||
video_layout.addWidget(self.video_frame)
|
||||
video_layout.addWidget(self.controls_bar)
|
||||
|
||||
# --- Right Side: Chat ---
|
||||
self.chat_container = QFrame()
|
||||
self.chat_container.setObjectName("chatContainer")
|
||||
self.chat_container.setMinimumWidth(200)
|
||||
self.chat_container.setMaximumWidth(500)
|
||||
chat_layout = QVBoxLayout(self.chat_container)
|
||||
|
||||
chat_header = QLabel("Live Chat")
|
||||
chat_header.setObjectName("chatHeader")
|
||||
|
||||
self.popout_btn = QPushButton("↗")
|
||||
self.popout_btn.setObjectName("iconBtn")
|
||||
self.popout_btn.setFixedSize(24, 24)
|
||||
self.popout_btn.setToolTip("Pop out chat")
|
||||
self.popout_btn.clicked.connect(self.popout_chat)
|
||||
|
||||
header_layout = QHBoxLayout()
|
||||
header_layout.addWidget(chat_header)
|
||||
header_layout.addStretch()
|
||||
header_layout.addWidget(self.popout_btn)
|
||||
|
||||
self.users_lbl = QLabel("0 watching")
|
||||
self.users_lbl.setObjectName("usersLbl")
|
||||
|
||||
# Tags Container (Hidden by default)
|
||||
self.tags_container = QFrame()
|
||||
self.tags_container.setObjectName("tagsContainer")
|
||||
self.tags_container.hide() # Only show when there are tags
|
||||
tags_layout = QVBoxLayout(self.tags_container)
|
||||
tags_layout.setContentsMargins(0, 0, 0, 10)
|
||||
|
||||
self.tags_header_btn = QPushButton("▼ Highlights")
|
||||
self.tags_header_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.tags_header_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
color: #ccc;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 2px 0px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
color: #fff;
|
||||
}
|
||||
""")
|
||||
self.tags_header_btn.clicked.connect(self.toggle_tags_panel)
|
||||
|
||||
self.tags_list = QTextBrowser()
|
||||
self.tags_list.setObjectName("tagsList")
|
||||
self.tags_list.setFixedHeight(100) # Keep it small and unobtrusive
|
||||
self.tags_list.setOpenExternalLinks(False)
|
||||
self.tags_list.setOpenLinks(False)
|
||||
self.tags_list.anchorClicked.connect(self.on_chat_link_clicked)
|
||||
self.tags_list.setStyleSheet("""
|
||||
QTextBrowser {
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
color: #ddd;
|
||||
font-size: 12px;
|
||||
}
|
||||
""")
|
||||
|
||||
tags_layout.addWidget(self.tags_header_btn)
|
||||
tags_layout.addWidget(self.tags_list)
|
||||
|
||||
self.chat_messages = QScrollArea()
|
||||
self.chat_messages.setWidgetResizable(True)
|
||||
self.chat_messages.setObjectName("chatMessages")
|
||||
self.chat_messages.setStyleSheet("background-color: transparent; border: none;")
|
||||
|
||||
self.chat_content = QWidget()
|
||||
self.chat_content.setObjectName("chatContent")
|
||||
self.chat_content.setStyleSheet("background: transparent;")
|
||||
self.chat_content_layout = QVBoxLayout(self.chat_content)
|
||||
self.chat_content_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.chat_content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.chat_content_layout.setSpacing(0)
|
||||
|
||||
self.chat_messages.setWidget(self.chat_content)
|
||||
self.chat_messages.toPlainText = self.toPlainText # For test compatibility
|
||||
|
||||
|
||||
chat_input_layout = QHBoxLayout()
|
||||
self.chat_input = QLineEdit()
|
||||
self.chat_input.setPlaceholderText("Send a message...")
|
||||
self.chat_send_btn = QPushButton("Send")
|
||||
|
||||
chat_input_layout.addWidget(self.chat_input)
|
||||
chat_input_layout.addWidget(self.chat_send_btn)
|
||||
|
||||
# Chat actions
|
||||
self.chat_send_btn.clicked.connect(self.send_chat)
|
||||
self.chat_input.returnPressed.connect(self.send_chat)
|
||||
|
||||
chat_layout.addLayout(header_layout)
|
||||
chat_layout.addWidget(self.users_lbl)
|
||||
chat_layout.addWidget(self.tags_container)
|
||||
chat_layout.addWidget(self.chat_messages, 1)
|
||||
chat_layout.addLayout(chat_input_layout)
|
||||
|
||||
self.splitter.addWidget(video_container)
|
||||
self.splitter.addWidget(self.chat_container)
|
||||
self.splitter.setStretchFactor(0, 1)
|
||||
self.splitter.setStretchFactor(1, 0)
|
||||
self.splitter.setSizes([700, 320])
|
||||
|
||||
layout.addWidget(self.splitter)
|
||||
|
||||
# Prevent UI components from stealing focus (which breaks spacebar shortcuts)
|
||||
for w in [self.copy_code_btn, self.leave_btn, self.play_btn, self.fullscreen_btn,
|
||||
self.chat_send_btn, self.seekbar, self.volume_slider, self.tags_header_btn,
|
||||
self.chat_toggle_btn, self.popout_btn]:
|
||||
w.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
# scroll area handles focus properly
|
||||
self.chat_messages.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
# Instantiate the VLC Player Wrapper
|
||||
self.vlc_player = VLCSyncPlayer(self.video_frame)
|
||||
self.vlc_player.signals.time_changed.connect(self.on_vlc_time)
|
||||
self.vlc_player.signals.state_changed.connect(self.on_vlc_state)
|
||||
self.vlc_player.set_volume(self.volume_slider.value())
|
||||
|
||||
self.play_btn.clicked.connect(self.toggle_playback)
|
||||
|
||||
# Fullscreen auto-hide timer
|
||||
self._fs_hide_timer = QTimer()
|
||||
self._fs_hide_timer.setSingleShot(True)
|
||||
self._fs_hide_timer.setInterval(3000)
|
||||
self._fs_hide_timer.timeout.connect(self._hide_fullscreen_controls)
|
||||
|
||||
# Mouse movement polling (needed because VLC's native window eats mouse events)
|
||||
self._last_mouse_pos = None
|
||||
self._mouse_poll_timer = QTimer()
|
||||
self._mouse_poll_timer.setInterval(200)
|
||||
self._mouse_poll_timer.timeout.connect(self._check_mouse_movement)
|
||||
|
||||
def get_seekbar_tooltip(self, value):
|
||||
length_ms = self.vlc_player.get_length()
|
||||
if length_ms > 0:
|
||||
target_ms = int((value / 1000.0) * length_ms)
|
||||
return format_time_hms(target_ms)
|
||||
return ""
|
||||
|
||||
def setup_room(self, room_code: str, username: str, file_name: str, file_path: str, start_time_s: float = 0.0):
|
||||
self.room_code = room_code
|
||||
self.username = username
|
||||
self.set_room_code_display(room_code)
|
||||
self.room_file_badge.setText(f"📄 {file_name}")
|
||||
|
||||
# Clear chat
|
||||
for i in reversed(range(self.chat_content_layout.count())):
|
||||
self.chat_content_layout.itemAt(i).widget().setParent(None)
|
||||
|
||||
|
||||
self.current_users = []
|
||||
self._is_first_user_update = True
|
||||
|
||||
if file_path:
|
||||
self.vlc_player.load_media(file_path, start_time_s)
|
||||
self.vlc_player.set_volume(self.volume_slider.value())
|
||||
|
||||
def cleanup(self):
|
||||
self.vlc_player.stop()
|
||||
self.current_users = []
|
||||
self._is_first_user_update = True
|
||||
|
||||
def set_room_code_display(self, text: str):
|
||||
self.room_code_display.setText(f"Room: {text}")
|
||||
|
||||
def update_connection_status(self, connected: bool):
|
||||
if connected:
|
||||
self.status_dot.setStyleSheet("color: #4BB543; font-size: 14px; background: transparent;")
|
||||
self.status_dot.setToolTip("Connected")
|
||||
else:
|
||||
self.status_dot.setStyleSheet("color: #ff4e45; font-size: 14px; background: transparent;")
|
||||
self.status_dot.setToolTip("Disconnected")
|
||||
self._latency_ms = None
|
||||
|
||||
def update_latency(self, latency_ms: int):
|
||||
self._latency_ms = latency_ms
|
||||
if latency_ms < 100:
|
||||
color = "#4BB543" # green
|
||||
elif latency_ms < 250:
|
||||
color = "#f0ad4e" # yellow
|
||||
else:
|
||||
color = "#ff4e45" # red
|
||||
self.status_dot.setStyleSheet(f"color: {color}; font-size: 14px; background: transparent;")
|
||||
self.status_dot.setToolTip(f"Latency: {latency_ms}ms")
|
||||
|
||||
def toggle_tags_panel(self):
|
||||
if self.tags_list.isHidden():
|
||||
self.tags_list.show()
|
||||
self.tags_header_btn.setText("▼ Highlights")
|
||||
else:
|
||||
self.tags_list.hide()
|
||||
self.tags_header_btn.setText("▶ Highlights")
|
||||
|
||||
def toggle_chat(self):
|
||||
if self.popout_window:
|
||||
self.popout_window.activateWindow()
|
||||
self.popout_window.raise_()
|
||||
return
|
||||
|
||||
if self.chat_container.isVisible():
|
||||
self._saved_chat_width = self.chat_container.width()
|
||||
self.chat_container.hide()
|
||||
else:
|
||||
self.chat_container.show()
|
||||
w = getattr(self, '_saved_chat_width', 320)
|
||||
sizes = self.splitter.sizes()
|
||||
self.splitter.setSizes([sizes[0] - w, w])
|
||||
|
||||
def popout_chat(self):
|
||||
if self.popout_window:
|
||||
return
|
||||
|
||||
self.popout_window = ChatPopoutWindow(self.window())
|
||||
self.popout_window.setWindowTitle(f"Chat - {self.room_code}")
|
||||
|
||||
# Detach from layout
|
||||
self.chat_container.setParent(None)
|
||||
|
||||
# Set central widget
|
||||
central = QWidget()
|
||||
l = QVBoxLayout(central)
|
||||
l.setContentsMargins(0, 0, 0, 0)
|
||||
l.addWidget(self.chat_container)
|
||||
self.popout_window.setCentralWidget(central)
|
||||
|
||||
self.popout_window.closed.connect(self.on_popout_closed)
|
||||
self.popout_window.show()
|
||||
|
||||
self.popout_btn.hide()
|
||||
|
||||
# Collapse the space in splitter
|
||||
self.splitter.setSizes([self.width(), 0])
|
||||
|
||||
def on_popout_closed(self):
|
||||
self.popout_window = None
|
||||
|
||||
# Re-attach to layout
|
||||
self.chat_container.setParent(self)
|
||||
self.splitter.insertWidget(1, self.chat_container)
|
||||
|
||||
self.popout_btn.show()
|
||||
|
||||
# Restore splitter sizes
|
||||
w = getattr(self, '_saved_chat_width', 320)
|
||||
self.splitter.setSizes([self.width() - w, w])
|
||||
self.chat_container.show()
|
||||
|
||||
def toggle_fullscreen(self):
|
||||
top_window = self.window()
|
||||
if top_window.isFullScreen():
|
||||
top_window.showNormal()
|
||||
self.fullscreen_btn.setText("⛶")
|
||||
self.chat_container.show()
|
||||
self.topbar.show()
|
||||
self.controls_bar.show()
|
||||
self._fs_hide_timer.stop()
|
||||
self._mouse_poll_timer.stop()
|
||||
self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
|
||||
else:
|
||||
top_window.showFullScreen()
|
||||
self.fullscreen_btn.setText("🗗")
|
||||
self.chat_container.hide()
|
||||
self.topbar.hide()
|
||||
self._last_mouse_pos = QCursor.pos()
|
||||
self._fs_hide_timer.start()
|
||||
self._mouse_poll_timer.start()
|
||||
|
||||
def _hide_fullscreen_controls(self):
|
||||
if self.window().isFullScreen():
|
||||
self.controls_bar.hide()
|
||||
self.setCursor(QCursor(Qt.CursorShape.BlankCursor))
|
||||
|
||||
def _show_fullscreen_controls(self):
|
||||
self.controls_bar.show()
|
||||
self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
|
||||
self._fs_hide_timer.start()
|
||||
|
||||
def _check_mouse_movement(self):
|
||||
pos = QCursor.pos()
|
||||
if pos != self._last_mouse_pos:
|
||||
self._last_mouse_pos = pos
|
||||
if not self.controls_bar.isVisible():
|
||||
self._show_fullscreen_controls()
|
||||
|
||||
def toggle_mute(self):
|
||||
current_vol = self.vlc_player.get_volume()
|
||||
if current_vol > 0:
|
||||
self._last_volume = current_vol
|
||||
self.on_volume_changed(0)
|
||||
self.volume_slider.setValue(0)
|
||||
else:
|
||||
vol = getattr(self, '_last_volume', 100)
|
||||
self.on_volume_changed(vol)
|
||||
self.volume_slider.setValue(vol)
|
||||
|
||||
def seek_relative(self, offset_ms):
|
||||
length_ms = self.vlc_player.get_length()
|
||||
if length_ms > 0:
|
||||
current_ms = self.vlc_player.current_time_ms
|
||||
target_ms = max(0, min(current_ms + offset_ms, length_ms))
|
||||
req = self._tell_vlc_and_expect("seek", target_ms / 1000.0)
|
||||
self.sync_action_requested.emit("seek", target_ms / 1000.0, req)
|
||||
|
||||
def change_volume(self, offset):
|
||||
new_vol = max(0, min(100, self.volume_slider.value() + offset))
|
||||
self.volume_slider.setValue(new_vol)
|
||||
self.on_volume_changed(new_vol)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self.setFocus()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
# Allow typing in input fields without triggering shortcuts
|
||||
focus_widget = QApplication.focusWidget()
|
||||
|
||||
# Spacebar should always play/pause unless typing in chat input
|
||||
if event.key() == Qt.Key.Key_Space and not isinstance(focus_widget, QLineEdit):
|
||||
self.toggle_playback()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if isinstance(focus_widget, QLineEdit) or isinstance(focus_widget, QTextEdit) or isinstance(focus_widget, QTextBrowser):
|
||||
if event.key() == Qt.Key.Key_Escape:
|
||||
focus_widget.clearFocus()
|
||||
self.setFocus()
|
||||
event.accept()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
return
|
||||
|
||||
if event.key() == Qt.Key.Key_F:
|
||||
self.toggle_fullscreen()
|
||||
event.accept()
|
||||
elif event.key() == Qt.Key.Key_M:
|
||||
self.toggle_mute()
|
||||
event.accept()
|
||||
elif event.key() == Qt.Key.Key_Left:
|
||||
self.seek_relative(-5000)
|
||||
event.accept()
|
||||
elif event.key() == Qt.Key.Key_Right:
|
||||
self.seek_relative(5000)
|
||||
event.accept()
|
||||
elif event.key() == Qt.Key.Key_Up:
|
||||
self.change_volume(5)
|
||||
event.accept()
|
||||
elif event.key() == Qt.Key.Key_Down:
|
||||
self.change_volume(-5)
|
||||
event.accept()
|
||||
elif event.key() == Qt.Key.Key_Escape:
|
||||
if self.window().isFullScreen():
|
||||
self.toggle_fullscreen()
|
||||
event.accept()
|
||||
elif event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
self.chat_input.setFocus()
|
||||
event.accept()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def copy_room_code(self):
|
||||
if self.room_code:
|
||||
QApplication.clipboard().setText(self.room_code)
|
||||
self.copy_code_btn.setIcon(self.check_icon)
|
||||
|
||||
toast = QLabel("Copied!", self)
|
||||
toast.setStyleSheet("background-color: #4BB543; color: white; padding: 4px 8px; border-radius: 4px; font-weight: bold;")
|
||||
toast.setWindowFlags(Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint)
|
||||
|
||||
pos = self.copy_code_btn.mapToGlobal(self.copy_code_btn.rect().bottomLeft())
|
||||
toast.move(pos.x(), pos.y() + 5)
|
||||
toast.show()
|
||||
|
||||
def reset():
|
||||
self.copy_code_btn.setIcon(self.copy_icon)
|
||||
toast.deleteLater()
|
||||
QTimer.singleShot(1500, reset)
|
||||
|
||||
def _tell_vlc_and_expect(self, action: str, position_s: float):
|
||||
req_id = str(uuid.uuid4())[:8]
|
||||
target_ms = int(position_s * 1000)
|
||||
|
||||
# Clean up old expectations (e.g. VLC dropped the event or we missed it)
|
||||
now = datetime.datetime.now()
|
||||
self.expected_vlc_events = [e for e in self.expected_vlc_events
|
||||
if (now - e.timestamp).total_seconds() < 3.0]
|
||||
|
||||
self.expected_vlc_events.append(ExpectedVlcEvent(action, req_id, target_ms))
|
||||
|
||||
if action == "play":
|
||||
self.vlc_player.seek(target_ms)
|
||||
self.vlc_player.play()
|
||||
self.play_btn.setText("⏸")
|
||||
elif action == "pause":
|
||||
self.vlc_player.seek(target_ms)
|
||||
self.vlc_player.pause()
|
||||
self.play_btn.setText("▶")
|
||||
elif action == "seek":
|
||||
self.vlc_player.seek(target_ms)
|
||||
|
||||
return req_id
|
||||
|
||||
def toggle_playback(self):
|
||||
position_s = self.vlc_player.current_time_ms / 1000.0
|
||||
if self.vlc_player.is_playing:
|
||||
req = self._tell_vlc_and_expect("pause", position_s)
|
||||
self.sync_action_requested.emit("pause", position_s, req)
|
||||
else:
|
||||
req = self._tell_vlc_and_expect("play", position_s)
|
||||
self.sync_action_requested.emit("play", position_s, req)
|
||||
|
||||
def on_vlc_time(self, time_ms: int):
|
||||
length_ms = self.vlc_player.get_length()
|
||||
if length_ms > 0:
|
||||
if not self.seekbar.isSliderDown():
|
||||
self.time_lbl.setText(f"{format_time_hms(time_ms)} / {format_time_hms(length_ms)}")
|
||||
progress = int((time_ms / length_ms) * 1000)
|
||||
self.seekbar.blockSignals(True)
|
||||
self.seekbar.setValue(progress)
|
||||
self.seekbar.blockSignals(False)
|
||||
|
||||
if self.last_reported_time_ms is not None:
|
||||
diff = abs(time_ms - self.last_reported_time_ms)
|
||||
|
||||
if diff > 1500:
|
||||
# Look for a pending seek expectation
|
||||
matched = False
|
||||
for i, expected in enumerate(self.expected_vlc_events):
|
||||
if expected.action == "seek" and (expected.target_val is None or abs(expected.target_val - time_ms) < 2000):
|
||||
matched = True
|
||||
self.expected_vlc_events.pop(i)
|
||||
break
|
||||
|
||||
if not matched:
|
||||
# Genuine user scrub!
|
||||
req = str(uuid.uuid4())[:8]
|
||||
self.sync_action_requested.emit("seek", time_ms / 1000.0, req)
|
||||
|
||||
self.last_reported_time_ms = time_ms
|
||||
|
||||
def on_vlc_state(self, playing: bool, time_ms: int):
|
||||
action = "play" if playing else "pause"
|
||||
|
||||
# Look for a pending state change expectation
|
||||
matched = False
|
||||
for i, expected in enumerate(self.expected_vlc_events):
|
||||
if expected.action == action:
|
||||
matched = True
|
||||
self.expected_vlc_events.pop(i)
|
||||
break
|
||||
|
||||
if not matched:
|
||||
req = str(uuid.uuid4())[:8]
|
||||
self.sync_action_requested.emit(action, time_ms / 1000.0, req)
|
||||
|
||||
def on_seekbar_dragged(self, value):
|
||||
length_ms = self.vlc_player.get_length()
|
||||
if length_ms > 0:
|
||||
target_ms = int((value / 1000.0) * length_ms)
|
||||
self.time_lbl.setText(f"{format_time_hms(target_ms)} / {format_time_hms(length_ms)}")
|
||||
# Scrub the video locally in real-time
|
||||
self._tell_vlc_and_expect("seek", target_ms / 1000.0)
|
||||
|
||||
def on_seekbar_released(self):
|
||||
length_ms = self.vlc_player.get_length()
|
||||
if length_ms > 0:
|
||||
target_ms = int((self.seekbar.value() / 1000.0) * length_ms)
|
||||
req = self._tell_vlc_and_expect("seek", target_ms / 1000.0)
|
||||
self.sync_action_requested.emit("seek", target_ms / 1000.0, req)
|
||||
|
||||
def on_volume_changed(self, value):
|
||||
self.vlc_player.set_volume(value)
|
||||
self._update_vol_icon(value)
|
||||
|
||||
def _update_vol_icon(self, volume):
|
||||
if volume == 0:
|
||||
self.vol_icon.setText("🔇")
|
||||
elif volume < 33:
|
||||
self.vol_icon.setText("🔈")
|
||||
elif volume < 66:
|
||||
self.vol_icon.setText("🔉")
|
||||
else:
|
||||
self.vol_icon.setText("🔊")
|
||||
|
||||
# --- Incoming Sync Logic ---
|
||||
def handle_sync_event(self, msg: dict):
|
||||
action = msg.get("action")
|
||||
if not action:
|
||||
if msg.get("playing", False): action = "play"
|
||||
elif msg.get("playing") is False: action = "pause"
|
||||
|
||||
position_s = msg.get("position", 0)
|
||||
|
||||
if action in ["play", "pause", "seek"]:
|
||||
self._tell_vlc_and_expect(action, position_s)
|
||||
|
||||
username = msg.get("username")
|
||||
if username and username != self.username:
|
||||
if action == "play": self.add_system_message(f"{username} pressed play")
|
||||
elif action == "pause": self.add_system_message(f"{username} paused")
|
||||
elif action == "seek":
|
||||
self.add_system_message(f"{username} seeked to {format_time_hms(int(position_s * 1000))}")
|
||||
|
||||
def update_users(self, users: list):
|
||||
if self._is_first_user_update:
|
||||
self._is_first_user_update = False
|
||||
else:
|
||||
joined = set(users) - set(self.current_users)
|
||||
left = set(self.current_users) - set(users)
|
||||
|
||||
for user in joined:
|
||||
if user != self.username:
|
||||
self.add_system_message(f"👋 {user} joined the room")
|
||||
|
||||
for user in left:
|
||||
if user != self.username:
|
||||
self.add_system_message(f"🚪 {user} left the room")
|
||||
|
||||
self.current_users = users
|
||||
self.users_lbl.setText(f"{len(users)} watching: {', '.join(users)}")
|
||||
|
||||
def on_chat_link_clicked(self, url):
|
||||
link = url.toString()
|
||||
print(f"DEBUG: Link clicked: {link}")
|
||||
if link.startswith("seek:"):
|
||||
# Format: 'seek:MM:SS?tag=TagName'
|
||||
data = link[5:]
|
||||
if "?" in data and "tag=" in data:
|
||||
time_str, query = data.split("?", 1)
|
||||
tag_name = ""
|
||||
for pair in query.split("&"):
|
||||
if pair.startswith("tag="):
|
||||
tag_name = urllib.parse.unquote(pair[4:])
|
||||
break
|
||||
self.add_system_message(f"Seeking to <b>{tag_name}</b> ({time_str})")
|
||||
self._handle_seek_command(time_str)
|
||||
else:
|
||||
self._handle_seek_command(data)
|
||||
|
||||
def add_chat_message(self, author: str, text: str, timestamp: int):
|
||||
try:
|
||||
dt = datetime.datetime.fromtimestamp(timestamp / 1000.0)
|
||||
except (ValueError, OSError, TypeError):
|
||||
dt = datetime.datetime.now()
|
||||
time_str = dt.strftime("%I:%M %p")
|
||||
|
||||
safe_text = html.escape(text)
|
||||
safe_author = html.escape(author)
|
||||
|
||||
# Regex to find timestamps like 1:23 or 01:23:45
|
||||
pattern = r'\b(\d{1,2}:\d{2}(?::\d{2})?)\b'
|
||||
linked_text = re.sub(pattern, r'<a href="seek:\1" style="color: #66b3ff; text-decoration: none;">\1</a>', safe_text)
|
||||
|
||||
is_self = author == self.username
|
||||
|
||||
# Auto-extract tags added by users directly via /tag command
|
||||
# We look for the pattern generated by /tag: "MM:SS tag text"
|
||||
# Since we just emit the text for /tag, it won't be linked yet in the raw text
|
||||
tag_match = re.match(r'^(\d{1,2}:\d{2}(?::\d{2})?)\s+(.+)$', text)
|
||||
if tag_match:
|
||||
self.add_highlight(tag_match.group(1), tag_match.group(2), author)
|
||||
return # Don't show this as a chat message bubble
|
||||
|
||||
bubble = ChatBubble(author, linked_text, time_str, is_self, self._handle_link_from_bubble)
|
||||
self.chat_content_layout.addWidget(bubble)
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def _handle_link_from_bubble(self, url_str):
|
||||
if url_str.startswith("seek:"):
|
||||
time_str = url_str[5:]
|
||||
self._handle_seek_command(time_str)
|
||||
|
||||
def _scroll_to_bottom(self):
|
||||
# Allow layout to finish before scrolling
|
||||
QTimer.singleShot(10, lambda: self.chat_messages.verticalScrollBar().setValue(
|
||||
self.chat_messages.verticalScrollBar().maximum()
|
||||
))
|
||||
|
||||
def toPlainText(self):
|
||||
# For compatibility with existing tests
|
||||
text = ""
|
||||
for i in range(self.chat_content_layout.count()):
|
||||
item = self.chat_content_layout.itemAt(i)
|
||||
if not item: continue
|
||||
w = item.widget()
|
||||
if isinstance(w, ChatBubble):
|
||||
text += w.text_lbl.text() + "\n"
|
||||
elif isinstance(w, SystemMessageWidget):
|
||||
lbl = w.findChild(QLabel)
|
||||
if lbl: text += lbl.text() + "\n"
|
||||
return text
|
||||
|
||||
def add_highlight(self, time_str: str, tag_text: str, author: str):
|
||||
if self.tags_container.isHidden():
|
||||
self.tags_container.show()
|
||||
|
||||
safe_tag = urllib.parse.quote(tag_text)
|
||||
highlight_html = f"• <a href='seek:{time_str}?tag={safe_tag}' style='color: #66b3ff; text-decoration: none;'>[{time_str}]</a> {html.escape(tag_text)} <span style='color: #666; font-size: 10px;'>- {author}</span>"
|
||||
self.tags_list.append(highlight_html)
|
||||
|
||||
def add_system_message(self, text: str):
|
||||
msg = SystemMessageWidget(text)
|
||||
self.chat_content_layout.addWidget(msg)
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def clear_chat(self):
|
||||
for i in reversed(range(self.chat_content_layout.count())):
|
||||
item = self.chat_content_layout.itemAt(i)
|
||||
if item.widget():
|
||||
item.widget().setParent(None)
|
||||
|
||||
def send_chat(self):
|
||||
text = self.chat_input.text().strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
if text.startswith("/"):
|
||||
parts = text.split()
|
||||
cmd = parts[0].lower()
|
||||
self.chat_input.setText("")
|
||||
if handle_chat_command(cmd, parts, self):
|
||||
return
|
||||
|
||||
self.chat_message_ready.emit(text)
|
||||
self.chat_input.setText("")
|
||||
|
||||
def _parse_time_arg(self, arg: str):
|
||||
"""Delegates to the standalone parse_time_arg function."""
|
||||
current_ms = self.vlc_player.current_time_ms
|
||||
length_ms = self.vlc_player.get_length()
|
||||
return parse_time_arg(arg, current_ms, length_ms)
|
||||
|
||||
def _handle_seek_command(self, arg: str) -> bool:
|
||||
target_ms = self._parse_time_arg(arg)
|
||||
if target_ms is not None:
|
||||
req = self._tell_vlc_and_expect("seek", target_ms / 1000.0)
|
||||
self.sync_action_requested.emit("seek", target_ms / 1000.0, req)
|
||||
return True
|
||||
return False
|
||||
|
||||
25
desktop-client/run-videosync.sh
Normal file
25
desktop-client/run-videosync.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# VideoSync Desktop Client Launcher
|
||||
# Fetches the latest version from the repo and runs the desktop client.
|
||||
# Prerequisites: git, uv
|
||||
|
||||
set -e
|
||||
|
||||
REPO_URL="https://gitea.peterstockings.com/peterstockings/video-sync.git"
|
||||
INSTALL_DIR="$HOME/.videosync"
|
||||
|
||||
echo "🎬 VideoSync Desktop Client Launcher"
|
||||
echo "======================================"
|
||||
|
||||
# Clone or pull the latest version
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
echo "📥 Pulling latest changes..."
|
||||
git -C "$INSTALL_DIR" pull --ff-only
|
||||
else
|
||||
echo "📥 Cloning repository..."
|
||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
echo "🚀 Launching VideoSync..."
|
||||
cd "$INSTALL_DIR/desktop-client"
|
||||
uv run main.py
|
||||
114
desktop-client/sync_client.py
Normal file
114
desktop-client/sync_client.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import websockets
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
class SyncClientThread(QThread):
|
||||
# Signals from WebSocket -> PyQt UI
|
||||
connected = pyqtSignal()
|
||||
disconnected = pyqtSignal()
|
||||
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)
|
||||
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
|
||||
|
||||
def run(self):
|
||||
"""Runs strictly within the newly created QThread"""
|
||||
self.running = True
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.loop.run_until_complete(self._connect_and_listen())
|
||||
self.loop.close()
|
||||
|
||||
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):
|
||||
while self.running:
|
||||
try:
|
||||
async with websockets.connect(self.url) 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:
|
||||
print(f"WebSocket Error: {e}")
|
||||
|
||||
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"]:
|
||||
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)
|
||||
60
desktop-client/test_app.py
Normal file
60
desktop-client/test_app.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import pytest
|
||||
from PyQt6.QtCore import Qt
|
||||
from main import VlcSyncApp
|
||||
|
||||
def test_app_ui_flow(qtbot):
|
||||
# Initialize the main application
|
||||
app = VlcSyncApp()
|
||||
qtbot.addWidget(app)
|
||||
|
||||
# 1. Test Lobby View
|
||||
assert app.stacked_widget.currentIndex() == 0
|
||||
assert not app.lobby_widget.create_room_btn.isEnabled()
|
||||
|
||||
# Fill out the Lobby Form
|
||||
app.lobby_widget.username_input.setText("PyTestUser")
|
||||
|
||||
# Mocking file selection instead of opening the native dialog
|
||||
app.lobby_widget.local_file_path = "test_pytest.mkv"
|
||||
app.lobby_widget.local_file_name = "test_pytest.mkv"
|
||||
app.lobby_widget.local_file_size = 1048576
|
||||
app.lobby_widget.check_inputs()
|
||||
|
||||
# Button should now be active
|
||||
assert app.lobby_widget.create_room_btn.isEnabled()
|
||||
|
||||
# 2. Test Creating Room matches integration pipeline
|
||||
qtbot.mouseClick(app.lobby_widget.create_room_btn, Qt.MouseButton.LeftButton)
|
||||
|
||||
# Wait for the WebSocket connected signal, the room_created server response, and UI transition
|
||||
def check_room_joined():
|
||||
assert app.stacked_widget.currentIndex() == 1
|
||||
assert len(app.room_code) > 2
|
||||
|
||||
qtbot.waitUntil(check_room_joined, timeout=5000)
|
||||
|
||||
# 3. Test Chat flow End-to-End
|
||||
# Type a message
|
||||
qtbot.keyClicks(app.room_widget.chat_input, "Automated UI Test Message")
|
||||
# Click Send
|
||||
qtbot.mouseClick(app.room_widget.chat_send_btn, Qt.MouseButton.LeftButton)
|
||||
|
||||
# Wait until the Bun server grabs the websocket payload, stores it, and broadcasts it back to the UI!
|
||||
def check_chat_received():
|
||||
assert "Automated UI Test Message" in app.room_widget.chat_messages.toPlainText()
|
||||
|
||||
qtbot.waitUntil(check_chat_received, timeout=3000)
|
||||
|
||||
# 4. Test Playback Sync (UI updates and internal flags)
|
||||
assert app.room_widget.play_btn.text() == "▶"
|
||||
|
||||
# Click Play
|
||||
qtbot.mouseClick(app.room_widget.play_btn, Qt.MouseButton.LeftButton)
|
||||
|
||||
def check_playback_started():
|
||||
assert app.room_widget.play_btn.text() == "⏸"
|
||||
|
||||
qtbot.waitUntil(check_playback_started, timeout=2000)
|
||||
|
||||
# Clean up background threads
|
||||
app._on_room_leave()
|
||||
145
desktop-client/test_integration.py
Normal file
145
desktop-client/test_integration.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import asyncio
|
||||
import json
|
||||
import websockets
|
||||
import sys
|
||||
|
||||
async def run_integration_test():
|
||||
url = "wss://video-sync.peterstockings.com/ws"
|
||||
|
||||
print("🤖 Test: Starting Integration Test Suite...")
|
||||
|
||||
# Client 1: Creator
|
||||
try:
|
||||
creator_ws = await websockets.connect(url)
|
||||
print("✅ Creator connected to WebSocket")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect to server: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
creator_msg = {
|
||||
"type": "create_room",
|
||||
"username": "TestCreator",
|
||||
"fileInfo": {
|
||||
"name": "test_video.mkv",
|
||||
"size": 1048576, # 1MB
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
await creator_ws.send(json.dumps(creator_msg))
|
||||
print("📤 Creator sent 'create_room'")
|
||||
|
||||
room_code = None
|
||||
|
||||
# Wait for room_created
|
||||
try:
|
||||
response_str = await asyncio.wait_for(creator_ws.recv(), timeout=2.0)
|
||||
response = json.loads(response_str)
|
||||
if response.get("type") == "room_created":
|
||||
room_code = response.get("code")
|
||||
print(f"✅ Creator received room_created with code: {room_code}")
|
||||
else:
|
||||
print(f"❌ Unexpected response for creator: {response}")
|
||||
sys.exit(1)
|
||||
except asyncio.TimeoutError:
|
||||
print("❌ Timeout waiting for 'room_created'")
|
||||
sys.exit(1)
|
||||
|
||||
# Client 2: Joiner
|
||||
try:
|
||||
joiner_ws = await websockets.connect(url)
|
||||
print("✅ Joiner connected to WebSocket")
|
||||
except Exception as e:
|
||||
print(f"❌ Joiner failed to connect to server: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
joiner_msg = {
|
||||
"type": "join_room",
|
||||
"username": "TestJoiner",
|
||||
"code": room_code
|
||||
}
|
||||
|
||||
await joiner_ws.send(json.dumps(joiner_msg))
|
||||
print(f"📤 Joiner sent 'join_room' for code: {room_code}")
|
||||
|
||||
# Wait for file_check needed
|
||||
try:
|
||||
response_str = await asyncio.wait_for(joiner_ws.recv(), timeout=2.0)
|
||||
response = json.loads(response_str)
|
||||
if response.get("type") == "room_file_check":
|
||||
print(f"✅ Joiner received room_file_check from server. Payload: {response}")
|
||||
|
||||
# Send file confirmation
|
||||
confirm_msg = {
|
||||
"type": "confirm_join",
|
||||
"fileInfo": {
|
||||
"name": "test_video.mkv",
|
||||
"size": 1048576,
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
await joiner_ws.send(json.dumps(confirm_msg))
|
||||
print("📤 Joiner sent 'confirm_join'")
|
||||
|
||||
# Wait for successful room_joined
|
||||
response_str = await asyncio.wait_for(joiner_ws.recv(), timeout=2.0)
|
||||
response = json.loads(response_str)
|
||||
if response.get("type") == "room_joined":
|
||||
print(f"✅ Joiner successfully received room_joined: {list(response.keys())}")
|
||||
|
||||
# Check users payload from the join response itself
|
||||
users = response.get("users", [])
|
||||
print(f"✅ Joiner received user list from room_joined payload: {users}")
|
||||
assert len(users) == 2, "Expected 2 users in the room!"
|
||||
|
||||
# Setup wait task for the Joiner to receive a Play sync event
|
||||
print("⏳ Creator is sending a Play sync event...")
|
||||
await creator_ws.send(json.dumps({
|
||||
"type": "sync",
|
||||
"action": "play",
|
||||
"position": 5.0
|
||||
}))
|
||||
|
||||
# Creator will get an echo, Joiner will get the broadcast
|
||||
creator_echo = json.loads(await asyncio.wait_for(creator_ws.recv(), timeout=2.0))
|
||||
joiner_sync = json.loads(await asyncio.wait_for(joiner_ws.recv(), timeout=2.0))
|
||||
|
||||
if joiner_sync.get("type") == "sync":
|
||||
print(f"✅ Joiner received sync event: {joiner_sync}")
|
||||
assert joiner_sync.get("action") == "play", "Expected 'play' sync event"
|
||||
assert joiner_sync.get("position") == 5.0, "Expected position 5.0"
|
||||
else:
|
||||
print(f"❌ Joiner expected sync, got: {joiner_sync}")
|
||||
sys.exit(1)
|
||||
|
||||
# Setup wait task for the Joiner to send a Chat message
|
||||
print("⏳ Joiner is sending a Chat message...")
|
||||
await joiner_ws.send(json.dumps({
|
||||
"type": "chat",
|
||||
"message": "Hello from integration test!"
|
||||
}))
|
||||
|
||||
creator_chat = json.loads(await asyncio.wait_for(creator_ws.recv(), timeout=2.0))
|
||||
joiner_chat = json.loads(await asyncio.wait_for(joiner_ws.recv(), timeout=2.0))
|
||||
|
||||
if creator_chat.get("type") == "chat":
|
||||
print(f"✅ Creator received chat event: {creator_chat}")
|
||||
assert creator_chat.get("username") == "TestJoiner", "Expected TestJoiner author"
|
||||
assert creator_chat.get("message") == "Hello from integration test!", "Expected correct message"
|
||||
else:
|
||||
print(f"❌ Creator expected chat, got: {creator_chat}")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
print(f"❌ Joiner expected room_joined, got: {response}")
|
||||
else:
|
||||
print(f"❌ Joiner expected room_file_check, got: {response}")
|
||||
except asyncio.TimeoutError:
|
||||
print("❌ Timeout waiting for server response to 'join_room'")
|
||||
sys.exit(1)
|
||||
|
||||
await creator_ws.close()
|
||||
await joiner_ws.close()
|
||||
print("🎉 Integration test completed successfully!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_integration_test())
|
||||
11
desktop-client/utils.py
Normal file
11
desktop-client/utils.py
Normal file
@@ -0,0 +1,11 @@
|
||||
def format_time_hms(ms: int) -> str:
|
||||
"""Format milliseconds as HH:MM:SS."""
|
||||
s = max(0, ms) // 1000
|
||||
return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}"
|
||||
|
||||
def format_time_short(ms: int) -> str:
|
||||
"""Format milliseconds as MM:SS or HH:MM:SS if >= 1 hour."""
|
||||
s = int(max(0, ms) // 1000)
|
||||
if s >= 3600:
|
||||
return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}"
|
||||
return f"{(s % 3600) // 60:02d}:{s % 60:02d}"
|
||||
234
desktop-client/uv.lock
generated
Normal file
234
desktop-client/uv.lock
generated
Normal file
@@ -0,0 +1,234 @@
|
||||
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 = "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 = "pillow" },
|
||||
{ name = "pyinstaller" },
|
||||
{ name = "pyqt6" },
|
||||
{ name = "python-vlc" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "pillow", specifier = ">=12.1.1" },
|
||||
{ name = "pyinstaller", specifier = ">=6.19.0" },
|
||||
{ name = "pyqt6", specifier = ">=6.10.2" },
|
||||
{ name = "python-vlc", specifier = ">=3.0.21203" },
|
||||
{ name = "websockets", specifier = ">=16.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-vlc"
|
||||
version = "3.0.21203"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/5b/f9ce6f0c9877b6fe5eafbade55e0dcb6b2b30f1c2c95837aef40e390d63b/python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec", size = 162211, upload-time = "2024-10-07T14:39:54.755Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/ee/7d76eb3b50ccb1397621f32ede0fb4d17aa55a9aa2251bc34e6b9929fdce/python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320", size = 87651, upload-time = "2024-10-07T14:39:50.021Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
version = "0.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "82.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
110
desktop-client/vlc_player.py
Normal file
110
desktop-client/vlc_player.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import vlc
|
||||
import sys
|
||||
from PyQt6.QtWidgets import QFrame
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QObject
|
||||
import threading
|
||||
|
||||
class VLCSignals(QObject):
|
||||
state_changed = pyqtSignal(bool, int) # is_playing, time_ms
|
||||
time_changed = pyqtSignal(int) # time_ms
|
||||
|
||||
class VLCSyncPlayer:
|
||||
def __init__(self, frame: QFrame):
|
||||
self.frame = frame
|
||||
self.signals = VLCSignals()
|
||||
|
||||
# Initialize VLC instance
|
||||
# --no-xlib prevents crashes on Linux
|
||||
# --drop-late-frames improves sync by not delaying playback when CPU is slow
|
||||
# --no-keyboard stops VLC from capturing and swallowing keyboard events
|
||||
self.instance = vlc.Instance("--no-xlib", "--drop-late-frames", "--no-keyboard")
|
||||
self.media_player = self.instance.media_player_new()
|
||||
|
||||
# Embed the VLC player into the provided PyQt QFrame
|
||||
# On Windows, PyQt6 widgets don't have a native handle by default
|
||||
self.frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow)
|
||||
|
||||
if sys.platform.startswith('linux'):
|
||||
self.media_player.set_xwindow(int(self.frame.winId()))
|
||||
elif sys.platform == "win32":
|
||||
# For Windows, we must explicitly cast winId() to int
|
||||
self.media_player.set_hwnd(int(self.frame.winId()))
|
||||
elif sys.platform == "darwin":
|
||||
self.media_player.set_nsobject(int(self.frame.winId()))
|
||||
|
||||
# Register Event Callbacks
|
||||
self.events = self.media_player.event_manager()
|
||||
self.events.event_attach(vlc.EventType.MediaPlayerPlaying, self._on_playing)
|
||||
self.events.event_attach(vlc.EventType.MediaPlayerPaused, self._on_paused)
|
||||
self.events.event_attach(vlc.EventType.MediaPlayerTimeChanged, self._on_time_changed)
|
||||
|
||||
# Local State
|
||||
self.is_playing = False
|
||||
self.current_time_ms = 0
|
||||
self.ignore_next_event = False
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def load_media(self, path: str, start_time_s: float = 0.0):
|
||||
media = self.instance.media_new(path)
|
||||
if start_time_s > 0:
|
||||
media.add_option(f"start-time={start_time_s}")
|
||||
self.media_player.set_media(media)
|
||||
|
||||
def play(self):
|
||||
with self.lock:
|
||||
self.ignore_next_event = True
|
||||
self.media_player.play()
|
||||
|
||||
def pause(self):
|
||||
with self.lock:
|
||||
self.ignore_next_event = True
|
||||
self.media_player.set_pause(1)
|
||||
|
||||
def seek(self, position_ms: int):
|
||||
with self.lock:
|
||||
self.ignore_next_event = True
|
||||
self.media_player.set_time(position_ms)
|
||||
|
||||
def set_volume(self, volume: int):
|
||||
self.media_player.audio_set_volume(volume)
|
||||
|
||||
def get_volume(self) -> int:
|
||||
return self.media_player.audio_get_volume()
|
||||
|
||||
# --- Internal VLC Callbacks ---
|
||||
|
||||
@vlc.callbackmethod
|
||||
def _on_playing(self, event):
|
||||
self.is_playing = True
|
||||
with self.lock:
|
||||
if self.ignore_next_event:
|
||||
self.ignore_next_event = False
|
||||
return
|
||||
|
||||
time_ms = self.media_player.get_time()
|
||||
# Fire signal to PyQt thread
|
||||
self.signals.state_changed.emit(True, time_ms)
|
||||
|
||||
@vlc.callbackmethod
|
||||
def _on_paused(self, event):
|
||||
self.is_playing = False
|
||||
with self.lock:
|
||||
if self.ignore_next_event:
|
||||
self.ignore_next_event = False
|
||||
return
|
||||
|
||||
time_ms = self.media_player.get_time()
|
||||
self.signals.state_changed.emit(False, time_ms)
|
||||
|
||||
@vlc.callbackmethod
|
||||
def _on_time_changed(self, event):
|
||||
# Emitted constantly during playback
|
||||
self.current_time_ms = event.u.new_time
|
||||
# We also want to fire this signal so the UI scrubber/time label can update
|
||||
self.signals.time_changed.emit(self.current_time_ms)
|
||||
|
||||
def get_length(self):
|
||||
return self.media_player.get_length()
|
||||
|
||||
def stop(self):
|
||||
self.media_player.stop()
|
||||
114
public/app.js
114
public/app.js
@@ -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%;
|
||||
|
||||
23
server.ts
23
server.ts
@@ -13,7 +13,6 @@ interface RoomState {
|
||||
fileInfo: FileInfo;
|
||||
playing: boolean;
|
||||
position: number;
|
||||
speed: number;
|
||||
lastUpdate: number; // timestamp when position was last set
|
||||
users: Map<string, WebSocket>;
|
||||
chatHistory: ChatMessage[];
|
||||
@@ -41,7 +40,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) {
|
||||
@@ -138,6 +137,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) {
|
||||
@@ -159,7 +162,6 @@ const server = Bun.serve<WSData>({
|
||||
},
|
||||
playing: false,
|
||||
position: 0,
|
||||
speed: 1,
|
||||
lastUpdate: Date.now(),
|
||||
users: new Map(),
|
||||
chatHistory: [],
|
||||
@@ -249,7 +251,6 @@ const server = Bun.serve<WSData>({
|
||||
state: {
|
||||
playing: room.playing,
|
||||
position: getCurrentPosition(room),
|
||||
speed: room.speed,
|
||||
},
|
||||
chatHistory: room.chatHistory.slice(-50),
|
||||
})
|
||||
@@ -285,10 +286,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,9 +296,9 @@ 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
|
||||
);
|
||||
@@ -343,7 +340,6 @@ const server = Bun.serve<WSData>({
|
||||
state: {
|
||||
playing: room.playing,
|
||||
position: getCurrentPosition(room),
|
||||
speed: room.speed,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -388,7 +384,6 @@ const server = Bun.serve<WSData>({
|
||||
state: {
|
||||
playing: room.playing,
|
||||
position: getCurrentPosition(room),
|
||||
speed: room.speed,
|
||||
},
|
||||
chatHistory: room.chatHistory.slice(-50),
|
||||
})
|
||||
@@ -417,9 +412,9 @@ const server = Bun.serve<WSData>({
|
||||
const room = rooms.get(roomCode);
|
||||
if (!room) return;
|
||||
|
||||
// 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);
|
||||
@@ -443,7 +438,7 @@ const server = Bun.serve<WSData>({
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
}, 90_000);
|
||||
|
||||
pendingDisconnects.set(disconnectKey, timer);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user