Add desktop ready checks, smarter chat scrolling, and fight-night reactions
This commit is contained in:
@@ -2,13 +2,15 @@ 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
|
||||
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
|
||||
@@ -18,6 +20,7 @@ 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:
|
||||
@@ -31,6 +34,8 @@ 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__()
|
||||
@@ -43,6 +48,19 @@ class RoomWidget(QWidget):
|
||||
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
|
||||
@@ -110,6 +128,7 @@ class RoomWidget(QWidget):
|
||||
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()
|
||||
@@ -192,6 +211,30 @@ class RoomWidget(QWidget):
|
||||
|
||||
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()
|
||||
@@ -252,7 +295,44 @@ class RoomWidget(QWidget):
|
||||
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()
|
||||
@@ -269,8 +349,11 @@ class RoomWidget(QWidget):
|
||||
|
||||
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)
|
||||
@@ -286,6 +369,8 @@ class RoomWidget(QWidget):
|
||||
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)
|
||||
@@ -326,8 +411,9 @@ class RoomWidget(QWidget):
|
||||
|
||||
# Clear chat
|
||||
self._clear_chat_alert()
|
||||
for i in reversed(range(self.chat_content_layout.count())):
|
||||
self.chat_content_layout.itemAt(i).widget().setParent(None)
|
||||
self.clear_chat()
|
||||
self.update_ready_check({})
|
||||
self.reset_fight_reactions()
|
||||
|
||||
|
||||
self.current_users = []
|
||||
@@ -339,6 +425,9 @@ class RoomWidget(QWidget):
|
||||
|
||||
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
|
||||
@@ -347,6 +436,13 @@ class RoomWidget(QWidget):
|
||||
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")
|
||||
@@ -751,6 +847,19 @@ class RoomWidget(QWidget):
|
||||
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()
|
||||
|
||||
@@ -762,7 +871,7 @@ class RoomWidget(QWidget):
|
||||
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():
|
||||
if self._chat_has_focus() and self._follow_chat:
|
||||
self._clear_chat_alert()
|
||||
|
||||
def _clear_chat_alert(self):
|
||||
@@ -778,10 +887,150 @@ class RoomWidget(QWidget):
|
||||
self._handle_seek_command(time_str)
|
||||
|
||||
def _scroll_to_bottom(self):
|
||||
# Allow layout to finish before scrolling
|
||||
QTimer.singleShot(10, lambda: self.chat_messages.verticalScrollBar().setValue(
|
||||
self.chat_messages.verticalScrollBar().maximum()
|
||||
))
|
||||
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
|
||||
@@ -812,10 +1061,16 @@ class RoomWidget(QWidget):
|
||||
|
||||
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().setParent(None)
|
||||
item.widget().deleteLater()
|
||||
self.chat_content_layout.removeWidget(item.widget())
|
||||
|
||||
def send_chat(self):
|
||||
text = self.chat_input.text().strip()
|
||||
|
||||
Reference in New Issue
Block a user