762 lines
32 KiB
Python
762 lines
32 KiB
Python
import os
|
|
import datetime
|
|
import html
|
|
import re
|
|
from PyQt6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
|
QPushButton, QFrame, QSlider, QTextEdit, QTextBrowser, QApplication, QToolTip
|
|
)
|
|
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
|
|
from PyQt6.QtGui import QIcon, QCursor
|
|
|
|
import uuid
|
|
|
|
from vlc_player import VLCSyncPlayer
|
|
|
|
class ClickableSlider(QSlider):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self._get_tooltip_text = None
|
|
|
|
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 event.buttons() & Qt.MouseButton.LeftButton:
|
|
if self._get_tooltip_text:
|
|
text = self._get_tooltip_text(self.value())
|
|
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._setup_ui()
|
|
|
|
def _setup_ui(self):
|
|
layout = QHBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(0)
|
|
|
|
# --- 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.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.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)
|
|
|
|
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.setFixedWidth(320)
|
|
chat_layout = QVBoxLayout(self.chat_container)
|
|
|
|
chat_header = QLabel("Live Chat")
|
|
chat_header.setObjectName("chatHeader")
|
|
|
|
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.addWidget(chat_header)
|
|
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)
|
|
|
|
layout.addWidget(video_container, 1)
|
|
layout.addWidget(self.chat_container)
|
|
|
|
# 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]:
|
|
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 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_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'<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>"
|
|
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'^<a href="seek:(\d{1,2}:\d{2}(?::\d{2})?)[^>]+>([^<]+)</a>\s+(.+)$', linked_text)
|
|
if match:
|
|
# It's a tag! Add it to the highlights panel
|
|
self.add_highlight(match.group(1), match.group(3), safe_author)
|
|
|
|
def add_highlight(self, time_str: str, tag_text: str, author: str):
|
|
if self.tags_container.isHidden():
|
|
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>"
|
|
self.tags_list.append(highlight_html)
|
|
|
|
def add_system_message(self, text: str):
|
|
new_msg = f"<i style='color: #888;'>{text}</i>"
|
|
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: <a href='seek:{time_str}' style='color: #66b3ff; text-decoration: none;'>{time_str}</a>")
|
|
return
|
|
elif cmd == "/help":
|
|
self.chat_input.setText("")
|
|
self.add_system_message("Available commands:<br><b>/play</b> - Resume playback<br><b>/pause</b> - Pause playback<br><b>/seek [time]</b> - Seek to specific time<br><b>/tag [now|time] [name]</b> - Tag a timestamp<br><b>/time</b> - Show current time<br><b>/help</b> - Show this message")
|
|
return
|
|
|
|
self.chat_message_ready.emit(text)
|
|
self.chat_input.setText("")
|
|
|
|
def _parse_time_arg(self, arg: str) -> float | None:
|
|
"""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
|