oficina / meet Public

Merge branch 'feat/mute-indicators' into integrate/native-2026-09-23

Carries each peer's mute and camera-off state in the room (protocol media
message, hub state), badges it on web tiles, and on the Mac and iPhone
tiles through MutedBadge and Call.peerMedia/isMuted/isCameraOff. Merged
without textual conflicts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 244e3638acd3dde41095e04124b4d7971e7c8bbe parent 5c8dc0aparent 1a98dc5
30 files changed, +1699 −20
  • Makefile +1 −0
  • callscore/media_test.go +177 −0
  • callscore/room.go +66 −2
  • docs/superpowers/specs/2026-09-07-meet-design.md +68 −0
  • internal/calls/hub/admission.go +2 −2
  • internal/calls/hub/hub.go +4 −0
  • internal/calls/hub/media.go +63 −0
  • internal/calls/hub/media_test.go +361 −0
  • internal/calls/hub/messages.go +2 −0
  • internal/calls/icons/icons.go +2 −0
  • internal/calls/signal.go +58 −6
  • internal/calls/static/calls.css +48 −0
  • internal/calls/static/room.js +187 −2
  • internal/calls/templates/room.html +14 −0
  • internal/callstest/browser_test.go +1 −0
  • internal/callstest/media_test.go +170 −0
  • native/apple/Sources/CallsInterop/Interop.swift +9 −0
  • native/apple/Sources/CallsKit/Call.swift +26 −0
  • native/apple/Sources/CallsKit/MacCallView.swift +8 −3
  • native/apple/Sources/CallsKit/MacVideoTile.swift +4 −1
  • native/apple/Sources/CallsKit/MutedBadge.swift +23 −0
  • native/apple/Sources/CallsKit/Wire.swift +9 −0
  • native/apple/Tests/CallsKitTests/PeerMediaTests.swift +51 −0
  • native/bridge/bridge.go +5 −0
  • native/ios/Sources/CallsPhone.swift +13 −4
  • protocol/messages.go +20 −0
  • test/browser/call.mjs +111 −0
  • test/browser/native.mjs +19 −0
  • test/browser/negotiation.test.mjs +3 −0
  • test/browser/room-mute.test.mjs +174 −0
diff --git a/Makefile b/Makefile
index dbeab73..7a770bd 100644
--- a/Makefile
+++ b/Makefile
@@ -147,6 +147,7 @@ browser:
node --test test/browser/negotiation.test.mjs
node --test test/browser/chat.test.mjs
node --test test/browser/room-share.test.mjs
+ node --test test/browser/room-mute.test.mjs
node --test test/browser/room-lane.test.mjs
node --test test/browser/media.test.mjs
node --test test/browser/audio.test.mjs
diff --git a/callscore/media_test.go b/callscore/media_test.go
new file mode 100644
index 0000000..53c1d39
--- /dev/null
+++ b/callscore/media_test.go
@@ -0,0 +1,177 @@
+package callscore
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/calls/protocol"
+)
+
+// mediaRoom is a Room seeded as connected-but-not-admitted, with every
+// POST it makes captured, so the tests below can drive receive() by hand
+// and see exactly which reports go out.
+func mediaRoom(t *testing.T) (*Room, chan protocol.Signal) {
+ t.Helper()
+ posted := make(chan protocol.Signal, 16)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ var s protocol.Signal
+ _ = json.NewDecoder(req.Body).Decode(&s)
+ posted <- s
+ }))
+ t.Cleanup(srv.Close)
+ r, err := New(srv.URL, "room", srv.Client(), func(Event) {})
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.started = true
+ r.state.Peer = "self"
+ t.Cleanup(r.Close)
+ return r, posted
+}
+
+func nextMedia(t *testing.T, posted chan protocol.Signal) protocol.MediaState {
+ t.Helper()
+ select {
+ case s := <-posted:
+ if s.Type != protocol.TypeMedia || s.To != "" || s.Seq != 0 || s.Gen != 0 || s.Peer != "self" {
+ t.Fatalf("posted %+v, want an untargeted media report", s)
+ }
+ var m protocol.MediaState
+ if err := json.Unmarshal(s.Payload, &m); err != nil {
+ t.Fatal(err)
+ }
+ return m
+ case <-time.After(time.Second):
+ t.Fatal("no media report was posted")
+ }
+ return protocol.MediaState{}
+}
+
+func nothingPosted(t *testing.T, posted chan protocol.Signal, why string) {
+ t.Helper()
+ select {
+ case s := <-posted:
+ t.Fatalf("%s, but %+v was posted", why, s)
+ case <-time.After(150 * time.Millisecond):
+ }
+}
+
+func roster(peers ...protocol.RosterPeer) protocol.Message {
+ return protocol.Message{Type: protocol.TypeRoster, Roster: &protocol.Roster{Peers: peers}}
+}
+
+// §15.19: the host's own state is reported when it is admitted, after a
+// resume whose roster disagrees, and on every change while admitted —
+// and not while waiting, where there is nobody to tell.
+func TestOwnMediaIsReportedOnAdmissionAndOnChange(t *testing.T) {
+ r, posted := mediaRoom(t)
+
+ // Muted in the lobby: remembered, not sent.
+ if err := r.SetMedia(true, false); err != nil {
+ t.Fatal(err)
+ }
+ nothingPosted(t, posted, "a client that is not admitted has nobody to tell")
+
+ // Admitted, with no media for ourselves in the roster: the hub has
+ // never heard from this participation, so the report goes now.
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self"}, protocol.RosterPeer{Peer: "other", Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); !m.Muted || m.CameraOff {
+ t.Fatalf("admission reported %+v, want muted", m)
+ }
+
+ // A later roster that already says what we say: nothing to send.
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{Muted: true}}, protocol.RosterPeer{Peer: "other", Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ nothingPosted(t, posted, "a roster that agrees needs no report")
+
+ // A change while admitted goes at once.
+ if err := r.SetMedia(true, true); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); !m.Muted || !m.CameraOff {
+ t.Fatalf("change reported %+v, want muted and camera off", m)
+ }
+
+ // A resume whose roster is behind — the report was lost while the
+ // stream was down — is corrected.
+ if err := r.receive(roster(protocol.RosterPeer{Peer: "self", Media: &protocol.MediaState{Muted: true}}, protocol.RosterPeer{Peer: "other", Gen: 1})); err != nil {
+ t.Fatal(err)
+ }
+ if m := nextMedia(t, posted); !m.Muted || !m.CameraOff {
+ t.Fatalf("resume reported %+v, want the host's current state", m)
+ }
+}
+
+// Other peers' state arrives two ways — in the roster and as `media`
+// events — and both land on the snapshot's peers, where the native UI
+// reads it. A peer that never reported stays nil: unknown, not "on".
+func TestRemoteMediaReachesTheSnapshot(t *testing.T) {
+ r, posted := mediaRoom(t)
+ if err := r.receive(roster(
+ protocol.RosterPeer{Peer: "self"},
+ protocol.RosterPeer{Peer: "old", Gen: 1},
+ protocol.RosterPeer{Peer: "new", Gen: 1, Media: &protocol.MediaState{Muted: true}},
+ )); err != nil {
+ t.Fatal(err)
+ }
+ <-posted // our own first report; covered above
+ before := r.Snapshot()
+
+ if err := r.receive(protocol.Message{Type: protocol.TypeMedia, From: "new", Payload: json.RawMessage(`{"muted":false,"cameraOff":true}`)}); err != nil {
+ t.Fatal(err)
+ }
+ // A malformed one, and one from nobody, change nothing.
+ _ = r.receive(protocol.Message{Type: protocol.TypeMedia, From: "old", Payload: json.RawMessage(`"nope"`)})
+ _ = r.receive(protocol.Message{Type: protocol.TypeMedia, Payload: json.RawMessage(`{"muted":true,"cameraOff":true}`)})
+
+ after := r.Snapshot()
+ byID := map[protocol.PeerID]*protocol.MediaState{}
+ for _, p := range after.Peers {
+ byID[p.Peer] = p.Media
+ }
+ if m := byID["new"]; m == nil || m.Muted || !m.CameraOff {
+ t.Fatalf("new = %+v, want unmuted with camera off", m)
+ }
+ if byID["old"] != nil {
+ t.Fatalf("a peer that never reported must stay unknown, got %+v", byID["old"])
+ }
+ // The snapshot taken before is untouched: the event replaced the
+ // pointer rather than writing through the one it had handed out.
+ for _, p := range before.Peers {
+ if p.Peer == "new" && (p.Media == nil || !p.Media.Muted || p.Media.CameraOff) {
+ t.Fatalf("an earlier snapshot changed under its holder: %+v", p.Media)
+ }
+ }
+ nothingPosted(t, posted, "hearing about somebody else sends nothing")
+}
+
+// A fresh join after a restart starts a new participation the hub knows
+// nothing about, so the host's state — kept across it — is reported again.
+func TestOwnMediaSurvivesAFreshJoin(t *testing.T) {
+ r, posted := mediaRoom(t)
+ _ = r.SetMedia(true, false)
+ _ = r.receive(roster(protocol.RosterPeer{Peer: "self"}))
+ nextMedia(t, posted)
+
+ r.fresh()
+ r.mu.Lock()
+ r.state.Peer = "self2"
+ r.mu.Unlock()
+ _ = r.receive(roster(protocol.RosterPeer{Peer: "self2"}))
+ select {
+ case s := <-posted:
+ var m protocol.MediaState
+ _ = json.Unmarshal(s.Payload, &m)
+ if s.Type != protocol.TypeMedia || s.Peer != "self2" || !m.Muted {
+ t.Fatalf("after a restart posted %+v (%+v), want the kept muted state from the new peer", s, m)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("no report after the fresh join")
+ }
+}
diff --git a/callscore/room.go b/callscore/room.go
index 01d240d..a02c121 100644
--- a/callscore/room.go
+++ b/callscore/room.go
@@ -56,6 +56,10 @@ type Room struct {
lanes map[protocol.PeerID]*lane
blocked map[protocol.PeerID]bool
iceWake chan struct{}
+ // media is this client's own microphone and camera, as the host last
+ // set it (§15.19). It outlives a fresh join: a restart does not
+ // unmute anybody.
+ media protocol.MediaState
}
func New(origin, room string, authenticated *http.Client, callback func(Event)) (*Room, error) {
@@ -226,6 +230,60 @@ func pause(ctx context.Context, d time.Duration) bool {
return true
}
}
+
+// SetMedia records this client's own microphone and camera state and,
+// once admitted, reports it (§15.19). Before admission there is nobody
+// to tell; the roster that admits this client is compared against it
+// and the report goes then, as it does after every resume and restart.
+func (r *Room) SetMedia(muted, cameraOff bool) error {
+ r.mu.Lock()
+ r.media = protocol.MediaState{Muted: muted, CameraOff: cameraOff}
+ report := r.state.Phase == "admitted" && !r.stopped
+ r.mu.Unlock()
+ if !report {
+ return nil
+ }
+ return r.sendMedia()
+}
+
+func (r *Room) sendMedia() error {
+ r.mu.Lock()
+ b, err := json.Marshal(r.media)
+ r.mu.Unlock()
+ if err != nil {
+ return err
+ }
+ return r.Send(protocol.TypeMedia, "", 0, string(b))
+}
+
+// mediaStaleLocked says whether the hub's record of this client — its
+// own entry in the roster just applied — differs from what the host last
+// set, or is missing, which is how a fresh participation, a restarted
+// server, or a report lost while the stream was down all look.
+func (r *Room) mediaStaleLocked() bool {
+ for _, p := range r.state.Peers {
+ if p.Peer == r.state.Peer {
+ return p.Media == nil || *p.Media != r.media
+ }
+ }
+ return false
+}
+
+// applyMediaLocked is a `media` event: one peer's new state, replacing
+// its roster entry's pointer rather than writing through it, because
+// snapshots already handed out share the old one.
+func (r *Room) applyMediaLocked(m protocol.Message) {
+ var next protocol.MediaState
+ if m.From == "" || json.Unmarshal(m.Payload, &next) != nil {
+ return
+ }
+ for i, p := range r.state.Peers {
+ if p.Peer == m.From {
+ r.state.Peers[i].Media = &next
+ }
+ }
+}
+
func (r *Room) generationLocked(peer protocol.PeerID) uint64 {
for _, p := range r.state.Peers {
if p.Peer == peer {
@@ -244,7 +302,7 @@ func (r *Room) receive(m protocol.Message) error {
r.mu.Unlock()
return context.Canceled
}
- join, ice, reset := false, false, protocol.PeerID("")
+ join, ice, reset, report := false, false, protocol.PeerID(""), false
switch m.Type {
case protocol.TypeHello:
join = r.state.Peer == ""
@@ -278,6 +336,7 @@ func (r *Room) receive(m protocol.Message) error {
r.state.Waiting = append([]protocol.RosterPeer{}, m.Roster.Waiting...)
r.state.Phase = "admitted"
ice = true
+ report = r.mediaStaleLocked()
case protocol.TypePeer:
if m.Roster != nil {
for _, p := range m.Roster.Peers {
@@ -307,6 +366,8 @@ func (r *Room) receive(m protocol.Message) error {
delete(r.blocked, m.Peer)
}
}
+ case protocol.TypeMedia:
+ r.applyMediaLocked(m)
case protocol.TypeGap:
reset = m.From
case protocol.TypeLost:
@@ -354,6 +415,9 @@ func (r *Room) receive(m protocol.Message) error {
if reset != "" {
_ = r.Send(protocol.TypeReset, string(reset), 0, "")
}
+ if report {
+ _ = r.sendMedia()
+ }
return nil
}
func without(peers []protocol.RosterPeer, id protocol.PeerID) []protocol.RosterPeer {
@@ -380,7 +444,7 @@ func (r *Room) Send(typ, to string, expectedGen uint64, payload string) error {
return errors.New("room is not connected")
}
switch typ {
- case protocol.TypeJoin, protocol.TypeAdmit, protocol.TypeReset, protocol.TypeChat, protocol.TypeShare, protocol.TypeOffer, protocol.TypeAnswer, protocol.TypeCandidate:
+ case protocol.TypeJoin, protocol.TypeAdmit, protocol.TypeReset, protocol.TypeChat, protocol.TypeShare, protocol.TypeMedia, protocol.TypeOffer, protocol.TypeAnswer, protocol.TypeCandidate:
default:
return errors.New("unsupported room command")
}
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
index 67a4798..9e3a680 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -567,6 +567,7 @@ a multi-host fleet; that is noted, not solved.
| `reset {to}` | an admitted peer | both peers of the pair | Under the hub lock, increment the pair's generation and send `pair {peer, gen}` to both, into their replay rings. Idempotent in effect: two resets yield one higher generation both sides converge on. |
| `chat {text}` | an admitted peer | all admitted peers | Fan out with the sender's name. 2 KB. Not stored. |
| `share {on}` | an admitted peer | all admitted peers | Fan out so tiles can be labelled. |
+| `media {muted, cameraOff}` (2026-09-23, §15.19) | an admitted peer | all other admitted peers | Record on the participation, carry in every later `roster` and `peer`; fan out only a change. |
| `hello`, `roster`, `peer`, `gone`, `knock`, `knock-gone`, `full`, `ended`, `lost`, `pair`, `gap`, `state` | server | as named | `roster`, `peer`, `pair` and `gap` go only to admitted peers; a waiting guest receives `hello`, `state`, then `roster` on admission, or `lost`, or `ended`, and nothing else. |
`offer`, `answer` and `candidate` also carry `seq` and `gen` (§4.6);
@@ -2227,3 +2228,70 @@ match on 2026-09-14, in the same commit.
state as of that observation, not asserted as current here. This
slice does not touch that queue or announce a deploy of its own,
which is checkable from this diff alone.
+
+### 15.19 §4.3, §4.5 — media state: who is muted, whose camera is off (2026-09-23)
+
+**Why.** Paul, 2026-09-23: "add mute indicators so that folks can see
+when someone is muted." Until now muting disabled the local audio track
+and sent silence, and nothing on the wire said so; nobody else could
+tell a muted person from a broken microphone. Camera off had the same
+gap, and a remote tile showed a black or frozen frame for it.
+
+**Decision.** A new message type, `media`, that goes both ways and is
+**state, not an event**:
+
+- Upstream, `POST /signal {"peer","type":"media","payload":
+ {"muted":bool,"cameraOff":bool}}`. Both keys, spelled exactly, each
+ once, both booleans, nothing else, judged on the raw bytes as §15.15
+ judges chat and share (a token walk, not a map). The raw payload is
+ capped at 128 bytes before decoding (`413 {"error":"too-large"}`);
+ any other shape, a `to`, a `seq` or a `gen` is
+ `400 {"error":"bad-request"}`. The sender's authority is `Fanout`'s
+ (§15.15): admitted, bound to this room and session, a member's
+ standing re-derived first; a waiting or unjoined participation gets
+ the same 404 an unknown peer gets.
+- The hub records the value on the participation (`Participation.Media`,
+ nil until the first report). **Every roster and every `peer` event
+ carries it**, as `media` on the `RosterPeer`, for every admitted peer
+ including the recipient itself. That is what makes a late joiner, a
+ resume (§4.5) and a restart correct without any new replay rule:
+ nothing about `media` goes in the replay ring, because the roster a
+ resume is answered with is already the authority, exactly as it is
+ for who is present.
+- Downstream, a **change** is fanned out to every other admitted peer as
+ `{type:"media", from, name, payload:{"muted","cameraOff"}}`, the
+ envelope rebuilt from the sender's participation and the payload
+ re-encoded from the two validated booleans. A report equal to what the
+ hub already holds is accepted (200) and sends nothing, so clients may
+ repeat it freely.
+
+**What a client does.** It keeps its own state locally and reports it
+whenever a roster's entry for itself disagrees or is missing — which is
+admission, a resume after a report was lost, and a fresh join after a
+restart — and at once on each toggle while admitted. Before admission it
+sends nothing. `muted` means the person's microphone is not being sent
+(toggled off, or there is no microphone track at all); `cameraOff` the
+same for the camera. Both are the sender's own claim about what it is
+choosing to send. The server never sees media (§0.1) and cannot check
+them, and nothing depends on them being true: a peer that lies about
+being muted is heard anyway.
+
+**Unknown is not muted.** A `RosterPeer` with no `media` key is a peer
+that has not reported — every client that predates this section, and
+every peer for the moment between admission and its first report.
+Clients show that as neither muted nor camera-off. The zero value is
+not used for it, because `{"muted":false,"cameraOff":false}` is a real
+report and must be told apart from silence.
+
+**Mixed versions.** An old client ignores the `media` key on a roster
+and the `media` event type (the web client's dispatch and the native
+core both drop unknown types), so it never breaks and simply never
+shows or sends indicators; the people talking to it see it as unknown.
+A new client against an old server has its report refused
+`400 {"error":"bad-request"}` as an unknown type; it does not retry a
+400 and shows nothing. Server and clients should still deploy together
+for the feature to exist, but no order of deployment breaks a call.
+
+**Not logged, not stored.** Like every accepted message, a report logs
+nothing; a refused one logs type, room, peer and outcome only (§4.4).
+The state lives on the in-memory participation and dies with it (§2.2).
diff --git a/internal/calls/hub/admission.go b/internal/calls/hub/admission.go
index f736614..0bc7871 100644
--- a/internal/calls/hub/admission.go
+++ b/internal/calls/hub/admission.go
@@ -31,7 +31,7 @@ func (h *Hub) rosterFor(r *room, viewer *Participation) *Roster {
if !p.Admitted {
continue
}
- rp := RosterPeer{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String()}
+ rp := RosterPeer{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String(), Media: p.Media}
if p.Peer != viewer.Peer {
rp.Gen = h.genLocked(r, viewer.Peer, p.Peer)
}
@@ -77,7 +77,7 @@ func (h *Hub) admitLocked(r *room, p *Participation) bool {
// generation with the newcomer; browsers read it from
// roster.peers[0].
q.sub.send(Message{Type: TypePeer, Peer: p.Peer, Name: p.Name,
- Roster: &Roster{Peers: []RosterPeer{{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String(), Gen: h.genLocked(r, q.Peer, p.Peer)}}}})
+ Roster: &Roster{Peers: []RosterPeer{{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String(), Gen: h.genLocked(r, q.Peer, p.Peer), Media: p.Media}}}})
}
}
if wasWaiting {
diff --git a/internal/calls/hub/hub.go b/internal/calls/hub/hub.go
index 0ef0aed..59c3619 100644
--- a/internal/calls/hub/hub.go
+++ b/internal/calls/hub/hub.go
@@ -98,6 +98,10 @@ type Participation struct {
Joined bool // has posted join (waiting or admitted)
JoinedAt time.Time
OpenedAt time.Time
+ // Media is the peer's last report of its own microphone and camera
+ // (§15.19), nil until it makes one. It lives and dies with the
+ // participation, so a resume keeps it and a fresh join starts unknown.
+ Media *MediaState
sub *Sub
resumeUntil time.Time // zero while a stream is attached
diff --git a/internal/calls/hub/media.go b/internal/calls/hub/media.go
new file mode 100644
index 0000000..6504a0d
--- /dev/null
+++ b/internal/calls/hub/media.go
@@ -0,0 +1,63 @@
+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.
+//
+// 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 == m {
+ return nil
+ }
+ // A fresh value, never a write through the old pointer: rosters
+ // already sent hold that one.
+ next := m
+ 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
+}
diff --git a/internal/calls/hub/media_test.go b/internal/calls/hub/media_test.go
new file mode 100644
index 0000000..6890c14
--- /dev/null
+++ b/internal/calls/hub/media_test.go
@@ -0,0 +1,361 @@
+package hub
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "sync"
+ "testing"
+ "time"
+)
+
+// rosterMedia finds one peer's media in a roster, and whether the peer
+// is in it at all.
+func rosterMedia(ro *Roster, peer PeerID) (*MediaState, bool) {
+ if ro == nil {
+ return nil, false
+ }
+ for _, rp := range ro.Peers {
+ if rp.Peer == peer {
+ return rp.Media, true
+ }
+ }
+ return nil, false
+}
+
+// TestSetMediaFansOutAChangeAndNothingElse is §15.19's delivery: every
+// other admitted peer hears a change, in an envelope the hub built, and
+// nobody else hears anything — not the sender, not a waiting guest, and
+// nothing in any ring. A repeat of the same state is accepted and silent.
+func TestSetMediaFansOutAChangeAndNothingElse(t *testing.T) {
+ now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ ctx := context.Background()
+ a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
+ h.Join(ctx, "r1", "sa", a.Peer)
+ b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
+ h.Join(ctx, "r1", "sb", b.Peer)
+ g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
+ h.Join(ctx, "r1", "sg", g.Peer) // waiting
+ drain(a.Sub)
+ drain(b.Sub)
+ drain(g.Sub)
+
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil {
+ t.Fatal(err)
+ }
+ got := drain(b.Sub)
+ if len(got) != 1 {
+ t.Fatalf("Bob got %d events, want 1: %v", len(got), types(got))
+ }
+ m := got[0]
+ if m.Type != TypeMedia || m.From != a.Peer || m.Name != "Ann" {
+ t.Fatalf("Bob got %+v, want media from Ann", m)
+ }
+ if string(m.Payload) != `{"muted":true,"cameraOff":false}` {
+ t.Fatalf("payload %s, want the canonical two booleans", m.Payload)
+ }
+ if m.To != "" || m.Seq != 0 || m.Gen != 0 || m.Roster != nil {
+ t.Fatalf("Bob got %+v, want nothing but type, from, name and payload", m)
+ }
+ if n := len(drain(a.Sub)); n != 0 {
+ t.Fatalf("the sender must not hear its own report, got %d", n)
+ }
+ if n := len(drain(g.Sub)); n != 0 {
+ t.Fatalf("a waiting guest must hear nothing, got %d", n)
+ }
+ if n := ringLen(h, "r1", "sb", b.Peer); n != 0 {
+ t.Fatalf("media is state, never ringed; ring has %d", n)
+ }
+
+ // The same state again: accepted, and the room hears nothing.
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil {
+ t.Fatal(err)
+ }
+ if n := len(drain(b.Sub)); n != 0 {
+ t.Fatalf("a report that changes nothing must send nothing, got %d", n)
+ }
+ // A different state is a change again.
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true, CameraOff: true}); err != nil {
+ t.Fatal(err)
+ }
+ got = drain(b.Sub)
+ if len(got) != 1 || string(got[0].Payload) != `{"muted":true,"cameraOff":true}` {
+ t.Fatalf("camera off should be one more media event, got %v", got)
+ }
+}
+
+// TestRostersCarryMediaAndUnknownIsAbsent is the "state, not event"
+// half: a peer admitted after the report learns it from its roster, the
+// room learns a newcomer's from the peer event once it has one, and a
+// peer that has never reported — every client older than §15.19 — has
+// no media at all rather than a guessed false.
+func TestRostersCarryMediaAndUnknownIsAbsent(t *testing.T) {
+ now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ ctx := context.Background()
+ a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
+ h.Join(ctx, "r1", "sa", a.Peer)
+ b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
+ h.Join(ctx, "r1", "sb", b.Peer) // never reports: an old client
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil {
+ t.Fatal(err)
+ }
+ drain(a.Sub)
+ drain(b.Sub)
+
+ c, _ := h.Open("r1", "sc", 3, "Cy", KindMember)
+ h.Join(ctx, "r1", "sc", c.Peer)
+ cev := drain(c.Sub)
+ var ro *Roster
+ for _, m := range cev {
+ if m.Type == TypeRoster {
+ ro = m.Roster
+ }
+ }
+ am, ok := rosterMedia(ro, a.Peer)
+ if !ok || am == nil || !am.Muted || am.CameraOff {
+ t.Fatalf("Cy's roster should say Ann is muted, got %+v (present %v)", am, ok)
+ }
+ bm, ok := rosterMedia(ro, b.Peer)
+ if !ok || bm != nil {
+ t.Fatalf("Bob never reported, so his roster entry must carry no media, got %+v (present %v)", bm, ok)
+ }
+ // And on the wire, absent rather than a zero object: that is what
+ // an old client ignores and a new one reads as unknown.
+ raw, _ := json.Marshal(RosterPeer{Peer: b.Peer, Name: "Bob", Kind: "member"})
+ if string(raw) != `{"peer":"`+string(b.Peer)+`","name":"Bob","kind":"member"}` {
+ t.Fatalf("an unreported peer marshals as %s, want no media key", raw)
+ }
+
+ // The newcomer's own first report reaches everyone already there,
+ // the old client included: it ignores the type, and it is not the
+ // hub's business to know that.
+ drain(a.Sub)
+ drain(b.Sub)
+ if err := h.SetMedia(ctx, "r1", "sc", c.Peer, MediaState{CameraOff: true}); err != nil {
+ t.Fatal(err)
+ }
+ for _, s := range []*Sub{a.Sub, b.Sub} {
+ ev := drain(s)
+ if len(ev) != 1 || ev[0].Type != TypeMedia || ev[0].From != c.Peer {
+ t.Fatalf("Cy's report should reach both others once, got %v", types(ev))
+ }
+ }
+}
+
+// TestPeerEventCarriesMediaFromTheParticipation: the `peer` event is
+// built from the participation, like the roster, so whatever media the
+// participation holds at admission goes with it. Today a report needs
+// admission first, so this is nil in practice — the test pins the
+// contract so that loosening that rule later cannot forget the event.
+func TestPeerEventCarriesMediaFromTheParticipation(t *testing.T) {
+ now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ ctx := context.Background()
+ a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
+ h.Join(ctx, "r1", "sa", a.Peer)
+ drain(a.Sub)
+ b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
+ // Bob's media is set directly under the lock, as if he had reported
+ // before this admission; the hub's own paths only allow a report once
+ // admitted, so this is the peer event's contract, not a flow.
+ h.mu.Lock()
+ p, _ := h.findLocked("r1", "sb", b.Peer)
+ p.Media = &MediaState{Muted: true}
+ h.mu.Unlock()
+ h.Join(ctx, "r1", "sb", b.Peer)
+ ev := drain(a.Sub)
+ if len(ev) != 1 || ev[0].Type != TypePeer {
+ t.Fatalf("Ann should get one peer event, got %v", types(ev))
+ }
+ bm, ok := rosterMedia(ev[0].Roster, b.Peer)
+ if !ok || bm == nil || !bm.Muted {
+ t.Fatalf("the peer event should carry Bob's media, got %+v", bm)
+ }
+}
+
+// TestResumeRosterCarriesMediaMissedWhileDetached: a change made while a
+// peer's stream is down is not ringed and not replayed, and it does not
+// need to be — the roster the resume is answered with says it.
+func TestResumeRosterCarriesMediaMissedWhileDetached(t *testing.T) {
+ now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ ctx := context.Background()
+ a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
+ h.Join(ctx, "r1", "sa", a.Peer)
+ b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
+ h.Join(ctx, "r1", "sb", b.Peer)
+ if err := h.SetMedia(ctx, "r1", "sb", b.Peer, MediaState{Muted: true}); err != nil {
+ t.Fatal(err)
+ }
+ drain(a.Sub)
+ drain(b.Sub)
+
+ h.Detach("r1", b.Peer, b.Sub)
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil {
+ t.Fatal(err)
+ }
+ if n := ringLen(h, "r1", "sb", b.Peer); n != 0 {
+ t.Fatalf("a media report to a detached peer must not be ringed, ring has %d", n)
+ }
+ res, err := h.Resume(ctx, "r1", "sb", b.Peer, map[PeerID]uint64{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(res.Replay) != 0 || len(res.Gap) != 0 {
+ t.Fatalf("nothing to replay and no gap, got %d replayed and %d gaps", len(res.Replay), len(res.Gap))
+ }
+ am, _ := rosterMedia(res.Roster, a.Peer)
+ if am == nil || !am.Muted {
+ t.Fatalf("the resume roster should say Ann is muted, got %+v", am)
+ }
+ // Bob's own state survived his detach: the participation holds it.
+ bm, _ := rosterMedia(res.Roster, b.Peer)
+ if bm == nil || !bm.Muted {
+ t.Fatalf("Bob's own report should survive his resume, got %+v", bm)
+ }
+
+ // A leave ends the participation, and with it the state: a fresh
+ // join starts unknown.
+ if err := h.Leave("r1", "sb", b.Peer); err != nil {
+ t.Fatal(err)
+ }
+ b2, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
+ h.Join(ctx, "r1", "sb", b2.Peer)
+ ev := drain(a.Sub)
+ var fresh *Roster
+ for _, m := range ev {
+ if m.Type == TypePeer && m.Peer == b2.Peer {
+ fresh = m.Roster
+ }
+ }
+ if bm, ok := rosterMedia(fresh, b2.Peer); !ok || bm != nil {
+ t.Fatalf("a rejoin must start with no media, got %+v (present %v)", bm, ok)
+ }
+}
+
+// TestSetMediaAuthority is Fanout's authority, reused: bound to this
+// room and session, admitted, and a member's standing re-derived.
+func TestSetMediaAuthority(t *testing.T) {
+ now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
+ standing := map[int64]bool{1: true, 2: true, 100: true}
+ h := New(Config{
+ Now: func() time.Time { return now },
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
+ if id >= 100 {
+ return KindGuest, standing[id], nil
+ }
+ return KindMember, standing[id], nil
+ },
+ })
+ ctx := context.Background()
+ m := MediaState{Muted: true}
+ a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, m); !errors.Is(err, ErrNotAdmitted) {
+ t.Fatalf("unjoined: %v", err)
+ }
+ h.Join(ctx, "r1", "sa", a.Peer)
+ if err := h.SetMedia(ctx, "r1", "sb", a.Peer, m); !errors.Is(err, ErrUnknownPeer) {
+ t.Fatalf("another session: %v", err)
+ }
+ if err := h.SetMedia(ctx, "r2", "sa", a.Peer, m); !errors.Is(err, ErrUnknownPeer) {
+ t.Fatalf("another room: %v", err)
+ }
+ g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
+ h.Join(ctx, "r1", "sg", g.Peer)
+ if err := h.SetMedia(ctx, "r1", "sg", g.Peer, m); !errors.Is(err, ErrNotAdmitted) {
+ t.Fatalf("waiting guest: %v", err)
+ }
+ b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
+ h.Join(ctx, "r1", "sb", b.Peer)
+ drain(a.Sub)
+ drain(b.Sub)
+
+ standing[1] = false
+ if err := h.SetMedia(ctx, "r1", "sa", a.Peer, m); !errors.Is(err, ErrNotYours) {
+ t.Fatalf("member whose standing is gone: %v", err)
+ }
+ ev := drain(b.Sub)
+ if len(ev) != 1 || ev[0].Type != TypeGone {
+ t.Fatalf("Bob should see Ann gone and no media, got %v", types(ev))
+ }
+}
+
+// TestSetMediaUnderConcurrency is for `make race`: reports, resumes,
+// rosters and a tick all at once, over the same participations. Media
+// is a pointer handed to rosters that are serialised on other
+// goroutines, so a write through it anywhere would show up here.
+func TestSetMediaUnderConcurrency(t *testing.T) {
+ now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
+ var clock sync.Mutex
+ h := New(Config{
+ Now: func() time.Time {
+ clock.Lock()
+ defer clock.Unlock()
+ return now
+ },
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
+ return KindMember, true, nil
+ },
+ })
+ ctx := context.Background()
+ a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
+ h.Join(ctx, "r1", "sa", a.Peer)
+ b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
+ h.Join(ctx, "r1", "sb", b.Peer)
+
+ var wg sync.WaitGroup
+ stop := make(chan struct{})
+ // Readers: marshal whatever arrives, the way the stream handler does.
+ for _, s := range []*Sub{a.Sub, b.Sub} {
+ wg.Add(1)
+ go func(s *Sub) {
+ defer wg.Done()
+ for {
+ select {
+ case <-stop:
+ return
+ case m, ok := <-s.C():
+ if !ok {
+ return
+ }
+ _, _ = json.Marshal(m)
+ }
+ }
+ }(s)
+ }
+ var writers sync.WaitGroup
+ for i, who := range []struct {
+ session string
+ peer PeerID
+ }{{"sa", a.Peer}, {"sb", b.Peer}} {
+ writers.Add(1)
+ go func(i int, session string, peer PeerID) {
+ defer writers.Done()
+ for n := 0; n < 200; n++ {
+ _ = h.SetMedia(ctx, "r1", session, peer, MediaState{Muted: n%2 == 0, CameraOff: (n+i)%3 == 0})
+ }
+ }(i, who.session, who.peer)
+ }
+ writers.Add(1)
+ go func() {
+ defer writers.Done()
+ for n := 0; n < 50; n++ {
+ h.Tick(ctx)
+ c, err := h.Open("r1", "sc", 3, "Cy", KindMember)
+ if err == nil {
+ _ = h.Join(ctx, "r1", "sc", c.Peer)
+ _, _ = json.Marshal(drain(c.Sub))
+ _ = h.Leave("r1", "sc", c.Peer)
+ }
+ }
+ }()
+ writers.Wait()
+ close(stop)
+ wg.Wait()
+}
diff --git a/internal/calls/hub/messages.go b/internal/calls/hub/messages.go
index f32ab43..45f3353 100644
--- a/internal/calls/hub/messages.go
+++ b/internal/calls/hub/messages.go
@@ -9,6 +9,7 @@ type Reason = protocol.Reason
type Message = protocol.Message
type Roster = protocol.Roster
type RosterPeer = protocol.RosterPeer
+type MediaState = protocol.MediaState
const (
KindGuest = protocol.KindGuest
@@ -27,6 +28,7 @@ const (
TypeReset = protocol.TypeReset
TypeChat = protocol.TypeChat
TypeShare = protocol.TypeShare
+ TypeMedia = protocol.TypeMedia
TypeHello = protocol.TypeHello
TypeRoster = protocol.TypeRoster
TypePeer = protocol.TypePeer
diff --git a/internal/calls/icons/icons.go b/internal/calls/icons/icons.go
index 0289e41..a1f5590 100644
--- a/internal/calls/icons/icons.go
+++ b/internal/calls/icons/icons.go
@@ -21,7 +21,9 @@ import "html/template"
var icons = map[string]template.HTML{
"mic": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/></svg>`,
+ "mic-off": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 10v2a7 7 0 0 0 12 5M15 9.34V5a3 3 0 0 0-5.68-1.33M9 9v3a3 3 0 0 0 5.12 2.12M18.89 13.23A7.12 7.12 0 0 0 19 12v-2M12 19v3M2 2l20 20"/></svg>`,
"video": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="12" height="14" rx="2"/><path d="m15 10 6-4v12l-6-4"/></svg>`,
+ "video-off": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.66 6H14a2 2 0 0 1 2 2v2.5l5.25-3.06A.5.5 0 0 1 22 7.87v8.2M16 16a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2M2 2l20 20"/></svg>`,
"monitor": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`,
"message-circle": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11a9 9 0 0 1-9 9H3l1-5a9 9 0 1 1 17-4Z"/></svg>`,
"sliders-horizontal": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg>`,
diff --git a/internal/calls/signal.go b/internal/calls/signal.go
index ef60b7a..ae2608f 100644
--- a/internal/calls/signal.go
+++ b/internal/calls/signal.go
@@ -137,7 +137,17 @@ func validateFanout(typ string, raw json.RawMessage) (json.RawMessage, int, stri
// {"text":"b"}, and the first of those is a payload whose raw bytes say
// one thing and whose decoded value says another. The walk counts every
// member it passes, so it can refuse both.
-func exactKey(raw json.RawMessage, want string) bool {
+func exactKey(raw json.RawMessage, want string) bool { return exactKeys(raw, want) }
+
+// exactKeys is exactKey for an object with more than one member: every
+// name in want, each spelled byte-for-byte and appearing exactly once,
+// in any order, and no other member at all.
+func exactKeys(raw json.RawMessage, want ...string) bool {
+ seen := make(map[string]bool, len(want))
+ allowed := make(map[string]bool, len(want))
+ for _, w := range want {
+ allowed[w] = true
+ }
dec := json.NewDecoder(bytes.NewReader(raw))
open, err := dec.Token()
if err != nil {
@@ -153,13 +163,11 @@ func exactKey(raw json.RawMessage, want string) bool {
return false
}
name, ok := key.(string)
- if !ok || name != want {
+ if !ok || !allowed[name] || seen[name] {
return false
}
+ seen[name] = true
members++
- if members > 1 {
- return false
- }
// The value, whatever shape it takes: read past it so the next
// token is the next key or the closing brace. Its type is the
// business of the decode above, not of this walk.
@@ -173,7 +181,36 @@ func exactKey(raw json.RawMessage, want string) bool {
if _, err := dec.Token(); err != nil {
return false
}
- return members == 1 && !dec.More()
+ return members == len(want) && !dec.More()
+}
+
+// maxMediaBytes bounds §15.19's payload before it is decoded. The
+// longest honest one, {"muted":false,"cameraOff":false}, is 34 bytes;
+// the rest is room for whitespace a client's encoder might add, and
+// nothing a peer can use to put more on anyone's stream — what goes out
+// is re-encoded from two booleans.
+const maxMediaBytes = 128
+
+// validateMedia is §15.19's shape: exactly {"muted": bool, "cameraOff":
+// bool}, both present, keys byte-exact and each once, no other member,
+// judged on the raw bytes the same way validateFanout judges chat and
+// share (Codex S4-1). It returns the decoded state, or a status and an
+// outcome word for the refusal log.
+func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) {
+ if len(raw) > maxMediaBytes {
+ return protocol.MediaState{}, http.StatusRequestEntityTooLarge, "size"
+ }
+ if !exactKeys(raw, "muted", "cameraOff") {
+ return protocol.MediaState{}, http.StatusBadRequest, "shape"
+ }
+ var p struct {
+ Muted *bool `json:"muted"`
+ CameraOff *bool `json:"cameraOff"`
+ }
+ if err := json.Unmarshal(raw, &p); err != nil || p.Muted == nil || p.CameraOff == nil {
+ return protocol.MediaState{}, http.StatusBadRequest, "shape"
+ }
+ return protocol.MediaState{Muted: *p.Muted, CameraOff: *p.CameraOff}, 0, ""
}
// canonicalJSON encodes one validated value the way it goes out on the
@@ -214,6 +251,7 @@ var knownSignalTypes = map[string]bool{
hub.TypeJoin: true, hub.TypeAdmit: true, hub.TypeLeave: true,
hub.TypeOffer: true, hub.TypeAnswer: true, hub.TypeCandidate: true,
hub.TypeReset: true, hub.TypeChat: true, hub.TypeShare: true,
+ hub.TypeMedia: true,
}
func safeSignalType(t string) string {
@@ -352,6 +390,20 @@ func (a *app) signal(w http.ResponseWriter, r *http.Request) {
return
}
herr = a.hub.Fanout(r.Context(), sqid, session, body.Peer, hub.Message{Type: body.Type, Payload: payload})
+ case hub.TypeMedia:
+ // §15.19: state about the sender, never targeted, and never
+ // carrying a seq or a generation — none of them means anything
+ // for a message the hub records rather than relays.
+ if body.To != "" || body.Seq != 0 || body.Gen != 0 {
+ a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"})
+ return
+ }
+ m, status, outcome := validateMedia(body.Payload)
+ if status != 0 {
+ a.refuseSignal(w, sqid, body.Peer, body.Type, status, outcome, map[string]any{"error": fanoutError(status)})
+ return
+ }
+ herr = a.hub.SetMedia(r.Context(), sqid, session, body.Peer, m)
default:
a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "type", map[string]any{"error": "bad-request"})
return
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index da333b5..782e9a8 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -160,6 +160,54 @@
cursor: pointer;
}
+/* Spec §15.19: a muted peer's badge sits beside their name — the
+ slashed microphone and the vendored badge's word, so the state is a
+ shape and a word and never a colour. It is a flex item of the caption
+ row, so it never covers the name. */
+.calls-tile-muted {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--rst-sp-1);
+ flex: none;
+}
+
+.calls-tile-muted .icon {
+ inline-size: 1em;
+ block-size: 1em;
+}
+
+.calls-tile-muted[hidden],
+.calls-tile-off[hidden] {
+ display: none;
+}
+
+/* A camera that is off sends black frames, or holds the last one. The
+ cover says what that is instead of leaving a frozen face or a black
+ rectangle to be read as a fault. It lies over the video rather than
+ hiding it: the element is also what plays the peer's audio. */
+.calls-tile-off {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--rst-sp-2);
+ background: var(--rst-surface-2);
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+}
+
+.calls-tile-off .icon {
+ inline-size: 2rem;
+ block-size: 2rem;
+}
+
+/* The caption stays above the cover. */
+.calls-tile-camera-off .calls-tile-name {
+ z-index: 1;
+}
+
/* Never colour alone: a failed connection says so in words, and the
weight is only a second cue on top of the sentence. */
.calls-tile-note {
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index 8b3b909..49e10a5 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -27,6 +27,9 @@
// §6 chat the broadcast lane and the panel: chat.js owns
// the words and the log, this file owns the wire
// and when the panel is usable
+// §15.19 media state who is muted and whose camera is off: our own
+// reported on the broadcast lane, everyone else's
+// read from the roster and `media`
//
// House rules this file is written under: no library, no inline script
// (the page's CSP is `default-src 'self'`), nothing built with
@@ -64,6 +67,13 @@
const muteState = $('mute-state');
const cameraState = $('camera-state');
const shareState = $('share-state');
+ // §15.19: the tile marks, rendered by the server so they are the
+ // vendored badge and the app's own icons rather than a copy of either,
+ // and the polite region that says a change out loud. #status is no
+ // use for that: renderStatus rewrites it on every render.
+ const mutedMark = $('tile-muted');
+ const cameraOffMark = $('tile-camera-off');
+ const mediaLive = $('media-live');
// The chat panel's root (§6). chat.js owns everything inside it; this
// file owns the wire under it and the moment it becomes usable.
const chatEl = $('chat');
@@ -241,6 +251,12 @@
const names = new Map(); // peer id -> display name, from the server only
const frames = new Map(); // peer id -> frames rendered, for the test hook
const tiles = new Map(); // tile key -> {figure, video}
+ // §15.19: peer id -> {muted, cameraOff}, for the peers that have
+ // reported. A peer absent here is unknown and is drawn as neither.
+ const remoteMedia = new Map();
+ // The state this tab last put on the broadcast lane, as mediaKey
+ // writes it, so a toggle that changes nothing sends nothing.
+ let mediaSent = '';
// §5.2's list and §5.3's clocks. `expiresAt`, `renewAt`, `fetchedAt`
// and `lastFetchAt` are all milliseconds since the epoch: the server
@@ -504,6 +520,11 @@
muteButton.disabled = !hasAudio;
cameraButton.disabled = !hasVideo;
}
+ // §15.19: every path that can change what we send ends here — the
+ // two toggles, a device switch, a stream that lost a track — so the
+ // report is made here too, and the tile marks follow.
+ reportMedia();
+ if (tiles.has('self')) markTile('self', localMedia());
}
function stopLocal() {
@@ -838,7 +859,10 @@
// §4.3's fan-out pair. They carry no `to`, no `seq` and no `gen`, so
// nothing about them goes through §4.6's inbound rules: the hub
// rebuilt the envelope and there is nothing to dedupe against.
- 'chat', 'share'];
+ 'chat', 'share',
+ // §15.19: one peer's media state changed. State, not an event —
+ // the roster carries it too — so a missed one costs nothing.
+ 'media'];
function streamPath(peer) {
let path = '/m/' + encodeURIComponent(sqid) + '/stream';
@@ -1140,6 +1164,7 @@
case 'candidate': return onCandidate(msg);
case 'chat': return onChat(msg);
case 'share': return onShare(msg);
+ case 'media': return onMedia(msg);
default: return undefined;
}
}
@@ -1207,12 +1232,14 @@
const rp = (msg.roster && msg.roster.peers && msg.roster.peers[0]) || {};
const g = rp.gen || 1;
if (rp.name) names.set(id, rp.name);
+ learnMedia(id, rp.media);
ensurePC(id, g);
renderTiles();
}
function onGone(msg) {
if (!msg.from) return;
+ remoteMedia.delete(msg.from);
closePC(msg.from, true);
dropWaiting(msg.from);
renderTiles();
@@ -1390,6 +1417,8 @@
// roster costs one `setEnabled(true)` and nothing else.
mountChat();
chatEnabled(true);
+ // §15.19: before the tiles are drawn, so they are drawn right.
+ rosterMedia(roster);
renderTiles();
renderQueue();
renderStatus();
@@ -3879,6 +3908,144 @@
return out;
}
+ // ---------------------------------------------------------------
+ // Media state (§15.19): who is muted, whose camera is off
+ // ---------------------------------------------------------------
+ //
+ // State, not an event. The hub keeps each peer's last report and puts
+ // it in every roster, so what a tile shows is always the roster's word
+ // or a `media` since; nothing here is replayed or reconstructed. A
+ // peer that never reports — a client from before this — is unknown,
+ // and unknown is drawn as neither muted nor camera-off.
+
+ // mediaFrom accepts exactly the two booleans and nothing looser, for a
+ // roster entry or a `media` payload alike. Pure, and lifted by name in
+ // the node test.
+ function mediaFrom(value) {
+ if (!value || typeof value !== 'object') return null;
+ if (typeof value.muted !== 'boolean' || typeof value.cameraOff !== 'boolean') return null;
+ return { muted: value.muted, cameraOff: value.cameraOff };
+ }
+
+ function sameMedia(a, b) {
+ return !!a && !!b && a.muted === b.muted && a.cameraOff === b.cameraOff;
+ }
+
+ function mediaKey(m) {
+ return (m.muted ? 'm' : '-') + (m.cameraOff ? 'c' : '-');
+ }
+
+ // localMedia is what this tab is actually sending. No microphone at
+ // all is muted as far as anyone listening can tell, and no camera is
+ // off; the preference alone would claim a microphone that is not there.
+ function localMedia() {
+ const hasAudio = !!(local && local.getAudioTracks().length);
+ const hasVideo = !!(local && local.getVideoTracks().length);
+ return { muted: !!prefs.muted || !hasAudio, cameraOff: !!prefs.cameraOff || !hasVideo };
+ }
+
+ // reportMedia puts our state on the broadcast lane, once per change,
+ // and only once admitted: before that the hub refuses it, and the
+ // roster that admits us is where it is sent from. A refusal forgets
+ // what was sent, so the next toggle or roster tries again — which
+ // against a server from before §15.19 is a quiet 400 each time.
+ function reportMedia() {
+ if (!state.admitted || state.stopped || !state.peer) return;
+ const now = localMedia();
+ const key = mediaKey(now);
+ if (key === mediaSent) return;
+ mediaSent = key;
+ broadcast('media', now).then((ok) => {
+ if (!ok && mediaSent === key) mediaSent = '';
+ });
+ }
+
+ // rosterMedia takes a roster's word on everybody's media. On ours as
+ // well: missing is a participation the hub has never heard from — a
+ // fresh join, or a restart — and different is a report lost while the
+ // stream was down, and either way it is told again.
+ function rosterMedia(roster) {
+ for (const rp of roster.peers || []) {
+ if (!rp || !rp.peer) continue;
+ if (rp.peer !== state.peer) {
+ learnMedia(rp.peer, rp.media);
+ continue;
+ }
+ const now = localMedia();
+ mediaSent = sameMedia(mediaFrom(rp.media), now) ? mediaKey(now) : '';
+ }
+ reportMedia();
+ }
+
+ // learnMedia records a roster's or a peer event's word on one peer.
+ // Absent is unknown, and forgets anything older: the roster is the
+ // authority, and a participation that has not reported has not.
+ function learnMedia(id, value) {
+ const m = mediaFrom(value);
+ if (m) remoteMedia.set(id, m);
+ else remoteMedia.delete(id);
+ }
+
+ // onMedia is one peer's change. Only a peer we hold a connection to
+ // is drawn; the roster will carry the rest.
+ function onMedia(msg) {
+ const id = msg.from;
+ const next = mediaFrom(msg.payload);
+ if (!id || id === state.peer || !next) return;
+ const before = remoteMedia.get(id) || null;
+ remoteMedia.set(id, next);
+ announceMedia(mediaChange(msg.name || names.get(id) || 'Someone', before, next));
+ markTile(id, next);
+ }
+
+ // mediaChange is the sentence a change earns, or '' for none. A first
+ // report is not news — it is somebody arriving the way they arrived —
+ // so only a known state that moved is said out loud. Pure, and lifted
+ // by name in the node test.
+ function mediaChange(name, before, after) {
+ if (!before || !after) return '';
+ const parts = [];
+ if (before.muted !== after.muted) {
+ parts.push(name + (after.muted ? ' muted their microphone.' : ' unmuted their microphone.'));
+ }
+ if (before.cameraOff !== after.cameraOff) {
+ parts.push(name + (after.cameraOff ? ' turned their camera off.' : ' turned their camera on.'));
+ }
+ return parts.join(' ');
+ }
+
+ function announceMedia(text) {
+ if (!text || !mediaLive) return;
+ mediaLive.textContent = text;
+ }
+
+ // tileMarks builds one tile's pair of marks from the server's
+ // templates: the muted badge beside the name, and the camera-off cover
+ // over the video. The cover sits over the element rather than hiding
+ // it — the element is also what plays the peer's audio.
+ function tileMarks() {
+ const muted = document.createElement('span');
+ muted.className = 'calls-tile-muted';
+ muted.hidden = true;
+ if (mutedMark) muted.append(mutedMark.content.cloneNode(true));
+ else muted.textContent = 'Muted';
+ const off = document.createElement('div');
+ off.className = 'calls-tile-off';
+ off.hidden = true;
+ if (cameraOffMark) off.append(cameraOffMark.content.cloneNode(true));
+ else off.textContent = 'Camera off';
+ return { muted, off };
+ }
+
+ // markTile shows or hides one tile's marks. `media` null is unknown.
+ function markTile(key, media) {
+ const t = tiles.get(key);
+ if (!t || !t.muted) return;
+ t.muted.hidden = !(media && media.muted);
+ t.off.hidden = !(media && media.cameraOff);
+ t.figure.classList.toggle('calls-tile-camera-off', !!(media && media.cameraOff));
+ }
+
// ---------------------------------------------------------------
// Terminal states (§4.5, §4.2)
// ---------------------------------------------------------------
@@ -3921,6 +4088,7 @@
// transceivers are already closed with their connections, so this is
// the capture itself and the button's own state.
stopSharing();
+ remoteMedia.clear();
renderTiles();
iceWarning(false);
controlsEnabled(false);
@@ -4029,7 +4197,10 @@
tiles.delete(key);
}
}
- if (local) tile('self', selfName, local, true, '');
+ if (local) {
+ tile('self', selfName, local, true, '');
+ markTile('self', localMedia());
+ }
for (const [id, entry] of pcs) {
const name = names.get(id) || 'Someone';
// The track as well as the stream: which video track a tile is
@@ -4039,6 +4210,7 @@
// which case tile() falls back to the stream's own first video
// track, which is unambiguous by then.
tile(id, name, entry.stream, false, tileNote(id, entry), entry.cameraTrack);
+ markTile(id, remoteMedia.get(id) || null);
// The screen's own tile carries no note: §5.3's and §5.5's
// sentences are about a connection, and this is the same
// connection as the camera tile above, which is already saying it.
@@ -4078,6 +4250,7 @@
if (!t) {
const figure = document.createElement('figure');
figure.className = 'calls-tile';
+ figure.dataset.tile = key;
const video = document.createElement('video');
video.autoplay = true;
video.playsInline = true;
@@ -4113,12 +4286,20 @@
});
label.append(nameEl, note, retry, play);
figure.append(video, label);
+ // §15.19's two marks, hidden until markTile says otherwise. A
+ // screen has neither: it has no microphone, and it is never "off".
+ const marks = key.slice(-SHARE_SUFFIX.length) === SHARE_SUFFIX ? null : tileMarks();
+ if (marks) {
+ nameEl.after(marks.muted);
+ figure.append(marks.off);
+ }
tilesEl.append(figure);
// `self` is kept on the record because rearmFrames reads it: the
// local preview is never counted, and only tile() knows which
// tile is ours.
t = {
figure, video, label, nameEl, note, retry, play,
+ muted: marks && marks.muted, off: marks && marks.off,
remove: null, stream: null, track: null, self: isSelf,
// What judgeRender remembers between polls: the element's own
// clock, the decoder's count, and how long the two have
@@ -5092,6 +5273,10 @@
// the message is what draws the tile.
sharing: !!share,
shares: sharingPeers(),
+ // §15.19: what this tab reports, and what it has been told, by
+ // peer id. Peers that never reported are absent.
+ media: localMedia(),
+ remoteMedia: Object.fromEntries(remoteMedia),
};
},
};
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html
index 992527f..ff1fb23 100644
--- a/internal/calls/templates/room.html
+++ b/internal/calls/templates/room.html
@@ -87,6 +87,20 @@
</div>
<div id="queue" class="calls-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div>
<p id="status" class="calls-status" role="status"></p>
+ {{/* Spec §15.19. Someone muting or turning their camera off, said
+ politely and once. A region of its own for the reason
+ #device-note has one: renderStatus rewrites #status on every
+ render, and would take the sentence away before it was read.
+ Present from the first paint, empty, so a reader is already
+ watching it when room.js first writes to it. */}}
+ <p id="media-live" class="calls-sr-only" role="status" aria-live="polite"></p>
+ {{/* The two tile marks room.js clones onto each tile, rendered here so
+ they are the vendored badge and this app's own icons rather than a
+ copy of either in script. The badge's word is the state, for a
+ reader and for anyone who cannot tell a slashed microphone from a
+ plain one; the icon is on top of it, never instead. */}}
+ <template id="tile-muted">{{icon "mic-off"}}{{template "badge" dict "Label" "Muted"}}</template>
+ <template id="tile-camera-off">{{icon "video-off"}}<span>Camera off</span></template>
</div>
{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener">End the meeting</a></p>{{end}}
{{end}}
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index d139c84..33b92d8 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -84,6 +84,7 @@ var scenarios = []string{
"layout",
"devices",
"call",
+ "mute",
"switching",
"share",
"mutualshare",
diff --git a/internal/callstest/media_test.go b/internal/callstest/media_test.go
new file mode 100644
index 0000000..008549f
--- /dev/null
+++ b/internal/callstest/media_test.go
@@ -0,0 +1,170 @@
+package callstest
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Spec §15.19 over the real handler: a peer's own media report reaches
+// the other admitted peer as `media`, from the hub's envelope, with the
+// payload re-encoded from the two booleans the handler judged — never
+// the bytes the sender chose — and the sender hears nothing back.
+func TestMediaReachesTheOtherPeerCanonicallyAndNotTheSender(t *testing.T) {
+ a, sqid, pa, sb := chatPair(t)
+
+ // Whitespace, and the keys in the other order: both legal, and
+ // neither must survive onto Bob's stream.
+ body := `{"peer":"` + pa + `","type":"media","payload":{ "cameraOff" : false , "muted" : true }}`
+ res, rb := a.signalRaw(sqid, body)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("media: %d %s", res.StatusCode, rb)
+ }
+ ev, data, ok := sb.nextOf("media", 2*time.Second)
+ if !ok || ev != "media" {
+ t.Fatal("Bob did not receive the media report")
+ }
+ s := string(data)
+ if !strings.Contains(s, `"from":"`+pa+`"`) || !strings.Contains(s, `"name":"paul"`) ||
+ !strings.Contains(s, `"payload":{"muted":true,"cameraOff":false}`) {
+ t.Fatalf("envelope %s", s)
+ }
+ if strings.Contains(s, `"seq"`) || strings.Contains(s, `"to"`) || strings.Contains(s, `"gen"`) {
+ t.Fatalf("a media envelope carries no seq, to or gen: %s", s)
+ }
+
+ // The same state again is accepted and silent.
+ res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":true,"cameraOff":false}}`)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("repeat: %d", res.StatusCode)
+ }
+ if ev, _, ok := sb.next(300 * time.Millisecond); ok && ev == "media" {
+ t.Fatal("a report that changed nothing reached Bob")
+ }
+}
+
+// §15.19's "state, not event": a peer who joins after the report reads
+// it from the roster it is admitted with, and a peer who has never
+// reported — an old client — is in that roster with no media at all.
+func TestMediaIsInTheRosterALateJoinerGets(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ a := x.browser(t)
+ a.signIn("paul@example.com")
+ sa := a.stream("/m/" + sqid + "/stream")
+ defer sa.close()
+ pa, _ := sa.hello()
+ a.signal(sqid, pa, `{"type":"join"}`)
+ sa.nextOf("roster", 2*time.Second)
+ if res, rb := a.signal(sqid, pa, `{"type":"media","payload":{"muted":true,"cameraOff":true}}`); res.StatusCode != http.StatusOK {
+ t.Fatalf("media: %d %s", res.StatusCode, rb)
+ }
+
+ b := newMemberBrowser(t, x, "paul@rslw.com")
+ sb := b.stream("/m/" + sqid + "/stream")
+ defer sb.close()
+ pb, _ := sb.hello()
+ b.signal(sqid, pb, `{"type":"join"}`)
+ _, data, ok := sb.nextOf("roster", 2*time.Second)
+ if !ok {
+ t.Fatal("Bob got no roster")
+ }
+ s := string(data)
+ want := `"peer":"` + pa + `","name":"paul","kind":"member","gen":1,"media":{"muted":true,"cameraOff":true}`
+ if !strings.Contains(s, want) {
+ t.Fatalf("Bob's roster should carry Paul's media; got %s", s)
+ }
+ // Bob has not reported, so his own entry carries no media key.
+ if strings.Count(s, `"media"`) != 1 {
+ t.Fatalf("only Paul has reported; roster %s", s)
+ }
+}
+
+// The shape is exact, and judged on raw bytes the way chat and share
+// are (§15.15): both keys, spelled exactly, each once, booleans, and
+// nothing else — and never targeted.
+func TestMediaShapes(t *testing.T) {
+ a, sqid, pa, _ := chatPair(t)
+ bad := []struct {
+ name, payload string
+ }{
+ {"missing cameraOff", `{"muted":true}`},
+ {"missing muted", `{"cameraOff":true}`},
+ {"empty object", `{}`},
+ {"a third key", `{"muted":true,"cameraOff":false,"deafened":true}`},
+ {"a capitalised key", `{"Muted":true,"cameraOff":false}`},
+ {"snake case", `{"muted":true,"camera_off":false}`},
+ {"a duplicated key", `{"muted":true,"muted":false,"cameraOff":false}`},
+ {"a string for a bool", `{"muted":"yes","cameraOff":false}`},
+ {"null for a bool", `{"muted":null,"cameraOff":false}`},
+ {"a number for a bool", `{"muted":1,"cameraOff":0}`},
+ {"not an object", `[true,false]`},
+ {"trailing content", `{"muted":true,"cameraOff":false} {}`},
+ }
+ for _, c := range bad {
+ t.Run(c.name, func(t *testing.T) {
+ body := `{"peer":"` + pa + `","type":"media","payload":` + c.payload + `}`
+ res, rb := a.signalRaw(sqid, body)
+ if res.StatusCode != http.StatusBadRequest || !strings.Contains(string(rb), `"error":"bad-request"`) {
+ t.Fatalf("%d %s, want 400 bad-request", res.StatusCode, rb)
+ }
+ })
+ }
+ t.Run("no payload at all", func(t *testing.T) {
+ res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media"}`)
+ if res.StatusCode != http.StatusBadRequest {
+ t.Fatalf("%d %s, want 400", res.StatusCode, rb)
+ }
+ })
+ t.Run("targeted", func(t *testing.T) {
+ res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","to":"`+strings.Repeat("a", 32)+`","payload":{"muted":true,"cameraOff":false}}`)
+ if res.StatusCode != http.StatusBadRequest {
+ t.Fatalf("%d %s, want 400", res.StatusCode, rb)
+ }
+ })
+ t.Run("with a seq", func(t *testing.T) {
+ res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","seq":3,"payload":{"muted":true,"cameraOff":false}}`)
+ if res.StatusCode != http.StatusBadRequest {
+ t.Fatalf("%d %s, want 400", res.StatusCode, rb)
+ }
+ })
+ t.Run("padded past the cap", func(t *testing.T) {
+ payload := `{"muted":true,` + strings.Repeat(" ", 128) + `"cameraOff":false}`
+ res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":`+payload+`}`)
+ if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(rb), `"error":"too-large"`) {
+ t.Fatalf("%d %s, want 413 too-large", res.StatusCode, rb)
+ }
+ })
+}
+
+// A waiting guest has nobody to tell: its report is the usual 404, and
+// it hears nothing of the room's.
+func TestMediaFromAWaitingGuestIsTheUsual404(t *testing.T) {
+ x, sqid, tok := memberWithMeeting(t)
+ enableWaitingRoom(t, x, sqid)
+ a := x.browser(t)
+ a.signIn("paul@example.com")
+ sa := a.stream("/m/" + sqid + "/stream")
+ defer sa.close()
+ pa, _ := sa.hello()
+ a.signal(sqid, pa, `{"type":"join"}`)
+ sa.nextOf("roster", 2*time.Second)
+
+ g := x.browser(t)
+ g.get("/j/" + tok)
+ g.signIn("waiting-guest@example.com")
+ gs := g.stream("/m/" + sqid + "/stream")
+ defer gs.close()
+ gpeer, _ := gs.hello()
+ g.signal(sqid, gpeer, `{"type":"join"}`)
+ sa.nextOf("knock", 2*time.Second)
+
+ res, rb := g.signal(sqid, gpeer, `{"type":"media","payload":{"muted":true,"cameraOff":false}}`)
+ if res.StatusCode != http.StatusNotFound || !strings.Contains(string(rb), `"error":"not-found"`) {
+ t.Fatalf("a waiting guest's media must be the usual 404: %d %s", res.StatusCode, rb)
+ }
+ a.signal(sqid, pa, `{"type":"media","payload":{"muted":true,"cameraOff":false}}`)
+ if ev, _, ok := gs.next(400 * time.Millisecond); ok && ev == "media" {
+ t.Fatal("a waiting guest heard a media report")
+ }
+}
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index be0ff5b..8d3cfc0 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -37,6 +37,11 @@ struct Input: Decodable {
await call.leave()
exit(0)
}
+ if command == "mute" {
+ await call.mute()
+ print("{\"muted\":\(await call.muted)}")
+ fflush(stdout)
+ }
if command == "chat" {
await call.sendChat("native chat probe")
print("{\"chatCommandHandled\":true}")
@@ -79,6 +84,10 @@ struct Input: Decodable {
"receivedChatProbe": call.chat.contains { $0.hasSuffix(": browser chat probe") },
"sentChatProbe": call.chat.contains("You: native chat probe"),
"receivedChatCount": call.receivedChatCount,
+ // §15.19: remote peers this app has been told are muted.
+ "remoteMuted": (call.state?.peers ?? []).filter {
+ $0.peer != call.state?.peer && call.isMuted($0.peer)
+ }.count,
]), let text = String(data: data, encoding: .utf8) {
print(text)
fflush(stdout)
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index 2a010db..a6307b5 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -116,6 +116,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
guard epoch == joiningEpoch, bridge === client else { throw CancellationError() }
localTrack = media.localVideo
currentCamera = media.currentCamera
+ reportMedia()
audio.refresh()
media.audio.reduceNoise = reduceNoise
audioProcessing = media.audio.report()
@@ -159,10 +160,35 @@ private final class Events: NSObject, BridgeListenerProtocol {
public func mute() {
muted.toggle()
media.localAudio?.isEnabled = !muted
+ reportMedia()
}
public func toggleCamera() {
cameraOff.toggle()
media.localVideo?.isEnabled = !cameraOff
+ reportMedia()
+ }
+
+ // ---------------------------------------------------------------
+ // Media state (§15.19)
+ // ---------------------------------------------------------------
+
+ /// Tells the core what this app is sending. The core reports it once
+ /// admitted, again whenever a roster says the server has not got it —
+ /// a resume, a restart — and at once on a change, so this is called on
+ /// each toggle and once the tracks exist, and nowhere else. No track at
+ /// all is muted, or off, as far as anyone listening can tell.
+ private func reportMedia() {
+ try? bridge?.setMedia(muted || media.localAudio == nil, cameraOff: cameraOff || media.localVideo == nil)
+ }
+
+ /// A remote peer's reported state, or nil when it has not reported.
+ public func peerMedia(_ peer: String) -> PeerMedia? { Self.peerMedia(peer, in: state) }
+ /// Whether a remote peer has said it is muted. Unknown is not muted.
+ public func isMuted(_ peer: String) -> Bool { peerMedia(peer)?.muted ?? false }
+ /// Whether a remote peer has said its camera is off. Unknown is not off.
+ public func isCameraOff(_ peer: String) -> Bool { peerMedia(peer)?.cameraOff ?? false }
+ nonisolated static func peerMedia(_ peer: String, in state: RoomState?) -> PeerMedia? {
+ state?.peers.first { $0.peer == peer }?.media
}
// ---------------------------------------------------------------
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift
index 93a201c..e61f42b 100644
--- a/native/apple/Sources/CallsKit/MacCallView.swift
+++ b/native/apple/Sources/CallsKit/MacCallView.swift
@@ -121,8 +121,11 @@
)
.aspectRatio(16 / 9, contentMode: .fit)
ForEach(peers) { peer in
- MacVideoTile(track: call.remoteTracks[peer.peer], label: peer.name)
- .aspectRatio(16 / 9, contentMode: .fit)
+ MacVideoTile(
+ track: call.remoteTracks[peer.peer], label: peer.name,
+ muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer)
+ )
+ .aspectRatio(16 / 9, contentMode: .fit)
}
}
}
@@ -130,7 +133,9 @@
GeometryReader { geometry in
ZStack(alignment: .bottomTrailing) {
if let peer = focusedPeer {
- MacVideoTile(track: call.remoteTracks[peer.peer], label: peer.name)
+ MacVideoTile(
+ track: call.remoteTracks[peer.peer], label: peer.name,
+ muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer))
} else {
ContentUnavailableView(
"Waiting for others", systemImage: "person.2",
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift
index b1429ff..7475eaa 100644
--- a/native/apple/Sources/CallsKit/MacVideoTile.swift
+++ b/native/apple/Sources/CallsKit/MacVideoTile.swift
@@ -6,6 +6,8 @@
struct MacVideoTile: View {
let track: RTCVideoTrack?
let label: String
+ /// §15.19: the peer has said it is muted. False for unknown.
+ var muted = false
var mirrored = false
var cameraOff = false
@@ -27,9 +29,10 @@
.lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6)
.background(.black.opacity(0.65), in: Capsule()).padding(10)
}
+ .overlay(alignment: .topLeading) { if muted { MutedBadge().padding(10) } }
.clipShape(RoundedRectangle(cornerRadius: 16))
.accessibilityElement(children: .ignore)
- .accessibilityLabel(label)
+ .accessibilityLabel(tileAccessibilityLabel(label, muted: muted))
.accessibilityValue(
cameraOff ? "Camera off" : track == nil ? "Waiting for video" : "Video on")
}
diff --git a/native/apple/Sources/CallsKit/MutedBadge.swift b/native/apple/Sources/CallsKit/MutedBadge.swift
new file mode 100644
index 0000000..a06440b
--- /dev/null
+++ b/native/apple/Sources/CallsKit/MutedBadge.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+
+/// Spec §15.19: the mark a video tile carries while its peer is muted.
+/// A slashed microphone and the word beside it, so the state is a shape
+/// and a word and never a colour. The tile folds it into its own
+/// accessibility label, so this hides itself from assistive technology
+/// rather than being read twice.
+public struct MutedBadge: View {
+ public init() {}
+ public var body: some View {
+ Label("Muted", systemImage: "mic.slash.fill")
+ .font(.caption.bold()).foregroundStyle(.white)
+ .padding(.horizontal, 8).padding(.vertical, 4)
+ .background(.black.opacity(0.65), in: Capsule())
+ .accessibilityHidden(true)
+ }
+}
+
+/// What a tile's accessibility label says: the name, and "muted" when it
+/// is. Shared by the Mac and iPhone tiles so VoiceOver hears one phrase.
+public func tileAccessibilityLabel(_ label: String, muted: Bool) -> String {
+ muted ? "\(label), muted" : label
+}
diff --git a/native/apple/Sources/CallsKit/Wire.swift b/native/apple/Sources/CallsKit/Wire.swift
index 6abe484..4659648 100644
--- a/native/apple/Sources/CallsKit/Wire.swift
+++ b/native/apple/Sources/CallsKit/Wire.swift
@@ -5,8 +5,17 @@ public struct RosterPeer: Decodable, Identifiable {
public let name: String
public let kind: String
public let gen: Int64?
+ /// The peer's own last report of its microphone and camera (spec
+ /// §15.19). Nil is unknown — a client that predates the report, or one
+ /// that has not made it yet — and is shown as neither muted nor off.
+ public let media: PeerMedia?
public var id: String { peer }
}
+/// What a peer says about its own outgoing media (spec §15.19).
+public struct PeerMedia: Decodable, Equatable, Sendable {
+ public let muted: Bool
+ public let cameraOff: Bool
+}
public struct RoomState: Decodable {
public let phase: String
public let peer: String
diff --git a/native/apple/Tests/CallsKitTests/PeerMediaTests.swift b/native/apple/Tests/CallsKitTests/PeerMediaTests.swift
new file mode 100644
index 0000000..5e5deed
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/PeerMediaTests.swift
@@ -0,0 +1,51 @@
+import XCTest
+
+@testable import CallsKit
+
+/// Spec §15.19 on the native side: the core puts each peer's reported
+/// media on the roster snapshot, and the tiles read it through Call. What
+/// is held here is the part that can be wrong without a call running —
+/// the wire shape, and unknown never reading as muted or off.
+final class PeerMediaTests: XCTestCase {
+ private func state(_ json: String) throws -> RoomState {
+ try JSONDecoder().decode(RoomState.self, from: Data(json.utf8))
+ }
+
+ func testARosterCarriesReportedMediaAndLeavesTheRestUnknown() throws {
+ let s = try state(
+ #"""
+ {"phase":"admitted","peer":"me","boot":"b","waiting":[],"peers":[
+ {"peer":"me","name":"Me","kind":"member","media":{"muted":false,"cameraOff":false}},
+ {"peer":"bea","name":"Bea","kind":"member","gen":1,"media":{"muted":true,"cameraOff":true}},
+ {"peer":"old","name":"Old","kind":"guest","gen":1}
+ ]}
+ """#)
+ XCTAssertEqual(Call.peerMedia("bea", in: s), PeerMedia(muted: true, cameraOff: true))
+ // A client from before §15.19 never reports: the snapshot says
+ // nothing, and nothing is guessed.
+ XCTAssertNil(Call.peerMedia("old", in: s))
+ XCTAssertNil(Call.peerMedia("nobody", in: s))
+ XCTAssertNil(Call.peerMedia("bea", in: nil))
+ }
+
+ func testAMalformedReportDoesNotCostTheWholeState() throws {
+ // The core only ever forwards two booleans, but a snapshot the app
+ // cannot decode is a call that stops updating, so the shape is pinned.
+ XCTAssertThrowsError(
+ try state(
+ #"{"phase":"admitted","peer":"me","boot":"b","waiting":[],"peers":[{"peer":"x","name":"X","kind":"member","media":{"muted":"yes","cameraOff":false}}]}"#
+ ))
+ }
+
+ @MainActor func testUnknownIsNeitherMutedNorOff() {
+ let call = Call(playAudio: false)
+ XCTAssertFalse(call.isMuted("anyone"))
+ XCTAssertFalse(call.isCameraOff("anyone"))
+ XCTAssertNil(call.peerMedia("anyone"))
+ }
+
+ func testTheTileSaysMutedInWordsForVoiceOver() {
+ XCTAssertEqual(tileAccessibilityLabel("Bea", muted: true), "Bea, muted")
+ XCTAssertEqual(tileAccessibilityLabel("Bea", muted: false), "Bea")
+ }
+}
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go
index cafacab..d00541f 100644
--- a/native/bridge/bridge.go
+++ b/native/bridge/bridge.go
@@ -106,6 +106,11 @@ func (c *Client) Send(typ, to string, generation int64, payload string) error {
func (c *Client) Leave() { c.room.Leave() }
func (c *Client) Close() { c.room.Close() }
+// SetMedia is this app's own microphone and camera state (spec §15.19).
+// The core reports it once admitted, again whenever a roster says the
+// server has not got it, and at once on every change.
+func (c *Client) SetMedia(muted, cameraOff bool) error { return c.room.SetMedia(muted, cameraOff) }
+
// RegisterDevice hands the server an Apple device token so a knock can
// reach this device when the app is closed. Best effort: the caller
// ignores the error beyond logging, because a device that did not
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index ab160d9..5d1d88a 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -125,8 +125,11 @@ struct PhoneCall: View {
)
.aspectRatio(4 / 3, contentMode: .fit)
ForEach(peers) { peer in
- PhoneVideoTile(track: call.remoteTracks[peer.peer], label: peer.name)
- .aspectRatio(4 / 3, contentMode: .fit)
+ PhoneVideoTile(
+ track: call.remoteTracks[peer.peer], label: peer.name,
+ muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer)
+ )
+ .aspectRatio(4 / 3, contentMode: .fit)
}
}
}
@@ -134,7 +137,9 @@ struct PhoneCall: View {
GeometryReader { geometry in
ZStack(alignment: .bottomTrailing) {
if let peer = focused {
- PhoneVideoTile(track: call.remoteTracks[peer.peer], label: peer.name)
+ PhoneVideoTile(
+ track: call.remoteTracks[peer.peer], label: peer.name,
+ muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer))
} else {
ContentUnavailableView(
"Waiting for others", systemImage: "person.2",
@@ -233,6 +238,8 @@ struct PhoneCall: View {
struct PhoneVideoTile: View {
let track: RTCVideoTrack?
let label: String
+ /// §15.19: the peer has said it is muted. False for unknown.
+ var muted = false
var mirrored = false
var cameraOff = false
var body: some View {
@@ -250,8 +257,10 @@ struct PhoneVideoTile: View {
.padding(.horizontal, 10).padding(.vertical, 6)
.background(.black.opacity(0.65), in: Capsule()).padding(8)
}
+ .overlay(alignment: .topLeading) { if muted { MutedBadge().padding(8) } }
.clipShape(RoundedRectangle(cornerRadius: 18))
- .accessibilityElement(children: .ignore).accessibilityLabel(label)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(tileAccessibilityLabel(label, muted: muted))
.accessibilityValue(cameraOff ? "Camera off" : track == nil ? "Waiting for video" : "Video on")
}
}
diff --git a/protocol/messages.go b/protocol/messages.go
index cbb5670..67cac5e 100644
--- a/protocol/messages.go
+++ b/protocol/messages.go
@@ -48,6 +48,10 @@ const (
TypeReset = "reset"
TypeChat = "chat" // slice 4
TypeShare = "share" // slice 4
+ // TypeMedia goes both ways: a peer reports its own MediaState
+ // upstream, and the hub fans the change out downstream as
+ // {type, from, name, payload: MediaState} (spec §15.19).
+ TypeMedia = "media"
)
// Downstream types the server emits.
@@ -99,4 +103,20 @@ type RosterPeer struct {
Name string `json:"name"`
Kind string `json:"kind"`
Gen uint64 `json:"gen,omitempty"`
+ // Media is the peer's own last report of its microphone and camera,
+ // absent until it has made one — which is every client that predates
+ // §15.19, and every peer for the moment between admission and its
+ // first report. Absent means unknown, and a client shows unknown as
+ // neither muted nor camera-off. Never written through: a snapshot
+ // copies the pointer, so a change replaces it.
+ Media *MediaState `json:"media,omitempty"`
+}
+
+// MediaState is what a peer says about its own outgoing media (spec
+// §15.19): whether its microphone is muted and whether its camera is
+// off. It is a claim about what the peer is choosing to send, not a
+// measurement of what arrives — the server never sees media.
+type MediaState struct {
+ Muted bool `json:"muted"`
+ CameraOff bool `json:"cameraOff"`
}
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 819b624..399a780 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -1136,6 +1136,31 @@ function shareControl(page) {
});
}
+// tileMarks is what spec §15.19 draws on one peer's tile, read the way a
+// person reads it: whether the muted badge and the camera-off cover are
+// showing, and the words each carries — the words, not the colour or
+// the icon, being the part AGENTS.md makes the contract.
+function tileMarks(page, key) {
+ return page.evaluate((k) => {
+ const f = document.querySelector('#tiles figure[data-tile="' + k + '"]');
+ if (!f) return null;
+ const muted = f.querySelector('.calls-tile-muted');
+ const off = f.querySelector('.calls-tile-off');
+ const shown = (el) => !!(el && !el.hidden && el.getClientRects().length);
+ return {
+ muted: shown(muted),
+ mutedText: muted ? (muted.textContent || '').trim() : '',
+ cameraOff: shown(off),
+ offText: off ? (off.textContent || '').trim() : '',
+ caption: ((f.querySelector('figcaption') || {}).textContent || '').trim(),
+ };
+ }, key);
+}
+
+function mediaLive(page) {
+ return page.evaluate(() => ((document.getElementById('media-live') || {}).textContent || '').trim());
+}
+
// ── the scenarios ────────────────────────────────────────────────────
const run = {
@@ -1221,6 +1246,92 @@ const run = {
await ctxB.close();
},
+ // Spec §15.19: one peer mutes and turns the camera off, and the other
+ // sees both on that peer's tile — in words, not only an icon — and
+ // hears about it through the polite live region. Then the watcher
+ // leaves and comes back, and the state is on the tile from the roster
+ // alone, with no event to carry it: that is the late-joiner and resume
+ // path, which is the reason the state lives in the roster at all.
+ async mute() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA);
+ let pageB = await openRoom(ctxB);
+
+ const pair = await assertPair(pageA, pageB, 'member A', 'member B');
+ if (!pair) return;
+ const a = pair.a.peer;
+ const nameA = await selfName(pageA);
+
+ // Both report on admission, so each knows the other is on — known,
+ // not merely unknown — before anybody touches anything.
+ const known = await until(async () => {
+ const s = await pageB.evaluate(() => window.meet.state().remoteMedia);
+ return s[a] && s[a].muted === false && s[a].cameraOff === false ? s : false;
+ }, 10000);
+ check(known, 'B has A’s first report: on, not unknown (' + JSON.stringify(known) + ')');
+ const before = await tileMarks(pageB, a);
+ check(before && !before.muted && !before.cameraOff, 'A’s tile starts with neither mark (' + JSON.stringify(before) + ')');
+
+ await pageA.click('#mute');
+ const muted = await until(async () => {
+ const m = await tileMarks(pageB, a);
+ return m && m.muted ? m : false;
+ }, 10000);
+ check(muted, 'B sees A’s muted badge after A mutes (' + JSON.stringify(muted || (await tileMarks(pageB, a))) + ')');
+ check(muted && /muted/i.test(muted.mutedText), 'the badge says Muted in words (' + (muted && muted.mutedText) + ')');
+ check(muted && !muted.cameraOff, 'muting alone does not cover the video');
+ const said = await until(async () => {
+ const t = await mediaLive(pageB);
+ return t.includes('muted their microphone') ? t : false;
+ }, 5000);
+ check(said && said.startsWith(nameA), 'B’s live region says so, politely, by name (' + (said || (await mediaLive(pageB))) + ')');
+ const selfMark = await tileMarks(pageA, 'self');
+ check(selfMark && selfMark.muted, 'A’s own tile carries the badge too (' + JSON.stringify(selfMark) + ')');
+
+ await pageA.click('#camera');
+ const off = await until(async () => {
+ const m = await tileMarks(pageB, a);
+ return m && m.cameraOff ? m : false;
+ }, 10000);
+ check(off, 'B sees A’s camera-off cover (' + JSON.stringify(off || (await tileMarks(pageB, a))) + ')');
+ check(off && off.offText === 'Camera off', 'the cover says Camera off in words (' + (off && off.offText) + ')');
+ check(off && off.muted, 'and A is still muted');
+ // For a person to look at, as guestdirect keeps the settings page.
+ await pageB.screenshot({ path: '/tmp/calls-mute.png', fullPage: true });
+
+ // The watcher comes back: the roster alone must carry both.
+ await leaveRoom(pageB);
+ await pageB.close();
+ pageB = await openRoom(ctxB);
+ const again = await until(async () => {
+ const s = await state(pageB);
+ if (!s.admitted || !s.peers.includes(a)) return false;
+ const m = await tileMarks(pageB, a);
+ return m && m.muted && m.cameraOff ? m : false;
+ }, 25000);
+ check(again, 'B, rejoining, reads both marks from the roster (' + JSON.stringify(again || (await tileMarks(pageB, a))) + ')');
+ check((await mediaLive(pageB)) === '', 'a state learned from the roster is not announced as news');
+
+ // And back on: both marks go.
+ await pageA.click('#mute');
+ await pageA.click('#camera');
+ const clear = await until(async () => {
+ const m = await tileMarks(pageB, a);
+ return m && !m.muted && !m.cameraOff ? m : false;
+ }, 10000);
+ check(clear, 'unmuting and turning the camera on clears both marks (' + JSON.stringify(clear || (await tileMarks(pageB, a))) + ')');
+ const fb = await waitFrames(pageB, a);
+ check(fb, 'B is still decoding A’s video afterwards');
+
+ check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
+ check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
// Changing a device without leaving the call. The panel opens, the
// microphone and then the camera are chosen again from the list, and
// the far side's media is watched across both.
diff --git a/test/browser/native.mjs b/test/browser/native.mjs
index cee1b62..7227503 100644
--- a/test/browser/native.mjs
+++ b/test/browser/native.mjs
@@ -142,6 +142,25 @@ try {
await page.locator('[data-chat-log]').getByText('native chat probe', { exact: true }).waitFor();
await wait(() => sample?.sentChatProbe === true, 'native displays accepted outgoing chat');
console.log('PASS chat delivered from browser to native and native to browser');
+ // Spec §15.19 across the two clients: the app's mute reaches the
+ // browser tile as the badge, the browser's reaches the app's snapshot,
+ // and both clear again.
+ {
+ const nativePeer = sample.peer;
+ const nativeMarked = () => page.evaluate((id) => {
+ const m = document.querySelector('#tiles figure[data-tile="' + id + '"] .calls-tile-muted');
+ return !!(m && !m.hidden);
+ }, nativePeer);
+ assert.equal(await nativeMarked(), false, 'the native peer starts unmuted on the browser tile');
+ native.stdin.write('mute\n');
+ await wait(nativeMarked, 'browser shows the native peer muted');
+ await page.click('#mute');
+ await wait(() => sample?.remoteMuted === 1, 'native sees the browser muted');
+ await page.click('#mute');
+ native.stdin.write('mute\n');
+ await wait(async () => !(await nativeMarked()) && sample?.remoteMuted === 0, 'both unmuted again');
+ console.log('PASS mute state crosses between native and browser both ways');
+ }
if (mode === 'chat') {
native.stdin.write('share\n');
await wait(() => invitationReady, 'native member retrieves the original invitation');
diff --git a/test/browser/negotiation.test.mjs b/test/browser/negotiation.test.mjs
index 79a7aec..af952ba 100644
--- a/test/browser/negotiation.test.mjs
+++ b/test/browser/negotiation.test.mjs
@@ -615,6 +615,8 @@ const reconciler = new Function(
'state', 'stale', 'names', 'pcs', 'gen', 'seqOut', 'lastIn', 'queue', 'frames',
'renewals', 'ensurePC', 'renderTiles', 'renderQueue', 'renderStatus',
'rosters', 'lastRoster', 'waiting', 'mountChat', 'chatEnabled', 'SHARE_SUFFIX',
+ // §15.19's roster half is its own function, gated in room-mute.test.mjs.
+ 'rosterMedia',
// shareKey comes in as source rather than as a stub: closePC forgets a
// peer's share tile through it (§5.4), and a stub would be testing the
// stub.
@@ -631,6 +633,7 @@ function reconcileOver(pcs, seqOut, lastIn, frames) {
frames || new Map(),
new Map(), (id, g) => built.push([id, g]), () => {}, () => {}, () => {},
0, null, [], () => chat.push('mount'), (on) => chat.push(on), '#share',
+ () => {},
);
return { reconcile, closePC, built, chat };
}
diff --git a/test/browser/room-mute.test.mjs b/test/browser/room-mute.test.mjs
new file mode 100644
index 0000000..927b0ea
--- /dev/null
+++ b/test/browser/room-mute.test.mjs
@@ -0,0 +1,174 @@
+// room-mute.test.mjs — room.js's media state (spec §15.19), gated
+// without a browser.
+//
+// node --test test/browser/room-mute.test.mjs
+//
+// Same trick as room-share.test.mjs: the functions are lifted out of the
+// shipped room.js by name, so a rename fails here loudly instead of
+// passing against a copy.
+//
+// mediaFrom what counts as a report: exactly two booleans, from a
+// roster entry or a `media` payload, and nothing looser —
+// an absent or malformed one is unknown, never "on"
+// sameMedia whether the hub's word on us already matches ours
+// mediaKey the dedupe key a toggle is compared on
+// mediaChange the sentence the live region says, and when it says
+// nothing at all
+// rosterMedia with reportMedia: when our own report goes — on the
+// roster that admits us, on one that disagrees, on a
+// toggle, never before admission, and again after a 400
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
+const src = readFileSync(roomJS, 'utf8');
+
+function lift(source, name) {
+ const start = source.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing');
+ let depth = 0;
+ for (let i = source.indexOf('{', start); i < source.length; i++) {
+ if (source[i] === '{') depth += 1;
+ else if (source[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return source.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name + ' out of room.js');
+}
+
+const build = (name) =>
+ // eslint-disable-next-line no-new-func
+ new Function(lift(src, name) + '; return ' + name + ';')();
+
+const mediaFrom = build('mediaFrom');
+const sameMedia = build('sameMedia');
+const mediaKey = build('mediaKey');
+const mediaChange = build('mediaChange');
+
+test('mediaFrom takes exactly two booleans and nothing looser', () => {
+ assert.deepEqual(mediaFrom({ muted: true, cameraOff: false }), { muted: true, cameraOff: false });
+ // Extra keys are dropped rather than carried: what is drawn is what
+ // was checked.
+ assert.deepEqual(mediaFrom({ muted: false, cameraOff: true, extra: 1 }), { muted: false, cameraOff: true });
+ for (const bad of [
+ undefined, null, 'muted', 1, [],
+ {}, { muted: true }, { cameraOff: true },
+ { muted: 'true', cameraOff: false }, { muted: 1, cameraOff: 0 }, { muted: null, cameraOff: false },
+ ]) {
+ assert.equal(mediaFrom(bad), null, JSON.stringify(bad) + ' is unknown');
+ }
+});
+
+test('sameMedia never matches unknown', () => {
+ const on = { muted: false, cameraOff: false };
+ assert.equal(sameMedia(on, { muted: false, cameraOff: false }), true);
+ assert.equal(sameMedia(on, { muted: true, cameraOff: false }), false);
+ assert.equal(sameMedia(on, { muted: false, cameraOff: true }), false);
+ // The roster's silence about us is not agreement with "on": a fresh
+ // participation must be told, or a peer who joined unmuted stays
+ // unknown to everyone for the whole call.
+ assert.equal(sameMedia(null, on), false);
+ assert.equal(sameMedia(on, null), false);
+});
+
+test('mediaKey tells every state apart', () => {
+ const keys = new Set();
+ for (const muted of [false, true]) {
+ for (const cameraOff of [false, true]) keys.add(mediaKey({ muted, cameraOff }));
+ }
+ assert.equal(keys.size, 4);
+ // And never collides with the empty string mediaSent starts at.
+ assert.equal(keys.has(''), false);
+});
+
+test('mediaChange says what moved, and nothing for a first report', () => {
+ const on = { muted: false, cameraOff: false };
+ assert.equal(mediaChange('Bea', null, { muted: true, cameraOff: false }), '',
+ 'a first report is somebody arriving, not news');
+ assert.equal(mediaChange('Bea', on, on), '');
+ assert.equal(mediaChange('Bea', on, { muted: true, cameraOff: false }), 'Bea muted their microphone.');
+ assert.equal(mediaChange('Bea', { muted: true, cameraOff: false }, on), 'Bea unmuted their microphone.');
+ assert.equal(mediaChange('Bea', on, { muted: false, cameraOff: true }), 'Bea turned their camera off.');
+ assert.equal(mediaChange('Bea', { muted: false, cameraOff: true }, on), 'Bea turned their camera on.');
+ assert.equal(mediaChange('Bea', on, { muted: true, cameraOff: true }),
+ 'Bea muted their microphone. Bea turned their camera off.');
+});
+
+test('the dispatch routes media, and the report rides the broadcast lane', () => {
+ // Wire-level facts a lift cannot reach: the event name the hub sends,
+ // and that the report is an untargeted broadcast rather than a send()
+ // with a seq and a generation the hub would refuse.
+ assert.match(src, /case 'media': return onMedia\(msg\);/);
+ // An EventSource hears only the names it listens for: the dispatch
+ // above is dead without this, and the roster path hides it (the
+ // browser gate caught exactly that).
+ assert.match(src, /const DOWNSTREAM = \[[^\]]*'media'\]/);
+ assert.match(src, /broadcast\('media', now\)/);
+});
+
+// rosterMedia and reportMedia, lifted together over real Maps with only
+// the wire and the local stream stubbed: the decision under test is when
+// a report goes, and that is the pair of them.
+function reporter({ admitted = true, local = { muted: false, cameraOff: false }, answer = true } = {}) {
+ const sent = [];
+ const env = { local, answer };
+ const made = new Function(
+ 'state', 'remoteMedia', 'localMedia', 'broadcast',
+ ['mediaFrom', 'sameMedia', 'mediaKey', 'learnMedia', 'reportMedia', 'rosterMedia']
+ .map((n) => lift(src, n)).join('\n') +
+ '\nlet mediaSent = "";' +
+ '\nreturn { rosterMedia, reportMedia, memo: () => mediaSent };',
+ )(
+ { admitted, stopped: false, peer: 'me' },
+ new Map(),
+ () => env.local,
+ (type, payload) => {
+ sent.push([type, payload]);
+ return Promise.resolve(env.answer);
+ },
+ );
+ return { ...made, sent, env };
+}
+
+test('a roster that has never heard from us is answered with a report', () => {
+ const r = reporter({ local: { muted: true, cameraOff: false } });
+ r.rosterMedia({ peers: [{ peer: 'me' }, { peer: 'bea' }] });
+ assert.deepEqual(r.sent, [['media', { muted: true, cameraOff: false }]]);
+});
+
+test('a roster that already agrees costs nothing, and a toggle then goes once', () => {
+ const r = reporter();
+ r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false } }] });
+ assert.equal(r.sent.length, 0);
+ r.env.local = { muted: true, cameraOff: false };
+ r.reportMedia();
+ r.reportMedia();
+ assert.equal(r.sent.length, 1, 'a second call with nothing changed must not send again');
+});
+
+test('a roster that disagrees — a report lost while the stream was down — is corrected', () => {
+ const r = reporter({ local: { muted: true, cameraOff: true } });
+ r.reportMedia();
+ assert.equal(r.sent.length, 1);
+ r.rosterMedia({ peers: [{ peer: 'me', media: { muted: true, cameraOff: false } }] });
+ assert.equal(r.sent.length, 2);
+ assert.deepEqual(r.sent[1][1], { muted: true, cameraOff: true });
+});
+
+test('nothing is reported before admission', () => {
+ const r = reporter({ admitted: false, local: { muted: true, cameraOff: false } });
+ r.reportMedia();
+ assert.equal(r.sent.length, 0);
+});
+
+test('a refused report is forgotten, so the next chance tries again', async () => {
+ const r = reporter({ answer: false, local: { muted: true, cameraOff: false } });
+ r.reportMedia();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.equal(r.memo(), '', 'an old server’s 400 must not be remembered as sent');
+ r.reportMedia();
+ assert.equal(r.sent.length, 2);
+});