Compare commits
10 Commits
bec873a9c7
...
4665cf700e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4665cf700e | ||
|
|
a5b7e08a6a | ||
|
|
31c79e794e | ||
|
|
4cf4c153cd | ||
|
|
9d22860f0d | ||
|
|
3e1ea32383 | ||
|
|
36d6aeaf51 | ||
|
|
b587b5e87d | ||
|
|
475fdbb2b8 | ||
|
|
2020b59259 |
23
.gitignore
vendored
23
.gitignore
vendored
@@ -3,3 +3,26 @@ bun.lockb
|
||||
.env
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
**/.pytest_cache/
|
||||
|
||||
# Environments (uv / venv)
|
||||
.venv/
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Local media testing files
|
||||
*.mkv
|
||||
*.mp4
|
||||
*.avi
|
||||
|
||||
# PyInstaller / Build
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
*.exe
|
||||
|
||||
1
desktop-client/.python-version
Normal file
1
desktop-client/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
0
desktop-client/README.md
Normal file
0
desktop-client/README.md
Normal file
2
desktop-client/build.ps1
Normal file
2
desktop-client/build.ps1
Normal file
@@ -0,0 +1,2 @@
|
||||
uv add pyinstaller pillow
|
||||
uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --add-data "icon.png;." main.py
|
||||
3
desktop-client/check.svg
Normal file
3
desktop-client/check.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4BB543" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
4
desktop-client/copy.svg
Normal file
4
desktop-client/copy.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#aaaaaa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 333 B |
BIN
desktop-client/icon.png
Normal file
BIN
desktop-client/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 382 KiB |
879
desktop-client/main.py
Normal file
879
desktop-client/main.py
Normal file
@@ -0,0 +1,879 @@
|
||||
import sys
|
||||
import os
|
||||
import ctypes
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QStackedWidget, QWidget, QVBoxLayout,
|
||||
QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox,
|
||||
QFrame, QSlider, QTextEdit
|
||||
)
|
||||
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("wss://video-sync.peterstockings.com/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.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({
|
||||
"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}", "", 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 == 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.setHtml("Welcome to the room! 👋")
|
||||
|
||||
if self.local_file_path:
|
||||
self.vlc_player.load_media(self.local_file_path)
|
||||
self.vlc_player.set_volume(self.volume_slider.value())
|
||||
|
||||
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.setHtml("Welcome to the room! 👋")
|
||||
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")
|
||||
|
||||
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):
|
||||
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;
|
||||
}
|
||||
|
||||
#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;
|
||||
color: #f1f1f1;
|
||||
}
|
||||
|
||||
#chatMessages QScrollBar:vertical {
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 8px;
|
||||
margin: 0px;
|
||||
}
|
||||
#chatMessages QScrollBar::handle:vertical {
|
||||
background: #555;
|
||||
min-height: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
#chatMessages QScrollBar::handle:vertical:hover {
|
||||
background: #666;
|
||||
}
|
||||
#chatMessages QScrollBar::add-line:vertical, #chatMessages QScrollBar::sub-line:vertical {
|
||||
height: 0px;
|
||||
}
|
||||
#chatMessages QScrollBar::add-page:vertical, #chatMessages QScrollBar::sub-page:vertical {
|
||||
background: none;
|
||||
}
|
||||
""")
|
||||
|
||||
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())
|
||||
13
desktop-client/pyproject.toml
Normal file
13
desktop-client/pyproject.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "python-app"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"pillow>=12.1.1",
|
||||
"pyinstaller>=6.19.0",
|
||||
"pyqt6>=6.10.2",
|
||||
"python-vlc>=3.0.21203",
|
||||
"websockets>=16.0",
|
||||
]
|
||||
91
desktop-client/sync_client.py
Normal file
91
desktop-client/sync_client.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import asyncio
|
||||
import json
|
||||
import websockets
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
class SyncClientThread(QThread):
|
||||
# Signals from WebSocket -> PyQt UI
|
||||
connected = pyqtSignal()
|
||||
disconnected = pyqtSignal()
|
||||
room_joined = pyqtSignal(dict)
|
||||
room_error = pyqtSignal(str)
|
||||
file_check_needed = pyqtSignal(dict) # msg
|
||||
users_updated = pyqtSignal(list)
|
||||
chat_message = pyqtSignal(str, str, int) # author, text, timestamp
|
||||
system_message = pyqtSignal(str)
|
||||
sync_event = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, url="ws://localhost:3000/ws"):
|
||||
super().__init__()
|
||||
self.url = url
|
||||
self.ws = None
|
||||
self.loop = None
|
||||
self.running = False
|
||||
|
||||
def run(self):
|
||||
"""Runs strictly within the newly created QThread"""
|
||||
self.running = True
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.loop.run_until_complete(self._connect_and_listen())
|
||||
self.loop.close()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
if self.ws and self.loop:
|
||||
asyncio.run_coroutine_threadsafe(self.ws.close(), self.loop)
|
||||
self.quit()
|
||||
self.wait()
|
||||
|
||||
def send_message(self, message: dict):
|
||||
"""Called safely from the main PyQt thread to send data out"""
|
||||
if self.ws and self.loop:
|
||||
json_str = json.dumps(message)
|
||||
asyncio.run_coroutine_threadsafe(self.ws.send(json_str), self.loop)
|
||||
|
||||
async def _connect_and_listen(self):
|
||||
while self.running:
|
||||
try:
|
||||
async with websockets.connect(self.url) as ws:
|
||||
self.ws = ws
|
||||
self.connected.emit()
|
||||
|
||||
try:
|
||||
async for message in ws:
|
||||
if not self.running:
|
||||
break
|
||||
self._handle_message(json.loads(message))
|
||||
except websockets.ConnectionClosed:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f"WebSocket Error: {e}")
|
||||
|
||||
self.disconnected.emit()
|
||||
if self.running:
|
||||
# Reconnect backoff
|
||||
await asyncio.sleep(2)
|
||||
|
||||
def _handle_message(self, msg: dict):
|
||||
t = msg.get("type")
|
||||
|
||||
if t == "room_created":
|
||||
self.room_joined.emit(msg)
|
||||
|
||||
elif t == "room_joined" or t == "room_rejoined":
|
||||
self.room_joined.emit(msg)
|
||||
|
||||
elif t == "error":
|
||||
self.room_error.emit(msg.get("message", "Unknown error"))
|
||||
|
||||
elif t == "room_file_check":
|
||||
self.file_check_needed.emit(msg)
|
||||
|
||||
elif t in ["user_joined", "user_left"]:
|
||||
self.users_updated.emit(msg.get("users", []))
|
||||
|
||||
elif t == "chat":
|
||||
self.chat_message.emit(msg.get("username", "Unknown"), msg.get("message", ""), msg.get("timestamp", 0))
|
||||
|
||||
elif t == "sync":
|
||||
self.sync_event.emit(msg)
|
||||
60
desktop-client/test_app.py
Normal file
60
desktop-client/test_app.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import pytest
|
||||
from PyQt6.QtCore import Qt
|
||||
from main import VlcSyncApp
|
||||
|
||||
def test_app_ui_flow(qtbot):
|
||||
# Initialize the main application
|
||||
app = VlcSyncApp()
|
||||
qtbot.addWidget(app)
|
||||
|
||||
# 1. Test Lobby View
|
||||
assert app.stacked_widget.currentIndex() == 0
|
||||
assert not app.create_room_btn.isEnabled()
|
||||
|
||||
# Fill out the Lobby Form
|
||||
app.username_input.setText("PyTestUser")
|
||||
|
||||
# Mocking file selection instead of opening the native dialog
|
||||
app.local_file_path = "test_pytest.mkv"
|
||||
app.local_file_name = "test_pytest.mkv"
|
||||
app.local_file_size = 1048576
|
||||
app.check_inputs()
|
||||
|
||||
# Button should now be active
|
||||
assert app.create_room_btn.isEnabled()
|
||||
|
||||
# 2. Test Creating Room matches integration pipeline
|
||||
qtbot.mouseClick(app.create_room_btn, Qt.MouseButton.LeftButton)
|
||||
|
||||
# Wait for the WebSocket connected signal, the room_created server response, and UI transition
|
||||
def check_room_joined():
|
||||
assert app.stacked_widget.currentIndex() == 1
|
||||
assert len(app.room_code) > 2
|
||||
|
||||
qtbot.waitUntil(check_room_joined, timeout=5000)
|
||||
|
||||
# 3. Test Chat flow End-to-End
|
||||
# Type a message
|
||||
qtbot.keyClicks(app.chat_input, "Automated UI Test Message")
|
||||
# Click Send
|
||||
qtbot.mouseClick(app.chat_send_btn, Qt.MouseButton.LeftButton)
|
||||
|
||||
# Wait until the Bun server grabs the websocket payload, stores it, and broadcasts it back to the UI!
|
||||
def check_chat_received():
|
||||
assert "Automated UI Test Message" in app.chat_messages.text()
|
||||
|
||||
qtbot.waitUntil(check_chat_received, timeout=3000)
|
||||
|
||||
# 4. Test Playback Sync (UI updates and internal flags)
|
||||
assert app.play_btn.text() == "▶"
|
||||
|
||||
# Click Play
|
||||
qtbot.mouseClick(app.play_btn, Qt.MouseButton.LeftButton)
|
||||
|
||||
def check_playback_started():
|
||||
assert app.play_btn.text() == "⏸"
|
||||
|
||||
qtbot.waitUntil(check_playback_started, timeout=2000)
|
||||
|
||||
# Clean up background threads
|
||||
app.leave_room()
|
||||
145
desktop-client/test_integration.py
Normal file
145
desktop-client/test_integration.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import asyncio
|
||||
import json
|
||||
import websockets
|
||||
import sys
|
||||
|
||||
async def run_integration_test():
|
||||
url = "wss://video-sync.peterstockings.com/ws"
|
||||
|
||||
print("🤖 Test: Starting Integration Test Suite...")
|
||||
|
||||
# Client 1: Creator
|
||||
try:
|
||||
creator_ws = await websockets.connect(url)
|
||||
print("✅ Creator connected to WebSocket")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect to server: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
creator_msg = {
|
||||
"type": "create_room",
|
||||
"username": "TestCreator",
|
||||
"fileInfo": {
|
||||
"name": "test_video.mkv",
|
||||
"size": 1048576, # 1MB
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
await creator_ws.send(json.dumps(creator_msg))
|
||||
print("📤 Creator sent 'create_room'")
|
||||
|
||||
room_code = None
|
||||
|
||||
# Wait for room_created
|
||||
try:
|
||||
response_str = await asyncio.wait_for(creator_ws.recv(), timeout=2.0)
|
||||
response = json.loads(response_str)
|
||||
if response.get("type") == "room_created":
|
||||
room_code = response.get("code")
|
||||
print(f"✅ Creator received room_created with code: {room_code}")
|
||||
else:
|
||||
print(f"❌ Unexpected response for creator: {response}")
|
||||
sys.exit(1)
|
||||
except asyncio.TimeoutError:
|
||||
print("❌ Timeout waiting for 'room_created'")
|
||||
sys.exit(1)
|
||||
|
||||
# Client 2: Joiner
|
||||
try:
|
||||
joiner_ws = await websockets.connect(url)
|
||||
print("✅ Joiner connected to WebSocket")
|
||||
except Exception as e:
|
||||
print(f"❌ Joiner failed to connect to server: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
joiner_msg = {
|
||||
"type": "join_room",
|
||||
"username": "TestJoiner",
|
||||
"code": room_code
|
||||
}
|
||||
|
||||
await joiner_ws.send(json.dumps(joiner_msg))
|
||||
print(f"📤 Joiner sent 'join_room' for code: {room_code}")
|
||||
|
||||
# Wait for file_check needed
|
||||
try:
|
||||
response_str = await asyncio.wait_for(joiner_ws.recv(), timeout=2.0)
|
||||
response = json.loads(response_str)
|
||||
if response.get("type") == "room_file_check":
|
||||
print(f"✅ Joiner received room_file_check from server. Payload: {response}")
|
||||
|
||||
# Send file confirmation
|
||||
confirm_msg = {
|
||||
"type": "confirm_join",
|
||||
"fileInfo": {
|
||||
"name": "test_video.mkv",
|
||||
"size": 1048576,
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
await joiner_ws.send(json.dumps(confirm_msg))
|
||||
print("📤 Joiner sent 'confirm_join'")
|
||||
|
||||
# Wait for successful room_joined
|
||||
response_str = await asyncio.wait_for(joiner_ws.recv(), timeout=2.0)
|
||||
response = json.loads(response_str)
|
||||
if response.get("type") == "room_joined":
|
||||
print(f"✅ Joiner successfully received room_joined: {list(response.keys())}")
|
||||
|
||||
# Check users payload from the join response itself
|
||||
users = response.get("users", [])
|
||||
print(f"✅ Joiner received user list from room_joined payload: {users}")
|
||||
assert len(users) == 2, "Expected 2 users in the room!"
|
||||
|
||||
# Setup wait task for the Joiner to receive a Play sync event
|
||||
print("⏳ Creator is sending a Play sync event...")
|
||||
await creator_ws.send(json.dumps({
|
||||
"type": "sync",
|
||||
"action": "play",
|
||||
"position": 5.0
|
||||
}))
|
||||
|
||||
# Creator will get an echo, Joiner will get the broadcast
|
||||
creator_echo = json.loads(await asyncio.wait_for(creator_ws.recv(), timeout=2.0))
|
||||
joiner_sync = json.loads(await asyncio.wait_for(joiner_ws.recv(), timeout=2.0))
|
||||
|
||||
if joiner_sync.get("type") == "sync":
|
||||
print(f"✅ Joiner received sync event: {joiner_sync}")
|
||||
assert joiner_sync.get("action") == "play", "Expected 'play' sync event"
|
||||
assert joiner_sync.get("position") == 5.0, "Expected position 5.0"
|
||||
else:
|
||||
print(f"❌ Joiner expected sync, got: {joiner_sync}")
|
||||
sys.exit(1)
|
||||
|
||||
# Setup wait task for the Joiner to send a Chat message
|
||||
print("⏳ Joiner is sending a Chat message...")
|
||||
await joiner_ws.send(json.dumps({
|
||||
"type": "chat",
|
||||
"message": "Hello from integration test!"
|
||||
}))
|
||||
|
||||
creator_chat = json.loads(await asyncio.wait_for(creator_ws.recv(), timeout=2.0))
|
||||
joiner_chat = json.loads(await asyncio.wait_for(joiner_ws.recv(), timeout=2.0))
|
||||
|
||||
if creator_chat.get("type") == "chat":
|
||||
print(f"✅ Creator received chat event: {creator_chat}")
|
||||
assert creator_chat.get("username") == "TestJoiner", "Expected TestJoiner author"
|
||||
assert creator_chat.get("message") == "Hello from integration test!", "Expected correct message"
|
||||
else:
|
||||
print(f"❌ Creator expected chat, got: {creator_chat}")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
print(f"❌ Joiner expected room_joined, got: {response}")
|
||||
else:
|
||||
print(f"❌ Joiner expected room_file_check, got: {response}")
|
||||
except asyncio.TimeoutError:
|
||||
print("❌ Timeout waiting for server response to 'join_room'")
|
||||
sys.exit(1)
|
||||
|
||||
await creator_ws.close()
|
||||
await joiner_ws.close()
|
||||
print("🎉 Integration test completed successfully!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_integration_test())
|
||||
234
desktop-client/uv.lock
generated
Normal file
234
desktop-client/uv.lock
generated
Normal file
@@ -0,0 +1,234 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "altgraph"
|
||||
version = "0.17.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macholib"
|
||||
version = "1.16.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "altgraph" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pefile"
|
||||
version = "2024.8.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller"
|
||||
version = "6.19.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "altgraph" },
|
||||
{ name = "macholib", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pefile", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyinstaller-hooks-contrib" },
|
||||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/63/fd62472b6371d89dc138d40c36d87a50dc2de18a035803bbdc376b4ffac4/pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865", size = 4036072, upload-time = "2026-02-14T18:06:28.718Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/eb/23374721fecfa72677e79800921cb6aceefa6ba48574dc404f3f6c6c3be7/pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134", size = 1040563, upload-time = "2026-02-14T18:05:22.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/7e/dfd724b0b533f5aaec0ee5df406fe2319987ed6964480a706f85478b12ea/pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11", size = 735477, upload-time = "2026-02-14T18:05:27.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/c9/ee3a4101c31f26344e66896c73c1fd6ed8282bf871473365b7f8674af406/pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf", size = 747143, upload-time = "2026-02-14T18:05:31.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0a/fc77e9f861be8cf300ac37155f59cc92aff99b29f2ddd78546f563a5b5a6/pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122", size = 744849, upload-time = "2026-02-14T18:05:35.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/e3/6872e020ee758afe0b821663858492c10745608b07150e5e2c824a5b3e1c/pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63", size = 741590, upload-time = "2026-02-14T18:05:39.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/60/b8db5f1a4b0fb228175f2ea0aa33f949adcc097fbe981cc524f9faf85777/pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe", size = 741448, upload-time = "2026-02-14T18:05:45.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/4d/63b0600f2694e9141b83129fbc1c488ec84d5a0770b1448ec154dcd0fee9/pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83", size = 740613, upload-time = "2026-02-14T18:05:49.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/d4/e812ad36178093a0e9fd4b8127577748dd85b0cb71de912229dca21fd741/pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6", size = 740350, upload-time = "2026-02-14T18:05:54.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/03/b2c2ee41fb8e10fd2a45d21f5ec2ef25852cfb978dbf762972eed59e3d63/pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2", size = 1324317, upload-time = "2026-02-14T18:06:00.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/d3/6d5e62b8270e2b53a6065e281b3a7785079b00e9019c8019952828dd1669/pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33", size = 1384894, upload-time = "2026-02-14T18:06:06.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/65/458cd523308a101a22fd2742893405030cc24994cc74b1b767cecf137160/pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea", size = 1325374, upload-time = "2026-02-14T18:06:12.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller-hooks-contrib"
|
||||
version = "2026.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/eb/e1dd9a5348e4cf348471c0e5fd617d948779bc3199cf4edb134d8fceca91/pyinstaller_hooks_contrib-2026.1.tar.gz", hash = "sha256:a5f0891a1e81e92406ab917d9e76adfd7a2b68415ee2e35c950a7b3910bc361b", size = 171504, upload-time = "2026-02-18T13:01:15.711Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/69/12bafee3cc485d977f596e0d803d7c6fb147430fc35dfe505730aa3a28dd/pyinstaller_hooks_contrib-2026.1-py3-none-any.whl", hash = "sha256:66ad4888ba67de6f3cfd7ef554f9dd1a4389e2eb19f84d7129a5a6818e3f2180", size = 452841, upload-time = "2026-02-18T13:01:14.471Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyqt6"
|
||||
version = "6.10.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyqt6-qt6" },
|
||||
{ name = "pyqt6-sip" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/03/e756f52e8b0d7bb5527baf8c46d59af0746391943bdb8655acba22ee4168/pyqt6-6.10.2.tar.gz", hash = "sha256:6c0db5d8cbb9a3e7e2b5b51d0ff3f283121fa27b864db6d2f35b663c9be5cc83", size = 1085573, upload-time = "2026-01-08T16:40:00.244Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/3f/f073a980969aa485ef288eb2e3b94c223ba9c7ac9941543f19b51659b98d/pyqt6-6.10.2-cp39-abi3-macosx_10_14_universal2.whl", hash = "sha256:37ae7c1183fe4dd0c6aefd2006a35731245de1cb6f817bb9e414a3e4848dfd6d", size = 60244482, upload-time = "2026-01-08T16:38:50.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/3e/9a015651ec71cea2e2f960c37edeb21623ba96a74956c0827def837f7c6b/pyqt6-6.10.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:78e1b3d5763e4cbc84485aef600e0aba5e1932fd263b716f92cd1a40dfa5e924", size = 37899440, upload-time = "2026-01-08T16:39:09.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/74/a88fec2b99700270ca5d7dc7d650236a4990ed6fc88e055ca0fc8a339ee3/pyqt6-6.10.2-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bbc3af541bbecd27301bfe69fe445aa1611a9b490bd3de77306b12df632f7ec6", size = 40748467, upload-time = "2026-01-08T16:39:29.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/34/be7a55529607b21db00a49ca53cb07c3092d2a5a95ea19bb95cfa0346904/pyqt6-6.10.2-cp39-abi3-win_amd64.whl", hash = "sha256:bd328cb70bc382c48861cd5f0a11b2b8ae6f5692d5a2d6679ba52785dced327b", size = 26015391, upload-time = "2026-01-08T16:39:42.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/de/d9c88f976602b7884fec4ad54a4575d48e23e4f390e5357ea83917358846/pyqt6-6.10.2-cp39-abi3-win_arm64.whl", hash = "sha256:7901ba1df024b7ee9fdacfb2b7661aeb3749ae8b0bef65428077de3e0450eabb", size = 26208415, upload-time = "2026-01-08T16:39:57.751Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyqt6-qt6"
|
||||
version = "6.10.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/eb/f04d547d8ed9f20c7b246db4ef5d93b49cab4692009a10652ed0a8b9d2aa/pyqt6_qt6-6.10.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:5761cfccc721da2311c3f1213577f0ff1df07bbbbe3fa3a209a256b82cf057e3", size = 68688870, upload-time = "2026-01-29T12:26:48.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/c8/d99e65ab01c2402fb6bc4f77abef7244f7d5fb2f2e6d5b0abdf71bb2e4fc/pyqt6_qt6-6.10.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6dda853a8db1b8d1a2ddbbe76cc6c3aa86614cad14056bd3c0435d8feea73b2d", size = 62512013, upload-time = "2026-01-29T12:27:24.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fe/01fd9b9d2ca139ef61582f2e2da249fa169229144294c1bb27db59ad8420/pyqt6_qt6-6.10.2-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:19c10b5f0806e9f9bac2c9759bd5d7d19a78967f330fd60a2db409177fa76e49", size = 84028760, upload-time = "2026-01-29T12:28:03.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/20/a0d027ebb267d3afaf319d94efe1ff4d667004ee83b96701329a4d11fb95/pyqt6_qt6-6.10.2-py3-none-manylinux_2_39_aarch64.whl", hash = "sha256:2e60d616861ca4565cd295418d605975aa2dc407ba4b94c1586a70c92e9cb052", size = 83063975, upload-time = "2026-01-29T12:28:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/8e/595f215876d507417cc8565e05519916d3b0b76baedea6a1e4e5105633fc/pyqt6_qt6-6.10.2-py3-none-win_amd64.whl", hash = "sha256:c4b7f7d66cc58bddf1bc1ca28dfcf7a45f58cfcb11d81d13a0510409dd4957ac", size = 78433821, upload-time = "2026-01-29T12:29:35.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/5f/2196e2b536217b87cb3d2ce13ef8f7607d08b02f1990a4bd84a88d293a3c/pyqt6_qt6-6.10.2-py3-none-win_arm64.whl", hash = "sha256:7164a6f0c1335358a3026df9865c8f75395b01f60f0dcd2f66c029ec16fc83d2", size = 58354426, upload-time = "2026-01-29T12:30:02.95Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyqt6-sip"
|
||||
version = "13.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/7d/d2916048e2e3960f68cb4e93907639844f7b8ff95897dcc98553776ccdfc/pyqt6_sip-13.11.0.tar.gz", hash = "sha256:d463af37738bda1856c9ef513e5620a37b7a005e9d589c986c3304db4a8a14d3", size = 92509, upload-time = "2026-01-13T16:01:32.16Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/28/a5178c8e005bafbf9c0fd507f45a3eef619ab582811414a0a461ee75994f/pyqt6_sip-13.11.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4dc9c4df24af0571423c3e85b5c008bad42ed48558eef80fbc3e5d30274c5abb", size = 112431, upload-time = "2026-01-13T16:01:23.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/3c/02770b02b5a05779e26bd02c202c2fd32aa38e225d01f14c06908e33738c/pyqt6_sip-13.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c974d5a193f32e55e746e9b63138503163ac63500dbb1fd67233d8a8d71369bd", size = 301236, upload-time = "2026-01-13T16:01:28.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/47/5af493a698cc520581ca1000b4ab09b8182992053ffe2478062dde5e4671/pyqt6_sip-13.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4284540ffccd8349763ddce3518264dde62f20556720d4061b9c895e09011ca0", size = 323919, upload-time = "2026-01-13T16:01:25.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/2d/64b26e21183a7ff180105871dd5983a8da539d8768921728268dc6d0a73d/pyqt6_sip-13.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:9bd81cb351640abc803ea2fe7262b5adea28615c9b96fd103d1b6f3459937211", size = 55078, upload-time = "2026-01-13T16:01:29.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/36/23f699fa8b1c3fcc312ecd12661a1df6057d92e16d4def2399b59cf7bf22/pyqt6_sip-13.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:cd95ec98f8edb15bcea832b8657809f69d758bc4151cc6fd7790c0181949e45f", size = 49465, upload-time = "2026-01-13T16:01:31.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-app"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "pillow" },
|
||||
{ name = "pyinstaller" },
|
||||
{ name = "pyqt6" },
|
||||
{ name = "python-vlc" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "pillow", specifier = ">=12.1.1" },
|
||||
{ name = "pyinstaller", specifier = ">=6.19.0" },
|
||||
{ name = "pyqt6", specifier = ">=6.10.2" },
|
||||
{ name = "python-vlc", specifier = ">=3.0.21203" },
|
||||
{ name = "websockets", specifier = ">=16.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-vlc"
|
||||
version = "3.0.21203"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/5b/f9ce6f0c9877b6fe5eafbade55e0dcb6b2b30f1c2c95837aef40e390d63b/python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec", size = 162211, upload-time = "2024-10-07T14:39:54.755Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/ee/7d76eb3b50ccb1397621f32ede0fb4d17aa55a9aa2251bc34e6b9929fdce/python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320", size = 87651, upload-time = "2024-10-07T14:39:50.021Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
version = "0.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "82.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
107
desktop-client/vlc_player.py
Normal file
107
desktop-client/vlc_player.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import vlc
|
||||
import sys
|
||||
from PyQt6.QtWidgets import QFrame
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QObject
|
||||
import threading
|
||||
|
||||
class VLCSignals(QObject):
|
||||
state_changed = pyqtSignal(bool, int) # is_playing, time_ms
|
||||
time_changed = pyqtSignal(int) # time_ms
|
||||
|
||||
class VLCSyncPlayer:
|
||||
def __init__(self, frame: QFrame):
|
||||
self.frame = frame
|
||||
self.signals = VLCSignals()
|
||||
|
||||
# Initialize VLC instance
|
||||
# --no-xlib prevents crashes on Linux
|
||||
# --drop-late-frames improves sync by not delaying playback when CPU is slow
|
||||
self.instance = vlc.Instance("--no-xlib", "--drop-late-frames")
|
||||
self.media_player = self.instance.media_player_new()
|
||||
|
||||
# Embed the VLC player into the provided PyQt QFrame
|
||||
# On Windows, PyQt6 widgets don't have a native handle by default
|
||||
self.frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow)
|
||||
|
||||
if sys.platform.startswith('linux'):
|
||||
self.media_player.set_xwindow(int(self.frame.winId()))
|
||||
elif sys.platform == "win32":
|
||||
# For Windows, we must explicitly cast winId() to int
|
||||
self.media_player.set_hwnd(int(self.frame.winId()))
|
||||
elif sys.platform == "darwin":
|
||||
self.media_player.set_nsobject(int(self.frame.winId()))
|
||||
|
||||
# Register Event Callbacks
|
||||
self.events = self.media_player.event_manager()
|
||||
self.events.event_attach(vlc.EventType.MediaPlayerPlaying, self._on_playing)
|
||||
self.events.event_attach(vlc.EventType.MediaPlayerPaused, self._on_paused)
|
||||
self.events.event_attach(vlc.EventType.MediaPlayerTimeChanged, self._on_time_changed)
|
||||
|
||||
# Local State
|
||||
self.is_playing = False
|
||||
self.current_time_ms = 0
|
||||
self.ignore_next_event = False
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def load_media(self, path: str):
|
||||
media = self.instance.media_new(path)
|
||||
self.media_player.set_media(media)
|
||||
|
||||
def play(self):
|
||||
with self.lock:
|
||||
self.ignore_next_event = True
|
||||
self.media_player.play()
|
||||
|
||||
def pause(self):
|
||||
with self.lock:
|
||||
self.ignore_next_event = True
|
||||
self.media_player.set_pause(1)
|
||||
|
||||
def seek(self, position_ms: int):
|
||||
with self.lock:
|
||||
self.ignore_next_event = True
|
||||
self.media_player.set_time(position_ms)
|
||||
|
||||
def set_volume(self, volume: int):
|
||||
self.media_player.audio_set_volume(volume)
|
||||
|
||||
def get_volume(self) -> int:
|
||||
return self.media_player.audio_get_volume()
|
||||
|
||||
# --- Internal VLC Callbacks ---
|
||||
|
||||
@vlc.callbackmethod
|
||||
def _on_playing(self, event):
|
||||
self.is_playing = True
|
||||
with self.lock:
|
||||
if self.ignore_next_event:
|
||||
self.ignore_next_event = False
|
||||
return
|
||||
|
||||
time_ms = self.media_player.get_time()
|
||||
# Fire signal to PyQt thread
|
||||
self.signals.state_changed.emit(True, time_ms)
|
||||
|
||||
@vlc.callbackmethod
|
||||
def _on_paused(self, event):
|
||||
self.is_playing = False
|
||||
with self.lock:
|
||||
if self.ignore_next_event:
|
||||
self.ignore_next_event = False
|
||||
return
|
||||
|
||||
time_ms = self.media_player.get_time()
|
||||
self.signals.state_changed.emit(False, time_ms)
|
||||
|
||||
@vlc.callbackmethod
|
||||
def _on_time_changed(self, event):
|
||||
# Emitted constantly during playback
|
||||
self.current_time_ms = event.u.new_time
|
||||
# We also want to fire this signal so the UI scrubber/time label can update
|
||||
self.signals.time_changed.emit(self.current_time_ms)
|
||||
|
||||
def get_length(self):
|
||||
return self.media_player.get_length()
|
||||
|
||||
def stop(self):
|
||||
self.media_player.stop()
|
||||
110
public/app.js
110
public/app.js
@@ -10,8 +10,6 @@
|
||||
let joinFile = null; // File object for the joiner
|
||||
let ignoreSync = false; // flag to avoid feedback loops
|
||||
let syncTimeout = null;
|
||||
let driftInterval = null;
|
||||
let serverState = { playing: false, position: 0, speed: 1 };
|
||||
|
||||
// Reconnection state
|
||||
let reconnectAttempts = 0;
|
||||
@@ -73,7 +71,6 @@
|
||||
const volumeSlider = $("volume-slider");
|
||||
const currentTimeEl = $("current-time");
|
||||
const durationEl = $("duration");
|
||||
const speedSelect = $("speed-select");
|
||||
const fullscreenBtn = $("fullscreen-btn");
|
||||
const userCountEl = $("user-count");
|
||||
const usersList = $("users-list");
|
||||
@@ -243,7 +240,6 @@
|
||||
applySync(msg.state);
|
||||
}
|
||||
addSystemMessage("Reconnected");
|
||||
startDriftCorrection();
|
||||
flushMessageQueue();
|
||||
break;
|
||||
|
||||
@@ -298,82 +294,22 @@
|
||||
// Load local file into video player
|
||||
const file = localFile || joinFile;
|
||||
if (file) {
|
||||
loadVideoSource(file);
|
||||
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
|
||||
currentBlobUrl = URL.createObjectURL(file);
|
||||
videoPlayer.src = currentBlobUrl;
|
||||
}
|
||||
|
||||
// --- Handle false "ended" events (Chromium MKV blob bug) ---
|
||||
// Chrome can't properly index MKV containers loaded via blob URLs.
|
||||
// After ~30s it may jump to the end and fire "ended" even though
|
||||
// the video isn't actually over. Recovery: reload the blob and seek.
|
||||
let recoveryAttempts = 0;
|
||||
const MAX_RECOVERY_ATTEMPTS = 5;
|
||||
|
||||
videoPlayer.addEventListener("ended", () => {
|
||||
const duration = videoPlayer.duration || 0;
|
||||
// Only attempt recovery if we have server state showing we're not near the end
|
||||
if (duration > 0 && lastServerState.position < duration - 5 && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
|
||||
recoveryAttempts++;
|
||||
console.log(`[MKV-RECOVERY] False ended detected. Server pos=${lastServerState.position.toFixed(1)}, duration=${duration.toFixed(1)}. Reloading source (attempt ${recoveryAttempts}/${MAX_RECOVERY_ATTEMPTS})`);
|
||||
|
||||
const targetPos = lastServerState.position;
|
||||
const wasPlaying = lastServerState.playing;
|
||||
const currentFile = localFile || joinFile;
|
||||
|
||||
if (currentFile) {
|
||||
// Reload the video with a fresh blob URL
|
||||
loadVideoSource(currentFile, targetPos, wasPlaying);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reset recovery counter when video plays successfully for a while
|
||||
let recoveryResetTimer = null;
|
||||
videoPlayer.addEventListener("timeupdate", () => {
|
||||
if (recoveryAttempts > 0) {
|
||||
clearTimeout(recoveryResetTimer);
|
||||
recoveryResetTimer = setTimeout(() => {
|
||||
recoveryAttempts = 0;
|
||||
}, 10000); // Reset after 10s of successful playback
|
||||
}
|
||||
});
|
||||
|
||||
// --- Resync on tab focus (handles background tab throttling) ---
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && roomCode && ws && ws.readyState === WebSocket.OPEN) {
|
||||
console.log("[SYNC] Tab became visible, requesting state resync");
|
||||
send({ type: "request_state" });
|
||||
}
|
||||
});
|
||||
|
||||
// Start drift correction
|
||||
startDriftCorrection();
|
||||
}
|
||||
|
||||
// --- Video Source Loading ---
|
||||
// --- State tracking ---
|
||||
let currentBlobUrl = null;
|
||||
let lastServerState = { playing: false, position: 0, speed: 1 };
|
||||
|
||||
function loadVideoSource(file, seekTo, shouldPlay) {
|
||||
// Revoke old blob URL to free memory
|
||||
if (currentBlobUrl) {
|
||||
URL.revokeObjectURL(currentBlobUrl);
|
||||
}
|
||||
currentBlobUrl = URL.createObjectURL(file);
|
||||
videoPlayer.src = currentBlobUrl;
|
||||
|
||||
if (seekTo !== undefined) {
|
||||
videoPlayer.addEventListener("loadedmetadata", function onMeta() {
|
||||
videoPlayer.removeEventListener("loadedmetadata", onMeta);
|
||||
videoPlayer.currentTime = seekTo;
|
||||
if (shouldPlay) {
|
||||
videoPlayer.play().then(() => {
|
||||
videoWrapper.classList.add("playing");
|
||||
updatePlayPauseIcon();
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let lastServerState = { playing: false, position: 0 };
|
||||
|
||||
// --- File Check Modal ---
|
||||
let pendingRoomFileInfo = null;
|
||||
@@ -459,12 +395,7 @@
|
||||
// Track latest server state for recovery
|
||||
if (data.position !== undefined) lastServerState.position = data.position;
|
||||
if (data.playing !== undefined) lastServerState.playing = data.playing;
|
||||
if (data.speed !== undefined) lastServerState.speed = data.speed;
|
||||
|
||||
if (data.speed !== undefined && videoPlayer.playbackRate !== data.speed) {
|
||||
videoPlayer.playbackRate = data.speed;
|
||||
speedSelect.value = String(data.speed);
|
||||
}
|
||||
|
||||
if (data.position !== undefined) {
|
||||
const diff = Math.abs(videoPlayer.currentTime - data.position);
|
||||
@@ -510,14 +441,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startDriftCorrection() {
|
||||
if (driftInterval) clearInterval(driftInterval);
|
||||
driftInterval = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
send({ type: "request_state" });
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
|
||||
// --- Get video duration from a file (used for file info) ---
|
||||
function getVideoDuration(file) {
|
||||
@@ -544,6 +468,17 @@
|
||||
container.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function isMkvFile(file) {
|
||||
return file.name.toLowerCase().endsWith(".mkv");
|
||||
}
|
||||
|
||||
function showFormatWarning(container) {
|
||||
const warning = document.createElement("div");
|
||||
warning.className = "format-warning";
|
||||
warning.innerHTML = `⚠️ <strong>MKV files may not play correctly</strong> in browsers. Convert to MP4 for best results:<br><code>ffmpeg -i file.mkv -c copy file.mp4</code>`;
|
||||
container.parentNode.insertBefore(warning, container.nextSibling);
|
||||
}
|
||||
|
||||
// ===== EVENT LISTENERS =====
|
||||
|
||||
// --- Lobby: Username ---
|
||||
@@ -560,7 +495,11 @@
|
||||
localFile = file;
|
||||
const duration = await getVideoDuration(file);
|
||||
localFile._duration = duration;
|
||||
// Remove any previous format warning
|
||||
const oldWarning = createFileInfo.parentNode.querySelector(".format-warning");
|
||||
if (oldWarning) oldWarning.remove();
|
||||
renderFileInfo(createFileInfo, file, duration);
|
||||
if (isMkvFile(file)) showFormatWarning(createFileInfo);
|
||||
createRoomBtn.disabled = !usernameInput.value.trim();
|
||||
});
|
||||
|
||||
@@ -679,7 +618,6 @@
|
||||
joinFile = null;
|
||||
roomFileInfo = null;
|
||||
messageQueue = [];
|
||||
if (driftInterval) clearInterval(driftInterval);
|
||||
videoPlayer.src = "";
|
||||
chatMessages.innerHTML = '<div class="chat-welcome" id="chat-welcome"><p>Welcome to the room! 👋</p></div>';
|
||||
createFileInfo.classList.add("hidden");
|
||||
@@ -766,12 +704,6 @@
|
||||
volOffIcon.classList.toggle("hidden", !videoPlayer.muted);
|
||||
});
|
||||
|
||||
// Speed
|
||||
speedSelect.addEventListener("change", () => {
|
||||
const speed = parseFloat(speedSelect.value);
|
||||
videoPlayer.playbackRate = speed;
|
||||
send({ type: "sync", action: "speed", speed });
|
||||
});
|
||||
|
||||
// Fullscreen
|
||||
fullscreenBtn.addEventListener("click", () => {
|
||||
|
||||
@@ -158,15 +158,6 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="controls-right">
|
||||
<select id="speed-select" class="speed-select" title="Playback speed">
|
||||
<option value="0.25">0.25x</option>
|
||||
<option value="0.5">0.5x</option>
|
||||
<option value="0.75">0.75x</option>
|
||||
<option value="1" selected>1x</option>
|
||||
<option value="1.25">1.25x</option>
|
||||
<option value="1.5">1.5x</option>
|
||||
<option value="2">2x</option>
|
||||
</select>
|
||||
<button id="fullscreen-btn" class="ctrl-btn" title="Fullscreen">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="white">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
|
||||
@@ -282,6 +282,30 @@ select {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Format warning (MKV etc.) */
|
||||
.format-warning {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(243, 156, 18, 0.1);
|
||||
border: 1px solid rgba(243, 156, 18, 0.3);
|
||||
border-radius: var(--radius);
|
||||
color: var(--warning);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.format-warning code {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
|
||||
12
server.ts
12
server.ts
@@ -13,7 +13,6 @@ interface RoomState {
|
||||
fileInfo: FileInfo;
|
||||
playing: boolean;
|
||||
position: number;
|
||||
speed: number;
|
||||
lastUpdate: number; // timestamp when position was last set
|
||||
users: Map<string, WebSocket>;
|
||||
chatHistory: ChatMessage[];
|
||||
@@ -41,7 +40,7 @@ function generateRoomCode(): string {
|
||||
function getCurrentPosition(room: RoomState): number {
|
||||
if (!room.playing) return room.position;
|
||||
const elapsed = (Date.now() - room.lastUpdate) / 1000;
|
||||
return room.position + elapsed * room.speed;
|
||||
return room.position + elapsed; // assume 1x speed
|
||||
}
|
||||
|
||||
function broadcastToRoom(room: RoomState, message: object, excludeWs?: WebSocket) {
|
||||
@@ -159,7 +158,6 @@ const server = Bun.serve<WSData>({
|
||||
},
|
||||
playing: false,
|
||||
position: 0,
|
||||
speed: 1,
|
||||
lastUpdate: Date.now(),
|
||||
users: new Map(),
|
||||
chatHistory: [],
|
||||
@@ -249,7 +247,6 @@ const server = Bun.serve<WSData>({
|
||||
state: {
|
||||
playing: room.playing,
|
||||
position: getCurrentPosition(room),
|
||||
speed: room.speed,
|
||||
},
|
||||
chatHistory: room.chatHistory.slice(-50),
|
||||
})
|
||||
@@ -285,10 +282,6 @@ const server = Bun.serve<WSData>({
|
||||
} else if (msg.action === "seek") {
|
||||
room.position = msg.position;
|
||||
room.lastUpdate = Date.now();
|
||||
} else if (msg.action === "speed") {
|
||||
room.position = getCurrentPosition(room);
|
||||
room.speed = msg.speed;
|
||||
room.lastUpdate = Date.now();
|
||||
}
|
||||
|
||||
// Broadcast to others
|
||||
@@ -299,7 +292,6 @@ const server = Bun.serve<WSData>({
|
||||
action: msg.action,
|
||||
position: room.position,
|
||||
playing: room.playing,
|
||||
speed: room.speed,
|
||||
username: ws.data.username,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
@@ -343,7 +335,6 @@ const server = Bun.serve<WSData>({
|
||||
state: {
|
||||
playing: room.playing,
|
||||
position: getCurrentPosition(room),
|
||||
speed: room.speed,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -388,7 +379,6 @@ const server = Bun.serve<WSData>({
|
||||
state: {
|
||||
playing: room.playing,
|
||||
position: getCurrentPosition(room),
|
||||
speed: room.speed,
|
||||
},
|
||||
chatHistory: room.chatHistory.slice(-50),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user