// ABOUTME: ServerClient represents one accepted connection on a sendspin Server // ABOUTME: Exposes ID/Name/Roles/Send surface for the Group/GroupRole layer package sendspin import ( "encoding/json" "fmt" "log" "strings" "sync" "time" "github.com/Sendspin/sendspin-go/internal/server" "github.com/Sendspin/sendspin-go/pkg/audio" "github.com/Sendspin/sendspin-go/pkg/protocol" "github.com/gorilla/websocket" ) // ServerClient represents one accepted WebSocket connection on a Server. // It owns the client's negotiated roles, capabilities, playback state, // per-codec encoder state, and the outbound send channel. // // Exposed accessors (ID, Name, Roles, HasRole, Send, SendBinary) are the // stable surface the future Group/GroupRole layer depends on. Internal // fields remain unexported so the server package can mutate them through // the mutex without leaking that detail to callers. type ServerClient struct { id string name string conn *websocket.Conn roles []string capabilities *protocol.PlayerV1Support state string volume int muted bool codec string opusEncoder *server.OpusEncoder flacEncoder *server.FLACEncoder resampler *audio.Resampler // non-nil only when source rate != 48kHz bufferTracker *BufferTracker sendChan chan interface{} done chan struct{} mu sync.RWMutex } // ID returns the client-supplied unique identifier from client/hello. func (c *ServerClient) ID() string { return c.id } // Name returns the human-friendly name from client/hello. func (c *ServerClient) Name() string { return c.name } // Roles returns the client's advertised role list as a fresh slice. // Callers may mutate the returned slice without affecting the ServerClient. func (c *ServerClient) Roles() []string { out := make([]string, len(c.roles)) copy(out, c.roles) return out } // HasRole reports whether this client advertised a given role family. // It matches both exact ("player") and versioned ("player@v1") forms so // callers can query by family without tracking versions. func (c *ServerClient) HasRole(role string) bool { for _, r := range c.roles { if r == role || strings.HasPrefix(r, role+"@") { return true } } return false } // Send enqueues a typed control message for transmission. Returns an // error immediately if the client's send buffer is full rather than // blocking — callers decide whether to drop or disconnect. // // A nil return means the message was enqueued, not that it was delivered: // after the client's writer goroutine has exited (e.g., post-disconnect), // enqueued messages are dropped silently. Callers should treat this as // best-effort once they've observed a client leaving. func (c *ServerClient) Send(msgType string, payload interface{}) error { msg := protocol.Message{ Type: msgType, Payload: payload, } select { case c.sendChan <- msg: return nil default: return fmt.Errorf("client send buffer full") } } // SendBinary enqueues a raw binary frame (e.g., an audio chunk) for // transmission. Same non-blocking semantics as Send. // // A nil return means the frame was enqueued, not that it was delivered: // after the client's writer goroutine has exited (e.g., post-disconnect), // enqueued frames are dropped silently. Callers should treat this as // best-effort once they've observed a client leaving. func (c *ServerClient) SendBinary(data []byte) error { select { case c.sendChan <- data: return nil default: return fmt.Errorf("client send buffer full (depth=%d, cap=%d)", len(c.sendChan), cap(c.sendChan)) } } // State returns the client's current playback state ("synchronized", // "playing", "paused"). Safe to call concurrently with state updates. func (c *ServerClient) State() string { c.mu.RLock() defer c.mu.RUnlock() return c.state } // Volume returns the client's reported volume (0-100). Safe to call // concurrently with state updates. func (c *ServerClient) Volume() int { c.mu.RLock() defer c.mu.RUnlock() return c.volume } // Muted returns the client's mute state. Safe to call concurrently with // state updates. func (c *ServerClient) Muted() bool { c.mu.RLock() defer c.mu.RUnlock() return c.muted } // Codec returns the currently-negotiated codec name ("pcm", "opus", ""). // Returns the empty string before stream negotiation completes. Safe to // call concurrently with state updates. func (c *ServerClient) Codec() string { c.mu.RLock() defer c.mu.RUnlock() return c.codec } // NewServerClientFromConn wraps an existing WebSocket connection in a // ServerClient and starts a background writer goroutine. This is the // entry point for code that accepts its own WebSocket connections // (e.g., conformance adapters) and wants to use the typed Send/SendBinary // API without constructing a full Server. // // The caller is responsible for reading from the connection; the writer // goroutine handles outbound messages via Send/SendBinary. Call Close() // when done to stop the writer and release resources. func NewServerClientFromConn(conn *websocket.Conn, id, name string, roles []string, capabilities *protocol.PlayerV1Support) *ServerClient { c := &ServerClient{ id: id, name: name, conn: conn, roles: roles, capabilities: capabilities, state: "synchronized", volume: 100, sendChan: make(chan interface{}, 100), done: make(chan struct{}), } go c.runWriter() return c } // runWriter drains the sendChan and writes messages to the WebSocket. // Exits when the done channel is closed (via Close). This is the // standalone equivalent of Server.clientWriter for ServerClients // created via NewServerClientFromConn. func (c *ServerClient) runWriter() { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() const writeDeadline = 10 * time.Second exit := func(reason string, err error) { log.Printf("runWriter %s exiting: %s: %v (queue depth %d)", c.name, reason, err, len(c.sendChan)) } for { select { case msg := <-c.sendChan: queueDepth := len(c.sendChan) switch v := msg.(type) { case []byte: c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)) wStart := time.Now() if err := c.conn.WriteMessage(websocket.BinaryMessage, v); err != nil { exit("binary write", err) return } wDur := time.Since(wStart) if wDur > 5*time.Millisecond || queueDepth > 5 { log.Printf("ws write %s: %s for %d bytes (queue depth %d)", c.name, wDur.Round(time.Microsecond), len(v), queueDepth) } default: data, err := json.Marshal(v) if err != nil { continue } c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)) if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { exit("text write", err) return } } case <-ticker.C: if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil { exit("ping", err) return } case <-c.done: return } } } // Close stops the writer goroutine and signals that this ServerClient // is done. Safe to call multiple times. Does NOT close the underlying // WebSocket connection — the caller owns that lifecycle. func (c *ServerClient) Close() { c.mu.Lock() defer c.mu.Unlock() select { case <-c.done: // Already closed default: close(c.done) } }