From 66342706dba753cb2cc784f25e0aec2efe60c64c Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 9 Mar 2026 20:58:14 +1100 Subject: [PATCH] Split out components into new files --- desktop-client/chat_widgets.py | 82 +++++++++++ desktop-client/commands.py | 106 ++++++++++++++ desktop-client/custom_widgets.py | 48 ++++++ desktop-client/room_widget.py | 241 ++----------------------------- desktop-client/utils.py | 11 ++ 5 files changed, 258 insertions(+), 230 deletions(-) create mode 100644 desktop-client/chat_widgets.py create mode 100644 desktop-client/commands.py create mode 100644 desktop-client/custom_widgets.py create mode 100644 desktop-client/utils.py 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}"