151 lines
4.6 KiB
Go
151 lines
4.6 KiB
Go
// ABOUTME: MetadataGroupRole pushes track metadata to joining clients
|
|
// ABOUTME: Sends server/state with metadata snapshot on join, transition, or on demand
|
|
package sendspin
|
|
|
|
import (
|
|
"log"
|
|
"sync"
|
|
|
|
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
|
)
|
|
|
|
// MetadataConfig configures the MetadataGroupRole.
|
|
type MetadataConfig struct {
|
|
// GetMetadata returns the current track metadata (title, artist, album).
|
|
GetMetadata func() (title, artist, album string)
|
|
|
|
// ClockMicros returns the server's current clock in microseconds.
|
|
ClockMicros func() int64
|
|
}
|
|
|
|
// MetadataGroupRole handles the "metadata" role family. It pushes a
|
|
// server/state message with the current track metadata to clients
|
|
// that advertise the metadata role: on join, on stopped→playing
|
|
// transitions, and on demand via BroadcastMetadata.
|
|
type MetadataGroupRole struct {
|
|
config MetadataConfig
|
|
|
|
mu sync.RWMutex
|
|
group *Group
|
|
}
|
|
|
|
// NewMetadataRole creates a MetadataGroupRole with the given config.
|
|
// The role must be registered on a Group via Group.RegisterRole; the
|
|
// group reference is captured so OnPlaybackStateChanged and
|
|
// BroadcastMetadata can iterate the group's attached clients.
|
|
func NewMetadataRole(config MetadataConfig) *MetadataGroupRole {
|
|
return &MetadataGroupRole{config: config}
|
|
}
|
|
|
|
// attachToGroup is called by Group.RegisterRole to give the role a
|
|
// back-reference for client iteration. Unexported because role authors
|
|
// must register via Group.RegisterRole rather than calling this directly.
|
|
func (r *MetadataGroupRole) attachToGroup(g *Group) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.group = g
|
|
}
|
|
|
|
// attachedGroup returns the group this role was registered on, or nil
|
|
// if it has not yet been registered.
|
|
func (r *MetadataGroupRole) attachedGroup() *Group {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.group
|
|
}
|
|
|
|
func (r *MetadataGroupRole) Role() string { return "metadata" }
|
|
|
|
// snapshotMessage builds the current server/state metadata snapshot.
|
|
// Returns nil if no GetMetadata callback is configured.
|
|
func (r *MetadataGroupRole) snapshotMessage() *protocol.ServerStateMessage {
|
|
if r.config.GetMetadata == nil {
|
|
return nil
|
|
}
|
|
|
|
title, artist, album := r.config.GetMetadata()
|
|
|
|
var clockMicros int64
|
|
if r.config.ClockMicros != nil {
|
|
clockMicros = r.config.ClockMicros()
|
|
}
|
|
|
|
return &protocol.ServerStateMessage{
|
|
Metadata: &protocol.MetadataState{
|
|
Timestamp: clockMicros,
|
|
Title: strPtr(title),
|
|
Artist: strPtr(artist),
|
|
Album: strPtr(album),
|
|
},
|
|
}
|
|
}
|
|
|
|
// OnClientJoin sends the current metadata snapshot to clients that
|
|
// advertise the metadata role.
|
|
func (r *MetadataGroupRole) OnClientJoin(c *ServerClient) {
|
|
if !c.HasRole("metadata") {
|
|
return
|
|
}
|
|
|
|
state := r.snapshotMessage()
|
|
if state == nil {
|
|
return
|
|
}
|
|
|
|
if err := c.Send("server/state", *state); err != nil {
|
|
log.Printf("MetadataRole: failed to send state to %s: %v", c.Name(), err)
|
|
}
|
|
}
|
|
|
|
func (r *MetadataGroupRole) OnClientLeave(id string, name string) {}
|
|
|
|
// OnPlaybackStateChanged broadcasts the current metadata snapshot to
|
|
// every attached metadata-role client when the group transitions to
|
|
// "playing" from a non-playing state. This closes the gap where a
|
|
// client connects while the group is stopped (and thus receives only
|
|
// the empty-state on join), then never sees metadata when playback
|
|
// subsequently begins.
|
|
//
|
|
// Other transitions (playing→paused, playing→stopped) are intentionally
|
|
// not re-broadcast — the existing client state is already correct for
|
|
// "the same track is paused/stopped".
|
|
func (r *MetadataGroupRole) OnPlaybackStateChanged(oldState, newState string) {
|
|
if newState != "playing" || oldState == "playing" {
|
|
return
|
|
}
|
|
r.broadcast()
|
|
}
|
|
|
|
// BroadcastMetadata pushes the current metadata snapshot to every
|
|
// attached metadata-role client. Use this when the server's metadata
|
|
// source has mutated and connected clients need to see the new value
|
|
// (e.g., HLS playlist advance, controller-driven track change).
|
|
//
|
|
// Safe to call concurrently with the group's own event dispatch — uses
|
|
// Group.Clients which returns a snapshot copy.
|
|
func (r *MetadataGroupRole) BroadcastMetadata() {
|
|
r.broadcast()
|
|
}
|
|
|
|
// broadcast sends the snapshot to every metadata-role client in the
|
|
// attached group. Logs and continues on per-client send errors so one
|
|
// dropped client does not stall the rest.
|
|
func (r *MetadataGroupRole) broadcast() {
|
|
g := r.attachedGroup()
|
|
if g == nil {
|
|
return
|
|
}
|
|
state := r.snapshotMessage()
|
|
if state == nil {
|
|
return
|
|
}
|
|
for _, c := range g.Clients() {
|
|
if !c.HasRole("metadata") {
|
|
continue
|
|
}
|
|
if err := c.Send("server/state", *state); err != nil {
|
|
log.Printf("MetadataRole: broadcast to %s failed: %v", c.Name(), err)
|
|
}
|
|
}
|
|
}
|