604 lines
22 KiB
Python
604 lines
22 KiB
Python
import sys
|
||
import os
|
||
import ctypes
|
||
import json
|
||
from PyQt6.QtWidgets import (
|
||
QApplication, QMainWindow, QStackedWidget, QFileDialog, QMessageBox
|
||
)
|
||
from PyQt6.QtCore import Qt, QTimer, QSettings, QUrl
|
||
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||
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, settings=None):
|
||
super().__init__()
|
||
self.settings = settings if settings is not None else QSettings("VideoSync", "DesktopClient")
|
||
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)
|
||
self.room_widget.ready_action_requested.connect(lambda message: self.sync_client.send_message(message))
|
||
self.room_widget.fight_action_requested.connect(lambda message: self.sync_client.send_message(message))
|
||
|
||
# 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.connection_error.connect(self.on_connection_error)
|
||
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.ready_check_updated.connect(self.room_widget.update_ready_check)
|
||
self.sync_client.fight_reaction.connect(self.room_widget.handle_fight_reaction)
|
||
self.sync_client.fight_poll_updated.connect(self.room_widget.update_fight_poll)
|
||
self.sync_client.latency_updated.connect(self.room_widget.update_latency)
|
||
|
||
self.apply_stylesheet()
|
||
self._restore_preferences()
|
||
self.lobby_widget.username_input.textChanged.connect(
|
||
lambda name: self.settings.setValue("username", name))
|
||
self.lobby_widget.file_selected.connect(self._remember_media_folder)
|
||
self.room_widget.volume_slider.valueChanged.connect(
|
||
lambda volume: self.settings.setValue("volume", volume))
|
||
self.discovery_network = QNetworkAccessManager(self)
|
||
self.discovery_reply = None
|
||
self.discovery_timer = QTimer(self)
|
||
self.discovery_timer.setInterval(15000)
|
||
self.discovery_timer.timeout.connect(self.refresh_sessions)
|
||
self.lobby_widget.refresh_requested.connect(self.refresh_sessions)
|
||
self.discovery_timer.start()
|
||
QTimer.singleShot(0, self.refresh_sessions)
|
||
|
||
def refresh_sessions(self):
|
||
if self.stacked_widget.currentIndex() != 0 or self.discovery_reply is not None:
|
||
return
|
||
url = self.sync_client.url.replace("wss://", "https://").replace("ws://", "http://")
|
||
request = QNetworkRequest(QUrl(url.rsplit("/", 1)[0] + "/api/rooms"))
|
||
request.setTransferTimeout(8000)
|
||
self.lobby_widget.refresh_btn.setEnabled(False)
|
||
reply = self.discovery_network.get(request)
|
||
self.discovery_reply = reply
|
||
reply.finished.connect(lambda: self._finish_discovery(reply))
|
||
|
||
def _finish_discovery(self, reply):
|
||
try:
|
||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||
raise ValueError("Discovery unavailable")
|
||
rooms = json.loads(bytes(reply.readAll()))["rooms"]
|
||
self.lobby_widget.update_sessions(rooms)
|
||
except (ValueError, KeyError, TypeError, OverflowError):
|
||
self.lobby_widget.sessions_list.clear()
|
||
self.lobby_widget.sessions_list.hide()
|
||
self.lobby_widget.session_count.setText("Join by code is still available.")
|
||
self.lobby_widget.check_inputs()
|
||
self.lobby_widget.sessions_status.setText("Couldn’t check for sessions. You can retry or join by code.")
|
||
finally:
|
||
self.discovery_reply = None
|
||
self.lobby_widget.refresh_btn.setEnabled(True)
|
||
reply.deleteLater()
|
||
|
||
def _restore_preferences(self):
|
||
self.lobby_widget.username_input.setText(self.settings.value("username", "", type=str))
|
||
folder = self.settings.value("media_folder", "", type=str)
|
||
self.lobby_widget.media_folder = folder if os.path.isdir(folder) else ""
|
||
volume = self.settings.value("volume", 100, type=int)
|
||
self.room_widget.volume_slider.setValue(max(0, min(100, volume)))
|
||
geometry = self.settings.value("window_geometry")
|
||
if geometry is not None:
|
||
self.restoreGeometry(geometry)
|
||
width = max(200, min(800, self.settings.value("chat_width", 320, type=int)))
|
||
self.room_widget._saved_chat_width = width
|
||
self.room_widget.splitter.setSizes([max(100, self.width() - width), width])
|
||
self.room_widget.chat_container.setVisible(self.settings.value("chat_visible", True, type=bool))
|
||
if not self.settings.value("highlights_visible", True, type=bool):
|
||
self.room_widget.tags_list.hide()
|
||
self.room_widget.tags_header_btn.setText("▶ Highlights")
|
||
|
||
def _remember_media_folder(self, path):
|
||
folder = os.path.dirname(path)
|
||
self.lobby_widget.media_folder = folder
|
||
self.settings.setValue("media_folder", folder)
|
||
|
||
def closeEvent(self, event):
|
||
self.discovery_timer.stop()
|
||
if self.discovery_reply is not None:
|
||
self.discovery_reply.abort()
|
||
room = self.room_widget
|
||
self.settings.setValue("window_geometry", self.saveGeometry())
|
||
width = (room.chat_container.width() if not room.popout_window and
|
||
not room.chat_container.isHidden() else getattr(room, "_saved_chat_width", 320))
|
||
self.settings.setValue("chat_width", width)
|
||
self.settings.setValue("chat_visible", not room.chat_container.isHidden())
|
||
self.settings.setValue("highlights_visible", not room.tags_list.isHidden())
|
||
self.settings.sync()
|
||
if room.popout_window:
|
||
room.popout_window.close()
|
||
self.sync_client.stop()
|
||
room.cleanup()
|
||
super().closeEvent(event)
|
||
|
||
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",
|
||
"listed": self.lobby_widget.listed_checkbox.isChecked(),
|
||
"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()
|
||
|
||
self.refresh_sessions()
|
||
|
||
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_connection_error(self, msg: str):
|
||
QMessageBox.critical(self, "Secure Connection Error", msg)
|
||
self.pending_connect_action = None
|
||
self.lobby_widget.reset_ui()
|
||
self.sync_client.stop()
|
||
|
||
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")
|
||
|
||
ext = os.path.splitext(req_name)[1].lower()
|
||
file_filter = f"Video Files (*{ext});;All Files (*)" if ext else "All Files (*)"
|
||
file_path, _ = QFileDialog.getOpenFileName(
|
||
self, f"Select {req_name}", self.lobby_widget.media_folder, file_filter
|
||
)
|
||
|
||
if file_path:
|
||
self._remember_media_folder(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! 👋")
|
||
self.room_widget.finish_chat_history()
|
||
self.room_widget.update_ready_check(msg.get("readyCheck") or {})
|
||
self.room_widget.update_fight_poll(msg.get("fightPoll") or {})
|
||
|
||
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)
|
||
|
||
self.room_widget.finish_chat_history()
|
||
self.room_widget.update_ready_check(msg.get("readyCheck") or {})
|
||
self.room_widget.update_fight_poll(msg.get("fightPoll") or {})
|
||
|
||
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())
|