fix: Microphone should still work after a view change (#1835)
- Closes #1810
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user