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