Files
synctv-weihang/windows_py_client/synctv_client/main.py
2026-06-15 22:46:12 +08:00

270 lines
9.9 KiB
Python

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