Compare commits
21 Commits
2e31eab0ca
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d2d1995bd | ||
|
|
66342706db | ||
|
|
386aa18ba1 | ||
|
|
e2edd296fc | ||
|
|
42f7ee7f12 | ||
|
|
5b857bf878 | ||
|
|
99a0694830 | ||
|
|
887648bf85 | ||
|
|
a301d1521b | ||
|
|
dd00011b77 | ||
|
|
25ea1694f1 | ||
|
|
367da66c0b | ||
|
|
b59d08d098 | ||
|
|
ce87a817ea | ||
|
|
dae4af9ab8 | ||
|
|
02092bab69 | ||
|
|
777e08ff85 | ||
|
|
d1a87e004e | ||
|
|
6a8dc7e5e6 | ||
|
|
c4bef281f7 | ||
|
|
e120813a01 |
74
README.md
74
README.md
@@ -2,17 +2,28 @@
|
|||||||
|
|
||||||
Watch videos together in real-time. Each user loads a local video file — only sync commands and chat messages travel over the network.
|
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
|
## Features
|
||||||
|
|
||||||
- **Room system** — create a room, share the 6-character code with friends
|
- **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)
|
- **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
|
- **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
|
- **Drift correction** — automatic re-sync every 5 seconds to keep everyone aligned.
|
||||||
- **Live chat** — YouTube-style chat sidebar with colored usernames
|
- **Live chat** — YouTube-style chat sidebar with colored usernames.
|
||||||
- **Local playback** — multi-gigabyte files work fine since nothing is uploaded
|
- **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
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Start the Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Bun (if not already installed)
|
# Install Bun (if not already installed)
|
||||||
curl -fsSL https://bun.sh/install | bash
|
curl -fsSL https://bun.sh/install | bash
|
||||||
@@ -21,15 +32,44 @@ curl -fsSL https://bun.sh/install | bash
|
|||||||
bun run server.ts
|
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**
|
1. Enter your name and select a video file → **Create Room**
|
||||||
2. Share the room code with a friend
|
2. Share the room code with a friend
|
||||||
3. Friend enters the code → **Join Room** → selects their copy of the same file
|
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
|
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
|
## Deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -53,17 +93,21 @@ location / {
|
|||||||
|
|
||||||
| Component | Technology |
|
| Component | Technology |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| Server | Bun (native HTTP + WebSocket) |
|
| **Server** | Bun (native HTTP + WebSocket) |
|
||||||
| Frontend | Vanilla HTML / CSS / JS |
|
| **Web Frontend** | Vanilla HTML / CSS / JS |
|
||||||
| Dependencies | None |
|
| **Desktop App**| Python, PyQt6, python-vlc, websockets |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── server.ts # Bun WebSocket server
|
├── server.ts # Bun WebSocket server
|
||||||
├── package.json
|
├── package.json
|
||||||
└── public/
|
├── public/
|
||||||
├── index.html # Single-page app
|
│ ├── index.html # Single-page web app
|
||||||
├── style.css # Dark theme
|
│ ├── style.css # Dark theme
|
||||||
└── app.js # Client sync + chat logic
|
│ └── app.js # Client sync + chat logic
|
||||||
|
└── desktop-client/
|
||||||
|
├── main.py # Desktop app entry point
|
||||||
|
├── pyproject.toml # Python dependencies (uv)
|
||||||
|
└── vlc_player.py # Embedded VLC player
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
uv add pyinstaller pillow
|
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
|
||||||
|
|||||||
82
desktop-client/chat_widgets.py
Normal file
82
desktop-client/chat_widgets.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame,
|
||||||
|
QMainWindow
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
|
|
||||||
|
|
||||||
|
class ChatBubble(QWidget):
|
||||||
|
def __init__(self, author, text, time_str, is_self, on_link_clicked):
|
||||||
|
super().__init__()
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(5, 2, 5, 2)
|
||||||
|
|
||||||
|
# Container for the actual bubble
|
||||||
|
self.bubble = QFrame()
|
||||||
|
self.bubble.setObjectName("bubble")
|
||||||
|
|
||||||
|
bg_color = "#0084FF" if is_self else "#2a2a2e"
|
||||||
|
text_color = "#FFFFFF"
|
||||||
|
author_color = "#E1E1E1" if is_self else "#3ea6ff"
|
||||||
|
|
||||||
|
self.bubble.setStyleSheet(f"""
|
||||||
|
#bubble {{
|
||||||
|
background-color: {bg_color};
|
||||||
|
border-radius: 12px;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
bubble_layout = QVBoxLayout(self.bubble)
|
||||||
|
bubble_layout.setContentsMargins(10, 8, 10, 6)
|
||||||
|
bubble_layout.setSpacing(2)
|
||||||
|
|
||||||
|
# Author
|
||||||
|
author_lbl = QLabel("You" if is_self else author)
|
||||||
|
author_lbl.setStyleSheet(f"color: {author_color}; font-weight: bold; font-size: 11px; background: transparent;")
|
||||||
|
bubble_layout.addWidget(author_lbl)
|
||||||
|
|
||||||
|
# Text
|
||||||
|
self.text_lbl = QLabel(text)
|
||||||
|
self.text_lbl.setWordWrap(True)
|
||||||
|
self.text_lbl.setStyleSheet(f"color: {text_color}; font-size: 13px; border: none; background: transparent;")
|
||||||
|
self.text_lbl.setOpenExternalLinks(False)
|
||||||
|
self.text_lbl.linkActivated.connect(on_link_clicked)
|
||||||
|
bubble_layout.addWidget(self.text_lbl)
|
||||||
|
|
||||||
|
# Time
|
||||||
|
time_lbl = QLabel(time_str)
|
||||||
|
time_lbl.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||||
|
time_lbl.setStyleSheet(f"color: {text_color}; font-size: 9px; opacity: 0.6; border: none; background: transparent;")
|
||||||
|
bubble_layout.addWidget(time_lbl)
|
||||||
|
|
||||||
|
if is_self:
|
||||||
|
layout.addStretch()
|
||||||
|
layout.addWidget(self.bubble)
|
||||||
|
else:
|
||||||
|
layout.addWidget(self.bubble)
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
|
||||||
|
class SystemMessageWidget(QWidget):
|
||||||
|
def __init__(self, text):
|
||||||
|
super().__init__()
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 5, 0, 5)
|
||||||
|
lbl = QLabel(text)
|
||||||
|
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
lbl.setWordWrap(True)
|
||||||
|
lbl.setStyleSheet("color: #888; font-style: italic; font-size: 11px; background: transparent;")
|
||||||
|
layout.addWidget(lbl)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatPopoutWindow(QMainWindow):
|
||||||
|
closed = pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Live Chat")
|
||||||
|
self.resize(350, 600)
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
self.closed.emit()
|
||||||
|
super().closeEvent(event)
|
||||||
106
desktop-client/commands.py
Normal file
106
desktop-client/commands.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
from utils import format_time_short
|
||||||
|
|
||||||
|
HELP_TEXT = """
|
||||||
|
<b>Available Commands:</b><br>
|
||||||
|
• <b>/play</b>, <b>/pause</b> - Control playback<br>
|
||||||
|
• <b>/seek [time]</b> - Seek (e.g., 1:23, +30s, -1m)<br>
|
||||||
|
• <b>/tag [now|time] [name]</b> - Create a highlight<br>
|
||||||
|
• <b>/time</b> - Show current timestamp<br>
|
||||||
|
• <b>/help</b> - Show this message<br><br>
|
||||||
|
<b>Keyboard Shortcuts:</b><br>
|
||||||
|
• <b>Space</b>: Play/Pause<br>
|
||||||
|
• <b>F</b>: Fullscreen | <b>M</b>: Mute<br>
|
||||||
|
• <b>Left/Right</b>: Seek ±5s<br>
|
||||||
|
• <b>Up/Down</b>: Volume ±5%<br>
|
||||||
|
• <b>Enter</b>: Focus chat | <b>Esc</b>: Clear focus
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_arg(arg: str, current_ms: int, length_ms: int):
|
||||||
|
"""Parses a time string (absolute time, offset like +30s, or 'now') and returns the target time in MS. Returns None on error."""
|
||||||
|
if arg == "now":
|
||||||
|
return current_ms
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_ms = 0
|
||||||
|
if arg.startswith('+') or arg.startswith('-'):
|
||||||
|
modifier = 1 if arg.startswith('+') else -1
|
||||||
|
num_str = arg[1:]
|
||||||
|
if num_str.endswith('s'): val = float(num_str[:-1]) * 1000
|
||||||
|
elif num_str.endswith('m'): val = float(num_str[:-1]) * 60 * 1000
|
||||||
|
elif num_str.endswith('h'): val = float(num_str[:-1]) * 3600 * 1000
|
||||||
|
else: val = float(num_str) * 1000
|
||||||
|
target_ms = current_ms + (val * modifier)
|
||||||
|
elif ":" in arg:
|
||||||
|
parts = arg.split(":")
|
||||||
|
parts.reverse()
|
||||||
|
if len(parts) > 0: target_ms += float(parts[0]) * 1000
|
||||||
|
if len(parts) > 1: target_ms += float(parts[1]) * 60 * 1000
|
||||||
|
if len(parts) > 2: target_ms += float(parts[2]) * 3600 * 1000
|
||||||
|
else:
|
||||||
|
if arg.endswith('s'): target_ms = float(arg[:-1]) * 1000
|
||||||
|
elif arg.endswith('m'): target_ms = float(arg[:-1]) * 60 * 1000
|
||||||
|
elif arg.endswith('h'): target_ms = float(arg[:-1]) * 3600 * 1000
|
||||||
|
else: target_ms = float(arg) * 1000
|
||||||
|
|
||||||
|
if length_ms > 0:
|
||||||
|
return max(0, min(target_ms, length_ms))
|
||||||
|
return max(0, target_ms)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def handle_chat_command(cmd: str, parts: list, widget):
|
||||||
|
"""Handle a slash command. Returns True if the command was handled."""
|
||||||
|
if cmd == "/play":
|
||||||
|
if not widget.vlc_player.is_playing:
|
||||||
|
widget.toggle_playback()
|
||||||
|
widget.add_system_message(" ".join(parts))
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif cmd == "/pause":
|
||||||
|
if widget.vlc_player.is_playing:
|
||||||
|
widget.toggle_playback()
|
||||||
|
widget.add_system_message(" ".join(parts))
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif cmd == "/seek":
|
||||||
|
if len(parts) > 1:
|
||||||
|
if widget._handle_seek_command(parts[1]):
|
||||||
|
widget.add_system_message(" ".join(parts))
|
||||||
|
else:
|
||||||
|
widget.add_system_message("Invalid time format. Use: 1:23, +30s, -1m")
|
||||||
|
else:
|
||||||
|
widget.add_system_message("Usage: /seek [time]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif cmd == "/tag":
|
||||||
|
if len(parts) > 1:
|
||||||
|
time_arg = parts[1].lower()
|
||||||
|
tag_name = " ".join(parts[2:])
|
||||||
|
|
||||||
|
current_ms = widget.vlc_player.current_time_ms
|
||||||
|
length_ms = widget.vlc_player.get_length()
|
||||||
|
target_ms = parse_time_arg(time_arg, current_ms, length_ms)
|
||||||
|
|
||||||
|
if target_ms is not None:
|
||||||
|
time_str = format_time_short(target_ms)
|
||||||
|
msg = f"{time_str} {tag_name}".strip()
|
||||||
|
widget.chat_message_ready.emit(msg)
|
||||||
|
else:
|
||||||
|
widget.add_system_message("Invalid time format. Use: now, 1:23, +30s, -1m")
|
||||||
|
else:
|
||||||
|
widget.add_system_message("Usage: /tag [now|time] [name]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif cmd == "/time":
|
||||||
|
current_ms = widget.vlc_player.current_time_ms
|
||||||
|
time_str = format_time_short(current_ms)
|
||||||
|
widget.add_system_message(f"Current time: <a href='seek:{time_str}' style='color: #66b3ff; text-decoration: none;'>{time_str}</a>")
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif cmd == "/help":
|
||||||
|
widget.add_system_message(HELP_TEXT)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
48
desktop-client/custom_widgets.py
Normal file
48
desktop-client/custom_widgets.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from PyQt6.QtWidgets import QSlider, QToolTip
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
class ClickableSlider(QSlider):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._get_tooltip_text = None
|
||||||
|
self.setMouseTracking(True)
|
||||||
|
|
||||||
|
def set_tooltip_provider(self, provider_func):
|
||||||
|
self._get_tooltip_text = provider_func
|
||||||
|
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
super().mousePressEvent(event)
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
val = 0
|
||||||
|
if self.orientation() == Qt.Orientation.Horizontal:
|
||||||
|
if self.width() > 0:
|
||||||
|
val = self.minimum() + ((self.maximum() - self.minimum()) * event.pos().x()) / self.width()
|
||||||
|
else:
|
||||||
|
if self.height() > 0:
|
||||||
|
val = self.minimum() + ((self.maximum() - self.minimum()) * (self.height() - event.pos().y())) / self.height()
|
||||||
|
|
||||||
|
val = max(self.minimum(), min(self.maximum(), int(val)))
|
||||||
|
self.setValue(val)
|
||||||
|
self.sliderMoved.emit(val)
|
||||||
|
|
||||||
|
if self._get_tooltip_text:
|
||||||
|
text = self._get_tooltip_text(val)
|
||||||
|
if text:
|
||||||
|
QToolTip.showText(event.globalPosition().toPoint(), text, self)
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event):
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
if self._get_tooltip_text and self.width() > 0:
|
||||||
|
hover_val = self.minimum() + ((self.maximum() - self.minimum()) * event.pos().x()) / self.width()
|
||||||
|
hover_val = max(self.minimum(), min(self.maximum(), int(hover_val)))
|
||||||
|
text = self._get_tooltip_text(hover_val)
|
||||||
|
if text:
|
||||||
|
QToolTip.showText(event.globalPosition().toPoint(), text, self)
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
super().mouseReleaseEvent(event)
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
# Explicitly emit sliderReleased on mouse release
|
||||||
|
# to ensure single clicks on the track also trigger the release logic
|
||||||
|
self.sliderReleased.emit()
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
|
||||||
QFileDialog, QFrame
|
QFileDialog, QFrame, QApplication
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal
|
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):
|
class LobbyWidget(QWidget):
|
||||||
# Signals to communicate to VlcSyncApp
|
# Signals to communicate to VlcSyncApp
|
||||||
create_requested = pyqtSignal(str, str, str, object) # username, path, filename, size
|
create_requested = pyqtSignal(str, str, str, object) # username, path, filename, size
|
||||||
@@ -80,7 +95,7 @@ class LobbyWidget(QWidget):
|
|||||||
# Join Room Panel
|
# Join Room Panel
|
||||||
join_panel = QVBoxLayout()
|
join_panel = QVBoxLayout()
|
||||||
join_panel.addWidget(QLabel("Join a Room"))
|
join_panel.addWidget(QLabel("Join a Room"))
|
||||||
self.room_code_input = QLineEdit()
|
self.room_code_input = RoomCodeInput()
|
||||||
self.room_code_input.setPlaceholderText("e.g. ABC123")
|
self.room_code_input.setPlaceholderText("e.g. ABC123")
|
||||||
self.join_room_btn = QPushButton("Join Room")
|
self.join_room_btn = QPushButton("Join Room")
|
||||||
self.join_room_btn.setObjectName("secondaryBtn")
|
self.join_room_btn.setObjectName("secondaryBtn")
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ class VlcSyncApp(QMainWindow):
|
|||||||
# Network Service
|
# Network Service
|
||||||
self.sync_client = SyncClientThread("wss://video-sync.peterstockings.com/ws")
|
self.sync_client = SyncClientThread("wss://video-sync.peterstockings.com/ws")
|
||||||
self.sync_client.connected.connect(self.on_ws_connected)
|
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(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_joined.connect(self.on_room_joined)
|
||||||
self.sync_client.room_rejoined.connect(self.on_room_rejoined)
|
self.sync_client.room_rejoined.connect(self.on_room_rejoined)
|
||||||
self.sync_client.room_error.connect(self.on_room_error)
|
self.sync_client.room_error.connect(self.on_room_error)
|
||||||
@@ -61,6 +63,7 @@ class VlcSyncApp(QMainWindow):
|
|||||||
self.sync_client.chat_message.connect(self.room_widget.add_chat_message)
|
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.system_message.connect(self.room_widget.add_system_message)
|
||||||
self.sync_client.sync_event.connect(self.room_widget.handle_sync_event)
|
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()
|
self.apply_stylesheet()
|
||||||
|
|
||||||
@@ -187,6 +190,11 @@ class VlcSyncApp(QMainWindow):
|
|||||||
|
|
||||||
self.room_widget.setup_room(self.room_code, self.username, self.local_file_name, self.local_file_path, start_time_s)
|
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", [])
|
users = msg.get("users", [])
|
||||||
if users:
|
if users:
|
||||||
self.room_widget.update_users(users)
|
self.room_widget.update_users(users)
|
||||||
@@ -194,13 +202,17 @@ class VlcSyncApp(QMainWindow):
|
|||||||
if state:
|
if state:
|
||||||
self.room_widget.handle_sync_event(state)
|
self.room_widget.handle_sync_event(state)
|
||||||
|
|
||||||
chat_history = msg.get("chatHistory", [])
|
self.room_widget.add_system_message("Welcome to the room! 👋")
|
||||||
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))
|
|
||||||
|
|
||||||
def on_room_rejoined(self, msg: dict):
|
def on_room_rejoined(self, msg: dict):
|
||||||
self.room_widget.set_room_code_display(self.room_code)
|
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.")
|
self.room_widget.add_system_message("✅ Reconnected to the room.")
|
||||||
|
|
||||||
users = msg.get("users", [])
|
users = msg.get("users", [])
|
||||||
@@ -345,6 +357,75 @@ class VlcSyncApp(QMainWindow):
|
|||||||
background: #333;
|
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 {
|
#chatContainer {
|
||||||
background-color: #111;
|
background-color: #111;
|
||||||
border-left: 1px solid #333;
|
border-left: 1px solid #333;
|
||||||
@@ -364,7 +445,6 @@ class VlcSyncApp(QMainWindow):
|
|||||||
#chatMessages {
|
#chatMessages {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #f1f1f1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#chatMessages QScrollBar:vertical {
|
#chatMessages QScrollBar:vertical {
|
||||||
@@ -374,12 +454,12 @@ class VlcSyncApp(QMainWindow):
|
|||||||
margin: 0px;
|
margin: 0px;
|
||||||
}
|
}
|
||||||
#chatMessages QScrollBar::handle:vertical {
|
#chatMessages QScrollBar::handle:vertical {
|
||||||
background: #555;
|
background: #333;
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
#chatMessages QScrollBar::handle:vertical:hover {
|
#chatMessages QScrollBar::handle:vertical:hover {
|
||||||
background: #666;
|
background: #444;
|
||||||
}
|
}
|
||||||
#chatMessages QScrollBar::add-line:vertical, #chatMessages QScrollBar::sub-line:vertical {
|
#chatMessages QScrollBar::add-line:vertical, #chatMessages QScrollBar::sub-line:vertical {
|
||||||
height: 0px;
|
height: 0px;
|
||||||
@@ -387,6 +467,10 @@ class VlcSyncApp(QMainWindow):
|
|||||||
#chatMessages QScrollBar::add-page:vertical, #chatMessages QScrollBar::sub-page:vertical {
|
#chatMessages QScrollBar::add-page:vertical, #chatMessages QScrollBar::sub-page:vertical {
|
||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#bubble {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import os
|
import os
|
||||||
import datetime
|
import datetime
|
||||||
|
import html
|
||||||
|
import re
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||||
QPushButton, QFrame, QSlider, QTextEdit, QApplication
|
QPushButton, QFrame, QTextEdit, QTextBrowser, QApplication, QSplitter,
|
||||||
|
QScrollArea
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
|
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QEvent
|
||||||
from PyQt6.QtGui import QIcon
|
from PyQt6.QtGui import QIcon, QCursor
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
from vlc_player import VLCSyncPlayer
|
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:
|
class ExpectedVlcEvent:
|
||||||
def __init__(self, action: str, req_id: str, target_val=None):
|
def __init__(self, action: str, req_id: str, target_val=None):
|
||||||
@@ -32,13 +42,26 @@ class RoomWidget(QWidget):
|
|||||||
self.current_users = []
|
self.current_users = []
|
||||||
self._is_first_user_update = True
|
self._is_first_user_update = True
|
||||||
|
|
||||||
|
self.popout_window = None
|
||||||
|
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
|
|
||||||
def _setup_ui(self):
|
def _setup_ui(self):
|
||||||
layout = QHBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(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 ---
|
# --- Left Side: Video ---
|
||||||
video_container = QWidget()
|
video_container = QWidget()
|
||||||
video_layout = QVBoxLayout(video_container)
|
video_layout = QVBoxLayout(video_container)
|
||||||
@@ -61,6 +84,12 @@ class RoomWidget(QWidget):
|
|||||||
self.copy_code_btn.setToolTip("Copy Room Code")
|
self.copy_code_btn.setToolTip("Copy Room Code")
|
||||||
self.copy_code_btn.clicked.connect(self.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 = QLabel("📄 No file")
|
||||||
self.room_file_badge.setObjectName("fileBadge")
|
self.room_file_badge.setObjectName("fileBadge")
|
||||||
|
|
||||||
@@ -70,6 +99,7 @@ class RoomWidget(QWidget):
|
|||||||
|
|
||||||
topbar_layout.addWidget(self.room_code_display)
|
topbar_layout.addWidget(self.room_code_display)
|
||||||
topbar_layout.addWidget(self.copy_code_btn)
|
topbar_layout.addWidget(self.copy_code_btn)
|
||||||
|
topbar_layout.addWidget(self.status_dot)
|
||||||
topbar_layout.addStretch()
|
topbar_layout.addStretch()
|
||||||
topbar_layout.addWidget(self.room_file_badge)
|
topbar_layout.addWidget(self.room_file_badge)
|
||||||
topbar_layout.addWidget(self.leave_btn)
|
topbar_layout.addWidget(self.leave_btn)
|
||||||
@@ -80,33 +110,35 @@ class RoomWidget(QWidget):
|
|||||||
self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
|
self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
|
||||||
|
|
||||||
# Controls Bar
|
# Controls Bar
|
||||||
controls = QFrame()
|
self.controls_bar = QFrame()
|
||||||
controls.setObjectName("controlsBar")
|
self.controls_bar.setObjectName("controlsBar")
|
||||||
controls.setFixedHeight(60)
|
self.controls_bar.setFixedHeight(60)
|
||||||
controls_layout = QHBoxLayout(controls)
|
controls_layout = QHBoxLayout(self.controls_bar)
|
||||||
|
|
||||||
self.play_btn = QPushButton("▶")
|
self.play_btn = QPushButton("▶")
|
||||||
self.play_btn.setFixedSize(40, 40)
|
self.play_btn.setFixedSize(40, 40)
|
||||||
self.play_btn.setObjectName("playBtn")
|
self.play_btn.setObjectName("playBtn")
|
||||||
|
|
||||||
# Time and SeekBar
|
# Time and SeekBar
|
||||||
self.seekbar = QSlider(Qt.Orientation.Horizontal)
|
self.seekbar = ClickableSlider(Qt.Orientation.Horizontal)
|
||||||
self.seekbar.setRange(0, 1000)
|
self.seekbar.setRange(0, 1000)
|
||||||
self.seekbar.setObjectName("seekBar")
|
self.seekbar.setObjectName("seekBar")
|
||||||
self.seekbar.sliderMoved.connect(self.on_seekbar_dragged)
|
self.seekbar.sliderMoved.connect(self.on_seekbar_dragged)
|
||||||
self.seekbar.sliderReleased.connect(self.on_seekbar_released)
|
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")
|
self.time_lbl = QLabel("00:00:00 / 00:00:00")
|
||||||
|
|
||||||
# Volume
|
# Volume
|
||||||
self.vol_icon = QLabel("🔊")
|
self.vol_icon = QLabel("🔊")
|
||||||
self.vol_icon.setObjectName("volIcon")
|
self.vol_icon.setObjectName("volIcon")
|
||||||
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
|
self.volume_slider = ClickableSlider(Qt.Orientation.Horizontal)
|
||||||
self.volume_slider.setRange(0, 100)
|
self.volume_slider.setRange(0, 100)
|
||||||
self.volume_slider.setValue(100)
|
self.volume_slider.setValue(100)
|
||||||
self.volume_slider.setFixedWidth(100)
|
self.volume_slider.setFixedWidth(100)
|
||||||
self.volume_slider.setObjectName("volumeSlider")
|
self.volume_slider.setObjectName("volumeSlider")
|
||||||
self.volume_slider.valueChanged.connect(self.on_volume_changed)
|
self.volume_slider.valueChanged.connect(self.on_volume_changed)
|
||||||
|
self.volume_slider.set_tooltip_provider(lambda v: f"{v}%")
|
||||||
|
|
||||||
# Fullscreen
|
# Fullscreen
|
||||||
self.fullscreen_btn = QPushButton("⛶")
|
self.fullscreen_btn = QPushButton("⛶")
|
||||||
@@ -123,26 +155,103 @@ class RoomWidget(QWidget):
|
|||||||
controls_layout.addSpacing(10)
|
controls_layout.addSpacing(10)
|
||||||
controls_layout.addWidget(self.fullscreen_btn)
|
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.topbar)
|
||||||
video_layout.addWidget(self.video_frame)
|
video_layout.addWidget(self.video_frame)
|
||||||
video_layout.addWidget(controls)
|
video_layout.addWidget(self.controls_bar)
|
||||||
|
|
||||||
# --- Right Side: Chat ---
|
# --- Right Side: Chat ---
|
||||||
self.chat_container = QFrame()
|
self.chat_container = QFrame()
|
||||||
self.chat_container.setObjectName("chatContainer")
|
self.chat_container.setObjectName("chatContainer")
|
||||||
self.chat_container.setFixedWidth(320)
|
self.chat_container.setMinimumWidth(200)
|
||||||
|
self.chat_container.setMaximumWidth(500)
|
||||||
chat_layout = QVBoxLayout(self.chat_container)
|
chat_layout = QVBoxLayout(self.chat_container)
|
||||||
|
|
||||||
chat_header = QLabel("Live Chat")
|
chat_header = QLabel("Live Chat")
|
||||||
chat_header.setObjectName("chatHeader")
|
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 = QLabel("0 watching")
|
||||||
self.users_lbl.setObjectName("usersLbl")
|
self.users_lbl.setObjectName("usersLbl")
|
||||||
|
|
||||||
self.chat_messages = QTextEdit()
|
# 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.setObjectName("chatMessages")
|
||||||
self.chat_messages.setReadOnly(True)
|
self.chat_messages.setStyleSheet("background-color: transparent; border: none;")
|
||||||
self.chat_messages.setHtml("Welcome to the room! 👋")
|
|
||||||
|
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()
|
chat_input_layout = QHBoxLayout()
|
||||||
self.chat_input = QLineEdit()
|
self.chat_input = QLineEdit()
|
||||||
@@ -156,13 +265,29 @@ class RoomWidget(QWidget):
|
|||||||
self.chat_send_btn.clicked.connect(self.send_chat)
|
self.chat_send_btn.clicked.connect(self.send_chat)
|
||||||
self.chat_input.returnPressed.connect(self.send_chat)
|
self.chat_input.returnPressed.connect(self.send_chat)
|
||||||
|
|
||||||
chat_layout.addWidget(chat_header)
|
chat_layout.addLayout(header_layout)
|
||||||
chat_layout.addWidget(self.users_lbl)
|
chat_layout.addWidget(self.users_lbl)
|
||||||
|
chat_layout.addWidget(self.tags_container)
|
||||||
chat_layout.addWidget(self.chat_messages, 1)
|
chat_layout.addWidget(self.chat_messages, 1)
|
||||||
chat_layout.addLayout(chat_input_layout)
|
chat_layout.addLayout(chat_input_layout)
|
||||||
|
|
||||||
layout.addWidget(video_container, 1)
|
self.splitter.addWidget(video_container)
|
||||||
layout.addWidget(self.chat_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
|
# Instantiate the VLC Player Wrapper
|
||||||
self.vlc_player = VLCSyncPlayer(self.video_frame)
|
self.vlc_player = VLCSyncPlayer(self.video_frame)
|
||||||
@@ -172,12 +297,36 @@ class RoomWidget(QWidget):
|
|||||||
|
|
||||||
self.play_btn.clicked.connect(self.toggle_playback)
|
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):
|
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.room_code = room_code
|
||||||
self.username = username
|
self.username = username
|
||||||
self.set_room_code_display(room_code)
|
self.set_room_code_display(room_code)
|
||||||
self.room_file_badge.setText(f"📄 {file_name}")
|
self.room_file_badge.setText(f"📄 {file_name}")
|
||||||
self.chat_messages.setHtml("Welcome to the room! 👋")
|
|
||||||
|
# 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.current_users = []
|
||||||
self._is_first_user_update = True
|
self._is_first_user_update = True
|
||||||
|
|
||||||
@@ -193,6 +342,88 @@ class RoomWidget(QWidget):
|
|||||||
def set_room_code_display(self, text: str):
|
def set_room_code_display(self, text: str):
|
||||||
self.room_code_display.setText(f"Room: {text}")
|
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):
|
def toggle_fullscreen(self):
|
||||||
top_window = self.window()
|
top_window = self.window()
|
||||||
if top_window.isFullScreen():
|
if top_window.isFullScreen():
|
||||||
@@ -200,11 +431,110 @@ class RoomWidget(QWidget):
|
|||||||
self.fullscreen_btn.setText("⛶")
|
self.fullscreen_btn.setText("⛶")
|
||||||
self.chat_container.show()
|
self.chat_container.show()
|
||||||
self.topbar.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:
|
else:
|
||||||
top_window.showFullScreen()
|
top_window.showFullScreen()
|
||||||
self.fullscreen_btn.setText("🗗")
|
self.fullscreen_btn.setText("🗗")
|
||||||
self.chat_container.hide()
|
self.chat_container.hide()
|
||||||
self.topbar.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):
|
def copy_room_code(self):
|
||||||
if self.room_code:
|
if self.room_code:
|
||||||
@@ -260,12 +590,8 @@ class RoomWidget(QWidget):
|
|||||||
def on_vlc_time(self, time_ms: int):
|
def on_vlc_time(self, time_ms: int):
|
||||||
length_ms = self.vlc_player.get_length()
|
length_ms = self.vlc_player.get_length()
|
||||||
if length_ms > 0:
|
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():
|
if not self.seekbar.isSliderDown():
|
||||||
self.time_lbl.setText(f"{fmt(time_ms)} / {fmt(length_ms)}")
|
self.time_lbl.setText(f"{format_time_hms(time_ms)} / {format_time_hms(length_ms)}")
|
||||||
progress = int((time_ms / length_ms) * 1000)
|
progress = int((time_ms / length_ms) * 1000)
|
||||||
self.seekbar.blockSignals(True)
|
self.seekbar.blockSignals(True)
|
||||||
self.seekbar.setValue(progress)
|
self.seekbar.setValue(progress)
|
||||||
@@ -309,10 +635,9 @@ class RoomWidget(QWidget):
|
|||||||
length_ms = self.vlc_player.get_length()
|
length_ms = self.vlc_player.get_length()
|
||||||
if length_ms > 0:
|
if length_ms > 0:
|
||||||
target_ms = int((value / 1000.0) * length_ms)
|
target_ms = int((value / 1000.0) * length_ms)
|
||||||
def fmt(ms):
|
self.time_lbl.setText(f"{format_time_hms(target_ms)} / {format_time_hms(length_ms)}")
|
||||||
s = max(0, ms) // 1000
|
# Scrub the video locally in real-time
|
||||||
return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
|
self._tell_vlc_and_expect("seek", target_ms / 1000.0)
|
||||||
self.time_lbl.setText(f"{fmt(target_ms)} / {fmt(length_ms)}")
|
|
||||||
|
|
||||||
def on_seekbar_released(self):
|
def on_seekbar_released(self):
|
||||||
length_ms = self.vlc_player.get_length()
|
length_ms = self.vlc_player.get_length()
|
||||||
@@ -323,6 +648,17 @@ class RoomWidget(QWidget):
|
|||||||
|
|
||||||
def on_volume_changed(self, value):
|
def on_volume_changed(self, value):
|
||||||
self.vlc_player.set_volume(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 ---
|
# --- Incoming Sync Logic ---
|
||||||
def handle_sync_event(self, msg: dict):
|
def handle_sync_event(self, msg: dict):
|
||||||
@@ -341,8 +677,7 @@ class RoomWidget(QWidget):
|
|||||||
if action == "play": self.add_system_message(f"{username} pressed play")
|
if action == "play": self.add_system_message(f"{username} pressed play")
|
||||||
elif action == "pause": self.add_system_message(f"{username} paused")
|
elif action == "pause": self.add_system_message(f"{username} paused")
|
||||||
elif action == "seek":
|
elif action == "seek":
|
||||||
def fmt(s): return f"{int(s)//3600:02d}:{(int(s)%3600)//60:02d}:{int(s)%60:02d}"
|
self.add_system_message(f"{username} seeked to {format_time_hms(int(position_s * 1000))}")
|
||||||
self.add_system_message(f"{username} seeked to {fmt(position_s)}")
|
|
||||||
|
|
||||||
def update_users(self, users: list):
|
def update_users(self, users: list):
|
||||||
if self._is_first_user_update:
|
if self._is_first_user_update:
|
||||||
@@ -362,18 +697,95 @@ class RoomWidget(QWidget):
|
|||||||
self.current_users = users
|
self.current_users = users
|
||||||
self.users_lbl.setText(f"{len(users)} watching: {', '.join(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):
|
def add_chat_message(self, author: str, text: str, timestamp: int):
|
||||||
try:
|
try:
|
||||||
dt = datetime.datetime.fromtimestamp(timestamp / 1000.0)
|
dt = datetime.datetime.fromtimestamp(timestamp / 1000.0)
|
||||||
except (ValueError, OSError, TypeError):
|
except (ValueError, OSError, TypeError):
|
||||||
dt = datetime.datetime.now()
|
dt = datetime.datetime.now()
|
||||||
time_str = dt.strftime("%I:%M %p")
|
time_str = dt.strftime("%I:%M %p")
|
||||||
new_msg = f"<b>{author}</b>: {text} <span style='color: gray; font-size: 10px;'>{time_str}</span>"
|
|
||||||
self.chat_messages.append(new_msg)
|
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):
|
def add_system_message(self, text: str):
|
||||||
new_msg = f"<i style='color: #888;'>{text}</i>"
|
msg = SystemMessageWidget(text)
|
||||||
self.chat_messages.append(new_msg)
|
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):
|
def send_chat(self):
|
||||||
text = self.chat_input.text().strip()
|
text = self.chat_input.text().strip()
|
||||||
@@ -383,68 +795,24 @@ class RoomWidget(QWidget):
|
|||||||
if text.startswith("/"):
|
if text.startswith("/"):
|
||||||
parts = text.split()
|
parts = text.split()
|
||||||
cmd = parts[0].lower()
|
cmd = parts[0].lower()
|
||||||
|
self.chat_input.setText("")
|
||||||
if cmd == "/play":
|
if handle_chat_command(cmd, parts, self):
|
||||||
self.chat_input.setText("")
|
|
||||||
if not self.vlc_player.is_playing:
|
|
||||||
self.toggle_playback()
|
|
||||||
self.add_system_message(text)
|
|
||||||
return
|
|
||||||
elif cmd == "/pause":
|
|
||||||
self.chat_input.setText("")
|
|
||||||
if self.vlc_player.is_playing:
|
|
||||||
self.toggle_playback()
|
|
||||||
self.add_system_message(text)
|
|
||||||
return
|
|
||||||
elif cmd == "/seek":
|
|
||||||
self.chat_input.setText("")
|
|
||||||
if len(parts) > 1:
|
|
||||||
if self._handle_seek_command(parts[1]):
|
|
||||||
self.add_system_message(text)
|
|
||||||
else:
|
|
||||||
self.add_system_message("Invalid time format. Use: 1:23, +30s, -1m")
|
|
||||||
else:
|
|
||||||
self.add_system_message("Usage: /seek [time]")
|
|
||||||
return
|
|
||||||
elif cmd == "/help":
|
|
||||||
self.chat_input.setText("")
|
|
||||||
self.add_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
|
return
|
||||||
|
|
||||||
self.chat_message_ready.emit(text)
|
self.chat_message_ready.emit(text)
|
||||||
self.chat_input.setText("")
|
self.chat_input.setText("")
|
||||||
|
|
||||||
def _handle_seek_command(self, arg: str) -> bool:
|
def _parse_time_arg(self, arg: str):
|
||||||
|
"""Delegates to the standalone parse_time_arg function."""
|
||||||
current_ms = self.vlc_player.current_time_ms
|
current_ms = self.vlc_player.current_time_ms
|
||||||
length_ms = self.vlc_player.get_length()
|
length_ms = self.vlc_player.get_length()
|
||||||
if length_ms <= 0:
|
return parse_time_arg(arg, current_ms, length_ms)
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
def _handle_seek_command(self, arg: str) -> bool:
|
||||||
target_ms = 0
|
target_ms = self._parse_time_arg(arg)
|
||||||
if arg.startswith('+') or arg.startswith('-'):
|
if target_ms is not None:
|
||||||
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
|
|
||||||
|
|
||||||
target_ms = max(0, min(target_ms, length_ms))
|
|
||||||
req = self._tell_vlc_and_expect("seek", target_ms / 1000.0)
|
req = self._tell_vlc_and_expect("seek", target_ms / 1000.0)
|
||||||
self.sync_action_requested.emit("seek", target_ms / 1000.0, req)
|
self.sync_action_requested.emit("seek", target_ms / 1000.0, req)
|
||||||
return True
|
return True
|
||||||
except ValueError:
|
return False
|
||||||
return False
|
|
||||||
|
|||||||
25
desktop-client/run-videosync.sh
Normal file
25
desktop-client/run-videosync.sh
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# VideoSync Desktop Client Launcher
|
||||||
|
# Fetches the latest version from the repo and runs the desktop client.
|
||||||
|
# Prerequisites: git, uv
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO_URL="https://gitea.peterstockings.com/peterstockings/video-sync.git"
|
||||||
|
INSTALL_DIR="$HOME/.videosync"
|
||||||
|
|
||||||
|
echo "🎬 VideoSync Desktop Client Launcher"
|
||||||
|
echo "======================================"
|
||||||
|
|
||||||
|
# Clone or pull the latest version
|
||||||
|
if [ -d "$INSTALL_DIR" ]; then
|
||||||
|
echo "📥 Pulling latest changes..."
|
||||||
|
git -C "$INSTALL_DIR" pull --ff-only
|
||||||
|
else
|
||||||
|
echo "📥 Cloning repository..."
|
||||||
|
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 Launching VideoSync..."
|
||||||
|
cd "$INSTALL_DIR/desktop-client"
|
||||||
|
uv run main.py
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
import websockets
|
import websockets
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ class SyncClientThread(QThread):
|
|||||||
chat_message = pyqtSignal(str, str, int) # author, text, timestamp
|
chat_message = pyqtSignal(str, str, int) # author, text, timestamp
|
||||||
system_message = pyqtSignal(str)
|
system_message = pyqtSignal(str)
|
||||||
sync_event = pyqtSignal(dict)
|
sync_event = pyqtSignal(dict)
|
||||||
|
latency_updated = pyqtSignal(int) # latency in ms
|
||||||
|
|
||||||
def __init__(self, url="ws://localhost:3000/ws"):
|
def __init__(self, url="ws://localhost:3000/ws"):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -22,6 +24,7 @@ class SyncClientThread(QThread):
|
|||||||
self.ws = None
|
self.ws = None
|
||||||
self.loop = None
|
self.loop = None
|
||||||
self.running = False
|
self.running = False
|
||||||
|
self._ping_sent_at = 0
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Runs strictly within the newly created QThread"""
|
"""Runs strictly within the newly created QThread"""
|
||||||
@@ -44,6 +47,19 @@ class SyncClientThread(QThread):
|
|||||||
json_str = json.dumps(message)
|
json_str = json.dumps(message)
|
||||||
asyncio.run_coroutine_threadsafe(self.ws.send(json_str), self.loop)
|
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):
|
async def _connect_and_listen(self):
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
@@ -51,6 +67,7 @@ class SyncClientThread(QThread):
|
|||||||
self.ws = ws
|
self.ws = ws
|
||||||
self.connected.emit()
|
self.connected.emit()
|
||||||
|
|
||||||
|
ping_task = asyncio.create_task(self._ping_loop(ws))
|
||||||
try:
|
try:
|
||||||
async for message in ws:
|
async for message in ws:
|
||||||
if not self.running:
|
if not self.running:
|
||||||
@@ -58,6 +75,8 @@ class SyncClientThread(QThread):
|
|||||||
self._handle_message(json.loads(message))
|
self._handle_message(json.loads(message))
|
||||||
except websockets.ConnectionClosed:
|
except websockets.ConnectionClosed:
|
||||||
pass
|
pass
|
||||||
|
finally:
|
||||||
|
ping_task.cancel()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"WebSocket Error: {e}")
|
print(f"WebSocket Error: {e}")
|
||||||
|
|||||||
11
desktop-client/utils.py
Normal file
11
desktop-client/utils.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
def format_time_hms(ms: int) -> str:
|
||||||
|
"""Format milliseconds as HH:MM:SS."""
|
||||||
|
s = max(0, ms) // 1000
|
||||||
|
return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}"
|
||||||
|
|
||||||
|
def format_time_short(ms: int) -> str:
|
||||||
|
"""Format milliseconds as MM:SS or HH:MM:SS if >= 1 hour."""
|
||||||
|
s = int(max(0, ms) // 1000)
|
||||||
|
if s >= 3600:
|
||||||
|
return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}"
|
||||||
|
return f"{(s % 3600) // 60:02d}:{s % 60:02d}"
|
||||||
@@ -16,7 +16,8 @@ class VLCSyncPlayer:
|
|||||||
# Initialize VLC instance
|
# Initialize VLC instance
|
||||||
# --no-xlib prevents crashes on Linux
|
# --no-xlib prevents crashes on Linux
|
||||||
# --drop-late-frames improves sync by not delaying playback when CPU is slow
|
# --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()
|
self.media_player = self.instance.media_player_new()
|
||||||
|
|
||||||
# Embed the VLC player into the provided PyQt QFrame
|
# Embed the VLC player into the provided PyQt QFrame
|
||||||
|
|||||||
@@ -235,6 +235,10 @@
|
|||||||
// Seamless reconnection — sync to server state
|
// Seamless reconnection — sync to server state
|
||||||
isReconnecting = false;
|
isReconnecting = false;
|
||||||
showConnectionStatus("reconnected");
|
showConnectionStatus("reconnected");
|
||||||
|
if (msg.chatHistory) {
|
||||||
|
chatMessages.innerHTML = "";
|
||||||
|
msg.chatHistory.forEach((m) => addChatMessage(m.username, m.message, m.timestamp));
|
||||||
|
}
|
||||||
updateUsers(msg.users);
|
updateUsers(msg.users);
|
||||||
if (msg.state) {
|
if (msg.state) {
|
||||||
applySync(msg.state);
|
applySync(msg.state);
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ const server = Bun.serve<WSData>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
|
case "ping": {
|
||||||
|
ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "create_room": {
|
case "create_room": {
|
||||||
const username = (msg.username || "").trim();
|
const username = (msg.username || "").trim();
|
||||||
if (!username) {
|
if (!username) {
|
||||||
|
|||||||
Reference in New Issue
Block a user