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
+5 -5
View File
@@ -8,12 +8,12 @@ live:
``` ```
| Option | Default | Description | | Option | Default | Description |
| ------------------------ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions` | | [Actions](actions/README.md) to use for the `live` view. | | `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_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 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_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 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_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 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. | | `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. | | `controls` | | Configuration for the `live` view controls. See below. |
| `display` | | Controls whether to show a single or grid `live` view. 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. | | `draggable` | `true` | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. |
+5 -5
View File
@@ -8,12 +8,12 @@ media_viewer:
``` ```
| Option | Default | Description | | Option | Default | Description |
| --------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions` | | [Actions](actions/README.md) to use for all views that use the `media_viewer` (e.g. `clip`, `snapshot`). | | `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_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 and `hidden` will automatically pause when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically pause. | | `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 the carousel and `visible` will automatically play when the browser/tab becomes visible. Use an empty list (`[]`) to never automatically play. | | `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 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. | | `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. | | `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. | | `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. | | `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. |
+48 -42
View File
@@ -34,6 +34,11 @@ type PlayerElement = HTMLElement & FrigateCardMediaPlayer;
* start the video. * start the video.
*/ */
type MediaActionsTarget = {
selected: boolean;
index: number;
};
export class MediaActionsController { export class MediaActionsController {
protected _options: MediaActionsControllerOptions | null = null; protected _options: MediaActionsControllerOptions | null = null;
protected _viewportIntersecting: boolean | null = null; protected _viewportIntersecting: boolean | null = null;
@@ -42,7 +47,7 @@ export class MediaActionsController {
protected _eventListeners = new Map<HTMLElement, () => void>(); protected _eventListeners = new Map<HTMLElement, () => void>();
protected _children: PlayerElement[] = []; protected _children: PlayerElement[] = [];
protected _selected: number | null = null; protected _target: MediaActionsTarget | null = null;
protected _mutationObserver = new MutationObserver(this._mutationHandler.bind(this)); protected _mutationObserver = new MutationObserver(this._mutationHandler.bind(this));
protected _intersectionObserver = new IntersectionObserver( protected _intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this), this._intersectionHandler.bind(this),
@@ -67,59 +72,62 @@ export class MediaActionsController {
this._root = null; this._root = null;
this._removeChildHandlers(); this._removeChildHandlers();
this._children = []; this._children = [];
this._selected = null; this._target = null;
this._mutationObserver.disconnect(); this._mutationObserver.disconnect();
this._intersectionObserver.disconnect(); this._intersectionObserver.disconnect();
this._options?.microphoneManager?.removeListener(this._microphoneChangeHandler); this._options?.microphoneManager?.removeListener(this._microphoneChangeHandler);
document.removeEventListener('visibilitychange', this._visibilityHandler); document.removeEventListener('visibilitychange', this._visibilityHandler);
} }
public async select(index: number): Promise<void> { public async setTarget(index: number, selected: boolean): Promise<void> {
if (this._selected === index) { if (this._target?.index === index && this._target?.selected === selected) {
return; return;
} }
if (this._selected !== null) {
await this.unselect();
}
this._selected = index;
await this._unmuteSelectedIfConfigured('selected');
await this._playSelectedIfConfigured('selected');
}
public async unselect(): Promise<void> { // If there's already a selected target, unselect it.
await this._pauseSelectedIfConfigured('unselected'); if (!!this._target?.selected) {
await this._muteSelectedIfConfigured('unselected'); await this._pauseTargetIfConfigured('unselected');
await this._muteTargetIfConfigured('unselected');
this._microphoneMuteTimer.stop(); this._microphoneMuteTimer.stop();
this._selected = null;
} }
public async unselectAll(): Promise<void> { this._target = {
this._selected = null; selected,
await this._pauseAllIfConfigured('unselected'); index,
await this._muteAllIfConfigured('unselected'); };
if (selected) {
await this._unmuteTargetIfConfigured('selected');
await this._playTargetIfConfigured('selected');
} else {
await this._unmuteTargetIfConfigured('visible');
await this._playTargetIfConfigured('visible');
}
} }
protected async _playSelectedIfConfigured( public unsetTarget(): void {
condition: AutoPlayCondition, this._target = null;
): Promise<void> { }
protected async _playTargetIfConfigured(condition: AutoPlayCondition): Promise<void> {
if ( if (
this._selected !== null && this._target !== null &&
this._options?.autoPlayConditions?.includes(condition) this._options?.autoPlayConditions?.includes(condition)
) { ) {
await this._play(this._selected); await this._play(this._target.index);
} }
} }
protected async _play(index: number): Promise<void> { protected async _play(index: number): Promise<void> {
await this._children[index]?.play(); await this._children[index]?.play();
} }
protected async _unmuteSelectedIfConfigured( protected async _unmuteTargetIfConfigured(
condition: AutoUnmuteCondition, condition: AutoUnmuteCondition,
): Promise<void> { ): Promise<void> {
if ( if (
this._selected !== null && this._target !== null &&
this._options?.autoUnmuteConditions?.includes(condition) this._options?.autoUnmuteConditions?.includes(condition)
) { ) {
await this._unmute(this._selected); await this._unmute(this._target.index);
} }
} }
protected async _unmute(index: number): Promise<void> { protected async _unmute(index: number): Promise<void> {
@@ -133,14 +141,14 @@ export class MediaActionsController {
} }
} }
} }
protected async _pauseSelectedIfConfigured( protected async _pauseTargetIfConfigured(
condition: AutoPauseCondition, condition: AutoPauseCondition,
): Promise<void> { ): Promise<void> {
if ( if (
this._selected !== null && this._target !== null &&
this._options?.autoPauseConditions?.includes(condition) this._options?.autoPauseConditions?.includes(condition)
) { ) {
await this._pause(this._selected); await this._pause(this._target.index);
} }
} }
protected async _pause(index: number): Promise<void> { protected async _pause(index: number): Promise<void> {
@@ -154,14 +162,12 @@ export class MediaActionsController {
} }
} }
} }
protected async _muteSelectedIfConfigured( protected async _muteTargetIfConfigured(condition: AutoMuteCondition): Promise<void> {
condition: AutoMuteCondition,
): Promise<void> {
if ( if (
this._selected !== null && this._target !== null &&
this._options?.autoMuteConditions?.includes(condition) this._options?.autoMuteConditions?.includes(condition)
) { ) {
await this._mute(this._selected); await this._mute(this._target.index);
} }
} }
protected async _mute(index: number): Promise<void> { protected async _mute(index: number): Promise<void> {
@@ -178,11 +184,11 @@ export class MediaActionsController {
} }
protected _mediaLoadedHandler = async (index: number): Promise<void> => { protected _mediaLoadedHandler = async (index: number): Promise<void> => {
if (this._selected !== index) { if (this._target?.index !== index) {
return; return;
} }
await this._unmuteSelectedIfConfigured('selected'); await this._unmuteTargetIfConfigured(this._target.selected ? 'selected' : 'visible');
await this._playSelectedIfConfigured('selected'); await this._playTargetIfConfigured(this._target.selected ? 'selected' : 'visible');
}; };
protected _removeChildHandlers(): void { protected _removeChildHandlers(): void {
@@ -242,8 +248,8 @@ export class MediaActionsController {
protected _changeVisibility = async (visible: boolean): Promise<void> => { protected _changeVisibility = async (visible: boolean): Promise<void> => {
if (visible) { if (visible) {
await this._unmuteSelectedIfConfigured('visible'); await this._unmuteTargetIfConfigured('visible');
await this._playSelectedIfConfigured('visible'); await this._playTargetIfConfigured('visible');
} else { } else {
await this._pauseAllIfConfigured('hidden'); await this._pauseAllIfConfigured('hidden');
await this._muteAllIfConfigured('hidden'); await this._muteAllIfConfigured('hidden');
@@ -253,7 +259,7 @@ export class MediaActionsController {
change: MicrophoneManagerListenerChange, change: MicrophoneManagerListenerChange,
): Promise<void> => { ): Promise<void> => {
if (change === 'unmuted') { if (change === 'unmuted') {
await this._unmuteSelectedIfConfigured('microphone'); await this._unmuteTargetIfConfigured('microphone');
} else if ( } else if (
change === 'muted' && change === 'muted' &&
this._options?.autoMuteConditions?.includes('microphone') this._options?.autoMuteConditions?.includes('microphone')
@@ -261,7 +267,7 @@ export class MediaActionsController {
this._microphoneMuteTimer.start( this._microphoneMuteTimer.start(
this._options.microphoneMuteSeconds ?? 60, this._options.microphoneMuteSeconds ?? 60,
async () => { 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 { protected _getSelectedCameraIndex(): number {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); if (this.viewFilterCameraID) {
const view = this.viewManagerEpoch?.manager.getView();
if (!cameraIDs?.size || !view || this.viewFilterCameraID) {
// If the carousel is limited to a single cameraID, the first (only) // If the carousel is limited to a single cameraID, the first (only)
// element is always the selected one. // element is always the selected one.
return 0; 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)); return Math.max(0, Array.from(cameraIDs).indexOf(view.camera));
} }
@@ -364,13 +368,19 @@ export class FrigateCardLiveCarousel extends LitElement {
} }
if (changedProps.has('viewManagerEpoch')) { if (changedProps.has('viewManagerEpoch')) {
if ( const view = this.viewManagerEpoch?.manager.getView();
this.viewFilterCameraID && const selectedCameraIndex = this._getSelectedCameraIndex();
this.viewManagerEpoch?.manager.getView()?.camera !== this.viewFilterCameraID
) { if (this.viewFilterCameraID) {
this._mediaActionsController.unselectAll(); 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 { } 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; this._selected = newSelected;
} }
if (!newMedia) { if (!newMedia?.length) {
this._mediaActionsController.unselectAll(); // No media will be rendered.
this._mediaActionsController.unsetTarget();
} else { } 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'; import { z } from 'zod';
export const deviceSchema = z.object({ const deviceSchema = z.object({
id: z.string(), id: z.string(),
model: z.string().nullable(), model: z.string().nullable(),
config_entries: z.string().array(), config_entries: z.string().array(),
@@ -90,7 +90,7 @@ describe('MediaActionsController', () => {
const parent = createParent({ children: children }); const parent = createParent({ children: children });
controller.initialize(parent); controller.initialize(parent);
await controller.select(0); await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).not.toBeCalled(); expect(getPlayer(children[0], 'video')?.play).not.toBeCalled();
}); });
@@ -112,7 +112,7 @@ describe('MediaActionsController', () => {
await callMutationHandler(); await callMutationHandler();
await controller.select(1); await controller.setTarget(1, true);
expect(newPlayer.play).toBeCalled(); expect(newPlayer.play).toBeCalled();
}); });
@@ -132,13 +132,13 @@ describe('MediaActionsController', () => {
controller.destroy(); controller.destroy();
await controller.select(0); await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).not.toBeCalled(); expect(getPlayer(children[0], 'video')?.play).not.toBeCalled();
}); });
}); });
describe('should respond to select', () => { describe('should respond to setting target', () => {
it.each([ it.each([
['should play', { autoPlayConditions: ['selected' as const] }, 'play', true], ['should play', { autoPlayConditions: ['selected' as const] }, 'play', true],
['should not play', { autoPlayConditions: [] }, 'play', false], ['should not play', { autoPlayConditions: [] }, 'play', false],
@@ -161,13 +161,13 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0); 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(); const controller = new MediaActionsController();
controller.setOptions({ controller.setOptions({
autoPlayConditions: ['selected' as const], autoPlayConditions: ['selected' as const],
@@ -177,14 +177,14 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1); 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); 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(); const controller = new MediaActionsController();
controller.setOptions({ controller.setOptions({
autoPauseConditions: ['unselected' as const], autoPauseConditions: ['unselected' as const],
@@ -195,16 +195,66 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
await controller.select(1); await controller.setTarget(1, true);
expect(getPlayer(children[0], 'video')?.pause).toBeCalled(); expect(getPlayer(children[0], 'video')?.pause).toBeCalled();
expect(getPlayer(children[0], 'video')?.mute).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', () => { describe('should respond to media loaded', () => {
it('should play', async () => { it('should play after media load', async () => {
const controller = new MediaActionsController(); const controller = new MediaActionsController();
controller.setOptions({ controller.setOptions({
autoPlayConditions: ['selected' as const], autoPlayConditions: ['selected' as const],
@@ -214,7 +264,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1); expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
getPlayer(children[0], 'video')?.dispatchEvent( getPlayer(children[0], 'video')?.dispatchEvent(
@@ -226,7 +276,7 @@ describe('MediaActionsController', () => {
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(2); expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(2);
}); });
it('should unmute', async () => { it('should unmute after media load', async () => {
const controller = new MediaActionsController(); const controller = new MediaActionsController();
controller.setOptions({ controller.setOptions({
autoUnmuteConditions: ['selected' as const], autoUnmuteConditions: ['selected' as const],
@@ -236,7 +286,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1); expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(1);
getPlayer(children[0], 'video')?.dispatchEvent( getPlayer(children[0], 'video')?.dispatchEvent(
@@ -248,18 +298,18 @@ describe('MediaActionsController', () => {
expect(getPlayer(children[0], 'video')?.unmute).toBeCalledTimes(2); 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(); const controller = new MediaActionsController();
controller.setOptions({ controller.setOptions({
autoPlayConditions: ['selected' as const], autoPlayConditions: ['selected' as const, 'visible' as const],
autoUnmuteConditions: ['selected' as const], autoUnmuteConditions: ['selected' as const, 'visible' as const],
playerSelector: 'video', playerSelector: 'video',
}); });
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
getPlayer(children[9], 'video')?.dispatchEvent( getPlayer(children[9], 'video')?.dispatchEvent(
new Event('frigate-card:media:loaded'), 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')?.play).not.toBeCalled();
expect(getPlayer(children[9], 'video')?.unmute).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',
}); });
describe('should respond to unselect', () => { 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 take action on unselect', () => {
it.each([ it.each([
['should pause', { autoPauseConditions: ['unselected' as const] }, 'pause', true], ['should pause', { autoPauseConditions: ['unselected' as const] }, 'pause', true],
['should not pause', { autoPauseConditions: [] }, 'pause', false], ['should not pause', { autoPauseConditions: [] }, 'pause', false],
@@ -295,47 +371,15 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
await controller.unselect(); await controller.setTarget(0, false);
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0); expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
}, },
); );
}); });
describe('should respond to unselect all', () => { describe('should take action on page being visible', () => {
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', () => {
it.each([ it.each([
['should play', { autoPlayConditions: ['visible' as const] }, 'play', true], ['should play', { autoPlayConditions: ['visible' as const] }, 'play', true],
['should not play', { autoPlayConditions: [] }, 'play', false], ['should not play', { autoPlayConditions: [] }, 'play', false],
@@ -359,7 +403,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
// Not configured to take action on selection. // Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled(); 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(() => { beforeAll(() => {
vi.spyOn(global.document, 'addEventListener'); vi.spyOn(global.document, 'addEventListener');
}); });
@@ -402,7 +446,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
// Not configured to take action on selection. // Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled(); 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([ it.each([
['should play', { autoPlayConditions: ['visible' as const] }, 'play', true], ['should play', { autoPlayConditions: ['visible' as const] }, 'play', true],
['should not play', { autoPlayConditions: [] }, 'play', false], ['should not play', { autoPlayConditions: [] }, 'play', false],
@@ -441,7 +485,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
// Not configured to take action on selection. // Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled(); 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([ it.each([
['should play', { autoPlayConditions: ['visible' as const] }, 'play', true], ['should play', { autoPlayConditions: ['visible' as const] }, 'play', true],
['should not play', { autoPlayConditions: [] }, 'play', false], ['should not play', { autoPlayConditions: [] }, 'play', false],
@@ -480,7 +524,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
// Not configured to take action on selection. // Not configured to take action on selection.
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled(); 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(() => { beforeAll(() => {
vi.useFakeTimers(); vi.useFakeTimers();
}); });
@@ -519,7 +563,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
callMicrophoneListener(microphoneManager, 'unmuted'); callMicrophoneListener(microphoneManager, 'unmuted');
@@ -539,7 +583,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
callMicrophoneListener(microphoneManager, 'muted'); callMicrophoneListener(microphoneManager, 'muted');
@@ -561,7 +605,7 @@ describe('MediaActionsController', () => {
const children = createPlayerSlideNodes(); const children = createPlayerSlideNodes();
controller.initialize(createParent({ children: children })); controller.initialize(createParent({ children: children }));
await controller.select(0); await controller.setTarget(0, true);
callMicrophoneListener(microphoneManager, 'muted'); callMicrophoneListener(microphoneManager, 'muted');