from __future__ import annotations import base64 import sys import time import vlc from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtWidgets import ( QApplication, QCheckBox, QComboBox, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QSplitter, QVBoxLayout, QWidget, ) from synctv_client.api_client import ApiClient from synctv_client.device_store import DeviceStore from synctv_client.models import PlaybackInfo, RoomSnapshot, SourceInfo from synctv_client.sync_client import SyncClient class MainWindow(QMainWindow): event_received = Signal(str, dict) error_received = Signal(str) def __init__(self) -> None: super().__init__() self.setWindowTitle("SyncTV") self.resize(1180, 760) self.device_id = DeviceStore().get_or_create() self.snapshot: RoomSnapshot | None = None self.api: ApiClient | None = None self.sync = SyncClient(self.event_received.emit, self.error_received.emit) self.vlc_instance = vlc.Instance() self.player = self.vlc_instance.media_player_new() self._build_ui() self.event_received.connect(self._handle_event) self.error_received.connect(self._set_error) self.progress_timer = QTimer(self) self.progress_timer.setInterval(3000) self.progress_timer.timeout.connect(self._sync_progress) self.progress_timer.start() def _build_ui(self) -> None: root = QSplitter(Qt.Orientation.Horizontal) root.setChildrenCollapsible(False) self.setCentralWidget(root) side = QWidget() side.setMinimumWidth(360) side.setMaximumWidth(420) side_layout = QVBoxLayout(side) title = QLabel("SyncTV") title.setStyleSheet("font-size: 28px; font-weight: 600;") side_layout.addWidget(title) self.status_label = QLabel("Not connected") self.status_label.setStyleSheet("color: #64748b;") side_layout.addWidget(self.status_label) form = QFormLayout() self.server_input = QLineEdit("http://yuyun-us1.stormrain.cn:8088") self.room_input = QLineEdit() self.room_input.setPlaceholderText("A7K29Q") form.addRow("Server", self.server_input) form.addRow("Room Code", self.room_input) side_layout.addLayout(form) row = QHBoxLayout() create_btn = QPushButton("Create") join_btn = QPushButton("Join") create_btn.clicked.connect(self._create_room) join_btn.clicked.connect(self._join_room) row.addWidget(create_btn) row.addWidget(join_btn) side_layout.addLayout(row) side_layout.addSpacing(16) side_layout.addWidget(QLabel("Source")) self.source_type = QComboBox() self.source_type.addItems(["url", "oss", "webdav", "live"]) self.source_mode = QComboBox() self.source_mode.addItems(["direct", "proxy"]) self.source_url = QLineEdit() self.username = QLineEdit() self.password = QLineEdit() self.password.setEchoMode(QLineEdit.EchoMode.Password) self.is_live = QCheckBox("Live stream") source_form = QFormLayout() source_form.addRow("Type", self.source_type) source_form.addRow("Connection", self.source_mode) source_form.addRow("URL", self.source_url) source_form.addRow("Username", self.username) source_form.addRow("Password", self.password) source_form.addRow("", self.is_live) side_layout.addLayout(source_form) self.apply_source_btn = QPushButton("Apply Source") self.apply_source_btn.setEnabled(False) self.apply_source_btn.clicked.connect(self._apply_source) side_layout.addWidget(self.apply_source_btn) side_layout.addSpacing(16) control_row = QHBoxLayout() play_btn = QPushButton("Play") pause_btn = QPushButton("Pause") play_btn.clicked.connect(self._play) pause_btn.clicked.connect(self._pause) control_row.addWidget(play_btn) control_row.addWidget(pause_btn) side_layout.addLayout(control_row) live_btn = QPushButton("Back To Live") live_btn.clicked.connect(lambda: self.sync.sync_to_live()) side_layout.addWidget(live_btn) self.error_label = QLabel() self.error_label.setWordWrap(True) self.error_label.setStyleSheet("color: #dc2626;") side_layout.addWidget(self.error_label) side_layout.addStretch(1) self.video_frame = QFrame() self.video_frame.setStyleSheet("background: black;") root.addWidget(side) root.addWidget(self.video_frame) root.setSizes([380, 800]) def showEvent(self, event) -> None: # type: ignore[override] super().showEvent(event) self.player.set_hwnd(int(self.video_frame.winId())) def _create_room(self) -> None: try: self.api = ApiClient(self.server_input.text()) room = self.api.create_room() self.room_input.setText(room.code) self._join_room() except Exception as exc: self._set_error(str(exc)) def _join_room(self) -> None: try: self.api = ApiClient(self.server_input.text()) code = self.room_input.text().strip().upper() snapshot = self.api.get_room(code, self.device_id) self._apply_snapshot(snapshot) self.sync.connect(self.server_input.text(), code, self.device_id) self._open_source(snapshot.room.source) self._apply_playback(snapshot.room.playback, snapshot.server_now) except Exception as exc: self._set_error(str(exc)) def _apply_source(self) -> None: source = SourceInfo( type=self.source_type.currentText(), mode=self.source_mode.currentText(), url=self.source_url.text().strip(), is_live=self.is_live.isChecked() or self.source_type.currentText() == "live", username=self.username.text().strip(), password=self.password.text(), ) self.sync.set_source(source.to_json()) def _play(self) -> None: position = max(0, self.player.get_time()) self.sync.play(position) self.player.play() def _pause(self) -> None: position = max(0, self.player.get_time()) self.sync.pause(position) self.player.pause() def _handle_event(self, event_type: str, payload: dict) -> None: try: if event_type in {"roomSnapshot", "sourceChanged"}: snapshot = RoomSnapshot.from_json(payload) self._apply_snapshot(snapshot) self._open_source(snapshot.room.source) self._apply_playback(snapshot.room.playback, snapshot.server_now) elif event_type == "playbackChanged": playback = PlaybackInfo.from_json(payload.get("playback")) self._apply_playback(playback, int(payload.get("serverNow", int(time.time() * 1000)))) elif event_type == "presenceChanged" and self.snapshot: count = payload.get("onlineCount", self.snapshot.room.online_count) self.status_label.setText( f"{self.snapshot.room.code} · {'Owner' if self.snapshot.is_owner else 'Member'} · {count} online" ) elif event_type == "error": self._set_error(payload.get("message", "unknown error")) except Exception as exc: self._set_error(str(exc)) def _apply_snapshot(self, snapshot: RoomSnapshot) -> None: self.snapshot = snapshot self.room_input.setText(snapshot.room.code) self.apply_source_btn.setEnabled(snapshot.is_owner) self.status_label.setText( f"{snapshot.room.code} · {'Owner' if snapshot.is_owner else 'Member'} · {snapshot.room.online_count} online" ) if snapshot.room.source: self.source_url.setText(snapshot.room.source.url) self.is_live.setChecked(snapshot.room.source.is_live) self.source_type.setCurrentText(snapshot.room.source.type) self.source_mode.setCurrentText(snapshot.room.source.mode) def _open_source(self, source: SourceInfo | None) -> None: if not source or not source.url: return url = source.url if url.startswith("/api/"): url = self.server_input.text().rstrip("/") + url media = self.vlc_instance.media_new(url) if source.username or source.password: auth = base64.b64encode(f"{source.username}:{source.password}".encode()).decode() media.add_option(f":http-header=Authorization: Basic {auth}") media.add_option(f":http-user={source.username}") media.add_option(f":http-pwd={source.password}") self.player.set_media(media) def _apply_playback(self, playback: PlaybackInfo, server_now: int) -> None: if playback.state == "playing": target = playback.position_ms + max(0, server_now - playback.server_updated_at) if target > 0: self.player.set_time(target) self.player.play() elif playback.state == "paused": if playback.position_ms > 0: self.player.set_time(playback.position_ms) self.player.pause() def _sync_progress(self) -> None: # The Go server accepts progress events, but first version keeps this conservative. pass def _set_error(self, message: str) -> None: self.error_label.setText(message) def closeEvent(self, event) -> None: # type: ignore[override] self.sync.close() self.player.stop() super().closeEvent(event) def main() -> int: app = QApplication(sys.argv) window = MainWindow() window.show() return app.exec() if __name__ == "__main__": raise SystemExit(main())