From 3003f821701055ba3e99c0fd300dfb4209914e61 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 18 Jan 2025 14:54:40 -0800 Subject: [PATCH] fix: Microphone should still work after a view change (#1835) - Closes #1810 --- src/card-controller/conditions-manager.ts | 9 +- src/card-controller/microphone-manager.ts | 118 +++++++----------- src/card-controller/types.ts | 8 ++ src/card.ts | 2 +- .../media-actions-controller.ts | 39 +++--- src/components/live/carousel.ts | 14 +-- src/components/live/grid.ts | 6 +- src/components/live/index.ts | 6 +- src/components/live/provider.ts | 5 +- src/components/live/providers/go2rtc/index.ts | 21 ++-- .../live/providers/go2rtc/video-rtc.js | 7 +- src/components/views.ts | 6 +- .../microphone-manager.test.ts | 98 +++++++-------- .../media-actions-controller.test.ts | 59 +++++---- 14 files changed, 186 insertions(+), 212 deletions(-) diff --git a/src/card-controller/conditions-manager.ts b/src/card-controller/conditions-manager.ts index 1950b47b..727caf90 100644 --- a/src/card-controller/conditions-manager.ts +++ b/src/card-controller/conditions-manager.ts @@ -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; interaction?: boolean; - microphone?: MicrophoneConditionState; + microphone?: MicrophoneState; user?: CurrentUser; keys?: KeysState; user_agent?: string; diff --git a/src/card-controller/microphone-manager.ts b/src/card-controller/microphone-manager.ts index 86cc31a0..0cd56624 100644 --- a/src/card-controller/microphone-manager.ts +++ b/src/card-controller/microphone-manager.ts @@ -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 { @@ -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(); } } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 55ac324f..f78a20ff 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -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; } diff --git a/src/card.ts b/src/card.ts index 5ce39029..52a39d0a 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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} diff --git a/src/components-lib/media-actions-controller.ts b/src/components-lib/media-actions-controller.ts index 181e2580..4f34c122 100644 --- a/src/components-lib/media-actions-controller.ts +++ b/src/components-lib/media-actions-controller.ts @@ -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 => { - if (change === 'unmuted') { + + protected async _microphoneStateChangeHandler( + oldState?: MicrophoneState, + newState?: MicrophoneState, + ): Promise { + 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 { }, ); } - }; + } } diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts index 2e1262d2..960a4df1 100644 --- a/src/components/live/carousel.ts +++ b/src/components/live/carousel.ts @@ -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 {
; @@ -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)} > diff --git a/src/components/live/index.ts b/src/components/live/index.ts index 11a23b11..758bc4b5 100644 --- a/src/components/live/index.ts +++ b/src/components/live/index.ts @@ -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; @@ -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} > diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 3d3ac811..22554874 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -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()} diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index 79e4b899..2f7c57af 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -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(); } } diff --git a/src/components/live/providers/go2rtc/video-rtc.js b/src/components/live/providers/go2rtc/video-rtc.js index 7f1caa82..7ab76783 100644 --- a/src/components/live/providers/go2rtc/video-rtc.js +++ b/src/components/live/providers/go2rtc/video-rtc.js @@ -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; } diff --git a/src/components/views.ts b/src/components/views.ts index e637c1dd..044f4226 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -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; @@ -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)}" > diff --git a/tests/card-controller/microphone-manager.test.ts b/tests/card-controller/microphone-manager.test.ts index d8c30201..4f8a0765 100644 --- a/tests/card-controller/microphone-manager.test.ts +++ b/tests/card-controller/microphone-manager.test.ts @@ -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, }), ); }); diff --git a/tests/components-lib/media-actions-controller.test.ts b/tests/components-lib/media-actions-controller.test.ts index d2bfdb11..2e39c960 100644 --- a/tests/components-lib/media-actions-controller.test.ts +++ b/tests/components-lib/media-actions-controller.test.ts @@ -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 => { + return { + muted: true, + forbidden: false, + connected: false, + ...state, + }; + }; + it('should unmute when microphone unmuted', async () => { - const microphoneManager = mock(); 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(); + 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(); + 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();