package hub import ( "context" "encoding/json" ) // SetMedia is §15.19: an admitted peer reports its own microphone and // camera state. The hub records it on the participation, where every // later roster — admission, resume, a kind change — carries it, and // fans a change out to every other admitted peer as // {type: "media", from, name, payload: MediaState}, the envelope // rebuilt from the sender's participation exactly as Fanout's is. // // §15.20 adds the receiving half to the same report — audioBlocked and // noSoundFrom, each present only when it has bad news, and 2026-09-28's // screenStalled beside them, and §15.24's hearing after those — and nothing // about the rules: it is still the sender's own claim, recorded and // carried in rosters, and fanned out only when it changes. // // It is state, not an event. A report that changes nothing is accepted // and sends nothing, so a client may repeat it freely — on every roster, // after every resume — and the room hears only changes. Nothing goes in // a replay ring: a peer that missed a `media` while detached learns the // current state from the roster its resume is answered with (§4.5), // which is the authority a ring would only have approximated. // // The sender's authority is Fanout's: a member re-derives standing // first (§15.12) and one whose standing has gone is ended here and // answered ErrNotYours; a guest never consults standing (ruling 14); a // participation not yet admitted is refused ErrNotAdmitted, because a // waiting guest has nobody to tell and learns nothing about the room by // asking (ruling 9). func (h *Hub) SetMedia(ctx context.Context, sqid, session string, from PeerID, m MediaState) error { gone, err := h.memberStandingGone(ctx, sqid, session, from, true) if err != nil { return err } if gone { return ErrNotYours } h.mu.Lock() defer h.mu.Unlock() r, ok := h.rooms[sqid] if !ok { return ErrUnknownPeer } p, found := h.findLocked(sqid, session, from) if !found { return ErrUnknownPeer } if !p.Admitted { return ErrNotAdmitted } if p.Media != nil && p.Media.Equal(m) { return nil } // A fresh value, never a write through the old pointer: rosters // already sent hold that one. The list is copied too, so nothing the // caller still holds is shared with a roster. next := m next.NoSoundFrom = append([]PeerID(nil), m.NoSoundFrom...) next.ScreenStalled = append([]PeerID(nil), m.ScreenStalled...) // §15.24's hearing keeps its emptiness: [] says "nobody yet", where // nil says nothing at all. if m.Hearing != nil { next.Hearing = append(make([]PeerID, 0, len(m.Hearing)), m.Hearing...) } p.Media = &next payload, err := json.Marshal(next) if err != nil { return err } out := Message{Type: TypeMedia, From: p.Peer, Name: p.Name, Payload: payload} h.broadcastLocked(r, out, func(q *Participation) bool { return q.Admitted && q.Peer != p.Peer }) return nil }