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

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.idea
client_flutter/build
server/bin
**/.dart_tool
**/.packages
**/node_modules

24
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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房间创建、加入、房主判定、房间快照、过期清理。
- SyncGatewayWebSocket 连接、播放控制广播、心跳、重连。
- 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创建房间、加入房间、房间状态。
- SocketClientWebSocket 连接、重连、事件收发。
- PlayerController播放、暂停、seek、倍速、直播控制。
- SyncEngine进度计算、误差校正、直播延迟同步。
- SourceManager播放源初始化、凭据处理、URL 刷新。
- LocalDeviceStore本地 deviceId 和 lastRoomCode。
## 10. 后端架构
后端第一版建议保持单体服务,不拆微服务。
最小结构:
- HTTP API创建房间、加入房间、配置播放源、获取房间快照。
- WebSocket Gateway播放、暂停、seek、直播同步、心跳、断线重连。
- Redis Store房间状态、连接状态、播放源配置、过期时间。
- Stream Proxy可选代理入口只有房主开启代理模式时使用。
代码模块:
- RoomService房间创建、房间加入、房主判定、快照维护。
- PlaybackService播放状态写入和广播。
- SourceService播放源保存、直连信息下发、代理 URL 生成。
- StreamProxyServiceWebDAV/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
View 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>

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

View 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
}

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

View 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
View File

@@ -0,0 +1,7 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
return { app }
}

View 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"
}

View 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
View File

@@ -0,0 +1,16 @@
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "SyncTV"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "SyncTV",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f8fafc"
}
}

View 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
View 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"]

View 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
View 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
View 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=

View 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
}

View 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

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

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

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

View 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
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,269 @@
from __future__ import annotations
import base64
import sys
import time
import vlc
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QFormLayout,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QPushButton,
QSplitter,
QVBoxLayout,
QWidget,
)
from synctv_client.api_client import ApiClient
from synctv_client.device_store import DeviceStore
from synctv_client.models import PlaybackInfo, RoomSnapshot, SourceInfo
from synctv_client.sync_client import SyncClient
class MainWindow(QMainWindow):
event_received = Signal(str, dict)
error_received = Signal(str)
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("SyncTV")
self.resize(1180, 760)
self.device_id = DeviceStore().get_or_create()
self.snapshot: RoomSnapshot | None = None
self.api: ApiClient | None = None
self.sync = SyncClient(self.event_received.emit, self.error_received.emit)
self.vlc_instance = vlc.Instance()
self.player = self.vlc_instance.media_player_new()
self._build_ui()
self.event_received.connect(self._handle_event)
self.error_received.connect(self._set_error)
self.progress_timer = QTimer(self)
self.progress_timer.setInterval(3000)
self.progress_timer.timeout.connect(self._sync_progress)
self.progress_timer.start()
def _build_ui(self) -> None:
root = QSplitter(Qt.Orientation.Horizontal)
root.setChildrenCollapsible(False)
self.setCentralWidget(root)
side = QWidget()
side.setMinimumWidth(360)
side.setMaximumWidth(420)
side_layout = QVBoxLayout(side)
title = QLabel("SyncTV")
title.setStyleSheet("font-size: 28px; font-weight: 600;")
side_layout.addWidget(title)
self.status_label = QLabel("Not connected")
self.status_label.setStyleSheet("color: #64748b;")
side_layout.addWidget(self.status_label)
form = QFormLayout()
self.server_input = QLineEdit("http://yuyun-us1.stormrain.cn:8088")
self.room_input = QLineEdit()
self.room_input.setPlaceholderText("A7K29Q")
form.addRow("Server", self.server_input)
form.addRow("Room Code", self.room_input)
side_layout.addLayout(form)
row = QHBoxLayout()
create_btn = QPushButton("Create")
join_btn = QPushButton("Join")
create_btn.clicked.connect(self._create_room)
join_btn.clicked.connect(self._join_room)
row.addWidget(create_btn)
row.addWidget(join_btn)
side_layout.addLayout(row)
side_layout.addSpacing(16)
side_layout.addWidget(QLabel("Source"))
self.source_type = QComboBox()
self.source_type.addItems(["url", "oss", "webdav", "live"])
self.source_mode = QComboBox()
self.source_mode.addItems(["direct", "proxy"])
self.source_url = QLineEdit()
self.username = QLineEdit()
self.password = QLineEdit()
self.password.setEchoMode(QLineEdit.EchoMode.Password)
self.is_live = QCheckBox("Live stream")
source_form = QFormLayout()
source_form.addRow("Type", self.source_type)
source_form.addRow("Connection", self.source_mode)
source_form.addRow("URL", self.source_url)
source_form.addRow("Username", self.username)
source_form.addRow("Password", self.password)
source_form.addRow("", self.is_live)
side_layout.addLayout(source_form)
self.apply_source_btn = QPushButton("Apply Source")
self.apply_source_btn.setEnabled(False)
self.apply_source_btn.clicked.connect(self._apply_source)
side_layout.addWidget(self.apply_source_btn)
side_layout.addSpacing(16)
control_row = QHBoxLayout()
play_btn = QPushButton("Play")
pause_btn = QPushButton("Pause")
play_btn.clicked.connect(self._play)
pause_btn.clicked.connect(self._pause)
control_row.addWidget(play_btn)
control_row.addWidget(pause_btn)
side_layout.addLayout(control_row)
live_btn = QPushButton("Back To Live")
live_btn.clicked.connect(lambda: self.sync.sync_to_live())
side_layout.addWidget(live_btn)
self.error_label = QLabel()
self.error_label.setWordWrap(True)
self.error_label.setStyleSheet("color: #dc2626;")
side_layout.addWidget(self.error_label)
side_layout.addStretch(1)
self.video_frame = QFrame()
self.video_frame.setStyleSheet("background: black;")
root.addWidget(side)
root.addWidget(self.video_frame)
root.setSizes([380, 800])
def showEvent(self, event) -> None: # type: ignore[override]
super().showEvent(event)
self.player.set_hwnd(int(self.video_frame.winId()))
def _create_room(self) -> None:
try:
self.api = ApiClient(self.server_input.text())
room = self.api.create_room()
self.room_input.setText(room.code)
self._join_room()
except Exception as exc:
self._set_error(str(exc))
def _join_room(self) -> None:
try:
self.api = ApiClient(self.server_input.text())
code = self.room_input.text().strip().upper()
snapshot = self.api.get_room(code, self.device_id)
self._apply_snapshot(snapshot)
self.sync.connect(self.server_input.text(), code, self.device_id)
self._open_source(snapshot.room.source)
self._apply_playback(snapshot.room.playback, snapshot.server_now)
except Exception as exc:
self._set_error(str(exc))
def _apply_source(self) -> None:
source = SourceInfo(
type=self.source_type.currentText(),
mode=self.source_mode.currentText(),
url=self.source_url.text().strip(),
is_live=self.is_live.isChecked() or self.source_type.currentText() == "live",
username=self.username.text().strip(),
password=self.password.text(),
)
self.sync.set_source(source.to_json())
def _play(self) -> None:
position = max(0, self.player.get_time())
self.sync.play(position)
self.player.play()
def _pause(self) -> None:
position = max(0, self.player.get_time())
self.sync.pause(position)
self.player.pause()
def _handle_event(self, event_type: str, payload: dict) -> None:
try:
if event_type in {"roomSnapshot", "sourceChanged"}:
snapshot = RoomSnapshot.from_json(payload)
self._apply_snapshot(snapshot)
self._open_source(snapshot.room.source)
self._apply_playback(snapshot.room.playback, snapshot.server_now)
elif event_type == "playbackChanged":
playback = PlaybackInfo.from_json(payload.get("playback"))
self._apply_playback(playback, int(payload.get("serverNow", int(time.time() * 1000))))
elif event_type == "presenceChanged" and self.snapshot:
count = payload.get("onlineCount", self.snapshot.room.online_count)
self.status_label.setText(
f"{self.snapshot.room.code} · {'Owner' if self.snapshot.is_owner else 'Member'} · {count} online"
)
elif event_type == "error":
self._set_error(payload.get("message", "unknown error"))
except Exception as exc:
self._set_error(str(exc))
def _apply_snapshot(self, snapshot: RoomSnapshot) -> None:
self.snapshot = snapshot
self.room_input.setText(snapshot.room.code)
self.apply_source_btn.setEnabled(snapshot.is_owner)
self.status_label.setText(
f"{snapshot.room.code} · {'Owner' if snapshot.is_owner else 'Member'} · {snapshot.room.online_count} online"
)
if snapshot.room.source:
self.source_url.setText(snapshot.room.source.url)
self.is_live.setChecked(snapshot.room.source.is_live)
self.source_type.setCurrentText(snapshot.room.source.type)
self.source_mode.setCurrentText(snapshot.room.source.mode)
def _open_source(self, source: SourceInfo | None) -> None:
if not source or not source.url:
return
url = source.url
if url.startswith("/api/"):
url = self.server_input.text().rstrip("/") + url
media = self.vlc_instance.media_new(url)
if source.username or source.password:
auth = base64.b64encode(f"{source.username}:{source.password}".encode()).decode()
media.add_option(f":http-header=Authorization: Basic {auth}")
media.add_option(f":http-user={source.username}")
media.add_option(f":http-pwd={source.password}")
self.player.set_media(media)
def _apply_playback(self, playback: PlaybackInfo, server_now: int) -> None:
if playback.state == "playing":
target = playback.position_ms + max(0, server_now - playback.server_updated_at)
if target > 0:
self.player.set_time(target)
self.player.play()
elif playback.state == "paused":
if playback.position_ms > 0:
self.player.set_time(playback.position_ms)
self.player.pause()
def _sync_progress(self) -> None:
# The Go server accepts progress events, but first version keeps this conservative.
pass
def _set_error(self, message: str) -> None:
self.error_label.setText(message)
def closeEvent(self, event) -> None: # type: ignore[override]
self.sync.close()
self.player.stop()
super().closeEvent(event)
def main() -> int:
app = QApplication(sys.argv)
window = MainWindow()
window.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())

View File

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

View File

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