init
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.idea
|
||||
client_flutter/build
|
||||
server/bin
|
||||
**/.dart_tool
|
||||
**/.packages
|
||||
**/node_modules
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
.idea/
|
||||
|
||||
# Go
|
||||
server/synctv-server
|
||||
server/synctv-server.exe
|
||||
server/bin/
|
||||
|
||||
# Local env
|
||||
.env
|
||||
|
||||
# Local toolchains and downloaded SDK archives
|
||||
.tools/
|
||||
flutter_windows_*.zip
|
||||
|
||||
# Python Windows client
|
||||
windows_py_client/.venv/
|
||||
windows_py_client/build/
|
||||
windows_py_client/dist/
|
||||
windows_py_client/*.spec
|
||||
windows_py_client/**/__pycache__/
|
||||
windows_py_client/**/*.pyc
|
||||
|
||||
# HBuilder
|
||||
hbuilder_app/unpackage/
|
||||
103
README.md
Normal file
103
README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# SyncTV
|
||||
|
||||
SyncTV is a no-account synchronized watching app. The first version keeps the server thin: room state, WebSocket signaling, Redis-backed temporary state, and optional streaming proxy when the room owner enables it.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Server:
|
||||
|
||||
- HTTP: `http://yuyun-us1.stormrain.cn:8088`
|
||||
- WebSocket: `ws://yuyun-us1.stormrain.cn:8088/ws?roomCode=<CODE>&deviceId=<DEVICE_ID>`
|
||||
- Health: `GET /health`
|
||||
|
||||
## Local Server
|
||||
|
||||
```bash
|
||||
cd server
|
||||
$env:SYNCTV_CONFIG="../config/server.local.json"
|
||||
go run ./cmd/synctv-server
|
||||
```
|
||||
|
||||
Create a local config with Redis pointing at your local Redis instance.
|
||||
|
||||
## HTTP API
|
||||
|
||||
- `GET /health`
|
||||
- `POST /api/rooms`
|
||||
- `GET /api/rooms/{code}?deviceId={deviceId}`
|
||||
- `POST /api/rooms/{code}/source` with `X-Device-Id`
|
||||
- `GET /api/rooms/{code}/stream` for proxy mode playback
|
||||
|
||||
## WebSocket
|
||||
|
||||
Connect:
|
||||
|
||||
```text
|
||||
ws://yuyun-us1.stormrain.cn:8088/ws?roomCode=A7K29Q&deviceId=dev_xxx
|
||||
```
|
||||
|
||||
Client events:
|
||||
|
||||
- `setSource`
|
||||
- `play`
|
||||
- `pause`
|
||||
- `seek`
|
||||
- `syncProgress`
|
||||
- `syncToLive`
|
||||
- `heartbeat`
|
||||
|
||||
Server events:
|
||||
|
||||
- `roomSnapshot`
|
||||
- `sourceChanged`
|
||||
- `playbackChanged`
|
||||
- `presenceChanged`
|
||||
- `heartbeatAck`
|
||||
- `error`
|
||||
|
||||
## Clients
|
||||
|
||||
The product is split by platform.
|
||||
|
||||
### Windows
|
||||
|
||||
Windows source lives in `windows_py_client`.
|
||||
|
||||
Required tools:
|
||||
|
||||
- Python 3.11+
|
||||
- VLC 64-bit runtime
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
cd windows_py_client
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\pip install -r requirements.txt
|
||||
.\.venv\Scripts\python synctv_client\main.py
|
||||
```
|
||||
|
||||
Build exe:
|
||||
|
||||
```powershell
|
||||
.\build_exe.ps1
|
||||
```
|
||||
|
||||
The Windows client uses PySide6 + python-vlc.
|
||||
|
||||
### App
|
||||
|
||||
HBuilder/uni-app source lives in `hbuilder_app`.
|
||||
|
||||
Open `hbuilder_app` in HBuilderX and run/build as an App project.
|
||||
|
||||
Playback direction:
|
||||
|
||||
- Development fallback: built-in `<video>`.
|
||||
- Production target: `nativeplugins/SyncTV-VLC`, a uni-app native plugin wrapping VLC.
|
||||
|
||||
HBuilder cannot directly call VLC from Vue/JavaScript. It needs a native plugin bridge. The JS facade is already defined in `hbuilder_app/common/player.js`.
|
||||
30
config/server.json
Normal file
30
config/server.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"http": {
|
||||
"addr": ":8080",
|
||||
"allowedOrigins": ["*"]
|
||||
},
|
||||
"redis": {
|
||||
"addr": "redis:6379",
|
||||
"password": "",
|
||||
"db": 0
|
||||
},
|
||||
"room": {
|
||||
"codeLength": 6,
|
||||
"emptyTtl": "30m",
|
||||
"ownerReconnectHold": "30s"
|
||||
},
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"requestTimeout": "0s",
|
||||
"maxIdleConns": 256,
|
||||
"responseHeaderBytes": 1048576
|
||||
},
|
||||
"websocket": {
|
||||
"pingInterval": "25s",
|
||||
"pongTimeout": "60s",
|
||||
"writeTimeout": "10s"
|
||||
},
|
||||
"source": {
|
||||
"defaultMode": "direct"
|
||||
}
|
||||
}
|
||||
30
config/server.local.json
Normal file
30
config/server.local.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"http": {
|
||||
"addr": ":8080",
|
||||
"allowedOrigins": ["*"]
|
||||
},
|
||||
"redis": {
|
||||
"addr": "localhost:6379",
|
||||
"password": "",
|
||||
"db": 0
|
||||
},
|
||||
"room": {
|
||||
"codeLength": 6,
|
||||
"emptyTtl": "30m",
|
||||
"ownerReconnectHold": "30s"
|
||||
},
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"requestTimeout": "0s",
|
||||
"maxIdleConns": 256,
|
||||
"responseHeaderBytes": 1048576
|
||||
},
|
||||
"websocket": {
|
||||
"pingInterval": "25s",
|
||||
"pongTimeout": "60s",
|
||||
"writeTimeout": "10s"
|
||||
},
|
||||
"source": {
|
||||
"defaultMode": "direct"
|
||||
}
|
||||
}
|
||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
container_name: synctv-redis
|
||||
command: ["redis-server", "--appendonly", "no"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server/Dockerfile
|
||||
container_name: synctv-server
|
||||
environment:
|
||||
SYNCTV_CONFIG: /app/config/server.json
|
||||
volumes:
|
||||
- ./config/server.json:/app/config/server.json:ro
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- redis
|
||||
717
docs/first-version-plan.md
Normal file
717
docs/first-version-plan.md
Normal file
@@ -0,0 +1,717 @@
|
||||
# SyncTV 第一版方案
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
SyncTV 第一版是一个无注册、无用户体系的同步观影客户端,面向 App 和 Windows 应用。
|
||||
|
||||
核心目标:
|
||||
|
||||
- 输入房间 Code 即可进入房间。
|
||||
- 不做账号、昵称、好友、聊天、历史用户等用户体系。
|
||||
- 不支持本地视频。
|
||||
- 只支持网络播放源:普通链接、OSS、WebDAV、直播流。
|
||||
- 客户端负责解码播放,避免网页播放器格式支持弱的问题。
|
||||
- 后端负责房间状态、同步信令、播放源解析、临时凭据保存,以及必要时的流式转发。
|
||||
- 支持断线重连,并恢复到房间当前播放进度。
|
||||
|
||||
第一版优先把三件事做好:
|
||||
|
||||
- 能播。
|
||||
- 同步准。
|
||||
- 断线能回来。
|
||||
|
||||
## 2. 第一版功能范围
|
||||
|
||||
### 2.1 创建和进入房间
|
||||
|
||||
用户打开应用后可以创建房间,或输入已有房间 Code 加入房间。
|
||||
|
||||
房间可以先没有播放源。第一个加入房间成功的设备自动成为房主。
|
||||
|
||||
房主可以配置:
|
||||
|
||||
- 播放源类型。
|
||||
- 播放源地址或配置。
|
||||
- 是否为直播流。
|
||||
- 连接方式,默认客户端直连,可手动切换为服务端代理。
|
||||
|
||||
创建新房间时服务端生成房间 Code,例如:
|
||||
|
||||
```text
|
||||
A7K29Q
|
||||
```
|
||||
|
||||
### 2.2 加入房间
|
||||
|
||||
客户端只需要输入房间 Code。
|
||||
|
||||
加入后客户端自动获取:
|
||||
|
||||
- 播放源信息。
|
||||
- 当前播放状态。
|
||||
- 当前播放进度。
|
||||
- 是否直播。
|
||||
- 当前房主设备信息。
|
||||
|
||||
不需要用户登录,也不需要设置昵称。
|
||||
|
||||
只有房主可以配置或更换播放源。所有房间成员都可以执行播放、暂停、跳转进度、回到直播等播放控制。
|
||||
|
||||
播放源连接方式由房主在配置时选择。默认是客户端直连源站,代理模式必须由房主主动打开。
|
||||
|
||||
### 2.3 播放控制同步
|
||||
|
||||
点播场景支持:
|
||||
|
||||
- 播放。
|
||||
- 暂停。
|
||||
- 跳转进度。
|
||||
- 当前进度同步。
|
||||
- 缓冲后恢复。
|
||||
|
||||
直播场景支持:
|
||||
|
||||
- 播放。
|
||||
- 暂停。
|
||||
- 回到直播。
|
||||
- 直播延迟同步。
|
||||
|
||||
### 2.4 断线重连
|
||||
|
||||
客户端 WebSocket 断开后自动重连。
|
||||
|
||||
重连时携带:
|
||||
|
||||
- 房间 Code。
|
||||
- 本地设备 ID。
|
||||
|
||||
服务端返回最新房间快照,客户端重新计算应播放位置并恢复。
|
||||
|
||||
房间无人在线后保留一段时间,建议第一版保留 30 分钟。超过后销毁房间和临时播放源凭据。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
第一版不做:
|
||||
|
||||
- 注册。
|
||||
- 登录。
|
||||
- 昵称。
|
||||
- 用户资料。
|
||||
- 好友。
|
||||
- 聊天。
|
||||
- 弹幕。
|
||||
- 房间列表。
|
||||
- 历史记录。
|
||||
- 本地视频同步。
|
||||
- 内置影视内容。
|
||||
- 完整资源站。
|
||||
|
||||
## 4. 技术选型建议
|
||||
|
||||
### 4.1 客户端
|
||||
|
||||
客户端改为分端开发。
|
||||
|
||||
Windows 客户端建议:
|
||||
|
||||
- Python。
|
||||
- PySide6。
|
||||
- python-vlc。
|
||||
- PyInstaller 打包 Windows exe。
|
||||
- 系统播放器逻辑和同步逻辑分离。
|
||||
|
||||
移动 App 建议:
|
||||
|
||||
- HBuilder/uni-app。
|
||||
- UI 和房间同步逻辑使用 Vue/uni-app。
|
||||
- 播放器通过 App 原生插件接 VLC。
|
||||
- Android 原生插件封装 libVLC。
|
||||
- iOS 原生插件封装 MobileVLCKit / VLCKit。
|
||||
|
||||
分端开发的原因:
|
||||
|
||||
- 避免重型桌面工具链问题。
|
||||
- Windows 端优先把强播放能力跑通。
|
||||
- App 端通过 HBuilder 快速开发,同时保留原生 VLC 播放能力。
|
||||
- 客户端共享后端协议,不共享 UI 代码。
|
||||
|
||||
选择播放器时需要重点验证:
|
||||
|
||||
- mp4。
|
||||
- mkv。
|
||||
- flv。
|
||||
- m3u8。
|
||||
- dash。
|
||||
- 常见编码格式。
|
||||
- 直播流。
|
||||
- HTTP Header 自定义。
|
||||
- Cookie / Authorization Header。
|
||||
- WebDAV URL 播放。
|
||||
- 字幕和音轨扩展能力。
|
||||
|
||||
### 4.2 后端
|
||||
|
||||
第一版服务端原则:
|
||||
|
||||
- 尽量轻。
|
||||
- 尽量少模块。
|
||||
- 尽量少状态。
|
||||
- 默认不接触视频流。
|
||||
- 只有房主手动开启代理时才做流式转发。
|
||||
- 不做用户系统。
|
||||
- 不做数据库表设计。
|
||||
- 不做文件存储。
|
||||
|
||||
推荐:
|
||||
|
||||
- Go。
|
||||
- 标准库 net/http 或 Gin/Fiber/Echo。
|
||||
- WebSocket。
|
||||
- Redis。
|
||||
- Docker + Nginx。
|
||||
|
||||
第一版不建议上 PostgreSQL。房间状态、连接状态、临时播放源和过期时间都放 Redis,开发更快,清理也简单。
|
||||
|
||||
Go 更适合服务端保持轻量:单二进制部署,内存占用低,并发连接和流式转发能力好。第一版可以优先使用标准库 net/http,减少框架依赖。
|
||||
|
||||
### 4.3 服务模块
|
||||
|
||||
后端建议只拆最少模块:
|
||||
|
||||
- RoomService:房间创建、加入、房主判定、房间快照、过期清理。
|
||||
- SyncGateway:WebSocket 连接、播放控制广播、心跳、重连。
|
||||
- SourceService:播放源保存、直连信息下发、代理 URL 生成。
|
||||
- StreamProxy:房主手动开启代理时,对 WebDAV/OSS/普通私有链接做流式转发,不落盘存储。
|
||||
|
||||
第一版可以先不单独拆 CredentialService、SourceResolver 等细模块,避免服务端过早变重。等代理来源和鉴权方式变多后再拆。
|
||||
|
||||
## 5. 播放源设计
|
||||
|
||||
第一版支持四类来源。
|
||||
|
||||
| 类型 | 示例 | 第一版处理方式 |
|
||||
| --- | --- | --- |
|
||||
| 普通链接 | mp4、m3u8、flv、dash | 默认客户端直连,可手动开启代理 |
|
||||
| OSS | 阿里云 OSS、S3 兼容对象存储 | 默认客户端直连,可手动开启代理 |
|
||||
| WebDAV | WebDAV 文件 URL | 默认客户端直连,可手动开启代理 |
|
||||
| 直播流 | m3u8、flv、其他播放器支持协议 | 默认客户端直连,可手动开启代理 |
|
||||
|
||||
房主配置播放源时需要有一个明确的连接方式控件:
|
||||
|
||||
- 默认:客户端直连。
|
||||
- 可选:服务端代理。
|
||||
|
||||
这个控件可以做成分段按钮或开关,不做自动切换。代理模式涉及服务器带宽成本和隐私边界,必须让房主显式选择。
|
||||
|
||||
### 5.1 普通链接
|
||||
|
||||
普通链接是最简单的来源。
|
||||
|
||||
后端只保存 URL 和基础元信息,客户端直接播放。
|
||||
|
||||
需要支持:
|
||||
|
||||
- 普通 HTTP/HTTPS URL。
|
||||
- m3u8。
|
||||
- flv。
|
||||
- dash。
|
||||
- 带必要 Header 的 URL。
|
||||
|
||||
### 5.2 OSS
|
||||
|
||||
OSS 有两种使用方式。
|
||||
|
||||
方式一:用户直接输入公开 URL 或签名 URL。
|
||||
|
||||
- 实现简单。
|
||||
- 客户端直接播放。
|
||||
- 后端只保存 URL。
|
||||
- 适合第一版快速落地。
|
||||
|
||||
方式二:房主输入 OSS 配置,服务端保存临时凭据,并返回代理播放 URL。
|
||||
|
||||
- 更安全。
|
||||
- 不把 Secret 直接广播给所有客户端。
|
||||
- 服务端只做流式转发,不下载、不落盘。
|
||||
- 房间销毁后清理配置。
|
||||
- 适合稍微完整的第一版。
|
||||
|
||||
建议第一版支持方式一和方式二。方式一是默认模式;方式二需要房主手动开启,会增加服务器带宽压力,但不会增加硬盘存储压力。
|
||||
|
||||
### 5.3 WebDAV
|
||||
|
||||
WebDAV 是第一版需要重点设计的来源,因为它通常需要账号密码。
|
||||
|
||||
常见 WebDAV 鉴权方式:
|
||||
|
||||
- Basic Auth。
|
||||
- Digest Auth。
|
||||
- Cookie。
|
||||
- Token。
|
||||
|
||||
播放器播放 WebDAV 文件时,客户端通常必须具备以下能力之一:
|
||||
|
||||
- URL 中包含凭据。
|
||||
- 播放请求附带 Authorization Header。
|
||||
- 访问服务端流式代理地址。
|
||||
|
||||
所以“WebDAV 的 key 不给客户端还能不能用”的答案是:
|
||||
|
||||
> 如果客户端要直接连接 WebDAV,凭据或等价的临时授权信息必须给客户端;不给就不能直连播放。
|
||||
>
|
||||
> 如果使用服务端流式代理,客户端不需要拿到原始 WebDAV 凭据;服务端拿到凭据后直接向 WebDAV 拉流,并边读边转发给客户端,不下载、不落盘。
|
||||
|
||||
#### 5.3.1 极简方案:凭据下发客户端
|
||||
|
||||
创建房间时,房主输入:
|
||||
|
||||
- WebDAV 文件 URL。
|
||||
- 用户名。
|
||||
- 密码或 Token。
|
||||
|
||||
服务端保存房间播放源,并在成员加入房间时下发给客户端。
|
||||
|
||||
客户端用这些凭据直接播放。
|
||||
|
||||
优点:
|
||||
|
||||
- 实现最简单。
|
||||
- 后端不承担视频流量。
|
||||
- 播放性能最好。
|
||||
- 成本最低。
|
||||
|
||||
缺点:
|
||||
|
||||
- 所有加入房间的客户端都能拿到 WebDAV 凭据。
|
||||
- 如果房间 Code 泄露,凭据也可能泄露。
|
||||
- 对安全和隐私不友好。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 早期内测。
|
||||
- 用户之间互相信任。
|
||||
- WebDAV 凭据本身是临时的。
|
||||
- 产品明确提示风险。
|
||||
|
||||
#### 5.3.2 代理模式:服务端保存凭据并流式转发
|
||||
|
||||
创建房间时,房主提交 WebDAV 配置给后端。
|
||||
|
||||
后端:
|
||||
|
||||
- 加密保存 WebDAV 凭据。
|
||||
- 验证文件是否可访问。
|
||||
- 为房间成员生成代理播放 URL。
|
||||
- 使用 WebDAV 凭据向源站发起请求。
|
||||
- 将源站响应以流式方式转发给客户端。
|
||||
- 不把视频文件下载到服务器硬盘。
|
||||
- 不做长期缓存。
|
||||
- 房间过期后删除凭据。
|
||||
|
||||
客户端播放:
|
||||
|
||||
```text
|
||||
https://api.example.com/rooms/A7K29Q/source/stream?token=short-lived-token
|
||||
```
|
||||
|
||||
服务端收到请求后使用 WebDAV 凭据去拉取源文件,再把内容边读边转发给客户端。
|
||||
|
||||
优点:
|
||||
|
||||
- 客户端拿不到原始 WebDAV 密码。
|
||||
- 可以做房间权限、过期、限速、防盗链。
|
||||
- 房间销毁后授权自然失效。
|
||||
- 不承担硬盘存储压力。
|
||||
|
||||
缺点:
|
||||
|
||||
- 后端要承担视频流量,带宽成本高。
|
||||
- 需要支持 Range 请求,否则拖动进度会很差。
|
||||
- 大文件和高并发时压力明显。
|
||||
- 直播和大码率视频对服务端要求更高。
|
||||
- 需要处理连接中断、源站限速、响应头透传等转发细节。
|
||||
|
||||
这个方案安全性更好,不占用硬盘,但基础设施带宽成本更高。
|
||||
|
||||
#### 5.3.3 第一版建议
|
||||
|
||||
第一版建议做两个模式,但默认始终是直连模式:
|
||||
|
||||
1. 直连模式:客户端直连 WebDAV。
|
||||
- 房主输入 WebDAV URL 和凭据。
|
||||
- 后端临时保存并下发给房间客户端。
|
||||
- 明确这是共享房间凭据,适合可信房间。
|
||||
- 这是默认模式。
|
||||
|
||||
2. 代理模式:服务端流式转发 WebDAV。
|
||||
- 房主输入 WebDAV URL 和凭据。
|
||||
- 房主必须手动打开代理模式。
|
||||
- 后端保存凭据。
|
||||
- 客户端只拿代理播放 URL。
|
||||
- 后端支持 Range 请求。
|
||||
- 后端不下载、不落盘,只做流式转发。
|
||||
- 后端承担带宽和连接压力。
|
||||
|
||||
普通 URL 和 OSS 也可以使用同样的代理模式。只要服务端能拿到访问源站所需的 URL、Header、Cookie 或 Secret,就可以向源站发起请求并流式转发给客户端。
|
||||
|
||||
## 6. 房间状态模型
|
||||
|
||||
Redis 中建议保存房间快照。
|
||||
|
||||
点播房间示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"roomCode": "A7K29Q",
|
||||
"sourceType": "webdav",
|
||||
"sourceId": "src_123",
|
||||
"isLive": false,
|
||||
"playbackState": "playing",
|
||||
"positionMs": 128400,
|
||||
"serverUpdatedAt": 1780000000000,
|
||||
"ownerDeviceId": "dev_abc",
|
||||
"onlineCount": 2,
|
||||
"expireAt": 1780001800000
|
||||
}
|
||||
```
|
||||
|
||||
直播房间示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"roomCode": "A7K29Q",
|
||||
"sourceType": "url",
|
||||
"sourceId": "src_456",
|
||||
"isLive": true,
|
||||
"playbackState": "playing",
|
||||
"liveSyncMode": "latency",
|
||||
"targetLatencyMs": 3000,
|
||||
"serverUpdatedAt": 1780000000000,
|
||||
"ownerDeviceId": "dev_abc",
|
||||
"onlineCount": 2,
|
||||
"expireAt": 1780001800000
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 同步协议
|
||||
|
||||
### 7.1 WebSocket 事件
|
||||
|
||||
客户端到服务端:
|
||||
|
||||
- createRoom
|
||||
- joinRoom
|
||||
- leaveRoom
|
||||
- play
|
||||
- pause
|
||||
- seek
|
||||
- syncProgress
|
||||
- syncToLive
|
||||
- heartbeat
|
||||
- reconnectRoom
|
||||
|
||||
服务端到客户端:
|
||||
|
||||
- roomCreated
|
||||
- roomJoined
|
||||
- roomSnapshot
|
||||
- playbackChanged
|
||||
- progressSynced
|
||||
- liveSynced
|
||||
- controllerChanged
|
||||
- roomExpired
|
||||
- error
|
||||
|
||||
### 7.2 点播同步
|
||||
|
||||
控制端执行播放控制时发送事件。
|
||||
|
||||
播放事件示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "play",
|
||||
"roomCode": "A7K29Q",
|
||||
"positionMs": 128400,
|
||||
"clientSentAt": 1780000000000
|
||||
}
|
||||
```
|
||||
|
||||
服务端写入:
|
||||
|
||||
```json
|
||||
{
|
||||
"playbackState": "playing",
|
||||
"positionMs": 128400,
|
||||
"serverUpdatedAt": 1780000000120
|
||||
}
|
||||
```
|
||||
|
||||
其他客户端收到后计算目标进度:
|
||||
|
||||
```text
|
||||
targetPositionMs = positionMs + (clientNowServerTime - serverUpdatedAt)
|
||||
```
|
||||
|
||||
误差处理建议:
|
||||
|
||||
- 小于 500ms:不处理。
|
||||
- 500ms 到 2000ms:短时间微调倍速。
|
||||
- 大于 2000ms:直接 seek。
|
||||
|
||||
控制端每 3 秒上报一次当前状态,用于校准。
|
||||
|
||||
### 7.3 直播同步
|
||||
|
||||
直播不以 positionMs 为核心,而以直播延迟为核心。
|
||||
|
||||
建议同步字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"isLive": true,
|
||||
"targetLatencyMs": 3000,
|
||||
"playbackState": "playing"
|
||||
}
|
||||
```
|
||||
|
||||
客户端尽量保持距离直播边缘 3 秒左右。
|
||||
|
||||
直播控制:
|
||||
|
||||
- play:开始拉流。
|
||||
- pause:暂停本地播放。
|
||||
- syncToLive:回到直播边缘或目标延迟。
|
||||
|
||||
直播流由于源站、CDN、客户端缓冲策略不同,很难做到毫秒级同步。第一版目标应是让多个客户端处于接近的直播延迟。
|
||||
|
||||
## 8. 断线重连恢复
|
||||
|
||||
### 8.1 客户端本地保存
|
||||
|
||||
客户端本地保存:
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceId": "dev_abc",
|
||||
"lastRoomCode": "A7K29Q",
|
||||
"lastConnectedAt": 1780000000000
|
||||
}
|
||||
```
|
||||
|
||||
deviceId 是本地随机生成的设备标识。
|
||||
|
||||
它不是账号,也不是用户 ID,只用于:
|
||||
|
||||
- 断线重连。
|
||||
- 控制权恢复。
|
||||
- 同一设备重复连接识别。
|
||||
|
||||
### 8.2 重连流程
|
||||
|
||||
1. WebSocket 断开。
|
||||
2. 客户端进入重连状态。
|
||||
3. 客户端带 roomCode 和 deviceId 重连。
|
||||
4. 服务端检查房间是否还存在。
|
||||
5. 服务端返回 roomSnapshot。
|
||||
6. 客户端重新解析播放源。
|
||||
7. 点播房间根据 positionMs 和 serverUpdatedAt 恢复。
|
||||
8. 直播房间恢复到目标直播延迟。
|
||||
|
||||
### 8.3 房主和控制权处理
|
||||
|
||||
建议第一版规则:
|
||||
|
||||
- 第一个加入房间成功的设备成为房主。
|
||||
- 只有房主可以配置或更换播放源。
|
||||
- 所有房间成员都可以播放、暂停、跳转进度、回到直播。
|
||||
- 房主短暂断线 30 秒内,房主身份保留。
|
||||
- 房主超过 30 秒未恢复,允许当前房间内最早在线的设备成为新房主。
|
||||
- 房间无人后进入保留期,保留期间房主身份和房间状态仍然存在。
|
||||
|
||||
这个规则避免了用户体系,同时保留了“谁能换片”的边界。
|
||||
|
||||
## 9. 客户端架构
|
||||
|
||||
客户端建议拆成:
|
||||
|
||||
- RoomController:创建房间、加入房间、房间状态。
|
||||
- SocketClient:WebSocket 连接、重连、事件收发。
|
||||
- PlayerController:播放、暂停、seek、倍速、直播控制。
|
||||
- SyncEngine:进度计算、误差校正、直播延迟同步。
|
||||
- SourceManager:播放源初始化、凭据处理、URL 刷新。
|
||||
- LocalDeviceStore:本地 deviceId 和 lastRoomCode。
|
||||
|
||||
## 10. 后端架构
|
||||
|
||||
后端第一版建议保持单体服务,不拆微服务。
|
||||
|
||||
最小结构:
|
||||
|
||||
- HTTP API:创建房间、加入房间、配置播放源、获取房间快照。
|
||||
- WebSocket Gateway:播放、暂停、seek、直播同步、心跳、断线重连。
|
||||
- Redis Store:房间状态、连接状态、播放源配置、过期时间。
|
||||
- Stream Proxy:可选代理入口,只有房主开启代理模式时使用。
|
||||
|
||||
代码模块:
|
||||
|
||||
- RoomService:房间创建、房间加入、房主判定、快照维护。
|
||||
- PlaybackService:播放状态写入和广播。
|
||||
- SourceService:播放源保存、直连信息下发、代理 URL 生成。
|
||||
- StreamProxyService:WebDAV/OSS/私有链接流式转发,不落盘存储。
|
||||
|
||||
暂时不做:
|
||||
|
||||
- 用户数据库。
|
||||
- PostgreSQL。
|
||||
- 微服务拆分。
|
||||
- 消息队列。
|
||||
- 文件存储。
|
||||
- 复杂权限系统。
|
||||
|
||||
Redis Key 示例:
|
||||
|
||||
```text
|
||||
room:A7K29Q
|
||||
room:A7K29Q:connections
|
||||
source:src_123
|
||||
device:dev_abc:last
|
||||
```
|
||||
|
||||
## 11. WebDAV 凭据结论
|
||||
|
||||
WebDAV 凭据问题可以概括为一句话:
|
||||
|
||||
> 客户端直连 WebDAV 时,客户端必须拿到凭据或等价授权;代理模式下客户端只拿我们服务端的播放 URL,原始凭据由服务端持有。
|
||||
|
||||
两种可选做法:
|
||||
|
||||
| 方案 | 客户端拿到什么 | 后端流量成本 | 安全性 | 实现难度 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 直连模式 | 原始凭据或 Basic Auth Header | 低 | 低 | 低 |
|
||||
| 代理模式 | 服务端代理 URL | 高 | 高 | 中高 |
|
||||
|
||||
代理模式的关键点:
|
||||
|
||||
- 服务端拿到用户的 key、Cookie、Header 或账号密码。
|
||||
- 服务端向 WebDAV/OSS/私有源站发起请求。
|
||||
- 服务端把响应流转发给客户端。
|
||||
- 服务端不下载完整文件。
|
||||
- 服务端不做硬盘存储。
|
||||
- 服务端必须正确支持 Range、Content-Type、Content-Length、Accept-Ranges 等响应头。
|
||||
- 服务端需要承担带宽、并发连接和转发稳定性压力。
|
||||
|
||||
第一版建议:
|
||||
|
||||
- 同时支持直连模式和代理模式。
|
||||
- 房主配置播放源时通过按钮或开关选择连接方式。
|
||||
- 默认连接方式是客户端直连。
|
||||
- 代理模式必须由房主主动开启。
|
||||
- 直连模式适合可信房间和低成本场景。
|
||||
- 代理模式适合不想把 key 暴露给房间成员的场景。
|
||||
|
||||
## 12. 第一版里程碑
|
||||
|
||||
### Milestone 1:最小可跑通
|
||||
|
||||
- Windows 客户端。
|
||||
- 轻量 Node.js 服务端。
|
||||
- Redis 房间状态。
|
||||
- 房间创建和加入。
|
||||
- 普通 URL 播放。
|
||||
- 播放、暂停、seek 同步。
|
||||
- WebSocket 断线重连。
|
||||
|
||||
### Milestone 2:播放源扩展
|
||||
|
||||
- m3u8 直播流。
|
||||
- OSS 签名 URL。
|
||||
- WebDAV 直连模式。
|
||||
- HTTP Header 支持。
|
||||
|
||||
### Milestone 3:同步体验打磨
|
||||
|
||||
- 周期性进度校准。
|
||||
- 小误差倍速追赶。
|
||||
- 大误差 seek。
|
||||
- 直播回到实时。
|
||||
|
||||
### Milestone 4:移动端
|
||||
|
||||
- App 客户端。
|
||||
- 复用房间和同步协议。
|
||||
- 验证播放器内核能力。
|
||||
|
||||
### Milestone 5:代理能力增强
|
||||
|
||||
- WebDAV/OSS 最小流式代理。
|
||||
- 凭据加密存储。
|
||||
- 代理 URL 短期 Token。
|
||||
- Range 请求和拖动进度优化。
|
||||
- 源站 Header 透传和错误处理。
|
||||
- 房间过期自动清理。
|
||||
|
||||
## 13. 关键风险
|
||||
|
||||
### 13.1 播放器能力风险
|
||||
|
||||
不同平台播放器能力不一致,尤其是:
|
||||
|
||||
- iOS 对部分格式限制更多。
|
||||
- Android 机型和系统版本差异大。
|
||||
- Windows mpv/libVLC 能力较强,但集成和打包需要验证。
|
||||
|
||||
第一阶段应优先做播放内核 PoC。
|
||||
|
||||
### 13.2 WebDAV 兼容风险
|
||||
|
||||
不同 WebDAV 服务差异很大:
|
||||
|
||||
- 鉴权方式不同。
|
||||
- 是否支持 Range 请求不同。
|
||||
- 是否支持文件直链不同。
|
||||
- 是否限制 User-Agent 或 Header。
|
||||
|
||||
第一版应先支持最常见的 Basic Auth + Range。
|
||||
|
||||
### 13.3 服务端流式代理成本风险
|
||||
|
||||
如果服务端代理 WebDAV 或 OSS 视频流,所有视频流量都会走后端。
|
||||
|
||||
这会带来:
|
||||
|
||||
- 高带宽成本。
|
||||
- 高并发压力。
|
||||
- 大文件拖动复杂度。
|
||||
- 直播流转发压力。
|
||||
- 但不会带来硬盘存储压力,因为代理模式只做流式转发,不下载、不落盘。
|
||||
|
||||
因此代理模式适合安全要求更高的房间,但需要配套限流、超时、Range 支持和错误恢复。
|
||||
|
||||
### 13.4 直播同步风险
|
||||
|
||||
直播流本身缓冲和 CDN 延迟不稳定。
|
||||
|
||||
第一版不要承诺毫秒级同步直播,目标是保持相近的直播延迟。
|
||||
|
||||
## 14. 推荐第一版决策
|
||||
|
||||
建议第一版采用:
|
||||
|
||||
- Windows 和 App 分端开发。
|
||||
- Windows 优先跑通。
|
||||
- Windows 客户端使用 Python + PySide6 + python-vlc,并用 PyInstaller 打包。
|
||||
- App 使用 HBuilder/uni-app + 原生 VLC 插件。
|
||||
- 后端 Go + WebSocket + Redis。
|
||||
- 服务端保持单体和轻量,不上 PostgreSQL,不拆微服务。
|
||||
- 普通 URL、OSS、WebDAV、m3u8 直播优先。
|
||||
- 播放源支持直连和服务端流式代理。
|
||||
- 默认客户端直连,代理模式由房主手动开启。
|
||||
- 第一个加入房间的设备为房主。
|
||||
- 只有房主能配置或更换播放源。
|
||||
- 所有人都可以播放、暂停、跳转进度、回到直播。
|
||||
- 房间无人后 30 分钟销毁。
|
||||
|
||||
最终第一版交付目标:
|
||||
|
||||
> 用户创建或加入房间,第一个加入者成为房主并配置网络播放源;默认由客户端直接获取源站内容,房主也可以手动开启服务端代理;其他设备输入 Code 加入后自动播放同一来源,并同步播放、暂停、跳转;断线后自动回到房间当前进度;支持普通链接、OSS、WebDAV 和直播流;代理模式下服务端拿到 key 后流式转发给客户端,不下载、不落盘。
|
||||
13
hbuilder_app/App.vue
Normal file
13
hbuilder_app/App.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
export default {
|
||||
onLaunch() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
page {
|
||||
background: #f8fafc;
|
||||
color: #111827;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
</style>
|
||||
29
hbuilder_app/common/api.js
Normal file
29
hbuilder_app/common/api.js
Normal file
@@ -0,0 +1,29 @@
|
||||
export function createApi(baseUrl) {
|
||||
const root = baseUrl.replace(/\/$/, '')
|
||||
return {
|
||||
createRoom() {
|
||||
return request({ url: `${root}/api/rooms`, method: 'POST' })
|
||||
},
|
||||
getRoom(code, deviceId) {
|
||||
return request({ url: `${root}/api/rooms/${code}?deviceId=${encodeURIComponent(deviceId)}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
success(res) {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(res.data)
|
||||
} else {
|
||||
reject(new Error(typeof res.data === 'string' ? res.data : JSON.stringify(res.data)))
|
||||
}
|
||||
},
|
||||
fail(err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
8
hbuilder_app/common/device.js
Normal file
8
hbuilder_app/common/device.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export function getDeviceId() {
|
||||
let id = uni.getStorageSync('deviceId')
|
||||
if (!id) {
|
||||
id = 'dev_' + Math.random().toString(16).slice(2) + Date.now().toString(16)
|
||||
uni.setStorageSync('deviceId', id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
71
hbuilder_app/common/player.js
Normal file
71
hbuilder_app/common/player.js
Normal file
@@ -0,0 +1,71 @@
|
||||
export function createPlayer({ onError } = {}) {
|
||||
let nativePlayer = null
|
||||
try {
|
||||
// Available only after the native plugin is added in HBuilderX.
|
||||
nativePlayer = uni.requireNativePlugin && uni.requireNativePlugin('SyncTV-VLC')
|
||||
} catch (err) {
|
||||
nativePlayer = null
|
||||
}
|
||||
|
||||
return {
|
||||
hasNative: !!nativePlayer,
|
||||
|
||||
open(source, server) {
|
||||
const url = source.url && source.url.startsWith('/api/')
|
||||
? server.replace(/\/$/, '') + source.url
|
||||
: source.url
|
||||
if (nativePlayer) {
|
||||
nativePlayer.open({
|
||||
url,
|
||||
headers: source.headers || {},
|
||||
username: source.username || '',
|
||||
password: source.password || '',
|
||||
isLive: !!source.isLive
|
||||
})
|
||||
}
|
||||
return url
|
||||
},
|
||||
|
||||
play() {
|
||||
if (nativePlayer) {
|
||||
nativePlayer.play()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
pause() {
|
||||
if (nativePlayer) {
|
||||
nativePlayer.pause()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
seek(positionMs) {
|
||||
if (nativePlayer) {
|
||||
nativePlayer.seek({ positionMs })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
getPosition(callback) {
|
||||
if (nativePlayer) {
|
||||
nativePlayer.getPosition(callback)
|
||||
return
|
||||
}
|
||||
callback({ positionMs: 0 })
|
||||
},
|
||||
|
||||
release() {
|
||||
if (nativePlayer) {
|
||||
nativePlayer.release()
|
||||
}
|
||||
},
|
||||
|
||||
error(message) {
|
||||
if (onError) onError(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
hbuilder_app/common/sync.js
Normal file
52
hbuilder_app/common/sync.js
Normal file
@@ -0,0 +1,52 @@
|
||||
export class SyncClient {
|
||||
constructor({ onEvent, onError }) {
|
||||
this.socket = null
|
||||
this.onEvent = onEvent
|
||||
this.onError = onError
|
||||
}
|
||||
|
||||
connect(baseUrl, roomCode, deviceId) {
|
||||
this.close()
|
||||
const wsBase = baseUrl.replace(/\/$/, '').replace('http://', 'ws://').replace('https://', 'wss://')
|
||||
this.socket = uni.connectSocket({
|
||||
url: `${wsBase}/ws?roomCode=${encodeURIComponent(roomCode)}&deviceId=${encodeURIComponent(deviceId)}`,
|
||||
complete: () => {}
|
||||
})
|
||||
this.socket.onMessage((message) => {
|
||||
const data = JSON.parse(message.data)
|
||||
this.onEvent(data.type, data.payload || {})
|
||||
})
|
||||
this.socket.onError((err) => this.onError(JSON.stringify(err)))
|
||||
this.socket.onClose(() => this.onError('websocket closed'))
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.socket) {
|
||||
this.socket.close({})
|
||||
this.socket = null
|
||||
}
|
||||
}
|
||||
|
||||
setSource(source) {
|
||||
this.send('setSource', source)
|
||||
}
|
||||
|
||||
play(positionMs) {
|
||||
this.send('play', { positionMs })
|
||||
}
|
||||
|
||||
pause(positionMs) {
|
||||
this.send('pause', { positionMs })
|
||||
}
|
||||
|
||||
syncToLive() {
|
||||
this.send('syncToLive', { state: 'playing', positionMs: 0, targetLatencyMs: 3000 })
|
||||
}
|
||||
|
||||
send(type, payload) {
|
||||
if (!this.socket) return
|
||||
this.socket.send({
|
||||
data: JSON.stringify({ type, payload })
|
||||
})
|
||||
}
|
||||
}
|
||||
7
hbuilder_app/main.js
Normal file
7
hbuilder_app/main.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
return { app }
|
||||
}
|
||||
31
hbuilder_app/manifest.json
Normal file
31
hbuilder_app/manifest.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "SyncTV",
|
||||
"appid": "__UNI__SYNCTV",
|
||||
"description": "SyncTV synchronized watching app",
|
||||
"versionName": "0.1.0",
|
||||
"versionCode": "1",
|
||||
"transformPx": false,
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": false,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
|
||||
]
|
||||
},
|
||||
"ios": {}
|
||||
}
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {},
|
||||
"vueVersion": "3"
|
||||
}
|
||||
57
hbuilder_app/nativeplugins/SyncTV-VLC/README.md
Normal file
57
hbuilder_app/nativeplugins/SyncTV-VLC/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# SyncTV VLC Native Plugin
|
||||
|
||||
HBuilder/uni-app cannot call VLC directly from Vue/JavaScript. The app needs a native plugin layer:
|
||||
|
||||
```text
|
||||
uni-app UI
|
||||
-> SyncTV-VLC plugin JS facade
|
||||
-> Android native module: libVLC
|
||||
-> iOS native module: MobileVLCKit / VLCKit
|
||||
```
|
||||
|
||||
The Vue page should only call the facade API below. The plugin implementation can then be filled in per platform without changing room/sync UI code.
|
||||
|
||||
## JS Facade API
|
||||
|
||||
```js
|
||||
const player = uni.requireNativePlugin('SyncTV-VLC')
|
||||
|
||||
player.open({
|
||||
url,
|
||||
headers,
|
||||
username,
|
||||
password,
|
||||
isLive
|
||||
})
|
||||
|
||||
player.play()
|
||||
player.pause()
|
||||
player.seek({ positionMs: 120000 })
|
||||
player.getPosition((result) => {
|
||||
console.log(result.positionMs)
|
||||
})
|
||||
player.release()
|
||||
```
|
||||
|
||||
## Android Implementation Target
|
||||
|
||||
- Use `org.videolan.android:libvlc-all`.
|
||||
- Expose methods through a uni-app native plugin module.
|
||||
- Render into a native view component if possible.
|
||||
- Support:
|
||||
- HTTP/HTTPS URL.
|
||||
- m3u8.
|
||||
- WebDAV Basic Auth.
|
||||
- Custom headers.
|
||||
- Live stream.
|
||||
- play/pause/seek/getPosition.
|
||||
|
||||
## iOS Implementation Target
|
||||
|
||||
- Use MobileVLCKit / VLCKit.
|
||||
- Expose equivalent plugin methods.
|
||||
- Render through native view component.
|
||||
|
||||
## Fallback
|
||||
|
||||
The current page keeps a `<video>` fallback for development preview. Production app playback should use this native plugin because the built-in video component still depends on system/WebView media support.
|
||||
16
hbuilder_app/pages.json
Normal file
16
hbuilder_app/pages.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "SyncTV"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "SyncTV",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
}
|
||||
305
hbuilder_app/pages/index/index.vue
Normal file
305
hbuilder_app/pages/index/index.vue
Normal file
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="panel">
|
||||
<text class="title">SyncTV</text>
|
||||
<text class="status">{{ status }}</text>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">Server</text>
|
||||
<input v-model="server" class="input" />
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">Room Code</text>
|
||||
<input v-model="roomCode" class="input" />
|
||||
</view>
|
||||
|
||||
<view class="row">
|
||||
<button class="primary" @click="createRoom">Create</button>
|
||||
<button @click="joinRoom">Join</button>
|
||||
</view>
|
||||
|
||||
<view class="divider" />
|
||||
|
||||
<text class="section">Source</text>
|
||||
<picker :range="sourceTypes" :value="sourceTypeIndex" @change="sourceTypeIndex = Number($event.detail.value)">
|
||||
<view class="select">Type: {{ sourceTypes[sourceTypeIndex] }}</view>
|
||||
</picker>
|
||||
<picker :range="sourceModes" :value="sourceModeIndex" @change="sourceModeIndex = Number($event.detail.value)">
|
||||
<view class="select">Connection: {{ sourceModes[sourceModeIndex] }}</view>
|
||||
</picker>
|
||||
<view class="field">
|
||||
<text class="label">URL</text>
|
||||
<input v-model="sourceUrl" class="input" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="label">Username</text>
|
||||
<input v-model="username" class="input" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="label">Password / Token</text>
|
||||
<input v-model="password" password class="input" />
|
||||
</view>
|
||||
<label class="checkbox">
|
||||
<checkbox :checked="isLive" @click="isLive = !isLive" />
|
||||
<text>Live stream</text>
|
||||
</label>
|
||||
<button :disabled="!isOwner" class="primary" @click="applySource">Apply Source</button>
|
||||
</view>
|
||||
|
||||
<view v-if="player.hasNative" class="native-player">
|
||||
<text class="native-player-text">VLC native player active</text>
|
||||
</view>
|
||||
<video
|
||||
v-else
|
||||
id="player"
|
||||
class="player"
|
||||
:src="playerUrl"
|
||||
controls
|
||||
autoplay="false"
|
||||
@timeupdate="onTimeUpdate"
|
||||
/>
|
||||
|
||||
<view class="controls">
|
||||
<button @click="play">Play</button>
|
||||
<button @click="pause">Pause</button>
|
||||
<button @click="syncToLive">Back To Live</button>
|
||||
</view>
|
||||
|
||||
<text class="error">{{ error }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { createApi } from '../../common/api'
|
||||
import { getDeviceId } from '../../common/device'
|
||||
import { createPlayer } from '../../common/player'
|
||||
import { SyncClient } from '../../common/sync'
|
||||
|
||||
const server = ref('http://yuyun-us1.stormrain.cn:8088')
|
||||
const roomCode = ref('')
|
||||
const status = ref('Not connected')
|
||||
const error = ref('')
|
||||
const isOwner = ref(false)
|
||||
const deviceId = getDeviceId()
|
||||
|
||||
const sourceTypes = ['url', 'oss', 'webdav', 'live']
|
||||
const sourceModes = ['direct', 'proxy']
|
||||
const sourceTypeIndex = ref(0)
|
||||
const sourceModeIndex = ref(0)
|
||||
const sourceUrl = ref('')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const isLive = ref(false)
|
||||
const playerUrl = ref('')
|
||||
const currentTimeMs = ref(0)
|
||||
const player = createPlayer({
|
||||
onError(message) {
|
||||
error.value = message
|
||||
}
|
||||
})
|
||||
|
||||
const sync = new SyncClient({
|
||||
onEvent: handleEvent,
|
||||
onError(message) {
|
||||
error.value = message
|
||||
}
|
||||
})
|
||||
|
||||
function api() {
|
||||
return createApi(server.value)
|
||||
}
|
||||
|
||||
async function createRoom() {
|
||||
try {
|
||||
error.value = ''
|
||||
const data = await api().createRoom()
|
||||
roomCode.value = data.room.code
|
||||
await joinRoom()
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
}
|
||||
}
|
||||
|
||||
async function joinRoom() {
|
||||
try {
|
||||
error.value = ''
|
||||
const snapshot = await api().getRoom(roomCode.value.toUpperCase(), deviceId)
|
||||
applySnapshot(snapshot)
|
||||
sync.connect(server.value, roomCode.value.toUpperCase(), deviceId)
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
}
|
||||
}
|
||||
|
||||
function applySource() {
|
||||
sync.setSource({
|
||||
type: sourceTypes[sourceTypeIndex.value],
|
||||
mode: sourceModes[sourceModeIndex.value],
|
||||
url: sourceUrl.value,
|
||||
isLive: isLive.value || sourceTypes[sourceTypeIndex.value] === 'live',
|
||||
username: username.value,
|
||||
password: password.value
|
||||
})
|
||||
}
|
||||
|
||||
function play() {
|
||||
sync.play(currentTimeMs.value)
|
||||
if (!player.play()) {
|
||||
uni.createVideoContext('player').play()
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
sync.pause(currentTimeMs.value)
|
||||
if (!player.pause()) {
|
||||
uni.createVideoContext('player').pause()
|
||||
}
|
||||
}
|
||||
|
||||
function syncToLive() {
|
||||
sync.syncToLive()
|
||||
}
|
||||
|
||||
function onTimeUpdate(event) {
|
||||
currentTimeMs.value = Math.floor(event.detail.currentTime * 1000)
|
||||
}
|
||||
|
||||
function handleEvent(type, payload) {
|
||||
if (type === 'roomSnapshot' || type === 'sourceChanged') {
|
||||
applySnapshot(payload)
|
||||
} else if (type === 'playbackChanged') {
|
||||
applyPlayback(payload.playback, payload.serverNow)
|
||||
} else if (type === 'presenceChanged') {
|
||||
status.value = `${roomCode.value} · ${isOwner.value ? 'Owner' : 'Member'} · ${payload.onlineCount} online`
|
||||
} else if (type === 'error') {
|
||||
error.value = payload.message || 'unknown error'
|
||||
}
|
||||
}
|
||||
|
||||
function applySnapshot(snapshot) {
|
||||
roomCode.value = snapshot.room.code
|
||||
isOwner.value = snapshot.isOwner
|
||||
status.value = `${snapshot.room.code} · ${snapshot.isOwner ? 'Owner' : 'Member'} · ${snapshot.room.onlineCount} online`
|
||||
if (snapshot.room.source) {
|
||||
const source = snapshot.room.source
|
||||
sourceUrl.value = source.url
|
||||
isLive.value = !!source.isLive
|
||||
sourceTypeIndex.value = Math.max(0, sourceTypes.indexOf(source.type))
|
||||
sourceModeIndex.value = Math.max(0, sourceModes.indexOf(source.mode))
|
||||
playerUrl.value = player.open(source, server.value)
|
||||
}
|
||||
if (snapshot.room.playback) {
|
||||
applyPlayback(snapshot.room.playback, snapshot.serverNow)
|
||||
}
|
||||
}
|
||||
|
||||
function applyPlayback(playback, serverNow) {
|
||||
const ctx = uni.createVideoContext('player')
|
||||
if (playback.state === 'playing') {
|
||||
const target = playback.positionMs + Math.max(0, serverNow - playback.serverUpdatedAt)
|
||||
if (target > 0) {
|
||||
if (!player.seek(target)) ctx.seek(target / 1000)
|
||||
}
|
||||
if (!player.play()) ctx.play()
|
||||
} else if (playback.state === 'paused') {
|
||||
if (playback.positionMs > 0) {
|
||||
if (!player.seek(playback.positionMs)) ctx.seek(playback.positionMs / 1000)
|
||||
}
|
||||
if (!player.pause()) ctx.pause()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sync.close()
|
||||
player.release()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 24rpx;
|
||||
}
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
.title {
|
||||
display: block;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.status {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
margin: 8rpx 0 24rpx;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.input,
|
||||
.select {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8rpx;
|
||||
padding: 16rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.primary {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
margin: 24rpx 0;
|
||||
}
|
||||
.section {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 14rpx 0;
|
||||
}
|
||||
.player {
|
||||
width: 100%;
|
||||
height: 420rpx;
|
||||
margin-top: 24rpx;
|
||||
background: #000000;
|
||||
}
|
||||
.native-player {
|
||||
height: 420rpx;
|
||||
margin-top: 24rpx;
|
||||
background: #000000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.native-player-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.error {
|
||||
display: block;
|
||||
color: #dc2626;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
</style>
|
||||
19
server/Dockerfile
Normal file
19
server/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
COPY server/go.mod server/go.sum* ./
|
||||
RUN go mod download
|
||||
|
||||
COPY server/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/synctv-server ./cmd/synctv-server
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/synctv-server /app/synctv-server
|
||||
COPY config/server.json /app/config/server.json
|
||||
|
||||
ENV SYNCTV_CONFIG=/app/config/server.json
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["/app/synctv-server"]
|
||||
77
server/cmd/synctv-server/main.go
Normal file
77
server/cmd/synctv-server/main.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"synctv/server/internal/config"
|
||||
"synctv/server/internal/httpapi"
|
||||
"synctv/server/internal/proxy"
|
||||
"synctv/server/internal/room"
|
||||
"synctv/server/internal/store"
|
||||
"synctv/server/internal/ws"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
redisStore := store.NewRedis(cfg.Redis.Addr, cfg.Redis.Password, cfg.Redis.DB, time.Duration(cfg.Room.EmptyTTL))
|
||||
defer redisStore.Close()
|
||||
if err := redisStore.Ping(ctx); err != nil {
|
||||
log.Fatalf("connect redis: %v", err)
|
||||
}
|
||||
|
||||
roomService := room.NewService(
|
||||
redisStore,
|
||||
cfg.Room.CodeLength,
|
||||
time.Duration(cfg.Room.EmptyTTL),
|
||||
time.Duration(cfg.Room.OwnerReconnectHold),
|
||||
)
|
||||
hub := ws.NewHub(
|
||||
roomService,
|
||||
cfg.HTTP.AllowedOrigins,
|
||||
time.Duration(cfg.WebSocket.PingInterval),
|
||||
time.Duration(cfg.WebSocket.PongTimeout),
|
||||
time.Duration(cfg.WebSocket.WriteTimeout),
|
||||
)
|
||||
streamProxy := proxy.New(
|
||||
roomService,
|
||||
cfg.Proxy.Enabled,
|
||||
time.Duration(cfg.Proxy.RequestTimeout),
|
||||
cfg.Proxy.MaxIdleConns,
|
||||
cfg.Proxy.ResponseHeaderBytes,
|
||||
)
|
||||
api := httpapi.New(roomService, hub, streamProxy)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTP.Addr,
|
||||
Handler: api.Routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("synctv server listening on %s", cfg.HTTP.Addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
13
server/go.mod
Normal file
13
server/go.mod
Normal file
@@ -0,0 +1,13 @@
|
||||
module synctv/server
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/redis/go-redis/v9 v9.17.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
)
|
||||
12
server/go.sum
Normal file
12
server/go.sum
Normal file
@@ -0,0 +1,12 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs=
|
||||
github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
123
server/internal/config/config.go
Normal file
123
server/internal/config/config.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTP HTTPConfig `json:"http"`
|
||||
Redis RedisConfig `json:"redis"`
|
||||
Room RoomConfig `json:"room"`
|
||||
Proxy ProxyConfig `json:"proxy"`
|
||||
WebSocket WebSocketConfig `json:"websocket"`
|
||||
Source SourceConfig `json:"source"`
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
AllowedOrigins []string `json:"allowedOrigins"`
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
Password string `json:"password"`
|
||||
DB int `json:"db"`
|
||||
}
|
||||
|
||||
type RoomConfig struct {
|
||||
CodeLength int `json:"codeLength"`
|
||||
EmptyTTL Duration `json:"emptyTtl"`
|
||||
OwnerReconnectHold Duration `json:"ownerReconnectHold"`
|
||||
}
|
||||
|
||||
type ProxyConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RequestTimeout Duration `json:"requestTimeout"`
|
||||
MaxIdleConns int `json:"maxIdleConns"`
|
||||
ResponseHeaderBytes int64 `json:"responseHeaderBytes"`
|
||||
}
|
||||
|
||||
type WebSocketConfig struct {
|
||||
PingInterval Duration `json:"pingInterval"`
|
||||
PongTimeout Duration `json:"pongTimeout"`
|
||||
WriteTimeout Duration `json:"writeTimeout"`
|
||||
}
|
||||
|
||||
type SourceConfig struct {
|
||||
DefaultMode string `json:"defaultMode"`
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
if path == "" {
|
||||
path = os.Getenv("SYNCTV_CONFIG")
|
||||
}
|
||||
if path == "" {
|
||||
path = "config/config.json"
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{
|
||||
HTTP: HTTPConfig{
|
||||
Addr: ":8080",
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
Addr: "localhost:6379",
|
||||
},
|
||||
Room: RoomConfig{
|
||||
CodeLength: 6,
|
||||
EmptyTTL: Duration(30 * time.Minute),
|
||||
OwnerReconnectHold: Duration(30 * time.Second),
|
||||
},
|
||||
Proxy: ProxyConfig{
|
||||
Enabled: true,
|
||||
RequestTimeout: 0,
|
||||
MaxIdleConns: 256,
|
||||
ResponseHeaderBytes: 1 << 20,
|
||||
},
|
||||
WebSocket: WebSocketConfig{
|
||||
PingInterval: Duration(25 * time.Second),
|
||||
PongTimeout: Duration(60 * time.Second),
|
||||
WriteTimeout: Duration(10 * time.Second),
|
||||
},
|
||||
Source: SourceConfig{
|
||||
DefaultMode: "direct",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.HTTP.Addr == "" {
|
||||
return errors.New("http.addr is required")
|
||||
}
|
||||
if c.Redis.Addr == "" {
|
||||
return errors.New("redis.addr is required")
|
||||
}
|
||||
if c.Room.CodeLength < 4 {
|
||||
return errors.New("room.codeLength must be at least 4")
|
||||
}
|
||||
if c.Room.EmptyTTL <= 0 {
|
||||
return errors.New("room.emptyTtl must be positive")
|
||||
}
|
||||
if c.Source.DefaultMode != "direct" && c.Source.DefaultMode != "proxy" {
|
||||
return errors.New("source.defaultMode must be direct or proxy")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
server/internal/config/duration.go
Normal file
31
server/internal/config/duration.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Duration) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err == nil {
|
||||
v, parseErr := time.ParseDuration(s)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
var n int64
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Duration(time.Duration(n))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Duration) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(time.Duration(d).String())
|
||||
}
|
||||
|
||||
type Duration time.Duration
|
||||
120
server/internal/httpapi/server.go
Normal file
120
server/internal/httpapi/server.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"synctv/server/internal/room"
|
||||
"synctv/server/internal/store"
|
||||
)
|
||||
|
||||
type Rooms interface {
|
||||
Create(ctx context.Context) (room.Room, error)
|
||||
Get(ctx context.Context, code string) (room.Room, error)
|
||||
SetSource(ctx context.Context, code, deviceID string, src room.Source) (room.Room, error)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
rooms Rooms
|
||||
hub http.Handler
|
||||
proxy http.Handler
|
||||
}
|
||||
|
||||
func New(rooms Rooms, hub http.Handler, proxy http.Handler) *Server {
|
||||
return &Server{rooms: rooms, hub: hub, proxy: proxy}
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", s.health)
|
||||
mux.HandleFunc("POST /api/rooms", s.createRoom)
|
||||
mux.HandleFunc("GET /api/rooms/{code}", s.getRoom)
|
||||
mux.HandleFunc("POST /api/rooms/{code}/source", s.setSource)
|
||||
mux.Handle("GET /api/rooms/{code}/stream", s.proxy)
|
||||
mux.Handle("GET /ws", s.hub)
|
||||
return withCORS(mux)
|
||||
}
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) createRoom(w http.ResponseWriter, r *http.Request) {
|
||||
rm, err := s.rooms.Create(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"room": rm})
|
||||
}
|
||||
|
||||
func (s *Server) getRoom(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
deviceID := r.URL.Query().Get("deviceId")
|
||||
rm, err := s.rooms.Get(r.Context(), code)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, room.Snapshot{
|
||||
Room: rm.PublicFor(deviceID),
|
||||
IsOwner: deviceID != "" && rm.OwnerDeviceID == deviceID,
|
||||
DeviceID: deviceID,
|
||||
ServerNow: room.NowMS(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setSource(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
deviceID := r.Header.Get("X-Device-Id")
|
||||
if strings.TrimSpace(deviceID) == "" {
|
||||
writeError(w, http.StatusBadRequest, "X-Device-Id is required")
|
||||
return
|
||||
}
|
||||
var src room.Source
|
||||
if err := json.NewDecoder(r.Body).Decode(&src); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
rm, err := s.rooms.SetSource(r.Context(), code, deviceID, src)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, room.Snapshot{
|
||||
Room: rm.PublicFor(deviceID),
|
||||
IsOwner: rm.OwnerDeviceID == deviceID,
|
||||
DeviceID: deviceID,
|
||||
ServerNow: room.NowMS(),
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]any{"error": message})
|
||||
}
|
||||
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Device-Id")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
112
server/internal/proxy/proxy.go
Normal file
112
server/internal/proxy/proxy.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"synctv/server/internal/room"
|
||||
)
|
||||
|
||||
type Rooms interface {
|
||||
Get(ctx context.Context, code string) (room.Room, error)
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
rooms Rooms
|
||||
enabled bool
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(rooms Rooms, enabled bool, timeout time.Duration, maxIdleConns int, responseHeaderBytes int64) *Proxy {
|
||||
return &Proxy{
|
||||
rooms: rooms,
|
||||
enabled: enabled,
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxIdleConns: maxIdleConns,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
MaxResponseHeaderBytes: responseHeaderBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if !p.enabled {
|
||||
http.Error(w, "proxy is disabled", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
code := r.PathValue("code")
|
||||
rm, err := p.rooms.Get(r.Context(), code)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if rm.Source == nil || rm.Source.Mode != room.SourceModeProxy {
|
||||
http.Error(w, "room source is not in proxy mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := p.stream(w, r, *rm.Source); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) stream(w http.ResponseWriter, r *http.Request, src room.Source) error {
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, src.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
copyRequestHeader(req.Header, r.Header, "Range", "User-Agent", "Accept", "Accept-Encoding")
|
||||
for k, v := range src.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if src.Username != "" || src.Password != "" {
|
||||
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(src.Username+":"+src.Password)))
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
copyResponseHeaders(w.Header(), resp.Header)
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, err = io.Copy(w, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyRequestHeader(dst, src http.Header, keys ...string) {
|
||||
for _, key := range keys {
|
||||
value := src.Get(key)
|
||||
if strings.TrimSpace(value) != "" {
|
||||
dst.Set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyResponseHeaders(dst, src http.Header) {
|
||||
for _, key := range []string{
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Content-Range",
|
||||
"Accept-Ranges",
|
||||
"Cache-Control",
|
||||
"Last-Modified",
|
||||
"ETag",
|
||||
} {
|
||||
for _, value := range src.Values(key) {
|
||||
dst.Add(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
80
server/internal/room/model.go
Normal file
80
server/internal/room/model.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package room
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
PlaybackIdle = "idle"
|
||||
PlaybackPlaying = "playing"
|
||||
PlaybackPaused = "paused"
|
||||
|
||||
SourceModeDirect = "direct"
|
||||
SourceModeProxy = "proxy"
|
||||
)
|
||||
|
||||
type Room struct {
|
||||
Code string `json:"code"`
|
||||
OwnerDeviceID string `json:"ownerDeviceId,omitempty"`
|
||||
OwnerLastSeenAt int64 `json:"ownerLastSeenAt,omitempty"`
|
||||
OnlineCount int `json:"onlineCount"`
|
||||
Source *Source `json:"source,omitempty"`
|
||||
Playback Playback `json:"playback"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
Type string `json:"type"`
|
||||
Mode string `json:"mode"`
|
||||
URL string `json:"url"`
|
||||
IsLive bool `json:"isLive"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
ProxyPath string `json:"proxyPath,omitempty"`
|
||||
}
|
||||
|
||||
type Playback struct {
|
||||
State string `json:"state"`
|
||||
PositionMS int64 `json:"positionMs"`
|
||||
ServerUpdatedAt int64 `json:"serverUpdatedAt"`
|
||||
TargetLatencyMS int64 `json:"targetLatencyMs,omitempty"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Room Room `json:"room"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
ServerNow int64 `json:"serverNow"`
|
||||
}
|
||||
|
||||
func New(code string, ttl time.Duration) Room {
|
||||
now := time.Now()
|
||||
return Room{
|
||||
Code: code,
|
||||
Playback: Playback{State: PlaybackIdle, ServerUpdatedAt: now.UnixMilli()},
|
||||
CreatedAt: now.UnixMilli(),
|
||||
UpdatedAt: now.UnixMilli(),
|
||||
ExpiresAt: now.Add(ttl).UnixMilli(),
|
||||
OnlineCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (r Room) PublicFor(deviceID string) Room {
|
||||
out := r
|
||||
if out.Source != nil {
|
||||
src := *out.Source
|
||||
if src.Mode == SourceModeProxy {
|
||||
src.URL = src.ProxyPath
|
||||
src.Headers = nil
|
||||
src.Username = ""
|
||||
src.Password = ""
|
||||
}
|
||||
out.Source = &src
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NowMS() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
139
server/internal/room/service.go
Normal file
139
server/internal/room/service.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package room
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
type Store interface {
|
||||
CreateRoom(ctx context.Context, r Room) error
|
||||
GetRoom(ctx context.Context, code string) (Room, error)
|
||||
SaveRoom(ctx context.Context, r Room) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
codeLength int
|
||||
emptyTTL time.Duration
|
||||
ownerReconnectHold time.Duration
|
||||
}
|
||||
|
||||
func NewService(st Store, codeLength int, emptyTTL, ownerReconnectHold time.Duration) *Service {
|
||||
return &Service{
|
||||
store: st,
|
||||
codeLength: codeLength,
|
||||
emptyTTL: emptyTTL,
|
||||
ownerReconnectHold: ownerReconnectHold,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context) (Room, error) {
|
||||
for i := 0; i < 10; i++ {
|
||||
code, err := randomCode(s.codeLength)
|
||||
if err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
r := New(code, s.emptyTTL)
|
||||
if err := s.store.CreateRoom(ctx, r); err == nil {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
return Room{}, errors.New("failed to allocate room code")
|
||||
}
|
||||
|
||||
func (s *Service) Join(ctx context.Context, code, deviceID string, onlineCount int) (Room, bool, error) {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
if code == "" || deviceID == "" {
|
||||
return Room{}, false, errors.New("room code and device id are required")
|
||||
}
|
||||
|
||||
r, err := s.store.GetRoom(ctx, code)
|
||||
if err != nil {
|
||||
return Room{}, false, err
|
||||
}
|
||||
now := NowMS()
|
||||
if r.OwnerDeviceID == "" {
|
||||
r.OwnerDeviceID = deviceID
|
||||
}
|
||||
if r.OwnerDeviceID == deviceID {
|
||||
r.OwnerLastSeenAt = now
|
||||
}
|
||||
r.OnlineCount = onlineCount
|
||||
if err := s.store.SaveRoom(ctx, r); err != nil {
|
||||
return Room{}, false, err
|
||||
}
|
||||
return r, r.OwnerDeviceID == deviceID, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, code string) (Room, error) {
|
||||
return s.store.GetRoom(ctx, strings.ToUpper(strings.TrimSpace(code)))
|
||||
}
|
||||
|
||||
func (s *Service) SetOnlineCount(ctx context.Context, code string, count int) error {
|
||||
r, err := s.Get(ctx, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.OnlineCount = count
|
||||
return s.store.SaveRoom(ctx, r)
|
||||
}
|
||||
|
||||
func (s *Service) SetSource(ctx context.Context, code, deviceID string, src Source) (Room, error) {
|
||||
r, err := s.Get(ctx, code)
|
||||
if err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
if r.OwnerDeviceID != deviceID {
|
||||
return Room{}, errors.New("only room owner can configure source")
|
||||
}
|
||||
if src.Mode == "" {
|
||||
src.Mode = SourceModeDirect
|
||||
}
|
||||
if src.Mode != SourceModeDirect && src.Mode != SourceModeProxy {
|
||||
return Room{}, errors.New("source mode must be direct or proxy")
|
||||
}
|
||||
if strings.TrimSpace(src.URL) == "" {
|
||||
return Room{}, errors.New("source url is required")
|
||||
}
|
||||
if src.Mode == SourceModeProxy {
|
||||
src.ProxyPath = "/api/rooms/" + r.Code + "/stream"
|
||||
}
|
||||
r.Source = &src
|
||||
r.Playback = Playback{State: PlaybackIdle, ServerUpdatedAt: NowMS()}
|
||||
if err := s.store.SaveRoom(ctx, r); err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdatePlayback(ctx context.Context, code string, p Playback) (Room, error) {
|
||||
r, err := s.Get(ctx, code)
|
||||
if err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
p.ServerUpdatedAt = NowMS()
|
||||
r.Playback = p
|
||||
if err := s.store.SaveRoom(ctx, r); err != nil {
|
||||
return Room{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func randomCode(n int) (string, error) {
|
||||
var b strings.Builder
|
||||
b.Grow(n)
|
||||
for i := 0; i < n; i++ {
|
||||
x, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteByte(alphabet[x.Int64()])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
82
server/internal/store/redis.go
Normal file
82
server/internal/store/redis.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"synctv/server/internal/room"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("room not found")
|
||||
|
||||
type RedisStore struct {
|
||||
client *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewRedis(addr, password string, db int, ttl time.Duration) *RedisStore {
|
||||
return &RedisStore{
|
||||
client: redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
DB: db,
|
||||
}),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RedisStore) Ping(ctx context.Context) error {
|
||||
return s.client.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
func (s *RedisStore) Close() error {
|
||||
return s.client.Close()
|
||||
}
|
||||
|
||||
func (s *RedisStore) GetRoom(ctx context.Context, code string) (room.Room, error) {
|
||||
b, err := s.client.Get(ctx, roomKey(code)).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return room.Room{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return room.Room{}, err
|
||||
}
|
||||
var r room.Room
|
||||
if err := json.Unmarshal(b, &r); err != nil {
|
||||
return room.Room{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *RedisStore) SaveRoom(ctx context.Context, r room.Room) error {
|
||||
r.UpdatedAt = room.NowMS()
|
||||
r.ExpiresAt = time.Now().Add(s.ttl).UnixMilli()
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.client.Set(ctx, roomKey(r.Code), b, s.ttl).Err()
|
||||
}
|
||||
|
||||
func (s *RedisStore) CreateRoom(ctx context.Context, r room.Room) error {
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok, err := s.client.SetNX(ctx, roomKey(r.Code), b, s.ttl).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("room code already exists")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func roomKey(code string) string {
|
||||
return fmt.Sprintf("room:%s", code)
|
||||
}
|
||||
285
server/internal/ws/hub.go
Normal file
285
server/internal/ws/hub.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"synctv/server/internal/room"
|
||||
)
|
||||
|
||||
type RoomService interface {
|
||||
Join(ctx context.Context, code, deviceID string, onlineCount int) (room.Room, bool, error)
|
||||
SetOnlineCount(ctx context.Context, code string, count int) error
|
||||
SetSource(ctx context.Context, code, deviceID string, src room.Source) (room.Room, error)
|
||||
UpdatePlayback(ctx context.Context, code string, p room.Playback) (room.Room, error)
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
service RoomService
|
||||
upgrader websocket.Upgrader
|
||||
pingInterval time.Duration
|
||||
pongTimeout time.Duration
|
||||
writeTimeout time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
rooms map[string]map[*Client]struct{}
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
roomCode string
|
||||
deviceID string
|
||||
send chan any
|
||||
}
|
||||
|
||||
func NewHub(service RoomService, allowedOrigins []string, pingInterval, pongTimeout, writeTimeout time.Duration) *Hub {
|
||||
originAllowed := map[string]struct{}{}
|
||||
allowAll := false
|
||||
for _, origin := range allowedOrigins {
|
||||
if origin == "*" {
|
||||
allowAll = true
|
||||
break
|
||||
}
|
||||
originAllowed[origin] = struct{}{}
|
||||
}
|
||||
|
||||
return &Hub{
|
||||
service: service,
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
if allowAll {
|
||||
return true
|
||||
}
|
||||
_, ok := originAllowed[r.Header.Get("Origin")]
|
||||
return ok
|
||||
},
|
||||
},
|
||||
pingInterval: pingInterval,
|
||||
pongTimeout: pongTimeout,
|
||||
writeTimeout: writeTimeout,
|
||||
rooms: make(map[string]map[*Client]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
roomCode := r.URL.Query().Get("roomCode")
|
||||
deviceID := r.URL.Query().Get("deviceId")
|
||||
if roomCode == "" || deviceID == "" {
|
||||
http.Error(w, "roomCode and deviceId are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := h.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
roomCode: roomCode,
|
||||
deviceID: deviceID,
|
||||
send: make(chan any, 32),
|
||||
}
|
||||
|
||||
h.register(c)
|
||||
count := h.count(roomCode)
|
||||
rm, isOwner, err := h.service.Join(r.Context(), roomCode, deviceID, count)
|
||||
if err != nil {
|
||||
c.writeJSON(map[string]any{"type": "error", "message": err.Error()})
|
||||
h.unregister(c)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
c.send <- map[string]any{
|
||||
"type": "roomSnapshot",
|
||||
"payload": room.Snapshot{
|
||||
Room: rm.PublicFor(deviceID),
|
||||
IsOwner: isOwner,
|
||||
DeviceID: deviceID,
|
||||
ServerNow: room.NowMS(),
|
||||
},
|
||||
}
|
||||
h.broadcast(roomCode, map[string]any{"type": "presenceChanged", "payload": map[string]any{"onlineCount": count}}, c)
|
||||
|
||||
go c.writePump()
|
||||
go c.readPump()
|
||||
}
|
||||
|
||||
func (h *Hub) register(c *Client) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.rooms[c.roomCode] == nil {
|
||||
h.rooms[c.roomCode] = make(map[*Client]struct{})
|
||||
}
|
||||
h.rooms[c.roomCode][c] = struct{}{}
|
||||
}
|
||||
|
||||
func (h *Hub) unregister(c *Client) {
|
||||
h.mu.Lock()
|
||||
if clients := h.rooms[c.roomCode]; clients != nil {
|
||||
delete(clients, c)
|
||||
if len(clients) == 0 {
|
||||
delete(h.rooms, c.roomCode)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
close(c.send)
|
||||
|
||||
count := h.count(c.roomCode)
|
||||
if err := h.service.SetOnlineCount(context.Background(), c.roomCode, count); err != nil {
|
||||
log.Printf("update online count: %v", err)
|
||||
}
|
||||
h.broadcast(c.roomCode, map[string]any{"type": "presenceChanged", "payload": map[string]any{"onlineCount": count}}, nil)
|
||||
}
|
||||
|
||||
func (h *Hub) count(roomCode string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.rooms[roomCode])
|
||||
}
|
||||
|
||||
func (h *Hub) broadcast(roomCode string, msg any, except *Client) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.rooms[roomCode] {
|
||||
if c == except {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case c.send <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.unregister(c)
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
c.conn.SetReadLimit(1 << 20)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.hub.pongTimeout))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
return c.conn.SetReadDeadline(time.Now().Add(c.hub.pongTimeout))
|
||||
})
|
||||
|
||||
for {
|
||||
var event Event
|
||||
if err := c.conn.ReadJSON(&event); err != nil {
|
||||
return
|
||||
}
|
||||
c.handle(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(c.hub.pingInterval)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.writeTimeout))
|
||||
if !ok {
|
||||
_ = c.conn.WriteMessage(websocket.CloseMessage, nil)
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteJSON(msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.writeTimeout))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writeJSON(v any) {
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.writeTimeout))
|
||||
_ = c.conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
func (c *Client) handle(event Event) {
|
||||
switch event.Type {
|
||||
case "setSource":
|
||||
var src room.Source
|
||||
if err := json.Unmarshal(event.Payload, &src); err != nil {
|
||||
c.sendError(err)
|
||||
return
|
||||
}
|
||||
rm, err := c.hub.service.SetSource(context.Background(), c.roomCode, c.deviceID, src)
|
||||
if err != nil {
|
||||
c.sendError(err)
|
||||
return
|
||||
}
|
||||
c.hub.broadcastSnapshot(c.roomCode, "sourceChanged", rm)
|
||||
case "play", "pause", "seek", "syncProgress", "syncToLive":
|
||||
var p room.Playback
|
||||
if err := json.Unmarshal(event.Payload, &p); err != nil {
|
||||
c.sendError(err)
|
||||
return
|
||||
}
|
||||
if event.Type == "play" {
|
||||
p.State = room.PlaybackPlaying
|
||||
}
|
||||
if event.Type == "pause" {
|
||||
p.State = room.PlaybackPaused
|
||||
}
|
||||
rm, err := c.hub.service.UpdatePlayback(context.Background(), c.roomCode, p)
|
||||
if err != nil {
|
||||
c.sendError(err)
|
||||
return
|
||||
}
|
||||
c.hub.broadcast(c.roomCode, map[string]any{
|
||||
"type": "playbackChanged",
|
||||
"payload": map[string]any{
|
||||
"playback": rm.Playback,
|
||||
"serverNow": room.NowMS(),
|
||||
},
|
||||
}, nil)
|
||||
case "heartbeat":
|
||||
c.send <- map[string]any{"type": "heartbeatAck", "payload": map[string]any{"serverNow": room.NowMS()}}
|
||||
default:
|
||||
c.sendErrorString("unknown event type: " + event.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) sendError(err error) {
|
||||
c.sendErrorString(err.Error())
|
||||
}
|
||||
|
||||
func (c *Client) sendErrorString(message string) {
|
||||
c.send <- map[string]any{"type": "error", "payload": map[string]any{"message": message}}
|
||||
}
|
||||
|
||||
func (h *Hub) broadcastSnapshot(roomCode, eventType string, rm room.Room) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.rooms[roomCode] {
|
||||
c.send <- map[string]any{
|
||||
"type": eventType,
|
||||
"payload": room.Snapshot{
|
||||
Room: rm.PublicFor(c.deviceID),
|
||||
IsOwner: rm.OwnerDeviceID == c.deviceID,
|
||||
DeviceID: c.deviceID,
|
||||
ServerNow: room.NowMS(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
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