import os import datetime import html import re from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFrame, QTextEdit, QTextBrowser, QApplication, QSplitter, QScrollArea ) 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 ExpectedVlcEvent: def __init__(self, action: str, req_id: str, target_val=None): self.action = action self.req_id = req_id self.target_val = target_val self.timestamp = datetime.datetime.now() class RoomWidget(QWidget): leave_requested = pyqtSignal() sync_action_requested = pyqtSignal(str, float, str) # action, position_s, req_id chat_message_ready = pyqtSignal(str) # raw message text def __init__(self): super().__init__() self.username = "" self.room_code = "" self.expected_vlc_events = [] self.last_reported_time_ms = 0 self.current_users = [] self._is_first_user_update = True self.popout_window = None self._setup_ui() def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.splitter = QSplitter(Qt.Orientation.Horizontal) self.splitter.setHandleWidth(3) self.splitter.setStyleSheet(""" QSplitter::handle { background-color: #333; } QSplitter::handle:hover { background-color: #3ea6ff; } """) # --- Left Side: Video --- video_container = QWidget() video_layout = QVBoxLayout(video_container) video_layout.setContentsMargins(0, 0, 0, 0) video_layout.setSpacing(0) # Topbar self.topbar = QFrame() self.topbar.setObjectName("topbar") self.topbar.setFixedHeight(50) topbar_layout = QHBoxLayout(self.topbar) self.room_code_display = QLabel("Room: XXXX") self.copy_code_btn = QPushButton() self.copy_code_btn.setObjectName("iconBtn") self.copy_icon = QIcon(os.path.join(os.path.dirname(__file__), "copy.svg")) self.check_icon = QIcon(os.path.join(os.path.dirname(__file__), "check.svg")) self.copy_code_btn.setIcon(self.copy_icon) self.copy_code_btn.setFixedSize(30, 30) self.copy_code_btn.setToolTip("Copy Room Code") self.copy_code_btn.clicked.connect(self.copy_room_code) self.status_dot = QLabel("●") self.status_dot.setFixedWidth(20) self.status_dot.setStyleSheet("color: #888; font-size: 14px; background: transparent;") self.status_dot.setToolTip("Connecting...") self._latency_ms = None self.room_file_badge = QLabel("📄 No file") self.room_file_badge.setObjectName("fileBadge") self.leave_btn = QPushButton("Leave Room") self.leave_btn.setObjectName("dangerBtn") self.leave_btn.clicked.connect(self.leave_requested.emit) topbar_layout.addWidget(self.room_code_display) topbar_layout.addWidget(self.copy_code_btn) topbar_layout.addWidget(self.status_dot) topbar_layout.addStretch() topbar_layout.addWidget(self.room_file_badge) topbar_layout.addWidget(self.leave_btn) # Video Frame Placeholder self.video_frame = QFrame() self.video_frame.setStyleSheet("background-color: black;") self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True) # Controls Bar self.controls_bar = QFrame() self.controls_bar.setObjectName("controlsBar") self.controls_bar.setFixedHeight(60) controls_layout = QHBoxLayout(self.controls_bar) self.play_btn = QPushButton("▶") self.play_btn.setFixedSize(40, 40) self.play_btn.setObjectName("playBtn") # Time and SeekBar self.seekbar = ClickableSlider(Qt.Orientation.Horizontal) self.seekbar.setRange(0, 1000) self.seekbar.setObjectName("seekBar") self.seekbar.sliderMoved.connect(self.on_seekbar_dragged) self.seekbar.sliderReleased.connect(self.on_seekbar_released) self.seekbar.set_tooltip_provider(self.get_seekbar_tooltip) self.time_lbl = QLabel("00:00:00 / 00:00:00") # Volume self.vol_icon = QLabel("🔊") self.vol_icon.setObjectName("volIcon") self.volume_slider = ClickableSlider(Qt.Orientation.Horizontal) self.volume_slider.setRange(0, 100) self.volume_slider.setValue(100) self.volume_slider.setFixedWidth(100) self.volume_slider.setObjectName("volumeSlider") self.volume_slider.valueChanged.connect(self.on_volume_changed) self.volume_slider.set_tooltip_provider(lambda v: f"{v}%") # Fullscreen self.fullscreen_btn = QPushButton("⛶") self.fullscreen_btn.setFixedSize(40, 40) self.fullscreen_btn.setObjectName("iconBtn") self.fullscreen_btn.clicked.connect(self.toggle_fullscreen) controls_layout.addWidget(self.play_btn) controls_layout.addWidget(self.seekbar, 1) controls_layout.addWidget(self.time_lbl) controls_layout.addSpacing(15) controls_layout.addWidget(self.vol_icon) controls_layout.addWidget(self.volume_slider) controls_layout.addSpacing(10) controls_layout.addWidget(self.fullscreen_btn) # Chat toggle self.chat_toggle_btn = QPushButton("💬") self.chat_toggle_btn.setFixedSize(40, 40) self.chat_toggle_btn.setObjectName("iconBtn") self.chat_toggle_btn.setToolTip("Toggle Chat") self.chat_toggle_btn.clicked.connect(self.toggle_chat) controls_layout.addWidget(self.chat_toggle_btn) video_layout.addWidget(self.topbar) video_layout.addWidget(self.video_frame) video_layout.addWidget(self.controls_bar) # --- Right Side: Chat --- self.chat_container = QFrame() self.chat_container.setObjectName("chatContainer") self.chat_container.setMinimumWidth(200) self.chat_container.setMaximumWidth(500) chat_layout = QVBoxLayout(self.chat_container) 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") # Tags Container (Hidden by default) self.tags_container = QFrame() self.tags_container.setObjectName("tagsContainer") self.tags_container.hide() # Only show when there are tags tags_layout = QVBoxLayout(self.tags_container) tags_layout.setContentsMargins(0, 0, 0, 10) self.tags_header_btn = QPushButton("▼ Highlights") self.tags_header_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.tags_header_btn.setStyleSheet(""" QPushButton { color: #ccc; font-weight: bold; font-size: 12px; text-align: left; border: none; background: transparent; padding: 2px 0px; } QPushButton:hover { color: #fff; } """) self.tags_header_btn.clicked.connect(self.toggle_tags_panel) self.tags_list = QTextBrowser() self.tags_list.setObjectName("tagsList") self.tags_list.setFixedHeight(100) # Keep it small and unobtrusive self.tags_list.setOpenExternalLinks(False) self.tags_list.setOpenLinks(False) self.tags_list.anchorClicked.connect(self.on_chat_link_clicked) self.tags_list.setStyleSheet(""" QTextBrowser { background-color: #1e1e1e; border: 1px solid #333; border-radius: 4px; padding: 4px; color: #ddd; font-size: 12px; } """) tags_layout.addWidget(self.tags_header_btn) tags_layout.addWidget(self.tags_list) self.chat_messages = QScrollArea() self.chat_messages.setWidgetResizable(True) self.chat_messages.setObjectName("chatMessages") self.chat_messages.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() self.chat_input.setPlaceholderText("Send a message...") self.chat_send_btn = QPushButton("Send") chat_input_layout.addWidget(self.chat_input) chat_input_layout.addWidget(self.chat_send_btn) # Chat actions self.chat_send_btn.clicked.connect(self.send_chat) self.chat_input.returnPressed.connect(self.send_chat) chat_layout.addLayout(header_layout) chat_layout.addWidget(self.users_lbl) chat_layout.addWidget(self.tags_container) chat_layout.addWidget(self.chat_messages, 1) chat_layout.addLayout(chat_input_layout) self.splitter.addWidget(video_container) self.splitter.addWidget(self.chat_container) self.splitter.setStretchFactor(0, 1) self.splitter.setStretchFactor(1, 0) self.splitter.setSizes([700, 320]) layout.addWidget(self.splitter) # Prevent UI components from stealing focus (which breaks spacebar shortcuts) for w in [self.copy_code_btn, self.leave_btn, self.play_btn, self.fullscreen_btn, self.chat_send_btn, self.seekbar, self.volume_slider, self.tags_header_btn, self.chat_toggle_btn, self.popout_btn]: w.setFocusPolicy(Qt.FocusPolicy.NoFocus) # scroll area handles focus properly self.chat_messages.setFocusPolicy(Qt.FocusPolicy.ClickFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Instantiate the VLC Player Wrapper self.vlc_player = VLCSyncPlayer(self.video_frame) self.vlc_player.signals.time_changed.connect(self.on_vlc_time) self.vlc_player.signals.state_changed.connect(self.on_vlc_state) self.vlc_player.set_volume(self.volume_slider.value()) self.play_btn.clicked.connect(self.toggle_playback) # Fullscreen auto-hide timer self._fs_hide_timer = QTimer() self._fs_hide_timer.setSingleShot(True) self._fs_hide_timer.setInterval(3000) self._fs_hide_timer.timeout.connect(self._hide_fullscreen_controls) # Mouse movement polling (needed because VLC's native window eats mouse events) self._last_mouse_pos = None self._mouse_poll_timer = QTimer() self._mouse_poll_timer.setInterval(200) self._mouse_poll_timer.timeout.connect(self._check_mouse_movement) def get_seekbar_tooltip(self, value): length_ms = self.vlc_player.get_length() if length_ms > 0: target_ms = int((value / 1000.0) * length_ms) return format_time_hms(target_ms) return "" def setup_room(self, room_code: str, username: str, file_name: str, file_path: str, start_time_s: float = 0.0): self.room_code = room_code self.username = username self.set_room_code_display(room_code) self.room_file_badge.setText(f"📄 {file_name}") # 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 if file_path: self.vlc_player.load_media(file_path, start_time_s) self.vlc_player.set_volume(self.volume_slider.value()) def cleanup(self): self.vlc_player.stop() self.current_users = [] self._is_first_user_update = True def set_room_code_display(self, text: str): self.room_code_display.setText(f"Room: {text}") def update_connection_status(self, connected: bool): if connected: self.status_dot.setStyleSheet("color: #4BB543; font-size: 14px; background: transparent;") self.status_dot.setToolTip("Connected") else: self.status_dot.setStyleSheet("color: #ff4e45; font-size: 14px; background: transparent;") self.status_dot.setToolTip("Disconnected") self._latency_ms = None def update_latency(self, latency_ms: int): self._latency_ms = latency_ms if latency_ms < 100: color = "#4BB543" # green elif latency_ms < 250: color = "#f0ad4e" # yellow else: color = "#ff4e45" # red self.status_dot.setStyleSheet(f"color: {color}; font-size: 14px; background: transparent;") self.status_dot.setToolTip(f"Latency: {latency_ms}ms") def toggle_tags_panel(self): if self.tags_list.isHidden(): self.tags_list.show() self.tags_header_btn.setText("▼ Highlights") else: self.tags_list.hide() self.tags_header_btn.setText("▶ Highlights") def toggle_chat(self): if self.popout_window: self.popout_window.activateWindow() self.popout_window.raise_() return if self.chat_container.isVisible(): self._saved_chat_width = self.chat_container.width() self.chat_container.hide() else: self.chat_container.show() w = getattr(self, '_saved_chat_width', 320) sizes = self.splitter.sizes() self.splitter.setSizes([sizes[0] - w, w]) def popout_chat(self): if self.popout_window: return self.popout_window = ChatPopoutWindow(self.window()) self.popout_window.setWindowTitle(f"Chat - {self.room_code}") # Detach from layout self.chat_container.setParent(None) # Set central widget central = QWidget() l = QVBoxLayout(central) l.setContentsMargins(0, 0, 0, 0) l.addWidget(self.chat_container) self.popout_window.setCentralWidget(central) self.popout_window.closed.connect(self.on_popout_closed) self.popout_window.show() self.popout_btn.hide() # Collapse the space in splitter self.splitter.setSizes([self.width(), 0]) def on_popout_closed(self): self.popout_window = None # Re-attach to layout self.chat_container.setParent(self) self.splitter.insertWidget(1, self.chat_container) self.popout_btn.show() # Restore splitter sizes w = getattr(self, '_saved_chat_width', 320) self.splitter.setSizes([self.width() - w, w]) self.chat_container.show() def toggle_fullscreen(self): top_window = self.window() if top_window.isFullScreen(): top_window.showNormal() self.fullscreen_btn.setText("⛶") self.chat_container.show() self.topbar.show() self.controls_bar.show() self._fs_hide_timer.stop() self._mouse_poll_timer.stop() self.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) else: top_window.showFullScreen() self.fullscreen_btn.setText("🗗") self.chat_container.hide() self.topbar.hide() self._last_mouse_pos = QCursor.pos() self._fs_hide_timer.start() self._mouse_poll_timer.start() def _hide_fullscreen_controls(self): if self.window().isFullScreen(): self.controls_bar.hide() self.setCursor(QCursor(Qt.CursorShape.BlankCursor)) def _show_fullscreen_controls(self): self.controls_bar.show() self.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) self._fs_hide_timer.start() def _check_mouse_movement(self): pos = QCursor.pos() if pos != self._last_mouse_pos: self._last_mouse_pos = pos if not self.controls_bar.isVisible(): self._show_fullscreen_controls() def toggle_mute(self): current_vol = self.vlc_player.get_volume() if current_vol > 0: self._last_volume = current_vol self.on_volume_changed(0) self.volume_slider.setValue(0) else: vol = getattr(self, '_last_volume', 100) self.on_volume_changed(vol) self.volume_slider.setValue(vol) def seek_relative(self, offset_ms): length_ms = self.vlc_player.get_length() if length_ms > 0: current_ms = self.vlc_player.current_time_ms target_ms = max(0, min(current_ms + offset_ms, length_ms)) req = self._tell_vlc_and_expect("seek", target_ms / 1000.0) self.sync_action_requested.emit("seek", target_ms / 1000.0, req) def change_volume(self, offset): new_vol = max(0, min(100, self.volume_slider.value() + offset)) self.volume_slider.setValue(new_vol) self.on_volume_changed(new_vol) def mousePressEvent(self, event): self.setFocus() super().mousePressEvent(event) def keyPressEvent(self, event): # Allow typing in input fields without triggering shortcuts focus_widget = QApplication.focusWidget() # Spacebar should always play/pause unless typing in chat input if event.key() == Qt.Key.Key_Space and not isinstance(focus_widget, QLineEdit): self.toggle_playback() event.accept() return if isinstance(focus_widget, QLineEdit) or isinstance(focus_widget, QTextEdit) or isinstance(focus_widget, QTextBrowser): if event.key() == Qt.Key.Key_Escape: focus_widget.clearFocus() self.setFocus() event.accept() else: super().keyPressEvent(event) return if event.key() == Qt.Key.Key_F: self.toggle_fullscreen() event.accept() elif event.key() == Qt.Key.Key_M: self.toggle_mute() event.accept() elif event.key() == Qt.Key.Key_Left: self.seek_relative(-5000) event.accept() elif event.key() == Qt.Key.Key_Right: self.seek_relative(5000) event.accept() elif event.key() == Qt.Key.Key_Up: self.change_volume(5) event.accept() elif event.key() == Qt.Key.Key_Down: self.change_volume(-5) event.accept() elif event.key() == Qt.Key.Key_Escape: if self.window().isFullScreen(): self.toggle_fullscreen() event.accept() elif event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self.chat_input.setFocus() event.accept() else: super().keyPressEvent(event) def copy_room_code(self): if self.room_code: QApplication.clipboard().setText(self.room_code) self.copy_code_btn.setIcon(self.check_icon) toast = QLabel("Copied!", self) toast.setStyleSheet("background-color: #4BB543; color: white; padding: 4px 8px; border-radius: 4px; font-weight: bold;") toast.setWindowFlags(Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint) pos = self.copy_code_btn.mapToGlobal(self.copy_code_btn.rect().bottomLeft()) toast.move(pos.x(), pos.y() + 5) toast.show() def reset(): self.copy_code_btn.setIcon(self.copy_icon) toast.deleteLater() QTimer.singleShot(1500, reset) def _tell_vlc_and_expect(self, action: str, position_s: float): req_id = str(uuid.uuid4())[:8] target_ms = int(position_s * 1000) # Clean up old expectations (e.g. VLC dropped the event or we missed it) now = datetime.datetime.now() self.expected_vlc_events = [e for e in self.expected_vlc_events if (now - e.timestamp).total_seconds() < 3.0] self.expected_vlc_events.append(ExpectedVlcEvent(action, req_id, target_ms)) if action == "play": self.vlc_player.seek(target_ms) self.vlc_player.play() self.play_btn.setText("⏸") elif action == "pause": self.vlc_player.seek(target_ms) self.vlc_player.pause() self.play_btn.setText("▶") elif action == "seek": self.vlc_player.seek(target_ms) return req_id def toggle_playback(self): position_s = self.vlc_player.current_time_ms / 1000.0 if self.vlc_player.is_playing: req = self._tell_vlc_and_expect("pause", position_s) self.sync_action_requested.emit("pause", position_s, req) else: req = self._tell_vlc_and_expect("play", position_s) self.sync_action_requested.emit("play", position_s, req) def on_vlc_time(self, time_ms: int): length_ms = self.vlc_player.get_length() if length_ms > 0: if not self.seekbar.isSliderDown(): 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) self.seekbar.blockSignals(False) if self.last_reported_time_ms is not None: diff = abs(time_ms - self.last_reported_time_ms) if diff > 1500: # Look for a pending seek expectation matched = False for i, expected in enumerate(self.expected_vlc_events): if expected.action == "seek" and (expected.target_val is None or abs(expected.target_val - time_ms) < 2000): matched = True self.expected_vlc_events.pop(i) break if not matched: # Genuine user scrub! req = str(uuid.uuid4())[:8] self.sync_action_requested.emit("seek", time_ms / 1000.0, req) self.last_reported_time_ms = time_ms def on_vlc_state(self, playing: bool, time_ms: int): action = "play" if playing else "pause" # Look for a pending state change expectation matched = False for i, expected in enumerate(self.expected_vlc_events): if expected.action == action: matched = True self.expected_vlc_events.pop(i) break if not matched: req = str(uuid.uuid4())[:8] self.sync_action_requested.emit(action, time_ms / 1000.0, req) def on_seekbar_dragged(self, value): length_ms = self.vlc_player.get_length() if length_ms > 0: target_ms = int((value / 1000.0) * 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) def on_seekbar_released(self): length_ms = self.vlc_player.get_length() if length_ms > 0: target_ms = int((self.seekbar.value() / 1000.0) * length_ms) req = self._tell_vlc_and_expect("seek", target_ms / 1000.0) self.sync_action_requested.emit("seek", target_ms / 1000.0, req) def on_volume_changed(self, value): self.vlc_player.set_volume(value) self._update_vol_icon(value) def _update_vol_icon(self, volume): if volume == 0: self.vol_icon.setText("🔇") elif volume < 33: self.vol_icon.setText("🔈") elif volume < 66: self.vol_icon.setText("🔉") else: self.vol_icon.setText("🔊") # --- Incoming Sync Logic --- def handle_sync_event(self, msg: dict): action = msg.get("action") if not action: if msg.get("playing", False): action = "play" elif msg.get("playing") is False: action = "pause" position_s = msg.get("position", 0) if action in ["play", "pause", "seek"]: self._tell_vlc_and_expect(action, position_s) username = msg.get("username") if username and username != self.username: if action == "play": self.add_system_message(f"{username} pressed play") elif action == "pause": self.add_system_message(f"{username} paused") elif action == "seek": 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: self._is_first_user_update = False else: joined = set(users) - set(self.current_users) left = set(self.current_users) - set(users) for user in joined: if user != self.username: self.add_system_message(f"👋 {user} joined the room") for user in left: if user != self.username: self.add_system_message(f"🚪 {user} left the room") self.current_users = users self.users_lbl.setText(f"{len(users)} watching: {', '.join(users)}") def on_chat_link_clicked(self, url): link = url.toString() print(f"DEBUG: Link clicked: {link}") if link.startswith("seek:"): # Format: 'seek:MM:SS?tag=TagName' data = link[5:] if "?" in data and "tag=" in data: time_str, query = data.split("?", 1) tag_name = "" for pair in query.split("&"): if pair.startswith("tag="): tag_name = urllib.parse.unquote(pair[4:]) break self.add_system_message(f"Seeking to {tag_name} ({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: dt = datetime.datetime.fromtimestamp(timestamp / 1000.0) except (ValueError, OSError, TypeError): dt = datetime.datetime.now() time_str = dt.strftime("%I:%M %p") safe_text = html.escape(text) safe_author = html.escape(author) # Regex to find timestamps like 1:23 or 01:23:45 pattern = r'\b(\d{1,2}:\d{2}(?::\d{2})?)\b' linked_text = re.sub(pattern, r'\1', safe_text) is_self = author == self.username # Auto-extract tags added by users directly via /tag command # We look for the pattern generated by /tag: "MM:SS tag text" # Since we just emit the text for /tag, it won't be linked yet in the raw text tag_match = re.match(r'^(\d{1,2}:\d{2}(?::\d{2})?)\s+(.+)$', text) if tag_match: self.add_highlight(tag_match.group(1), tag_match.group(2), author) return # Don't show this as a chat message bubble bubble = ChatBubble(author, linked_text, time_str, is_self, self._handle_link_from_bubble) self.chat_content_layout.addWidget(bubble) self._scroll_to_bottom() def _handle_link_from_bubble(self, url_str): if url_str.startswith("seek:"): time_str = url_str[5:] self._handle_seek_command(time_str) def _scroll_to_bottom(self): # Allow layout to finish before scrolling QTimer.singleShot(10, lambda: self.chat_messages.verticalScrollBar().setValue( self.chat_messages.verticalScrollBar().maximum() )) def toPlainText(self): # For compatibility with existing tests text = "" for i in range(self.chat_content_layout.count()): item = self.chat_content_layout.itemAt(i) if not item: continue w = item.widget() if isinstance(w, ChatBubble): text += w.text_lbl.text() + "\n" elif isinstance(w, SystemMessageWidget): lbl = w.findChild(QLabel) if lbl: text += lbl.text() + "\n" return text def add_highlight(self, time_str: str, tag_text: str, author: str): if self.tags_container.isHidden(): self.tags_container.show() safe_tag = urllib.parse.quote(tag_text) highlight_html = f"• [{time_str}] {html.escape(tag_text)} - {author}" self.tags_list.append(highlight_html) def add_system_message(self, text: str): 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() if not text: return if text.startswith("/"): parts = text.split() cmd = parts[0].lower() 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): """Delegates to the standalone parse_time_arg function.""" current_ms = self.vlc_player.current_time_ms length_ms = self.vlc_player.get_length() return parse_time_arg(arg, current_ms, length_ms) def _handle_seek_command(self, arg: str) -> bool: target_ms = self._parse_time_arg(arg) if target_ms is not None: req = self._tell_vlc_and_expect("seek", target_ms / 1000.0) self.sync_action_requested.emit("seek", target_ms / 1000.0, req) return True return False