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>
9 files changed,
+162
−8
native/apple/Sources/CallsInterop/Interop.swift+9 −0native/apple/Sources/CallsKit/Call.swift+26 −0native/apple/Sources/CallsKit/MacCallView.swift+8 −3native/apple/Sources/CallsKit/MacVideoTile.swift+4 −1native/apple/Sources/CallsKit/MutedBadge.swift+23 −0native/apple/Sources/CallsKit/Wire.swift+9 −0native/apple/Tests/CallsKitTests/PeerMediaTests.swift+51 −0native/ios/Sources/CallsPhone.swift+13 −4test/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'); |