diff --git a/src/utils/audio.ts b/src/utils/audio.ts index e1db1237..aa0a2608 100644 --- a/src/utils/audio.ts +++ b/src/utils/audio.ts @@ -39,10 +39,12 @@ export const hasAudio = ( // We check that the track is not muted because muted means no media data // is flowing (e.g., the source isn't producing audio). It is not related to // the audio being muted by the user on the receiving end. - if (pc) { + // Only trust receivers when the connection is actually established — a stale + // RTCPeerConnection (e.g. WebRTC failed, fell back to MSE) will have + // receivers with muted tracks that don't reflect actual media availability. + // See: https://github.com/dermotduffy/advanced-camera-card/issues/2417 + if (pc && pc.connectionState === 'connected') { const receivers = pc.getReceivers(); - - // Only trust receivers if they're populated (connection established) if (receivers.length > 0) { return receivers.some( (receiver) => receiver.track?.kind === 'audio' && !receiver.track?.muted, diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index ed77d45a..4f697def 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -1793,6 +1793,24 @@ describe('MenuButtonController', () => { }); }); + it('should not have mute button without audio', () => { + const mediaPlayerController = mock(); + const buttons = calculateButtons(controller, { + currentMediaLoadedInfo: createMediaLoadedInfo({ + capabilities: { + hasAudio: false, + }, + mediaPlayerController, + }), + }); + + expect(buttons).not.toContainEqual( + expect.objectContaining({ + title: 'Mute / Unmute', + }), + ); + }); + it('should have screenshot button', () => { const buttons = calculateButtons(controller, { currentMediaLoadedInfo: createMediaLoadedInfo({ diff --git a/tests/utils/audio.test.ts b/tests/utils/audio.test.ts index 0739d1e8..497fbcb0 100644 --- a/tests/utils/audio.test.ts +++ b/tests/utils/audio.test.ts @@ -82,8 +82,12 @@ describe('hasAudio', () => { } as unknown as RTCRtpReceiver; }; - const createMockPeerConnection = (receivers: RTCRtpReceiver[]): RTCPeerConnection => { + const createMockPeerConnection = ( + receivers: RTCRtpReceiver[], + connectionState: RTCPeerConnectionState = 'connected', + ): RTCPeerConnection => { return { + connectionState, getReceivers: () => receivers, } as unknown as RTCPeerConnection; }; @@ -116,6 +120,21 @@ describe('hasAudio', () => { // With no properties set on video, mayHaveAudio returns true (generous default) expect(hasAudio(createMockVideo(), pc, '')).toBe(true); }); + + it('should ignore receivers when connection is not established', () => { + const pc = createMockPeerConnection([createMockReceiver('audio', true)], 'new'); + + // Stale connection with muted receiver should fall through to + // mayHaveAudio (generous default returns true) + expect(hasAudio(createMockVideo(), pc, '')).toBe(true); + }); + + it('should fall through to mseCodecs when connection is not established', () => { + const pc = createMockPeerConnection([createMockReceiver('audio', true)], 'new'); + + // Stale WebRTC connection but MSE has audio codecs + expect(hasAudio(createMockVideo(), pc, 'avc1.640029,flac')).toBe(true); + }); }); describe('MSE codec detection', () => {