import os import datetime import html import re from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFrame, QSlider, QTextEdit, QTextBrowser, QApplication, QToolTip, QSplitter, QMainWindow ) from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QEvent from PyQt6.QtGui import QIcon, QCursor import uuid from vlc_player import VLCSyncPlayer 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): 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 = QTextBrowser() self.chat_messages.setObjectName("chatMessages") self.chat_messages.setOpenExternalLinks(False) self.chat_messages.setOpenLinks(False) self.chat_messages.anchorClicked.connect(self.on_chat_link_clicked) self.chat_messages.setHtml("Welcome to the room! 👋") 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) # specifically for chat_messages, we allow clicking links, but avoid focus stealing 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) s = max(0, target_ms) // 1000 return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}" 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}") self.chat_messages.setHtml("Welcome to the room! 👋") 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: def fmt(ms): s = max(0, ms) // 1000 return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}" if not self.seekbar.isSliderDown(): self.time_lbl.setText(f"{fmt(time_ms)} / {fmt(length_ms)}") 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) def fmt(ms): s = max(0, ms) // 1000 return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}" self.time_lbl.setText(f"{fmt(target_ms)} / {fmt(length_ms)}") # 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": def fmt(s): return f"{int(s)//3600:02d}:{(int(s)%3600)//60:02d}:{int(s)%60:02d}" self.add_system_message(f"{username} seeked to {fmt(position_s)}") 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() if link.startswith("seek:"): time_str = link[5:] self._handle_seek_command(time_str) 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) new_msg = f"{safe_author}: {linked_text} {time_str}" self.chat_messages.append(new_msg) # Auto-extract tags added by users directly via /tag command # This will catch messages that look like purely a tag command # (Since we just append the output of /tag to chat) match = re.match(r'^[{time_str}] {tag_text} - {author}" self.tags_list.append(highlight_html) def add_system_message(self, text: str): new_msg = f"{text}" self.chat_messages.append(new_msg) def send_chat(self): text = self.chat_input.text().strip() if not text: return if text.startswith("/"): parts = text.split() cmd = parts[0].lower() if cmd == "/play": self.chat_input.setText("") if not self.vlc_player.is_playing: self.toggle_playback() self.add_system_message(text) return elif cmd == "/pause": self.chat_input.setText("") if self.vlc_player.is_playing: self.toggle_playback() self.add_system_message(text) return elif cmd == "/seek": self.chat_input.setText("") if len(parts) > 1: if self._handle_seek_command(parts[1]): self.add_system_message(text) else: self.add_system_message("Invalid time format. Use: 1:23, +30s, -1m") else: self.add_system_message("Usage: /seek [time]") return elif cmd == "/tag": self.chat_input.setText("") if len(parts) > 1: time_arg = parts[1].lower() tag_name = " ".join(parts[2:]) target_ms = self._parse_time_arg(time_arg) if target_ms is not None: s = int(max(0, target_ms) // 1000) if s >= 3600: time_str = f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}" else: time_str = f"{(s%3600)//60:02d}:{s%60:02d}" msg = f"{time_str} {tag_name}".strip() self.chat_message_ready.emit(msg) else: self.add_system_message("Invalid time format. Use: now, 1:23, +30s, -1m") else: self.add_system_message("Usage: /tag [now|time] [name]") return elif cmd == "/time": self.chat_input.setText("") current_ms = self.vlc_player.current_time_ms s = max(0, current_ms) // 1000 if s >= 3600: time_str = f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}" else: time_str = f"{(s%3600)//60:02d}:{s%60:02d}" self.add_system_message(f"Current time: {time_str}") return elif cmd == "/help": self.chat_input.setText("") self.add_system_message("Available commands:
/play - Resume playback
/pause - Pause playback
/seek [time] - Seek to specific time
/tag [now|time] [name] - Tag a timestamp
/time - Show current time
/help - Show this message") 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.""" current_ms = self.vlc_player.current_time_ms length_ms = self.vlc_player.get_length() if length_ms <= 0: return None if arg == "now": return current_ms try: target_ms = 0 if arg.startswith('+') or arg.startswith('-'): modifier = 1 if arg.startswith('+') else -1 num_str = arg[1:] if num_str.endswith('s'): val = float(num_str[:-1]) * 1000 elif num_str.endswith('m'): val = float(num_str[:-1]) * 60 * 1000 elif num_str.endswith('h'): val = float(num_str[:-1]) * 3600 * 1000 else: val = float(num_str) * 1000 target_ms = current_ms + (val * modifier) elif ":" in arg: parts = arg.split(":") parts.reverse() if len(parts) > 0: target_ms += float(parts[0]) * 1000 if len(parts) > 1: target_ms += float(parts[1]) * 60 * 1000 if len(parts) > 2: target_ms += float(parts[2]) * 3600 * 1000 else: if arg.endswith('s'): target_ms = float(arg[:-1]) * 1000 elif arg.endswith('m'): target_ms = float(arg[:-1]) * 60 * 1000 elif arg.endswith('h'): target_ms = float(arg[:-1]) * 3600 * 1000 else: target_ms = float(arg) * 1000 return max(0, min(target_ms, length_ms)) except ValueError: return None 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