Compare commits

...

7 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
9 changed files with 471 additions and 177 deletions

View File

@@ -57,6 +57,19 @@ 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

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

@@ -190,22 +190,31 @@ class VlcSyncApp(QMainWindow):
self.room_widget.setup_room(self.room_code, self.username, self.local_file_name, self.local_file_path, start_time_s)
chat_history = msg.get("chatHistory", [])
if chat_history:
for chat in chat_history:
self.room_widget.add_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0))
users = msg.get("users", [])
if users:
self.room_widget.update_users(users)
if state:
self.room_widget.handle_sync_event(state)
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))
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)
self.room_widget.add_system_message("✅ Reconnected to the room.")
chat_history = msg.get("chatHistory", [])
if chat_history:
self.room_widget.clear_chat()
for chat in chat_history:
self.room_widget.add_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0))
self.room_widget.add_system_message("✅ Reconnected to the room.")
users = msg.get("users", [])
if users:
self.room_widget.update_users(users)
@@ -436,7 +445,6 @@ class VlcSyncApp(QMainWindow):
#chatMessages {
background-color: transparent;
border: none;
color: #f1f1f1;
}
#chatMessages QScrollBar:vertical {
@@ -446,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;
@@ -459,6 +467,10 @@ class VlcSyncApp(QMainWindow):
#chatMessages QScrollBar::add-page:vertical, #chatMessages QScrollBar::sub-page:vertical {
background: none;
}
#bubble {
border: none;
}
""")
if __name__ == "__main__":

View File

@@ -4,59 +4,21 @@ import html
import re
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QFrame, QSlider, QTextEdit, QTextBrowser, QApplication, QToolTip, QSplitter
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, 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 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()
class ExpectedVlcEvent:
def __init__(self, action: str, req_id: str, target_val=None):
@@ -80,6 +42,8 @@ class RoomWidget(QWidget):
self.current_users = []
self._is_first_user_update = True
self.popout_window = None
self._setup_ui()
def _setup_ui(self):
@@ -213,6 +177,17 @@ class RoomWidget(QWidget):
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")
@@ -261,12 +236,22 @@ class RoomWidget(QWidget):
tags_layout.addWidget(self.tags_header_btn)
tags_layout.addWidget(self.tags_list)
self.chat_messages = QTextBrowser()
self.chat_messages = QScrollArea()
self.chat_messages.setWidgetResizable(True)
self.chat_messages.setObjectName("chatMessages")
self.chat_messages.setOpenExternalLinks(False)
self.chat_messages.setOpenLinks(False)
self.chat_messages.anchorClicked.connect(self.on_chat_link_clicked)
self.chat_messages.setHtml("Welcome to the room! 👋")
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()
@@ -280,7 +265,7 @@ class RoomWidget(QWidget):
self.chat_send_btn.clicked.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.tags_container)
chat_layout.addWidget(self.chat_messages, 1)
@@ -296,10 +281,11 @@ class RoomWidget(QWidget):
# 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.chat_send_btn, self.seekbar, self.volume_slider, self.tags_header_btn,
self.chat_toggle_btn, self.popout_btn]:
w.setFocusPolicy(Qt.FocusPolicy.NoFocus)
# specifically for chat_messages, we allow clicking links, but avoid focus stealing
# scroll area handles focus properly
self.chat_messages.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -327,8 +313,7 @@ class RoomWidget(QWidget):
length_ms = self.vlc_player.get_length()
if length_ms > 0:
target_ms = int((value / 1000.0) * length_ms)
s = max(0, target_ms) // 1000
return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
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):
@@ -336,7 +321,12 @@ class RoomWidget(QWidget):
self.username = username
self.set_room_code_display(room_code)
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._is_first_user_update = True
@@ -381,6 +371,11 @@ class RoomWidget(QWidget):
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()
@@ -390,6 +385,45 @@ class RoomWidget(QWidget):
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():
@@ -556,12 +590,8 @@ class RoomWidget(QWidget):
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)}")
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)
@@ -605,10 +635,7 @@ class RoomWidget(QWidget):
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)}")
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)
@@ -650,8 +677,7 @@ class RoomWidget(QWidget):
if action == "play": self.add_system_message(f"{username} pressed play")
elif action == "pause": self.add_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.add_system_message(f"{username} seeked to {fmt(position_s)}")
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:
@@ -673,9 +699,21 @@ class RoomWidget(QWidget):
def on_chat_link_clicked(self, url):
link = url.toString()
print(f"DEBUG: Link clicked: {link}")
if link.startswith("seek:"):
time_str = link[5:]
self._handle_seek_command(time_str)
# 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:
@@ -691,27 +729,63 @@ class RoomWidget(QWidget):
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)
new_msg = f"<b>{safe_author}</b>: {linked_text} <span style='color: gray; font-size: 10px;'>{time_str}</span>"
self.chat_messages.append(new_msg)
is_self = author == self.username
# Auto-extract tags added by users directly via /tag command
# This will catch messages that look like purely a tag command
# (Since we just append the output of /tag to chat)
match = re.match(r'^<a href="seek:(\d{1,2}:\d{2}(?::\d{2})?)[^>]+>([^<]+)</a>\s+(.+)$', linked_text)
if match:
# It's a tag! Add it to the highlights panel
self.add_highlight(match.group(1), match.group(3), safe_author)
# 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()
highlight_html = f"• <a href='seek:{time_str}' style='color: #66b3ff; text-decoration: none;'>[{time_str}]</a> {tag_text} <span style='color: #666; font-size: 10px;'>- {author}</span>"
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):
new_msg = f"<i style='color: #888;'>{text}</i>"
self.chat_messages.append(new_msg)
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()
@@ -721,100 +795,18 @@ class RoomWidget(QWidget):
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.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 == "/tag":
self.chat_input.setText("")
if len(parts) > 1:
time_arg = parts[1].lower()
tag_name = " ".join(parts[2:])
target_ms = self._parse_time_arg(time_arg)
if target_ms is not None:
s = int(max(0, target_ms) // 1000)
if s >= 3600: time_str = f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
else: time_str = f"{(s%3600)//60:02d}:{s%60:02d}"
msg = f"{time_str} {tag_name}".strip()
self.chat_message_ready.emit(msg)
else:
self.add_system_message("Invalid time format. Use: now, 1:23, +30s, -1m")
else:
self.add_system_message("Usage: /tag [now|time] [name]")
return
elif cmd == "/time":
self.chat_input.setText("")
current_ms = self.vlc_player.current_time_ms
s = max(0, current_ms) // 1000
if s >= 3600: time_str = f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
else: time_str = f"{(s%3600)//60:02d}:{s%60:02d}"
self.add_system_message(f"Current time: <a href='seek:{time_str}' style='color: #66b3ff; text-decoration: none;'>{time_str}</a>")
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<br><b>/tag [now|time] [name]</b> - Tag a timestamp<br><b>/time</b> - Show current time<br><b>/help</b> - Show this message")
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) -> float | None:
"""Parses a time string (absolute time, offset like +30s, or 'now') and returns the target time in MS. Returns None on error."""
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()
if length_ms <= 0:
return None
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
return max(0, min(target_ms, length_ms))
except ValueError:
return None
return parse_time_arg(arg, current_ms, length_ms)
def _handle_seek_command(self, arg: str) -> bool:
target_ms = self._parse_time_arg(arg)
@@ -823,3 +815,4 @@ class RoomWidget(QWidget):
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

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

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