Files
video-sync/desktop-client/room_widget.py
T

1104 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <b>{tag_name}</b> ({time_str})")
self._handle_seek_command(time_str)
else:
self._handle_seek_command(data)
def add_chat_message(self, author: str, text: str, timestamp: int):
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'<a href="seek:\1" style="color: #66b3ff; text-decoration: none;">\1</a>', safe_text)
linked_text = linked_text.replace('\n', '<br>')
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"<span>{html.escape(state.get('requestedBy', 'Someone'))} started a ready check.</span>")
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"• <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)
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