init
This commit is contained in:
269
windows_py_client/synctv_client/main.py
Normal file
269
windows_py_client/synctv_client/main.py
Normal file
@@ -0,0 +1,269 @@
|
||||
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())
|
||||
Reference in New Issue
Block a user