🎉 live server seems to be working now
This commit is contained in:
9
internal/live/errors.go
Normal file
9
internal/live/errors.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package live
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
errNotFound = errors.New("preset not found")
|
||||
errBadRequest = errors.New("invalid request")
|
||||
errNoActivePreset = errors.New("no active preset")
|
||||
)
|
||||
293
internal/live/http.go
Normal file
293
internal/live/http.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/sendspin"
|
||||
|
||||
"rpi-sendspin/internal/pipewire"
|
||||
)
|
||||
|
||||
type HTTPServer struct {
|
||||
mgr *Manager
|
||||
store *Store
|
||||
disc *Discovery
|
||||
roster *Roster
|
||||
srv *sendspin.Server
|
||||
}
|
||||
|
||||
func NewHTTPServer(mgr *Manager, store *Store, disc *Discovery, roster *Roster, srv *sendspin.Server) *HTTPServer {
|
||||
return &HTTPServer{mgr: mgr, store: store, disc: disc, roster: roster, srv: srv}
|
||||
}
|
||||
|
||||
func (h *HTTPServer) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /api/presets", h.listPresets)
|
||||
mux.HandleFunc("POST /api/presets", h.createPreset)
|
||||
mux.HandleFunc("GET /api/presets/{id}", h.getPreset)
|
||||
mux.HandleFunc("PUT /api/presets/{id}", h.putPreset)
|
||||
mux.HandleFunc("DELETE /api/presets/{id}", h.deletePreset)
|
||||
|
||||
mux.HandleFunc("GET /api/clients", h.listClients)
|
||||
mux.HandleFunc("GET /api/sources", h.listSources)
|
||||
mux.HandleFunc("GET /api/state", h.getState)
|
||||
|
||||
mux.HandleFunc("POST /api/active_preset", h.setActivePreset)
|
||||
mux.HandleFunc("POST /api/playback", h.setPlayback)
|
||||
mux.HandleFunc("POST /api/volume", h.setVolume)
|
||||
|
||||
mux.Handle("/", staticHandler())
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
type setActivePresetReq struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (h *HTTPServer) setActivePreset(w http.ResponseWriter, r *http.Request) {
|
||||
var req setActivePresetReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := h.mgr.SetActivePreset(req.ID); err != nil {
|
||||
httpError(w, MapManagerError(err), err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type setPlaybackReq struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
func (h *HTTPServer) setPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
var req setPlaybackReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := h.mgr.SetPlayback(req.Command); err != nil {
|
||||
httpError(w, MapManagerError(err), err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type setVolumeReq struct {
|
||||
Volume int `json:"volume"`
|
||||
}
|
||||
|
||||
func (h *HTTPServer) setVolume(w http.ResponseWriter, r *http.Request) {
|
||||
var req setVolumeReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := h.mgr.SetVolume(req.Volume); err != nil {
|
||||
httpError(w, MapManagerError(err), err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *HTTPServer) listPresets(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.store.List())
|
||||
}
|
||||
|
||||
func (h *HTTPServer) createPreset(w http.ResponseWriter, r *http.Request) {
|
||||
var p Preset
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := h.store.Create(p); err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (h *HTTPServer) getPreset(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, ok := h.store.Get(id)
|
||||
if !ok {
|
||||
httpError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (h *HTTPServer) putPreset(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var p Preset
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if p.ID == "" {
|
||||
p.ID = id
|
||||
}
|
||||
if p.ID != id {
|
||||
httpError(w, http.StatusBadRequest, "id mismatch")
|
||||
return
|
||||
}
|
||||
if err := h.store.Put(p); err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (h *HTTPServer) deletePreset(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
// If the deleted preset is currently active, transition to off first.
|
||||
active, _, _, _ := h.mgr.State()
|
||||
if active == id {
|
||||
_ = h.mgr.SetActivePreset("")
|
||||
}
|
||||
ok, err := h.store.Delete(id)
|
||||
if err != nil {
|
||||
httpError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
httpError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type clientView struct {
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Connected bool `json:"connected"`
|
||||
LastSeen time.Time `json:"last_seen,omitempty"`
|
||||
// Source indicates how this entry was discovered:
|
||||
// "connected" → currently in the active group (after ClientFilter accepted hello)
|
||||
// "roster" → handshaked at least once (we know its client_id; not currently in group)
|
||||
// "mdns" → only seen via mDNS; never handshaked, no client_id known
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func (h *HTTPServer) listClients(w http.ResponseWriter, _ *http.Request) {
|
||||
// Build a unified view of every speaker we know about, keyed by
|
||||
// client_id wherever possible so the UI can present a real, stable
|
||||
// identifier rather than the mDNS instance name (which differs from
|
||||
// client_id on most ESPHome / firmware-derived setups).
|
||||
out := make([]clientView, 0)
|
||||
byID := map[string]int{}
|
||||
|
||||
// 1) Currently-connected clients: authoritative; takes precedence.
|
||||
for _, c := range h.srv.Clients() {
|
||||
byID[c.ID] = len(out)
|
||||
out = append(out, clientView{
|
||||
ClientID: c.ID,
|
||||
Name: c.Name,
|
||||
Connected: true,
|
||||
LastSeen: time.Now(),
|
||||
Source: "connected",
|
||||
})
|
||||
}
|
||||
|
||||
// 2) Roster: every client_id we've seen via hello, even if rejected.
|
||||
for _, e := range h.roster.List() {
|
||||
if _, ok := byID[e.ClientID]; ok {
|
||||
continue
|
||||
}
|
||||
byID[e.ClientID] = len(out)
|
||||
out = append(out, clientView{
|
||||
ClientID: e.ClientID,
|
||||
Name: e.Name,
|
||||
Connected: false,
|
||||
LastSeen: e.LastSeen,
|
||||
Source: "roster",
|
||||
})
|
||||
}
|
||||
|
||||
// 3) mDNS-only entries: never handshaked, so we don't know
|
||||
// client_id. Useful for "the speaker is on the network but
|
||||
// something is wrong" diagnostics; not directly usable for
|
||||
// preset membership.
|
||||
for _, d := range h.disc.List() {
|
||||
out = append(out, clientView{
|
||||
Instance: d.Instance,
|
||||
Name: d.Instance,
|
||||
LastSeen: d.LastSeen,
|
||||
Source: "mdns",
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type sourceView struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsMonitor bool `json:"is_monitor"`
|
||||
}
|
||||
|
||||
func (h *HTTPServer) listSources(w http.ResponseWriter, _ *http.Request) {
|
||||
targets, err := pipewire.ListCaptureTargets()
|
||||
if err != nil {
|
||||
httpError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]sourceView, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
out = append(out, sourceView{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
IsMonitor: t.IsMonitor,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type stateView struct {
|
||||
ActivePreset string `json:"active_preset"`
|
||||
Playback Playback `json:"playback"`
|
||||
Volume int `json:"volume"`
|
||||
Members []MemberState `json:"members"`
|
||||
}
|
||||
|
||||
func (h *HTTPServer) getState(w http.ResponseWriter, _ *http.Request) {
|
||||
active, pb, vol, members := h.mgr.State()
|
||||
writeJSON(w, http.StatusOK, stateView{
|
||||
ActivePreset: active,
|
||||
Playback: pb,
|
||||
Volume: vol,
|
||||
Members: members,
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func httpError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// MapManagerError converts manager sentinel errors to HTTP status codes.
|
||||
// Currently unused by the HTTP layer (handlers use sentinel directly),
|
||||
// but exported for potential use by the MQTT bridge command path.
|
||||
func MapManagerError(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, errNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, errBadRequest), errors.Is(err, errNoActivePreset):
|
||||
return http.StatusBadRequest
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
456
internal/live/manager.go
Normal file
456
internal/live/manager.go
Normal file
@@ -0,0 +1,456 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/sendspin"
|
||||
)
|
||||
|
||||
type Playback string
|
||||
|
||||
const (
|
||||
Off Playback = "off"
|
||||
Paused Playback = "paused"
|
||||
Playing Playback = "playing"
|
||||
)
|
||||
|
||||
// StateChange is published to subscribers (MQTT bridge) whenever the
|
||||
// manager's externally-visible state changes.
|
||||
type StateChange struct {
|
||||
ActivePreset string
|
||||
Playback Playback
|
||||
Volume int
|
||||
Presets []Preset
|
||||
}
|
||||
|
||||
// MemberState is per-speaker detail used by /api/state.
|
||||
type MemberState struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Connected bool `json:"connected"`
|
||||
Muted bool `json:"muted"`
|
||||
Volume int `json:"volume"`
|
||||
}
|
||||
|
||||
// Manager is the single source of truth for the live-audio server. All
|
||||
// state transitions are serialized on its internal mutex.
|
||||
type Manager struct {
|
||||
store *Store
|
||||
server *sendspin.Server
|
||||
src *Source
|
||||
|
||||
mu sync.Mutex
|
||||
active string // preset id, "" when off
|
||||
playback Playback
|
||||
sourceOverride string // when non-empty, wins over preset.Source
|
||||
|
||||
subscribers []chan StateChange
|
||||
subscribersMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewManager(store *Store, server *sendspin.Server, src *Source) *Manager {
|
||||
m := &Manager{
|
||||
store: store,
|
||||
server: server,
|
||||
src: src,
|
||||
playback: Off,
|
||||
}
|
||||
// React to preset edits: if the active preset's content changed (or
|
||||
// it was deleted), reapply current state.
|
||||
store.OnWrite(m.reapply)
|
||||
return m
|
||||
}
|
||||
|
||||
// SetSourceOverride pins the captured PipeWire node, ignoring whatever
|
||||
// `source` field the active preset carries. Passing "" reverts to
|
||||
// preset-driven behavior. Safe to call before or after activation; the
|
||||
// new value takes effect on the next preset transition (or immediately
|
||||
// if a preset is already active).
|
||||
func (m *Manager) SetSourceOverride(node string) {
|
||||
m.mu.Lock()
|
||||
m.sourceOverride = node
|
||||
active := m.active
|
||||
m.mu.Unlock()
|
||||
|
||||
if active == "" {
|
||||
return
|
||||
}
|
||||
// Reapply so the running pw-cat picks up the new target.
|
||||
m.reapply()
|
||||
}
|
||||
|
||||
// resolveSource returns the PipeWire node to capture for the given
|
||||
// preset, honoring sourceOverride when set. Caller must hold m.mu.
|
||||
func (m *Manager) resolveSource(p Preset) string {
|
||||
if m.sourceOverride != "" {
|
||||
return m.sourceOverride
|
||||
}
|
||||
return p.Source
|
||||
}
|
||||
|
||||
// ClientAllowed is intended to be plugged into ServerConfig.ClientFilter.
|
||||
// It admits a client only if it belongs to the currently active preset.
|
||||
func (m *Manager) ClientAllowed(clientID string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.active == "" {
|
||||
return false
|
||||
}
|
||||
p, ok := m.store.Get(m.active)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, id := range p.Members {
|
||||
if id == clientID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Subscribe returns a channel that receives a StateChange whenever the
|
||||
// externally-visible state changes. The channel is buffered; slow
|
||||
// subscribers drop events.
|
||||
func (m *Manager) Subscribe() <-chan StateChange {
|
||||
ch := make(chan StateChange, 8)
|
||||
m.subscribersMu.Lock()
|
||||
m.subscribers = append(m.subscribers, ch)
|
||||
m.subscribersMu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) publish() {
|
||||
m.subscribersMu.Lock()
|
||||
subs := append([]chan StateChange(nil), m.subscribers...)
|
||||
m.subscribersMu.Unlock()
|
||||
|
||||
state := StateChange{
|
||||
ActivePreset: m.active,
|
||||
Playback: m.playback,
|
||||
Volume: m.groupVolumeLocked(),
|
||||
Presets: m.store.List(),
|
||||
}
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- state:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetActivePreset transitions to the given preset id ("" → off). Members
|
||||
// of the new preset are claimed; members no longer needed are released.
|
||||
// Carries over playback state where possible.
|
||||
func (m *Manager) SetActivePreset(id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if id == m.active {
|
||||
return nil
|
||||
}
|
||||
|
||||
var nextMembers map[string]struct{}
|
||||
var nextSource string
|
||||
if id != "" {
|
||||
p, ok := m.store.Get(id)
|
||||
if !ok {
|
||||
return errNotFound
|
||||
}
|
||||
nextMembers = make(map[string]struct{}, len(p.Members))
|
||||
for _, id := range p.Members {
|
||||
nextMembers[id] = struct{}{}
|
||||
}
|
||||
nextSource = m.resolveSource(p)
|
||||
}
|
||||
|
||||
// Drop currently-connected clients that don't belong to the new preset.
|
||||
for _, c := range m.server.Clients() {
|
||||
if _, ok := nextMembers[c.ID]; !ok {
|
||||
m.server.DisconnectClient(c.ID)
|
||||
}
|
||||
}
|
||||
|
||||
prev := m.active
|
||||
m.active = id
|
||||
|
||||
if id == "" {
|
||||
// off
|
||||
if m.playback == Playing {
|
||||
m.server.NotifyStreamEndAll()
|
||||
}
|
||||
m.playback = Off
|
||||
m.src.SetActive(false)
|
||||
m.src.SetTarget("")
|
||||
log.Printf("live: %s → off", prev)
|
||||
m.publish()
|
||||
return nil
|
||||
}
|
||||
|
||||
// On entry from off, default to paused; otherwise carry over.
|
||||
if prev == "" {
|
||||
m.playback = Paused
|
||||
}
|
||||
|
||||
m.src.SetTarget(nextSource)
|
||||
if m.playback == Playing {
|
||||
m.src.SetActive(true)
|
||||
m.server.NotifyStreamStartAll()
|
||||
} else {
|
||||
m.src.SetActive(false)
|
||||
}
|
||||
|
||||
log.Printf("live: active preset %q (%s)", id, m.playback)
|
||||
m.publish()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPlayback applies a play/pause/stop command. Stop sets the active
|
||||
// preset to "".
|
||||
func (m *Manager) SetPlayback(cmd string) error {
|
||||
switch cmd {
|
||||
case "stop":
|
||||
return m.SetActivePreset("")
|
||||
case "play":
|
||||
return m.setPlayback(Playing)
|
||||
case "pause":
|
||||
return m.setPlayback(Paused)
|
||||
default:
|
||||
return errBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) setPlayback(next Playback) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.active == "" {
|
||||
return errNoActivePreset
|
||||
}
|
||||
if m.playback == next {
|
||||
return nil
|
||||
}
|
||||
prev := m.playback
|
||||
m.playback = next
|
||||
|
||||
switch next {
|
||||
case Playing:
|
||||
m.src.SetActive(true)
|
||||
// If transitioning from paused, clients are already connected
|
||||
// but received stream/end. Resend stream/start.
|
||||
if prev == Paused {
|
||||
m.server.NotifyStreamStartAll()
|
||||
}
|
||||
case Paused:
|
||||
m.src.SetActive(false)
|
||||
if prev == Playing {
|
||||
m.server.NotifyStreamEndAll()
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("live: %s → %s", prev, next)
|
||||
m.publish()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetVolume applies the spec's group-volume algorithm to the currently
|
||||
// connected player members. No-op when off.
|
||||
func (m *Manager) SetVolume(target int) error {
|
||||
if target < 0 {
|
||||
target = 0
|
||||
}
|
||||
if target > 100 {
|
||||
target = 100
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.active == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
clients := m.server.Clients()
|
||||
players := make([]sendspin.ClientInfo, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
players = append(players, c)
|
||||
}
|
||||
if len(players) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Per spec ("Setting group volume", §Controller messages): apply
|
||||
// the *full* delta to every player, clamp out-of-range values, then
|
||||
// redistribute the lost delta across the still-eligible players.
|
||||
// Iterate until nothing clamps or every player is at a boundary.
|
||||
proposed := make(map[string]int, len(players))
|
||||
for _, c := range players {
|
||||
proposed[c.ID] = c.Volume
|
||||
}
|
||||
|
||||
sum := 0
|
||||
for _, c := range players {
|
||||
sum += proposed[c.ID]
|
||||
}
|
||||
delta := target - sum/len(players)
|
||||
for _, c := range players {
|
||||
proposed[c.ID] += delta
|
||||
}
|
||||
|
||||
clamped := make(map[string]bool, len(players))
|
||||
for range 10 {
|
||||
lost := 0
|
||||
any := false
|
||||
for _, c := range players {
|
||||
v := proposed[c.ID]
|
||||
if v < 0 {
|
||||
lost += v // negative
|
||||
proposed[c.ID] = 0
|
||||
if !clamped[c.ID] {
|
||||
any = true
|
||||
clamped[c.ID] = true
|
||||
}
|
||||
} else if v > 100 {
|
||||
lost += v - 100 // positive
|
||||
proposed[c.ID] = 100
|
||||
if !clamped[c.ID] {
|
||||
any = true
|
||||
clamped[c.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any || lost == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
eligible := make([]string, 0, len(players))
|
||||
for _, c := range players {
|
||||
if !clamped[c.ID] {
|
||||
eligible = append(eligible, c.ID)
|
||||
}
|
||||
}
|
||||
if len(eligible) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
share := lost / len(eligible)
|
||||
rem := lost - share*len(eligible)
|
||||
for _, id := range eligible {
|
||||
d := share
|
||||
if rem > 0 {
|
||||
d++
|
||||
rem--
|
||||
} else if rem < 0 {
|
||||
d--
|
||||
rem++
|
||||
}
|
||||
proposed[id] += d
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range m.serverClientsByID(players) {
|
||||
v := proposed[c.ID()]
|
||||
_ = c.Send("server/command", map[string]any{
|
||||
"player": map[string]any{
|
||||
"command": "volume",
|
||||
"volume": v,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
m.publish()
|
||||
return nil
|
||||
}
|
||||
|
||||
// reapply is invoked by the preset store after a write so the manager
|
||||
// can re-evaluate which speakers should be connected and what source to
|
||||
// capture (e.g. the active preset's member list was edited, or it was
|
||||
// deleted).
|
||||
func (m *Manager) reapply() {
|
||||
m.mu.Lock()
|
||||
active := m.active
|
||||
m.mu.Unlock()
|
||||
|
||||
if active == "" {
|
||||
return
|
||||
}
|
||||
p, ok := m.store.Get(active)
|
||||
if !ok {
|
||||
_ = m.SetActivePreset("")
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
allowed := make(map[string]struct{}, len(p.Members))
|
||||
for _, id := range p.Members {
|
||||
allowed[id] = struct{}{}
|
||||
}
|
||||
for _, c := range m.server.Clients() {
|
||||
if _, ok := allowed[c.ID]; !ok {
|
||||
m.server.DisconnectClient(c.ID)
|
||||
}
|
||||
}
|
||||
m.src.SetTarget(m.resolveSource(p))
|
||||
m.publish()
|
||||
}
|
||||
|
||||
// State returns a read-only snapshot for the HTTP /api/state endpoint.
|
||||
func (m *Manager) State() (string, Playback, int, []MemberState) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
members := make([]MemberState, 0)
|
||||
allowed := map[string]struct{}{}
|
||||
if p, ok := m.store.Get(m.active); ok {
|
||||
for _, id := range p.Members {
|
||||
allowed[id] = struct{}{}
|
||||
members = append(members, MemberState{ClientID: id})
|
||||
}
|
||||
}
|
||||
for _, c := range m.server.Clients() {
|
||||
if _, ok := allowed[c.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
for i, mem := range members {
|
||||
if mem.ClientID == c.ID {
|
||||
members[i].Connected = true
|
||||
members[i].Muted = c.Muted
|
||||
members[i].Volume = c.Volume
|
||||
}
|
||||
}
|
||||
}
|
||||
return m.active, m.playback, m.groupVolumeLocked(), members
|
||||
}
|
||||
|
||||
func (m *Manager) groupVolumeLocked() int {
|
||||
if m.active == "" {
|
||||
return 0
|
||||
}
|
||||
clients := m.server.Clients()
|
||||
if len(clients) == 0 {
|
||||
return 0
|
||||
}
|
||||
sum := 0
|
||||
for _, c := range clients {
|
||||
sum += c.Volume
|
||||
}
|
||||
return sum / len(clients)
|
||||
}
|
||||
|
||||
// serverClientsByID returns the live *ServerClient instances matching
|
||||
// the IDs in the snapshot. Used so SetVolume can call .Send() rather
|
||||
// than going through the snapshot, which is value-typed.
|
||||
func (m *Manager) serverClientsByID(snapshot []sendspin.ClientInfo) []*sendspin.ServerClient {
|
||||
if m.server.Group() == nil {
|
||||
return nil
|
||||
}
|
||||
want := make(map[string]struct{}, len(snapshot))
|
||||
for _, c := range snapshot {
|
||||
want[c.ID] = struct{}{}
|
||||
}
|
||||
out := make([]*sendspin.ServerClient, 0, len(snapshot))
|
||||
for _, c := range m.server.Group().Clients() {
|
||||
if _, ok := want[c.ID()]; ok {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
164
internal/live/mdns.go
Normal file
164
internal/live/mdns.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/mdns"
|
||||
)
|
||||
|
||||
// DiscoveredClient is a snapshot of one mDNS advertisement for a
|
||||
// Sendspin client (`_sendspin._tcp`). It does not include client_id —
|
||||
// that field is only known after WebSocket handshake. The HTTP layer
|
||||
// merges this snapshot with sendspin.Server.Clients() to produce the
|
||||
// full /api/clients view.
|
||||
type DiscoveredClient struct {
|
||||
Instance string `json:"instance"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
// Discovery continuously browses for _sendspin._tcp services. The list
|
||||
// of currently-known clients is exposed via List(). Entries persist for
|
||||
// `ttl` after their most recent sighting, then are pruned.
|
||||
type Discovery struct {
|
||||
ttl time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
clients map[string]DiscoveredClient
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewDiscovery() *Discovery {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Discovery{
|
||||
ttl: 2 * time.Minute,
|
||||
clients: make(map[string]DiscoveredClient),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Discovery) Start() {
|
||||
go d.browseLoop()
|
||||
go d.pruneLoop()
|
||||
}
|
||||
|
||||
func (d *Discovery) Stop() {
|
||||
d.cancel()
|
||||
}
|
||||
|
||||
func (d *Discovery) List() []DiscoveredClient {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
out := make([]DiscoveredClient, 0, len(d.clients))
|
||||
for _, c := range d.clients {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Instance < out[j].Instance })
|
||||
return out
|
||||
}
|
||||
|
||||
// sendspinService matches entries advertised under `_sendspin._tcp` —
|
||||
// other devices on the LAN (WLED, Meshtastic, AirPlay, …) hit the same
|
||||
// multicast channel and hashicorp/mdns happily writes them to our
|
||||
// entries chan regardless of what we queried for. Filter by service
|
||||
// type before recording.
|
||||
const sendspinService = "_sendspin._tcp"
|
||||
|
||||
func (d *Discovery) record(entry *mdns.ServiceEntry) {
|
||||
if !strings.Contains(entry.Name, "."+sendspinService) {
|
||||
return
|
||||
}
|
||||
host := ""
|
||||
if entry.AddrV4 != nil {
|
||||
host = entry.AddrV4.String()
|
||||
} else if entry.AddrV6 != nil {
|
||||
host = entry.AddrV6.String()
|
||||
}
|
||||
if host == "" {
|
||||
return
|
||||
}
|
||||
instance := entry.Name
|
||||
if i := strings.Index(instance, "."); i > 0 {
|
||||
instance = instance[:i]
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
d.clients[instance] = DiscoveredClient{
|
||||
Instance: instance,
|
||||
Host: host,
|
||||
Port: entry.Port,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
var silentLogger = log.New(io.Discard, "", 0)
|
||||
|
||||
func (d *Discovery) browseLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-d.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
entries := make(chan *mdns.ServiceEntry, 32)
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
for entry := range entries {
|
||||
d.record(entry)
|
||||
}
|
||||
}()
|
||||
|
||||
params := &mdns.QueryParam{
|
||||
Service: "_sendspin._tcp",
|
||||
Domain: "local",
|
||||
Timeout: 5 * time.Second,
|
||||
Entries: entries,
|
||||
Logger: silentLogger,
|
||||
}
|
||||
if err := mdns.Query(params); err != nil {
|
||||
log.Printf("mdns query: %v", err)
|
||||
}
|
||||
close(entries)
|
||||
<-done
|
||||
|
||||
select {
|
||||
case <-d.ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Discovery) pruneLoop() {
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-d.ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
cutoff := time.Now().Add(-d.ttl)
|
||||
d.mu.Lock()
|
||||
for k, v := range d.clients {
|
||||
if v.LastSeen.Before(cutoff) {
|
||||
delete(d.clients, k)
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
241
internal/live/mqtt.go
Normal file
241
internal/live/mqtt.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// MQTTConfig holds the bridge configuration loaded from CLI flags / env.
|
||||
type MQTTConfig struct {
|
||||
URL string
|
||||
Username string
|
||||
Password string
|
||||
BaseTopic string // e.g., "sendspin/live"
|
||||
DiscoveryPrefix string // e.g., "homeassistant"
|
||||
InstanceID string // used in HA discovery object_ids; defaults from BaseTopic
|
||||
}
|
||||
|
||||
type MQTTBridge struct {
|
||||
cfg MQTTConfig
|
||||
mgr *Manager
|
||||
client mqtt.Client
|
||||
}
|
||||
|
||||
func NewMQTTBridge(cfg MQTTConfig, mgr *Manager) *MQTTBridge {
|
||||
if cfg.BaseTopic == "" {
|
||||
cfg.BaseTopic = "sendspin/live"
|
||||
}
|
||||
if cfg.DiscoveryPrefix == "" {
|
||||
cfg.DiscoveryPrefix = "homeassistant"
|
||||
}
|
||||
if cfg.InstanceID == "" {
|
||||
cfg.InstanceID = strings.ReplaceAll(cfg.BaseTopic, "/", "_")
|
||||
}
|
||||
return &MQTTBridge{cfg: cfg, mgr: mgr}
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) Start() error {
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(b.cfg.URL).
|
||||
SetClientID("sendspin-live-" + b.cfg.InstanceID).
|
||||
SetUsername(b.cfg.Username).
|
||||
SetPassword(b.cfg.Password).
|
||||
SetAutoReconnect(true).
|
||||
SetCleanSession(true).
|
||||
SetKeepAlive(30 * time.Second).
|
||||
SetWill(b.topic("availability"), "offline", 1, true).
|
||||
SetOnConnectHandler(b.onConnect).
|
||||
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
|
||||
log.Printf("mqtt connection lost: %v", err)
|
||||
})
|
||||
|
||||
b.client = mqtt.NewClient(opts)
|
||||
tok := b.client.Connect()
|
||||
tok.Wait()
|
||||
if err := tok.Error(); err != nil {
|
||||
return fmt.Errorf("mqtt connect: %w", err)
|
||||
}
|
||||
|
||||
// React to manager state changes.
|
||||
go b.publishLoop(b.mgr.Subscribe())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) Stop() {
|
||||
if b.client != nil && b.client.IsConnected() {
|
||||
_ = b.publish("availability", "offline", true)
|
||||
b.client.Disconnect(250)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) topic(suffix string) string {
|
||||
return b.cfg.BaseTopic + "/" + suffix
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) publish(suffix, payload string, retain bool) error {
|
||||
t := b.client.Publish(b.topic(suffix), 1, retain, payload)
|
||||
t.Wait()
|
||||
return t.Error()
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) onConnect(_ mqtt.Client) {
|
||||
log.Printf("mqtt connected")
|
||||
_ = b.publish("availability", "online", true)
|
||||
b.publishDiscovery()
|
||||
|
||||
// Snapshot the current state and publish.
|
||||
active, pb, vol, _ := b.mgr.State()
|
||||
b.publishState(StateChange{
|
||||
ActivePreset: active,
|
||||
Playback: pb,
|
||||
Volume: vol,
|
||||
Presets: b.mgr.store.List(),
|
||||
})
|
||||
|
||||
// Subscribe command topics.
|
||||
b.client.Subscribe(b.topic("active_preset/set"), 1, b.handleActivePresetSet)
|
||||
b.client.Subscribe(b.topic("playback/set"), 1, b.handlePlaybackSet)
|
||||
b.client.Subscribe(b.topic("volume/set"), 1, b.handleVolumeSet)
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) handleActivePresetSet(_ mqtt.Client, msg mqtt.Message) {
|
||||
id := strings.TrimSpace(string(msg.Payload()))
|
||||
if err := b.mgr.SetActivePreset(id); err != nil {
|
||||
log.Printf("mqtt active_preset/set %q: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) handlePlaybackSet(_ mqtt.Client, msg mqtt.Message) {
|
||||
cmd := strings.TrimSpace(string(msg.Payload()))
|
||||
if err := b.mgr.SetPlayback(cmd); err != nil {
|
||||
log.Printf("mqtt playback/set %q: %v", cmd, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) handleVolumeSet(_ mqtt.Client, msg mqtt.Message) {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(string(msg.Payload())))
|
||||
if err != nil {
|
||||
log.Printf("mqtt volume/set bad payload: %v", err)
|
||||
return
|
||||
}
|
||||
if err := b.mgr.SetVolume(v); err != nil {
|
||||
log.Printf("mqtt volume/set: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) publishLoop(events <-chan StateChange) {
|
||||
for ev := range events {
|
||||
b.publishState(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) publishState(ev StateChange) {
|
||||
_ = b.publish("active_preset", ev.ActivePreset, true)
|
||||
_ = b.publish("playback", string(ev.Playback), true)
|
||||
_ = b.publish("volume", strconv.Itoa(ev.Volume), true)
|
||||
|
||||
type p struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
out := make([]p, 0, len(ev.Presets))
|
||||
for _, pr := range ev.Presets {
|
||||
out = append(out, p{ID: pr.ID, Name: pr.Name})
|
||||
}
|
||||
data, _ := json.Marshal(out)
|
||||
_ = b.publish("presets", string(data), true)
|
||||
|
||||
// HA discovery for the preset select depends on the list of preset
|
||||
// IDs, so republish the select config whenever presets change.
|
||||
b.publishSelectConfig(ev.Presets)
|
||||
}
|
||||
|
||||
// publishDiscovery emits HA MQTT discovery payloads for the entities
|
||||
// this bridge exposes. Retained + idempotent so repeating on every
|
||||
// connect is harmless.
|
||||
func (b *MQTTBridge) publishDiscovery() {
|
||||
b.publishSelectConfig(b.mgr.store.List())
|
||||
|
||||
// Playback select.
|
||||
b.publishConfig("select", "playback", map[string]any{
|
||||
"name": "Sendspin Live Playback",
|
||||
"unique_id": b.cfg.InstanceID + "_playback",
|
||||
"command_topic": b.topic("playback/set"),
|
||||
"state_topic": b.topic("playback"),
|
||||
"options": []string{"playing", "paused", "off"},
|
||||
"availability_topic": b.topic("availability"),
|
||||
"device": b.deviceBlock(),
|
||||
})
|
||||
|
||||
// Volume number.
|
||||
b.publishConfig("number", "volume", map[string]any{
|
||||
"name": "Sendspin Live Volume",
|
||||
"unique_id": b.cfg.InstanceID + "_volume",
|
||||
"command_topic": b.topic("volume/set"),
|
||||
"state_topic": b.topic("volume"),
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
"mode": "slider",
|
||||
"availability_topic": b.topic("availability"),
|
||||
"device": b.deviceBlock(),
|
||||
})
|
||||
|
||||
// Availability binary_sensor.
|
||||
b.publishConfig("binary_sensor", "availability", map[string]any{
|
||||
"name": "Sendspin Live Available",
|
||||
"unique_id": b.cfg.InstanceID + "_availability",
|
||||
"state_topic": b.topic("availability"),
|
||||
"payload_on": "online",
|
||||
"payload_off": "offline",
|
||||
"device_class": "connectivity",
|
||||
"device": b.deviceBlock(),
|
||||
})
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) publishSelectConfig(presets []Preset) {
|
||||
options := make([]string, 0, len(presets)+1)
|
||||
options = append(options, "")
|
||||
for _, p := range presets {
|
||||
options = append(options, p.ID)
|
||||
}
|
||||
b.publishConfig("select", "active_preset", map[string]any{
|
||||
"name": "Sendspin Live Preset",
|
||||
"unique_id": b.cfg.InstanceID + "_active_preset",
|
||||
"command_topic": b.topic("active_preset/set"),
|
||||
"state_topic": b.topic("active_preset"),
|
||||
"options": options,
|
||||
"availability_topic": b.topic("availability"),
|
||||
"device": b.deviceBlock(),
|
||||
})
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) publishConfig(component, objectID string, payload map[string]any) {
|
||||
topic := fmt.Sprintf("%s/%s/%s/%s/config",
|
||||
b.cfg.DiscoveryPrefix, component, b.cfg.InstanceID, objectID)
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("mqtt discovery marshal: %v", err)
|
||||
return
|
||||
}
|
||||
t := b.client.Publish(topic, 1, true, data)
|
||||
t.Wait()
|
||||
if err := t.Error(); err != nil {
|
||||
log.Printf("mqtt publish %s: %v", topic, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *MQTTBridge) deviceBlock() map[string]any {
|
||||
return map[string]any{
|
||||
"identifiers": []string{b.cfg.InstanceID},
|
||||
"name": "Sendspin Live",
|
||||
"manufacturer": "sendspin",
|
||||
"model": "live-server",
|
||||
}
|
||||
}
|
||||
168
internal/live/preset.go
Normal file
168
internal/live/preset.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Preset maps one PipeWire capture source to a set of Sendspin clients.
|
||||
// Members are sendspin `client_id` values, not friendly names.
|
||||
type Preset struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Members []string `json:"members"`
|
||||
}
|
||||
|
||||
var idRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
func (p *Preset) validate() error {
|
||||
if p.ID == "" {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
if !idRe.MatchString(p.ID) {
|
||||
return fmt.Errorf("id must be lowercase kebab-case")
|
||||
}
|
||||
if p.Name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
if p.Source == "" {
|
||||
return fmt.Errorf("source is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store is an in-memory, file-backed preset collection. Writes are
|
||||
// serialized; the file is rewritten atomically on every change.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
presets map[string]Preset
|
||||
onWrite func()
|
||||
}
|
||||
|
||||
func NewStore(path string) (*Store, error) {
|
||||
s := &Store{
|
||||
path: path,
|
||||
presets: make(map[string]Preset),
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// OnWrite registers a callback that runs after every successful write.
|
||||
// Used by the manager to refresh active state when the active preset
|
||||
// changes underneath it.
|
||||
func (s *Store) OnWrite(fn func()) {
|
||||
s.mu.Lock()
|
||||
s.onWrite = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) load() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var list []Preset
|
||||
if err := json.Unmarshal(data, &list); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", s.path, err)
|
||||
}
|
||||
for _, p := range list {
|
||||
s.presets[p.ID] = p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) writeLocked() error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
list := make([]Preset, 0, len(s.presets))
|
||||
for _, p := range s.presets {
|
||||
list = append(list, p)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
|
||||
|
||||
data, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.onWrite != nil {
|
||||
go s.onWrite()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) List() []Preset {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Preset, 0, len(s.presets))
|
||||
for _, p := range s.presets {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (Preset, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
p, ok := s.presets[id]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
func (s *Store) Put(p Preset) error {
|
||||
if err := p.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.presets[p.ID] = p
|
||||
return s.writeLocked()
|
||||
}
|
||||
|
||||
// Create adds a preset, failing if the ID already exists.
|
||||
func (s *Store) Create(p Preset) error {
|
||||
if err := p.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.presets[p.ID]; exists {
|
||||
return fmt.Errorf("preset %q already exists", p.ID)
|
||||
}
|
||||
s.presets[p.ID] = p
|
||||
return s.writeLocked()
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.presets[id]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.presets, id)
|
||||
return true, s.writeLocked()
|
||||
}
|
||||
58
internal/live/roster.go
Normal file
58
internal/live/roster.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RosterEntry is one observed Sendspin client. Populated from
|
||||
// client/hello messages — independent of whether the connection was
|
||||
// then accepted or rejected. This is what gives the UI the ability to
|
||||
// show the *actual* `client_id` of every speaker on the network, even
|
||||
// when ClientFilter rejected its current attempt.
|
||||
type RosterEntry struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
// Roster caches every (client_id, name) pair the sendspin server has
|
||||
// observed via client/hello. Records are kept indefinitely — speakers
|
||||
// usually keep the same client_id across reboots, so a stale entry
|
||||
// remains useful when the user is configuring presets offline.
|
||||
type Roster struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]RosterEntry
|
||||
}
|
||||
|
||||
func NewRoster() *Roster {
|
||||
return &Roster{entries: make(map[string]RosterEntry)}
|
||||
}
|
||||
|
||||
// Record is intended to be plugged into ServerConfig.OnClientHello.
|
||||
// Updates the entry's LastSeen on every call so the UI can show
|
||||
// "active now" vs. "last seen 5 minutes ago".
|
||||
func (r *Roster) Record(clientID, name string) {
|
||||
if clientID == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.entries[clientID] = RosterEntry{
|
||||
ClientID: clientID,
|
||||
Name: name,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Roster) List() []RosterEntry {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]RosterEntry, 0, len(r.entries))
|
||||
for _, e := range r.entries {
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ClientID < out[j].ClientID })
|
||||
return out
|
||||
}
|
||||
141
internal/live/source.go
Normal file
141
internal/live/source.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"rpi-sendspin/internal/pipewire"
|
||||
)
|
||||
|
||||
// Source is the sendspin AudioSource used by the live server. It wraps
|
||||
// a single pipewire.Source instance and exposes runtime control:
|
||||
//
|
||||
// - SetTarget swaps the captured PipeWire node and recreates the
|
||||
// underlying pw-cat process.
|
||||
// - SetActive(false) makes Read() return (0, nil) on every call so the
|
||||
// sendspin audio engine skips its tick without surfacing an error.
|
||||
// pw-cat is killed in this mode so we don't burn CPU capturing audio
|
||||
// no one is listening to.
|
||||
//
|
||||
// The sample rate / channel count are fixed at construction; v1 of the
|
||||
// live server does not retune between presets.
|
||||
type Source struct {
|
||||
sampleRate int
|
||||
channels int
|
||||
|
||||
mu sync.Mutex
|
||||
target string
|
||||
active bool
|
||||
pw *pipewire.Source
|
||||
retryAt time.Time // earliest time the next pw-cat start may be attempted
|
||||
failures int // consecutive failures, drives exponential backoff
|
||||
lastError string // last error message; logged once per change to avoid spam
|
||||
}
|
||||
|
||||
const (
|
||||
backoffMin = 500 * time.Millisecond
|
||||
backoffMax = 10 * time.Second
|
||||
)
|
||||
|
||||
func NewSource(sampleRate, channels int) *Source {
|
||||
return &Source{
|
||||
sampleRate: sampleRate,
|
||||
channels: channels,
|
||||
}
|
||||
}
|
||||
|
||||
// SetTarget sets the PipeWire node to capture from. If the target
|
||||
// changes while the source is active, the underlying pw-cat process is
|
||||
// torn down and recreated on the next Read.
|
||||
func (s *Source) SetTarget(target string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.target == target {
|
||||
return
|
||||
}
|
||||
s.target = target
|
||||
s.failures = 0
|
||||
s.retryAt = time.Time{}
|
||||
s.lastError = ""
|
||||
s.teardownLocked()
|
||||
}
|
||||
|
||||
// SetActive controls whether Read pulls from pw-cat. When set to false
|
||||
// any running pw-cat is killed and subsequent Reads return (0, nil).
|
||||
func (s *Source) SetActive(active bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.active == active {
|
||||
return
|
||||
}
|
||||
s.active = active
|
||||
if !active {
|
||||
s.teardownLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) teardownLocked() {
|
||||
if s.pw != nil {
|
||||
_ = s.pw.Close()
|
||||
s.pw = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) Read(samples []int32) (int, error) {
|
||||
s.mu.Lock()
|
||||
if !s.active || s.target == "" {
|
||||
s.mu.Unlock()
|
||||
return 0, nil
|
||||
}
|
||||
// Backoff: when pw-cat keeps dying (e.g. invalid target), don't
|
||||
// fork-bomb a new process on every 20 ms tick. Return (0, nil) so
|
||||
// the sendspin audio engine skips the tick silently until retryAt.
|
||||
if !s.retryAt.IsZero() && time.Now().Before(s.retryAt) {
|
||||
s.mu.Unlock()
|
||||
return 0, nil
|
||||
}
|
||||
if s.pw == nil {
|
||||
s.pw = pipewire.NewSource(s.target, s.sampleRate, s.channels)
|
||||
}
|
||||
pw := s.pw
|
||||
target := s.target
|
||||
s.mu.Unlock()
|
||||
|
||||
n, err := pw.Read(samples)
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
s.teardownLocked()
|
||||
s.failures++
|
||||
backoff := backoffMin << (s.failures - 1)
|
||||
if backoff > backoffMax || backoff <= 0 {
|
||||
backoff = backoffMax
|
||||
}
|
||||
s.retryAt = time.Now().Add(backoff)
|
||||
msg := err.Error()
|
||||
if msg != s.lastError {
|
||||
log.Printf("pw-cat[%s] read error: %v (retry in %s)", target, err, backoff)
|
||||
s.lastError = msg
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return 0, nil
|
||||
}
|
||||
if n > 0 {
|
||||
s.mu.Lock()
|
||||
s.failures = 0
|
||||
s.lastError = ""
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *Source) SampleRate() int { return s.sampleRate }
|
||||
func (s *Source) Channels() int { return s.channels }
|
||||
func (s *Source) Metadata() (string, string, string) { return "", "", "" }
|
||||
|
||||
func (s *Source) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.teardownLocked()
|
||||
return nil
|
||||
}
|
||||
19
internal/live/web.go
Normal file
19
internal/live/web.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed web/index.html
|
||||
var webFS embed.FS
|
||||
|
||||
func staticHandler() http.Handler {
|
||||
sub, err := fs.Sub(webFS, "web")
|
||||
if err != nil {
|
||||
// Embed misconfiguration is a build-time bug; fall back to 404.
|
||||
return http.NotFoundHandler()
|
||||
}
|
||||
return http.FileServer(http.FS(sub))
|
||||
}
|
||||
593
internal/live/web/index.html
Normal file
593
internal/live/web/index.html
Normal file
@@ -0,0 +1,593 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Sendspin Live</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1115;
|
||||
--panel: #161a22;
|
||||
--panel2: #1d222c;
|
||||
--border: #2a303c;
|
||||
--text: #e6e9ef;
|
||||
--muted: #8b94a7;
|
||||
--accent: #4c9aff;
|
||||
--accent-hover: #3a86e8;
|
||||
--good: #4ade80;
|
||||
--bad: #ef4444;
|
||||
--warn: #f59e0b;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
background: var(--panel);
|
||||
}
|
||||
header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
margin-right: auto;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.pill {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel2);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.pill.playing { background: rgba(74,222,128,0.15); color: var(--good); }
|
||||
.pill.paused { background: rgba(245,158,11,0.15); color: var(--warn); }
|
||||
.pill.off { background: rgba(139,148,167,0.15); color: var(--muted); }
|
||||
main {
|
||||
max-width: 1100px;
|
||||
margin: 24px auto;
|
||||
padding: 0 24px 80px;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
@media (max-width: 800px) { main { grid-template-columns: 1fr; } }
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.row:first-of-type { border-top: none; }
|
||||
.row .grow { flex: 1; }
|
||||
.muted { color: var(--muted); font-size: 12px; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||
button, select, input[type=text], input[type=number] {
|
||||
background: var(--panel2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
font: inherit;
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:hover:not(:disabled) { border-color: var(--accent); }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
button.danger { color: var(--bad); }
|
||||
button.danger:hover:not(:disabled) { border-color: var(--bad); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-group { display: inline-flex; gap: 6px; }
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
.dot.connected { background: var(--good); }
|
||||
.dot.discovered { background: var(--warn); }
|
||||
input[type=range] { flex: 1; accent-color: var(--accent); }
|
||||
label { display: block; margin: 12px 0 4px; font-size: 12px; color: var(--muted); }
|
||||
label.inline { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); margin: 0; }
|
||||
.stack > * + * { margin-top: 14px; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--panel2);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.badge.active { background: rgba(76,154,255,0.15); color: var(--accent); }
|
||||
.members-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: var(--panel2);
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 22px;
|
||||
width: min(500px, 92vw);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal h3 { margin: 0 0 14px; font-size: 16px; }
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 18px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
input[type=text]:focus, input[type=number]:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>Sendspin Live</h1>
|
||||
<span id="state-pill" class="pill off">off</span>
|
||||
<span id="active-pill" class="pill"></span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="card">
|
||||
<h2>
|
||||
Now playing
|
||||
</h2>
|
||||
<div class="stack">
|
||||
<div class="row">
|
||||
<span class="grow">Active preset</span>
|
||||
<select id="active-preset"></select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="grow">Playback</span>
|
||||
<div class="btn-group">
|
||||
<button id="btn-play">Play</button>
|
||||
<button id="btn-pause">Pause</button>
|
||||
<button id="btn-stop">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="grow">Volume <span id="volume-label" class="mono"></span></span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="volume" type="range" min="0" max="100" step="1" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="grow">Members</span>
|
||||
</div>
|
||||
<div id="members" class="muted">—</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>
|
||||
Discovered clients
|
||||
<button id="btn-refresh-clients" class="muted" style="background:none;border:none;font-size:12px;">refresh</button>
|
||||
</h2>
|
||||
<div id="clients"></div>
|
||||
</section>
|
||||
|
||||
<section class="card" style="grid-column: 1 / -1;">
|
||||
<h2>
|
||||
Presets
|
||||
<button id="btn-new-preset" class="primary">New preset</button>
|
||||
</h2>
|
||||
<div id="presets"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="modal-overlay" id="modal">
|
||||
<div class="modal">
|
||||
<h3 id="modal-title">New preset</h3>
|
||||
<form id="preset-form" autocomplete="off">
|
||||
<label for="f-id">ID <span class="muted">(lowercase, kebab-case)</span></label>
|
||||
<input id="f-id" type="text" required pattern="[a-z0-9]+(-[a-z0-9]+)*" style="width:100%" />
|
||||
<label for="f-name">Name</label>
|
||||
<input id="f-name" type="text" required style="width:100%" />
|
||||
<label for="f-source">PipeWire source</label>
|
||||
<select id="f-source" required style="width:100%"></select>
|
||||
<label>Members</label>
|
||||
<div class="members-list" id="f-members"></div>
|
||||
<label for="f-custom-member">Add member by client_id</label>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<input id="f-custom-member" type="text" placeholder="speaker-living-room" style="flex:1" />
|
||||
<button type="button" id="f-add-member">Add</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="f-cancel">Cancel</button>
|
||||
<button type="submit" class="primary" id="f-save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
let state = { active_preset: "", playback: "off", volume: 0, members: [] };
|
||||
let presets = [];
|
||||
let clients = [];
|
||||
let sources = [];
|
||||
let editing = null; // preset being edited, or null for new
|
||||
let formMembers = []; // strings (client_ids)
|
||||
let volumeDirty = false; // user holding the slider — don't clobber
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: { "content-type": "application/json" } };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const r = await fetch(path, opts);
|
||||
if (!r.ok) {
|
||||
let msg = r.statusText;
|
||||
try { msg = (await r.json()).error || msg; } catch {}
|
||||
throw new Error(`${method} ${path}: ${msg}`);
|
||||
}
|
||||
if (r.status === 204) return null;
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
try {
|
||||
[state, presets, clients, sources] = await Promise.all([
|
||||
api("GET", "/api/state"),
|
||||
api("GET", "/api/presets"),
|
||||
api("GET", "/api/clients"),
|
||||
api("GET", "/api/sources"),
|
||||
]);
|
||||
render();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
// header pills
|
||||
const sp = $("state-pill");
|
||||
sp.className = "pill " + state.playback;
|
||||
sp.textContent = state.playback;
|
||||
|
||||
const ap = $("active-pill");
|
||||
if (state.active_preset) {
|
||||
const p = presets.find(x => x.id === state.active_preset);
|
||||
ap.textContent = p ? p.name : state.active_preset;
|
||||
ap.style.display = "";
|
||||
} else {
|
||||
ap.style.display = "none";
|
||||
}
|
||||
|
||||
// active preset selector
|
||||
const sel = $("active-preset");
|
||||
sel.innerHTML = "";
|
||||
const noneOpt = document.createElement("option");
|
||||
noneOpt.value = ""; noneOpt.textContent = "— off —";
|
||||
sel.appendChild(noneOpt);
|
||||
for (const p of presets) {
|
||||
const o = document.createElement("option");
|
||||
o.value = p.id; o.textContent = p.name;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = state.active_preset || "";
|
||||
|
||||
// playback buttons
|
||||
$("btn-play").disabled = !state.active_preset || state.playback === "playing";
|
||||
$("btn-pause").disabled = state.playback !== "playing";
|
||||
$("btn-stop").disabled = !state.active_preset;
|
||||
|
||||
// volume slider
|
||||
$("volume-label").textContent = state.volume + "%";
|
||||
if (!volumeDirty) $("volume").value = state.volume;
|
||||
$("volume").disabled = !state.active_preset;
|
||||
|
||||
// members of the active preset
|
||||
const mEl = $("members");
|
||||
if (!state.members || !state.members.length) {
|
||||
mEl.innerHTML = '<div class="empty">No active preset</div>';
|
||||
} else {
|
||||
mEl.innerHTML = "";
|
||||
for (const m of state.members) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "row";
|
||||
div.innerHTML = `
|
||||
<span class="dot ${m.connected ? 'connected' : ''}"></span>
|
||||
<span class="grow mono">${escapeHTML(m.client_id)}</span>
|
||||
<span class="muted">${m.connected ? `vol ${m.volume}${m.muted ? ' · muted' : ''}` : 'offline'}</span>
|
||||
`;
|
||||
mEl.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
// discovered clients
|
||||
const cEl = $("clients");
|
||||
if (!clients.length) {
|
||||
cEl.innerHTML = '<div class="empty">No clients discovered yet</div>';
|
||||
} else {
|
||||
cEl.innerHTML = "";
|
||||
for (const c of clients) {
|
||||
const dot = c.connected ? "connected" : "discovered";
|
||||
const id = c.client_id || c.instance || "—";
|
||||
const label = c.name || c.instance || id;
|
||||
let badge;
|
||||
if (c.source === "connected") {
|
||||
badge = '<span class="badge active">connected</span>';
|
||||
} else if (c.source === "roster") {
|
||||
badge = '<span class="badge" title="Speaker has handshaked at least once; we know its client_id">known</span>';
|
||||
} else {
|
||||
badge = '<span class="badge" title="Only seen via mDNS; never handshaked, so client_id is not yet known">mDNS only</span>';
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
div.className = "row";
|
||||
div.innerHTML = `
|
||||
<span class="dot ${dot}"></span>
|
||||
<span class="grow">
|
||||
<div>${escapeHTML(label)}</div>
|
||||
<div class="mono muted">${escapeHTML(id)}</div>
|
||||
</span>
|
||||
${badge}
|
||||
`;
|
||||
cEl.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
// presets list
|
||||
const pEl = $("presets");
|
||||
if (!presets.length) {
|
||||
pEl.innerHTML = '<div class="empty">No presets — click "New preset" to create one.</div>';
|
||||
} else {
|
||||
pEl.innerHTML = "";
|
||||
for (const p of presets) {
|
||||
const isActive = p.id === state.active_preset;
|
||||
const div = document.createElement("div");
|
||||
div.className = "row";
|
||||
div.innerHTML = `
|
||||
<span class="grow">
|
||||
<div>
|
||||
${escapeHTML(p.name)}
|
||||
${isActive ? '<span class="badge active">active</span>' : ''}
|
||||
</div>
|
||||
<div class="mono muted">${escapeHTML(p.id)} · ${escapeHTML(p.source)} · ${p.members.length} member${p.members.length === 1 ? '' : 's'}</div>
|
||||
</span>
|
||||
<div class="btn-group">
|
||||
<button data-act="activate" data-id="${escapeAttr(p.id)}" ${isActive ? 'disabled' : ''}>Activate</button>
|
||||
<button data-act="edit" data-id="${escapeAttr(p.id)}">Edit</button>
|
||||
<button data-act="delete" data-id="${escapeAttr(p.id)}" class="danger">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
pEl.appendChild(div);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(s) { return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
||||
function escapeAttr(s) { return escapeHTML(s); }
|
||||
|
||||
$("active-preset").addEventListener("change", async (e) => {
|
||||
try { await api("POST", "/api/active_preset", { id: e.target.value }); }
|
||||
catch (err) { alert(err.message); }
|
||||
refreshAll();
|
||||
});
|
||||
|
||||
$("btn-play").onclick = async () => { await api("POST", "/api/playback", { command: "play" }).catch(e => alert(e.message)); refreshAll(); };
|
||||
$("btn-pause").onclick = async () => { await api("POST", "/api/playback", { command: "pause" }).catch(e => alert(e.message)); refreshAll(); };
|
||||
$("btn-stop").onclick = async () => { await api("POST", "/api/playback", { command: "stop" }).catch(e => alert(e.message)); refreshAll(); };
|
||||
|
||||
const vol = $("volume");
|
||||
vol.addEventListener("input", () => { volumeDirty = true; $("volume-label").textContent = vol.value + "%"; });
|
||||
vol.addEventListener("change", async () => {
|
||||
try { await api("POST", "/api/volume", { volume: parseInt(vol.value, 10) }); }
|
||||
catch (e) { alert(e.message); }
|
||||
volumeDirty = false;
|
||||
refreshAll();
|
||||
});
|
||||
|
||||
$("btn-refresh-clients").onclick = refreshAll;
|
||||
|
||||
$("btn-new-preset").onclick = () => openModal(null);
|
||||
|
||||
document.getElementById("presets").addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button[data-act]");
|
||||
if (!btn) return;
|
||||
const id = btn.dataset.id;
|
||||
if (btn.dataset.act === "activate") {
|
||||
try { await api("POST", "/api/active_preset", { id }); } catch (err) { alert(err.message); }
|
||||
refreshAll();
|
||||
} else if (btn.dataset.act === "edit") {
|
||||
const p = presets.find(x => x.id === id);
|
||||
if (p) openModal(p);
|
||||
} else if (btn.dataset.act === "delete") {
|
||||
if (!confirm(`Delete preset "${id}"?`)) return;
|
||||
try { await api("DELETE", "/api/presets/" + encodeURIComponent(id)); } catch (err) { alert(err.message); }
|
||||
refreshAll();
|
||||
}
|
||||
});
|
||||
|
||||
function openModal(p) {
|
||||
editing = p;
|
||||
$("modal-title").textContent = p ? "Edit preset" : "New preset";
|
||||
$("f-id").value = p ? p.id : "";
|
||||
$("f-id").disabled = !!p;
|
||||
$("f-name").value = p ? p.name : "";
|
||||
formMembers = p ? [...p.members] : [];
|
||||
|
||||
// source dropdown
|
||||
const src = $("f-source");
|
||||
src.innerHTML = "";
|
||||
const known = new Set(sources.map(s => s.name));
|
||||
if (p && !known.has(p.source)) {
|
||||
// preserve current source even if PipeWire doesn't currently advertise it
|
||||
const o = document.createElement("option");
|
||||
o.value = p.source;
|
||||
o.textContent = p.source + " (not found)";
|
||||
src.appendChild(o);
|
||||
}
|
||||
// Group monitors vs real inputs into optgroups so "stream what's
|
||||
// playing" is the obvious choice.
|
||||
const monitorGrp = document.createElement("optgroup");
|
||||
monitorGrp.label = "Monitors (capture playback)";
|
||||
const inputGrp = document.createElement("optgroup");
|
||||
inputGrp.label = "Inputs (microphones, line-in)";
|
||||
for (const s of sources) {
|
||||
const o = document.createElement("option");
|
||||
o.value = s.name;
|
||||
const desc = s.description || s.name;
|
||||
o.textContent = s.description ? `${desc} [${s.name}]` : s.name;
|
||||
if (s.is_monitor) {
|
||||
monitorGrp.appendChild(o);
|
||||
} else {
|
||||
inputGrp.appendChild(o);
|
||||
}
|
||||
}
|
||||
if (monitorGrp.children.length) src.appendChild(monitorGrp);
|
||||
if (inputGrp.children.length) src.appendChild(inputGrp);
|
||||
if (p) src.value = p.source;
|
||||
|
||||
renderFormMembers();
|
||||
$("modal").classList.add("open");
|
||||
$("f-name").focus();
|
||||
}
|
||||
|
||||
function closeModal() { $("modal").classList.remove("open"); editing = null; }
|
||||
|
||||
function renderFormMembers() {
|
||||
const el = $("f-members");
|
||||
el.innerHTML = "";
|
||||
// Only entries with a known client_id (connected or roster) can be
|
||||
// checked on. mDNS-only entries don't expose a client_id, so they
|
||||
// can't be safely used as preset members — the user must wait for
|
||||
// the speaker to handshake once, or paste its client_id below.
|
||||
const knownIDs = new Map();
|
||||
for (const c of clients) {
|
||||
if (!c.client_id) continue;
|
||||
if (!knownIDs.has(c.client_id)) {
|
||||
knownIDs.set(c.client_id, { id: c.client_id, label: c.name || c.client_id, connected: !!c.connected });
|
||||
}
|
||||
}
|
||||
for (const id of formMembers) {
|
||||
if (!knownIDs.has(id)) knownIDs.set(id, { id, label: id, connected: false });
|
||||
}
|
||||
if (knownIDs.size === 0) {
|
||||
el.innerHTML = '<div class="muted">No speakers have handshaked yet. Make sure they\'re on the network, or add one by client_id below.</div>';
|
||||
return;
|
||||
}
|
||||
for (const entry of knownIDs.values()) {
|
||||
const id = entry.id;
|
||||
const checked = formMembers.includes(id);
|
||||
const row = document.createElement("label");
|
||||
row.className = "inline";
|
||||
row.innerHTML = `
|
||||
<input type="checkbox" data-id="${escapeAttr(id)}" ${checked ? 'checked' : ''} />
|
||||
<span class="dot ${entry.connected ? 'connected' : ''}"></span>
|
||||
<span class="grow">${escapeHTML(entry.label)}</span>
|
||||
<span class="mono muted">${escapeHTML(id)}</span>
|
||||
`;
|
||||
row.querySelector("input").addEventListener("change", (e) => {
|
||||
if (e.target.checked) {
|
||||
if (!formMembers.includes(id)) formMembers.push(id);
|
||||
} else {
|
||||
formMembers = formMembers.filter(m => m !== id);
|
||||
}
|
||||
});
|
||||
el.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
$("f-add-member").onclick = () => {
|
||||
const input = $("f-custom-member");
|
||||
const id = input.value.trim();
|
||||
if (!id) return;
|
||||
if (!formMembers.includes(id)) formMembers.push(id);
|
||||
input.value = "";
|
||||
renderFormMembers();
|
||||
};
|
||||
|
||||
$("f-cancel").onclick = closeModal;
|
||||
$("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
|
||||
|
||||
$("preset-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
id: $("f-id").value.trim(),
|
||||
name: $("f-name").value.trim(),
|
||||
source: $("f-source").value,
|
||||
members: formMembers,
|
||||
};
|
||||
try {
|
||||
if (editing) {
|
||||
await api("PUT", "/api/presets/" + encodeURIComponent(payload.id), payload);
|
||||
} else {
|
||||
await api("POST", "/api/presets", payload);
|
||||
}
|
||||
closeModal();
|
||||
refreshAll();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
refreshAll();
|
||||
setInterval(refreshAll, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user