Compare commits

..

29 Commits

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
15 changed files with 1575 additions and 601 deletions

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

@@ -1,2 +1,2 @@
uv add pyinstaller pillow
uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --add-data "icon.png;." main.py
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)

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

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

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

View File

@@ -2,22 +2,20 @@ import sys
import os
import ctypes
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QStackedWidget, QWidget, QVBoxLayout,
QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox,
QFrame, QSlider, QTextEdit
QApplication, QMainWindow, QStackedWidget, QFileDialog, QMessageBox
)
from PyQt6.QtCore import Qt, pyqtSignal, QObject, QTimer
from PyQt6.QtGui import QFont, QIcon, QColor
from vlc_player import VLCSyncPlayer
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QIcon
from sync_client import SyncClientThread
import datetime
from lobby_widget import LobbyWidget
from room_widget import RoomWidget
class VlcSyncApp(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("VideoSync — Watch Together")
# Set window icon
icon_path = os.path.join(os.path.dirname(__file__), "icon.png")
if os.path.exists(icon_path):
self.setWindowIcon(QIcon(icon_path))
@@ -25,389 +23,55 @@ class VlcSyncApp(QMainWindow):
self.setMinimumSize(900, 600)
self.resize(1100, 700)
# Main stacked widget to switch between Lobby and Room
# Main stacked widget
self.stacked_widget = QStackedWidget()
self.setCentralWidget(self.stacked_widget)
# Setup Views
self.lobby_view = self.create_lobby_view()
self.room_view = self.create_room_view()
self.lobby_widget = LobbyWidget()
self.room_widget = RoomWidget()
self.stacked_widget.addWidget(self.lobby_view)
self.stacked_widget.addWidget(self.room_view)
self.stacked_widget.addWidget(self.lobby_widget)
self.stacked_widget.addWidget(self.room_widget)
# State
# 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
self.ignore_vlc_events = False
self.last_reported_time_ms = 0
# 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.on_users_updated)
self.sync_client.chat_message.connect(self.on_chat_message)
self.sync_client.system_message.connect(self.on_system_message)
self.sync_client.sync_event.connect(self.on_sync_event)
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 create_lobby_view(self):
widget = QWidget()
layout = QVBoxLayout(widget)
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 = QLineEdit()
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)
return widget
def create_room_view(self):
widget = QWidget()
layout = QHBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# --- 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.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_room)
topbar_layout.addWidget(self.room_code_display)
topbar_layout.addWidget(self.copy_code_btn)
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;")
# Fix for Windows QWidgetWindow Error:
# Force the frame to have its own native HWND so VLC can attach to it without complaining it's not a top-level window.
self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
# Controls Bar
controls = QFrame()
controls.setObjectName("controlsBar")
controls.setFixedHeight(60)
controls_layout = QHBoxLayout(controls)
self.play_btn = QPushButton("")
self.play_btn.setFixedSize(40, 40)
self.play_btn.setObjectName("playBtn")
# Time and SeekBar
self.seekbar = QSlider(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.time_lbl = QLabel("00:00:00 / 00:00:00")
# Volume
self.vol_icon = QLabel("🔊")
self.vol_icon.setObjectName("volIcon")
self.volume_slider = QSlider(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)
# 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) # SeekBar gets the stretch
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)
video_layout.addWidget(self.topbar)
video_layout.addWidget(self.video_frame)
video_layout.addWidget(controls)
# --- Right Side: Chat ---
self.chat_container = QFrame()
self.chat_container.setObjectName("chatContainer")
self.chat_container.setFixedWidth(320)
chat_layout = QVBoxLayout(self.chat_container)
chat_header = QLabel("Live Chat")
chat_header.setObjectName("chatHeader")
self.users_lbl = QLabel("0 watching")
self.users_lbl.setObjectName("usersLbl")
self.chat_messages = QTextEdit()
self.chat_messages.setObjectName("chatMessages")
self.chat_messages.setReadOnly(True)
self.chat_messages.setHtml("Welcome to the room! 👋")
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.addWidget(chat_header)
chat_layout.addWidget(self.users_lbl)
chat_layout.addWidget(self.chat_messages, 1) # stretch
chat_layout.addLayout(chat_input_layout)
layout.addWidget(video_container, 1) # stretch
layout.addWidget(self.chat_container)
# 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)
return widget
def toggle_fullscreen(self):
if self.isFullScreen():
self.showNormal()
self.fullscreen_btn.setText("")
self.chat_container.show()
self.topbar.show()
else:
self.showFullScreen()
self.fullscreen_btn.setText("🗗")
self.chat_container.hide()
self.topbar.hide()
def toggle_playback(self):
position_s = self.vlc_player.current_time_ms / 1000.0
if self.vlc_player.is_playing:
self.vlc_player.pause()
self.play_btn.setText("")
self.sync_client.send_message({"type": "sync", "action": "pause", "position": position_s})
else:
self.vlc_player.play()
self.play_btn.setText("")
self.sync_client.send_message({"type": "sync", "action": "play", "position": position_s})
def on_vlc_time(self, time_ms: int):
length_ms = self.vlc_player.get_length()
if length_ms > 0:
def fmt(ms):
s = max(0, ms) // 1000
return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
if not self.seekbar.isSliderDown():
self.time_lbl.setText(f"{fmt(time_ms)} / {fmt(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 > 2500 and not self.ignore_vlc_events:
self.sync_client.send_message({"type": "sync", "action": "seek", "position": time_ms / 1000.0})
self.last_reported_time_ms = time_ms
def on_vlc_state(self, playing: bool, time_ms: int):
if self.ignore_vlc_events:
return
action = "play" if playing else "pause"
self.sync_client.send_message({"type": "sync", "action": action, "position": time_ms / 1000.0})
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)
def fmt(ms):
s = max(0, ms) // 1000
return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
self.time_lbl.setText(f"{fmt(target_ms)} / {fmt(length_ms)}")
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)
self.vlc_player.seek(target_ms)
self.sync_client.send_message({"type": "sync", "action": "seek", "position": target_ms / 1000.0})
def on_volume_changed(self, value):
self.vlc_player.set_volume(value)
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.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 copy_room_code(self):
if self.room_code:
QApplication.clipboard().setText(self.room_code)
self.copy_code_btn.setIcon(self.check_icon)
# Show floating tooltip for feedback
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)
# Position the toast slightly below the button
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 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):
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)
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",
@@ -418,63 +82,72 @@ class VlcSyncApp(QMainWindow):
"duration": 0
}
})
if self.sync_client.running and self.sync_client.ws:
self.on_ws_connected()
else:
self.sync_client.start()
self._ensure_connection()
def join_room(self):
self.username = self.username_input.text().strip()
self.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)
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 leave_room(self):
self.vlc_player.stop()
def _on_room_leave(self):
self.room_widget.cleanup()
self.sync_client.stop()
self.stacked_widget.setCurrentIndex(0)
self.local_file_path = None
self.create_file_info.hide()
self.room_code = ""
self.check_inputs()
self.create_room_btn.setText("Create Room")
self.join_room_btn.setText("Join Room")
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.create_room_btn.setText("Create Room")
self.join_room_btn.setText("Join Room")
self.check_inputs()
self.lobby_widget.reset_ui()
self.sync_client.stop()
def on_file_check_needed(self, msg: dict):
# Defer execution to the PyQt Main Thread to avoid deadlocking the WebSocket thread
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")
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})"
@@ -485,7 +158,7 @@ class VlcSyncApp(QMainWindow):
self.local_file_name = os.path.basename(file_path)
self.local_file_size = os.path.getsize(file_path)
if self.local_file_name == req_name and self.local_file_size == req_size:
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": {
@@ -495,14 +168,13 @@ class VlcSyncApp(QMainWindow):
}
})
else:
QMessageBox.critical(self, "File Mismatch", "The selected file does not exactly match the room's required file.")
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.join_room_btn.setText("Join Room")
self.check_inputs()
self.lobby_widget.reset_ui()
else:
self.sync_client.stop()
self.join_room_btn.setText("Join Room")
self.check_inputs()
self.lobby_widget.reset_ui()
def on_room_joined(self, msg: dict):
if "room" in msg:
@@ -511,174 +183,45 @@ class VlcSyncApp(QMainWindow):
self.room_code = msg.get("code", "")
self.stacked_widget.setCurrentIndex(1)
self.room_code_display.setText(f"Room: {self.room_code}")
self.room_file_badge.setText(f"📄 {self.local_file_name}")
self.create_room_btn.setText("Create Room")
self.join_room_btn.setText("Join Room")
self.chat_messages.setHtml("Welcome to the room! 👋")
self.lobby_widget.reset_ui()
if self.local_file_path:
self.vlc_player.load_media(self.local_file_path)
self.vlc_player.set_volume(self.volume_slider.value())
users = msg.get("users", [])
if users:
self.on_users_updated(users)
state = msg.get("state", {})
if state:
self.on_sync_event(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:
self.chat_messages.setHtml("Welcome to the room! 👋")
for chat in chat_history:
self.on_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0))
def on_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()
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)
time_str = dt.strftime("%I:%M %p")
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)
new_msg = f"<b>{author}</b>: {text} <span style='color: gray; font-size: 10px;'>{time_str}</span>"
self.chat_messages.append(new_msg)
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))
def on_system_message(self, text: str):
new_msg = f"<i style='color: #888;'>{text}</i>"
self.chat_messages.append(new_msg)
def on_users_updated(self, users: list):
self.users_lbl.setText(f"{len(users)} watching: {', '.join(users)}")
def on_sync_event(self, msg: dict):
self.ignore_vlc_events = True
action = msg.get("action")
# Handle full state sync vs event sync
if not action:
if msg.get("playing", False):
action = "play"
elif msg.get("playing") is False:
action = "pause"
position_s = msg.get("position", 0)
position_ms = int(position_s * 1000)
if action == "play":
self.vlc_player.seek(position_ms)
self.vlc_player.play()
self.play_btn.setText("")
elif action == "pause":
self.vlc_player.seek(position_ms)
self.vlc_player.pause()
self.play_btn.setText("")
elif action == "seek":
self.vlc_player.seek(position_ms)
self.room_widget.add_system_message("✅ Reconnected to the room.")
users = msg.get("users", [])
if users:
self.room_widget.update_users(users)
def clear_ignore():
self.ignore_vlc_events = False
QTimer.singleShot(1500, clear_ignore)
# System notification
username = msg.get("username")
if username and username != self.username:
if action == "play":
self.on_system_message(f"{username} pressed play")
elif action == "pause":
self.on_system_message(f"{username} paused")
elif action == "seek":
def fmt(s): return f"{int(s)//3600:02d}:{(int(s)%3600)//60:02d}:{int(s)%60:02d}"
self.on_system_message(f"{username} seeked to {fmt(position_s)}")
def _handle_seek_command(self, arg: str) -> bool:
current_ms = self.vlc_player.current_time_ms
length_ms = self.vlc_player.get_length()
if length_ms <= 0:
return False
try:
target_ms = 0
if arg.startswith('+') or arg.startswith('-'):
# relative
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 # Default to seconds
target_ms = current_ms + (val * modifier)
elif ":" in arg:
# absolute time like HH:MM:SS or MM:SS
parts = arg.split(":")
parts.reverse() # seconds, minutes, hours
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:
# absolute seconds or something with a suffix but no + or -
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
target_ms = max(0, min(target_ms, length_ms))
self.vlc_player.seek(int(target_ms))
self.sync_client.send_message({"type": "sync", "action": "seek", "position": target_ms / 1000.0})
return True
except ValueError:
return False
def send_chat(self):
text = self.chat_input.text().strip()
if not text:
return
if text.startswith("/"):
parts = text.split()
cmd = parts[0].lower()
if cmd == "/play":
self.chat_input.setText("")
if not self.vlc_player.is_playing:
self.toggle_playback()
self.on_system_message(text)
return
elif cmd == "/pause":
self.chat_input.setText("")
if self.vlc_player.is_playing:
self.toggle_playback()
self.on_system_message(text)
return
elif cmd == "/seek":
self.chat_input.setText("")
if len(parts) > 1:
if self._handle_seek_command(parts[1]):
self.on_system_message(text)
else:
self.on_system_message("Invalid time format. Use: 1:23, +30s, -1m")
else:
self.on_system_message("Usage: /seek [time]")
return
elif cmd == "/help":
self.chat_input.setText("")
self.on_system_message("Available commands:<br><b>/play</b> - Resume playback<br><b>/pause</b> - Pause playback<br><b>/seek [time]</b> - Seek to specific time (e.g., 1:23) or offset (e.g., +30s, -1m)<br><b>/help</b> - Show this message")
return
self.sync_client.send_message({"type": "chat", "message": text})
self.chat_input.setText("")
state = msg.get("state", {})
if state:
self.room_widget.handle_sync_event(state)
def apply_stylesheet(self):
self.setStyleSheet("""
@@ -814,6 +357,75 @@ class VlcSyncApp(QMainWindow):
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;
@@ -833,7 +445,6 @@ class VlcSyncApp(QMainWindow):
#chatMessages {
background-color: transparent;
border: none;
color: #f1f1f1;
}
#chatMessages QScrollBar:vertical {
@@ -843,12 +454,12 @@ class VlcSyncApp(QMainWindow):
margin: 0px;
}
#chatMessages QScrollBar::handle:vertical {
background: #555;
background: #333;
min-height: 20px;
border-radius: 4px;
}
#chatMessages QScrollBar::handle:vertical:hover {
background: #666;
background: #444;
}
#chatMessages QScrollBar::add-line:vertical, #chatMessages QScrollBar::sub-line:vertical {
height: 0px;
@@ -856,10 +467,14 @@ class VlcSyncApp(QMainWindow):
#chatMessages QScrollBar::add-page:vertical, #chatMessages QScrollBar::sub-page:vertical {
background: none;
}
#bubble {
border: none;
}
""")
if __name__ == "__main__":
# Tell Windows this is a distinct app so the taskbar icon updates correctly
import ctypes
if os.name == 'nt':
myappid = 'vlcsync.desktopclient.app.1'
try:
@@ -869,7 +484,6 @@ if __name__ == "__main__":
app = QApplication(sys.argv)
# Set app-level icon
icon_path = os.path.join(os.path.dirname(__file__), "icon.png")
if os.path.exists(icon_path):
app.setWindowIcon(QIcon(icon_path))
@@ -877,3 +491,4 @@ if __name__ == "__main__":
window = VlcSyncApp()
window.show()
sys.exit(app.exec())

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

@@ -1,5 +1,6 @@
import asyncio
import json
import time
import websockets
from PyQt6.QtCore import QThread, pyqtSignal
@@ -8,12 +9,14 @@ class SyncClientThread(QThread):
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__()
@@ -21,6 +24,7 @@ class SyncClientThread(QThread):
self.ws = None
self.loop = None
self.running = False
self._ping_sent_at = 0
def run(self):
"""Runs strictly within the newly created QThread"""
@@ -43,6 +47,19 @@ class SyncClientThread(QThread):
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:
@@ -50,6 +67,7 @@ class SyncClientThread(QThread):
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:
@@ -57,6 +75,8 @@ class SyncClientThread(QThread):
self._handle_message(json.loads(message))
except websockets.ConnectionClosed:
pass
finally:
ping_task.cancel()
except Exception as e:
print(f"WebSocket Error: {e}")
@@ -72,9 +92,12 @@ class SyncClientThread(QThread):
if t == "room_created":
self.room_joined.emit(msg)
elif t == "room_joined" or t == "room_rejoined":
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"))

View File

@@ -9,22 +9,22 @@ def test_app_ui_flow(qtbot):
# 1. Test Lobby View
assert app.stacked_widget.currentIndex() == 0
assert not app.create_room_btn.isEnabled()
assert not app.lobby_widget.create_room_btn.isEnabled()
# Fill out the Lobby Form
app.username_input.setText("PyTestUser")
app.lobby_widget.username_input.setText("PyTestUser")
# Mocking file selection instead of opening the native dialog
app.local_file_path = "test_pytest.mkv"
app.local_file_name = "test_pytest.mkv"
app.local_file_size = 1048576
app.check_inputs()
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.create_room_btn.isEnabled()
assert app.lobby_widget.create_room_btn.isEnabled()
# 2. Test Creating Room matches integration pipeline
qtbot.mouseClick(app.create_room_btn, Qt.MouseButton.LeftButton)
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():
@@ -35,26 +35,26 @@ def test_app_ui_flow(qtbot):
# 3. Test Chat flow End-to-End
# Type a message
qtbot.keyClicks(app.chat_input, "Automated UI Test Message")
qtbot.keyClicks(app.room_widget.chat_input, "Automated UI Test Message")
# Click Send
qtbot.mouseClick(app.chat_send_btn, Qt.MouseButton.LeftButton)
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.chat_messages.text()
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.play_btn.text() == ""
assert app.room_widget.play_btn.text() == ""
# Click Play
qtbot.mouseClick(app.play_btn, Qt.MouseButton.LeftButton)
qtbot.mouseClick(app.room_widget.play_btn, Qt.MouseButton.LeftButton)
def check_playback_started():
assert app.play_btn.text() == ""
assert app.room_widget.play_btn.text() == ""
qtbot.waitUntil(check_playback_started, timeout=2000)
# Clean up background threads
app.leave_room()
app._on_room_leave()

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}"

View File

@@ -16,7 +16,8 @@ class VLCSyncPlayer:
# Initialize VLC instance
# --no-xlib prevents crashes on Linux
# --drop-late-frames improves sync by not delaying playback when CPU is slow
self.instance = vlc.Instance("--no-xlib", "--drop-late-frames")
# --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
@@ -43,8 +44,10 @@ class VLCSyncPlayer:
self.ignore_next_event = False
self.lock = threading.Lock()
def load_media(self, path: str):
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):

View File

@@ -235,6 +235,10 @@
// 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);

View File

@@ -137,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) {
@@ -294,6 +298,7 @@ const server = Bun.serve<WSData>({
playing: room.playing,
username: ws.data.username,
timestamp: Date.now(),
req_id: msg.req_id,
},
ws as unknown as WebSocket
);
@@ -407,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);
@@ -433,7 +438,7 @@ const server = Bun.serve<WSData>({
});
}
}
}, 30_000);
}, 90_000);
pendingDisconnects.set(disconnectKey, timer);
},