Move automatic media actions out of carousels
This commit is contained in:
+1
-1
@@ -51,7 +51,7 @@ const plugins = [
|
|||||||
typescript({
|
typescript({
|
||||||
sourceMap: dev,
|
sourceMap: dev,
|
||||||
inlineSources: dev,
|
inlineSources: dev,
|
||||||
exclude: ['tests/**/*.test.ts'],
|
exclude: ['dist/**', 'tests/**/*.test.ts'],
|
||||||
}),
|
}),
|
||||||
json({ exclude: 'package.json' }),
|
json({ exclude: 'package.json' }),
|
||||||
replace({
|
replace({
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import {
|
||||||
|
MicrophoneManagerListenerChange,
|
||||||
|
ReadonlyMicrophoneManager,
|
||||||
|
} from '../card-controller/microphone-manager.js';
|
||||||
|
import {
|
||||||
|
AutoMuteCondition,
|
||||||
|
AutoPauseCondition,
|
||||||
|
AutoPlayCondition,
|
||||||
|
AutoUnmuteCondition,
|
||||||
|
} from '../config/types.js';
|
||||||
|
import { FrigateCardMediaPlayer } from '../types.js';
|
||||||
|
import { FrigateCardMediaLoadedEventTarget } from '../utils/media-info.js';
|
||||||
|
import { Timer } from '../utils/timer.js';
|
||||||
|
|
||||||
|
export interface MediaActionsControllerOptions {
|
||||||
|
playerSelector: string;
|
||||||
|
|
||||||
|
autoPlayConditions?: readonly AutoPlayCondition[];
|
||||||
|
autoUnmuteConditions?: readonly AutoUnmuteCondition[];
|
||||||
|
autoPauseConditions?: readonly AutoPauseCondition[];
|
||||||
|
autoMuteConditions?: readonly AutoMuteCondition[];
|
||||||
|
|
||||||
|
microphoneManager?: ReadonlyMicrophoneManager;
|
||||||
|
microphoneMuteSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenderRoot = HTMLElement & FrigateCardMediaLoadedEventTarget;
|
||||||
|
type PlayerElement = HTMLElement & FrigateCardMediaPlayer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General note: Always unmute before playing, since Chrome may pause a piece of
|
||||||
|
* media if the page hasn't been interacted with first, after unmute. By unmuting
|
||||||
|
* first, even if the unmute call fails a subsequent call to play will still
|
||||||
|
* start the video.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class MediaActionsController {
|
||||||
|
protected _options: MediaActionsControllerOptions | null = null;
|
||||||
|
protected _viewportIntersecting: boolean | null = null;
|
||||||
|
protected _microphoneMuteTimer = new Timer();
|
||||||
|
protected _root: RenderRoot | null = null;
|
||||||
|
|
||||||
|
protected _eventListeners = new Map<HTMLElement, () => void>();
|
||||||
|
protected _children: PlayerElement[] = [];
|
||||||
|
protected _selected: number | null = null;
|
||||||
|
protected _mutationObserver = new MutationObserver(this._mutationHandler.bind(this));
|
||||||
|
protected _intersectionObserver = new IntersectionObserver(
|
||||||
|
this._intersectionHandler.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
|
public setOptions(options: MediaActionsControllerOptions): void {
|
||||||
|
this._options = options;
|
||||||
|
|
||||||
|
if (this._options?.microphoneManager) {
|
||||||
|
this._options.microphoneManager.removeListener(this._microphoneChangeHandler);
|
||||||
|
this._options.microphoneManager.addListener(this._microphoneChangeHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasRoot(): boolean {
|
||||||
|
return !!this._root;
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this._viewportIntersecting = null;
|
||||||
|
this._microphoneMuteTimer.stop();
|
||||||
|
this._root = null;
|
||||||
|
this._removeChildHandlers();
|
||||||
|
this._children = [];
|
||||||
|
this._selected = 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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._selected !== null) {
|
||||||
|
await this.unselect();
|
||||||
|
}
|
||||||
|
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 async unselectAll(): Promise<void> {
|
||||||
|
this._selected = null;
|
||||||
|
await this._pauseAllIfConfigured('unselected');
|
||||||
|
await this._muteAllIfConfigured('unselected');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _playSelectedIfConfigured(
|
||||||
|
condition: AutoPlayCondition,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
this._selected !== null &&
|
||||||
|
this._options?.autoPlayConditions?.includes(condition)
|
||||||
|
) {
|
||||||
|
await this._play(this._selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected async _play(index: number): Promise<void> {
|
||||||
|
await this._children[index]?.play();
|
||||||
|
}
|
||||||
|
protected async _unmuteSelectedIfConfigured(
|
||||||
|
condition: AutoUnmuteCondition,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
this._selected !== null &&
|
||||||
|
this._options?.autoUnmuteConditions?.includes(condition)
|
||||||
|
) {
|
||||||
|
await this._unmute(this._selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected async _unmute(index: number): Promise<void> {
|
||||||
|
await this._children[index]?.unmute();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _pauseAllIfConfigured(condition: AutoPauseCondition): Promise<void> {
|
||||||
|
if (this._options?.autoPauseConditions?.includes(condition)) {
|
||||||
|
for (const index of this._children.keys()) {
|
||||||
|
await this._pause(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected async _pauseSelectedIfConfigured(
|
||||||
|
condition: AutoPauseCondition,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
this._selected !== null &&
|
||||||
|
this._options?.autoPauseConditions?.includes(condition)
|
||||||
|
) {
|
||||||
|
await this._pause(this._selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected async _pause(index: number): Promise<void> {
|
||||||
|
await this._children[index]?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _muteAllIfConfigured(condition: AutoMuteCondition): Promise<void> {
|
||||||
|
if (this._options?.autoMuteConditions?.includes(condition)) {
|
||||||
|
for (const index of this._children.keys()) {
|
||||||
|
await this._mute(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected async _muteSelectedIfConfigured(
|
||||||
|
condition: AutoMuteCondition,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
this._selected !== null &&
|
||||||
|
this._options?.autoMuteConditions?.includes(condition)
|
||||||
|
) {
|
||||||
|
await this._mute(this._selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected async _mute(index: number): Promise<void> {
|
||||||
|
await this._children[index]?.mute();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _mutationHandler(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_mutations: MutationRecord[],
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_observer: MutationObserver,
|
||||||
|
): void {
|
||||||
|
this._initializeRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _mediaLoadedHandler = async (index: number): Promise<void> => {
|
||||||
|
if (this._selected !== index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this._unmuteSelectedIfConfigured('selected');
|
||||||
|
await this._playSelectedIfConfigured('selected');
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _removeChildHandlers(): void {
|
||||||
|
for (const [child, callback] of this._eventListeners.entries()) {
|
||||||
|
child.removeEventListener('frigate-card:media:loaded', callback);
|
||||||
|
}
|
||||||
|
this._eventListeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public initialize(root: RenderRoot): void {
|
||||||
|
this._root = root;
|
||||||
|
this._initializeRoot();
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', this._visibilityHandler);
|
||||||
|
|
||||||
|
this._intersectionObserver.disconnect();
|
||||||
|
this._intersectionObserver.observe(root);
|
||||||
|
|
||||||
|
this._mutationObserver.disconnect();
|
||||||
|
this._mutationObserver.observe(this._root, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _initializeRoot(): void {
|
||||||
|
if (!this._options || !this._root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._removeChildHandlers();
|
||||||
|
|
||||||
|
this._children = [
|
||||||
|
...this._root.querySelectorAll<PlayerElement>(this._options.playerSelector),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [index, child] of this._children.entries()) {
|
||||||
|
const eventListener = () => this._mediaLoadedHandler(index);
|
||||||
|
this._eventListeners.set(child, eventListener);
|
||||||
|
child.addEventListener('frigate-card:media:loaded', eventListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected async _intersectionHandler(
|
||||||
|
entries: IntersectionObserverEntry[],
|
||||||
|
): Promise<void> {
|
||||||
|
const wasIntersecting = this._viewportIntersecting;
|
||||||
|
this._viewportIntersecting = entries.some((entry) => entry.isIntersecting);
|
||||||
|
|
||||||
|
if (wasIntersecting !== null && wasIntersecting !== this._viewportIntersecting) {
|
||||||
|
// If the live view is preloaded (i.e. in the background) we may need to
|
||||||
|
// take media actions, e.g. muting a live stream that is now running in
|
||||||
|
// the background, so we act even if the new state is hidden.
|
||||||
|
await this._changeVisibility(this._viewportIntersecting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _visibilityHandler = async (): Promise<void> => {
|
||||||
|
await this._changeVisibility(document.visibilityState === 'visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _changeVisibility = async (visible: boolean): Promise<void> => {
|
||||||
|
if (visible) {
|
||||||
|
await this._unmuteSelectedIfConfigured('visible');
|
||||||
|
await this._playSelectedIfConfigured('visible');
|
||||||
|
} else {
|
||||||
|
await this._pauseAllIfConfigured('hidden');
|
||||||
|
await this._muteAllIfConfigured('hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
protected _microphoneChangeHandler = async (
|
||||||
|
change: MicrophoneManagerListenerChange,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (change === 'unmuted') {
|
||||||
|
await this._unmuteSelectedIfConfigured('microphone');
|
||||||
|
} else if (
|
||||||
|
change === 'muted' &&
|
||||||
|
this._options?.autoMuteConditions?.includes('microphone')
|
||||||
|
) {
|
||||||
|
this._microphoneMuteTimer.start(
|
||||||
|
this._options.microphoneMuteSeconds ?? 60,
|
||||||
|
async () => {
|
||||||
|
await this._muteSelectedIfConfigured('microphone');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+58
-21
@@ -47,7 +47,6 @@ import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
|||||||
import { aspectRatioToString, contentsChanged } from '../../utils/basic.js';
|
import { aspectRatioToString, contentsChanged } from '../../utils/basic.js';
|
||||||
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
|
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
|
||||||
import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
|
import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
|
||||||
import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
|
|
||||||
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
|
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
|
||||||
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
|
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
|
||||||
import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js';
|
import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js';
|
||||||
@@ -62,6 +61,7 @@ import '../next-prev-control.js';
|
|||||||
import '../ptz.js';
|
import '../ptz.js';
|
||||||
import { FrigateCardPTZ } from '../ptz.js';
|
import { FrigateCardPTZ } from '../ptz.js';
|
||||||
import '../surround.js';
|
import '../surround.js';
|
||||||
|
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
||||||
|
|
||||||
const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider';
|
const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider';
|
||||||
|
|
||||||
@@ -290,10 +290,33 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
// Index between camera name and slide number.
|
// Index between camera name and slide number.
|
||||||
protected _cameraToSlide: Record<string, number> = {};
|
protected _cameraToSlide: Record<string, number> = {};
|
||||||
protected _refPTZControl: Ref<FrigateCardPTZ> = createRef();
|
protected _refPTZControl: Ref<FrigateCardPTZ> = createRef();
|
||||||
|
protected _refCarousel: Ref<HTMLElement> = createRef();
|
||||||
|
|
||||||
|
protected _mediaActionsController = new MediaActionsController();
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
protected _mediaHasLoaded = false;
|
protected _mediaHasLoaded = false;
|
||||||
|
|
||||||
|
public connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
// Request update in order to reinitialize the media action controller.
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public disconnectedCallback(): void {
|
||||||
|
this._mediaActionsController.destroy();
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(changedProperties: PropertyValues): void {
|
||||||
|
super.updated(changedProperties);
|
||||||
|
|
||||||
|
if (!this._mediaActionsController.hasRoot() && this._refCarousel.value) {
|
||||||
|
this._mediaActionsController.initialize(this._refCarousel.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected _getTransitionEffect(): TransitionEffect {
|
protected _getTransitionEffect(): TransitionEffect {
|
||||||
return (
|
return (
|
||||||
this.overriddenLiveConfig?.transition_effect ??
|
this.overriddenLiveConfig?.transition_effect ??
|
||||||
@@ -312,19 +335,12 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
return Math.max(0, Array.from(cameraIDs).indexOf(view.camera));
|
return Math.max(0, Array.from(cameraIDs).indexOf(view.camera));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getPlugins(): EmblaCarouselPlugins {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
return [
|
if (
|
||||||
AutoLazyLoad({
|
changedProps.has('microphoneManager') ||
|
||||||
...(this.overriddenLiveConfig?.lazy_load && {
|
changedProps.has('overriddenLiveConfig')
|
||||||
lazyLoadCallback: (index, slide) =>
|
) {
|
||||||
this._lazyloadOrUnloadSlide('load', index, slide),
|
this._mediaActionsController.setOptions({
|
||||||
}),
|
|
||||||
lazyUnloadConditions: this.overriddenLiveConfig?.lazy_unload,
|
|
||||||
lazyUnloadCallback: (index, slide) =>
|
|
||||||
this._lazyloadOrUnloadSlide('unload', index, slide),
|
|
||||||
}),
|
|
||||||
AutoMediaLoadedInfo(),
|
|
||||||
AutoMediaActions({
|
|
||||||
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
|
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
|
||||||
...(this.overriddenLiveConfig?.auto_play && {
|
...(this.overriddenLiveConfig?.auto_play && {
|
||||||
autoPlayConditions: this.overriddenLiveConfig.auto_play,
|
autoPlayConditions: this.overriddenLiveConfig.auto_play,
|
||||||
@@ -344,7 +360,33 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
microphoneMuteSeconds:
|
microphoneMuteSeconds:
|
||||||
this.overriddenLiveConfig.microphone.mute_after_microphone_mute_seconds,
|
this.overriddenLiveConfig.microphone.mute_after_microphone_mute_seconds,
|
||||||
}),
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedProps.has('viewManagerEpoch')) {
|
||||||
|
if (
|
||||||
|
this.viewFilterCameraID &&
|
||||||
|
this.viewManagerEpoch?.manager.getView()?.camera !== this.viewFilterCameraID
|
||||||
|
) {
|
||||||
|
this._mediaActionsController.unselectAll();
|
||||||
|
} else {
|
||||||
|
this._mediaActionsController.select(this._getSelectedCameraIndex());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getPlugins(): EmblaCarouselPlugins {
|
||||||
|
return [
|
||||||
|
AutoLazyLoad({
|
||||||
|
...(this.overriddenLiveConfig?.lazy_load && {
|
||||||
|
lazyLoadCallback: (index, slide) =>
|
||||||
|
this._lazyloadOrUnloadSlide('load', index, slide),
|
||||||
|
}),
|
||||||
|
lazyUnloadConditions: this.overriddenLiveConfig?.lazy_unload,
|
||||||
|
lazyUnloadCallback: (index, slide) =>
|
||||||
|
this._lazyloadOrUnloadSlide('unload', index, slide),
|
||||||
}),
|
}),
|
||||||
|
AutoMediaLoadedInfo(),
|
||||||
AutoSize(),
|
AutoSize(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -541,15 +583,10 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
// Notes on the below:
|
// Notes on the below:
|
||||||
// - guard() is used to avoid reseting the carousel unless the
|
// - guard() is used to avoid reseting the carousel unless the
|
||||||
// options/plugins actually change.
|
// options/plugins actually change.
|
||||||
// - the 'carousel:settle' event is listened for (instead of
|
|
||||||
// 'carousel:select') to only trigger the view change (which subsequently
|
|
||||||
// fetches thumbnails) after the carousel has stopped moving. This gives a
|
|
||||||
// much smoother carousel experience since network fetches are not at the
|
|
||||||
// same time as carousel movement (at a cost of fetching thumbnails a
|
|
||||||
// little later).
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<frigate-card-carousel
|
<frigate-card-carousel
|
||||||
|
${ref(this._refCarousel)}
|
||||||
.loop=${hasMultipleCameras}
|
.loop=${hasMultipleCameras}
|
||||||
.dragEnabled=${hasMultipleCameras && this.overriddenLiveConfig?.draggable}
|
.dragEnabled=${hasMultipleCameras && this.overriddenLiveConfig?.draggable}
|
||||||
.plugins=${guard(
|
.plugins=${guard(
|
||||||
@@ -748,7 +785,7 @@ export class FrigateCardLiveProvider
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
public disconnectedCallback(): void {
|
||||||
this._isVideoMediaLoaded = false;
|
this._isVideoMediaLoaded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-20
@@ -13,6 +13,7 @@ import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
|||||||
import { CameraManager } from '../camera-manager/manager.js';
|
import { CameraManager } from '../camera-manager/manager.js';
|
||||||
import { RemoveContextPropertyViewModifier } from '../card-controller/view/modifiers/remove-context-property.js';
|
import { RemoveContextPropertyViewModifier } from '../card-controller/view/modifiers/remove-context-property.js';
|
||||||
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||||
|
import { MediaActionsController } from '../components-lib/media-actions-controller.js';
|
||||||
import { MediaGridSelected } from '../components-lib/media-grid-controller.js';
|
import { MediaGridSelected } from '../components-lib/media-grid-controller.js';
|
||||||
import { ZoomSettingsObserved } from '../components-lib/zoom/types.js';
|
import { ZoomSettingsObserved } from '../components-lib/zoom/types.js';
|
||||||
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context.js';
|
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context.js';
|
||||||
@@ -47,7 +48,6 @@ import {
|
|||||||
} from '../utils/basic.js';
|
} from '../utils/basic.js';
|
||||||
import { CarouselSelected } from '../utils/embla/carousel-controller.js';
|
import { CarouselSelected } from '../utils/embla/carousel-controller.js';
|
||||||
import { AutoLazyLoad } from '../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
|
import { AutoLazyLoad } from '../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
|
||||||
import { AutoMediaActions } from '../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
|
|
||||||
import AutoMediaLoadedInfo from '../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
|
import AutoMediaLoadedInfo from '../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
|
||||||
import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js';
|
import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js';
|
||||||
import { canonicalizeHAURL } from '../utils/ha/index.js';
|
import { canonicalizeHAURL } from '../utils/ha/index.js';
|
||||||
@@ -189,12 +189,10 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
protected _selected = 0;
|
protected _selected = 0;
|
||||||
|
|
||||||
protected _media: ViewMedia[] | null = null;
|
protected _media: ViewMedia[] | null = null;
|
||||||
|
protected _mediaActionsController = new MediaActionsController();
|
||||||
protected _player: FrigateCardMediaPlayer | null = null;
|
protected _player: FrigateCardMediaPlayer | null = null;
|
||||||
|
protected _refCarousel: Ref<HTMLElement> = createRef();
|
||||||
|
|
||||||
/**
|
|
||||||
* The updated lifecycle callback for this element.
|
|
||||||
* @param changedProperties The properties that were changed in this render.
|
|
||||||
*/
|
|
||||||
updated(changedProperties: PropertyValues): void {
|
updated(changedProperties: PropertyValues): void {
|
||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
|
|
||||||
@@ -209,6 +207,22 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
this._seekHandler();
|
this._seekHandler();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this._mediaActionsController.hasRoot() && this._refCarousel.value) {
|
||||||
|
this._mediaActionsController.initialize(this._refCarousel.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
// Request update in order to reinitialize the media action controller.
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public disconnectedCallback(): void {
|
||||||
|
this._mediaActionsController.destroy();
|
||||||
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -234,21 +248,6 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
AutoMediaLoadedInfo(),
|
AutoMediaLoadedInfo(),
|
||||||
AutoMediaActions({
|
|
||||||
playerSelector: FRIGATE_CARD_VIEWER_PROVIDER,
|
|
||||||
...(this.viewerConfig?.auto_play && {
|
|
||||||
autoPlayConditions: this.viewerConfig.auto_play,
|
|
||||||
}),
|
|
||||||
...(this.viewerConfig?.auto_pause && {
|
|
||||||
autoPauseConditions: this.viewerConfig.auto_pause,
|
|
||||||
}),
|
|
||||||
...(this.viewerConfig?.auto_mute && {
|
|
||||||
autoMuteConditions: this.viewerConfig.auto_mute,
|
|
||||||
}),
|
|
||||||
...(this.viewerConfig?.auto_unmute && {
|
|
||||||
autoUnmuteConditions: this.viewerConfig.auto_unmute,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
AutoSize(),
|
AutoSize(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -356,6 +355,24 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
|
if (changedProps.has('viewerConfig')) {
|
||||||
|
this._mediaActionsController.setOptions({
|
||||||
|
playerSelector: FRIGATE_CARD_VIEWER_PROVIDER,
|
||||||
|
...(this.viewerConfig?.auto_play && {
|
||||||
|
autoPlayConditions: this.viewerConfig.auto_play,
|
||||||
|
}),
|
||||||
|
...(this.viewerConfig?.auto_pause && {
|
||||||
|
autoPauseConditions: this.viewerConfig.auto_pause,
|
||||||
|
}),
|
||||||
|
...(this.viewerConfig?.auto_mute && {
|
||||||
|
autoMuteConditions: this.viewerConfig.auto_mute,
|
||||||
|
}),
|
||||||
|
...(this.viewerConfig?.auto_unmute && {
|
||||||
|
autoUnmuteConditions: this.viewerConfig.auto_unmute,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (changedProps.has('viewManagerEpoch')) {
|
if (changedProps.has('viewManagerEpoch')) {
|
||||||
const view = this.viewManagerEpoch?.manager.getView();
|
const view = this.viewManagerEpoch?.manager.getView();
|
||||||
const newMedia = view?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
|
const newMedia = view?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
|
||||||
@@ -368,6 +385,12 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
this._media = newMedia;
|
this._media = newMedia;
|
||||||
this._selected = newSelected;
|
this._selected = newSelected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!newMedia) {
|
||||||
|
this._mediaActionsController.unselectAll();
|
||||||
|
} else {
|
||||||
|
this._mediaActionsController.select(newSelected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +428,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<frigate-card-carousel
|
<frigate-card-carousel
|
||||||
|
${ref(this._refCarousel)}
|
||||||
.dragEnabled=${this.viewerConfig?.draggable ?? true}
|
.dragEnabled=${this.viewerConfig?.draggable ?? true}
|
||||||
.plugins=${guard([this.viewerConfig, this._media], this._getPlugins.bind(this))}
|
.plugins=${guard([this.viewerConfig, this._media], this._getPlugins.bind(this))}
|
||||||
.selected=${this._selected}
|
.selected=${this._selected}
|
||||||
|
|||||||
+3
-3
@@ -62,15 +62,15 @@ export const MEDIA_ACTION_NEGATIVE_CONDITIONS = ['unselected', 'hidden'] as cons
|
|||||||
export type LazyUnloadCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
|
export type LazyUnloadCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
|
||||||
export type AutoPauseCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
|
export type AutoPauseCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
|
||||||
|
|
||||||
export const MEDIA_ACTION_POSITIVE_CONDITIONS = ['selected', 'visible'] as const;
|
const MEDIA_ACTION_POSITIVE_CONDITIONS = ['selected', 'visible'] as const;
|
||||||
export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
|
export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
|
||||||
export const MEDIA_UNMUTE_CONDITIONS = [
|
const MEDIA_UNMUTE_CONDITIONS = [
|
||||||
...MEDIA_ACTION_POSITIVE_CONDITIONS,
|
...MEDIA_ACTION_POSITIVE_CONDITIONS,
|
||||||
'microphone',
|
'microphone',
|
||||||
] as const;
|
] as const;
|
||||||
export type AutoUnmuteCondition = (typeof MEDIA_UNMUTE_CONDITIONS)[number];
|
export type AutoUnmuteCondition = (typeof MEDIA_UNMUTE_CONDITIONS)[number];
|
||||||
|
|
||||||
export const MEDIA_MUTE_CONDITIONS = [
|
const MEDIA_MUTE_CONDITIONS = [
|
||||||
...MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
...MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
||||||
'microphone',
|
'microphone',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*******************************************************
|
||||||
|
* Non-hover styles should not interact with the pointer
|
||||||
|
*******************************************************/
|
||||||
|
|
||||||
|
:host(:not([data-style*='hover'])) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/***********************************
|
/***********************************
|
||||||
* Aligned divs: matching & opposing
|
* Aligned divs: matching & opposing
|
||||||
***********************************/
|
***********************************/
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
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,
|
|
||||||
AutoPlayCondition,
|
|
||||||
AutoUnmuteCondition,
|
|
||||||
} from '../../../../config/types.js';
|
|
||||||
import { FrigateCardMediaPlayer } from '../../../../types.js';
|
|
||||||
import { Timer } from '../../../timer.js';
|
|
||||||
|
|
||||||
declare module 'embla-carousel/components/Plugins' {
|
|
||||||
interface EmblaPluginsType {
|
|
||||||
autoMediaActions?: AutoMediaActionsType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type OptionsType = CreateOptionsType<{
|
|
||||||
playerSelector?: string;
|
|
||||||
|
|
||||||
autoPlayConditions?: readonly AutoPlayCondition[];
|
|
||||||
autoUnmuteConditions?: readonly AutoUnmuteCondition[];
|
|
||||||
autoPauseConditions?: readonly AutoPauseCondition[];
|
|
||||||
autoMuteConditions?: readonly AutoMuteCondition[];
|
|
||||||
|
|
||||||
microphoneManager?: ReadonlyMicrophoneManager;
|
|
||||||
microphoneMuteSeconds?: number;
|
|
||||||
}>;
|
|
||||||
export type AutoMediaActionsOptionsType = Partial<OptionsType>;
|
|
||||||
|
|
||||||
const defaultOptions: OptionsType = {
|
|
||||||
active: true,
|
|
||||||
breakpoints: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutoMediaActionsType = CreatePluginType<
|
|
||||||
LoosePluginType,
|
|
||||||
AutoMediaActionsOptionsType
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function AutoMediaActions(
|
|
||||||
userOptions: AutoMediaActionsOptionsType = {},
|
|
||||||
): AutoMediaActionsType {
|
|
||||||
let options: OptionsType;
|
|
||||||
let emblaApi: EmblaCarouselType;
|
|
||||||
let slides: HTMLElement[];
|
|
||||||
let viewportIntersecting: boolean | null = null;
|
|
||||||
const microphoneMuteTimer = new Timer();
|
|
||||||
|
|
||||||
const intersectionObserver: IntersectionObserver = new IntersectionObserver(
|
|
||||||
intersectionHandler,
|
|
||||||
);
|
|
||||||
|
|
||||||
function init(
|
|
||||||
emblaApiInstance: EmblaCarouselType,
|
|
||||||
optionsHandler: OptionsHandlerType,
|
|
||||||
): void {
|
|
||||||
emblaApi = emblaApiInstance;
|
|
||||||
|
|
||||||
const { mergeOptions, optionsAtMedia } = optionsHandler;
|
|
||||||
options = optionsAtMedia(mergeOptions(defaultOptions, userOptions));
|
|
||||||
|
|
||||||
slides = emblaApi.slideNodes();
|
|
||||||
|
|
||||||
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.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.autoPauseConditions?.includes('unselected')) {
|
|
||||||
emblaApi.on('select', pausePrevious);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.autoMuteConditions?.includes('unselected')) {
|
|
||||||
emblaApi.on('select', mutePrevious);
|
|
||||||
}
|
|
||||||
|
|
||||||
emblaApi.on('destroy', pause);
|
|
||||||
emblaApi.on('destroy', mute);
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', visibilityHandler);
|
|
||||||
intersectionObserver.observe(emblaApi.rootNode());
|
|
||||||
|
|
||||||
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.autoPlayConditions?.includes('selected')) {
|
|
||||||
emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', play);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.autoUnmuteConditions?.includes('selected')) {
|
|
||||||
emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', unmute);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.autoPauseConditions?.includes('unselected')) {
|
|
||||||
emblaApi.off('select', pausePrevious);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.autoMuteConditions?.includes('unselected')) {
|
|
||||||
emblaApi.off('select', mutePrevious);
|
|
||||||
}
|
|
||||||
|
|
||||||
emblaApi.off('destroy', pause);
|
|
||||||
emblaApi.off('destroy', mute);
|
|
||||||
|
|
||||||
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.autoPlayConditions?.includes('visible')) {
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
if (options.autoUnmuteConditions?.includes('visible')) {
|
|
||||||
unmute();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (options.autoPauseConditions?.includes('hidden')) {
|
|
||||||
pauseAll();
|
|
||||||
}
|
|
||||||
if (options.autoMuteConditions?.includes('hidden')) {
|
|
||||||
muteAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibilityHandler(): void {
|
|
||||||
actOnVisibilityChange(document.visibilityState === 'visible');
|
|
||||||
}
|
|
||||||
|
|
||||||
function intersectionHandler(entries: IntersectionObserverEntry[]): void {
|
|
||||||
const wasIntersecting = viewportIntersecting;
|
|
||||||
viewportIntersecting = entries.some((entry) => entry.isIntersecting);
|
|
||||||
|
|
||||||
if (wasIntersecting !== null && wasIntersecting !== viewportIntersecting) {
|
|
||||||
// If the live view is preloaded (i.e. in the background) we may need to
|
|
||||||
// take media actions, e.g. muting a live stream that is now running in
|
|
||||||
// the background, so we act even if the new state is hidden.
|
|
||||||
actOnVisibilityChange(viewportIntersecting);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null {
|
|
||||||
return options.playerSelector
|
|
||||||
? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function play(): void {
|
|
||||||
getPlayer(slides[emblaApi.selectedScrollSnap()])?.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
function pause(): void {
|
|
||||||
getPlayer(slides[emblaApi.selectedScrollSnap()])?.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
function pausePrevious(): void {
|
|
||||||
getPlayer(slides[emblaApi.previousScrollSnap()])?.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseAll(): void {
|
|
||||||
for (const slide of slides) {
|
|
||||||
getPlayer(slide)?.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function unmute(): void {
|
|
||||||
getPlayer(slides[emblaApi.selectedScrollSnap()])?.unmute();
|
|
||||||
}
|
|
||||||
|
|
||||||
function mute(): void {
|
|
||||||
getPlayer(slides[emblaApi.selectedScrollSnap()])?.mute();
|
|
||||||
}
|
|
||||||
|
|
||||||
function mutePrevious(): void {
|
|
||||||
getPlayer(slides[emblaApi.previousScrollSnap()])?.mute();
|
|
||||||
}
|
|
||||||
|
|
||||||
function muteAll(): void {
|
|
||||||
for (const slide of slides) {
|
|
||||||
getPlayer(slide)?.mute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const self: AutoMediaActionsType = {
|
|
||||||
name: 'autoMediaActions',
|
|
||||||
options: userOptions,
|
|
||||||
init,
|
|
||||||
destroy,
|
|
||||||
};
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
@@ -46,7 +46,9 @@ function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType {
|
|||||||
function mediaLoadedInfoHandler(ev: CustomEvent<MediaLoadedInfo>): void {
|
function mediaLoadedInfoHandler(ev: CustomEvent<MediaLoadedInfo>): void {
|
||||||
const eventPath = ev.composedPath();
|
const eventPath = ev.composedPath();
|
||||||
|
|
||||||
for (const [index, slide] of slides.entries()) {
|
// As an optimization, the most recent slide is the one at the end. That's
|
||||||
|
// where most users are spending time, so start the search there.
|
||||||
|
for (const [index, slide] of [...slides.entries()].reverse()) {
|
||||||
if (eventPath.includes(slide)) {
|
if (eventPath.includes(slide)) {
|
||||||
mediaLoadedInfo[index] = ev.detail;
|
mediaLoadedInfo[index] = ev.detail;
|
||||||
if (index !== emblaApi.selectedScrollSnap()) {
|
if (index !== emblaApi.selectedScrollSnap()) {
|
||||||
@@ -76,7 +78,8 @@ function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType {
|
|||||||
const savedMediaLoadedInfo: MediaLoadedInfo | undefined = mediaLoadedInfo[index];
|
const savedMediaLoadedInfo: MediaLoadedInfo | undefined = mediaLoadedInfo[index];
|
||||||
if (savedMediaLoadedInfo) {
|
if (savedMediaLoadedInfo) {
|
||||||
dispatchExistingMediaLoadedInfoAsEvent(
|
dispatchExistingMediaLoadedInfoAsEvent(
|
||||||
emblaApi.containerNode(),
|
// Event is redispatched from source element.
|
||||||
|
slides[index],
|
||||||
savedMediaLoadedInfo,
|
savedMediaLoadedInfo,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,573 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
MicrophoneManagerListenerChange,
|
||||||
|
ReadonlyMicrophoneManager,
|
||||||
|
} from '../../src/card-controller/microphone-manager';
|
||||||
|
import {
|
||||||
|
MediaActionsController,
|
||||||
|
MediaActionsControllerOptions,
|
||||||
|
} from '../../src/components-lib/media-actions-controller';
|
||||||
|
import { FrigateCardMediaPlayer } from '../../src/types';
|
||||||
|
import {
|
||||||
|
IntersectionObserverMock,
|
||||||
|
MutationObserverMock,
|
||||||
|
callIntersectionHandler,
|
||||||
|
callMutationHandler,
|
||||||
|
createParent,
|
||||||
|
flushPromises,
|
||||||
|
} from '../test-utils';
|
||||||
|
import { callVisibilityHandler, createTestSlideNodes } from '../utils/embla/test-utils';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
const getPlayer = (
|
||||||
|
element: HTMLElement,
|
||||||
|
selector: string,
|
||||||
|
): (HTMLElement & FrigateCardMediaPlayer) | null => {
|
||||||
|
return element.querySelector(selector);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPlayer = (): HTMLElement & FrigateCardMediaPlayer => {
|
||||||
|
const player = document.createElement('video');
|
||||||
|
|
||||||
|
player['play'] = vi.fn();
|
||||||
|
player['pause'] = vi.fn();
|
||||||
|
player['mute'] = vi.fn();
|
||||||
|
player['unmute'] = vi.fn();
|
||||||
|
player['isMuted'] = vi.fn().mockReturnValue(true);
|
||||||
|
player['seek'] = vi.fn();
|
||||||
|
player['getScreenshotURL'] = vi.fn();
|
||||||
|
player['setControls'] = vi.fn();
|
||||||
|
player['isPaused'] = vi.fn();
|
||||||
|
|
||||||
|
return player as unknown as HTMLElement & FrigateCardMediaPlayer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPlayerSlideNodes = (n = 10): HTMLElement[] => {
|
||||||
|
const divs = createTestSlideNodes({ n: n });
|
||||||
|
for (const div of divs) {
|
||||||
|
div.appendChild(createPlayer());
|
||||||
|
}
|
||||||
|
return divs;
|
||||||
|
};
|
||||||
|
|
||||||
|
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('MediaActionsController', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
|
||||||
|
vi.stubGlobal('MutationObserver', MutationObserverMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should initialize', () => {
|
||||||
|
it('should have root', async () => {
|
||||||
|
const controller = new MediaActionsController();
|
||||||
|
|
||||||
|
controller.initialize(createParent());
|
||||||
|
|
||||||
|
expect(controller.hasRoot()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing without options', async () => {
|
||||||
|
const controller = new MediaActionsController();
|
||||||
|
|
||||||
|
const children = createPlayerSlideNodes();
|
||||||
|
const parent = createParent({ children: children });
|
||||||
|
|
||||||
|
controller.initialize(parent);
|
||||||
|
await controller.select(0);
|
||||||
|
|
||||||
|
expect(getPlayer(children[0], 'video')?.play).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-initialize after mutation', async () => {
|
||||||
|
const controller = new MediaActionsController();
|
||||||
|
controller.setOptions({
|
||||||
|
playerSelector: 'video',
|
||||||
|
autoPlayConditions: ['selected' as const],
|
||||||
|
});
|
||||||
|
|
||||||
|
const parent = createParent({ children: createPlayerSlideNodes(1) });
|
||||||
|
controller.initialize(parent);
|
||||||
|
|
||||||
|
const newPlayer = createPlayer();
|
||||||
|
const newChild = document.createElement('div');
|
||||||
|
newChild.appendChild(newPlayer);
|
||||||
|
parent.append(newChild);
|
||||||
|
|
||||||
|
await callMutationHandler();
|
||||||
|
|
||||||
|
await controller.select(1);
|
||||||
|
|
||||||
|
expect(newPlayer.play).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should destroy', () => {
|
||||||
|
it('should do nothing after destroy', async () => {
|
||||||
|
const controller = new MediaActionsController();
|
||||||
|
controller.setOptions({
|
||||||
|
playerSelector: 'video',
|
||||||
|
autoPlayConditions: ['selected' as const],
|
||||||
|
});
|
||||||
|
|
||||||
|
const children = createPlayerSlideNodes();
|
||||||
|
const parent = createParent({ children: children });
|
||||||
|
controller.initialize(parent);
|
||||||
|
|
||||||
|
controller.destroy();
|
||||||
|
|
||||||
|
await controller.select(0);
|
||||||
|
|
||||||
|
expect(getPlayer(children[0], 'video')?.play).not.toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should respond to select', () => {
|
||||||
|
it.each([
|
||||||
|
['should play', { autoPlayConditions: ['selected' as const] }, 'play', true],
|
||||||
|
['should not play', { autoPlayConditions: [] }, 'play', false],
|
||||||
|
['should unmute', { autoUnmuteConditions: ['selected' as const] }, 'unmute', true],
|
||||||
|
['should not unmute', { autoUnmuteConditions: [] }, 'unmute', 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.select(0);
|
||||||
|
|
||||||
|
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should not re-select', async () => {
|
||||||
|
const controller = new MediaActionsController();
|
||||||
|
controller.setOptions({
|
||||||
|
autoPlayConditions: ['selected' as const],
|
||||||
|
playerSelector: 'video',
|
||||||
|
});
|
||||||
|
|
||||||
|
const children = createPlayerSlideNodes();
|
||||||
|
controller.initialize(createParent({ children: children }));
|
||||||
|
|
||||||
|
await controller.select(0);
|
||||||
|
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
await controller.select(0);
|
||||||
|
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unselect first', async () => {
|
||||||
|
const controller = new MediaActionsController();
|
||||||
|
controller.setOptions({
|
||||||
|
autoPauseConditions: ['unselected' as const],
|
||||||
|
autoMuteConditions: ['unselected' as const],
|
||||||
|
playerSelector: 'video',
|
||||||
|
});
|
||||||
|
|
||||||
|
const children = createPlayerSlideNodes();
|
||||||
|
controller.initialize(createParent({ children: children }));
|
||||||
|
|
||||||
|
await controller.select(0);
|
||||||
|
await controller.select(1);
|
||||||
|
|
||||||
|
expect(getPlayer(children[0], 'video')?.pause).toBeCalled();
|
||||||
|
expect(getPlayer(children[0], 'video')?.mute).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should respond to media loaded', () => {
|
||||||
|
it('should play', async () => {
|
||||||
|
const controller = new MediaActionsController();
|
||||||
|
controller.setOptions({
|
||||||
|
autoPlayConditions: ['selected' as const],
|
||||||
|
playerSelector: 'video',
|
||||||
|
});
|
||||||
|
|
||||||
|
const children = createPlayerSlideNodes();
|
||||||
|
controller.initialize(createParent({ children: children }));
|
||||||
|
|
||||||
|
await controller.select(0);
|
||||||
|
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
getPlayer(children[0], 'video')?.dispatchEvent(
|
||||||
|
new Event('frigate-card:media:loaded'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(getPlayer(children[0], 'video')?.play).toBeCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unmute', async () => {
|
||||||
|
const controller = new MediaActionsController();
|
||||||
|
controller.setOptions({
|
||||||
|
autoUnmuteConditions: ['selected' as const],
|
||||||
|
playerSelector: 'video',
|
||||||
|
});
|
||||||
|
|
||||||
|
const children = createPlayerSlideNodes();
|
||||||
|
controller.initialize(createParent({ children: children }));
|
||||||
|
|
||||||
|
await controller.select(0);
|
||||||
|
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')?.unmute).toBeCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should take no action on unselected media load', 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.select(0);
|
||||||
|
|
||||||
|
getPlayer(children[9], 'video')?.dispatchEvent(
|
||||||
|
new Event('frigate-card:media:loaded'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(getPlayer(children[9], 'video')?.play).not.toBeCalled();
|
||||||
|
expect(getPlayer(children[9], 'video')?.unmute).not.toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should respond to unselect', () => {
|
||||||
|
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.select(0);
|
||||||
|
await controller.unselect();
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
it.each([
|
||||||
|
['should play', { autoPlayConditions: ['visible' as const] }, 'play', true],
|
||||||
|
['should not play', { autoPlayConditions: [] }, 'play', false],
|
||||||
|
['should unmute', { autoUnmuteConditions: ['visible' as const] }, 'unmute', true],
|
||||||
|
['should not unmute', { autoUnmuteConditions: [] }, 'unmute', false],
|
||||||
|
])(
|
||||||
|
'%s',
|
||||||
|
async (
|
||||||
|
_: string,
|
||||||
|
options: Partial<MediaActionsControllerOptions>,
|
||||||
|
func: string,
|
||||||
|
called: boolean,
|
||||||
|
) => {
|
||||||
|
vi.spyOn(global.document, 'addEventListener');
|
||||||
|
|
||||||
|
const controller = new MediaActionsController();
|
||||||
|
controller.setOptions({
|
||||||
|
playerSelector: 'video',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
const children = createPlayerSlideNodes();
|
||||||
|
controller.initialize(createParent({ children: children }));
|
||||||
|
await controller.select(0);
|
||||||
|
|
||||||
|
// Not configured to take action on selection.
|
||||||
|
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled();
|
||||||
|
|
||||||
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
|
value: 'visible',
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
await callVisibilityHandler();
|
||||||
|
|
||||||
|
// Not configured to take action on selection.
|
||||||
|
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should respond to page being hiddne', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.spyOn(global.document, 'addEventListener');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['should pause', { autoPauseConditions: ['hidden' as const] }, 'pause', true],
|
||||||
|
['should not pause', { autoPauseConditions: [] }, 'pause', false],
|
||||||
|
['should mute', { autoMuteConditions: ['hidden' 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.select(0);
|
||||||
|
|
||||||
|
// Not configured to take action on selection.
|
||||||
|
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled();
|
||||||
|
|
||||||
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
|
value: 'hidden',
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
await callVisibilityHandler();
|
||||||
|
|
||||||
|
// Not configured to take action on selection.
|
||||||
|
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should respond to page intersecting with viewport', () => {
|
||||||
|
it.each([
|
||||||
|
['should play', { autoPlayConditions: ['visible' as const] }, 'play', true],
|
||||||
|
['should not play', { autoPlayConditions: [] }, 'play', false],
|
||||||
|
['should unmute', { autoUnmuteConditions: ['visible' as const] }, 'unmute', true],
|
||||||
|
['should not unmute', { autoUnmuteConditions: [] }, 'unmute', 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.select(0);
|
||||||
|
|
||||||
|
// Not configured to take action on selection.
|
||||||
|
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled();
|
||||||
|
|
||||||
|
// There's always a first call to an intersection observer handler. In
|
||||||
|
// this case the MediaActionsController ignores it.
|
||||||
|
await callIntersectionHandler(false);
|
||||||
|
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
// Not configured to take action on selection.
|
||||||
|
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should respond to page not intersecting with viewport', () => {
|
||||||
|
it.each([
|
||||||
|
['should play', { autoPlayConditions: ['visible' as const] }, 'play', true],
|
||||||
|
['should not play', { autoPlayConditions: [] }, 'play', false],
|
||||||
|
['should unmute', { autoUnmuteConditions: ['visible' as const] }, 'unmute', true],
|
||||||
|
['should not unmute', { autoUnmuteConditions: [] }, 'unmute', 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.select(0);
|
||||||
|
|
||||||
|
// Not configured to take action on selection.
|
||||||
|
expect(getPlayer(children[0], 'video')?.[func]).not.toBeCalled();
|
||||||
|
|
||||||
|
// There's always a first call to an intersection observer handler. In
|
||||||
|
// this case the MediaActionsController ignores it.
|
||||||
|
await callIntersectionHandler(false);
|
||||||
|
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
// Not configured to take action on selection.
|
||||||
|
expect(getPlayer(children[0], 'video')?.[func]).toBeCalledTimes(called ? 1 : 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should respond to microphone changes', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unmute when microphone unmuted', async () => {
|
||||||
|
const microphoneManager = mock<ReadonlyMicrophoneManager>();
|
||||||
|
const controller = new MediaActionsController();
|
||||||
|
|
||||||
|
controller.setOptions({
|
||||||
|
autoUnmuteConditions: ['microphone' as const],
|
||||||
|
playerSelector: 'video',
|
||||||
|
microphoneManager: microphoneManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
const children = createPlayerSlideNodes();
|
||||||
|
controller.initialize(createParent({ children: children }));
|
||||||
|
|
||||||
|
await controller.select(0);
|
||||||
|
|
||||||
|
callMicrophoneListener(microphoneManager, 'unmuted');
|
||||||
|
|
||||||
|
expect(getPlayer(children[0], 'video')?.unmute).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-mute after delay after microphone unmuted', async () => {
|
||||||
|
const microphoneManager = mock<ReadonlyMicrophoneManager>();
|
||||||
|
const controller = new MediaActionsController();
|
||||||
|
|
||||||
|
controller.setOptions({
|
||||||
|
autoMuteConditions: ['microphone' as const],
|
||||||
|
playerSelector: 'video',
|
||||||
|
microphoneManager: microphoneManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
const children = createPlayerSlideNodes();
|
||||||
|
controller.initialize(createParent({ children: children }));
|
||||||
|
|
||||||
|
await controller.select(0);
|
||||||
|
|
||||||
|
callMicrophoneListener(microphoneManager, 'muted');
|
||||||
|
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
|
||||||
|
expect(getPlayer(children[0], 'video')?.mute).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not re-mute after delay after microphone unmuted', async () => {
|
||||||
|
const microphoneManager = mock<ReadonlyMicrophoneManager>();
|
||||||
|
const controller = new MediaActionsController();
|
||||||
|
|
||||||
|
controller.setOptions({
|
||||||
|
autoMuteConditions: [],
|
||||||
|
playerSelector: 'video',
|
||||||
|
microphoneManager: microphoneManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
const children = createPlayerSlideNodes();
|
||||||
|
controller.initialize(createParent({ children: children }));
|
||||||
|
|
||||||
|
await controller.select(0);
|
||||||
|
|
||||||
|
callMicrophoneListener(microphoneManager, 'muted');
|
||||||
|
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
|
||||||
|
expect(getPlayer(children[0], 'video')?.mute).not.toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+27
-2
@@ -385,13 +385,20 @@ export const requestAnimationFrameMock = (callback: FrameRequestCallback) => {
|
|||||||
return 1;
|
return 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const callIntersectionHandler = (intersecting = true, n = 0): void => {
|
export const callIntersectionHandler = async (
|
||||||
|
intersecting = true,
|
||||||
|
n = 0,
|
||||||
|
): Promise<void> => {
|
||||||
const mockResult = vi.mocked(IntersectionObserver).mock.results[n];
|
const mockResult = vi.mocked(IntersectionObserver).mock.results[n];
|
||||||
if (mockResult.type !== 'return') {
|
if (mockResult.type !== 'return') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const observer = mockResult.value;
|
const observer = mockResult.value;
|
||||||
vi.mocked(IntersectionObserver).mock.calls[n][0](
|
await (
|
||||||
|
vi.mocked(IntersectionObserver).mock.calls[n][0] as
|
||||||
|
| IntersectionObserverCallback
|
||||||
|
| ((_: unknown) => Promise<void>)
|
||||||
|
)(
|
||||||
// Note this is a very incomplete / invalid IntersectionObserverEntry that
|
// Note this is a very incomplete / invalid IntersectionObserverEntry that
|
||||||
// just provides the bare basics current implementation uses.
|
// just provides the bare basics current implementation uses.
|
||||||
intersecting ? [{ isIntersecting: true } as IntersectionObserverEntry] : [],
|
intersecting ? [{ isIntersecting: true } as IntersectionObserverEntry] : [],
|
||||||
@@ -399,6 +406,24 @@ export const callIntersectionHandler = (intersecting = true, n = 0): void => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const callMutationHandler = async (n = 0): Promise<void> => {
|
||||||
|
const mockResult = vi.mocked(MutationObserver).mock.results[n];
|
||||||
|
if (mockResult.type !== 'return') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const observer = mockResult.value;
|
||||||
|
await (
|
||||||
|
vi.mocked(MutationObserver).mock.calls[n][0] as
|
||||||
|
| MutationCallback
|
||||||
|
| ((_: unknown) => Promise<void>)
|
||||||
|
)(
|
||||||
|
// Note this is a very incomplete / invalid IntersectionObserverEntry that
|
||||||
|
// just provides the bare basics current implementation uses.
|
||||||
|
[],
|
||||||
|
observer,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const createSlotHost = (options?: {
|
export const createSlotHost = (options?: {
|
||||||
slot?: HTMLSlotElement;
|
slot?: HTMLSlotElement;
|
||||||
children?: HTMLElement[];
|
children?: HTMLElement[];
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { CarouselController } from '../../../src/utils/embla/carousel-controller
|
|||||||
import AutoMediaLoadedInfo from '../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
|
import AutoMediaLoadedInfo from '../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
|
||||||
import {
|
import {
|
||||||
MutationObserverMock,
|
MutationObserverMock,
|
||||||
|
callMutationHandler,
|
||||||
createParent,
|
createParent,
|
||||||
createSlot,
|
createSlot,
|
||||||
createSlotHost,
|
createSlotHost,
|
||||||
} from '../../test-utils';
|
} from '../../test-utils';
|
||||||
import {
|
import {
|
||||||
callEmblaHandler,
|
callEmblaHandler,
|
||||||
callMutationHandler,
|
|
||||||
createEmblaApiInstance,
|
createEmblaApiInstance,
|
||||||
createTestSlideNodes,
|
createTestSlideNodes,
|
||||||
} from './test-utils';
|
} from './test-utils';
|
||||||
|
|||||||
@@ -1,455 +0,0 @@
|
|||||||
import { add } from 'date-fns';
|
|
||||||
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,
|
|
||||||
AutoMediaActionsOptionsType,
|
|
||||||
AutoMediaActionsType,
|
|
||||||
} from '../../../../../src/utils/embla/plugins/auto-media-actions/auto-media-actions';
|
|
||||||
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../../../../src/utils/media-info';
|
|
||||||
import {
|
|
||||||
IntersectionObserverMock,
|
|
||||||
callIntersectionHandler,
|
|
||||||
createMediaLoadedInfo,
|
|
||||||
createParent,
|
|
||||||
} from '../../../../test-utils';
|
|
||||||
import {
|
|
||||||
callEmblaHandler,
|
|
||||||
callVisibilityHandler,
|
|
||||||
createEmblaApiInstance,
|
|
||||||
createTestEmblaOptionHandler,
|
|
||||||
createTestSlideNodes,
|
|
||||||
} from '../../test-utils';
|
|
||||||
|
|
||||||
const getPlayer = (
|
|
||||||
element: HTMLElement,
|
|
||||||
selector: string,
|
|
||||||
): (HTMLElement & FrigateCardMediaPlayer) | null => {
|
|
||||||
return element.querySelector(selector);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createPlayerSlideNodes = (n = 10): HTMLElement[] => {
|
|
||||||
const slides = createTestSlideNodes({ n: n });
|
|
||||||
for (const slide of slides) {
|
|
||||||
const player = document.createElement('video');
|
|
||||||
|
|
||||||
player['play'] = vi.fn();
|
|
||||||
player['pause'] = vi.fn();
|
|
||||||
player['mute'] = vi.fn();
|
|
||||||
player['unmute'] = vi.fn();
|
|
||||||
player['isMuted'] = vi.fn().mockReturnValue(true);
|
|
||||||
player['seek'] = vi.fn();
|
|
||||||
player['getScreenshotURL'] = vi.fn();
|
|
||||||
player['setControls'] = vi.fn();
|
|
||||||
player['isPaused'] = vi.fn();
|
|
||||||
|
|
||||||
slide.appendChild(player);
|
|
||||||
}
|
|
||||||
return slides;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createPlugin = (options?: AutoMediaActionsOptionsType): AutoMediaActionsType => {
|
|
||||||
return AutoMediaActions({
|
|
||||||
playerSelector: 'video',
|
|
||||||
autoPlayConditions: MEDIA_ACTION_POSITIVE_CONDITIONS,
|
|
||||||
autoUnmuteConditions: MEDIA_UNMUTE_CONDITIONS,
|
|
||||||
autoPauseConditions: MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
|
||||||
autoMuteConditions: MEDIA_MUTE_CONDITIONS,
|
|
||||||
microphoneManager: mock<ReadonlyMicrophoneManager>(),
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should construct', () => {
|
|
||||||
const plugin = AutoMediaActions();
|
|
||||||
expect(plugin.name).toBe('autoMediaActions');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should init without any conditions', () => {
|
|
||||||
const microphoneManager = mock<ReadonlyMicrophoneManager>();
|
|
||||||
const plugin = createPlugin({
|
|
||||||
autoPlayConditions: [],
|
|
||||||
autoUnmuteConditions: [],
|
|
||||||
autoPauseConditions: [],
|
|
||||||
autoMuteConditions: [],
|
|
||||||
microphoneManager: microphoneManager,
|
|
||||||
});
|
|
||||||
const parent = createParent();
|
|
||||||
const addEventListener = vi.fn();
|
|
||||||
parent.addEventListener = addEventListener;
|
|
||||||
const emblaApi = createEmblaApiInstance({ containerNode: parent });
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
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', () => {
|
|
||||||
const plugin = createPlugin();
|
|
||||||
const parent = createParent();
|
|
||||||
const removeEventListener = vi.fn();
|
|
||||||
parent.removeEventListener = removeEventListener;
|
|
||||||
const emblaApi = createEmblaApiInstance({ containerNode: parent });
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
plugin.destroy();
|
|
||||||
|
|
||||||
expect(emblaApi.off).toBeCalledWith('destroy', expect.anything());
|
|
||||||
expect(emblaApi.off).toBeCalledWith('select', expect.anything());
|
|
||||||
expect(removeEventListener).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should destroy without any conditions', () => {
|
|
||||||
const microphoneManager = mock<ReadonlyMicrophoneManager>();
|
|
||||||
const plugin = createPlugin({
|
|
||||||
autoPlayConditions: [],
|
|
||||||
autoUnmuteConditions: [],
|
|
||||||
autoPauseConditions: [],
|
|
||||||
autoMuteConditions: [],
|
|
||||||
microphoneManager: microphoneManager,
|
|
||||||
});
|
|
||||||
const parent = createParent();
|
|
||||||
const removeEventListener = vi.fn();
|
|
||||||
parent.removeEventListener = removeEventListener;
|
|
||||||
const emblaApi = createEmblaApiInstance({ containerNode: parent });
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
plugin.destroy();
|
|
||||||
|
|
||||||
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', () => {
|
|
||||||
const plugin = createPlugin();
|
|
||||||
const children = createPlayerSlideNodes();
|
|
||||||
const parent = createParent({ children: children });
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
containerNode: parent,
|
|
||||||
selectedScrollSnap: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
callEmblaHandler(emblaApi, 'destroy');
|
|
||||||
|
|
||||||
expect(getPlayer(children[5], 'video')?.pause).toBeCalled();
|
|
||||||
expect(getPlayer(children[5], 'video')?.mute).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should play and unmute on media load', () => {
|
|
||||||
const plugin = createPlugin();
|
|
||||||
const children = createPlayerSlideNodes();
|
|
||||||
const parent = createParent({ children: children });
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
containerNode: parent,
|
|
||||||
selectedScrollSnap: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
dispatchExistingMediaLoadedInfoAsEvent(parent, createMediaLoadedInfo());
|
|
||||||
|
|
||||||
expect(getPlayer(children[5], 'video')?.play).toBeCalled();
|
|
||||||
expect(getPlayer(children[5], 'video')?.unmute).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not play or unmute on media load when player selecter not provided', () => {
|
|
||||||
const plugin = createPlugin({ playerSelector: undefined });
|
|
||||||
const children = createPlayerSlideNodes();
|
|
||||||
const parent = createParent({ children: children });
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
containerNode: parent,
|
|
||||||
selectedScrollSnap: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
dispatchExistingMediaLoadedInfoAsEvent(parent, createMediaLoadedInfo());
|
|
||||||
|
|
||||||
expect(getPlayer(children[5], 'video')?.play).not.toBeCalled();
|
|
||||||
expect(getPlayer(children[5], 'video')?.unmute).not.toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pause and mute previous on select', () => {
|
|
||||||
const plugin = createPlugin();
|
|
||||||
const children = createPlayerSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
previousScrollSnap: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
callEmblaHandler(emblaApi, 'select');
|
|
||||||
|
|
||||||
expect(getPlayer(children[4], 'video')?.pause).toBeCalled();
|
|
||||||
expect(getPlayer(children[4], 'video')?.mute).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should play and unmute on visibility change to visible', () => {
|
|
||||||
vi.spyOn(global.document, 'addEventListener');
|
|
||||||
|
|
||||||
const plugin = createPlugin();
|
|
||||||
const children = createPlayerSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
selectedScrollSnap: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
|
||||||
value: 'visible',
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
callVisibilityHandler();
|
|
||||||
|
|
||||||
expect(getPlayer(children[5], 'video')?.play).toBeCalled();
|
|
||||||
expect(getPlayer(children[5], 'video')?.unmute).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pause and unmute on visibility change to hidden', () => {
|
|
||||||
vi.spyOn(global.document, 'addEventListener');
|
|
||||||
|
|
||||||
const plugin = createPlugin();
|
|
||||||
const children = createPlayerSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
});
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
|
||||||
value: 'hidden',
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
callVisibilityHandler();
|
|
||||||
|
|
||||||
for (const child of children) {
|
|
||||||
expect(getPlayer(child, 'video')?.pause).toBeCalled();
|
|
||||||
expect(getPlayer(child, 'video')?.mute).toBeCalled();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('should take no action on visibility change without callbacks', () => {
|
|
||||||
it.each([['visible' as const], ['hidden' as const]])(
|
|
||||||
'%s',
|
|
||||||
(visibilityState: 'visible' | 'hidden') => {
|
|
||||||
vi.spyOn(global.document, 'addEventListener');
|
|
||||||
|
|
||||||
const plugin = AutoMediaActions();
|
|
||||||
const children = createPlayerSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
});
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
|
||||||
value: visibilityState,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
callVisibilityHandler();
|
|
||||||
|
|
||||||
for (const child of children) {
|
|
||||||
expect(getPlayer(child, 'video')?.play).not.toBeCalled();
|
|
||||||
expect(getPlayer(child, 'video')?.pause).not.toBeCalled();
|
|
||||||
expect(getPlayer(child, 'video')?.mute).not.toBeCalled();
|
|
||||||
expect(getPlayer(child, 'video')?.unmute).not.toBeCalled();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should play and unmute on intersection', () => {
|
|
||||||
const plugin = createPlugin();
|
|
||||||
const children = createPlayerSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
selectedScrollSnap: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
// Intersection observer always calls handler on creation (and we ignore
|
|
||||||
// these first calls).
|
|
||||||
callIntersectionHandler(false);
|
|
||||||
callIntersectionHandler(true);
|
|
||||||
|
|
||||||
expect(getPlayer(children[5], 'video')?.play).toBeCalled();
|
|
||||||
expect(getPlayer(children[5], 'video')?.unmute).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pause and mute on intersection', () => {
|
|
||||||
vi.spyOn(global.document, 'addEventListener');
|
|
||||||
|
|
||||||
const plugin = createPlugin();
|
|
||||||
const children = createPlayerSlideNodes();
|
|
||||||
const emblaApi = createEmblaApiInstance({
|
|
||||||
slideNodes: children,
|
|
||||||
});
|
|
||||||
|
|
||||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
|
||||||
|
|
||||||
// Intersection observer always calls handler on creation (and we ignore
|
|
||||||
// these first calls).
|
|
||||||
callIntersectionHandler(true);
|
|
||||||
callIntersectionHandler(false);
|
|
||||||
|
|
||||||
for (const child of children) {
|
|
||||||
expect(getPlayer(child, 'video')?.pause).toBeCalled();
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -16,6 +16,7 @@ export const createTestEmblaOptionHandler = (): OptionsHandlerType => ({
|
|||||||
optionsAtMedia: <Type extends LooseOptionsType>(options: Type): Type => {
|
optionsAtMedia: <Type extends LooseOptionsType>(options: Type): Type => {
|
||||||
return options;
|
return options;
|
||||||
},
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
optionsMediaQueries: (_optionsList: LooseOptionsType[]): MediaQueryList[] => [],
|
optionsMediaQueries: (_optionsList: LooseOptionsType[]): MediaQueryList[] => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,29 +35,15 @@ export const callEmblaHandler = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const callVisibilityHandler = (): void => {
|
export const callVisibilityHandler = async (): Promise<void> => {
|
||||||
const mock = vi.mocked(global.document.addEventListener).mock;
|
const mock = vi.mocked(global.document.addEventListener).mock;
|
||||||
for (const [evt, cb] of mock.calls) {
|
for (const [evt, cb] of mock.calls) {
|
||||||
if (evt === 'visibilitychange' && typeof cb === 'function') {
|
if (evt === 'visibilitychange' && typeof cb === 'function') {
|
||||||
cb(new Event('foo'));
|
await (cb as EventListener | ((_: unknown) => Promise<void>))(new Event('foo'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const callMutationHandler = (n = 0): void => {
|
|
||||||
const mockResult = vi.mocked(MutationObserver).mock.results[n];
|
|
||||||
if (mockResult.type !== 'return') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const observer = mockResult.value;
|
|
||||||
vi.mocked(MutationObserver).mock.calls[n][0](
|
|
||||||
// Note this is a very incomplete / invalid IntersectionObserverEntry that
|
|
||||||
// just provides the bare basics current implementation uses.
|
|
||||||
[],
|
|
||||||
observer,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const callResizeHandler = (
|
export const callResizeHandler = (
|
||||||
entries: {
|
entries: {
|
||||||
target: HTMLElement;
|
target: HTMLElement;
|
||||||
@@ -110,5 +97,6 @@ export const createEmblaApiInstance = (options?: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createTestSlideNodes = (options?: { n?: number }): HTMLElement[] => {
|
export const createTestSlideNodes = (options?: { n?: number }): HTMLElement[] => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
return [...Array(options?.n ?? 10).keys()].map((_) => document.createElement('div'));
|
return [...Array(options?.n ?? 10).keys()].map((_) => document.createElement('div'));
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-9
@@ -12,15 +12,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
|
|||||||
'camera-manager/motioneye/icon.ts',
|
'camera-manager/motioneye/icon.ts',
|
||||||
'camera-manager/utils/*.ts',
|
'camera-manager/utils/*.ts',
|
||||||
'card-controller/**/*.ts',
|
'card-controller/**/*.ts',
|
||||||
'components-lib/cached-value-controller.ts',
|
'components-lib/**/!(timeline-source.ts)',
|
||||||
'components-lib/key-assigner-controller.ts',
|
|
||||||
'components-lib/live/**/*.ts',
|
|
||||||
'components-lib/media-filter-controller.ts',
|
|
||||||
'components-lib/menu-button-controller.ts',
|
|
||||||
'components-lib/menu-controller.ts',
|
|
||||||
'components-lib/status-bar-controller.ts',
|
|
||||||
'components-lib/ptz/*.ts',
|
|
||||||
'components-lib/zoom/*.ts',
|
|
||||||
'config/**/*.ts',
|
'config/**/*.ts',
|
||||||
'const.ts',
|
'const.ts',
|
||||||
'types.ts',
|
'types.ts',
|
||||||
|
|||||||
Reference in New Issue
Block a user