Compare commits

...
2 Commits
9 changed files with 261 additions and 25 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
uv add pyinstaller pillow uv sync
uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --add-data "icon.png;." --add-data "copy.svg;." --add-data "check.svg;." main.py uv run pyinstaller --noconfirm --onefile --windowed --name "VideoSync" --icon "icon.png" --collect-data "certifi" --add-data "icon.png;." --add-data "copy.svg;." --add-data "check.svg;." main.py
+11
View File
@@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt, pyqtSignal
class ChatBubble(QWidget): class ChatBubble(QWidget):
def __init__(self, author, text, time_str, is_self, on_link_clicked): def __init__(self, author, text, time_str, is_self, on_link_clicked):
super().__init__() super().__init__()
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
layout = QHBoxLayout(self) layout = QHBoxLayout(self)
layout.setContentsMargins(5, 2, 5, 2) layout.setContentsMargins(5, 2, 5, 2)
@@ -39,6 +40,7 @@ class ChatBubble(QWidget):
# Author # Author
author_lbl = QLabel("You" if is_self else author) author_lbl = QLabel("You" if is_self else author)
author_lbl.setTextFormat(Qt.TextFormat.PlainText)
author_lbl.setStyleSheet(f"color: {author_color}; font-weight: bold; font-size: 11px; background: transparent;") author_lbl.setStyleSheet(f"color: {author_color}; font-weight: bold; font-size: 11px; background: transparent;")
bubble_layout.addWidget(author_lbl) bubble_layout.addWidget(author_lbl)
@@ -47,6 +49,7 @@ class ChatBubble(QWidget):
# width of 0 lets the label wrap within whatever space the bubble # width of 0 lets the label wrap within whatever space the bubble
# actually receives rather than expanding the bubble outward. # actually receives rather than expanding the bubble outward.
self.text_lbl = QLabel(text) self.text_lbl = QLabel(text)
self.text_lbl.setTextFormat(Qt.TextFormat.RichText)
self.text_lbl.setWordWrap(True) self.text_lbl.setWordWrap(True)
self.text_lbl.setMinimumWidth(0) self.text_lbl.setMinimumWidth(0)
self.text_lbl.setSizePolicy( self.text_lbl.setSizePolicy(
@@ -71,6 +74,14 @@ class ChatBubble(QWidget):
layout.addWidget(self.bubble) layout.addWidget(self.bubble)
layout.addStretch() layout.addStretch()
def resizeEvent(self, event):
super().resizeEvent(event)
# Recalculate after the scrollbar or splitter changes the available width.
# A fixed row height prevents the scroll layout squeezing wrapped text.
height = self.layout().heightForWidth(event.size().width())
if height >= 0:
self.setFixedHeight(height)
class SystemMessageWidget(QWidget): class SystemMessageWidget(QWidget):
def __init__(self, text): def __init__(self, text):
+7 -1
View File
@@ -55,6 +55,7 @@ class VlcSyncApp(QMainWindow):
self.sync_client.connected.connect(lambda: self.room_widget.update_connection_status(True)) self.sync_client.connected.connect(lambda: self.room_widget.update_connection_status(True))
self.sync_client.disconnected.connect(self.on_ws_disconnected) self.sync_client.disconnected.connect(self.on_ws_disconnected)
self.sync_client.disconnected.connect(lambda: self.room_widget.update_connection_status(False)) self.sync_client.disconnected.connect(lambda: self.room_widget.update_connection_status(False))
self.sync_client.connection_error.connect(self.on_connection_error)
self.sync_client.room_joined.connect(self.on_room_joined) self.sync_client.room_joined.connect(self.on_room_joined)
self.sync_client.room_rejoined.connect(self.on_room_rejoined) self.sync_client.room_rejoined.connect(self.on_room_rejoined)
self.sync_client.room_error.connect(self.on_room_error) self.sync_client.room_error.connect(self.on_room_error)
@@ -135,6 +136,12 @@ class VlcSyncApp(QMainWindow):
self.lobby_widget.reset_ui() self.lobby_widget.reset_ui()
self.pending_connect_action = None self.pending_connect_action = None
def on_connection_error(self, msg: str):
QMessageBox.critical(self, "Secure Connection Error", msg)
self.pending_connect_action = None
self.lobby_widget.reset_ui()
self.sync_client.stop()
def on_room_error(self, msg: str): def on_room_error(self, msg: str):
QMessageBox.critical(self, "Room Error", msg) QMessageBox.critical(self, "Room Error", msg)
self.lobby_widget.reset_ui() self.lobby_widget.reset_ui()
@@ -493,4 +500,3 @@ if __name__ == "__main__":
window = VlcSyncApp() window = VlcSyncApp()
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())
+1
View File
@@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"certifi>=2026.6.17",
"pillow>=12.1.1", "pillow>=12.1.1",
"pyinstaller>=6.19.0", "pyinstaller>=6.19.0",
"pyqt6>=6.10.2", "pyqt6>=6.10.2",
+33 -3
View File
@@ -45,6 +45,8 @@ class RoomWidget(QWidget):
self.popout_window = None self.popout_window = None
self._setup_ui() self._setup_ui()
self._unread_messages = 0
QApplication.instance().focusChanged.connect(self._on_chat_focus_changed)
def _setup_ui(self): def _setup_ui(self):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -174,7 +176,7 @@ class RoomWidget(QWidget):
self.chat_container.setMaximumWidth(500) self.chat_container.setMaximumWidth(500)
chat_layout = QVBoxLayout(self.chat_container) chat_layout = QVBoxLayout(self.chat_container)
chat_header = QLabel("Live Chat") chat_header = self.chat_header = QLabel("Live Chat")
chat_header.setObjectName("chatHeader") chat_header.setObjectName("chatHeader")
self.popout_btn = QPushButton("↗") self.popout_btn = QPushButton("↗")
@@ -323,6 +325,7 @@ class RoomWidget(QWidget):
self.room_file_badge.setText(f"📄 {file_name}") self.room_file_badge.setText(f"📄 {file_name}")
# Clear chat # Clear chat
self._clear_chat_alert()
for i in reversed(range(self.chat_content_layout.count())): for i in reversed(range(self.chat_content_layout.count())):
self.chat_content_layout.itemAt(i).widget().setParent(None) self.chat_content_layout.itemAt(i).widget().setParent(None)
@@ -335,6 +338,7 @@ class RoomWidget(QWidget):
self.vlc_player.set_volume(self.volume_slider.value()) self.vlc_player.set_volume(self.volume_slider.value())
def cleanup(self): def cleanup(self):
self._clear_chat_alert()
self.vlc_player.stop() self.vlc_player.stop()
self.current_users = [] self.current_users = []
self._is_first_user_update = True self._is_first_user_update = True
@@ -723,13 +727,20 @@ class RoomWidget(QWidget):
time_str = dt.strftime("%I:%M %p") time_str = dt.strftime("%I:%M %p")
safe_text = html.escape(text) safe_text = html.escape(text)
safe_author = html.escape(author)
# Regex to find timestamps like 1:23 or 01:23:45 # Regex to find timestamps like 1:23 or 01:23:45
pattern = r'\b(\d{1,2}:\d{2}(?::\d{2})?)\b' pattern = r'\b(\d{1,2}:\d{2}(?::\d{2})?)\b'
linked_text = re.sub(pattern, r'<a href="seek:\1" style="color: #66b3ff; text-decoration: none;">\1</a>', safe_text) linked_text = re.sub(pattern, r'<a href="seek:\1" style="color: #66b3ff; text-decoration: none;">\1</a>', safe_text)
linked_text = linked_text.replace('\n', '<br>')
is_self = author == self.username is_self = author == self.username
if not is_self and not self._chat_has_focus():
self._unread_messages += 1
self.chat_header.setText(f"Live Chat • {self._unread_messages} new")
self.chat_toggle_btn.setText("💬•")
self.chat_toggle_btn.setToolTip(f"{self._unread_messages} new chat messages")
self.chat_toggle_btn.setStyleSheet("color: #66b3ff; border: 1px solid #66b3ff; border-radius: 6px;")
QApplication.alert(self.chat_container.window(), 3000)
# Auto-extract tags added by users directly via /tag command # Auto-extract tags added by users directly via /tag command
# We look for the pattern generated by /tag: "MM:SS tag text" # We look for the pattern generated by /tag: "MM:SS tag text"
@@ -742,6 +753,24 @@ class RoomWidget(QWidget):
bubble = ChatBubble(author, linked_text, time_str, is_self, self._handle_link_from_bubble) bubble = ChatBubble(author, linked_text, time_str, is_self, self._handle_link_from_bubble)
self.chat_content_layout.addWidget(bubble) self.chat_content_layout.addWidget(bubble)
self._scroll_to_bottom() self._scroll_to_bottom()
def _chat_has_focus(self):
focus = QApplication.focusWidget()
return (self.chat_container.isVisible()
and self.chat_container.window().isActiveWindow()
and focus is not None
and (focus is self.chat_container or self.chat_container.isAncestorOf(focus)))
def _on_chat_focus_changed(self, old, new):
if self._chat_has_focus():
self._clear_chat_alert()
def _clear_chat_alert(self):
self._unread_messages = 0
self.chat_header.setText("Live Chat")
self.chat_toggle_btn.setText("💬")
self.chat_toggle_btn.setToolTip("Toggle Chat")
self.chat_toggle_btn.setStyleSheet("")
def _handle_link_from_bubble(self, url_str): def _handle_link_from_bubble(self, url_str):
if url_str.startswith("seek:"): if url_str.startswith("seek:"):
@@ -782,6 +811,7 @@ class RoomWidget(QWidget):
self._scroll_to_bottom() self._scroll_to_bottom()
def clear_chat(self): def clear_chat(self):
self._clear_chat_alert()
for i in reversed(range(self.chat_content_layout.count())): for i in reversed(range(self.chat_content_layout.count())):
item = self.chat_content_layout.itemAt(i) item = self.chat_content_layout.itemAt(i)
if item.widget(): if item.widget():
+69 -19
View File
@@ -1,22 +1,27 @@
import asyncio import asyncio
import json import json
import ssl
import time import time
import certifi
import websockets import websockets
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
class SyncClientThread(QThread): class SyncClientThread(QThread):
# Signals from WebSocket -> PyQt UI # Signals from WebSocket -> PyQt UI
connected = pyqtSignal() connected = pyqtSignal()
disconnected = pyqtSignal() disconnected = pyqtSignal()
connection_error = pyqtSignal(str)
room_joined = pyqtSignal(dict) room_joined = pyqtSignal(dict)
room_rejoined = pyqtSignal(dict) room_rejoined = pyqtSignal(dict)
room_error = pyqtSignal(str) room_error = pyqtSignal(str)
file_check_needed = pyqtSignal(dict) # msg file_check_needed = pyqtSignal(dict) # msg
users_updated = pyqtSignal(list) users_updated = pyqtSignal(list)
chat_message = pyqtSignal(str, str, int) # author, text, timestamp chat_message = pyqtSignal(str, str, int) # author, text, timestamp
system_message = pyqtSignal(str) system_message = pyqtSignal(str)
sync_event = pyqtSignal(dict) sync_event = pyqtSignal(dict)
latency_updated = pyqtSignal(int) # latency in ms latency_updated = pyqtSignal(int) # latency in ms
def __init__(self, url="ws://localhost:3000/ws"): def __init__(self, url="ws://localhost:3000/ws"):
super().__init__() super().__init__()
@@ -26,13 +31,31 @@ class SyncClientThread(QThread):
self.running = False self.running = False
self._ping_sent_at = 0 self._ping_sent_at = 0
@staticmethod
def _create_ssl_context():
"""Build a verified TLS context from the CA bundle shipped with the app."""
return ssl.create_default_context(cafile=certifi.where())
@staticmethod
def _certificate_error(exc):
"""Return a nested certificate verification error, if present."""
while exc is not None:
if isinstance(exc, ssl.SSLCertVerificationError):
return exc
exc = exc.__cause__ or exc.__context__
return None
def run(self): def run(self):
"""Runs strictly within the newly created QThread""" """Runs strictly within the newly created QThread"""
self.running = True self.running = True
self.loop = asyncio.new_event_loop() self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop) asyncio.set_event_loop(self.loop)
self.loop.run_until_complete(self._connect_and_listen()) try:
self.loop.close() self.loop.run_until_complete(self._connect_and_listen())
finally:
self.ws = None
self.running = False
self.loop.close()
def stop(self): def stop(self):
self.running = False self.running = False
@@ -61,12 +84,17 @@ class SyncClientThread(QThread):
await asyncio.sleep(5) await asyncio.sleep(5)
async def _connect_and_listen(self): async def _connect_and_listen(self):
ssl_context = (
self._create_ssl_context() if self.url.startswith("wss://") else None
)
while self.running: while self.running:
try: try:
async with websockets.connect(self.url) as ws: connect_options = {"ssl": ssl_context} if ssl_context else {}
async with websockets.connect(self.url, **connect_options) as ws:
self.ws = ws self.ws = ws
self.connected.emit() self.connected.emit()
ping_task = asyncio.create_task(self._ping_loop(ws)) ping_task = asyncio.create_task(self._ping_loop(ws))
try: try:
async for message in ws: async for message in ws:
@@ -77,38 +105,60 @@ class SyncClientThread(QThread):
pass pass
finally: finally:
ping_task.cancel() ping_task.cancel()
except Exception as e: except Exception as e:
certificate_error = self._certificate_error(e)
if certificate_error:
verification_reason = getattr(
certificate_error, "verify_message", str(certificate_error)
)
message = (
"Secure connection failed because the server certificate could not be "
f"verified ({verification_reason}). "
"Check that your computer's date and time are correct, then update or "
"restart VideoSync."
)
print(f"WebSocket TLS Error: {certificate_error}")
self.connection_error.emit(message)
self.running = False
break
print(f"WebSocket Error: {e}") print(f"WebSocket Error: {e}")
self.disconnected.emit() finally:
self.ws = None
self.disconnected.emit()
if self.running: if self.running:
# Reconnect backoff # Reconnect backoff
await asyncio.sleep(2) await asyncio.sleep(2)
def _handle_message(self, msg: dict): def _handle_message(self, msg: dict):
t = msg.get("type") t = msg.get("type")
if t == "room_created": if t == "room_created":
self.room_joined.emit(msg) self.room_joined.emit(msg)
elif t == "room_joined": elif t == "room_joined":
self.room_joined.emit(msg) self.room_joined.emit(msg)
elif t == "room_rejoined": elif t == "room_rejoined":
self.room_rejoined.emit(msg) self.room_rejoined.emit(msg)
elif t == "error": elif t == "error":
self.room_error.emit(msg.get("message", "Unknown error")) self.room_error.emit(msg.get("message", "Unknown error"))
elif t == "room_file_check": elif t == "room_file_check":
self.file_check_needed.emit(msg) self.file_check_needed.emit(msg)
elif t in ["user_joined", "user_left"]: elif t in ["user_joined", "user_left"]:
self.users_updated.emit(msg.get("users", [])) self.users_updated.emit(msg.get("users", []))
elif t == "chat": elif t == "chat":
self.chat_message.emit(msg.get("username", "Unknown"), msg.get("message", ""), msg.get("timestamp", 0)) self.chat_message.emit(
msg.get("username", "Unknown"),
msg.get("message", ""),
msg.get("timestamp", 0),
)
elif t == "sync": elif t == "sync":
self.sync_event.emit(msg) self.sync_event.emit(msg)
+103
View File
@@ -0,0 +1,103 @@
from unittest.mock import Mock
import pytest
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtGui import QTextDocument
from PyQt6.QtWidgets import QApplication
import room_widget
class PlayerSignals(QObject):
time_changed = pyqtSignal(int)
state_changed = pyqtSignal(bool, int)
@pytest.fixture
def room(qtbot, monkeypatch):
player = Mock(signals=PlayerSignals())
monkeypatch.setattr(room_widget, 'VLCSyncPlayer', lambda frame: player)
widget = room_widget.RoomWidget()
widget.username = 'Me'
qtbot.addWidget(widget)
widget.resize(1050, 500)
widget.show()
return widget
@pytest.mark.parametrize('text', ["It's someone's & my <message>",
"It's at 1:23 & <b>literal</b>",
'line one\nline two', 'literal &#39; entity'])
def test_message_rendering(room, text):
room.add_chat_message("<b>O'Brien</b>", text, 0)
bubble = room.chat_content_layout.itemAt(0).widget()
doc = QTextDocument()
doc.setHtml(bubble.text_lbl.text())
assert doc.toPlainText() == text
if '1:23' in text:
assert 'href="seek:1:23"' in bubble.text_lbl.text()
def test_bubbles_do_not_shrink_or_clip_when_overflowing(room, qtbot):
text = 'A wrapped message with several words. ' * 8
room.add_chat_message('Other', text, 0)
qtbot.wait(100)
first = room.chat_content_layout.itemAt(0).widget()
initial_height = first.height()
for i in range(35):
room.add_chat_message('Me' if i % 2 else 'Other', text, 0)
qtbot.wait(200)
assert room.chat_messages.verticalScrollBar().maximum() > 0
assert first.height() >= initial_height
for width in (220, 450, 280):
room.splitter.setSizes([room.width() - width, width])
qtbot.wait(100)
for i in range(room.chat_content_layout.count()):
bubble = room.chat_content_layout.itemAt(i).widget()
label = bubble.text_lbl
assert label.height() >= label.heightForWidth(label.width())
assert bubble.bubble.rect().contains(label.geometry())
def test_unread_alert_and_focus_clear(room, qtbot, monkeypatch):
alert = Mock()
monkeypatch.setattr(QApplication, 'alert', alert)
room.activateWindow()
room.setFocus()
qtbot.wait(50)
room.add_chat_message('Other', 'hello', 0)
assert room._unread_messages == 1
alert.assert_called_once()
room.add_chat_message('Me', 'reply', 0)
assert room._unread_messages == 1
room.chat_input.setFocus()
qtbot.wait(50)
assert room._unread_messages == 0
room.add_chat_message('Other', 'focused', 0)
assert room._unread_messages == 0
room.chat_container.hide()
room.add_chat_message('Other', 'hidden', 0)
assert room._unread_messages == 1
room.clear_chat()
assert room._unread_messages == 0
def test_popout_focus_and_wrapping(room, qtbot, monkeypatch):
monkeypatch.setattr(QApplication, 'alert', Mock())
room.popout_chat()
popout = room.popout_window
qtbot.addWidget(popout)
popout.activateWindow()
room.chat_input.setFocus()
qtbot.wait(100)
room.add_chat_message('Other', 'Wrapped popout text ' * 40, 0)
qtbot.wait(100)
assert room._unread_messages == 0
bubble = room.chat_content_layout.itemAt(0).widget()
assert bubble.text_lbl.height() >= bubble.text_lbl.heightForWidth(bubble.text_lbl.width())
room.activateWindow()
room.setFocus()
qtbot.wait(100)
room.add_chat_message('Other', 'Background popout', 0)
assert room._unread_messages == 1
popout.close()
+24
View File
@@ -0,0 +1,24 @@
import ssl
from sync_client import SyncClientThread
def test_ssl_context_uses_bundled_ca_certificates():
context = SyncClientThread._create_ssl_context()
assert context.verify_mode == ssl.CERT_REQUIRED
assert context.check_hostname is True
assert context.cert_store_stats()["x509_ca"] > 0
def test_certificate_error_finds_nested_verification_error():
certificate_error = ssl.SSLCertVerificationError(1, "certificate has expired")
try:
raise RuntimeError("connection failed") from certificate_error
except RuntimeError as error:
assert SyncClientThread._certificate_error(error) is certificate_error
def test_non_certificate_error_is_ignored():
assert SyncClientThread._certificate_error(ConnectionError("offline")) is None
+11
View File
@@ -11,6 +11,15 @@ 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" }, { 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 = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]] [[package]]
name = "macholib" name = "macholib"
version = "1.16.4" version = "1.16.4"
@@ -163,6 +172,7 @@ name = "python-app"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "certifi" },
{ name = "pillow" }, { name = "pillow" },
{ name = "pyinstaller" }, { name = "pyinstaller" },
{ name = "pyqt6" }, { name = "pyqt6" },
@@ -172,6 +182,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "certifi", specifier = ">=2026.6.17" },
{ name = "pillow", specifier = ">=12.1.1" }, { name = "pillow", specifier = ">=12.1.1" },
{ name = "pyinstaller", specifier = ">=6.19.0" }, { name = "pyinstaller", specifier = ">=6.19.0" },
{ name = "pyqt6", specifier = ">=6.10.2" }, { name = "pyqt6", specifier = ">=6.10.2" },