Compare commits

...

10 Commits

Author SHA1 Message Date
Peter Stockings
4665cf700e Add support for /seek, /play, /pause commands in chat 2026-03-03 19:38:10 +11:00
Peter Stockings
a5b7e08a6a Improve look of start screen and add toast to copy room ID button 2026-03-03 19:18:57 +11:00
Peter Stockings
31c79e794e Make chat window scrollable 2026-03-03 19:04:27 +11:00
Peter Stockings
4cf4c153cd Format video time as hh:mm:ss 2026-03-03 19:00:36 +11:00
Peter Stockings
9d22860f0d Add script to build desktop client 2026-03-02 22:24:49 +11:00
Peter Stockings
3e1ea32383 Upate desktop app to point from local to production server 2026-03-02 22:13:19 +11:00
Peter Stockings
36d6aeaf51 Set initial volume in desktop app 2026-03-02 22:07:42 +11:00
Peter Stockings
b587b5e87d When joining a room only allow clients to select a video with the same name as the one already selected by room creator 2026-03-02 22:00:51 +11:00
Peter Stockings
475fdbb2b8 Create desktop app due to mkv issue with chrome 2026-03-02 21:54:22 +11:00
Peter Stockings
2020b59259 Remove speed control 2026-03-02 21:28:24 +11:00
18 changed files with 1608 additions and 109 deletions

23
.gitignore vendored
View File

@@ -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

View File

@@ -0,0 +1 @@
3.14

0
desktop-client/README.md Normal file
View File

2
desktop-client/build.ps1 Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

879
desktop-client/main.py Normal file
View 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())

View 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",
]

View 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)

View 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()

View 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
View 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" },
]

View 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()

View File

@@ -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", () => {

View File

@@ -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" />

View File

@@ -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%;

View File

@@ -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),
})