From d3260d516c19bfd530dc8d795acb9a72e74c4f0a Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 28 Jan 2024 20:30:09 -0800 Subject: [PATCH] Allow the inbound audio to unmute when the microphone is unmuted. --- README.md | 55 +- src/card-controller/microphone-manager.ts | 60 +- src/card.ts | 2 +- src/components/live/live.ts | 31 +- src/components/viewer.ts | 8 +- src/components/views.ts | 5 +- src/config-mgmt.ts | 31 + src/config/types.ts | 67 +- src/const.ts | 5 +- src/editor.ts | 57 +- src/localize/languages/en.json | 5 +- src/localize/languages/it.json | 5 +- src/localize/languages/pt-BR.json | 5 +- src/localize/languages/pt-PT.json | 5 +- .../plugins/auto-lazy-load/auto-lazy-load.ts | 8 +- .../auto-media-actions/auto-media-actions.ts | 119 +- .../microphone-manager.test.ts | 31 + tests/config-mgmt.test.ts | 1182 +++++++++++------ tests/config/types.test.ts | 30 +- .../auto-lazy-load/auto-lazy-load.test.ts | 9 +- .../auto-media-actions.test.ts | 167 ++- vite.config.ts | 8 +- 22 files changed, 1313 insertions(+), 582 deletions(-) diff --git a/README.md b/README.md index 3cec8618..6c1ba897 100644 --- a/README.md +++ b/README.md @@ -517,12 +517,12 @@ See the [fully expanded live configuration example](#config-expanded-live) for h | Option | Default | Overridable | Description | | - | - | - | - | | `preload` | `false` | :heavy_multiplication_x: | Whether or not to preload the live view. Preloading causes the live view to render in the background regardless of what view is actually shown, so it's instantly available when requested. This consumes additional network/CPU resources continually. | -| `auto_play` | `all` | :heavy_multiplication_x: | Whether to automatically play live camera feeds. `never` will never automatically play, `selected` will automatically play when a camera is selected in the carousel, `visible` will automatically play when the browser/tab becomes visible or `all` on any opportunity to automatically play (i.e. either case). Some live providers (e.g. `webrtc-card`, `jsmpeg`) do not support the prevention of automatic play on initial load, but should still respect the value of this flag on play-after-pause.| -| `auto_pause` | `never` | :heavy_multiplication_x: | Whether to automatically pause live camera feeds. `never` will never automatically pause, `unselected` will automatically pause when a camera is unselected in the carousel, `hidden` will automatically pause when the browser/tab becomes hidden or `all` on any opportunity to automatically pause (i.e. either case). **Caution**: Some live providers (e.g. `jsmpeg`) may not offer human-accessible means to resume play if it is paused, unless the `auto_play` option (above) is used.| -| `auto_mute` | `all` | :heavy_multiplication_x: | Whether to automatically mute live camera feeds. `never` will never automatically mute, `unselected` will automatically mute when a camera is unselected in the carousel, `hidden` will automatically mute when the browser/tab becomes hidden or `all` on any opportunity to automatically mute (i.e. either case). Note that if `auto_play` is enabled, the stream may mute itself automatically in order to honor the `auto_play` setting, as some browsers will not auto play media that is unmuted -- that is to say, where necessary, the `auto_play` parameter will take priority over the `auto_mute` parameter.| -| `auto_unmute` | `never` | :heavy_multiplication_x: | Whether to automatically unmute live camera feeds. `never` will never automatically unmute, `selected` will automatically unmute when a camera is unselected in the carousel, `visible` will automatically unmute when the browser/tab becomes visible or `all` on any opportunity to automatically unmute (i.e. either case).| +| `auto_play` | `[selected, visible]` | :heavy_multiplication_x: | An array of conditions in which live camera feeds are automatically played.`selected` will automatically play when a camera is selected in the carousel and `visible` will automatically play when the browser/tab becomes visible. Use an empty list (`[]`) to never automatically play. Some live providers (e.g. `webrtc-card`, `jsmpeg`) do not support the prevention of automatic play on initial load, but should still respect the value of this flag on play-after-pause.| +| `auto_pause` | `[]` | :heavy_multiplication_x: | An array of conditions in which live camera feeds are automatically paused. `unselected` will automatically pause when a camera is unselected in the carousel and `hidden` will automatically pause when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically pause. **Caution**: Some live providers (e.g. `jsmpeg`) may not offer human-accessible means to resume play if it is paused, unless the `auto_play` option (above) is used.| +| `auto_mute` | `[unselected, hidden, microphone]` | :heavy_multiplication_x: | An array of conditions in which live camera feeds are muted. `unselected` will automatically mute when a camera is unselected in the carousel, `hidden` will automatically mute when the browser/tab becomes hidden or `microphone` will automatically mute after the microphone is muted as long as the camera stays selected (see the `live.microphone.mute_after_microphone_mute_seconds` to control how long after). Use an empty list (`[]`) to never automatically mute. Note that if `auto_play` is enabled, the stream may mute itself automatically in order to honor the `auto_play` setting, as some browsers will not auto play media that is unmuted -- that is to say, where necessary, the `auto_play` parameter will take priority over the `auto_mute` parameter.| +| `auto_unmute` | `[microphone]` | :heavy_multiplication_x: | An array of conditions in which live camera feeds are unmuted. `selected` will automatically unmute when a camera is unselected in the carousel, `visible` will automatically unmute when the browser/tab becomes visible or `microphone` will automatically unmute after the microphone is unmuted. Use an empty list (`[]`) to never automatically unmute.| | `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load cameras in the camera carousel. Setting this will `false` will cause all cameras to load simultaneously when the `live` carousel is opened (or cause all cameras to load continually if both `lazy_load` and `preload` are `true`). This will result in a smoother carousel experience at a cost of (potentially) a substantial amount of continually streamed data. | -| `lazy_unload` | `never` | :heavy_multiplication_x: | When to lazily **un**load lazyily-loaded cameras. `never` will never lazily-unload, `unselected` will lazy-unload a camera when it is unselected in the carousel, `hidden` will lazy-unload all cameras when the browser/tab becomes hidden or `all` on any opportunity to lazily unload (i.e. either case). This will cause a reloading delay on revisiting that camera in the carousel but will save the streaming network resources that are otherwise consumed. This option has no effect if `lazy_load` is false. Some live providers (e.g. `webrtc-card`) implement their own lazy unloading independently which may occur regardless of the value of this setting.| +| `lazy_unload` | `[]` | :heavy_multiplication_x: | An array of conditions in which live camera feeds are unloaded. `unselected` will lazy-unload a camera when it is unselected in the carousel and `hidden` will lazy-unload all cameras when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically unload. This will cause a reloading delay on revisiting that camera in the carousel but will save the streaming network resources that are otherwise consumed. This option has no effect if `lazy_load` is false. Some live providers (e.g. `webrtc-card`) implement their own lazy unloading independently which may occur regardless of the value of this setting.| | `draggable` | `true` | :heavy_multiplication_x: | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. | | `zoomable` | `true` | :white_check_mark: | Whether or not the live carousel can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. | | `transition_effect` | `slide` | :heavy_multiplication_x: | Effect to apply as a transition between live cameras. Accepted values: `slide` or `none`. | @@ -633,7 +633,8 @@ live: | Option | Default | Overridable | Description | | - | - | - | - | | `always_connected` | `false` | :white_check_mark: | Whether or not to keep the microphone stream continually connected while the card is running, or only when microphone is used (default). In the latter case there'll be a connection reset when the microphone is first used -- using this option can avoid that reset.| -| `disconnect_seconds` | `60` | :white_check_mark: | The number of seconds after microphone usage to disconnect the microphone from the stream. `0` implies never. Not relevant if `always_connected` is `true`.| +| `disconnect_seconds` | `90` | :white_check_mark: | The number of seconds after microphone usage to disconnect the microphone from the stream. `0` implies never. Not relevant if `always_connected` is `true`.| +| `mute_after_microphone_mute_seconds` | `60` | :white_check_mark: | The number of seconds after the microphone mutes to automatically mute the inbound audio when `live.auto_mute` includes `microphone`.| See [Using 2-way audio](#using-2-way-audio) for more information about the very particular requirements that must be followed for 2-way audio to work. @@ -704,10 +705,10 @@ See the [fully expanded Media viewer configuration example](#config-expanded-med | Option | Default | Overridable | Description | | - | - | - | - | -| `auto_play` | `all` | :heavy_multiplication_x: | Whether to automatically play events. `never` will never automatically play, `selected` will automatically play when an event is selected in the carousel, `visible` will automatically play when the browser/tab becomes visible or `all` on any opportunity to automatically play (i.e. either case).| -| `auto_pause` | `all` | :heavy_multiplication_x: | Whether to automatically pause events. `never` will never automatically pause, `unselected` will automatically pause when an event is unselected in the carousel, `hidden` will automatically pause when the browser/tab becomes hidden or `all` on any opportunity to automatically pause (i.e. either case).| -| `auto_mute` | `all` | :heavy_multiplication_x: | Whether to automatically mute events. `never` will never automatically mute, `unselected` will automatically mute when an event is unselected in the carousel, `hidden` will automatically mute when the browser/tab becomes hidden or `all` on any opportunity to automatically mute (i.e. either case).| -| `auto_unmute` | `never` | :heavy_multiplication_x: | Whether to automatically unmute events. `never` will never automatically unmute, `selected` will automatically unmute when an event is selected in the carousel, `visible` will automatically unmute when the browser/tab becomes visible or `all` on any opportunity to automatically unmute (i.e. either case). Note that some browsers will not allow automated unmute until the user has interacted with the page in some way -- if the user has not then the browser may pause the media instead.| +| `auto_play` | `[selected, visible]` | :heavy_multiplication_x: | An array of conditions in which media items are automatically played.`selected` will automatically play when a media item is selected in the carousel and `visible` will automatically play when the browser/tab becomes visible. Use an empty list (`[]`) to never automatically play.| +| `auto_pause` | `[unselected, hidden]` | :heavy_multiplication_x: | An array of conditions in which media items are automatically paused. `unselected` will automatically pause when a media item is unselected in the carousel and `hidden` will automatically pause when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically pause.| +| `auto_mute` | `[unselected, hidden]` | :heavy_multiplication_x: | An array of conditions in which media items are muted. `unselected` will automatically mute when a media item is unselected in the carousel and `hidden` will automatically mute when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically mute.| +| `auto_unmute` | `[]` | :heavy_multiplication_x: | An array of conditions in which media items are unmuted. `selected` will automatically unmute when a media item is unselected in the carousel and `visible` will automatically unmute when the browser/tab becomes visible. Use an empty list (`[]`) to never automatically unmute. Note that some browsers will not allow automated unmute until the user has interacted with the page in some way -- if the user has not then the browser may pause the media instead.| | `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load media in the Media viewer carousel. Setting this will false will fetch all media immediately which may make the carousel experience smoother at a cost of (potentially) a substantial number of simultaneous media fetches on load. | | `draggable` | `true` | :heavy_multiplication_x: | Whether or not the Media viewer carousel can be dragged left or right, via touch/swipe and mouse dragging. | | `zoomable` | `true` | :heavy_multiplication_x: | Whether or not the Media Viewer can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. | @@ -2057,13 +2058,18 @@ Reference: [Live Options](#live-options). ```yaml live: - auto_play: all - auto_pause: never - auto_mute: all - auto_unmute: never + auto_play: + - selected + - visible + auto_pause: [] + auto_mute: + - unselected + - hidden + auto_unmute: + - microphone preload: false lazy_load: true - lazy_unload: never + lazy_unload: [] draggable: true zoomable: true transition_effect: slide @@ -2097,7 +2103,8 @@ live: y: 50 microphone: always_connected: false - disconnect_seconds: 60 + disconnect_seconds: 90 + mute_after_microphone_mute_seconds: 60 ptz: mode: on position: bottom-right @@ -2150,10 +2157,16 @@ Reference: [Media Viewer Options](#event-viewer-options). ```yaml media_viewer: - auto_play: all - auto_pause: all - auto_mute: all - auto_unmute: never + auto_play: + - selected + - visible + auto_pause: + - unselected + - hidden + auto_mute: + - unselected + - hidden + auto_unmute: [] lazy_load: true draggable: true zoomable: true @@ -4346,7 +4359,7 @@ Your browser will not allow a page/script (like this card) to pass credentials t ### Chrome autoplays when a tab becomes visible again -Even if `live.auto_play` or `media_viewer.auto_play` is set to `never`, Chrome itself will still auto play a video that was previously playing prior to the tab being hidden, once that tab is visible again. This behavior cannot be influenced by the card. Other browsers (e.g. Firefox, Safari) do not exhibit this behavior. +Even if `live.auto_play` or `media_viewer.auto_play` is set to `[]`, Chrome itself will still auto play a video that was previously playing prior to the tab being hidden, once that tab is visible again. This behavior cannot be influenced by the card. Other browsers (e.g. Firefox, Safari) do not exhibit this behavior. ### The `live` view just shows a blank white image during loading diff --git a/src/card-controller/microphone-manager.ts b/src/card-controller/microphone-manager.ts index 303a79c6..8aed6315 100644 --- a/src/card-controller/microphone-manager.ts +++ b/src/card-controller/microphone-manager.ts @@ -2,10 +2,23 @@ import { errorToConsole } from '../utils/basic'; import { Timer } from '../utils/timer'; import { CardMicrophoneAPI } from './types'; -export class MicrophoneManager { +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 { 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 @@ -44,21 +57,20 @@ export class MicrophoneManager { return this._stream ?? undefined; } - protected _setMute(): void { - this._stream?.getTracks().forEach((track) => { - track.enabled = !this._mute; - }); - this._startTimer(); - - this._api.getCardElementManager().update(); - } - public mute(): void { + const wasMuted = this.isMuted(); + this._mute = true; this._setMute(); + + if (!wasMuted) { + this._callListeners('muted'); + } } public async unmute(): Promise { + const wasUnmuted = !this.isMuted(); + const unmute = (): void => { this._mute = false; this._setMute(); @@ -76,6 +88,10 @@ export class MicrophoneManager { } else if (this.isConnected()) { unmute(); } + + if (!wasUnmuted) { + this._callListeners('unmuted'); + } } public isConnected(): boolean { @@ -92,9 +108,29 @@ export class MicrophoneManager { 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 { + this._stream?.getTracks().forEach((track) => { + track.enabled = !this._mute; + }); + + this._startTimer(); + this._api.getCardElementManager().update(); + } + protected _startTimer(): void { - const microphoneConfig = this._api.getConfigManager().getConfig() - ?.live.microphone; + const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone; if (microphoneConfig?.always_connected) { return; diff --git a/src/card.ts b/src/card.ts index 550067c8..7fb158aa 100644 --- a/src/card.ts +++ b/src/card.ts @@ -306,7 +306,7 @@ class FrigateCard extends LitElement { .getConditionsManager() ?.getEpoch()} .hide=${!!this._controller.getMessageManager().hasMessage()} - .microphoneStream=${this._controller.getMicrophoneManager()?.getStream()} + .microphoneManager=${this._controller.getMicrophoneManager()} .triggeredCameraIDs=${this._config?.view.scan.show_trigger_status ? this._controller.getTriggersManager().getTriggeredCameraIDs() : undefined} diff --git a/src/components/live/live.ts b/src/components/live/live.ts index f69a04aa..4f01c22e 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -18,6 +18,7 @@ import { ConditionsManagerEpoch, getOverriddenConfig, } from '../../card-controller/conditions-manager.js'; +import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js'; import { MediaGridSelected } from '../../components-lib/media-grid-controller.js'; import { CameraConfig, @@ -113,7 +114,7 @@ export class FrigateCardLive extends LitElement { public cardWideConfig?: CardWideConfig; @property({ attribute: false }) - public microphoneStream?: MediaStream; + public microphoneManager?: ReadonlyMicrophoneManager; @property({ attribute: false }) public triggeredCameraIDs?: Set; @@ -222,7 +223,7 @@ export class FrigateCardLive extends LitElement { .liveOverrides=${this.liveOverrides} .cardWideConfig=${this.cardWideConfig} .cameraManager=${this.cameraManager} - .microphoneStream=${this.microphoneStream} + .microphoneManager=${this.microphoneManager} .triggeredCameraIDs=${this.triggeredCameraIDs} @frigate-card:message=${(ev: CustomEvent) => { this._renderKey++; @@ -286,7 +287,7 @@ export class FrigateCardLiveGrid extends LitElement { public cameraManager?: CameraManager; @property({ attribute: false }) - public microphoneStream?: MediaStream; + public microphoneManager?: ReadonlyMicrophoneManager; @property({ attribute: false }) public triggeredCameraIDs?: Set; @@ -306,7 +307,7 @@ export class FrigateCardLiveGrid extends LitElement { .liveOverrides=${this.liveOverrides} .cardWideConfig=${this.cardWideConfig} .cameraManager=${this.cameraManager} - .microphoneStream=${this.microphoneStream} + .microphoneManager=${this.microphoneManager} ?triggered=${triggeredCameraID && !!this.triggeredCameraIDs?.has(triggeredCameraID)} > @@ -394,7 +395,7 @@ export class FrigateCardLiveCarousel extends LitElement { public cameraManager?: CameraManager; @property({ attribute: false }) - public microphoneStream?: MediaStream; + public microphoneManager?: ReadonlyMicrophoneManager; @property({ attribute: false }) public viewFilterCameraID?: string; @@ -431,7 +432,7 @@ export class FrigateCardLiveCarousel extends LitElement { lazyLoadCallback: (index, slide) => this._lazyloadOrUnloadSlide('load', index, slide), }), - lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload, + lazyUnloadConditions: this.overriddenLiveConfig?.lazy_unload, lazyUnloadCallback: (index, slide) => this._lazyloadOrUnloadSlide('unload', index, slide), }), @@ -439,16 +440,22 @@ export class FrigateCardLiveCarousel extends LitElement { AutoMediaActions({ playerSelector: FRIGATE_CARD_LIVE_PROVIDER, ...(this.overriddenLiveConfig?.auto_play && { - autoPlayCondition: this.overriddenLiveConfig.auto_play, + autoPlayConditions: this.overriddenLiveConfig.auto_play, }), ...(this.overriddenLiveConfig?.auto_pause && { - autoPauseCondition: this.overriddenLiveConfig.auto_pause, + autoPauseConditions: this.overriddenLiveConfig.auto_pause, }), ...(this.overriddenLiveConfig?.auto_mute && { - autoMuteCondition: this.overriddenLiveConfig.auto_mute, + autoMuteConditions: this.overriddenLiveConfig.auto_mute, }), ...(this.overriddenLiveConfig?.auto_unmute && { - autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute, + autoUnmuteConditions: this.overriddenLiveConfig.auto_unmute, + }), + ...((this.overriddenLiveConfig?.auto_unmute || + this.overriddenLiveConfig?.auto_mute) && { + microphoneManager: this.microphoneManager, + microphoneMuteSeconds: + this.overriddenLiveConfig.microphone.mute_after_microphone_mute_seconds, }), }), AutoSize(), @@ -571,7 +578,7 @@ export class FrigateCardLiveCarousel extends LitElement { ; @@ -241,7 +242,7 @@ export class FrigateCardViews extends LitElement { )} .cameraManager=${this.cameraManager} .cardWideConfig=${this.cardWideConfig} - .microphoneStream=${this.microphoneStream} + .microphoneManager=${this.microphoneManager} .triggeredCameraIDs=${this.triggeredCameraIDs} class="${classMap(liveClasses)}" > diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index d6f5eb61..9fe304ad 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -485,4 +485,35 @@ const UPGRADES = [ }, ), upgradeMoveToWithOverrides('view.timeout_seconds', CONF_VIEW_INTERACTION_SECONDS), + upgradeWithOverrides('live.lazy_unload', (data) => + data === 'all' ? ['unselected', 'hidden'] : data === 'never' ? null : arrayify(data), + ), + upgradeWithOverrides('live.auto_play', (data) => + data === 'all' ? null : data === 'never' ? [] : arrayify(data), + ), + upgradeWithOverrides('live.auto_pause', (data) => + data === 'all' ? ['unselected', 'hidden'] : data === 'never' ? null : arrayify(data), + ), + upgradeWithOverrides('live.auto_mute', (data) => + data === 'all' ? null : data === 'never' ? [] : arrayify(data), + ), + upgradeWithOverrides('live.auto_unmute', (data) => + data === 'all' + ? ['selected', 'visible', 'microphone'] + : data === 'never' + ? null + : arrayify(data), + ), + upgradeWithOverrides('media_viewer.auto_play', (data) => + data === 'all' ? null : data === 'never' ? [] : arrayify(data), + ), + upgradeWithOverrides('media_viewer.auto_pause', (data) => + data === 'all' ? null : data === 'never' ? [] : arrayify(data), + ), + upgradeWithOverrides('media_viewer.auto_mute', (data) => + data === 'all' ? null : data === 'never' ? [] : arrayify(data), + ), + upgradeWithOverrides('media_viewer.auto_unmute', (data) => + data === 'all' ? ['selected', 'visible'] : data === 'never' ? null : arrayify(data), + ), ]; diff --git a/src/config/types.ts b/src/config/types.ts index 2da6551a..514b90ff 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -46,24 +46,23 @@ const FRIGATE_CARD_VIEWS = [ export type FrigateCardView = (typeof FRIGATE_CARD_VIEWS)[number]; export const FRIGATE_CARD_VIEW_DEFAULT = 'live' as const; -const MEDIA_ACTION_NEGATIVE_CONDITIONS = [ - 'all', - 'unselected', - 'hidden', - 'never', -] as const; +export const MEDIA_ACTION_NEGATIVE_CONDITIONS = ['unselected', 'hidden'] as const; export type LazyUnloadCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number]; -export type AutoMuteCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number]; export type AutoPauseCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number]; -const MEDIA_ACTION_POSITIVE_CONDITIONS = [ - 'all', - 'selected', - 'visible', - 'never', -] as const; -export type AutoUnmuteCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number]; +export const MEDIA_ACTION_POSITIVE_CONDITIONS = ['selected', 'visible'] as const; export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number]; +export const MEDIA_UNMUTE_CONDITIONS = [ + ...MEDIA_ACTION_POSITIVE_CONDITIONS, + 'microphone', +] as const; +export type AutoUnmuteCondition = (typeof MEDIA_UNMUTE_CONDITIONS)[number]; + +export const MEDIA_MUTE_CONDITIONS = [ + ...MEDIA_ACTION_NEGATIVE_CONDITIONS, + 'microphone', +] as const; +export type AutoMuteCondition = (typeof MEDIA_MUTE_CONDITIONS)[number]; const PTZ_BASE_ACTIONS = ['left', 'right', 'up', 'down', 'zoom_in', 'zoom_out'] as const; @@ -738,6 +737,7 @@ export type LiveProvider = (typeof LIVE_PROVIDERS)[number]; const microphoneConfigDefault = { always_connected: false, disconnect_seconds: 60, + mute_after_microphone_mute_seconds: 60, }; const microphoneConfigSchema = z @@ -747,6 +747,10 @@ const microphoneConfigSchema = z .number() .min(0) .default(microphoneConfigDefault.disconnect_seconds), + mute_after_microphone_mute_seconds: z + .number() + .min(0) + .default(microphoneConfigDefault.mute_after_microphone_mute_seconds), }) .default(microphoneConfigDefault); export type MicrophoneConfig = z.infer; @@ -860,13 +864,13 @@ const liveThumbnailControlsDefaults = { }; const liveConfigDefault = { - auto_play: 'all' as const, - auto_pause: 'never' as const, - auto_mute: 'all' as const, - auto_unmute: 'never' as const, + auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS], + auto_pause: [], + auto_mute: [...MEDIA_MUTE_CONDITIONS], + auto_unmute: ['microphone' as const], preload: false, lazy_load: true, - lazy_unload: 'never' as const, + lazy_unload: [], draggable: true, zoomable: true, transition_effect: 'slide' as const, @@ -929,23 +933,27 @@ const liveOverridableConfigSchema = z const liveConfigSchema = liveOverridableConfigSchema .extend({ - // Non-overrideable parameters. auto_play: z .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) + .array() .default(liveConfigDefault.auto_play), auto_pause: z .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .array() .default(liveConfigDefault.auto_pause), auto_mute: z - .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .enum(MEDIA_MUTE_CONDITIONS) + .array() .default(liveConfigDefault.auto_mute), auto_unmute: z - .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) + .enum(MEDIA_UNMUTE_CONDITIONS) + .array() .default(liveConfigDefault.auto_unmute), preload: z.boolean().default(liveConfigDefault.preload), lazy_load: z.boolean().default(liveConfigDefault.lazy_load), lazy_unload: z .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .array() .default(liveConfigDefault.lazy_unload), draggable: z.boolean().default(liveConfigDefault.draggable), transition_effect: transitionEffectConfigSchema.default( @@ -1296,10 +1304,10 @@ export type MenuConfig = z.infer; // ************************************************************************* const viewerConfigDefault = { - auto_play: 'all' as const, - auto_pause: 'all' as const, - auto_mute: 'all' as const, - auto_unmute: 'never' as const, + auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS], + auto_pause: [...MEDIA_ACTION_NEGATIVE_CONDITIONS], + auto_mute: [...MEDIA_ACTION_NEGATIVE_CONDITIONS], + auto_unmute: [], lazy_load: true, draggable: true, zoomable: true, @@ -1330,15 +1338,22 @@ const viewerConfigSchema = z .object({ auto_play: z .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) + .array() .default(viewerConfigDefault.auto_play), auto_pause: z .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .array() .default(viewerConfigDefault.auto_pause), + + // Don't use MEDIA_UNMUTE_CONDITIONS and MEDIA_MUTE_CONDITIONS here, since + // it includes 'microphone' which doesn't make sense for viewer media. auto_mute: z .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .array() .default(viewerConfigDefault.auto_mute), auto_unmute: z .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) + .array() .default(viewerConfigDefault.auto_unmute), lazy_load: z.boolean().default(viewerConfigDefault.lazy_load), draggable: z.boolean().default(viewerConfigDefault.draggable), diff --git a/src/const.ts b/src/const.ts index d0fddf23..92dc8064 100644 --- a/src/const.ts +++ b/src/const.ts @@ -75,7 +75,8 @@ export const CONF_VIEW_INTERACTION_SECONDS = `${CONF_VIEW}.interaction_seconds` export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const; export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const; export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const; -export const CONF_VIEW_RESET_AFTER_INTERACTION = `${CONF_VIEW}.reset_after_interaction` as const; +export const CONF_VIEW_RESET_AFTER_INTERACTION = + `${CONF_VIEW}.reset_after_interaction` as const; export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const; export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const; export const CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS = @@ -236,6 +237,8 @@ export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD = `${CONF_LIVE}.show_image_during_load` as const; export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS = `${CONF_LIVE}.microphone.disconnect_seconds` as const; +export const CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS = + `${CONF_LIVE}.microphone.mute_after_microphone_mute_seconds` as const; export const CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED = `${CONF_LIVE}.microphone.always_connected` as const; export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const; diff --git a/src/editor.ts b/src/editor.ts index 442afa1c..997820e5 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,3 +1,5 @@ +// TODO: menu in hover mode, hold down microphone, should be momentary, appears to stick on? + import { fireEvent, HomeAssistant, @@ -113,6 +115,7 @@ import { CONF_LIVE_LAZY_UNLOAD, CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED, CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS, + CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS, CONF_LIVE_PRELOAD, CONF_LIVE_SHOW_IMAGE_DURING_LOAD, CONF_LIVE_TRANSITION_EFFECT, @@ -519,18 +522,15 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor protected _mediaActionNegativeConditions: EditorSelectOption[] = [ { value: '', label: '' }, - { value: 'all', label: localize('config.common.media_action_conditions.all') }, { value: 'unselected', label: localize('config.common.media_action_conditions.unselected'), }, { value: 'hidden', label: localize('config.common.media_action_conditions.hidden') }, - { value: 'never', label: localize('config.common.media_action_conditions.never') }, ]; protected _mediaActionPositiveConditions: EditorSelectOption[] = [ { value: '', label: '' }, - { value: 'all', label: localize('config.common.media_action_conditions.all') }, { value: 'selected', label: localize('config.common.media_action_conditions.selected'), @@ -539,7 +539,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor value: 'visible', label: localize('config.common.media_action_conditions.visible'), }, - { value: 'never', label: localize('config.common.media_action_conditions.never') }, + ]; + + protected _mediaLiveUnmuteConditions: EditorSelectOption[] = [ + ...this._mediaActionPositiveConditions, + { + value: 'microphone', + label: localize('config.common.media_action_conditions.microphone_unmute'), + }, + ]; + + protected _mediaLiveMuteConditions: EditorSelectOption[] = [ + ...this._mediaActionNegativeConditions, + { + value: 'microphone', + label: localize('config.common.media_action_conditions.microphone_mute'), + }, ]; protected _layoutFits: EditorSelectOption[] = [ @@ -2011,22 +2026,37 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSelector( CONF_LIVE_LAZY_UNLOAD, this._mediaActionNegativeConditions, + { + multiple: true, + }, )} ${this._renderOptionSelector( CONF_LIVE_AUTO_PLAY, this._mediaActionPositiveConditions, + { + multiple: true, + }, )} ${this._renderOptionSelector( CONF_LIVE_AUTO_PAUSE, this._mediaActionNegativeConditions, + { + multiple: true, + }, )} ${this._renderOptionSelector( CONF_LIVE_AUTO_MUTE, - this._mediaActionNegativeConditions, + this._mediaLiveMuteConditions, + { + multiple: true, + }, )} ${this._renderOptionSelector( CONF_LIVE_AUTO_UNMUTE, - this._mediaActionPositiveConditions, + this._mediaLiveUnmuteConditions, + { + multiple: true, + }, )} ${this._renderOptionSelector( CONF_LIVE_TRANSITION_EFFECT, @@ -2153,6 +2183,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED, this._defaults.live.microphone.always_connected, )} + ${this._renderNumberInput( + CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS, + )} `, )} @@ -2182,18 +2215,30 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSelector( CONF_MEDIA_VIEWER_AUTO_PLAY, this._mediaActionPositiveConditions, + { + multiple: true, + }, )} ${this._renderOptionSelector( CONF_MEDIA_VIEWER_AUTO_PAUSE, this._mediaActionNegativeConditions, + { + multiple: true, + }, )} ${this._renderOptionSelector( CONF_MEDIA_VIEWER_AUTO_MUTE, this._mediaActionNegativeConditions, + { + multiple: true, + }, )} ${this._renderOptionSelector( CONF_MEDIA_VIEWER_AUTO_UNMUTE, this._mediaActionPositiveConditions, + { + multiple: true, + }, )} ${this._renderSwitch( CONF_MEDIA_VIEWER_DRAGGABLE, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 94d05c5c..bb246156 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -182,6 +182,8 @@ "media_action_conditions": { "all": "All opportunities", "hidden": "On browser/tab hiding", + "microphone_mute": "On microphone mute", + "microphone_unmute": "On microphone unmute", "never": "Never", "selected": "On selection", "unselected": "On unselection", @@ -267,7 +269,8 @@ "always_connected": "Always keep the microphone connected", "disconnect_seconds": "Seconds after which to disconnect microphone (0=never)", "editor_label": "Microphone", - "enabled": "Microphone enabled" + "enabled": "Microphone enabled", + "mute_after_microphone_mute_seconds": "Seconds after microphone mute to mute inbound audio" }, "preload": "Preload live view in the background", "show_image_during_load": "Show still image while the live stream is loading", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 7cf37f87..570d1dc4 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -182,6 +182,8 @@ "media_action_conditions": { "all": "Tutte le opportunità", "hidden": "Sul browser/nascondere le schede", + "microphone_mute": "", + "microphone_unmute": "", "never": "Mai", "selected": "Sulla selezione", "unselected": "Sulla non selezione", @@ -267,7 +269,8 @@ "always_connected": "", "disconnect_seconds": "", "editor_label": "", - "enabled": "" + "enabled": "", + "mute_after_microphone_mute_seconds": "" }, "preload": "Precarica Live View in background", "show_image_during_load": "Mostra un'immagine fissa durante il caricamento del live streaming", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 73ed9a70..2909b41b 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -182,6 +182,8 @@ "media_action_conditions": { "all": "Todas as oportunidades", "hidden": "Ao ocultar o navegador/aba", + "microphone_mute": "", + "microphone_unmute": "", "never": "Nunca", "selected": "Ao selecionar", "unselected": "Ao desselecionar", @@ -267,7 +269,8 @@ "always_connected": "", "disconnect_seconds": "", "editor_label": "", - "enabled": "" + "enabled": "", + "mute_after_microphone_mute_seconds": "" }, "preload": "Pré-carregar a visualização ao vivo em segundo plano", "show_image_during_load": "Mostrar imagem estática enquanto a transmissão ao vivo está carregando", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 25140dd8..e224ccf0 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -182,6 +182,8 @@ "media_action_conditions": { "all": "Todas as oportunidades", "hidden": "Ao ocultar o navegador/aba", + "microphone_mute": "", + "microphone_unmute": "", "never": "Nunca", "selected": "Ao selecionar", "unselected": "Ao desselecionar", @@ -260,7 +262,8 @@ "always_connected": "", "disconnect_seconds": "", "editor_label": "", - "enabled": "" + "enabled": "", + "mute_after_microphone_mute_seconds": "" }, "preload": "Pré-carregar a visualização ao vivo em segundo plano", "show_image_during_load": "Mostar imagem durante o carregamento", diff --git a/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts b/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts index efd4f2f4..c35b825d 100644 --- a/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts +++ b/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts @@ -14,7 +14,7 @@ type OptionsType = CreateOptionsType<{ // Number of slides to lazyload left/right of selected (0 == only selected // slide). lazyLoadCount: number; - lazyUnloadCondition?: LazyUnloadCondition; + lazyUnloadConditions?: readonly LazyUnloadCondition[]; lazyLoadCallback?: (index: number, slide: HTMLElement) => void; lazyUnloadCallback?: (index: number, slide: HTMLElement) => void; @@ -55,8 +55,7 @@ export function AutoLazyLoad( } if ( options.lazyUnloadCallback && - options.lazyUnloadCondition && - ['all', 'unselected'].includes(options.lazyUnloadCondition) + options.lazyUnloadConditions?.includes('unselected') ) { unloadEvents.forEach((evt) => emblaApi.on(evt, lazyUnloadPreviousHandler)); } @@ -76,8 +75,7 @@ export function AutoLazyLoad( function visibilityHandler(): void { if ( document.visibilityState === 'hidden' && - options.lazyUnloadCondition && - ['all', 'hidden'].includes(options.lazyUnloadCondition) + options.lazyUnloadConditions?.includes('hidden') ) { lazyUnloadAllHandler(); } else if (document.visibilityState === 'visible' && options.lazyLoadCallback) { diff --git a/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts b/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts index cb47af69..e31b10db 100644 --- a/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts +++ b/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts @@ -2,6 +2,10 @@ import { EmblaCarouselType } from 'embla-carousel'; import { CreateOptionsType } from 'embla-carousel/components/Options.js'; import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler.js'; import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins.js'; +import { + MicrophoneManagerListenerChange, + ReadonlyMicrophoneManager, +} from '../../../../card-controller/microphone-manager.js'; import { AutoMuteCondition, AutoPauseCondition, @@ -9,6 +13,7 @@ import { AutoUnmuteCondition, } from '../../../../config/types.js'; import { FrigateCardMediaPlayer } from '../../../../types.js'; +import { Timer } from '../../../timer.js'; declare module 'embla-carousel/components/Plugins' { interface EmblaPluginsType { @@ -19,10 +24,13 @@ declare module 'embla-carousel/components/Plugins' { type OptionsType = CreateOptionsType<{ playerSelector?: string; - autoPlayCondition?: AutoPlayCondition; - autoUnmuteCondition?: AutoUnmuteCondition; - autoPauseCondition?: AutoPauseCondition; - autoMuteCondition?: AutoMuteCondition; + autoPlayConditions?: readonly AutoPlayCondition[]; + autoUnmuteConditions?: readonly AutoUnmuteCondition[]; + autoPauseConditions?: readonly AutoPauseCondition[]; + autoMuteConditions?: readonly AutoMuteCondition[]; + + microphoneManager?: ReadonlyMicrophoneManager; + microphoneMuteSeconds?: number; }>; export type AutoMediaActionsOptionsType = Partial; @@ -43,6 +51,7 @@ export function AutoMediaActions( let emblaApi: EmblaCarouselType; let slides: HTMLElement[]; let hadInitialIntersectionCall: boolean | null = false; + const microphoneMuteTimer = new Timer(); const intersectionObserver: IntersectionObserver = new IntersectionObserver( intersectionHandler, @@ -59,35 +68,23 @@ export function AutoMediaActions( slides = emblaApi.slideNodes(); - if ( - options.autoPlayCondition && - ['all', 'selected'].includes(options.autoPlayCondition) - ) { + if (options.autoPlayConditions?.includes('selected')) { // Auto play when the media loads not necessarily when the slide is // selected (to allow for lazyloading). emblaApi.containerNode().addEventListener('frigate-card:media:loaded', play); } - if ( - options.autoUnmuteCondition && - ['all', 'selected'].includes(options.autoUnmuteCondition) - ) { + if (options.autoUnmuteConditions?.includes('selected')) { // Auto unmute when the media loads not necessarily when the slide is // selected (to allow for lazyloading). emblaApi.containerNode().addEventListener('frigate-card:media:loaded', unmute); } - if ( - options.autoPauseCondition && - ['all', 'unselected'].includes(options.autoPauseCondition) - ) { + if (options.autoPauseConditions?.includes('unselected')) { emblaApi.on('select', pausePrevious); } - if ( - options.autoMuteCondition && - ['all', 'unselected'].includes(options.autoMuteCondition) - ) { + if (options.autoMuteConditions?.includes('unselected')) { emblaApi.on('select', mutePrevious); } @@ -96,34 +93,54 @@ export function AutoMediaActions( document.addEventListener('visibilitychange', visibilityHandler); intersectionObserver.observe(emblaApi.containerNode()); + + if ( + options.autoUnmuteConditions?.includes('microphone') || + options.autoMuteConditions?.includes('microphone') + ) { + // For some reason mergeOptions() appears to break mock objects passed in, + // so unittesting doesn't work when using options (vs userOptions where it + // does). + userOptions.microphoneManager?.addListener(microphoneChangeHandler); + + // Stop the microphone mute timer if the media changes. + emblaApi + .containerNode() + .addEventListener('frigate-card:media:loaded', stopMicrophoneTimer); + } + } + + function stopMicrophoneTimer(): void { + microphoneMuteTimer.stop(); + } + + function microphoneChangeHandler(change: MicrophoneManagerListenerChange): void { + if (change === 'unmuted' && options.autoUnmuteConditions?.includes('microphone')) { + unmute(); + } else if ( + change === 'muted' && + options.autoMuteConditions?.includes('microphone') + ) { + microphoneMuteTimer.start(options.microphoneMuteSeconds ?? 60, () => { + mute(); + }); + } } function destroy(): void { - if ( - options.autoPlayCondition && - ['all', 'selected'].includes(options.autoPlayCondition) - ) { + if (options.autoPlayConditions?.includes('selected')) { emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', play); } - if ( - options.autoUnmuteCondition && - ['all', 'selected'].includes(options.autoUnmuteCondition) - ) { + if (options.autoUnmuteConditions?.includes('selected')) { emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', unmute); } - if ( - options.autoPauseCondition && - ['all', 'unselected'].includes(options.autoPauseCondition) - ) { + if (options.autoPauseConditions?.includes('unselected')) { emblaApi.off('select', pausePrevious); } - if ( - options.autoMuteCondition && - ['all', 'unselected'].includes(options.autoMuteCondition) - ) { + if (options.autoMuteConditions?.includes('unselected')) { emblaApi.off('select', mutePrevious); } @@ -132,33 +149,31 @@ export function AutoMediaActions( document.removeEventListener('visibilitychange', visibilityHandler); intersectionObserver.disconnect(); + + if ( + options.autoUnmuteConditions?.includes('microphone') || + options.autoMuteConditions?.includes('microphone') + ) { + userOptions.microphoneManager?.removeListener(microphoneChangeHandler); + emblaApi + .containerNode() + .removeEventListener('frigate-card:media:loaded', stopMicrophoneTimer); + } } function actOnVisibilityChange(visible: boolean): void { if (visible) { - if ( - options.autoPlayCondition && - ['all', 'visible'].includes(options.autoPlayCondition) - ) { + if (options.autoPlayConditions?.includes('visible')) { play(); } - if ( - options.autoUnmuteCondition && - ['all', 'visible'].includes(options.autoUnmuteCondition) - ) { + if (options.autoUnmuteConditions?.includes('visible')) { unmute(); } } else { - if ( - options.autoPauseCondition && - ['all', 'hidden'].includes(options.autoPauseCondition) - ) { + if (options.autoPauseConditions?.includes('hidden')) { pauseAll(); } - if ( - options.autoMuteCondition && - ['all', 'hidden'].includes(options.autoMuteCondition) - ) { + if (options.autoMuteConditions?.includes('hidden')) { muteAll(); } } diff --git a/tests/card-controller/microphone-manager.test.ts b/tests/card-controller/microphone-manager.test.ts index fe8355e8..df9d6408 100644 --- a/tests/card-controller/microphone-manager.test.ts +++ b/tests/card-controller/microphone-manager.test.ts @@ -190,4 +190,35 @@ describe('MicrophoneManager', () => { expect(manager.isConnected()).toBeTruthy(); expect(api.getCardElementManager().update).toBeCalledTimes(1); }); + + it('should respect listeners', async () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + + const listener = vi.fn(); + manager.addListener(listener); + + await manager.connect(); + expect(listener).not.toHaveBeenCalled(); + + await manager.mute(); + expect(listener).not.toHaveBeenCalled(); + + await manager.unmute(); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenLastCalledWith('unmuted'); + + await manager.unmute(); + expect(listener).toHaveBeenCalledTimes(1); + + await manager.mute(); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith('muted'); + + manager.removeListener(listener); + + await manager.unmute(); + expect(listener).toHaveBeenCalledTimes(2); + }); }); diff --git a/tests/config-mgmt.test.ts b/tests/config-mgmt.test.ts index 834928de..bba0efc3 100644 --- a/tests/config-mgmt.test.ts +++ b/tests/config-mgmt.test.ts @@ -617,7 +617,7 @@ describe('should handle version specific upgrades', () => { }); }); - describe('v5.2.0', () => { + describe('v5.2.0 -> v6.0.0', () => { describe('should rename service_data to data', () => { it('positive case', () => { const config = { @@ -738,445 +738,487 @@ describe('should handle version specific upgrades', () => { }); }); }); - }); - describe('should move PTZ elements to live', () => { - it('case with 1 element', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - type: 'custom:frigate-card-ptz', - orientation: 'vertical', - style: { - right: '20px', - top: '20px', - color: 'white', - }, - actions_up: { - tap_action: { - action: 'call-service', - service: 'notify.persistent_notification', - service_data: { - message: 'Hello 1', - }, + describe('should move PTZ elements to live', () => { + it('case with 1 element', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', }, - }, - }, - ], - }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - cameras: [{ camera_entity: 'camera.office' }], - live: { - controls: { - ptz: { actions_up: { tap_action: { action: 'call-service', - data: { + service: 'notify.persistent_notification', + service_data: { message: 'Hello 1', }, - service: 'notify.persistent_notification', - }, - }, - orientation: 'vertical', - style: { - color: 'white', - right: '20px', - top: '20px', - }, - }, - }, - }, - type: 'custom:frigate-card', - }); - }); - - it('case with >1 element', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - type: 'custom:frigate-card-ptz', - orientation: 'vertical', - style: { - right: '20px', - top: '20px', - color: 'white', - }, - actions_up: { - tap_action: { - action: 'call-service', - service: 'notify.persistent_notification', - service_data: { - message: 'Hello 1', }, }, }, - }, - { - type: 'service-button', - title: 'title', - service: 'service', - service_data: { - message: "It's a trick", - }, - }, - ], - }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - service: 'service', - service_data: { - message: "It's a trick", - }, - title: 'title', - type: 'service-button', - }, - ], - live: { - controls: { - ptz: { - actions_up: { - tap_action: { - action: 'call-service', - data: { - message: 'Hello 1', - }, - service: 'notify.persistent_notification', - }, - }, - orientation: 'vertical', - style: { - color: 'white', - right: '20px', - top: '20px', - }, - }, - }, - }, - type: 'custom:frigate-card', - }); - }); - - it('case with custom conditional element with 2 PTZ but nothing else', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - type: 'custom:frigate-card-conditional', - conditions: { - fullscreen: true, - media_loaded: true, - }, - elements: [ - { - type: 'custom:frigate-card-ptz', - orientation: 'vertical', - style: { - right: '20px', - top: '20px', - color: 'white', - }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + live: { + controls: { + ptz: { actions_up: { tap_action: { action: 'call-service', - service: 'notify.persistent_notification', - service_data: { + data: { message: 'Hello 1', }, + service: 'notify.persistent_notification', }, }, - }, - { - type: 'custom:frigate-card-ptz', orientation: 'vertical', style: { + color: 'white', right: '20px', top: '20px', - color: 'white', - }, - actions_up: { - tap_action: { - action: 'call-service', - service: 'notify.persistent_notification', - service_data: { - message: 'Hello 2', - }, - }, }, }, - ], + }, }, - ], - }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - cameras: [{ camera_entity: 'camera.office' }], - live: { - controls: { - ptz: { + type: 'custom:frigate-card', + }); + }); + + it('case with >1 element', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, actions_up: { tap_action: { action: 'call-service', - data: { + service: 'notify.persistent_notification', + service_data: { message: 'Hello 1', }, - service: 'notify.persistent_notification', }, }, - orientation: 'vertical', - style: { - color: 'white', - right: '20px', - top: '20px', - }, }, - }, - }, - type: 'custom:frigate-card', - }); - }); - - it('case with custom conditional element with 1 PTZ and another element', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - type: 'custom:frigate-card-conditional', - conditions: { - fullscreen: true, - media_loaded: true, - }, - elements: [ - { - type: 'service-button', - title: 'title', - service: 'service', - service_data: { - message: "It's a trick", - }, + { + type: 'service-button', + title: 'title', + service: 'service', + service_data: { + message: "It's a trick", }, - { - type: 'custom:frigate-card-ptz', - orientation: 'vertical', - style: { - right: '20px', - top: '20px', - color: 'white', - }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + service: 'service', + service_data: { + message: "It's a trick", + }, + title: 'title', + type: 'service-button', + }, + ], + live: { + controls: { + ptz: { actions_up: { tap_action: { action: 'call-service', - service: 'notify.persistent_notification', - service_data: { + data: { message: 'Hello 1', }, + service: 'notify.persistent_notification', }, }, - }, - ], - }, - ], - }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - cameras: [{ camera_entity: 'camera.office' }], - live: { - controls: { - ptz: { - actions_up: { - tap_action: { - action: 'call-service', - data: { - message: 'Hello 1', - }, - service: 'notify.persistent_notification', - }, - }, - orientation: 'vertical', - style: { - color: 'white', - right: '20px', - top: '20px', - }, - }, - }, - }, - elements: [ - { - type: 'custom:frigate-card-conditional', - conditions: { - fullscreen: true, - media_loaded: true, - }, - elements: [ - { - type: 'service-button', - title: 'title', - service: 'service', - service_data: { - message: "It's a trick", - }, - }, - ], - }, - ], - type: 'custom:frigate-card', - }); - }); - - it('case with stock conditional element with 1 PTZ', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - elements: [ - { - type: 'conditional', - conditions: [{ entity: 'light.office', state: 'on' }], - elements: [ - { - type: 'custom:frigate-card-ptz', orientation: 'vertical', style: { + color: 'white', right: '20px', top: '20px', - color: 'white', }, - actions_up: { - tap_action: { - action: 'call-service', - service: 'notify.persistent_notification', - service_data: { - message: 'Hello 1', + }, + }, + }, + type: 'custom:frigate-card', + }); + }); + + it('case with custom conditional element with 2 PTZ but nothing else', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:frigate-card-conditional', + conditions: { + fullscreen: true, + media_loaded: true, + }, + elements: [ + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, + actions_up: { + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { + message: 'Hello 1', + }, }, }, }, - }, - ], - }, - ], - }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - cameras: [{ camera_entity: 'camera.office' }], - live: { - controls: { - ptz: { - actions_up: { - tap_action: { - action: 'call-service', - data: { - message: 'Hello 1', + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, + actions_up: { + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { + message: 'Hello 2', + }, + }, }, - service: 'notify.persistent_notification', }, - }, - orientation: 'vertical', - style: { - color: 'white', - right: '20px', - top: '20px', + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + live: { + controls: { + ptz: { + actions_up: { + tap_action: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + orientation: 'vertical', + style: { + color: 'white', + right: '20px', + top: '20px', + }, }, }, }, - }, - type: 'custom:frigate-card', + type: 'custom:frigate-card', + }); + }); + + it('case with custom conditional element with 1 PTZ and another element', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:frigate-card-conditional', + conditions: { + fullscreen: true, + media_loaded: true, + }, + elements: [ + { + type: 'service-button', + title: 'title', + service: 'service', + service_data: { + message: "It's a trick", + }, + }, + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, + actions_up: { + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { + message: 'Hello 1', + }, + }, + }, + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + live: { + controls: { + ptz: { + actions_up: { + tap_action: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + orientation: 'vertical', + style: { + color: 'white', + right: '20px', + top: '20px', + }, + }, + }, + }, + elements: [ + { + type: 'custom:frigate-card-conditional', + conditions: { + fullscreen: true, + media_loaded: true, + }, + elements: [ + { + type: 'service-button', + title: 'title', + service: 'service', + service_data: { + message: "It's a trick", + }, + }, + ], + }, + ], + type: 'custom:frigate-card', + }); + }); + + it('case with stock conditional element with 1 PTZ', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'conditional', + conditions: [{ entity: 'light.office', state: 'on' }], + elements: [ + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, + actions_up: { + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { + message: 'Hello 1', + }, + }, + }, + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + live: { + controls: { + ptz: { + actions_up: { + tap_action: { + action: 'call-service', + data: { + message: 'Hello 1', + }, + service: 'notify.persistent_notification', + }, + }, + orientation: 'vertical', + style: { + color: 'white', + right: '20px', + top: '20px', + }, + }, + }, + }, + type: 'custom:frigate-card', + }); + }); + + it('case when live.controls.ptz already exists', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + live: { + controls: { + ptz: { + actions_up: { + tap_action: { + action: 'call-service', + data: { + message: 'Original', + }, + service: 'notify.persistent_notification', + }, + }, + orientation: 'vertical', + style: { + color: 'white', + right: '20px', + top: '20px', + }, + }, + }, + }, + elements: [ + { + type: 'custom:frigate-card-ptz', + orientation: 'vertical', + style: { + right: '20px', + top: '20px', + color: 'white', + }, + actions_up: { + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { + message: 'Replacement that should be ignored', + }, + }, + }, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + live: { + controls: { + ptz: { + actions_up: { + tap_action: { + action: 'call-service', + data: { + message: 'Original', + }, + service: 'notify.persistent_notification', + }, + }, + orientation: 'vertical', + style: { + color: 'white', + right: '20px', + top: '20px', + }, + }, + }, + }, + type: 'custom:frigate-card', + }); }); }); - it('case when live.controls.ptz already exists', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - live: { - controls: { - ptz: { - actions_up: { - tap_action: { - action: 'call-service', - data: { - message: 'Original', - }, - service: 'notify.persistent_notification', - }, - }, - orientation: 'vertical', - style: { - color: 'white', - right: '20px', - top: '20px', + describe('should move and transform untrigger_reset', () => { + it('when true', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + view: { + scan: { + untrigger_reset: true, + }, + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + view: { + scan: { + actions: { + untrigger: 'default', }, }, }, - }, - elements: [ - { - type: 'custom:frigate-card-ptz', - orientation: 'vertical', - style: { - right: '20px', - top: '20px', - color: 'white', - }, - actions_up: { - tap_action: { - action: 'call-service', - service: 'notify.persistent_notification', - service_data: { - message: 'Replacement that should be ignored', - }, - }, + }); + }); + + it('when false', () => { + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + view: { + scan: { + untrigger_reset: false, }, }, - ], - }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - cameras: [{ camera_entity: 'camera.office' }], - live: { - controls: { - ptz: { - actions_up: { - tap_action: { - action: 'call-service', - data: { - message: 'Original', - }, - service: 'notify.persistent_notification', - }, - }, - orientation: 'vertical', - style: { - color: 'white', - right: '20px', - top: '20px', - }, - }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + view: { + scan: {}, }, - }, - type: 'custom:frigate-card', + }); }); }); - }); - describe('should move and transform untrigger_reset', () => { - it('when true', () => { + it('should move view.timeout_seconds', () => { const config = { type: 'custom:frigate-card', cameras: [{ camera_entity: 'camera.office' }], view: { - scan: { - untrigger_reset: true, - }, + timeout_seconds: 200, }, }; expect(upgradeConfig(config)).toBeTruthy(); @@ -1184,51 +1226,369 @@ describe('should handle version specific upgrades', () => { type: 'custom:frigate-card', cameras: [{ camera_entity: 'camera.office' }], view: { - scan: { - actions: { - untrigger: 'default', - }, - }, + interaction_seconds: 200, }, }); }); - it('when false', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - view: { - scan: { - untrigger_reset: false, - }, - }, - }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - view: { - scan: {}, - }, - }); - }); - }); + describe('should handle all and never action conditions', () => { + describe('live', () => { + describe('lazy_unload', () => { + it('all', () => { + const config = { + live: { + lazy_unload: 'all', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: { + lazy_unload: ['unselected', 'hidden'], + }, + }); + }); + it('never', () => { + const config = { + live: { + lazy_unload: 'never', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: {}, + }); + }); + it('other value', () => { + const config = { + live: { + lazy_unload: 'unselected', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: { + lazy_unload: ['unselected'], + }, + }); + }); + }); - it('should move view.timeout_seconds', () => { - const config = { - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - view: { - timeout_seconds: 200, - }, - }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config).toEqual({ - type: 'custom:frigate-card', - cameras: [{ camera_entity: 'camera.office' }], - view: { - interaction_seconds: 200, - }, + describe('auto_play', () => { + it('all', () => { + const config = { + live: { + auto_play: 'all', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: {}, + }); + }); + it('never', () => { + const config = { + live: { + auto_play: 'never', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: { + auto_play: [], + }, + }); + }); + it('other value', () => { + const config = { + live: { + auto_play: 'selected', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: { + auto_play: ['selected'], + }, + }); + }); + }); + describe('auto_pause', () => { + it('all', () => { + const config = { + live: { + auto_pause: 'all', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: { + auto_pause: ['unselected', 'hidden'], + }, + }); + }); + it('never', () => { + const config = { + live: { + auto_pause: 'never', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: {}, + }); + }); + it('other value', () => { + const config = { + live: { + auto_pause: 'unselected', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: { + auto_pause: ['unselected'], + }, + }); + }); + }); + describe('auto_mute', () => { + it('all', () => { + const config = { + live: { + auto_mute: 'all', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: {}, + }); + }); + it('never', () => { + const config = { + live: { + auto_mute: 'never', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: { + auto_mute: [], + }, + }); + }); + it('other value', () => { + const config = { + live: { + auto_mute: 'unselected', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: { + auto_mute: ['unselected'], + }, + }); + }); + }); + describe('auto_unmute', () => { + it('all', () => { + const config = { + live: { + auto_unmute: 'all', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: { + auto_unmute: ['selected', 'visible', 'microphone'], + }, + }); + }); + it('never', () => { + const config = { + live: { + auto_unmute: 'never', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: {}, + }); + }); + it('other value', () => { + const config = { + live: { + auto_unmute: 'selected', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + live: { + auto_unmute: ['selected'], + }, + }); + }); + }); + }); + + describe('media_viewer', () => { + describe('auto_play', () => { + it('all', () => { + const config = { + media_viewer: { + auto_play: 'all', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + media_viewer: {}, + }); + }); + it('never', () => { + const config = { + media_viewer: { + auto_play: 'never', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + media_viewer: { + auto_play: [], + }, + }); + }); + it('other value', () => { + const config = { + media_viewer: { + auto_play: 'selected', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + media_viewer: { + auto_play: ['selected'], + }, + }); + }); + }); + describe('auto_pause', () => { + it('all', () => { + const config = { + media_viewer: { + auto_pause: 'all', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + media_viewer: {}, + }); + }); + it('never', () => { + const config = { + media_viewer: { + auto_pause: 'never', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + media_viewer: { + auto_pause: [], + }, + }); + }); + it('other value', () => { + const config = { + media_viewer: { + auto_pause: 'unselected', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + media_viewer: { + auto_pause: ['unselected'], + }, + }); + }); + }); + describe('auto_mute', () => { + it('all', () => { + const config = { + media_viewer: { + auto_mute: 'all', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + media_viewer: {}, + }); + }); + it('never', () => { + const config = { + media_viewer: { + auto_mute: 'never', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + media_viewer: { + auto_mute: [], + }, + }); + }); + it('other value', () => { + const config = { + media_viewer: { + auto_mute: 'unselected', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + media_viewer: { + auto_mute: ['unselected'], + }, + }); + }); + }); + describe('auto_unmute', () => { + it('all', () => { + const config = { + media_viewer: { + auto_unmute: 'all', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + media_viewer: { + auto_unmute: ['selected', 'visible'], + }, + }); + }); + it('never', () => { + const config = { + media_viewer: { + auto_unmute: 'never', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + media_viewer: {}, + }); + }); + it('other value', () => { + const config = { + media_viewer: { + auto_unmute: 'selected', + }, + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config).toEqual({ + media_viewer: { + auto_unmute: ['selected'], + }, + }); + }); + }); + }); }); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 95de1a00..aa41d7b8 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -58,10 +58,10 @@ describe('config defaults', () => { zoomable: true, }, live: { - auto_mute: 'all', - auto_pause: 'never', - auto_play: 'all', - auto_unmute: 'never', + auto_mute: ['unselected', 'hidden', 'microphone'], + auto_pause: [], + auto_play: ['selected', 'visible'], + auto_unmute: ['microphone'], controls: { builtin: true, next_previous: { @@ -96,10 +96,11 @@ describe('config defaults', () => { }, draggable: true, lazy_load: true, - lazy_unload: 'never', + lazy_unload: [], microphone: { always_connected: false, disconnect_seconds: 60, + mute_after_microphone_mute_seconds: 60, }, preload: false, show_image_during_load: true, @@ -122,10 +123,10 @@ describe('config defaults', () => { }, }, media_viewer: { - auto_mute: 'all', - auto_pause: 'all', - auto_play: 'all', - auto_unmute: 'never', + auto_mute: ['unselected', 'hidden'], + auto_pause: ['unselected', 'hidden'], + auto_play: ['selected', 'visible'], + auto_unmute: [], controls: { builtin: true, next_previous: { @@ -473,3 +474,14 @@ it('should strip trailing slashes from go2rtc url', () => { expect(config).toBeTruthy(); expect(config.cameras[0].go2rtc.url).toBe('https://my-custom-go2rtc'); }); + +it('media viewer should not support microphone based conditions', () => { + expect(() => + createConfig({ + cameras: [], + media_viewer: { + auto_unmute: 'microphone' as const, + }, + }), + ).toThrowError(); +}); diff --git a/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts b/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts index d2ec3916..3282dc57 100644 --- a/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts +++ b/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { MEDIA_ACTION_NEGATIVE_CONDITIONS } from '../../../../../src/config/types'; import { AutoLazyLoad } from '../../../../../src/utils/embla/plugins/auto-lazy-load/auto-lazy-load'; import { callEmblaHandler, @@ -91,7 +92,7 @@ describe('AutoLazyLoad', () => { lazyLoadCallback: vi.fn(), lazyLoadCount: 3, lazyUnloadCallback: lazyUnloadCallback, - lazyUnloadCondition: 'all', + lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS, }); const children = createTestSlideNodes(); @@ -143,7 +144,7 @@ describe('AutoLazyLoad', () => { const plugin = AutoLazyLoad({ lazyLoadCallback: vi.fn(), lazyUnloadCallback: lazyUnloadCallback, - lazyUnloadCondition: 'all', + lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS, }); const children = createTestSlideNodes(); @@ -172,7 +173,7 @@ describe('AutoLazyLoad', () => { const lazyLoadCallback = vi.fn(); const plugin = AutoLazyLoad({ lazyLoadCallback: lazyLoadCallback, - lazyUnloadCondition: 'all', + lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS, // No lazy unload callback. }); const children = createTestSlideNodes(); @@ -200,7 +201,7 @@ describe('AutoLazyLoad', () => { vi.spyOn(global.document, 'addEventListener'); const plugin = AutoLazyLoad({ - lazyUnloadCondition: 'all', + lazyUnloadConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS, // No callbacks provided. }); diff --git a/tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts b/tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts index 9e8b7092..d941d2c4 100644 --- a/tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts +++ b/tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts @@ -1,4 +1,16 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import add from 'date-fns/add'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { + MicrophoneManagerListenerChange, + ReadonlyMicrophoneManager, +} from '../../../../../src/card-controller/microphone-manager'; +import { + MEDIA_ACTION_NEGATIVE_CONDITIONS, + MEDIA_ACTION_POSITIVE_CONDITIONS, + MEDIA_MUTE_CONDITIONS, + MEDIA_UNMUTE_CONDITIONS, +} from '../../../../../src/config/types'; import { FrigateCardMediaPlayer } from '../../../../../src/types'; import { AutoMediaActions, @@ -50,18 +62,33 @@ const createPlayerSlideNodes = (n = 10): HTMLElement[] => { const createPlugin = (options?: AutoMediaActionsOptionsType): AutoMediaActionsType => { return AutoMediaActions({ playerSelector: 'video', - autoPlayCondition: 'all', - autoUnmuteCondition: 'all', - autoPauseCondition: 'all', - autoMuteCondition: 'all', + autoPlayConditions: MEDIA_ACTION_POSITIVE_CONDITIONS, + autoUnmuteConditions: MEDIA_UNMUTE_CONDITIONS, + autoPauseConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS, + autoMuteConditions: MEDIA_MUTE_CONDITIONS, + microphoneManager: mock(), ...options, }); }; +export 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('AutoMediaActions', () => { beforeAll(() => { vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); }); beforeEach(() => { @@ -74,8 +101,14 @@ describe('AutoMediaActions', () => { }); it('should init without any conditions', () => { - const plugin = AutoMediaActions(); - + const microphoneManager = mock(); + const plugin = createPlugin({ + autoPlayConditions: [], + autoUnmuteConditions: [], + autoPauseConditions: [], + autoMuteConditions: [], + microphoneManager: microphoneManager, + }); const parent = createParent(); const addEventListener = vi.fn(); parent.addEventListener = addEventListener; @@ -86,6 +119,7 @@ describe('AutoMediaActions', () => { expect(emblaApi.on).toBeCalledWith('destroy', expect.anything()); expect(emblaApi.on).not.toBeCalledWith('select', expect.anything()); expect(addEventListener).not.toBeCalled(); + expect(microphoneManager.addListener).not.toBeCalled(); }); it('should destroy', () => { @@ -105,7 +139,14 @@ describe('AutoMediaActions', () => { }); it('should destroy without any conditions', () => { - const plugin = AutoMediaActions(); + const microphoneManager = mock(); + const plugin = createPlugin({ + autoPlayConditions: [], + autoUnmuteConditions: [], + autoPauseConditions: [], + autoMuteConditions: [], + microphoneManager: microphoneManager, + }); const parent = createParent(); const removeEventListener = vi.fn(); parent.removeEventListener = removeEventListener; @@ -118,6 +159,7 @@ describe('AutoMediaActions', () => { expect(emblaApi.off).toBeCalledWith('destroy', expect.anything()); expect(emblaApi.off).not.toBeCalledWith('select', expect.anything()); expect(removeEventListener).not.toBeCalled(); + expect(microphoneManager.removeListener).not.toBeCalled(); }); it('should mute and pause on destroy', () => { @@ -301,4 +343,113 @@ describe('AutoMediaActions', () => { expect(getPlayer(child, 'video')?.mute).toBeCalled(); } }); + + describe('should handle microphone triggers', () => { + it('should unmute on microphone unmute', () => { + const microphoneManager = mock(); + const plugin = createPlugin({ microphoneManager: microphoneManager }); + const children = createPlayerSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + selectedScrollSnap: 5, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + callMicrophoneListener(microphoneManager, 'unmuted'); + + expect(getPlayer(children[5], 'video')?.unmute).toBeCalled(); + }); + + it('should not unmute on microphone unmute when not configured', () => { + const microphoneManager = mock(); + const plugin = createPlugin({ + microphoneManager: microphoneManager, + autoUnmuteConditions: [], + }); + const children = createPlayerSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + selectedScrollSnap: 5, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + callMicrophoneListener(microphoneManager, 'unmuted'); + + expect(getPlayer(children[5], 'video')?.unmute).not.toBeCalled(); + }); + + it('should mute on microphone mute after default delay', () => { + const start = new Date('2024-01-28T14:42'); + + const microphoneManager = mock(); + const plugin = createPlugin({ microphoneManager: microphoneManager }); + const children = createPlayerSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + selectedScrollSnap: 5, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + vi.setSystemTime(start); + callMicrophoneListener(microphoneManager, 'muted'); + + expect(getPlayer(children[5], 'video')?.mute).not.toBeCalled(); + vi.setSystemTime(add(start, { seconds: 60 })); + vi.runOnlyPendingTimers(); + expect(getPlayer(children[5], 'video')?.mute).toBeCalled(); + }); + + it('should mute on microphone mute after configured delay', () => { + const start = new Date('2024-01-28T14:42'); + + const microphoneManager = mock(); + const plugin = createPlugin({ + microphoneManager: microphoneManager, + microphoneMuteSeconds: 30, + }); + const children = createPlayerSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + selectedScrollSnap: 5, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + vi.setSystemTime(start); + callMicrophoneListener(microphoneManager, 'muted'); + + expect(getPlayer(children[5], 'video')?.mute).not.toBeCalled(); + vi.setSystemTime(add(start, { seconds: 30 })); + vi.runOnlyPendingTimers(); + expect(getPlayer(children[5], 'video')?.mute).toBeCalled(); + }); + + it('should not mute on microphone mute after delay when not configured', () => { + const start = new Date('2024-01-28T14:42'); + + const microphoneManager = mock(); + const plugin = createPlugin({ + microphoneManager: microphoneManager, + autoMuteConditions: [], + }); + const children = createPlayerSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + selectedScrollSnap: 5, + }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + vi.setSystemTime(start); + callMicrophoneListener(microphoneManager, 'muted'); + + expect(getPlayer(children[5], 'video')?.mute).not.toBeCalled(); + vi.setSystemTime(add(start, { seconds: 60 })); + vi.runOnlyPendingTimers(); + expect(getPlayer(children[5], 'video')?.mute).not.toBeCalled(); + }); + }); }); diff --git a/vite.config.ts b/vite.config.ts index c788fc81..5f1aa02a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,10 +10,10 @@ export default defineConfig({ // Thresholds will automatically be updated as coverage improves to avoid // back-sliding. thresholdAutoUpdate: true, - statements: 71.95, - branches: 60.78, - functions: 73.04, - lines: 71.84, + statements: 72.2, + branches: 61.18, + functions: 73.47, + lines: 72.09, }, }, });