Split out components into new files
This commit is contained in:
82
desktop-client/chat_widgets.py
Normal file
82
desktop-client/chat_widgets.py
Normal file
@@ -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)
|
||||||
106
desktop-client/commands.py
Normal file
106
desktop-client/commands.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
from utils import format_time_short
|
||||||
|
|
||||||
|
HELP_TEXT = """
|
||||||
|
<b>Available Commands:</b><br>
|
||||||
|
• <b>/play</b>, <b>/pause</b> - Control playback<br>
|
||||||
|
• <b>/seek [time]</b> - Seek (e.g., 1:23, +30s, -1m)<br>
|
||||||
|
• <b>/tag [now|time] [name]</b> - Create a highlight<br>
|
||||||
|
• <b>/time</b> - Show current timestamp<br>
|
||||||
|
• <b>/help</b> - Show this message<br><br>
|
||||||
|
<b>Keyboard Shortcuts:</b><br>
|
||||||
|
• <b>Space</b>: Play/Pause<br>
|
||||||
|
• <b>F</b>: Fullscreen | <b>M</b>: Mute<br>
|
||||||
|
• <b>Left/Right</b>: Seek ±5s<br>
|
||||||
|
• <b>Up/Down</b>: Volume ±5%<br>
|
||||||
|
• <b>Enter</b>: Focus chat | <b>Esc</b>: 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: <a href='seek:{time_str}' style='color: #66b3ff; text-decoration: none;'>{time_str}</a>")
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif cmd == "/help":
|
||||||
|
widget.add_system_message(HELP_TEXT)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
48
desktop-client/custom_widgets.py
Normal file
48
desktop-client/custom_widgets.py
Normal file
@@ -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()
|
||||||
@@ -4,8 +4,8 @@ import html
|
|||||||
import re
|
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, QTextEdit, QTextBrowser, QApplication, QSplitter,
|
||||||
QMainWindow, QScrollArea
|
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
|
||||||
@@ -14,137 +14,11 @@ import uuid
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from vlc_player import VLCSyncPlayer
|
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:
|
class ExpectedVlcEvent:
|
||||||
def __init__(self, action: str, req_id: str, target_val=None):
|
def __init__(self, action: str, req_id: str, target_val=None):
|
||||||
@@ -921,112 +795,18 @@ class RoomWidget(QWidget):
|
|||||||
if text.startswith("/"):
|
if text.startswith("/"):
|
||||||
parts = text.split()
|
parts = text.split()
|
||||||
cmd = parts[0].lower()
|
cmd = parts[0].lower()
|
||||||
|
|
||||||
if cmd == "/play":
|
|
||||||
self.chat_input.setText("")
|
self.chat_input.setText("")
|
||||||
if not self.vlc_player.is_playing:
|
if handle_chat_command(cmd, parts, self):
|
||||||
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: <a href='seek:{time_str}' style='color: #66b3ff; text-decoration: none;'>{time_str}</a>")
|
|
||||||
return
|
|
||||||
elif cmd == "/help":
|
|
||||||
self.chat_input.setText("")
|
|
||||||
help_text = """
|
|
||||||
<b>Available Commands:</b><br>
|
|
||||||
• <b>/play</b>, <b>/pause</b> - Control playback<br>
|
|
||||||
• <b>/seek [time]</b> - Seek (e.g., 1:23, +30s, -1m)<br>
|
|
||||||
• <b>/tag [now|time] [name]</b> - Create a highlight<br>
|
|
||||||
• <b>/time</b> - Show current timestamp<br>
|
|
||||||
• <b>/help</b> - Show this message<br><br>
|
|
||||||
<b>Keyboard Shortcuts:</b><br>
|
|
||||||
• <b>Space</b>: Play/Pause<br>
|
|
||||||
• <b>F</b>: Fullscreen | <b>M</b>: Mute<br>
|
|
||||||
• <b>Left/Right</b>: Seek ±5s<br>
|
|
||||||
• <b>Up/Down</b>: Volume ±5%<br>
|
|
||||||
• <b>Enter</b>: Focus chat | <b>Esc</b>: Clear focus
|
|
||||||
"""
|
|
||||||
self.add_system_message(help_text)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self.chat_message_ready.emit(text)
|
self.chat_message_ready.emit(text)
|
||||||
self.chat_input.setText("")
|
self.chat_input.setText("")
|
||||||
|
|
||||||
def _parse_time_arg(self, arg: str):
|
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
|
current_ms = self.vlc_player.current_time_ms
|
||||||
length_ms = self.vlc_player.get_length()
|
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
|
return parse_time_arg(arg, current_ms, length_ms)
|
||||||
# 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
|
|
||||||
|
|
||||||
def _handle_seek_command(self, arg: str) -> bool:
|
def _handle_seek_command(self, arg: str) -> bool:
|
||||||
target_ms = self._parse_time_arg(arg)
|
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)
|
self.sync_action_requested.emit("seek", target_ms / 1000.0, req)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
11
desktop-client/utils.py
Normal file
11
desktop-client/utils.py
Normal file
@@ -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}"
|
||||||
Reference in New Issue
Block a user