From f67620397f163f3e3caa9d203be557e9e186e81c Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 7 Apr 2024 20:21:09 -0700 Subject: [PATCH 1/2] Set timeline to now not to most recent event. --- src/components-lib/live/live-controller.ts | 224 +++++++++++ src/components-lib/media-grid-controller.ts | 4 +- src/components/live/live.ts | 151 ++----- src/components/message.ts | 30 ++ src/components/surround.ts | 90 +---- src/components/timeline-core.ts | 9 +- src/components/views.ts | 10 +- src/types.ts | 1 - .../auto-media-loaded-info.ts | 6 +- src/utils/media-info.ts | 12 +- src/utils/media-to-view.ts | 10 +- src/view/view.ts | 30 ++ .../card-element-manager.test.ts | 29 +- tests/card-controller/view-manager.test.ts | 2 +- .../live/live-controller.test.ts | 377 ++++++++++++++++++ .../media-filter-controller.test.ts | 66 ++- tests/components-lib/menu-controller.test.ts | 52 +-- tests/test-utils.ts | 42 +- .../auto-media-actions.test.ts | 2 +- .../embla/plugins/auto-size/auto-size.test.ts | 2 +- tests/utils/embla/test-utils.ts | 14 - vite.config.ts | 1 + 22 files changed, 828 insertions(+), 336 deletions(-) create mode 100644 src/components-lib/live/live-controller.ts create mode 100644 tests/components-lib/live/live-controller.test.ts diff --git a/src/components-lib/live/live-controller.ts b/src/components-lib/live/live-controller.ts new file mode 100644 index 00000000..3080ea78 --- /dev/null +++ b/src/components-lib/live/live-controller.ts @@ -0,0 +1,224 @@ +import sub from 'date-fns/sub'; +import { LitElement, ReactiveController } from 'lit'; +import { ViewContext } from 'view'; +import { CameraManager } from '../../camera-manager/manager.js'; +import { FrigateCardMessageEventTarget } from '../../components/message.js'; +import { CardWideConfig, LiveConfig } from '../../config/types.js'; +import { MediaLoadedInfo, Message } from '../../types.js'; +import { + FrigateCardMediaLoadedEventTarget, + dispatchExistingMediaLoadedInfoAsEvent, +} from '../../utils/media-info.js'; +import { + changeViewToRecentEventsForCameraAndDependents, + changeViewToRecentRecordingForCameraAndDependents, +} from '../../utils/media-to-view.js'; +import { FrigateCardViewChangeEventTarget, View } from '../../view/view.js'; + +interface LiveViewContext { + // A cameraID override (used for dependencies/substreams to force a different + // camera to be live rather than the camera selected in the view). + overrides?: Map; + + ptzVisible?: boolean; + fetchThumbnails?: boolean; +} + +declare module 'view' { + interface ViewContext { + live?: LiveViewContext; + } +} + +interface LastMediaLoadedInfo { + mediaLoadedInfo: MediaLoadedInfo; + source: EventTarget; +} + +type LiveControllerHost = LitElement & + FrigateCardMediaLoadedEventTarget & + FrigateCardMessageEventTarget & + FrigateCardViewChangeEventTarget; + +export class LiveController implements ReactiveController { + protected _host: LiveControllerHost; + + // Whether or not the live view is currently in the background (i.e. preloaded + // but not visible). + protected _inBackground = false; + + // Intersection handler is used to detect when the live view flips between + // foreground and background (in preload mode). + protected _intersectionObserver: IntersectionObserver; + + // Whether or not to allow updates. + protected _messageReceived = false; + + // MediaLoadedInfo object and target from the underlying live media. In the + // case of pre-loading these may be propagated later (from the original + // source). + protected _lastMediaLoadedInfo: LastMediaLoadedInfo | null = null; + + protected _renderEpoch = 0; + + constructor(host: LiveControllerHost) { + this._host = host; + this._intersectionObserver = new IntersectionObserver( + this._intersectionHandler.bind(this), + ); + } + + public shouldUpdate(): boolean { + // Don't process updates if it's in the background and a message was + // received (otherwise an error message thrown by the background live + // component may continually be re-spammed hitting performance). + return !this._inBackground || !this._messageReceived; + } + + public hostConnected(): void { + this._intersectionObserver.observe(this._host); + + this._host.addEventListener('frigate-card:media:loaded', this._handleMediaLoaded); + this._host.addEventListener('frigate-card:message', this._handleMessage); + this._host.addEventListener('frigate-card:view:change', this._handleViewChange); + } + + public hostDisconnected(): void { + this._intersectionObserver.disconnect(); + + this._host.removeEventListener('frigate-card:media:loaded', this._handleMediaLoaded); + this._host.removeEventListener('frigate-card:message', this._handleMessage); + this._host.removeEventListener('frigate-card:view:change', this._handleViewChange); + } + + public clearMessageReceived(): void { + this._messageReceived = false; + } + + public isInBackground(): boolean { + return this._inBackground; + } + + public getRenderEpoch(): number { + return this._renderEpoch; + } + + protected _handleMessage = (ev: CustomEvent): void => { + this._messageReceived = true; + + if (this._inBackground) { + ev.stopPropagation(); + + // Force the whole DOM to re-render next time. + this._renderEpoch++; + } + }; + + protected _handleMediaLoaded = (ev: CustomEvent): void => { + this._lastMediaLoadedInfo = { + source: ev.composedPath()[0], + mediaLoadedInfo: ev.detail, + }; + + if (this._inBackground) { + ev.stopPropagation(); + } + }; + + protected _handleViewChange = (ev: CustomEvent): void => { + if (this._inBackground) { + ev.stopPropagation(); + } + }; + + protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { + const wasInBackground = this._inBackground; + this._inBackground = !entries.some((entry) => entry.isIntersecting); + + if (!this._inBackground && !this._messageReceived && this._lastMediaLoadedInfo) { + // If this isn't being rendered in the background, the last render did not + // generate a message and there's a saved MediaInfo, dispatch it upwards. + dispatchExistingMediaLoadedInfoAsEvent( + // Specifically dispatch the event "where it came from", as otherwise + // the intermediate layers (e.g. media-carousel which controls the title + // popups) will not re-receive the events. + this._lastMediaLoadedInfo.source, + this._lastMediaLoadedInfo.mediaLoadedInfo, + ); + } + + if (wasInBackground !== this._inBackground) { + this._host.requestUpdate(); + } + } + + /** + * Fetch thumbnail media when a target is not already specified in the view + * (e.g. first time live is visited). + */ + public async fetchMediaInBackgroundIfNecessary( + view: View, + cameraManager: CameraManager, + cardWideConfig: CardWideConfig, + overriddenLiveConfig: LiveConfig, + ): Promise { + if ( + this._inBackground || + // Only fetch media if there isn't any already. + view.query || + overriddenLiveConfig.controls.thumbnails.mode === 'none' || + view.context?.live?.fetchThumbnails === false + ) { + return; + } + + const mediaType = overriddenLiveConfig.controls.thumbnails.media_type; + const now = new Date(); + const viewContext: ViewContext = { + // Force the window to start at the most recent time, not + // necessarily when the most recent event/recording was: + // https://github.com/dermotduffy/frigate-hass-card/issues/1301 + timeline: { + window: { + start: sub(now, { + seconds: overriddenLiveConfig.controls.timeline.window_seconds, + }), + end: now, + }, + }, + }; + + /* istanbul ignore else: the else path cannot be reached -- @preserve */ + if (mediaType === 'events') { + await changeViewToRecentEventsForCameraAndDependents( + this._host, + cameraManager, + cardWideConfig, + view, + { + allCameras: view.isGrid(), + targetView: view.view, + eventsMediaType: overriddenLiveConfig.controls.thumbnails.events_media_type, + select: 'latest', + // Force the window to start at the most recent time, not + // necessarily when the most recent event was: + // https://github.com/dermotduffy/frigate-hass-card/issues/1301 + viewContext: viewContext, + }, + ); + } else if (mediaType === 'recordings') { + await changeViewToRecentRecordingForCameraAndDependents( + this._host, + cameraManager, + cardWideConfig, + view, + { + allCameras: view.isGrid(), + targetView: view.view, + select: 'latest', + viewContext: viewContext, + }, + ); + } + } +} diff --git a/src/components-lib/media-grid-controller.ts b/src/components-lib/media-grid-controller.ts index 89a02b61..353ef331 100644 --- a/src/components-lib/media-grid-controller.ts +++ b/src/components-lib/media-grid-controller.ts @@ -9,7 +9,7 @@ import { setOrRemoveAttribute, } from '../utils/basic'; import { - FrigateMediaLoadedEventTarget, + FrigateCardMediaLoadedEventTarget, dispatchExistingMediaLoadedInfoAsEvent, dispatchMediaUnloadedEvent, } from '../utils/media-info'; @@ -23,7 +23,7 @@ const MEDIA_GRID_DEFAULT_IDEAL_CELL_WIDTH = 600; const MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR = 2.0; type GridID = string; -type MediaGridChild = HTMLElement & FrigateMediaLoadedEventTarget; +type MediaGridChild = HTMLElement & FrigateCardMediaLoadedEventTarget; type MediaGridContents = Map; export interface MediaGridSelected { diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 4eacabfc..fd478d29 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -19,6 +19,7 @@ import { getOverriddenConfig, } from '../../card-controller/conditions-manager.js'; import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js'; +import { LiveController } from '../../components-lib/live/live-controller.js'; import { MediaGridSelected } from '../../components-lib/media-grid-controller.js'; import { CameraConfig, @@ -34,12 +35,7 @@ import basicBlockStyle from '../../scss/basic-block.scss'; import liveCarouselStyle from '../../scss/live-carousel.scss'; import liveGridStyle from '../../scss/live-grid.scss'; import liveProviderStyle from '../../scss/live-provider.scss'; -import { - ExtendedHomeAssistant, - FrigateCardMediaPlayer, - MediaLoadedInfo, - Message, -} from '../../types.js'; +import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { aspectRatioToString, contentsChanged } from '../../utils/basic.js'; import { CarouselSelected } from '../../utils/embla/carousel-controller.js'; @@ -48,10 +44,7 @@ import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/a 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'; -import { - dispatchExistingMediaLoadedInfoAsEvent, - dispatchMediaUnloadedEvent, -} from '../../utils/media-info.js'; +import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; import { playMediaMutingIfNecessary } from '../../utils/media.js'; import { dispatchViewContextChangeEvent, View } from '../../view/view.js'; @@ -67,24 +60,6 @@ import { getDefaultTitleConfigForView, } from '../title-control.js'; -interface LiveViewContext { - // A cameraID override (used for dependencies/substreams to force a different - // camera to be live rather than the camera selected in the view). - overrides?: Map; - ptzVisible?: boolean; -} - -declare module 'view' { - interface ViewContext { - live?: LiveViewContext; - } -} - -interface LastMediaLoadedInfo { - mediaLoadedInfo: MediaLoadedInfo; - source: EventTarget; -} - const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider'; @customElement('frigate-card-live') @@ -101,7 +76,7 @@ export class FrigateCardLive extends LitElement { @property({ attribute: false }) public nonOverriddenLiveConfig?: LiveConfig; - @property({ attribute: false }) + @property({ attribute: false }>) public overriddenLiveConfig?: LiveConfig; @property({ attribute: false, hasChanged: contentsChanged }) @@ -119,76 +94,32 @@ export class FrigateCardLive extends LitElement { @property({ attribute: false }) public triggeredCameraIDs?: Set; - // Whether or not the live view is currently in the background (i.e. preloaded - // but not visible) - @state() - protected _inBackground?: boolean = false; - - // Intersection handler is used to detect when the live view flips between - // foreground and background (in preload mode). - protected _intersectionObserver: IntersectionObserver; - - // MediaLoadedInfo object and target from the underlying live object. In the - // case of pre-loading these may be propagated later (from the original - // source). - protected _lastMediaLoadedInfo: LastMediaLoadedInfo | null = null; - - protected _messageReceivedPostRender = false; - protected _renderKey = 0; - - constructor() { - super(); - this._intersectionObserver = new IntersectionObserver( - this._intersectionHandler.bind(this), - ); - } - - /** - * Called when the live view intersects with the viewport. - * @param entries The IntersectionObserverEntry entries (should be only 1). - */ - protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { - this._inBackground = !entries.some((entry) => entry.isIntersecting); - - if ( - !this._inBackground && - !this._messageReceivedPostRender && - this._lastMediaLoadedInfo - ) { - // If this isn't being rendered in the background, the last render did not - // generate a message and there's a saved MediaInfo, dispatch it upwards. - dispatchExistingMediaLoadedInfoAsEvent( - // Specifically dispatch the event "where it came from", as otherwise - // the intermediate layers (e.g. media-carousel which controls the title - // popups) will not re-receive the events. - this._lastMediaLoadedInfo.source, - this._lastMediaLoadedInfo.mediaLoadedInfo, - ); - } - - // Trigger a re-render which may be necessary if the prior render resulted - // in a message. - if (this._messageReceivedPostRender && !this._inBackground) { - this.requestUpdate(); - } - } + protected _controller = new LiveController(this); // eslint-disable-next-line @typescript-eslint/no-unused-vars protected shouldUpdate(_changedProps: PropertyValues): boolean { - // Don't process updates if it's in the background and a message was - // received (otherwise an error message thrown by the background live - // component may continually be re-spammed hitting performance). - return !this._inBackground || !this._messageReceivedPostRender; + return this._controller.shouldUpdate(); } - connectedCallback(): void { - this._intersectionObserver.observe(this); - super.connectedCallback(); - } + protected willUpdate(changedProperties: PropertyValues): void { + if ( + ['view', 'cameraManager', 'cardWideConfig', 'overriddenLiveConfig'].some((prop) => + changedProperties.has(prop), + ) && + this.view && + this.cameraManager && + this.cardWideConfig && + this.overriddenLiveConfig + ) { + this._controller.fetchMediaInBackgroundIfNecessary( + this.view, + this.cameraManager, + this.cardWideConfig, + this.overriddenLiveConfig, + ); + } - disconnectedCallback(): void { - super.disconnectedCallback(); - this._intersectionObserver.disconnect(); + this._controller.clearMessageReceived(); } protected render(): TemplateResult | void { @@ -210,49 +141,25 @@ export class FrigateCardLive extends LitElement { // - The entire returned template is keyed to allow for the whole template // to be re-rendered in certain circumstances (specifically: if a message // is received when the card is in the background). - const result = html`${keyed( - this._renderKey, + return html`${keyed( + this._controller.getRenderEpoch(), html` ) => { - this._renderKey++; - this._messageReceivedPostRender = true; - if (this._inBackground) { - ev.stopPropagation(); - } - }} - @frigate-card:media:loaded=${(ev: CustomEvent) => { - this._lastMediaLoadedInfo = { - source: ev.composedPath()[0], - mediaLoadedInfo: ev.detail, - }; - if (this._inBackground) { - ev.stopPropagation(); - } - }} - @frigate-card:view:change=${(ev: CustomEvent) => { - if (this._inBackground) { - ev.stopPropagation(); - } - }} > `, )}`; - - this._messageReceivedPostRender = false; - return result; } static get styles(): CSSResultGroup { @@ -525,7 +432,7 @@ export class FrigateCardLiveCarousel extends LitElement { }) // Don't yet fetch thumbnails (they will be fetched when the carousel // settles). - .mergeInContext({ thumbnails: { fetch: false } }) + .mergeInContext({ live: { fetchThumbnails: false } }) .dispatchChangeEvent(this); } } @@ -673,7 +580,7 @@ export class FrigateCardLiveCarousel extends LitElement { @frigate-card:carousel:select=${this._setViewHandler.bind(this)} @frigate-card:carousel:settle=${() => { // Fetch the thumbnails after the carousel has settled. - dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } }); + dispatchViewContextChangeEvent(this, { live: { fetchThumbnails: true } }); }} @frigate-card:media:loaded=${() => { if (this._refTitleControl.value) { diff --git a/src/components/message.ts b/src/components/message.ts index fd0106b8..0f704279 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -200,6 +200,36 @@ export function dispatchFrigateCardErrorEvent( } } +// Facilitates correct typing of event handlers. +export interface FrigateCardMessageEventTarget extends EventTarget { + addEventListener( + event: 'frigate-card:message', + listener: ( + this: FrigateCardMessageEventTarget, + ev: CustomEvent, + ) => void, + options?: AddEventListenerOptions | boolean, + ): void; + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean, + ): void; + removeEventListener( + event: 'frigate-card:message', + listener: ( + this: FrigateCardMessageEventTarget, + ev: CustomEvent, + ) => void, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; +} + declare global { interface HTMLElementTagNameMap { 'frigate-card-progress-indicator': FrigateCardProgressIndicator; diff --git a/src/components/surround.ts b/src/components/surround.ts index cab592b3..4ff26aa8 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -14,31 +14,12 @@ import { ThumbnailsControlConfig, } from '../config/types.js'; import basicBlockStyle from '../scss/basic-block.scss'; -import { - ClipsOrSnapshotsOrAll, - EventsOrRecordings, - ExtendedHomeAssistant, -} from '../types.js'; +import { ExtendedHomeAssistant } from '../types.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; -import { - changeViewToRecentEventsForCameraAndDependents, - changeViewToRecentRecordingForCameraAndDependents, -} from '../utils/media-to-view'; import { View } from '../view/view.js'; import './surround-basic.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; -interface ThumbnailViewContext { - // Whether or not to fetch thumbnails. - fetch?: boolean; -} - -declare module 'view' { - interface ViewContext { - thumbnails?: ThumbnailViewContext; - } -} - @customElement('frigate-card-surround') export class FrigateCardSurround extends LitElement { @property({ attribute: false }) @@ -53,13 +34,6 @@ export class FrigateCardSurround extends LitElement { @property({ attribute: false, hasChanged: contentsChanged }) public timelineConfig?: MiniTimelineControlConfig; - // If fetchMedia is not specified, no fetching is done. - @property({ attribute: false, hasChanged: contentsChanged }) - public fetchMediaType?: EventsOrRecordings; - - @property({ attribute: false, hasChanged: contentsChanged }) - public fetchEventsMediaType?: ClipsOrSnapshotsOrAll; - @property({ attribute: false }) public cameraManager?: CameraManager; @@ -68,56 +42,6 @@ export class FrigateCardSurround extends LitElement { protected _cameraIDsForTimeline?: Set; - /** - * Fetch thumbnail media when a target is not specified in the view (e.g. for - * the live view). - * @param param Task parameters. - * @returns - */ - protected async _fetchMedia(): Promise { - if ( - !this.cameraManager || - !this.cardWideConfig || - !this.fetchMediaType || - !this.fetchEventsMediaType || - !this.hass || - !this.view || - this.view.query || - !this.thumbnailConfig || - this.thumbnailConfig.mode === 'none' || - !(this.view.context?.thumbnails?.fetch ?? true) - ) { - return; - } - - if (this.fetchMediaType === 'events') { - await changeViewToRecentEventsForCameraAndDependents( - this, - this.cameraManager, - this.cardWideConfig, - this.view, - { - allCameras: this.view.isGrid(), - targetView: this.view.view, - eventsMediaType: this.fetchEventsMediaType, - select: 'latest', - }, - ); - } else if (this.fetchMediaType === 'recordings') { - await changeViewToRecentRecordingForCameraAndDependents( - this, - this.cameraManager, - this.cardWideConfig, - this.view, - { - allCameras: this.view.isGrid(), - targetView: this.view.view, - select: 'latest', - }, - ); - } - } - /** * Determine if a drawer is being used. * @returns `true` if a drawer is used, `false` otherwise. @@ -128,9 +52,6 @@ export class FrigateCardSurround extends LitElement { ); } - /** - * Called before each update. - */ protected willUpdate(changedProperties: PropertyValues): void { if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') { import('./timeline-core.js'); @@ -147,15 +68,6 @@ export class FrigateCardSurround extends LitElement { ) { this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined; } - - // Once the component will certainly update, dispatch a media request. Only - // do so if properties relevant to the request have changed (as per their - // hasChanged). - if ( - ['view', 'fetch', 'browseMediaParams'].some((prop) => changedProperties.has(prop)) - ) { - this._fetchMedia(); - } } protected _getCameraIDsForTimeline(): Set | null { diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index c46bc8f3..dbd3eca2 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -651,7 +651,14 @@ export class FrigateCardTimelineCore extends LitElement { }): Promise { this._removeTargetBar(); - if (!this._timeline || !this.view) { + if ( + !this._timeline || + !this.view || + // When in mini mode, something else is in charge of the primary media + // population (e.g. the live view), in this case only act when the user + // themselves are interacting with the timeline. + (this.mini && !properties.byUser) + ) { return; } diff --git a/src/components/views.ts b/src/components/views.ts index 352547b9..d1c3a2ef 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -163,12 +163,6 @@ export class FrigateCardViews extends LitElement { class="${classMap(overallClasses)}" .hass=${this.hass} .view=${this.view} - .fetchMediaType=${this.view?.is('live') - ? this.overriddenConfig.live.controls.thumbnails.media_type - : undefined} - .fetchEventsMediaType=${this.view?.is('live') - ? this.overriddenConfig.live.controls.thumbnails.events_media_type - : undefined} .thumbnailConfig=${!this.hide ? thumbnailConfig : undefined} .timelineConfig=${!this.hide ? miniTimelineConfig : undefined} .cameraManager=${this.cameraManager} @@ -254,6 +248,10 @@ export class FrigateCardViews extends LitElement { : `` } `; + + // .fetchMediaType=${this.view?.is('live') ? this.overriddenConfig.live.controls.thumbnails.media_type : undefined} + // .fetchEventsMediaType=${this.view?.is('live') ? this.overriddenConfig.live.controls.thumbnails.events_media_type : undefined} + } static get styles(): CSSResultGroup { diff --git a/src/types.ts b/src/types.ts index 40afdd63..06258cf6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,7 +8,6 @@ import { z } from 'zod'; export type ClipsOrSnapshots = 'clips' | 'snapshots'; export type ClipsOrSnapshotsOrAll = 'clips' | 'snapshots' | 'all'; -export type EventsOrRecordings = 'events' | 'recordings'; export class FrigateCardError extends Error { context?: unknown; 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 402b9d9e..bc104ed7 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 @@ -1,11 +1,11 @@ import { EmblaCarouselType } from 'embla-carousel'; +import { LooseOptionsType } from 'embla-carousel/components/Options'; import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; import { MediaLoadedInfo } from '../../../../types'; import { + FrigateCardMediaLoadedEventTarget, dispatchExistingMediaLoadedInfoAsEvent, - FrigateMediaLoadedEventTarget, } from '../../../media-info'; -import { LooseOptionsType } from 'embla-carousel/components/Options'; declare module 'embla-carousel/components/Plugins' { interface EmblaPluginsType { @@ -17,7 +17,7 @@ type AutoMediaLoadedInfoType = CreatePluginType, ) => void, options?: AddEventListenerOptions | boolean, ): void; addEventListener( event: 'frigate-card:media:unloaded', - listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void, + listener: (this: FrigateCardMediaLoadedEventTarget, ev: CustomEvent) => void, options?: AddEventListenerOptions | boolean, ): void; addEventListener( @@ -135,14 +135,14 @@ export interface FrigateMediaLoadedEventTarget extends EventTarget { removeEventListener( event: 'frigate-card:media:loaded', listener: ( - this: FrigateMediaLoadedEventTarget, + this: FrigateCardMediaLoadedEventTarget, ev: CustomEvent, ) => void, options?: boolean | EventListenerOptions, ): void; removeEventListener( event: 'frigate-card:media:unloaded', - listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void, + listener: (this: FrigateCardMediaLoadedEventTarget, ev: CustomEvent) => void, options?: boolean | EventListenerOptions, ): void; removeEventListener( diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 4fbe8fc8..6ca25497 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -28,6 +28,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async ( targetView?: FrigateCardView; select?: ResultSelectType; useCache?: boolean; + viewContext?: ViewContext; }, ): Promise => { const capabilitySearch: CapabilitySearchOptions = @@ -61,6 +62,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async ( targetView: options?.targetView, select: options?.select, useCache: options?.useCache, + viewContext: options?.viewContext }, ) )?.dispatchChangeEvent(element); @@ -103,6 +105,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( targetView?: FrigateCardView; select?: ResultSelectType; useCache?: boolean; + viewContext?: ViewContext; }, ): Promise => { const cameraIDs = options?.allCameras @@ -131,6 +134,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( targetView: options?.targetView, select: options?.select, useCache: options?.useCache, + viewContext: options?.viewContext, }, ) )?.dispatchChangeEvent(element); @@ -159,6 +163,7 @@ export const executeMediaQueryForView = async ( targetTime?: Date; select?: ResultSelectType; useCache?: boolean; + viewContext?: ViewContext; }, ): Promise => { const queries = query.getQueries(); @@ -195,7 +200,8 @@ export const executeMediaQueryForView = async ( view: options?.targetView, camera: cameraID, }) - .mergeInContext(viewerContext); + .mergeInContext(options?.viewContext) + .mergeInContext(viewerContext) }; export const executeMediaQueryForViewWithErrorDispatching = async ( @@ -209,6 +215,7 @@ export const executeMediaQueryForViewWithErrorDispatching = async ( targetTime?: Date; select?: ResultSelectType; useCache?: boolean; + viewContext?: ViewContext; }, ): Promise => { try { @@ -218,6 +225,7 @@ export const executeMediaQueryForViewWithErrorDispatching = async ( targetTime: options?.targetTime, select: options?.select, useCache: options?.useCache, + viewContext: options?.viewContext, }); } catch (e: unknown) { errorToConsole(e as Error); diff --git a/src/view/view.ts b/src/view/view.ts index 6d395a9b..03dbfeec 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -269,6 +269,36 @@ export class View { } } +// Facilitates correct typing of event handlers. +export interface FrigateCardViewChangeEventTarget extends EventTarget { + addEventListener( + event: 'frigate-card:view:change', + listener: ( + this: FrigateCardViewChangeEventTarget, + ev: CustomEvent, + ) => void, + options?: AddEventListenerOptions | boolean, + ): void; + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean, + ): void; + removeEventListener( + event: 'frigate-card:view:change', + listener: ( + this: FrigateCardViewChangeEventTarget, + ev: CustomEvent, + ) => void, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; +} + /** * Dispatch an event to change the view context. * @param target The EventTarget to send the event from. diff --git a/tests/card-controller/card-element-manager.test.ts b/tests/card-controller/card-element-manager.test.ts index a695b637..c378ab10 100644 --- a/tests/card-controller/card-element-manager.test.ts +++ b/tests/card-controller/card-element-manager.test.ts @@ -1,16 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - CardElementManager, - CardHTMLElement, -} from '../../src/card-controller/card-element-manager'; -import { createCardAPI } from '../test-utils'; import { mock } from 'vitest-mock-extended'; - -const createElement = (): CardHTMLElement => { - const element = document.createElement('div') as unknown as CardHTMLElement; - element.requestUpdate = vi.fn(); - return element as CardHTMLElement; -}; +import { CardElementManager } from '../../src/card-controller/card-element-manager'; +import { createCardAPI, createLitElement } from '../test-utils'; // @vitest-environment jsdom describe('CardElementManager', () => { @@ -20,7 +11,7 @@ describe('CardElementManager', () => { }); it('should get element', () => { - const element = createElement(); + const element = createLitElement(); const manager = new CardElementManager( createCardAPI(), element, @@ -35,7 +26,7 @@ describe('CardElementManager', () => { const callback = vi.fn(); const manager = new CardElementManager( createCardAPI(), - createElement(), + createLitElement(), callback, () => undefined, ); @@ -49,7 +40,7 @@ describe('CardElementManager', () => { const callback = vi.fn(); const manager = new CardElementManager( createCardAPI(), - createElement(), + createLitElement(), () => undefined, callback, ); @@ -60,7 +51,7 @@ describe('CardElementManager', () => { }); it('should update', () => { - const element = createElement(); + const element = createLitElement(); const manager = new CardElementManager( createCardAPI(), element, @@ -73,7 +64,7 @@ describe('CardElementManager', () => { }); it('should get hasUpdated', () => { - const element = createElement(); + const element = createLitElement(); element.hasUpdated = true; const manager = new CardElementManager( createCardAPI(), @@ -86,7 +77,7 @@ describe('CardElementManager', () => { }); it('should get height', () => { - const element = createElement(); + const element = createLitElement(); element.getBoundingClientRect = vi.fn().mockReturnValue({ width: 200, height: 800, @@ -106,7 +97,7 @@ describe('CardElementManager', () => { const windowAddEventListener = vi.spyOn(global.window, 'addEventListener'); const addEventListener = vi.fn(); - const element = createElement(); + const element = createLitElement(); element.addEventListener = addEventListener; const api = createCardAPI(); @@ -147,7 +138,7 @@ describe('CardElementManager', () => { it('should disconnect', () => { const windowRemoveEventListener = vi.spyOn(global.window, 'removeEventListener'); - const element = createElement(); + const element = createLitElement(); element.setAttribute('panel', ''); const removeEventListener = vi.fn(); diff --git a/tests/card-controller/view-manager.test.ts b/tests/card-controller/view-manager.test.ts index 9fd7b5d4..f1395938 100644 --- a/tests/card-controller/view-manager.test.ts +++ b/tests/card-controller/view-manager.test.ts @@ -78,7 +78,7 @@ describe('ViewManager.setView', () => { it('should set view with new context', () => { const api = createCardAPI(); const manager = new ViewManager(api); - const context = { thumbnails: { fetch: false } }; + const context = { live: { fetchThumbnails: false } }; // Setting context with no existing view does nothing. manager.setViewWithNewContext(context); diff --git a/tests/components-lib/live/live-controller.test.ts b/tests/components-lib/live/live-controller.test.ts new file mode 100644 index 00000000..b3dea6c9 --- /dev/null +++ b/tests/components-lib/live/live-controller.test.ts @@ -0,0 +1,377 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LiveController } from '../../../src/components-lib/live/live-controller'; +import { dispatchMessageEvent } from '../../../src/components/message'; +import { + changeViewToRecentEventsForCameraAndDependents, + changeViewToRecentRecordingForCameraAndDependents, +} from '../../../src/utils/media-to-view'; +import { EventMediaQueries } from '../../../src/view/media-queries'; +import { + IntersectionObserverMock, + callIntersectionHandler, + createCameraManager, + createConfig, + createLitElement, + createMediaLoadedInfo, + createMediaLoadedInfoEvent, + createParent, + createView, + createViewChangeEvent, +} from '../../test-utils'; + +vi.mock('../../../src/utils/media-to-view'); + +// @vitest-environment jsdom +describe('LiveController', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); + }); + + it('should be constructable', () => { + const controller = new LiveController(createLitElement()); + expect(controller).toBeTruthy(); + }); + + it('should connect and disconnect', () => { + const host = createLitElement(); + const parent = createParent({ children: [host] }); + const eventListener = vi.fn(); + parent.addEventListener('frigate-card:message', eventListener); + + const controller = new LiveController(host); + + controller.hostConnected(); + + callIntersectionHandler(false); + dispatchMessageEvent(host, 'message', 'info'); + + expect(eventListener).toBeCalledTimes(0); + + controller.hostDisconnected(); + dispatchMessageEvent(host, 'message', 'info'); + + expect(eventListener).toBeCalledTimes(1); + }); + + describe('should handle background / foreground', () => { + it('should start in the foreground', () => { + const controller = new LiveController(createLitElement()); + expect(controller.isInBackground()).toBeFalsy(); + }); + + it('should handle changing to background', () => { + const element = createLitElement(); + const controller = new LiveController(element); + expect(controller.isInBackground()).toBeFalsy(); + expect(element.requestUpdate).toBeCalledTimes(0); + + callIntersectionHandler(true); + expect(controller.isInBackground()).toBeFalsy(); + expect(element.requestUpdate).toBeCalledTimes(0); + + callIntersectionHandler(false); + expect(controller.isInBackground()).toBeTruthy(); + expect(element.requestUpdate).toBeCalledTimes(1); + }); + + it('should dispatch media loaded on background change', () => { + const host = createLitElement(); + const parent = createParent({ children: [host] }); + const eventListener = vi.fn(); + parent.addEventListener('frigate-card:media:loaded', eventListener); + + const controller = new LiveController(host); + const mediaLoadedInfo = createMediaLoadedInfo(); + + controller.hostConnected(); + + callIntersectionHandler(false); + expect(controller.isInBackground()).toBeTruthy(); + + host.dispatchEvent(createMediaLoadedInfoEvent(mediaLoadedInfo)); + expect(eventListener).toBeCalledTimes(0); + + callIntersectionHandler(true); + expect(eventListener).toBeCalledTimes(1); + expect(eventListener).toHaveBeenLastCalledWith( + expect.objectContaining({ + detail: mediaLoadedInfo, + }), + ); + + host.dispatchEvent(createMediaLoadedInfoEvent(mediaLoadedInfo)); + expect(eventListener).toBeCalledTimes(2); + + callIntersectionHandler(false); + dispatchMessageEvent(host, 'message', 'info'); + callIntersectionHandler(true); + expect(eventListener).toBeCalledTimes(2); + + controller.clearMessageReceived(); + callIntersectionHandler(true); + expect(eventListener).toBeCalledTimes(3); + }); + }); + + describe('should correctly allow updates', () => { + it('when not in background', () => { + const controller = new LiveController(createLitElement()); + expect(controller.shouldUpdate()).toBeTruthy(); + }); + + it('when in background without message', () => { + const host = createLitElement(); + const controller = new LiveController(host); + controller.hostConnected(); + + callIntersectionHandler(false); + + expect(controller.shouldUpdate()).toBeTruthy(); + }); + + it('when in background with message', () => { + const host = createLitElement(); + const controller = new LiveController(host); + controller.hostConnected(); + + callIntersectionHandler(false); + dispatchMessageEvent(host, 'message', 'info'); + + expect(controller.shouldUpdate()).toBeFalsy(); + }); + }); + + it('should handle message', () => { + const host = createLitElement(); + const parent = createParent({ children: [host] }); + const eventListener = vi.fn(); + parent.addEventListener('frigate-card:message', eventListener); + + const controller = new LiveController(host); + controller.hostConnected(); + + callIntersectionHandler(false); + expect(controller.isInBackground()).toBeTruthy(); + + const firstRenderEpoch = controller.getRenderEpoch(); + + dispatchMessageEvent(host, 'message', 'info'); + expect(eventListener).toBeCalledTimes(0); + + const secondRenderEpoch = controller.getRenderEpoch(); + expect(secondRenderEpoch).not.toBe(firstRenderEpoch); + + callIntersectionHandler(true); + + dispatchMessageEvent(host, 'message', 'info'); + expect(eventListener).toBeCalledTimes(1); + expect(controller.getRenderEpoch()).toBe(secondRenderEpoch); + }); + + it('should handle view change', () => { + const host = createLitElement(); + const parent = createParent({ children: [host] }); + const eventListener = vi.fn(); + parent.addEventListener('frigate-card:view:change', eventListener); + + const controller = new LiveController(host); + controller.hostConnected(); + const view = createView(); + + callIntersectionHandler(false); + expect(controller.isInBackground()).toBeTruthy(); + host.dispatchEvent(createViewChangeEvent(view)); + + expect(eventListener).toBeCalledTimes(0); + + callIntersectionHandler(true); + expect(controller.isInBackground()).toBeFalsy(); + + host.dispatchEvent(createViewChangeEvent(view)); + + expect(eventListener).toBeCalledTimes(1); + }); + + describe('should fetch media', () => { + it('when in background', async () => { + const controller = new LiveController(createLitElement()); + + callIntersectionHandler(false); + + await controller.fetchMediaInBackgroundIfNecessary( + createView(), + createCameraManager(), + {}, + createConfig().live, + ); + + expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled(); + expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled(); + }); + + it('when has existing query', async () => { + const controller = new LiveController(createLitElement()); + + await controller.fetchMediaInBackgroundIfNecessary( + createView({ query: new EventMediaQueries() }), + createCameraManager(), + {}, + createConfig().live, + ); + + expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled(); + expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled(); + }); + + it('when has no thumbnails', async () => { + const controller = new LiveController(createLitElement()); + + await controller.fetchMediaInBackgroundIfNecessary( + createView(), + createCameraManager(), + {}, + createConfig({ + live: { + controls: { + thumbnails: { + mode: 'none', + }, + }, + }, + }).live, + ); + + expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled(); + expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled(); + }); + + it('when fetch disabled in context', async () => { + const controller = new LiveController(createLitElement()); + + await controller.fetchMediaInBackgroundIfNecessary( + createView({ + context: { + live: { + fetchThumbnails: false, + }, + }, + }), + createCameraManager(), + {}, + createConfig().live, + ); + + expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled(); + expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled(); + }); + + describe('with fetch', () => { + const now = new Date('2024-04-07T19:43'); + beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('events', async () => { + const host = createLitElement(); + const controller = new LiveController(host); + const view = createView(); + const cameraManager = createCameraManager(); + const cardWideConfig = {}; + + await controller.fetchMediaInBackgroundIfNecessary( + view, + cameraManager, + cardWideConfig, + createConfig({ + live: { + controls: { + thumbnails: { + media_type: 'events', + events_media_type: 'all', + }, + timeline: { + window_seconds: 3600, + }, + }, + }, + }).live, + ); + + expect(changeViewToRecentEventsForCameraAndDependents).toBeCalledWith( + host, + cameraManager, + cardWideConfig, + view, + expect.objectContaining({ + allCameras: false, + targetView: 'live', + eventsMediaType: 'all', + select: 'latest', + viewContext: expect.objectContaining({ + timeline: { + window: { + start: new Date('2024-04-07T18:43'), + end: now, + }, + }, + }), + }), + ); + expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled(); + }); + + it('recordings', async () => { + const host = createLitElement(); + const controller = new LiveController(host); + const view = createView(); + const cameraManager = createCameraManager(); + const cardWideConfig = {}; + + await controller.fetchMediaInBackgroundIfNecessary( + view, + cameraManager, + cardWideConfig, + createConfig({ + live: { + controls: { + thumbnails: { + media_type: 'recordings', + }, + timeline: { + window_seconds: 3600, + }, + }, + }, + }).live, + ); + + expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled(); + expect(changeViewToRecentRecordingForCameraAndDependents).toBeCalledWith( + host, + cameraManager, + cardWideConfig, + view, + expect.objectContaining({ + allCameras: false, + targetView: 'live', + select: 'latest', + viewContext: expect.objectContaining({ + timeline: { + window: { + start: new Date('2024-04-07T18:43'), + end: now, + }, + }, + }), + }), + ); + }); + }); + }); +}); diff --git a/tests/components-lib/media-filter-controller.test.ts b/tests/components-lib/media-filter-controller.test.ts index e88052e3..623b4fbc 100644 --- a/tests/components-lib/media-filter-controller.test.ts +++ b/tests/components-lib/media-filter-controller.test.ts @@ -1,7 +1,6 @@ import endOfDay from 'date-fns/endOfDay'; import startOfDay from 'date-fns/startOfDay'; import sub from 'date-fns/sub'; -import { LitElement } from 'lit'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { Capabilities } from '../../src/camera-manager/capabilities'; import { CameraManagerStore } from '../../src/camera-manager/store'; @@ -23,6 +22,7 @@ import { createCameraConfig, createCameraManager, createCapabilities, + createLitElement, createPerformanceConfig, createStore, createView, @@ -30,12 +30,6 @@ import { vi.mock('../../src/utils/media-to-view'); -const createHost = (): LitElement => { - const host = document.createElement('div') as unknown as LitElement; - host.requestUpdate = vi.fn(); - return host; -}; - const createCameraStore = (options?: { capabilities: Capabilities; }): CameraManagerStore => { @@ -64,7 +58,7 @@ describe('MediaFilterController', () => { describe('should have correct default options', () => { it('media type', () => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); expect(controller.getMediaTypeOptions()).toEqual([ { value: MediaFilterMediaType.Clips, @@ -82,7 +76,7 @@ describe('MediaFilterController', () => { }); it('favorite', () => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); expect(controller.getFavoriteOptions()).toEqual([ { value: MediaFilterCoreFavoriteSelection.Favorite, @@ -96,7 +90,7 @@ describe('MediaFilterController', () => { }); it('when', () => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); expect(controller.getWhenOptions()).toEqual([ { value: MediaFilterCoreWhen.Today, @@ -122,22 +116,22 @@ describe('MediaFilterController', () => { }); it('cameras', () => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); expect(controller.getCameraOptions()).toEqual([]); }); it('what', () => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); expect(controller.getWhatOptions()).toEqual([]); }); it('where', () => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); expect(controller.getWhereOptions()).toEqual([]); }); it('tags', () => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); expect(controller.getTagsOptions()).toEqual([]); }); }); @@ -152,7 +146,7 @@ describe('MediaFilterController', () => { }); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); controller.computeCameraOptions(cameraManager); expect(controller.getCameraOptions()).toEqual([ { @@ -178,7 +172,7 @@ describe('MediaFilterController', () => { }), ); - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); controller.computeCameraOptions(cameraManager); expect(controller.getCameraOptions()).toEqual([]); }); @@ -188,7 +182,7 @@ describe('MediaFilterController', () => { vi.mocked(cameraManager.getCameraMetadata).mockReturnValue(null); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); controller.computeCameraOptions(cameraManager); expect(controller.getCameraOptions()).toEqual([ { @@ -206,7 +200,7 @@ describe('MediaFilterController', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getMediaMetadata).mockRejectedValue(new Error('error')); - const host = createHost(); + const host = createLitElement(); const controller = new MediaFilterController(host); await controller.computeMetadataOptions(cameraManager); expect(host.requestUpdate).not.toBeCalled(); @@ -218,7 +212,7 @@ describe('MediaFilterController', () => { what: new Set(['person', 'car']), }); - const host = createHost(); + const host = createLitElement(); const controller = new MediaFilterController(host); await controller.computeMetadataOptions(cameraManager); expect(controller.getWhatOptions()).toEqual([ @@ -240,7 +234,7 @@ describe('MediaFilterController', () => { where: new Set(['front_door', 'back_yard']), }); - const host = createHost(); + const host = createLitElement(); const controller = new MediaFilterController(host); await controller.computeMetadataOptions(cameraManager); expect(controller.getWhereOptions()).toEqual([ @@ -262,7 +256,7 @@ describe('MediaFilterController', () => { tags: new Set(['tag-1', 'tag-2']), }); - const host = createHost(); + const host = createLitElement(); const controller = new MediaFilterController(host); await controller.computeMetadataOptions(cameraManager); expect(controller.getTagsOptions()).toEqual([ @@ -284,7 +278,7 @@ describe('MediaFilterController', () => { days: new Set(['2024-02-04', '2024-02-05']), }); - const host = createHost(); + const host = createLitElement(); const controller = new MediaFilterController(host); await controller.computeMetadataOptions(cameraManager); @@ -306,7 +300,7 @@ describe('MediaFilterController', () => { const view = createView({ query: new EventMediaQueries() }); const cameraManager = createCameraManager(); - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ events: true, recordings: false, @@ -317,7 +311,7 @@ describe('MediaFilterController', () => { const view = createView({ query: new RecordingMediaQueries() }); const cameraManager = createCameraManager(); - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ events: false, recordings: true, @@ -331,7 +325,7 @@ describe('MediaFilterController', () => { createCapabilities({ 'favorite-events': true }), ); - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ favorites: true, }); @@ -344,7 +338,7 @@ describe('MediaFilterController', () => { createCapabilities({ 'favorite-recordings': true }), ); - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ favorites: true, }); @@ -354,7 +348,7 @@ describe('MediaFilterController', () => { const view = createView(); const cameraManager = createCameraManager(); - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({ favorites: false, }); @@ -363,7 +357,7 @@ describe('MediaFilterController', () => { describe('should handle value change', () => { it('must have visible cameras', async () => { - const host = createHost(); + const host = createLitElement(); const controller = new MediaFilterController(host); await controller.valueChangeHandler( createCameraManager(), @@ -380,7 +374,7 @@ describe('MediaFilterController', () => { '%s', async (viewName: 'clips' | 'snapshots') => { const eventListener = vi.fn(); - const host = createHost(); + const host = createLitElement(); host.addEventListener('frigate-card:view:change', eventListener); const controller = new MediaFilterController(host); @@ -458,7 +452,7 @@ describe('MediaFilterController', () => { it('with recordings media type', async () => { const eventListener = vi.fn(); - const host = createHost(); + const host = createLitElement(); host.addEventListener('frigate-card:view:change', eventListener); const controller = new MediaFilterController(host); @@ -522,7 +516,7 @@ describe('MediaFilterController', () => { }); it('without favorites', async () => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); @@ -583,7 +577,7 @@ describe('MediaFilterController', () => { new Date('2024-02-29T23:59:59.999'), ], ])('%s', async (value: MediaFilterCoreWhen | string, from: Date, to: Date) => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); @@ -614,7 +608,7 @@ describe('MediaFilterController', () => { }); it('custom without values', async () => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); @@ -646,7 +640,7 @@ describe('MediaFilterController', () => { describe('should calculate correct defaults', () => { it('with no queries', () => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); controller.computeInitialDefaultsFromView(createCameraManager(), createView()); @@ -654,7 +648,7 @@ describe('MediaFilterController', () => { }); it('with no cameras', () => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); controller.computeInitialDefaultsFromView( createCameraManager(), @@ -956,7 +950,7 @@ describe('MediaFilterController', () => { mediaQueries: MediaQueries, defaults: MediaFilterCoreDefaults | null, ) => { - const controller = new MediaFilterController(createHost()); + const controller = new MediaFilterController(createLitElement()); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore()); diff --git a/tests/components-lib/menu-controller.test.ts b/tests/components-lib/menu-controller.test.ts index 163e621b..596d9312 100644 --- a/tests/components-lib/menu-controller.test.ts +++ b/tests/components-lib/menu-controller.test.ts @@ -1,25 +1,15 @@ -import { - HASSDomEvent, - handleActionConfig, -} from '@dermotduffy/custom-card-helpers'; -import { LitElement } from 'lit'; +import { HASSDomEvent, handleActionConfig } from '@dermotduffy/custom-card-helpers'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FRIGATE_ICON_SVG_PATH } from '../../src/camera-manager/frigate/icon'; import { MenuController } from '../../src/components-lib/menu-controller'; import { ActionsConfig, MenuConfig, menuConfigSchema } from '../../src/config/types'; import { StateParameters } from '../../src/types'; import { refreshDynamicStateParameters } from '../../src/utils/ha'; -import { createHASS } from '../test-utils'; +import { createHASS, createLitElement } from '../test-utils'; vi.mock('@dermotduffy/custom-card-helpers'); vi.mock('../../src/utils/ha'); -const createHost = (): LitElement => { - const host = document.createElement('div') as unknown as LitElement; - host.requestUpdate = vi.fn(); - return host; -}; - const createMenuConfig = (config: unknown): MenuConfig => { return menuConfigSchema.parse(config); }; @@ -59,7 +49,7 @@ describe('MenuController', () => { }); it('should set and get menu config', () => { - const host = createHost(); + const host = createLitElement(); const controller = new MenuController(host); const config = createMenuConfig({ @@ -79,7 +69,7 @@ describe('MenuController', () => { describe('should set and sort buttons', () => { it('by priority', () => { - const controller = new MenuController(createHost()); + const controller = new MenuController(createLitElement()); controller.setButtons([ { type: 'custom:frigate-card-menu-icon', @@ -146,7 +136,7 @@ describe('MenuController', () => { }); it('with frigate button first', () => { - const controller = new MenuController(createHost()); + const controller = new MenuController(createLitElement()); controller.setMenuConfig( createMenuConfig({ style: 'hidden', @@ -197,7 +187,7 @@ describe('MenuController', () => { describe('should get buttons', () => { it('with matching alignment', () => { - const controller = new MenuController(createHost()); + const controller = new MenuController(createLitElement()); controller.setButtons([ { type: 'custom:frigate-card-menu-icon', @@ -229,7 +219,7 @@ describe('MenuController', () => { }); it('with disabled buttons', () => { - const controller = new MenuController(createHost()); + const controller = new MenuController(createLitElement()); controller.setButtons([ { type: 'custom:frigate-card-menu-icon', @@ -261,7 +251,7 @@ describe('MenuController', () => { }); it('with hidden non-expanded menu', () => { - const controller = new MenuController(createHost()); + const controller = new MenuController(createLitElement()); controller.setMenuConfig( createMenuConfig({ style: 'hidden', @@ -303,7 +293,7 @@ describe('MenuController', () => { describe('should get fresh button state', () => { it('on state icon', () => { - const controller = new MenuController(createHost()); + const controller = new MenuController(createLitElement()); const stateButton = { type: 'custom:frigate-card-menu-state-icon' as const, icon: 'mdi:sheep', @@ -322,7 +312,7 @@ describe('MenuController', () => { }); it('on non state icon', () => { - const controller = new MenuController(createHost()); + const controller = new MenuController(createLitElement()); const button = { type: 'custom:frigate-card-menu-icon' as const, icon: 'mdi:sheep', @@ -335,7 +325,7 @@ describe('MenuController', () => { describe('should get svg path', () => { it('frigate icon', () => { - const controller = new MenuController(createHost()); + const controller = new MenuController(createLitElement()); const button = { type: 'custom:frigate-card-menu-icon' as const, icon: 'frigate', @@ -344,7 +334,7 @@ describe('MenuController', () => { }); it('non-frigate icon', () => { - const controller = new MenuController(createHost()); + const controller = new MenuController(createLitElement()); const button = { type: 'custom:frigate-card-menu-icon' as const, icon: 'mdi:cow', @@ -355,13 +345,13 @@ describe('MenuController', () => { describe('should handle actions', () => { it('should bail without config', () => { - const controller = new MenuController(createHost()); + const controller = new MenuController(createLitElement()); controller.actionHandler(createHASS(), createEvent('tap')); expect(vi.mocked(handleActionConfig)).not.toBeCalled(); }); it('should execute simple action in non-hidden menu', () => { - const host = createHost(); + const host = createLitElement(); const hass = createHASS(); const controller = new MenuController(host); @@ -377,7 +367,7 @@ describe('MenuController', () => { }); it('should execute simple action in with config in event', () => { - const host = createHost(); + const host = createLitElement(); const hass = createHASS(); const controller = new MenuController(host); @@ -391,7 +381,7 @@ describe('MenuController', () => { }); it('should execute simple array of actions in non-hidden menu', () => { - const host = createHost(); + const host = createLitElement(); const hass = createHASS(); const controller = new MenuController(host); @@ -401,7 +391,7 @@ describe('MenuController', () => { describe('should close menu', () => { it('tap', () => { - const host = createHost(); + const host = createLitElement(); const hass = createHASS(); const controller = new MenuController(host); controller.setMenuConfig( @@ -418,7 +408,7 @@ describe('MenuController', () => { }); it('end_tap', () => { - const host = createHost(); + const host = createLitElement(); const hass = createHASS(); const controller = new MenuController(host); controller.setMenuConfig( @@ -439,7 +429,7 @@ describe('MenuController', () => { describe('should not close menu', () => { it('start_tap with later action', () => { - const host = createHost(); + const host = createLitElement(); const hass = createHASS(); const controller = new MenuController(host); controller.setMenuConfig( @@ -459,7 +449,7 @@ describe('MenuController', () => { }); it('with a menu toggle action', () => { - const host = createHost(); + const host = createLitElement(); const hass = createHASS(); const controller = new MenuController(host); controller.setMenuConfig( @@ -479,7 +469,7 @@ describe('MenuController', () => { }); it('when no action is actually taken', () => { - const host = createHost(); + const host = createLitElement(); const hass = createHASS(); const controller = new MenuController(host); controller.setMenuConfig( diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 0ae2a6f6..2009ceaf 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,5 +1,6 @@ import { CurrentUser, HomeAssistant } from '@dermotduffy/custom-card-helpers'; import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; +import { LitElement } from 'lit'; import { expect, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { Camera } from '../src/camera-manager/camera'; @@ -13,7 +14,6 @@ import { CameraEventCallback, CameraManagerMediaCapabilities, } from '../src/camera-manager/types'; -import { CapabilitiesRaw } from '../src/types'; import { ActionsManager } from '../src/card-controller/actions-manager'; import { AutoUpdateManager } from '../src/card-controller/auto-update-manager'; import { AutomationsManager } from '../src/card-controller/automations-manager'; @@ -47,7 +47,7 @@ import { frigateCardConfigSchema, performanceConfigSchema, } from '../src/config/types'; -import { ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types'; +import { CapabilitiesRaw, ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types'; import { EntityRegistryManager } from '../src/utils/ha/entity-registry'; import { Entity } from '../src/utils/ha/entity-registry/types'; import { ViewMedia, ViewMediaType } from '../src/view/media'; @@ -248,6 +248,24 @@ export const createMediaLoadedInfo = ( }; }; +export const createMediaLoadedInfoEvent = ( + mediaLoadedInfo?: MediaLoadedInfo, +): CustomEvent => { + return new CustomEvent('frigate-card:media:loaded', { + detail: mediaLoadedInfo ?? createMediaLoadedInfo(), + composed: true, + bubbles: true, + }); +}; + +export const createViewChangeEvent = (view?: View): CustomEvent => { + return new CustomEvent('frigate-card:view:change', { + detail: view ?? createView(), + composed: true, + bubbles: true, + }); +}; + export const createPerformanceConfig = (config: unknown): PerformanceConfig => { return performanceConfigSchema.parse(config); }; @@ -342,6 +360,20 @@ export const requestAnimationFrameMock = (callback: FrameRequestCallback) => { return 1; }; +export const callIntersectionHandler = (intersecting = true, n = 0): void => { + const mockResult = vi.mocked(IntersectionObserver).mock.results[n]; + if (mockResult.type !== 'return') { + return; + } + const observer = mockResult.value; + vi.mocked(IntersectionObserver).mock.calls[n][0]( + // Note this is a very incomplete / invalid IntersectionObserverEntry that + // just provides the bare basics current implementation uses. + intersecting ? [{ isIntersecting: true } as IntersectionObserverEntry] : [], + observer, + ); +}; + export const createSlotHost = (options?: { slot?: HTMLSlotElement; children?: HTMLElement[]; @@ -370,6 +402,12 @@ export const createParent = (options?: { children?: HTMLElement[] }): HTMLElemen return parent; }; +export const createLitElement = (): LitElement => { + const element = document.createElement('div') as unknown as LitElement; + element.requestUpdate = vi.fn(); + return element; +}; + export const createCardAPI = (): CardController => { const api = mock(); 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 index 8ebb42f7..4cbfea08 100644 --- 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 @@ -20,12 +20,12 @@ import { import { dispatchExistingMediaLoadedInfoAsEvent } from '../../../../../src/utils/media-info'; import { IntersectionObserverMock, + callIntersectionHandler, createMediaLoadedInfo, createParent, } from '../../../../test-utils'; import { callEmblaHandler, - callIntersectionHandler, callVisibilityHandler, createEmblaApiInstance, createTestEmblaOptionHandler, diff --git a/tests/utils/embla/plugins/auto-size/auto-size.test.ts b/tests/utils/embla/plugins/auto-size/auto-size.test.ts index b04784c3..021a01ed 100644 --- a/tests/utils/embla/plugins/auto-size/auto-size.test.ts +++ b/tests/utils/embla/plugins/auto-size/auto-size.test.ts @@ -3,12 +3,12 @@ import AutoSize from '../../../../../src/utils/embla/plugins/auto-size/auto-size import { IntersectionObserverMock, ResizeObserverMock, + callIntersectionHandler, createParent, requestAnimationFrameMock, } from '../../../../test-utils'; import { callEmblaHandler, - callIntersectionHandler, callResizeHandler, createEmblaApiInstance, createTestEmblaOptionHandler, diff --git a/tests/utils/embla/test-utils.ts b/tests/utils/embla/test-utils.ts index e3ab0389..66aee8a8 100644 --- a/tests/utils/embla/test-utils.ts +++ b/tests/utils/embla/test-utils.ts @@ -43,20 +43,6 @@ export const callVisibilityHandler = (): void => { } }; -export const callIntersectionHandler = (intersecting = true, n = 0): void => { - const mockResult = vi.mocked(IntersectionObserver).mock.results[n]; - if (mockResult.type !== 'return') { - return; - } - const observer = mockResult.value; - vi.mocked(IntersectionObserver).mock.calls[n][0]( - // Note this is a very incomplete / invalid IntersectionObserverEntry that - // just provides the bare basics current implementation uses. - intersecting ? [{ isIntersecting: true } as IntersectionObserverEntry] : [], - observer, - ); -}; - export const callMutationHandler = (n = 0): void => { const mockResult = vi.mocked(MutationObserver).mock.results[n]; if (mockResult.type !== 'return') { diff --git a/vite.config.ts b/vite.config.ts index 0aaea85e..4479c43b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,6 +13,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'camera-manager/utils/*.ts', 'card-controller/*.ts', 'components-lib/cached-value-controller.ts', + 'components-lib/live/live-controller.ts', 'components-lib/media-filter-controller.ts', 'components-lib/menu-button-controller.ts', 'components-lib/menu-controller.ts', From d191e9e608415a865fb994aac5326bbb780f600f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 7 Apr 2024 20:24:12 -0700 Subject: [PATCH 2/2] Fix typo. --- 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 fd478d29..fe075223 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -76,7 +76,7 @@ export class FrigateCardLive extends LitElement { @property({ attribute: false }) public nonOverriddenLiveConfig?: LiveConfig; - @property({ attribute: false }>) + @property({ attribute: false }) public overriddenLiveConfig?: LiveConfig; @property({ attribute: false, hasChanged: contentsChanged })