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

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,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)
@@ -197,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", [])
@@ -436,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 {
@@ -446,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;
@@ -459,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__":

View File

@@ -4,59 +4,21 @@ import html
import re import re
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, 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 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 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: class ExpectedVlcEvent:
def __init__(self, action: str, req_id: str, target_val=None): def __init__(self, action: str, req_id: str, target_val=None):
@@ -80,6 +42,8 @@ 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):
@@ -213,6 +177,17 @@ class RoomWidget(QWidget):
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")
@@ -261,12 +236,22 @@ class RoomWidget(QWidget):
tags_layout.addWidget(self.tags_header_btn) tags_layout.addWidget(self.tags_header_btn)
tags_layout.addWidget(self.tags_list) 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.setObjectName("chatMessages")
self.chat_messages.setOpenExternalLinks(False) self.chat_messages.setStyleSheet("background-color: transparent; border: none;")
self.chat_messages.setOpenLinks(False)
self.chat_messages.anchorClicked.connect(self.on_chat_link_clicked) self.chat_content = QWidget()
self.chat_messages.setHtml("Welcome to the room! 👋") 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()
@@ -280,7 +265,7 @@ 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.tags_container)
chat_layout.addWidget(self.chat_messages, 1) chat_layout.addWidget(self.chat_messages, 1)
@@ -296,10 +281,11 @@ class RoomWidget(QWidget):
# Prevent UI components from stealing focus (which breaks spacebar shortcuts) # 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, 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) 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.chat_messages.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -327,8 +313,7 @@ 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)
s = max(0, target_ms) // 1000 return format_time_hms(target_ms)
return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
return "" 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):
@@ -336,7 +321,12 @@ class RoomWidget(QWidget):
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
@@ -381,6 +371,11 @@ class RoomWidget(QWidget):
self.tags_header_btn.setText("▶ Highlights") self.tags_header_btn.setText("▶ Highlights")
def toggle_chat(self): def toggle_chat(self):
if self.popout_window:
self.popout_window.activateWindow()
self.popout_window.raise_()
return
if self.chat_container.isVisible(): if self.chat_container.isVisible():
self._saved_chat_width = self.chat_container.width() self._saved_chat_width = self.chat_container.width()
self.chat_container.hide() self.chat_container.hide()
@@ -390,6 +385,45 @@ class RoomWidget(QWidget):
sizes = self.splitter.sizes() sizes = self.splitter.sizes()
self.splitter.setSizes([sizes[0] - w, w]) 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():
@@ -556,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)
@@ -605,10 +635,7 @@ 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
return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
self.time_lbl.setText(f"{fmt(target_ms)} / {fmt(length_ms)}")
# Scrub the video locally in real-time # Scrub the video locally in real-time
self._tell_vlc_and_expect("seek", target_ms / 1000.0) 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") 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:
@@ -673,9 +699,21 @@ class RoomWidget(QWidget):
def on_chat_link_clicked(self, url): def on_chat_link_clicked(self, url):
link = url.toString() link = url.toString()
print(f"DEBUG: Link clicked: {link}")
if link.startswith("seek:"): if link.startswith("seek:"):
time_str = link[5:] # Format: 'seek:MM:SS?tag=TagName'
self._handle_seek_command(time_str) 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:
@@ -691,27 +729,63 @@ class RoomWidget(QWidget):
pattern = r'\b(\d{1,2}:\d{2}(?::\d{2})?)\b' 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) 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>" is_self = author == self.username
self.chat_messages.append(new_msg)
# Auto-extract tags added by users directly via /tag command # Auto-extract tags added by users directly via /tag command
# This will catch messages that look like purely a tag command # We look for the pattern generated by /tag: "MM:SS tag text"
# (Since we just append the output of /tag to chat) # Since we just emit the text for /tag, it won't be linked yet in the raw text
match = re.match(r'^<a href="seek:(\d{1,2}:\d{2}(?::\d{2})?)[^>]+>([^<]+)</a>\s+(.+)$', linked_text) tag_match = re.match(r'^(\d{1,2}:\d{2}(?::\d{2})?)\s+(.+)$', text)
if match: if tag_match:
# It's a tag! Add it to the highlights panel self.add_highlight(tag_match.group(1), tag_match.group(2), author)
self.add_highlight(match.group(1), match.group(3), safe_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): def add_highlight(self, time_str: str, tag_text: str, author: str):
if self.tags_container.isHidden(): if self.tags_container.isHidden():
self.tags_container.show() 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) 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()
@@ -721,100 +795,18 @@ 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 == "/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")
return return
self.chat_message_ready.emit(text) self.chat_message_ready.emit(text)
self.chat_input.setText("") self.chat_input.setText("")
def _parse_time_arg(self, arg: str) -> float | None: def _parse_time_arg(self, arg: str):
"""Parses a time string (absolute time, offset like +30s, or 'now') and returns the target time in MS. Returns None on error.""" """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 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
def _handle_seek_command(self, arg: str) -> bool: def _handle_seek_command(self, arg: str) -> bool:
target_ms = self._parse_time_arg(arg) 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) self.sync_action_requested.emit("seek", target_ms / 1000.0, req)
return True return True
return False 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 // 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);