From 1a0e219c61ac91f74858d2c86d06030f6172b828 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 7 Sep 2024 15:04:23 -0700 Subject: [PATCH 1/2] Move automatic media actions out of carousels --- rollup.config.js | 2 +- .../media-actions-controller.ts | 269 ++++++++ src/components/live/live.ts | 79 ++- src/components/viewer.ts | 64 +- src/config/types.ts | 6 +- src/scss/menu.scss | 8 + .../auto-media-actions/auto-media-actions.ts | 247 -------- .../auto-media-loaded-info.ts | 7 +- .../media-actions-controller.test.ts | 573 ++++++++++++++++++ tests/test-utils.ts | 29 +- tests/utils/embla/carousel-controller.test.ts | 2 +- .../auto-media-actions.test.ts | 455 -------------- tests/utils/embla/test-utils.ts | 20 +- ...get-state-obj.ts => get-state-obj.test.ts} | 0 vite.config.ts | 10 +- 15 files changed, 994 insertions(+), 777 deletions(-) create mode 100644 src/components-lib/media-actions-controller.ts delete mode 100644 src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts create mode 100644 tests/components-lib/media-actions-controller.test.ts delete mode 100644 tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts rename tests/utils/{get-state-obj.ts => get-state-obj.test.ts} (100%) diff --git a/rollup.config.js b/rollup.config.js index be40c43a..faa6e37a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -51,7 +51,7 @@ const plugins = [ typescript({ sourceMap: dev, inlineSources: dev, - exclude: ['tests/**/*.test.ts'], + exclude: ['dist/**', 'tests/**/*.test.ts'], }), json({ exclude: 'package.json' }), replace({ diff --git a/src/components-lib/media-actions-controller.ts b/src/components-lib/media-actions-controller.ts new file mode 100644 index 00000000..af998eb9 --- /dev/null +++ b/src/components-lib/media-actions-controller.ts @@ -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 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 { + 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 { + await this._pauseSelectedIfConfigured('unselected'); + await this._muteSelectedIfConfigured('unselected'); + this._microphoneMuteTimer.stop(); + this._selected = null; + } + + public async unselectAll(): Promise { + this._selected = null; + await this._pauseAllIfConfigured('unselected'); + await this._muteAllIfConfigured('unselected'); + } + + protected async _playSelectedIfConfigured( + condition: AutoPlayCondition, + ): Promise { + if ( + this._selected !== null && + this._options?.autoPlayConditions?.includes(condition) + ) { + await this._play(this._selected); + } + } + protected async _play(index: number): Promise { + await this._children[index]?.play(); + } + protected async _unmuteSelectedIfConfigured( + condition: AutoUnmuteCondition, + ): Promise { + if ( + this._selected !== null && + this._options?.autoUnmuteConditions?.includes(condition) + ) { + await this._unmute(this._selected); + } + } + protected async _unmute(index: number): Promise { + await this._children[index]?.unmute(); + } + + protected async _pauseAllIfConfigured(condition: AutoPauseCondition): Promise { + if (this._options?.autoPauseConditions?.includes(condition)) { + for (const index of this._children.keys()) { + await this._pause(index); + } + } + } + protected async _pauseSelectedIfConfigured( + condition: AutoPauseCondition, + ): Promise { + if ( + this._selected !== null && + this._options?.autoPauseConditions?.includes(condition) + ) { + await this._pause(this._selected); + } + } + protected async _pause(index: number): Promise { + await this._children[index]?.pause(); + } + + protected async _muteAllIfConfigured(condition: AutoMuteCondition): Promise { + if (this._options?.autoMuteConditions?.includes(condition)) { + for (const index of this._children.keys()) { + await this._mute(index); + } + } + } + protected async _muteSelectedIfConfigured( + condition: AutoMuteCondition, + ): Promise { + if ( + this._selected !== null && + this._options?.autoMuteConditions?.includes(condition) + ) { + await this._mute(this._selected); + } + } + protected async _mute(index: number): Promise { + 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 => { + 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(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 { + 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 => { + await this._changeVisibility(document.visibilityState === 'visible'); + }; + + protected _changeVisibility = async (visible: boolean): Promise => { + 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 => { + 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'); + }, + ); + } + }; +} diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 1bc3834b..cb6abbf2 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -47,7 +47,6 @@ import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { aspectRatioToString, contentsChanged } from '../../utils/basic.js'; import { CarouselSelected } from '../../utils/embla/carousel-controller.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 AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js'; import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js'; @@ -62,6 +61,7 @@ import '../next-prev-control.js'; import '../ptz.js'; import { FrigateCardPTZ } from '../ptz.js'; import '../surround.js'; +import { MediaActionsController } from '../../components-lib/media-actions-controller.js'; 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. protected _cameraToSlide: Record = {}; protected _refPTZControl: Ref = createRef(); + protected _refCarousel: Ref = createRef(); + + protected _mediaActionsController = new MediaActionsController(); @state() 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 { return ( this.overriddenLiveConfig?.transition_effect ?? @@ -312,19 +335,12 @@ export class FrigateCardLiveCarousel extends LitElement { return Math.max(0, Array.from(cameraIDs).indexOf(view.camera)); } - 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(), - AutoMediaActions({ + protected willUpdate(changedProps: PropertyValues): void { + if ( + changedProps.has('microphoneManager') || + changedProps.has('overriddenLiveConfig') + ) { + this._mediaActionsController.setOptions({ playerSelector: FRIGATE_CARD_LIVE_PROVIDER, ...(this.overriddenLiveConfig?.auto_play && { autoPlayConditions: this.overriddenLiveConfig.auto_play, @@ -344,7 +360,33 @@ export class FrigateCardLiveCarousel extends LitElement { microphoneMuteSeconds: 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(), ]; } @@ -541,15 +583,10 @@ export class FrigateCardLiveCarousel extends LitElement { // Notes on the below: // - guard() is used to avoid reseting the carousel unless the // 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` = createRef(); - /** - * The updated lifecycle callback for this element. - * @param changedProperties The properties that were changed in this render. - */ updated(changedProperties: PropertyValues): void { super.updated(changedProperties); @@ -209,6 +207,22 @@ export class FrigateCardViewerCarousel extends LitElement { 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(), - 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(), ]; } @@ -356,6 +355,24 @@ export class FrigateCardViewerCarousel extends LitElement { } 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')) { const view = this.viewManagerEpoch?.manager.getView(); const newMedia = view?.queryResults?.getResults(this.viewFilterCameraID) ?? null; @@ -368,6 +385,12 @@ export class FrigateCardViewerCarousel extends LitElement { this._media = newMedia; this._selected = newSelected; } + + if (!newMedia) { + this._mediaActionsController.unselectAll(); + } else { + this._mediaActionsController.select(newSelected); + } } } @@ -405,6 +428,7 @@ export class FrigateCardViewerCarousel extends LitElement { return html` ; -export type AutoMediaActionsOptionsType = Partial; - -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; -} diff --git a/src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.ts b/src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.ts index bc104ed7..db65a86c 100644 --- a/src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.ts +++ b/src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.ts @@ -46,7 +46,9 @@ function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType { function mediaLoadedInfoHandler(ev: CustomEvent): void { 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)) { mediaLoadedInfo[index] = ev.detail; if (index !== emblaApi.selectedScrollSnap()) { @@ -76,7 +78,8 @@ function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType { const savedMediaLoadedInfo: MediaLoadedInfo | undefined = mediaLoadedInfo[index]; if (savedMediaLoadedInfo) { dispatchExistingMediaLoadedInfoAsEvent( - emblaApi.containerNode(), + // Event is redispatched from source element. + slides[index], savedMediaLoadedInfo, ); } diff --git a/tests/components-lib/media-actions-controller.test.ts b/tests/components-lib/media-actions-controller.test.ts new file mode 100644 index 00000000..df551761 --- /dev/null +++ b/tests/components-lib/media-actions-controller.test.ts @@ -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, + 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, + 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, + 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, + 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, + 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, + 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, + 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(); + 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(); + 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(); + 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(); + }); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 31d705af..cbe59207 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -385,13 +385,20 @@ export const requestAnimationFrameMock = (callback: FrameRequestCallback) => { return 1; }; -export const callIntersectionHandler = (intersecting = true, n = 0): void => { +export const callIntersectionHandler = async ( + intersecting = true, + n = 0, +): Promise => { const mockResult = vi.mocked(IntersectionObserver).mock.results[n]; if (mockResult.type !== 'return') { return; } const observer = mockResult.value; - vi.mocked(IntersectionObserver).mock.calls[n][0]( + await ( + vi.mocked(IntersectionObserver).mock.calls[n][0] as + | IntersectionObserverCallback + | ((_: unknown) => Promise) + )( // Note this is a very incomplete / invalid IntersectionObserverEntry that // just provides the bare basics current implementation uses. intersecting ? [{ isIntersecting: true } as IntersectionObserverEntry] : [], @@ -399,6 +406,24 @@ export const callIntersectionHandler = (intersecting = true, n = 0): void => { ); }; +export const callMutationHandler = async (n = 0): Promise => { + 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) + )( + // Note this is a very incomplete / invalid IntersectionObserverEntry that + // just provides the bare basics current implementation uses. + [], + observer, + ); +}; + export const createSlotHost = (options?: { slot?: HTMLSlotElement; children?: HTMLElement[]; diff --git a/tests/utils/embla/carousel-controller.test.ts b/tests/utils/embla/carousel-controller.test.ts index 8eef025f..8b3352bf 100644 --- a/tests/utils/embla/carousel-controller.test.ts +++ b/tests/utils/embla/carousel-controller.test.ts @@ -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 { MutationObserverMock, + callMutationHandler, createParent, createSlot, createSlotHost, } from '../../test-utils'; import { callEmblaHandler, - callMutationHandler, createEmblaApiInstance, createTestSlideNodes, } from './test-utils'; diff --git a/tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts b/tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts deleted file mode 100644 index d508772e..00000000 --- a/tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts +++ /dev/null @@ -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(), - ...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(); - 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(); - 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(); - const plugin = createPlugin({ microphoneManager: microphoneManager }); - const children = createPlayerSlideNodes(); - const emblaApi = createEmblaApiInstance({ - slideNodes: children, - selectedScrollSnap: 5, - }); - - plugin.init(emblaApi, createTestEmblaOptionHandler()); - - callMicrophoneListener(microphoneManager, 'unmuted'); - - expect(getPlayer(children[5], 'video')?.unmute).toBeCalled(); - }); - - it('should not unmute on microphone unmute when not configured', () => { - const microphoneManager = mock(); - const plugin = createPlugin({ - microphoneManager: microphoneManager, - autoUnmuteConditions: [], - }); - const children = createPlayerSlideNodes(); - const emblaApi = createEmblaApiInstance({ - slideNodes: children, - selectedScrollSnap: 5, - }); - - plugin.init(emblaApi, createTestEmblaOptionHandler()); - - callMicrophoneListener(microphoneManager, 'unmuted'); - - expect(getPlayer(children[5], 'video')?.unmute).not.toBeCalled(); - }); - - it('should mute on microphone mute after default delay', () => { - const start = new Date('2024-01-28T14:42'); - - const microphoneManager = mock(); - const plugin = createPlugin({ microphoneManager: microphoneManager }); - const children = createPlayerSlideNodes(); - const emblaApi = createEmblaApiInstance({ - slideNodes: children, - selectedScrollSnap: 5, - }); - - plugin.init(emblaApi, createTestEmblaOptionHandler()); - - vi.setSystemTime(start); - callMicrophoneListener(microphoneManager, 'muted'); - - expect(getPlayer(children[5], 'video')?.mute).not.toBeCalled(); - vi.setSystemTime(add(start, { seconds: 60 })); - vi.runOnlyPendingTimers(); - expect(getPlayer(children[5], 'video')?.mute).toBeCalled(); - }); - - it('should mute on microphone mute after configured delay', () => { - const start = new Date('2024-01-28T14:42'); - - const microphoneManager = mock(); - const plugin = createPlugin({ - microphoneManager: microphoneManager, - microphoneMuteSeconds: 30, - }); - const children = createPlayerSlideNodes(); - const emblaApi = createEmblaApiInstance({ - slideNodes: children, - selectedScrollSnap: 5, - }); - - plugin.init(emblaApi, createTestEmblaOptionHandler()); - - vi.setSystemTime(start); - callMicrophoneListener(microphoneManager, 'muted'); - - expect(getPlayer(children[5], 'video')?.mute).not.toBeCalled(); - vi.setSystemTime(add(start, { seconds: 30 })); - vi.runOnlyPendingTimers(); - expect(getPlayer(children[5], 'video')?.mute).toBeCalled(); - }); - - it('should not mute on microphone mute after delay when not configured', () => { - const start = new Date('2024-01-28T14:42'); - - const microphoneManager = mock(); - const plugin = createPlugin({ - microphoneManager: microphoneManager, - autoMuteConditions: [], - }); - const children = createPlayerSlideNodes(); - const emblaApi = createEmblaApiInstance({ - slideNodes: children, - selectedScrollSnap: 5, - }); - - plugin.init(emblaApi, createTestEmblaOptionHandler()); - - vi.setSystemTime(start); - callMicrophoneListener(microphoneManager, 'muted'); - - expect(getPlayer(children[5], 'video')?.mute).not.toBeCalled(); - vi.setSystemTime(add(start, { seconds: 60 })); - vi.runOnlyPendingTimers(); - expect(getPlayer(children[5], 'video')?.mute).not.toBeCalled(); - }); - }); -}); diff --git a/tests/utils/embla/test-utils.ts b/tests/utils/embla/test-utils.ts index 4aad2d0f..66779fe6 100644 --- a/tests/utils/embla/test-utils.ts +++ b/tests/utils/embla/test-utils.ts @@ -16,6 +16,7 @@ export const createTestEmblaOptionHandler = (): OptionsHandlerType => ({ optionsAtMedia: (options: Type): Type => { return options; }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars optionsMediaQueries: (_optionsList: LooseOptionsType[]): MediaQueryList[] => [], }); @@ -34,29 +35,15 @@ export const callEmblaHandler = ( } }; -export const callVisibilityHandler = (): void => { +export const callVisibilityHandler = async (): Promise => { const mock = vi.mocked(global.document.addEventListener).mock; for (const [evt, cb] of mock.calls) { if (evt === 'visibilitychange' && typeof cb === 'function') { - cb(new Event('foo')); + await (cb as EventListener | ((_: unknown) => Promise))(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 = ( entries: { target: HTMLElement; @@ -110,5 +97,6 @@ export const createEmblaApiInstance = (options?: { }; 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')); }; diff --git a/tests/utils/get-state-obj.ts b/tests/utils/get-state-obj.test.ts similarity index 100% rename from tests/utils/get-state-obj.ts rename to tests/utils/get-state-obj.test.ts diff --git a/vite.config.ts b/vite.config.ts index 0369ec03..bbc3c8e5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,15 +12,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'camera-manager/motioneye/icon.ts', 'camera-manager/utils/*.ts', 'card-controller/**/*.ts', - 'components-lib/cached-value-controller.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', + 'components-lib/**/!(timeline-source.ts)', 'config/**/*.ts', 'const.ts', 'types.ts', From e0dda84295fa7e764a24bb1dd138762d973ba9da Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 7 Sep 2024 15:07:57 -0700 Subject: [PATCH 2/2] Small formatting fix. --- src/components/live/live.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/live/live.ts b/src/components/live/live.ts index cb6abbf2..df5f2144 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -300,7 +300,7 @@ export class FrigateCardLiveCarousel extends LitElement { public connectedCallback(): void { super.connectedCallback(); - // Request update in order to reinitialize the media action controller. + // Request update in order to reinitialize the media action controller. this.requestUpdate(); }