495 lines
16 KiB
Python
495 lines
16 KiB
Python
import sys
|
|
import os
|
|
import ctypes
|
|
from PyQt6.QtWidgets import (
|
|
QApplication, QMainWindow, QStackedWidget, QFileDialog, QMessageBox
|
|
)
|
|
from PyQt6.QtCore import Qt, QTimer
|
|
from PyQt6.QtGui import QIcon
|
|
|
|
from sync_client import SyncClientThread
|
|
from lobby_widget import LobbyWidget
|
|
from room_widget import RoomWidget
|
|
|
|
class VlcSyncApp(QMainWindow):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle("VideoSync — Watch Together")
|
|
|
|
icon_path = os.path.join(os.path.dirname(__file__), "icon.png")
|
|
if os.path.exists(icon_path):
|
|
self.setWindowIcon(QIcon(icon_path))
|
|
|
|
self.setMinimumSize(900, 600)
|
|
self.resize(1100, 700)
|
|
|
|
# Main stacked widget
|
|
self.stacked_widget = QStackedWidget()
|
|
self.setCentralWidget(self.stacked_widget)
|
|
|
|
# Setup Views
|
|
self.lobby_widget = LobbyWidget()
|
|
self.room_widget = RoomWidget()
|
|
|
|
self.stacked_widget.addWidget(self.lobby_widget)
|
|
self.stacked_widget.addWidget(self.room_widget)
|
|
|
|
# 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.room_code = ""
|
|
self.local_file_path = None
|
|
self.local_file_name = None
|
|
self.local_file_size = 0
|
|
self.pending_connect_action = None
|
|
|
|
# Network Service
|
|
self.sync_client = SyncClientThread("wss://video-sync.peterstockings.com/ws")
|
|
self.sync_client.connected.connect(self.on_ws_connected)
|
|
self.sync_client.connected.connect(lambda: self.room_widget.update_connection_status(True))
|
|
self.sync_client.disconnected.connect(self.on_ws_disconnected)
|
|
self.sync_client.disconnected.connect(lambda: self.room_widget.update_connection_status(False))
|
|
self.sync_client.room_joined.connect(self.on_room_joined)
|
|
self.sync_client.room_rejoined.connect(self.on_room_rejoined)
|
|
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.users_updated.connect(self.room_widget.update_users)
|
|
self.sync_client.chat_message.connect(self.room_widget.add_chat_message)
|
|
self.sync_client.system_message.connect(self.room_widget.add_system_message)
|
|
self.sync_client.sync_event.connect(self.room_widget.handle_sync_event)
|
|
self.sync_client.latency_updated.connect(self.room_widget.update_latency)
|
|
|
|
self.apply_stylesheet()
|
|
|
|
def _on_lobby_create(self, username, file_path, file_name, file_size):
|
|
self.username = username
|
|
self.local_file_path = file_path
|
|
self.local_file_name = file_name
|
|
self.local_file_size = file_size
|
|
|
|
self.pending_connect_action = lambda: self.sync_client.send_message({
|
|
"type": "create_room",
|
|
"username": self.username,
|
|
"fileInfo": {
|
|
"name": self.local_file_name,
|
|
"size": self.local_file_size,
|
|
"duration": 0
|
|
}
|
|
})
|
|
self._ensure_connection()
|
|
|
|
def _on_lobby_join(self, username, room_code):
|
|
self.username = username
|
|
self.room_code = room_code
|
|
self.pending_connect_action = lambda: self.sync_client.send_message({
|
|
"type": "join_room",
|
|
"username": self.username,
|
|
"code": self.room_code
|
|
})
|
|
self._ensure_connection()
|
|
|
|
def _ensure_connection(self):
|
|
if self.sync_client.running and self.sync_client.ws:
|
|
self.on_ws_connected()
|
|
else:
|
|
self.sync_client.start()
|
|
|
|
def _on_room_leave(self):
|
|
self.room_widget.cleanup()
|
|
self.sync_client.stop()
|
|
self.stacked_widget.setCurrentIndex(0)
|
|
self.room_code = ""
|
|
self.local_file_path = None
|
|
self.lobby_widget.clear_file()
|
|
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 ---
|
|
def on_ws_connected(self):
|
|
if self.pending_connect_action:
|
|
self.pending_connect_action()
|
|
self.pending_connect_action = None
|
|
elif self.stacked_widget.currentIndex() == 1 and self.room_code and self.username:
|
|
self.sync_client.send_message({
|
|
"type": "rejoin_room",
|
|
"username": self.username,
|
|
"code": self.room_code
|
|
})
|
|
|
|
def on_ws_disconnected(self):
|
|
if self.stacked_widget.currentIndex() == 1:
|
|
self.room_widget.set_room_code_display(f"{self.room_code} (Reconnecting...)")
|
|
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):
|
|
QMessageBox.critical(self, "Room Error", msg)
|
|
self.lobby_widget.reset_ui()
|
|
self.sync_client.stop()
|
|
|
|
def on_file_check_needed(self, msg: dict):
|
|
QTimer.singleShot(0, lambda: self._handle_file_check(msg))
|
|
|
|
def _handle_file_check(self, msg: dict):
|
|
req_name = msg["fileInfo"].get("name", "Unknown")
|
|
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")
|
|
|
|
file_path, _ = QFileDialog.getOpenFileName(
|
|
self, f"Select {req_name}", "", f"Required File ({req_name})"
|
|
)
|
|
|
|
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)
|
|
|
|
if self.local_file_name.lower() == req_name.lower() and int(self.local_file_size) == int(req_size):
|
|
self.sync_client.send_message({
|
|
"type": "confirm_join",
|
|
"fileInfo": {
|
|
"name": self.local_file_name,
|
|
"size": self.local_file_size,
|
|
"duration": 0
|
|
}
|
|
})
|
|
else:
|
|
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.lobby_widget.reset_ui()
|
|
else:
|
|
self.sync_client.stop()
|
|
self.lobby_widget.reset_ui()
|
|
|
|
def on_room_joined(self, msg: dict):
|
|
if "room" in msg:
|
|
self.room_code = msg["room"]["code"]
|
|
else:
|
|
self.room_code = msg.get("code", "")
|
|
|
|
self.stacked_widget.setCurrentIndex(1)
|
|
self.lobby_widget.reset_ui()
|
|
|
|
state = msg.get("state", {})
|
|
start_time_s = state.get("position", 0.0) if state else 0.0
|
|
|
|
self.room_widget.setup_room(self.room_code, self.username, self.local_file_name, self.local_file_path, start_time_s)
|
|
|
|
chat_history = msg.get("chatHistory", [])
|
|
if chat_history:
|
|
for chat in chat_history:
|
|
self.room_widget.add_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0))
|
|
|
|
users = msg.get("users", [])
|
|
if users:
|
|
self.room_widget.update_users(users)
|
|
|
|
if state:
|
|
self.room_widget.handle_sync_event(state)
|
|
|
|
self.room_widget.add_system_message("Welcome to the room! 👋")
|
|
|
|
def on_room_rejoined(self, msg: dict):
|
|
self.room_widget.set_room_code_display(self.room_code)
|
|
|
|
chat_history = msg.get("chatHistory", [])
|
|
if chat_history:
|
|
self.room_widget.clear_chat()
|
|
for chat in chat_history:
|
|
self.room_widget.add_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0))
|
|
|
|
self.room_widget.add_system_message("✅ Reconnected to the room.")
|
|
|
|
users = msg.get("users", [])
|
|
if users:
|
|
self.room_widget.update_users(users)
|
|
|
|
state = msg.get("state", {})
|
|
if state:
|
|
self.room_widget.handle_sync_event(state)
|
|
|
|
def apply_stylesheet(self):
|
|
self.setStyleSheet("""
|
|
QWidget {
|
|
background-color: #0f0f0f;
|
|
color: #f1f1f1;
|
|
font-family: 'Segoe UI', 'Roboto', sans-serif;
|
|
font-size: 14px;
|
|
}
|
|
|
|
#lobbyCard {
|
|
background-color: #1a1a1a;
|
|
border: 1px solid #333;
|
|
border-radius: 12px;
|
|
}
|
|
|
|
#brandTitle {
|
|
font-size: 32px;
|
|
font-weight: bold;
|
|
color: #3ea6ff;
|
|
}
|
|
|
|
#brandTagline {
|
|
font-size: 14px;
|
|
color: #aaaaaa;
|
|
}
|
|
|
|
QLabel {
|
|
color: #aaaaaa;
|
|
font-weight: bold;
|
|
font-size: 12px;
|
|
background-color: transparent;
|
|
}
|
|
|
|
#fileInfo {
|
|
background-color: rgba(62, 166, 255, 0.1);
|
|
color: #3ea6ff;
|
|
padding: 10px;
|
|
border-radius: 6px;
|
|
font-weight: normal;
|
|
font-size: 13px;
|
|
}
|
|
|
|
QLineEdit {
|
|
background-color: #272727;
|
|
border: 1px solid #333;
|
|
border-radius: 6px;
|
|
padding: 10px;
|
|
color: white;
|
|
font-size: 14px;
|
|
}
|
|
|
|
QLineEdit:focus {
|
|
border: 1px solid #3ea6ff;
|
|
}
|
|
|
|
QPushButton {
|
|
border-radius: 6px;
|
|
padding: 10px 16px;
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
background-color: #272727;
|
|
color: white;
|
|
border: 1px solid #444;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #333;
|
|
}
|
|
QPushButton:disabled {
|
|
border-color: #222;
|
|
color: #666;
|
|
}
|
|
|
|
#primaryBtn {
|
|
background-color: #3ea6ff;
|
|
color: black;
|
|
border: none;
|
|
}
|
|
#primaryBtn:hover {
|
|
background-color: #65b8ff;
|
|
}
|
|
#primaryBtn:disabled {
|
|
background-color: #333;
|
|
color: #666;
|
|
}
|
|
|
|
#iconBtn {
|
|
background-color: transparent;
|
|
border: none;
|
|
padding: 5px;
|
|
}
|
|
#iconBtn:hover {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
#dangerBtn {
|
|
background-color: transparent;
|
|
color: #ff4e45;
|
|
border: 1px solid #ff4e45;
|
|
padding: 6px 12px;
|
|
font-size: 12px;
|
|
}
|
|
#dangerBtn:hover {
|
|
background-color: #ff4e45;
|
|
color: white;
|
|
}
|
|
|
|
#divider {
|
|
color: #555;
|
|
}
|
|
|
|
#topbar {
|
|
background-color: #1a1a1a;
|
|
border-bottom: 1px solid #333;
|
|
}
|
|
|
|
#fileBadge {
|
|
background-color: #272727;
|
|
padding: 4px 10px;
|
|
border-radius: 12px;
|
|
font-size: 12px;
|
|
font-weight: normal;
|
|
}
|
|
|
|
#controlsBar {
|
|
background-color: #1a1a1a;
|
|
} #playBtn {
|
|
background: transparent;
|
|
color: white;
|
|
font-size: 20px;
|
|
border: none;
|
|
} #playBtn:hover {
|
|
background: #333;
|
|
}
|
|
|
|
#seekBar {
|
|
border: none;
|
|
background: transparent;
|
|
}
|
|
#seekBar::groove:horizontal {
|
|
height: 4px;
|
|
background: #444;
|
|
border: none;
|
|
border-radius: 2px;
|
|
}
|
|
#seekBar::sub-page:horizontal {
|
|
background: #ff0000;
|
|
height: 4px;
|
|
border-radius: 2px;
|
|
}
|
|
#seekBar::add-page:horizontal {
|
|
background: #444;
|
|
height: 4px;
|
|
border-radius: 2px;
|
|
}
|
|
#seekBar::handle:horizontal {
|
|
background: #ff0000;
|
|
width: 12px;
|
|
height: 12px;
|
|
margin: -4px 0;
|
|
border-radius: 6px;
|
|
}
|
|
#seekBar::handle:horizontal:hover {
|
|
background: #ff3333;
|
|
width: 14px;
|
|
height: 14px;
|
|
margin: -5px 0;
|
|
border-radius: 7px;
|
|
}
|
|
|
|
#volumeSlider {
|
|
border: none;
|
|
background: transparent;
|
|
}
|
|
#volumeSlider::groove:horizontal {
|
|
height: 4px;
|
|
background: #444;
|
|
border: none;
|
|
border-radius: 2px;
|
|
}
|
|
#volumeSlider::sub-page:horizontal {
|
|
background: #fff;
|
|
height: 4px;
|
|
border-radius: 2px;
|
|
}
|
|
#volumeSlider::add-page:horizontal {
|
|
background: #444;
|
|
height: 4px;
|
|
border-radius: 2px;
|
|
}
|
|
#volumeSlider::handle:horizontal {
|
|
background: #fff;
|
|
width: 10px;
|
|
height: 10px;
|
|
margin: -3px 0;
|
|
border-radius: 5px;
|
|
}
|
|
#volumeSlider::handle:horizontal:hover {
|
|
width: 12px;
|
|
height: 12px;
|
|
margin: -4px 0;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
#chatContainer {
|
|
background-color: #111;
|
|
border-left: 1px solid #333;
|
|
}
|
|
|
|
#chatHeader {
|
|
font-size: 16px;
|
|
color: white;
|
|
padding: 10px 0;
|
|
}
|
|
|
|
#usersLbl {
|
|
color: #3ea6ff;
|
|
font-size: 12px;
|
|
}
|
|
|
|
#chatMessages {
|
|
background-color: transparent;
|
|
border: none;
|
|
}
|
|
|
|
#chatMessages QScrollBar:vertical {
|
|
border: none;
|
|
background: transparent;
|
|
width: 8px;
|
|
margin: 0px;
|
|
}
|
|
#chatMessages QScrollBar::handle:vertical {
|
|
background: #333;
|
|
min-height: 20px;
|
|
border-radius: 4px;
|
|
}
|
|
#chatMessages QScrollBar::handle:vertical:hover {
|
|
background: #444;
|
|
}
|
|
#chatMessages QScrollBar::add-line:vertical, #chatMessages QScrollBar::sub-line:vertical {
|
|
height: 0px;
|
|
}
|
|
#chatMessages QScrollBar::add-page:vertical, #chatMessages QScrollBar::sub-page:vertical {
|
|
background: none;
|
|
}
|
|
|
|
#bubble {
|
|
border: none;
|
|
}
|
|
""")
|
|
|
|
if __name__ == "__main__":
|
|
import ctypes
|
|
if os.name == 'nt':
|
|
myappid = 'vlcsync.desktopclient.app.1'
|
|
try:
|
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
|
|
except Exception:
|
|
pass
|
|
|
|
app = QApplication(sys.argv)
|
|
|
|
icon_path = os.path.join(os.path.dirname(__file__), "icon.png")
|
|
if os.path.exists(icon_path):
|
|
app.setWindowIcon(QIcon(icon_path))
|
|
|
|
window = VlcSyncApp()
|
|
window.show()
|
|
sys.exit(app.exec())
|
|
|