oficina / meet Public

Badge muted peers on the Mac and iPhone tiles

Call tells the core its own state on join and on each toggle (no track
counts as muted/off); the core reports it on admission and whenever a
roster disagrees. RosterPeer carries the optional PeerMedia; Call exposes
peerMedia/isMuted/isCameraOff, unknown reading as neither. MacVideoTile
and PhoneVideoTile take `muted` and draw a shared MutedBadge (slashed mic
plus the word), folding "muted" into the tile's VoiceOver label; remote
camera-off reuses the tiles' existing "Camera off" face.

The interop executable gains a `mute` command and a remoteMuted count,
and native.mjs checks mute crossing both ways.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 1a98dc5d1bd549255f433b13ede3a826d3f1bf27 parent bd1af6b
9 files changed, +162 −8
  • 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/ios/Sources/CallsPhone.swift +13 −4
  • test/browser/native.mjs +19 −0
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index c358db5..a86011a 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 9235300..8992af9 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -99,6 +99,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
guard epoch == joiningEpoch, bridge === client else { throw CancellationError() }
localTrack = media.localVideo
currentCamera = media.currentCamera
+ reportMedia()
audio.refresh()
KnockNotice.requestPermission()
// Asking Apple at join, once there is a session to register the
@@ -139,10 +140,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 061b5de..6a0f4ac 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/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index 6bad8fd..d52de6c 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -114,8 +114,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)
}
}
}
@@ -123,7 +126,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",
@@ -222,6 +227,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 {
@@ -239,8 +246,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/test/browser/native.mjs b/test/browser/native.mjs
index e104eec..0c27887 100644
--- a/test/browser/native.mjs
+++ b/test/browser/native.mjs
@@ -133,6 +133,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');