fix: Don't block for microphone initialization if browser does not support microphone (#1576)

This commit is contained in:
Dermot Duffy
2024-09-23 20:08:25 -07:00
committed by GitHub
parent 9c68b30ae3
commit d203f529f8
6 changed files with 213 additions and 46 deletions
@@ -52,7 +52,7 @@ export class InitializationManager {
InitializationAspect.LANGUAGES, InitializationAspect.LANGUAGES,
InitializationAspect.SIDE_LOAD_ELEMENTS, InitializationAspect.SIDE_LOAD_ELEMENTS,
InitializationAspect.CAMERAS, InitializationAspect.CAMERAS,
...(config.live.microphone.always_connected ...(this._api.getMicrophoneManager().shouldConnectOnInitialization()
? [InitializationAspect.MICROPHONE_CONNECT] ? [InitializationAspect.MICROPHONE_CONNECT]
: []), : []),
InitializationAspect.VIEW, InitializationAspect.VIEW,
@@ -99,7 +99,7 @@ export class InitializationManager {
// avoid issues with some cameras that only allow 2-way audio on the // avoid issues with some cameras that only allow 2-way audio on the
// first stream initialized. See: // first stream initialized. See:
// https://github.com/dermotduffy/frigate-hass-card/issues/1235 // https://github.com/dermotduffy/frigate-hass-card/issues/1235
...(config.live.microphone.always_connected && { ...(this._api.getMicrophoneManager().shouldConnectOnInitialization() && {
[InitializationAspect.MICROPHONE_CONNECT]: async () => [InitializationAspect.MICROPHONE_CONNECT]: async () =>
await this._api.getMicrophoneManager().connect(), await this._api.getMicrophoneManager().connect(),
}), }),
+24
View File
@@ -33,7 +33,27 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager {
this._setConditionState(); 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<boolean> { public async connect(): Promise<boolean> {
if (!this.isSupported()) {
return false;
}
try { try {
this._stream = await navigator.mediaDevices.getUserMedia({ this._stream = await navigator.mediaDevices.getUserMedia({
audio: true, audio: true,
@@ -76,6 +96,10 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager {
} }
public async unmute(): Promise<void> { public async unmute(): Promise<void> {
if (!this.isSupported()) {
return;
}
const wasUnmuted = !this.isMuted(); const wasUnmuted = !this.isMuted();
const unmute = (): void => { const unmute = (): void => {
+6 -5
View File
@@ -374,11 +374,12 @@ export class MenuButtonController {
currentMediaLoadedInfo?: MediaLoadedInfo | null, currentMediaLoadedInfo?: MediaLoadedInfo | null,
): MenuItem | null { ): MenuItem | null {
if (microphoneManager && currentMediaLoadedInfo?.capabilities?.supports2WayAudio) { if (microphoneManager && currentMediaLoadedInfo?.capabilities?.supports2WayAudio) {
const forbidden = microphoneManager.isForbidden(); const unavailable =
microphoneManager.isForbidden() || !microphoneManager.isSupported();
const muted = microphoneManager.isMuted(); const muted = microphoneManager.isMuted();
const buttonType = config.menu.buttons.microphone.type; const buttonType = config.menu.buttons.microphone.type;
return { return {
icon: forbidden icon: unavailable
? 'mdi:microphone-message-off' ? 'mdi:microphone-message-off'
: muted : muted
? 'mdi:microphone-off' ? 'mdi:microphone-off'
@@ -386,8 +387,8 @@ export class MenuButtonController {
...config.menu.buttons.microphone, ...config.menu.buttons.microphone,
type: 'custom:frigate-card-menu-icon', type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.microphone'), title: localize('config.menu.buttons.microphone'),
style: forbidden || muted ? {} : this._getEmphasizedStyle(true), style: unavailable || muted ? {} : this._getEmphasizedStyle(true),
...(!forbidden && ...(!unavailable &&
buttonType === 'momentary' && { buttonType === 'momentary' && {
start_tap_action: createGeneralAction( start_tap_action: createGeneralAction(
'microphone_unmute', 'microphone_unmute',
@@ -396,7 +397,7 @@ export class MenuButtonController {
'microphone_mute', 'microphone_mute',
) as FrigateCardCustomAction, ) as FrigateCardCustomAction,
}), }),
...(!forbidden && ...(!unavailable &&
buttonType === 'toggle' && { buttonType === 'toggle' && {
tap_action: createGeneralAction( tap_action: createGeneralAction(
muted ? 'microphone_unmute' : 'microphone_mute', muted ? 'microphone_unmute' : 'microphone_mute',
@@ -39,15 +39,10 @@ describe('InitializationManager', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new InitializationManager(api); const manager = new InitializationManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
createConfig({ vi.mocked(
live: { api.getMicrophoneManager().shouldConnectOnInitialization,
microphone: { ).mockReturnValue(true);
always_connected: true,
},
},
}),
);
expect(manager.isInitializedMandatory()).toBeFalsy(); expect(manager.isInitializedMandatory()).toBeFalsy();
}); });
@@ -103,15 +98,10 @@ describe('InitializationManager', () => {
it('successfully with microphone if configured', async () => { it('successfully with microphone if configured', async () => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(
createConfig({ api.getMicrophoneManager().shouldConnectOnInitialization,
live: { ).mockReturnValue(true);
microphone: { vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
always_connected: true,
},
},
}),
);
vi.mocked(loadLanguages).mockResolvedValue(true); vi.mocked(loadLanguages).mockResolvedValue(true);
vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true); vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true);
vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockResolvedValue( vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockResolvedValue(
+120 -11
View File
@@ -3,12 +3,21 @@ import { mock } from 'vitest-mock-extended';
import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
import { createCardAPI, createConfig } from '../test-utils'; import { createCardAPI, createConfig } from '../test-utils';
const navigatorMock = { const navigatorMock: Navigator = {
...mock<Navigator>(),
mediaDevices: { mediaDevices: {
...mock<MediaDevices>(),
getUserMedia: vi.fn(), 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 // @vitest-environment jsdom
describe('MicrophoneManager', () => { describe('MicrophoneManager', () => {
beforeEach(() => { beforeEach(() => {
@@ -19,6 +28,7 @@ describe('MicrophoneManager', () => {
afterEach(() => { afterEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.unstubAllGlobals; vi.unstubAllGlobals;
navigator.mediaDevices;
}); });
const createMockStream = (mute?: boolean): MediaStream => { const createMockStream = (mute?: boolean): MediaStream => {
@@ -45,7 +55,7 @@ describe('MicrophoneManager', () => {
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
const stream = createMockStream(); const stream = createMockStream();
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(stream); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
await manager.connect(); await manager.connect();
@@ -55,13 +65,35 @@ describe('MicrophoneManager', () => {
expect(api.getCardElementManager().update).toBeCalled(); 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 () => { it('should be forbidden when permission denied', async () => {
// Don't actually log messages to the console during the test. // Don't actually log messages to the console during the test.
vi.spyOn(global.console, 'warn').mockReturnValue(undefined); vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
const api = createCardAPI(); const api = createCardAPI();
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
navigatorMock.mediaDevices.getUserMedia.mockRejectedValue(new Error()); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error());
expect(await manager.connect()).toBeFalsy(); expect(await manager.connect()).toBeFalsy();
@@ -73,7 +105,9 @@ describe('MicrophoneManager', () => {
it('should mute and unmute', async () => { it('should mute and unmute', async () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.connect(); await manager.connect();
expect(manager.isMuted()).toBeTruthy(); expect(manager.isMuted()).toBeTruthy();
@@ -91,7 +125,7 @@ describe('MicrophoneManager', () => {
it('should not unmute when microphone forbidden', async () => { it('should not unmute when microphone forbidden', async () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(null); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error());
await manager.connect(); await manager.connect();
@@ -103,10 +137,23 @@ describe('MicrophoneManager', () => {
expect(api.getCardElementManager().update).toBeCalledTimes(1); 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 () => { it('should connect on unmute', async () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
expect(manager.isConnected()).toBeFalsy(); expect(manager.isConnected()).toBeFalsy();
@@ -122,7 +169,9 @@ describe('MicrophoneManager', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.connect(); await manager.connect();
expect(manager.isConnected()).toBeTruthy(); expect(manager.isConnected()).toBeTruthy();
@@ -139,7 +188,9 @@ describe('MicrophoneManager', () => {
const disconnectSeconds = 10; const disconnectSeconds = 10;
const api = createCardAPI(); const api = createCardAPI();
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ createConfig({
@@ -168,7 +219,9 @@ describe('MicrophoneManager', () => {
const disconnectSeconds = 10; const disconnectSeconds = 10;
const api = createCardAPI(); const api = createCardAPI();
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ createConfig({
@@ -191,10 +244,64 @@ describe('MicrophoneManager', () => {
expect(api.getCardElementManager().update).toBeCalledTimes(1); 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 () => { it('should respect listeners', async () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
const listener = vi.fn(); const listener = vi.fn();
manager.addListener(listener); manager.addListener(listener);
@@ -235,7 +342,9 @@ describe('MicrophoneManager', () => {
it('should set condition state', async () => { it('should set condition state', async () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
expect(api.getConditionsManager().setState).not.toBeCalled(); expect(api.getConditionsManager().setState).not.toBeCalled();
@@ -903,7 +903,11 @@ describe('MenuButtonController', () => {
describe('should have microphone button', () => { describe('should have microphone button', () => {
it('with suitable loaded media', () => { it('with suitable loaded media', () => {
const microphoneManager = new MicrophoneManager(createCardAPI()); const microphoneManager = mock<MicrophoneManager>();
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
microphoneManager: microphoneManager, microphoneManager: microphoneManager,
currentMediaLoadedInfo: createMediaLoadedInfo({ currentMediaLoadedInfo: createMediaLoadedInfo({
@@ -935,7 +939,11 @@ describe('MenuButtonController', () => {
}); });
it('without suitable loaded media', () => { it('without suitable loaded media', () => {
const microphoneManager = new MicrophoneManager(createCardAPI()); const microphoneManager = mock<MicrophoneManager>();
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
microphoneManager: microphoneManager, microphoneManager: microphoneManager,
currentMediaLoadedInfo: createMediaLoadedInfo({ currentMediaLoadedInfo: createMediaLoadedInfo({
@@ -951,8 +959,9 @@ describe('MenuButtonController', () => {
}); });
it('with forbidden microphone', () => { it('with forbidden microphone', () => {
const microphoneManager = new MicrophoneManager(createCardAPI()); const microphoneManager = mock<MicrophoneManager>();
mock<MicrophoneManager>(microphoneManager).isForbidden.mockReturnValue(true); vi.mocked(microphoneManager.isForbidden).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
microphoneManager: microphoneManager, microphoneManager: microphoneManager,
currentMediaLoadedInfo: createMediaLoadedInfo({ currentMediaLoadedInfo: createMediaLoadedInfo({
@@ -973,8 +982,11 @@ describe('MenuButtonController', () => {
}); });
it('with muted microphone', () => { it('with muted microphone', () => {
const microphoneManager = new MicrophoneManager(createCardAPI()); const microphoneManager = mock<MicrophoneManager>();
mock<MicrophoneManager>(microphoneManager).isMuted.mockReturnValue(true); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
microphoneManager: microphoneManager, microphoneManager: microphoneManager,
currentMediaLoadedInfo: createMediaLoadedInfo({ currentMediaLoadedInfo: createMediaLoadedInfo({
@@ -1002,9 +1014,37 @@ describe('MenuButtonController', () => {
}); });
}); });
it('with unsupported microphone', () => {
const microphoneManager = mock<MicrophoneManager>();
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', () => { it('with muted toggle type microphone', () => {
const microphoneManager = new MicrophoneManager(createCardAPI()); const microphoneManager = mock<MicrophoneManager>();
mock<MicrophoneManager>(microphoneManager).isMuted.mockReturnValue(true); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
vi.mocked(microphoneManager.isMuted).mockReturnValue(true);
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
microphoneManager: microphoneManager, microphoneManager: microphoneManager,
currentMediaLoadedInfo: createMediaLoadedInfo({ currentMediaLoadedInfo: createMediaLoadedInfo({
@@ -1032,8 +1072,11 @@ describe('MenuButtonController', () => {
}); });
it('with unmuted toggle type microphone', () => { it('with unmuted toggle type microphone', () => {
const microphoneManager = new MicrophoneManager(createCardAPI()); const microphoneManager = mock<MicrophoneManager>();
mock<MicrophoneManager>(microphoneManager).isMuted.mockReturnValue(false); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
microphoneManager: microphoneManager, microphoneManager: microphoneManager,
currentMediaLoadedInfo: createMediaLoadedInfo({ currentMediaLoadedInfo: createMediaLoadedInfo({