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

View File

@@ -0,0 +1,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(),
},
}
}
}