This commit is contained in:
2026-06-15 22:46:12 +08:00
commit f6508eccdb
38 changed files with 3133 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
# SyncTV Windows Python Client
Run in development:
```powershell
python -m venv .venv
.\.venv\Scripts\pip install -r requirements.txt
.\.venv\Scripts\python synctv_client\main.py
```
Build exe:
```powershell
.\build_exe.ps1
```
VLC runtime is required by `python-vlc`. Install VLC 64-bit if playback cannot initialize.

View File

@@ -0,0 +1,13 @@
$ErrorActionPreference = "Stop"
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\pip.exe install -r requirements.txt
.\.venv\Scripts\pyinstaller.exe `
--noconfirm `
--windowed `
--name SyncTV `
--add-data "README_CLIENT.md;." `
synctv_client\main.py
Write-Host "Built dist\SyncTV\SyncTV.exe"

View File

@@ -0,0 +1,5 @@
PySide6==6.9.1
requests==2.32.4
websocket-client==1.8.0
python-vlc==3.0.21203
pyinstaller==6.14.1

View File

@@ -0,0 +1 @@
"""SyncTV Windows Python client."""

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import requests
from .models import RoomInfo, RoomSnapshot
class ApiClient:
def __init__(self, base_url: str) -> None:
self.base_url = base_url.rstrip("/")
def create_room(self) -> RoomInfo:
response = requests.post(f"{self.base_url}/api/rooms", timeout=10)
response.raise_for_status()
return RoomInfo.from_json(response.json()["room"])
def get_room(self, code: str, device_id: str) -> RoomSnapshot:
response = requests.get(
f"{self.base_url}/api/rooms/{code}",
params={"deviceId": device_id},
timeout=10,
)
response.raise_for_status()
return RoomSnapshot.from_json(response.json())

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
import secrets
from pathlib import Path
class DeviceStore:
def __init__(self) -> None:
self.path = Path.home() / "AppData" / "Roaming" / "SyncTV" / "device.id"
def get_or_create(self) -> str:
if self.path.exists():
current = self.path.read_text(encoding="utf-8").strip()
if current:
return current
self.path.parent.mkdir(parents=True, exist_ok=True)
device_id = "dev_" + secrets.token_hex(12)
self.path.write_text(device_id, encoding="utf-8")
return device_id

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

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class PlaybackInfo:
state: str = "idle"
position_ms: int = 0
server_updated_at: int = 0
target_latency_ms: int | None = None
@staticmethod
def from_json(data: dict[str, Any] | None) -> "PlaybackInfo":
data = data or {}
return PlaybackInfo(
state=data.get("state", "idle"),
position_ms=int(data.get("positionMs", 0)),
server_updated_at=int(data.get("serverUpdatedAt", 0)),
target_latency_ms=data.get("targetLatencyMs"),
)
def to_json(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"state": self.state,
"positionMs": self.position_ms,
"serverUpdatedAt": self.server_updated_at,
}
if self.target_latency_ms is not None:
payload["targetLatencyMs"] = self.target_latency_ms
return payload
@dataclass
class SourceInfo:
type: str = "url"
mode: str = "direct"
url: str = ""
is_live: bool = False
headers: dict[str, str] = field(default_factory=dict)
username: str = ""
password: str = ""
@staticmethod
def from_json(data: dict[str, Any] | None) -> "SourceInfo | None":
if not data:
return None
return SourceInfo(
type=data.get("type", "url"),
mode=data.get("mode", "direct"),
url=data.get("url", ""),
is_live=bool(data.get("isLive", False)),
headers={str(k): str(v) for k, v in (data.get("headers") or {}).items()},
username=data.get("username") or "",
password=data.get("password") or "",
)
def to_json(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"type": self.type,
"mode": self.mode,
"url": self.url,
"isLive": self.is_live,
}
if self.headers:
payload["headers"] = self.headers
if self.username:
payload["username"] = self.username
if self.password:
payload["password"] = self.password
return payload
@dataclass
class RoomInfo:
code: str
owner_device_id: str = ""
online_count: int = 0
source: SourceInfo | None = None
playback: PlaybackInfo = field(default_factory=PlaybackInfo)
@staticmethod
def from_json(data: dict[str, Any]) -> "RoomInfo":
return RoomInfo(
code=data["code"],
owner_device_id=data.get("ownerDeviceId") or "",
online_count=int(data.get("onlineCount", 0)),
source=SourceInfo.from_json(data.get("source")),
playback=PlaybackInfo.from_json(data.get("playback")),
)
@dataclass
class RoomSnapshot:
room: RoomInfo
is_owner: bool
device_id: str
server_now: int
@staticmethod
def from_json(data: dict[str, Any]) -> "RoomSnapshot":
return RoomSnapshot(
room=RoomInfo.from_json(data["room"]),
is_owner=bool(data.get("isOwner", False)),
device_id=data.get("deviceId", ""),
server_now=int(data.get("serverNow", 0)),
)

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import json
import threading
from collections.abc import Callable
import websocket
class SyncClient:
def __init__(
self,
on_event: Callable[[str, dict], None],
on_error: Callable[[str], None],
) -> None:
self.on_event = on_event
self.on_error = on_error
self.ws: websocket.WebSocketApp | None = None
self.thread: threading.Thread | None = None
def connect(self, server_base_url: str, room_code: str, device_id: str) -> None:
self.close()
ws_base = (
server_base_url.rstrip("/")
.replace("https://", "wss://")
.replace("http://", "ws://")
)
url = f"{ws_base}/ws?roomCode={room_code}&deviceId={device_id}"
self.ws = websocket.WebSocketApp(
url,
on_message=self._on_message,
on_error=lambda _ws, err: self.on_error(str(err)),
on_close=lambda _ws, _code, reason: self.on_error(f"websocket closed: {reason}"),
)
self.thread = threading.Thread(target=self.ws.run_forever, daemon=True)
self.thread.start()
def close(self) -> None:
if self.ws:
self.ws.close()
self.ws = None
def set_source(self, payload: dict) -> None:
self._send("setSource", payload)
def play(self, position_ms: int) -> None:
self._send("play", {"positionMs": position_ms})
def pause(self, position_ms: int) -> None:
self._send("pause", {"positionMs": position_ms})
def seek(self, position_ms: int) -> None:
self._send("seek", {"state": "playing", "positionMs": position_ms})
def sync_to_live(self) -> None:
self._send("syncToLive", {"state": "playing", "positionMs": 0, "targetLatencyMs": 3000})
def _send(self, event_type: str, payload: dict) -> None:
if self.ws:
self.ws.send(json.dumps({"type": event_type, "payload": payload}))
def _on_message(self, _ws: websocket.WebSocketApp, message: str) -> None:
data = json.loads(message)
self.on_event(data.get("type", ""), data.get("payload") or {})