|
|
|
@@ -1,8 +1,10 @@
|
|
|
|
import os
|
|
|
|
import os
|
|
|
|
import datetime
|
|
|
|
import datetime
|
|
|
|
|
|
|
|
import html
|
|
|
|
|
|
|
|
import re
|
|
|
|
from PyQt6.QtWidgets import (
|
|
|
|
from PyQt6.QtWidgets import (
|
|
|
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
|
|
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
|
|
|
QPushButton, QFrame, QSlider, QTextEdit, QApplication, QToolTip
|
|
|
|
QPushButton, QFrame, QSlider, QTextEdit, QTextBrowser, QApplication, QToolTip
|
|
|
|
)
|
|
|
|
)
|
|
|
|
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
|
|
|
|
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
|
|
|
|
from PyQt6.QtGui import QIcon
|
|
|
|
from PyQt6.QtGui import QIcon
|
|
|
|
@@ -184,9 +186,11 @@ class RoomWidget(QWidget):
|
|
|
|
self.users_lbl = QLabel("0 watching")
|
|
|
|
self.users_lbl = QLabel("0 watching")
|
|
|
|
self.users_lbl.setObjectName("usersLbl")
|
|
|
|
self.users_lbl.setObjectName("usersLbl")
|
|
|
|
|
|
|
|
|
|
|
|
self.chat_messages = QTextEdit()
|
|
|
|
self.chat_messages = QTextBrowser()
|
|
|
|
self.chat_messages.setObjectName("chatMessages")
|
|
|
|
self.chat_messages.setObjectName("chatMessages")
|
|
|
|
self.chat_messages.setReadOnly(True)
|
|
|
|
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! 👋")
|
|
|
|
self.chat_messages.setHtml("Welcome to the room! 👋")
|
|
|
|
|
|
|
|
|
|
|
|
chat_input_layout = QHBoxLayout()
|
|
|
|
chat_input_layout = QHBoxLayout()
|
|
|
|
@@ -209,6 +213,15 @@ class RoomWidget(QWidget):
|
|
|
|
layout.addWidget(video_container, 1)
|
|
|
|
layout.addWidget(video_container, 1)
|
|
|
|
layout.addWidget(self.chat_container)
|
|
|
|
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]:
|
|
|
|
|
|
|
|
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
|
|
|
|
# Instantiate the VLC Player Wrapper
|
|
|
|
self.vlc_player = VLCSyncPlayer(self.video_frame)
|
|
|
|
self.vlc_player = VLCSyncPlayer(self.video_frame)
|
|
|
|
self.vlc_player.signals.time_changed.connect(self.on_vlc_time)
|
|
|
|
self.vlc_player.signals.time_changed.connect(self.on_vlc_time)
|
|
|
|
@@ -259,6 +272,81 @@ class RoomWidget(QWidget):
|
|
|
|
self.chat_container.hide()
|
|
|
|
self.chat_container.hide()
|
|
|
|
self.topbar.hide()
|
|
|
|
self.topbar.hide()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
def copy_room_code(self):
|
|
|
|
if self.room_code:
|
|
|
|
if self.room_code:
|
|
|
|
QApplication.clipboard().setText(self.room_code)
|
|
|
|
QApplication.clipboard().setText(self.room_code)
|
|
|
|
@@ -417,13 +505,27 @@ class RoomWidget(QWidget):
|
|
|
|
self.current_users = users
|
|
|
|
self.current_users = users
|
|
|
|
self.users_lbl.setText(f"{len(users)} watching: {', '.join(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):
|
|
|
|
def add_chat_message(self, author: str, text: str, timestamp: int):
|
|
|
|
try:
|
|
|
|
try:
|
|
|
|
dt = datetime.datetime.fromtimestamp(timestamp / 1000.0)
|
|
|
|
dt = datetime.datetime.fromtimestamp(timestamp / 1000.0)
|
|
|
|
except (ValueError, OSError, TypeError):
|
|
|
|
except (ValueError, OSError, TypeError):
|
|
|
|
dt = datetime.datetime.now()
|
|
|
|
dt = datetime.datetime.now()
|
|
|
|
time_str = dt.strftime("%I:%M %p")
|
|
|
|
time_str = dt.strftime("%I:%M %p")
|
|
|
|
new_msg = f"<b>{author}</b>: {text} <span style='color: gray; font-size: 10px;'>{time_str}</span>"
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
self.chat_messages.append(new_msg)
|
|
|
|
|
|
|
|
|
|
|
|
def add_system_message(self, text: str):
|
|
|
|
def add_system_message(self, text: str):
|
|
|
|
@@ -461,9 +563,36 @@ class RoomWidget(QWidget):
|
|
|
|
else:
|
|
|
|
else:
|
|
|
|
self.add_system_message("Usage: /seek [time]")
|
|
|
|
self.add_system_message("Usage: /seek [time]")
|
|
|
|
return
|
|
|
|
return
|
|
|
|
|
|
|
|
elif cmd == "/tag":
|
|
|
|
|
|
|
|
self.chat_input.setText("")
|
|
|
|
|
|
|
|
if len(parts) > 1:
|
|
|
|
|
|
|
|
time_arg = parts[1].lower()
|
|
|
|
|
|
|
|
tag_name = " ".join(parts[2:])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if time_arg == "now":
|
|
|
|
|
|
|
|
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}"
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
time_str = time_arg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
msg = f"{time_str} {tag_name}".strip()
|
|
|
|
|
|
|
|
self.chat_message_ready.emit(msg)
|
|
|
|
|
|
|
|
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":
|
|
|
|
elif cmd == "/help":
|
|
|
|
self.chat_input.setText("")
|
|
|
|
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 (e.g., 1:23) or offset (e.g., +30s, -1m)<br><b>/help</b> - Show this message")
|
|
|
|
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
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
self.chat_message_ready.emit(text)
|
|
|
|
self.chat_message_ready.emit(text)
|
|
|
|
|