mute controls
3 changed files+131−1
CLAUDE.mdmodified+7−0View file
@@ -45,6 +45,13 @@ src/App.tsx join form, roster, ring/accept UI, video views
4545 renegotiation. While screen sharing, resolution downscaling is skipped
4646 (downscaled text is unreadable) and degradationPreference is
4747 maintain-resolution; caps are re-derived on every share start/stop.
48+- **Mute is per-party, NOT a shared setting.** Each side owns its own flags —
49+ no revision counters, the ordered control channel makes last-sent win.
50+ Toggling `track.enabled` sends silence/black without renegotiation; the
51+ other side is told via `{t:'mute', audio, video}` and shows badges. The
52+ notice carries the EFFECTIVE outgoing video state: while screen sharing the
53+ screen is always live, so a muted camera is latent until the share ends
54+ (share start/stop re-sends the notice).
4855
4956 ## Testing
5057
src/App.tsxmodified+57−0View file
@@ -39,6 +39,23 @@ const disabledStyle: React.CSSProperties = {
3939 cursor: 'not-allowed'
4040 }
4141
42+// A mute/cam-off button while its mute is engaged.
43+const engagedBtn: React.CSSProperties = {
44+ ...btn,
45+ background: '#555',
46+ borderColor: '#555',
47+ color: '#fff'
48+}
49+
50+// Badge overlaid on the remote video reporting the other party's mute state.
51+const muteChip: React.CSSProperties = {
52+ background: 'rgba(0, 0, 0, 0.65)',
53+ color: '#fff',
54+ borderRadius: 999,
55+ padding: '0.15rem 0.6rem',
56+ fontSize: '0.8rem'
57+}
58+
4259 function VideoView({
4360 stream,
4461 muted,
@@ -207,6 +224,20 @@ export default function App() {
207224 objectFit: 'cover'
208225 }}
209226 />
227+ {(call.peerAudioMuted || call.peerVideoMuted) && (
228+ <div
229+ style={{
230+ position: 'absolute',
231+ top: 10,
232+ left: 10,
233+ display: 'flex',
234+ gap: '0.4rem'
235+ }}
236+ >
237+ {call.peerAudioMuted && <span style={muteChip}>mic muted</span>}
238+ {call.peerVideoMuted && <span style={muteChip}>camera off</span>}
239+ </div>
240+ )}
210241 <VideoView
211242 stream={call.screenStream ?? call.localStream}
212243 muted
@@ -240,6 +271,32 @@ export default function App() {
240271 : `In a call with ${call.peerName}`
241272 : `Connecting to ${call.peerName}…`}
242273 </span>
274+ <button
275+ style={{
276+ ...(call.audioMuted ? engagedBtn : btn),
277+ ...(call.localStream ? null : disabledStyle)
278+ }}
279+ disabled={!call.localStream}
280+ title={call.audioMuted ? 'Unmute your microphone' : 'Mute your microphone'}
281+ onClick={() => network.setAudioMuted(!call.audioMuted)}
282+ >
283+ {call.audioMuted ? 'Unmute' : 'Mute'}
284+ </button>
285+ <button
286+ style={{
287+ ...(call.videoMuted ? engagedBtn : btn),
288+ ...(call.localStream ? null : disabledStyle)
289+ }}
290+ disabled={!call.localStream}
291+ title={
292+ call.videoMuted
293+ ? 'Turn your camera back on'
294+ : 'Turn your camera off'
295+ }
296+ onClick={() => network.setVideoMuted(!call.videoMuted)}
297+ >
298+ {call.videoMuted ? 'Cam on' : 'Cam off'}
299+ </button>
243300 <label
244301 title="Video quality for both directions — either of you can change it"
245302 style={{
src/p2p/network.tsmodified+67−1View file
@@ -44,6 +44,7 @@ type PeerMsg =
4444 type ControlMsg =
4545 | {t: 'hang-up'}
4646 | {t: 'set'; key: string; value: unknown; rev: number}
47+ | {t: 'mute'; audio: boolean; video: boolean}
4748
4849 const ROOM_ID = 'default'
4950 const ANNOUNCE_INTERVAL_MS = 5000
@@ -73,6 +74,12 @@ interface Call {
7374 settings: CallSettings
7475 /** Per-key revision counters for the last-writer-wins settings sync. */
7576 settingsRevs: Partial<Record<keyof CallSettings, number>>
77+ /** Local mute state (audio = mic; video = camera, latent while sharing). */
78+ audioMuted: boolean
79+ videoMuted: boolean
80+ /** The other party's effective outgoing mute state, as they reported it. */
81+ peerAudioMuted: boolean
82+ peerVideoMuted: boolean
7683 ringInterval: number | null
7784 ringTimeout: number | null
7885 connectTimeout: number | null
@@ -92,6 +99,10 @@ export interface CallInfo {
9299 remoteStream: MediaStream | null
93100 screenStream: MediaStream | null
94101 settings: CallSettings
102+ audioMuted: boolean
103+ videoMuted: boolean
104+ peerAudioMuted: boolean
105+ peerVideoMuted: boolean
95106 }
96107
97108 export interface Snapshot {
@@ -287,6 +298,10 @@ export class Network {
287298 pendingSignals: [],
288299 settings: {...DEFAULT_SETTINGS},
289300 settingsRevs: {},
301+ audioMuted: false,
302+ videoMuted: false,
303+ peerAudioMuted: false,
304+ peerVideoMuted: false,
290305 ringInterval: null,
291306 ringTimeout: null,
292307 connectTimeout: null
@@ -371,6 +386,13 @@ export class Network {
371386 this.teardown(`${call.peerName} hung up.`)
372387 } else if (msg.t === 'set') {
373388 this.applyRemoteSetting(call, msg)
389+ } else if (msg.t === 'mute') {
390+ if (typeof msg.audio !== 'boolean' || typeof msg.video !== 'boolean') {
391+ return
392+ }
393+ call.peerAudioMuted = msg.audio
394+ call.peerVideoMuted = msg.video
395+ this.rebuildSnapshot()
374396 }
375397 },
376398 close: () => {
@@ -446,6 +468,44 @@ export class Network {
446468 })
447469 }
448470
471+ // ---- mute ------------------------------------------------------------
472+ //
473+ // Mute is per-party state, not a shared setting: each side owns its own
474+ // flags (so no revision counters — the ordered channel makes last-sent
475+ // win naturally) and just notifies the other side. Toggling track.enabled
476+ // sends silence/black frames without renegotiation.
477+
478+ setAudioMuted(muted: boolean) {
479+ const call = this.call
480+ if (!call || !call.localStream || call.audioMuted === muted) return
481+ call.audioMuted = muted
482+ for (const t of call.localStream.getAudioTracks()) t.enabled = !muted
483+ this.sendMuteNotice(call)
484+ this.rebuildSnapshot()
485+ }
486+
487+ setVideoMuted(muted: boolean) {
488+ const call = this.call
489+ if (!call || !call.localStream || call.videoMuted === muted) return
490+ call.videoMuted = muted
491+ for (const t of call.localStream.getVideoTracks()) t.enabled = !muted
492+ this.sendMuteNotice(call)
493+ this.rebuildSnapshot()
494+ }
495+
496+ /** Tell the peer our EFFECTIVE outgoing mute state: while screen sharing
497+ * the outgoing video is the (always live) screen, so a muted camera is
498+ * latent until the share ends. */
499+ private sendMuteNotice(call: Call) {
500+ call.peer?.send(
501+ JSON.stringify({
502+ t: 'mute',
503+ audio: call.audioMuted,
504+ video: call.videoMuted && !call.screenStream
505+ })
506+ )
507+ }
508+
449509 private teardown(notice: string | null) {
450510 const call = this.call
451511 if (!call) return
@@ -561,6 +621,7 @@ export class Network {
561621 }
562622 call.screenStream = stream
563623 this.applyVideoParams(call) // re-derive caps for screen-share mode
624+ this.sendMuteNotice(call) // outgoing video is now the live screen
564625 // The browser's own "Stop sharing" bar ends the track; swap back then.
565626 track.onended = () => void this.stopScreenShare()
566627 this.rebuildSnapshot()
@@ -576,6 +637,7 @@ export class Network {
576637 for (const t of screen.getTracks()) t.stop()
577638 if (this.call === call) {
578639 this.applyVideoParams(call) // restore camera-mode caps
640+ this.sendMuteNotice(call) // the camera, with its mute state, is back
579641 this.rebuildSnapshot()
580642 }
581643 }
@@ -607,7 +669,11 @@ export class Network {
607669 localStream: this.call.localStream,
608670 remoteStream: this.call.remoteStream,
609671 screenStream: this.call.screenStream,
610- settings: this.call.settings
672+ settings: this.call.settings,
673+ audioMuted: this.call.audioMuted,
674+ videoMuted: this.call.videoMuted,
675+ peerAudioMuted: this.call.peerAudioMuted,
676+ peerVideoMuted: this.call.peerVideoMuted
611677 }
612678 : null
613679 this.snapshot = {