From 44a55ed893d6c1bb075e5d50bf6e195734b5256b Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 23 Sep 2024 20:03:49 -0700 Subject: [PATCH] fix: Don't block for microphone initialization if browser does not support microphone --- src/card-controller/initialization-manager.ts | 4 +- src/card-controller/microphone-manager.ts | 24 ++++ src/components-lib/menu-button-controller.ts | 11 +- .../initialization-manager.test.ts | 26 ++-- .../microphone-manager.test.ts | 131 ++++++++++++++++-- .../menu-button-controller.test.ts | 63 +++++++-- 6 files changed, 213 insertions(+), 46 deletions(-) diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index 15239d63..248f8ff6 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -52,7 +52,7 @@ export class InitializationManager { InitializationAspect.LANGUAGES, InitializationAspect.SIDE_LOAD_ELEMENTS, InitializationAspect.CAMERAS, - ...(config.live.microphone.always_connected + ...(this._api.getMicrophoneManager().shouldConnectOnInitialization() ? [InitializationAspect.MICROPHONE_CONNECT] : []), InitializationAspect.VIEW, @@ -99,7 +99,7 @@ export class InitializationManager { // avoid issues with some cameras that only allow 2-way audio on the // first stream initialized. See: // https://github.com/dermotduffy/frigate-hass-card/issues/1235 - ...(config.live.microphone.always_connected && { + ...(this._api.getMicrophoneManager().shouldConnectOnInitialization() && { [InitializationAspect.MICROPHONE_CONNECT]: async () => await this._api.getMicrophoneManager().connect(), }), diff --git a/src/card-controller/microphone-manager.ts b/src/card-controller/microphone-manager.ts index 19bc7c5f..86cc31a0 100644 --- a/src/card-controller/microphone-manager.ts +++ b/src/card-controller/microphone-manager.ts @@ -33,7 +33,27 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager { this._setConditionState(); } + public shouldConnectOnInitialization(): boolean { + return ( + !!this._api.getConfigManager().getConfig()?.live.microphone?.always_connected && + // If it won't be possible to connect the microphone at all, we do not + // block the initialization of the card (the microphone just won't work) + this.isSupported() + ); + } + + public isSupported(): boolean { + // Some browsers will have mediaDevices/getUserMedia as undefined if + // accessed over http. + // See: https://github.com/dermotduffy/frigate-hass-card/issues/1543 + return !!navigator.mediaDevices?.getUserMedia; + } + public async connect(): Promise { + if (!this.isSupported()) { + return false; + } + try { this._stream = await navigator.mediaDevices.getUserMedia({ audio: true, @@ -76,6 +96,10 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager { } public async unmute(): Promise { + if (!this.isSupported()) { + return; + } + const wasUnmuted = !this.isMuted(); const unmute = (): void => { diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 6abf28f9..ff7a418f 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -374,11 +374,12 @@ export class MenuButtonController { currentMediaLoadedInfo?: MediaLoadedInfo | null, ): MenuItem | null { if (microphoneManager && currentMediaLoadedInfo?.capabilities?.supports2WayAudio) { - const forbidden = microphoneManager.isForbidden(); + const unavailable = + microphoneManager.isForbidden() || !microphoneManager.isSupported(); const muted = microphoneManager.isMuted(); const buttonType = config.menu.buttons.microphone.type; return { - icon: forbidden + icon: unavailable ? 'mdi:microphone-message-off' : muted ? 'mdi:microphone-off' @@ -386,8 +387,8 @@ export class MenuButtonController { ...config.menu.buttons.microphone, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.microphone'), - style: forbidden || muted ? {} : this._getEmphasizedStyle(true), - ...(!forbidden && + style: unavailable || muted ? {} : this._getEmphasizedStyle(true), + ...(!unavailable && buttonType === 'momentary' && { start_tap_action: createGeneralAction( 'microphone_unmute', @@ -396,7 +397,7 @@ export class MenuButtonController { 'microphone_mute', ) as FrigateCardCustomAction, }), - ...(!forbidden && + ...(!unavailable && buttonType === 'toggle' && { tap_action: createGeneralAction( muted ? 'microphone_unmute' : 'microphone_mute', diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index 8052d8cc..ba1eb29c 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -39,15 +39,10 @@ describe('InitializationManager', () => { const api = createCardAPI(); const manager = new InitializationManager(api); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - live: { - microphone: { - always_connected: true, - }, - }, - }), - ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked( + api.getMicrophoneManager().shouldConnectOnInitialization, + ).mockReturnValue(true); expect(manager.isInitializedMandatory()).toBeFalsy(); }); @@ -103,15 +98,10 @@ describe('InitializationManager', () => { it('successfully with microphone if configured', async () => { const api = createCardAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - live: { - microphone: { - always_connected: true, - }, - }, - }), - ); + vi.mocked( + api.getMicrophoneManager().shouldConnectOnInitialization, + ).mockReturnValue(true); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); vi.mocked(loadLanguages).mockResolvedValue(true); vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true); vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockResolvedValue( diff --git a/tests/card-controller/microphone-manager.test.ts b/tests/card-controller/microphone-manager.test.ts index c3d521e5..d8c30201 100644 --- a/tests/card-controller/microphone-manager.test.ts +++ b/tests/card-controller/microphone-manager.test.ts @@ -3,12 +3,21 @@ import { mock } from 'vitest-mock-extended'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; import { createCardAPI, createConfig } from '../test-utils'; -const navigatorMock = { +const navigatorMock: Navigator = { + ...mock(), mediaDevices: { + ...mock(), getUserMedia: vi.fn(), }, }; +const medialessNavigatorMock: Navigator = { + ...navigatorMock, + + // Some browser will set mediaDevices to undefined when access over http. + mediaDevices: undefined as unknown as MediaDevices, +}; + // @vitest-environment jsdom describe('MicrophoneManager', () => { beforeEach(() => { @@ -19,6 +28,7 @@ describe('MicrophoneManager', () => { afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals; + navigator.mediaDevices; }); const createMockStream = (mute?: boolean): MediaStream => { @@ -45,7 +55,7 @@ describe('MicrophoneManager', () => { const manager = new MicrophoneManager(api); const stream = createMockStream(); - navigatorMock.mediaDevices.getUserMedia.mockReturnValue(stream); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream); await manager.connect(); @@ -55,13 +65,35 @@ describe('MicrophoneManager', () => { expect(api.getCardElementManager().update).toBeCalled(); }); + it('should be unsupported without browser support', () => { + vi.stubGlobal('navigator', medialessNavigatorMock); + + const manager = new MicrophoneManager(createCardAPI()); + + expect(manager.isSupported()).toBeFalsy(); + }); + + it('should not connect when not supported', async () => { + vi.stubGlobal('navigator', medialessNavigatorMock); + + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + + const stream = createMockStream(); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream); + + await manager.connect(); + + expect(manager.isConnected()).toBeFalsy(); + }); + it('should be forbidden when permission denied', async () => { // Don't actually log messages to the console during the test. vi.spyOn(global.console, 'warn').mockReturnValue(undefined); const api = createCardAPI(); const manager = new MicrophoneManager(api); - navigatorMock.mediaDevices.getUserMedia.mockRejectedValue(new Error()); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error()); expect(await manager.connect()).toBeFalsy(); @@ -73,7 +105,9 @@ describe('MicrophoneManager', () => { it('should mute and unmute', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); - navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( + createMockStream(), + ); await manager.connect(); expect(manager.isMuted()).toBeTruthy(); @@ -91,7 +125,7 @@ describe('MicrophoneManager', () => { it('should not unmute when microphone forbidden', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); - navigatorMock.mediaDevices.getUserMedia.mockReturnValue(null); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error()); await manager.connect(); @@ -103,10 +137,23 @@ describe('MicrophoneManager', () => { expect(api.getCardElementManager().update).toBeCalledTimes(1); }); + it('should not unmute when not supported', async () => { + vi.stubGlobal('navigator', medialessNavigatorMock); + + const manager = new MicrophoneManager(createCardAPI()); + + await manager.unmute(); + + expect(manager.isConnected()).toBeFalsy(); + expect(manager.isMuted()).toBeTruthy(); + }); + it('should connect on unmute', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); - navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( + createMockStream(), + ); expect(manager.isConnected()).toBeFalsy(); @@ -122,7 +169,9 @@ describe('MicrophoneManager', () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); - navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( + createMockStream(), + ); await manager.connect(); expect(manager.isConnected()).toBeTruthy(); @@ -139,7 +188,9 @@ describe('MicrophoneManager', () => { const disconnectSeconds = 10; const api = createCardAPI(); const manager = new MicrophoneManager(api); - navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( + createMockStream(), + ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ @@ -168,7 +219,9 @@ describe('MicrophoneManager', () => { const disconnectSeconds = 10; const api = createCardAPI(); const manager = new MicrophoneManager(api); - navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( + createMockStream(), + ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ @@ -191,10 +244,64 @@ describe('MicrophoneManager', () => { expect(api.getCardElementManager().update).toBeCalledTimes(1); }); + describe('should require initialization', async () => { + it('when configured and supported', async () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + live: { + microphone: { + always_connected: true, + }, + }, + }), + ); + + await manager.connect(); + + expect(manager.shouldConnectOnInitialization()).toBeTruthy(); + }); + + it('when configured but not supported', async () => { + vi.stubGlobal('navigator', medialessNavigatorMock); + + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + live: { + microphone: { + always_connected: true, + }, + }, + }), + ); + + await manager.connect(); + + expect(manager.shouldConnectOnInitialization()).toBeFalsy(); + }); + + it('when neither configured nor supported', async () => { + vi.stubGlobal('navigator', medialessNavigatorMock); + + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + + await manager.connect(); + + expect(manager.shouldConnectOnInitialization()).toBeFalsy(); + }); + }); + it('should respect listeners', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); - navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( + createMockStream(), + ); const listener = vi.fn(); manager.addListener(listener); @@ -235,7 +342,9 @@ describe('MicrophoneManager', () => { it('should set condition state', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); - navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( + createMockStream(), + ); expect(api.getConditionsManager().setState).not.toBeCalled(); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index dc466efb..65cb5be1 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -903,7 +903,11 @@ describe('MenuButtonController', () => { describe('should have microphone button', () => { it('with suitable loaded media', () => { - const microphoneManager = new MicrophoneManager(createCardAPI()); + const microphoneManager = mock(); + vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); + vi.mocked(microphoneManager.isMuted).mockReturnValue(false); + vi.mocked(microphoneManager.isSupported).mockReturnValue(true); + const buttons = calculateButtons(controller, { microphoneManager: microphoneManager, currentMediaLoadedInfo: createMediaLoadedInfo({ @@ -935,7 +939,11 @@ describe('MenuButtonController', () => { }); it('without suitable loaded media', () => { - const microphoneManager = new MicrophoneManager(createCardAPI()); + const microphoneManager = mock(); + vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); + vi.mocked(microphoneManager.isMuted).mockReturnValue(false); + vi.mocked(microphoneManager.isSupported).mockReturnValue(true); + const buttons = calculateButtons(controller, { microphoneManager: microphoneManager, currentMediaLoadedInfo: createMediaLoadedInfo({ @@ -951,8 +959,9 @@ describe('MenuButtonController', () => { }); it('with forbidden microphone', () => { - const microphoneManager = new MicrophoneManager(createCardAPI()); - mock(microphoneManager).isForbidden.mockReturnValue(true); + const microphoneManager = mock(); + vi.mocked(microphoneManager.isForbidden).mockReturnValue(true); + const buttons = calculateButtons(controller, { microphoneManager: microphoneManager, currentMediaLoadedInfo: createMediaLoadedInfo({ @@ -973,8 +982,11 @@ describe('MenuButtonController', () => { }); it('with muted microphone', () => { - const microphoneManager = new MicrophoneManager(createCardAPI()); - mock(microphoneManager).isMuted.mockReturnValue(true); + const microphoneManager = mock(); + vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); + vi.mocked(microphoneManager.isMuted).mockReturnValue(true); + vi.mocked(microphoneManager.isSupported).mockReturnValue(true); + const buttons = calculateButtons(controller, { microphoneManager: microphoneManager, currentMediaLoadedInfo: createMediaLoadedInfo({ @@ -1002,9 +1014,37 @@ describe('MenuButtonController', () => { }); }); + it('with unsupported microphone', () => { + const microphoneManager = mock(); + vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); + vi.mocked(microphoneManager.isMuted).mockReturnValue(true); + vi.mocked(microphoneManager.isSupported).mockReturnValue(false); + + const buttons = calculateButtons(controller, { + microphoneManager: microphoneManager, + currentMediaLoadedInfo: createMediaLoadedInfo({ + capabilities: { + supports2WayAudio: true, + }, + }), + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:microphone-message-off', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Microphone', + style: {}, + }); + }); + it('with muted toggle type microphone', () => { - const microphoneManager = new MicrophoneManager(createCardAPI()); - mock(microphoneManager).isMuted.mockReturnValue(true); + const microphoneManager = mock(); + vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); + vi.mocked(microphoneManager.isMuted).mockReturnValue(true); + vi.mocked(microphoneManager.isSupported).mockReturnValue(true); + const buttons = calculateButtons(controller, { microphoneManager: microphoneManager, currentMediaLoadedInfo: createMediaLoadedInfo({ @@ -1032,8 +1072,11 @@ describe('MenuButtonController', () => { }); it('with unmuted toggle type microphone', () => { - const microphoneManager = new MicrophoneManager(createCardAPI()); - mock(microphoneManager).isMuted.mockReturnValue(false); + const microphoneManager = mock(); + vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); + vi.mocked(microphoneManager.isMuted).mockReturnValue(false); + vi.mocked(microphoneManager.isSupported).mockReturnValue(true); + const buttons = calculateButtons(controller, { microphoneManager: microphoneManager, currentMediaLoadedInfo: createMediaLoadedInfo({