Files
video-sync/desktop-client/main.py
T

602 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.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())