Compare commits
6 Commits
3eb4b671c6
...
2e31eab0ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e31eab0ca | ||
|
|
b29b540331 | ||
|
|
43929ea94d | ||
|
|
0e7f80ff1f | ||
|
|
bd43cd10f6 | ||
|
|
de0a7172e7 |
175
desktop-client/lobby_widget.py
Normal file
175
desktop-client/lobby_widget.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import os
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
|
||||||
|
QFileDialog, QFrame
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
|
|
||||||
|
class LobbyWidget(QWidget):
|
||||||
|
# Signals to communicate to VlcSyncApp
|
||||||
|
create_requested = pyqtSignal(str, str, str, object) # username, path, filename, size
|
||||||
|
join_requested = pyqtSignal(str, str) # username, room_code
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setAcceptDrops(True)
|
||||||
|
|
||||||
|
self.local_file_path = None
|
||||||
|
self.local_file_name = None
|
||||||
|
self.local_file_size = 0
|
||||||
|
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
# Container for centering
|
||||||
|
container = QFrame()
|
||||||
|
container.setObjectName("lobbyCard")
|
||||||
|
container.setFixedWidth(500)
|
||||||
|
container_layout = QVBoxLayout(container)
|
||||||
|
container_layout.setContentsMargins(30, 30, 30, 30)
|
||||||
|
container_layout.setSpacing(15)
|
||||||
|
|
||||||
|
# Brand
|
||||||
|
title = QLabel("VideoSync")
|
||||||
|
title.setObjectName("brandTitle")
|
||||||
|
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
tagline = QLabel("Watch together, anywhere")
|
||||||
|
tagline.setObjectName("brandTagline")
|
||||||
|
tagline.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
container_layout.addWidget(title)
|
||||||
|
container_layout.addWidget(tagline)
|
||||||
|
container_layout.addSpacing(20)
|
||||||
|
|
||||||
|
# Username
|
||||||
|
self.username_input = QLineEdit()
|
||||||
|
self.username_input.setPlaceholderText("Enter a display name")
|
||||||
|
container_layout.addWidget(QLabel("YOUR NAME"))
|
||||||
|
container_layout.addWidget(self.username_input)
|
||||||
|
|
||||||
|
container_layout.addSpacing(20)
|
||||||
|
|
||||||
|
# Actions Layout (Create vs Join)
|
||||||
|
actions_layout = QHBoxLayout()
|
||||||
|
actions_layout.setSpacing(20)
|
||||||
|
|
||||||
|
# Create Room Panel
|
||||||
|
create_panel = QVBoxLayout()
|
||||||
|
create_panel.addWidget(QLabel("Create a Room"))
|
||||||
|
self.create_file_btn = QPushButton("Choose Video File")
|
||||||
|
self.create_file_btn.setObjectName("secondaryBtn")
|
||||||
|
self.create_file_btn.clicked.connect(self.select_file)
|
||||||
|
|
||||||
|
self.create_file_info = QLabel("")
|
||||||
|
self.create_file_info.setObjectName("fileInfo")
|
||||||
|
self.create_file_info.setWordWrap(True)
|
||||||
|
self.create_file_info.hide()
|
||||||
|
|
||||||
|
self.create_room_btn = QPushButton("Create Room")
|
||||||
|
self.create_room_btn.setObjectName("primaryBtn")
|
||||||
|
self.create_room_btn.setEnabled(False)
|
||||||
|
self.create_room_btn.clicked.connect(self.create_room)
|
||||||
|
|
||||||
|
create_panel.addWidget(self.create_file_btn)
|
||||||
|
create_panel.addWidget(self.create_file_info)
|
||||||
|
create_panel.addWidget(self.create_room_btn)
|
||||||
|
|
||||||
|
# Join Room Panel
|
||||||
|
join_panel = QVBoxLayout()
|
||||||
|
join_panel.addWidget(QLabel("Join a Room"))
|
||||||
|
self.room_code_input = QLineEdit()
|
||||||
|
self.room_code_input.setPlaceholderText("e.g. ABC123")
|
||||||
|
self.join_room_btn = QPushButton("Join Room")
|
||||||
|
self.join_room_btn.setObjectName("secondaryBtn")
|
||||||
|
self.join_room_btn.setEnabled(False)
|
||||||
|
self.join_room_btn.clicked.connect(self.join_room)
|
||||||
|
|
||||||
|
join_panel.addWidget(self.room_code_input)
|
||||||
|
join_panel.addWidget(self.join_room_btn)
|
||||||
|
|
||||||
|
actions_layout.addLayout(create_panel)
|
||||||
|
|
||||||
|
# Divider
|
||||||
|
divider = QLabel("OR")
|
||||||
|
divider.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
divider.setObjectName("divider")
|
||||||
|
actions_layout.addWidget(divider)
|
||||||
|
|
||||||
|
actions_layout.addLayout(join_panel)
|
||||||
|
|
||||||
|
container_layout.addLayout(actions_layout)
|
||||||
|
layout.addWidget(container)
|
||||||
|
|
||||||
|
# Signals to enable/disable buttons
|
||||||
|
self.username_input.textChanged.connect(self.check_inputs)
|
||||||
|
self.room_code_input.textChanged.connect(self.check_inputs)
|
||||||
|
|
||||||
|
def _set_local_file(self, file_path: str):
|
||||||
|
self.local_file_path = file_path
|
||||||
|
self.local_file_name = os.path.basename(file_path)
|
||||||
|
self.local_file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
size_mb = self.local_file_size / (1024 * 1024)
|
||||||
|
self.create_file_info.setText(f"{self.local_file_name}\n{size_mb:.1f} MB")
|
||||||
|
self.create_file_info.show()
|
||||||
|
self.check_inputs()
|
||||||
|
|
||||||
|
def select_file(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self, "Select Video File", "", "Video Files (*.mp4 *.mkv *.avi *.mov *.webm);;All Files (*)"
|
||||||
|
)
|
||||||
|
if file_path:
|
||||||
|
self._set_local_file(file_path)
|
||||||
|
|
||||||
|
def dragEnterEvent(self, event):
|
||||||
|
if event.mimeData().hasUrls():
|
||||||
|
event.accept()
|
||||||
|
else:
|
||||||
|
event.ignore()
|
||||||
|
|
||||||
|
def dropEvent(self, event):
|
||||||
|
for url in event.mimeData().urls():
|
||||||
|
file_path = url.toLocalFile()
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
if ext in ['.mp4', '.mkv', '.avi', '.mov', '.webm']:
|
||||||
|
self._set_local_file(file_path)
|
||||||
|
break
|
||||||
|
|
||||||
|
def check_inputs(self):
|
||||||
|
has_name = len(self.username_input.text().strip()) > 0
|
||||||
|
has_file = self.local_file_path is not None
|
||||||
|
has_code = len(self.room_code_input.text().strip()) >= 4
|
||||||
|
|
||||||
|
self.create_room_btn.setEnabled(has_name and has_file)
|
||||||
|
self.join_room_btn.setEnabled(has_name and has_code)
|
||||||
|
|
||||||
|
def create_room(self):
|
||||||
|
username = self.username_input.text().strip()
|
||||||
|
self.create_room_btn.setText("Connecting...")
|
||||||
|
self.create_room_btn.setEnabled(False)
|
||||||
|
self.join_room_btn.setEnabled(False)
|
||||||
|
self.create_requested.emit(username, self.local_file_path, self.local_file_name, self.local_file_size)
|
||||||
|
|
||||||
|
def join_room(self):
|
||||||
|
username = self.username_input.text().strip()
|
||||||
|
room_code = self.room_code_input.text().strip().upper()
|
||||||
|
self.join_room_btn.setText("Connecting...")
|
||||||
|
self.join_room_btn.setEnabled(False)
|
||||||
|
self.create_room_btn.setEnabled(False)
|
||||||
|
self.join_requested.emit(username, room_code)
|
||||||
|
|
||||||
|
def reset_ui(self):
|
||||||
|
self.create_room_btn.setText("Create Room")
|
||||||
|
self.join_room_btn.setText("Join Room")
|
||||||
|
self.check_inputs()
|
||||||
|
|
||||||
|
def clear_file(self):
|
||||||
|
self.local_file_path = None
|
||||||
|
self.local_file_name = None
|
||||||
|
self.local_file_size = 0
|
||||||
|
self.create_file_info.hide()
|
||||||
|
self.check_inputs()
|
||||||
@@ -2,22 +2,20 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import ctypes
|
import ctypes
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication, QMainWindow, QStackedWidget, QWidget, QVBoxLayout,
|
QApplication, QMainWindow, QStackedWidget, QFileDialog, QMessageBox
|
||||||
QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox,
|
|
||||||
QFrame, QSlider, QTextEdit
|
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal, QObject, QTimer
|
from PyQt6.QtCore import Qt, QTimer
|
||||||
from PyQt6.QtGui import QFont, QIcon, QColor
|
from PyQt6.QtGui import QIcon
|
||||||
from vlc_player import VLCSyncPlayer
|
|
||||||
from sync_client import SyncClientThread
|
from sync_client import SyncClientThread
|
||||||
import datetime
|
from lobby_widget import LobbyWidget
|
||||||
|
from room_widget import RoomWidget
|
||||||
|
|
||||||
class VlcSyncApp(QMainWindow):
|
class VlcSyncApp(QMainWindow):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle("VideoSync — Watch Together")
|
self.setWindowTitle("VideoSync — Watch Together")
|
||||||
|
|
||||||
# Set window icon
|
|
||||||
icon_path = os.path.join(os.path.dirname(__file__), "icon.png")
|
icon_path = os.path.join(os.path.dirname(__file__), "icon.png")
|
||||||
if os.path.exists(icon_path):
|
if os.path.exists(icon_path):
|
||||||
self.setWindowIcon(QIcon(icon_path))
|
self.setWindowIcon(QIcon(icon_path))
|
||||||
@@ -25,28 +23,33 @@ class VlcSyncApp(QMainWindow):
|
|||||||
self.setMinimumSize(900, 600)
|
self.setMinimumSize(900, 600)
|
||||||
self.resize(1100, 700)
|
self.resize(1100, 700)
|
||||||
|
|
||||||
# Main stacked widget to switch between Lobby and Room
|
# Main stacked widget
|
||||||
self.stacked_widget = QStackedWidget()
|
self.stacked_widget = QStackedWidget()
|
||||||
self.setCentralWidget(self.stacked_widget)
|
self.setCentralWidget(self.stacked_widget)
|
||||||
|
|
||||||
# Setup Views
|
# Setup Views
|
||||||
self.lobby_view = self.create_lobby_view()
|
self.lobby_widget = LobbyWidget()
|
||||||
self.room_view = self.create_room_view()
|
self.room_widget = RoomWidget()
|
||||||
|
|
||||||
self.stacked_widget.addWidget(self.lobby_view)
|
self.stacked_widget.addWidget(self.lobby_widget)
|
||||||
self.stacked_widget.addWidget(self.room_view)
|
self.stacked_widget.addWidget(self.room_widget)
|
||||||
|
|
||||||
# State
|
# Connect View Signals
|
||||||
|
self.lobby_widget.create_requested.connect(self._on_lobby_create)
|
||||||
|
self.lobby_widget.join_requested.connect(self._on_lobby_join)
|
||||||
|
self.room_widget.leave_requested.connect(self._on_room_leave)
|
||||||
|
self.room_widget.sync_action_requested.connect(self._on_room_sync_action)
|
||||||
|
self.room_widget.chat_message_ready.connect(self._on_room_chat)
|
||||||
|
|
||||||
|
# App State
|
||||||
self.username = ""
|
self.username = ""
|
||||||
self.room_code = ""
|
self.room_code = ""
|
||||||
self.local_file_path = None
|
self.local_file_path = None
|
||||||
self.local_file_name = None
|
self.local_file_name = None
|
||||||
self.local_file_size = 0
|
self.local_file_size = 0
|
||||||
|
|
||||||
self.pending_connect_action = None
|
self.pending_connect_action = None
|
||||||
self.ignore_vlc_events = False
|
|
||||||
self.last_reported_time_ms = 0
|
|
||||||
|
|
||||||
|
# Network Service
|
||||||
self.sync_client = SyncClientThread("wss://video-sync.peterstockings.com/ws")
|
self.sync_client = SyncClientThread("wss://video-sync.peterstockings.com/ws")
|
||||||
self.sync_client.connected.connect(self.on_ws_connected)
|
self.sync_client.connected.connect(self.on_ws_connected)
|
||||||
self.sync_client.disconnected.connect(self.on_ws_disconnected)
|
self.sync_client.disconnected.connect(self.on_ws_disconnected)
|
||||||
@@ -54,362 +57,18 @@ class VlcSyncApp(QMainWindow):
|
|||||||
self.sync_client.room_rejoined.connect(self.on_room_rejoined)
|
self.sync_client.room_rejoined.connect(self.on_room_rejoined)
|
||||||
self.sync_client.room_error.connect(self.on_room_error)
|
self.sync_client.room_error.connect(self.on_room_error)
|
||||||
self.sync_client.file_check_needed.connect(self.on_file_check_needed)
|
self.sync_client.file_check_needed.connect(self.on_file_check_needed)
|
||||||
self.sync_client.users_updated.connect(self.on_users_updated)
|
self.sync_client.users_updated.connect(self.room_widget.update_users)
|
||||||
self.sync_client.chat_message.connect(self.on_chat_message)
|
self.sync_client.chat_message.connect(self.room_widget.add_chat_message)
|
||||||
self.sync_client.system_message.connect(self.on_system_message)
|
self.sync_client.system_message.connect(self.room_widget.add_system_message)
|
||||||
self.sync_client.sync_event.connect(self.on_sync_event)
|
self.sync_client.sync_event.connect(self.room_widget.handle_sync_event)
|
||||||
|
|
||||||
self.apply_stylesheet()
|
self.apply_stylesheet()
|
||||||
|
|
||||||
def create_lobby_view(self):
|
def _on_lobby_create(self, username, file_path, file_name, file_size):
|
||||||
widget = QWidget()
|
self.username = username
|
||||||
layout = QVBoxLayout(widget)
|
self.local_file_path = file_path
|
||||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.local_file_name = file_name
|
||||||
|
self.local_file_size = file_size
|
||||||
# Container for centering
|
|
||||||
container = QFrame()
|
|
||||||
container.setObjectName("lobbyCard")
|
|
||||||
container.setFixedWidth(500)
|
|
||||||
container_layout = QVBoxLayout(container)
|
|
||||||
container_layout.setContentsMargins(30, 30, 30, 30)
|
|
||||||
container_layout.setSpacing(15)
|
|
||||||
|
|
||||||
# Brand
|
|
||||||
title = QLabel("VideoSync")
|
|
||||||
title.setObjectName("brandTitle")
|
|
||||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
tagline = QLabel("Watch together, anywhere")
|
|
||||||
tagline.setObjectName("brandTagline")
|
|
||||||
tagline.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
|
|
||||||
container_layout.addWidget(title)
|
|
||||||
container_layout.addWidget(tagline)
|
|
||||||
container_layout.addSpacing(20)
|
|
||||||
|
|
||||||
# Username
|
|
||||||
self.username_input = QLineEdit()
|
|
||||||
self.username_input.setPlaceholderText("Enter a display name")
|
|
||||||
container_layout.addWidget(QLabel("YOUR NAME"))
|
|
||||||
container_layout.addWidget(self.username_input)
|
|
||||||
|
|
||||||
container_layout.addSpacing(20)
|
|
||||||
|
|
||||||
# Actions Layout (Create vs Join)
|
|
||||||
actions_layout = QHBoxLayout()
|
|
||||||
actions_layout.setSpacing(20)
|
|
||||||
|
|
||||||
# Create Room Panel
|
|
||||||
create_panel = QVBoxLayout()
|
|
||||||
create_panel.addWidget(QLabel("Create a Room"))
|
|
||||||
self.create_file_btn = QPushButton("Choose Video File")
|
|
||||||
self.create_file_btn.setObjectName("secondaryBtn")
|
|
||||||
self.create_file_btn.clicked.connect(self.select_file)
|
|
||||||
|
|
||||||
self.create_file_info = QLabel("")
|
|
||||||
self.create_file_info.setObjectName("fileInfo")
|
|
||||||
self.create_file_info.setWordWrap(True)
|
|
||||||
self.create_file_info.hide()
|
|
||||||
|
|
||||||
self.create_room_btn = QPushButton("Create Room")
|
|
||||||
self.create_room_btn.setObjectName("primaryBtn")
|
|
||||||
self.create_room_btn.setEnabled(False)
|
|
||||||
self.create_room_btn.clicked.connect(self.create_room)
|
|
||||||
|
|
||||||
create_panel.addWidget(self.create_file_btn)
|
|
||||||
create_panel.addWidget(self.create_file_info)
|
|
||||||
create_panel.addWidget(self.create_room_btn)
|
|
||||||
|
|
||||||
# Join Room Panel
|
|
||||||
join_panel = QVBoxLayout()
|
|
||||||
join_panel.addWidget(QLabel("Join a Room"))
|
|
||||||
self.room_code_input = QLineEdit()
|
|
||||||
self.room_code_input.setPlaceholderText("e.g. ABC123")
|
|
||||||
self.join_room_btn = QPushButton("Join Room")
|
|
||||||
self.join_room_btn.setObjectName("secondaryBtn")
|
|
||||||
self.join_room_btn.setEnabled(False)
|
|
||||||
self.join_room_btn.clicked.connect(self.join_room)
|
|
||||||
|
|
||||||
join_panel.addWidget(self.room_code_input)
|
|
||||||
join_panel.addWidget(self.join_room_btn)
|
|
||||||
|
|
||||||
actions_layout.addLayout(create_panel)
|
|
||||||
|
|
||||||
# Divider
|
|
||||||
divider = QLabel("OR")
|
|
||||||
divider.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
divider.setObjectName("divider")
|
|
||||||
actions_layout.addWidget(divider)
|
|
||||||
|
|
||||||
actions_layout.addLayout(join_panel)
|
|
||||||
|
|
||||||
container_layout.addLayout(actions_layout)
|
|
||||||
layout.addWidget(container)
|
|
||||||
|
|
||||||
# Signals to enable/disable buttons
|
|
||||||
self.username_input.textChanged.connect(self.check_inputs)
|
|
||||||
self.room_code_input.textChanged.connect(self.check_inputs)
|
|
||||||
|
|
||||||
return widget
|
|
||||||
|
|
||||||
def create_room_view(self):
|
|
||||||
widget = QWidget()
|
|
||||||
layout = QHBoxLayout(widget)
|
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
layout.setSpacing(0)
|
|
||||||
|
|
||||||
# --- Left Side: Video ---
|
|
||||||
video_container = QWidget()
|
|
||||||
video_layout = QVBoxLayout(video_container)
|
|
||||||
video_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
video_layout.setSpacing(0)
|
|
||||||
|
|
||||||
# Topbar
|
|
||||||
self.topbar = QFrame()
|
|
||||||
self.topbar.setObjectName("topbar")
|
|
||||||
self.topbar.setFixedHeight(50)
|
|
||||||
topbar_layout = QHBoxLayout(self.topbar)
|
|
||||||
|
|
||||||
self.room_code_display = QLabel("Room: XXXX")
|
|
||||||
self.copy_code_btn = QPushButton()
|
|
||||||
self.copy_code_btn.setObjectName("iconBtn")
|
|
||||||
self.copy_icon = QIcon(os.path.join(os.path.dirname(__file__), "copy.svg"))
|
|
||||||
self.check_icon = QIcon(os.path.join(os.path.dirname(__file__), "check.svg"))
|
|
||||||
self.copy_code_btn.setIcon(self.copy_icon)
|
|
||||||
self.copy_code_btn.setFixedSize(30, 30)
|
|
||||||
self.copy_code_btn.setToolTip("Copy Room Code")
|
|
||||||
self.copy_code_btn.clicked.connect(self.copy_room_code)
|
|
||||||
|
|
||||||
self.room_file_badge = QLabel("📄 No file")
|
|
||||||
self.room_file_badge.setObjectName("fileBadge")
|
|
||||||
|
|
||||||
self.leave_btn = QPushButton("Leave Room")
|
|
||||||
self.leave_btn.setObjectName("dangerBtn")
|
|
||||||
self.leave_btn.clicked.connect(self.leave_room)
|
|
||||||
|
|
||||||
topbar_layout.addWidget(self.room_code_display)
|
|
||||||
topbar_layout.addWidget(self.copy_code_btn)
|
|
||||||
topbar_layout.addStretch()
|
|
||||||
topbar_layout.addWidget(self.room_file_badge)
|
|
||||||
topbar_layout.addWidget(self.leave_btn)
|
|
||||||
|
|
||||||
# Video Frame Placeholder
|
|
||||||
self.video_frame = QFrame()
|
|
||||||
self.video_frame.setStyleSheet("background-color: black;")
|
|
||||||
# Fix for Windows QWidgetWindow Error:
|
|
||||||
# Force the frame to have its own native HWND so VLC can attach to it without complaining it's not a top-level window.
|
|
||||||
self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
|
|
||||||
|
|
||||||
# Controls Bar
|
|
||||||
controls = QFrame()
|
|
||||||
controls.setObjectName("controlsBar")
|
|
||||||
controls.setFixedHeight(60)
|
|
||||||
controls_layout = QHBoxLayout(controls)
|
|
||||||
|
|
||||||
self.play_btn = QPushButton("▶")
|
|
||||||
self.play_btn.setFixedSize(40, 40)
|
|
||||||
self.play_btn.setObjectName("playBtn")
|
|
||||||
|
|
||||||
# Time and SeekBar
|
|
||||||
self.seekbar = QSlider(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.time_lbl = QLabel("00:00:00 / 00:00:00")
|
|
||||||
|
|
||||||
# Volume
|
|
||||||
self.vol_icon = QLabel("🔊")
|
|
||||||
self.vol_icon.setObjectName("volIcon")
|
|
||||||
self.volume_slider = QSlider(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)
|
|
||||||
|
|
||||||
# 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) # SeekBar gets the stretch
|
|
||||||
controls_layout.addWidget(self.time_lbl)
|
|
||||||
controls_layout.addSpacing(15)
|
|
||||||
controls_layout.addWidget(self.vol_icon)
|
|
||||||
controls_layout.addWidget(self.volume_slider)
|
|
||||||
controls_layout.addSpacing(10)
|
|
||||||
controls_layout.addWidget(self.fullscreen_btn)
|
|
||||||
|
|
||||||
video_layout.addWidget(self.topbar)
|
|
||||||
video_layout.addWidget(self.video_frame)
|
|
||||||
video_layout.addWidget(controls)
|
|
||||||
|
|
||||||
# --- Right Side: Chat ---
|
|
||||||
self.chat_container = QFrame()
|
|
||||||
self.chat_container.setObjectName("chatContainer")
|
|
||||||
self.chat_container.setFixedWidth(320)
|
|
||||||
chat_layout = QVBoxLayout(self.chat_container)
|
|
||||||
|
|
||||||
chat_header = QLabel("Live Chat")
|
|
||||||
chat_header.setObjectName("chatHeader")
|
|
||||||
|
|
||||||
self.users_lbl = QLabel("0 watching")
|
|
||||||
self.users_lbl.setObjectName("usersLbl")
|
|
||||||
|
|
||||||
self.chat_messages = QTextEdit()
|
|
||||||
self.chat_messages.setObjectName("chatMessages")
|
|
||||||
self.chat_messages.setReadOnly(True)
|
|
||||||
self.chat_messages.setHtml("Welcome to the room! 👋")
|
|
||||||
|
|
||||||
chat_input_layout = QHBoxLayout()
|
|
||||||
self.chat_input = QLineEdit()
|
|
||||||
self.chat_input.setPlaceholderText("Send a message...")
|
|
||||||
self.chat_send_btn = QPushButton("Send")
|
|
||||||
|
|
||||||
chat_input_layout.addWidget(self.chat_input)
|
|
||||||
chat_input_layout.addWidget(self.chat_send_btn)
|
|
||||||
|
|
||||||
# Chat actions
|
|
||||||
self.chat_send_btn.clicked.connect(self.send_chat)
|
|
||||||
self.chat_input.returnPressed.connect(self.send_chat)
|
|
||||||
|
|
||||||
chat_layout.addWidget(chat_header)
|
|
||||||
chat_layout.addWidget(self.users_lbl)
|
|
||||||
chat_layout.addWidget(self.chat_messages, 1) # stretch
|
|
||||||
chat_layout.addLayout(chat_input_layout)
|
|
||||||
|
|
||||||
layout.addWidget(video_container, 1) # stretch
|
|
||||||
layout.addWidget(self.chat_container)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
return widget
|
|
||||||
|
|
||||||
def toggle_fullscreen(self):
|
|
||||||
if self.isFullScreen():
|
|
||||||
self.showNormal()
|
|
||||||
self.fullscreen_btn.setText("⛶")
|
|
||||||
self.chat_container.show()
|
|
||||||
self.topbar.show()
|
|
||||||
else:
|
|
||||||
self.showFullScreen()
|
|
||||||
self.fullscreen_btn.setText("🗗")
|
|
||||||
self.chat_container.hide()
|
|
||||||
self.topbar.hide()
|
|
||||||
|
|
||||||
def toggle_playback(self):
|
|
||||||
position_s = self.vlc_player.current_time_ms / 1000.0
|
|
||||||
if self.vlc_player.is_playing:
|
|
||||||
self.vlc_player.pause()
|
|
||||||
self.play_btn.setText("▶")
|
|
||||||
self.sync_client.send_message({"type": "sync", "action": "pause", "position": position_s})
|
|
||||||
else:
|
|
||||||
self.vlc_player.play()
|
|
||||||
self.play_btn.setText("⏸")
|
|
||||||
self.sync_client.send_message({"type": "sync", "action": "play", "position": position_s})
|
|
||||||
|
|
||||||
def on_vlc_time(self, time_ms: int):
|
|
||||||
length_ms = self.vlc_player.get_length()
|
|
||||||
if length_ms > 0:
|
|
||||||
def fmt(ms):
|
|
||||||
s = max(0, ms) // 1000
|
|
||||||
return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
|
|
||||||
|
|
||||||
if not self.seekbar.isSliderDown():
|
|
||||||
self.time_lbl.setText(f"{fmt(time_ms)} / {fmt(length_ms)}")
|
|
||||||
progress = int((time_ms / length_ms) * 1000)
|
|
||||||
self.seekbar.blockSignals(True)
|
|
||||||
self.seekbar.setValue(progress)
|
|
||||||
self.seekbar.blockSignals(False)
|
|
||||||
|
|
||||||
if self.last_reported_time_ms is not None:
|
|
||||||
diff = abs(time_ms - self.last_reported_time_ms)
|
|
||||||
if diff > 2500 and not self.ignore_vlc_events:
|
|
||||||
self.sync_client.send_message({"type": "sync", "action": "seek", "position": time_ms / 1000.0})
|
|
||||||
self.last_reported_time_ms = time_ms
|
|
||||||
|
|
||||||
def on_vlc_state(self, playing: bool, time_ms: int):
|
|
||||||
if self.ignore_vlc_events:
|
|
||||||
return
|
|
||||||
action = "play" if playing else "pause"
|
|
||||||
self.sync_client.send_message({"type": "sync", "action": action, "position": time_ms / 1000.0})
|
|
||||||
|
|
||||||
def on_seekbar_dragged(self, value):
|
|
||||||
length_ms = self.vlc_player.get_length()
|
|
||||||
if length_ms > 0:
|
|
||||||
target_ms = int((value / 1000.0) * length_ms)
|
|
||||||
def fmt(ms):
|
|
||||||
s = max(0, ms) // 1000
|
|
||||||
return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
|
|
||||||
self.time_lbl.setText(f"{fmt(target_ms)} / {fmt(length_ms)}")
|
|
||||||
|
|
||||||
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)
|
|
||||||
self.vlc_player.seek(target_ms)
|
|
||||||
self.sync_client.send_message({"type": "sync", "action": "seek", "position": target_ms / 1000.0})
|
|
||||||
|
|
||||||
def on_volume_changed(self, value):
|
|
||||||
self.vlc_player.set_volume(value)
|
|
||||||
|
|
||||||
def select_file(self):
|
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
|
||||||
self, "Select Video File", "", "Video Files (*.mp4 *.mkv *.avi *.mov *.webm);;All Files (*)"
|
|
||||||
)
|
|
||||||
if file_path:
|
|
||||||
self.local_file_path = file_path
|
|
||||||
self.local_file_name = os.path.basename(file_path)
|
|
||||||
self.local_file_size = os.path.getsize(file_path)
|
|
||||||
|
|
||||||
size_mb = self.local_file_size / (1024 * 1024)
|
|
||||||
self.create_file_info.setText(f"{self.local_file_name}\n{size_mb:.1f} MB")
|
|
||||||
self.create_file_info.show()
|
|
||||||
self.check_inputs()
|
|
||||||
|
|
||||||
def copy_room_code(self):
|
|
||||||
if self.room_code:
|
|
||||||
QApplication.clipboard().setText(self.room_code)
|
|
||||||
self.copy_code_btn.setIcon(self.check_icon)
|
|
||||||
|
|
||||||
# Show floating tooltip for feedback
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Position the toast slightly below the button
|
|
||||||
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 check_inputs(self):
|
|
||||||
has_name = len(self.username_input.text().strip()) > 0
|
|
||||||
has_file = self.local_file_path is not None
|
|
||||||
has_code = len(self.room_code_input.text().strip()) >= 4
|
|
||||||
|
|
||||||
self.create_room_btn.setEnabled(has_name and has_file)
|
|
||||||
self.join_room_btn.setEnabled(has_name and has_code)
|
|
||||||
|
|
||||||
def create_room(self):
|
|
||||||
self.username = self.username_input.text().strip()
|
|
||||||
self.create_room_btn.setText("Connecting...")
|
|
||||||
self.create_room_btn.setEnabled(False)
|
|
||||||
self.join_room_btn.setEnabled(False)
|
|
||||||
|
|
||||||
self.pending_connect_action = lambda: self.sync_client.send_message({
|
self.pending_connect_action = lambda: self.sync_client.send_message({
|
||||||
"type": "create_room",
|
"type": "create_room",
|
||||||
@@ -420,40 +79,38 @@ class VlcSyncApp(QMainWindow):
|
|||||||
"duration": 0
|
"duration": 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
self._ensure_connection()
|
||||||
if self.sync_client.running and self.sync_client.ws:
|
|
||||||
self.on_ws_connected()
|
|
||||||
else:
|
|
||||||
self.sync_client.start()
|
|
||||||
|
|
||||||
def join_room(self):
|
def _on_lobby_join(self, username, room_code):
|
||||||
self.username = self.username_input.text().strip()
|
self.username = username
|
||||||
self.room_code = self.room_code_input.text().strip().upper()
|
self.room_code = room_code
|
||||||
self.join_room_btn.setText("Connecting...")
|
|
||||||
self.join_room_btn.setEnabled(False)
|
|
||||||
self.create_room_btn.setEnabled(False)
|
|
||||||
|
|
||||||
self.pending_connect_action = lambda: self.sync_client.send_message({
|
self.pending_connect_action = lambda: self.sync_client.send_message({
|
||||||
"type": "join_room",
|
"type": "join_room",
|
||||||
"username": self.username,
|
"username": self.username,
|
||||||
"code": self.room_code
|
"code": self.room_code
|
||||||
})
|
})
|
||||||
|
self._ensure_connection()
|
||||||
|
|
||||||
|
def _ensure_connection(self):
|
||||||
if self.sync_client.running and self.sync_client.ws:
|
if self.sync_client.running and self.sync_client.ws:
|
||||||
self.on_ws_connected()
|
self.on_ws_connected()
|
||||||
else:
|
else:
|
||||||
self.sync_client.start()
|
self.sync_client.start()
|
||||||
|
|
||||||
def leave_room(self):
|
def _on_room_leave(self):
|
||||||
self.vlc_player.stop()
|
self.room_widget.cleanup()
|
||||||
self.sync_client.stop()
|
self.sync_client.stop()
|
||||||
self.stacked_widget.setCurrentIndex(0)
|
self.stacked_widget.setCurrentIndex(0)
|
||||||
self.local_file_path = None
|
|
||||||
self.create_file_info.hide()
|
|
||||||
self.room_code = ""
|
self.room_code = ""
|
||||||
self.check_inputs()
|
self.local_file_path = None
|
||||||
self.create_room_btn.setText("Create Room")
|
self.lobby_widget.clear_file()
|
||||||
self.join_room_btn.setText("Join Room")
|
self.lobby_widget.reset_ui()
|
||||||
|
|
||||||
|
def _on_room_sync_action(self, action, position_s, req_id):
|
||||||
|
self.sync_client.send_message({"type": "sync", "action": action, "position": position_s, "req_id": req_id})
|
||||||
|
|
||||||
|
def _on_room_chat(self, text):
|
||||||
|
self.sync_client.send_message({"type": "chat", "message": text})
|
||||||
|
|
||||||
# --- WebSocket Callbacks ---
|
# --- WebSocket Callbacks ---
|
||||||
def on_ws_connected(self):
|
def on_ws_connected(self):
|
||||||
@@ -461,7 +118,6 @@ class VlcSyncApp(QMainWindow):
|
|||||||
self.pending_connect_action()
|
self.pending_connect_action()
|
||||||
self.pending_connect_action = None
|
self.pending_connect_action = None
|
||||||
elif self.stacked_widget.currentIndex() == 1 and self.room_code and self.username:
|
elif self.stacked_widget.currentIndex() == 1 and self.room_code and self.username:
|
||||||
# We are already in a room and just reconnected
|
|
||||||
self.sync_client.send_message({
|
self.sync_client.send_message({
|
||||||
"type": "rejoin_room",
|
"type": "rejoin_room",
|
||||||
"username": self.username,
|
"username": self.username,
|
||||||
@@ -470,25 +126,25 @@ class VlcSyncApp(QMainWindow):
|
|||||||
|
|
||||||
def on_ws_disconnected(self):
|
def on_ws_disconnected(self):
|
||||||
if self.stacked_widget.currentIndex() == 1:
|
if self.stacked_widget.currentIndex() == 1:
|
||||||
self.room_code_display.setText(f"Room: {self.room_code} (Reconnecting...)")
|
self.room_widget.set_room_code_display(f"{self.room_code} (Reconnecting...)")
|
||||||
self.on_system_message("⚠️ Connection lost. Trying to reconnect...")
|
self.room_widget.add_system_message("⚠️ Connection lost. Trying to reconnect...")
|
||||||
|
else:
|
||||||
|
self.lobby_widget.reset_ui()
|
||||||
|
self.pending_connect_action = None
|
||||||
|
|
||||||
def on_room_error(self, msg: str):
|
def on_room_error(self, msg: str):
|
||||||
QMessageBox.critical(self, "Room Error", msg)
|
QMessageBox.critical(self, "Room Error", msg)
|
||||||
self.create_room_btn.setText("Create Room")
|
self.lobby_widget.reset_ui()
|
||||||
self.join_room_btn.setText("Join Room")
|
|
||||||
self.check_inputs()
|
|
||||||
self.sync_client.stop()
|
self.sync_client.stop()
|
||||||
|
|
||||||
def on_file_check_needed(self, msg: dict):
|
def on_file_check_needed(self, msg: dict):
|
||||||
# Defer execution to the PyQt Main Thread to avoid deadlocking the WebSocket thread
|
|
||||||
QTimer.singleShot(0, lambda: self._handle_file_check(msg))
|
QTimer.singleShot(0, lambda: self._handle_file_check(msg))
|
||||||
|
|
||||||
def _handle_file_check(self, msg: dict):
|
def _handle_file_check(self, msg: dict):
|
||||||
req_name = msg["fileInfo"].get("name", "Unknown")
|
req_name = msg["fileInfo"].get("name", "Unknown")
|
||||||
req_size = msg["fileInfo"].get("size", 0)
|
req_size = msg["fileInfo"].get("size", 0)
|
||||||
|
|
||||||
QMessageBox.information(self, "File Required", f"To join this room, you need to select:\\n\\nName: {req_name}\\nSize: {req_size / (1024*1024):.1f} MB")
|
QMessageBox.information(self, "File Required", f"To join this room, you need to select:\n\nName: {req_name}\nSize: {req_size / (1024*1024):.1f} MB")
|
||||||
|
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
self, f"Select {req_name}", "", f"Required File ({req_name})"
|
self, f"Select {req_name}", "", f"Required File ({req_name})"
|
||||||
@@ -499,7 +155,7 @@ class VlcSyncApp(QMainWindow):
|
|||||||
self.local_file_name = os.path.basename(file_path)
|
self.local_file_name = os.path.basename(file_path)
|
||||||
self.local_file_size = os.path.getsize(file_path)
|
self.local_file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
if self.local_file_name == req_name and self.local_file_size == req_size:
|
if self.local_file_name.lower() == req_name.lower() and int(self.local_file_size) == int(req_size):
|
||||||
self.sync_client.send_message({
|
self.sync_client.send_message({
|
||||||
"type": "confirm_join",
|
"type": "confirm_join",
|
||||||
"fileInfo": {
|
"fileInfo": {
|
||||||
@@ -509,14 +165,13 @@ class VlcSyncApp(QMainWindow):
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
QMessageBox.critical(self, "File Mismatch", "The selected file does not exactly match the room's required file.")
|
err_msg = f"Expected: {req_name} ({req_size} bytes)\nGot: {self.local_file_name} ({self.local_file_size} bytes)"
|
||||||
|
QMessageBox.critical(self, "File Mismatch", f"The selected file does not exactly match the room's required file.\n\n{err_msg}")
|
||||||
self.sync_client.stop()
|
self.sync_client.stop()
|
||||||
self.join_room_btn.setText("Join Room")
|
self.lobby_widget.reset_ui()
|
||||||
self.check_inputs()
|
|
||||||
else:
|
else:
|
||||||
self.sync_client.stop()
|
self.sync_client.stop()
|
||||||
self.join_room_btn.setText("Join Room")
|
self.lobby_widget.reset_ui()
|
||||||
self.check_inputs()
|
|
||||||
|
|
||||||
def on_room_joined(self, msg: dict):
|
def on_room_joined(self, msg: dict):
|
||||||
if "room" in msg:
|
if "room" in msg:
|
||||||
@@ -525,189 +180,36 @@ class VlcSyncApp(QMainWindow):
|
|||||||
self.room_code = msg.get("code", "")
|
self.room_code = msg.get("code", "")
|
||||||
|
|
||||||
self.stacked_widget.setCurrentIndex(1)
|
self.stacked_widget.setCurrentIndex(1)
|
||||||
self.room_code_display.setText(f"Room: {self.room_code}")
|
self.lobby_widget.reset_ui()
|
||||||
self.room_file_badge.setText(f"📄 {self.local_file_name}")
|
|
||||||
self.create_room_btn.setText("Create Room")
|
|
||||||
self.join_room_btn.setText("Join Room")
|
|
||||||
self.chat_messages.setHtml("Welcome to the room! 👋")
|
|
||||||
|
|
||||||
state = msg.get("state", {})
|
state = msg.get("state", {})
|
||||||
start_time_s = state.get("position", 0.0) if state else 0.0
|
start_time_s = state.get("position", 0.0) if state else 0.0
|
||||||
|
|
||||||
if self.local_file_path:
|
self.room_widget.setup_room(self.room_code, self.username, self.local_file_name, self.local_file_path, start_time_s)
|
||||||
self.vlc_player.load_media(self.local_file_path, start_time_s)
|
|
||||||
self.vlc_player.set_volume(self.volume_slider.value())
|
|
||||||
|
|
||||||
users = msg.get("users", [])
|
users = msg.get("users", [])
|
||||||
if users:
|
if users:
|
||||||
self.on_users_updated(users)
|
self.room_widget.update_users(users)
|
||||||
|
|
||||||
if state:
|
if state:
|
||||||
self.on_sync_event(state)
|
self.room_widget.handle_sync_event(state)
|
||||||
|
|
||||||
chat_history = msg.get("chatHistory", [])
|
chat_history = msg.get("chatHistory", [])
|
||||||
if chat_history:
|
if chat_history:
|
||||||
self.chat_messages.setHtml("Welcome to the room! 👋")
|
|
||||||
for chat in chat_history:
|
for chat in chat_history:
|
||||||
self.on_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0))
|
self.room_widget.add_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0))
|
||||||
|
|
||||||
def on_room_rejoined(self, msg: dict):
|
def on_room_rejoined(self, msg: dict):
|
||||||
self.room_code_display.setText(f"Room: {self.room_code}")
|
self.room_widget.set_room_code_display(self.room_code)
|
||||||
self.on_system_message("✅ Reconnected to the room.")
|
self.room_widget.add_system_message("✅ Reconnected to the room.")
|
||||||
|
|
||||||
users = msg.get("users", [])
|
users = msg.get("users", [])
|
||||||
if users:
|
if users:
|
||||||
self.on_users_updated(users)
|
self.room_widget.update_users(users)
|
||||||
|
|
||||||
state = msg.get("state", {})
|
state = msg.get("state", {})
|
||||||
if state:
|
if state:
|
||||||
# Don't reload media, just resync playback state
|
self.room_widget.handle_sync_event(state)
|
||||||
self.on_sync_event(state)
|
|
||||||
|
|
||||||
def on_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")
|
|
||||||
|
|
||||||
new_msg = f"<b>{author}</b>: {text} <span style='color: gray; font-size: 10px;'>{time_str}</span>"
|
|
||||||
self.chat_messages.append(new_msg)
|
|
||||||
|
|
||||||
def on_system_message(self, text: str):
|
|
||||||
new_msg = f"<i style='color: #888;'>{text}</i>"
|
|
||||||
self.chat_messages.append(new_msg)
|
|
||||||
|
|
||||||
def on_users_updated(self, users: list):
|
|
||||||
self.users_lbl.setText(f"{len(users)} watching: {', '.join(users)}")
|
|
||||||
|
|
||||||
def on_sync_event(self, msg: dict):
|
|
||||||
self.ignore_vlc_events = True
|
|
||||||
|
|
||||||
action = msg.get("action")
|
|
||||||
# Handle full state sync vs event sync
|
|
||||||
if not action:
|
|
||||||
if msg.get("playing", False):
|
|
||||||
action = "play"
|
|
||||||
elif msg.get("playing") is False:
|
|
||||||
action = "pause"
|
|
||||||
|
|
||||||
position_s = msg.get("position", 0)
|
|
||||||
position_ms = int(position_s * 1000)
|
|
||||||
|
|
||||||
if action == "play":
|
|
||||||
self.vlc_player.seek(position_ms)
|
|
||||||
self.vlc_player.play()
|
|
||||||
self.play_btn.setText("⏸")
|
|
||||||
elif action == "pause":
|
|
||||||
self.vlc_player.seek(position_ms)
|
|
||||||
self.vlc_player.pause()
|
|
||||||
self.play_btn.setText("▶")
|
|
||||||
elif action == "seek":
|
|
||||||
self.vlc_player.seek(position_ms)
|
|
||||||
|
|
||||||
def clear_ignore():
|
|
||||||
self.ignore_vlc_events = False
|
|
||||||
QTimer.singleShot(1500, clear_ignore)
|
|
||||||
|
|
||||||
# System notification
|
|
||||||
username = msg.get("username")
|
|
||||||
if username and username != self.username:
|
|
||||||
if action == "play":
|
|
||||||
self.on_system_message(f"{username} pressed play")
|
|
||||||
elif action == "pause":
|
|
||||||
self.on_system_message(f"{username} paused")
|
|
||||||
elif action == "seek":
|
|
||||||
def fmt(s): return f"{int(s)//3600:02d}:{(int(s)%3600)//60:02d}:{int(s)%60:02d}"
|
|
||||||
self.on_system_message(f"{username} seeked to {fmt(position_s)}")
|
|
||||||
|
|
||||||
def _handle_seek_command(self, arg: str) -> bool:
|
|
||||||
current_ms = self.vlc_player.current_time_ms
|
|
||||||
length_ms = self.vlc_player.get_length()
|
|
||||||
if length_ms <= 0:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
target_ms = 0
|
|
||||||
if arg.startswith('+') or arg.startswith('-'):
|
|
||||||
# relative
|
|
||||||
modifier = 1 if arg.startswith('+') else -1
|
|
||||||
num_str = arg[1:]
|
|
||||||
|
|
||||||
if num_str.endswith('s'):
|
|
||||||
val = float(num_str[:-1]) * 1000
|
|
||||||
elif num_str.endswith('m'):
|
|
||||||
val = float(num_str[:-1]) * 60 * 1000
|
|
||||||
elif num_str.endswith('h'):
|
|
||||||
val = float(num_str[:-1]) * 3600 * 1000
|
|
||||||
else:
|
|
||||||
val = float(num_str) * 1000 # Default to seconds
|
|
||||||
|
|
||||||
target_ms = current_ms + (val * modifier)
|
|
||||||
elif ":" in arg:
|
|
||||||
# absolute time like HH:MM:SS or MM:SS
|
|
||||||
parts = arg.split(":")
|
|
||||||
parts.reverse() # seconds, minutes, hours
|
|
||||||
if len(parts) > 0: target_ms += float(parts[0]) * 1000
|
|
||||||
if len(parts) > 1: target_ms += float(parts[1]) * 60 * 1000
|
|
||||||
if len(parts) > 2: target_ms += float(parts[2]) * 3600 * 1000
|
|
||||||
else:
|
|
||||||
# absolute seconds or something with a suffix but no + or -
|
|
||||||
if arg.endswith('s'):
|
|
||||||
target_ms = float(arg[:-1]) * 1000
|
|
||||||
elif arg.endswith('m'):
|
|
||||||
target_ms = float(arg[:-1]) * 60 * 1000
|
|
||||||
elif arg.endswith('h'):
|
|
||||||
target_ms = float(arg[:-1]) * 3600 * 1000
|
|
||||||
else:
|
|
||||||
target_ms = float(arg) * 1000
|
|
||||||
|
|
||||||
target_ms = max(0, min(target_ms, length_ms))
|
|
||||||
self.vlc_player.seek(int(target_ms))
|
|
||||||
self.sync_client.send_message({"type": "sync", "action": "seek", "position": target_ms / 1000.0})
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def send_chat(self):
|
|
||||||
text = self.chat_input.text().strip()
|
|
||||||
if not text:
|
|
||||||
return
|
|
||||||
|
|
||||||
if text.startswith("/"):
|
|
||||||
parts = text.split()
|
|
||||||
cmd = parts[0].lower()
|
|
||||||
|
|
||||||
if cmd == "/play":
|
|
||||||
self.chat_input.setText("")
|
|
||||||
if not self.vlc_player.is_playing:
|
|
||||||
self.toggle_playback()
|
|
||||||
self.on_system_message(text)
|
|
||||||
return
|
|
||||||
elif cmd == "/pause":
|
|
||||||
self.chat_input.setText("")
|
|
||||||
if self.vlc_player.is_playing:
|
|
||||||
self.toggle_playback()
|
|
||||||
self.on_system_message(text)
|
|
||||||
return
|
|
||||||
elif cmd == "/seek":
|
|
||||||
self.chat_input.setText("")
|
|
||||||
if len(parts) > 1:
|
|
||||||
if self._handle_seek_command(parts[1]):
|
|
||||||
self.on_system_message(text)
|
|
||||||
else:
|
|
||||||
self.on_system_message("Invalid time format. Use: 1:23, +30s, -1m")
|
|
||||||
else:
|
|
||||||
self.on_system_message("Usage: /seek [time]")
|
|
||||||
return
|
|
||||||
elif cmd == "/help":
|
|
||||||
self.chat_input.setText("")
|
|
||||||
self.on_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")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.sync_client.send_message({"type": "chat", "message": text})
|
|
||||||
self.chat_input.setText("")
|
|
||||||
|
|
||||||
def apply_stylesheet(self):
|
def apply_stylesheet(self):
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet("""
|
||||||
@@ -888,7 +390,7 @@ class VlcSyncApp(QMainWindow):
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Tell Windows this is a distinct app so the taskbar icon updates correctly
|
import ctypes
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
myappid = 'vlcsync.desktopclient.app.1'
|
myappid = 'vlcsync.desktopclient.app.1'
|
||||||
try:
|
try:
|
||||||
@@ -898,7 +400,6 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
# Set app-level icon
|
|
||||||
icon_path = os.path.join(os.path.dirname(__file__), "icon.png")
|
icon_path = os.path.join(os.path.dirname(__file__), "icon.png")
|
||||||
if os.path.exists(icon_path):
|
if os.path.exists(icon_path):
|
||||||
app.setWindowIcon(QIcon(icon_path))
|
app.setWindowIcon(QIcon(icon_path))
|
||||||
@@ -906,3 +407,4 @@ if __name__ == "__main__":
|
|||||||
window = VlcSyncApp()
|
window = VlcSyncApp()
|
||||||
window.show()
|
window.show()
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|||||||
450
desktop-client/room_widget.py
Normal file
450
desktop-client/room_widget.py
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
import os
|
||||||
|
import datetime
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||||
|
QPushButton, QFrame, QSlider, QTextEdit, QApplication
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
|
||||||
|
from PyQt6.QtGui import QIcon
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from vlc_player import VLCSyncPlayer
|
||||||
|
|
||||||
|
class ExpectedVlcEvent:
|
||||||
|
def __init__(self, action: str, req_id: str, target_val=None):
|
||||||
|
self.action = action
|
||||||
|
self.req_id = req_id
|
||||||
|
self.target_val = target_val
|
||||||
|
self.timestamp = datetime.datetime.now()
|
||||||
|
|
||||||
|
class RoomWidget(QWidget):
|
||||||
|
leave_requested = pyqtSignal()
|
||||||
|
sync_action_requested = pyqtSignal(str, float, str) # action, position_s, req_id
|
||||||
|
chat_message_ready = pyqtSignal(str) # raw message text
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.username = ""
|
||||||
|
self.room_code = ""
|
||||||
|
self.expected_vlc_events = []
|
||||||
|
self.last_reported_time_ms = 0
|
||||||
|
self.current_users = []
|
||||||
|
self._is_first_user_update = True
|
||||||
|
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
# --- Left Side: Video ---
|
||||||
|
video_container = QWidget()
|
||||||
|
video_layout = QVBoxLayout(video_container)
|
||||||
|
video_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
video_layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Topbar
|
||||||
|
self.topbar = QFrame()
|
||||||
|
self.topbar.setObjectName("topbar")
|
||||||
|
self.topbar.setFixedHeight(50)
|
||||||
|
topbar_layout = QHBoxLayout(self.topbar)
|
||||||
|
|
||||||
|
self.room_code_display = QLabel("Room: XXXX")
|
||||||
|
self.copy_code_btn = QPushButton()
|
||||||
|
self.copy_code_btn.setObjectName("iconBtn")
|
||||||
|
self.copy_icon = QIcon(os.path.join(os.path.dirname(__file__), "copy.svg"))
|
||||||
|
self.check_icon = QIcon(os.path.join(os.path.dirname(__file__), "check.svg"))
|
||||||
|
self.copy_code_btn.setIcon(self.copy_icon)
|
||||||
|
self.copy_code_btn.setFixedSize(30, 30)
|
||||||
|
self.copy_code_btn.setToolTip("Copy Room Code")
|
||||||
|
self.copy_code_btn.clicked.connect(self.copy_room_code)
|
||||||
|
|
||||||
|
self.room_file_badge = QLabel("📄 No file")
|
||||||
|
self.room_file_badge.setObjectName("fileBadge")
|
||||||
|
|
||||||
|
self.leave_btn = QPushButton("Leave Room")
|
||||||
|
self.leave_btn.setObjectName("dangerBtn")
|
||||||
|
self.leave_btn.clicked.connect(self.leave_requested.emit)
|
||||||
|
|
||||||
|
topbar_layout.addWidget(self.room_code_display)
|
||||||
|
topbar_layout.addWidget(self.copy_code_btn)
|
||||||
|
topbar_layout.addStretch()
|
||||||
|
topbar_layout.addWidget(self.room_file_badge)
|
||||||
|
topbar_layout.addWidget(self.leave_btn)
|
||||||
|
|
||||||
|
# Video Frame Placeholder
|
||||||
|
self.video_frame = QFrame()
|
||||||
|
self.video_frame.setStyleSheet("background-color: black;")
|
||||||
|
self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
|
||||||
|
|
||||||
|
# Controls Bar
|
||||||
|
controls = QFrame()
|
||||||
|
controls.setObjectName("controlsBar")
|
||||||
|
controls.setFixedHeight(60)
|
||||||
|
controls_layout = QHBoxLayout(controls)
|
||||||
|
|
||||||
|
self.play_btn = QPushButton("▶")
|
||||||
|
self.play_btn.setFixedSize(40, 40)
|
||||||
|
self.play_btn.setObjectName("playBtn")
|
||||||
|
|
||||||
|
# Time and SeekBar
|
||||||
|
self.seekbar = QSlider(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.time_lbl = QLabel("00:00:00 / 00:00:00")
|
||||||
|
|
||||||
|
# Volume
|
||||||
|
self.vol_icon = QLabel("🔊")
|
||||||
|
self.vol_icon.setObjectName("volIcon")
|
||||||
|
self.volume_slider = QSlider(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)
|
||||||
|
|
||||||
|
# Fullscreen
|
||||||
|
self.fullscreen_btn = QPushButton("⛶")
|
||||||
|
self.fullscreen_btn.setFixedSize(40, 40)
|
||||||
|
self.fullscreen_btn.setObjectName("iconBtn")
|
||||||
|
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
|
||||||
|
|
||||||
|
controls_layout.addWidget(self.play_btn)
|
||||||
|
controls_layout.addWidget(self.seekbar, 1)
|
||||||
|
controls_layout.addWidget(self.time_lbl)
|
||||||
|
controls_layout.addSpacing(15)
|
||||||
|
controls_layout.addWidget(self.vol_icon)
|
||||||
|
controls_layout.addWidget(self.volume_slider)
|
||||||
|
controls_layout.addSpacing(10)
|
||||||
|
controls_layout.addWidget(self.fullscreen_btn)
|
||||||
|
|
||||||
|
video_layout.addWidget(self.topbar)
|
||||||
|
video_layout.addWidget(self.video_frame)
|
||||||
|
video_layout.addWidget(controls)
|
||||||
|
|
||||||
|
# --- Right Side: Chat ---
|
||||||
|
self.chat_container = QFrame()
|
||||||
|
self.chat_container.setObjectName("chatContainer")
|
||||||
|
self.chat_container.setFixedWidth(320)
|
||||||
|
chat_layout = QVBoxLayout(self.chat_container)
|
||||||
|
|
||||||
|
chat_header = QLabel("Live Chat")
|
||||||
|
chat_header.setObjectName("chatHeader")
|
||||||
|
|
||||||
|
self.users_lbl = QLabel("0 watching")
|
||||||
|
self.users_lbl.setObjectName("usersLbl")
|
||||||
|
|
||||||
|
self.chat_messages = QTextEdit()
|
||||||
|
self.chat_messages.setObjectName("chatMessages")
|
||||||
|
self.chat_messages.setReadOnly(True)
|
||||||
|
self.chat_messages.setHtml("Welcome to the room! 👋")
|
||||||
|
|
||||||
|
chat_input_layout = QHBoxLayout()
|
||||||
|
self.chat_input = QLineEdit()
|
||||||
|
self.chat_input.setPlaceholderText("Send a message...")
|
||||||
|
self.chat_send_btn = QPushButton("Send")
|
||||||
|
|
||||||
|
chat_input_layout.addWidget(self.chat_input)
|
||||||
|
chat_input_layout.addWidget(self.chat_send_btn)
|
||||||
|
|
||||||
|
# Chat actions
|
||||||
|
self.chat_send_btn.clicked.connect(self.send_chat)
|
||||||
|
self.chat_input.returnPressed.connect(self.send_chat)
|
||||||
|
|
||||||
|
chat_layout.addWidget(chat_header)
|
||||||
|
chat_layout.addWidget(self.users_lbl)
|
||||||
|
chat_layout.addWidget(self.chat_messages, 1)
|
||||||
|
chat_layout.addLayout(chat_input_layout)
|
||||||
|
|
||||||
|
layout.addWidget(video_container, 1)
|
||||||
|
layout.addWidget(self.chat_container)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
def setup_room(self, room_code: str, username: str, file_name: str, file_path: str, start_time_s: float = 0.0):
|
||||||
|
self.room_code = room_code
|
||||||
|
self.username = username
|
||||||
|
self.set_room_code_display(room_code)
|
||||||
|
self.room_file_badge.setText(f"📄 {file_name}")
|
||||||
|
self.chat_messages.setHtml("Welcome to the room! 👋")
|
||||||
|
self.current_users = []
|
||||||
|
self._is_first_user_update = True
|
||||||
|
|
||||||
|
if file_path:
|
||||||
|
self.vlc_player.load_media(file_path, start_time_s)
|
||||||
|
self.vlc_player.set_volume(self.volume_slider.value())
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
self.vlc_player.stop()
|
||||||
|
self.current_users = []
|
||||||
|
self._is_first_user_update = True
|
||||||
|
|
||||||
|
def set_room_code_display(self, text: str):
|
||||||
|
self.room_code_display.setText(f"Room: {text}")
|
||||||
|
|
||||||
|
def toggle_fullscreen(self):
|
||||||
|
top_window = self.window()
|
||||||
|
if top_window.isFullScreen():
|
||||||
|
top_window.showNormal()
|
||||||
|
self.fullscreen_btn.setText("⛶")
|
||||||
|
self.chat_container.show()
|
||||||
|
self.topbar.show()
|
||||||
|
else:
|
||||||
|
top_window.showFullScreen()
|
||||||
|
self.fullscreen_btn.setText("🗗")
|
||||||
|
self.chat_container.hide()
|
||||||
|
self.topbar.hide()
|
||||||
|
|
||||||
|
def copy_room_code(self):
|
||||||
|
if self.room_code:
|
||||||
|
QApplication.clipboard().setText(self.room_code)
|
||||||
|
self.copy_code_btn.setIcon(self.check_icon)
|
||||||
|
|
||||||
|
toast = QLabel("Copied!", self)
|
||||||
|
toast.setStyleSheet("background-color: #4BB543; color: white; padding: 4px 8px; border-radius: 4px; font-weight: bold;")
|
||||||
|
toast.setWindowFlags(Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint)
|
||||||
|
|
||||||
|
pos = self.copy_code_btn.mapToGlobal(self.copy_code_btn.rect().bottomLeft())
|
||||||
|
toast.move(pos.x(), pos.y() + 5)
|
||||||
|
toast.show()
|
||||||
|
|
||||||
|
def reset():
|
||||||
|
self.copy_code_btn.setIcon(self.copy_icon)
|
||||||
|
toast.deleteLater()
|
||||||
|
QTimer.singleShot(1500, reset)
|
||||||
|
|
||||||
|
def _tell_vlc_and_expect(self, action: str, position_s: float):
|
||||||
|
req_id = str(uuid.uuid4())[:8]
|
||||||
|
target_ms = int(position_s * 1000)
|
||||||
|
|
||||||
|
# Clean up old expectations (e.g. VLC dropped the event or we missed it)
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
self.expected_vlc_events = [e for e in self.expected_vlc_events
|
||||||
|
if (now - e.timestamp).total_seconds() < 3.0]
|
||||||
|
|
||||||
|
self.expected_vlc_events.append(ExpectedVlcEvent(action, req_id, target_ms))
|
||||||
|
|
||||||
|
if action == "play":
|
||||||
|
self.vlc_player.seek(target_ms)
|
||||||
|
self.vlc_player.play()
|
||||||
|
self.play_btn.setText("⏸")
|
||||||
|
elif action == "pause":
|
||||||
|
self.vlc_player.seek(target_ms)
|
||||||
|
self.vlc_player.pause()
|
||||||
|
self.play_btn.setText("▶")
|
||||||
|
elif action == "seek":
|
||||||
|
self.vlc_player.seek(target_ms)
|
||||||
|
|
||||||
|
return req_id
|
||||||
|
|
||||||
|
def toggle_playback(self):
|
||||||
|
position_s = self.vlc_player.current_time_ms / 1000.0
|
||||||
|
if self.vlc_player.is_playing:
|
||||||
|
req = self._tell_vlc_and_expect("pause", position_s)
|
||||||
|
self.sync_action_requested.emit("pause", position_s, req)
|
||||||
|
else:
|
||||||
|
req = self._tell_vlc_and_expect("play", position_s)
|
||||||
|
self.sync_action_requested.emit("play", position_s, req)
|
||||||
|
|
||||||
|
def on_vlc_time(self, time_ms: int):
|
||||||
|
length_ms = self.vlc_player.get_length()
|
||||||
|
if length_ms > 0:
|
||||||
|
def fmt(ms):
|
||||||
|
s = max(0, ms) // 1000
|
||||||
|
return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
|
||||||
|
|
||||||
|
if not self.seekbar.isSliderDown():
|
||||||
|
self.time_lbl.setText(f"{fmt(time_ms)} / {fmt(length_ms)}")
|
||||||
|
progress = int((time_ms / length_ms) * 1000)
|
||||||
|
self.seekbar.blockSignals(True)
|
||||||
|
self.seekbar.setValue(progress)
|
||||||
|
self.seekbar.blockSignals(False)
|
||||||
|
|
||||||
|
if self.last_reported_time_ms is not None:
|
||||||
|
diff = abs(time_ms - self.last_reported_time_ms)
|
||||||
|
|
||||||
|
if diff > 1500:
|
||||||
|
# Look for a pending seek expectation
|
||||||
|
matched = False
|
||||||
|
for i, expected in enumerate(self.expected_vlc_events):
|
||||||
|
if expected.action == "seek" and (expected.target_val is None or abs(expected.target_val - time_ms) < 2000):
|
||||||
|
matched = True
|
||||||
|
self.expected_vlc_events.pop(i)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
# Genuine user scrub!
|
||||||
|
req = str(uuid.uuid4())[:8]
|
||||||
|
self.sync_action_requested.emit("seek", time_ms / 1000.0, req)
|
||||||
|
|
||||||
|
self.last_reported_time_ms = time_ms
|
||||||
|
|
||||||
|
def on_vlc_state(self, playing: bool, time_ms: int):
|
||||||
|
action = "play" if playing else "pause"
|
||||||
|
|
||||||
|
# Look for a pending state change expectation
|
||||||
|
matched = False
|
||||||
|
for i, expected in enumerate(self.expected_vlc_events):
|
||||||
|
if expected.action == action:
|
||||||
|
matched = True
|
||||||
|
self.expected_vlc_events.pop(i)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
req = str(uuid.uuid4())[:8]
|
||||||
|
self.sync_action_requested.emit(action, time_ms / 1000.0, req)
|
||||||
|
|
||||||
|
def on_seekbar_dragged(self, value):
|
||||||
|
length_ms = self.vlc_player.get_length()
|
||||||
|
if length_ms > 0:
|
||||||
|
target_ms = int((value / 1000.0) * length_ms)
|
||||||
|
def fmt(ms):
|
||||||
|
s = max(0, ms) // 1000
|
||||||
|
return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
|
||||||
|
self.time_lbl.setText(f"{fmt(target_ms)} / {fmt(length_ms)}")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# --- Incoming Sync Logic ---
|
||||||
|
def handle_sync_event(self, msg: dict):
|
||||||
|
action = msg.get("action")
|
||||||
|
if not action:
|
||||||
|
if msg.get("playing", False): action = "play"
|
||||||
|
elif msg.get("playing") is False: action = "pause"
|
||||||
|
|
||||||
|
position_s = msg.get("position", 0)
|
||||||
|
|
||||||
|
if action in ["play", "pause", "seek"]:
|
||||||
|
self._tell_vlc_and_expect(action, position_s)
|
||||||
|
|
||||||
|
username = msg.get("username")
|
||||||
|
if username and username != self.username:
|
||||||
|
if action == "play": self.add_system_message(f"{username} pressed play")
|
||||||
|
elif action == "pause": self.add_system_message(f"{username} paused")
|
||||||
|
elif action == "seek":
|
||||||
|
def fmt(s): return f"{int(s)//3600:02d}:{(int(s)%3600)//60:02d}:{int(s)%60:02d}"
|
||||||
|
self.add_system_message(f"{username} seeked to {fmt(position_s)}")
|
||||||
|
|
||||||
|
def update_users(self, users: list):
|
||||||
|
if self._is_first_user_update:
|
||||||
|
self._is_first_user_update = False
|
||||||
|
else:
|
||||||
|
joined = set(users) - set(self.current_users)
|
||||||
|
left = set(self.current_users) - set(users)
|
||||||
|
|
||||||
|
for user in joined:
|
||||||
|
if user != self.username:
|
||||||
|
self.add_system_message(f"👋 {user} joined the room")
|
||||||
|
|
||||||
|
for user in left:
|
||||||
|
if user != self.username:
|
||||||
|
self.add_system_message(f"🚪 {user} left the room")
|
||||||
|
|
||||||
|
self.current_users = users
|
||||||
|
self.users_lbl.setText(f"{len(users)} watching: {', '.join(users)}")
|
||||||
|
|
||||||
|
def 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")
|
||||||
|
new_msg = f"<b>{author}</b>: {text} <span style='color: gray; font-size: 10px;'>{time_str}</span>"
|
||||||
|
self.chat_messages.append(new_msg)
|
||||||
|
|
||||||
|
def add_system_message(self, text: str):
|
||||||
|
new_msg = f"<i style='color: #888;'>{text}</i>"
|
||||||
|
self.chat_messages.append(new_msg)
|
||||||
|
|
||||||
|
def send_chat(self):
|
||||||
|
text = self.chat_input.text().strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
if text.startswith("/"):
|
||||||
|
parts = text.split()
|
||||||
|
cmd = parts[0].lower()
|
||||||
|
|
||||||
|
if cmd == "/play":
|
||||||
|
self.chat_input.setText("")
|
||||||
|
if not self.vlc_player.is_playing:
|
||||||
|
self.toggle_playback()
|
||||||
|
self.add_system_message(text)
|
||||||
|
return
|
||||||
|
elif cmd == "/pause":
|
||||||
|
self.chat_input.setText("")
|
||||||
|
if self.vlc_player.is_playing:
|
||||||
|
self.toggle_playback()
|
||||||
|
self.add_system_message(text)
|
||||||
|
return
|
||||||
|
elif cmd == "/seek":
|
||||||
|
self.chat_input.setText("")
|
||||||
|
if len(parts) > 1:
|
||||||
|
if self._handle_seek_command(parts[1]):
|
||||||
|
self.add_system_message(text)
|
||||||
|
else:
|
||||||
|
self.add_system_message("Invalid time format. Use: 1:23, +30s, -1m")
|
||||||
|
else:
|
||||||
|
self.add_system_message("Usage: /seek [time]")
|
||||||
|
return
|
||||||
|
elif cmd == "/help":
|
||||||
|
self.chat_input.setText("")
|
||||||
|
self.add_system_message("Available commands:<br><b>/play</b> - Resume playback<br><b>/pause</b> - Pause playback<br><b>/seek [time]</b> - Seek to specific time (e.g., 1:23) or offset (e.g., +30s, -1m)<br><b>/help</b> - Show this message")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.chat_message_ready.emit(text)
|
||||||
|
self.chat_input.setText("")
|
||||||
|
|
||||||
|
def _handle_seek_command(self, arg: str) -> bool:
|
||||||
|
current_ms = self.vlc_player.current_time_ms
|
||||||
|
length_ms = self.vlc_player.get_length()
|
||||||
|
if length_ms <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_ms = 0
|
||||||
|
if arg.startswith('+') or arg.startswith('-'):
|
||||||
|
modifier = 1 if arg.startswith('+') else -1
|
||||||
|
num_str = arg[1:]
|
||||||
|
if num_str.endswith('s'): val = float(num_str[:-1]) * 1000
|
||||||
|
elif num_str.endswith('m'): val = float(num_str[:-1]) * 60 * 1000
|
||||||
|
elif num_str.endswith('h'): val = float(num_str[:-1]) * 3600 * 1000
|
||||||
|
else: val = float(num_str) * 1000
|
||||||
|
target_ms = current_ms + (val * modifier)
|
||||||
|
elif ":" in arg:
|
||||||
|
parts = arg.split(":")
|
||||||
|
parts.reverse()
|
||||||
|
if len(parts) > 0: target_ms += float(parts[0]) * 1000
|
||||||
|
if len(parts) > 1: target_ms += float(parts[1]) * 60 * 1000
|
||||||
|
if len(parts) > 2: target_ms += float(parts[2]) * 3600 * 1000
|
||||||
|
else:
|
||||||
|
if arg.endswith('s'): target_ms = float(arg[:-1]) * 1000
|
||||||
|
elif arg.endswith('m'): target_ms = float(arg[:-1]) * 60 * 1000
|
||||||
|
elif arg.endswith('h'): target_ms = float(arg[:-1]) * 3600 * 1000
|
||||||
|
else: target_ms = float(arg) * 1000
|
||||||
|
|
||||||
|
target_ms = max(0, min(target_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)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
@@ -9,22 +9,22 @@ def test_app_ui_flow(qtbot):
|
|||||||
|
|
||||||
# 1. Test Lobby View
|
# 1. Test Lobby View
|
||||||
assert app.stacked_widget.currentIndex() == 0
|
assert app.stacked_widget.currentIndex() == 0
|
||||||
assert not app.create_room_btn.isEnabled()
|
assert not app.lobby_widget.create_room_btn.isEnabled()
|
||||||
|
|
||||||
# Fill out the Lobby Form
|
# Fill out the Lobby Form
|
||||||
app.username_input.setText("PyTestUser")
|
app.lobby_widget.username_input.setText("PyTestUser")
|
||||||
|
|
||||||
# Mocking file selection instead of opening the native dialog
|
# Mocking file selection instead of opening the native dialog
|
||||||
app.local_file_path = "test_pytest.mkv"
|
app.lobby_widget.local_file_path = "test_pytest.mkv"
|
||||||
app.local_file_name = "test_pytest.mkv"
|
app.lobby_widget.local_file_name = "test_pytest.mkv"
|
||||||
app.local_file_size = 1048576
|
app.lobby_widget.local_file_size = 1048576
|
||||||
app.check_inputs()
|
app.lobby_widget.check_inputs()
|
||||||
|
|
||||||
# Button should now be active
|
# Button should now be active
|
||||||
assert app.create_room_btn.isEnabled()
|
assert app.lobby_widget.create_room_btn.isEnabled()
|
||||||
|
|
||||||
# 2. Test Creating Room matches integration pipeline
|
# 2. Test Creating Room matches integration pipeline
|
||||||
qtbot.mouseClick(app.create_room_btn, Qt.MouseButton.LeftButton)
|
qtbot.mouseClick(app.lobby_widget.create_room_btn, Qt.MouseButton.LeftButton)
|
||||||
|
|
||||||
# Wait for the WebSocket connected signal, the room_created server response, and UI transition
|
# Wait for the WebSocket connected signal, the room_created server response, and UI transition
|
||||||
def check_room_joined():
|
def check_room_joined():
|
||||||
@@ -35,26 +35,26 @@ def test_app_ui_flow(qtbot):
|
|||||||
|
|
||||||
# 3. Test Chat flow End-to-End
|
# 3. Test Chat flow End-to-End
|
||||||
# Type a message
|
# Type a message
|
||||||
qtbot.keyClicks(app.chat_input, "Automated UI Test Message")
|
qtbot.keyClicks(app.room_widget.chat_input, "Automated UI Test Message")
|
||||||
# Click Send
|
# Click Send
|
||||||
qtbot.mouseClick(app.chat_send_btn, Qt.MouseButton.LeftButton)
|
qtbot.mouseClick(app.room_widget.chat_send_btn, Qt.MouseButton.LeftButton)
|
||||||
|
|
||||||
# Wait until the Bun server grabs the websocket payload, stores it, and broadcasts it back to the UI!
|
# Wait until the Bun server grabs the websocket payload, stores it, and broadcasts it back to the UI!
|
||||||
def check_chat_received():
|
def check_chat_received():
|
||||||
assert "Automated UI Test Message" in app.chat_messages.text()
|
assert "Automated UI Test Message" in app.room_widget.chat_messages.toPlainText()
|
||||||
|
|
||||||
qtbot.waitUntil(check_chat_received, timeout=3000)
|
qtbot.waitUntil(check_chat_received, timeout=3000)
|
||||||
|
|
||||||
# 4. Test Playback Sync (UI updates and internal flags)
|
# 4. Test Playback Sync (UI updates and internal flags)
|
||||||
assert app.play_btn.text() == "▶"
|
assert app.room_widget.play_btn.text() == "▶"
|
||||||
|
|
||||||
# Click Play
|
# Click Play
|
||||||
qtbot.mouseClick(app.play_btn, Qt.MouseButton.LeftButton)
|
qtbot.mouseClick(app.room_widget.play_btn, Qt.MouseButton.LeftButton)
|
||||||
|
|
||||||
def check_playback_started():
|
def check_playback_started():
|
||||||
assert app.play_btn.text() == "⏸"
|
assert app.room_widget.play_btn.text() == "⏸"
|
||||||
|
|
||||||
qtbot.waitUntil(check_playback_started, timeout=2000)
|
qtbot.waitUntil(check_playback_started, timeout=2000)
|
||||||
|
|
||||||
# Clean up background threads
|
# Clean up background threads
|
||||||
app.leave_room()
|
app._on_room_leave()
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ const server = Bun.serve<WSData>({
|
|||||||
playing: room.playing,
|
playing: room.playing,
|
||||||
username: ws.data.username,
|
username: ws.data.username,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
req_id: msg.req_id,
|
||||||
},
|
},
|
||||||
ws as unknown as WebSocket
|
ws as unknown as WebSocket
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user