fix: Use auto_unmute as input to stream selection for HA (#2562)
- Closes #2479
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
import {
|
||||||
|
MEDIA_ACTION_POSITIVE_CONDITIONS,
|
||||||
|
type AutoUnmuteCondition,
|
||||||
|
} from '../../config/schema/common/media-actions.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the configured auto-unmute policy will unmute a stream as it loads
|
||||||
|
* (i.e. on selection or visibility), rather than only later in response to a
|
||||||
|
* user action or call event. Used to decide whether to pre-select a camera's
|
||||||
|
* audio-carrying stream up front instead of switching to it after the fact.
|
||||||
|
*/
|
||||||
|
export const isAudioIntendedOnLoad = (
|
||||||
|
autoUnmute: readonly AutoUnmuteCondition[],
|
||||||
|
): boolean =>
|
||||||
|
MEDIA_ACTION_POSITIVE_CONDITIONS.some((condition) => autoUnmute.includes(condition));
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||||
|
|
||||||
|
// Dispatched by the ha-camera-stream patch when the VISIBLE leaf's output mute
|
||||||
|
// changes. The patch resolves the visible leaf synchronously and ships the
|
||||||
|
// value here, so consumers never have to query it asynchronously.
|
||||||
|
export const HA_CAMERA_STREAM_MUTE_CHANGE_EVENT =
|
||||||
|
'advanced-camera-card:ha-camera-stream:mute-change';
|
||||||
|
|
||||||
|
interface HACameraStreamMuteChangeDetail {
|
||||||
|
muted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HAStreamMuteControllerOptions {
|
||||||
|
// Effective camera entity id of the currently displayed (possibly substream)
|
||||||
|
// camera. A change resets the stream selection, since a reused element must
|
||||||
|
// not inherit the previous camera's selection.
|
||||||
|
getCameraEntityID: () => string | null;
|
||||||
|
|
||||||
|
// Whether audio is intended on load for this camera (its auto-unmute policy
|
||||||
|
// fires on selection/visibility). Seeds the selection on a camera change so a
|
||||||
|
// mixed-capability camera starts on the audio-capable stream.
|
||||||
|
getPreferAudioStream: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMuteChangeEvent = (
|
||||||
|
ev: Event,
|
||||||
|
): ev is CustomEvent<HACameraStreamMuteChangeDetail> => {
|
||||||
|
if (!(ev instanceof CustomEvent)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const detail: unknown = ev.detail;
|
||||||
|
return (
|
||||||
|
typeof detail === 'object' &&
|
||||||
|
detail !== null &&
|
||||||
|
'muted' in detail &&
|
||||||
|
typeof detail.muted === 'boolean'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the HA stream's mute state for `advanced-camera-card-live-ha`, split into
|
||||||
|
* the two roles HA conflates in `muted`:
|
||||||
|
*
|
||||||
|
* - `intendedMute`: a one-way latch feeding ha-camera-stream's `muted` (HA's
|
||||||
|
* stream chooser). Seeded from the audio intent on a camera change, flipped
|
||||||
|
* to false the first time the visible leaf is unmuted, and never flipped
|
||||||
|
* back except on a camera change. Keeps muted views on the low-latency
|
||||||
|
* stream and prevents an autoplay force-mute from downgrading the stream.
|
||||||
|
* - `outputMute`: the visible leaf's real output mute, mirrored from the
|
||||||
|
* patch's mute-change event. The leaf players bind to this (not the latch),
|
||||||
|
* so a remount restores the real mute instead of the sticky latch value.
|
||||||
|
*
|
||||||
|
* See: https://github.com/dermotduffy/advanced-camera-card/issues/2479
|
||||||
|
*/
|
||||||
|
export class HAStreamMuteController implements ReactiveController {
|
||||||
|
private _host: ReactiveControllerHost & HTMLElement;
|
||||||
|
private _options: HAStreamMuteControllerOptions;
|
||||||
|
|
||||||
|
private _intendedMute = true;
|
||||||
|
private _outputMute = true;
|
||||||
|
|
||||||
|
// The camera entity the current state belongs to, to detect a camera change.
|
||||||
|
private _cameraEntityID: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
host: ReactiveControllerHost & HTMLElement,
|
||||||
|
options: HAStreamMuteControllerOptions,
|
||||||
|
) {
|
||||||
|
this._host = host;
|
||||||
|
this._options = options;
|
||||||
|
host.addController(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getIntendedMute(): boolean {
|
||||||
|
return this._intendedMute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getOutputMute(): boolean {
|
||||||
|
return this._outputMute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostConnected(): void {
|
||||||
|
this._host.addEventListener(
|
||||||
|
HA_CAMERA_STREAM_MUTE_CHANGE_EVENT,
|
||||||
|
this._muteChangeHandler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostDisconnected(): void {
|
||||||
|
this._host.removeEventListener(
|
||||||
|
HA_CAMERA_STREAM_MUTE_CHANGE_EVENT,
|
||||||
|
this._muteChangeHandler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostUpdate(): void {
|
||||||
|
// Reset only when the displayed camera changes, never on an intent change
|
||||||
|
// alone: that would clobber a user's runtime unmute.
|
||||||
|
const cameraEntityID = this._options.getCameraEntityID();
|
||||||
|
if (cameraEntityID !== this._cameraEntityID) {
|
||||||
|
this._cameraEntityID = cameraEntityID;
|
||||||
|
this._intendedMute = !this._options.getPreferAudioStream();
|
||||||
|
this._outputMute = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _muteChangeHandler = (ev: Event): void => {
|
||||||
|
if (!isMuteChangeEvent(ev)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const muted = ev.detail.muted;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
if (muted !== this._outputMute) {
|
||||||
|
this._outputMute = muted;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmuting flips the selection latch; muting never does (one-way).
|
||||||
|
if (this._intendedMute && !muted) {
|
||||||
|
this._intendedMute = false;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
this._host.requestUpdate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
|||||||
|
|
||||||
import type { Camera } from '../../camera-manager/camera.js';
|
import type { Camera } from '../../camera-manager/camera.js';
|
||||||
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||||
|
import { isAudioIntendedOnLoad } from '../../components-lib/live/audio-intent.js';
|
||||||
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
|
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
|
||||||
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
|
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
|
||||||
import type { PartialZoomSettings } from '../../components-lib/zoom/types.js';
|
import type { PartialZoomSettings } from '../../components-lib/zoom/types.js';
|
||||||
@@ -335,6 +336,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.camera=${this.camera}
|
.camera=${this.camera}
|
||||||
.targetID=${this.targetID}
|
.targetID=${this.targetID}
|
||||||
|
.preferAudioStream=${this.forceSelected &&
|
||||||
|
isAudioIntendedOnLoad(this.liveConfig?.auto_unmute ?? [])}
|
||||||
?controls=${this._getEffectiveBuiltinControls()}
|
?controls=${this._getEffectiveBuiltinControls()}
|
||||||
@advanced-camera-card:live:error=${(ev: Event) =>
|
@advanced-camera-card:live:error=${(ev: Event) =>
|
||||||
this._providerErrorHandler(ev)}
|
this._providerErrorHandler(ev)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { customElement, property } from 'lit/decorators.js';
|
|||||||
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
||||||
|
|
||||||
import type { Camera } from '../../../camera-manager/camera.js';
|
import type { Camera } from '../../../camera-manager/camera.js';
|
||||||
|
import { HAStreamMuteController } from '../../../components-lib/live/ha-stream-mute-controller.js';
|
||||||
import type { HomeAssistant } from '../../../ha/types';
|
import type { HomeAssistant } from '../../../ha/types';
|
||||||
|
|
||||||
import '../../../patches/ha-camera-stream';
|
import '../../../patches/ha-camera-stream';
|
||||||
@@ -37,8 +38,20 @@ export class AdvancedCameraCardLiveHA extends LitElement implements MediaPlayer
|
|||||||
@property({ attribute: true, type: Boolean })
|
@property({ attribute: true, type: Boolean })
|
||||||
public controls = false;
|
public controls = false;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public preferAudioStream = false;
|
||||||
|
|
||||||
private _playerRef: Ref<MediaPlayerElement> = createRef();
|
private _playerRef: Ref<MediaPlayerElement> = createRef();
|
||||||
|
|
||||||
|
// Owns the mute state for the underlying ha-camera-stream: it feeds `muted`
|
||||||
|
// (which is surprisingly used by HA to select WebRTC vs HLS streams) and
|
||||||
|
// `outputMute` (the actual player's audio output) into the element, seeded
|
||||||
|
// from the audio intent.
|
||||||
|
private _muteController = new HAStreamMuteController(this, {
|
||||||
|
getCameraEntityID: () => this.camera?.getConfig()?.camera_entity ?? null,
|
||||||
|
getPreferAudioStream: () => this.preferAudioStream,
|
||||||
|
});
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
return (await this._playerRef.value?.getMediaPlayerController()) ?? null;
|
return (await this._playerRef.value?.getMediaPlayerController()) ?? null;
|
||||||
@@ -56,6 +69,8 @@ export class AdvancedCameraCardLiveHA extends LitElement implements MediaPlayer
|
|||||||
.stateObj=${cameraEntity ? this.hass.states[cameraEntity] : undefined}
|
.stateObj=${cameraEntity ? this.hass.states[cameraEntity] : undefined}
|
||||||
.controls=${this.controls}
|
.controls=${this.controls}
|
||||||
.targetID=${this.targetID}
|
.targetID=${this.targetID}
|
||||||
|
.muted=${this._muteController.getIntendedMute()}
|
||||||
|
.outputMute=${this._muteController.getOutputMute()}
|
||||||
>
|
>
|
||||||
</advanced-camera-card-ha-camera-stream>`;
|
</advanced-camera-card-ha-camera-stream>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
type CSSResultGroup,
|
type CSSResultGroup,
|
||||||
type PropertyValues,
|
type PropertyValues,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
import { HA_CAMERA_STREAM_MUTE_CHANGE_EVENT } from '../components-lib/live/ha-stream-mute-controller.js';
|
||||||
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
|
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
|
||||||
|
|
||||||
import '../components/image-player.js';
|
import '../components/image-player.js';
|
||||||
@@ -75,77 +76,45 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
// The currently-visible stream type, refreshed in `updated()`.
|
// The currently-visible stream type, refreshed in `updated()`.
|
||||||
private _visibleStreamType: StreamType | null = null;
|
private _visibleStreamType: StreamType | null = null;
|
||||||
|
|
||||||
// -------- Audio / stream selection model (hacking around HA!) --------
|
// HA chooses between a camera's low-latency and audio-carrying streams from
|
||||||
//
|
// `muted`, and never re-selects in response to the native <video> controls.
|
||||||
// The HA frontend chooses between a camera's streams (e.g. low-latency
|
// ACC's live view is interactive, so the mute state is owned above this
|
||||||
// WebRTC vs higher-latency HLS) from `muted`: when unmuted it switches to a
|
// element, by HAStreamMuteController on `advanced-camera-card-live-ha`:
|
||||||
// stream that carries audio if the chosen one has none. HA sets `muted`
|
// - `this.muted` (which stream HA selects) and `outputMute` (the player's
|
||||||
// statically per context (i.e. a stock card sets it once); the native
|
// audio mute) are inputs from it.
|
||||||
// <video> controls only toggle the video element's output -- so HA never
|
// - this element reports the visible player's real mute back up, on any
|
||||||
// re-selects in response to native video controls
|
// control changing it, via `HA_CAMERA_STREAM_MUTE_CHANGE_EVENT` (this
|
||||||
//
|
// is unlike HA which does not surface native-control changes itself).
|
||||||
// ACC's live view is interactive, with both external audio controls (i.e.
|
|
||||||
// menu buttons) and native video audio controls, so it must split the two
|
|
||||||
// roles HA conflates in the single `muted` variable:
|
|
||||||
// - `this.muted` is a one-way stream-selection latch (as defined in the
|
|
||||||
// HA frontend code that this builds on). It starts `true` (low-latency
|
|
||||||
// WebRTC) and flips to `false` the first time the visible stream is
|
|
||||||
// unmuted by any control, switching to the audio-capable stream (if
|
|
||||||
// necessary). It never flips back, so a muted view keeps low latency
|
|
||||||
// and an autoplay force-mute cannot downgrade the stream.
|
|
||||||
// - `_outputMuted` is the stream's actual output mute state, mirrored
|
|
||||||
// from the stream's `volumechange` event. The stream players bind to
|
|
||||||
// this (not the latch), so a remount (e.g. lazy reload) restores the
|
|
||||||
// real mute instead of the latch value -- otherwise a muted view could
|
|
||||||
// return unmuted.
|
|
||||||
//
|
//
|
||||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2479
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2479
|
||||||
// ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
// The stream's true muted state.
|
// The visible player's output mute.
|
||||||
@state()
|
@property({ attribute: false })
|
||||||
private _streamMuted = true;
|
public outputMute = true;
|
||||||
|
|
||||||
// On any stream volume change: mirror it into `_streamMuted` (so a remount
|
// Report the visible player's real mute upward on any volume change (native
|
||||||
// of an element can restore it), and latch `muted` to false the first time
|
// or menu control) so the controller can react.
|
||||||
// the stream becomes unmuted (switching to the audio-capable stream).
|
private _volumeChangeHandler = (): void => {
|
||||||
// Muting is never latched, i.e. unmuting can cause a stream switch, but
|
const muted =
|
||||||
// muting cannot.
|
|
||||||
private _streamVolumeChangeHandler = (): void => {
|
|
||||||
const leafMuted =
|
|
||||||
this._getVisibleMediaLoadedInfo()?.mediaPlayerController?.isMuted() ?? true;
|
this._getVisibleMediaLoadedInfo()?.mediaPlayerController?.isMuted() ?? true;
|
||||||
this._streamMuted = leafMuted;
|
this.dispatchEvent(
|
||||||
|
new CustomEvent(HA_CAMERA_STREAM_MUTE_CHANGE_EVENT, {
|
||||||
if (this.muted && !leafMuted) {
|
detail: { muted },
|
||||||
this.muted = false;
|
bubbles: true,
|
||||||
}
|
composed: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
// Start muted: low-latency WebRTC (HaCameraStream defaults `muted` false,
|
|
||||||
// in ACC unmute is controlled by user-specified policy.
|
|
||||||
this.muted = true;
|
|
||||||
|
|
||||||
this.addEventListener(
|
this.addEventListener(
|
||||||
'advanced-camera-card:media:volumechange',
|
'advanced-camera-card:media:volumechange',
|
||||||
this._streamVolumeChangeHandler,
|
this._volumeChangeHandler,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public willUpdate(changedProps: PropertyValues): void {
|
|
||||||
super.willUpdate(changedProps);
|
|
||||||
|
|
||||||
// A new camera (entity) on a reused element must not inherit the previous
|
|
||||||
// camera's audio latch -- start muted on the low-latency stream again.
|
|
||||||
const previousStateObj = changedProps.get('stateObj');
|
|
||||||
if (previousStateObj && previousStateObj.entity_id !== this.stateObj?.entity_id) {
|
|
||||||
this.muted = true;
|
|
||||||
this._streamMuted = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================================
|
// ========================================================================================
|
||||||
// Minor modifications from:
|
// Minor modifications from:
|
||||||
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-camera-stream.ts
|
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-camera-stream.ts
|
||||||
@@ -156,7 +125,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
return this._getVisibleMediaLoadedInfo()?.mediaPlayerController ?? null;
|
return this._getVisibleMediaLoadedInfo()?.mediaPlayerController ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The visible stream's leaf info, looked up by the live stream type.
|
// The visible stream's player info, looked up by the live stream type.
|
||||||
private _getVisibleMediaLoadedInfo(): MediaLoadedInfo | null {
|
private _getVisibleMediaLoadedInfo(): MediaLoadedInfo | null {
|
||||||
return this._visibleStreamType
|
return this._visibleStreamType
|
||||||
? this._mediaLoadedInfoPerStream[this._visibleStreamType] ?? null
|
? this._mediaLoadedInfoPerStream[this._visibleStreamType] ?? null
|
||||||
@@ -167,7 +136,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
stream: StreamType,
|
stream: StreamType,
|
||||||
ev: CustomEvent<MediaLoadedInfoEventDetail>,
|
ev: CustomEvent<MediaLoadedInfoEventDetail>,
|
||||||
) {
|
) {
|
||||||
// Stop the inner-leaf event at the aggregator boundary; the visible
|
// Stop the inner-player event at the aggregator boundary; the visible
|
||||||
// stream's info is republished via this aggregator's own source
|
// stream's info is republished via this aggregator's own source
|
||||||
// controller in updated().
|
// controller in updated().
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
@@ -205,7 +174,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
?autoplay=${false}
|
?autoplay=${false}
|
||||||
playsinline
|
playsinline
|
||||||
.allowExoPlayer=${this.allowExoPlayer}
|
.allowExoPlayer=${this.allowExoPlayer}
|
||||||
.muted=${this._streamMuted}
|
.muted=${this.outputMute}
|
||||||
.controls=${this.controls}
|
.controls=${this.controls}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.entityid=${this.stateObj.entity_id}
|
.entityid=${this.stateObj.entity_id}
|
||||||
@@ -223,7 +192,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
return html`<advanced-camera-card-ha-web-rtc-player
|
return html`<advanced-camera-card-ha-web-rtc-player
|
||||||
?autoplay=${false}
|
?autoplay=${false}
|
||||||
playsinline
|
playsinline
|
||||||
.muted=${this._streamMuted}
|
.muted=${this.outputMute}
|
||||||
.controls=${this.controls}
|
.controls=${this.controls}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.entityid=${this.stateObj.entity_id}
|
.entityid=${this.stateObj.entity_id}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { isAudioIntendedOnLoad } from '../../../src/components-lib/live/audio-intent';
|
||||||
|
|
||||||
|
describe('isAudioIntendedOnLoad', () => {
|
||||||
|
it('should return false when no conditions are set', () => {
|
||||||
|
expect(isAudioIntendedOnLoad([])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for the default microphone and call conditions', () => {
|
||||||
|
expect(isAudioIntendedOnLoad(['microphone', 'call'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when selected is a condition', () => {
|
||||||
|
expect(isAudioIntendedOnLoad(['selected'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when visible is a condition', () => {
|
||||||
|
expect(isAudioIntendedOnLoad(['visible'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when a positive condition is combined with others', () => {
|
||||||
|
expect(isAudioIntendedOnLoad(['call', 'visible'])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
HA_CAMERA_STREAM_MUTE_CHANGE_EVENT,
|
||||||
|
HAStreamMuteController,
|
||||||
|
} from '../../../src/components-lib/live/ha-stream-mute-controller';
|
||||||
|
import { createLitElement } from '../../test-utils';
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
interface ControllerState {
|
||||||
|
cameraEntityID: string | null;
|
||||||
|
preferAudioStream: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createController = (state: ControllerState) => {
|
||||||
|
const host = createLitElement();
|
||||||
|
const controller = new HAStreamMuteController(host, {
|
||||||
|
getCameraEntityID: () => state.cameraEntityID,
|
||||||
|
getPreferAudioStream: () => state.preferAudioStream,
|
||||||
|
});
|
||||||
|
return { host, controller };
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatchMuteChange = (host: HTMLElement, muted: boolean): void => {
|
||||||
|
host.dispatchEvent(
|
||||||
|
new CustomEvent(HA_CAMERA_STREAM_MUTE_CHANGE_EVENT, { detail: { muted } }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('HAStreamMuteController', () => {
|
||||||
|
it('should add itself as a controller to the host', () => {
|
||||||
|
const host = createLitElement();
|
||||||
|
const controller = new HAStreamMuteController(host, {
|
||||||
|
getCameraEntityID: () => 'camera.test',
|
||||||
|
getPreferAudioStream: () => false,
|
||||||
|
});
|
||||||
|
expect(host.addController).toHaveBeenCalledWith(controller);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default both the intended and output mute to muted', () => {
|
||||||
|
const { controller } = createController({
|
||||||
|
cameraEntityID: 'camera.test',
|
||||||
|
preferAudioStream: false,
|
||||||
|
});
|
||||||
|
expect(controller.getIntendedMute()).toBe(true);
|
||||||
|
expect(controller.getOutputMute()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seeding on a camera change', () => {
|
||||||
|
it('should seed the intended mute as muted when audio is not intended', () => {
|
||||||
|
const { controller } = createController({
|
||||||
|
cameraEntityID: 'camera.test',
|
||||||
|
preferAudioStream: false,
|
||||||
|
});
|
||||||
|
controller.hostUpdate();
|
||||||
|
expect(controller.getIntendedMute()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should seed the intended mute as unmuted when audio is intended', () => {
|
||||||
|
const { controller } = createController({
|
||||||
|
cameraEntityID: 'camera.test',
|
||||||
|
preferAudioStream: true,
|
||||||
|
});
|
||||||
|
controller.hostUpdate();
|
||||||
|
expect(controller.getIntendedMute()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('on a visible-leaf mute change', () => {
|
||||||
|
it('should mirror the output mute', () => {
|
||||||
|
const { host, controller } = createController({
|
||||||
|
cameraEntityID: 'camera.test',
|
||||||
|
preferAudioStream: false,
|
||||||
|
});
|
||||||
|
controller.hostConnected();
|
||||||
|
controller.hostUpdate();
|
||||||
|
|
||||||
|
dispatchMuteChange(host, false);
|
||||||
|
expect(controller.getOutputMute()).toBe(false);
|
||||||
|
|
||||||
|
dispatchMuteChange(host, true);
|
||||||
|
expect(controller.getOutputMute()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flip the intended mute to unmuted on the first unmute', () => {
|
||||||
|
const { host, controller } = createController({
|
||||||
|
cameraEntityID: 'camera.test',
|
||||||
|
preferAudioStream: false,
|
||||||
|
});
|
||||||
|
controller.hostConnected();
|
||||||
|
controller.hostUpdate();
|
||||||
|
expect(controller.getIntendedMute()).toBe(true);
|
||||||
|
|
||||||
|
dispatchMuteChange(host, false);
|
||||||
|
expect(controller.getIntendedMute()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not flip the intended mute back when muted again', () => {
|
||||||
|
const { host, controller } = createController({
|
||||||
|
cameraEntityID: 'camera.test',
|
||||||
|
preferAudioStream: false,
|
||||||
|
});
|
||||||
|
controller.hostConnected();
|
||||||
|
controller.hostUpdate();
|
||||||
|
|
||||||
|
dispatchMuteChange(host, false);
|
||||||
|
dispatchMuteChange(host, true);
|
||||||
|
|
||||||
|
expect(controller.getIntendedMute()).toBe(false);
|
||||||
|
expect(controller.getOutputMute()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should request a host update only when the state changes', () => {
|
||||||
|
const { host, controller } = createController({
|
||||||
|
cameraEntityID: 'camera.test',
|
||||||
|
preferAudioStream: false,
|
||||||
|
});
|
||||||
|
controller.hostConnected();
|
||||||
|
controller.hostUpdate();
|
||||||
|
vi.mocked(host.requestUpdate).mockClear();
|
||||||
|
|
||||||
|
// Already muted: no change.
|
||||||
|
dispatchMuteChange(host, true);
|
||||||
|
expect(host.requestUpdate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Unmute: change.
|
||||||
|
dispatchMuteChange(host, false);
|
||||||
|
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore malformed mute-change events', () => {
|
||||||
|
const { host, controller } = createController({
|
||||||
|
cameraEntityID: 'camera.test',
|
||||||
|
preferAudioStream: false,
|
||||||
|
});
|
||||||
|
controller.hostConnected();
|
||||||
|
controller.hostUpdate();
|
||||||
|
vi.mocked(host.requestUpdate).mockClear();
|
||||||
|
|
||||||
|
const eventName = HA_CAMERA_STREAM_MUTE_CHANGE_EVENT;
|
||||||
|
host.dispatchEvent(new Event(eventName)); // not a CustomEvent
|
||||||
|
host.dispatchEvent(new CustomEvent(eventName)); // detail is null
|
||||||
|
host.dispatchEvent(new CustomEvent(eventName, { detail: 'x' })); // detail not an object
|
||||||
|
host.dispatchEvent(new CustomEvent(eventName, { detail: {} })); // no `muted`
|
||||||
|
host.dispatchEvent(new CustomEvent(eventName, { detail: { muted: 1 } })); // `muted` not boolean
|
||||||
|
|
||||||
|
expect(host.requestUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(controller.getIntendedMute()).toBe(true);
|
||||||
|
expect(controller.getOutputMute()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetting on a camera change', () => {
|
||||||
|
it('should reseed the intended mute and clear the output mute on a new camera', () => {
|
||||||
|
const state: ControllerState = {
|
||||||
|
cameraEntityID: 'camera.one',
|
||||||
|
preferAudioStream: false,
|
||||||
|
};
|
||||||
|
const { host, controller } = createController(state);
|
||||||
|
controller.hostConnected();
|
||||||
|
controller.hostUpdate();
|
||||||
|
|
||||||
|
dispatchMuteChange(host, false);
|
||||||
|
expect(controller.getIntendedMute()).toBe(false);
|
||||||
|
expect(controller.getOutputMute()).toBe(false);
|
||||||
|
|
||||||
|
state.cameraEntityID = 'camera.two';
|
||||||
|
controller.hostUpdate();
|
||||||
|
|
||||||
|
expect(controller.getIntendedMute()).toBe(true);
|
||||||
|
expect(controller.getOutputMute()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not reset when only the audio intent changes', () => {
|
||||||
|
const state: ControllerState = {
|
||||||
|
cameraEntityID: 'camera.one',
|
||||||
|
preferAudioStream: false,
|
||||||
|
};
|
||||||
|
const { host, controller } = createController(state);
|
||||||
|
controller.hostConnected();
|
||||||
|
controller.hostUpdate();
|
||||||
|
|
||||||
|
dispatchMuteChange(host, false);
|
||||||
|
expect(controller.getIntendedMute()).toBe(false);
|
||||||
|
|
||||||
|
state.preferAudioStream = true;
|
||||||
|
controller.hostUpdate();
|
||||||
|
|
||||||
|
// Same camera: the user's unmute is preserved, not reseeded.
|
||||||
|
expect(controller.getIntendedMute()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lifecycle', () => {
|
||||||
|
it('should stop responding after disconnect', () => {
|
||||||
|
const { host, controller } = createController({
|
||||||
|
cameraEntityID: 'camera.test',
|
||||||
|
preferAudioStream: false,
|
||||||
|
});
|
||||||
|
controller.hostConnected();
|
||||||
|
controller.hostUpdate();
|
||||||
|
|
||||||
|
controller.hostDisconnected();
|
||||||
|
dispatchMuteChange(host, false);
|
||||||
|
|
||||||
|
expect(controller.getIntendedMute()).toBe(true);
|
||||||
|
expect(controller.getOutputMute()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user