Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/card-controller/initialization-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
}),
Expand Down
24 changes: 24 additions & 0 deletions src/card-controller/microphone-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
if (!this.isSupported()) {
return false;
}

try {
this._stream = await navigator.mediaDevices.getUserMedia({
audio: true,
Expand Down Expand Up @@ -76,6 +96,10 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager {
}

public async unmute(): Promise<void> {
if (!this.isSupported()) {
return;
}

const wasUnmuted = !this.isMuted();

const unmute = (): void => {
Expand Down
11 changes: 6 additions & 5 deletions src/components-lib/menu-button-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,20 +374,21 @@ 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'
: 'mdi:microphone',
...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',
Expand All @@ -396,7 +397,7 @@ export class MenuButtonController {
'microphone_mute',
) as FrigateCardCustomAction,
}),
...(!forbidden &&
...(!unavailable &&
buttonType === 'toggle' && {
tap_action: createGeneralAction(
muted ? 'microphone_unmute' : 'microphone_mute',
Expand Down
26 changes: 8 additions & 18 deletions tests/card-controller/initialization-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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(
Expand Down
131 changes: 120 additions & 11 deletions tests/card-controller/microphone-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Navigator>(),
mediaDevices: {
...mock<MediaDevices>(),
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(() => {
Expand All @@ -19,6 +28,7 @@ describe('MicrophoneManager', () => {
afterEach(() => {
vi.resetAllMocks();
vi.unstubAllGlobals;
navigator.mediaDevices;
});

const createMockStream = (mute?: boolean): MediaStream => {
Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -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();
Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -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();
Expand All @@ -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({
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -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();

Expand Down
Loading
Loading