Improve look of chat messages
This commit is contained in:
@@ -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__":
|
||||||
|
|||||||
@@ -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'
|
||||||
|
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)
|
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
|
||||||
|
|
||||||
|
if length_ms > 0:
|
||||||
return max(0, min(target_ms, length_ms))
|
return max(0, min(target_ms, length_ms))
|
||||||
|
return max(0, target_ms)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user