Improve look of chat messages

This commit is contained in:
Peter Stockings
2026-03-09 20:13:56 +11:00
parent 99a0694830
commit 5b857bf878
2 changed files with 154 additions and 27 deletions

View File

@@ -436,7 +436,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 +445,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 +458,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

@@ -5,15 +5,78 @@ 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, QSlider, QTextEdit, QTextBrowser, QApplication, QToolTip, QSplitter,
QMainWindow QMainWindow, QScrollArea
) )
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QEvent 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
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): class ChatPopoutWindow(QMainWindow):
closed = pyqtSignal() closed = pyqtSignal()
@@ -287,12 +350,24 @@ 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
# Initial welcome message
self.add_system_message("Welcome to the room! 👋")
chat_input_layout = QHBoxLayout() chat_input_layout = QHBoxLayout()
self.chat_input = QLineEdit() self.chat_input = QLineEdit()
@@ -326,7 +401,7 @@ class RoomWidget(QWidget):
self.chat_toggle_btn, self.popout_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)
@@ -363,7 +438,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.add_system_message("Welcome to the room! 👋")
self.current_users = [] self.current_users = []
self._is_first_user_update = True self._is_first_user_update = True
@@ -744,9 +824,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:
@@ -762,27 +854,57 @@ 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 send_chat(self): def send_chat(self):
text = self.chat_input.text().strip() text = self.chat_input.text().strip()
@@ -855,8 +977,8 @@ class RoomWidget(QWidget):
"""Parses a time string (absolute time, offset like +30s, or 'now') and returns the target time in MS. Returns None on error.""" """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 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: # Don't strictly block on length_ms > 0 here, as we might be seeking before length is known
return None # or length might be reported as 0 for some streams.
if arg == "now": if arg == "now":
return current_ms return current_ms
@@ -883,7 +1005,9 @@ class RoomWidget(QWidget):
elif arg.endswith('h'): target_ms = float(arg[:-1]) * 3600 * 1000 elif arg.endswith('h'): target_ms = float(arg[:-1]) * 3600 * 1000
else: target_ms = float(arg) * 1000 else: target_ms = float(arg) * 1000
return max(0, min(target_ms, length_ms)) if length_ms > 0:
return max(0, min(target_ms, length_ms))
return max(0, target_ms)
except ValueError: except ValueError:
return None return None