diff --git a/desktop-client/chat_widgets.py b/desktop-client/chat_widgets.py
new file mode 100644
index 0000000..48b97af
--- /dev/null
+++ b/desktop-client/chat_widgets.py
@@ -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)
diff --git a/desktop-client/commands.py b/desktop-client/commands.py
new file mode 100644
index 0000000..78be26f
--- /dev/null
+++ b/desktop-client/commands.py
@@ -0,0 +1,106 @@
+from utils import format_time_short
+
+HELP_TEXT = """
+Available Commands:
+• /play, /pause - Control playback
+• /seek [time] - Seek (e.g., 1:23, +30s, -1m)
+• /tag [now|time] [name] - Create a highlight
+• /time - Show current timestamp
+• /help - Show this message
+Keyboard Shortcuts:
+• Space: Play/Pause
+• F: Fullscreen | M: Mute
+• Left/Right: Seek ±5s
+• Up/Down: Volume ±5%
+• Enter: Focus chat | Esc: 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: {time_str}")
+ return True
+
+ elif cmd == "/help":
+ widget.add_system_message(HELP_TEXT)
+ return True
+
+ return False
diff --git a/desktop-client/custom_widgets.py b/desktop-client/custom_widgets.py
new file mode 100644
index 0000000..e236e4a
--- /dev/null
+++ b/desktop-client/custom_widgets.py
@@ -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()
diff --git a/desktop-client/room_widget.py b/desktop-client/room_widget.py
index bd8b99d..a93c8ad 100644
--- a/desktop-client/room_widget.py
+++ b/desktop-client/room_widget.py
@@ -4,8 +4,8 @@ import html
import re
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
- QPushButton, QFrame, QSlider, QTextEdit, QTextBrowser, QApplication, QToolTip, QSplitter,
- QMainWindow, QScrollArea
+ QPushButton, QFrame, QTextEdit, QTextBrowser, QApplication, QSplitter,
+ QScrollArea
)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QEvent
from PyQt6.QtGui import QIcon, QCursor
@@ -14,137 +14,11 @@ 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
-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}"
-
-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)
-
-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):
@@ -921,112 +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:
- time_str = format_time_short(target_ms)
-
- 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
- time_str = format_time_short(current_ms)
- self.add_system_message(f"Current time: {time_str}")
- return
- elif cmd == "/help":
- self.chat_input.setText("")
- help_text = """
- Available Commands:
- • /play, /pause - Control playback
- • /seek [time] - Seek (e.g., 1:23, +30s, -1m)
- • /tag [now|time] [name] - Create a highlight
- • /time - Show current timestamp
- • /help - Show this message
- Keyboard Shortcuts:
- • Space: Play/Pause
- • F: Fullscreen | M: Mute
- • Left/Right: Seek ±5s
- • Up/Down: Volume ±5%
- • Enter: Focus chat | Esc: Clear focus
- """
- self.add_system_message(help_text)
+ 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):
- """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
length_ms = self.vlc_player.get_length()
- # Don't strictly block on length_ms > 0 here, as we might be seeking before length is known
- # or length might be reported as 0 for some streams.
-
- 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
+ return parse_time_arg(arg, current_ms, length_ms)
def _handle_seek_command(self, arg: str) -> bool:
target_ms = self._parse_time_arg(arg)
@@ -1035,3 +815,4 @@ class RoomWidget(QWidget):
self.sync_action_requested.emit("seek", target_ms / 1000.0, req)
return True
return False
+
diff --git a/desktop-client/utils.py b/desktop-client/utils.py
new file mode 100644
index 0000000..be41d69
--- /dev/null
+++ b/desktop-client/utils.py
@@ -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}"