Create desktop app due to mkv issue with chrome
This commit is contained in:
745
desktop-client/main.py
Normal file
745
desktop-client/main.py
Normal file
@@ -0,0 +1,745 @@
|
||||
import sys
|
||||
import os
|
||||
import ctypes
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QStackedWidget, QWidget, QVBoxLayout,
|
||||
QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox,
|
||||
QFrame, QSlider
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QObject, QTimer
|
||||
from PyQt6.QtGui import QFont, QIcon, QColor
|
||||
from vlc_player import VLCSyncPlayer
|
||||
from sync_client import SyncClientThread
|
||||
import datetime
|
||||
|
||||
class VlcSyncApp(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("VideoSync — Watch Together")
|
||||
|
||||
# Set window icon
|
||||
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 to switch between Lobby and Room
|
||||
self.stacked_widget = QStackedWidget()
|
||||
self.setCentralWidget(self.stacked_widget)
|
||||
|
||||
# Setup Views
|
||||
self.lobby_view = self.create_lobby_view()
|
||||
self.room_view = self.create_room_view()
|
||||
|
||||
self.stacked_widget.addWidget(self.lobby_view)
|
||||
self.stacked_widget.addWidget(self.room_view)
|
||||
|
||||
# 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
|
||||
self.ignore_vlc_events = False
|
||||
self.last_reported_time_ms = 0
|
||||
|
||||
self.sync_client = SyncClientThread("ws://localhost:3000/ws")
|
||||
self.sync_client.connected.connect(self.on_ws_connected)
|
||||
self.sync_client.room_joined.connect(self.on_room_joined)
|
||||
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.on_users_updated)
|
||||
self.sync_client.chat_message.connect(self.on_chat_message)
|
||||
self.sync_client.system_message.connect(self.on_system_message)
|
||||
self.sync_client.sync_event.connect(self.on_sync_event)
|
||||
|
||||
self.apply_stylesheet()
|
||||
|
||||
def create_lobby_view(self):
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
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)
|
||||
|
||||
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.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("0:00 / 0: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("fullscreenBtn")
|
||||
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 = QLabel("Welcome to the room! 👋\n")
|
||||
self.chat_messages.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
||||
self.chat_messages.setWordWrap(True)
|
||||
# We should really use a QScrollArea or QTextEdit here eventually
|
||||
|
||||
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.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//60}:{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//60}:{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.setText("✓")
|
||||
QTimer.singleShot(2000, lambda: self.copy_code_btn.setText("📋"))
|
||||
|
||||
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({
|
||||
"type": "create_room",
|
||||
"username": self.username,
|
||||
"fileInfo": {
|
||||
"name": self.local_file_name,
|
||||
"size": self.local_file_size,
|
||||
"duration": 0
|
||||
}
|
||||
})
|
||||
|
||||
if self.sync_client.running and self.sync_client.ws:
|
||||
self.on_ws_connected()
|
||||
else:
|
||||
self.sync_client.start()
|
||||
|
||||
def join_room(self):
|
||||
self.username = self.username_input.text().strip()
|
||||
self.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.pending_connect_action = lambda: self.sync_client.send_message({
|
||||
"type": "join_room",
|
||||
"username": self.username,
|
||||
"code": self.room_code
|
||||
})
|
||||
|
||||
if self.sync_client.running and self.sync_client.ws:
|
||||
self.on_ws_connected()
|
||||
else:
|
||||
self.sync_client.start()
|
||||
|
||||
def leave_room(self):
|
||||
self.vlc_player.stop()
|
||||
self.sync_client.stop()
|
||||
self.stacked_widget.setCurrentIndex(0)
|
||||
self.local_file_path = None
|
||||
self.create_file_info.hide()
|
||||
self.room_code = ""
|
||||
self.check_inputs()
|
||||
self.create_room_btn.setText("Create Room")
|
||||
self.join_room_btn.setText("Join Room")
|
||||
|
||||
# --- WebSocket Callbacks ---
|
||||
def on_ws_connected(self):
|
||||
if self.pending_connect_action:
|
||||
self.pending_connect_action()
|
||||
self.pending_connect_action = None
|
||||
|
||||
def on_room_error(self, msg: str):
|
||||
QMessageBox.critical(self, "Room Error", msg)
|
||||
self.create_room_btn.setText("Create Room")
|
||||
self.join_room_btn.setText("Join Room")
|
||||
self.check_inputs()
|
||||
self.sync_client.stop()
|
||||
|
||||
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))
|
||||
|
||||
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}", "", "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)
|
||||
|
||||
if self.local_file_name == req_name and self.local_file_size == req_size:
|
||||
self.sync_client.send_message({
|
||||
"type": "confirm_join",
|
||||
"fileInfo": {
|
||||
"name": self.local_file_name,
|
||||
"size": self.local_file_size,
|
||||
"duration": 0
|
||||
}
|
||||
})
|
||||
else:
|
||||
QMessageBox.critical(self, "File Mismatch", "The selected file does not exactly match the room's required file.")
|
||||
self.sync_client.stop()
|
||||
self.join_room_btn.setText("Join Room")
|
||||
self.check_inputs()
|
||||
else:
|
||||
self.sync_client.stop()
|
||||
self.join_room_btn.setText("Join Room")
|
||||
self.check_inputs()
|
||||
|
||||
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.room_code_display.setText(f"Room: {self.room_code}")
|
||||
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.setText("Welcome to the room! 👋<br>")
|
||||
|
||||
if self.local_file_path:
|
||||
self.vlc_player.load_media(self.local_file_path)
|
||||
|
||||
users = msg.get("users", [])
|
||||
if users:
|
||||
self.on_users_updated(users)
|
||||
|
||||
state = msg.get("state", {})
|
||||
if state:
|
||||
self.on_sync_event(state)
|
||||
|
||||
chat_history = msg.get("chatHistory", [])
|
||||
if chat_history:
|
||||
self.chat_messages.setText("Welcome to the room! 👋<br>")
|
||||
for chat in chat_history:
|
||||
self.on_chat_message(chat.get("username", "Unknown"), chat.get("message", ""), chat.get("timestamp", 0))
|
||||
|
||||
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")
|
||||
|
||||
current = self.chat_messages.text()
|
||||
new_msg = f"<b>{author}</b>: {text} <span style='color: gray; font-size: 10px;'>{time_str}</span><br>"
|
||||
self.chat_messages.setText(current + new_msg)
|
||||
|
||||
def on_system_message(self, text: str):
|
||||
current = self.chat_messages.text()
|
||||
new_msg = f"<i style='color: #888;'>{text}</i><br>"
|
||||
self.chat_messages.setText(current + 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)//60}:{int(s)%60:02d}"
|
||||
self.on_system_message(f"{username} seeked to {fmt(position_s)}")
|
||||
|
||||
def send_chat(self):
|
||||
text = self.chat_input.text().strip()
|
||||
if text:
|
||||
self.sync_client.send_message({"type": "chat", "message": text})
|
||||
self.chat_input.setText("")
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
#primaryBtn {
|
||||
background-color: #3ea6ff;
|
||||
color: black;
|
||||
border: none;
|
||||
}
|
||||
#primaryBtn:hover {
|
||||
background-color: #65b8ff;
|
||||
}
|
||||
#primaryBtn:disabled {
|
||||
background-color: #333;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
#secondaryBtn {
|
||||
background-color: #272727;
|
||||
color: white;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
#secondaryBtn:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
#secondaryBtn:disabled {
|
||||
border-color: #222;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
#chatContainer {
|
||||
background-color: #111;
|
||||
border-left: 1px solid #333;
|
||||
}
|
||||
|
||||
#chatHeader {
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
#usersLbl {
|
||||
color: #3ea6ff;
|
||||
font-size: 12px;
|
||||
}
|
||||
""")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Tell Windows this is a distinct app so the taskbar icon updates correctly
|
||||
if os.name == 'nt':
|
||||
myappid = 'vlcsync.desktopclient.app.1'
|
||||
try:
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Set app-level icon
|
||||
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())
|
||||
Reference in New Issue
Block a user