Compare commits

..

8 Commits

Author SHA1 Message Date
Peter Stockings
887648bf85 Add network status/latency info 2026-03-05 14:09:58 +11:00
Peter Stockings
a301d1521b Add support for ping messages in server for determining latency 2026-03-05 13:54:08 +11:00
Peter Stockings
dd00011b77 Add tooltip to seekbar on hover 2026-03-05 13:45:49 +11:00
Peter Stockings
25ea1694f1 Add ability to toggle and resize chat bar 2026-03-05 13:40:17 +11:00
Peter Stockings
367da66c0b Auto fill room ID from clip board upon interacting with input 2026-03-05 13:11:31 +11:00
Peter Stockings
b59d08d098 Auto show/hide controls in fullscreen based on activity 2026-03-05 13:06:50 +11:00
Peter Stockings
ce87a817ea Improve look of seek/volume sliders 2026-03-05 12:57:53 +11:00
Peter Stockings
dae4af9ab8 Change volume icon based on volume 2026-03-05 12:48:48 +11:00
5 changed files with 241 additions and 19 deletions

View File

@@ -1,10 +1,25 @@
import os import os
import re
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QFileDialog, QFrame QFileDialog, QFrame, QApplication
) )
from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtCore import Qt, pyqtSignal
class RoomCodeInput(QLineEdit):
"""QLineEdit that auto-fills from clipboard if it looks like a room code."""
def focusInEvent(self, event):
super().focusInEvent(event)
if not self.text().strip():
self._try_paste_room_code()
def _try_paste_room_code(self):
clipboard = QApplication.clipboard()
text = (clipboard.text() or "").strip().upper()
if re.match(r'^[A-Z0-9]{4,8}$', text):
self.setText(text)
self.selectAll()
class LobbyWidget(QWidget): class LobbyWidget(QWidget):
# Signals to communicate to VlcSyncApp # Signals to communicate to VlcSyncApp
create_requested = pyqtSignal(str, str, str, object) # username, path, filename, size create_requested = pyqtSignal(str, str, str, object) # username, path, filename, size
@@ -80,7 +95,7 @@ class LobbyWidget(QWidget):
# Join Room Panel # Join Room Panel
join_panel = QVBoxLayout() join_panel = QVBoxLayout()
join_panel.addWidget(QLabel("Join a Room")) join_panel.addWidget(QLabel("Join a Room"))
self.room_code_input = QLineEdit() self.room_code_input = RoomCodeInput()
self.room_code_input.setPlaceholderText("e.g. ABC123") self.room_code_input.setPlaceholderText("e.g. ABC123")
self.join_room_btn = QPushButton("Join Room") self.join_room_btn = QPushButton("Join Room")
self.join_room_btn.setObjectName("secondaryBtn") self.join_room_btn.setObjectName("secondaryBtn")

View File

@@ -52,7 +52,9 @@ class VlcSyncApp(QMainWindow):
# Network Service # Network Service
self.sync_client = SyncClientThread("wss://video-sync.peterstockings.com/ws") self.sync_client = SyncClientThread("wss://video-sync.peterstockings.com/ws")
self.sync_client.connected.connect(self.on_ws_connected) self.sync_client.connected.connect(self.on_ws_connected)
self.sync_client.connected.connect(lambda: self.room_widget.update_connection_status(True))
self.sync_client.disconnected.connect(self.on_ws_disconnected) self.sync_client.disconnected.connect(self.on_ws_disconnected)
self.sync_client.disconnected.connect(lambda: self.room_widget.update_connection_status(False))
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)
@@ -61,6 +63,7 @@ class VlcSyncApp(QMainWindow):
self.sync_client.chat_message.connect(self.room_widget.add_chat_message) self.sync_client.chat_message.connect(self.room_widget.add_chat_message)
self.sync_client.system_message.connect(self.room_widget.add_system_message) self.sync_client.system_message.connect(self.room_widget.add_system_message)
self.sync_client.sync_event.connect(self.room_widget.handle_sync_event) self.sync_client.sync_event.connect(self.room_widget.handle_sync_event)
self.sync_client.latency_updated.connect(self.room_widget.update_latency)
self.apply_stylesheet() self.apply_stylesheet()
@@ -345,6 +348,75 @@ class VlcSyncApp(QMainWindow):
background: #333; background: #333;
} }
#seekBar {
border: none;
background: transparent;
}
#seekBar::groove:horizontal {
height: 4px;
background: #444;
border: none;
border-radius: 2px;
}
#seekBar::sub-page:horizontal {
background: #ff0000;
height: 4px;
border-radius: 2px;
}
#seekBar::add-page:horizontal {
background: #444;
height: 4px;
border-radius: 2px;
}
#seekBar::handle:horizontal {
background: #ff0000;
width: 12px;
height: 12px;
margin: -4px 0;
border-radius: 6px;
}
#seekBar::handle:horizontal:hover {
background: #ff3333;
width: 14px;
height: 14px;
margin: -5px 0;
border-radius: 7px;
}
#volumeSlider {
border: none;
background: transparent;
}
#volumeSlider::groove:horizontal {
height: 4px;
background: #444;
border: none;
border-radius: 2px;
}
#volumeSlider::sub-page:horizontal {
background: #fff;
height: 4px;
border-radius: 2px;
}
#volumeSlider::add-page:horizontal {
background: #444;
height: 4px;
border-radius: 2px;
}
#volumeSlider::handle:horizontal {
background: #fff;
width: 10px;
height: 10px;
margin: -3px 0;
border-radius: 5px;
}
#volumeSlider::handle:horizontal:hover {
width: 12px;
height: 12px;
margin: -4px 0;
border-radius: 6px;
}
#chatContainer { #chatContainer {
background-color: #111; background-color: #111;
border-left: 1px solid #333; border-left: 1px solid #333;

View File

@@ -4,10 +4,11 @@ import html
import re import re
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QFrame, QSlider, QTextEdit, QTextBrowser, QApplication, QToolTip QPushButton, QFrame, QSlider, QTextEdit, QTextBrowser, QApplication, QToolTip, QSplitter
) )
from PyQt6.QtCore import Qt, pyqtSignal, QTimer from PyQt6.QtCore import Qt, pyqtSignal, QTimer
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon, QCursor
import uuid import uuid
from vlc_player import VLCSyncPlayer from vlc_player import VLCSyncPlayer
@@ -16,6 +17,7 @@ class ClickableSlider(QSlider):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._get_tooltip_text = None self._get_tooltip_text = None
self.setMouseTracking(True)
def set_tooltip_provider(self, provider_func): def set_tooltip_provider(self, provider_func):
self._get_tooltip_text = provider_func self._get_tooltip_text = provider_func
@@ -42,9 +44,10 @@ class ClickableSlider(QSlider):
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
if event.buttons() & Qt.MouseButton.LeftButton: if self._get_tooltip_text and self.width() > 0:
if self._get_tooltip_text: hover_val = self.minimum() + ((self.maximum() - self.minimum()) * event.pos().x()) / self.width()
text = self._get_tooltip_text(self.value()) hover_val = max(self.minimum(), min(self.maximum(), int(hover_val)))
text = self._get_tooltip_text(hover_val)
if text: if text:
QToolTip.showText(event.globalPosition().toPoint(), text, self) QToolTip.showText(event.globalPosition().toPoint(), text, self)
@@ -80,10 +83,21 @@ class RoomWidget(QWidget):
self._setup_ui() self._setup_ui()
def _setup_ui(self): def _setup_ui(self):
layout = QHBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) layout.setSpacing(0)
self.splitter = QSplitter(Qt.Orientation.Horizontal)
self.splitter.setHandleWidth(3)
self.splitter.setStyleSheet("""
QSplitter::handle {
background-color: #333;
}
QSplitter::handle:hover {
background-color: #3ea6ff;
}
""")
# --- Left Side: Video --- # --- Left Side: Video ---
video_container = QWidget() video_container = QWidget()
video_layout = QVBoxLayout(video_container) video_layout = QVBoxLayout(video_container)
@@ -106,6 +120,12 @@ class RoomWidget(QWidget):
self.copy_code_btn.setToolTip("Copy Room Code") self.copy_code_btn.setToolTip("Copy Room Code")
self.copy_code_btn.clicked.connect(self.copy_room_code) self.copy_code_btn.clicked.connect(self.copy_room_code)
self.status_dot = QLabel("")
self.status_dot.setFixedWidth(20)
self.status_dot.setStyleSheet("color: #888; font-size: 14px; background: transparent;")
self.status_dot.setToolTip("Connecting...")
self._latency_ms = None
self.room_file_badge = QLabel("📄 No file") self.room_file_badge = QLabel("📄 No file")
self.room_file_badge.setObjectName("fileBadge") self.room_file_badge.setObjectName("fileBadge")
@@ -115,6 +135,7 @@ class RoomWidget(QWidget):
topbar_layout.addWidget(self.room_code_display) topbar_layout.addWidget(self.room_code_display)
topbar_layout.addWidget(self.copy_code_btn) topbar_layout.addWidget(self.copy_code_btn)
topbar_layout.addWidget(self.status_dot)
topbar_layout.addStretch() topbar_layout.addStretch()
topbar_layout.addWidget(self.room_file_badge) topbar_layout.addWidget(self.room_file_badge)
topbar_layout.addWidget(self.leave_btn) topbar_layout.addWidget(self.leave_btn)
@@ -125,10 +146,10 @@ class RoomWidget(QWidget):
self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True) self.video_frame.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
# Controls Bar # Controls Bar
controls = QFrame() self.controls_bar = QFrame()
controls.setObjectName("controlsBar") self.controls_bar.setObjectName("controlsBar")
controls.setFixedHeight(60) self.controls_bar.setFixedHeight(60)
controls_layout = QHBoxLayout(controls) controls_layout = QHBoxLayout(self.controls_bar)
self.play_btn = QPushButton("") self.play_btn = QPushButton("")
self.play_btn.setFixedSize(40, 40) self.play_btn.setFixedSize(40, 40)
@@ -170,14 +191,23 @@ class RoomWidget(QWidget):
controls_layout.addSpacing(10) controls_layout.addSpacing(10)
controls_layout.addWidget(self.fullscreen_btn) controls_layout.addWidget(self.fullscreen_btn)
# Chat toggle
self.chat_toggle_btn = QPushButton("💬")
self.chat_toggle_btn.setFixedSize(40, 40)
self.chat_toggle_btn.setObjectName("iconBtn")
self.chat_toggle_btn.setToolTip("Toggle Chat")
self.chat_toggle_btn.clicked.connect(self.toggle_chat)
controls_layout.addWidget(self.chat_toggle_btn)
video_layout.addWidget(self.topbar) video_layout.addWidget(self.topbar)
video_layout.addWidget(self.video_frame) video_layout.addWidget(self.video_frame)
video_layout.addWidget(controls) video_layout.addWidget(self.controls_bar)
# --- Right Side: Chat --- # --- Right Side: Chat ---
self.chat_container = QFrame() self.chat_container = QFrame()
self.chat_container.setObjectName("chatContainer") self.chat_container.setObjectName("chatContainer")
self.chat_container.setFixedWidth(320) self.chat_container.setMinimumWidth(200)
self.chat_container.setMaximumWidth(500)
chat_layout = QVBoxLayout(self.chat_container) chat_layout = QVBoxLayout(self.chat_container)
chat_header = QLabel("Live Chat") chat_header = QLabel("Live Chat")
@@ -256,12 +286,17 @@ class RoomWidget(QWidget):
chat_layout.addWidget(self.chat_messages, 1) chat_layout.addWidget(self.chat_messages, 1)
chat_layout.addLayout(chat_input_layout) chat_layout.addLayout(chat_input_layout)
layout.addWidget(video_container, 1) self.splitter.addWidget(video_container)
layout.addWidget(self.chat_container) self.splitter.addWidget(self.chat_container)
self.splitter.setStretchFactor(0, 1)
self.splitter.setStretchFactor(1, 0)
self.splitter.setSizes([700, 320])
layout.addWidget(self.splitter)
# Prevent UI components from stealing focus (which breaks spacebar shortcuts) # Prevent UI components from stealing focus (which breaks spacebar shortcuts)
for w in [self.copy_code_btn, self.leave_btn, self.play_btn, self.fullscreen_btn, for w in [self.copy_code_btn, self.leave_btn, self.play_btn, self.fullscreen_btn,
self.chat_send_btn, self.seekbar, self.volume_slider, self.tags_header_btn]: self.chat_send_btn, self.seekbar, self.volume_slider, self.tags_header_btn, self.chat_toggle_btn]:
w.setFocusPolicy(Qt.FocusPolicy.NoFocus) w.setFocusPolicy(Qt.FocusPolicy.NoFocus)
# specifically for chat_messages, we allow clicking links, but avoid focus stealing # specifically for chat_messages, we allow clicking links, but avoid focus stealing
@@ -276,6 +311,18 @@ class RoomWidget(QWidget):
self.play_btn.clicked.connect(self.toggle_playback) self.play_btn.clicked.connect(self.toggle_playback)
# Fullscreen auto-hide timer
self._fs_hide_timer = QTimer()
self._fs_hide_timer.setSingleShot(True)
self._fs_hide_timer.setInterval(3000)
self._fs_hide_timer.timeout.connect(self._hide_fullscreen_controls)
# Mouse movement polling (needed because VLC's native window eats mouse events)
self._last_mouse_pos = None
self._mouse_poll_timer = QTimer()
self._mouse_poll_timer.setInterval(200)
self._mouse_poll_timer.timeout.connect(self._check_mouse_movement)
def get_seekbar_tooltip(self, value): def get_seekbar_tooltip(self, value):
length_ms = self.vlc_player.get_length() length_ms = self.vlc_player.get_length()
if length_ms > 0: if length_ms > 0:
@@ -305,6 +352,26 @@ class RoomWidget(QWidget):
def set_room_code_display(self, text: str): def set_room_code_display(self, text: str):
self.room_code_display.setText(f"Room: {text}") self.room_code_display.setText(f"Room: {text}")
def update_connection_status(self, connected: bool):
if connected:
self.status_dot.setStyleSheet("color: #4BB543; font-size: 14px; background: transparent;")
self.status_dot.setToolTip("Connected")
else:
self.status_dot.setStyleSheet("color: #ff4e45; font-size: 14px; background: transparent;")
self.status_dot.setToolTip("Disconnected")
self._latency_ms = None
def update_latency(self, latency_ms: int):
self._latency_ms = latency_ms
if latency_ms < 100:
color = "#4BB543" # green
elif latency_ms < 250:
color = "#f0ad4e" # yellow
else:
color = "#ff4e45" # red
self.status_dot.setStyleSheet(f"color: {color}; font-size: 14px; background: transparent;")
self.status_dot.setToolTip(f"Latency: {latency_ms}ms")
def toggle_tags_panel(self): def toggle_tags_panel(self):
if self.tags_list.isHidden(): if self.tags_list.isHidden():
self.tags_list.show() self.tags_list.show()
@@ -313,6 +380,16 @@ class RoomWidget(QWidget):
self.tags_list.hide() self.tags_list.hide()
self.tags_header_btn.setText("▶ Highlights") self.tags_header_btn.setText("▶ Highlights")
def toggle_chat(self):
if self.chat_container.isVisible():
self._saved_chat_width = self.chat_container.width()
self.chat_container.hide()
else:
self.chat_container.show()
w = getattr(self, '_saved_chat_width', 320)
sizes = self.splitter.sizes()
self.splitter.setSizes([sizes[0] - w, w])
def toggle_fullscreen(self): def toggle_fullscreen(self):
top_window = self.window() top_window = self.window()
if top_window.isFullScreen(): if top_window.isFullScreen():
@@ -320,11 +397,35 @@ class RoomWidget(QWidget):
self.fullscreen_btn.setText("") self.fullscreen_btn.setText("")
self.chat_container.show() self.chat_container.show()
self.topbar.show() self.topbar.show()
self.controls_bar.show()
self._fs_hide_timer.stop()
self._mouse_poll_timer.stop()
self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
else: else:
top_window.showFullScreen() top_window.showFullScreen()
self.fullscreen_btn.setText("🗗") self.fullscreen_btn.setText("🗗")
self.chat_container.hide() self.chat_container.hide()
self.topbar.hide() self.topbar.hide()
self._last_mouse_pos = QCursor.pos()
self._fs_hide_timer.start()
self._mouse_poll_timer.start()
def _hide_fullscreen_controls(self):
if self.window().isFullScreen():
self.controls_bar.hide()
self.setCursor(QCursor(Qt.CursorShape.BlankCursor))
def _show_fullscreen_controls(self):
self.controls_bar.show()
self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
self._fs_hide_timer.start()
def _check_mouse_movement(self):
pos = QCursor.pos()
if pos != self._last_mouse_pos:
self._last_mouse_pos = pos
if not self.controls_bar.isVisible():
self._show_fullscreen_controls()
def toggle_mute(self): def toggle_mute(self):
current_vol = self.vlc_player.get_volume() current_vol = self.vlc_player.get_volume()
@@ -520,6 +621,17 @@ class RoomWidget(QWidget):
def on_volume_changed(self, value): def on_volume_changed(self, value):
self.vlc_player.set_volume(value) self.vlc_player.set_volume(value)
self._update_vol_icon(value)
def _update_vol_icon(self, volume):
if volume == 0:
self.vol_icon.setText("🔇")
elif volume < 33:
self.vol_icon.setText("🔈")
elif volume < 66:
self.vol_icon.setText("🔉")
else:
self.vol_icon.setText("🔊")
# --- Incoming Sync Logic --- # --- Incoming Sync Logic ---
def handle_sync_event(self, msg: dict): def handle_sync_event(self, msg: dict):

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
import time
import websockets import websockets
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
@@ -15,6 +16,7 @@ class SyncClientThread(QThread):
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
def __init__(self, url="ws://localhost:3000/ws"): def __init__(self, url="ws://localhost:3000/ws"):
super().__init__() super().__init__()
@@ -22,6 +24,7 @@ class SyncClientThread(QThread):
self.ws = None self.ws = None
self.loop = None self.loop = None
self.running = False self.running = False
self._ping_sent_at = 0
def run(self): def run(self):
"""Runs strictly within the newly created QThread""" """Runs strictly within the newly created QThread"""
@@ -44,6 +47,19 @@ class SyncClientThread(QThread):
json_str = json.dumps(message) json_str = json.dumps(message)
asyncio.run_coroutine_threadsafe(self.ws.send(json_str), self.loop) asyncio.run_coroutine_threadsafe(self.ws.send(json_str), self.loop)
async def _ping_loop(self, ws):
"""Sends WebSocket protocol-level pings every 5s to measure latency."""
while self.running:
try:
pong = await ws.ping()
sent_at = time.time()
await asyncio.wait_for(pong, timeout=5)
latency_ms = int((time.time() - sent_at) * 1000)
self.latency_updated.emit(latency_ms)
except Exception:
break
await asyncio.sleep(5)
async def _connect_and_listen(self): async def _connect_and_listen(self):
while self.running: while self.running:
try: try:
@@ -51,6 +67,7 @@ class SyncClientThread(QThread):
self.ws = ws self.ws = ws
self.connected.emit() self.connected.emit()
ping_task = asyncio.create_task(self._ping_loop(ws))
try: try:
async for message in ws: async for message in ws:
if not self.running: if not self.running:
@@ -58,6 +75,8 @@ class SyncClientThread(QThread):
self._handle_message(json.loads(message)) self._handle_message(json.loads(message))
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
pass pass
finally:
ping_task.cancel()
except Exception as e: except Exception as e:
print(f"WebSocket Error: {e}") print(f"WebSocket Error: {e}")

View File

@@ -137,6 +137,10 @@ const server = Bun.serve<WSData>({
} }
switch (msg.type) { switch (msg.type) {
case "ping": {
ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp }));
break;
}
case "create_room": { case "create_room": {
const username = (msg.username || "").trim(); const username = (msg.username || "").trim();
if (!username) { if (!username) {