package handlers import ( "encoding/json" "fmt" "net/http" "sync" "time" "github.com/gin-gonic/gin" ) type OverlayState struct { Title string `json:"title"` Artist string `json:"artist"` Cover string `json:"cover"` IsPlaying bool `json:"is_playing"` Progress float64 `json:"progress"` Duration float64 `json:"duration"` Enabled bool `json:"enabled"` Design string `json:"design"` Style string `json:"style"` AccentColor string `json:"accent_color"` Position string `json:"position"` Font string `json:"font"` TextColor string `json:"text_color"` ShowCover bool `json:"show_cover"` ShowEq bool `json:"show_eq"` Palette string `json:"palette"` CustomBg string `json:"custom_bg"` CustomText string `json:"custom_text"` CustomText2 string `json:"custom_text2"` CustomChroma string `json:"custom_chroma"` CustomTitleBg string `json:"custom_title_bg"` CustomBodyBg string `json:"custom_body_bg"` Margin float64 `json:"margin"` Scale float64 `json:"scale"` Opacity float64 `json:"opacity"` UpdatedAt int64 `json:"updated_at"` } type overlayEntry struct { mu sync.RWMutex state OverlayState } var overlayMap sync.Map // userID -> *overlayEntry // ── SSE hub ─────────────────────────────────────────────────────────────────── type sseHub struct { mu sync.RWMutex clients map[chan OverlayState]struct{} } var sseHubs sync.Map // userID -> *sseHub func getOrCreateOverlayEntry(userID string) *overlayEntry { v, _ := overlayMap.LoadOrStore(userID, &overlayEntry{ state: OverlayState{Design: "minimal", Enabled: true, Scale: 1, Opacity: 1, Margin: 24}, }) return v.(*overlayEntry) } func getOrCreateHub(userID string) *sseHub { v, _ := sseHubs.LoadOrStore(userID, &sseHub{ clients: make(map[chan OverlayState]struct{}), }) return v.(*sseHub) } func broadcastOverlay(userID string, state OverlayState) { hub, ok := sseHubs.Load(userID) if !ok { return } h := hub.(*sseHub) h.mu.RLock() defer h.mu.RUnlock() for ch := range h.clients { select { case ch <- state: default: } } } // PUT /api/overlay/state (requires auth) func PushOverlayState(c *gin.Context) { userID := currentUserID(c) var state OverlayState if err := c.ShouldBindJSON(&state); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } state.UpdatedAt = time.Now().UnixMilli() entry := getOrCreateOverlayEntry(userID) entry.mu.Lock() entry.state = state entry.mu.Unlock() broadcastOverlay(userID, state) c.JSON(http.StatusOK, gin.H{"ok": true}) } // GET /api/overlay/:token/state (public, fallback polling) func GetOverlayState(c *gin.Context) { token := c.Param("token") v, ok := overlayMap.Load(token) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } entry := v.(*overlayEntry) entry.mu.RLock() state := entry.state entry.mu.RUnlock() c.JSON(http.StatusOK, state) } // GET /api/overlay/:token/stream (SSE, public) func StreamOverlayState(c *gin.Context) { token := c.Param("token") c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") // Send current state immediately if v, ok := overlayMap.Load(token); ok { entry := v.(*overlayEntry) entry.mu.RLock() state := entry.state entry.mu.RUnlock() data, _ := json.Marshal(state) fmt.Fprintf(c.Writer, "data: %s\n\n", data) } else { fmt.Fprintf(c.Writer, "event: notfound\ndata: {}\n\n") } c.Writer.Flush() ch := make(chan OverlayState, 8) hub := getOrCreateHub(token) hub.mu.Lock() hub.clients[ch] = struct{}{} hub.mu.Unlock() defer func() { hub.mu.Lock() delete(hub.clients, ch) hub.mu.Unlock() close(ch) }() ctx := c.Request.Context() ticker := time.NewTicker(25 * time.Second) defer ticker.Stop() for { select { case state, ok := <-ch: if !ok { return } data, _ := json.Marshal(state) fmt.Fprintf(c.Writer, "data: %s\n\n", data) c.Writer.Flush() case <-ticker.C: fmt.Fprintf(c.Writer, ": keepalive\n\n") c.Writer.Flush() case <-ctx.Done(): return } } }