init
This commit is contained in:
17
windows_py_client/README_CLIENT.md
Normal file
17
windows_py_client/README_CLIENT.md
Normal 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.
|
||||
13
windows_py_client/build_exe.ps1
Normal file
13
windows_py_client/build_exe.ps1
Normal 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"
|
||||
5
windows_py_client/requirements.txt
Normal file
5
windows_py_client/requirements.txt
Normal 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
|
||||
1
windows_py_client/synctv_client/__init__.py
Normal file
1
windows_py_client/synctv_client/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""SyncTV Windows Python client."""
|
||||
24
windows_py_client/synctv_client/api_client.py
Normal file
24
windows_py_client/synctv_client/api_client.py
Normal 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())
|
||||
19
windows_py_client/synctv_client/device_store.py
Normal file
19
windows_py_client/synctv_client/device_store.py
Normal 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
|
||||
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())
|
||||
108
windows_py_client/synctv_client/models.py
Normal file
108
windows_py_client/synctv_client/models.py
Normal 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)),
|
||||
)
|
||||
64
windows_py_client/synctv_client/sync_client.py
Normal file
64
windows_py_client/synctv_client/sync_client.py
Normal 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 {})
|
||||
Reference in New Issue
Block a user