fix: Treat a un/selection of grid items as selected for auto media actions (#1623)

This is a slight behavioral change vs earlier v6 betas, but makes the media actions less surprising. This also allows for automated unmuting in a grid when a camera is selected, without having _every_ camera automatically unmuted.
This commit is contained in:
Dermot Duffy
2024-10-06 16:32:16 -07:00
committed by GitHub
parent d7fa2933da
commit 323d1ff7c4
7 changed files with 223 additions and 152 deletions
+17 -17
View File
@@ -7,23 +7,23 @@ live:
# [...]
```
| Option | Default | Description |
| ------------------------ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions` | | [Actions](actions/README.md) to use for the `live` view. |
| `auto_mute` | `[unselected, hidden, microphone]` | A list 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_pause` | `[]` | A list 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 is used. |
| `auto_play` | `[selected, visible]` | A list 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_unmute` | `[microphone]` | A list 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. |
| `controls` | | Configuration for the `live` view controls. See below. |
| `display` | | Controls whether to show a single or grid `live` view. See below. |
| `draggable` | `true` | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. |
| `lazy_load` | `true` | 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` | `[]` | A list 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. |
| `microphone` | | See below. |
| `preload` | `false` | 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. |
| `show_image_during_load` | `true` | If `true`, during the initial stream load, the `image` live provider will be shown instead of the loading video stream. This still image will auto-refresh and is replaced with the live stream once loaded. |
| `transition_effect` | `slide` | Effect to apply as a transition between live cameras. Accepted values: `slide` or `none`. |
| `zoomable` | `true` | Whether or not the live carousel can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. |
| Option | Default | Description |
| ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions` | | [Actions](actions/README.md) to use for the `live` view. |
| `auto_mute` | `[unselected, hidden, microphone]` | A list of conditions in which live camera feeds are muted. `unselected` will automatically mute when a camera is unselected in the carousel or grid. `hidden` will automatically mute when the camera becomes hidden (e.g. browser tab change) 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_pause` | `[]` | A list of conditions in which live camera feeds are automatically paused. `unselected` will automatically pause when a camera is unselected in the carousel or grid. `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 is used. |
| `auto_play` | `[selected, visible]` | A list of conditions in which live camera feeds are automatically played. `selected` will automatically play when a camera is selected in a carousel or grid. `visible` will automatically play when a camera becomes visible (e.g. browser tab change, or visible in a grid but not selected). 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 parameter on play-after-pause. |
| `auto_unmute` | `[microphone]` | A list of conditions in which live camera feeds are unmuted. `selected` will automatically unmute when a camera is selected in a carousel or grid. `visible` will automatically unmute when a camera becomes visible (e.g. a browser/tab change, or visible in a grid but not selected). `microphone` will automatically unmute after the microphone is unmuted. Use an empty list (`[]`) to never automatically unmute. 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. |
| `controls` | | Configuration for the `live` view controls. See below. |
| `display` | | Controls whether to show a single or grid `live` view. See below. |
| `draggable` | `true` | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. |
| `lazy_load` | `true` | 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` | `[]` | A list 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. |
| `microphone` | | See below. |
| `preload` | `false` | 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. |
| `show_image_during_load` | `true` | If `true`, during the initial stream load, the `image` live provider will be shown instead of the loading video stream. This still image will auto-refresh and is replaced with the live stream once loaded. |
| `transition_effect` | `slide` | Effect to apply as a transition between live cameras. Accepted values: `slide` or `none`. |
| `zoomable` | `true` | Whether or not the live carousel can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. |
## `controls`
+13 -13
View File
@@ -7,19 +7,19 @@ media_viewer:
# [...]
```
| Option | Default | Description |
| --------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_viewer` (e.g. `clip`, `snapshot`). |
| `auto_mute` | `[unselected, hidden]` | A list 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_pause` | `[unselected, hidden]` | A list 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_play` | `[selected, visible]` | A list 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_unmute` | `[]` | A list 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. |
| `controls` | | Configuration for the Media viewer controls. See below. |
| `draggable` | `true` | Whether or not the Media viewer carousel can be dragged left or right, via touch/swipe and mouse dragging. |
| `lazy_load` | `true` | 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. |
| `snapshot_click_plays_clip` | `true` | Whether clicking on a snapshot in the media viewer should play a related clip. |
| `transition_effect` | `slide` | Effect to apply as a transition between event media. Accepted values: `slide` or `none`. |
| `zoomable` | `true` | Whether or not the Media Viewer can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. |
| Option | Default | Description |
| --------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_viewer` (e.g. `clip`, `snapshot`). |
| `auto_mute` | `[unselected, hidden]` | A list of conditions in which media items are muted. `unselected` will automatically mute when a media item is unselected in the carousel or grid. `hidden` will automatically mute when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically mute. |
| `auto_pause` | `[unselected, hidden]` | A list of conditions in which media items are automatically paused. `unselected` will automatically pause when a media item is unselected in the carousel or grid. `hidden` will automatically pause when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically pause. |
| `auto_play` | `[selected, visible]` | A list of conditions in which media items are automatically played. `selected` will automatically play when a media item is selected in a carousel or grid. `visible` will automatically play when a media item becomes visible (e.g. browser tab change, or visible in a grid but not selected). Use an empty list (`[]`) to never automatically play. |
| `auto_unmute` | `[]` | A list of conditions in which media items are unmuted. `selected` will automatically unmute when a media item is selected in a carousel or grid. `visible` will automatically unmute when a media item becomes visible (e.g. a browser/tab change, or visible in a grid but not selected). Use an empty list (`[]`) to never automatically unmute. 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. |
| `controls` | | Configuration for the Media viewer controls. See below. |
| `draggable` | `true` | Whether or not the Media viewer carousel can be dragged left or right, via touch/swipe and mouse dragging. |
| `lazy_load` | `true` | 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. |
| `snapshot_click_plays_clip` | `true` | Whether clicking on a snapshot in the media viewer should play a related clip. |
| `transition_effect` | `slide` | Effect to apply as a transition between event media. Accepted values: `slide` or `none`. |
| `zoomable` | `true` | Whether or not the Media Viewer can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. |
## `controls`
+49 -43
View File
@@ -34,6 +34,11 @@ type PlayerElement = HTMLElement & FrigateCardMediaPlayer;
* start the video.
*/
type MediaActionsTarget = {
selected: boolean;
index: number;
};
export class MediaActionsController {
protected _options: MediaActionsControllerOptions | null = null;
protected _viewportIntersecting: boolean | null = null;
@@ -42,7 +47,7 @@ export class MediaActionsController {
protected _eventListeners = new Map<HTMLElement, () => void>();
protected _children: PlayerElement[] = [];
protected _selected: number | null = null;
protected _target: MediaActionsTarget | null = null;
protected _mutationObserver = new MutationObserver(this._mutationHandler.bind(this));
protected _intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
@@ -67,59 +72,62 @@ export class MediaActionsController {
this._root = null;
this._removeChildHandlers();
this._children = [];
this._selected = null;
this._target = null;
this._mutationObserver.disconnect();
this._intersectionObserver.disconnect();
this._options?.microphoneManager?.removeListener(this._microphoneChangeHandler);
document.removeEventListener('visibilitychange', this._visibilityHandler);
}
public async select(index: number): Promise<void> {
if (this._selected === index) {
public async setTarget(index: number, selected: boolean): Promise<void> {
if (this._target?.index === index && this._target?.selected === selected) {
return;
}
if (this._selected !== null) {
await this.unselect();
// If there's already a selected target, unselect it.
if (!!this._target?.selected) {
await this._pauseTargetIfConfigured('unselected');
await this._muteTargetIfConfigured('unselected');
this._microphoneMuteTimer.stop();
}
this._target = {
selected,
index,
};
if (selected) {
await this._unmuteTargetIfConfigured('selected');
await this._playTargetIfConfigured('selected');
} else {
await this._unmuteTargetIfConfigured('visible');
await this._playTargetIfConfigured('visible');
}
this._selected = index;
await this._unmuteSelectedIfConfigured('selected');
await this._playSelectedIfConfigured('selected');
}
public async unselect(): Promise<void> {
await this._pauseSelectedIfConfigured('unselected');
await this._muteSelectedIfConfigured('unselected');
this._microphoneMuteTimer.stop();
this._selected = null;
public unsetTarget(): void {
this._target = null;
}
public async unselectAll(): Promise<void> {
this._selected = null;
await this._pauseAllIfConfigured('unselected');
await this._muteAllIfConfigured('unselected');
}
protected async _playSelectedIfConfigured(
condition: AutoPlayCondition,
): Promise<void> {
protected async _playTargetIfConfigured(condition: AutoPlayCondition): Promise<void> {
if (
this._selected !== null &&
this._target !== null &&
this._options?.autoPlayConditions?.includes(condition)
) {
await this._play(this._selected);
await this._play(this._target.index);
}
}
protected async _play(index: number): Promise<void> {
await this._children[index]?.play();
}
protected async _unmuteSelectedIfConfigured(
protected async _unmuteTargetIfConfigured(
condition: AutoUnmuteCondition,
): Promise<void> {
if (
this._selected !== null &&
this._target !== null &&
this._options?.autoUnmuteConditions?.includes(condition)
) {
await this._unmute(this._selected);
await this._unmute(this._target.index);
}
}
protected async _unmute(index: number): Promise<void> {
@@ -133,14 +141,14 @@ export class MediaActionsController {
}
}
}
protected async _pauseSelectedIfConfigured(
protected async _pauseTargetIfConfigured(
condition: AutoPauseCondition,
): Promise<void> {
if (
this._selected !== null &&
this._target !== null &&
this._options?.autoPauseConditions?.includes(condition)
) {
await this._pause(this._selected);
await this._pause(this._target.index);
}
}
protected async _pause(index: number): Promise<void> {
@@ -154,14 +162,12 @@ export class MediaActionsController {
}
}
}
protected async _muteSelectedIfConfigured(
condition: AutoMuteCondition,
): Promise<void> {
protected async _muteTargetIfConfigured(condition: AutoMuteCondition): Promise<void> {
if (
this._selected !== null &&
this._target !== null &&
this._options?.autoMuteConditions?.includes(condition)
) {
await this._mute(this._selected);
await this._mute(this._target.index);
}
}
protected async _mute(index: number): Promise<void> {
@@ -178,11 +184,11 @@ export class MediaActionsController {
}
protected _mediaLoadedHandler = async (index: number): Promise<void> => {
if (this._selected !== index) {
if (this._target?.index !== index) {
return;
}
await this._unmuteSelectedIfConfigured('selected');
await this._playSelectedIfConfigured('selected');
await this._unmuteTargetIfConfigured(this._target.selected ? 'selected' : 'visible');
await this._playTargetIfConfigured(this._target.selected ? 'selected' : 'visible');
};
protected _removeChildHandlers(): void {
@@ -242,8 +248,8 @@ export class MediaActionsController {
protected _changeVisibility = async (visible: boolean): Promise<void> => {
if (visible) {
await this._unmuteSelectedIfConfigured('visible');
await this._playSelectedIfConfigured('visible');
await this._unmuteTargetIfConfigured('visible');
await this._playTargetIfConfigured('visible');
} else {
await this._pauseAllIfConfigured('hidden');
await this._muteAllIfConfigured('hidden');
@@ -253,7 +259,7 @@ export class MediaActionsController {
change: MicrophoneManagerListenerChange,
): Promise<void> => {
if (change === 'unmuted') {
await this._unmuteSelectedIfConfigured('microphone');
await this._unmuteTargetIfConfigured('microphone');
} else if (
change === 'muted' &&
this._options?.autoMuteConditions?.includes('microphone')
@@ -261,7 +267,7 @@ export class MediaActionsController {
this._microphoneMuteTimer.start(
this._options.microphoneMuteSeconds ?? 60,
async () => {
await this._muteSelectedIfConfigured('microphone');
await this._muteTargetIfConfigured('microphone');
},
);
}
+19 -9
View File
@@ -325,13 +325,17 @@ export class FrigateCardLiveCarousel extends LitElement {
}
protected _getSelectedCameraIndex(): number {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
const view = this.viewManagerEpoch?.manager.getView();
if (!cameraIDs?.size || !view || this.viewFilterCameraID) {
if (this.viewFilterCameraID) {
// If the carousel is limited to a single cameraID, the first (only)
// element is always the selected one.
return 0;
}
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
const view = this.viewManagerEpoch?.manager.getView();
if (!cameraIDs?.size || !view) {
return 0;
}
return Math.max(0, Array.from(cameraIDs).indexOf(view.camera));
}
@@ -364,13 +368,19 @@ export class FrigateCardLiveCarousel extends LitElement {
}
if (changedProps.has('viewManagerEpoch')) {
if (
this.viewFilterCameraID &&
this.viewManagerEpoch?.manager.getView()?.camera !== this.viewFilterCameraID
) {
this._mediaActionsController.unselectAll();
const view = this.viewManagerEpoch?.manager.getView();
const selectedCameraIndex = this._getSelectedCameraIndex();
if (this.viewFilterCameraID) {
this._mediaActionsController.setTarget(
selectedCameraIndex,
// Camera in this carousel is only selected if the camera from the
// view matches the filtered camera.
view?.camera === this.viewFilterCameraID,
);
} else {
this._mediaActionsController.select(this._getSelectedCameraIndex());
// Carousel is not filtered, so the targeted camera is always selected.
this._mediaActionsController.setTarget(selectedCameraIndex, true);
}
}
}
+14 -3
View File
@@ -386,10 +386,21 @@ export class FrigateCardViewerCarousel extends LitElement {
this._selected = newSelected;
}
if (!newMedia) {
this._mediaActionsController.unselectAll();
if (!newMedia?.length) {
// No media will be rendered.
this._mediaActionsController.unsetTarget();
} else {
this._mediaActionsController.select(newSelected);
if (this.viewFilterCameraID) {
this._mediaActionsController.setTarget(
newSelected,
// Camera in this carousel is only selected if the camera from the
// view matches the filtered camera.
view?.camera === this.viewFilterCameraID,
);
} else {
// Carousel is not filtered, so the targeted camera is always selected.
this._mediaActionsController.setTarget(newSelected, true);
}
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
export const deviceSchema = z.object({
const deviceSchema = z.object({
id: z.string(),
model: z.string().nullable(),
config_entries: z.string().array(),
@@ -90,7 +90,7 @@ describe('MediaActionsController', () => {
const parent = createParent({ children: children });
controller.initialize(parent);
await controller.select(0);
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).not.toBeCalled();
});
@@ -112,7 +112,7 @@ describe('MediaActionsController', () => {
await callMutationHandler();
await controller.select(1);
await controller.setTarget(1, true);
expect(newPlayer.play).toBeCalled();
});
@@ -132,13 +132,13 @@ describe('MediaActionsController', () => {
controller.destroy();
await controller.select(0);
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).not.toBeCalled();
});
});
describe('should respond to select', () => {
describe('should respond to setting target', () => {
it.each([
['should play', { autoPlayConditions: ['selected' as const] }, 'play', true],
['should not play', { autoPlayConditions: [] }, 'play', false],
@@ -161,13 +161,13 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
},
);
it('should not re-select', async () => {
it('should not reselect previously selected target', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPlayConditions: ['selected' as const],
@@ -177,14 +177,14 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
await controller.select(0);
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
});
it('should unselect first', async () => {
it('should unselect before selecting a new target', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPauseConditions: ['unselected' as const],
@@ -195,16 +195,66 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.select(1);
await controller.setTarget(0, true);
await controller.setTarget(1, true);
expect(getPlayer(children[0], 'video')?.pause).toBeCalled();
expect(getPlayer(children[0], 'video')?.mute).toBeCalled();
});
it('should select after target was previously visible', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPlayConditions: ['selected' as const],
autoUnmuteConditions: ['selected' as const],
playerSelector: 'video',
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, false);
expect(getPlayer(children[0], 'video')?.play).not.toBeCalled();
expect(getPlayer(children[0], 'video')?.unmute).not.toBeCalled();
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).toBeCalled();
expect(getPlayer(children[0], 'video')?.unmute).toBeCalled();
});
});
it('should take no action after target unset', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPlayConditions: ['selected' as const, 'visible' as const],
autoUnmuteConditions: ['selected' as const, 'visible' as const],
playerSelector: 'video',
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1);
controller.unsetTarget();
getPlayer(children[0], 'video')?.dispatchEvent(
new Event('frigate-card:media:loaded'),
);
await flushPromises();
// Play/Mute will not have been called again.
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1);
});
describe('should respond to media loaded', () => {
it('should play', async () => {
it('should play after media load', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPlayConditions: ['selected' as const],
@@ -214,7 +264,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
getPlayer(children[0], 'video')?.dispatchEvent(
@@ -226,7 +276,7 @@ describe('MediaActionsController', () => {
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(2);
});
it('should unmute', async () => {
it('should unmute after media load', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoUnmuteConditions: ['selected' as const],
@@ -236,7 +286,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1);
getPlayer(children[0], 'video')?.dispatchEvent(
@@ -248,18 +298,18 @@ describe('MediaActionsController', () => {
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(2);
});
it('should take no action on unselected media load', async () => {
it('should take no action on unrelated media load', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPlayConditions: ['selected' as const],
autoUnmuteConditions: ['selected' as const],
autoPlayConditions: ['selected' as const, 'visible' as const],
autoUnmuteConditions: ['selected' as const, 'visible' as const],
playerSelector: 'video',
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.setTarget(0, true);
getPlayer(children[9], 'video')?.dispatchEvent(
new Event('frigate-card:media:loaded'),
@@ -270,9 +320,35 @@ describe('MediaActionsController', () => {
expect(getPlayer(children[9], 'video')?.play).not.toBeCalled();
expect(getPlayer(children[9], 'video')?.unmute).not.toBeCalled();
});
it('should play and unmute on unselected but targeted media load', async () => {
const controller = new MediaActionsController();
controller.setOptions({
autoPlayConditions: ['visible' as const],
autoUnmuteConditions: ['visible' as const],
playerSelector: 'video',
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.setTarget(0, false);
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1);
getPlayer(children[0], 'video')?.dispatchEvent(
new Event('frigate-card:media:loaded'),
);
await flushPromises();
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(2);
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(2);
});
});
describe('should respond to unselect', () => {
describe('should take action on unselect', () => {
it.each([
['should pause', { autoPauseConditions: ['unselected' as const] }, 'pause', true],
['should not pause', { autoPauseConditions: [] }, 'pause', false],
@@ -295,47 +371,15 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.unselect();
await controller.setTarget(0, true);
await controller.setTarget(0, false);
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
},
);
});
describe('should respond to unselect all', () => {
it.each([
['should pause', { autoPauseConditions: ['unselected' as const] }, 'pause', true],
['should not pause', { autoPauseConditions: [] }, 'pause', false],
['should mute', { autoMuteConditions: ['unselected' as const] }, 'mute', true],
['should not mute', { autoMuteConditions: [] }, 'mute', false],
])(
'%s',
async (
_: string,
options: Partial<MediaActionsControllerOptions>,
func: string,
called: boolean,
) => {
const controller = new MediaActionsController();
controller.setOptions({
playerSelector: 'video',
...options,
});
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.unselectAll();
children.forEach((child) => {
expect(getPlayer(child, 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
});
},
);
});
describe('should respond to page being visible', () => {
describe('should take action on page being visible', () => {
it.each([
['should play', { autoPlayConditions: ['visible' as const] }, 'play', true],
['should not play', { autoPlayConditions: [] }, 'play', false],
@@ -359,7 +403,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.setTarget(0, true);
// Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled();
@@ -376,7 +420,7 @@ describe('MediaActionsController', () => {
);
});
describe('should respond to page being hiddne', () => {
describe('should take action on page being hiddne', () => {
beforeAll(() => {
vi.spyOn(global.document, 'addEventListener');
});
@@ -402,7 +446,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.setTarget(0, true);
// Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled();
@@ -419,7 +463,7 @@ describe('MediaActionsController', () => {
);
});
describe('should respond to page intersecting with viewport', () => {
describe('should take action on page intersecting with viewport', () => {
it.each([
['should play', { autoPlayConditions: ['visible' as const] }, 'play', true],
['should not play', { autoPlayConditions: [] }, 'play', false],
@@ -441,7 +485,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.setTarget(0, true);
// Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled();
@@ -458,7 +502,7 @@ describe('MediaActionsController', () => {
);
});
describe('should respond to page not intersecting with viewport', () => {
describe('should take action on page not intersecting with viewport', () => {
it.each([
['should play', { autoPlayConditions: ['visible' as const] }, 'play', true],
['should not play', { autoPlayConditions: [] }, 'play', false],
@@ -480,7 +524,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.setTarget(0, true);
// Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled();
@@ -497,7 +541,7 @@ describe('MediaActionsController', () => {
);
});
describe('should respond to microphone changes', () => {
describe('should take action on microphone changes', () => {
beforeAll(() => {
vi.useFakeTimers();
});
@@ -519,7 +563,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.setTarget(0, true);
callMicrophoneListener(microphoneManager, 'unmuted');
@@ -539,7 +583,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.setTarget(0, true);
callMicrophoneListener(microphoneManager, 'muted');
@@ -561,7 +605,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children }));
await controller.select(0);
await controller.setTarget(0, true);
callMicrophoneListener(microphoneManager, 'muted');