Allow the inbound audio to unmute when the microphone is unmuted.

This commit is contained in:
Dermot Duffy
2024-01-28 20:30:09 -08:00
parent a0dc1a1625
commit d3260d516c
22 changed files with 1313 additions and 582 deletions
+34 -21
View File
@@ -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
+48 -12
View File
@@ -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<void> {
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;
+1 -1
View File
@@ -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}
+19 -12
View File
@@ -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<string>;
@@ -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<Message>) => {
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<string>;
@@ -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 {
<frigate-card-live-provider
?load=${!config.lazy_load}
.microphoneStream=${this.view?.camera === cameraID
? this.microphoneStream
? this.microphoneManager?.getStream()
: undefined}
.cameraConfig=${cameraConfig}
.cameraEndpoints=${guard(
@@ -658,7 +665,7 @@ export class FrigateCardLiveCarousel extends LitElement {
.loop=${hasMultipleCameras}
.dragEnabled=${hasMultipleCameras && this.overriddenLiveConfig?.draggable}
.plugins=${guard(
[this.cameraManager, this.overriddenLiveConfig],
[this.cameraManager, this.overriddenLiveConfig, this.microphoneManager],
this._getPlugins.bind(this),
)}
.selected=${this._getSelectedCameraIndex()}
+4 -4
View File
@@ -272,16 +272,16 @@ export class FrigateCardViewerCarousel extends LitElement {
AutoMediaActions({
playerSelector: FRIGATE_CARD_VIEWER_PROVIDER,
...(this.viewerConfig?.auto_play && {
autoPlayCondition: this.viewerConfig.auto_play,
autoPlayConditions: this.viewerConfig.auto_play,
}),
...(this.viewerConfig?.auto_pause && {
autoPauseCondition: this.viewerConfig.auto_pause,
autoPauseConditions: this.viewerConfig.auto_pause,
}),
...(this.viewerConfig?.auto_mute && {
autoMuteCondition: this.viewerConfig.auto_mute,
autoMuteConditions: this.viewerConfig.auto_mute,
}),
...(this.viewerConfig?.auto_unmute && {
autoUnmuteCondition: this.viewerConfig.auto_unmute,
autoUnmuteConditions: this.viewerConfig.auto_unmute,
}),
}),
AutoSize(),
+3 -2
View File
@@ -13,6 +13,7 @@ import {
ConditionsManagerEpoch,
getOverridesByKey,
} from '../card-controller/conditions-manager.js';
import { ReadonlyMicrophoneManager } from '../card-controller/microphone-manager.js';
import {
CardWideConfig,
FrigateCardConfig,
@@ -60,7 +61,7 @@ export class FrigateCardViews extends LitElement {
public hide?: boolean;
@property({ attribute: false })
public microphoneStream?: MediaStream;
public microphoneManager?: ReadonlyMicrophoneManager;
@property({ attribute: false })
public triggeredCameraIDs?: Set<string>;
@@ -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)}"
>
+31
View File
@@ -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),
),
];
+41 -26
View File
@@ -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<typeof microphoneConfigSchema>;
@@ -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<typeof menuConfigSchema>;
// *************************************************************************
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),
+4 -1
View File
@@ -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;
+51 -6
View File
@@ -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,
)}
`,
)}
</div>
@@ -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,
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
@@ -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) {
@@ -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<OptionsType>;
@@ -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();
}
}
@@ -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);
});
});
+771 -411
View File
File diff suppressed because it is too large Load Diff
+21 -9
View File
@@ -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();
});
@@ -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.
});
@@ -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<ReadonlyMicrophoneManager>(),
...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<ReadonlyMicrophoneManager>();
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<ReadonlyMicrophoneManager>();
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<ReadonlyMicrophoneManager>();
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<ReadonlyMicrophoneManager>();
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<ReadonlyMicrophoneManager>();
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<ReadonlyMicrophoneManager>();
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<ReadonlyMicrophoneManager>();
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();
});
});
});
+4 -4
View File
@@ -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,
},
},
});