39 Commits
1.0 ... master

Author SHA1 Message Date
Peter Stockings
9d2d1995bd Add bash script to pull the latest version of desktop client and run 2026-03-09 21:04:58 +11:00
Peter Stockings
66342706db Split out components into new files 2026-03-09 20:58:14 +11:00
Peter Stockings
386aa18ba1 Consolidate format time methods 2026-03-09 20:48:23 +11:00
Peter Stockings
e2edd296fc When joining a room show already sent chat messages 2026-03-09 20:43:46 +11:00
Peter Stockings
42f7ee7f12 Update help command to include keyboard shortcuts 2026-03-09 20:24:22 +11:00
Peter Stockings
5b857bf878 Improve look of chat messages 2026-03-09 20:21:51 +11:00
Peter Stockings
99a0694830 Add ability to popout chat into seperate window 2026-03-09 16:02:59 +11:00
Peter Stockings
887648bf85 Add network status/latency info 2026-03-05 14:09:58 +11:00
Peter Stockings
a301d1521b Add support for ping messages in server for determining latency 2026-03-05 13:54:08 +11:00
Peter Stockings
dd00011b77 Add tooltip to seekbar on hover 2026-03-05 13:45:49 +11:00
Peter Stockings
25ea1694f1 Add ability to toggle and resize chat bar 2026-03-05 13:40:17 +11:00
Peter Stockings
367da66c0b Auto fill room ID from clip board upon interacting with input 2026-03-05 13:11:31 +11:00
Peter Stockings
b59d08d098 Auto show/hide controls in fullscreen based on activity 2026-03-05 13:06:50 +11:00
Peter Stockings
ce87a817ea Improve look of seek/volume sliders 2026-03-05 12:57:53 +11:00
Peter Stockings
dae4af9ab8 Change volume icon based on volume 2026-03-05 12:48:48 +11:00
Peter Stockings
02092bab69 Make tags/highlights panel collapsible and add support for relative time for tags 2026-03-05 12:38:39 +11:00
Peter Stockings
777e08ff85 Add /tag & /time command to allow users to sending clickable timestamps in chat 2026-03-04 23:25:57 +11:00
Peter Stockings
d1a87e004e Add keyboard shortcuts
* spacebar: play/pause
* f: toggle full screen
* enter: focus chat
* horizontal arrows: +- 5s seek
* vertical arrows: +- volume
* m: mute
2026-03-04 23:06:01 +11:00
Peter Stockings
6a8dc7e5e6 Update build script to include copy and check images 2026-03-04 23:03:19 +11:00
Peter Stockings
c4bef281f7 Add video scrubbing and click to seek/change volume on sliders 2026-03-03 23:53:27 +11:00
Peter Stockings
e120813a01 Update readme 2026-03-03 23:31:16 +11:00
Peter Stockings
2e31eab0ca Show system messages when users join/leave room 2026-03-03 23:26:58 +11:00
Peter Stockings
b29b540331 Fix issue where canceling join left button disabled 2026-03-03 23:22:12 +11:00
Peter Stockings
43929ea94d Use state machine for syncing playback 2026-03-03 23:15:19 +11:00
Peter Stockings
0e7f80ff1f Add request id to server response 2026-03-03 23:10:04 +11:00
Peter Stockings
bd43cd10f6 Refactor desktop client codebase 2026-03-03 22:50:02 +11:00
Peter Stockings
de0a7172e7 Allow file drops 2026-03-03 20:25:36 +11:00
Peter Stockings
3eb4b671c6 Add reconnect logic for desktop app 2026-03-03 20:10:05 +11:00
Peter Stockings
92aa926cf0 When joining a room sync video playback if playing 2026-03-03 19:55:18 +11:00
Peter Stockings
4665cf700e Add support for /seek, /play, /pause commands in chat 2026-03-03 19:38:10 +11:00
Peter Stockings
a5b7e08a6a Improve look of start screen and add toast to copy room ID button 2026-03-03 19:18:57 +11:00
Peter Stockings
31c79e794e Make chat window scrollable 2026-03-03 19:04:27 +11:00
Peter Stockings
4cf4c153cd Format video time as hh:mm:ss 2026-03-03 19:00:36 +11:00
Peter Stockings
9d22860f0d Add script to build desktop client 2026-03-02 22:24:49 +11:00
Peter Stockings
3e1ea32383 Upate desktop app to point from local to production server 2026-03-02 22:13:19 +11:00
Peter Stockings
36d6aeaf51 Set initial volume in desktop app 2026-03-02 22:07:42 +11:00
Peter Stockings
b587b5e87d When joining a room only allow clients to select a video with the same name as the one already selected by room creator 2026-03-02 22:00:51 +11:00
Peter Stockings
475fdbb2b8 Create desktop app due to mkv issue with chrome 2026-03-02 21:54:22 +11:00
Peter Stockings
2020b59259 Remove speed control 2026-03-02 21:28:24 +11:00
26 changed files with 2600 additions and 127 deletions

23
.gitignore vendored
View File

@@ -3,3 +3,26 @@ bun.lockb
.env
.DS_Store
Thumbs.db
# Python
__pycache__/
*.py[cod]
*$py.class
**/.pytest_cache/
# Environments (uv / venv)
.venv/
env/
venv/
ENV/
# Local media testing files
*.mkv
*.mp4
*.avi
# PyInstaller / Build
build/
dist/
*.spec
*.exe

View File

@@ -2,17 +2,28 @@
Watch videos together in real-time. Each user loads a local video file — only sync commands and chat messages travel over the network.
## Architecture
This project consists of three main components:
- **Server**: A fast, lightweight WebSocket server built with Bun.
- **Web Frontend**: A vanilla HTML/CSS/JS single-page application for joining rooms and syncing video playback.
- **Desktop App**: A Python/PyQt6 application with embedded VLC (`python-vlc`). **Note:** The desktop client was specifically created because web browsers (like Chrome) have issues handling and playing MKV files natively.
## Features
- **Room system** — create a room, share the 6-character code with friends
- **File verification** — joiners must have the exact same file (matched by name + size)
- **Playback sync** — play, pause, seek, and speed changes broadcast to all clients instantly
- **Drift correction** — automatic re-sync every 5 seconds to keep everyone aligned
- **Live chat** — YouTube-style chat sidebar with colored usernames
- **Local playback** — multi-gigabyte files work fine since nothing is uploaded
- **Room system** — create a room, share the 6-character code with friends.
- **File verification** — joiners must have the exact same file (matched by name + size).
- **Playback sync** — play, pause, seek, and speed changes broadcast to all clients instantly.
- **Drift correction** — automatic re-sync every 5 seconds to keep everyone aligned.
- **Live chat** — YouTube-style chat sidebar with colored usernames.
- **Local playback** — multi-gigabyte files work fine since nothing is uploaded.
- **Broad format support** — the desktop app leverages VLC to play MKV and other formats that browsers struggle with.
## Quick Start
### 1. Start the Server
```bash
# Install Bun (if not already installed)
curl -fsSL https://bun.sh/install | bash
@@ -21,15 +32,44 @@ curl -fsSL https://bun.sh/install | bash
bun run server.ts
```
Open **http://localhost:3000** in your browser.
### 2. Connect via Web Frontend
## How It Works
Once the server is running, open **http://localhost:3000** in your browser to access the web app.
1. Enter your name and select a video file → **Create Room**
2. Share the room code with a friend
3. Friend enters the code → **Join Room** → selects their copy of the same file
4. Play/pause/seek in either browser — the other stays in sync
### 3. Connect via Desktop App (Recommended for MKV files)
If you are playing MKV files or formats not supported by your browser, use the desktop client:
```bash
cd desktop-client
# Install dependencies using uv
uv sync
# Run the app
uv run main.py
```
You can also build a standalone executable for the desktop app using the provided `build.ps1` script (requires PyInstaller).
### Quick Run on Linux
If you just want to run the desktop client without cloning the repo manually, use the launcher script. Prerequisites: `git` and [`uv`](https://docs.astral.sh/uv/).
```bash
# Download the script
curl -fsSL https://gitea.peterstockings.com/peterstockings/video-sync/raw/branch/master/desktop-client/run-videosync.sh -o run-videosync.sh
chmod +x run-videosync.sh
# Run (clones on first launch, pulls updates on subsequent runs)
./run-videosync.sh
```
## Deployment
```bash
@@ -53,17 +93,21 @@ location / {
| Component | Technology |
|-----------|-----------|
| Server | Bun (native HTTP + WebSocket) |
| Frontend | Vanilla HTML / CSS / JS |
| Dependencies | None |
| **Server** | Bun (native HTTP + WebSocket) |
| **Web Frontend** | Vanilla HTML / CSS / JS |
| **Desktop App**| Python, PyQt6, python-vlc, websockets |
## Project Structure
```
├── server.ts # Bun WebSocket server
├── package.json
── public/
├── index.html # Single-page app
├── style.css # Dark theme
└── app.js # Client sync + chat logic
── public/
├── index.html # Single-page web app
├── style.css # Dark theme
└── app.js # Client sync + chat logic
└── desktop-client/
├── main.py # Desktop app entry point
├── pyproject.toml # Python dependencies (uv)
└── vlc_player.py # Embedded VLC player
```

View File

@@ -0,0 +1 @@
3.14

0
desktop-client/README.md Normal file
View File

2
desktop-client/build.ps1 Normal file
View 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

View 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
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4BB543" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 237 B

106
desktop-client/commands.py Normal file
View 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
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#aaaaaa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>

After

Width:  |  Height:  |  Size: 333 B

View File

@@ -0,0 +1,48 @@
from PyQt6.QtWidgets import QSlider, QToolTip
from PyQt6.QtCore import Qt
class ClickableSlider(QSlider):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._get_tooltip_text = None
self.setMouseTracking(True)
def set_tooltip_provider(self, provider_func):
self._get_tooltip_text = provider_func
def mousePressEvent(self, event):
super().mousePressEvent(event)
if event.button() == Qt.MouseButton.LeftButton:
val = 0
if self.orientation() == Qt.Orientation.Horizontal:
if self.width() > 0:
val = self.minimum() + ((self.maximum() - self.minimum()) * event.pos().x()) / self.width()
else:
if self.height() > 0:
val = self.minimum() + ((self.maximum() - self.minimum()) * (self.height() - event.pos().y())) / self.height()
val = max(self.minimum(), min(self.maximum(), int(val)))
self.setValue(val)
self.sliderMoved.emit(val)
if self._get_tooltip_text:
text = self._get_tooltip_text(val)
if text:
QToolTip.showText(event.globalPosition().toPoint(), text, self)
def mouseMoveEvent(self, event):
super().mouseMoveEvent(event)
if self._get_tooltip_text and self.width() > 0:
hover_val = self.minimum() + ((self.maximum() - self.minimum()) * event.pos().x()) / self.width()
hover_val = max(self.minimum(), min(self.maximum(), int(hover_val)))
text = self._get_tooltip_text(hover_val)
if text:
QToolTip.showText(event.globalPosition().toPoint(), text, self)
def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
if event.button() == Qt.MouseButton.LeftButton:
# Explicitly emit sliderReleased on mouse release
# to ensure single clicks on the track also trigger the release logic
self.sliderReleased.emit()

BIN
desktop-client/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

View 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
View 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())

View 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",
]

View 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

View File

@@ -0,0 +1,25 @@
#!/bin/bash
# VideoSync Desktop Client Launcher
# Fetches the latest version from the repo and runs the desktop client.
# Prerequisites: git, uv
set -e
REPO_URL="https://gitea.peterstockings.com/peterstockings/video-sync.git"
INSTALL_DIR="$HOME/.videosync"
echo "🎬 VideoSync Desktop Client Launcher"
echo "======================================"
# Clone or pull the latest version
if [ -d "$INSTALL_DIR" ]; then
echo "📥 Pulling latest changes..."
git -C "$INSTALL_DIR" pull --ff-only
else
echo "📥 Cloning repository..."
git clone "$REPO_URL" "$INSTALL_DIR"
fi
echo "🚀 Launching VideoSync..."
cd "$INSTALL_DIR/desktop-client"
uv run main.py

View 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)

View File

@@ -0,0 +1,60 @@
import pytest
from PyQt6.QtCore import Qt
from main import VlcSyncApp
def test_app_ui_flow(qtbot):
# Initialize the main application
app = VlcSyncApp()
qtbot.addWidget(app)
# 1. Test Lobby View
assert app.stacked_widget.currentIndex() == 0
assert not app.lobby_widget.create_room_btn.isEnabled()
# Fill out the Lobby Form
app.lobby_widget.username_input.setText("PyTestUser")
# Mocking file selection instead of opening the native dialog
app.lobby_widget.local_file_path = "test_pytest.mkv"
app.lobby_widget.local_file_name = "test_pytest.mkv"
app.lobby_widget.local_file_size = 1048576
app.lobby_widget.check_inputs()
# Button should now be active
assert app.lobby_widget.create_room_btn.isEnabled()
# 2. Test Creating Room matches integration pipeline
qtbot.mouseClick(app.lobby_widget.create_room_btn, Qt.MouseButton.LeftButton)
# Wait for the WebSocket connected signal, the room_created server response, and UI transition
def check_room_joined():
assert app.stacked_widget.currentIndex() == 1
assert len(app.room_code) > 2
qtbot.waitUntil(check_room_joined, timeout=5000)
# 3. Test Chat flow End-to-End
# Type a message
qtbot.keyClicks(app.room_widget.chat_input, "Automated UI Test Message")
# Click Send
qtbot.mouseClick(app.room_widget.chat_send_btn, Qt.MouseButton.LeftButton)
# Wait until the Bun server grabs the websocket payload, stores it, and broadcasts it back to the UI!
def check_chat_received():
assert "Automated UI Test Message" in app.room_widget.chat_messages.toPlainText()
qtbot.waitUntil(check_chat_received, timeout=3000)
# 4. Test Playback Sync (UI updates and internal flags)
assert app.room_widget.play_btn.text() == ""
# Click Play
qtbot.mouseClick(app.room_widget.play_btn, Qt.MouseButton.LeftButton)
def check_playback_started():
assert app.room_widget.play_btn.text() == ""
qtbot.waitUntil(check_playback_started, timeout=2000)
# Clean up background threads
app._on_room_leave()

View File

@@ -0,0 +1,145 @@
import asyncio
import json
import websockets
import sys
async def run_integration_test():
url = "wss://video-sync.peterstockings.com/ws"
print("🤖 Test: Starting Integration Test Suite...")
# Client 1: Creator
try:
creator_ws = await websockets.connect(url)
print("✅ Creator connected to WebSocket")
except Exception as e:
print(f"❌ Failed to connect to server: {e}")
sys.exit(1)
creator_msg = {
"type": "create_room",
"username": "TestCreator",
"fileInfo": {
"name": "test_video.mkv",
"size": 1048576, # 1MB
"duration": 0
}
}
await creator_ws.send(json.dumps(creator_msg))
print("📤 Creator sent 'create_room'")
room_code = None
# Wait for room_created
try:
response_str = await asyncio.wait_for(creator_ws.recv(), timeout=2.0)
response = json.loads(response_str)
if response.get("type") == "room_created":
room_code = response.get("code")
print(f"✅ Creator received room_created with code: {room_code}")
else:
print(f"❌ Unexpected response for creator: {response}")
sys.exit(1)
except asyncio.TimeoutError:
print("❌ Timeout waiting for 'room_created'")
sys.exit(1)
# Client 2: Joiner
try:
joiner_ws = await websockets.connect(url)
print("✅ Joiner connected to WebSocket")
except Exception as e:
print(f"❌ Joiner failed to connect to server: {e}")
sys.exit(1)
joiner_msg = {
"type": "join_room",
"username": "TestJoiner",
"code": room_code
}
await joiner_ws.send(json.dumps(joiner_msg))
print(f"📤 Joiner sent 'join_room' for code: {room_code}")
# Wait for file_check needed
try:
response_str = await asyncio.wait_for(joiner_ws.recv(), timeout=2.0)
response = json.loads(response_str)
if response.get("type") == "room_file_check":
print(f"✅ Joiner received room_file_check from server. Payload: {response}")
# Send file confirmation
confirm_msg = {
"type": "confirm_join",
"fileInfo": {
"name": "test_video.mkv",
"size": 1048576,
"duration": 0
}
}
await joiner_ws.send(json.dumps(confirm_msg))
print("📤 Joiner sent 'confirm_join'")
# Wait for successful room_joined
response_str = await asyncio.wait_for(joiner_ws.recv(), timeout=2.0)
response = json.loads(response_str)
if response.get("type") == "room_joined":
print(f"✅ Joiner successfully received room_joined: {list(response.keys())}")
# Check users payload from the join response itself
users = response.get("users", [])
print(f"✅ Joiner received user list from room_joined payload: {users}")
assert len(users) == 2, "Expected 2 users in the room!"
# Setup wait task for the Joiner to receive a Play sync event
print("⏳ Creator is sending a Play sync event...")
await creator_ws.send(json.dumps({
"type": "sync",
"action": "play",
"position": 5.0
}))
# Creator will get an echo, Joiner will get the broadcast
creator_echo = json.loads(await asyncio.wait_for(creator_ws.recv(), timeout=2.0))
joiner_sync = json.loads(await asyncio.wait_for(joiner_ws.recv(), timeout=2.0))
if joiner_sync.get("type") == "sync":
print(f"✅ Joiner received sync event: {joiner_sync}")
assert joiner_sync.get("action") == "play", "Expected 'play' sync event"
assert joiner_sync.get("position") == 5.0, "Expected position 5.0"
else:
print(f"❌ Joiner expected sync, got: {joiner_sync}")
sys.exit(1)
# Setup wait task for the Joiner to send a Chat message
print("⏳ Joiner is sending a Chat message...")
await joiner_ws.send(json.dumps({
"type": "chat",
"message": "Hello from integration test!"
}))
creator_chat = json.loads(await asyncio.wait_for(creator_ws.recv(), timeout=2.0))
joiner_chat = json.loads(await asyncio.wait_for(joiner_ws.recv(), timeout=2.0))
if creator_chat.get("type") == "chat":
print(f"✅ Creator received chat event: {creator_chat}")
assert creator_chat.get("username") == "TestJoiner", "Expected TestJoiner author"
assert creator_chat.get("message") == "Hello from integration test!", "Expected correct message"
else:
print(f"❌ Creator expected chat, got: {creator_chat}")
sys.exit(1)
else:
print(f"❌ Joiner expected room_joined, got: {response}")
else:
print(f"❌ Joiner expected room_file_check, got: {response}")
except asyncio.TimeoutError:
print("❌ Timeout waiting for server response to 'join_room'")
sys.exit(1)
await creator_ws.close()
await joiner_ws.close()
print("🎉 Integration test completed successfully!")
if __name__ == "__main__":
asyncio.run(run_integration_test())

11
desktop-client/utils.py Normal file
View File

@@ -0,0 +1,11 @@
def format_time_hms(ms: int) -> str:
"""Format milliseconds as HH:MM:SS."""
s = max(0, ms) // 1000
return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}"
def format_time_short(ms: int) -> str:
"""Format milliseconds as MM:SS or HH:MM:SS if >= 1 hour."""
s = int(max(0, ms) // 1000)
if s >= 3600:
return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}"
return f"{(s % 3600) // 60:02d}:{s % 60:02d}"

234
desktop-client/uv.lock generated Normal file
View 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" },
]

View File

@@ -0,0 +1,110 @@
import vlc
import sys
from PyQt6.QtWidgets import QFrame
from PyQt6.QtCore import Qt, pyqtSignal, QObject
import threading
class VLCSignals(QObject):
state_changed = pyqtSignal(bool, int) # is_playing, time_ms
time_changed = pyqtSignal(int) # time_ms
class VLCSyncPlayer:
def __init__(self, frame: QFrame):
self.frame = frame
self.signals = VLCSignals()
# Initialize VLC instance
# --no-xlib prevents crashes on Linux
# --drop-late-frames improves sync by not delaying playback when CPU is slow
# --no-keyboard stops VLC from capturing and swallowing keyboard events
self.instance = vlc.Instance("--no-xlib", "--drop-late-frames", "--no-keyboard")
self.media_player = self.instance.media_player_new()
# Embed the VLC player into the provided PyQt QFrame
# On Windows, PyQt6 widgets don't have a native handle by default
self.frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow)
if sys.platform.startswith('linux'):
self.media_player.set_xwindow(int(self.frame.winId()))
elif sys.platform == "win32":
# For Windows, we must explicitly cast winId() to int
self.media_player.set_hwnd(int(self.frame.winId()))
elif sys.platform == "darwin":
self.media_player.set_nsobject(int(self.frame.winId()))
# Register Event Callbacks
self.events = self.media_player.event_manager()
self.events.event_attach(vlc.EventType.MediaPlayerPlaying, self._on_playing)
self.events.event_attach(vlc.EventType.MediaPlayerPaused, self._on_paused)
self.events.event_attach(vlc.EventType.MediaPlayerTimeChanged, self._on_time_changed)
# Local State
self.is_playing = False
self.current_time_ms = 0
self.ignore_next_event = False
self.lock = threading.Lock()
def load_media(self, path: str, start_time_s: float = 0.0):
media = self.instance.media_new(path)
if start_time_s > 0:
media.add_option(f"start-time={start_time_s}")
self.media_player.set_media(media)
def play(self):
with self.lock:
self.ignore_next_event = True
self.media_player.play()
def pause(self):
with self.lock:
self.ignore_next_event = True
self.media_player.set_pause(1)
def seek(self, position_ms: int):
with self.lock:
self.ignore_next_event = True
self.media_player.set_time(position_ms)
def set_volume(self, volume: int):
self.media_player.audio_set_volume(volume)
def get_volume(self) -> int:
return self.media_player.audio_get_volume()
# --- Internal VLC Callbacks ---
@vlc.callbackmethod
def _on_playing(self, event):
self.is_playing = True
with self.lock:
if self.ignore_next_event:
self.ignore_next_event = False
return
time_ms = self.media_player.get_time()
# Fire signal to PyQt thread
self.signals.state_changed.emit(True, time_ms)
@vlc.callbackmethod
def _on_paused(self, event):
self.is_playing = False
with self.lock:
if self.ignore_next_event:
self.ignore_next_event = False
return
time_ms = self.media_player.get_time()
self.signals.state_changed.emit(False, time_ms)
@vlc.callbackmethod
def _on_time_changed(self, event):
# Emitted constantly during playback
self.current_time_ms = event.u.new_time
# We also want to fire this signal so the UI scrubber/time label can update
self.signals.time_changed.emit(self.current_time_ms)
def get_length(self):
return self.media_player.get_length()
def stop(self):
self.media_player.stop()

View File

@@ -10,8 +10,6 @@
let joinFile = null; // File object for the joiner
let ignoreSync = false; // flag to avoid feedback loops
let syncTimeout = null;
let driftInterval = null;
let serverState = { playing: false, position: 0, speed: 1 };
// Reconnection state
let reconnectAttempts = 0;
@@ -73,7 +71,6 @@
const volumeSlider = $("volume-slider");
const currentTimeEl = $("current-time");
const durationEl = $("duration");
const speedSelect = $("speed-select");
const fullscreenBtn = $("fullscreen-btn");
const userCountEl = $("user-count");
const usersList = $("users-list");
@@ -238,12 +235,15 @@
// Seamless reconnection — sync to server state
isReconnecting = false;
showConnectionStatus("reconnected");
if (msg.chatHistory) {
chatMessages.innerHTML = "";
msg.chatHistory.forEach((m) => addChatMessage(m.username, m.message, m.timestamp));
}
updateUsers(msg.users);
if (msg.state) {
applySync(msg.state);
}
addSystemMessage("Reconnected");
startDriftCorrection();
flushMessageQueue();
break;
@@ -298,82 +298,22 @@
// Load local file into video player
const file = localFile || joinFile;
if (file) {
loadVideoSource(file);
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = URL.createObjectURL(file);
videoPlayer.src = currentBlobUrl;
}
// --- Handle false "ended" events (Chromium MKV blob bug) ---
// Chrome can't properly index MKV containers loaded via blob URLs.
// After ~30s it may jump to the end and fire "ended" even though
// the video isn't actually over. Recovery: reload the blob and seek.
let recoveryAttempts = 0;
const MAX_RECOVERY_ATTEMPTS = 5;
videoPlayer.addEventListener("ended", () => {
const duration = videoPlayer.duration || 0;
// Only attempt recovery if we have server state showing we're not near the end
if (duration > 0 && lastServerState.position < duration - 5 && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
recoveryAttempts++;
console.log(`[MKV-RECOVERY] False ended detected. Server pos=${lastServerState.position.toFixed(1)}, duration=${duration.toFixed(1)}. Reloading source (attempt ${recoveryAttempts}/${MAX_RECOVERY_ATTEMPTS})`);
const targetPos = lastServerState.position;
const wasPlaying = lastServerState.playing;
const currentFile = localFile || joinFile;
if (currentFile) {
// Reload the video with a fresh blob URL
loadVideoSource(currentFile, targetPos, wasPlaying);
}
}
});
// Reset recovery counter when video plays successfully for a while
let recoveryResetTimer = null;
videoPlayer.addEventListener("timeupdate", () => {
if (recoveryAttempts > 0) {
clearTimeout(recoveryResetTimer);
recoveryResetTimer = setTimeout(() => {
recoveryAttempts = 0;
}, 10000); // Reset after 10s of successful playback
}
});
// --- Resync on tab focus (handles background tab throttling) ---
document.addEventListener("visibilitychange", () => {
if (!document.hidden && roomCode && ws && ws.readyState === WebSocket.OPEN) {
console.log("[SYNC] Tab became visible, requesting state resync");
send({ type: "request_state" });
}
});
// Start drift correction
startDriftCorrection();
}
// --- Video Source Loading ---
// --- State tracking ---
let currentBlobUrl = null;
let lastServerState = { playing: false, position: 0, speed: 1 };
function loadVideoSource(file, seekTo, shouldPlay) {
// Revoke old blob URL to free memory
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
}
currentBlobUrl = URL.createObjectURL(file);
videoPlayer.src = currentBlobUrl;
if (seekTo !== undefined) {
videoPlayer.addEventListener("loadedmetadata", function onMeta() {
videoPlayer.removeEventListener("loadedmetadata", onMeta);
videoPlayer.currentTime = seekTo;
if (shouldPlay) {
videoPlayer.play().then(() => {
videoWrapper.classList.add("playing");
updatePlayPauseIcon();
}).catch(() => {});
}
});
}
}
let lastServerState = { playing: false, position: 0 };
// --- File Check Modal ---
let pendingRoomFileInfo = null;
@@ -459,12 +399,7 @@
// Track latest server state for recovery
if (data.position !== undefined) lastServerState.position = data.position;
if (data.playing !== undefined) lastServerState.playing = data.playing;
if (data.speed !== undefined) lastServerState.speed = data.speed;
if (data.speed !== undefined && videoPlayer.playbackRate !== data.speed) {
videoPlayer.playbackRate = data.speed;
speedSelect.value = String(data.speed);
}
if (data.position !== undefined) {
const diff = Math.abs(videoPlayer.currentTime - data.position);
@@ -510,14 +445,7 @@
}
}
function startDriftCorrection() {
if (driftInterval) clearInterval(driftInterval);
driftInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
send({ type: "request_state" });
}
}, 5000);
}
// --- Get video duration from a file (used for file info) ---
function getVideoDuration(file) {
@@ -544,6 +472,17 @@
container.classList.remove("hidden");
}
function isMkvFile(file) {
return file.name.toLowerCase().endsWith(".mkv");
}
function showFormatWarning(container) {
const warning = document.createElement("div");
warning.className = "format-warning";
warning.innerHTML = `⚠️ <strong>MKV files may not play correctly</strong> in browsers. Convert to MP4 for best results:<br><code>ffmpeg -i file.mkv -c copy file.mp4</code>`;
container.parentNode.insertBefore(warning, container.nextSibling);
}
// ===== EVENT LISTENERS =====
// --- Lobby: Username ---
@@ -560,7 +499,11 @@
localFile = file;
const duration = await getVideoDuration(file);
localFile._duration = duration;
// Remove any previous format warning
const oldWarning = createFileInfo.parentNode.querySelector(".format-warning");
if (oldWarning) oldWarning.remove();
renderFileInfo(createFileInfo, file, duration);
if (isMkvFile(file)) showFormatWarning(createFileInfo);
createRoomBtn.disabled = !usernameInput.value.trim();
});
@@ -679,7 +622,6 @@
joinFile = null;
roomFileInfo = null;
messageQueue = [];
if (driftInterval) clearInterval(driftInterval);
videoPlayer.src = "";
chatMessages.innerHTML = '<div class="chat-welcome" id="chat-welcome"><p>Welcome to the room! 👋</p></div>';
createFileInfo.classList.add("hidden");
@@ -766,12 +708,6 @@
volOffIcon.classList.toggle("hidden", !videoPlayer.muted);
});
// Speed
speedSelect.addEventListener("change", () => {
const speed = parseFloat(speedSelect.value);
videoPlayer.playbackRate = speed;
send({ type: "sync", action: "speed", speed });
});
// Fullscreen
fullscreenBtn.addEventListener("click", () => {

View File

@@ -158,15 +158,6 @@
</span>
</div>
<div class="controls-right">
<select id="speed-select" class="speed-select" title="Playback speed">
<option value="0.25">0.25x</option>
<option value="0.5">0.5x</option>
<option value="0.75">0.75x</option>
<option value="1" selected>1x</option>
<option value="1.25">1.25x</option>
<option value="1.5">1.5x</option>
<option value="2">2x</option>
</select>
<button id="fullscreen-btn" class="ctrl-btn" title="Fullscreen">
<svg viewBox="0 0 24 24" width="22" height="22" fill="white">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />

View File

@@ -282,6 +282,30 @@ select {
font-size: 0.75rem;
}
/* Format warning (MKV etc.) */
.format-warning {
width: 100%;
margin-top: 8px;
padding: 10px 14px;
background: rgba(243, 156, 18, 0.1);
border: 1px solid rgba(243, 156, 18, 0.3);
border-radius: var(--radius);
color: var(--warning);
font-size: 0.78rem;
line-height: 1.5;
}
.format-warning code {
display: inline-block;
margin-top: 4px;
padding: 2px 8px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
color: var(--text-primary);
}
/* Buttons */
.btn-primary {
width: 100%;

View File

@@ -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);
},