import os
import datetime
import html
import re
import time
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QFrame, QTextEdit, QTextBrowser, QApplication, QSplitter,
QScrollArea
)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QEvent, QStandardPaths, QUrl
from PyQt6.QtGui import QIcon, QCursor
from PyQt6.QtMultimedia import QSoundEffect
import uuid
import urllib.parse
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
from fight_reactions import REACTIONS, RingsideStrip, write_reaction_sound
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
ready_action_requested = pyqtSignal(dict)
fight_action_requested = pyqtSignal(dict)
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._follow_chat = True
self._pending_chat_messages = 0
self._unread_divider = None
self._ready_check = {}
self._connected = True
self._fight_poll = {}
self._reaction_sounds = {}
self._fight_poll_timer = QTimer(self)
self._fight_poll_timer.setInterval(1000)
self._fight_poll_timer.timeout.connect(self._refresh_fight_poll)
self._chat_scroll_timer = QTimer(self)
self._chat_scroll_timer.setSingleShot(True)
self._chat_scroll_timer.timeout.connect(self._apply_chat_scroll)
self._setup_ui()
self._unread_messages = 0
QApplication.instance().focusChanged.connect(self._on_chat_focus_changed)
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)
self.ringside_strip = RingsideStrip(self.video_frame)
# 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 = self.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")
self.ready_panel = QFrame()
ready_layout = QVBoxLayout(self.ready_panel)
ready_layout.setContentsMargins(0, 6, 0, 6)
self.ready_title = QLabel()
self.ready_title.setTextFormat(Qt.TextFormat.PlainText)
self.ready_title.setWordWrap(True)
self.ready_users = QLabel()
self.ready_users.setTextFormat(Qt.TextFormat.PlainText)
self.ready_users.setWordWrap(True)
ready_layout.addWidget(self.ready_title)
ready_layout.addWidget(self.ready_users)
ready_buttons = QHBoxLayout()
self.ready_btn = QPushButton("I'm ready")
self.ready_start_btn = QPushButton("Start together")
self.ready_cancel_btn = QPushButton("Cancel")
for button in (self.ready_btn, self.ready_cancel_btn):
ready_buttons.addWidget(button)
ready_layout.addLayout(ready_buttons)
ready_layout.addWidget(self.ready_start_btn)
self.ready_btn.clicked.connect(self._vote_ready)
self.ready_start_btn.clicked.connect(lambda: self._send_ready_action("start"))
self.ready_cancel_btn.clicked.connect(lambda: self._send_ready_action("cancel"))
self.ready_panel.hide()
# 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 = QScrollArea()
self.chat_messages.setWidgetResizable(True)
self.chat_messages.setObjectName("chatMessages")
self.chat_messages.setStyleSheet("background-color: transparent; border: none;")
self.chat_content = QWidget()
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.verticalScrollBar().valueChanged.connect(self._on_chat_scroll)
self.chat_messages.verticalScrollBar().rangeChanged.connect(self._on_chat_range_changed)
self.chat_messages.viewport().installEventFilter(self)
self.chat_messages.verticalScrollBar().installEventFilter(self)
self.chat_messages.toPlainText = self.toPlainText # For test compatibility
self.chat_jump_btn = QPushButton()
self.chat_jump_btn.hide()
self.chat_jump_btn.clicked.connect(self.jump_to_latest)
self.fight_poll_panel = QFrame()
poll_layout = QVBoxLayout(self.fight_poll_panel)
poll_layout.setContentsMargins(0, 6, 0, 6)
poll_header = QHBoxLayout()
self.fight_poll_title = QLabel("Was that a robbery?")
self.fight_poll_title.setWordWrap(True)
poll_header.addWidget(self.fight_poll_title, 1)
poll_dismiss = self.fight_poll_dismiss_btn = QPushButton("Γ")
poll_dismiss.setObjectName("iconBtn")
poll_dismiss.setToolTip("Dismiss this vote")
poll_dismiss.setFixedSize(28, 28)
poll_dismiss.setFocusPolicy(Qt.FocusPolicy.NoFocus)
poll_dismiss.clicked.connect(self.fight_poll_panel.hide)
poll_header.addWidget(poll_dismiss)
poll_layout.addLayout(poll_header)
self.fight_poll_result = QLabel()
self.fight_poll_result.setWordWrap(True)
poll_layout.addWidget(self.fight_poll_result)
poll_buttons = QVBoxLayout()
self.fight_yes_btn = QPushButton("Absolutely")
self.fight_no_btn = QPushButton("Fair decision")
for button in (self.fight_yes_btn, self.fight_no_btn):
button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
poll_buttons.addWidget(button)
poll_layout.addLayout(poll_buttons)
self.fight_yes_btn.clicked.connect(lambda: self._vote_fight_poll("yes"))
self.fight_no_btn.clicked.connect(lambda: self._vote_fight_poll("no"))
self.fight_poll_panel.hide()
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.ready_panel)
chat_layout.addWidget(self.tags_container)
chat_layout.addWidget(self.chat_messages, 1)
chat_layout.addWidget(self.chat_jump_btn)
chat_layout.addWidget(self.fight_poll_panel)
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)
for button in (self.ready_btn, self.ready_start_btn, self.ready_cancel_btn, self.chat_jump_btn):
button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
# scroll area handles focus properly
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)
return format_time_hms(target_ms)
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}")
# Clear chat
self._clear_chat_alert()
self.clear_chat()
self.update_ready_check({})
self.reset_fight_reactions()
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._clear_chat_alert()
self.clear_chat()
self.update_ready_check({})
self.reset_fight_reactions()
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):
self._connected = connected
self.ready_btn.setEnabled(connected)
self.ready_start_btn.setEnabled(connected and self._can_start_ready())
self.ready_cancel_btn.setEnabled(connected)
self._refresh_fight_poll()
if not connected:
self.ringside_strip.dismiss()
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:
if not self.seekbar.isSliderDown():
self.time_lbl.setText(f"{format_time_hms(time_ms)} / {format_time_hms(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)
self.time_lbl.setText(f"{format_time_hms(target_ms)} / {format_time_hms(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":
self.add_system_message(f"{username} seeked to {format_time_hms(int(position_s * 1000))}")
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()
print(f"DEBUG: Link clicked: {link}")
if link.startswith("seek:"):
# 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 {tag_name} ({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):
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)
# 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)
linked_text = linked_text.replace('\n', '
')
is_self = author == self.username
if not is_self and not self._chat_has_focus():
self._unread_messages += 1
self.chat_header.setText(f"Live Chat β’ {self._unread_messages} new")
self.chat_toggle_btn.setText("π¬β’")
self.chat_toggle_btn.setToolTip(f"{self._unread_messages} new chat messages")
self.chat_toggle_btn.setStyleSheet("color: #66b3ff; border: 1px solid #66b3ff; border-radius: 6px;")
QApplication.alert(self.chat_container.window(), 3000)
# Auto-extract tags added by users directly via /tag command
# We look for the pattern generated by /tag: "MM:SS tag text"
# Since we just emit the text for /tag, it won't be linked yet in the raw text
tag_match = re.match(r'^(\d{1,2}:\d{2}(?::\d{2})?)\s+(.+)$', text)
if tag_match:
self.add_highlight(tag_match.group(1), tag_match.group(2), 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)
if not self._follow_chat and not is_self:
if self._pending_chat_messages == 0:
if self._unread_divider is not None:
self.chat_content_layout.removeWidget(self._unread_divider)
self._unread_divider.deleteLater()
self._unread_divider = QLabel("ββββ New messages ββββ")
self._unread_divider.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._unread_divider.setFixedHeight(28)
self._unread_divider.setStyleSheet("color: #66b3ff; background: transparent;")
self.chat_content_layout.addWidget(self._unread_divider)
self._pending_chat_messages += 1
self.chat_jump_btn.setText(f"β {self._pending_chat_messages} new messages Β· Jump to latest")
self.chat_jump_btn.show()
self.chat_content_layout.addWidget(bubble)
self._scroll_to_bottom()
def _chat_has_focus(self):
focus = QApplication.focusWidget()
return (self.chat_container.isVisible()
and self.chat_container.window().isActiveWindow()
and focus is not None
and (focus is self.chat_container or self.chat_container.isAncestorOf(focus)))
def _on_chat_focus_changed(self, old, new):
if self._chat_has_focus() and self._follow_chat:
self._clear_chat_alert()
def _clear_chat_alert(self):
self._unread_messages = 0
self.chat_header.setText("Live Chat")
self.chat_toggle_btn.setText("π¬")
self.chat_toggle_btn.setToolTip("Toggle Chat")
self.chat_toggle_btn.setStyleSheet("")
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):
if self._follow_chat:
self._chat_scroll_timer.start(10)
def eventFilter(self, watched, event):
if event.type() in (QEvent.Type.Wheel, QEvent.Type.MouseButtonPress, QEvent.Type.KeyPress):
# User scrolling takes precedence over an already queued tail update.
self._chat_scroll_timer.stop()
return super().eventFilter(watched, event)
def _apply_chat_scroll(self):
if self._follow_chat:
bar = self.chat_messages.verticalScrollBar()
bar.setValue(bar.maximum())
def _on_chat_range_changed(self, minimum, maximum):
self._scroll_to_bottom()
def _on_chat_scroll(self, value):
if self._chat_scroll_timer.isActive():
return
self._follow_chat = self.chat_messages.verticalScrollBar().maximum() - value <= 24
if self._follow_chat:
self._pending_chat_messages = 0
self.chat_jump_btn.hide()
if self._chat_has_focus():
self._clear_chat_alert()
def jump_to_latest(self):
self._follow_chat = True
self._pending_chat_messages = 0
self.chat_jump_btn.hide()
self._clear_chat_alert()
self._scroll_to_bottom()
def finish_chat_history(self):
self.jump_to_latest()
def _can_start_ready(self):
participants = self._ready_check.get("participants", [])
return bool(participants) and all(p.get("ready") and p.get("connected") for p in participants)
def update_ready_check(self, state):
new_check = state.get("active") and state.get("id") != self._ready_check.get("id")
self._ready_check = state if state.get("active") else {}
if not self._ready_check:
self.ready_panel.hide()
return
participants = state.get("participants", [])
ready_count = sum(bool(p.get("ready") and p.get("connected")) for p in participants)
self.ready_title.setText(f"Ready check Β· {ready_count} of {len(participants)} ready\nStarted by {state.get('requestedBy', '')}")
self.ready_users.setText("\n".join(
f"{p['username']}: " + ("Reconnectingβ¦" if not p.get('connected') else "β Ready" if p.get('ready') else "Not ready")
for p in participants))
mine = next((p for p in participants if p['username'] == self.username), {})
self.ready_btn.setText("Not ready" if mine.get("ready") else "I'm ready")
initiator = state.get("requestedBy") == self.username
self.ready_start_btn.setVisible(initiator)
self.ready_cancel_btn.setVisible(initiator)
self.ready_btn.setEnabled(self._connected)
self.ready_cancel_btn.setEnabled(self._connected)
self.ready_start_btn.setEnabled(self._connected and self._can_start_ready())
self.ready_panel.show()
if new_check:
self.add_system_message(f"{html.escape(state.get('requestedBy', 'Someone'))} started a ready check.")
if not self._chat_has_focus():
self.chat_toggle_btn.setText("π¬β’")
self.chat_toggle_btn.setToolTip("A ready check is waiting")
self.chat_toggle_btn.setStyleSheet("color: #66b3ff; border: 1px solid #66b3ff; border-radius: 6px;")
QApplication.alert(self.chat_container.window(), 3000)
def _send_ready_action(self, action, **extra):
if self._ready_check:
self.ready_action_requested.emit({"type": "ready_check", "action": action,
"id": self._ready_check['id'], **extra})
def _vote_ready(self):
mine = next((p for p in self._ready_check.get("participants", []) if p['username'] == self.username), {})
self._send_ready_action("vote", ready=not mine.get("ready", False))
def handle_fight_reaction(self, message):
kind = message.get("kind")
if kind not in REACTIONS:
return
position = format_time_short(int(message.get("position", 0) * 1000))
shown = self.ringside_strip.show_reaction(kind, message.get("username", "Someone"), position)
if shown:
self._play_reaction_sound(kind)
def _play_reaction_sound(self, kind):
try:
if kind not in self._reaction_sounds:
cache = os.path.join(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.GenericCacheLocation),
"VideoSync", "reaction-sounds-v1")
path = write_reaction_sound(cache, kind)
sound = QSoundEffect(self)
sound.setVolume(.18)
sound.setSource(QUrl.fromLocalFile(str(path)))
self._reaction_sounds[kind] = sound
self._reaction_sounds[kind].play()
except OSError:
# A read-only cache must not prevent the visual reaction.
pass
def update_fight_poll(self, state):
previous_id = self._fight_poll.get("id")
self._fight_poll = state if state.get("id") else {}
if not self._fight_poll:
self._fight_poll_timer.stop()
self.fight_poll_panel.hide()
return
if previous_id != state.get("id"):
self.fight_poll_panel.show()
self._refresh_fight_poll()
if state.get("active") and state.get("expiresAt", 0) > time.time() * 1000:
self._fight_poll_timer.start()
def _refresh_fight_poll(self):
if not self._fight_poll:
return
remaining = max(0, int((self._fight_poll.get("expiresAt", 0) - time.time() * 1000 + 999) / 1000))
active = self._fight_poll.get("active", False) and remaining > 0
if not active:
self._fight_poll_timer.stop()
votes = self._fight_poll.get("votes", {})
yes = sum(choice == "yes" for choice in votes.values())
no = sum(choice == "no" for choice in votes.values())
status = f"{remaining}s left" if active else "Vote closed"
mine = votes.get(self.username)
self.fight_poll_title.setText(f"Was that a robbery? Β· {status}")
self.fight_poll_result.setText(f"Absolutely {yes} Β· Fair decision {no}" +
(" Β· Your vote is in" if mine else ""))
for button, choice in ((self.fight_yes_btn, "yes"), (self.fight_no_btn, "no")):
button.setEnabled(bool(active and self._connected))
button.setStyleSheet("border: 1px solid #66b3ff;" if mine == choice else "")
def _vote_fight_poll(self, choice):
if self._fight_poll:
self.fight_action_requested.emit({"type": "fight_poll_vote", "id": self._fight_poll['id'], "choice": choice})
def reset_fight_reactions(self):
self.ringside_strip.dismiss()
self.update_fight_poll({})
for sound in self._reaction_sounds.values():
sound.stop()
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):
if self.tags_container.isHidden():
self.tags_container.show()
safe_tag = urllib.parse.quote(tag_text)
highlight_html = f"β’ [{time_str}] {html.escape(tag_text)} - {author}"
self.tags_list.append(highlight_html)
def add_system_message(self, text: str):
msg = SystemMessageWidget(text)
self.chat_content_layout.addWidget(msg)
self._scroll_to_bottom()
def clear_chat(self):
self._clear_chat_alert()
self._chat_scroll_timer.stop()
self._follow_chat = True
self._pending_chat_messages = 0
self._unread_divider = None
self.chat_jump_btn.hide()
for i in reversed(range(self.chat_content_layout.count())):
item = self.chat_content_layout.itemAt(i)
if item.widget():
item.widget().deleteLater()
self.chat_content_layout.removeWidget(item.widget())
def send_chat(self):
text = self.chat_input.text().strip()
if not text:
return
if text.startswith("/"):
parts = text.split()
cmd = parts[0].lower()
self.chat_input.setText("")
if handle_chat_command(cmd, parts, self):
return
self.chat_message_ready.emit(text)
self.chat_input.setText("")
def _parse_time_arg(self, arg: str):
"""Delegates to the standalone parse_time_arg function."""
current_ms = self.vlc_player.current_time_ms
length_ms = self.vlc_player.get_length()
return parse_time_arg(arg, current_ms, length_ms)
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