fix: Microphone should still work after a view change (#1835)

- Closes #1810
This commit is contained in:
Dermot Duffy
2025-01-18 14:54:40 -08:00
committed by GitHub
parent 57a687d659
commit 3003f82170
14 changed files with 186 additions and 212 deletions
+2 -7
View File
@@ -20,12 +20,7 @@ import { localize } from '../localize/localize';
import { FrigateCardError } from '../types';
import { desparsifyArrays } from '../utils/basic';
import { isCompanionApp } from '../utils/companion';
import { CardConditionAPI, KeysState } from './types';
interface MicrophoneConditionState {
connected?: boolean;
muted?: boolean;
}
import { CardConditionAPI, KeysState, MicrophoneState } from './types';
interface ConditionState {
view?: string;
@@ -37,7 +32,7 @@ interface ConditionState {
displayMode?: ViewDisplayMode;
triggered?: Set<string>;
interaction?: boolean;
microphone?: MicrophoneConditionState;
microphone?: MicrophoneState;
user?: CurrentUser;
keys?: KeysState;
user_agent?: string;
+42 -76
View File
@@ -1,36 +1,33 @@
import { errorToConsole } from '../utils/basic';
import { Timer } from '../utils/timer';
import { CardMicrophoneAPI } from './types';
import { CardMicrophoneAPI, MicrophoneState } from './types';
export type MicrophoneManagerListenerChange = 'muted' | 'unmuted';
type MicrophoneManagerListener = (change: MicrophoneManagerListenerChange) => void;
export interface ReadonlyMicrophoneManager {
getStream(): MediaStream | undefined;
addListener(listener: MicrophoneManagerListener): void;
removeListener(listener: MicrophoneManagerListener): void;
isConnected(): boolean;
isForbidden(): boolean;
isMuted(): boolean;
}
export class MicrophoneManager implements ReadonlyMicrophoneManager {
export class MicrophoneManager {
protected _api: CardMicrophoneAPI;
protected _stream?: MediaStream | null;
protected _timer = new Timer();
protected _listeners: MicrophoneManagerListener[] = [];
// We keep mute state separate from the stream state so that mute/unmute can
// be expressed before the stream is created -- and when it's create it will
// have the right mute status.
protected _mute = true;
protected _state: MicrophoneState = {
connected: false,
muted: true,
forbidden: false,
};
// We keep desired mute state separate from the overall state so that
// mute/unmute can be expressed before the stream is even created -- and when
// it's created it will have the right mute status.
protected _desireMute = true;
constructor(api: CardMicrophoneAPI) {
this._api = api;
}
public getState(): MicrophoneState {
return this._state;
}
public initialize(): void {
this._setConditionState();
this._setState();
}
public shouldConnectOnInitialization(): boolean {
@@ -63,11 +60,11 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager {
errorToConsole(e as Error);
this._stream = null;
this._api.getCardElementManager().update();
this._setState();
return false;
}
this._setMute();
this._setConditionState();
this._setDesiredMuteOnStream();
this._setState();
return true;
}
@@ -75,8 +72,7 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager {
this._stream?.getTracks().forEach((track) => track.stop());
this._stream = undefined;
this._setConditionState();
this._api.getCardElementManager().update();
this._setState();
}
public getStream(): MediaStream | undefined {
@@ -84,15 +80,9 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager {
}
public mute(): void {
const wasMuted = this.isMuted();
this._mute = true;
this._setMute();
this._setConditionState();
if (!wasMuted) {
this._callListeners('muted');
}
this._desireMute = true;
this._setDesiredMuteOnStream();
this._setState();
}
public async unmute(): Promise<void> {
@@ -100,29 +90,14 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager {
return;
}
const wasUnmuted = !this.isMuted();
const unmute = (): void => {
this._mute = false;
this._setMute();
};
this._desireMute = false;
if (!this.isConnected() && !this.isForbidden()) {
// The connect() call is async and make take an arbitrary amount of
// time for the user to grant access to their microphone. With a
// momentary microphone button the mute call (on mouse release) may
// arrive before the connection is even granted, so we unmute first
// before the connection is made, so the mute call on release will not
// be 'overwritten' incorrectly.
unmute();
// Connecting will automatically set the desired mute.
await this.connect();
} else if (this.isConnected()) {
unmute();
}
this._setConditionState();
if (!wasUnmuted) {
this._callListeners('unmuted');
this._setDesiredMuteOnStream();
this._setState();
}
}
@@ -136,32 +111,19 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager {
public isMuted(): boolean {
// For safety, this function always returns the stream mute status directly
// (rather the internal state).
// (rather the desired internal state).
return !this._stream || this._stream.getTracks().every((track) => !track.enabled);
}
public addListener(listener: MicrophoneManagerListener): void {
this._listeners.push(listener);
}
public removeListener(listener: MicrophoneManagerListener): void {
this._listeners = this._listeners.filter((l) => l !== listener);
}
protected _callListeners(change: MicrophoneManagerListenerChange): void {
this._listeners.forEach((listener) => listener(change));
}
protected _setMute(): void {
protected _setDesiredMuteOnStream(): void {
this._stream?.getTracks().forEach((track) => {
track.enabled = !this._mute;
track.enabled = !this._desireMute;
});
this._startTimer();
this._api.getCardElementManager().update();
this._startDisconnectTimer();
}
protected _startTimer(): void {
protected _startDisconnectTimer(): void {
const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone;
if (microphoneConfig?.always_connected) {
@@ -177,12 +139,16 @@ export class MicrophoneManager implements ReadonlyMicrophoneManager {
}
}
protected _setConditionState(): void {
protected _setState(): void {
this._state = {
stream: this._stream,
connected: this.isConnected(),
muted: this.isMuted(),
forbidden: this.isForbidden(),
};
this._api.getConditionsManager().setState({
microphone: {
muted: this.isMuted(),
connected: this.isConnected(),
},
microphone: this._state,
});
this._api.getCardElementManager().update();
}
}
+8
View File
@@ -285,6 +285,14 @@ export interface KeysState {
meta: boolean;
};
}
export interface MicrophoneState {
stream?: MediaStream | null;
connected: boolean;
muted: boolean;
forbidden: boolean;
}
interface TaggedAutomation extends Automation {
tag?: unknown;
}
+1 -1
View File
@@ -405,7 +405,7 @@ class FrigateCard extends LitElement {
.getConditionsManager()
?.getEpoch()}
.hide=${!!this._controller.getMessageManager().hasMessage()}
.microphoneManager=${this._controller.getMicrophoneManager()}
.microphoneState=${this._controller.getMicrophoneManager().getState()}
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
? this._controller.getTriggersManager().getTriggeredCameraIDs()
: undefined}
+22 -17
View File
@@ -1,7 +1,4 @@
import {
MicrophoneManagerListenerChange,
ReadonlyMicrophoneManager,
} from '../card-controller/microphone-manager.js';
import { MicrophoneState } from '../card-controller/types.js';
import {
AutoMuteCondition,
AutoPauseCondition,
@@ -20,7 +17,7 @@ export interface MediaActionsControllerOptions {
autoPauseConditions?: readonly AutoPauseCondition[];
autoMuteConditions?: readonly AutoMuteCondition[];
microphoneManager?: ReadonlyMicrophoneManager;
microphoneState?: MicrophoneState;
microphoneMuteSeconds?: number;
}
@@ -54,12 +51,14 @@ export class MediaActionsController {
);
public setOptions(options: MediaActionsControllerOptions): void {
this._options = options;
if (this._options?.microphoneManager) {
this._options.microphoneManager.removeListener(this._microphoneChangeHandler);
this._options.microphoneManager.addListener(this._microphoneChangeHandler);
if (this._options?.microphoneState !== options.microphoneState) {
this._microphoneStateChangeHandler(
this._options?.microphoneState,
options.microphoneState,
);
}
this._options = options;
}
public hasRoot(): boolean {
@@ -75,7 +74,6 @@ export class MediaActionsController {
this._target = null;
this._mutationObserver.disconnect();
this._intersectionObserver.disconnect();
this._options?.microphoneManager?.removeListener(this._microphoneChangeHandler);
document.removeEventListener('visibilitychange', this._visibilityHandler);
}
@@ -255,13 +253,20 @@ export class MediaActionsController {
await this._muteAllIfConfigured('hidden');
}
};
protected _microphoneChangeHandler = async (
change: MicrophoneManagerListenerChange,
): Promise<void> => {
if (change === 'unmuted') {
protected async _microphoneStateChangeHandler(
oldState?: MicrophoneState,
newState?: MicrophoneState,
): Promise<void> {
if (!oldState || !newState) {
return;
}
if (oldState.muted && !newState.muted) {
await this._unmuteTargetIfConfigured('microphone');
} else if (
change === 'muted' &&
!oldState.muted &&
newState.muted &&
this._options?.autoMuteConditions?.includes('microphone')
) {
this._microphoneMuteTimer.start(
@@ -271,5 +276,5 @@ export class MediaActionsController {
},
);
}
};
}
}
+7 -7
View File
@@ -15,7 +15,7 @@ import {
ConditionsManagerEpoch,
getOverriddenConfig,
} from '../../card-controller/conditions-manager.js';
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
import { MicrophoneState } from '../../card-controller/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
@@ -88,7 +88,7 @@ export class FrigateCardLiveCarousel extends LitElement {
public cameraManager?: CameraManager;
@property({ attribute: false })
public microphoneManager?: ReadonlyMicrophoneManager;
public microphoneState?: MicrophoneState;
@property({ attribute: false })
public viewFilterCameraID?: string;
@@ -139,7 +139,7 @@ export class FrigateCardLiveCarousel extends LitElement {
protected willUpdate(changedProps: PropertyValues): void {
if (
changedProps.has('microphoneManager') ||
changedProps.has('microphoneState') ||
changedProps.has('overriddenLiveConfig')
) {
this._mediaActionsController.setOptions({
@@ -158,7 +158,7 @@ export class FrigateCardLiveCarousel extends LitElement {
}),
...((this.overriddenLiveConfig?.auto_unmute ||
this.overriddenLiveConfig?.auto_mute) && {
microphoneManager: this.microphoneManager,
microphoneState: this.microphoneState,
microphoneMuteSeconds:
this.overriddenLiveConfig.microphone.mute_after_microphone_mute_seconds,
}),
@@ -302,8 +302,8 @@ export class FrigateCardLiveCarousel extends LitElement {
<div class="embla__slide">
<frigate-card-live-provider
?load=${!liveConfig.lazy_load}
.microphoneStream=${view?.camera === cameraID
? this.microphoneManager?.getStream()
.microphoneState=${view?.camera === cameraID
? this.microphoneState
: undefined}
.cameraConfig=${cameraConfig}
.cameraEndpoints=${guard(
@@ -429,7 +429,7 @@ export class FrigateCardLiveCarousel extends LitElement {
.loop=${hasMultipleCameras}
.dragEnabled=${hasMultipleCameras && this.overriddenLiveConfig?.draggable}
.plugins=${guard(
[this.cameraManager, this.overriddenLiveConfig, this.microphoneManager],
[this.cameraManager, this.overriddenLiveConfig],
this._getPlugins.bind(this),
)}
.selected=${this._getSelectedCameraIndex()}
+3 -3
View File
@@ -10,7 +10,7 @@ import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ConditionsManagerEpoch } from '../../card-controller/conditions-manager.js';
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
import { MicrophoneState } from '../../card-controller/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
import { CardWideConfig, LiveConfig, Overrides } from '../../config/types.js';
@@ -46,7 +46,7 @@ export class FrigateCardLiveGrid extends LitElement {
public cameraManager?: CameraManager;
@property({ attribute: false })
public microphoneManager?: ReadonlyMicrophoneManager;
public microphoneState?: MicrophoneState;
@property({ attribute: false })
public triggeredCameraIDs?: Set<string>;
@@ -67,7 +67,7 @@ export class FrigateCardLiveGrid extends LitElement {
.overrides=${this.overrides}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
.microphoneManager=${this.microphoneManager}
.microphoneState=${this.microphoneState}
?triggered=${triggeredCameraID &&
!!this.triggeredCameraIDs?.has(triggeredCameraID)}
>
+3 -3
View File
@@ -2,7 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ConditionsManagerEpoch } from '../../card-controller/conditions-manager.js';
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
import { MicrophoneState } from '../../card-controller/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { LiveController } from '../../components-lib/live/live-controller.js';
import { CardWideConfig, LiveConfig, Overrides } from '../../config/types.js';
@@ -38,7 +38,7 @@ export class FrigateCardLive extends LitElement {
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public microphoneManager?: ReadonlyMicrophoneManager;
public microphoneState?: MicrophoneState;
@property({ attribute: false })
public triggeredCameraIDs?: Set<string>;
@@ -67,7 +67,7 @@ export class FrigateCardLive extends LitElement {
.overrides=${this.overrides}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
.microphoneManager=${this.microphoneManager}
.microphoneState=${this.microphoneState}
.triggeredCameraIDs=${this.triggeredCameraIDs}
>
</frigate-card-live-grid>
+3 -2
View File
@@ -11,6 +11,7 @@ import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { MicrophoneState } from '../../card-controller/types.js';
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
import { PartialZoomSettings } from '../../components-lib/zoom/types.js';
import {
@@ -64,7 +65,7 @@ export class FrigateCardLiveProvider
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public microphoneStream?: MediaStream;
public microphoneState?: MicrophoneState;
@property({ attribute: false })
public zoomSettings?: PartialZoomSettings | null;
@@ -351,7 +352,7 @@ export class FrigateCardLiveProvider
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
.microphoneStream=${this.microphoneStream}
.microphoneState=${this.microphoneState}
.microphoneConfig=${this.liveConfig.microphone}
?controls=${this.liveConfig.controls.builtin}
@frigate-card:live:error=${() => this._providerErrorHandler()}
+12 -9
View File
@@ -8,6 +8,7 @@ import {
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { CameraEndpoints } from '../../../../camera-manager/types.js';
import { MicrophoneState } from '../../../../card-controller/types.js';
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
import { CameraConfig, MicrophoneConfig } from '../../../../config/types.js';
import { localize } from '../../../../localize/localize.js';
@@ -44,7 +45,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
public cameraEndpoints?: CameraEndpoints;
@property({ attribute: false })
public microphoneStream?: MediaStream;
public microphoneState?: MicrophoneState;
@property({ attribute: false })
public microphoneConfig?: MicrophoneConfig;
@@ -147,7 +148,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
this._player = new VideoRTC();
this._player.containingPlayer = this;
this._player.microphoneStream = this.microphoneStream ?? null;
this._player.microphoneStream = this.microphoneState?.stream ?? null;
this._player.src = address;
this._player.visibilityCheck = false;
this._player.controls = this.controls;
@@ -172,14 +173,16 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
this._player.controls = this.controls;
}
if (this._player && changedProps.has('microphoneStream')) {
if (this._player?.microphoneStream !== this.microphoneStream) {
this._player.microphoneStream = this.microphoneStream ?? null;
if (
this._player &&
changedProps.has('microphoneState') &&
this._player?.microphoneStream !== this.microphoneState?.stream
) {
this._player.microphoneStream = this.microphoneState?.stream ?? null;
// Need to force a reconnect if the microphone stream changes since
// WebRTC cannot introduce a new stream after the offer is already made.
this._player.reconnect();
}
// Need to force a reconnect if the microphone stream changes since
// WebRTC cannot introduce a new stream after the offer is already made.
this._player.reconnect();
}
}
@@ -394,9 +394,10 @@ export class VideoRTC extends HTMLElement {
this.pcState = WebSocket.CLOSED;
if (this.pc) {
this.pc.getSenders().forEach((sender) => {
if (sender.track) sender.track.stop();
});
// Do not close the (microphone) track attached to the peer connection as
// that is controlled by MicrophoneManager.
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1810
this.pc.close();
this.pc = null;
}
+3 -3
View File
@@ -10,7 +10,7 @@ import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { ConditionsManagerEpoch } from '../card-controller/conditions-manager.js';
import { ReadonlyMicrophoneManager } from '../card-controller/microphone-manager.js';
import { MicrophoneState } from '../card-controller/types.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import {
CardWideConfig,
@@ -60,7 +60,7 @@ export class FrigateCardViews extends LitElement {
public hide?: boolean;
@property({ attribute: false })
public microphoneManager?: ReadonlyMicrophoneManager;
public microphoneState?: MicrophoneState;
@property({ attribute: false })
public triggeredCameraIDs?: Set<string>;
@@ -243,7 +243,7 @@ export class FrigateCardViews extends LitElement {
.overrides=${this.overriddenConfig.overrides}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.microphoneManager=${this.microphoneManager}
.microphoneState=${this.microphoneState}
.triggeredCameraIDs=${this.triggeredCameraIDs}
class="${classMap(liveClasses)}"
>
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
import { MicrophoneState } from '../../src/card-controller/types';
import { createCardAPI, createConfig } from '../test-utils';
const navigatorMock: Navigator = {
@@ -162,7 +163,7 @@ describe('MicrophoneManager', () => {
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeFalsy();
expect(api.getCardElementManager().update).toBeCalledTimes(2);
expect(api.getCardElementManager().update).toBeCalled();
});
it('should disconnect', async () => {
@@ -296,95 +297,82 @@ describe('MicrophoneManager', () => {
});
});
it('should respect listeners', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
const listener = vi.fn();
manager.addListener(listener);
await manager.connect();
expect(listener).not.toHaveBeenCalled();
manager.mute();
expect(listener).not.toHaveBeenCalled();
await manager.unmute();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenLastCalledWith('unmuted');
await manager.unmute();
expect(listener).toHaveBeenCalledTimes(1);
manager.mute();
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenLastCalledWith('muted');
manager.removeListener(listener);
await manager.unmute();
expect(listener).toHaveBeenCalledTimes(2);
});
it('should initialize', () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
manager.initialize();
expect(api.getConditionsManager().setState).toBeCalledWith({
microphone: { connected: false, muted: true },
microphone: { connected: false, muted: true, forbidden: false, stream: undefined },
});
});
it('should set condition state', async () => {
it('should set state', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
expect(api.getConditionsManager().setState).not.toBeCalled();
await manager.connect();
let expectedState: MicrophoneState = {
forbidden: false,
stream: stream,
connected: true,
muted: true,
};
expect(manager.getState()).toEqual(expectedState);
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: {
connected: true,
muted: true,
},
microphone: expectedState,
}),
);
await manager.unmute();
expectedState = {
forbidden: false,
stream: stream,
connected: true,
muted: false,
};
expect(manager.getState()).toEqual(expectedState);
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: {
connected: true,
muted: false,
},
microphone: expectedState,
}),
);
manager.mute();
expectedState = {
forbidden: false,
stream: stream,
connected: true,
muted: true,
};
expect(manager.getState()).toEqual(expectedState);
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: {
connected: true,
muted: true,
},
microphone: expectedState,
}),
);
manager.disconnect();
expectedState = {
forbidden: false,
stream: undefined,
connected: false,
muted: true,
};
expect(manager.getState()).toEqual(expectedState);
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: {
connected: false,
muted: true,
},
microphone: expectedState,
}),
);
});
@@ -1,8 +1,5 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import {
MicrophoneManagerListenerChange,
ReadonlyMicrophoneManager,
} from '../../src/card-controller/microphone-manager';
import { MicrophoneState } from '../../src/card-controller/types';
import {
MediaActionsController,
MediaActionsControllerOptions,
@@ -17,7 +14,6 @@ import {
flushPromises,
} from '../test-utils';
import { callVisibilityHandler, createTestSlideNodes } from '../utils/embla/test-utils';
import { mock } from 'vitest-mock-extended';
const getPlayer = (
element: HTMLElement,
@@ -50,15 +46,6 @@ const createPlayerSlideNodes = (n = 10): HTMLElement[] => {
return divs;
};
const callMicrophoneListener = (
microphoneManager: ReadonlyMicrophoneManager,
action: MicrophoneManagerListenerChange,
n = 0,
): void => {
const mock = vi.mocked(microphoneManager.addListener).mock;
mock.calls[n][0](action);
};
// @vitest-environment jsdom
describe('MediaActionsController', () => {
beforeAll(() => {
@@ -541,7 +528,7 @@ describe('MediaActionsController', () => {
);
});
describe('should take action on microphone changes', () => {
describe('should take action on microphone state changes', () => {
beforeAll(() => {
vi.useFakeTimers();
});
@@ -550,14 +537,24 @@ describe('MediaActionsController', () => {
vi.useRealTimers();
});
const createMicrophoneState = (
state?: Partial<MicrophoneState>,
): MicrophoneState => {
return {
muted: true,
forbidden: false,
connected: false,
...state,
};
};
it('should unmute when microphone unmuted', async () => {
const microphoneManager = mock<ReadonlyMicrophoneManager>();
const controller = new MediaActionsController();
controller.setOptions({
autoUnmuteConditions: ['microphone' as const],
playerSelector: 'video',
microphoneManager: microphoneManager,
microphoneState: createMicrophoneState({ muted: true }),
});
const children = createPlayerSlideNodes();
@@ -565,19 +562,22 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, true);
callMicrophoneListener(microphoneManager, 'unmuted');
controller.setOptions({
autoUnmuteConditions: ['microphone' as const],
playerSelector: 'video',
microphoneState: createMicrophoneState({ muted: false }),
});
expect(getPlayer(children[0], 'video')?.unmute).toBeCalled();
});
it('should re-mute after delay after microphone unmuted', async () => {
const microphoneManager = mock<ReadonlyMicrophoneManager>();
it('should mute after delay after microphone muted', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoMuteConditions: ['microphone' as const],
playerSelector: 'video',
microphoneManager: microphoneManager,
microphoneState: createMicrophoneState({ muted: false }),
});
const children = createPlayerSlideNodes();
@@ -585,21 +585,24 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, true);
callMicrophoneListener(microphoneManager, 'muted');
controller.setOptions({
autoMuteConditions: ['microphone' as const],
playerSelector: 'video',
microphoneState: createMicrophoneState({ muted: true }),
});
vi.runOnlyPendingTimers();
expect(getPlayer(children[0], 'video')?.mute).toBeCalled();
});
it('should not re-mute after delay after microphone unmuted', async () => {
const microphoneManager = mock<ReadonlyMicrophoneManager>();
it('should not mute after delay after microphone muted', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoMuteConditions: [],
playerSelector: 'video',
microphoneManager: microphoneManager,
microphoneState: createMicrophoneState({ muted: false }),
});
const children = createPlayerSlideNodes();
@@ -607,7 +610,11 @@ describe('MediaActionsController', () => {
await controller.setTarget(0, true);
callMicrophoneListener(microphoneManager, 'muted');
controller.setOptions({
autoMuteConditions: ['microphone' as const],
playerSelector: 'video',
microphoneState: createMicrophoneState({ muted: true }),
});
vi.runOnlyPendingTimers();