Files
-rpi-sendspin/third_party/sendspin-go/pkg/sendspin/role_metadata.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)
}
}
}