| 1 | package hub |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | ) |
| 7 | |
| 8 | // SetMedia is §15.19: an admitted peer reports its own microphone and |
| 9 | // camera state. The hub records it on the participation, where every |
| 10 | // later roster — admission, resume, a kind change — carries it, and |
| 11 | // fans a change out to every other admitted peer as |
| 12 | // {type: "media", from, name, payload: MediaState}, the envelope |
| 13 | // rebuilt from the sender's participation exactly as Fanout's is. |
| 14 | // |
| 15 | // §15.20 adds the receiving half to the same report — audioBlocked and |
| 16 | // noSoundFrom, each present only when it has bad news, and 2026-09-28's |
| 17 | // screenStalled beside them, and §15.24's hearing after those — and nothing |
| 18 | // about the rules: it is still the sender's own claim, recorded and |
| 19 | // carried in rosters, and fanned out only when it changes. |
| 20 | // |
| 21 | // It is state, not an event. A report that changes nothing is accepted |
| 22 | // and sends nothing, so a client may repeat it freely — on every roster, |
| 23 | // after every resume — and the room hears only changes. Nothing goes in |
| 24 | // a replay ring: a peer that missed a `media` while detached learns the |
| 25 | // current state from the roster its resume is answered with (§4.5), |
| 26 | // which is the authority a ring would only have approximated. |
| 27 | // |
| 28 | // The sender's authority is Fanout's: a member re-derives standing |
| 29 | // first (§15.12) and one whose standing has gone is ended here and |
| 30 | // answered ErrNotYours; a guest never consults standing (ruling 14); a |
| 31 | // participation not yet admitted is refused ErrNotAdmitted, because a |
| 32 | // waiting guest has nobody to tell and learns nothing about the room by |
| 33 | // asking (ruling 9). |
| 34 | func (h *Hub) SetMedia(ctx context.Context, sqid, session string, from PeerID, m MediaState) error { |
| 35 | gone, err := h.memberStandingGone(ctx, sqid, session, from, true) |
| 36 | if err != nil { |
| 37 | return err |
| 38 | } |
| 39 | if gone { |
| 40 | return ErrNotYours |
| 41 | } |
| 42 | h.mu.Lock() |
| 43 | defer h.mu.Unlock() |
| 44 | r, ok := h.rooms[sqid] |
| 45 | if !ok { |
| 46 | return ErrUnknownPeer |
| 47 | } |
| 48 | p, found := h.findLocked(sqid, session, from) |
| 49 | if !found { |
| 50 | return ErrUnknownPeer |
| 51 | } |
| 52 | if !p.Admitted { |
| 53 | return ErrNotAdmitted |
| 54 | } |
| 55 | if p.Media != nil && p.Media.Equal(m) { |
| 56 | return nil |
| 57 | } |
| 58 | // A fresh value, never a write through the old pointer: rosters |
| 59 | // already sent hold that one. The list is copied too, so nothing the |
| 60 | // caller still holds is shared with a roster. |
| 61 | next := m |
| 62 | next.NoSoundFrom = append([]PeerID(nil), m.NoSoundFrom...) |
| 63 | next.ScreenStalled = append([]PeerID(nil), m.ScreenStalled...) |
| 64 | // §15.24's hearing keeps its emptiness: [] says "nobody yet", where |
| 65 | // nil says nothing at all. |
| 66 | if m.Hearing != nil { |
| 67 | next.Hearing = append(make([]PeerID, 0, len(m.Hearing)), m.Hearing...) |
| 68 | } |
| 69 | p.Media = &next |
| 70 | payload, err := json.Marshal(next) |
| 71 | if err != nil { |
| 72 | return err |
| 73 | } |
| 74 | out := Message{Type: TypeMedia, From: p.Peer, Name: p.Name, Payload: payload} |
| 75 | h.broadcastLocked(r, out, func(q *Participation) bool { return q.Admitted && q.Peer != p.Peer }) |
| 76 | return nil |
| 77 | } |
| 78 | |