From b663e0b73183454d905b895eda93f87faf493df9 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 8 Aug 2023 22:26:25 -0700 Subject: [PATCH 01/33] Initial support for grid for live and media viewer. --- .vscode/settings.json | 3 +- package.json | 2 + src/card.ts | 74 +++- src/components/gallery.ts | 6 +- src/components/live/live-image.ts | 6 +- src/components/live/live.ts | 232 ++++++++--- src/components/media-carousel.ts | 3 - src/components/media-grid.ts | 80 ++++ src/components/surround.ts | 24 +- src/components/thumbnail-carousel.ts | 2 +- src/components/timeline-core.ts | 71 ++-- src/components/timeline.ts | 13 +- src/components/title-control.ts | 22 +- src/components/viewer.ts | 358 ++++++++++++----- src/components/views.ts | 3 +- src/conditions.ts | 12 +- src/const.ts | 19 +- src/editor.ts | 79 +++- src/localize/languages/en.json | 15 + src/localize/languages/it.json | 14 + src/localize/languages/pt-BR.json | 15 + src/localize/languages/pt-PT.json | 15 + src/scss/{live.scss => basic-block.scss} | 0 src/scss/live-carousel.scss | 9 + src/scss/live-image.scss | 5 - src/scss/media-filter.scss | 2 +- src/scss/media-grid.scss | 50 +++ src/scss/surround.scss | 5 - src/scss/timeline.scss | 5 - src/scss/title-control.scss | 2 + src/scss/viewer-carousel.scss | 32 ++ src/types.ts | 43 +- src/utils/action.ts | 13 + src/utils/basic.ts | 5 + src/utils/media-grid-controller.ts | 328 ++++++++++++++++ src/utils/media-info.ts | 30 ++ src/utils/media-to-view.ts | 57 ++- src/utils/menu-controller.ts | 30 +- src/utils/zoom/zoom.ts | 15 +- src/view/media-queries-results.ts | 268 ++++++++++--- src/view/media-queries.ts | 12 +- src/view/media.ts | 3 - src/view/view.ts | 30 +- tests/camera-manager/utils.test.ts | 44 +-- tests/conditions.test.ts | 13 +- tests/test-utils.ts | 81 +++- tests/utils/action.test.ts | 18 + tests/utils/basic.test.ts | 18 +- tests/utils/media-grid-controller.test.ts | 400 +++++++++++++++++++ tests/utils/media-to-view.test.ts | 452 ++++++++++++++++++++++ tests/utils/media.test.ts | 7 + tests/utils/menu-controller.test.ts | 33 +- tests/utils/screenshot.test.ts | 26 +- tests/view/media-classifier.test.ts | 66 ++++ tests/view/media-queries-results.test.ts | 233 +++++++++++ tests/view/media-queries.test.ts | 102 +++++ tests/view/media.test.ts | 60 +++ tests/view/view.test.ts | 94 ++++- yarn.lock | 78 ++++ 59 files changed, 3308 insertions(+), 429 deletions(-) create mode 100644 src/components/media-grid.ts rename src/scss/{live.scss => basic-block.scss} (100%) delete mode 100644 src/scss/live-image.scss create mode 100644 src/scss/media-grid.scss delete mode 100644 src/scss/surround.scss delete mode 100644 src/scss/timeline.scss create mode 100644 src/utils/media-grid-controller.ts create mode 100644 tests/utils/media-grid-controller.test.ts create mode 100644 tests/utils/media-to-view.test.ts create mode 100644 tests/view/media-classifier.test.ts create mode 100644 tests/view/media-queries-results.test.ts create mode 100644 tests/view/media-queries.test.ts create mode 100644 tests/view/media.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 31c57af5..1814a117 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,6 @@ "i18n-ally.keepFulfilled": true, "i18n-ally.editor.preferEditor": true, "i18n-ally.translate.saveAsCandidates": true, - "vitest.commandLine": "npx vitest --root ." + "vitest.commandLine": "npx vitest --root .", + "diffEditor.experimental.useVersion2": true } diff --git a/package.json b/package.json index 260fa40f..ef7c35fd 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "lit": "^2.3.1", "lit-flatpickr": "^0.4.0", "lodash-es": "^4.17.21", + "masonry-layout": "^4.2.2", "moment": "^2.29.4", "propagating-hammerjs": "^2.0.1", "quick-lru": "^6.1.0", @@ -58,6 +59,7 @@ "@rollup/plugin-node-resolve": "^13.3.0", "@rollup/plugin-replace": "^4.0.0", "@types/lodash-es": "^4.17.5", + "@types/masonry-layout": "^4.2.5", "@typescript-eslint/eslint-plugin": "^5.36.2", "@typescript-eslint/parser": "^5.36.2", "@vitest/coverage-c8": "^0.29.8", diff --git a/src/card.ts b/src/card.ts index b0ead46e..21f182b9 100644 --- a/src/card.ts +++ b/src/card.ts @@ -59,6 +59,7 @@ import { Message, MESSAGE_TYPE_PRIORITIES, RawFrigateCardConfig, + ViewDisplayMode, } from './types.js'; import { convertActionToFrigateCardCustomAction, @@ -97,6 +98,7 @@ import { } from './utils/substream'; import { Timer } from './utils/timer'; import { getParseErrorPaths } from './utils/zod.js'; +import { ViewMediaClassifier } from './view/media-classifier'; import { View } from './view/view.js'; /** A note on media callbacks: @@ -186,6 +188,9 @@ class FrigateCard extends LitElement { @state() protected _expand = false; + @state() + protected _viewDisplayMode?: ViewDisplayMode; + protected _microphoneController?: MicrophoneController; protected _conditionController?: ConditionController; protected _automationsController?: AutomationsController; @@ -414,6 +419,7 @@ class FrigateCard extends LitElement { state: this._hass.states, }), media_loaded: this._mediaLoadedInfoController.has(), + displayMode: undefined, }); } @@ -466,6 +472,7 @@ class FrigateCard extends LitElement { this._conditionController?.setState({ view: this._view.view, camera: this._view.camera, + displayMode: this._view.displayMode ?? undefined, }); }; @@ -493,10 +500,14 @@ class FrigateCard extends LitElement { } if (cameraID) { + const viewName = args?.viewName ?? this._getConfig().view.default; + const displayMode = + this._viewDisplayMode ?? this._getDefaultDisplayModeForView(viewName); changeView( new View({ - view: args?.viewName ?? this._getConfig().view.default, + view: viewName, camera: cameraID, + displayMode: displayMode, }), ); @@ -641,7 +652,10 @@ class FrigateCard extends LitElement { targetCamera && (this._view.camera !== targetCamera || !this._view.is('live')) ) { - this._changeView({ view: new View({ view: 'live', camera: targetCamera }) }); + this._changeView({ + viewName: 'live', + cameraID: targetCamera, + }); changedCamera = true; } } @@ -1003,7 +1017,7 @@ class FrigateCard extends LitElement { if (this._view.isViewerView() && media) { media_content_id = media.getContentID(); - media_content_type = media.getContentType(); + media_content_type = ViewMediaClassifier.isVideo(media) ? 'video' : 'image'; title = media.getTitle(); thumbnail = media.getThumbnail(); } else if (this._view?.is('live') && cameraEntity) { @@ -1049,6 +1063,24 @@ class FrigateCard extends LitElement { } } + protected _getDefaultDisplayModeForView(view: FrigateCardView): ViewDisplayMode { + let mode: ViewDisplayMode | null = null; + switch (view) { + case 'clip': + case 'clips': + case 'recording': + case 'recordings': + case 'snapshot': + case 'snapshots': + mode = this._getConfig().media_viewer.display?.mode ?? null; + break; + case 'live': + mode = this._getConfig().live.display?.mode ?? null; + break; + } + return mode ?? 'single'; + } + protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void { // Note: This function needs to process (view-related) commands even when // _view has not yet been initialized (since it may be used to set a view @@ -1119,7 +1151,8 @@ class FrigateCard extends LitElement { ? targetView : FRIGATE_CARD_VIEW_DEFAULT; this._changeView({ - view: new View({ view: actualView, camera: selectCameraID }), + viewName: actualView, + cameraID: selectCameraID, }); } break; @@ -1203,6 +1236,25 @@ class FrigateCard extends LitElement { } }); break; + case 'display_mode_select': + this._viewDisplayMode = frigateCardAction.display_mode; + this._conditionController?.setState({ + displayMode: this._viewDisplayMode, + }); + // If the new mode is for all cameras, but the current query does not + // have a query for every cameraID, reset it. + const resetQuery = + frigateCardAction.mode === 'grid' && + !this._view?.query?.hasQueriesForCameraIDs( + this._cameraManager.getStore().getVisibleCameraIDs(), + ); + this._changeView({ + view: this._view?.evolve({ + displayMode: frigateCardAction.display_mode, + ...(resetQuery && { query: null, queryResults: null }), + }), + }); + break; default: console.warn(`Frigate card received unknown card action: ${action}`); } @@ -1501,15 +1553,21 @@ class FrigateCard extends LitElement { ? `${lastKnown.width} / ${lastKnown.height}` : 'unset', ); - // Non-media mays have no intrinsic dimensions and so we need to explicit - // request the dialog to use all available space. + // Non-media may have no intrinsic dimensions (or multiple media items in a + // grid) and so we need to explicit request the dialog to use all available + // space. + const isGrid = this._view?.isGrid(); this.style.setProperty( '--frigate-card-expand-width', - this._view?.isAnyMediaView() ? 'none' : 'var(--frigate-card-expand-max-width)', + !isGrid && this._view?.isAnyMediaView() + ? 'none' + : 'var(--frigate-card-expand-max-width)', ); this.style.setProperty( '--frigate-card-expand-height', - this._view?.isAnyMediaView() ? 'none' : 'var(--frigate-card-expand-max-height)', + !isGrid && this._view?.isAnyMediaView() + ? 'none' + : 'var(--frigate-card-expand-max-height)', ); } diff --git a/src/components/gallery.ts b/src/components/gallery.ts index c331be0d..fcf6fed6 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -368,7 +368,9 @@ export class FrigateCardGalleryCore extends LitElement { this.view ?.evolve({ query: newMediaQueries, - queryResults: new MediaQueriesResults(extension.results).selectResultIfFound( + queryResults: new MediaQueriesResults({ + results: extension.results, + }).selectResultIfFound( (media) => media === this.view?.queryResults?.getSelectedResult(), ), }) @@ -468,7 +470,7 @@ export class FrigateCardGalleryCore extends LitElement { this.view .evolve({ view: 'media', - queryResults: this.view.queryResults?.clone().selectResult( + queryResults: this.view.queryResults?.clone().selectIndex( // Media in the gallery is reversed vs the queryResults (see // note above). this._media.length - index - 1, diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts index 58e4a03d..3aad8aa6 100644 --- a/src/components/live/live-image.ts +++ b/src/components/live/live-image.ts @@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import liveImageStyle from '../../scss/live-image.scss'; +import basicBlockStyle from '../../scss/basic-block.scss'; import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js'; import '../image.js'; import { getStateObjOrDispatchError } from './live.js'; @@ -50,7 +50,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia } public async getScreenshotURL(): Promise { - return await this._refImage.value?.getScreenshotURL() ?? null; + return (await this._refImage.value?.getScreenshotURL()) ?? null; } protected render(): TemplateResult | void { @@ -77,7 +77,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia } static get styles(): CSSResultGroup { - return unsafeCSS(liveImageStyle); + return unsafeCSS(basicBlockStyle); } } diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 23e34688..cdbf87e8 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -16,12 +16,12 @@ import { guard } from 'lit/directives/guard.js'; import { keyed } from 'lit/directives/keyed.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; -import { CameraEndpoints } from '../../camera-manager/types.js'; +import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js'; import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js'; import { localize } from '../../localize/localize.js'; import liveCarouselStyle from '../../scss/live-carousel.scss'; import liveProviderStyle from '../../scss/live-provider.scss'; -import liveStyle from '../../scss/live.scss'; +import basicBlockStyle from '../../scss/basic-block.scss'; import { CameraConfig, CardWideConfig, @@ -56,6 +56,9 @@ import '../surround.js'; import '../title-control.js'; import { AutoMediaPlugin } from './../embla-plugins/automedia.js'; import { Lazyload } from './../embla-plugins/lazyload.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { MediaGridSelected } from '../../utils/media-grid-controller.js'; +import { getDefaultTitleConfigForView } from '../title-control.js'; interface LiveViewContext { // A cameraID override (used for dependencies/substreams to force a different @@ -126,7 +129,10 @@ export class FrigateCardLive extends LitElement { public view?: Readonly; @property({ attribute: false }) - public liveConfig?: LiveConfig; + public nonOverriddenLiveConfig?: LiveConfig; + + @property({ attribute: false }) + public overriddenLiveConfig?: LiveConfig; @property({ attribute: false, hasChanged: contentsChanged }) public liveOverrides?: LiveOverrides; @@ -228,13 +234,18 @@ export class FrigateCardLive extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.liveConfig || !this.cameraManager || !this.view) { + if ( + !this.hass || + !this.nonOverriddenLiveConfig || + !this.cameraManager || + !this.view + ) { return; } // Notes: - // - See use of liveConfig and not config below -- the carousel will - // independently override the liveConfig to reflect the camera in the + // - See use of liveConfig and not config below -- the underlying carousel + // will independently override the liveConfig to reflect the camera in the // carousel (not necessarily the selected camera). // - Various events are captured to prevent them propagating upwards if the // card is in the background. @@ -244,10 +255,11 @@ export class FrigateCardLive extends LitElement { const result = html`${keyed( this._renderKey, html` - - + `, )}`; @@ -284,16 +296,13 @@ export class FrigateCardLive extends LitElement { return result; } - /** - * Get styles. - */ static get styles(): CSSResultGroup { - return unsafeCSS(liveStyle); + return unsafeCSS(basicBlockStyle); } } -@customElement('frigate-card-live-carousel') -export class FrigateCardLiveCarousel extends LitElement { +@customElement('frigate-card-live-grid') +export class FrigateCardLiveGrid extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @@ -301,7 +310,10 @@ export class FrigateCardLiveCarousel extends LitElement { public view?: Readonly; @property({ attribute: false }) - public liveConfig?: LiveConfig; + public nonOverriddenLiveConfig?: LiveConfig; + + @property({ attribute: false }) + public overriddenLiveConfig?: LiveConfig; @property({ attribute: false, hasChanged: contentsChanged }) public liveOverrides?: LiveOverrides; @@ -321,6 +333,109 @@ export class FrigateCardLiveCarousel extends LitElement { @property({ attribute: false }) public microphoneStream?: MediaStream; + protected _renderCarousel(cameraID?: string): TemplateResult { + return html` + + + `; + } + + protected _gridSelectCamera(cameraID: string, view?: View): void { + (view ?? this.view) + ?.evolve({ + camera: cameraID, + }) + .dispatchChangeEvent(this); + } + + protected _needsGrid(): boolean { + const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); + return !!this.view?.isGrid() && !!cameraIDs && cameraIDs.size >= 1; + } + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('view') && this._needsGrid()) { + import('../media-grid.js'); + } + } + + protected render(): TemplateResult | void { + if (!this.conditionControllerEpoch || !this.nonOverriddenLiveConfig) { + return; + } + const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); + if (!this._needsGrid() || !cameraIDs) { + return this._renderCarousel(); + } + return html` + ) => + this._gridSelectCamera(ev.detail.selected)} + @frigate-card:view:change=${(ev: CustomEvent) => { + ev.stopPropagation(); + this._gridSelectCamera(ev.detail.camera, ev.detail); + }} + > + ${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))} + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(basicBlockStyle); + } +} + +@customElement('frigate-card-live-carousel') +export class FrigateCardLiveCarousel extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public nonOverriddenLiveConfig?: LiveConfig; + + @property({ attribute: false }) + public overriddenLiveConfig?: LiveConfig; + + @property({ attribute: false, hasChanged: contentsChanged }) + public liveOverrides?: LiveOverrides; + + @property({ attribute: false }) + public inBackground?: boolean; + + @property({ attribute: false }) + public conditionControllerEpoch?: ConditionControllerEpoch; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public microphoneStream?: MediaStream; + + @property({ attribute: false }) + public viewFilterCameraID?: string; + // Index between camera name and slide number. protected _cameraToSlide: Record = {}; protected _refMediaCarousel: Ref = createRef(); @@ -357,7 +472,7 @@ export class FrigateCardLiveCarousel extends LitElement { */ protected _getTransitionEffect(): TransitionEffect { return ( - this.liveConfig?.transition_effect ?? + this.overriddenLiveConfig?.transition_effect ?? frigateCardConfigDefaults.live.transition_effect ); } @@ -376,7 +491,9 @@ export class FrigateCardLiveCarousel extends LitElement { */ protected _getOptions(): EmblaOptionsType { return { - draggable: this.liveConfig?.draggable, + // If the carousel is being filtered to a single cameraID, it is never + // draggable. + draggable: !this.viewFilterCameraID && this.overriddenLiveConfig?.draggable, loop: true, }; } @@ -386,10 +503,12 @@ export class FrigateCardLiveCarousel extends LitElement { * @returns A list of EmblaOptionsTypes. */ protected _getPlugins(): EmblaCarouselPlugins { - const cameras = this.cameraManager?.getStore().getVisibleCameraIDs(); + const cameraCount = this.viewFilterCameraID + ? 1 + : this.cameraManager?.getStore().getVisibleCameraCount() ?? 0; return [ // Only enable wheel plugin if there is more than one camera. - ...(cameras && cameras.size > 1 + ...(cameraCount > 1 ? [ WheelGesturesPlugin({ // Whether the carousel is vertical or horizontal, interpret y-axis wheel @@ -399,28 +518,28 @@ export class FrigateCardLiveCarousel extends LitElement { ] : []), Lazyload({ - ...(this.liveConfig?.lazy_load && { + ...(this.overriddenLiveConfig?.lazy_load && { lazyLoadCallback: (index, slide) => this._lazyloadOrUnloadSlide('load', index, slide), }), - lazyUnloadCondition: this.liveConfig?.lazy_unload, + lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload, lazyUnloadCallback: (index, slide) => this._lazyloadOrUnloadSlide('unload', index, slide), }), AutoMediaPlugin({ playerSelector: FRIGATE_CARD_LIVE_PROVIDER, - ...(this.liveConfig?.auto_play && { - autoPlayCondition: this.liveConfig.auto_play, + ...(this.overriddenLiveConfig?.auto_play && { + autoPlayCondition: this.overriddenLiveConfig.auto_play, }), - ...(this.liveConfig?.auto_pause && { - autoPauseCondition: this.liveConfig.auto_pause, + ...(this.overriddenLiveConfig?.auto_pause && { + autoPauseCondition: this.overriddenLiveConfig.auto_pause, }), - ...(this.liveConfig?.auto_mute && { - autoMuteCondition: this.liveConfig.auto_mute, + ...(this.overriddenLiveConfig?.auto_mute && { + autoMuteCondition: this.overriddenLiveConfig.auto_mute, }), - ...(this.liveConfig?.auto_unmute && { - autoUnmuteCondition: this.liveConfig.auto_unmute, + ...(this.overriddenLiveConfig?.auto_unmute && { + autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute, }), }), ]; @@ -435,7 +554,7 @@ export class FrigateCardLiveCarousel extends LitElement { */ protected _getLazyLoadCount(): number | null { // Defaults to fully-lazy loading. - return this.liveConfig?.lazy_load === false ? null : 0; + return this.overriddenLiveConfig?.lazy_load === false ? null : 0; } /** @@ -444,15 +563,25 @@ export class FrigateCardLiveCarousel extends LitElement { * name to slide number. */ protected _getSlides(): [TemplateResult[], Record] { - const visibleCameras = this.cameraManager?.getStore().getVisibleCameras(); - if (!visibleCameras) { + let cameras: CameraConfigs | null = null; + if (this.viewFilterCameraID) { + const config = this.cameraManager + ?.getStore() + .getCameraConfig(this.viewFilterCameraID); + if (config) { + cameras = new Map([[this.viewFilterCameraID, config]]); + } + } else { + cameras = this.cameraManager?.getStore().getVisibleCameras() ?? null; + } + if (!cameras) { return [[], {}]; } const slides: TemplateResult[] = []; const cameraToSlide: Record = {}; - for (const [cameraID, cameraConfig] of visibleCameras) { + for (const [cameraID, cameraConfig] of cameras) { const liveCameraID = this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID; const liveCameraConfig = @@ -525,7 +654,8 @@ export class FrigateCardLiveCarousel extends LitElement { slideIndex: number, ): TemplateResult | void { if ( - !this.liveConfig || + !this.overriddenLiveConfig || + !this.nonOverriddenLiveConfig || !this.hass || !this.cameraManager || !this.conditionControllerEpoch @@ -538,7 +668,7 @@ export class FrigateCardLiveCarousel extends LitElement { // stateOverride to evaluate the condition in that context. const config = getOverriddenConfig( this.conditionControllerEpoch.controller, - this.liveConfig, + this.nonOverriddenLiveConfig, this.liveOverrides, { camera: cameraID }, ) as LiveConfig; @@ -548,7 +678,7 @@ export class FrigateCardLiveCarousel extends LitElement { return html`
{ await this.updateComplete; await this._refProvider.value?.updateComplete; - return await this._refProvider.value?.getScreenshotURL() ?? null; + return (await this._refProvider.value?.getScreenshotURL()) ?? null; } /** @@ -1002,6 +1139,7 @@ declare global { interface HTMLElementTagNameMap { FRIGATE_CARD_LIVE_PROVIDER: FrigateCardLiveProvider; 'frigate-card-live-carousel': FrigateCardLiveCarousel; + 'frigate-card-live-grid': FrigateCardLiveGrid; 'frigate-card-live': FrigateCardLive; } } diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 5e9df428..470e564b 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -428,9 +428,6 @@ export class FrigateCardMediaCarousel extends LitElement { : ``}`; } - /** - * Get element styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(mediaCarouselStyle); } diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts new file mode 100644 index 00000000..e0b599b3 --- /dev/null +++ b/src/components/media-grid.ts @@ -0,0 +1,80 @@ +// TODO: Performance of video scanning (pause/play?) +// TODO: Investigate query spam during a grid load +// TODO: Test live pre-load +// TODO: Is the query reset in card.ts correct for media filter multi-camera queries that are not all cameras? +// TODO: Do I need column max? +// TODO: test changing tabs in a dashboard (to trigger disconnect, do I still receive media loads from media that was already loaded)? +// TODO: Can SELECT_CHILD_EVENTS only be 'click' and it still work on Android? + +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import mediaGridStyle from '../scss/media-grid.scss'; +import { ViewDisplayConfig } from '../types.js'; +import { MediaGridController } from '../utils/media-grid-controller.js'; + +@customElement('frigate-card-media-grid') +export class FrigateCardMediaGrid extends LitElement { + @property({ attribute: false }) + public selected?: string; + + @property({ attribute: false }) + public displayConfig?: ViewDisplayConfig; + + protected _controller: MediaGridController | null = null; + protected _refSlot: Ref = createRef(); + + connectedCallback(): void { + super.connectedCallback(); + + // Ensure the controller is recreated. + this.requestUpdate(); + } + + disconnectedCallback(): void { + this._controller?.destroy(); + this._controller = null; + super.disconnectedCallback(); + } + + protected updated(changedProps: PropertyValues): void { + if (!this._controller && this._refSlot.value) { + this._controller = new MediaGridController(this._refSlot.value, { + selected: this.selected, + }); + } + + if (changedProps.has('selected')) { + if (this.selected) { + this._controller?.selectCell(this.selected); + } else { + this._controller?.unselectAll(); + } + } + + if (changedProps.has('displayConfig')) { + this._controller?.setDisplayConfig(this.displayConfig ?? null); + } + } + + protected render(): TemplateResult | void { + return html` `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(mediaGridStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-media-grid': FrigateCardMediaGrid; + } +} diff --git a/src/components/surround.ts b/src/components/surround.ts index 79f69242..ec771339 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -7,7 +7,9 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import surroundStyle from '../scss/surround.scss'; +import { CameraManager } from '../camera-manager/manager.js'; +import type { DataQuery } from '../camera-manager/types'; +import basicBlockStyle from '../scss/basic-block.scss'; import { CardWideConfig, ClipsOrSnapshotsOrAll, @@ -16,13 +18,11 @@ import { ThumbnailsControlConfig, } from '../types.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; -import { CameraManager } from '../camera-manager/manager.js'; -import { View } from '../view/view.js'; -import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; -import './surround-basic.js'; -import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view'; import { getAllDependentCameras } from '../utils/camera.js'; -import type { DataQuery } from '../camera-manager/types'; +import { changeViewToRecentEventsForCameraAndDependents } 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. @@ -88,6 +88,7 @@ export class FrigateCardSurround extends LitElement { this.cardWideConfig, this.view, { + allCameras: this.view.isGrid(), targetView: this.view.view, mediaType: this.fetchMedia, select: 'latest', @@ -151,10 +152,6 @@ export class FrigateCardSurround extends LitElement { return null; } - /** - * Master render method. - * @returns A rendered template. - */ protected render(): TemplateResult | void { if (!this.hass || !this.view) { return; @@ -230,11 +227,8 @@ export class FrigateCardSurround extends LitElement { `; } - /** - * Return compiled CSS styles. - */ static get styles(): CSSResultGroup { - return unsafeCSS(surroundStyle); + return unsafeCSS(basicBlockStyle); } } diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index cc550fe7..c7a20351 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -170,7 +170,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { this, 'thumbnail-carousel:tap', { - queryResults: this.view.queryResults.clone().selectResult(index), + queryResults: this.view.queryResults.clone().selectIndex(index), }, ); } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 18eff32e..8b33f147 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -485,7 +485,10 @@ export class FrigateCardTimelineCore extends LitElement { : results .clone() .resetSelectedResult() - .selectBestResult((media) => findBestMediaIndex(media, targetTime)); + .selectBestResult((media) => findBestMediaIndex(media, targetTime), { + allCameras: true, + main: true, + }); const desiredView: FrigateCardView = this.mini ? targetTime >= new Date() @@ -496,8 +499,7 @@ export class FrigateCardTimelineCore extends LitElement { this.view .evolve({ view: desiredView, - ...(newResults && - newResults.hasSelectedResult() && { queryResults: newResults }), + queryResults: newResults, }) // Whether or not to set the timeline window. .mergeInContext({ ...(canSeek && { mediaViewer: { seek: targetTime } }), @@ -597,10 +599,15 @@ export class FrigateCardTimelineCore extends LitElement { ); } } else if (properties.item && properties.what === 'item') { + const cameraID = String(properties.group); + const criteria = { + main: true, + ...(cameraID && this.view.isGrid() && { cameraID: cameraID }), + }; const newResults = this.view.queryResults ?.clone() .resetSelectedResult() - .selectResultIfFound((media) => media.getID() === properties.item); + .selectResultIfFound((media) => media.getID() === properties.item, criteria); if (!newResults || !newResults.hasSelectedResult()) { // This can happen in a few situations: @@ -788,11 +795,12 @@ export class FrigateCardTimelineCore extends LitElement { return new DataSet(groups); } - protected _getPerfectWindowFromMedia(media: ViewMedia): TimelineWindow | null { - const startTime = media.getStartTime(); - const endTime = media.getEndTime(); - - if (ViewMediaClassifier.isEvent(media)) { + protected _getPerfectWindowFromMediaStartAndEndTime( + isEvent: boolean, + startTime: Date | null, + endTime: Date | null, + ): TimelineWindow | null { + if (isEvent) { const windowSeconds = this._getConfiguredWindowSeconds(); if (startTime && endTime) { @@ -820,7 +828,7 @@ export class FrigateCardTimelineCore extends LitElement { end: add(startTime, { seconds: windowSeconds / 2 }), }; } - } else if (ViewMediaClassifier.isRecording(media) && startTime && endTime) { + } else if (startTime && endTime) { return { start: startTime, end: endTime, @@ -980,8 +988,10 @@ export class FrigateCardTimelineCore extends LitElement { let desiredWindow = timelineWindow; const media = this.view.queryResults?.getSelectedResult(); - const mediaStartTime = media?.getStartTime(); - const mediaEndTime = media?.getEndTime(); + const mediaStartTime = media?.getStartTime() ?? null; + const mediaEndTime = media?.getEndTime() ?? null; + const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false; + const mediaWindow: TimelineWindow | null = media && mediaStartTime ? // If this media has no end time, it's just a "point" in time so the @@ -996,8 +1006,12 @@ export class FrigateCardTimelineCore extends LitElement { if (context && context.window) { desiredWindow = context.window; - } else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { - const perfectMediaWindow = this._getPerfectWindowFromMedia(media); + } else if (mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { + const perfectMediaWindow = this._getPerfectWindowFromMediaStartAndEndTime( + mediaIsEvent, + mediaStartTime, + mediaEndTime, + ); if (perfectMediaWindow) { desiredWindow = perfectMediaWindow; } @@ -1013,22 +1027,22 @@ export class FrigateCardTimelineCore extends LitElement { await this._timelineSource?.refresh(this.hass, prefetchedWindow); } + const currentSelection = this._timeline.getSelection(); const mediaID = media?.getID(); - if (media && mediaID && this._isClustering()) { - // Hack: Clustering may not update unless the dataset changes, artifically - // update the dataset to ensure the newly selected item cannot be included - // in a cluster. Only do this when the pointer is not held to avoid - // interrupting the user and to make the timeline smoother. + const needToSelect = mediaID && mediaIsEvent && !currentSelection.includes(mediaID); - // Need to this rewrite prior to setting the selection (just below), or - // the selection will be lost on rewrite. - this._timelineSource?.rewriteEvent(mediaID); - } + if (needToSelect) { + if (this._isClustering()) { + // Hack: Clustering may not update unless the dataset changes, artifically + // update the dataset to ensure the newly selected item cannot be included + // in a cluster. - const desiredId = - !!media && ViewMediaClassifier.isEvent(media) ? media.getID() : null; - if (desiredId) { - this._timeline?.setSelection([desiredId], { + // Need to this rewrite prior to setting the selection (just below), or + // the selection will be lost on rewrite. + this._timelineSource?.rewriteEvent(mediaID); + } + + this._timeline?.setSelection([mediaID], { focus: false, animation: { animation: false, @@ -1244,9 +1258,6 @@ export class FrigateCardTimelineCore extends LitElement { } } - /** - * Return compiled CSS styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(timelineCoreStyle); } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index f70a7db1..4f3cce06 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,8 +1,8 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import timelineStyle from '../scss/timeline.scss'; -import { CardWideConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; import { CameraManager } from '../camera-manager/manager'; +import basicBlockStyle from '../scss/basic-block.scss'; +import { CardWideConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; import { View } from '../view/view'; import './surround.js'; import './timeline-core.js'; @@ -24,10 +24,6 @@ export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) public cardWideConfig?: CardWideConfig; - /** - * Master render method. - * @returns A rendered template. - */ protected render(): TemplateResult | void { if (!this.timelineConfig) { return html``; @@ -49,11 +45,8 @@ export class FrigateCardTimeline extends LitElement { `; } - /** - * Return compiled CSS styles. - */ static get styles(): CSSResultGroup { - return unsafeCSS(timelineStyle); + return unsafeCSS(basicBlockStyle); } } diff --git a/src/components/title-control.ts b/src/components/title-control.ts index 16f14c9e..7bbda1e9 100644 --- a/src/components/title-control.ts +++ b/src/components/title-control.ts @@ -1,14 +1,28 @@ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { TitleControlConfig } from '../types.js'; - +import { createRef, Ref, ref } from 'lit/directives/ref.js'; import titleStyle from '../scss/title-control.scss'; +import { TitleControlConfig } from '../types.js'; +import { View } from '../view/view.js'; type PaperToast = HTMLElement & { opened: boolean; }; +export const getDefaultTitleConfigForView = ( + view?: Readonly, + baseConfig?: TitleControlConfig, +): TitleControlConfig | null => { + if (!baseConfig && view?.isGrid()) { + return { mode: 'none', duration_seconds: 2 }; + } + return { + mode: 'popup-bottom-right', + duration_seconds: 2, + ...baseConfig, + }; +}; + @customElement('frigate-card-title-control') export class FrigateCardTitleControl extends LitElement { @property({ attribute: false }) diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 37980d93..fd030904 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -10,7 +10,9 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; +import basicBlockStyle from '../../scss/basic-block.scss'; import { CameraManager } from '../camera-manager/manager.js'; import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js'; import { localize } from '../localize/localize.js'; @@ -29,9 +31,14 @@ import { } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { mayHaveAudio } from '../utils/audio.js'; -import { contentsChanged, errorToConsole } from '../utils/basic.js'; +import { + contentsChanged, + errorToConsole, + setOrRemoveAttribute, +} from '../utils/basic.js'; import { canonicalizeHAURL } from '../utils/ha/index.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; +import { MediaGridSelected } from '../utils/media-grid-controller.js'; import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, @@ -65,6 +72,7 @@ import { import './next-prev-control.js'; import './surround.js'; import './title-control.js'; +import { getDefaultTitleConfigForView } from './title-control.js'; export interface MediaViewerViewContext { seek?: Date; @@ -76,6 +84,16 @@ declare module 'view' { } } +interface MediaNeighbor { + index: number; + media: ViewMedia; +} + +interface MediaNeighbors { + previous?: MediaNeighbor; + next?: MediaNeighbor; +} + @customElement('frigate-card-viewer') export class FrigateCardViewer extends LitElement { @property({ attribute: false }) @@ -129,6 +147,7 @@ export class FrigateCardViewer extends LitElement { this.cardWideConfig, this.view, { + allCameras: this.view.isGrid(), targetView: 'recording', select: 'latest', }, @@ -141,6 +160,7 @@ export class FrigateCardViewer extends LitElement { this.cardWideConfig, this.view, { + allCameras: this.view.isGrid(), targetView: 'media', mediaType: mediaType, select: 'latest', @@ -150,17 +170,15 @@ export class FrigateCardViewer extends LitElement { return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); } - return html` - - - `; + return html` + `; } /** @@ -181,6 +199,9 @@ export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) public view?: Readonly; + @property({ attribute: false }) + public viewFilterCameraID?: string; + // Resetting the viewer configuration causes a full reset so ensure the config // has actually changed with a full comparison (dynamic configuration // overrides may causes changes elsewhere in the full card configuration that @@ -198,7 +219,11 @@ export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; + @property({ attribute: false }) + public selected = 0; + protected _refMediaCarousel: Ref = createRef(); + protected _media: ViewMedia[] | null = null; /** * The updated lifecycle callback for this element. @@ -255,7 +280,7 @@ export class FrigateCardViewerCarousel extends LitElement { protected _getPlugins(): EmblaPluginType[] { return [ // Only enable wheel plugin if there is more than one media item. - ...(this.view?.queryResults?.getResultsCount() ?? 0 > 1 + ...(this._media && this._media.length > 1 ? [ WheelGesturesPlugin({ // Whether the carousel is vertical or horizontal, interpret y-axis wheel @@ -292,20 +317,28 @@ export class FrigateCardViewerCarousel extends LitElement { * @returns A BrowseMediaNeighbors with indices and objects of true media * neighbors. */ - protected _getMediaNeighbors(): [ViewMedia | null, ViewMedia | null] { - const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null; - const resultCount = this.view?.queryResults?.getResultsCount() ?? 0; - if (!this.view || !this.view.queryResults || selectedIndex === null) { - return [null, null]; + protected _getMediaNeighbors(): MediaNeighbors | null { + const mediaCount = this._media?.length ?? 0; + if (!this._media || this.selected === null) { + return null; } - const previous: ViewMedia | null = - selectedIndex > 0 ? this.view.queryResults.getResult(selectedIndex - 1) : null; - const next: ViewMedia | null = - selectedIndex + 1 < resultCount - ? this.view.queryResults.getResult(selectedIndex + 1) - : null; - return [previous, next]; + const prevIndex = this.selected > 0 ? this.selected - 1 : null; + const nextIndex = this.selected + 1 < mediaCount ? this.selected + 1 : null; + return { + ...(prevIndex !== null && { + previous: { + index: prevIndex, + media: this._media[prevIndex], + }, + }), + ...(nextIndex !== null && { + next: { + index: nextIndex, + media: this._media[nextIndex], + }, + }), + }; } protected _setViewHandler(ev: CustomEvent): void { @@ -313,23 +346,26 @@ export class FrigateCardViewerCarousel extends LitElement { } protected _setViewSelectedIndex(index: number): void { - if (!this.view?.queryResults) { + if (!this._media) { return; } - const selectedIndex = this.view.queryResults.getSelectedIndex(); - if (selectedIndex === null || selectedIndex === index) { + if (this.selected === null || this.selected === index) { // The slide may already be selected on load, so don't dispatch a new view // unless necessary (i.e. the new index is different from the current // index). return; } - const newResults = this.view?.queryResults?.clone().selectResult(index); + const newResults = this.view?.queryResults + ?.clone() + .selectIndex(index, this.viewFilterCameraID); if (!newResults) { return; } - const cameraID = newResults.getSelectedResult()?.getCameraID(); + const cameraID = newResults + .getSelectedResult(this.viewFilterCameraID) + ?.getCameraID(); this.view ?.evolve({ @@ -338,6 +374,7 @@ export class FrigateCardViewerCarousel extends LitElement { // Always change the camera to the owner of the selected media. ...(cameraID && { camera: cameraID }), }) + .removeContextProperty('mediaViewer', 'seek') .dispatchChangeEvent(this); } @@ -363,13 +400,13 @@ export class FrigateCardViewerCarousel extends LitElement { * @returns The slides to include in the render. */ protected _getSlides(): TemplateResult[] { - if (!this.view || !this.view.queryResults) { + if (!this._media) { return []; } const slides: TemplateResult[] = []; - for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) { - const media = this.view.queryResults.getResult(i); + for (let i = 0; i < this._media.length; ++i) { + const media = this._media[i]; if (media) { const slide = this._renderMediaItem(media, i); if (slide) { @@ -388,11 +425,25 @@ export class FrigateCardViewerCarousel extends LitElement { if (changedProps.has('viewerConfig')) { updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout); } + + if (changedProps.has('view')) { + const newMedia = + this.view?.queryResults?.getResults(this.viewFilterCameraID) ?? null; + const newSelected = + this.view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0; + const newSeek = this.view?.context?.mediaViewer?.seek; + + if (newMedia !== this._media || newSelected !== this.selected || !newSeek) { + setOrRemoveAttribute(this, false, 'unseekable'); + this._media = newMedia; + this.selected = newSelected; + } + } } protected render(): TemplateResult | void { - const resultCount = this.view?.queryResults?.getResultsCount() ?? 0; - if (!resultCount) { + const mediaCount = this._media?.length ?? 0; + if (!this._media || !mediaCount) { return dispatchMessageEvent(this, localize('common.no_media'), 'info', { icon: 'mdi:multimedia', }); @@ -401,82 +452,86 @@ export class FrigateCardViewerCarousel extends LitElement { // If there's no selected media, just choose the last (most recent one) to // avoid rendering a blank. This situation should not occur in practice, as // this view should not be called without a selected media. - const media = - this.view?.queryResults?.getSelectedResult() ?? - this.view?.queryResults?.getResult(resultCount - 1); - if ( - !this.hass || - !this.cameraManager || - !media || - !this.view || - !this.view.queryResults - ) { + const selectedMedia = this._media[this.selected] ?? this._media[mediaCount - 1]; + + if (!this.hass || !this.cameraManager || !selectedMedia) { return; } - const [prev, next] = this._getMediaNeighbors(); - + const neighbors = this._getMediaNeighbors(); const scroll = (direction: 'previous' | 'next'): void => { - const currentIndex = this.view?.queryResults?.getSelectedIndex() ?? null; - if (!this.view || !this.view?.queryResults || currentIndex === null) { + if (!neighbors || !this._media) { return; } - const newIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1; - if (newIndex >= 0 && newIndex < this.view.queryResults.getResultsCount()) { + const newIndex = + (direction === 'previous' ? neighbors.previous?.index : neighbors.next?.index) ?? + null; + if (newIndex !== null) { this._setViewSelectedIndex(newIndex); } }; const cameraMetadata = this.cameraManager.getCameraMetadata( this.hass, - media.getCameraID(), + selectedMedia.getCameraID(), ); - return html` ({ - draggable: this.viewerConfig?.draggable ?? true, - }))} - .carouselPlugins=${guard( - [this.viewerConfig, this.view.queryResults.getResults()], - this._getPlugins.bind(this), - )} - .label=${media.getTitle() ?? undefined} - .logo=${cameraMetadata?.engineLogo} - .titlePopupConfig=${this.viewerConfig?.controls.title} - .selected=${this.view?.queryResults?.getSelectedIndex() ?? 0} - transitionEffect=${this._getTransitionEffect()} - @frigate-card:media-carousel:select=${this._setViewHandler.bind(this)} - @frigate-card:media:loaded=${this._seekHandler.bind(this)} - > - { - scroll('previous'); - stopEventFromActivatingCardWideActions(ev); - }} - > - ${guard(this.view?.queryResults?.getResults(), () => this._getSlides())} - { - scroll('next'); - stopEventFromActivatingCardWideActions(ev); - }} - > - `; + const titleConfig = getDefaultTitleConfigForView( + this.view, + this.viewerConfig?.controls.title, + ); + + return html` + ({ + draggable: this.viewerConfig?.draggable ?? true, + }))} + .carouselPlugins=${guard( + [this.viewerConfig, this._media], + this._getPlugins.bind(this), + )} + .label=${selectedMedia.getTitle() ?? undefined} + .logo=${cameraMetadata?.engineLogo} + .titlePopupConfig=${titleConfig ?? undefined} + .selected=${this.selected ?? 0} + transitionEffect=${this._getTransitionEffect()} + @frigate-card:media-carousel:select=${this._setViewHandler.bind(this)} + @frigate-card:media:loaded=${this._seekHandler.bind(this)} + > + { + scroll('previous'); + stopEventFromActivatingCardWideActions(ev); + }} + > + ${guard(this._media, () => this._getSlides())} + { + scroll('next'); + stopEventFromActivatingCardWideActions(ev); + }} + > + +
+ + +
+ `; } /** @@ -484,13 +539,26 @@ export class FrigateCardViewerCarousel extends LitElement { */ protected async _seekHandler(): Promise { const seek = this.view?.context?.mediaViewer?.seek; - const media = this.view?.queryResults?.getSelectedResult(); - if (!this.hass || !media || !seek) { + if (!this.hass || !seek || !this._media || this.selected === null) { + return; + } + const selectedMedia = this._media[this.selected]; + if (!selectedMedia) { return; } + const seekTimeInMedia = selectedMedia.includesTime(seek); + + setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable'); + if (!seekTimeInMedia) { + this._getPlayer()?.pause(); + } else { + this._getPlayer()?.play(); + } + const seekTime = - (await this.cameraManager?.getMediaSeekTime(this.hass, media, seek)) ?? null; + (await this.cameraManager?.getMediaSeekTime(this.hass, selectedMedia, seek)) ?? + null; const player = this._getPlayer(); if (player && seekTime !== null) { player.seek(seekTime); @@ -530,6 +598,93 @@ export class FrigateCardViewerCarousel extends LitElement { } } +@customElement('frigate-card-viewer-grid') +export class FrigateCardViewerGrid extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public viewerConfig?: ViewerConfig; + + @property({ attribute: false }) + public resolvedMediaCache?: ResolvedMediaCache; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + protected _renderCarousel(filterCamera?: string): TemplateResult { + return html` + + + `; + } + + protected _gridSelectCamera(cameraID: string, view?: View): void { + const newView = view ?? this.view; + const promotedQueryResults = newView?.queryResults + ?.clone() + .promoteCameraSelectionToMainSelection(cameraID); + newView + ?.evolve({ + camera: cameraID, + queryResults: promotedQueryResults, + }) + .dispatchChangeEvent(this); + } + + protected willUpdate(changedProps: PropertyValues): void { + if ( + changedProps.has('view') && + this.view?.isGrid() && + this.view?.hasMultipleDisplayModes() + ) { + import('./media-grid.js'); + } + } + + protected render(): TemplateResult { + const cameraIDs = this.view?.queryResults?.getCameraIDs(); + if (!cameraIDs || !this.view?.isGrid() || !this.view?.hasMultipleDisplayModes()) { + return this._renderCarousel(); + } + + return html` + ) => + this._gridSelectCamera(ev.detail.selected)} + @frigate-card:view:change=${(ev: CustomEvent) => { + ev.stopPropagation(); + const childView = ev.detail; + this._gridSelectCamera(childView.camera, childView); + }} + > + ${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))} + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(basicBlockStyle); + } +} + @customElement(FRIGATE_CARD_VIEWER_PROVIDER) export class FrigateCardViewerProvider extends LitElement @@ -679,7 +834,7 @@ export class FrigateCardViewerProvider return; } - const results = new MediaQueriesResults(mediaArray); + const results = new MediaQueriesResults({ results: mediaArray }); results.selectResultIfFound( (clipMedia) => clipMedia.getID() === this.media?.getID(), ); @@ -830,6 +985,7 @@ declare global { interface HTMLElementTagNameMap { 'frigate-card-viewer-carousel': FrigateCardViewerCarousel; 'frigate-card-viewer': FrigateCardViewer; + 'frigate-card-viewer-grid': FrigateCardViewerGrid; FRIGATE_CARD_VIEWER_PROVIDER: FrigateCardViewerProvider; } } diff --git a/src/components/views.ts b/src/components/views.ts index f7032397..97a9a54b 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -199,7 +199,8 @@ export class FrigateCardViews extends LitElement { ` @@ -1826,6 +1885,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_LIVE_SHOW_IMAGE_DURING_LOAD, this._defaults.live.show_image_during_load, )} + ${this._renderViewDisplay( + MENU_LIVE_DISPLAY, + CONF_LIVE_DISPLAY_MODE, + CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR, + CONF_LIVE_DISPLAY_GRID_COLUMNS, + CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS, + )} ${this._putInSubmenu( MENU_LIVE_CONTROLS, true, @@ -1957,6 +2023,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP, this._defaults.media_viewer.snapshot_click_plays_clip, )} + ${this._renderViewDisplay( + MENU_MEDIA_VIEWER_DISPLAY, + CONF_MEDIA_VIEWER_DISPLAY_MODE, + CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR, + CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS, + CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS, + )} ${this._putInSubmenu( MENU_MEDIA_VIEWER_CONTROLS, true, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index a5b5b780..e84d933f 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -148,6 +148,17 @@ } } }, + "display": { + "editor_label": "Display", + "grid_columns": "Exact number of grid columns", + "grid_max_columns": "Maximum number of grid columns", + "grid_selected_width_factor": "Increase selected media width by this factor", + "mode": "Mode", + "modes": { + "single": "Show single media viewer", + "grid": "Show media viewer for each camera in a grid" + } + }, "layout": { "fit": "Layout fit", "fits": { @@ -269,6 +280,7 @@ "cameras": "Cameras", "clips": "Clips", "download": "Download", + "display_mode": "Display mode", "enabled": "Button enabled", "expand": "Expand", "frigate": "Frigate menu / Default view", @@ -454,6 +466,9 @@ "what": "What", "where": "Where" }, + "media_viewer": { + "unseekable": "Seek time not found in media" + }, "media_filter": { "all": "All", "camera": "Camera", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 2643abd5..35a150c4 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -148,6 +148,16 @@ } } }, + "display": { + "editor_label": "", + "grid_columns": "", + "grid_max_columns": "", + "grid_selected_width_factor": "", + "modes": { + "single": "", + "grid": "" + } + }, "layout": { "fit": "Adatta al layout", "fits": { @@ -267,6 +277,7 @@ "camera_ui": "Interfaccia utente della fotocamera", "cameras": "Telecamere", "clips": "Clip", + "display_mode": "", "download": "Download", "enabled": "Pulsante abilitato", "expand": "Espandere", @@ -473,6 +484,9 @@ }, "where": "Dove" }, + "media_viewer": { + "unseekable": "" + }, "recording": { "camera": "Camera", "duration": "Durata", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index c873ceb9..1609d398 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -148,6 +148,17 @@ } } }, + "display": { + "editor_label": "", + "grid_columns": "", + "grid_max_columns": "", + "grid_selected_width_factor": "", + "mode": "", + "modes": { + "single": "", + "grid": "" + } + }, "layout": { "fit": "Ajuste de layout", "fits": { @@ -268,6 +279,7 @@ "camera_ui": "Interface de usuário da câmera", "cameras": "Selecionar câmera", "clips": "Clipes", + "display_mode": "", "download": "Baixe a mídia do evento", "enabled": "Botão ativado", "expand": "Expandir", @@ -482,6 +494,9 @@ }, "where": "Onde" }, + "media_viewer": { + "unseekable": "" + }, "recording": { "camera": "Câmera", "duration": "Duração", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 6f4cd0ef..57fc800f 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -148,6 +148,17 @@ } } }, + "display": { + "editor_label": "", + "grid_columns": "", + "grid_max_columns": "", + "grid_selected_width_factor": "", + "mode": "", + "modes": { + "single": "", + "grid": "" + } + }, "layout": { "fit": "Fit", "fits": { @@ -260,6 +271,7 @@ "camera_ui": "Camera", "cameras": "Selecionar câmera", "clips": "Clipes", + "display_mode": "", "download": "Descarregar mídia do evento", "enabled": "Botão ativado", "expand": "Expandir", @@ -465,6 +477,9 @@ }, "where": "Onde" }, + "media_viewer": { + "unseekable": "" + }, "recording": { "camera": "Camera", "duration": "Duração", diff --git a/src/scss/live.scss b/src/scss/basic-block.scss similarity index 100% rename from src/scss/live.scss rename to src/scss/basic-block.scss diff --git a/src/scss/live-carousel.scss b/src/scss/live-carousel.scss index aab21ed3..d4dafaf1 100644 --- a/src/scss/live-carousel.scss +++ b/src/scss/live-carousel.scss @@ -1,3 +1,12 @@ +// If the carousel has an unselected attribute set on it, do not let the +// pointer interact (e.g. hover, scroll) with underlying elements. This is used +// when the carousel is part of a media-grid. Without this next/prev controls +// will enlarge on hover, and the wheel-gestures plugin may block scrolling. +// See matching in viewer-carousel.scss . +:host([unselected]) frigate-card-media-carousel { + pointer-events: none; +} + .embla__slide { height: 100%; flex: 0 0 100%; diff --git a/src/scss/live-image.scss b/src/scss/live-image.scss deleted file mode 100644 index f0f64c07..00000000 --- a/src/scss/live-image.scss +++ /dev/null @@ -1,5 +0,0 @@ -:host { - width: 100%; - height: 100%; - display: block; -} \ No newline at end of file diff --git a/src/scss/media-filter.scss b/src/scss/media-filter.scss index 46ad7beb..6dc9aa2f 100644 --- a/src/scss/media-filter.scss +++ b/src/scss/media-filter.scss @@ -21,4 +21,4 @@ frigate-card-select { padding: 5px; -} \ No newline at end of file +} diff --git a/src/scss/media-grid.scss b/src/scss/media-grid.scss new file mode 100644 index 00000000..a6b9b49c --- /dev/null +++ b/src/scss/media-grid.scss @@ -0,0 +1,50 @@ +:host { + display: block; + width: 100%; + height: 100%; + + --frigate-card-grid-border-size: 3px; + --frigate-card-grid-column-size: 100%; + --frigate-card-grid-selected-width-factor: 2; + + // Allow the grid to scroll if necessary (e.g. fullscreen). + overflow: auto; + + // Hide scrollbar: Firefox + scrollbar-width: none; + // Hide scrollbar: IE and Edge + -ms-overflow-style: none; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +:host::-webkit-scrollbar { + display: none; +} + +::slotted(*) { + box-sizing: border-box; + border-radius: var(--ha-card-border-radius, 4px); + overflow: hidden; + width: var(--frigate-card-grid-column-size); + + // Unselected items included a transparent border to act as the effective + // gutter between elements, and to ensure when the item is selected it does + // not change in size (even border-box sizing appears to allow size to change + // when the element has a non-fixed height). + border: var(--frigate-card-grid-border-size) solid transparent; +} + +::slotted([selected]) { + border: var(--frigate-card-grid-border-size) solid var(--primary-color); + width: min( + 100%, + calc( + var(--frigate-card-grid-selected-width-factor) * + var(--frigate-card-grid-column-size) + ) + ); +} + +slot { + display: block; +} diff --git a/src/scss/surround.scss b/src/scss/surround.scss deleted file mode 100644 index 9f92a4e0..00000000 --- a/src/scss/surround.scss +++ /dev/null @@ -1,5 +0,0 @@ -:host { - width: 100%; - height: 100%; - display: block; -} diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss deleted file mode 100644 index f0f64c07..00000000 --- a/src/scss/timeline.scss +++ /dev/null @@ -1,5 +0,0 @@ -:host { - width: 100%; - height: 100%; - display: block; -} \ No newline at end of file diff --git a/src/scss/title-control.scss b/src/scss/title-control.scss index 6f2c9264..ca49513a 100644 --- a/src/scss/title-control.scss +++ b/src/scss/title-control.scss @@ -1,6 +1,8 @@ :host { --paper-toast-background-color: rgba(0,0,0,0.6); --paper-toast-color: white; + + pointer-events: none; } paper-toast { diff --git a/src/scss/viewer-carousel.scss b/src/scss/viewer-carousel.scss index aab21ed3..b7bc6bfb 100644 --- a/src/scss/viewer-carousel.scss +++ b/src/scss/viewer-carousel.scss @@ -1,3 +1,35 @@ +:host { + // Center unseekable icon. + position: relative; +} + +// If the carousel has an unselected attribute set on it, do not let the +// pointer interact (e.g. hover, scroll) with underlying elements. This is used +// when the carousel is part of a media-grid. Without this next/prev controls +// will enlarge on hover, and the wheel-gestures plugin may block scrolling. +// See matching in live-carousel.scss . +:host([unselected]) frigate-card-media-carousel, +:host([unselected]) .seek-warning +{ + pointer-events: none; +} + +:host([unseekable]) frigate-card-media-carousel { + filter: brightness(50%); +} +:host([unseekable]) .seek-warning { + display: block +} + +.seek-warning { + display: none; + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); + color: white; +} + .embla__slide { height: 100%; flex: 0 0 100%; diff --git a/src/types.ts b/src/types.ts index 7f3e0f7d..d98ec638 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,6 +105,17 @@ export class FrigateCardError extends Error { } } +const viewDisplayModeSchema = z.enum(['single', 'grid']); +export type ViewDisplayMode = z.infer; + +const viewDisplaySchema = z.object({ + mode: viewDisplayModeSchema, + grid_selected_width_factor: z.number().min(0).optional(), + grid_max_columns: z.number().min(0).optional(), + grid_columns: z.number().min(0).optional(), +}).optional(); +export type ViewDisplayConfig = z.infer; + /** * Action Types (for "Picture Elements" / Menu) */ @@ -231,6 +242,7 @@ const FRIGATE_CARD_ACTIONS = [ 'camera_select', 'live_substream_select', 'media_player', + 'display_mode_select', ] as const; export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number]; @@ -256,6 +268,12 @@ const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.ex media_player: z.string(), media_player_action: z.enum(['play', 'stop']), }); +const frigateCardViewDisplayModeActionSchema = frigateCardCustomActionsBaseSchema.extend( + { + frigate_card_action: z.literal('display_mode_select'), + display_mode: viewDisplayModeSchema, + }, +); export const frigateCardCustomActionSchema = z.union([ frigateCardViewActionSchema, @@ -263,6 +281,7 @@ export const frigateCardCustomActionSchema = z.union([ frigateCardCameraSelectActionSchema, frigateCardLiveDependencySelectActionSchema, frigateCardMediaPlayerActionSchema, + frigateCardViewDisplayModeActionSchema, ]); export type FrigateCardCustomAction = z.infer; @@ -647,6 +666,7 @@ export const frigateCardConditionSchema = z.object({ media_loaded: z.boolean().optional(), state: stateConditions.optional(), media_query: z.string().optional(), + display_mode: viewDisplayModeSchema.optional(), }); export type FrigateCardCondition = z.infer; @@ -946,6 +966,7 @@ const liveConfigDefault = { zoomable: true, transition_effect: 'slide' as const, show_image_during_load: true, + mode: 'single' as const, controls: { builtin: true, next_previous: { @@ -954,10 +975,6 @@ const liveConfigDefault = { }, thumbnails: liveThumbnailControlsDefaults, timeline: miniTimelineConfigDefault, - title: { - mode: 'popup-bottom-right' as const, - duration_seconds: 2, - }, }, microphone: { ...microphoneConfigDefault, @@ -990,16 +1007,7 @@ const liveOverridableConfigSchema = z liveConfigDefault.controls.thumbnails, ), timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline), - title: titleControlConfigSchema - .extend({ - mode: titleControlConfigSchema.shape.mode.default( - liveConfigDefault.controls.title.mode, - ), - duration_seconds: titleControlConfigSchema.shape.duration_seconds.default( - liveConfigDefault.controls.title.duration_seconds, - ), - }) - .default(liveConfigDefault.controls.title), + title: titleControlConfigSchema.optional(), }) .default(liveConfigDefault.controls), show_image_during_load: z @@ -1008,6 +1016,7 @@ const liveOverridableConfigSchema = z layout: mediaLayoutConfigSchema.optional(), microphone: microphoneConfigSchema.default(liveConfigDefault.microphone), zoomable: z.boolean().default(liveConfigDefault.zoomable), + display: viewDisplaySchema, }) .merge(actionsSchema); @@ -1078,6 +1087,7 @@ const menuConfigDefault = { play: hiddenButtonDefault, recordings: hiddenButtonDefault, screenshot: hiddenButtonDefault, + display_mode: visibleButtonDefault, }, button_size: 40, }; @@ -1124,6 +1134,7 @@ const menuConfigSchema = z mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute), play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot), + display_mode: visibleButtonSchema.default(menuConfigDefault.buttons.display_mode), }) .default(menuConfigDefault.buttons), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), @@ -1144,6 +1155,7 @@ const viewerConfigDefault = { zoomable: true, transition_effect: 'slide' as const, snapshot_click_plays_clip: true, + display_mode: 'single' as const, controls: { builtin: true, next_previous: { @@ -1190,6 +1202,7 @@ const viewerConfigSchema = z snapshot_click_plays_clip: z .boolean() .default(viewerConfigDefault.snapshot_click_plays_clip), + display: viewDisplaySchema, controls: z .object({ builtin: z.boolean().default(viewerConfigDefault.controls.builtin), @@ -1373,7 +1386,7 @@ const performanceConfigDefault = { }, }; -const performanceConfigSchema = z +export const performanceConfigSchema = z .object({ profile: z.enum(['low', 'high']).default(performanceConfigDefault.profile), features: z diff --git a/src/utils/action.ts b/src/utils/action.ts index e065d7be..2c7ce74a 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -11,6 +11,7 @@ import { FrigateCardCustomAction, frigateCardCustomActionSchema, FrigateCardViewAction, + ViewDisplayMode, } from '../types.js'; /** @@ -42,6 +43,7 @@ export function createFrigateCardCustomAction( camera?: string; media_player?: string; media_player_action?: 'play' | 'stop'; + display_mode?: ViewDisplayMode; }, ): FrigateCardCustomAction | null { if (action === 'camera_select' || action === 'live_substream_select') { @@ -67,6 +69,17 @@ export function createFrigateCardCustomAction( ...(args.cardID && { card_id: args.cardID }), }; } + if (action === 'display_mode_select') { + if (!args?.display_mode) { + return null; + } + return { + action: 'fire-dom-event', + frigate_card_action: action, + display_mode: args?.display_mode, + ...(args.cardID && { card_id: args.cardID }), + }; + } return { action: 'fire-dom-event', frigate_card_action: action, diff --git a/src/utils/basic.ts b/src/utils/basic.ts index ac4b9a2d..2321fe5e 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -221,3 +221,8 @@ export const setOrRemoveAttribute = ( element.removeAttribute(name); } }; + +/** + * Allow typescript to narrow types based on truthy filter. + */ +export const filterTruthy = (x: T | false | undefined | null | '' | 0): x is T => !!x; diff --git a/src/utils/media-grid-controller.ts b/src/utils/media-grid-controller.ts new file mode 100644 index 00000000..6f09dfb9 --- /dev/null +++ b/src/utils/media-grid-controller.ts @@ -0,0 +1,328 @@ +import throttle from 'lodash-es/throttle'; +import Masonry from 'masonry-layout'; +import { MediaLoadedInfo, ViewDisplayConfig } from '../types'; +import { dispatchFrigateCardEvent, setOrRemoveAttribute } from './basic'; +import { + FrigateMediaLoadedEventTarget, + dispatchExistingMediaLoadedInfoAsEvent, + dispatchMediaUnloadedEvent, +} from './media-info'; + +// The default minimum cell width: if the columns are not specified this value +// is used to compute the number of columns, always trying to keep each cell as +// at least this width. On Android, a card in portrait mode is 396 pixels, and +// we'd like to support two cells wide in that configuration. +const MEDIA_GRID_DEFAULT_MIN_CELL_WIDTH = 190; +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 MediaGridContents = Map; + +export interface MediaGridSelected { + selected: GridID; +} + +export interface MediaGridConstructorOptions { + selected?: GridID; + idAttribute?: string; +} + +const SELECT_CHILD_EVENTS = ['click', 'touchend']; + +export class MediaGridController { + protected _host: HTMLElement; + + protected _selected: GridID | null; + protected _mediaLoadedInfoMap: Map = new Map(); + protected _gridContents: MediaGridContents = new Map(); + protected _masonry: Masonry | null = null; + protected _displayConfig: ViewDisplayConfig | null = null; + protected _hostWidth: number; + protected _idAttribute: string; + + protected _throttledLayout = throttle( + () => this._masonry?.layout?.(), + // Throttle layout calls to larger than the masonry.js transitionDuration + // value specified below. + 500, + { trailing: true, leading: false }, + ); + + protected _mutationObserver = new MutationObserver( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_mutations: MutationRecord[], _observer: MutationObserver) => + this._calculateGridContentsFromHost(), + ); + protected _cellResizeObserver = new ResizeObserver(this._cellResizeHandler.bind(this)); + protected _hostResizeObserver = new ResizeObserver(this._hostResizeHandler.bind(this)); + + constructor(host: HTMLElement, options?: MediaGridConstructorOptions) { + this._host = host; + this._selected = options?.selected ?? null; + this._idAttribute = options?.idAttribute ?? 'grid-id'; + this._hostWidth = this._host.getBoundingClientRect().width; + this._hostResizeObserver.observe(host); + + this._calculateGridContentsFromHost(); + this._mutationObserver.observe(host, { childList: true }); + } + + public destroy(): void { + this._hostResizeObserver.disconnect(); + this._cellResizeObserver.disconnect(); + this._mediaLoadedInfoMap.clear(); + this._masonry?.destroy?.(); + this._masonry = null; + + for (const child of this._gridContents.values()) { + this._removeChildEventListeners(child); + } + this._gridContents.clear(); + } + + public setDisplayConfig(displayConfig: ViewDisplayConfig | null): void { + this._displayConfig = displayConfig; + this._calculateGridContentsFromHost(); + } + + public getGridContents(): MediaGridContents { + return this._gridContents; + } + + public getGridSize(): number { + return this._gridContents.size; + } + + public getSelected(): GridID | null { + return this._selected; + } + + public selectCell(id: GridID) { + if (this._selected === id) { + return; + } + + this._selected = id; + dispatchFrigateCardEvent(this._host, 'media-grid:selected', { selected: id }); + + const mediaLoadedInfo = this._mediaLoadedInfoMap.get(id); + if (mediaLoadedInfo) { + dispatchExistingMediaLoadedInfoAsEvent(this._host, mediaLoadedInfo); + } + + this._updateSelectedStylesOnElements(); + + // Sizes may change when an element is selected, so re-do the layout (must + // come after the call to _updateStylesOnElements in order to ensure the + // right styles are applied first). + this._throttledLayout(); + } + + public unselectAll() { + if (this._selected !== null) { + dispatchMediaUnloadedEvent(this._host); + dispatchFrigateCardEvent(this._host, 'media-grid:unselected'); + } + this._selected = null; + this._updateSelectedStylesOnElements(); + } + + protected _calculateGridContentsFromHost(): void { + let childrenElements: Element[]; + + if (this._host instanceof HTMLSlotElement) { + childrenElements = this._host.assignedElements({ flatten: true }); + } else { + childrenElements = [...this._host.children]; + } + + const gridContents: MediaGridContents = new Map(); + for (const child of childrenElements) { + if (child instanceof HTMLElement) { + const id = child.getAttribute(this._idAttribute) || String(gridContents.size); + gridContents.set(id, child); + } + } + + this._setGridContents(gridContents); + } + + protected _setGridContents(elements: MediaGridContents): void { + this._gridContents = elements; + + // Remove media loaded info objects that belong to objects no longer in the + // grid. + for (const key of this._mediaLoadedInfoMap.keys()) { + if (!elements.has(key)) { + this._mediaLoadedInfoMap.delete(key); + } + } + + if (this._selected !== null && !this._gridContents.has(this._selected)) { + this.unselectAll(); + } + + for (const element of elements.values()) { + this._removeChildEventListeners(element); + this._addChildEventListeners(element); + } + + this._setColumnSizeStyles(); + this._createMasonry(); + + // Observe grid elements for size changes. + this._cellResizeObserver.disconnect(); + for (const child of elements.values()) { + this._cellResizeObserver.observe(child); + } + + this._updateSelectedStylesOnElements(); + this._setColumnSizeStyles(); + } + + protected _handleMediaLoadedInfoEvent = (ev: CustomEvent): void => { + const eventPath = ev.composedPath(); + + for (const [id, element] of this._gridContents.entries()) { + if (eventPath.includes(element)) { + this._mediaLoadedInfoMap.set(id, ev.detail); + if (id !== this._selected) { + ev.stopPropagation(); + } + break; + } + } + }; + + protected _hostResizeHandler(): void { + const dimensions = this._host.getBoundingClientRect(); + + // Only resize things if the width has changed. It is expected that the + // height may change during the layout. + if (dimensions.width !== this._hostWidth) { + this._hostWidth = dimensions.width; + + // Reset the column CSS sizes first. + this._setColumnSizeStyles(); + + // Need to recreate the masonry layout since the column width will differ. + this._createMasonry(); + } + } + + protected _cellResizeHandler(): void { + this._throttledLayout(); + } + + protected _removeChildEventListeners(child: MediaGridChild): void { + for (const event of SELECT_CHILD_EVENTS) { + child.removeEventListener(event, this._handleSelectGridCellEvent, { + capture: true, + }); + } + + child.removeEventListener( + 'frigate-card:media:loaded', + this._handleMediaLoadedInfoEvent, + ); + } + + protected _addChildEventListeners(child: MediaGridChild): void { + for (const event of SELECT_CHILD_EVENTS) { + child.addEventListener(event, this._handleSelectGridCellEvent, { + capture: true, + }); + } + + child.addEventListener( + 'frigate-card:media:loaded', + this._handleMediaLoadedInfoEvent, + ); + } + + protected _createMasonry(): void { + if (this._masonry) { + this._masonry.destroy?.(); + } + + this._masonry = new Masonry(this._host, { + columnWidth: this._getColumnSize(), + initLayout: false, + percentPosition: true, + transitionDuration: '0.3s', + }); + this._masonry.addItems?.([...this._gridContents.values()]); + this._throttledLayout(); + } + + protected _handleSelectGridCellEvent = (ev: Event): void => { + const eventPath = ev.composedPath(); + + for (const [id, element] of this._gridContents.entries()) { + if (eventPath.includes(element)) { + if (this._selected !== id) { + this.selectCell(id); + ev.stopPropagation(); + } + break; + } + } + }; + + protected _updateSelectedStylesOnElements(): void { + for (const [id, element] of this._gridContents.entries()) { + setOrRemoveAttribute(element, id === this._selected, 'selected'); + + // Explicitly use an 'unselected' attribute vs a :not(selected) such that + // a carousel with neither selected nor unselected will behave normally. + // This matches a css selector in viewer-carousel.scss . + setOrRemoveAttribute(element, id !== this._selected, 'unselected'); + } + } + + protected _getColumnSize(): number { + return Math.round(this._hostWidth / this._getColumns()); + } + + protected _getColumns(): number { + if (this._displayConfig?.grid_columns) { + return this._displayConfig?.grid_columns; + } + + const maxColumns = this._displayConfig?.grid_max_columns ?? Infinity; + + // See if we can get a multi-column layout using the ideal cell width. + const idealColumns = Math.min( + maxColumns, + Math.floor(this._hostWidth / MEDIA_GRID_DEFAULT_IDEAL_CELL_WIDTH), + ); + if (idealColumns > 1) { + return idealColumns; + } + + // If not, get a multi-column view using the minimum cell width. + const minColumns = Math.floor( + Math.min(maxColumns, this._hostWidth / MEDIA_GRID_DEFAULT_MIN_CELL_WIDTH), + ); + + // Last result use at least 1 column. + return Math.max(1, minColumns); + } + + protected _setColumnSizeStyles(): void { + this._host.style.setProperty( + '--frigate-card-grid-column-size', + `${this._getColumnSize()}px`, + ); + + this._host.style.setProperty( + '--frigate-card-grid-selected-width-factor', + `${ + this._displayConfig?.grid_selected_width_factor ?? + MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR + }`, + ); + } +} diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts index 22637d7d..3e01807a 100644 --- a/src/utils/media-info.ts +++ b/src/utils/media-info.ts @@ -111,3 +111,33 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean { info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF ); } + +// Facilities correct Typescript typing of media:loaded event handlers. +export interface FrigateMediaLoadedEventTarget extends EventTarget { + addEventListener( + event: 'frigate-card:media:loaded', + listener: ( + this: FrigateMediaLoadedEventTarget, + ev: CustomEvent, + ) => void, + options?: AddEventListenerOptions | boolean, + ): void; + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean, + ): void; + removeEventListener( + event: 'frigate-card:media:loaded', + listener: ( + this: FrigateMediaLoadedEventTarget, + ev: CustomEvent, + ) => void, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; +} diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 655b5ca9..76215f78 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -1,20 +1,20 @@ +import { HomeAssistant } from 'custom-card-helpers'; import { ViewContext } from 'view'; +import { CameraManager } from '../camera-manager/manager'; +import { MediaQuery } from '../camera-manager/types'; +import { dispatchFrigateCardErrorEvent } from '../components/message'; +import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const'; import { CardWideConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; -import { View } from '../view/view'; +import { ViewMedia } from '../view/media'; import { EventMediaQueries, MediaQueries, RecordingMediaQueries, } from '../view/media-queries'; -import { CameraManager } from '../camera-manager/manager'; -import { getAllDependentCameras } from './camera.js'; -import { ViewMedia } from '../view/media'; -import { HomeAssistant } from 'custom-card-helpers'; -import { dispatchFrigateCardErrorEvent } from '../components/message'; import { MediaQueriesResults } from '../view/media-queries-results'; +import { View } from '../view/view'; import { errorToConsole } from './basic'; -import { MediaQuery } from '../camera-manager/types'; -import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const'; +import { getAllDependentCameras } from './camera.js'; type ResultSelectType = 'latest' | 'time' | 'none'; @@ -25,13 +25,16 @@ export const changeViewToRecentEventsForCameraAndDependents = async ( cardWideConfig: CardWideConfig, view: View, options?: { + allCameras?: boolean; mediaType?: ClipsOrSnapshotsOrAll; targetView?: FrigateCardView; select?: ResultSelectType; }, ): Promise => { - const cameraIDs = getAllDependentCameras(cameraManager, view.camera); - if (!cameraIDs) { + const cameraIDs = options?.allCameras + ? cameraManager.getStore().getVisibleCameraIDs() + : getAllDependentCameras(cameraManager, view.camera); + if (!cameraIDs.size) { return; } @@ -84,12 +87,15 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( cardWideConfig: CardWideConfig, view: View, options?: { + allCameras?: boolean; targetView?: 'recording' | 'recordings'; select?: ResultSelectType; }, ): Promise => { - const cameraIDs = getAllDependentCameras(cameraManager, view.camera); - if (!cameraIDs) { + const cameraIDs = options?.allCameras + ? cameraManager.getStore().getVisibleCameraIDs() + : getAllDependentCameras(cameraManager, view.camera); + if (!cameraIDs.size) { return; } @@ -161,12 +167,7 @@ export const executeMediaQueryForView = async ( return null; } - const queryResults = new MediaQueriesResults( - mediaArray, - options?.select === 'latest' && mediaArray.length - ? mediaArray.length - 1 - : undefined, - ); + const queryResults = new MediaQueriesResults({ results: mediaArray }); let viewerContext: ViewContext | undefined = {}; if (options?.select === 'time' && options?.targetTime) { @@ -180,16 +181,14 @@ export const executeMediaQueryForView = async ( }; } - return ( - view - ?.evolve({ - query: query, - queryResults: queryResults, - view: options?.targetView, - camera: options?.targetCameraID, - }) - .mergeInContext(viewerContext) ?? null - ); + return view + .evolve({ + query: query, + queryResults: queryResults, + view: options?.targetView, + camera: options?.targetCameraID, + }) + .mergeInContext(viewerContext); }; /** @@ -201,7 +200,7 @@ export const executeMediaQueryForView = async ( */ export const findBestMediaIndex = ( mediaArray: ViewMedia[], - targetTime: Date + targetTime: Date, ): number | null => { let bestMatch: | { diff --git a/src/utils/menu-controller.ts b/src/utils/menu-controller.ts index 18d9a841..64123abd 100644 --- a/src/utils/menu-controller.ts +++ b/src/utils/menu-controller.ts @@ -5,11 +5,11 @@ import { CameraManager } from '../camera-manager/manager'; import { FRIGATE_BUTTON_MENU_ICON } from '../const'; import { localize } from '../localize/localize.js'; import { - FRIGATE_CARD_VIEWS_USER_SPECIFIED, - FrigateCardConfig, - FrigateCardCustomAction, - MediaLoadedInfo, - MenuButton, + FrigateCardConfig, + FrigateCardCustomAction, + FRIGATE_CARD_VIEWS_USER_SPECIFIED, + MediaLoadedInfo, + MenuButton, } from '../types'; import { View } from '../view/view'; import { createFrigateCardCustomAction } from './action'; @@ -388,10 +388,28 @@ export class MenuButtonController { ...config.menu.buttons.screenshot, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.screenshot'), - tap_action: createFrigateCardCustomAction('screenshot') as FrigateCardCustomAction, + tap_action: createFrigateCardCustomAction( + 'screenshot', + ) as FrigateCardCustomAction, }); } + if (view.hasMultipleDisplayModes(visibleCameras.size)) { + const isGrid = view.isGrid(); + const action = createFrigateCardCustomAction('display_mode_select', { + display_mode: isGrid ? 'single' : 'grid', + }); + if (action) { + buttons.push({ + icon: isGrid ? 'mdi:grid-off' : 'mdi:grid', + ...config.menu.buttons.display_mode, + type: 'custom:frigate-card-menu-icon', + title: localize('config.menu.buttons.display_mode'), + tap_action: action, + }); + } + } + const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({ style: this._getStyleFromActions(config, view, button), ...button, diff --git a/src/utils/zoom/zoom.ts b/src/utils/zoom/zoom.ts index b3fc9d0d..08f1ce06 100644 --- a/src/utils/zoom/zoom.ts +++ b/src/utils/zoom/zoom.ts @@ -141,15 +141,20 @@ export class Zoom { } public deactivate(): void { - const unregisterListener = (events: string[], func: (ev: Event) => void) => { + const unregisterListener = ( + events: string[], + func: (ev: Event) => void, + options?: EventListenerOptions, + ) => { events.forEach((eventName) => { - this._element.removeEventListener(eventName, func); + this._element.removeEventListener(eventName, func, options); }); }; - unregisterListener(this._events['down'], this._downHandler); - unregisterListener(this._events['move'], this._moveHandler); - unregisterListener(this._events['up'], this._upHandler); + unregisterListener(this._events['down'], this._downHandler, { capture: true }); + unregisterListener(this._events['move'], this._moveHandler, { capture: true }); + unregisterListener(this._events['up'], this._upHandler, { capture: true }); unregisterListener(['wheel'], this._wheelHandler); + unregisterListener(['click'], this._clickHandler, { capture: true }); } } diff --git a/src/view/media-queries-results.ts b/src/view/media-queries-results.ts index e0d9f5d4..cde420c2 100644 --- a/src/view/media-queries-results.ts +++ b/src/view/media-queries-results.ts @@ -1,40 +1,151 @@ -import clone from 'lodash-es/clone.js'; import { isSuperset } from '../utils/basic.js'; import { ViewMedia } from './media.js'; -export class MediaQueriesResults { - protected _results: ViewMedia[] | null = null; - protected _resultsTimestamp: Date | null = null; - protected _selectedIndex: number | null = null; +type CameraResultSlices = Map; +type SelectApproach = 'first' | 'last'; - constructor(results?: ViewMedia[], selectedIndex?: number | null) { - if (results) { - this.setResults(results); +interface ResultSliceOptions { + results?: ViewMedia[]; + selectedIndex?: number | null; + selectApproach?: SelectApproach; +} + +class ResultSlice { + protected _results: ViewMedia[]; + protected _selectedIndex: number | null; + + constructor(options?: ResultSliceOptions) { + this._results = options?.results ?? []; + this._selectedIndex = this._getInitialSelectedIndex(options); + } + + protected _getInitialSelectedIndex(options?: ResultSliceOptions): number | null { + if (options?.selectedIndex !== undefined && options?.selectedIndex !== null) { + return options.selectedIndex; } - if (selectedIndex !== undefined) { - this.selectResult(selectedIndex); + if (options?.results && options.results.length) { + if (!options?.selectApproach || options?.selectApproach === 'last') { + return options.results.length - 1; + } else if (options.selectApproach === 'first') { + return 0; + } + } + return null; + } + + public clone(): ResultSlice { + return new ResultSlice({ + results: this._results, + selectedIndex: this._selectedIndex, + }); + } + + public getResults(): ViewMedia[] { + return this._results; + } + public getSelectedIndex(): number | null { + return this._selectedIndex; + } + public getResultsCount(): number { + return this.getResults().length; + } + public hasResults(): boolean { + return this.getResultsCount() !== 0; + } + public getResult(index?: number): ViewMedia | null { + return index === undefined ? null : this._results[index]; + } + public getSelectedResult(): ViewMedia | null { + const index = this.getSelectedIndex(); + return index !== null ? this.getResult(index) : null; + } + public hasSelectedResult(): boolean { + return this.getSelectedResult() !== null; + } + public resetSelectedResult(): void { + this._selectedIndex = null; + } + + public selectIndex(index: number | null): void { + if (index === null || (index >= 0 && index < this._results.length)) { + this._selectedIndex = index; + } + } + public selectResultIfFound(func: (media: ViewMedia) => boolean): void { + for (const [index, result] of this._results.entries()) { + if (func(result)) { + this.selectIndex(index); + break; + } + } + } + public selectBestResult(func: (media: ViewMedia[]) => number | null): void { + const resultIndex = func(this._results); + if (resultIndex !== null) { + this.selectIndex(resultIndex); + } + } +} + +interface ResultSliceSelectionCriteria { + main?: boolean; + cameraID?: string; + allCameras?: boolean; +} + +export class MediaQueriesResults { + protected _resultsTimestamp: Date | null = null; + protected _main: ResultSlice; + protected _cameras: CameraResultSlices = new Map(); + + constructor(options?: ResultSliceOptions) { + this._resultsTimestamp = new Date(); + this._main = new ResultSlice(options); + this._buildByCameraSlices(options?.selectApproach); + } + + protected _buildByCameraSlices(selectApproach?: SelectApproach): void { + const cameraMap: Map = new Map(); + for (const result of this._main.getResults()) { + const cameraID = result.getCameraID(); + const media: ViewMedia[] = cameraMap.get(cameraID) ?? []; + media.push(result); + cameraMap.set(cameraID, media); + } + + for (const [cameraID, media] of cameraMap.entries()) { + this._cameras.set( + cameraID, + new ResultSlice({ + results: media, + selectApproach: selectApproach, + }), + ); } } public clone(): MediaQueriesResults { - // Shallow clone -- will reuse the same _results object (as there are no + // Shallow clone -- will reuse the same results object (as there are no // methods that support modification of the results themselves, and since - // changing the selectedIndex on a consistent set of results is a common + // changing the index on a consistent set of results is a very common // operation). - return clone(this); + const copy = new MediaQueriesResults(); + copy._resultsTimestamp = this._resultsTimestamp; + copy._main = this._main.clone(); + + for (const [cameraID, slice] of this._cameras.entries()) { + copy._cameras.set(cameraID, slice.clone()); + } + return copy; } public isSupersetOf(that: MediaQueriesResults): boolean { - if (!this._results || !that._results) { - return false; - } - - const thisMediaIDs = new Set(this._results.map((media) => media.getID())); - const thatMediaIDs = new Set(that._results.map((media) => media.getID())); + const thisMediaIDs = new Set(this._main.getResults()?.map((media) => media.getID())); + const thatMediaIDs = new Set(that._main.getResults()?.map((media) => media.getID())); if ( - !thisMediaIDs || - !thatMediaIDs || + !thisMediaIDs.size || + !thatMediaIDs.size || // If either media sets contain a null identifier (i.e. a media item with // no ID) we must assume this is not a subset as multiple media items may // reduce to the same null identifier above. @@ -46,68 +157,105 @@ export class MediaQueriesResults { return isSuperset(thisMediaIDs, thatMediaIDs); } - public getResults(): ViewMedia[] | null { - return this._results; + public getCameraIDs(): Set { + return new Set(this._cameras.keys()); } - public getResultsCount(): number { - return this._results?.length ?? 0; + + public getSlice(cameraID?: string): ResultSlice | null { + return cameraID ? this._cameras.get(cameraID) ?? null : this._main; } - public hasResults(): boolean { - return !!this._results; + + public getResults(cameraID?: string): ViewMedia[] | null { + return this.getSlice(cameraID)?.getResults() ?? null; } - public setResults(results: ViewMedia[]) { - this._results = results; - this._resultsTimestamp = new Date(); + public getResultsCount(cameraID?: string): number { + return this.getSlice(cameraID)?.getResultsCount() ?? 0; } - public getResult(index?: number): ViewMedia | null { - if (!this._results || index === undefined) { - return null; - } - return this._results[index]; + public hasResults(cameraID?: string): boolean { + return this.getSlice(cameraID)?.getResultsCount() !== 0; } - public getSelectedResult(): ViewMedia | null { - return this._selectedIndex === null ? null : this.getResult(this._selectedIndex); + public getResult(index?: number, cameraID?: string): ViewMedia | null { + return this.getSlice(cameraID)?.getResult(index) ?? null; } - public getSelectedIndex(): number | null { - return this._selectedIndex; + public getSelectedIndex(cameraID?: string): number | null { + return this.getSlice(cameraID)?.getSelectedIndex() ?? null; } - public hasSelectedResult(): boolean { - return this.getSelectedResult() !== null; + public getSelectedResult(cameraID?: string): ViewMedia | null { + return this.getSlice(cameraID)?.getSelectedResult() ?? null; } - public resetSelectedResult(): MediaQueriesResults { - this._selectedIndex = null; + public hasSelectedResult(cameraID?: string): boolean { + return this.getSlice(cameraID)?.hasSelectedResult() ?? false; + } + public resetSelectedResult(cameraID?: string): MediaQueriesResults { + this.getSlice(cameraID)?.resetSelectedResult(); return this; } public getResultsTimestamp(): Date | null { return this._resultsTimestamp; } - public selectResult(index: number | null): MediaQueriesResults { - if ( - index === null || - (this._results && index >= 0 && index < this._results.length) - ) { - this._selectedIndex = index; + public selectIndex(index: number, cameraID?: string): MediaQueriesResults { + this.getSlice(cameraID)?.selectIndex(index); + if (!cameraID) { + // If the main selection is changed, it must also change the matching + // camera selection. + this.demoteMainSelectionToCameraSelection(); } return this; } - public selectResultIfFound(func: (media: ViewMedia) => boolean): MediaQueriesResults { - for (const [index, result] of this._results?.entries() ?? []) { - if (func(result)) { - this._selectedIndex = index; - break; - } + + public demoteMainSelectionToCameraSelection(): MediaQueriesResults { + const selected = this.getSelectedResult(); + if (selected) { + const cameraID = selected.getCameraID(); + this.resetSelectedResult(cameraID); + this.selectResultIfFound((media) => media === selected, { cameraID: cameraID }); + } + return this; + } + + public promoteCameraSelectionToMainSelection(cameraID: string): MediaQueriesResults { + const selected = this.getSelectedResult(cameraID); + this.resetSelectedResult(); + this.selectResultIfFound((media) => media === selected); + return this; + } + + protected _getCameraIDsFromCriteria( + criteria?: ResultSliceSelectionCriteria, + ): Set | null { + return criteria?.allCameras + ? this.getCameraIDs() + : criteria?.cameraID + ? new Set([criteria.cameraID]) + : null; + } + + public selectResultIfFound( + func: (media: ViewMedia) => boolean, + criteria?: ResultSliceSelectionCriteria, + ): MediaQueriesResults { + if (!criteria || criteria?.main) { + this._main.selectResultIfFound(func); + this.demoteMainSelectionToCameraSelection(); + } + const cameraIDs = this._getCameraIDsFromCriteria(criteria); + for (const cameraID of cameraIDs ?? []) { + this.getSlice(cameraID)?.selectResultIfFound(func); } return this; } public selectBestResult( func: (media: ViewMedia[]) => number | null, + criteria?: ResultSliceSelectionCriteria, ): MediaQueriesResults { - if (this._results) { - const resultIndex = func(this._results); - if (resultIndex !== null) { - this._selectedIndex = resultIndex; - } + if (!criteria || criteria.main) { + this._main.selectBestResult(func); + this.demoteMainSelectionToCameraSelection(); + } + const cameraIDs = this._getCameraIDsFromCriteria(criteria); + for (const cameraID of cameraIDs ?? []) { + this.getSlice(cameraID)?.selectBestResult(func); } return this; } diff --git a/src/view/media-queries.ts b/src/view/media-queries.ts index 718c1d98..2a75e5e3 100644 --- a/src/view/media-queries.ts +++ b/src/view/media-queries.ts @@ -23,14 +23,24 @@ class MediaQueriesBase { public setQueries(queries: T[]): void { this._queries = queries; } + + public hasQueriesForCameraIDs(cameraIDs: Set) { + for (const cameraID of cameraIDs) { + if (!this._queries?.some((query) => query.cameraIDs.has(cameraID))) { + return false; + } + } + return true; + } } export class EventMediaQueries extends MediaQueriesBase { - public convertToClipsQueries(): void { + public convertToClipsQueries(): this { for (const query of this._queries ?? []) { delete query.hasSnapshot; query.hasClip = true; } + return this; } public clone(): EventMediaQueries { diff --git a/src/view/media.ts b/src/view/media.ts index abb62371..369aa63e 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -13,9 +13,6 @@ export class ViewMedia { this._mediaType = mediaType; this._cameraID = cameraID; } - public getContentType(): 'image' | 'video' { - return this._mediaType === 'snapshot' ? 'image' : 'video'; - } public getCameraID(): string { return this._cameraID; } diff --git a/src/view/view.ts b/src/view/view.ts index 0f417081..e0f13ec1 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,5 +1,5 @@ import { ViewContext } from 'view'; -import { ClipsOrSnapshots, FrigateCardView } from '../types.js'; +import { ClipsOrSnapshots, FrigateCardView, ViewDisplayMode } from '../types.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { MediaQueries } from './media-queries'; import { MediaQueriesClassifier } from './media-queries-classifier.js'; @@ -11,6 +11,7 @@ interface ViewEvolveParameters { query?: MediaQueries | null; queryResults?: MediaQueriesResults | null; context?: ViewContext | null; + displayMode?: ViewDisplayMode | null; } export interface ViewParameters extends ViewEvolveParameters { @@ -24,6 +25,7 @@ export class View { public query: MediaQueries | null; public queryResults: MediaQueriesResults | null; public context: ViewContext | null; + public displayMode: ViewDisplayMode | null; constructor(params: ViewParameters) { this.view = params.view; @@ -31,6 +33,7 @@ export class View { this.query = params.query ?? null; this.queryResults = params.queryResults ?? null; this.context = params.context ?? null; + this.displayMode = params.displayMode ?? null; } /** @@ -142,6 +145,7 @@ export class View { query: this.query?.clone() ?? null, queryResults: this.queryResults?.clone() ?? null, context: this.context, + displayMode: this.displayMode, }); } @@ -160,6 +164,8 @@ export class View { ? params.queryResults : this.queryResults?.clone() ?? null, context: params.context !== undefined ? params.context : this.context, + displayMode: + params.displayMode !== undefined ? params.displayMode : this.displayMode, }); } @@ -185,6 +191,17 @@ export class View { return this; } + public removeContextProperty( + contextKey: keyof ViewContext, + removeKey: PropertyKey, + ): View { + const contextObj = this.context?.[contextKey]; + if (contextObj) { + delete contextObj[removeKey]; + } + return this; + } + /** * Determine if current view matches a named view. */ @@ -214,6 +231,13 @@ export class View { return ['clip', 'snapshot', 'media', 'recording'].includes(this.view); } + public hasMultipleDisplayModes(cameraCount?: number): boolean { + return ( + (this.is('live') && (cameraCount ?? 0) > 1) || + (this.isViewerView() && (this.queryResults?.getCameraIDs().size ?? 0) > 1) + ); + } + /** * Get the default media type for this view if available. * @returns Whether the default media is `clips`, `snapshots`, `recordings` or unknown @@ -232,6 +256,10 @@ export class View { return null; } + public isGrid(): boolean { + return this.displayMode === 'grid'; + } + /** * Dispatch an event to request a view change. * @param target The target dispatching the event. diff --git a/tests/camera-manager/utils.test.ts b/tests/camera-manager/utils.test.ts index 0a870cc4..913ad754 100644 --- a/tests/camera-manager/utils.test.ts +++ b/tests/camera-manager/utils.test.ts @@ -76,30 +76,26 @@ describe('capEndDate', () => { }); describe('sortMedia', () => { - const media_1 = new TestViewMedia( - 'id-1', - new Date('2023-04-29T14:25'), - 'clip', - 'camera-1', - ); - const media_2 = new TestViewMedia( - 'id-2', - new Date('2023-04-29T14:26'), - 'clip', - 'camera-1', - ); - const media_3_dup_id = new TestViewMedia( - 'id-2', - new Date('2023-04-29T14:26'), - 'clip', - 'camera-1', - ); - const media_4_no_id = new TestViewMedia( - null, - new Date('2023-04-29T14:27'), - 'clip', - 'camera-1', - ); + const media_1 = new TestViewMedia({ + id: 'id-1', + startTime: new Date('2023-04-29T14:25'), + cameraID: 'camera-1', + }); + const media_2 = new TestViewMedia({ + id: 'id-2', + startTime: new Date('2023-04-29T14:26'), + cameraID: 'camera-1', + }); + const media_3_dup_id = new TestViewMedia({ + id: 'id-2', + startTime: new Date('2023-04-29T14:26'), + cameraID: 'camera-1', + }); + const media_4_no_id = new TestViewMedia({ + id: null, + startTime: new Date('2023-04-29T14:27'), + cameraID: 'camera-1', + }); it('should sort sorted media', () => { const media = [media_1, media_2]; diff --git a/tests/conditions.test.ts b/tests/conditions.test.ts index 3c98db99..d5eb3fbc 100644 --- a/tests/conditions.test.ts +++ b/tests/conditions.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ConditionController, ConditionEvaluateRequestEvent, @@ -6,6 +6,7 @@ import { getOverriddenConfig, getOverridesByKey, } from '../src/conditions'; +import { FrigateCardCondition } from '../src/types'; import { createCondition, createConfig, createStateEntity } from './test-utils'; // @vitest-environment jsdom @@ -347,4 +348,14 @@ describe('ConditionController', () => { controller.destroy(); expect(removeEventListener).toBeCalled(); }); + + it('should evaluate conditions with display mode', () => { + const controller = new ConditionController(); + const condition: FrigateCardCondition = { display_mode: 'grid' }; + expect(controller.evaluateCondition(condition)).toBeFalsy(); + controller.setState({ displayMode: 'grid' }); + expect(controller.evaluateCondition(condition)).toBeTruthy(); + controller.setState({ displayMode: 'single' }); + expect(controller.evaluateCondition(condition)).toBeFalsy(); + }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index e7bda43d..1dc0bcec 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -9,6 +9,7 @@ import { CameraConfigs, CameraManagerCameraCapabilities, CameraManagerMediaCapabilities, + QueryType, } from '../src/camera-manager/types'; import { CameraConfig, @@ -16,10 +17,12 @@ import { FrigateCardCondition, FrigateCardConfig, MediaLoadedInfo, + PerformanceConfig, RawFrigateCardConfig, cameraConfigSchema, frigateCardConditionSchema, frigateCardConfigSchema, + performanceConfigSchema, } from '../src/types'; import { Entity } from '../src/utils/ha/entity-registry/types'; import { ViewMedia, ViewMediaType } from '../src/view/media'; @@ -126,11 +129,25 @@ export const createCameraManager = (options?: { const configs = options?.configs ?? new Map([['camera', createCameraConfig()]]); vi.mocked(store.getCameras).mockReturnValue(configs); vi.mocked(store.getVisibleCameras).mockReturnValue(configs); + vi.mocked(store.getVisibleCameraIDs).mockReturnValue(new Set(configs.keys())); vi.mocked(store.getCameraConfig).mockImplementation((cameraID): CameraConfig => { return configs.get(cameraID) ?? createCameraConfig(); }); } vi.mocked(cameraManager.getStore).mockReturnValue(store); + vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([ + { + cameraIDs: new Set(['camera']), + type: QueryType.Event, + }, + ]); + vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue([ + { + cameraIDs: new Set(['camera']), + type: QueryType.Recording, + }, + ]); + return cameraManager; }; @@ -169,21 +186,44 @@ export const createMediaLoadedInfo = ( }; }; +export const createPerformanceConfig = (config: unknown): PerformanceConfig => { + return performanceConfigSchema.parse(config); +}; + +export const generateViewMediaArray = (options?: { + cameraIDs?: string[]; + count?: number; +}): ViewMedia[] => { + const media: ViewMedia[] = []; + for (let i = 0; i < (options?.count ?? 100); ++i) { + for (const cameraID of options?.cameraIDs ?? ['kitchen', 'office']) { + media.push(new TestViewMedia({ cameraID: cameraID, id: `id-${cameraID}-${i}` })); + } + } + return media; +}; + // ViewMedia itself has no native way to set startTime and ID that aren't linked // to an engine. export class TestViewMedia extends ViewMedia { protected _id: string | null; - protected _startTime: Date; + protected _startTime: Date | null; + protected _endTime: Date | null; + protected _inProgress: boolean | null; - constructor( - id: string | null, - startTime: Date, - mediaType: ViewMediaType, - cameraID: string, - ) { - super(mediaType, cameraID); - this._id = id; - this._startTime = startTime; + constructor(options?: { + id?: string | null; + startTime?: Date; + mediaType?: ViewMediaType; + cameraID?: string; + endTime?: Date; + inProgress?: boolean; + }) { + super(options?.mediaType ?? 'clip', options?.cameraID ?? 'camera'); + this._id = options?.id !== undefined ? options.id : 'id'; + this._startTime = options?.startTime ?? null; + this._endTime = options?.endTime ?? null; + this._inProgress = options?.inProgress !== undefined ? options.inProgress : false; } public getID(): string | null { return this._id; @@ -191,4 +231,25 @@ export class TestViewMedia extends ViewMedia { public getStartTime(): Date | null { return this._startTime; } + public getEndTime(): Date | null { + return this._endTime; + } + public inProgress(): boolean | null { + return this._inProgress; + } } + +export const createResizeObserverImplementation = (): (() => void) => { + return () => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + }); +}; + +export const createMutationObserverImplementation = (): (() => void) => { + return () => ({ + observe: vi.fn(), + disconnect: vi.fn(), + }); +}; diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index d4ae8af8..3b0e6970 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -103,6 +103,24 @@ describe('createFrigateCardCustomAction', () => { card_id: 'card_id', }); }); + + it('should create display mode action', () => { + expect( + createFrigateCardCustomAction('display_mode_select', { + display_mode: 'grid', + cardID: 'card_id', + }), + ).toEqual({ + action: 'fire-dom-event', + frigate_card_action: 'display_mode_select', + display_mode: 'grid', + card_id: 'card_id', + }); + }); + + it('should not create display mode action without display mode', () => { + expect(createFrigateCardCustomAction('display_mode_select')).toBeNull(); + }); }); describe('getActionConfigGivenAction', () => { diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index a17fd576..6d45f08c 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -8,6 +8,7 @@ import { dayToDate, dispatchFrigateCardEvent, errorToConsole, + filterTruthy, formatDate, formatDateAndTime, getDurationString, @@ -227,7 +228,13 @@ describe('isValidDate', () => { }); describe('setOrRemoveAttribute', () => { - it('should set attribute', () => { + it('should set attribute without value', () => { + const element = document.createElement('div'); + setOrRemoveAttribute(element, true, 'key'); + expect(element.getAttribute('key')).toBe(''); + }); + + it('should set attribute with value', () => { const element = document.createElement('div'); setOrRemoveAttribute(element, true, 'key', 'value'); expect(element.getAttribute('key')).toBe('value'); @@ -240,3 +247,12 @@ describe('setOrRemoveAttribute', () => { expect(element.getAttribute('key')).toBeFalsy(); }); }); + +describe('filterTruthy', () => { + it('should return true for true', () => { + expect(filterTruthy(true)).toBeTruthy(); + }); + it('should return false for false', () => { + expect(filterTruthy(false)).toBeFalsy(); + }); +}); diff --git a/tests/utils/media-grid-controller.test.ts b/tests/utils/media-grid-controller.test.ts new file mode 100644 index 00000000..255c2105 --- /dev/null +++ b/tests/utils/media-grid-controller.test.ts @@ -0,0 +1,400 @@ +import Masonry from 'masonry-layout'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { MediaLoadedInfo } from '../../src/types'; +import { + MediaGridConstructorOptions, + MediaGridController, +} from '../../src/utils/media-grid-controller'; +import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info'; +import { + createMutationObserverImplementation, + createResizeObserverImplementation, +} from '../test-utils'; + +vi.mock('lodash-es/throttle', () => ({ + default: vi.fn((fn) => fn), +})); + +const masonry = mock(); +vi.mock('masonry-layout', () => ({ + default: vi.fn().mockImplementation(() => { + return masonry; + }), +})); + +const createChildren = (childIDs?: string[], idAttribute?: string): HTMLElement[] => { + const children: HTMLElement[] = []; + for (let i = 0; i < (childIDs?.length ?? 3); ++i) { + const child = document.createElement('div'); + if (childIDs) { + child.setAttribute(idAttribute ?? 'grid-id', childIDs[i]); + } + children.push(child); + } + return children; +}; + +const setElementWidth = (element: HTMLElement, width: number): void => { + element.getBoundingClientRect = vi.fn().mockReturnValue({ + width: width, + }); +}; + +const createHost = (options?: { + children?: HTMLElement[]; + width?: number; +}): HTMLElement => { + const host = document.createElement('div'); + if (options?.children) { + host.append(...options.children); + } + // Default Lovelace card width is 492. + setElementWidth(host, options?.width ?? 492); + return host; +}; + +const createSlotParent = (): HTMLElement => { + const parent = document.createElement('div'); + parent.attachShadow({ mode: 'open' }); + return parent; +}; + +const createSlotHost = (options?: { + children?: HTMLElement[]; + parent?: HTMLElement; +}): HTMLElement => { + const parent = options?.parent ?? createSlotParent(); + const slot = document.createElement('slot'); + parent.shadowRoot?.append(slot); + + if (options?.children) { + // Children will automatically be slotted into the default slot. + parent.append(...options.children); + } + + return slot; +}; + +const createController = (host: HTMLElement, options?: MediaGridConstructorOptions) => { + return new MediaGridController(host, options); +}; + +const triggerMutationObserver = (): void => { + const mutationObserverTrigger = vi.mocked(global.MutationObserver).mock.calls[0][0]; + mutationObserverTrigger([], mock()); +}; + +const triggerResizeObserver = (cellOrHost: 'cell' | 'host'): void => { + const resizeObserverTrigger = vi.mocked(global.ResizeObserver).mock.calls[ + cellOrHost === 'cell' ? 0 : 1 + ][0]; + resizeObserverTrigger([], mock()); +}; + +// @vitest-environment jsdom +describe('MediaGridController', () => { + const mediaLoadedInfo: MediaLoadedInfo = { + width: 10, + height: 20, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + global.ResizeObserver = vi + .fn() + // Caution: Order must match the order of initialization in + // media-grid-controller.ts . + .mockImplementationOnce(createResizeObserverImplementation()) + .mockImplementationOnce(createResizeObserverImplementation()); + + global.MutationObserver = vi + .fn() + .mockImplementation(createMutationObserverImplementation()); + //global.MutationObserver = mock(); + }); + + it('should be constructable', () => { + const controller = createController(createHost()); + expect(controller).toBeTruthy(); + expect(masonry.layout).toBeCalled(); + }); + + it('should set grid contents correctly from regular elements', () => { + const children = createChildren(); + const host = createHost({ children: children }); + const controller = createController(host); + expect(controller.getGridContents()).toEqual( + new Map([ + ['0', children[0]], + ['1', children[1]], + ['2', children[2]], + ]), + ); + expect(controller.getGridSize()).toBe(3); + expect(masonry.layout).toBeCalled(); + }); + + it('should set grid contents correctly from slotted elements', () => { + const children = createChildren(); + const host = createSlotHost({ children: children }); + const controller = createController(host); + expect(controller.getGridContents()).toEqual( + new Map([ + ['0', children[0]], + ['1', children[1]], + ['2', children[2]], + ]), + ); + expect(controller.getGridSize()).toBe(3); + }); + + it('should select element', () => { + const children = createChildren(); + const controller = createController(createSlotHost({ children: children })); + + // All children should be unselected. + expect(controller.getSelected()).toBeNull(); + for (const child of children) { + expect(child.getAttribute('selected')).toBeNull(); + expect(child.getAttribute('unselected')).toEqual(''); + } + + controller.selectCell('0'); + expect(controller.getSelected()).toBe('0'); + + // 1st child should now be selected. + expect(children[0].getAttribute('selected')).toEqual(''); + expect(children[0].getAttribute('unselected')).toBeNull(); + + // 2nd and 3rd should be unselected. + for (const child of children.slice(1)) { + expect(child.getAttribute('selected')).toBeNull(); + expect(child.getAttribute('unselected')).toEqual(''); + } + }); + + it('should re-select element', () => { + const controller = createController(createSlotHost({ children: createChildren() })); + + // All children should be unselected. + expect(controller.getSelected()).toBeNull(); + + controller.selectCell('0'); + expect(controller.getSelected()).toBe('0'); + + controller.selectCell('0'); + expect(controller.getSelected()).toBe('0'); + }); + + it('should dispatch media loaded info on selection', () => { + const children = createChildren(); + const host = createSlotHost({ children: children }); + const controller = createController(host); + + const mediaLoadedInfoHandler = vi.fn(); + host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler); + dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo); + + // Nothing is selected, so the event should not have propagated. + expect(mediaLoadedInfoHandler).not.toBeCalled(); + + controller.selectCell('0'); + expect(mediaLoadedInfoHandler).toBeCalledWith( + expect.objectContaining({ + detail: mediaLoadedInfo, + }), + ); + }); + + it('should unselect', () => { + const children = createChildren(); + const host = createSlotHost({ children: children }); + const controller = createController(host); + + const unselectedHandler = vi.fn(); + const unloadMediaHandler = vi.fn(); + host.addEventListener('frigate-card:media-grid:unselected', unselectedHandler); + host.addEventListener('frigate-card:media:unloaded', unloadMediaHandler); + + controller.selectCell('0'); + expect(controller.getSelected()).toBe('0'); + + // Unselect all elements. + controller.unselectAll(); + + // Expect selected to now be null. + expect(controller.getSelected()).toBeNull(); + + // Expect styles to have been updated. + for (const child of children) { + expect(child.getAttribute('selected')).toBeNull(); + expect(child.getAttribute('unselected')).toEqual(''); + } + + // Expect handlers to have been called. + expect(unselectedHandler).toBeCalled(); + expect(unloadMediaHandler).toBeCalled(); + }); + + it('should select in constructor', () => { + const children = createChildren(); + const host = createSlotHost({ children: children }); + const controller = createController(host, { selected: '2' }); + expect(controller.getSelected()).toBe('2'); + }); + + it('should respect grid attribute option', () => { + const children = createChildren(['one', 'two', 'three'], 'test-id'); + const host = createSlotHost({ children: children }); + const controller = createController(host, { idAttribute: 'test-id' }); + expect(controller.getGridContents()).toEqual( + new Map([ + ['one', children[0]], + ['two', children[1]], + ['three', children[2]], + ]), + ); + }); + + it('should destroy', () => { + const children = createChildren(); + const host = createSlotHost({ children: children }); + const controller = createController(host); + expect(controller.getGridSize()).toBe(3); + controller.destroy(); + expect(controller.getGridSize()).toBe(0); + }); + + it('should replace children when they change', () => { + const children = createChildren(); + const host = createHost({ children: children }); + const controller = createController(host, { selected: '1' }); + dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo); + + expect(controller.getSelected()).toBe('1'); + expect(controller.getGridSize()).toBe(3); + + children.forEach((child) => host.removeChild(child)); + const newChildren = createChildren(['one', 'two', 'three']); + newChildren.forEach((child) => host.appendChild(child)); + + triggerMutationObserver(); + + expect(controller.getGridContents()).toEqual( + new Map([ + ['one', newChildren[0]], + ['two', newChildren[1]], + ['three', newChildren[2]], + ]), + ); + expect(controller.getSelected()).toBeNull(); + }); + + it('should construct masonry correctly', () => { + const children = createChildren(); + const host = createHost({ children: children }); + createController(host); + expect(Masonry).toBeCalledWith( + host, + expect.objectContaining({ + initLayout: false, + percentPosition: true, + transitionDuration: '0.3s', + }), + ); + }); + + it('should set default column size correctly', () => { + const host = createHost({ children: createChildren() }); + createController(host); + expect(Masonry).toBeCalledWith( + host, + expect.objectContaining({ + columnWidth: 246, + }), + ); + expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); + }); + + it('should respect exact columns', () => { + const host = createHost({ children: createChildren(), width: 2000 }); + const controller = createController(host); + controller.setDisplayConfig({ mode: 'grid', grid_columns: 2 }); + + // Will have been called once on construction, and then again when the + // number of columns changes. + expect(Masonry).toBeCalledTimes(2); + expect(Masonry).toBeCalledWith( + host, + expect.objectContaining({ + columnWidth: 1000, + }), + ); + expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe( + '1000px', + ); + }); + + it('should respect selected width factor', () => { + const host = createHost({ children: createChildren(), width: 2000 }); + const controller = createController(host); + controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 }); + expect( + host.style.getPropertyValue('--frigate-card-grid-selected-width-factor'), + ).toBe('3'); + }); + + it('should select cell with interacted with', () => { + const children = createChildren(); + const host = createHost({ children: children, width: 2000 }); + const controller = createController(host); + + expect(controller.getSelected()).toBeNull(); + + const touchEvent = new TouchEvent('touchend'); + children[1].dispatchEvent(touchEvent); + + expect(controller.getSelected()).toBe('1'); + }); + + it('should re-layout when child size changes', () => { + createController(createHost({ children: createChildren() })); + + vi.mocked(masonry.layout)?.mockClear(); + triggerResizeObserver('cell'); + expect(masonry.layout).toBeCalled(); + }); + + it('should re-create masonry when host size changes', () => { + const children = createChildren(); + const host = createHost({ children: children }); + const controller = createController(host); + expect(Masonry).toBeCalledWith( + host, + expect.objectContaining({ + columnWidth: 246, + }), + ); + expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); + + // Clear mock state. + vi.mocked(Masonry).mockClear(); + vi.mocked(masonry.layout)?.mockClear(); + + // Resize the host. + setElementWidth(host, 2000); + triggerResizeObserver('host'); + + // Masonry should be reconstructed, styles set and layout called. + expect(Masonry).toBeCalledWith( + host, + expect.objectContaining({ + columnWidth: 667, + }), + ); + expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px'); + expect(masonry.layout).toBeCalled(); + }); +}); diff --git a/tests/utils/media-to-view.test.ts b/tests/utils/media-to-view.test.ts new file mode 100644 index 00000000..5b108127 --- /dev/null +++ b/tests/utils/media-to-view.test.ts @@ -0,0 +1,452 @@ +import add from 'date-fns/add'; +import sub from 'date-fns/sub'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { + CameraConfigs, + PartialRecordingQuery, + QueryType, +} from '../../src/camera-manager/types'; +import { setify } from '../../src/utils/basic'; +import { + changeViewToRecentEventsForCameraAndDependents, + changeViewToRecentRecordingForCameraAndDependents, + createQueriesForRecordingsView, + executeMediaQueryForView, + findBestMediaIndex, +} from '../../src/utils/media-to-view'; +import { ViewMedia } from '../../src/view/media'; +import { EventMediaQueries } from '../../src/view/media-queries'; +import { + createCameraManager, + createHASS, + createPerformanceConfig, + createView, + TestViewMedia, +} from '../test-utils'; + +vi.mock('../../src/camera-manager/manager.js'); + +const createElementListenForView = (): { + element: HTMLElement; + viewHandler: Mock; + messageHandler: Mock; +} => { + const element = document.createElement('div'); + + const viewHandler = vi.fn(); + element.addEventListener('frigate-card:view:change', viewHandler); + + const messageHandler = vi.fn(); + element.addEventListener('frigate-card:message', messageHandler); + + return { + element: element, + viewHandler: viewHandler, + messageHandler: messageHandler, + }; +}; + +const getMediaFromHandlerCall = (handler: Mock): ViewMedia[] | null => { + return handler.mock.calls[0][0].detail.queryResults.getResults(); +}; + +const generateViewMedia = ( + index: number, + base: Date, + durationSeconds: number, +): ViewMedia => { + return new TestViewMedia({ + id: `id-${index}`, + startTime: base, + endTime: add(base, { seconds: durationSeconds }), + }); +}; + +// @vitest-environment jsdom +describe('changeViewToRecentEventsForCameraAndDependents', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should do nothing without camera config for selected camera', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager({ configs: new Map() }); + + await changeViewToRecentEventsForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + }); + + it('should do nothing without camera configs for all cameras', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager({ configs: new Map() }); + + await changeViewToRecentEventsForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + { + allCameras: true, + }, + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + }); + + it('should do nothing unless queries can be created', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue(null); + + await changeViewToRecentEventsForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + { + mediaType: 'clips', + }, + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + }); + + it('should dispatch new view on success', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager(); + + const mediaArray = [new ViewMedia('clip', 'camera')]; + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); + + await changeViewToRecentEventsForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + { + targetView: 'clips', + select: 'latest', + }, + ); + expect(elementHandler.viewHandler).toBeCalled(); + expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray); + }); + + it('should dispatch error message on fail', async () => { + vi.spyOn(global.console, 'warn').mockImplementation(() => true); + + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(new Error()); + + await changeViewToRecentEventsForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + expect(elementHandler.messageHandler).toBeCalled(); + }); + + it('should respect media chunk size', async () => { + const cameraManager = createCameraManager(); + + await changeViewToRecentEventsForCameraAndDependents( + createElementListenForView().element, + createHASS(), + cameraManager, + { + performance: createPerformanceConfig({ + features: { + media_chunk_size: 1000, + }, + }), + }, + createView(), + ); + + expect(cameraManager.generateDefaultEventQueries).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + limit: 1000, + }), + ); + }); + + describe('should respect request for media type', () => { + it.each([ + ['snapshots' as const, 'hasSnapshot'], + ['clips' as const, 'hasClip'], + ])('%s', async (mediaType, queryParameter) => { + const cameraManager = createCameraManager(); + + await changeViewToRecentEventsForCameraAndDependents( + createElementListenForView().element, + createHASS(), + cameraManager, + {}, + createView(), + { + mediaType: mediaType, + }, + ); + + expect(cameraManager.generateDefaultEventQueries).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + [queryParameter]: true, + }), + ); + }); + }); +}); + +// @vitest-environment jsdom +describe('executeMediaQueryForView', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should not execute empty queries', async () => { + const elementHandler = createElementListenForView(); + const cameraConfigs: CameraConfigs = new Map(); + const cameraManager = createCameraManager({ configs: cameraConfigs }); + + expect( + await executeMediaQueryForView( + elementHandler.element, + createHASS(), + cameraManager, + createView(), + new EventMediaQueries(), + ), + ).toBeNull(); + }); + + it('should select time-based result', async () => { + const elementHandler = createElementListenForView(); + const cameraConfigs: CameraConfigs = new Map(); + const cameraManager = createCameraManager({ configs: cameraConfigs }); + + const now = new Date(); + const mediaArray = [ + generateViewMedia(0, now, 60), + generateViewMedia(1, now, 120), + generateViewMedia(2, now, 10), + ]; + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); + + const view = await executeMediaQueryForView( + elementHandler.element, + createHASS(), + cameraManager, + createView(), + new EventMediaQueries( + cameraManager.generateDefaultEventQueries('camera') ?? undefined, + ), + { + select: 'time', + targetTime: add(now, { seconds: 30 }), + }, + ); + + // Should select the longest event. + expect(view?.queryResults?.getSelectedIndex()).toBe(1); + expect(view?.queryResults?.getResults()).toBe(mediaArray); + }); + + it('should select nothing when time-based selection does not match', async () => { + const elementHandler = createElementListenForView(); + const cameraConfigs: CameraConfigs = new Map(); + const cameraManager = createCameraManager({ configs: cameraConfigs }); + + const now = new Date(); + const mediaArray = [ + generateViewMedia(0, now, 60), + generateViewMedia(1, now, 120), + generateViewMedia(2, now, 10), + ]; + + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); + + const view = await executeMediaQueryForView( + elementHandler.element, + createHASS(), + cameraManager, + createView(), + new EventMediaQueries( + cameraManager.generateDefaultEventQueries('camera') ?? undefined, + ), + { + select: 'time', + targetTime: sub(now, { seconds: 30 }), + }, + ); + + // Should leave selection untouched (last item will remain selected). + expect(view?.queryResults?.getSelectedIndex()).toBe(2); + expect(view?.queryResults?.getResults()).toBe(mediaArray); + }); +}); + +// @vitest-environment jsdom +describe('changeViewToRecentRecordingForCameraAndDependents', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should do nothing without camera config for selected camera', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager({ configs: new Map() }); + + await changeViewToRecentRecordingForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + }); + + it('should do nothing without camera configs for all cameras', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager({ configs: new Map() }); + + await changeViewToRecentRecordingForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + { + allCameras: true, + }, + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + }); + + it('should do nothing unless queries can be created', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue(null); + + await changeViewToRecentRecordingForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + ); + expect(elementHandler.viewHandler).not.toBeCalled(); + }); + + it('should dispatch new view on success', async () => { + const elementHandler = createElementListenForView(); + const cameraManager = createCameraManager(); + + const mediaArray = [new ViewMedia('recording', 'camera')]; + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray); + + await changeViewToRecentRecordingForCameraAndDependents( + elementHandler.element, + createHASS(), + cameraManager, + {}, + createView(), + { + targetView: 'recordings', + select: 'latest', + }, + ); + expect(elementHandler.viewHandler).toBeCalled(); + expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray); + }); + + it('should respect media chunk size', async () => { + const cameraManager = createCameraManager(); + + await changeViewToRecentRecordingForCameraAndDependents( + createElementListenForView().element, + createHASS(), + cameraManager, + { + performance: createPerformanceConfig({ + features: { + media_chunk_size: 1000, + }, + }), + }, + createView(), + ); + + expect(cameraManager.generateDefaultRecordingQueries).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + limit: 1000, + }), + ); + }); +}); + +// @vitest-environment jsdom +describe('createQueriesForRecordingsView', () => { + it('should respect start and end date in recording query', async () => { + const cameraManager = createCameraManager({ configs: new Map() }); + + vi.mocked(cameraManager.generateDefaultRecordingQueries).mockImplementation( + (cameraIDs: string | Set, partialQuery?: PartialRecordingQuery) => [ + { + cameraIDs: setify(cameraIDs), + type: QueryType.Recording, + ...partialQuery, + }, + ], + ); + + const start = new Date('2023-04-29T14:00:00'); + const end = new Date('2023-04-29T14:59:59'); + + const queries = createQueriesForRecordingsView( + cameraManager, + {}, + new Set(['camera']), + { + start: start, + end: end, + }, + ); + + expect(queries?.getQueries()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + start: start, + end: end, + }), + ]), + ); + }); +}); + +// @vitest-environment jsdom +describe('findBestMediaIndex', () => { + it('should find best media index', async () => { + const now = new Date(); + const mediaArray = [ + generateViewMedia(0, now, 60), + generateViewMedia(1, now, 120), + generateViewMedia(2, now, 10), + ]; + + expect(findBestMediaIndex(mediaArray, add(now, { seconds: 30 }))).toBe(1); + }); +}); diff --git a/tests/utils/media.test.ts b/tests/utils/media.test.ts index 3a9d6cd3..5e4aefc9 100644 --- a/tests/utils/media.test.ts +++ b/tests/utils/media.test.ts @@ -3,6 +3,7 @@ import { mock } from 'vitest-mock-extended'; import { FrigateCardMediaPlayer } from '../../src/types.js'; import { FrigateCardHTMLVideoElement, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, hideMediaControlsTemporarily, playMediaMutingIfNecessary, setControlsOnVideo, @@ -108,3 +109,9 @@ describe('playMediaMutingIfNecessary', () => { expect(player.mute).toBeCalled(); }); }); + +describe('constants', () => { + it('MEDIA_LOAD_CONTROLS_HIDE_SECONDS', () => { + expect(MEDIA_LOAD_CONTROLS_HIDE_SECONDS).toBe(2); + }); +}); diff --git a/tests/utils/menu-controller.test.ts b/tests/utils/menu-controller.test.ts index ffff040a..36adc42b 100644 --- a/tests/utils/menu-controller.test.ts +++ b/tests/utils/menu-controller.test.ts @@ -9,6 +9,7 @@ import { FrigateCardMediaPlayer, MediaLoadedInfo, MenuButton, + ViewDisplayMode, } from '../../src/types'; import { createFrigateCardCustomAction } from '../../src/utils/action'; import { MenuButtonController } from '../../src/utils/menu-controller'; @@ -67,6 +68,7 @@ const calculateButtons = ( ); }; +// @vitest-environment jsdom describe('MenuButtonController', () => { let controller: MenuButtonController; const dynamicButton: MenuButton = { @@ -612,7 +614,10 @@ describe('MenuButtonController', () => { const cameraManager = createCameraManager(); const view = createView({ - queryResults: new MediaQueriesResults([new ViewMedia('clip', 'camera-1')], 0), + queryResults: new MediaQueriesResults({ + results: [new ViewMedia('clip', 'camera-1')], + selectedIndex: 0, + }), }); mock(cameraManager).getMediaCapabilities.mockReturnValue( createMediaCapabilities({ canDownload: true }), @@ -640,7 +645,10 @@ describe('MenuButtonController', () => { const cameraManager = createCameraManager(); const view = createView({ - queryResults: new MediaQueriesResults([new ViewMedia('clip', 'camera-1')], 0), + queryResults: new MediaQueriesResults({ + results: [new ViewMedia('clip', 'camera-1')], + selectedIndex: 0, + }), }); mock(cameraManager).getMediaCapabilities.mockReturnValue( createMediaCapabilities({ canDownload: true }), @@ -1084,6 +1092,27 @@ describe('MenuButtonController', () => { }); }); + describe('should have grid button when display mode is', () => { + it.each([['single' as const], ['grid' as const]])( + '%s', + (displayMode: ViewDisplayMode) => { + const view = createView({ view: 'live', displayMode: displayMode }); + expect(calculateButtons(controller, { view: view })).toContainEqual({ + icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Display mode', + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'display_mode_select', + display_mode: displayMode === 'single' ? 'grid' : 'single', + }, + }); + }, + ); + }); + it('should handle dynamic buttons', () => { const button: MenuButton = { ...dynamicButton, diff --git a/tests/utils/screenshot.test.ts b/tests/utils/screenshot.test.ts index f6fcdd37..89241a99 100644 --- a/tests/utils/screenshot.test.ts +++ b/tests/utils/screenshot.test.ts @@ -62,32 +62,30 @@ describe('generateScreenshotTitle', () => { }); it('should get title for media viewer view with id', () => { - const media = new TestViewMedia( - 'id1', - new Date('2023-06-16T18:52'), - 'clip', - 'camera-1', - ); + const media = new TestViewMedia({ + id: 'id1', + startTime: new Date('2023-06-16T18:52'), + cameraID: 'camera-1', + }); const view = createView({ view: 'media', camera: 'camera-1', - queryResults: new MediaQueriesResults([media], 0), + queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }), }); expect(generateScreenshotTitle(view)).toBe('media-camera-1-id1.jpg'); }); it('should get title for media viewer view without id', () => { - const media = new TestViewMedia( - null, - new Date('2023-06-16T18:52'), - 'clip', - 'camera-1', - ); + const media = new TestViewMedia({ + id: null, + startTime: new Date('2023-06-16T18:52'), + cameraID: 'camera-1', + }); const view = createView({ view: 'media', camera: 'camera-1', - queryResults: new MediaQueriesResults([media], 0), + queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }), }); expect(generateScreenshotTitle(view)).toBe('media-camera-1.jpg'); diff --git a/tests/view/media-classifier.test.ts b/tests/view/media-classifier.test.ts new file mode 100644 index 00000000..af7d2e7f --- /dev/null +++ b/tests/view/media-classifier.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { ViewMediaType } from '../../src/view/media'; +import { ViewMediaClassifier } from '../../src/view/media-classifier'; +import { TestViewMedia } from '../test-utils'; + +describe('ViewMediaClassifier', () => { + describe('isEvent', () => { + it.each([ + ['clip' as const, true], + ['snapshot' as const, true], + ['recording' as const, false], + ])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => { + expect( + ViewMediaClassifier.isEvent(new TestViewMedia({ mediaType: mediaType })), + ).toBe(expectedResult); + }); + }); + + describe('isRecording', () => { + it.each([ + ['clip' as const, false], + ['snapshot' as const, false], + ['recording' as const, true], + ])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => { + expect( + ViewMediaClassifier.isRecording(new TestViewMedia({ mediaType: mediaType })), + ).toBe(expectedResult); + }); + }); + + describe('isClip', () => { + it.each([ + ['clip' as const, true], + ['snapshot' as const, false], + ['recording' as const, false], + ])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => { + expect( + ViewMediaClassifier.isClip(new TestViewMedia({ mediaType: mediaType })), + ).toBe(expectedResult); + }); + }); + + describe('isSnapshot', () => { + it.each([ + ['clip' as const, false], + ['snapshot' as const, true], + ['recording' as const, false], + ])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => { + expect( + ViewMediaClassifier.isSnapshot(new TestViewMedia({ mediaType: mediaType })), + ).toBe(expectedResult); + }); + }); + + describe('isVideo', () => { + it.each([ + ['clip' as const, true], + ['snapshot' as const, false], + ['recording' as const, true], + ])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => { + expect( + ViewMediaClassifier.isVideo(new TestViewMedia({ mediaType: mediaType })), + ).toBe(expectedResult); + }); + }); +}); diff --git a/tests/view/media-queries-results.test.ts b/tests/view/media-queries-results.test.ts new file mode 100644 index 00000000..ce11c9e7 --- /dev/null +++ b/tests/view/media-queries-results.test.ts @@ -0,0 +1,233 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ViewMedia } from '../../src/view/media'; +import { MediaQueriesResults } from '../../src/view/media-queries-results'; +import { generateViewMediaArray } from '../test-utils'; + +describe('dispatchViewContextChangeEvent', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('should function with empty results', () => { + const fakeNow = new Date('2023-08-07T20:44'); + vi.useFakeTimers(); + vi.setSystemTime(fakeNow); + + const results = new MediaQueriesResults(); + expect(results.isSupersetOf(results)).toBeFalsy(); + expect(results.getCameraIDs()).toEqual(new Set()); + expect(results.getResults()).toEqual([]); + expect(results.getResultsCount()).toEqual(0); + expect(results.hasResults()).toBeFalsy(); + expect(results.getResult(0)).toBeNull(); + expect(results.getSelectedIndex()).toBeNull(); + expect(results.getSelectedResult()).toBeNull(); + expect(results.hasSelectedResult()).toBeFalsy(); + + expect(results.resetSelectedResult()).toBe(results); + expect(results.getResultsTimestamp()).toEqual(fakeNow); + + expect(results.selectIndex(0)).toEqual(results); + expect(results.getSelectedResult()).toBeNull(); + + expect(results.selectResultIfFound((_media: ViewMedia) => true)).toEqual(results); + expect(results.getSelectedResult()).toBeNull(); + + expect(results.selectBestResult((_media: ViewMedia[]) => null)).toEqual(results); + expect(results.getSelectedResult()).toBeNull(); + }); + + it('should function with basic results', () => { + const testResults = generateViewMediaArray(); + const results = new MediaQueriesResults({ results: testResults }); + + expect(results.isSupersetOf(results)).toBeTruthy(); + expect(results.getCameraIDs()).toEqual(new Set(['kitchen', 'office'])); + expect(results.getResults()).toEqual(testResults); + expect(results.getResultsCount()).toEqual(200); + expect(results.hasResults()).toBeTruthy(); + expect(results.getResult(0)).not.toBeNull(); + expect(results.getSelectedIndex()).toBe(199); + expect(results.getSelectedResult()).not.toBeNull(); + expect(results.hasSelectedResult()).toBeTruthy(); + + expect(results.resetSelectedResult()).toBe(results); + expect(results.getSelectedResult()).toBeNull(); + + expect(results.selectIndex(100)).toEqual(results); + expect(results.getSelectedIndex()).toBe(100); + + expect( + results.selectResultIfFound( + (media: ViewMedia) => media.getID() === 'id-kitchen-42', + ), + ).toEqual(results); + expect(results.getSelectedResult()?.getID()).toBe('id-kitchen-42'); + + expect( + results.selectBestResult((mediaArray: ViewMedia[]) => + mediaArray.findIndex((media) => media.getID() === 'id-kitchen-43'), + ), + ).toEqual(results); + expect(results.getSelectedResult()?.getID()).toBe('id-kitchen-43'); + }); + + it('should function with camera slice', () => { + const testResults = generateViewMediaArray(); + const results = new MediaQueriesResults({ results: testResults }); + const slice = results.getSlice('office'); + expect(slice).not.toBeNull(); + expect(slice!.getResults()).toEqual( + testResults.filter((media) => media.getCameraID() === 'office'), + ); + expect(slice!.getResultsCount()).toEqual(100); + expect(slice!.hasResults()).toBeTruthy(); + expect(slice!.getResult(0)).not.toBeNull(); + expect(slice!.getResult()).toBeNull(); + expect(slice!.getSelectedIndex()).toBe(99); + expect(slice!.getSelectedResult()?.getID()).toEqual('id-office-99'); + expect(slice!.hasSelectedResult()).toBeTruthy(); + + expect(slice!.resetSelectedResult()); + expect(slice!.getSelectedResult()).toBeNull(); + + expect(slice!.selectIndex(10)); + expect(slice!.getSelectedIndex()).toBe(10); + + expect(slice!.selectIndex(10000)); + expect(slice!.getSelectedIndex()).toBe(10); + + expect(slice!.selectIndex(-10000)); + expect(slice!.getSelectedIndex()).toBe(10); + + slice!.selectResultIfFound((media: ViewMedia) => media.getID() === 'id-office-42'); + expect(slice!.getSelectedResult()?.getID()).toBe('id-office-42'); + + slice!.selectBestResult((mediaArray: ViewMedia[]) => + mediaArray.findIndex((media) => media.getID() === 'id-office-43'), + ); + expect(slice!.getSelectedResult()?.getID()).toBe('id-office-43'); + }); + + describe('should respect select approach during construction', () => { + it.each([ + ['first' as const, 0], + ['last' as const, 199], + ])('%s', async (selectApproach, expectedIndex) => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + selectApproach: selectApproach, + }); + expect(results.getSelectedIndex()).toBe(expectedIndex); + }); + }); + + it('should respect selectIndex during construction', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + selectedIndex: 42, + }); + expect(results.getSelectedIndex()).toBe(42); + }); + + it('should correctly clone a slice', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + const slice = results.getSlice('office'); + const clone = slice?.clone(); + expect(clone?.getResults()).toBe(slice?.getResults()); + expect(clone?.getSelectedIndex()).toBe(slice?.getSelectedIndex()); + }); + + it('should not get slice for non-existent camera', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + expect(results.getSlice('not-a-camera')).toBeNull(); + }); + + it('should get main slice', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + expect(results.getSlice()?.getResults()).toBe(results.getResults()); + }); + + it('should correctly clone', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + const clone = results.clone(); + expect(results.getResultsTimestamp()).toBe(clone.getResultsTimestamp()); + expect(results.getResults()).toBe(clone.getResults()); + for (const cameraID of results.getCameraIDs()) { + expect(results.getSlice(cameraID)?.getResults()).toBe( + clone.getSlice(cameraID)?.getResults(), + ); + } + }); + + it('should not getResults on invalid slice', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + expect(results.getResults('not-a-camera')).toBeNull(); + expect(results.getResultsCount('not-a-camera')).toBe(0); + expect(results.hasSelectedResult('not-a-camera')).toBeFalsy(); + }); + + it('should always demote main selection', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + results + .getSlice('office') + ?.selectResultIfFound((media) => media.getID() === 'id-office-42'); + + // Verify main and office selections are as expected. + expect(results.getSelectedIndex()).toBe(199); + expect(results.getSelectedResult('office')?.getID()).toBe('id-office-42'); + + // Select a different main result... + results?.selectResultIfFound((media) => media.getID() === 'id-office-80'); + + // ... and ensure that selection has been demoted into the camera slice. + expect(results.getSelectedResult('office')?.getID()).toBe('id-office-80'); + }); + + it('should promote camera selection', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + results + .getSlice('office') + ?.selectResultIfFound((media) => media.getID() === 'id-office-42'); + + expect(results.getSelectedIndex()).toBe(199); + + results.promoteCameraSelectionToMainSelection('office'); + + expect(results.getSelectedIndex()).not.toBe(199); + expect(results.getSelectedResult()?.getID()).toBe('id-office-42'); + }); + + it('should selectBestResult via advanced selection criteria', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + results.selectBestResult( + (mediaArray: ViewMedia[]) => { + const index = mediaArray.findIndex((media) => media.getID()?.endsWith('-42')); + return index < 0 ? null : index; + }, + { allCameras: true }, + ); + + expect(results.getSelectedResult('office')?.getID()).toBe('id-office-42'); + expect(results.getSelectedResult('kitchen')?.getID()).toBe('id-kitchen-42'); + }); +}); diff --git a/tests/view/media-queries.test.ts b/tests/view/media-queries.test.ts new file mode 100644 index 00000000..9ac570f5 --- /dev/null +++ b/tests/view/media-queries.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + EventQuery, + PartialEventQuery, + PartialRecordingQuery, + QueryType, + RecordingQuery, +} from '../../src/camera-manager/types'; +import { setify } from '../../src/utils/basic'; +import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries'; + +describe('EventMediaQueries', () => { + const createRawEventQueries = ( + cameraIDs: string | Set, + query?: PartialEventQuery, + ): EventQuery[] => { + return [ + { + type: QueryType.Event, + cameraIDs: setify(cameraIDs), + ...query, + }, + ]; + }; + + it('should construct', () => { + const rawQueries = createRawEventQueries('office'); + const queries = new EventMediaQueries(rawQueries); + expect(queries.getQueries()).toBe(rawQueries); + }); + + it('should set', () => { + const rawQueries = createRawEventQueries('office'); + const queries = new EventMediaQueries(rawQueries); + + const newRawQueries = createRawEventQueries('kitchen'); + queries.setQueries(newRawQueries); + expect(queries.getQueries()).toBe(newRawQueries); + }); + + it('should determine if queries exist for CameraIDs', () => { + const rawQueries = createRawEventQueries(new Set(['office', 'kitchen'])); + const queries = new EventMediaQueries(rawQueries); + + expect(queries.hasQueriesForCameraIDs(new Set(['office']))).toBeTruthy(); + expect(queries.hasQueriesForCameraIDs(new Set(['office', 'kitchen']))).toBeTruthy(); + expect( + queries.hasQueriesForCameraIDs(new Set(['office', 'front_door'])), + ).toBeFalsy(); + }); + + it('should convert to clips querys', () => { + const rawQueries = createRawEventQueries('office', { hasSnapshot: true }); + const queries = new EventMediaQueries(rawQueries); + + expect(queries.convertToClipsQueries().getQueries()).toEqual([ + { + type: QueryType.Event, + cameraIDs: new Set(['office']), + hasClip: true, + }, + ]); + }); + + it('should convert when queries are null', () => { + const queries = new EventMediaQueries(); + expect(queries.convertToClipsQueries().getQueries()).toBeNull(); + }); + + it('should clone', () => { + const rawQueries = createRawEventQueries('office', { hasSnapshot: true }); + const queries = new EventMediaQueries(rawQueries); + expect(queries.clone().getQueries()).toEqual(queries.getQueries()); + }); +}); + +describe('RecordingMediaQueries', () => { + const createRawRecordingQueries = ( + cameraIDs: string | Set, + query?: PartialRecordingQuery, + ): RecordingQuery[] => { + return [ + { + type: QueryType.Recording, + cameraIDs: setify(cameraIDs), + ...query, + }, + ]; + }; + + it('should construct', () => { + const rawQueries = createRawRecordingQueries('office'); + const queries = new RecordingMediaQueries(rawQueries); + expect(queries.getQueries()).toBe(rawQueries); + }); + + it('should clone', () => { + const rawQueries = createRawRecordingQueries('office'); + const queries = new RecordingMediaQueries(rawQueries); + expect(queries.clone().getQueries()).toEqual(queries.getQueries()); + }); +}); diff --git a/tests/view/media.test.ts b/tests/view/media.test.ts new file mode 100644 index 00000000..7aaa8131 --- /dev/null +++ b/tests/view/media.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { VideoContentType, ViewMedia } from '../../src/view/media'; +import { TestViewMedia } from '../test-utils'; + +describe('ViewMedia', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('should construct', () => { + const media = new ViewMedia('clip', 'camera'); + expect(media.getCameraID()).toBe('camera'); + expect(media.getMediaType()).toBe('clip'); + expect(media.getVideoContentType()).toBeNull(); + expect(media.getID()).toBeNull(); + expect(media.getStartTime()).toBeNull(); + expect(media.getEndTime()).toBeNull(); + expect(media.getUsableEndTime()).toBeNull(); + expect(media.inProgress()).toBeNull(); + expect(media.getContentID()).toBeNull(); + expect(media.getTitle()).toBeNull(); + expect(media.getThumbnail()).toBeNull(); + expect(media.getTitle()).toBeNull(); + expect(media.includesTime(new Date())).toBeFalsy(); + expect(media.getWhere()).toBeNull(); + expect(media.setFavorite(true)).toBeUndefined(); + expect(media.isFavorite()).toBeNull(); + }); + + it('should correctly determine if a media item includes a time', () => { + const media = new TestViewMedia({ + startTime: new Date('2023-08-08T17:00:00'), + endTime: new Date('2023-08-08T17:59:59'), + }); + expect(media.includesTime(new Date('2023-08-08T17:30:30'))).toBeTruthy(); + expect(media.includesTime(new Date('2023-08-08T18:00:00'))).toBeFalsy(); + }); + + it('should correctly get usable end time for in-progress event', () => { + const media = new TestViewMedia({ + startTime: new Date('2023-08-08T17:00:00'), + inProgress: true, + }); + + vi.useFakeTimers(); + const fakeNow = new Date('2023-08-08T17:15:00'); + vi.setSystemTime(fakeNow); + + expect(media.getUsableEndTime()).toEqual(fakeNow) + }); +}); + +describe('VideoContentType', () => { + it('MP4', () => { + expect(VideoContentType.MP4).toBe('mp4'); + }); + it('HLS', () => { + expect(VideoContentType.HLS).toBe('hls'); + }); +}); diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts index 1d97f060..de6a1912 100644 --- a/tests/view/view.test.ts +++ b/tests/view/view.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, it, test, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { QueryType } from '../../src/camera-manager/types'; import { ViewMedia } from '../../src/view/media'; import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View, dispatchViewContextChangeEvent } from '../../src/view/view'; -import { createView } from '../test-utils'; +import { createView, generateViewMediaArray } from '../test-utils'; // @vitest-environment jsdom describe('View Basics', () => { @@ -52,6 +52,7 @@ describe('View Basics', () => { query: new EventMediaQueries(), queryResults: new MediaQueriesResults(), context: {}, + displayMode: 'single', }); const evolved = view.evolve({ @@ -60,12 +61,14 @@ describe('View Basics', () => { query: new EventMediaQueries(), queryResults: new MediaQueriesResults(), context: {}, + displayMode: 'grid', }); expect(evolved.view).not.toBe(view.view); expect(evolved.camera).not.toBe(view.camera); expect(evolved.query).not.toBe(view.query); expect(evolved.queryResults).not.toBe(view.queryResults); expect(evolved.context).not.toBe(view.context); + expect(evolved.displayMode).not.toBe(view.displayMode); }); it('should evolve with nothing set', () => { @@ -119,6 +122,13 @@ describe('View Basics', () => { expect(view.context).toEqual({}); }); + it('should remove context property', () => { + const view = createView({ context: { live: { overrides: new Map() } } }); + + view.removeContextProperty('live', 'overrides'); + expect(view.context).toEqual({ live: {} }); + }); + it('should detect gallery views', () => { expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy(); expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy(); @@ -240,8 +250,8 @@ describe('View.isMajorMediaChange', () => { it('should consider result change as major in other view', () => { const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')]; - const queryResults_1 = new MediaQueriesResults(media, 0); - const queryResults_2 = new MediaQueriesResults(media, 1); + const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 }); + const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 }); expect( View.isMajorMediaChange( createView({ view: 'media', queryResults: queryResults_1 }), @@ -252,8 +262,8 @@ describe('View.isMajorMediaChange', () => { it('should not consider selected result change as major in live view', () => { const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')]; - const queryResults_1 = new MediaQueriesResults(media, 0); - const queryResults_2 = new MediaQueriesResults(media, 1); + const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 }); + const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 }); expect( View.isMajorMediaChange( createView({ queryResults: queryResults_1 }), @@ -282,7 +292,7 @@ describe('View.adoptFromViewIfAppropriate', () => { expect(next.queryResults).toBe(queryResults); }); - test.each([ + it.each([ [ new EventMediaQueries([ { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, @@ -409,6 +419,76 @@ describe('View.adoptFromViewIfAppropriate', () => { View.adoptFromViewIfAppropriate(next, current); expect(next.context?.live).toEqual(current.context?.live); }); + + it('should determine if display mode is grid', () => { + expect(createView({ displayMode: 'grid' }).isGrid()).toBeTruthy(); + expect(createView({ displayMode: 'single' }).isGrid()).toBeFalsy(); + expect(createView().isGrid()).toBeFalsy(); + }); + + it('should determine if view supports multiple display modes', () => { + const resultsOne = new MediaQueriesResults({ + results: generateViewMediaArray({ cameraIDs: ['office'] }), + }); + const resultsTwo = new MediaQueriesResults({ + results: generateViewMediaArray({ cameraIDs: ['office', 'kitchen'] }), + }); + + expect(createView({ view: 'live' }).hasMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'live' }).hasMultipleDisplayModes(0)).toBeFalsy(); + expect(createView({ view: 'live' }).hasMultipleDisplayModes(1)).toBeFalsy(); + expect(createView({ view: 'live' }).hasMultipleDisplayModes(2)).toBeTruthy(); + + expect(createView({ view: 'media' }).hasMultipleDisplayModes()).toBeFalsy(); + expect( + createView({ view: 'media', queryResults: resultsOne }).hasMultipleDisplayModes(), + ).toBeFalsy(); + expect( + createView({ view: 'media', queryResults: resultsTwo }).hasMultipleDisplayModes(), + ).toBeTruthy(); + + expect(createView({ view: 'clip' }).hasMultipleDisplayModes()).toBeFalsy(); + expect( + createView({ view: 'clip', queryResults: resultsOne }).hasMultipleDisplayModes(), + ).toBeFalsy(); + expect( + createView({ view: 'clip', queryResults: resultsTwo }).hasMultipleDisplayModes(), + ).toBeTruthy(); + + expect(createView({ view: 'snapshot' }).hasMultipleDisplayModes()).toBeFalsy(); + expect( + createView({ + view: 'snapshot', + queryResults: resultsOne, + }).hasMultipleDisplayModes(), + ).toBeFalsy(); + expect( + createView({ + view: 'snapshot', + queryResults: resultsTwo, + }).hasMultipleDisplayModes(), + ).toBeTruthy(); + + expect(createView({ view: 'recording' }).hasMultipleDisplayModes()).toBeFalsy(); + expect( + createView({ + view: 'recording', + queryResults: resultsOne, + }).hasMultipleDisplayModes(), + ).toBeFalsy(); + expect( + createView({ + view: 'recording', + queryResults: resultsTwo, + }).hasMultipleDisplayModes(), + ).toBeTruthy(); + + expect(createView({ view: 'clips' }).hasMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'snapshots' }).hasMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'recordings' }).hasMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'image' }).hasMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'timeline' }).hasMultipleDisplayModes()).toBeFalsy(); + }); }); // @vitest-environment jsdom diff --git a/yarn.lock b/yarn.lock index 8cf2f48d..7bc02b0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1052,6 +1052,15 @@ __metadata: languageName: node linkType: hard +"@types/jquery@npm:*": + version: 3.5.16 + resolution: "@types/jquery@npm:3.5.16" + dependencies: + "@types/sizzle": "*" + checksum: 13c995f15d1c2f1d322103dc1cb0a22b95eecc3e7546f00279b8731aea21d7ec04550af40e609ee48e755d4e11bf61c25b4aa9f53df3bcbec4b8fe8e81471732 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.9": version: 7.0.11 resolution: "@types/json-schema@npm:7.0.11" @@ -1082,6 +1091,15 @@ __metadata: languageName: node linkType: hard +"@types/masonry-layout@npm:^4.2.5": + version: 4.2.5 + resolution: "@types/masonry-layout@npm:4.2.5" + dependencies: + "@types/jquery": "*" + checksum: 68961e4dd393a8d51e6e80676a814a6741c0c9175501a1a556ec6d4ef5438aa1e982248a90c7f00099fb18da9529e9f6733be5ffebdb9b2e95cd36a6adeecb40 + languageName: node + linkType: hard + "@types/node@npm:*": version: 18.11.19 resolution: "@types/node@npm:18.11.19" @@ -1112,6 +1130,13 @@ __metadata: languageName: node linkType: hard +"@types/sizzle@npm:*": + version: 2.3.3 + resolution: "@types/sizzle@npm:2.3.3" + checksum: 586a9fb1f6ff3e325e0f2cc1596a460615f0bc8a28f6e276ac9b509401039dd242fa8b34496d3a30c52f5b495873922d09a9e76c50c2ab2bcc70ba3fb9c4e160 + languageName: node + linkType: hard + "@types/trusted-types@npm:^2.0.2": version: 2.0.2 resolution: "@types/trusted-types@npm:2.0.2" @@ -2238,6 +2263,13 @@ __metadata: languageName: node linkType: hard +"desandro-matches-selector@npm:^2.0.0": + version: 2.0.2 + resolution: "desandro-matches-selector@npm:2.0.2" + checksum: 30979e6b45d7720d259d4db11ec026fcbee242deee5ec0944c265d7e386a7358ab29f7417cbc3631830d0dbe6e827940dee31f789ed865dba22880d209fc9f8e + languageName: node + linkType: hard + "diff@npm:^5.1.0": version: 5.1.0 resolution: "diff@npm:5.1.0" @@ -2864,6 +2896,13 @@ __metadata: languageName: node linkType: hard +"ev-emitter@npm:^1.0.0": + version: 1.1.1 + resolution: "ev-emitter@npm:1.1.1" + checksum: 3dd78a7620701ef8095794f611dd251bdd9023badf322b3d6fa5e10a660c50e83eec5ee5bbc7654682635cf7b8858c94e2c77e1d10bdc188a2b0593436403476 + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.4": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7" @@ -2977,6 +3016,15 @@ __metadata: languageName: node linkType: hard +"fizzy-ui-utils@npm:^2.0.0": + version: 2.0.7 + resolution: "fizzy-ui-utils@npm:2.0.7" + dependencies: + desandro-matches-selector: ^2.0.0 + checksum: 001e54effa22c4f62728b90a25e8eb5477057ab53d0184e1c3231bbdfc7c34e278418fbcaf7d63c75ede823d82e61245e668ce8cba90e1c5699de960188c654f + languageName: node + linkType: hard + "flat-cache@npm:^3.0.4": version: 3.0.4 resolution: "flat-cache@npm:3.0.4" @@ -3052,6 +3100,7 @@ __metadata: "@rollup/plugin-replace": ^4.0.0 "@types/bluebird": ^3.5.36 "@types/lodash-es": ^4.17.5 + "@types/masonry-layout": ^4.2.5 "@typescript-eslint/eslint-plugin": ^5.36.2 "@typescript-eslint/parser": ^5.36.2 "@vitest/coverage-c8": ^0.29.8 @@ -3073,6 +3122,7 @@ __metadata: lit: ^2.3.1 lit-flatpickr: ^0.4.0 lodash-es: ^4.17.21 + masonry-layout: ^4.2.2 moment: ^2.29.4 prettier: ^2.6.0 propagating-hammerjs: ^2.0.1 @@ -3221,6 +3271,13 @@ __metadata: languageName: node linkType: hard +"get-size@npm:^2.0.2": + version: 2.0.3 + resolution: "get-size@npm:2.0.3" + checksum: 18d5a5fdb3f541db8b1e6ad46a5411d4bb7da3061d2ef7e544db3bbce7a13543f05c91fd40c7f68b4bc4bc035c160fed23c45514cde7d5fb18de1cce10208b19 + languageName: node + linkType: hard + "get-symbol-description@npm:^1.0.0": version: 1.0.0 resolution: "get-symbol-description@npm:1.0.0" @@ -4229,6 +4286,16 @@ __metadata: languageName: node linkType: hard +"masonry-layout@npm:^4.2.2": + version: 4.2.2 + resolution: "masonry-layout@npm:4.2.2" + dependencies: + get-size: ^2.0.2 + outlayer: ^2.1.0 + checksum: b947029b8fdfb384d17d7749d80a4f00f88d885acea04d77e1fefd36827cc2a345fd40d9ccddf720ed9323e133d8ea498125eece91d2c8517f69fd4e9e1bbdf4 + languageName: node + linkType: hard + "mdn-data@npm:2.0.14": version: 2.0.14 resolution: "mdn-data@npm:2.0.14" @@ -4653,6 +4720,17 @@ __metadata: languageName: node linkType: hard +"outlayer@npm:^2.1.0": + version: 2.1.1 + resolution: "outlayer@npm:2.1.1" + dependencies: + ev-emitter: ^1.0.0 + fizzy-ui-utils: ^2.0.0 + get-size: ^2.0.2 + checksum: a8b69d07bad6b498ea1956f89a4bae6d38414d81a5feeae6af06cd8206a1bf47db82f664dc3ccd13b8d2c5886a6cded4a4642d0fafcbbd80f68873b7eb9f4153 + languageName: node + linkType: hard + "p-finally@npm:^1.0.0": version: 1.0.0 resolution: "p-finally@npm:1.0.0" From e7094718bbf31e5e08c12d6b26df1f0dabf46e73 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 8 Aug 2023 22:48:50 -0700 Subject: [PATCH 02/33] Add documentation. --- README.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/README.md b/README.md index 264efabd..cb808211 100644 --- a/README.md +++ b/README.md @@ -474,6 +474,7 @@ See the [fully expanded live configuration example](#config-expanded-live) for h | `controls` | | :white_check_mark: | Configuration for the `live` view controls. See below. | | `layout` | | :white_check_mark: | See [media layout](#media-layout) below.| | `microphone` | | :white_check_mark: | See [microphone](#microphone) below.| +| `display` | | :white_check_mark: | See [display](#live-display) below.| #### Live Controls @@ -579,6 +580,35 @@ live: See [Using 2-way audio](#using-2-way-audio) for more information about the very particular requirements that must be followed for 2-way audio to work. + + +#### Live: Display + +All configuration is under: + +```yaml +live: + display: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `mode` | `single` | :white_check_mark: | Whether to display a `single` media item at a time, or a media item for all cameras in a `grid`` configuration.| +| `grid_selected_width_factor` | `2` | :white_check_mark: | How much to scale up the selected media item in a grid. A value of `1` will not scale the selected item at all, the default value of `2` will scale the media item width to twice what it would otherwise be, etc. | +| `grid_columns` | | :white_check_mark: | If specified the grid will always have exactly this number of columns.| +| `grid_max_columns` | `4` | :white_check_mark: | If specified, and `grid_columns` is not specified, the grid will not render more than this number of columns. The precise number will be calculated based on the [grid layout algorithm](#grid-layout-algorith). | + + + +##### Grid Layout Algorithm + +The grid will lay out cameras roughly in the order they are specified in the config (items may be moved to optimize grid 'density'). The following algorithm is used to calculate the number of columns. This attempts to offers a balance between configurability, reasonable display in a typical Lovelace card width and reasonable display in a typical fullscreen display. + +* Use `grid_columns` if specified. +* Otherwise, use the largest number of columns in the range `[2 - grid_max_columns]` that will fit at least a `600px` column width. +* Otherwise, use the largest number of columns in the range `[2 - grid_max_columns]` that will fit at least a `190px` column width. +* Otherwise, there will be `1` column only. + ### Media Viewer Options The `media_viewer` is used for viewing all `clip`, `snapshot` or recording media, in a media carousel. @@ -689,6 +719,24 @@ media_viewer: | `mode` | `popup-bottom-right` | :heavy_multiplication_x: | How to display the Media viewer media title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . | | `duration_seconds` | `2` | :heavy_multiplication_x: | The number of seconds to display the title popup. `0` implies forever.| + + +#### Media Viewer: Display + +All configuration is under: + +```yaml +media_viewer: + display: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `mode` | `single` | :white_check_mark: | Whether to display a `single` media item at a time, or a media item for all cameras in a `grid`` configuration.| +| `grid_selected_width_factor` | `2` | :white_check_mark: | How much to scale up the selected media item in a grid. A value of `1` will not scale the selected item at all, the default value of `2` will scale the media item width to twice what it would otherwise be, etc. | +| `grid_columns` | | :white_check_mark: | If specified the grid will always have exactly this number of columns.| +| `grid_max_columns` | `4` | :white_check_mark: | If specified, and `grid_columns` is not specified, the grid will not render more than this number of columns. The precise number will be calculated based on the [grid layout algorithm](#grid-layout-algorith). | + ### Media Gallery Options @@ -1946,6 +1994,10 @@ live: microphone: always_connected: false disconnect_seconds: 60 + display: + mode: single + grid_selected_width_factor: 2 + grid_max_columns: 4 actions: entity: light.office_main_lights tap_action: @@ -2007,6 +2059,10 @@ media_viewer: position: x: 50 y: 50 + display: + mode: single + grid_selected_width_factor: 2 + grid_max_columns: 4 actions: entity: light.office_main_lights tap_action: @@ -3823,6 +3879,32 @@ automations: ``` +### Grid display overrides + +
+ Expand: Change the grid layout configuration in fullscreen mode + +This example will always render 5 columns in fullscreen mode in both the live +and media viewer views, and will not enlarge the selected item. The normal +auto-layout behavior will be used outside of fullscreen mode. + +```yaml +overrides: + - conditions: + fullscreen: true + display_mode: grid + overrides: + live: + display: + grid_columns: 5 + grid_selected_width_factor: 1 + media_viewer: + display: + grid_columns: 5 + grid_selected_width_factor: 1 +``` +
+ ## Card Refreshes From d9b2b4d4d8e2a4ea8ca79663056846c7db1635c2 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 8 Aug 2023 22:49:52 -0700 Subject: [PATCH 03/33] Minor fixes. --- src/components/viewer.ts | 2 +- src/const.ts | 2 +- src/scss/media-grid.scss | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/viewer.ts b/src/components/viewer.ts index fd030904..945d74ec 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -12,7 +12,7 @@ import { customElement, property } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; -import basicBlockStyle from '../../scss/basic-block.scss'; +import basicBlockStyle from '../scss/basic-block.scss'; import { CameraManager } from '../camera-manager/manager.js'; import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js'; import { localize } from '../localize/localize.js'; diff --git a/src/const.ts b/src/const.ts index bfa27ea3..4cda24fe 100644 --- a/src/const.ts +++ b/src/const.ts @@ -287,7 +287,7 @@ export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; // The number of media items to fetch at a time (for clips/snapshot views, and // gallery chunks). Smaller values will cause more frequent smaller fetches, but // improved rendering performance. -export const MEDIA_CHUNK_SIZE_DEFAULT = 500; +export const MEDIA_CHUNK_SIZE_DEFAULT = 50; export const MEDIA_CHUNK_SIZE_MAX = 1000; export const FRIGATE_BUTTON_MENU_ICON = 'frigate'; diff --git a/src/scss/media-grid.scss b/src/scss/media-grid.scss index a6b9b49c..9efa5f6c 100644 --- a/src/scss/media-grid.scss +++ b/src/scss/media-grid.scss @@ -43,6 +43,10 @@ var(--frigate-card-grid-column-size) ) ); + + // When at item is selected, it may be enlarged -- it should render "in-front" + // of the other cameras that will then "move out of the way". + z-index: 2; } slot { From 330f927fdbd9866f335bebb20dd959aa68798f77 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 9 Aug 2023 21:26:12 -0700 Subject: [PATCH 04/33] Fix controls issue. --- src/action-handler-directive.ts | 5 ++++- src/components/media-grid.ts | 3 +-- src/utils/media-grid-controller.ts | 4 ++-- src/utils/media.ts | 5 ++++- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index 0dd34d12..17cad147 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -53,7 +53,10 @@ class ActionHandler extends HTMLElement implements ActionHandler { }); } - public bind(element: ActionHandlerElement, options): void { + public bind( + element: ActionHandlerElement, + options: FrigateCardActionHandlerOptions, + ): void { if (element.actionHandlerOptions) { // Reset the options on an existing actionHandler. element.actionHandlerOptions = options; diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index e0b599b3..f43b1c92 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -1,10 +1,9 @@ // TODO: Performance of video scanning (pause/play?) // TODO: Investigate query spam during a grid load -// TODO: Test live pre-load // TODO: Is the query reset in card.ts correct for media filter multi-camera queries that are not all cameras? // TODO: Do I need column max? -// TODO: test changing tabs in a dashboard (to trigger disconnect, do I still receive media loads from media that was already loaded)? // TODO: Can SELECT_CHILD_EVENTS only be 'click' and it still work on Android? +// TODO: With live autoplay off only white frames appear? import { CSSResultGroup, diff --git a/src/utils/media-grid-controller.ts b/src/utils/media-grid-controller.ts index 6f09dfb9..ac79a60d 100644 --- a/src/utils/media-grid-controller.ts +++ b/src/utils/media-grid-controller.ts @@ -46,7 +46,7 @@ export class MediaGridController { () => this._masonry?.layout?.(), // Throttle layout calls to larger than the masonry.js transitionDuration // value specified below. - 500, + 300, { trailing: true, leading: false }, ); @@ -251,7 +251,7 @@ export class MediaGridController { columnWidth: this._getColumnSize(), initLayout: false, percentPosition: true, - transitionDuration: '0.3s', + transitionDuration: '0.2s', }); this._masonry.addItems?.([...this._gridContents.values()]); this._throttledLayout(); diff --git a/src/utils/media.ts b/src/utils/media.ts index 7f3704ed..6bf63a93 100644 --- a/src/utils/media.ts +++ b/src/utils/media.ts @@ -9,6 +9,7 @@ const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1; export type FrigateCardHTMLVideoElement = HTMLVideoElement & { _controlsHideTimer?: Timer; + _controlsOriginalValue?: boolean; }; /** @@ -24,6 +25,7 @@ export const setControlsOnVideo = ( if (video._controlsHideTimer) { video._controlsHideTimer.stop(); delete video._controlsHideTimer; + delete video._controlsOriginalValue; } video.controls = value; }; @@ -38,9 +40,10 @@ export const hideMediaControlsTemporarily = ( video: FrigateCardHTMLVideoElement, seconds = MEDIA_SEEK_CONTROLS_HIDE_SECONDS, ): void => { - const oldValue = video.controls; + const oldValue = video._controlsOriginalValue ?? video.controls; setControlsOnVideo(video, false); video._controlsHideTimer ??= new Timer(); + video._controlsOriginalValue = oldValue; video._controlsHideTimer.start(seconds, () => { setControlsOnVideo(video, oldValue); }); From fe2adb5b89d58ba4f8baef0b74b719ae13ca60d2 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 9 Aug 2023 21:32:53 -0700 Subject: [PATCH 05/33] Reduce number of event listeners. --- src/components/media-grid.ts | 2 -- src/utils/media-grid-controller.ts | 26 ++++++++++---------------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index f43b1c92..4b5b27b1 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -2,8 +2,6 @@ // TODO: Investigate query spam during a grid load // TODO: Is the query reset in card.ts correct for media filter multi-camera queries that are not all cameras? // TODO: Do I need column max? -// TODO: Can SELECT_CHILD_EVENTS only be 'click' and it still work on Android? -// TODO: With live autoplay off only white frames appear? import { CSSResultGroup, diff --git a/src/utils/media-grid-controller.ts b/src/utils/media-grid-controller.ts index ac79a60d..c6633c3f 100644 --- a/src/utils/media-grid-controller.ts +++ b/src/utils/media-grid-controller.ts @@ -29,8 +29,6 @@ export interface MediaGridConstructorOptions { idAttribute?: string; } -const SELECT_CHILD_EVENTS = ['click', 'touchend']; - export class MediaGridController { protected _host: HTMLElement; @@ -216,27 +214,23 @@ export class MediaGridController { this._throttledLayout(); } - protected _removeChildEventListeners(child: MediaGridChild): void { - for (const event of SELECT_CHILD_EVENTS) { - child.removeEventListener(event, this._handleSelectGridCellEvent, { - capture: true, - }); - } + protected _addChildEventListeners(child: MediaGridChild): void { + child.addEventListener('click', this._handleSelectGridCellEvent, { + capture: true, + }); - child.removeEventListener( + child.addEventListener( 'frigate-card:media:loaded', this._handleMediaLoadedInfoEvent, ); } - protected _addChildEventListeners(child: MediaGridChild): void { - for (const event of SELECT_CHILD_EVENTS) { - child.addEventListener(event, this._handleSelectGridCellEvent, { - capture: true, - }); - } + protected _removeChildEventListeners(child: MediaGridChild): void { + child.removeEventListener('click', this._handleSelectGridCellEvent, { + capture: true, + }); - child.addEventListener( + child.removeEventListener( 'frigate-card:media:loaded', this._handleMediaLoadedInfoEvent, ); From cc77caa96d5ba1faa33185f0654badaec58a6ab4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 11 Aug 2023 19:49:42 -0700 Subject: [PATCH 06/33] Reset the query on grid change in a smarter way. --- src/card.ts | 43 +++++++++----- src/components/live/live.ts | 9 ++- src/components/media-grid.ts | 3 +- src/components/viewer.ts | 19 +++--- src/utils/menu-controller.ts | 2 +- src/view/media-queries.ts | 34 +++++++++-- src/view/view.ts | 7 +-- tests/utils/media-grid-controller.test.ts | 7 +-- tests/view/media-queries.test.ts | 30 ++++++++-- tests/view/view.test.ts | 70 ++++------------------- 10 files changed, 116 insertions(+), 108 deletions(-) diff --git a/src/card.ts b/src/card.ts index 21f182b9..9356401f 100644 --- a/src/card.ts +++ b/src/card.ts @@ -48,12 +48,12 @@ import { CameraConfig, CardWideConfig, ExtendedHomeAssistant, + FRIGATE_CARD_VIEW_DEFAULT, FrigateCardConfig, frigateCardConfigSchema, FrigateCardCustomAction, FrigateCardError, FrigateCardView, - FRIGATE_CARD_VIEW_DEFAULT, MediaLoadedInfo, MenuButton, Message, @@ -87,6 +87,7 @@ import { supportsFeature } from './utils/ha/update.js'; import { FrigateCardInitializer } from './utils/initializer.js'; import { MediaLoadedInfoController } from './utils/media-info-controller'; import { isValidMediaLoadedInfo } from './utils/media-info.js'; +import { executeMediaQueryForView } from './utils/media-to-view'; import { MenuButtonController } from './utils/menu-controller'; import { MicrophoneController } from './utils/microphone'; import { getActionsFromQueryString } from './utils/querystring.js'; @@ -1085,7 +1086,7 @@ class FrigateCard extends LitElement { // Note: This function needs to process (view-related) commands even when // _view has not yet been initialized (since it may be used to set a view // via the querystring). - if (!this._cameraManager) { + if (!this._cameraManager || !this._hass) { return; } @@ -1241,19 +1242,33 @@ class FrigateCard extends LitElement { this._conditionController?.setState({ displayMode: this._viewDisplayMode, }); - // If the new mode is for all cameras, but the current query does not - // have a query for every cameraID, reset it. - const resetQuery = - frigateCardAction.mode === 'grid' && - !this._view?.query?.hasQueriesForCameraIDs( - this._cameraManager.getStore().getVisibleCameraIDs(), - ); - this._changeView({ - view: this._view?.evolve({ - displayMode: frigateCardAction.display_mode, - ...(resetQuery && { query: null, queryResults: null }), - }), + + const newView = this._view?.evolve({ + displayMode: frigateCardAction.display_mode, }); + + const generateNewQuery = + newView?.isGrid() && + newView.query && + (newView.query.getQueryCameraIDs()?.size ?? 0) <= 1; + + if (generateNewQuery && newView && newView.query) { + // If the user requests a grid but the current query does not have a + // query for more than one camera, reset the query results, change the + // existing query to refer to all cameras and execute it to fetch new + // results. + executeMediaQueryForView( + this, + this._hass, + this._cameraManager, + newView, + newView.query + .clone() + .setQueryCameraIDs(this._cameraManager.getStore().getVisibleCameraIDs()), + ).then((view) => view && this._changeView({ view: view })); + } else { + this._changeView({ view: newView }); + } break; default: console.warn(`Frigate card received unknown card action: ${action}`); diff --git a/src/components/live/live.ts b/src/components/live/live.ts index cdbf87e8..957ce164 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -363,7 +363,12 @@ export class FrigateCardLiveGrid extends LitElement { protected _needsGrid(): boolean { const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); - return !!this.view?.isGrid() && !!cameraIDs && cameraIDs.size >= 1; + return ( + !!this.view?.isGrid() && + !!this.view?.supportsMultipleDisplayModes() && + !!cameraIDs && + cameraIDs.size > 1 + ); } protected willUpdate(changedProps: PropertyValues): void { @@ -377,7 +382,7 @@ export class FrigateCardLiveGrid extends LitElement { return; } const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); - if (!this._needsGrid() || !cameraIDs) { + if (!cameraIDs || !this._needsGrid()) { return this._renderCarousel(); } return html` diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index 4b5b27b1..af09624d 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -1,7 +1,8 @@ // TODO: Performance of video scanning (pause/play?) // TODO: Investigate query spam during a grid load -// TODO: Is the query reset in card.ts correct for media filter multi-camera queries that are not all cameras? + // TODO: Do I need column max? +// TODO: What thumbnails do I show in the live drawer? import { CSSResultGroup, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 945d74ec..b18c4ded 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -149,7 +149,6 @@ export class FrigateCardViewer extends LitElement { { allCameras: this.view.isGrid(), targetView: 'recording', - select: 'latest', }, ); } else { @@ -163,7 +162,6 @@ export class FrigateCardViewer extends LitElement { allCameras: this.view.isGrid(), targetView: 'media', mediaType: mediaType, - select: 'latest', }, ); } @@ -648,18 +646,23 @@ export class FrigateCardViewerGrid extends LitElement { } protected willUpdate(changedProps: PropertyValues): void { - if ( - changedProps.has('view') && - this.view?.isGrid() && - this.view?.hasMultipleDisplayModes() - ) { + if (changedProps.has('view') && this._needsGrid()) { import('./media-grid.js'); } } + protected _needsGrid(): boolean { + const cameraIDs = this.view?.queryResults?.getCameraIDs(); + return ( + !!this.view?.isGrid() && + !!this.view?.supportsMultipleDisplayModes() && + (cameraIDs?.size ?? 0) > 1 + ); + } + protected render(): TemplateResult { const cameraIDs = this.view?.queryResults?.getCameraIDs(); - if (!cameraIDs || !this.view?.isGrid() || !this.view?.hasMultipleDisplayModes()) { + if (!cameraIDs || !this._needsGrid()) { return this._renderCarousel(); } diff --git a/src/utils/menu-controller.ts b/src/utils/menu-controller.ts index 64123abd..2790e2c6 100644 --- a/src/utils/menu-controller.ts +++ b/src/utils/menu-controller.ts @@ -394,7 +394,7 @@ export class MenuButtonController { }); } - if (view.hasMultipleDisplayModes(visibleCameras.size)) { + if (view.supportsMultipleDisplayModes() && visibleCameras.size > 1) { const isGrid = view.isGrid(); const action = createFrigateCardCustomAction('display_mode_select', { display_mode: isGrid ? 'single' : 'grid', diff --git a/src/view/media-queries.ts b/src/view/media-queries.ts index 2a75e5e3..4a77ae7f 100644 --- a/src/view/media-queries.ts +++ b/src/view/media-queries.ts @@ -1,4 +1,6 @@ import cloneDeep from 'lodash-es/cloneDeep.js'; +import isEqual from 'lodash-es/isEqual.js'; +import uniqWith from 'lodash-es/uniqWith.js'; import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js'; export type MediaQueries = EventMediaQueries | RecordingMediaQueries; @@ -12,7 +14,7 @@ class MediaQueriesBase { } } - public clone(): MediaQueriesBase { + public clone(): this { return cloneDeep(this); } @@ -20,8 +22,32 @@ class MediaQueriesBase { return this._queries; } - public setQueries(queries: T[]): void { + public setQueries(queries: T[]): this { this._queries = queries; + return this; + } + + public getQueryCameraIDs(): Set | null { + if (!this._queries) { + return null; + } + const cameraIDs: Set = new Set(); + this._queries.forEach((query) => + [...query.cameraIDs].forEach((cameraID) => cameraIDs.add(cameraID)), + ); + return cameraIDs; + } + + public setQueryCameraIDs(cameraIDs: Set): this { + if (!this._queries) { + return this; + } + const rewrittenQueries: T[] = []; + this._queries.forEach((query) => + rewrittenQueries.push({ ...query, cameraIDs: cameraIDs }), + ); + this._queries = uniqWith(rewrittenQueries, isEqual); + return this; } public hasQueriesForCameraIDs(cameraIDs: Set) { @@ -42,10 +68,6 @@ export class EventMediaQueries extends MediaQueriesBase { } return this; } - - public clone(): EventMediaQueries { - return cloneDeep(this); - } } export class RecordingMediaQueries extends MediaQueriesBase {} diff --git a/src/view/view.ts b/src/view/view.ts index e0f13ec1..f4619678 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -231,11 +231,8 @@ export class View { return ['clip', 'snapshot', 'media', 'recording'].includes(this.view); } - public hasMultipleDisplayModes(cameraCount?: number): boolean { - return ( - (this.is('live') && (cameraCount ?? 0) > 1) || - (this.isViewerView() && (this.queryResults?.getCameraIDs().size ?? 0) > 1) - ); + public supportsMultipleDisplayModes(): boolean { + return this.isViewerView() || this.is('live'); } /** diff --git a/tests/utils/media-grid-controller.test.ts b/tests/utils/media-grid-controller.test.ts index 255c2105..f254a718 100644 --- a/tests/utils/media-grid-controller.test.ts +++ b/tests/utils/media-grid-controller.test.ts @@ -301,7 +301,7 @@ describe('MediaGridController', () => { expect.objectContaining({ initLayout: false, percentPosition: true, - transitionDuration: '0.3s', + transitionDuration: '0.2s', }), ); }); @@ -352,10 +352,7 @@ describe('MediaGridController', () => { const controller = createController(host); expect(controller.getSelected()).toBeNull(); - - const touchEvent = new TouchEvent('touchend'); - children[1].dispatchEvent(touchEvent); - + children[1].click(); expect(controller.getSelected()).toBe('1'); }); diff --git a/tests/view/media-queries.test.ts b/tests/view/media-queries.test.ts index 9ac570f5..b8c9c817 100644 --- a/tests/view/media-queries.test.ts +++ b/tests/view/media-queries.test.ts @@ -72,6 +72,30 @@ describe('EventMediaQueries', () => { const queries = new EventMediaQueries(rawQueries); expect(queries.clone().getQueries()).toEqual(queries.getQueries()); }); + + it('should get camera IDs when queries are null', () => { + expect(new EventMediaQueries().getQueryCameraIDs()).toBeNull(); + }); + + it('should get camera IDs', () => { + const cameraIDs = ['office', 'kitchen']; + const queries = new EventMediaQueries(createRawEventQueries(new Set(cameraIDs))); + expect(queries.getQueryCameraIDs()).toEqual(new Set(cameraIDs)); + }); + + it('should set camera IDs when queries are null', () => { + expect( + new EventMediaQueries().setQueryCameraIDs(new Set(['office'])).getQueryCameraIDs(), + ).toBeNull(); + }); + + it('should set camera IDs', () => { + const queries = new EventMediaQueries(createRawEventQueries('sitting_room')); + const newCameraIDs = new Set(['office', 'kitchen']); + expect(queries.setQueryCameraIDs(newCameraIDs).getQueryCameraIDs()).toEqual( + newCameraIDs, + ); + }); }); describe('RecordingMediaQueries', () => { @@ -93,10 +117,4 @@ describe('RecordingMediaQueries', () => { const queries = new RecordingMediaQueries(rawQueries); expect(queries.getQueries()).toBe(rawQueries); }); - - it('should clone', () => { - const rawQueries = createRawRecordingQueries('office'); - const queries = new RecordingMediaQueries(rawQueries); - expect(queries.clone().getQueries()).toEqual(queries.getQueries()); - }); }); diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts index de6a1912..165c7dc7 100644 --- a/tests/view/view.test.ts +++ b/tests/view/view.test.ts @@ -427,67 +427,17 @@ describe('View.adoptFromViewIfAppropriate', () => { }); it('should determine if view supports multiple display modes', () => { - const resultsOne = new MediaQueriesResults({ - results: generateViewMediaArray({ cameraIDs: ['office'] }), - }); - const resultsTwo = new MediaQueriesResults({ - results: generateViewMediaArray({ cameraIDs: ['office', 'kitchen'] }), - }); + expect(createView({ view: 'live' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'media' }).supportsMultipleDisplayModes()).toBeTruthy(); + expect(createView({ view: 'clip' }).supportsMultipleDisplayModes()).toBeTruthy(); + expect(createView({ view: 'snapshot' }).supportsMultipleDisplayModes()).toBeTruthy(); + expect(createView({ view: 'recording' }).supportsMultipleDisplayModes()).toBeTruthy(); - expect(createView({ view: 'live' }).hasMultipleDisplayModes()).toBeFalsy(); - expect(createView({ view: 'live' }).hasMultipleDisplayModes(0)).toBeFalsy(); - expect(createView({ view: 'live' }).hasMultipleDisplayModes(1)).toBeFalsy(); - expect(createView({ view: 'live' }).hasMultipleDisplayModes(2)).toBeTruthy(); - - expect(createView({ view: 'media' }).hasMultipleDisplayModes()).toBeFalsy(); - expect( - createView({ view: 'media', queryResults: resultsOne }).hasMultipleDisplayModes(), - ).toBeFalsy(); - expect( - createView({ view: 'media', queryResults: resultsTwo }).hasMultipleDisplayModes(), - ).toBeTruthy(); - - expect(createView({ view: 'clip' }).hasMultipleDisplayModes()).toBeFalsy(); - expect( - createView({ view: 'clip', queryResults: resultsOne }).hasMultipleDisplayModes(), - ).toBeFalsy(); - expect( - createView({ view: 'clip', queryResults: resultsTwo }).hasMultipleDisplayModes(), - ).toBeTruthy(); - - expect(createView({ view: 'snapshot' }).hasMultipleDisplayModes()).toBeFalsy(); - expect( - createView({ - view: 'snapshot', - queryResults: resultsOne, - }).hasMultipleDisplayModes(), - ).toBeFalsy(); - expect( - createView({ - view: 'snapshot', - queryResults: resultsTwo, - }).hasMultipleDisplayModes(), - ).toBeTruthy(); - - expect(createView({ view: 'recording' }).hasMultipleDisplayModes()).toBeFalsy(); - expect( - createView({ - view: 'recording', - queryResults: resultsOne, - }).hasMultipleDisplayModes(), - ).toBeFalsy(); - expect( - createView({ - view: 'recording', - queryResults: resultsTwo, - }).hasMultipleDisplayModes(), - ).toBeTruthy(); - - expect(createView({ view: 'clips' }).hasMultipleDisplayModes()).toBeFalsy(); - expect(createView({ view: 'snapshots' }).hasMultipleDisplayModes()).toBeFalsy(); - expect(createView({ view: 'recordings' }).hasMultipleDisplayModes()).toBeFalsy(); - expect(createView({ view: 'image' }).hasMultipleDisplayModes()).toBeFalsy(); - expect(createView({ view: 'timeline' }).hasMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'clips' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'snapshots' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'recordings' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'image' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'timeline' }).supportsMultipleDisplayModes()).toBeFalsy(); }); }); From 8a0ba11fa96ffab2288f74cd22b9f9559d0f0f6f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 12 Aug 2023 09:46:47 -0700 Subject: [PATCH 07/33] Reset query when changing to non-grid . --- src/card.ts | 12 ++++++++---- src/components/media-grid.ts | 2 -- src/view/media-queries.ts | 5 +++-- tests/view/view.test.ts | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/card.ts b/src/card.ts index 9356401f..16f6c201 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1248,9 +1248,9 @@ class FrigateCard extends LitElement { }); const generateNewQuery = - newView?.isGrid() && - newView.query && - (newView.query.getQueryCameraIDs()?.size ?? 0) <= 1; + newView?.query && + ((newView.isGrid() && (newView.query.getQueryCameraIDs()?.size ?? 0) <= 1) || + (!newView.isGrid() && (newView.query.getQueryCameraIDs()?.size ?? 0) > 1)); if (generateNewQuery && newView && newView.query) { // If the user requests a grid but the current query does not have a @@ -1264,7 +1264,11 @@ class FrigateCard extends LitElement { newView, newView.query .clone() - .setQueryCameraIDs(this._cameraManager.getStore().getVisibleCameraIDs()), + .setQueryCameraIDs( + newView.isGrid() + ? this._cameraManager.getStore().getVisibleCameraIDs() + : newView.camera, + ), ).then((view) => view && this._changeView({ view: view })); } else { this._changeView({ view: newView }); diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index af09624d..344e82cc 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -1,8 +1,6 @@ // TODO: Performance of video scanning (pause/play?) // TODO: Investigate query spam during a grid load - // TODO: Do I need column max? -// TODO: What thumbnails do I show in the live drawer? import { CSSResultGroup, diff --git a/src/view/media-queries.ts b/src/view/media-queries.ts index 4a77ae7f..2b5268c1 100644 --- a/src/view/media-queries.ts +++ b/src/view/media-queries.ts @@ -2,6 +2,7 @@ import cloneDeep from 'lodash-es/cloneDeep.js'; import isEqual from 'lodash-es/isEqual.js'; import uniqWith from 'lodash-es/uniqWith.js'; import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js'; +import { setify } from '../utils/basic.js'; export type MediaQueries = EventMediaQueries | RecordingMediaQueries; @@ -38,13 +39,13 @@ class MediaQueriesBase { return cameraIDs; } - public setQueryCameraIDs(cameraIDs: Set): this { + public setQueryCameraIDs(cameraIDs: string | Set): this { if (!this._queries) { return this; } const rewrittenQueries: T[] = []; this._queries.forEach((query) => - rewrittenQueries.push({ ...query, cameraIDs: cameraIDs }), + rewrittenQueries.push({ ...query, cameraIDs: setify(cameraIDs) }), ); this._queries = uniqWith(rewrittenQueries, isEqual); return this; diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts index 165c7dc7..e19f5da3 100644 --- a/tests/view/view.test.ts +++ b/tests/view/view.test.ts @@ -427,7 +427,7 @@ describe('View.adoptFromViewIfAppropriate', () => { }); it('should determine if view supports multiple display modes', () => { - expect(createView({ view: 'live' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect(createView({ view: 'live' }).supportsMultipleDisplayModes()).toBeTruthy(); expect(createView({ view: 'media' }).supportsMultipleDisplayModes()).toBeTruthy(); expect(createView({ view: 'clip' }).supportsMultipleDisplayModes()).toBeTruthy(); expect(createView({ view: 'snapshot' }).supportsMultipleDisplayModes()).toBeTruthy(); From c61ceb905df8e9727e1050d9d943e773d7229178 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 12 Aug 2023 20:35:02 -0700 Subject: [PATCH 08/33] Fix query/queryResults issue. --- src/components/carousel.ts | 45 ++++++++++++++++++-------------- src/components/media-carousel.ts | 6 ++--- src/components/media-grid.ts | 2 ++ src/components/viewer.ts | 2 +- 4 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 351bf339..c7de9885 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -1,20 +1,16 @@ import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel'; import { EmblaNodesType } from 'embla-carousel/components'; -import { - CreatePluginType, - EmblaPluginsType, - LoosePluginType, -} from 'embla-carousel/components/Plugins'; +import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; import { CSSResultGroup, - html, LitElement, PropertyValues, TemplateResult, + html, unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { Ref, createRef, ref } from 'lit/directives/ref.js'; import throttle from 'lodash-es/throttle'; import carouselStyle from '../scss/carousel.scss'; import { TransitionEffect } from '../types'; @@ -101,8 +97,8 @@ export class FrigateCardCarousel extends LitElement { * Get the selected slide. * @returns A CarouselSelect object (index & element). */ - public getCarouselSelected(): CarouselSelect | null { - const index = this._carousel?.selectedScrollSnap(); + public getCarouselSelected(slide?: number): CarouselSelect | null { + const index = slide ?? this._carousel?.selectedScrollSnap(); const element = index !== undefined ? this._carousel?.slideNodes()[index] ?? null : null; if (index !== undefined && element) { @@ -151,13 +147,6 @@ export class FrigateCardCarousel extends LitElement { } } - /** - * Get the live carousel plugins. - */ - public getCarouselPlugins(): EmblaPluginsType | null { - return this._carousel?.plugins() ?? null; - } - /** * The updated lifecycle callback for this element. * @param changedProperties The properties that were changed in this render. @@ -212,8 +201,8 @@ export class FrigateCardCarousel extends LitElement { }, this.carouselPlugins, ); - const selectSlide = (): void => { - const selected = this.getCarouselSelected(); + const selectSlide = (slide?: number): void => { + const selected = this.getCarouselSelected(slide); if (selected) { dispatchFrigateCardEvent(this, 'carousel:select', selected); } @@ -223,8 +212,24 @@ export class FrigateCardCarousel extends LitElement { this.requestUpdate(); }; - this._carousel.on('init', selectSlide); - this._carousel.on('select', selectSlide); + this._carousel.on( + 'init', + // On initialization selectedScrollSnap() will return 0, even if the + // startIndex during initialization is different, as such we override + // the selected slide as returned by the carousel. This need should be + // verified in future versions of Embla (tested as necessary on v7.0.9). + // Test case: + // + // - Start in `live` view in grid mode. + // - Select any camera that is not the first one. + // - Go to non-grid mode. + // - Go back to grid mode. + // - If successful, thumbnails will load correctly (and the query and + // queryResults in the view will be set vs having been reset in + // `_setViewCameraID` in `live.ts`). + () => selectSlide(this.selected), + ); + this._carousel.on('select', () => selectSlide()); this._carousel.on('scroll', () => { this._scrolling = true; }); diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 470e564b..8e8a2f0e 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -3,6 +3,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import debounce from 'lodash-es/debounce'; import mediaCarouselStyle from '../scss/media-carousel.scss'; import type { MediaLoadedInfo, @@ -15,14 +16,13 @@ import { dispatchExistingMediaLoadedInfoAsEvent, isValidMediaLoadedInfo, } from '../utils/media-info.js'; +import { Timer } from '../utils/timer'; import { CarouselSelect, EmblaCarouselPlugins, FrigateCardCarousel } from './carousel'; +import './carousel.js'; import { AutoMediaType } from './embla-plugins/automedia.js'; import './next-prev-control.js'; -import './carousel.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { FrigateCardTitleControl } from './title-control.js'; -import debounce from 'lodash-es/debounce'; -import { Timer } from '../utils/timer'; interface CarouselMediaLoadedInfo { slide: number; diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index 344e82cc..2c2f5d72 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -1,6 +1,8 @@ // TODO: Performance of video scanning (pause/play?) // TODO: Investigate query spam during a grid load // TODO: Do I need column max? +// TODO: frigate-card-live-go2rtc.hidden double media load event. +// TODO: Why am I creating so many carousels in the race-condition issue? Expecting 5, getting 10. import { CSSResultGroup, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index b18c4ded..dfcb896f 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -253,7 +253,7 @@ export class FrigateCardViewerCarousel extends LitElement { } /** - * The the HLS player on a slide (or current slide if not provided.) + * Get the media player on a slide (or current slide if not provided). * @param slide An optional slide. * @returns The FrigateCardMediaPlayer or null if not found. */ From b4595867b3c55560e736384f4e8228cdd1d43d17 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 13 Aug 2023 10:00:29 -0700 Subject: [PATCH 09/33] Avoid carousel init duplication. --- src/components/carousel.ts | 26 +++++++++++++++++++------- src/components/media-grid.ts | 4 ---- src/utils/basic.ts | 8 +++++++- tests/utils/basic.test.ts | 20 ++++++++++++++++---- 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/components/carousel.ts b/src/components/carousel.ts index c7de9885..59a43c87 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -11,10 +11,11 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js'; +import isEqual from 'lodash-es/isEqual'; import throttle from 'lodash-es/throttle'; import carouselStyle from '../scss/carousel.scss'; import { TransitionEffect } from '../types'; -import { dispatchFrigateCardEvent } from '../utils/basic.js'; +import { dispatchFrigateCardEvent, isHTMLElement } from '../utils/basic.js'; export interface CarouselSelect { index: number; @@ -175,6 +176,13 @@ export class FrigateCardCarousel extends LitElement { this._carousel = undefined; } + protected _getSlideElements(): HTMLElement[] { + return ( + this._refSlot.value?.assignedElements({ flatten: true }).filter(isHTMLElement) ?? + [] + ); + } + /** * Initialize the carousel. */ @@ -187,7 +195,7 @@ export class FrigateCardCarousel extends LitElement { root: carouselNode, // As the slides are slotted, need to explicitly pull them out and pass // them to Embla. - slides: this._refSlot.value?.assignedElements({ flatten: true }) as HTMLElement[], + slides: this._getSlideElements(), }; if (carouselNode && nodes.slides) { @@ -219,7 +227,7 @@ export class FrigateCardCarousel extends LitElement { // the selected slide as returned by the carousel. This need should be // verified in future versions of Embla (tested as necessary on v7.0.9). // Test case: - // + // // - Start in `live` view in grid mode. // - Select any camera that is not the first one. // - Go to non-grid mode. @@ -256,10 +264,14 @@ export class FrigateCardCarousel extends LitElement { * Called when the slotted children in the carousel change. */ protected _slotChanged(): void { - // Cannot just re-init, because the slide elements themselves may have - // changed, and only a carousel init can pass in new (slotted) children. If - this._destroyCarousel(); - this.requestUpdate(); + // Check whether the slotted elements have changed (without this check the + // carousel initializations are duplicated). + if (!isEqual(this._getSlideElements(), this._carousel?.slideNodes())) { + // Cannot just re-init, because the slide elements themselves may have + // changed, and only a carousel init can pass in new (slotted) children. + this._destroyCarousel(); + this.requestUpdate(); + } } protected render(): TemplateResult | void { diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index 2c2f5d72..b894f32b 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -1,8 +1,4 @@ // TODO: Performance of video scanning (pause/play?) -// TODO: Investigate query spam during a grid load -// TODO: Do I need column max? -// TODO: frigate-card-live-go2rtc.hidden double media load event. -// TODO: Why am I creating so many carousels in the race-condition issue? Expecting 5, getting 10. import { CSSResultGroup, diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 2321fe5e..51efa4cc 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -225,4 +225,10 @@ export const setOrRemoveAttribute = ( /** * Allow typescript to narrow types based on truthy filter. */ -export const filterTruthy = (x: T | false | undefined | null | '' | 0): x is T => !!x; +export const isTruthy = (x: T | false | undefined | null | '' | 0): x is T => !!x; + +/** + * Allow typescript to narrow types for HTMLElements. + */ +export const isHTMLElement = (element: unknown): element is HTMLElement => + element instanceof HTMLElement; diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index 6d45f08c..574a9803 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -8,7 +8,7 @@ import { dayToDate, dispatchFrigateCardEvent, errorToConsole, - filterTruthy, + isTruthy, formatDate, formatDateAndTime, getDurationString, @@ -20,6 +20,7 @@ import { setify, setOrRemoveAttribute, sleep, + isHTMLElement, } from '../../src/utils/basic'; // @vitest-environment jsdom @@ -248,11 +249,22 @@ describe('setOrRemoveAttribute', () => { }); }); -describe('filterTruthy', () => { +describe('isTruthy', () => { it('should return true for true', () => { - expect(filterTruthy(true)).toBeTruthy(); + expect(isTruthy(true)).toBeTruthy(); }); it('should return false for false', () => { - expect(filterTruthy(false)).toBeFalsy(); + expect(isTruthy(false)).toBeFalsy(); + }); +}); + +describe('isHTMLElement', () => { + it('should return true for HTMLElement', () => { + const htmlElement = document.createElement('div'); + expect(isHTMLElement(htmlElement)).toBeTruthy(); + }); + it('should return false for Element', () => { + const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + expect(isHTMLElement(svgElement)).toBeFalsy(); }); }); From c665f1b3514dae0a76047db02811e05f2e3302c7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 13 Aug 2023 12:37:20 -0700 Subject: [PATCH 10/33] Clean up seek handler. --- src/components/media-grid.ts | 3 ++- src/components/viewer.ts | 16 ++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index b894f32b..45135747 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -1,4 +1,5 @@ -// TODO: Performance of video scanning (pause/play?) +// TODO: Video scanning not select other camera? +// TODO: Drag from live results in different cameras being shown in timeline in media viewer? import { CSSResultGroup, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index dfcb896f..03480a80 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -541,24 +541,24 @@ export class FrigateCardViewerCarousel extends LitElement { return; } const selectedMedia = this._media[this.selected]; - if (!selectedMedia) { + const player = this._getPlayer(); + if (!selectedMedia || !player) { return; } const seekTimeInMedia = selectedMedia.includesTime(seek); - setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable'); - if (!seekTimeInMedia) { - this._getPlayer()?.pause(); - } else { - this._getPlayer()?.play(); + if (!seekTimeInMedia && !player.isPaused()) { + player.pause(); + } else if (seekTimeInMedia && player.isPaused()) { + player.play(); } const seekTime = (await this.cameraManager?.getMediaSeekTime(this.hass, selectedMedia, seek)) ?? null; - const player = this._getPlayer(); - if (player && seekTime !== null) { + + if (seekTime !== null) { player.seek(seekTime); } } From 5364c828ebb82fdf7018360d5082a633f12118b4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 13 Aug 2023 15:31:10 -0700 Subject: [PATCH 11/33] Show all cameras on timeline in grid mode. --- src/components/surround.ts | 12 ++++-------- src/components/timeline-core.ts | 2 ++ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/components/surround.ts b/src/components/surround.ts index ec771339..ef4a3995 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -8,7 +8,6 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../camera-manager/manager.js'; -import type { DataQuery } from '../camera-manager/types'; import basicBlockStyle from '../scss/basic-block.scss'; import { CardWideConfig, @@ -139,15 +138,12 @@ export class FrigateCardSurround extends LitElement { return null; } if (this.view?.is('live')) { - return getAllDependentCameras(this.cameraManager, this.view.camera); + return this.view.isGrid() + ? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null + : getAllDependentCameras(this.cameraManager, this.view.camera); } if (this.view.isViewerView()) { - return new Set( - this.view.query - ?.getQueries() - ?.map((query: DataQuery) => [...query.cameraIDs]) - .flat(), - ); + return this.view.query?.getQueryCameraIDs() ?? null; } return null; } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 8b33f147..bcc4ef3d 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -496,8 +496,10 @@ export class FrigateCardTimelineCore extends LitElement { : 'media' : this.view.view; + const selectedCamera = newResults?.getSelectedResult()?.getCameraID(); this.view .evolve({ + ...(selectedCamera && { camera: selectedCamera }), view: desiredView, queryResults: newResults, }) // Whether or not to set the timeline window. From 17bfe4f38b074c8d0f34d1ec3adcfadbb1cefd51 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 19 Aug 2023 12:28:11 -0700 Subject: [PATCH 12/33] Recalculate grid for a slot change. --- src/components/media-grid.ts | 4 ++ src/components/timeline-core.ts | 48 ++++++++++++++++------- src/components/viewer.ts | 11 +++++- src/types.ts | 33 ++++++---------- src/utils/media-grid-controller.ts | 28 ++++++++++--- src/utils/media-to-view.ts | 24 ++++++++++-- src/view/media-queries-results.ts | 19 +++++++++ tests/utils/media-grid-controller.test.ts | 28 ++++++++++++- tests/utils/media-to-view.test.ts | 19 +++++++++ tests/view/media-queries-results.test.ts | 13 ++++++ 10 files changed, 179 insertions(+), 48 deletions(-) diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index 45135747..a3bd54bf 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -1,5 +1,8 @@ // TODO: Video scanning not select other camera? // TODO: Drag from live results in different cameras being shown in timeline in media viewer? +// TODO: It's possible to get a single entry in the grid, rendered without the +// grid (correctly), but still with isGrid so the menu button still shows the +// take it out of grid mode option. import { CSSResultGroup, @@ -43,6 +46,7 @@ export class FrigateCardMediaGrid extends LitElement { if (!this._controller && this._refSlot.value) { this._controller = new MediaGridController(this._refSlot.value, { selected: this.selected, + displayConfig: this.displayConfig, }); } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index bcc4ef3d..729dcb63 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -48,6 +48,7 @@ import { dispatchFrigateCardEvent, formatDateAndTime, isHoverableDevice, + isTruthy, setOrRemoveAttribute, } from '../utils/basic'; import { @@ -485,10 +486,13 @@ export class FrigateCardTimelineCore extends LitElement { : results .clone() .resetSelectedResult() - .selectBestResult((media) => findBestMediaIndex(media, targetTime), { - allCameras: true, - main: true, - }); + .selectBestResult( + (media) => findBestMediaIndex(media, targetTime, this.view?.camera), + { + allCameras: true, + main: true, + }, + ); const desiredView: FrigateCardView = this.mini ? targetTime >= new Date() @@ -899,8 +903,7 @@ export class FrigateCardTimelineCore extends LitElement { maxItems: this.timelineConfig.clustering_threshold, clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { - const media = this.view?.queryResults?.getSelectedResult(); - const selectedId = media?.getID(); + const selectedIDs = this._getAllSelectedMediaIDsFromView(); const firstMedia = (first).media; const secondMedia = (second).media; @@ -910,8 +913,8 @@ export class FrigateCardTimelineCore extends LitElement { return ( first.type !== 'background' && first.type === second.type && - first.id !== selectedId && - second.id !== selectedId && + !selectedIDs.includes(first.id) && + !selectedIDs.includes(second.id) && !!firstMedia && !!secondMedia && ViewMediaClassifier.isEvent(firstMedia) && @@ -966,6 +969,18 @@ export class FrigateCardTimelineCore extends LitElement { return !!this.hass && !!this.cameraManager; } + protected _getAllSelectedMediaIDsFromView(): IdType[] { + return ( + this.view?.queryResults?.getMultipleSelectedResults({ + main: true, + ...(this.view.isGrid() && { allCameras: true }), + }) ?? [] + ) + .filter((media) => ViewMediaClassifier.isEvent(media)) + .map((media) => media.getID()) + .filter(isTruthy); + } + /** * Update the timeline from the view object. */ @@ -1030,8 +1045,11 @@ export class FrigateCardTimelineCore extends LitElement { } const currentSelection = this._timeline.getSelection(); - const mediaID = media?.getID(); - const needToSelect = mediaID && mediaIsEvent && !currentSelection.includes(mediaID); + const mediaIDsToSelect = this._getAllSelectedMediaIDsFromView(); + + const needToSelect = mediaIDsToSelect.some( + (mediaID) => !currentSelection.includes(mediaID), + ); if (needToSelect) { if (this._isClustering()) { @@ -1039,12 +1057,14 @@ export class FrigateCardTimelineCore extends LitElement { // update the dataset to ensure the newly selected item cannot be included // in a cluster. - // Need to this rewrite prior to setting the selection (just below), or - // the selection will be lost on rewrite. - this._timelineSource?.rewriteEvent(mediaID); + for (const mediaID of mediaIDsToSelect) { + // Need to this rewrite prior to setting the selection (just below), or + // the selection will be lost on rewrite. + this._timelineSource?.rewriteEvent(mediaID); + } } - this._timeline?.setSelection([mediaID], { + this._timeline?.setSelection(mediaIDsToSelect, { focus: false, animation: { animation: false, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 03480a80..e20244be 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -14,7 +14,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import basicBlockStyle from '../scss/basic-block.scss'; import { CameraManager } from '../camera-manager/manager.js'; -import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js'; +import { dispatchMessageEvent, renderMessage, renderProgressIndicator } from '../components/message.js'; import { localize } from '../localize/localize.js'; import '../patches/ha-hls-player'; import viewerCarouselStyle from '../scss/viewer-carousel.scss'; @@ -136,7 +136,14 @@ export class FrigateCardViewer extends LitElement { // timeline). const mediaType = this.view.getDefaultMediaType(); if (!mediaType) { - return; + // Directly render an error message (instead of dispatching it upwards) + // to preserve the mini-timeline if the user scans into an area with no + // media. + return renderMessage({ + type: 'info', + message: localize('common.no_media'), + icon: 'mdi:multimedia', + }); } if (mediaType === 'recordings') { diff --git a/src/types.ts b/src/types.ts index d98ec638..5d9c0963 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,12 +108,14 @@ export class FrigateCardError extends Error { const viewDisplayModeSchema = z.enum(['single', 'grid']); export type ViewDisplayMode = z.infer; -const viewDisplaySchema = z.object({ - mode: viewDisplayModeSchema, - grid_selected_width_factor: z.number().min(0).optional(), - grid_max_columns: z.number().min(0).optional(), - grid_columns: z.number().min(0).optional(), -}).optional(); +const viewDisplaySchema = z + .object({ + mode: viewDisplayModeSchema, + grid_selected_width_factor: z.number().min(0).optional(), + grid_max_columns: z.number().min(0).optional(), + grid_columns: z.number().min(0).optional(), + }) + .optional(); export type ViewDisplayConfig = z.infer; /** @@ -1134,7 +1136,9 @@ const menuConfigSchema = z mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute), play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot), - display_mode: visibleButtonSchema.default(menuConfigDefault.buttons.display_mode), + display_mode: visibleButtonSchema.default( + menuConfigDefault.buttons.display_mode, + ), }) .default(menuConfigDefault.buttons), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), @@ -1164,10 +1168,6 @@ const viewerConfigDefault = { }, thumbnails: thumbnailControlsDefaults, timeline: miniTimelineConfigDefault, - title: { - mode: 'popup-bottom-right' as const, - duration_seconds: 2, - }, }, }; const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({ @@ -1215,16 +1215,7 @@ const viewerConfigSchema = z timeline: miniTimelineConfigSchema.default( viewerConfigDefault.controls.timeline, ), - title: titleControlConfigSchema - .extend({ - mode: titleControlConfigSchema.shape.mode.default( - viewerConfigDefault.controls.title.mode, - ), - duration_seconds: titleControlConfigSchema.shape.duration_seconds.default( - viewerConfigDefault.controls.title.duration_seconds, - ), - }) - .default(viewerConfigDefault.controls.title), + title: titleControlConfigSchema.optional(), }) .default(viewerConfigDefault.controls), layout: mediaLayoutConfigSchema.optional(), diff --git a/src/utils/media-grid-controller.ts b/src/utils/media-grid-controller.ts index c6633c3f..32787d58 100644 --- a/src/utils/media-grid-controller.ts +++ b/src/utils/media-grid-controller.ts @@ -1,3 +1,4 @@ +import isEqual from 'lodash-es/isEqual'; import throttle from 'lodash-es/throttle'; import Masonry from 'masonry-layout'; import { MediaLoadedInfo, ViewDisplayConfig } from '../types'; @@ -27,6 +28,7 @@ export interface MediaGridSelected { export interface MediaGridConstructorOptions { selected?: GridID; idAttribute?: string; + displayConfig?: ViewDisplayConfig; } export class MediaGridController { @@ -62,14 +64,26 @@ export class MediaGridController { this._idAttribute = options?.idAttribute ?? 'grid-id'; this._hostWidth = this._host.getBoundingClientRect().width; this._hostResizeObserver.observe(host); + this._displayConfig = options?.displayConfig ?? null; - this._calculateGridContentsFromHost(); this._mutationObserver.observe(host, { childList: true }); + // Need to separately listen for slotchanges since mutation observer will + // not be called for shadom DOM slotted changes. + if (host instanceof HTMLSlotElement) { + host.addEventListener('slotchange', this._calculateGridContentsFromHost); + } + this._calculateGridContentsFromHost(); } public destroy(): void { this._hostResizeObserver.disconnect(); this._cellResizeObserver.disconnect(); + + this._mutationObserver.disconnect(); + if (this._host instanceof HTMLSlotElement) { + this._host.removeEventListener('slotchange', this._calculateGridContentsFromHost); + } + this._mediaLoadedInfoMap.clear(); this._masonry?.destroy?.(); this._masonry = null; @@ -81,8 +95,10 @@ export class MediaGridController { } public setDisplayConfig(displayConfig: ViewDisplayConfig | null): void { - this._displayConfig = displayConfig; - this._calculateGridContentsFromHost(); + if (!isEqual(displayConfig, this._displayConfig)) { + this._displayConfig = displayConfig; + this._calculateGridContentsFromHost(); + } } public getGridContents(): MediaGridContents { @@ -127,11 +143,11 @@ export class MediaGridController { this._updateSelectedStylesOnElements(); } - protected _calculateGridContentsFromHost(): void { + protected _calculateGridContentsFromHost = (): void => { let childrenElements: Element[]; if (this._host instanceof HTMLSlotElement) { - childrenElements = this._host.assignedElements({ flatten: true }); + childrenElements = this._host.assignedElements(); } else { childrenElements = [...this._host.children]; } @@ -145,7 +161,7 @@ export class MediaGridController { } this._setGridContents(gridContents); - } + }; protected _setGridContents(elements: MediaGridContents): void { this._gridContents = elements; diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 76215f78..6e0059f3 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -169,10 +169,11 @@ export const executeMediaQueryForView = async ( const queryResults = new MediaQueriesResults({ results: mediaArray }); let viewerContext: ViewContext | undefined = {}; + const cameraID = options?.targetCameraID ?? view.camera; if (options?.select === 'time' && options?.targetTime) { queryResults.selectBestResult((media) => - findBestMediaIndex(media, options.targetTime as Date), + findBestMediaIndex(media, options.targetTime as Date, cameraID), ); viewerContext = { mediaViewer: { @@ -186,7 +187,7 @@ export const executeMediaQueryForView = async ( query: query, queryResults: queryResults, view: options?.targetView, - camera: options?.targetCameraID, + camera: cameraID, }) .mergeInContext(viewerContext); }; @@ -201,11 +202,13 @@ export const executeMediaQueryForView = async ( export const findBestMediaIndex = ( mediaArray: ViewMedia[], targetTime: Date, + favorCameraID?: string, ): number | null => { let bestMatch: | { index: number; duration: number; + cameraID: string; } | undefined; @@ -215,8 +218,21 @@ export const findBestMediaIndex = ( if (media.includesTime(targetTime) && start && end) { const duration = end.getTime() - start.getTime(); - if (!bestMatch || duration > bestMatch.duration) { - bestMatch = { index: i, duration: duration }; + + if ( + // No best match so far ... + !bestMatch || + // ... or there is a best-match, but it's from a non-favored camera (unlike this one) ... + (favorCameraID && + bestMatch.cameraID !== favorCameraID && + media.getCameraID() === favorCameraID) || + // ... or this match is longer and either there's no favored camera or this is it. + (duration > bestMatch.duration && + (!favorCameraID || + bestMatch.cameraID !== favorCameraID || + media.getCameraID() === favorCameraID)) + ) { + bestMatch = { index: i, duration: duration, cameraID: media.getCameraID() }; } } } diff --git a/src/view/media-queries-results.ts b/src/view/media-queries-results.ts index cde420c2..3eec71be 100644 --- a/src/view/media-queries-results.ts +++ b/src/view/media-queries-results.ts @@ -183,6 +183,25 @@ export class MediaQueriesResults { public getSelectedResult(cameraID?: string): ViewMedia | null { return this.getSlice(cameraID)?.getSelectedResult() ?? null; } + public getMultipleSelectedResults( + criteria?: ResultSliceSelectionCriteria, + ): ViewMedia[] { + const results: ViewMedia[] = []; + if (!criteria || criteria.main) { + const mainResult = this.getSelectedResult(); + if (mainResult) { + results.push(mainResult); + } + } + const cameraIDs = this._getCameraIDsFromCriteria(criteria); + for (const cameraID of cameraIDs ?? []) { + const result = this.getSelectedResult(cameraID); + if (result) { + results.push(result); + } + } + return results; + } public hasSelectedResult(cameraID?: string): boolean { return this.getSlice(cameraID)?.hasSelectedResult() ?? false; } diff --git a/tests/utils/media-grid-controller.test.ts b/tests/utils/media-grid-controller.test.ts index f254a718..2066647b 100644 --- a/tests/utils/media-grid-controller.test.ts +++ b/tests/utils/media-grid-controller.test.ts @@ -63,7 +63,7 @@ const createSlotParent = (): HTMLElement => { const createSlotHost = (options?: { children?: HTMLElement[]; parent?: HTMLElement; -}): HTMLElement => { +}): HTMLSlotElement => { const parent = options?.parent ?? createSlotParent(); const slot = document.createElement('slot'); parent.shadowRoot?.append(slot); @@ -292,6 +292,32 @@ describe('MediaGridController', () => { expect(controller.getSelected()).toBeNull(); }); + it('should replace children of a slot when they change', () => { + const children = createChildren(); + const slotParent = createSlotParent(); + const host = createSlotHost({ children: children, parent: slotParent }); + + const controller = createController(host, { selected: '1' }); + + expect(controller.getSelected()).toBe('1'); + expect(controller.getGridSize()).toBe(3); + + children.forEach((child) => slotParent.removeChild(child)); + const newChildren = createChildren(['one', 'two', 'three']); + newChildren.forEach((child) => slotParent.append(child)); + + host.dispatchEvent(new Event('slotchange')); + + expect(controller.getGridContents()).toEqual( + new Map([ + ['one', newChildren[0]], + ['two', newChildren[1]], + ['three', newChildren[2]], + ]), + ); + expect(controller.getSelected()).toBeNull(); + }); + it('should construct masonry correctly', () => { const children = createChildren(); const host = createHost({ children: children }); diff --git a/tests/utils/media-to-view.test.ts b/tests/utils/media-to-view.test.ts index 5b108127..561bbc65 100644 --- a/tests/utils/media-to-view.test.ts +++ b/tests/utils/media-to-view.test.ts @@ -54,11 +54,13 @@ const generateViewMedia = ( index: number, base: Date, durationSeconds: number, + cameraID?: string, ): ViewMedia => { return new TestViewMedia({ id: `id-${index}`, startTime: base, endTime: add(base, { seconds: durationSeconds }), + ...(cameraID && { cameraID: cameraID }), }); }; @@ -449,4 +451,21 @@ describe('findBestMediaIndex', () => { expect(findBestMediaIndex(mediaArray, add(now, { seconds: 30 }))).toBe(1); }); + + it('should find best media index respecting favored cameraID', async () => { + const now = new Date(); + const mediaArray = [ + generateViewMedia(0, now, 60, 'less-good-camera'), + generateViewMedia(1, now, 120, 'less-good-camera'), + generateViewMedia(2, now, 10, 'favored-camera'), + generateViewMedia(3, now, 35, 'favored-camera'), + generateViewMedia(4, now, 40, 'favored-camera'), + generateViewMedia(5, now, 30, 'favored-camera'), + generateViewMedia(6, now, 300, 'less-good-camera'), + ]; + + expect( + findBestMediaIndex(mediaArray, add(now, { seconds: 30 }), 'favored-camera'), + ).toBe(4); + }); }); diff --git a/tests/view/media-queries-results.test.ts b/tests/view/media-queries-results.test.ts index ce11c9e7..aae224ef 100644 --- a/tests/view/media-queries-results.test.ts +++ b/tests/view/media-queries-results.test.ts @@ -35,6 +35,7 @@ describe('dispatchViewContextChangeEvent', () => { expect(results.selectBestResult((_media: ViewMedia[]) => null)).toEqual(results); expect(results.getSelectedResult()).toBeNull(); + expect(results.getMultipleSelectedResults()).toEqual([]); }); it('should function with basic results', () => { @@ -230,4 +231,16 @@ describe('dispatchViewContextChangeEvent', () => { expect(results.getSelectedResult('office')?.getID()).toBe('id-office-42'); expect(results.getSelectedResult('kitchen')?.getID()).toBe('id-kitchen-42'); }); + + it('should get multiple selected results', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + expect( + results + .getMultipleSelectedResults({ main: true, allCameras: true }) + .map((media) => media.getID()), + ).toEqual(['id-office-99', 'id-kitchen-99', 'id-office-99']); + }); }); From 89c16b58c5d7ce2fd7a06dba56f821198375ca6e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 19 Aug 2023 15:49:30 -0700 Subject: [PATCH 13/33] Remove old TODOs. --- src/components/media-grid.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index a3bd54bf..913e1aa7 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -1,9 +1,3 @@ -// TODO: Video scanning not select other camera? -// TODO: Drag from live results in different cameras being shown in timeline in media viewer? -// TODO: It's possible to get a single entry in the grid, rendered without the -// grid (correctly), but still with isGrid so the menu button still shows the -// take it out of grid mode option. - import { CSSResultGroup, html, From 3c23894c6da1701a5966847011b9954bbb25cbe8 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 19 Aug 2023 19:51:16 -0700 Subject: [PATCH 14/33] Scrubbing performance improvements. --- src/components/thumbnail.ts | 17 ++++++++++------- src/scss/drawer.scss | 8 ++++---- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 1625056c..edc157e2 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -333,11 +333,18 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement { @customElement('frigate-card-thumbnail') export class FrigateCardThumbnail extends LitElement { - // HomeAssistant object may be required for thumbnail signing (for Frigate - // events). - @property({ attribute: false }) + // Performance: During timeline scrubbing, hass may be updated + // continuously. As it is not needed for the thumbnail rendering itself, it + // does not trigger a re-render. The HomeAssistant object may be required for + // thumbnail signing (after initial signing the thumbnail is stored in a data + // URL, so the signing will not expire). public hass?: ExtendedHomeAssistant; + // Performance: During timeline scrubbing, the view will be updated + // continuously. As it is not needed for the thumbnail rendering itself, it + // does not trigger a re-render. + public view?: Readonly; + @property({ attribute: false }) public cameraManager?: CameraManager; @@ -359,9 +366,6 @@ export class FrigateCardThumbnail extends LitElement { @property({ attribute: false }) public seek?: Date; - @property({ attribute: false }) - public view?: Readonly; - /** * Render the element. * @returns A template to display to the user. @@ -381,7 +385,6 @@ export class FrigateCardThumbnail extends LitElement { const shouldShowTimelineControl = this.show_timeline_control && - this.view && (!ViewMediaClassifier.isRecording(this.media) || // Only show timeline control if the recording has a start & end time. (this.media.getStartTime() && this.media.getEndTime())); diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss index a86b3070..ebbb47fc 100644 --- a/src/scss/drawer.scss +++ b/src/scss/drawer.scss @@ -13,14 +13,14 @@ div.control-surround { padding-top: $drawer-padding-extend; padding-bottom: $drawer-padding-extend; } -:host([location=left]) div.control-surround { +:host([location='left']) div.control-surround { @if $drawer-icon-size < 32 { // Ensure the clickable area is at least 32px wide. padding-right: calc(32px - $drawer-icon-size); } left: 100%; } -:host([location=right]) div.control-surround { +:host([location='right']) div.control-surround { @if $drawer-icon-size < 32 { // See note above. padding-left: calc(32px - $drawer-icon-size); @@ -54,12 +54,12 @@ ha-icon.control { background-color: black; } -:host([location=left]) ha-icon.control { +:host([location='left']) ha-icon.control { border-top-right-radius: $drawer-icon-size; border-bottom-right-radius: $drawer-icon-size; } -:host([location=right]) ha-icon.control { +:host([location='right']) ha-icon.control { border-top-left-radius: $drawer-icon-size; border-bottom-left-radius: $drawer-icon-size; } From d23d412fbf3fa8c29ed26d09cf72034763cc3f83 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 19 Aug 2023 20:24:19 -0700 Subject: [PATCH 15/33] Fix typos. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cb808211..334d3166 100644 --- a/README.md +++ b/README.md @@ -593,7 +593,7 @@ live: | Option | Default | Overridable | Description | | - | - | - | - | -| `mode` | `single` | :white_check_mark: | Whether to display a `single` media item at a time, or a media item for all cameras in a `grid`` configuration.| +| `mode` | `single` | :white_check_mark: | Whether to display a `single` media item at a time, or a media item for all cameras in a `grid` configuration.| | `grid_selected_width_factor` | `2` | :white_check_mark: | How much to scale up the selected media item in a grid. A value of `1` will not scale the selected item at all, the default value of `2` will scale the media item width to twice what it would otherwise be, etc. | | `grid_columns` | | :white_check_mark: | If specified the grid will always have exactly this number of columns.| | `grid_max_columns` | `4` | :white_check_mark: | If specified, and `grid_columns` is not specified, the grid will not render more than this number of columns. The precise number will be calculated based on the [grid layout algorithm](#grid-layout-algorith). | @@ -732,7 +732,7 @@ media_viewer: | Option | Default | Overridable | Description | | - | - | - | - | -| `mode` | `single` | :white_check_mark: | Whether to display a `single` media item at a time, or a media item for all cameras in a `grid`` configuration.| +| `mode` | `single` | :white_check_mark: | Whether to display a `single` media item at a time, or a media item for all cameras in a `grid` configuration.| | `grid_selected_width_factor` | `2` | :white_check_mark: | How much to scale up the selected media item in a grid. A value of `1` will not scale the selected item at all, the default value of `2` will scale the media item width to twice what it would otherwise be, etc. | | `grid_columns` | | :white_check_mark: | If specified the grid will always have exactly this number of columns.| | `grid_max_columns` | `4` | :white_check_mark: | If specified, and `grid_columns` is not specified, the grid will not render more than this number of columns. The precise number will be calculated based on the [grid layout algorithm](#grid-layout-algorith). | From 2df22b96ea93cc5137019b1509394bc96d0c1c80 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Aug 2023 11:00:57 -0700 Subject: [PATCH 16/33] Fix various small issues. --- src/card.ts | 11 ++--------- src/components/timeline-core.ts | 2 +- src/view/view.ts | 7 ++++++- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/card.ts b/src/card.ts index 16f6c201..cfccf191 100644 --- a/src/card.ts +++ b/src/card.ts @@ -189,9 +189,6 @@ class FrigateCard extends LitElement { @state() protected _expand = false; - @state() - protected _viewDisplayMode?: ViewDisplayMode; - protected _microphoneController?: MicrophoneController; protected _conditionController?: ConditionController; protected _automationsController?: AutomationsController; @@ -503,7 +500,7 @@ class FrigateCard extends LitElement { if (cameraID) { const viewName = args?.viewName ?? this._getConfig().view.default; const displayMode = - this._viewDisplayMode ?? this._getDefaultDisplayModeForView(viewName); + this._view?.displayMode ?? this._getDefaultDisplayModeForView(viewName); changeView( new View({ view: viewName, @@ -1067,6 +1064,7 @@ class FrigateCard extends LitElement { protected _getDefaultDisplayModeForView(view: FrigateCardView): ViewDisplayMode { let mode: ViewDisplayMode | null = null; switch (view) { + case 'media': case 'clip': case 'clips': case 'recording': @@ -1238,11 +1236,6 @@ class FrigateCard extends LitElement { }); break; case 'display_mode_select': - this._viewDisplayMode = frigateCardAction.display_mode; - this._conditionController?.setState({ - displayMode: this._viewDisplayMode, - }); - const newView = this._view?.evolve({ displayMode: frigateCardAction.display_mode, }); diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 729dcb63..984400f6 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -482,7 +482,7 @@ export class FrigateCardTimelineCore extends LitElement { const canSeek = this._shouldSupportSeeking(); const newResults = this._panBehavior === 'seek-in-media' - ? null + ? results : results .clone() .resetSelectedResult() diff --git a/src/view/view.ts b/src/view/view.ts index f4619678..e41e1db4 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -91,7 +91,12 @@ export class View { let currentQueriesView: ClipsOrSnapshots | 'recordings' | null = null; if (MediaQueriesClassifier.areEventQueries(curr.query)) { const queries = curr.query.getQueries(); - if (queries?.every((query) => query.hasClip)) { + if ( + queries?.every((query) => query.hasClip) || + queries?.every( + (query) => query.hasClip === undefined && query.hasSnapshot === undefined, + ) + ) { currentQueriesView = 'clips'; } else if (queries?.every((query) => query.hasSnapshot)) { currentQueriesView = 'snapshots'; From 0e5e99419f88f016577dadbb4d14e024edce4b2d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Aug 2023 15:50:37 -0700 Subject: [PATCH 17/33] Better timeline functionality for recordings. --- src/components/timeline-core.ts | 152 ++++++++------------ src/scss/timeline-core.scss | 4 +- src/utils/media-to-view.ts | 8 +- src/utils/timeline-source.ts | 23 ++- src/view/media-queries-classifier.ts | 9 ++ tests/utils/media-to-view.test.ts | 47 +----- tests/view/media-queries-classifier.test.ts | 29 ++++ 7 files changed, 112 insertions(+), 160 deletions(-) create mode 100644 tests/view/media-queries-classifier.test.ts diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 984400f6..3e63d4d7 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -1,9 +1,7 @@ import add from 'date-fns/add'; import differenceInSeconds from 'date-fns/differenceInSeconds'; import endOfDay from 'date-fns/endOfDay'; -import endOfHour from 'date-fns/endOfHour'; import startOfDay from 'date-fns/startOfDay'; -import startOfHour from 'date-fns/startOfHour'; import sub from 'date-fns/sub'; import { CSSResultGroup, @@ -31,6 +29,7 @@ import { import { CameraManager } from '../camera-manager/manager'; import { rangesOverlap } from '../camera-manager/range'; import { MediaQuery } from '../camera-manager/types'; +import { convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; import { localize } from '../localize/localize'; import timelineCoreStyle from '../scss/timeline-core.scss'; import { @@ -51,16 +50,19 @@ import { isTruthy, setOrRemoveAttribute, } from '../utils/basic'; -import { - createQueriesForRecordingsView, - executeMediaQueryForView, - findBestMediaIndex, -} from '../utils/media-to-view'; +import { executeMediaQueryForView, findBestMediaIndex } from '../utils/media-to-view'; import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; import { ViewMedia } from '../view/media'; import { ViewMediaClassifier } from '../view/media-classifier'; -import { EventMediaQueries, MediaQueries } from '../view/media-queries'; -import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import { + EventMediaQueries, + MediaQueries, + RecordingMediaQueries, +} from '../view/media-queries'; +import { + MediaQueriesClassifier, + MediaQueriesType, +} from '../view/media-queries-classifier'; import { View } from '../view/view'; import './date-picker.js'; import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js'; @@ -541,6 +543,7 @@ export class FrigateCardTimelineCore extends LitElement { !this.cameraManager || !this.cardWideConfig || !timelineCameraIDs || + !this._timelineSource || !properties.what ) { return; @@ -551,45 +554,10 @@ export class FrigateCardTimelineCore extends LitElement { if ( this.timelineConfig?.show_recordings && - ['background', 'group-label'].includes(properties.what) + properties.time && + ['background', 'axis'].includes(properties.what) ) { - const cameraIDs = properties.group - ? new Set([String(properties.group)]) - : this._getTimelineCameraIDs(); - const query = cameraIDs - ? createQueriesForRecordingsView( - this.cameraManager, - this.cardWideConfig, - cameraIDs, - ) - : null; - if (query) { - view = await executeMediaQueryForView( - this, - this.hass, - this.cameraManager, - this.view, - query, - { - targetView: 'recording', - targetTime: - properties.what === 'background' - ? properties.time - : this._timeline.getWindow().end, - select: 'time', - }, - ); - } - } else if (this.timelineConfig?.show_recordings && properties.what === 'axis') { - const query = createQueriesForRecordingsView( - this.cameraManager, - this.cardWideConfig, - timelineCameraIDs, - { - start: startOfHour(properties.time), - end: endOfHour(properties.time), - }, - ); + const query = this._createMediaQueries('recording'); if (query) { view = await executeMediaQueryForView( this, @@ -623,8 +591,8 @@ export class FrigateCardTimelineCore extends LitElement { // gallery (i.e. any case where the thumbnails may not be match the // events on the timeline, e.g. in the snapshots viewer but // mini-timeline showing all media). - const fullEventView = await this._createViewWithEventMediaQuery( - this._createEventMediaQuerys(), + const fullEventView = await this._createViewWithMediaQueries( + this._createMediaQueries('event'), { selectedItem: properties.item, targetView: 'media', @@ -686,59 +654,58 @@ export class FrigateCardTimelineCore extends LitElement { } this._removeTargetBar(); - if (!this.hass) { + if (!this.hass || !this._timeline || !this.view) { return; } - const prefetchedWindow = this._getPrefetchWindow(properties); - await this._timelineSource?.refresh(this.hass, prefetchedWindow); + await this._timelineSource?.refresh(this.hass, this._getPrefetchWindow(properties)); - // Don't show event thumbnails if the user is looking at recordings, - // as the recording "hours" are the media, not the event - // clips/snapshots. - if ( - this._timeline && - this.view && - !MediaQueriesClassifier.areRecordingQueries(this.view.query) - ) { - const newView = await this._createViewWithEventMediaQuery( - this._createEventMediaQuerys({ window: this._timeline.getWindow() }), - ); + const queryType = MediaQueriesClassifier.getQueriesType(this.view.query); + if (!queryType) { + return; + } + const mediaQuery = this._createMediaQueries(queryType); + const newView = await this._createViewWithMediaQueries(mediaQuery); - // Specifically avoid dispatching new results on range change unless there - // is something to be gained by doing so. Example usecase: On initial view - // load in mini timeline mode, the first 50 events are fetched -- the - // first drag of the timeline should not dispatch new results unless - // something is actually useful (as otherwise it creates a visible - // 'flicker' for the user as the viewer reloads all the media). - const newResults = newView?.queryResults; - if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { - newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this); - } + // Specifically avoid dispatching new results on range change unless there + // is something to be gained by doing so. Example usecase: On initial view + // load in mini timeline mode, the first 50 events are fetched -- the + // first drag of the timeline should not dispatch new results unless + // something is actually useful (as otherwise it creates a visible + // 'flicker' for the user as the viewer reloads all the media). + const newResults = newView?.queryResults; + if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { + newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this); } } - protected _createEventMediaQuerys(options?: { - window?: TimelineWindow; - }): EventMediaQueries | null { - if (!this._timeline || !this._timelineSource || !this.cardWideConfig) { + protected _createMediaQueries( + type: MediaQueriesType, + options?: { + window?: TimelineWindow; + }, + ): MediaQueries | null { + if (!this._timeline || !this._timelineSource) { return null; } - const cacheFriendlyWindow = this._timelineSource.getCacheFriendlyEventWindow( - options?.window ?? this._timeline.getWindow(), + const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes( + this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()), ); - const eventQueries = - this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow); - if (!eventQueries) { - return null; + if (type === 'event') { + const queries = this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow); + return queries ? new EventMediaQueries(queries) : null; + } else if (type === 'recording') { + const queries = + this._timelineSource.getTimelineRecordingQueries(cacheFriendlyWindow); + return queries ? new RecordingMediaQueries(queries) : null; } - return new EventMediaQueries(eventQueries); + return null; } - protected async _createViewWithEventMediaQuery( - query: EventMediaQueries | null, + protected async _createViewWithMediaQueries( + query: MediaQueries | null, options?: { targetView?: FrigateCardView; selectedItem?: IdType; @@ -1089,20 +1056,23 @@ export class FrigateCardTimelineCore extends LitElement { // -> New view received ... [loop] // // Also don't generate thumbnails in mini-timelines (they will already have - // been generated), or if the view is for recordings (media thumbnails are - // recordings, not events in this case). + // been generated). - const freshMediaQuery = this._createEventMediaQuerys({ + const queryType = MediaQueriesClassifier.getQueriesType(this.view.query); + if (!queryType) { + return; + } + + const freshMediaQuery = this._createMediaQueries(queryType, { window: desiredWindow, }); if ( !this.mini && - !MediaQueriesClassifier.areRecordingQueries(this.view.query) && freshMediaQuery && !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) ) { - (await this._createViewWithEventMediaQuery(freshMediaQuery)) + (await this._createViewWithMediaQueries(freshMediaQuery)) ?.mergeInContext(this._getTimelineContext(desiredWindow)) .dispatchChangeEvent(this); } diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss index 299cd2f4..6d876cc9 100644 --- a/src/scss/timeline-core.scss +++ b/src/scss/timeline-core.scss @@ -84,7 +84,6 @@ div.timeline { cursor: pointer; } :host([recordings]) .vis-item.vis-background, -:host([recordings]) .vis-labelset, :host([recordings]) .vis-time-axis { cursor: crosshair; } @@ -119,8 +118,7 @@ div.timeline { .vis-label { transition: background-color 0.5s ease-out; } -:host([recordings]) .vis-text.vis-minor:hover, -:host([recordings]) .vis-label:hover { +:host([recordings]) .vis-text.vis-minor:hover { background-color: var(--primary-color); } diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 6e0059f3..b47b7a8b 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -116,21 +116,15 @@ export const changeViewToRecentRecordingForCameraAndDependents = async ( )?.dispatchChangeEvent(element); }; -export const createQueriesForRecordingsView = ( +const createQueriesForRecordingsView = ( cameraManager: CameraManager, cardWideConfig: CardWideConfig, cameraIDs: Set, - options?: { - start?: Date; - end?: Date; - }, ): RecordingMediaQueries | null => { const limit = cardWideConfig.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT; const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { limit: limit, - ...(options?.start && { start: options.start }), - ...(options?.end && { end: options.end }), }); return recordingQueries ? new RecordingMediaQueries(recordingQueries) : null; }; diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index f2646457..e50299b1 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -5,7 +5,7 @@ import { DataSet } from 'vis-data'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; import { ClipsOrSnapshotsOrAll } from '../types'; import { CameraManager } from '../camera-manager/manager'; -import { EventQuery, RecordingSegment } from '../camera-manager/types'; +import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types'; import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; import { ViewMedia } from '../view/media'; import { @@ -98,12 +98,6 @@ export class TimelineDataSource { } } - public getCacheFriendlyEventWindow(window: TimelineWindow): TimelineWindow { - return convertRangeToCacheFriendlyTimes(window, { - endCap: true, - }); - } - public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null { return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, { start: window.start, @@ -113,6 +107,13 @@ export class TimelineDataSource { }); } + public getTimelineRecordingQueries(window: TimelineWindow): RecordingQuery[] | null { + return this._cameraManager.generateDefaultRecordingQueries(this._cameraIDs, { + start: window.start, + end: window.end, + }); + } + protected async _refreshEvents( hass: HomeAssistant, window: TimelineWindow, @@ -127,8 +128,7 @@ export class TimelineDataSource { ) { return; } - - const cacheFriendlyWindow = this.getCacheFriendlyEventWindow(window); + const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window); const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow); if (!eventQueries) { return; @@ -217,10 +217,7 @@ export class TimelineDataSource { return; } - const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window, { - endCap: true, - }); - + const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window); const recordingQueries = this._cameraManager.generateDefaultRecordingSegmentsQueries( this._cameraIDs, { diff --git a/src/view/media-queries-classifier.ts b/src/view/media-queries-classifier.ts index 4508e97f..b63210bc 100644 --- a/src/view/media-queries-classifier.ts +++ b/src/view/media-queries-classifier.ts @@ -1,5 +1,6 @@ import { EventMediaQueries, MediaQueries, RecordingMediaQueries } from './media-queries'; +export type MediaQueriesType = 'event' | 'recording'; export class MediaQueriesClassifier { public static areEventQueries( queries?: MediaQueries | null, @@ -12,4 +13,12 @@ export class MediaQueriesClassifier { ): queries is RecordingMediaQueries { return queries instanceof RecordingMediaQueries; } + + public static getQueriesType(queries?: MediaQueries | null): MediaQueriesType | null { + return this.areEventQueries(queries) + ? 'event' + : this.areRecordingQueries(queries) + ? 'recording' + : null; + } } diff --git a/tests/utils/media-to-view.test.ts b/tests/utils/media-to-view.test.ts index 561bbc65..5e8974d5 100644 --- a/tests/utils/media-to-view.test.ts +++ b/tests/utils/media-to-view.test.ts @@ -1,16 +1,10 @@ import add from 'date-fns/add'; import sub from 'date-fns/sub'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { - CameraConfigs, - PartialRecordingQuery, - QueryType, -} from '../../src/camera-manager/types'; -import { setify } from '../../src/utils/basic'; +import { CameraConfigs } from '../../src/camera-manager/types'; import { changeViewToRecentEventsForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents, - createQueriesForRecordingsView, executeMediaQueryForView, findBestMediaIndex, } from '../../src/utils/media-to-view'; @@ -400,45 +394,6 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => { }); }); -// @vitest-environment jsdom -describe('createQueriesForRecordingsView', () => { - it('should respect start and end date in recording query', async () => { - const cameraManager = createCameraManager({ configs: new Map() }); - - vi.mocked(cameraManager.generateDefaultRecordingQueries).mockImplementation( - (cameraIDs: string | Set, partialQuery?: PartialRecordingQuery) => [ - { - cameraIDs: setify(cameraIDs), - type: QueryType.Recording, - ...partialQuery, - }, - ], - ); - - const start = new Date('2023-04-29T14:00:00'); - const end = new Date('2023-04-29T14:59:59'); - - const queries = createQueriesForRecordingsView( - cameraManager, - {}, - new Set(['camera']), - { - start: start, - end: end, - }, - ); - - expect(queries?.getQueries()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - start: start, - end: end, - }), - ]), - ); - }); -}); - // @vitest-environment jsdom describe('findBestMediaIndex', () => { it('should find best media index', async () => { diff --git a/tests/view/media-queries-classifier.test.ts b/tests/view/media-queries-classifier.test.ts new file mode 100644 index 00000000..0b96608b --- /dev/null +++ b/tests/view/media-queries-classifier.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries'; +import { MediaQueriesClassifier } from '../../src/view/media-queries-classifier'; + +describe('MediaQueriesClassifier', () => { + it('areEventQueries', () => { + expect(MediaQueriesClassifier.areEventQueries(new EventMediaQueries())).toBeTruthy(); + expect( + MediaQueriesClassifier.areEventQueries(new RecordingMediaQueries()), + ).toBeFalsy(); + }); + + it('areRecordingQueries', () => { + expect( + MediaQueriesClassifier.areRecordingQueries(new RecordingMediaQueries()), + ).toBeTruthy(); + expect( + MediaQueriesClassifier.areRecordingQueries(new EventMediaQueries()), + ).toBeFalsy(); + }); + + it('getQueriesType', () => { + expect(MediaQueriesClassifier.getQueriesType(new EventMediaQueries())).toBe('event'); + expect(MediaQueriesClassifier.getQueriesType(new RecordingMediaQueries())).toBe( + 'recording', + ); + expect(MediaQueriesClassifier.getQueriesType()).toBeNull(); + }); +}); From ef7598cd84efeec0489df0083de3b61c95773a1a Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Aug 2023 16:10:36 -0700 Subject: [PATCH 18/33] Use usable time for in progress events. --- src/utils/timeline-source.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index e50299b1..75bd426e 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -148,7 +148,7 @@ export class TimelineDataSource { media: media, start: startTime.getTime(), type: 'range', - end: endTime?.getTime() ?? startTime.getTime(), + end: media.getUsableEndTime()?.getTime() ?? startTime.getTime(), }); } } From 74f00e19855d92c5fe86fc43cc132818ec5d4b43 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 21 Aug 2023 19:46:35 -0700 Subject: [PATCH 19/33] Remove unused variable. --- src/utils/timeline-source.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 75bd426e..ba8ba6f7 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -137,7 +137,6 @@ export class TimelineDataSource { const mediaArray = await this._cameraManager.executeMediaQueries(hass, eventQueries); const data: FrigateCardTimelineItem[] = []; for (const media of mediaArray ?? []) { - const endTime = media.getEndTime(); const startTime = media.getStartTime(); const id = media.getID(); if (id && startTime) { From 87f137b474565bfe1a07478f49f6979b56db2c85 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 26 Aug 2023 11:11:44 -0700 Subject: [PATCH 20/33] Add support for timeline seeking locked to a camera. --- README.md | 11 ++++++ src/components/timeline-core.ts | 63 ++++++++++++++++++++----------- src/localize/languages/en.json | 1 + src/localize/languages/it.json | 1 + src/localize/languages/pt-BR.json | 1 + src/localize/languages/pt-PT.json | 7 ++-- 6 files changed, 60 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 334d3166..124f6e59 100644 --- a/README.md +++ b/README.md @@ -854,6 +854,17 @@ timeline: +#### Timeline Seek Behavior + +The behavior of the timeline during seeking/dragging can be controlled by means of the icon on the bottom-right of the timeline. + +| Option | Behavior | +| - | - | +| filmstrip-box-multiple | Dragging the timeline will seek / select across all available media from all cameras, selecting the media item with the longest duration whilst favoring (but not limited to) the currently selected camera. | +| play-box-lock | Dragging the timeline will seek within the selected media item only. | +| camera-lock | Dragging the timeline will seek / select across all available media from the selected camera only, selecting the media item with the longest duration. | +| pan-horizontal | Dragging the timeline will pan only without selected or seeking any media. | + ### Dimensions Options These options control the aspect-ratio of the entire card to make placement in diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 3e63d4d7..126ea111 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -63,6 +63,7 @@ import { MediaQueriesClassifier, MediaQueriesType, } from '../view/media-queries-classifier'; +import { MediaQueriesResults } from '../view/media-queries-results'; import { View } from '../view/view'; import './date-picker.js'; import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js'; @@ -85,7 +86,7 @@ interface TimelineViewContext { } type TimelineItemClickAction = 'play' | 'select'; -type TimelinePanBehavior = 'pan' | 'seek' | 'seek-in-media'; +type TimelinePanBehavior = 'pan' | 'seek' | 'seek-in-media' | 'seek-in-camera'; declare module 'view' { interface ViewContext { @@ -288,13 +289,17 @@ export class FrigateCardTimelineCore extends LitElement { ? localize('timeline.pan_behavior.pan') : this._panBehavior === 'seek' ? localize('timeline.pan_behavior.seek') - : localize('timeline.pan_behavior.seek-in-media'); + : this._panBehavior === 'seek-in-media' + ? localize('timeline.pan_behavior.seek-in-media') + : localize('timeline.pan_behavior.seek-in-camera'); const panIcon = this._panBehavior === 'pan' ? 'mdi:pan-horizontal' : this._panBehavior === 'seek' - ? 'mdi:filmstrip' - : 'mdi:lock'; + ? 'mdi:filmstrip-box-multiple' + : this._panBehavior === 'seek-in-media' + ? 'mdi:play-box-lock' + : 'mdi:camera-lock'; return html` ${capabilities?.supportsTimeline ? html`
{ const item = this._timelineSource?.dataset?.get(id); return ( + this._panBehavior !== 'seek-in-camera' || + item?.media?.getCameraID() === this.view?.camera, item && - item.start && - item.end && - targetTime.getTime() >= item.start && - targetTime.getTime() <= item.end + item.start && + item.end && + targetTime.getTime() >= item.start && + targetTime.getTime() <= item.end ); }))); @@ -482,19 +492,30 @@ export class FrigateCardTimelineCore extends LitElement { } const canSeek = this._shouldSupportSeeking(); - const newResults = - this._panBehavior === 'seek-in-media' - ? results - : results - .clone() - .resetSelectedResult() - .selectBestResult( - (media) => findBestMediaIndex(media, targetTime, this.view?.camera), - { - allCameras: true, - main: true, - }, - ); + let newResults: MediaQueriesResults | null = null; + + if (this._panBehavior === 'seek') { + newResults = results + .clone() + .resetSelectedResult() + .selectBestResult( + (mediaArray) => findBestMediaIndex(mediaArray, targetTime, this.view?.camera), + { + allCameras: true, + main: true, + }, + ); + } else if (this._panBehavior === 'seek-in-camera') { + newResults = results + .clone() + .resetSelectedResult() + .selectBestResult((mediaArray) => findBestMediaIndex(mediaArray, targetTime), { + cameraID: this.view.camera, + }) + .promoteCameraSelectionToMainSelection(this.view.camera); + } else if (this._panBehavior === 'seek-in-media') { + newResults = results; + } const desiredView: FrigateCardView = this.mini ? targetTime >= new Date() diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index e84d933f..d95f7d5b 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -516,6 +516,7 @@ "pan_behavior": { "pan": "Pan", "seek": "Pan seeks across all media", + "seek-in-camera": "Pan seeks within selected camera only", "seek-in-media": "Pan seeks within selected media item only" }, "select_date": "Choose date" diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 35a150c4..25fdaf4a 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -504,6 +504,7 @@ "pan_behavior": { "pan": "", "seek": "", + "seek-in-camera": "", "seek-in-media": "" }, "select_date": "Scegli la data" diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 1609d398..f5da9fb5 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -515,6 +515,7 @@ "pan_behavior": { "pan": "", "seek": "", + "seek-in-camera": "", "seek-in-media": "" }, "select_date": "Escolha a data" diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 57fc800f..f25d95f6 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -495,9 +495,10 @@ }, "timeline": { "pan_behavior": { - "pan": "Pan", - "seek": "Pan seeks across all media", - "seek-in-media": "Pan seeks within selected media item only" + "pan": "", + "seek": "", + "seek-in-camera": "", + "seek-in-media": "" }, "select_date": "Selecionar a data" } From af5b0f7f238e300f7350c1b095d8faf36a7d82dd Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 26 Aug 2023 11:41:06 -0700 Subject: [PATCH 21/33] Be less eager with carousel resizes. --- src/components/media-carousel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 8e8a2f0e..6f9e5a82 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -136,7 +136,7 @@ export class FrigateCardMediaCarousel extends LitElement { // Debounce multiple calls to adapt the container height. protected _debouncedAdaptContainerHeightToSlide = debounce( this._adaptContainerHeightToSlide.bind(this), - 1 * 100, + 1 * 250, {trailing: true}); // This carousel may be resized by Lovelace resizes, window resizes, From 48ece1e1546fbc115f4b5564386045b789ada3c4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 26 Aug 2023 14:51:48 -0700 Subject: [PATCH 22/33] Improve display mode button text. --- src/editor.ts | 4 ++-- src/localize/languages/en.json | 10 +++++----- src/localize/languages/it.json | 9 +++++---- src/localize/languages/pt-BR.json | 10 +++++----- src/localize/languages/pt-PT.json | 10 +++++----- src/utils/menu-controller.ts | 4 +++- tests/utils/menu-controller.test.ts | 5 ++++- 7 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/editor.ts b/src/editor.ts index 56a3b231..2bbc2b2c 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -557,8 +557,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor protected _displayModes: EditorSelectOption[] = [ { value: '', label: '' }, - { value: 'single', label: localize('config.common.display.modes.single') }, - { value: 'grid', label: localize('config.common.display.modes.grid') }, + { value: 'single', label: localize('display_modes.single') }, + { value: 'grid', label: localize('display_modes.grid') }, ]; public setConfig(config: RawFrigateCardConfig): void { diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index d95f7d5b..a5e860f4 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -153,11 +153,7 @@ "grid_columns": "Exact number of grid columns", "grid_max_columns": "Maximum number of grid columns", "grid_selected_width_factor": "Increase selected media width by this factor", - "mode": "Mode", - "modes": { - "single": "Show single media viewer", - "grid": "Show media viewer for each camera in a grid" - } + "mode": "Mode" }, "layout": { "fit": "Layout fit", @@ -376,6 +372,10 @@ } } }, + "display_modes": { + "single": "Show single media viewer", + "grid": "Show media viewer for each camera in a grid" + }, "editor": { "add_new_camera": "Add new camera", "button": "Button", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 25fdaf4a..fa1b9a97 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -153,10 +153,7 @@ "grid_columns": "", "grid_max_columns": "", "grid_selected_width_factor": "", - "modes": { - "single": "", - "grid": "" - } + "mode": "" }, "layout": { "fit": "Adatta al layout", @@ -369,6 +366,10 @@ } } }, + "display_modes": { + "grid": "", + "single": "" + }, "editor": { "add_new_camera": "Aggiungi nuova telecamera", "button": "Pulsante", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index f5da9fb5..827c014d 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -153,11 +153,7 @@ "grid_columns": "", "grid_max_columns": "", "grid_selected_width_factor": "", - "mode": "", - "modes": { - "single": "", - "grid": "" - } + "mode": "" }, "layout": { "fit": "Ajuste de layout", @@ -375,6 +371,10 @@ } } }, + "display_modes": { + "grid": "", + "single": "" + }, "editor": { "add_new_camera": "Adicionar nova câmera", "button": "Botão", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index f25d95f6..5ec59b7f 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -153,11 +153,7 @@ "grid_columns": "", "grid_max_columns": "", "grid_selected_width_factor": "", - "mode": "", - "modes": { - "single": "", - "grid": "" - } + "mode": "" }, "layout": { "fit": "Fit", @@ -363,6 +359,10 @@ } } }, + "display_modes": { + "grid": "", + "single": "" + }, "editor": { "add_new_camera": "Adicionar nova câmera", "button": "Botão", diff --git a/src/utils/menu-controller.ts b/src/utils/menu-controller.ts index 2790e2c6..f069397f 100644 --- a/src/utils/menu-controller.ts +++ b/src/utils/menu-controller.ts @@ -404,7 +404,9 @@ export class MenuButtonController { icon: isGrid ? 'mdi:grid-off' : 'mdi:grid', ...config.menu.buttons.display_mode, type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.display_mode'), + title: isGrid + ? localize('display_modes.single') + : localize('display_modes.grid'), tap_action: action, }); } diff --git a/tests/utils/menu-controller.test.ts b/tests/utils/menu-controller.test.ts index 36adc42b..00c578c5 100644 --- a/tests/utils/menu-controller.test.ts +++ b/tests/utils/menu-controller.test.ts @@ -1102,7 +1102,10 @@ describe('MenuButtonController', () => { enabled: true, priority: 50, type: 'custom:frigate-card-menu-icon', - title: 'Display mode', + title: + displayMode === 'grid' + ? 'Show single media viewer' + : 'Show media viewer for each camera in a grid', tap_action: { action: 'fire-dom-event', frigate_card_action: 'display_mode_select', From e830db1a7775475f946f79a7541948f8bb72facc Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 4 Sep 2023 16:25:32 -0700 Subject: [PATCH 23/33] Complete carousel refactor. Reduces one layer of DOM nesting for simplication, uses the latest Embla version, unittests for everything. --- package.json | 10 +- src/action-handler-directive.ts | 2 +- src/cached-value-controller.ts | 12 +- src/camera-manager/engine-factory.ts | 2 +- src/camera-manager/range.ts | 19 +- src/components/carousel.ts | 292 ++---- src/components/drawer.ts | 20 +- src/components/embla-plugins/automedia.ts | 234 ----- src/components/live/live.ts | 209 ++-- src/components/media-carousel.ts | 440 --------- src/components/thumbnail-carousel.ts | 214 ++--- src/components/title-control.ts | 41 +- src/components/viewer.ts | 169 ++-- src/scss/carousel.scss | 3 + src/scss/live-carousel.scss | 6 +- src/scss/live-ha.scss | 2 - src/scss/media-carousel.scss | 9 - src/scss/viewer-carousel.scss | 11 +- src/utils/basic.ts | 8 + src/utils/camera.ts | 4 +- src/utils/embla/carousel-controller.ts | 178 ++++ .../plugins/auto-lazy-load/auto-lazy-load.ts} | 106 +-- .../auto-media-actions/auto-media-actions.ts | 232 +++++ .../auto-media-loaded-info.ts | 94 ++ .../embla/plugins/auto-size/auto-size.ts | 143 +++ src/utils/embla/reinit-controller.ts | 63 ++ src/utils/media-grid-controller.ts | 21 +- src/utils/media-info.ts | 12 +- src/utils/menu-controller.ts | 3 +- src/utils/zod.ts | 17 +- src/view/media-queries-results.ts | 2 +- src/view/view.ts | 10 +- tests/camera-manager/cache.test.ts | 28 +- tests/camera-manager/engine-factory.test.ts | 5 + tests/camera-manager/range.test.ts | 5 + tests/conditions.test.ts | 7 + tests/test-utils.ts | 58 +- tests/utils/basic.test.ts | 33 +- tests/utils/download.test.ts | 97 +- tests/utils/embla/carousel-controller.test.ts | 291 ++++++ .../auto-lazy-load/auto-lazy-load.test.ts | 219 +++++ .../auto-media-actions.test.ts | 304 ++++++ .../auto-media-loaded-info.test.ts | 92 ++ .../embla/plugins/auto-size/auto-size.test.ts | 161 ++++ tests/utils/embla/reinit-controller.test.ts | 59 ++ tests/utils/embla/test-utils.ts | 132 +++ tests/utils/media-grid-controller.test.ts | 250 +++-- tests/utils/media.test.ts | 10 + tests/utils/menu-controller.test.ts | 33 +- tests/utils/substream.test.ts | 22 +- tests/utils/zod.test.ts | 11 +- tests/utils/zoom.test.ts | 67 ++ tests/view/media-queries-results.test.ts | 36 + tests/view/view.test.ts | 97 +- vite.config.ts | 11 + yarn.lock | 895 ++++++++++++------ 56 files changed, 3561 insertions(+), 1950 deletions(-) delete mode 100644 src/components/embla-plugins/automedia.ts delete mode 100644 src/components/media-carousel.ts delete mode 100644 src/scss/media-carousel.scss create mode 100644 src/utils/embla/carousel-controller.ts rename src/{components/embla-plugins/lazyload.ts => utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts} (57%) create mode 100644 src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts create mode 100644 src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.ts create mode 100644 src/utils/embla/plugins/auto-size/auto-size.ts create mode 100644 src/utils/embla/reinit-controller.ts create mode 100644 tests/utils/embla/carousel-controller.test.ts create mode 100644 tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts create mode 100644 tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts create mode 100644 tests/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.test.ts create mode 100644 tests/utils/embla/plugins/auto-size/auto-size.test.ts create mode 100644 tests/utils/embla/reinit-controller.test.ts create mode 100644 tests/utils/embla/test-utils.ts create mode 100644 vite.config.ts diff --git a/package.json b/package.json index ef7c35fd..e7f479de 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "custom-card-helpers": "^1.9.0", "date-fns": "^2.29.2", "date-fns-tz": "^1.3.7", - "embla-carousel": "^7.0.9", - "embla-carousel-wheel-gestures": "^3.0.0", + "embla-carousel": "8.0.0-rc12", + "embla-carousel-wheel-gestures": "8.0.0-rc04", "home-assistant-js-websocket": "^8.0.0", "keycharm": "^0.4.0", "lit": "^2.3.1", @@ -62,7 +62,7 @@ "@types/masonry-layout": "^4.2.5", "@typescript-eslint/eslint-plugin": "^5.36.2", "@typescript-eslint/parser": "^5.36.2", - "@vitest/coverage-c8": "^0.29.8", + "@vitest/coverage-istanbul": "^0.34.3", "eslint": "^8.23.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-prettier": "^8.5.0", @@ -80,8 +80,8 @@ "sass": "^1.54.9", "ts-prune": "^0.10.3", "typescript": "^4.9.5", - "vitest": "^0.29.8", - "vitest-mock-extended": "^1.1.3" + "vitest": "^0.34.3", + "vitest-mock-extended": "^1.2.1" }, "scripts": { "start": "rollup -c --watch", diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index 17cad147..170c3e41 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -6,9 +6,9 @@ import type { import { noChange } from 'lit'; import { AttributePart, - directive, Directive, DirectiveParameters, + directive, } from 'lit/directive.js'; import { stopEventFromActivatingCardWideActions } from './utils/action.js'; import { Timer } from './utils/timer.js'; diff --git a/src/cached-value-controller.ts b/src/cached-value-controller.ts index f0ce59dd..894d15c7 100644 --- a/src/cached-value-controller.ts +++ b/src/cached-value-controller.ts @@ -69,13 +69,11 @@ export class CachedValueController implements ReactiveController { public startTimer(): void { this.stopTimer(); - if (this._timerSeconds > 0) { - this._timerStartCallback?.(); - this._timer.startRepeated(this._timerSeconds, () => { - this.updateValue(); - this._host.requestUpdate(); - }); - } + this._timerStartCallback?.(); + this._timer.startRepeated(this._timerSeconds, () => { + this.updateValue(); + this._host.requestUpdate(); + }); } public hasTimer(): boolean { diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 7aab5ed1..61dd5901 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -66,7 +66,7 @@ export class CameraManagerEngineFactory { engine = Engine.MotionEye; } else if (cameraConfig.engine === 'generic') { engine = Engine.Generic; - } else if (cameraConfig.engine === 'auto') { + } else { const cameraEntity = getCameraEntityFromConfig(cameraConfig); if (cameraEntity) { diff --git a/src/camera-manager/range.ts b/src/camera-manager/range.ts index c358395b..e680a5e1 100644 --- a/src/camera-manager/range.ts +++ b/src/camera-manager/range.ts @@ -58,8 +58,8 @@ export class ExpiringMemoryRangeSet } public add(range: ExpiringRange): void { - this._expireOldRanges(); this._ranges.push(range); + this._expireOldRanges(); } protected _expireOldRanges(): void { @@ -95,26 +95,25 @@ export const compressRanges = ( ranges = orderBy(ranges, (range) => range.start, 'asc'); let current: Range | null = null; - for (let i = 0; i < ranges.length; ++i) { - const item = ranges[i]; - const itemStartSeconds = - item.start instanceof Date ? item.start.getTime() : item.start; + for (const range of ranges) { + const rangeStartSeconds: number = + range.start instanceof Date ? range.start.getTime() : range.start; if (!current) { - current = { ...item }; + current = { ...range }; continue; } const currentEndSeconds = current.end instanceof Date ? current.end.getTime() : (current.end as number); - if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) { - if (item.end > current.end) { - current.end = item.end; + if (currentEndSeconds + toleranceSeconds * 1000 >= rangeStartSeconds) { + if (range.end > current.end) { + current.end = range.end; } } else { compressedRanges.push(current); - current = { ...item }; + current = { ...range }; } } if (current) { diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 59a43c87..1b83b78c 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -1,5 +1,3 @@ -import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel'; -import { EmblaNodesType } from 'embla-carousel/components'; import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; import { CSSResultGroup, @@ -11,16 +9,12 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js'; -import isEqual from 'lodash-es/isEqual'; -import throttle from 'lodash-es/throttle'; import carouselStyle from '../scss/carousel.scss'; import { TransitionEffect } from '../types'; -import { dispatchFrigateCardEvent, isHTMLElement } from '../utils/basic.js'; - -export interface CarouselSelect { - index: number; - element: HTMLElement; -} +import { + CarouselController, + CarouselDirection, +} from '../utils/embla/carousel-controller'; export type EmblaCarouselPlugins = CreatePluginType< LoosePluginType, @@ -30,269 +24,91 @@ export type EmblaCarouselPlugins = CreatePluginType< @customElement('frigate-card-carousel') export class FrigateCardCarousel extends LitElement { @property({ attribute: true, reflect: true }) - public direction: 'vertical' | 'horizontal' = 'horizontal'; - - @property({ attribute: false }) - public carouselOptions?: EmblaOptionsType; - - @property({ attribute: false }) - public carouselPlugins?: EmblaCarouselPlugins; - - @property({ attribute: false }) - public selected = 0; + public direction: CarouselDirection = 'horizontal'; @property({ attribute: true }) public transitionEffect?: TransitionEffect; - protected _refSlot: Ref = createRef(); + @property({ attribute: false }) + public loop?: boolean; - protected _carousel?: EmblaCarouselType; + @property({ attribute: false }) + public dragFree?: boolean; - // Whether the carousel is actively scrolling. - protected _scrolling = false; + @property({ attribute: false }) + public dragEnabled = true; - // Whether to reinit the carousel when it settles. - protected _reInitOnSettle = false; + @property({ attribute: false }) + public plugins?: EmblaCarouselPlugins; - protected _carouselReInitInPlace = throttle( - this._carouselReInitInPlaceInternal.bind(this), - 500, - { trailing: true }, - ); + @property({ attribute: false }) + public selected = 0; + + protected _refParent: Ref = createRef(); + protected _refRoot: Ref = createRef(); + protected _carousel: CarouselController | null = null; connectedCallback(): void { super.connectedCallback(); - // Guarantee a re-render if the component is reconnected. See note in - // disconnectedCallback(). + // Guarantee recreation of carousel if the component is reconnected. this.requestUpdate(); } - /** - * Component disconnected callback. - */ disconnectedCallback(): void { // Destroy the carousel when the component is disconnected, which forces the // plugins (which may have registered event handlers) to also be destroyed. // The carousel will automatically reconstruct if the component is re-rendered. - this._destroyCarousel(); + this._carousel?.destroy(); super.disconnectedCallback(); } - /** - * Destroy the carousel if certain properties change. - * @param changedProps The changed properties - */ protected willUpdate(changedProps: PropertyValues): void { - const destroyProperties = [ - 'direction', - 'carouselOptions', - 'carouselPlugins', - ] as const; + if (changedProps.has('direction')) { + this.setAttribute('direction', this.direction); + } + + const destroyProperties = ['direction', 'dragFree', 'transitionEffect'] as const; if (destroyProperties.some((prop) => changedProps.has(prop))) { - this._destroyCarousel(); - } - } - - /** - * Get the selected slide. - * @returns A CarouselSelect object (index & element). - */ - public getCarouselSelected(slide?: number): CarouselSelect | null { - const index = slide ?? this._carousel?.selectedScrollSnap(); - const element = - index !== undefined ? this._carousel?.slideNodes()[index] ?? null : null; - if (index !== undefined && element) { - return { - index: index, - element: element, - }; - } - return null; - } - - /** - * Get the carousel. - */ - public carousel(): EmblaCarouselType | null { - return this._carousel ?? null; - } - - /** - * ReInit the carousel but stay on the current slide. - */ - protected _carouselReInitInPlaceInternal(): void { - const carouselReInit = (options?: EmblaOptionsType): void => { - // Allow the browser a moment to paint components that are inflight, to - // ensure accurate measurements are taken during the carousel - // reinitialization. - window.requestAnimationFrame(() => { - this._carousel?.reInit({ ...options }); - }); - }; - - carouselReInit({ - startIndex: this.selected, - }); - } - - /** - * ReInit the carousel when it is safe to do so without disturbing the - * appearance (i.e. cutting off a scroll in progress). - */ - public carouselReInitWhenSafe(): void { - if (this._scrolling) { - this._reInitOnSettle = true; - } else { - this._carouselReInitInPlace(); - } - } - - /** - * The updated lifecycle callback for this element. - * @param changedProperties The properties that were changed in this render. - */ - updated(changedProperties: PropertyValues): void { - super.updated(changedProperties); - - if (!this._carousel) { - this._initCarousel(); - } - - if (changedProperties.has('selected')) { - this._carousel?.scrollTo(this.selected, this.transitionEffect === 'none'); - } - } - - /** - * Destroy the carousel. - * @param options If `savePosition` is set the existing carousel position - * will be saved so it can be restored if the carousel is recreated. - */ - protected _destroyCarousel(): void { - if (this._carousel) { - this._carousel.destroy(); - } - this._carousel = undefined; - } - - protected _getSlideElements(): HTMLElement[] { - return ( - this._refSlot.value?.assignedElements({ flatten: true }).filter(isHTMLElement) ?? - [] - ); - } - - /** - * Initialize the carousel. - */ - protected _initCarousel(): void { - const carouselNode = this.renderRoot.querySelector( - '.embla__viewport', - ) as HTMLElement; - - const nodes: EmblaNodesType = { - root: carouselNode, - // As the slides are slotted, need to explicitly pull them out and pass - // them to Embla. - slides: this._getSlideElements(), - }; - - if (carouselNode && nodes.slides) { - this._carousel = EmblaCarousel( - nodes, - { - axis: this.direction == 'horizontal' ? 'x' : 'y', - speed: 30, - startIndex: this.selected, - ...this.carouselOptions, - }, - this.carouselPlugins, - ); - const selectSlide = (slide?: number): void => { - const selected = this.getCarouselSelected(slide); - if (selected) { - dispatchFrigateCardEvent(this, 'carousel:select', selected); - } - - // Make sure every select causes a refresh to allow for re-paint of the - // next/previous controls. - this.requestUpdate(); - }; - - this._carousel.on( - 'init', - // On initialization selectedScrollSnap() will return 0, even if the - // startIndex during initialization is different, as such we override - // the selected slide as returned by the carousel. This need should be - // verified in future versions of Embla (tested as necessary on v7.0.9). - // Test case: - // - // - Start in `live` view in grid mode. - // - Select any camera that is not the first one. - // - Go to non-grid mode. - // - Go back to grid mode. - // - If successful, thumbnails will load correctly (and the query and - // queryResults in the view will be set vs having been reset in - // `_setViewCameraID` in `live.ts`). - () => selectSlide(this.selected), - ); - this._carousel.on('select', () => selectSlide()); - this._carousel.on('scroll', () => { - this._scrolling = true; - }); - this._carousel.on('settle', () => { - // Reinitialize the carousel if a request to reinitialize was made - // during scrolling (instead the request is handled after the scrolling - // has settled). - this._scrolling = false; - if (this._reInitOnSettle) { - this._reInitOnSettle = false; - this._carouselReInitInPlace(); - } - }); - this._carousel.on('settle', () => { - const selected = this.getCarouselSelected(); - if (selected) { - dispatchFrigateCardEvent(this, 'carousel:settle', selected); - } - }); - } - } - - /** - * Called when the slotted children in the carousel change. - */ - protected _slotChanged(): void { - // Check whether the slotted elements have changed (without this check the - // carousel initializations are duplicated). - if (!isEqual(this._getSlideElements(), this._carousel?.slideNodes())) { - // Cannot just re-init, because the slide elements themselves may have - // changed, and only a carousel init can pass in new (slotted) children. - this._destroyCarousel(); - this.requestUpdate(); + this._carousel?.destroy(); + this._carousel = null; } } protected render(): TemplateResult | void { - const slides = this._refSlot.value?.assignedElements({ flatten: true }) || []; - const showPrevious = this.carouselOptions?.loop || this.selected > 0; - const showNext = this.carouselOptions?.loop || this.selected + 1 < slides.length; - return html`
- ${showPrevious ? html`` : ``} -
+ +
- +
- ${showNext ? html`` : ``} +
`; } - /** - * Get element styles. - */ + protected updated(changedProps: PropertyValues): void { + if (!this._carousel && this._refRoot.value && this._refParent.value) { + this._carousel = new CarouselController( + this._refRoot.value, + this._refParent.value, + { + direction: this.direction, + dragEnabled: this.dragEnabled, + dragFree: this.dragFree, + startIndex: this.selected, + transitionEffect: this.transitionEffect, + loop: this.loop, + plugins: this.plugins, + }, + ); + } + + if (changedProps.has('selected')) { + this._carousel?.selectSlide(this.selected); + } + } + static get styles(): CSSResultGroup { return unsafeCSS(carouselStyle); } diff --git a/src/components/drawer.ts b/src/components/drawer.ts index 0639c707..6ea33139 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -13,7 +13,7 @@ import { SideDrawer } from 'side-drawer'; import drawerInjectStyle from '../scss/drawer-inject.scss'; import drawerStyle from '../scss/drawer.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action'; -import { isHoverableDevice } from '../utils/basic'; +import { getChildrenFromElement, isHoverableDevice } from '../utils/basic'; export interface DrawerIcons { open?: string; @@ -67,12 +67,14 @@ export class FrigateCardDrawer extends LitElement { * Called when the slotted children in the drawer change. */ protected _slotChanged(): void { - const elements = this._refSlot.value?.assignedElements({ flatten: true }); + const children = this._refSlot.value + ? getChildrenFromElement(this._refSlot.value) + : []; // Watch all slot children for size changes. this._resizeObserver.disconnect(); - for (const element of elements ?? []) { - this._resizeObserver.observe(element); + for (const child of children) { + this._resizeObserver.observe(child); } this._hideDrawerIfNecessary(); } @@ -86,11 +88,13 @@ export class FrigateCardDrawer extends LitElement { return; } - const elements = this._refSlot.value?.assignedElements({ flatten: true }); + const children = this._refSlot.value + ? getChildrenFromElement(this._refSlot.value) + : null; this.empty = - !elements || - !elements.length || - elements.every((element) => { + !children || + !children.length || + children.every((element) => { const box = element.getBoundingClientRect(); return !box.width || !box.height; }); diff --git a/src/components/embla-plugins/automedia.ts b/src/components/embla-plugins/automedia.ts deleted file mode 100644 index a4f3dbd6..00000000 --- a/src/components/embla-plugins/automedia.ts +++ /dev/null @@ -1,234 +0,0 @@ -import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel'; -import { CreateOptionsType } from 'embla-carousel/components/Options.js'; -import { CreatePluginType } from 'embla-carousel/components/Plugins.js'; -import { - AutoMuteCondition, - AutoPauseCondition, - AutoPlayCondition, - AutoUnmuteCondition, - FrigateCardMediaPlayer, -} from '../../types.js'; - -type OptionsType = CreateOptionsType<{ - playerSelector?: string; - - // Note: Neither play nor unmute will activate on selection. The caller is - // expected to call the `play()` or `unmute()` methods manually when the media - // is actually loaded (and not just when the slide is visible -- the browser - // cannot play media that is not actually loaded yet, e.g. lazy loading). - autoPlayCondition?: AutoPlayCondition; - autoUnmuteCondition?: AutoUnmuteCondition; - autoPauseCondition?: AutoPauseCondition; - autoMuteCondition?: AutoMuteCondition; -}>; - -const defaultOptions: OptionsType = { - active: true, - breakpoints: {}, -}; - -type AutoMediaOptionsType = Partial - -export type AutoMediaType = CreatePluginType< - { - play: () => void; - pause: () => void; - mute: () => void; - unmute: () => void; - }, - AutoMediaOptionsType ->; - -declare module 'embla-carousel/components/Plugins' { - interface EmblaPluginsType { - autoMedia?: AutoMediaType - } -} - -/** - * An Embla plugin to take automated actions on media (e.g. pause, unmute, etc). - * @param userOptions - * @returns - */ -export function AutoMediaPlugin( - userOptions?: AutoMediaOptionsType, -): AutoMediaType { - const optionsHandler = EmblaCarousel.optionsHandler(); - const optionsBase = optionsHandler.merge( - defaultOptions, - AutoMediaPlugin.globalOptions, - ); - - let options: AutoMediaType['options']; - let carousel: EmblaCarouselType; - let slides: HTMLElement[]; - - /** - * Initialize the plugin. - */ - function init(embla: EmblaCarouselType): void { - carousel = embla; - options = optionsHandler.atMedia(self.options); - slides = carousel.slideNodes(); - - // Frigate card media autoplays when the media loads not necessarily when the - // slide is selected, so only pause (and not play/unmute) based on carousel - // events. - carousel.on('destroy', pause); - if ( - options.autoPauseCondition && - ['all', 'unselected'].includes(options.autoPauseCondition) - ) { - carousel.on('select', pausePrevious); - } - carousel.on('destroy', mute); - if ( - options.autoMuteCondition && - ['all', 'unselected'].includes(options.autoMuteCondition) - ) { - carousel.on('select', mutePrevious); - } - - document.addEventListener('visibilitychange', visibilityHandler); - } - - /** - * Destroy the plugin. - */ - function destroy(): void { - carousel.off('destroy', pause); - if ( - options.autoPauseCondition && - ['all', 'unselected'].includes(options.autoPauseCondition) - ) { - carousel.off('select', pausePrevious); - } - carousel.off('destroy', mute); - if ( - options.autoMuteCondition && - ['all', 'unselected'].includes(options.autoMuteCondition) - ) { - carousel.off('select', mutePrevious); - } - - document.removeEventListener('visibilitychange', visibilityHandler); - } - - /** - * Handle document visibility changes. - */ - function visibilityHandler(): void { - if (document.visibilityState === 'hidden') { - if ( - options.autoPauseCondition && - ['all', 'hidden'].includes(options.autoPauseCondition) - ) { - pauseAll(); - } - if ( - options.autoMuteCondition && - ['all', 'hidden'].includes(options.autoMuteCondition) - ) { - muteAll(); - } - } else if (document.visibilityState === 'visible') { - if ( - options.autoPlayCondition && - ['all', 'visible'].includes(options.autoPlayCondition) - ) { - play(); - } - if ( - options.autoUnmuteCondition && - ['all', 'visible'].includes(options.autoUnmuteCondition) - ) { - unmute(); - } - } - } - - /** - * Get the media player from a slide. - * @param slide - * @returns A FrigateCardMediaPlayer object or `null`. - */ - function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null { - return options.playerSelector - ? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null) - : null; - } - - /** - * Play the current slide. - */ - function play(): void { - getPlayer(slides[carousel.selectedScrollSnap()])?.play(); - } - - /** - * Pause the current slide. - */ - function pause(): void { - getPlayer(slides[carousel.selectedScrollSnap()])?.pause(); - } - - /** - * Pause the previous slide. - */ - function pausePrevious(): void { - getPlayer(slides[carousel.previousScrollSnap()])?.pause(); - } - - /** - * Pause all slides. - */ - function pauseAll(): void { - for (const slide of slides) { - getPlayer(slide)?.pause(); - } - } - - /** - * Unmute the current slide. - */ - function unmute(): void { - getPlayer(slides[carousel.selectedScrollSnap()])?.unmute(); - } - - /** - * Mute the current slide. - */ - function mute(): void { - getPlayer(slides[carousel.selectedScrollSnap()])?.mute(); - } - - /** - * Mute the previous slide. - */ - function mutePrevious(): void { - getPlayer(slides[carousel.previousScrollSnap()])?.mute(); - } - - /** - * Mute all slides. - */ - function muteAll(): void { - for (const slide of slides) { - getPlayer(slide)?.mute(); - } - } - - const self: AutoMediaType = { - name: 'autoMedia', - options: optionsHandler.merge(optionsBase, userOptions), - init, - destroy, - play, - pause, - mute, - unmute, - }; - return self; -} - -AutoMediaPlugin.globalOptions = undefined; diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 957ce164..25bfd7a0 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -1,6 +1,4 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { EmblaOptionsType } from 'embla-carousel'; -import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { HassEntity } from 'home-assistant-js-websocket'; import { CSSResultGroup, @@ -13,15 +11,16 @@ import { import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { guard } from 'lit/directives/guard.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import { keyed } from 'lit/directives/keyed.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js'; import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js'; import { localize } from '../../localize/localize.js'; +import basicBlockStyle from '../../scss/basic-block.scss'; import liveCarouselStyle from '../../scss/live-carousel.scss'; import liveProviderStyle from '../../scss/live-provider.scss'; -import basicBlockStyle from '../../scss/basic-block.scss'; import { CameraConfig, CardWideConfig, @@ -37,28 +36,30 @@ import { } from '../../types.js'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { 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 { MediaGridSelected } from '../../utils/media-grid-controller.js'; import { dispatchExistingMediaLoadedInfoAsEvent, dispatchMediaUnloadedEvent, } from '../../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; import { playMediaMutingIfNecessary } from '../../utils/media.js'; +import { Timer } from '../../utils/timer.js'; import { dispatchViewContextChangeEvent, View } from '../../view/view.js'; -import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js'; -import { - FrigateCardMediaCarousel, - wrapMediaLoadedEventForCarousel, - wrapMediaUnloadedEventForCarousel, -} from '../media-carousel.js'; +import { EmblaCarouselPlugins } from '../carousel.js'; import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js'; import '../next-prev-control.js'; import '../surround.js'; import '../title-control.js'; -import { AutoMediaPlugin } from './../embla-plugins/automedia.js'; -import { Lazyload } from './../embla-plugins/lazyload.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { MediaGridSelected } from '../../utils/media-grid-controller.js'; -import { getDefaultTitleConfigForView } from '../title-control.js'; +import { + FrigateCardTitleControl, + getDefaultTitleConfigForView, + showTitleControlAfterDelay, +} from '../title-control.js'; interface LiveViewContext { // A cameraID override (used for dependencies/substreams to force a different @@ -200,11 +201,6 @@ export class FrigateCardLive extends LitElement { } } - /** - * Determine whether the element should be updated. - * @param _changedProps The changed properties if any. - * @returns `true` if the element should be updated. - */ // 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 @@ -213,26 +209,16 @@ export class FrigateCardLive extends LitElement { return !this._inBackground || !this._messageReceivedPostRender; } - /** - * Component connected callback. - */ connectedCallback(): void { this._intersectionObserver.observe(this); super.connectedCallback(); } - /** - * Component disconnected callback. - */ disconnectedCallback(): void { super.disconnectedCallback(); this._intersectionObserver.disconnect(); } - /** - * Master render method. - * @returns A rendered template. - */ protected render(): TemplateResult | void { if ( !this.hass || @@ -318,9 +304,6 @@ export class FrigateCardLiveGrid extends LitElement { @property({ attribute: false, hasChanged: contentsChanged }) public liveOverrides?: LiveOverrides; - @property({ attribute: false }) - public inBackground?: boolean; - @property({ attribute: false }) public conditionControllerEpoch?: ConditionControllerEpoch; @@ -342,7 +325,6 @@ export class FrigateCardLiveGrid extends LitElement { .viewFilterCameraID=${cameraID} .nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig} .overriddenLiveConfig=${this.overriddenLiveConfig} - .inBackground=${this.inBackground} .conditionControllerEpoch=${this.conditionControllerEpoch} .liveOverrides=${this.liveOverrides} .cardWideConfig=${this.cardWideConfig} @@ -423,9 +405,6 @@ export class FrigateCardLiveCarousel extends LitElement { @property({ attribute: false, hasChanged: contentsChanged }) public liveOverrides?: LiveOverrides; - @property({ attribute: false }) - public inBackground?: boolean; - @property({ attribute: false }) public conditionControllerEpoch?: ConditionControllerEpoch; @@ -443,38 +422,9 @@ export class FrigateCardLiveCarousel extends LitElement { // Index between camera name and slide number. protected _cameraToSlide: Record = {}; - protected _refMediaCarousel: Ref = createRef(); + protected _titleTimer = new Timer(); + protected _refTitleControl: Ref = 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); - - if (changedProperties.has('inBackground')) { - this.updateComplete.then(async () => { - const frigateCardMediaCarousel = this._refMediaCarousel.value; - if (frigateCardMediaCarousel) { - await frigateCardMediaCarousel.updateComplete; - // If this has changed to be in the background (i.e. preloaded but not - // visible) take the appropriate play/pause/mute/unmute actions. - if (this.inBackground) { - frigateCardMediaCarousel.autoPause(); - frigateCardMediaCarousel.autoMute(); - } else { - frigateCardMediaCarousel.autoPlay(); - frigateCardMediaCarousel.autoUnmute(); - } - } - }); - } - } - - /** - * Get the transition effect to use. - * @returns An TransitionEffect object. - */ protected _getTransitionEffect(): TransitionEffect { return ( this.overriddenLiveConfig?.transition_effect ?? @@ -490,49 +440,19 @@ export class FrigateCardLiveCarousel extends LitElement { return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera)); } - /** - * Get the Embla options to use. - * @returns An EmblaOptionsType object or undefined for no options. - */ - protected _getOptions(): EmblaOptionsType { - return { - // If the carousel is being filtered to a single cameraID, it is never - // draggable. - draggable: !this.viewFilterCameraID && this.overriddenLiveConfig?.draggable, - loop: true, - }; - } - - /** - * Get the Embla plugins to use. - * @returns A list of EmblaOptionsTypes. - */ protected _getPlugins(): EmblaCarouselPlugins { - const cameraCount = this.viewFilterCameraID - ? 1 - : this.cameraManager?.getStore().getVisibleCameraCount() ?? 0; return [ - // Only enable wheel plugin if there is more than one camera. - ...(cameraCount > 1 - ? [ - WheelGesturesPlugin({ - // Whether the carousel is vertical or horizontal, interpret y-axis wheel - // gestures as scrolling for the carousel. - forceWheelAxis: 'y', - }), - ] - : []), - Lazyload({ + AutoLazyLoad({ ...(this.overriddenLiveConfig?.lazy_load && { lazyLoadCallback: (index, slide) => this._lazyloadOrUnloadSlide('load', index, slide), }), - lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload, lazyUnloadCallback: (index, slide) => this._lazyloadOrUnloadSlide('unload', index, slide), }), - AutoMediaPlugin({ + AutoMediaLoadedInfo(), + AutoMediaActions({ playerSelector: FRIGATE_CARD_LIVE_PROVIDER, ...(this.overriddenLiveConfig?.auto_play && { autoPlayCondition: this.overriddenLiveConfig.auto_play, @@ -547,6 +467,7 @@ export class FrigateCardLiveCarousel extends LitElement { autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute, }), }), + AutoSize(), ]; } @@ -562,11 +483,6 @@ export class FrigateCardLiveCarousel extends LitElement { return this.overriddenLiveConfig?.lazy_load === false ? null : 0; } - /** - * Get slides to include in the render. - * @returns The slides to include in the render and an index keyed by camera - * name to slide number. - */ protected _getSlides(): [TemplateResult[], Record] { let cameras: CameraConfigs | null = null; if (this.viewFilterCameraID) { @@ -595,7 +511,7 @@ export class FrigateCardLiveCarousel extends LitElement { : this.cameraManager?.getStore().getCameraConfig(liveCameraID); const slide = liveCameraConfig - ? this._renderLive(liveCameraID, liveCameraConfig, slides.length) + ? this._renderLive(liveCameraID, liveCameraConfig) : null; if (slide) { cameraToSlide[cameraID] = slides.length; @@ -605,10 +521,7 @@ export class FrigateCardLiveCarousel extends LitElement { return [slides, cameraToSlide]; } - /** - * Handle the user selecting a new slide in the carousel. - */ - protected _setViewHandler(ev: CustomEvent): void { + protected _setViewHandler(ev: CustomEvent): void { const cameras = this.cameraManager?.getStore().getVisibleCameras(); if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) { this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]); @@ -631,11 +544,6 @@ export class FrigateCardLiveCarousel extends LitElement { } } - /** - * Lazy load a slide. - * @param _index The slide number to lazy load. - * @param slide The slide to lazy load. - */ protected _lazyloadOrUnloadSlide( action: 'load' | 'unload', _index: number, @@ -649,14 +557,13 @@ export class FrigateCardLiveCarousel extends LitElement { FRIGATE_CARD_LIVE_PROVIDER, ) as FrigateCardLiveProvider | null; if (liveProvider) { - liveProvider.disabled = action !== 'load'; + liveProvider.load = action === 'load'; } } protected _renderLive( cameraID: string, cameraConfig: CameraConfig, - slideIndex: number, ): TemplateResult | void { if ( !this.overriddenLiveConfig || @@ -683,7 +590,7 @@ export class FrigateCardLiveCarousel extends LitElement { return html`
) => { - wrapMediaLoadedEventForCarousel(slideIndex, ev); - }} - @frigate-card:media:unloaded=${(ev: CustomEvent) => { - wrapMediaUnloadedEventForCarousel(slideIndex, ev); - }} >
@@ -728,10 +629,6 @@ export class FrigateCardLiveCarousel extends LitElement { ]; } - /** - * Render the element. - * @returns A template to display to the user. - */ protected render(): TemplateResult | void { if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) { return; @@ -743,6 +640,7 @@ export class FrigateCardLiveCarousel extends LitElement { return; } + const hasMultipleCameras = slides.length > 1; const [prevID, nextID] = this._getCameraIDsOfNeighbors(); const overrideCameraID = (cameraID: string): string => { @@ -776,28 +674,25 @@ export class FrigateCardLiveCarousel extends LitElement { // little later). return html` - { // Fetch the thumbnails after the carousel has settled. dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } }); }} + @frigate-card:media:loaded=${() => { + if (this._refTitleControl.value) { + showTitleControlAfterDelay(this._refTitleControl.value, this._titleTimer); + } + }} > - + + ${cameraMetadataCurrent && titleConfig + ? html` + ` + : ``} `; } - /** - * Get styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(liveCarouselStyle); } @@ -857,10 +761,11 @@ export class FrigateCardLiveProvider @property({ attribute: false }) public liveConfig?: LiveConfig; - // Whether or not to disable this entity. If `true`, no contents are rendered - // until this attribute is set to `false` (this is useful for lazy loading). + // Whether or not to load the video for this camera. If `false`, no contents + // are rendered until this attribute is set to `true` (this is useful for lazy + // loading). @property({ attribute: true, type: Boolean }) - public disabled = false; + public load = false; // Label that is used for ARIA support and as tooltip. @property({ attribute: false }) @@ -995,8 +900,8 @@ export class FrigateCardLiveProvider * Called before each update. */ protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('disabled')) { - if (this.disabled) { + if (changedProps.has('load')) { + if (!this.load) { this._isVideoMediaLoaded = false; dispatchMediaUnloadedEvent(this); } @@ -1051,7 +956,7 @@ export class FrigateCardLiveProvider * @returns A rendered template. */ protected render(): TemplateResult | void { - if (this.disabled || !this.hass || !this.liveConfig || !this.cameraConfig) { + if (!this.load || !this.hass || !this.liveConfig || !this.cameraConfig) { return; } diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts deleted file mode 100644 index 6f9e5a82..00000000 --- a/src/components/media-carousel.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { EmblaOptionsType } from 'embla-carousel'; -import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import debounce from 'lodash-es/debounce'; -import mediaCarouselStyle from '../scss/media-carousel.scss'; -import type { - MediaLoadedInfo, - NextPreviousControlConfig, - TitleControlConfig, - TransitionEffect, -} from '../types.js'; -import { dispatchFrigateCardEvent } from '../utils/basic'; -import { - dispatchExistingMediaLoadedInfoAsEvent, - isValidMediaLoadedInfo, -} from '../utils/media-info.js'; -import { Timer } from '../utils/timer'; -import { CarouselSelect, EmblaCarouselPlugins, FrigateCardCarousel } from './carousel'; -import './carousel.js'; -import { AutoMediaType } from './embla-plugins/automedia.js'; -import './next-prev-control.js'; -import { FrigateCardNextPreviousControl } from './next-prev-control.js'; -import { FrigateCardTitleControl } from './title-control.js'; - -interface CarouselMediaLoadedInfo { - slide: number; - mediaLoadedInfo: MediaLoadedInfo; -} - -interface CarouselMediaUnloadedInfo { - slide: number; -} - -/** - * Dispatch a carousel media loaded event. - * @param target The target to send it from. - * @param carouselMediaLoadedInfo The CarouselMediaLoadedInfo. - */ -const dispatchFrigateCardCarouselMediaLoaded = ( - target: EventTarget, - carouselMediaLoadedInfo: CarouselMediaLoadedInfo, -): void => { - dispatchFrigateCardEvent( - target, - 'carousel:media:loaded', - carouselMediaLoadedInfo, - ); -}; - -/** - * Dispatch a carousel media UNloaded event. - * @param target The target to send it from. - * @param carouselMediaUnloadedInfo The CarouselMediaUnloadedInfo. - */ -const dispatchFrigateCardCarouselMediaUnloaded = ( - target: EventTarget, - carouselMediaUnloadedInfo: CarouselMediaUnloadedInfo, -): void => { - dispatchFrigateCardEvent( - target, - 'carousel:media:unloaded', - carouselMediaUnloadedInfo, - ); -}; - -/** - * Turn a MediaLoadedInfo into a CarouselMediaLoadedInfo. - * @param slide The slide number. - * @param event The MediaShowEvent. - */ -export const wrapMediaLoadedEventForCarousel = ( - slide: number, - event: CustomEvent, -) => { - event.stopPropagation(); - dispatchFrigateCardCarouselMediaLoaded(event.composedPath()[0], { - slide: slide, - mediaLoadedInfo: event.detail, - }); -}; - -/** - * Turn a MediaUnloadedInfo into a CarouselMediaUnloadedInfo. - * @param slide The slide number. - * @param event The MediaUnloadedEvent. - */ -export const wrapMediaUnloadedEventForCarousel = ( - slide: number, - event: CustomEvent, -) => { - event.stopPropagation(); - dispatchFrigateCardCarouselMediaUnloaded(event.composedPath()[0], { - slide: slide, - }); -}; - -@customElement('frigate-card-media-carousel') -export class FrigateCardMediaCarousel extends LitElement { - @property({ attribute: false }) - public nextPreviousConfig?: NextPreviousControlConfig; - - @property({ attribute: false }) - public carouselOptions?: EmblaOptionsType; - - @property({ attribute: false }) - public carouselPlugins?: EmblaCarouselPlugins; - - @property({ attribute: false, type: Number }) - public selected = 0; - - @property({ attribute: true }) - public transitionEffect?: TransitionEffect; - - @property({ attribute: false }) - public label?: string; - - @property({ attribute: false }) - public logo?: string; - - @property({ attribute: false }) - public titlePopupConfig?: TitleControlConfig; - - // A "map" from slide number to MediaLoadedInfo object. - protected _mediaLoadedInfo: Record = {}; - protected _nextControlRef: Ref = createRef(); - protected _previousControlRef: Ref = createRef(); - protected _titleControlRef: Ref = createRef(); - protected _titleTimer = new Timer(); - - protected _boundAutoPlayHandler = this.autoPlay.bind(this); - protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this); - protected _boundTitleHandler = this._titleHandler.bind(this); - - // Debounce multiple calls to adapt the container height. - protected _debouncedAdaptContainerHeightToSlide = debounce( - this._adaptContainerHeightToSlide.bind(this), - 1 * 250, - {trailing: true}); - - // This carousel may be resized by Lovelace resizes, window resizes, - // fullscreen, etc. Always call the adaptive height handler when the size - // changes. - protected _slideResizeObserver: ResizeObserver; - protected _intersectionObserver: IntersectionObserver; - - protected _refCarousel: Ref = createRef(); - - constructor() { - super(); - // Need to watch both changes in this element (e.g. caused by a window - // resize or fullscreen change) and changes in the selected slide itself - // (e.g. changing from a progress indicator to a loaded media). - this._slideResizeObserver = new ResizeObserver( - this._reInitAndAdjustHeight.bind(this), - ); - this._intersectionObserver = new IntersectionObserver( - this._intersectionHandler.bind(this), - ); - } - - /** - * Get the underlying carousel. - */ - public frigateCardCarousel(): FrigateCardCarousel | null { - return this._refCarousel.value ?? null; - } - - /** - * Get the AutoMedia plugin (if any). - * @returns The plugin or `null`. - */ - protected _getAutoMediaPlugin(): AutoMediaType | null { - return this.frigateCardCarousel()?.carousel()?.plugins().autoMedia ?? null; - } - - /** - * Play the media on the selected slide. - */ - public autoPlay(): void { - const automediaOptions = this._getAutoMediaPlugin()?.options; - if ( - automediaOptions?.autoPlayCondition && - ['all', 'selected'].includes(automediaOptions?.autoPlayCondition) - ) { - this._getAutoMediaPlugin()?.play(); - } - } - - /** - * Pause the media on the selected slide. - */ - public autoPause(): void { - const automediaOptions = this._getAutoMediaPlugin()?.options; - if ( - automediaOptions?.autoPauseCondition && - ['all', 'selected'].includes(automediaOptions.autoPauseCondition) - ) { - this._getAutoMediaPlugin()?.pause(); - } - } - - /** - * Unmute the media on the selected slide. - */ - public autoUnmute(): void { - const automediaOptions = this._getAutoMediaPlugin()?.options; - if ( - automediaOptions?.autoUnmuteCondition && - ['all', 'selected'].includes(automediaOptions?.autoUnmuteCondition) - ) { - this._getAutoMediaPlugin()?.unmute(); - } - } - - /** - * Mute the media on the selected slide. - */ - public autoMute(): void { - const automediaOptions = this._getAutoMediaPlugin()?.options; - if ( - automediaOptions?.autoMuteCondition && - ['all', 'selected'].includes(automediaOptions?.autoMuteCondition) - ) { - this._getAutoMediaPlugin()?.mute(); - } - } - - /** - * Show the media title after the media loads. - */ - protected _titleHandler(): void { - const show = () => { - this._titleTimer.stop(); - this._titleControlRef.value?.show(); - }; - - if (this._titleControlRef.value?.isVisible()) { - // If it's already visible, update it immediately (but also update it - // after the timer expires to ensure it re-positions if necessary, see - // comment below). - show(); - } - - // Allow a brief pause after the media loads, but before the title is - // displayed. This allows for a pleasant appearance/disappear of the title, - // and allows for the browser to finish rendering the carousel. - this._titleTimer.start(0.5, show); - } - - /** - * Component connected callback. - */ - connectedCallback(): void { - super.connectedCallback(); - - this.addEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler); - this.addEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler); - this.addEventListener( - 'frigate-card:media:loaded', - this._debouncedAdaptContainerHeightToSlide, - ); - this.addEventListener('frigate-card:media:loaded', this._boundTitleHandler); - this._intersectionObserver.observe(this); - } - - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - this.removeEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler); - this.removeEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler); - this.removeEventListener( - 'frigate-card:media:loaded', - this._debouncedAdaptContainerHeightToSlide, - ); - this.removeEventListener('frigate-card:media:loaded', this._boundTitleHandler); - this._intersectionObserver.disconnect(); - - this._mediaLoadedInfo = {}; - super.disconnectedCallback(); - } - - /** - * ReInit the carousel and adapt the container height. - */ - protected _reInitAndAdjustHeight(): void { - this.frigateCardCarousel()?.carouselReInitWhenSafe(); - this._debouncedAdaptContainerHeightToSlide(); - } - - /** - * Called when the carousel intersects with the viewport. - * @param entries The IntersectionObserverEntry entries (should be only 1). - */ - protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { - /** - * - If the DOM that contains this carousel changes such that it causes - * slides to entirely appear/disappear (e.g. `display: none` or hidden), - * then the displayed slide sizes will significantly change and the - * carousel will need to be reinitialized. Without this, odd bugs may - * occur for some users in some circumstances causing the carousel to - * appear 'stuck'. - * - Example bug when this reinitialization is not performed: - * https://github.com/dermotduffy/frigate-hass-card/issues/651 - */ - if (entries.some((entry) => entry.isIntersecting)) { - this._reInitAndAdjustHeight(); - } - } - - /** - * Set the the height of the component on media load in case the dimensions - * have changed. This handler is not triggered from carousel events, as it's - * actually the media load/show that will change the dimensions, and that is - * async from carousel actions (e.g. lazy-loaded media). - * - * This component does not use the stock Embla auto-height plugin as that - * resizes the container only on selection rather than media load. - */ - protected _adaptContainerHeightToSlide(): void { - const selected = this.frigateCardCarousel()?.getCarouselSelected(); - if (selected) { - this.style.removeProperty('max-height'); - const height = selected.element.getBoundingClientRect().height; - if (height !== undefined && height > 0) { - this.style.maxHeight = `${height}px`; - } - } - } - - /** - * Fire a media show event when a slide is selected. - */ - protected _dispatchMediaLoadedInfo(selected: CarouselSelect): void { - const slideIndex = selected.index; - if (slideIndex !== undefined && slideIndex in this._mediaLoadedInfo) { - dispatchExistingMediaLoadedInfoAsEvent(this, this._mediaLoadedInfo[slideIndex]); - } - } - - /** - * Handle a media:loaded event that is generated by a child component, saving the - * contents for future use when the relevant slide is actually shown. - * @param slideIndex The relevant slide index. - * @param event The media:loaded event from the child component. - */ - protected _storeMediaLoadedInfo(event: CustomEvent): void { - // Don't allow the inbound event to propagate upwards, that will be - // automatically done at the appropriate time as the slide is shown. - event.stopPropagation(); - const mediaLoadedInfo = event.detail.mediaLoadedInfo; - const slideIndex = event.detail.slide; - - // isValidMediaLoadedInfo is used to prevent saving media info that will be - // rejected upstream (empty 1x1 images will be rejected here). - if (mediaLoadedInfo && isValidMediaLoadedInfo(mediaLoadedInfo)) { - this._mediaLoadedInfo[slideIndex] = mediaLoadedInfo; - if (this.frigateCardCarousel()?.getCarouselSelected()?.index === slideIndex) { - dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo); - } - } - } - - /** - * Remove a media loaded info (i.e. a media item has unloaded). - * @param event The CarouselMediaUnloadedInfo event. - */ - protected _removeMediaLoadedInfo(event: CustomEvent): void { - const slideIndex = event.detail.slide; - delete this._mediaLoadedInfo[slideIndex]; - - // If the slide that unloaded is not visible, don't propagate the event upwards. - if (this.frigateCardCarousel()?.getCarouselSelected()?.index !== slideIndex) { - event.stopPropagation(); - } - } - - protected render(): TemplateResult | void { - const selectSlide = (ev: CustomEvent): void => { - this._slideResizeObserver.disconnect(); - const parent = this.getRootNode(); - if (parent && parent instanceof ShadowRoot) { - this._slideResizeObserver.observe(parent.host); - } - - const selected = ev.detail; - this._slideResizeObserver.observe(selected.element); - - // Pass up the media-carousel select event first to allow parents to - // initialize/reset before the media info is dispatched. - dispatchFrigateCardEvent( - this, - 'media-carousel:select', - selected, - ); - - // Dispatch media info. - this._dispatchMediaLoadedInfo(selected); - } - - return html` ) => { - selectSlide(ev); - }} - @frigate-card:carousel:media:loaded=${this._storeMediaLoadedInfo.bind(this)} - @frigate-card:carousel:media:unloaded=${this._removeMediaLoadedInfo.bind(this)} - > - - - - - ${this.label && this.titlePopupConfig - ? html` - ` - : ``}`; - } - - static get styles(): CSSResultGroup { - return unsafeCSS(mediaCarouselStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'frigate-card-media-carousel': FrigateCardMediaCarousel; - } -} diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index c7a20351..1248efe0 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -1,5 +1,3 @@ -import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; -import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { CSSResultGroup, html, @@ -10,18 +8,16 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { CameraManager } from '../camera-manager/manager.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; import { ExtendedHomeAssistant, ThumbnailsControlConfig } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; -import { View } from '../view/view.js'; +import { CarouselDirection } from '../utils/embla/carousel-controller.js'; import { MediaQueriesResults } from '../view/media-queries-results'; -import { FrigateCardCarousel } from './carousel.js'; -import './thumbnail.js'; +import { View } from '../view/view.js'; import './carousel.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { CameraManager } from '../camera-manager/manager.js'; +import './thumbnail.js'; export interface ThumbnailCarouselTap { queryResults: MediaQueriesResults; @@ -38,83 +34,11 @@ export class FrigateCardThumbnailCarousel extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; - protected _refCarousel: Ref = createRef(); - - // Thumbnail carousels can expand (e.g. drawer-based carousels after the main - // media loads). The carousel must be re-initialized in these cases, or the - // dynamic sizing fails (and users can scroll past the end of the carousel). - protected _resizeObserver: ResizeObserver; - @property({ attribute: false }) public config?: ThumbnailsControlConfig; - @property({ attribute: false }) - public selected? = 0; + protected _thumbnailSlides: TemplateResult[] = []; - protected _carouselOptions?: EmblaOptionsType = { - containScroll: 'keepSnaps', - dragFree: true, - }; - - protected _carouselPlugins: EmblaPluginType[] = [ - WheelGesturesPlugin({ - // Whether the carousel is vertical or horizontal, interpret y-axis wheel - // gestures as scrolling for the carousel. - forceWheelAxis: 'y', - }), - ]; - - constructor() { - super(); - this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); - } - - /** - * Handle gallery resize. - */ - protected _resizeHandler(): void { - this._refCarousel.value?.carouselReInitWhenSafe(); - } - - /** - * Component connected callback. - */ - connectedCallback(): void { - super.connectedCallback(); - this._resizeObserver.observe(this); - } - - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - this._resizeObserver.disconnect(); - super.disconnectedCallback(); - } - - /** - * Get slides to include in the render. - * @returns The slides to include in the render. - */ - protected _getSlides(): TemplateResult[] { - if (!this.view?.query || !this.view.queryResults?.hasResults()) { - return []; - } - - const slides: TemplateResult[] = []; - for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) { - const thumbnail = this._renderThumbnail(i); - if (thumbnail) { - slides[i] = thumbnail; - } - } - return slides; - } - - /** - * Called when an update will occur. - * @param changedProps The changed properties - */ protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('config')) { if (this.config?.size) { @@ -128,95 +52,93 @@ export class FrigateCardThumbnailCarousel extends LitElement { } } - if (changedProps.has('selected')) { + const renderProperties = [ + 'cameraManager', + 'config', + 'transitionEffect', + 'view', + ] as const; + if (renderProperties.some((prop) => changedProps.has(prop))) { + this._thumbnailSlides = this._renderSlides(); + } + + if (changedProps.has('view')) { this.style.setProperty( '--frigate-card-carousel-thumbnail-opacity', - this.selected === undefined ? '1.0' : '0.4', + this._getSelectedSlide() === null ? '1.0' : '0.4', ); } } - /** - * Render a given thumbnail. - * @param mediaToRender The media item to render. - * @returns A template or void if the item could not be rendered. - */ - protected _renderThumbnail(index: number): TemplateResult | void { - const media = this.view?.queryResults?.getResult(index) ?? null; - if (!media || !this.view) { - return; - } - - const classes = { - embla__slide: true, - 'slide-selected': this.selected === index, - }; - - const seekTarget = this.view?.context?.mediaViewer?.seek; - return html` { - if (this.view && this.view.queryResults) { - dispatchFrigateCardEvent( - this, - 'thumbnail-carousel:tap', - { - queryResults: this.view.queryResults.clone().selectIndex(index), - }, - ); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - `; + protected _getSelectedSlide(view?: View): number | null { + return (view ?? this.view)?.queryResults?.getSelectedIndex() ?? null; } - /** - * Get the direction of the thumbnail carousel. - * @returns `vertical`, `horizontal` or undefined. - */ - protected _getDirection(): 'horizontal' | 'vertical' | undefined { + protected _renderSlides(): TemplateResult[] { + const slides: TemplateResult[] = []; + const seekTarget = this.view?.context?.mediaViewer?.seek; + const selectedIndex = this._getSelectedSlide(); + + for (const media of this.view?.queryResults?.getResults() ?? []) { + const index = slides.length; + const classes = { + embla__slide: true, + 'slide-selected': selectedIndex === index, + }; + + slides.push(html` { + if (this.view && this.view.queryResults) { + dispatchFrigateCardEvent( + this, + 'thumbnail-carousel:tap', + { + queryResults: this.view.queryResults.clone().selectIndex(index), + }, + ); + } + stopEventFromActivatingCardWideActions(ev); + }} + > + `); + } + return slides; + } + + protected _getDirection(): CarouselDirection | null { if (this.config?.mode === 'left' || this.config?.mode === 'right') { return 'vertical'; } else if (this.config?.mode === 'above' || this.config?.mode === 'below') { return 'horizontal'; } - return undefined; + return null; } - /** - * Render the element. - * @returns A template to display to the user. - */ protected render(): TemplateResult | void { - const slides = this._getSlides(); - if (!slides.length || !this.config || this.config.mode === 'none') { + const direction = this._getDirection(); + if (!this._thumbnailSlides.length || !this.config || !direction) { return; } return html` - ${slides} + ${this._thumbnailSlides} `; } - /** - * Get element styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(thumbnailCarouselStyle); } diff --git a/src/components/title-control.ts b/src/components/title-control.ts index 7bbda1e9..d6423a68 100644 --- a/src/components/title-control.ts +++ b/src/components/title-control.ts @@ -3,12 +3,36 @@ import { customElement, property } from 'lit/decorators.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import titleStyle from '../scss/title-control.scss'; import { TitleControlConfig } from '../types.js'; +import { Timer } from '../utils/timer'; import { View } from '../view/view.js'; type PaperToast = HTMLElement & { opened: boolean; }; +export const showTitleControlAfterDelay = ( + control: FrigateCardTitleControl, + timer: Timer, + delay = 0.5, +): void => { + const show = () => { + timer.stop(); + control.show(); + }; + + if (control.isVisible()) { + // If it's already visible, update it immediately (but also update it + // after the timer expires to ensure it re-positions if necessary, see + // comment below). + show(); + } + + // Allow a brief pause after the media loads, but before the title is + // displayed. This allows for a pleasant appearance/disappear of the title, + // and allows for the browser to finish rendering the carousel. + timer.start(delay, show); +}; + export const getDefaultTitleConfigForView = ( view?: Readonly, baseConfig?: TitleControlConfig, @@ -39,10 +63,6 @@ export class FrigateCardTitleControl extends LitElement { protected _toastRef: Ref = createRef(); - /** - * Master render method. - * @returns A rendered template. - */ protected render(): TemplateResult { if (!this.text || !this.config || this.config.mode == 'none' || !this.fitInto) { return html``; @@ -64,17 +84,10 @@ export class FrigateCardTitleControl extends LitElement { `; } - /** - * Determine if the toast is visible. - * @returns `true` if the toast is visible, `false` otherwise. - */ public isVisible(): boolean { return this._toastRef.value?.opened ?? false; } - /** - * Show the toast. - */ public hide(): void { if (this._toastRef.value) { // Set it to false first, to ensure the timer resets. @@ -82,9 +95,6 @@ export class FrigateCardTitleControl extends LitElement { } } - /** - * Show the toast. - */ public show(): void { if (this._toastRef.value) { // Set it to false first, to ensure the timer resets. @@ -93,9 +103,6 @@ export class FrigateCardTitleControl extends LitElement { } } - /** - * Get element styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(titleStyle); } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index e20244be..1b01f5bd 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -1,5 +1,3 @@ -import { EmblaPluginType } from 'embla-carousel'; -import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { CSSResultGroup, html, @@ -12,11 +10,15 @@ import { customElement, property } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; -import basicBlockStyle from '../scss/basic-block.scss'; import { CameraManager } from '../camera-manager/manager.js'; -import { dispatchMessageEvent, renderMessage, renderProgressIndicator } from '../components/message.js'; +import { + dispatchMessageEvent, + renderMessage, + renderProgressIndicator, +} from '../components/message.js'; import { localize } from '../localize/localize.js'; import '../patches/ha-hls-player'; +import basicBlockStyle from '../scss/basic-block.scss'; import viewerCarouselStyle from '../scss/viewer-carousel.scss'; import viewerProviderStyle from '../scss/viewer-provider.scss'; import viewerStyle from '../scss/viewer.scss'; @@ -36,6 +38,11 @@ import { errorToConsole, setOrRemoveAttribute, } 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 { canonicalizeHAURL } from '../utils/ha/index.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { MediaGridSelected } from '../utils/media-grid-controller.js'; @@ -57,22 +64,21 @@ import { setControlsOnVideo, } from '../utils/media.js'; import { screenshotMedia } from '../utils/screenshot.js'; +import { Timer } from '../utils/timer'; import { ViewMediaClassifier } from '../view/media-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { MediaQueriesResults } from '../view/media-queries-results.js'; import { VideoContentType, ViewMedia } from '../view/media.js'; import { View } from '../view/view.js'; -import type { CarouselSelect } from './carousel.js'; -import { AutoMediaPlugin } from './embla-plugins/automedia.js'; -import { Lazyload } from './embla-plugins/lazyload.js'; -import { - FrigateCardMediaCarousel, - wrapMediaLoadedEventForCarousel, -} from './media-carousel.js'; +import type { EmblaCarouselPlugins } from './carousel.js'; import './next-prev-control.js'; import './surround.js'; import './title-control.js'; -import { getDefaultTitleConfigForView } from './title-control.js'; +import { + FrigateCardTitleControl, + getDefaultTitleConfigForView, + showTitleControlAfterDelay, +} from './title-control.js'; export interface MediaViewerViewContext { seek?: Date; @@ -114,10 +120,6 @@ export class FrigateCardViewer extends LitElement { @property({ attribute: false }) public cardWideConfig?: CardWideConfig; - /** - * Master render method. - * @returns A rendered template. - */ protected render(): TemplateResult | void { if ( !this.hass || @@ -186,9 +188,6 @@ export class FrigateCardViewer extends LitElement { `; } - /** - * Get element styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(viewerStyle); } @@ -227,8 +226,10 @@ export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) public selected = 0; - protected _refMediaCarousel: Ref = createRef(); protected _media: ViewMedia[] | null = null; + protected _titleTimer = new Timer(); + protected _refTitleControl: Ref = createRef(); + protected _player: FrigateCardMediaPlayer | null = null; /** * The updated lifecycle callback for this element. @@ -259,47 +260,19 @@ export class FrigateCardViewerCarousel extends LitElement { ); } - /** - * Get the media player on a slide (or current slide if not provided). - * @param slide An optional slide. - * @returns The FrigateCardMediaPlayer or null if not found. - */ - protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null { - if (!slide) { - slide = this._refMediaCarousel.value - ?.frigateCardCarousel() - ?.getCarouselSelected()?.element; - } - - return ( - (slide?.querySelector( - FRIGATE_CARD_VIEWER_PROVIDER, - ) as unknown as FrigateCardMediaPlayer) ?? null - ); - } - /** * Get the Embla plugins to use. * @returns A list of EmblaOptionsTypes. */ - protected _getPlugins(): EmblaPluginType[] { + protected _getPlugins(): EmblaCarouselPlugins { return [ - // Only enable wheel plugin if there is more than one media item. - ...(this._media && this._media.length > 1 - ? [ - WheelGesturesPlugin({ - // Whether the carousel is vertical or horizontal, interpret y-axis wheel - // gestures as scrolling for the carousel. - forceWheelAxis: 'y', - }), - ] - : []), - Lazyload({ + AutoLazyLoad({ ...(this.viewerConfig?.lazy_load && { lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide), }), }), - AutoMediaPlugin({ + AutoMediaLoadedInfo(), + AutoMediaActions({ playerSelector: FRIGATE_CARD_VIEWER_PROVIDER, ...(this.viewerConfig?.auto_play && { autoPlayCondition: this.viewerConfig.auto_play, @@ -314,6 +287,7 @@ export class FrigateCardViewerCarousel extends LitElement { autoUnmuteCondition: this.viewerConfig.auto_unmute, }), }), + AutoSize(), ]; } @@ -346,10 +320,6 @@ export class FrigateCardViewerCarousel extends LitElement { }; } - protected _setViewHandler(ev: CustomEvent): void { - this._setViewSelectedIndex(ev.detail.index); - } - protected _setViewSelectedIndex(index: number): void { if (!this._media) { return; @@ -396,7 +366,7 @@ export class FrigateCardViewerCarousel extends LitElement { 'frigate-card-viewer-provider', ) as FrigateCardViewerProvider | null; if (viewerProvider) { - viewerProvider.disabled = false; + viewerProvider.load = true; } } @@ -413,7 +383,7 @@ export class FrigateCardViewerCarousel extends LitElement { for (let i = 0; i < this._media.length; ++i) { const media = this._media[i]; if (media) { - const slide = this._renderMediaItem(media, i); + const slide = this._renderMediaItem(media); if (slide) { slides[i] = slide; } @@ -487,22 +457,24 @@ export class FrigateCardViewerCarousel extends LitElement { ); return html` - ({ - draggable: this.viewerConfig?.draggable ?? true, - }))} - .carouselPlugins=${guard( - [this.viewerConfig, this._media], - this._getPlugins.bind(this), - )} - .label=${selectedMedia.getTitle() ?? undefined} - .logo=${cameraMetadata?.engineLogo} - .titlePopupConfig=${titleConfig ?? undefined} + ) => { + this._setViewSelectedIndex(ev.detail.index); + }} + @frigate-card:media:loaded=${(ev: CustomEvent) => { + if (this._refTitleControl.value) { + showTitleControlAfterDelay(this._refTitleControl.value, this._titleTimer); + } + this._player = ev.detail.player ?? null; + this._seekHandler(); + }} + @frigate-card:media:unloaded=${() => { + this._player = null; + }} > - +
+ ${cameraMetadata && titleConfig + ? html` + ` + : ``} `; } @@ -544,21 +526,20 @@ export class FrigateCardViewerCarousel extends LitElement { */ protected async _seekHandler(): Promise { const seek = this.view?.context?.mediaViewer?.seek; - if (!this.hass || !seek || !this._media || this.selected === null) { + if (!this.hass || !seek || !this._media || this.selected === null || !this._player) { return; } const selectedMedia = this._media[this.selected]; - const player = this._getPlayer(); - if (!selectedMedia || !player) { + if (!selectedMedia) { return; } const seekTimeInMedia = selectedMedia.includesTime(seek); setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable'); - if (!seekTimeInMedia && !player.isPaused()) { - player.pause(); - } else if (seekTimeInMedia && player.isPaused()) { - player.play(); + if (!seekTimeInMedia && !this._player.isPaused()) { + this._player.pause(); + } else if (seekTimeInMedia && this._player.isPaused()) { + this._player.play(); } const seekTime = @@ -566,17 +547,11 @@ export class FrigateCardViewerCarousel extends LitElement { null; if (seekTime !== null) { - player.seek(seekTime); + this._player.seek(seekTime); } } - /** - * Render a single media item in the viewer carousel. - * @param media The ViewMedia to render. - * @param index The (slide|queryResult) index of the item to render. - * @returns A rendered template. - */ - protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null { + protected _renderMediaItem(media: ViewMedia): TemplateResult | null { if (!this.hass || !this.view || !this.viewerConfig) { return null; } @@ -589,11 +564,8 @@ export class FrigateCardViewerCarousel extends LitElement { .viewerConfig=${this.viewerConfig} .resolvedMediaCache=${this.resolvedMediaCache} .cameraManager=${this.cameraManager} - .disabled=${this.viewerConfig.lazy_load} + .load=${!this.viewerConfig.lazy_load} .cardWideConfig=${this.cardWideConfig} - @frigate-card:media:loaded=${(e: CustomEvent) => { - wrapMediaLoadedEventForCarousel(index, e); - }} >
`; } @@ -715,10 +687,11 @@ export class FrigateCardViewerProvider @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; - // Whether or not to disable this entity. If `true`, no contents are rendered - // until this attribute is set to `false` (this is useful for lazy loading). + // Whether or not to load the viewer media. If `false`, no contents are + // rendered until this attribute is set to `true` (this is useful for lazy + // loading). @property({ attribute: false }) - public disabled = false; + public load = false; @property({ attribute: false }) public cameraManager?: CameraManager; @@ -865,7 +838,7 @@ export class FrigateCardViewerProvider const mediaContentID = this.media ? this.media.getContentID() : null; if ( - (changedProps.has('disabled') || + (changedProps.has('load') || changedProps.has('media') || changedProps.has('viewerConfig') || changedProps.has('resolvedMediaCache') || @@ -873,7 +846,7 @@ export class FrigateCardViewerProvider this.hass && mediaContentID && !this.resolvedMediaCache?.has(mediaContentID) && - (!this.viewerConfig?.lazy_load || !this.disabled) + (!this.viewerConfig?.lazy_load || this.load) ) { resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => { this.requestUpdate(); @@ -897,7 +870,7 @@ export class FrigateCardViewerProvider } protected render(): TemplateResult | void { - if (this.disabled || !this.media || !this.hass || !this.view || !this.viewerConfig) { + if (!this.load || !this.media || !this.hass || !this.view || !this.viewerConfig) { return; } diff --git a/src/scss/carousel.scss b/src/scss/carousel.scss index 167146a8..2ea19de8 100644 --- a/src/scss/carousel.scss +++ b/src/scss/carousel.scss @@ -2,6 +2,9 @@ display: block; height: 100%; width: 100%; + + // Keep carousel controls relative to the media carousel itself. + position: relative; } .embla { diff --git a/src/scss/live-carousel.scss b/src/scss/live-carousel.scss index d4dafaf1..81c26c13 100644 --- a/src/scss/live-carousel.scss +++ b/src/scss/live-carousel.scss @@ -1,9 +1,13 @@ +:host { + --video-max-height: none; +} + // If the carousel has an unselected attribute set on it, do not let the // pointer interact (e.g. hover, scroll) with underlying elements. This is used // when the carousel is part of a media-grid. Without this next/prev controls // will enlarge on hover, and the wheel-gestures plugin may block scrolling. // See matching in viewer-carousel.scss . -:host([unselected]) frigate-card-media-carousel { +:host([unselected]) frigate-card-carousel { pointer-events: none; } diff --git a/src/scss/live-ha.scss b/src/scss/live-ha.scss index a9b9a68b..f0f64c07 100644 --- a/src/scss/live-ha.scss +++ b/src/scss/live-ha.scss @@ -2,6 +2,4 @@ width: 100%; height: 100%; display: block; - - --video-max-height: none; } \ No newline at end of file diff --git a/src/scss/media-carousel.scss b/src/scss/media-carousel.scss deleted file mode 100644 index 45964ad6..00000000 --- a/src/scss/media-carousel.scss +++ /dev/null @@ -1,9 +0,0 @@ -:host { - display: block; - width: 100%; - height: 100%; - --video-max-height: none; - - // Keep the controls relative to the media carousel itself. - position: relative; -} diff --git a/src/scss/viewer-carousel.scss b/src/scss/viewer-carousel.scss index b7bc6bfb..5d4a4501 100644 --- a/src/scss/viewer-carousel.scss +++ b/src/scss/viewer-carousel.scss @@ -1,6 +1,8 @@ :host { // Center unseekable icon. position: relative; + + --video-max-height: none; } // If the carousel has an unselected attribute set on it, do not let the @@ -8,17 +10,16 @@ // when the carousel is part of a media-grid. Without this next/prev controls // will enlarge on hover, and the wheel-gestures plugin may block scrolling. // See matching in live-carousel.scss . -:host([unselected]) frigate-card-media-carousel, -:host([unselected]) .seek-warning -{ +:host([unselected]) frigate-card-carousel, +:host([unselected]) .seek-warning { pointer-events: none; } -:host([unseekable]) frigate-card-media-carousel { +:host([unseekable]) frigate-card-carousel { filter: brightness(50%); } :host([unseekable]) .seek-warning { - display: block + display: block; } .seek-warning { diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 51efa4cc..7209771a 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -232,3 +232,11 @@ export const isTruthy = (x: T | false | undefined | null | '' | 0): x is T => */ export const isHTMLElement = (element: unknown): element is HTMLElement => element instanceof HTMLElement; + +export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => { + const children = + parent instanceof HTMLSlotElement + ? parent.assignedElements({ flatten: true }) + : [...parent.children]; + return children.filter(isHTMLElement); +}; diff --git a/src/utils/camera.ts b/src/utils/camera.ts index a9b8c6d7..497d9355 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -65,8 +65,6 @@ export function getAllDependentCameras( } } }; - if (cameraID) { - getDependentCameras(cameraID); - } + getDependentCameras(cameraID); return cameraIDs; } diff --git a/src/utils/embla/carousel-controller.ts b/src/utils/embla/carousel-controller.ts new file mode 100644 index 00000000..acfb4849 --- /dev/null +++ b/src/utils/embla/carousel-controller.ts @@ -0,0 +1,178 @@ +import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel'; +import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; +import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; +import isEqual from 'lodash-es/isEqual'; +import { TransitionEffect } from '../../types'; +import { dispatchFrigateCardEvent, getChildrenFromElement } from '../basic.js'; + +export interface CarouselSelected { + index: number; + element: HTMLElement; +} + +type EmblaCarouselPlugins = CreatePluginType>[]; + +export type CarouselDirection = 'vertical' | 'horizontal'; + +export class CarouselController { + protected _parent: HTMLElement; + protected _root: HTMLElement; + protected _direction: CarouselDirection; + protected _startIndex: number; + protected _transitionEffect: TransitionEffect; + protected _loop: boolean; + protected _dragFree: boolean; + protected _draggable: boolean; + + protected _plugins: EmblaCarouselPlugins; + protected _carousel: EmblaCarouselType; + + protected _mutationObserver = new MutationObserver( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_mutations: MutationRecord[], _observer: MutationObserver) => + this._refreshCarouselContents(), + ); + + constructor( + root: HTMLElement, + parent: HTMLElement, + options?: { + direction?: CarouselDirection; + transitionEffect?: TransitionEffect; + startIndex?: number; + loop?: boolean; + dragEnabled?: boolean; + dragFree?: boolean; + plugins?: EmblaCarouselPlugins; + }, + ) { + this._root = root; + this._parent = parent; + this._direction = options?.direction ?? 'horizontal'; + this._transitionEffect = options?.transitionEffect ?? 'slide'; + this._startIndex = options?.startIndex ?? 0; + this._dragFree = options?.dragFree ?? false; + this._loop = options?.loop ?? false; + this._draggable = options?.dragEnabled ?? true; + this._plugins = options?.plugins ?? []; + + this._carousel = this._createCarousel(getChildrenFromElement(this._parent)); + + // Need to separately listen for slotchanges since mutation observer will + // not be called for shadom DOM slotted changes. + if (parent instanceof HTMLSlotElement) { + parent.addEventListener('slotchange', this._refreshCarouselContents); + } + this._mutationObserver.observe(this._parent, { childList: true }); + } + + public destroy() { + if (this._parent instanceof HTMLSlotElement) { + this._parent.removeEventListener('slotchange', this._refreshCarouselContents); + } + this._mutationObserver.disconnect(); + this._carousel.destroy(); + } + + public getSlide(index: number): HTMLElement | null { + return this._carousel.slideNodes()[index] ?? null; + } + + public getSelectedSlide(): HTMLElement | null { + return this.getSlide(this.getSelectedIndex()); + } + + public getSelectedIndex(): number { + return this._carousel.selectedScrollSnap(); + } + + public selectSlide(index: number): void { + this._carousel.scrollTo(index, this._transitionEffect === 'none'); + } + + protected _refreshCarouselContents = (): void => { + const newSlides = getChildrenFromElement(this._parent); + const slidesChanged = !isEqual(this._carousel.slideNodes(), newSlides); + if (slidesChanged) { + this._carousel.destroy(); + this._carousel = this._createCarousel(newSlides); + } + }; + + protected _createCarousel(slides: HTMLElement[]): EmblaCarouselType { + const carousel = EmblaCarousel( + this._root, + { + slides: slides, + + axis: this._direction === 'horizontal' ? 'x' : 'y', + duration: 20, + startIndex: this._startIndex, + dragFree: this._dragFree, + loop: this._loop, + + containScroll: 'trimSnaps', + + // This controller manages slide changes (including shadow DOM + // assignments, which the stock watcher does not handle). + watchSlides: false, + + // We use the auto-size plugin to manage resizes without carousel resets + // mid-scroll. + watchResize: false, + watchDrag: this._draggable, + }, + [ + ...this._plugins, + ...(slides.length > 1 + ? [ + WheelGesturesPlugin({ + // Whether the carousel is vertical or horizontal, interpret y-axis wheel + // gestures as scrolling for the carousel. + forceWheelAxis: 'y', + }), + ] + : []), + ], + ); + + const getCarouselSelectedObject = (): CarouselSelected | null => { + const selectedIndex = this.getSelectedIndex(); + const slide = this.getSlide(selectedIndex); + + if (selectedIndex !== null && slide) { + return { + index: selectedIndex, + element: slide, + }; + } + return null; + }; + + const selectSlide = (): void => { + const carouselSelected = getCarouselSelectedObject(); + if (carouselSelected) { + dispatchFrigateCardEvent( + this._parent, + 'carousel:select', + carouselSelected, + ); + } + }; + + carousel.on('init', () => selectSlide()); + carousel.on('select', () => selectSlide()); + carousel.on('settle', () => { + const carouselSelected = getCarouselSelectedObject(); + if (carouselSelected) { + dispatchFrigateCardEvent( + this._parent, + 'carousel:settle', + carouselSelected, + ); + } + }); + + return carousel; + } +} diff --git a/src/components/embla-plugins/lazyload.ts b/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts similarity index 57% rename from src/components/embla-plugins/lazyload.ts rename to src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts index 311ee6cc..2941f983 100644 --- a/src/components/embla-plugins/lazyload.ts +++ b/src/utils/embla/plugins/auto-lazy-load/auto-lazy-load.ts @@ -1,17 +1,26 @@ +import { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; import { CreateOptionsType } from 'embla-carousel/components/Options'; -import { CreatePluginType } from 'embla-carousel/components/Plugins'; -import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; -import { LazyUnloadCondition } from '../../types'; +import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler'; +import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; +import { LazyUnloadCondition } from '../../../../types'; + +declare module 'embla-carousel/components/Plugins' { + interface EmblaPluginsType { + lazyload?: AutoLazyLoadType; + } +} type OptionsType = CreateOptionsType<{ // Number of slides to lazyload left/right of selected (0 == only selected // slide). - lazyLoadCount?: number; + lazyLoadCount: number; lazyUnloadCondition?: LazyUnloadCondition; lazyLoadCallback?: (index: number, slide: HTMLElement) => void; lazyUnloadCallback?: (index: number, slide: HTMLElement) => void; }>; +type AutoLazyLoadOptionsType = Partial; +type AutoLazyLoadType = CreatePluginType; const defaultOptions: OptionsType = { active: true, @@ -19,74 +28,54 @@ const defaultOptions: OptionsType = { lazyLoadCount: 0, }; -type LazyloadOptionsType = Partial; - -type LazyloadType = CreatePluginType< - { - hasLazyloaded(index: number): boolean; - }, - LazyloadOptionsType ->; - -declare module 'embla-carousel/components/Plugins' { - interface EmblaPluginsType { - lazyload?: LazyloadType; - } -} - -export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { - const optionsHandler = EmblaCarousel.optionsHandler(); - const optionsBase = optionsHandler.merge(defaultOptions, Lazyload.globalOptions); - let options: LazyloadType['options']; - - let carousel: EmblaCarouselType; +export function AutoLazyLoad( + userOptions: AutoLazyLoadOptionsType = {}, +): AutoLazyLoadType { + let options: OptionsType; + let emblaApi: EmblaCarouselType; let slides: HTMLElement[]; const lazyLoadedSlides: Set = new Set(); - const loadEvents: EmblaEventType[] = ['init', 'select', 'resize']; + const loadEvents: EmblaEventType[] = ['init', 'select']; const unloadEvents: EmblaEventType[] = ['select']; - /** - * Initialize the plugin. - */ - function init(embla: EmblaCarouselType): void { - carousel = embla; - options = optionsHandler.atMedia(self.options); - slides = carousel.slideNodes(); + function init( + emblaApiInstance: EmblaCarouselType, + optionsHandler: OptionsHandlerType, + ): void { + const { mergeOptions, optionsAtMedia } = optionsHandler; + const allOptions = mergeOptions(defaultOptions, userOptions); + options = optionsAtMedia(allOptions); + + emblaApi = emblaApiInstance; + slides = emblaApi.slideNodes(); if (options.lazyLoadCallback) { - loadEvents.forEach((evt) => carousel.on(evt, lazyLoadHandler)); + loadEvents.forEach((evt) => emblaApi.on(evt, lazyLoadHandler)); } if ( options.lazyUnloadCallback && options.lazyUnloadCondition && ['all', 'unselected'].includes(options.lazyUnloadCondition) ) { - unloadEvents.forEach((evt) => carousel.on(evt, lazyUnloadPreviousHandler)); + unloadEvents.forEach((evt) => emblaApi.on(evt, lazyUnloadPreviousHandler)); } document.addEventListener('visibilitychange', visibilityHandler); } - /** - * Destroy the plugin. - */ function destroy(): void { if (options.lazyLoadCallback) { - loadEvents.forEach((evt) => carousel.off(evt, lazyLoadHandler)); + loadEvents.forEach((evt) => emblaApi.off(evt, lazyLoadHandler)); } if (options.lazyUnloadCallback) { - unloadEvents.forEach((evt) => carousel.off(evt, lazyUnloadPreviousHandler)); + unloadEvents.forEach((evt) => emblaApi.off(evt, lazyUnloadPreviousHandler)); } document.removeEventListener('visibilitychange', visibilityHandler); } - /** - * Handle document visibility changes. - */ function visibilityHandler(): void { if ( document.visibilityState === 'hidden' && - options.lazyUnloadCallback && options.lazyUnloadCondition && ['all', 'hidden'].includes(options.lazyUnloadCondition) ) { @@ -96,21 +85,13 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { } } - /** - * Determine if a slide index has been lazily loaded. - * @param index Slide index. - * @returns `true` if the slide has been lazily loaded. - */ function hasLazyloaded(index: number): boolean { return lazyLoadedSlides.has(index); } - /** - * Lazily load media in the carousel. - */ function lazyLoadHandler(): void { - const lazyLoadCount = options.lazyLoadCount ?? 0; - const currentIndex = carousel.selectedScrollSnap(); + const lazyLoadCount = options.lazyLoadCount; + const currentIndex = emblaApi.selectedScrollSnap(); const slidesToLoad = new Set(); // Lazily load 'count' slides on either side of the slides in view. @@ -130,9 +111,6 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { }); } - /** - * Lazily unload all media in the carousel. - */ function lazyUnloadAllHandler(): void { lazyLoadedSlides.forEach((index) => { if (options.lazyUnloadCallback) { @@ -142,11 +120,8 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { }); } - /** - * Lazily unload the previously selected media in the carousel. - */ function lazyUnloadPreviousHandler(): void { - const index = carousel.previousScrollSnap(); + const index = emblaApi.previousScrollSnap(); if (hasLazyloaded(index) && options.lazyUnloadCallback) { options.lazyUnloadCallback(index, slides[index]); @@ -154,14 +129,11 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { } } - const self: LazyloadType = { - name: 'lazyload', - options: optionsHandler.merge(optionsBase, userOptions), + const self: AutoLazyLoadType = { + name: 'autoLazyLoad', + options: userOptions, init, destroy, - hasLazyloaded, }; return self; } - -Lazyload.globalOptions = undefined; diff --git a/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts b/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts new file mode 100644 index 00000000..fbb3f3cf --- /dev/null +++ b/src/utils/embla/plugins/auto-media-actions/auto-media-actions.ts @@ -0,0 +1,232 @@ +import { EmblaCarouselType } from 'embla-carousel'; +import { CreateOptionsType } from 'embla-carousel/components/Options.js'; +import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler.js'; +import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins.js'; +import { + AutoMuteCondition, + AutoPauseCondition, + AutoPlayCondition, + AutoUnmuteCondition, + FrigateCardMediaPlayer, +} from '../../../../types.js'; + +declare module 'embla-carousel/components/Plugins' { + interface EmblaPluginsType { + autoMediaActions?: AutoMediaActionsType; + } +} + +type OptionsType = CreateOptionsType<{ + playerSelector?: string; + + autoPlayCondition?: AutoPlayCondition; + autoUnmuteCondition?: AutoUnmuteCondition; + autoPauseCondition?: AutoPauseCondition; + autoMuteCondition?: AutoMuteCondition; +}>; +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 hadInitialIntersectionCall: boolean | null = false; + + 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.autoPlayCondition && + ['all', 'selected'].includes(options.autoPlayCondition) + ) { + // 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.autoUnmuteCondition && + ['all', 'selected'].includes(options.autoUnmuteCondition) + ) { + // 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.autoPauseCondition && + ['all', 'unselected'].includes(options.autoPauseCondition) + ) { + emblaApi.on('select', pausePrevious); + } + + if ( + options.autoMuteCondition && + ['all', 'unselected'].includes(options.autoMuteCondition) + ) { + emblaApi.on('select', mutePrevious); + } + + emblaApi.on('destroy', pause); + emblaApi.on('destroy', mute); + + document.addEventListener('visibilitychange', visibilityHandler); + intersectionObserver.observe(emblaApi.containerNode()); + } + + function destroy(): void { + if ( + options.autoPlayCondition && + ['all', 'selected'].includes(options.autoPlayCondition) + ) { + emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', play); + } + + if ( + options.autoUnmuteCondition && + ['all', 'selected'].includes(options.autoUnmuteCondition) + ) { + emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', unmute); + } + + if ( + options.autoPauseCondition && + ['all', 'unselected'].includes(options.autoPauseCondition) + ) { + emblaApi.off('select', pausePrevious); + } + + if ( + options.autoMuteCondition && + ['all', 'unselected'].includes(options.autoMuteCondition) + ) { + emblaApi.off('select', mutePrevious); + } + + emblaApi.off('destroy', pause); + emblaApi.off('destroy', mute); + + document.removeEventListener('visibilitychange', visibilityHandler); + intersectionObserver.disconnect(); + } + + function actOnVisibilityChange(visible: boolean): void { + if (visible) { + if ( + options.autoPlayCondition && + ['all', 'visible'].includes(options.autoPlayCondition) + ) { + play(); + } + if ( + options.autoUnmuteCondition && + ['all', 'visible'].includes(options.autoUnmuteCondition) + ) { + unmute(); + } + } else { + if ( + options.autoPauseCondition && + ['all', 'hidden'].includes(options.autoPauseCondition) + ) { + pauseAll(); + } + if ( + options.autoMuteCondition && + ['all', 'hidden'].includes(options.autoMuteCondition) + ) { + muteAll(); + } + } + } + + function visibilityHandler(): void { + actOnVisibilityChange(document.visibilityState === 'visible'); + } + + function intersectionHandler(entries: IntersectionObserverEntry[]): void { + if (!hadInitialIntersectionCall) { + hadInitialIntersectionCall = true; + return; + } + + // 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. + actOnVisibilityChange(entries.some((entry) => entry.isIntersecting)); + } + + 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 new file mode 100644 index 00000000..402b9d9e --- /dev/null +++ b/src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.ts @@ -0,0 +1,94 @@ +import { EmblaCarouselType } from 'embla-carousel'; +import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; +import { MediaLoadedInfo } from '../../../../types'; +import { + dispatchExistingMediaLoadedInfoAsEvent, + FrigateMediaLoadedEventTarget, +} from '../../../media-info'; +import { LooseOptionsType } from 'embla-carousel/components/Options'; + +declare module 'embla-carousel/components/Plugins' { + interface EmblaPluginsType { + autoMediaLoadedInfo?: AutoMediaLoadedInfoType; + } +} + +type AutoMediaLoadedInfoType = CreatePluginType; + +function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType { + let emblaApi: EmblaCarouselType; + let slides: (HTMLElement & FrigateMediaLoadedEventTarget)[] = []; + const mediaLoadedInfo: MediaLoadedInfo[] = []; + + function init(emblaApiInstance: EmblaCarouselType): void { + emblaApi = emblaApiInstance; + slides = emblaApi.slideNodes(); + + for (const slide of slides) { + slide.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler); + slide.addEventListener('frigate-card:media:unloaded', mediaUnloadedInfoHandler); + } + + emblaApi.on('init', slideSelectHandler); + emblaApi.on('select', slideSelectHandler); + } + + function destroy(): void { + for (const slide of slides) { + slide.removeEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler); + slide.removeEventListener('frigate-card:media:unloaded', mediaUnloadedInfoHandler); + } + + emblaApi.off('init', slideSelectHandler); + emblaApi.off('select', slideSelectHandler); + } + + function mediaLoadedInfoHandler(ev: CustomEvent): void { + const eventPath = ev.composedPath(); + + for (const [index, slide] of slides.entries()) { + if (eventPath.includes(slide)) { + mediaLoadedInfo[index] = ev.detail; + if (index !== emblaApi.selectedScrollSnap()) { + ev.stopPropagation(); + } + break; + } + } + } + + function mediaUnloadedInfoHandler(ev: CustomEvent): void { + const eventPath = ev.composedPath(); + + for (const [index, slide] of slides.entries()) { + if (eventPath.includes(slide)) { + delete mediaLoadedInfo[index]; + if (index !== emblaApi.selectedScrollSnap()) { + ev.stopPropagation(); + } + break; + } + } + } + + function slideSelectHandler(): void { + const index = emblaApi.selectedScrollSnap(); + const savedMediaLoadedInfo: MediaLoadedInfo | undefined = mediaLoadedInfo[index]; + if (savedMediaLoadedInfo) { + dispatchExistingMediaLoadedInfoAsEvent( + emblaApi.containerNode(), + savedMediaLoadedInfo, + ); + } + } + + const self: AutoMediaLoadedInfoType = { + name: 'autoMediaLoadedInfo', + options: {}, + init, + destroy, + }; + return self; +} + +export default AutoMediaLoadedInfo; diff --git a/src/utils/embla/plugins/auto-size/auto-size.ts b/src/utils/embla/plugins/auto-size/auto-size.ts new file mode 100644 index 00000000..bfadef33 --- /dev/null +++ b/src/utils/embla/plugins/auto-size/auto-size.ts @@ -0,0 +1,143 @@ +import { EmblaCarouselType } from 'embla-carousel'; +import { LooseOptionsType } from 'embla-carousel/components/Options'; +import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; +import { EmblaReInitController } from '../../reinit-controller'; + +declare module 'embla-carousel/components/Plugins' { + interface EmblaPluginsType { + AutoSize?: AutoSizeType; + } +} + +type AutoSizeType = CreatePluginType; +interface SlideDimensions { + height: number; + width: number; +} + +/** + * This plugin offers the following functionality: + * - Auto-height: Automatically resize the container to fit the largest slide on + * view. Unlike the stock `auto-height` plugin, this version will use active + * DOM sizing vs the internal engine sizes to account for pre-reinit resize + * detection. + * - Resize and intersection re-initializing: Re-initialize the carousel on + * slide or container resizes, or container intersection changes. + */ + +function AutoSize(): AutoSizeType { + let emblaApi: EmblaCarouselType; + let reInitController: EmblaReInitController | null = null; + + let previousContainerIntersecting: boolean | null = null; + const previousDimensions: Map = new Map(); + + const resizeObserver: ResizeObserver = new ResizeObserver(resizeHandler); + const intersectionObserver: IntersectionObserver = new IntersectionObserver( + intersectionHandler, + ); + + function init(emblaApiInstance: EmblaCarouselType): void { + emblaApi = emblaApiInstance; + reInitController = new EmblaReInitController(emblaApi); + + intersectionObserver.observe(emblaApi.containerNode()); + resizeObserver.observe(emblaApi.containerNode()); + for (const slide of emblaApi.slideNodes()) { + resizeObserver.observe(slide); + } + + emblaApi.on('settle', setContainerHeight); + } + + function destroy(): void { + intersectionObserver.disconnect(); + resizeObserver.disconnect(); + reInitController?.destroy(); + + emblaApi.off('settle', setContainerHeight); + } + + function intersectionHandler(entries: IntersectionObserverEntry[]): void { + /** + * - If the DOM that contains this carousel changes such that it causes + * slides to entirely appear/disappear (e.g. `display: none` or hidden), + * then the displayed slide sizes will significantly change and the + * carousel will need to be reinitialized. Without this, odd bugs may + * occur for some users in some circumstances causing the carousel to + * appear 'stuck'. + * - Example bug when this reinitialization is not performed: + * https://github.com/dermotduffy/frigate-hass-card/issues/651 + */ + const isContainerIntersectingNow = entries.some((entry) => entry.isIntersecting); + + if (isContainerIntersectingNow !== previousContainerIntersecting) { + // Don't reinitialize on first call (intersectionHandler is always called + // on initial observation). + const callReInit = previousContainerIntersecting !== null; + previousContainerIntersecting = isContainerIntersectingNow; + if (callReInit) { + reInitController?.reinit(); + } + } + } + + function resizeHandler(entries: ResizeObserverEntry[]): void { + let callReInit = false; + + for (const entry of entries) { + const newDimensions: SlideDimensions = { + height: entry.contentRect.height, + width: entry.contentRect.width, + }; + + const oldDimensions = previousDimensions.get(entry.target); + if ( + newDimensions.width && + newDimensions.height && + (oldDimensions?.height !== newDimensions.height || + oldDimensions?.width !== newDimensions.width) + ) { + previousDimensions.set(entry.target, newDimensions); + callReInit = true; + } + } + + if (callReInit) { + reInitController?.reinit(); + } + } + + function setContainerHeight(): void { + const { + slideRegistry, + options: { axis }, + } = emblaApi.internalEngine(); + + if (axis === 'y') { + return; + } + + emblaApi.containerNode().style.removeProperty('max-height'); + + const selectedIndexes = slideRegistry[emblaApi.selectedScrollSnap()]; + const slides = emblaApi.slideNodes(); + const highest = Math.max( + ...selectedIndexes.map((i) => slides[i].getBoundingClientRect().height), + ); + + if (!isNaN(highest) && highest > 0) { + emblaApi.containerNode().style.maxHeight = `${highest}px`; + } + } + + const self: AutoSizeType = { + name: 'autoSize', + options: {}, + init, + destroy, + }; + return self; +} + +export default AutoSize; diff --git a/src/utils/embla/reinit-controller.ts b/src/utils/embla/reinit-controller.ts new file mode 100644 index 00000000..572e0f9c --- /dev/null +++ b/src/utils/embla/reinit-controller.ts @@ -0,0 +1,63 @@ +import { EmblaCarouselType } from 'embla-carousel'; +import debounce from 'lodash-es/debounce'; + +/** + * This class takes care of "safe re-initializing": Only re-initializing the + * carousel when it is not scrolling (unlike the builtin Embla reinitializations, + * e.g. slide additions or resizes). Without this class the carousel is visually + * jarring as in-progress transitions are skipped (vs completing prior to + * reinit). + */ + +export class EmblaReInitController { + protected _emblaApi: EmblaCarouselType; + protected _scrolling = false; + protected _shouldReInitOnScrollStop = false; + + constructor(emblaApi: EmblaCarouselType) { + this._emblaApi = emblaApi; + this._emblaApi.on('scroll', this._scrollingStart); + this._emblaApi.on('settle', this._scrollingStop); + this._emblaApi.on('destroy', this.destroy); + } + + public destroy(): void { + this._emblaApi.off('scroll', this._scrollingStart); + this._emblaApi.off('settle', this._scrollingStop); + this._emblaApi.off('destroy', this.destroy); + } + + public reinit(): void { + if (this._scrolling) { + this._shouldReInitOnScrollStop = true; + } else { + this._debouncedReInit(); + } + } + + protected _scrollingStart = (): void => { + this._scrolling = true; + }; + + protected _scrollingStop = (): void => { + this._scrolling = false; + + if (this._shouldReInitOnScrollStop) { + this._shouldReInitOnScrollStop = false; + this._debouncedReInit(); + } + }; + + protected _debouncedReInit = debounce( + () => { + // Allow the browser a moment to paint components that are inflight, to + // ensure accurate measurements are taken during the carousel + // reinitialization. + this._scrolling = false; + this._shouldReInitOnScrollStop = false; + this._emblaApi?.reInit(); + }, + 200, + { trailing: true }, + ); +} diff --git a/src/utils/media-grid-controller.ts b/src/utils/media-grid-controller.ts index 32787d58..f39f013e 100644 --- a/src/utils/media-grid-controller.ts +++ b/src/utils/media-grid-controller.ts @@ -2,7 +2,7 @@ import isEqual from 'lodash-es/isEqual'; import throttle from 'lodash-es/throttle'; import Masonry from 'masonry-layout'; import { MediaLoadedInfo, ViewDisplayConfig } from '../types'; -import { dispatchFrigateCardEvent, setOrRemoveAttribute } from './basic'; +import { dispatchFrigateCardEvent, getChildrenFromElement, setOrRemoveAttribute } from './basic'; import { FrigateMediaLoadedEventTarget, dispatchExistingMediaLoadedInfoAsEvent, @@ -144,20 +144,11 @@ export class MediaGridController { } protected _calculateGridContentsFromHost = (): void => { - let childrenElements: Element[]; - - if (this._host instanceof HTMLSlotElement) { - childrenElements = this._host.assignedElements(); - } else { - childrenElements = [...this._host.children]; - } - + const children = getChildrenFromElement(this._host); const gridContents: MediaGridContents = new Map(); - for (const child of childrenElements) { - if (child instanceof HTMLElement) { - const id = child.getAttribute(this._idAttribute) || String(gridContents.size); - gridContents.set(id, child); - } + for (const child of children) { + const id = child.getAttribute(this._idAttribute) || String(gridContents.size); + gridContents.set(id, child); } this._setGridContents(gridContents); @@ -200,6 +191,7 @@ export class MediaGridController { const eventPath = ev.composedPath(); for (const [id, element] of this._gridContents.entries()) { + /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (eventPath.includes(element)) { this._mediaLoadedInfoMap.set(id, ev.detail); if (id !== this._selected) { @@ -271,6 +263,7 @@ export class MediaGridController { const eventPath = ev.composedPath(); for (const [id, element] of this._gridContents.entries()) { + /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (eventPath.includes(element)) { if (this._selected !== id) { this.selectCell(id); diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts index 3e01807a..71886649 100644 --- a/src/utils/media-info.ts +++ b/src/utils/media-info.ts @@ -112,7 +112,7 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean { ); } -// Facilities correct Typescript typing of media:loaded event handlers. +// Facilitates correct Typescript typing of media:loaded/unloaded event handlers. export interface FrigateMediaLoadedEventTarget extends EventTarget { addEventListener( event: 'frigate-card:media:loaded', @@ -122,6 +122,11 @@ export interface FrigateMediaLoadedEventTarget extends EventTarget { ) => void, options?: AddEventListenerOptions | boolean, ): void; + addEventListener( + event: 'frigate-card:media:unloaded', + listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void, + options?: AddEventListenerOptions | boolean, + ): void; addEventListener( type: string, callback: EventListenerOrEventListenerObject, @@ -135,6 +140,11 @@ export interface FrigateMediaLoadedEventTarget extends EventTarget { ) => void, options?: boolean | EventListenerOptions, ): void; + removeEventListener( + event: 'frigate-card:media:unloaded', + listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void, + options?: boolean | EventListenerOptions, + ): void; removeEventListener( type: string, callback: EventListenerOrEventListenerObject, diff --git a/src/utils/menu-controller.ts b/src/utils/menu-controller.ts index f069397f..92ca9bdc 100644 --- a/src/utils/menu-controller.ts +++ b/src/utils/menu-controller.ts @@ -82,7 +82,7 @@ export class MenuButtonController { ) as FrigateCardCustomAction, }); - if (visibleCameras) { + if (visibleCameras.size) { const menuItems = Array.from(visibleCameras, ([cameraID, config]) => { const action = createFrigateCardCustomAction('camera_select', { camera: cameraID, @@ -399,6 +399,7 @@ export class MenuButtonController { const action = createFrigateCardCustomAction('display_mode_select', { display_mode: isGrid ? 'single' : 'grid', }); + /* istanbul ignore else: the else path cannot be reached -- @preserve */ if (action) { buttons.push({ icon: isGrid ? 'mdi:grid-off' : 'mdi:grid', diff --git a/src/utils/zod.ts b/src/utils/zod.ts index d3d53e8b..9f8037d6 100644 --- a/src/utils/zod.ts +++ b/src/utils/zod.ts @@ -74,23 +74,22 @@ export const getParseErrorPaths = (error: z.ZodError): Set | null * available unions). This usually suggests the user specified an incorrect * type name entirely. */ const contenders = new Set(); - if (error && error.issues) { - for (let i = 0; i < error.issues.length; i++) { - const issue = error.issues[i]; - if (issue.code == 'invalid_union') { + if (error.issues.length) { + for (const issue of error.issues) { + if (issue.code === 'invalid_union') { const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors; - for (let j = 0; j < unionErrors.length; j++) { - const nestedErrors = getParseErrorPaths(unionErrors[j]); + for (const unionError of unionErrors) { + const nestedErrors = getParseErrorPaths(unionError); if (nestedErrors && nestedErrors.size) { nestedErrors.forEach(contenders.add, contenders); } } - } else if (issue.code == 'invalid_type') { - if (issue.path[issue.path.length - 1] == 'type') { + } else if (issue.code === 'invalid_type') { + if (issue.path[issue.path.length - 1] === 'type') { return null; } contenders.add(getParseErrorPathString(issue.path)); - } else if (issue.code != 'custom') { + } else { contenders.add(getParseErrorPathString(issue.path)); } } diff --git a/src/view/media-queries-results.ts b/src/view/media-queries-results.ts index 3eec71be..f3d2588d 100644 --- a/src/view/media-queries-results.ts +++ b/src/view/media-queries-results.ts @@ -26,7 +26,7 @@ class ResultSlice { if (options?.results && options.results.length) { if (!options?.selectApproach || options?.selectApproach === 'last') { return options.results.length - 1; - } else if (options.selectApproach === 'first') { + } else { return 0; } } diff --git a/src/view/view.ts b/src/view/view.ts index e41e1db4..e89d010f 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -111,13 +111,9 @@ export class View { const switchingToMediaFromMedia = curr?.is('media') && next.is('media'); if (hasNoQueryOrResults) { - if (switchingToGalleryFromViewer) { - if (curr.query) { - next.query = curr.query; - } - if (curr.queryResults) { - next.queryResults = curr.queryResults; - } + if (switchingToGalleryFromViewer && curr.query && curr.queryResults) { + next.query = curr.query; + next.queryResults = curr.queryResults; } else if (switchingToMediaFromMedia && currentQueriesView) { next.view = currentQueriesView === 'clips' diff --git a/tests/camera-manager/cache.test.ts b/tests/camera-manager/cache.test.ts index 70889913..6746ec8a 100644 --- a/tests/camera-manager/cache.test.ts +++ b/tests/camera-manager/cache.test.ts @@ -59,7 +59,7 @@ describe('RecordingSegmentsCache', () => { start: now, end: add(now, { hours: 1 }), }; - const badRange = { start: sub(now, { hours: 1 }), end: now }; + const pastRange = { start: sub(now, { hours: 1 }), end: now }; const createSegment = (date: Date, id: string): RecordingSegment => { return { start_time: date.getTime() / 1000, @@ -89,7 +89,7 @@ describe('RecordingSegmentsCache', () => { }); it('should not get for other range', () => { cache.add('camera-1', range, segments); - expect(cache.get('camera-1', badRange)).toBeNull(); + expect(cache.get('camera-1', pastRange)).toBeNull(); }); it('should have coverage when added', () => { @@ -102,7 +102,7 @@ describe('RecordingSegmentsCache', () => { }); it('should not have coverage for other range', () => { cache.add('camera-1', range, segments); - expect(cache.hasCoverage('camera-1', badRange)).toBeFalsy(); + expect(cache.hasCoverage('camera-1', pastRange)).toBeFalsy(); }); it('should be empty when cleared', () => { @@ -114,11 +114,11 @@ describe('RecordingSegmentsCache', () => { it('should get size', () => { cache.add('camera-1', range, segments); - expect(cache.getSize("camera-1")).toBe(3); + expect(cache.getSize('camera-1')).toBe(3); }); it('should not size for other camera', () => { cache.add('camera-1', range, segments); - expect(cache.getSize("camera-2")).toBeNull(); + expect(cache.getSize('camera-2')).toBeNull(); }); it('should return cameraIDs', () => { @@ -127,6 +127,24 @@ describe('RecordingSegmentsCache', () => { expect(sortBy(cache.getCameraIDs())).toEqual(sortBy(['camera-1', 'camera-2'])); }); + it('should add segments to existing range', () => { + cache.add('camera-1', range, segments); + cache.add('camera-1', range, [ + createSegment(add(now, { seconds: 15 }), 'segment-2.5'), + ]); + expect(cache.get('camera-1', range)?.length).toBe(4); + }); + it('should not get segments that are outside range', () => { + cache.add('camera-1', range, segments); + expect(cache.get('camera-1', range)?.length).toBe(3); + // Add a segment before and after the desired range. + cache.add('camera-1', range, [ + createSegment(sub(now, { seconds: 15 }), 'segment-0'), + createSegment(add(range.end, { seconds: 10 }), 'segment-4'), + ]); + expect(cache.get('camera-1', range)?.length).toBe(3); + }); + it('should remove expired matches', () => { cache.add('camera-1', range, segments); cache.expireMatches('camera-1', (segment) => segment === segments[0]); diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index 24ebde87..db8df3e0 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -100,6 +100,11 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => { Engine.Frigate, ); }); + it('should get no engine from config with insufficient details', async () => { + const config = createCameraConfig({}); + expect(await createFactory().getEngineForCamera(createHASS(), config)).toBeNull(); + }); + it('should throw error on invalid entity', async () => { const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const entityRegistryManager = new EntityRegistryManager(new EntityCache()); diff --git a/tests/camera-manager/range.test.ts b/tests/camera-manager/range.test.ts index 8051baad..5d2a40c4 100644 --- a/tests/camera-manager/range.test.ts +++ b/tests/camera-manager/range.test.ts @@ -135,6 +135,7 @@ describe('compressRanges', () => { compressRanges([ { start: now, end: nowPlusOne }, { start: nowPlusOne, end: nowPlusTwo }, + { start: now, end: nowPlusOne }, ]), ).toEqual([{ start: now, end: nowPlusTwo }]); }); @@ -162,4 +163,8 @@ describe('compressRanges', () => { ]; expect(compressRanges(input)).toEqual([{ start: 1, end: 3 }]); }); + + it('should return nothing with no input', () => { + expect(compressRanges([])).toEqual([]); + }); }); diff --git a/tests/conditions.test.ts b/tests/conditions.test.ts index d5eb3fbc..de76966b 100644 --- a/tests/conditions.test.ts +++ b/tests/conditions.test.ts @@ -99,6 +99,13 @@ describe('getOverriddenConfig', () => { }, }); }); + + it('should do nothing without overrides', () => { + const controller = new ConditionController(); + controller.setState({ fullscreen: true }); + + expect(getOverriddenConfig(controller, config)).toBe(config); + }); }); describe('getOverridesByKey', () => { diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 1dc0bcec..d55088ec 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -239,17 +239,53 @@ export class TestViewMedia extends ViewMedia { } } -export const createResizeObserverImplementation = (): (() => void) => { - return () => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), - }); +export const ResizeObserverMock = vi.fn(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn(), +})); + +export const IntersectionObserverMock = vi.fn(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn(), +})); + +export const MutationObserverMock = vi.fn(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn(), +})); + +export const requestAnimationFrameMock = (callback: FrameRequestCallback) => { + callback(new Date().getTime()); + return 1; }; -export const createMutationObserverImplementation = (): (() => void) => { - return () => ({ - observe: vi.fn(), - disconnect: vi.fn(), - }); +export const createSlotHost = (options?: { + slot?: HTMLSlotElement; + children?: HTMLElement[]; +}): HTMLElement => { + const parent = document.createElement('div'); + parent.attachShadow({ mode: 'open' }); + + if (options?.slot) { + parent.shadowRoot?.append(options.slot); + } + if (options?.children) { + // Children will automatically be slotted into the default slot when it is + // created. + parent.append(...options.children); + } + return parent; +}; + +export const createSlot = (): HTMLSlotElement => { + return document.createElement('slot'); +}; + +export const createParent = (options?: { children?: HTMLElement[] }): HTMLElement => { + const parent = document.createElement('div'); + parent.append(...(options?.children ?? [])); + return parent; }; diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index 574a9803..0a58c87c 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -1,27 +1,29 @@ -import { describe, it, expect, vi, afterAll } from 'vitest'; +import { afterAll, describe, expect, it, vi } from 'vitest'; import { FrigateCardError } from '../../src/types'; import { allPromises, - arrayify, arrayMove, + arrayify, contentsChanged, dayToDate, dispatchFrigateCardEvent, errorToConsole, - isTruthy, formatDate, formatDateAndTime, + getChildrenFromElement, getDurationString, + isHTMLElement, isHoverableDevice, isSuperset, + isTruthy, isValidDate, prettifyTitle, runWhenIdleIfSupported, - setify, setOrRemoveAttribute, + setify, sleep, - isHTMLElement, } from '../../src/utils/basic'; +import { createSlot, createSlotHost } from '../test-utils'; // @vitest-environment jsdom describe('dispatchFrigateCardEvent', () => { @@ -183,6 +185,11 @@ describe('getDurationString', () => { const end = new Date(2023, 3, 14, 15, 37, 20); expect(getDurationString(start, end)).toBe('2h 2m 20s'); }); + it('should return very short duration', () => { + const start = new Date(2023, 3, 14, 13, 35, 10); + const end = new Date(2023, 3, 14, 13, 35, 12); + expect(getDurationString(start, end)).toBe('2s'); + }); }); describe('allPromises', () => { @@ -268,3 +275,19 @@ describe('isHTMLElement', () => { expect(isHTMLElement(svgElement)).toBeFalsy(); }); }); + +describe('getChildrenFromElement', () => { + it('should return children for simple parent', () => { + const children = [document.createElement('div'), document.createElement('div')]; + const parent = document.createElement('div'); + children.forEach((child) => parent.appendChild(child)); + expect(getChildrenFromElement(parent)).toEqual(children); + }); + + it('should return children for slot', () => { + const children = [document.createElement('div'), document.createElement('div')]; + const slot = createSlot(); + createSlotHost({ slot: slot, children: children }); + expect(getChildrenFromElement(slot)).toEqual(children); + }); +}); diff --git a/tests/utils/download.test.ts b/tests/utils/download.test.ts index 850528f3..c5c9ac0b 100644 --- a/tests/utils/download.test.ts +++ b/tests/utils/download.test.ts @@ -12,13 +12,66 @@ vi.mock('../../src/utils/ha'); const media = new ViewMedia('clip', 'camera-1'); // @vitest-environment jsdom +describe('downloadURL', () => { + afterEach(() => { + vi.restoreAllMocks(); + global.window.location = mock(); + }); + + it('should download same origin via link', () => { + const location: Location & { origin: string } = mock(); + location.origin = 'http://foo'; + global.window.location = location; + + const link = document.createElement('a'); + link.click = vi.fn(); + link.setAttribute = vi.fn(); + vi.spyOn(document, 'createElement').mockReturnValue(link); + + downloadURL('http://foo/url.mp4'); + + expect(link.href).toBe('http://foo/url.mp4'); + expect(link.setAttribute).toBeCalledWith('download', 'download'); + expect(link.click).toBeCalled(); + }); + + it('should download data URL via link', () => { + const link = document.createElement('a'); + link.click = vi.fn(); + link.setAttribute = vi.fn(); + vi.spyOn(document, 'createElement').mockReturnValue(link); + + downloadURL('data:text/plain;charset=utf-8;base64,VEhJUyBJUyBEQVRB'); + + expect(link.href).toBe('data:text/plain;charset=utf-8;base64,VEhJUyBJUyBEQVRB'); + expect(link.setAttribute).toBeCalledWith('download', 'download'); + expect(link.click).toBeCalled(); + }); + + it('should download in apps via window.open', () => { + // Set the origin to the same. + const location: Location & { origin: string } = mock(); + location.origin = 'http://foo'; + global.window.location = location; + + vi.stubGlobal('navigator', { + userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)', + }); + + const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); + + downloadURL('http://foo/url.mp4'); + expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank'); + }); +}); + describe('downloadMedia', () => { afterEach(() => { vi.resetAllMocks(); global.window.location = mock(); }); - it('should throw error when no media', async () => { + it('should throw error when no media', () => { const cameraManager = createCameraManager(); mock(cameraManager).getMediaDownloadPath.mockResolvedValue(null); @@ -55,44 +108,16 @@ describe('downloadMedia', () => { await downloadMedia(createHASS(), cameraManager, media); expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank'); }); -}); -describe('downloadURL', () => { - afterEach(() => { - vi.resetAllMocks(); - global.window.location = mock(); - }); - - it('should download same origin via link', async () => { - const location: Location & { origin: string } = mock(); - location.origin = 'http://foo'; - global.window.location = location; - - const link = document.createElement('a'); - link.click = vi.fn(); - link.setAttribute = vi.fn(); - vi.spyOn(document, 'createElement').mockReturnValue(link); - - downloadURL('http://foo/url.mp4'); - - expect(link.href).toBe('http://foo/url.mp4'); - expect(link.setAttribute).toBeCalledWith('download', 'download'); - expect(link.click).toBeCalled(); - }); - - it('should download in apps via window.open', async () => { - // Set the origin to the same. - const location: Location & { origin: string } = mock(); - location.origin = 'http://foo'; - global.window.location = location; - - vi.stubGlobal('navigator', { - userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)', + it('should download media without signing', async () => { + const cameraManager = createCameraManager(); + mock(cameraManager).getMediaDownloadPath.mockResolvedValue({ + sign: false, + endpoint: 'https://foo/', }); - const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); - downloadURL('http://foo/url.mp4'); - expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank'); + await downloadMedia(createHASS(), cameraManager, media); + expect(windowSpy).toBeCalledWith('https://foo/', '_blank'); }); }); diff --git a/tests/utils/embla/carousel-controller.test.ts b/tests/utils/embla/carousel-controller.test.ts new file mode 100644 index 00000000..bc5eb5be --- /dev/null +++ b/tests/utils/embla/carousel-controller.test.ts @@ -0,0 +1,291 @@ +import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel'; +import { MockedObject, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +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, + createParent, + createSlot, + createSlotHost, +} from '../../test-utils'; +import { + callEmblaHandler, + callMutationHandler, + createEmblaApiInstance, + createTestSlideNodes, +} from './test-utils'; + +vi.mock('embla-carousel', () => ({ + default: vi.fn().mockImplementation(() => { + return createEmblaApiInstance(); + }), +})); + +// Get the nth most recently constructed EmblaAPI instance. +const getEmblaApi = (n = 0): MockedObject | null => { + const constructions = vi.mocked(EmblaCarousel).mock.results; + const mostRecentResult = constructions[constructions.length - 1 - n] ?? null; + if (mostRecentResult && mostRecentResult.type === 'return') { + return vi.mocked(mostRecentResult.value); + } + return null; +}; + +const createRoot = (): HTMLElement => { + return document.createElement('div'); +}; + +// @vitest-environment jsdom +describe('CarouselController', () => { + beforeAll(() => { + vi.stubGlobal('MutationObserver', MutationObserverMock); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should construct', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const carousel = new CarouselController(createRoot(), parent); + expect(carousel).toBeTruthy(); + }); + + it('should construct with slot parent', () => { + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: createTestSlideNodes() }); + const carousel = new CarouselController(host, slot); + expect(carousel).toBeTruthy(); + }); + + it('should destroy', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const carousel = new CarouselController(createRoot(), parent); + + carousel.destroy(); + + expect(getEmblaApi()?.destroy).toBeCalled(); + }); + + it('should destroy with slot', () => { + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: createTestSlideNodes() }); + const carousel = new CarouselController(host, slot); + + carousel.destroy(); + + expect(getEmblaApi()?.destroy).toBeCalled(); + }); + + it('should get slide by index', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const carousel = new CarouselController(createRoot(), parent); + + getEmblaApi()?.slideNodes.mockReturnValue(children); + expect(carousel.getSlide(2)).toBe(children[2]); + }); + + it('should get slide by index when index is invalid', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const carousel = new CarouselController(createRoot(), parent); + + expect(carousel.getSlide(1000)).toBeNull(); + }); + + it('should get selected slide', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const carousel = new CarouselController(createRoot(), parent); + + getEmblaApi()?.slideNodes.mockReturnValue(children); + getEmblaApi()?.selectedScrollSnap.mockReturnValue(3); + expect(carousel.getSelectedIndex()).toBe(3); + expect(carousel.getSelectedSlide()).toBe(children[3]); + }); + + it('should select given slide', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const carousel = new CarouselController(createRoot(), parent); + + carousel.selectSlide(4); + + expect(getEmblaApi()?.scrollTo).toBeCalledWith(4, false); + }); + + it('should dispatch settle event', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + new CarouselController(createRoot(), parent); + + const settleHandler = vi.fn(); + parent.addEventListener('frigate-card:carousel:settle', settleHandler); + + callEmblaHandler(getEmblaApi(), 'settle'); + + expect(settleHandler).toBeCalled(); + }); + + describe('should dispatch select event on', () => { + it.each([['init' as const], ['select' as const]])( + '%s', + (emblaApiEvt: 'init' | 'select') => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + new CarouselController(createRoot(), parent); + + const selectHandler = vi.fn(); + parent.addEventListener('frigate-card:carousel:select', selectHandler); + + getEmblaApi()?.selectedScrollSnap.mockReturnValue(6); + getEmblaApi()?.slideNodes.mockReturnValue(children); + callEmblaHandler(getEmblaApi(), emblaApiEvt); + + expect(selectHandler).toBeCalledWith( + expect.objectContaining({ + detail: { + index: 6, + element: children[6], + }, + }), + ); + }, + ); + }); + + it('should not dispatch anything with an invalid scroll snap', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + new CarouselController(createRoot(), parent); + + const selectHandler = vi.fn(); + parent.addEventListener('frigate-card:carousel:select', selectHandler); + + getEmblaApi()?.selectedScrollSnap.mockReturnValue(1000); + getEmblaApi()?.slideNodes.mockReturnValue(children); + callEmblaHandler(getEmblaApi(), 'init'); + callEmblaHandler(getEmblaApi(), 'select'); + callEmblaHandler(getEmblaApi(), 'settle'); + + expect(selectHandler).not.toBeCalled(); + }); + + it('should honor creation options', () => { + const children = createTestSlideNodes({ n: 1 }); + const root = createRoot(); + const parent = createParent({ children: children }); + const plugins = [AutoMediaLoadedInfo()]; + + new CarouselController(root, parent, { + direction: 'vertical', + transitionEffect: 'none', + startIndex: 7, + dragFree: true, + loop: true, + dragEnabled: false, + plugins: plugins, + }); + + expect(EmblaCarousel).toBeCalledWith( + root, + { + slides: children, + axis: 'y', + duration: 20, + startIndex: 7, + dragFree: true, + loop: true, + containScroll: 'trimSnaps', + watchSlides: false, + watchResize: false, + watchDrag: false, + }, + plugins, + ); + }); + + it('should include wheel plugin when slides > 1', () => { + const children = createTestSlideNodes(); + const root = createRoot(); + const parent = createParent({ children: children }); + new CarouselController(root, parent); + + expect(EmblaCarousel).toBeCalledWith( + root, + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ + name: 'wheelGestures', + }), + ]), + ); + }); + + it('should recreate carousel when children are added', () => { + const children = createTestSlideNodes(); + const root = createRoot(); + const parent = createParent({ children: children }); + new CarouselController(root, parent); + + expect(EmblaCarousel).toBeCalledTimes(1); + + const originalEmblaApi = getEmblaApi(); + expect(originalEmblaApi).toBeTruthy(); + + originalEmblaApi?.slideNodes.mockReturnValue(children); + parent.appendChild(document.createElement('div')); + callMutationHandler(); + + expect(originalEmblaApi?.destroy).toBeCalled(); + expect(getEmblaApi()).not.toBe(originalEmblaApi); + + expect(EmblaCarousel).toBeCalledTimes(2); + }); + + it('should not recreate carousel when children have not changed', () => { + const children = createTestSlideNodes(); + const root = createRoot(); + const parent = createParent({ children: children }); + new CarouselController(root, parent); + + expect(EmblaCarousel).toBeCalledTimes(1); + + const originalEmblaApi = getEmblaApi(); + expect(originalEmblaApi).toBeTruthy(); + + originalEmblaApi?.slideNodes.mockReturnValue(children); + callMutationHandler(); + + expect(originalEmblaApi?.destroy).not.toBeCalled(); + expect(getEmblaApi()).toBe(originalEmblaApi); + + expect(EmblaCarousel).toBeCalledTimes(1); + }); + + it('should recreate carousel when children are added to slot', () => { + const children = createTestSlideNodes(); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); + + new CarouselController(host, slot); + + expect(EmblaCarousel).toBeCalledTimes(1); + + const originalEmblaApi = getEmblaApi(); + expect(originalEmblaApi).toBeTruthy(); + + originalEmblaApi?.slideNodes.mockReturnValue(children); + + host.appendChild(document.createElement('div')); + slot.dispatchEvent(new Event('slotchange')); + + expect(originalEmblaApi?.destroy).toBeCalled(); + expect(getEmblaApi()).not.toBe(originalEmblaApi); + + expect(EmblaCarousel).toBeCalledTimes(2); + }); +}); diff --git a/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts b/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts new file mode 100644 index 00000000..d2ec3916 --- /dev/null +++ b/tests/utils/embla/plugins/auto-lazy-load/auto-lazy-load.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AutoLazyLoad } from '../../../../../src/utils/embla/plugins/auto-lazy-load/auto-lazy-load'; +import { + callEmblaHandler, + callVisibilityHandler, + createEmblaApiInstance, + createTestEmblaOptionHandler, + createTestSlideNodes, +} from '../../test-utils'; + +// @vitest-environment jsdom +describe('AutoLazyLoad', () => { + it('should construct', () => { + const plugin = AutoLazyLoad(); + expect(plugin.name).toBe('autoLazyLoad'); + }); + + it('should destroy', () => { + const plugin = AutoLazyLoad({ + lazyLoadCallback: vi.fn(), + lazyUnloadCallback: vi.fn(), + }); + const emblaApi = createEmblaApiInstance(); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + plugin.destroy(); + + expect(emblaApi.off).toBeCalledWith('init', expect.anything()); + expect(emblaApi.off).toBeCalledWith('select', expect.anything()); + }); + + it('should do nothing without callbacks', () => { + const plugin = AutoLazyLoad({ + // No callbacks provided. + }); + + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ slideNodes: children }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + expect(emblaApi.on).not.toBeCalled(); + + plugin.destroy(); + expect(emblaApi.off).not.toBeCalled(); + }); + + it('should lazy load single slide on select', () => { + const lazyLoadCallback = vi.fn(); + const plugin = AutoLazyLoad({ + lazyLoadCallback: lazyLoadCallback, + }); + + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ slideNodes: children }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + expect(emblaApi.on).toBeCalledWith('init', expect.anything()); + expect(emblaApi.on).toBeCalledWith('select', expect.anything()); + + callEmblaHandler(emblaApi, 'init'); + expect(lazyLoadCallback).toBeCalledWith(0, children[0]); + + callEmblaHandler(emblaApi, 'select'); + + // The select call will not re-lazyload the same slide. + expect(lazyLoadCallback).toBeCalledTimes(1); + }); + + it('should lazy load multiple slides on select', () => { + const lazyLoadCallback = vi.fn(); + const plugin = AutoLazyLoad({ + lazyLoadCallback: lazyLoadCallback, + lazyLoadCount: 3, + }); + + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + selectedScrollSnap: 5, + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + callEmblaHandler(emblaApi, 'select'); + for (let i = 3; i <= 8; ++i) { + expect(lazyLoadCallback).toBeCalledWith(i, children[i]); + } + }); + + it('should lazy unload on select', () => { + const lazyUnloadCallback = vi.fn(); + const plugin = AutoLazyLoad({ + lazyLoadCallback: vi.fn(), + lazyLoadCount: 3, + lazyUnloadCallback: lazyUnloadCallback, + lazyUnloadCondition: 'all', + }); + + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + selectedScrollSnap: 5, + slideNodes: children, + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + callEmblaHandler(emblaApi, 'select'); + + // First call will not unload anything, since it was not lazy loaded. + expect(lazyUnloadCallback).not.toBeCalled(); + + vi.mocked(emblaApi.previousScrollSnap).mockReturnValue(5); + callEmblaHandler(emblaApi, 'select'); + + // Second call should lazy unload the previous slide. + expect(lazyUnloadCallback).toBeCalledWith(5, children[5]); + }); + + it('should lazy load on visibility', () => { + vi.spyOn(global.document, 'addEventListener'); + + const lazyLoadCallback = vi.fn(); + const plugin = AutoLazyLoad({ + lazyLoadCallback: lazyLoadCallback, + }); + + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + }); + callVisibilityHandler(); + expect(lazyLoadCallback).toBeCalledWith(0, children[0]); + }); + + it('should lazy unload on visibility', () => { + vi.spyOn(global.document, 'addEventListener'); + + const lazyUnloadCallback = vi.fn(); + + const plugin = AutoLazyLoad({ + lazyLoadCallback: vi.fn(), + lazyUnloadCallback: lazyUnloadCallback, + lazyUnloadCondition: 'all', + }); + + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + }); + callVisibilityHandler(); + + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + }); + callVisibilityHandler(); + expect(lazyUnloadCallback).toBeCalledWith(0, children[0]); + }); + + it('should not lazy unload on visibility without a callback', () => { + vi.spyOn(global.document, 'addEventListener'); + + const lazyLoadCallback = vi.fn(); + const plugin = AutoLazyLoad({ + lazyLoadCallback: lazyLoadCallback, + lazyUnloadCondition: 'all', + // No lazy unload callback. + }); + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + }); + callVisibilityHandler(); + expect(lazyLoadCallback).toBeCalledTimes(1); + + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + }); + callVisibilityHandler(); + expect(lazyLoadCallback).toBeCalledTimes(1); + }); + + it('should not lazy load or unload on visibility when no callback provided', () => { + vi.spyOn(global.document, 'addEventListener'); + + const plugin = AutoLazyLoad({ + lazyUnloadCondition: 'all', + // No callbacks provided. + }); + + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + slideNodes: children, + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + }); + callVisibilityHandler(); + }); +}); 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 new file mode 100644 index 00000000..9e8b7092 --- /dev/null +++ b/tests/utils/embla/plugins/auto-media-actions/auto-media-actions.test.ts @@ -0,0 +1,304 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +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, + createMediaLoadedInfo, + createParent, +} from '../../../../test-utils'; +import { + callEmblaHandler, + callIntersectionHandler, + 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', + autoPlayCondition: 'all', + autoUnmuteCondition: 'all', + autoPauseCondition: 'all', + autoMuteCondition: 'all', + ...options, + }); +}; + +// @vitest-environment jsdom +describe('AutoMediaActions', () => { + beforeAll(() => { + vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should construct', () => { + const plugin = AutoMediaActions(); + expect(plugin.name).toBe('autoMediaActions'); + }); + + it('should init without any conditions', () => { + const plugin = AutoMediaActions(); + + 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(); + }); + + 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 plugin = AutoMediaActions(); + 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(); + }); + + 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(true); + 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(); + } + }); +}); diff --git a/tests/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.test.ts b/tests/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.test.ts new file mode 100644 index 00000000..df0e145c --- /dev/null +++ b/tests/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import AutoMediaLoadedInfo from '../../../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info'; +import { + dispatchExistingMediaLoadedInfoAsEvent, + dispatchMediaUnloadedEvent, +} from '../../../../../src/utils/media-info'; +import { createMediaLoadedInfo, createParent } from '../../../../test-utils'; +import { + callEmblaHandler, + createEmblaApiInstance, + createTestEmblaOptionHandler, + createTestSlideNodes, +} from '../../test-utils'; + +// @vitest-environment jsdom +describe('AutoMediaLoadedInfo', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should construct', () => { + const plugin = AutoMediaLoadedInfo(); + expect(plugin.name).toBe('autoMediaLoadedInfo'); + }); + + it('should destroy', () => { + const plugin = AutoMediaLoadedInfo(); + const emblaApi = createEmblaApiInstance(); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + plugin.destroy(); + + expect(emblaApi.off).toBeCalledWith('init', expect.anything()); + expect(emblaApi.off).toBeCalledWith('select', expect.anything()); + }); + + describe('should correctly propogate media load/unload depending on whether media is currently selected', () => { + it.each([ + ['loaded' as const, true], + ['unloaded' as const, true], + ['loaded' as const, false], + ['unloaded' as const, false], + ])('%s', (type: string, selected: boolean) => { + const plugin = AutoMediaLoadedInfo(); + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + + const emblaApi = createEmblaApiInstance({ + containerNode: parent, + slideNodes: children, + selectedScrollSnap: selected ? 5 : 4, + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + const mediaLoadedHandler = vi.fn(); + parent.addEventListener('frigate-card:media:' + type, mediaLoadedHandler); + if (type === 'loaded') { + dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo()); + } else if (type === 'unloaded') { + dispatchMediaUnloadedEvent(children[5]); + } + + if (selected) { + expect(mediaLoadedHandler).toBeCalled(); + } else { + expect(mediaLoadedHandler).not.toBeCalled(); + } + }); + }); + + it('selecting a slide should dispatch a previously saved media loaded info if present', () => { + const plugin = AutoMediaLoadedInfo(); + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + const emblaApi = createEmblaApiInstance({ + containerNode: parent, + slideNodes: children, + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + const mediaLoadedHandler = vi.fn(); + parent.addEventListener('frigate-card:media:loaded', mediaLoadedHandler); + dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo()); + + vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(4); + callEmblaHandler(emblaApi, 'select'); + expect(mediaLoadedHandler).not.toBeCalled(); + + vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(5); + callEmblaHandler(emblaApi, 'select'); + expect(mediaLoadedHandler).toBeCalled(); + }); +}); diff --git a/tests/utils/embla/plugins/auto-size/auto-size.test.ts b/tests/utils/embla/plugins/auto-size/auto-size.test.ts new file mode 100644 index 00000000..d8e06169 --- /dev/null +++ b/tests/utils/embla/plugins/auto-size/auto-size.test.ts @@ -0,0 +1,161 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import AutoSize from '../../../../../src/utils/embla/plugins/auto-size/auto-size'; +import { + IntersectionObserverMock, + ResizeObserverMock, + createParent, + requestAnimationFrameMock, +} from '../../../../test-utils'; +import { + callEmblaHandler, + callIntersectionHandler, + callResizeHandler, + createEmblaApiInstance, + createTestEmblaOptionHandler, + createTestSlideNodes, +} from '../../test-utils'; + +// Mock out debouncing (used in the reinit controller). +vi.mock('lodash-es/debounce', () => ({ + default: vi.fn((fn) => fn), +})); + +// @vitest-environment jsdom +describe('AutoSize', () => { + beforeAll(() => { + // Mock out requestAnimationFrame (used in the reinit controller). + window.requestAnimationFrame = requestAnimationFrameMock; + vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should construct', () => { + const plugin = AutoSize(); + expect(plugin.name).toBe('autoSize'); + }); + + it('should destroy', () => { + const plugin = AutoSize(); + const emblaApi = createEmblaApiInstance(); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + plugin.destroy(); + + expect(emblaApi.off).toBeCalledWith('settle', expect.anything()); + + expect( + vi.mocked(IntersectionObserver).mock.results[0].value.disconnect, + ).toBeCalled(); + expect(vi.mocked(ResizeObserver).mock.results[0].value.disconnect).toBeCalled(); + }); + + it('should correctly handle intersection', () => { + const plugin = AutoSize(); + const emblaApi = createEmblaApiInstance(); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + // First intersection handler call sets the state only. + callIntersectionHandler(true); + + callIntersectionHandler(false); + callIntersectionHandler(false); + callIntersectionHandler(false); + + expect(emblaApi.reInit).toBeCalledTimes(1); + }); + + it('should correctly handle resize', () => { + const plugin = AutoSize(); + const parent = createParent(); + const emblaApi = createEmblaApiInstance({ containerNode: parent }); + + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + callResizeHandler([{ target: parent, width: 10, height: 20 }]); + callResizeHandler([{ target: parent, width: 10, height: 20 }]); + callResizeHandler([{ target: parent, width: 10, height: 20 }]); + + expect(emblaApi.reInit).toBeCalledTimes(1); + + callResizeHandler([{ target: parent, width: 20, height: 40 }]); + + expect(emblaApi.reInit).toBeCalledTimes(2); + }); + + it('should set container height on slide settle', () => { + const plugin = AutoSize(); + const parent = createParent(); + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + containerNode: parent, + selectedScrollSnap: 0, + slideNodes: children, + // 0th scroll snap shows the 0th slide only. + slideRegistry: [[0]], + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + children[0].getBoundingClientRect = vi.fn().mockReturnValue({ + width: 200, + height: 800, + }); + + // select should not do anything, we wait for it to have settled for + // smoothness. + callEmblaHandler(emblaApi, 'select'); + expect(parent.style.maxHeight).toBeFalsy(); + + callEmblaHandler(emblaApi, 'settle'); + expect(parent.style.maxHeight).toBe('800px'); + }); + + it('should not set container height on horizontal carousel', () => { + const plugin = AutoSize(); + const parent = createParent(); + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + containerNode: parent, + selectedScrollSnap: 0, + slideNodes: children, + axis: 'y', + // 0th scroll snap shows the 0th slide only. + slideRegistry: [[0]], + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + children[0].getBoundingClientRect = vi.fn().mockReturnValue({ + width: 200, + height: 800, + }); + + callEmblaHandler(emblaApi, 'settle'); + + expect(parent.style.maxHeight).toBeFalsy(); + }); + + it('should not set container height when slide dimensions are invalid', () => { + const plugin = AutoSize(); + const parent = createParent(); + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + containerNode: parent, + selectedScrollSnap: 0, + slideNodes: children, + axis: 'x', + // 0th scroll snap shows the 0th slide only. + slideRegistry: [[0]], + }); + plugin.init(emblaApi, createTestEmblaOptionHandler()); + + children[0].getBoundingClientRect = vi.fn().mockReturnValue(NaN); + callEmblaHandler(emblaApi, 'settle'); + + children[0].getBoundingClientRect = vi.fn().mockReturnValue(0); + callEmblaHandler(emblaApi, 'settle'); + + expect(parent.style.maxHeight).toBeFalsy(); + }); +}); diff --git a/tests/utils/embla/reinit-controller.test.ts b/tests/utils/embla/reinit-controller.test.ts new file mode 100644 index 00000000..38e03ba7 --- /dev/null +++ b/tests/utils/embla/reinit-controller.test.ts @@ -0,0 +1,59 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EmblaReInitController } from '../../../src/utils/embla/reinit-controller'; +import { requestAnimationFrameMock } from '../../test-utils'; +import { callEmblaHandler, createEmblaApiInstance } from './test-utils'; + +vi.mock('lodash-es/debounce', () => ({ + default: vi.fn((fn) => fn), +})); + +// @vitest-environment jsdom +describe('EmblaReInitController', () => { + beforeAll(() => { + window.requestAnimationFrame = requestAnimationFrameMock; + }); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should construct', () => { + const emblaApi = createEmblaApiInstance(); + new EmblaReInitController(emblaApi); + expect(emblaApi.on).toBeCalledWith('scroll', expect.anything()); + expect(emblaApi.on).toBeCalledWith('settle', expect.anything()); + expect(emblaApi.on).toBeCalledWith('destroy', expect.anything()); + }); + + it('should destroy', () => { + const emblaApi = createEmblaApiInstance(); + const controller = new EmblaReInitController(emblaApi); + + controller.destroy(); + + expect(emblaApi.off).toBeCalledWith('scroll', expect.anything()); + expect(emblaApi.off).toBeCalledWith('settle', expect.anything()); + expect(emblaApi.off).toBeCalledWith('destroy', expect.anything()); + }); + + it('should reinit when not scrolling', () => { + const emblaApi = createEmblaApiInstance(); + const controller = new EmblaReInitController(emblaApi); + + controller.reinit(); + + expect(emblaApi.reInit).toBeCalled(); + }); + + it('should carefully reinit when scrolling', () => { + const emblaApi = createEmblaApiInstance(); + const controller = new EmblaReInitController(emblaApi); + + callEmblaHandler(emblaApi, 'scroll'); + + controller.reinit(); + expect(emblaApi.reInit).not.toBeCalled(); + + callEmblaHandler(emblaApi, 'settle'); + expect(emblaApi.reInit).toBeCalled(); + }); +}); diff --git a/tests/utils/embla/test-utils.ts b/tests/utils/embla/test-utils.ts new file mode 100644 index 00000000..e3ab0389 --- /dev/null +++ b/tests/utils/embla/test-utils.ts @@ -0,0 +1,132 @@ +import { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; +import { EngineType } from 'embla-carousel/components/Engine'; +import { LooseOptionsType } from 'embla-carousel/components/Options'; +import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler'; +import merge from 'lodash-es/merge'; +import { vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; + +export const createTestEmblaOptionHandler = (): OptionsHandlerType => ({ + mergeOptions: ( + optionsA: TypeA, + optionsB?: TypeB, + ): TypeA => { + return merge({}, optionsA, optionsB); + }, + optionsAtMedia: (options: Type): Type => { + return options; + }, + optionsMediaQueries: (_optionsList: LooseOptionsType[]): MediaQueryList[] => [], +}); + +export const callEmblaHandler = ( + emblaApi: EmblaCarouselType | null, + eventName: EmblaEventType, +): void => { + if (!emblaApi) { + return; + } + const mock = vi.mocked(emblaApi.on).mock; + for (const [evt, cb] of mock.calls) { + if (evt === eventName) { + cb(emblaApi, evt); + } + } +}; + +export const callVisibilityHandler = (): void => { + 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')); + } + } +}; + +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') { + 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; + width: number; + height: number; + }[], + n = 0, +): void => { + const mockResult = vi.mocked(ResizeObserver).mock.results[n]; + if (mockResult.type !== 'return') { + return; + } + const observer = mockResult.value; + vi.mocked(ResizeObserver).mock.calls[n][0]( + // Note this is a very incomplete / invalid ResizeObserverEntry that + // just provides the bare basics current implementation uses. + entries.map( + (entry) => + ({ + target: entry.target, + contentRect: { + height: entry.height, + width: entry.width, + }, + } as unknown as ResizeObserverEntry), + ), + observer, + ); +}; + +export const createEmblaApiInstance = (options?: { + slideNodes?: HTMLElement[]; + selectedScrollSnap?: number; + previousScrollSnap?: number; + containerNode?: HTMLElement; + axis?: 'x' | 'y'; + slideRegistry?: number[][]; +}): EmblaCarouselType => { + const emblaApi = mock(); + emblaApi.slideNodes.mockReturnValue(options?.slideNodes ?? createTestSlideNodes()); + emblaApi.selectedScrollSnap.mockReturnValue(options?.selectedScrollSnap ?? 0); + emblaApi.previousScrollSnap.mockReturnValue(options?.previousScrollSnap ?? 0); + emblaApi.containerNode.mockReturnValue( + options?.containerNode ?? document.createElement('div'), + ); + emblaApi.internalEngine.mockReturnValue({ + options: { axis: options?.axis ?? 'x' }, + ...(options?.slideRegistry && { slideRegistry: options.slideRegistry }), + } as EngineType); + return emblaApi; +}; + +export const createTestSlideNodes = (options?: { + n?: number; +}): HTMLElement[] => { + return [...Array(options?.n ?? 10).keys()].map((_) => + document.createElement('div'), + ); +}; diff --git a/tests/utils/media-grid-controller.test.ts b/tests/utils/media-grid-controller.test.ts index 2066647b..4b19b6f9 100644 --- a/tests/utils/media-grid-controller.test.ts +++ b/tests/utils/media-grid-controller.test.ts @@ -8,8 +8,10 @@ import { } from '../../src/utils/media-grid-controller'; import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info'; import { - createMutationObserverImplementation, - createResizeObserverImplementation, + MutationObserverMock, + ResizeObserverMock, + createSlot, + createSlotHost, } from '../test-utils'; vi.mock('lodash-es/throttle', () => ({ @@ -41,7 +43,7 @@ const setElementWidth = (element: HTMLElement, width: number): void => { }); }; -const createHost = (options?: { +const createParent = (options?: { children?: HTMLElement[]; width?: number; }): HTMLElement => { @@ -54,28 +56,6 @@ const createHost = (options?: { return host; }; -const createSlotParent = (): HTMLElement => { - const parent = document.createElement('div'); - parent.attachShadow({ mode: 'open' }); - return parent; -}; - -const createSlotHost = (options?: { - children?: HTMLElement[]; - parent?: HTMLElement; -}): HTMLSlotElement => { - const parent = options?.parent ?? createSlotParent(); - const slot = document.createElement('slot'); - parent.shadowRoot?.append(slot); - - if (options?.children) { - // Children will automatically be slotted into the default slot. - parent.append(...options.children); - } - - return slot; -}; - const createController = (host: HTMLElement, options?: MediaGridConstructorOptions) => { return new MediaGridController(host, options); }; @@ -101,30 +81,20 @@ describe('MediaGridController', () => { beforeEach(() => { vi.clearAllMocks(); - - global.ResizeObserver = vi - .fn() - // Caution: Order must match the order of initialization in - // media-grid-controller.ts . - .mockImplementationOnce(createResizeObserverImplementation()) - .mockImplementationOnce(createResizeObserverImplementation()); - - global.MutationObserver = vi - .fn() - .mockImplementation(createMutationObserverImplementation()); - //global.MutationObserver = mock(); + vi.stubGlobal('MutationObserver', MutationObserverMock); + vi.stubGlobal('ResizeObserver', ResizeObserverMock); }); it('should be constructable', () => { - const controller = createController(createHost()); + const controller = createController(createParent()); expect(controller).toBeTruthy(); expect(masonry.layout).toBeCalled(); }); it('should set grid contents correctly from regular elements', () => { const children = createChildren(); - const host = createHost({ children: children }); - const controller = createController(host); + const parent = createParent({ children: children }); + const controller = createController(parent); expect(controller.getGridContents()).toEqual( new Map([ ['0', children[0]], @@ -138,7 +108,8 @@ describe('MediaGridController', () => { it('should set grid contents correctly from slotted elements', () => { const children = createChildren(); - const host = createSlotHost({ children: children }); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); const controller = createController(host); expect(controller.getGridContents()).toEqual( new Map([ @@ -152,7 +123,10 @@ describe('MediaGridController', () => { it('should select element', () => { const children = createChildren(); - const controller = createController(createSlotHost({ children: children })); + const slot = createSlot(); + createSlotHost({ slot: slot, children: children }); + + const controller = createController(slot); // All children should be unselected. expect(controller.getSelected()).toBeNull(); @@ -176,7 +150,10 @@ describe('MediaGridController', () => { }); it('should re-select element', () => { - const controller = createController(createSlotHost({ children: createChildren() })); + const children = createChildren(); + const slot = createSlot(); + createSlotHost({ slot: slot, children: children }); + const controller = createController(slot); // All children should be unselected. expect(controller.getSelected()).toBeNull(); @@ -190,16 +167,14 @@ describe('MediaGridController', () => { it('should dispatch media loaded info on selection', () => { const children = createChildren(); - const host = createSlotHost({ children: children }); - const controller = createController(host); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); + const controller = createController(slot); const mediaLoadedInfoHandler = vi.fn(); host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler); dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo); - // Nothing is selected, so the event should not have propagated. - expect(mediaLoadedInfoHandler).not.toBeCalled(); - controller.selectCell('0'); expect(mediaLoadedInfoHandler).toBeCalledWith( expect.objectContaining({ @@ -208,9 +183,45 @@ describe('MediaGridController', () => { ); }); + it('should dispatch media loaded info when cell is selected', () => { + const children = createChildren(); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); + const controller = createController(slot); + + controller.selectCell('0'); + + const mediaLoadedInfoHandler = vi.fn(); + host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler); + dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo); + + expect(mediaLoadedInfoHandler).toBeCalledWith( + expect.objectContaining({ + detail: mediaLoadedInfo, + }), + ); + }); + + it('should not dispatch media loaded info when cell is not selected', () => { + const children = createChildren(); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); + const controller = createController(host); + + controller.selectCell('1'); + + const mediaLoadedInfoHandler = vi.fn(); + host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler); + dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo); + + // Another element is selected, so the event should not have propagated. + expect(mediaLoadedInfoHandler).not.toBeCalled(); + }); + it('should unselect', () => { const children = createChildren(); - const host = createSlotHost({ children: children }); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); const controller = createController(host); const unselectedHandler = vi.fn(); @@ -234,20 +245,29 @@ describe('MediaGridController', () => { } // Expect handlers to have been called. - expect(unselectedHandler).toBeCalled(); - expect(unloadMediaHandler).toBeCalled(); + expect(unselectedHandler).toBeCalledTimes(1); + expect(unloadMediaHandler).toBeCalledTimes(1); + + // Unselecting a second time should do nothing. + controller.unselectAll(); + + expect(unselectedHandler).toBeCalledTimes(1); + expect(unloadMediaHandler).toBeCalledTimes(1); }); it('should select in constructor', () => { const children = createChildren(); - const host = createSlotHost({ children: children }); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); const controller = createController(host, { selected: '2' }); + expect(controller.getSelected()).toBe('2'); }); it('should respect grid attribute option', () => { const children = createChildren(['one', 'two', 'three'], 'test-id'); - const host = createSlotHost({ children: children }); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); const controller = createController(host, { idAttribute: 'test-id' }); expect(controller.getGridContents()).toEqual( new Map([ @@ -258,10 +278,21 @@ describe('MediaGridController', () => { ); }); - it('should destroy', () => { + it('should destroy with regular elements', () => { const children = createChildren(); - const host = createSlotHost({ children: children }); - const controller = createController(host); + const parent = createParent({ children: children }); + const controller = createController(parent); + expect(controller.getGridSize()).toBe(3); + controller.destroy(); + expect(controller.getGridSize()).toBe(0); + }); + + it('should destroy with slotted elements', () => { + const children = createChildren(); + const slot = createSlot(); + createSlotHost({ slot: slot, children: children }); + const controller = createController(slot); + expect(controller.getGridSize()).toBe(3); controller.destroy(); expect(controller.getGridSize()).toBe(0); @@ -269,16 +300,16 @@ describe('MediaGridController', () => { it('should replace children when they change', () => { const children = createChildren(); - const host = createHost({ children: children }); - const controller = createController(host, { selected: '1' }); + const parent = createParent({ children: children }); + const controller = createController(parent, { selected: '1' }); dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo); expect(controller.getSelected()).toBe('1'); expect(controller.getGridSize()).toBe(3); - children.forEach((child) => host.removeChild(child)); + children.forEach((child) => parent.removeChild(child)); const newChildren = createChildren(['one', 'two', 'three']); - newChildren.forEach((child) => host.appendChild(child)); + newChildren.forEach((child) => parent.appendChild(child)); triggerMutationObserver(); @@ -294,19 +325,19 @@ describe('MediaGridController', () => { it('should replace children of a slot when they change', () => { const children = createChildren(); - const slotParent = createSlotParent(); - const host = createSlotHost({ children: children, parent: slotParent }); + const slot = createSlot(); + const host = createSlotHost({ slot: slot, children: children }); - const controller = createController(host, { selected: '1' }); + const controller = createController(slot, { selected: '1' }); expect(controller.getSelected()).toBe('1'); expect(controller.getGridSize()).toBe(3); - children.forEach((child) => slotParent.removeChild(child)); + children.forEach((child) => host.removeChild(child)); const newChildren = createChildren(['one', 'two', 'three']); - newChildren.forEach((child) => slotParent.append(child)); + newChildren.forEach((child) => host.append(child)); - host.dispatchEvent(new Event('slotchange')); + slot.dispatchEvent(new Event('slotchange')); expect(controller.getGridContents()).toEqual( new Map([ @@ -320,10 +351,10 @@ describe('MediaGridController', () => { it('should construct masonry correctly', () => { const children = createChildren(); - const host = createHost({ children: children }); - createController(host); + const parent = createParent({ children: children }); + createController(parent); expect(Masonry).toBeCalledWith( - host, + parent, expect.objectContaining({ initLayout: false, percentPosition: true, @@ -333,57 +364,85 @@ describe('MediaGridController', () => { }); it('should set default column size correctly', () => { - const host = createHost({ children: createChildren() }); - createController(host); + const parent = createParent({ children: createChildren() }); + createController(parent); expect(Masonry).toBeCalledWith( - host, + parent, expect.objectContaining({ columnWidth: 246, }), ); - expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); + expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); }); it('should respect exact columns', () => { - const host = createHost({ children: createChildren(), width: 2000 }); - const controller = createController(host); + const parent = createParent({ children: createChildren(), width: 2000 }); + const controller = createController(parent); controller.setDisplayConfig({ mode: 'grid', grid_columns: 2 }); // Will have been called once on construction, and then again when the // number of columns changes. expect(Masonry).toBeCalledTimes(2); expect(Masonry).toBeCalledWith( - host, + parent, expect.objectContaining({ columnWidth: 1000, }), ); - expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe( + expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe( '1000px', ); }); it('should respect selected width factor', () => { - const host = createHost({ children: createChildren(), width: 2000 }); - const controller = createController(host); + const parent = createParent({ children: createChildren(), width: 2000 }); + const controller = createController(parent); controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 }); expect( - host.style.getPropertyValue('--frigate-card-grid-selected-width-factor'), + parent.style.getPropertyValue('--frigate-card-grid-selected-width-factor'), + ).toBe('3'); + + // Setting the same config again should do nothing. + controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 }); + expect( + parent.style.getPropertyValue('--frigate-card-grid-selected-width-factor'), ).toBe('3'); }); - it('should select cell with interacted with', () => { + it('should select cell when interacted with', () => { const children = createChildren(); - const host = createHost({ children: children, width: 2000 }); - const controller = createController(host); + const parent = createParent({ children: children, width: 2000 }); + const controller = createController(parent); expect(controller.getSelected()).toBeNull(); + + const clickHandler = vi.fn(); + parent.addEventListener('click', clickHandler); + children[1].click(); + + // Click will not be allowed through. + expect(clickHandler).not.toBeCalled(); + expect(controller.getSelected()).toBe('1'); + }); + + it('should ignore interaction events on already selected cell', () => { + const children = createChildren(); + const parent = createParent({ children: children, width: 2000 }); + const controller = createController(parent); + controller.selectCell('1'); + + const clickHandler = vi.fn(); + parent.addEventListener('click', clickHandler); + children[1].click(); + + // Click will be allowed through. + expect(clickHandler).toBeCalled(); expect(controller.getSelected()).toBe('1'); }); it('should re-layout when child size changes', () => { - createController(createHost({ children: createChildren() })); + createController(createParent({ children: createChildren() })); vi.mocked(masonry.layout)?.mockClear(); triggerResizeObserver('cell'); @@ -392,32 +451,41 @@ describe('MediaGridController', () => { it('should re-create masonry when host size changes', () => { const children = createChildren(); - const host = createHost({ children: children }); - const controller = createController(host); + const parent = createParent({ children: children }); + createController(parent); expect(Masonry).toBeCalledWith( - host, + parent, expect.objectContaining({ columnWidth: 246, }), ); - expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); + expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); // Clear mock state. vi.mocked(Masonry).mockClear(); vi.mocked(masonry.layout)?.mockClear(); // Resize the host. - setElementWidth(host, 2000); + setElementWidth(parent, 2000); triggerResizeObserver('host'); // Masonry should be reconstructed, styles set and layout called. expect(Masonry).toBeCalledWith( - host, + parent, expect.objectContaining({ columnWidth: 667, }), ); - expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px'); + expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px'); expect(masonry.layout).toBeCalled(); + + // Clear mock state. + vi.mocked(Masonry).mockClear(); + vi.mocked(masonry.layout)?.mockClear(); + + // Triger with the same sizes. + triggerResizeObserver('host'); + expect(Masonry).not.toBeCalled(); + expect(masonry.layout).not.toBeCalled(); }); }); diff --git a/tests/utils/media.test.ts b/tests/utils/media.test.ts index 5e4aefc9..01859e85 100644 --- a/tests/utils/media.test.ts +++ b/tests/utils/media.test.ts @@ -108,6 +108,16 @@ describe('playMediaMutingIfNecessary', () => { expect(player.isMuted).toBeCalled(); expect(player.mute).toBeCalled(); }); + + it('should ignore calls without a video', async () => { + const player = mock(); + player.isMuted.mockReturnValue(false); + + await playMediaMutingIfNecessary(player); + + expect(player.isMuted).not.toBeCalled(); + expect(player.mute).not.toBeCalled(); + }); }); describe('constants', () => { diff --git a/tests/utils/menu-controller.test.ts b/tests/utils/menu-controller.test.ts index 00c578c5..ef193ef0 100644 --- a/tests/utils/menu-controller.test.ts +++ b/tests/utils/menu-controller.test.ts @@ -1,4 +1,5 @@ import { HomeAssistant } from 'custom-card-helpers'; +import isEqual from 'lodash-es/isEqual'; import screenfull from 'screenfull'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; @@ -160,6 +161,26 @@ describe('MenuButtonController', () => { }); }); + it('should not have a cameras menu without a visible camera', () => { + const cameraManager = createCameraManager({ + configs: new Map([ + ['camera-1', createCameraConfig()], + ['camera-2', createCameraConfig()], + ]), + }); + + vi.mocked(cameraManager.getStore()).getVisibleCameras.mockReturnValue(new Map()); + + const buttons = calculateButtons(controller, { cameraManager: cameraManager }); + expect(buttons).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: 'Cameras', + }), + ]), + ); + }); + it('should have substream button with single dependency', () => { const cameraManager = createCameraManager({ configs: new Map([ @@ -1122,7 +1143,17 @@ describe('MenuButtonController', () => { style: {}, }; controller.addDynamicMenuButton(button); - expect(calculateButtons(controller)).toContainEqual(button); + expect( + calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton)) + .length, + ).toBe(1); + + // Adding it again will have no effect. + controller.addDynamicMenuButton(button); + expect( + calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton)) + .length, + ).toBe(1); controller.removeDynamicMenuButton(button); expect(calculateButtons(controller)).not.toContainEqual(button); diff --git a/tests/utils/substream.test.ts b/tests/utils/substream.test.ts index 5a4dd1bd..bc0227b1 100644 --- a/tests/utils/substream.test.ts +++ b/tests/utils/substream.test.ts @@ -52,6 +52,20 @@ describe('createViewWithoutSubstream', () => { const newView = createViewWithoutSubstream(view); expect(newView?.context?.live?.overrides).toEqual(new Map()); }); + + it('should create view with overrides untouched', () => { + const view = new View({ + view: 'live', + camera: 'camera-1', + context: { + live: { + overrides: new Map([['camera-2', 'camera-3']]), + }, + }, + }); + const newView = createViewWithoutSubstream(view); + expect(newView?.context?.live?.overrides).toEqual(view.context?.live?.overrides); + }); }); describe('hasSubstream', () => { @@ -95,7 +109,7 @@ describe('createViewWithNextStream', () => { camera: 'camera', }); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera'])); - const cameraManager = createCameraManager() + const cameraManager = createCameraManager(); const newView = createViewWithNextStream(cameraManager, view); expect(newView.camera).toBe(view.camera); expect(newView.view).toBe(view.view); @@ -107,7 +121,7 @@ describe('createViewWithNextStream', () => { camera: 'camera', }); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); - const cameraManager = createCameraManager() + const cameraManager = createCameraManager(); const newView = createViewWithNextStream(cameraManager, view); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']])); }); @@ -122,7 +136,7 @@ describe('createViewWithNextStream', () => { }, }); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); - const cameraManager = createCameraManager() + const cameraManager = createCameraManager(); const newView = createViewWithNextStream(cameraManager, view); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']])); }); @@ -137,7 +151,7 @@ describe('createViewWithNextStream', () => { }, }); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); - const cameraManager = createCameraManager() + const cameraManager = createCameraManager(); const newView = createViewWithNextStream(cameraManager, view); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']])); }); diff --git a/tests/utils/zod.test.ts b/tests/utils/zod.test.ts index 0d55f48e..b2d1b837 100644 --- a/tests/utils/zod.test.ts +++ b/tests/utils/zod.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { z } from 'zod'; +import { z, ZodError } from 'zod'; import { - deepRemoveDefaults, - getParseErrorKeys, - getParseErrorPaths, + deepRemoveDefaults, + getParseErrorKeys, + getParseErrorPaths, } from '../../src/utils/zod'; describe('deepRemoveDefaults', () => { @@ -88,4 +88,7 @@ describe('getParseErrorPaths', () => { new Set(['array[0] -> type', 'array[0] -> data']), ); }); + it('should get no paths for empty error', () => { + expect(getParseErrorPaths(new ZodError([]))).toEqual(new Set()); + }); }); diff --git a/tests/utils/zoom.test.ts b/tests/utils/zoom.test.ts index 9c81d8fe..f8817e2c 100644 --- a/tests/utils/zoom.test.ts +++ b/tests/utils/zoom.test.ts @@ -92,6 +92,27 @@ describe('Zoom', () => { expect(panzoom.handleUp).toBeCalledWith(ev_5); }); + it('should not respond to pointer when not zoomed', () => { + const element = document.createElement('div'); + + const panzoom = createMockPanZoom(); + vi.mocked(Panzoom).mockReturnValueOnce(panzoom); + + createAndRegisterZoom(element); + + const ev_1 = new PointerEvent('pointerdown'); + element.dispatchEvent(ev_1); + expect(panzoom.handleDown).not.toBeCalledWith(ev_1); + + const ev_2 = new PointerEvent('pointermove'); + element.dispatchEvent(ev_2); + expect(panzoom.handleDown).not.toBeCalledWith(ev_2); + + const ev_3 = new PointerEvent('pointerup'); + element.dispatchEvent(ev_3); + expect(panzoom.handleDown).not.toBeCalledWith(ev_3); + }); + it('should respond with touch', () => { mediaMediSpy.mockReturnValue({ matches: false }); @@ -252,4 +273,50 @@ describe('Zoom', () => { element.dispatchEvent(ev_2); expect(element.style.touchAction).toBeFalsy(); }); + + it('should not fire frigate cards when state has not changed or spurious events received', () => { + const element = document.createElement('div'); + + const zoomedFunc = vi.fn(); + const unzoomedFunc = vi.fn(); + + element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc); + element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc); + + vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom()); + + createAndRegisterZoom(element); + + const ev_1 = new CustomEvent('panzoomzoom', { + detail: { + x: 0, + y: 0, + scale: 1, + isSVG: false, + originalEvent: new PointerEvent('pointermove'), + }, + }); + element.dispatchEvent(ev_1); + + // Unzoomed event with scale === 1, this._zoomed will already be false. + expect(unzoomedFunc).not.toBeCalled(); + expect(zoomedFunc).not.toBeCalled(); + + const ev_2 = new CustomEvent('panzoomzoom', { + detail: { + x: 0, + y: 0, + scale: 1.2, + isSVG: false, + originalEvent: new PointerEvent('pointermove'), + }, + }); + element.dispatchEvent(ev_2); + expect(zoomedFunc).toBeCalledTimes(1); + expect(unzoomedFunc).not.toBeCalled(); + + // Another call when already zoomed will be ignored. + element.dispatchEvent(ev_2); + expect(zoomedFunc).toBeCalledTimes(1); + }); }); diff --git a/tests/view/media-queries-results.test.ts b/tests/view/media-queries-results.test.ts index aae224ef..4129af3a 100644 --- a/tests/view/media-queries-results.test.ts +++ b/tests/view/media-queries-results.test.ts @@ -243,4 +243,40 @@ describe('dispatchViewContextChangeEvent', () => { .map((media) => media.getID()), ).toEqual(['id-office-99', 'id-kitchen-99', 'id-office-99']); }); + + it('should get multiple selected results without main', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + expect( + results + .getMultipleSelectedResults({ main: false, allCameras: true }) + .map((media) => media.getID()), + ).toEqual(['id-kitchen-99', 'id-office-99']); + }); + + it('should get no results with invalid camera ID without main', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + expect( + results + .getMultipleSelectedResults({ main: false, cameraID: 'not-a-real-camera' }) + .map((media) => media.getID()), + ).toEqual([]); + }); + + it('should not demote main selection when selecting from a specific camera', () => { + const results = new MediaQueriesResults({ + results: generateViewMediaArray(), + }); + + results.selectIndex(42); + results.selectIndex(24, 'office'); + + expect(results.getSelectedIndex()).toBe(42); + expect(results.getSelectedIndex('office')).toBe(24); + }); }); diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts index e19f5da3..0703f226 100644 --- a/tests/view/view.test.ts +++ b/tests/view/view.test.ts @@ -4,7 +4,7 @@ import { ViewMedia } from '../../src/view/media'; import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View, dispatchViewContextChangeEvent } from '../../src/view/view'; -import { createView, generateViewMediaArray } from '../test-utils'; +import { createView } from '../test-utils'; // @vitest-environment jsdom describe('View Basics', () => { @@ -122,6 +122,14 @@ describe('View Basics', () => { expect(view.context).toEqual({}); }); + it('should not remove context when no context', () => { + const view = createView(); + expect(view.context).toBeNull(); + + view.removeContext('live'); + expect(view.context).toBeNull(); + }); + it('should remove context property', () => { const view = createView({ context: { live: { overrides: new Map() } } }); @@ -129,6 +137,13 @@ describe('View Basics', () => { expect(view.context).toEqual({ live: {} }); }); + it('should not remove context property that does not exist', () => { + const view = createView({ context: {} }); + + view.removeContextProperty('live', 'overrides'); + expect(view.context).toEqual({}); + }); + it('should detect gallery views', () => { expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy(); expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy(); @@ -292,6 +307,28 @@ describe('View.adoptFromViewIfAppropriate', () => { expect(next.queryResults).toBe(queryResults); }); + it('should not adopt for gallery case if neither query nor results in current view', () => { + const current = createView({ + view: 'clip', + query: null, + queryResults: null, + }); + + const nextQuery = new EventMediaQueries([ + { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, + ]); + const nextResults = new MediaQueriesResults(); + const next = createView({ + view: 'clips', + query: nextQuery, + queryResults: nextResults, + }); + View.adoptFromViewIfAppropriate(next, current); + expect(next.view).toBe('clips'); + expect(next.query).toBe(nextQuery); + expect(next.queryResults).toBe(nextResults); + }); + it.each([ [ new EventMediaQueries([ @@ -311,7 +348,7 @@ describe('View.adoptFromViewIfAppropriate', () => { ]), 'recording', ], - ])('should adopt for media case', (mediaQueries, expectedView) => { + ])('should adopt in media case', (mediaQueries, expectedView) => { const current = createView({ view: 'media', query: mediaQueries, @@ -324,6 +361,54 @@ describe('View.adoptFromViewIfAppropriate', () => { expect(next.queryResults).toBeFalsy(); }); + it('should not adopt for mixed queries in media case', () => { + const query = new EventMediaQueries([ + { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, + { type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true }, + ]); + const results = new MediaQueriesResults(); + const current = createView({ + view: 'media', + query: query, + queryResults: results, + }); + const next = createView({ view: 'media' }); + View.adoptFromViewIfAppropriate(next, current); + + expect(next.view).toBe('media'); + expect(next.query).toBeNull(); + expect(next.queryResults).toBeNull(); + }); + + it('should not adopt when queries and results present in next view in media case', () => { + const currentQuery = new EventMediaQueries([ + { type: QueryType.Event, cameraIDs: new Set(['camera-1']), hasClip: true }, + { type: QueryType.Event, cameraIDs: new Set(['camera-1']), hasSnapshot: true }, + ]); + const currentResults = new MediaQueriesResults(); + const current = createView({ + view: 'media', + query: currentQuery, + queryResults: currentResults, + }); + + const nextQuery = new EventMediaQueries([ + { type: QueryType.Event, cameraIDs: new Set(['camera-2']), hasClip: true }, + { type: QueryType.Event, cameraIDs: new Set(['camera-2']), hasSnapshot: true }, + ]); + const nextResults = new MediaQueriesResults(); + const next = createView({ + view: 'media', + query: nextQuery, + queryResults: nextResults, + }); + View.adoptFromViewIfAppropriate(next, current); + + expect(next.view).toBe('media'); + expect(next.query).toBe(nextQuery); + expect(next.queryResults).toBe(nextResults); + }); + it('should not adopt for other case', () => { const query = new EventMediaQueries([ { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, @@ -431,11 +516,15 @@ describe('View.adoptFromViewIfAppropriate', () => { expect(createView({ view: 'media' }).supportsMultipleDisplayModes()).toBeTruthy(); expect(createView({ view: 'clip' }).supportsMultipleDisplayModes()).toBeTruthy(); expect(createView({ view: 'snapshot' }).supportsMultipleDisplayModes()).toBeTruthy(); - expect(createView({ view: 'recording' }).supportsMultipleDisplayModes()).toBeTruthy(); + expect( + createView({ view: 'recording' }).supportsMultipleDisplayModes(), + ).toBeTruthy(); expect(createView({ view: 'clips' }).supportsMultipleDisplayModes()).toBeFalsy(); expect(createView({ view: 'snapshots' }).supportsMultipleDisplayModes()).toBeFalsy(); - expect(createView({ view: 'recordings' }).supportsMultipleDisplayModes()).toBeFalsy(); + expect( + createView({ view: 'recordings' }).supportsMultipleDisplayModes(), + ).toBeFalsy(); expect(createView({ view: 'image' }).supportsMultipleDisplayModes()).toBeFalsy(); expect(createView({ view: 'timeline' }).supportsMultipleDisplayModes()).toBeFalsy(); }); diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..c5d2f145 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// ts-prune-ignore-next +export default defineConfig({ + test: { + coverage: { + // Favor istanbul for coverage over v8 due to better accuracy. + provider: 'istanbul', + }, + }, +}); diff --git a/yarn.lock b/yarn.lock index 7bc02b0e..4d52591e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22,6 +22,16 @@ __metadata: languageName: node linkType: hard +"@ampproject/remapping@npm:^2.2.0": + version: 2.2.1 + resolution: "@ampproject/remapping@npm:2.2.1" + dependencies: + "@jridgewell/gen-mapping": ^0.3.0 + "@jridgewell/trace-mapping": ^0.3.9 + checksum: 03c04fd526acc64a1f4df22651186f3e5ef0a9d6d6530ce4482ec9841269cf7a11dbb8af79237c282d721c5312024ff17529cd72cc4768c11e999b58e2302079 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.18.6": version: 7.18.6 resolution: "@babel/code-frame@npm:7.18.6" @@ -31,6 +41,16 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.22.10, @babel/code-frame@npm:^7.22.5": + version: 7.22.13 + resolution: "@babel/code-frame@npm:7.22.13" + dependencies: + "@babel/highlight": ^7.22.13 + chalk: ^2.4.2 + checksum: 22e342c8077c8b77eeb11f554ecca2ba14153f707b85294fcf6070b6f6150aae88a7b7436dd88d8c9289970585f3fe5b9b941c5aa3aa26a6d5a8ef3f292da058 + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.20.5": version: 7.20.14 resolution: "@babel/compat-data@npm:7.20.14" @@ -38,6 +58,36 @@ __metadata: languageName: node linkType: hard +"@babel/compat-data@npm:^7.22.9": + version: 7.22.9 + resolution: "@babel/compat-data@npm:7.22.9" + checksum: bed77d9044ce948b4327b30dd0de0779fa9f3a7ed1f2d31638714ed00229fa71fc4d1617ae0eb1fad419338d3658d0e9a5a083297451e09e73e078d0347ff808 + languageName: node + linkType: hard + +"@babel/core@npm:^7.12.3": + version: 7.22.11 + resolution: "@babel/core@npm:7.22.11" + dependencies: + "@ampproject/remapping": ^2.2.0 + "@babel/code-frame": ^7.22.10 + "@babel/generator": ^7.22.10 + "@babel/helper-compilation-targets": ^7.22.10 + "@babel/helper-module-transforms": ^7.22.9 + "@babel/helpers": ^7.22.11 + "@babel/parser": ^7.22.11 + "@babel/template": ^7.22.5 + "@babel/traverse": ^7.22.11 + "@babel/types": ^7.22.11 + convert-source-map: ^1.7.0 + debug: ^4.1.0 + gensync: ^1.0.0-beta.2 + json5: ^2.2.3 + semver: ^6.3.1 + checksum: f258b2539ea2e5bfe55a708c2f3e1093a1b4744f12becc35abeb896037b66210de9a8ad6296a706046d5dc3a24e564362b73a9b814e5bfe500c8baab60c22d2e + languageName: node + linkType: hard + "@babel/core@npm:^7.19.0": version: 7.20.12 resolution: "@babel/core@npm:7.20.12" @@ -72,6 +122,18 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.22.10": + version: 7.22.10 + resolution: "@babel/generator@npm:7.22.10" + dependencies: + "@babel/types": ^7.22.10 + "@jridgewell/gen-mapping": ^0.3.2 + "@jridgewell/trace-mapping": ^0.3.17 + jsesc: ^2.5.1 + checksum: 59a79730abdff9070692834bd3af179e7a9413fa2ff7f83dff3eb888765aeaeb2bfc7b0238a49613ed56e1af05956eff303cc139f2407eda8df974813e486074 + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-annotate-as-pure@npm:7.18.6" @@ -96,6 +158,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-compilation-targets@npm:^7.22.10": + version: 7.22.10 + resolution: "@babel/helper-compilation-targets@npm:7.22.10" + dependencies: + "@babel/compat-data": ^7.22.9 + "@babel/helper-validator-option": ^7.22.5 + browserslist: ^4.21.9 + lru-cache: ^5.1.1 + semver: ^6.3.1 + checksum: f6f1896816392bcff671bbe6e277307729aee53befb4a66ea126e2a91eda78d819a70d06fa384c74ef46c1595544b94dca50bef6c78438d9ffd31776dafbd435 + languageName: node + linkType: hard + "@babel/helper-create-class-features-plugin@npm:^7.18.6, @babel/helper-create-class-features-plugin@npm:^7.20.12": version: 7.20.12 resolution: "@babel/helper-create-class-features-plugin@npm:7.20.12" @@ -121,6 +196,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-environment-visitor@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-environment-visitor@npm:7.22.5" + checksum: 248532077d732a34cd0844eb7b078ff917c3a8ec81a7f133593f71a860a582f05b60f818dc5049c2212e5baa12289c27889a4b81d56ef409b4863db49646c4b1 + languageName: node + linkType: hard + "@babel/helper-function-name@npm:^7.19.0": version: 7.19.0 resolution: "@babel/helper-function-name@npm:7.19.0" @@ -131,6 +213,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-function-name@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-function-name@npm:7.22.5" + dependencies: + "@babel/template": ^7.22.5 + "@babel/types": ^7.22.5 + checksum: 6b1f6ce1b1f4e513bf2c8385a557ea0dd7fa37971b9002ad19268ca4384bbe90c09681fe4c076013f33deabc63a53b341ed91e792de741b4b35e01c00238177a + languageName: node + linkType: hard + "@babel/helper-hoist-variables@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-hoist-variables@npm:7.18.6" @@ -140,6 +232,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-hoist-variables@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-hoist-variables@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: 394ca191b4ac908a76e7c50ab52102669efe3a1c277033e49467913c7ed6f7c64d7eacbeabf3bed39ea1f41731e22993f763b1edce0f74ff8563fd1f380d92cc + languageName: node + linkType: hard + "@babel/helper-member-expression-to-functions@npm:^7.20.7": version: 7.20.7 resolution: "@babel/helper-member-expression-to-functions@npm:7.20.7" @@ -158,6 +259,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-imports@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-module-imports@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: 9ac2b0404fa38b80bdf2653fbeaf8e8a43ccb41bd505f9741d820ed95d3c4e037c62a1bcdcb6c9527d7798d2e595924c4d025daed73283badc180ada2c9c49ad + languageName: node + linkType: hard + "@babel/helper-module-transforms@npm:^7.20.11": version: 7.20.11 resolution: "@babel/helper-module-transforms@npm:7.20.11" @@ -174,6 +284,21 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-transforms@npm:^7.22.9": + version: 7.22.9 + resolution: "@babel/helper-module-transforms@npm:7.22.9" + dependencies: + "@babel/helper-environment-visitor": ^7.22.5 + "@babel/helper-module-imports": ^7.22.5 + "@babel/helper-simple-access": ^7.22.5 + "@babel/helper-split-export-declaration": ^7.22.6 + "@babel/helper-validator-identifier": ^7.22.5 + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 2751f77660518cf4ff027514d6f4794f04598c6393be7b04b8e46c6e21606e11c19f3f57ab6129a9c21bacdf8b3ffe3af87bb401d972f34af2d0ffde02ac3001 + languageName: node + linkType: hard + "@babel/helper-optimise-call-expression@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-optimise-call-expression@npm:7.18.6" @@ -213,6 +338,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-simple-access@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-simple-access@npm:7.22.5" + dependencies: + "@babel/types": ^7.22.5 + checksum: fe9686714caf7d70aedb46c3cce090f8b915b206e09225f1e4dbc416786c2fdbbee40b38b23c268b7ccef749dd2db35f255338fb4f2444429874d900dede5ad2 + languageName: node + linkType: hard + "@babel/helper-skip-transparent-expression-wrappers@npm:^7.20.0": version: 7.20.0 resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.20.0" @@ -231,6 +365,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-split-export-declaration@npm:^7.22.6": + version: 7.22.6 + resolution: "@babel/helper-split-export-declaration@npm:7.22.6" + dependencies: + "@babel/types": ^7.22.5 + checksum: e141cace583b19d9195f9c2b8e17a3ae913b7ee9b8120246d0f9ca349ca6f03cb2c001fd5ec57488c544347c0bb584afec66c936511e447fd20a360e591ac921 + languageName: node + linkType: hard + "@babel/helper-string-parser@npm:^7.19.4": version: 7.19.4 resolution: "@babel/helper-string-parser@npm:7.19.4" @@ -238,6 +381,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-string-parser@npm:7.22.5" + checksum: 836851ca5ec813077bbb303acc992d75a360267aa3b5de7134d220411c852a6f17de7c0d0b8c8dcc0f567f67874c00f4528672b2a4f1bc978a3ada64c8c78467 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.18.6, @babel/helper-validator-identifier@npm:^7.19.1": version: 7.19.1 resolution: "@babel/helper-validator-identifier@npm:7.19.1" @@ -245,6 +395,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-validator-identifier@npm:7.22.5" + checksum: 7f0f30113474a28298c12161763b49de5018732290ca4de13cdaefd4fd0d635a6fe3f6686c37a02905fb1e64f21a5ee2b55140cf7b070e729f1bd66866506aea + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-validator-option@npm:7.18.6" @@ -252,6 +409,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-option@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-validator-option@npm:7.22.5" + checksum: bbeca8a85ee86990215c0424997438b388b8d642d69b9f86c375a174d3cdeb270efafd1ff128bc7a1d370923d13b6e45829ba8581c027620e83e3a80c5c414b3 + languageName: node + linkType: hard + "@babel/helpers@npm:^7.20.7": version: 7.20.13 resolution: "@babel/helpers@npm:7.20.13" @@ -263,6 +427,17 @@ __metadata: languageName: node linkType: hard +"@babel/helpers@npm:^7.22.11": + version: 7.22.11 + resolution: "@babel/helpers@npm:7.22.11" + dependencies: + "@babel/template": ^7.22.5 + "@babel/traverse": ^7.22.11 + "@babel/types": ^7.22.11 + checksum: 93186544228b5e371486466ec3b86a77cce91beeff24a5670ca8ec46d50328f7700dab82d532351286e9d68624dc51d6d71589b051dd9535e44be077a43ec013 + languageName: node + linkType: hard + "@babel/highlight@npm:^7.18.6": version: 7.18.6 resolution: "@babel/highlight@npm:7.18.6" @@ -274,6 +449,26 @@ __metadata: languageName: node linkType: hard +"@babel/highlight@npm:^7.22.13": + version: 7.22.13 + resolution: "@babel/highlight@npm:7.22.13" + dependencies: + "@babel/helper-validator-identifier": ^7.22.5 + chalk: ^2.4.2 + js-tokens: ^4.0.0 + checksum: 7266d2bff8aa8fc78eb65b6e92a8211e12897a731126a282d2f9bb50d8fcaa4c1b02af2284f990ac7e3ab8d892d448a2cab8f5ed0ea8a90bce2c025b11ebe802 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.14.7, @babel/parser@npm:^7.22.11, @babel/parser@npm:^7.22.5": + version: 7.22.14 + resolution: "@babel/parser@npm:7.22.14" + bin: + parser: ./bin/babel-parser.js + checksum: a2293971f0889726a3d5a35fcceedc71d2fa4c8d97f438fc348fe0cf7e739affc6e2665e4c6ddd4900714772e19bfd5d6feb967ca1f623b894c0099ecb148b52 + languageName: node + linkType: hard + "@babel/parser@npm:^7.20.13, @babel/parser@npm:^7.20.7": version: 7.20.15 resolution: "@babel/parser@npm:7.20.15" @@ -332,6 +527,17 @@ __metadata: languageName: node linkType: hard +"@babel/template@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/template@npm:7.22.5" + dependencies: + "@babel/code-frame": ^7.22.5 + "@babel/parser": ^7.22.5 + "@babel/types": ^7.22.5 + checksum: c5746410164039aca61829cdb42e9a55410f43cace6f51ca443313f3d0bdfa9a5a330d0b0df73dc17ef885c72104234ae05efede37c1cc8a72dc9f93425977a3 + languageName: node + linkType: hard + "@babel/traverse@npm:^7.20.10, @babel/traverse@npm:^7.20.12, @babel/traverse@npm:^7.20.13, @babel/traverse@npm:^7.20.7": version: 7.20.13 resolution: "@babel/traverse@npm:7.20.13" @@ -350,6 +556,24 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.22.11": + version: 7.22.11 + resolution: "@babel/traverse@npm:7.22.11" + dependencies: + "@babel/code-frame": ^7.22.10 + "@babel/generator": ^7.22.10 + "@babel/helper-environment-visitor": ^7.22.5 + "@babel/helper-function-name": ^7.22.5 + "@babel/helper-hoist-variables": ^7.22.5 + "@babel/helper-split-export-declaration": ^7.22.6 + "@babel/parser": ^7.22.11 + "@babel/types": ^7.22.11 + debug: ^4.1.0 + globals: ^11.1.0 + checksum: 4ad62d548ca8b95dbf45bae16cc167428f174f3c837d55a5878b1f17bdbc8b384d6df741dc7c461b62c04d881cf25644d3ab885909ba46e3ac43224e2b15b504 + languageName: node + linkType: hard + "@babel/types@npm:^7.18.6, @babel/types@npm:^7.19.0, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.2, @babel/types@npm:^7.20.7, @babel/types@npm:^7.8.3": version: 7.20.7 resolution: "@babel/types@npm:7.20.7" @@ -361,10 +585,14 @@ __metadata: languageName: node linkType: hard -"@bcoe/v8-coverage@npm:^0.2.3": - version: 0.2.3 - resolution: "@bcoe/v8-coverage@npm:0.2.3" - checksum: 850f9305536d0f2bd13e9e0881cb5f02e4f93fad1189f7b2d4bebf694e3206924eadee1068130d43c11b750efcc9405f88a8e42ef098b6d75239c0f047de1a27 +"@babel/types@npm:^7.22.10, @babel/types@npm:^7.22.11, @babel/types@npm:^7.22.5": + version: 7.22.11 + resolution: "@babel/types@npm:7.22.11" + dependencies: + "@babel/helper-string-parser": ^7.22.5 + "@babel/helper-validator-identifier": ^7.22.5 + to-fast-properties: ^2.0.0 + checksum: 431a6446896adb62c876d0fe75263835735d3c974aae05356a87eb55f087c20a777028cf08eadcace7993e058bbafe3b21ce2119363222c6cef9eedd7a204810 languageName: node linkType: hard @@ -672,13 +900,22 @@ __metadata: languageName: node linkType: hard -"@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3": +"@istanbuljs/schema@npm:^0.1.2": version: 0.1.3 resolution: "@istanbuljs/schema@npm:0.1.3" checksum: 5282759d961d61350f33d9118d16bcaed914ebf8061a52f4fa474b2cb08720c9c81d165e13b82f2e5a8a212cc5af482f0c6fc1ac27b9e067e5394c9a6ed186c9 languageName: node linkType: hard +"@jest/schemas@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/schemas@npm:29.6.3" + dependencies: + "@sinclair/typebox": ^0.27.8 + checksum: 910040425f0fc93cd13e68c750b7885590b8839066dfa0cd78e7def07bbb708ad869381f725945d66f2284de5663bbecf63e8fdd856e2ae6e261ba30b1687e93 + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.1.0": version: 0.1.1 resolution: "@jridgewell/gen-mapping@npm:0.1.1" @@ -707,6 +944,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.1 + resolution: "@jridgewell/resolve-uri@npm:3.1.1" + checksum: f5b441fe7900eab4f9155b3b93f9800a916257f4e8563afbcd3b5a5337b55e52bd8ae6735453b1b745457d9f6cdb16d74cd6220bbdd98cf153239e13f6cbb653 + languageName: node + linkType: hard + "@jridgewell/set-array@npm:^1.0.0, @jridgewell/set-array@npm:^1.0.1": version: 1.1.2 resolution: "@jridgewell/set-array@npm:1.1.2" @@ -731,7 +975,24 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.9": +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.15": + version: 1.4.15 + resolution: "@jridgewell/sourcemap-codec@npm:1.4.15" + checksum: b881c7e503db3fc7f3c1f35a1dd2655a188cc51a3612d76efc8a6eb74728bef5606e6758ee77423e564092b4a518aba569bbb21c9bac5ab7a35b0c6ae7e344c8 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.17": + version: 0.3.19 + resolution: "@jridgewell/trace-mapping@npm:0.3.19" + dependencies: + "@jridgewell/resolve-uri": ^3.1.0 + "@jridgewell/sourcemap-codec": ^1.4.14 + checksum: 956a6f0f6fec060fb48c6bf1f5ec2064e13cd38c8be3873877d4b92b4a27ba58289a34071752671262a3e3c202abcc3fa2aac64d8447b4b0fa1ba3c9047f1c20 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.9": version: 0.3.17 resolution: "@jridgewell/trace-mapping@npm:0.3.17" dependencies: @@ -957,6 +1218,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.27.8": + version: 0.27.8 + resolution: "@sinclair/typebox@npm:0.27.8" + checksum: 00bd7362a3439021aa1ea51b0e0d0a0e8ca1351a3d54c606b115fdcc49b51b16db6e5f43b4fe7a28c38688523e22a94d49dd31168868b655f0d4d50f032d07a1 + languageName: node + linkType: hard + "@stencil/core@npm:^2.20.0, @stencil/core@npm:^2.3.0": version: 2.22.2 resolution: "@stencil/core@npm:2.22.2" @@ -1008,13 +1276,20 @@ __metadata: languageName: node linkType: hard -"@types/chai@npm:*, @types/chai@npm:^4.3.4": +"@types/chai@npm:*": version: 4.3.4 resolution: "@types/chai@npm:4.3.4" checksum: 571184967beb03bf64c4392a13a7d44e72da9af5a1e83077ff81c39cf59c0fda2a5c78d2005084601cf8f3d11726608574d8b5b4a0e3e9736792807afd926cd0 languageName: node linkType: hard +"@types/chai@npm:^4.3.5": + version: 4.3.5 + resolution: "@types/chai@npm:4.3.5" + checksum: c8f26a88c6b5b53a3275c7f5ff8f107028e3cbb9ff26795fff5f3d9dea07106a54ce9e2dce5e40347f7c4cc35657900aaf0c83934a25a1ae12e61e0f5516e431 + languageName: node + linkType: hard + "@types/cssnano@npm:^5.0.0": version: 5.1.0 resolution: "@types/cssnano@npm:5.1.0" @@ -1045,13 +1320,6 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:^2.0.1": - version: 2.0.4 - resolution: "@types/istanbul-lib-coverage@npm:2.0.4" - checksum: a25d7589ee65c94d31464c16b72a9dc81dfa0bea9d3e105ae03882d616e2a0712a9c101a599ec482d297c3591e16336962878cb3eb1a0a62d5b76d277a890ce7 - languageName: node - linkType: hard - "@types/jquery@npm:*": version: 3.5.16 resolution: "@types/jquery@npm:3.5.16" @@ -1265,59 +1533,72 @@ __metadata: languageName: node linkType: hard -"@vitest/coverage-c8@npm:^0.29.8": - version: 0.29.8 - resolution: "@vitest/coverage-c8@npm:0.29.8" +"@vitest/coverage-istanbul@npm:^0.34.3": + version: 0.34.3 + resolution: "@vitest/coverage-istanbul@npm:0.34.3" dependencies: - c8: ^7.13.0 - picocolors: ^1.0.0 - std-env: ^3.3.1 + istanbul-lib-coverage: ^3.2.0 + istanbul-lib-instrument: ^6.0.0 + istanbul-lib-report: ^3.0.1 + istanbul-lib-source-maps: ^4.0.1 + istanbul-reports: ^3.1.5 + test-exclude: ^6.0.0 peerDependencies: - vitest: ">=0.29.0 <1" - checksum: 610bd2917aa160f5bf641f4d4dec539524039ea98fdc98a8d9288c7ea05090d7028d4504b8e1ecc1de1d1700c9a61a55abf285559da2c4bc8df238e559a505f7 + vitest: ">=0.32.0 <1" + checksum: 6e5f437d8b3d1e93aa25180661d47339b2a1df24686a5d7154cd266db47fbf0c2a2faa58290f79dd009e531e16c14bfb8a0fb49031d9a14b6e962de82155eb12 languageName: node linkType: hard -"@vitest/expect@npm:0.29.8": - version: 0.29.8 - resolution: "@vitest/expect@npm:0.29.8" +"@vitest/expect@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/expect@npm:0.34.3" dependencies: - "@vitest/spy": 0.29.8 - "@vitest/utils": 0.29.8 + "@vitest/spy": 0.34.3 + "@vitest/utils": 0.34.3 chai: ^4.3.7 - checksum: a80f9c352a979eb46690be2ea54b5ca391d3575b4053be80c1359325fb0cea913d6217f48d54e64ff5dda3b15bd7a6873a5f8128e8c098f7ebad1365d4065c5e + checksum: 79afaa37d2efb7bb5503332caf389860b2261f198dbe61557e8061262b628d18658e59eb51d1808ecd35fc59f4bb4d04c0e0f97a27c7db02584ab5b424147b8d languageName: node linkType: hard -"@vitest/runner@npm:0.29.8": - version: 0.29.8 - resolution: "@vitest/runner@npm:0.29.8" +"@vitest/runner@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/runner@npm:0.34.3" dependencies: - "@vitest/utils": 0.29.8 + "@vitest/utils": 0.34.3 p-limit: ^4.0.0 - pathe: ^1.1.0 - checksum: 8305370ff6c3fc6aea7189bd138ee4ff0e040a959c0fe6ab64bcb9e70ae5bf836b8dc058b1de288aa75c9d1cd648e5f112e7cd5691c03b7a1d32466d8bfc71a9 + pathe: ^1.1.1 + checksum: 945580eaa58e8edbe29a64059bc2a524a9e85117b6d600fdb457cfe84cbfb81bf6d7e98e1227e7cb4e7399992c8fe8d83d0791d0385ff005dc1a4d9da125443b languageName: node linkType: hard -"@vitest/spy@npm:0.29.8": - version: 0.29.8 - resolution: "@vitest/spy@npm:0.29.8" +"@vitest/snapshot@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/snapshot@npm:0.34.3" dependencies: - tinyspy: ^1.0.2 - checksum: 7b1607b696275bf94a497e92d7d10c466b9b3d08726bbedb3735bdf57f003763a9516e328af22746829526ce573f87eb6119ab64ce7db95794b2d220aa53b607 + magic-string: ^0.30.1 + pathe: ^1.1.1 + pretty-format: ^29.5.0 + checksum: 234893e91a1efd4bdbbde047a68de40975e02ead8407724ce8ca4a24edf0fb2d725f8a3efceb104965388407b598faf22407aadfbf4164cc74b3cf1e0e9f4543 languageName: node linkType: hard -"@vitest/utils@npm:0.29.8": - version: 0.29.8 - resolution: "@vitest/utils@npm:0.29.8" +"@vitest/spy@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/spy@npm:0.34.3" dependencies: - cli-truncate: ^3.1.0 - diff: ^5.1.0 + tinyspy: ^2.1.1 + checksum: a2b64b9c357a56ad2f2340ecd225ffe787e61afba4ffb24a6670aad3fc90ea2606ed48daa188ed62b3ef67d55c0259fda6b101143d6c91b58c9ac4298d8be4f9 + languageName: node + linkType: hard + +"@vitest/utils@npm:0.34.3": + version: 0.34.3 + resolution: "@vitest/utils@npm:0.34.3" + dependencies: + diff-sequences: ^29.4.3 loupe: ^2.3.6 - pretty-format: ^27.5.1 - checksum: fa18cccb6ab5295e43a1a43b9c022f070646a893adb0561c50b3e0c39f05ea74cbf379aef22ef485ea9acbf2bb8f0a224d457fd4f16b9e1bf509c13052c7f08b + pretty-format: ^29.5.0 + checksum: aeb8ef7fd98b32cb6c403796880d0aa8f5411bbdb249bb23b3301a70e1b7d1ee025ddb204aae8c1db5756f6ac428c49ebbb8e2ed23ce185c8a659b67413efa85 languageName: node linkType: hard @@ -1361,7 +1642,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.1.0, acorn@npm:^8.5.0, acorn@npm:^8.8.0, acorn@npm:^8.8.1, acorn@npm:^8.8.2": +"acorn@npm:^8.1.0, acorn@npm:^8.5.0, acorn@npm:^8.8.0, acorn@npm:^8.8.2": version: 8.8.2 resolution: "acorn@npm:8.8.2" bin: @@ -1370,6 +1651,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.10.0, acorn@npm:^8.9.0": + version: 8.10.0 + resolution: "acorn@npm:8.10.0" + bin: + acorn: bin/acorn + checksum: 538ba38af0cc9e5ef983aee196c4b8b4d87c0c94532334fa7e065b2c8a1f85863467bb774231aae91613fcda5e68740c15d97b1967ae3394d20faddddd8af61d + languageName: node + linkType: hard + "agent-base@npm:6, agent-base@npm:^6.0.2": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -1419,13 +1709,6 @@ __metadata: languageName: node linkType: hard -"ansi-regex@npm:^6.0.1": - version: 6.0.1 - resolution: "ansi-regex@npm:6.0.1" - checksum: 1ff8b7667cded1de4fa2c9ae283e979fc87036864317da86a2e546725f96406746411d0d85e87a2d12fa5abd715d90006de7fa4fa0477c92321ad3b4c7d4e169 - languageName: node - linkType: hard - "ansi-styles@npm:^3.2.1": version: 3.2.1 resolution: "ansi-styles@npm:3.2.1" @@ -1451,13 +1734,6 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^6.0.0": - version: 6.2.1 - resolution: "ansi-styles@npm:6.2.1" - checksum: ef940f2f0ced1a6347398da88a91da7930c33ecac3c77b72c5905f8b8fe402c52e6fde304ff5347f616e27a742da3f1dc76de98f6866c69251ad0b07a66776d9 - languageName: node - linkType: hard - "anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" @@ -1620,6 +1896,20 @@ __metadata: languageName: node linkType: hard +"browserslist@npm:^4.21.9": + version: 4.21.10 + resolution: "browserslist@npm:4.21.10" + dependencies: + caniuse-lite: ^1.0.30001517 + electron-to-chromium: ^1.4.477 + node-releases: ^2.0.13 + update-browserslist-db: ^1.0.11 + bin: + browserslist: cli.js + checksum: 1e27c0f111a35d1dd0e8fc2c61781b0daefabc2c9471b0b10537ce54843014bceb2a1ce4571af1a82b2bf1e6e6e05d38865916689a158f03bc2c7a4ec2577db8 + languageName: node + linkType: hard + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -1634,28 +1924,6 @@ __metadata: languageName: node linkType: hard -"c8@npm:^7.13.0": - version: 7.13.0 - resolution: "c8@npm:7.13.0" - dependencies: - "@bcoe/v8-coverage": ^0.2.3 - "@istanbuljs/schema": ^0.1.3 - find-up: ^5.0.0 - foreground-child: ^2.0.0 - istanbul-lib-coverage: ^3.2.0 - istanbul-lib-report: ^3.0.0 - istanbul-reports: ^3.1.4 - rimraf: ^3.0.2 - test-exclude: ^6.0.0 - v8-to-istanbul: ^9.0.0 - yargs: ^16.2.0 - yargs-parser: ^20.2.9 - bin: - c8: bin/c8.js - checksum: 491abf4cf3097cdcfd24dbac49162f1383861c22c77fdd9280bcd38240e1e07d2c6a59da5d4df59a61a8204e2fc297d31fd526e495faf8d2f20dcc12a37b144c - languageName: node - linkType: hard - "cac@npm:^6.7.14": version: 6.7.14 resolution: "cac@npm:6.7.14" @@ -1725,6 +1993,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001517": + version: 1.0.30001525 + resolution: "caniuse-lite@npm:1.0.30001525" + checksum: a0d190c185b8e1220dbc72e42f310633059aa175ca3396eb781b249ac3da6c62b30cb8efc5fa24d632cb938f58d90b0c7772d1c9942b6643cf418c27c2cb8632 + languageName: node + linkType: hard + "chai@npm:^4.3.7": version: 4.3.7 resolution: "chai@npm:4.3.7" @@ -1740,7 +2015,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^2.0.0": +"chalk@npm:^2.0.0, chalk@npm:^2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2" dependencies: @@ -1801,27 +2076,6 @@ __metadata: languageName: node linkType: hard -"cli-truncate@npm:^3.1.0": - version: 3.1.0 - resolution: "cli-truncate@npm:3.1.0" - dependencies: - slice-ansi: ^5.0.0 - string-width: ^5.0.0 - checksum: c3243e41974445691c63f8b405df1d5a24049dc33d324fe448dc572e561a7b772ae982692900b1a5960901cc4fc7def25a629b9c69a4208ee89d12ab3332617a - languageName: node - linkType: hard - -"cliui@npm:^7.0.2": - version: 7.0.4 - resolution: "cliui@npm:7.0.4" - dependencies: - string-width: ^4.2.0 - strip-ansi: ^6.0.0 - wrap-ansi: ^7.0.0 - checksum: ce2e8f578a4813806788ac399b9e866297740eecd4ad1823c27fd344d78b22c5f8597d548adbcc46f0573e43e21e751f39446c5a5e804a12aace402b7a315d7f - languageName: node - linkType: hard - "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -1953,7 +2207,7 @@ __metadata: languageName: node linkType: hard -"convert-source-map@npm:^1.6.0, convert-source-map@npm:^1.7.0": +"convert-source-map@npm:^1.7.0": version: 1.9.0 resolution: "convert-source-map@npm:1.9.0" checksum: dc55a1f28ddd0e9485ef13565f8f756b342f9a46c4ae18b843fe3c30c675d058d6a4823eff86d472f187b176f0adf51ea7b69ea38be34be4a63cbbf91b0593c8 @@ -1973,7 +2227,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2": +"cross-spawn@npm:^7.0.2": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" dependencies: @@ -2270,10 +2524,10 @@ __metadata: languageName: node linkType: hard -"diff@npm:^5.1.0": - version: 5.1.0 - resolution: "diff@npm:5.1.0" - checksum: c7bf0df7c9bfbe1cf8a678fd1b2137c4fb11be117a67bc18a0e03ae75105e8533dbfb1cda6b46beb3586ef5aed22143ef9d70713977d5fb1f9114e21455fba90 +"diff-sequences@npm:^29.4.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: f4914158e1f2276343d98ff5b31fc004e7304f5470bf0f1adb2ac6955d85a531a6458d33e87667f98f6ae52ebd3891bb47d420bb48a5bd8b7a27ee25b20e33aa languageName: node linkType: hard @@ -2351,13 +2605,6 @@ __metadata: languageName: node linkType: hard -"eastasianwidth@npm:^0.2.0": - version: 0.2.0 - resolution: "eastasianwidth@npm:0.2.0" - checksum: 7d00d7cd8e49b9afa762a813faac332dee781932d6f2c848dc348939c4253f1d4564341b7af1d041853bc3f32c2ef141b58e0a4d9862c17a7f08f68df1e0f1ed - languageName: node - linkType: hard - "electron-to-chromium@npm:^1.4.284": version: 1.4.288 resolution: "electron-to-chromium@npm:1.4.288" @@ -2365,21 +2612,28 @@ __metadata: languageName: node linkType: hard -"embla-carousel-wheel-gestures@npm:^3.0.0": - version: 3.0.0 - resolution: "embla-carousel-wheel-gestures@npm:3.0.0" - dependencies: - wheel-gestures: ^2.2.5 - peerDependencies: - embla-carousel: ^7.0.0 - checksum: 109c1d10fa6f892fe4c627212ee5d24ae2d2d0c28210af16a9770901c2b65faf32eb1a5c95e87451aec9407ec8e3248e49789d6998b7079c83fd13b50532431d +"electron-to-chromium@npm:^1.4.477": + version: 1.4.508 + resolution: "electron-to-chromium@npm:1.4.508" + checksum: 4475eb18f5805d43f84d9542364045a39b183a14cd9f4626e0951ea61d0fa4f84a5ed579c2c32189f9af4a27a31041d09fed78f60930ac36b3baa08547dd3aa6 languageName: node linkType: hard -"embla-carousel@npm:^7.0.9": - version: 7.0.9 - resolution: "embla-carousel@npm:7.0.9" - checksum: dabb371a44c2d67f21aa7a0ff3f452a5c2678b79e80cb251e0a83b707c4b684fbdbe010927142cdcae3d1c9711a269e05655bd05c39608337fd1b6f5d2c20371 +"embla-carousel-wheel-gestures@npm:8.0.0-rc04": + version: 8.0.0-rc04 + resolution: "embla-carousel-wheel-gestures@npm:8.0.0-rc04" + dependencies: + wheel-gestures: ^2.2.5 + peerDependencies: + embla-carousel: 8.0.0-rc03 + checksum: ace8ab88b661e8d360c2d99ae61c0fe61085ccd71f059ac7c6b8dc1ebb52c17919a00025010502d86012273490940c25c6c1fdb143f932670ab36c09fb4c6bca + languageName: node + linkType: hard + +"embla-carousel@npm:8.0.0-rc12": + version: 8.0.0-rc12 + resolution: "embla-carousel@npm:8.0.0-rc12" + checksum: 553f33f562458d7fd0cd4eff1db96ac3a02352b620705d892eb3d9a297924fa12fedaf5a5aa03418dd41905ce80997a99f8aeca955d024d5a49a56f5c7fbe63b languageName: node linkType: hard @@ -2390,13 +2644,6 @@ __metadata: languageName: node linkType: hard -"emoji-regex@npm:^9.2.2": - version: 9.2.2 - resolution: "emoji-regex@npm:9.2.2" - checksum: 8487182da74aabd810ac6d6f1994111dfc0e331b01271ae01ec1eb0ad7b5ecc2bbbbd2f053c05cb55a1ac30449527d819bbfbf0e3de1023db308cbcb47f86601 - languageName: node - linkType: hard - "emojis-list@npm:^3.0.0": version: 3.0.0 resolution: "emojis-list@npm:3.0.0" @@ -3058,16 +3305,6 @@ __metadata: languageName: node linkType: hard -"foreground-child@npm:^2.0.0": - version: 2.0.0 - resolution: "foreground-child@npm:2.0.0" - dependencies: - cross-spawn: ^7.0.0 - signal-exit: ^3.0.2 - checksum: f77ec9aff621abd6b754cb59e690743e7639328301fbea6ff09df27d2befaf7dd5b77cec51c32323d73a81a7d91caaf9413990d305cbe3d873eec4fe58960956 - languageName: node - linkType: hard - "form-data@npm:^4.0.0": version: 4.0.0 resolution: "form-data@npm:4.0.0" @@ -3103,14 +3340,14 @@ __metadata: "@types/masonry-layout": ^4.2.5 "@typescript-eslint/eslint-plugin": ^5.36.2 "@typescript-eslint/parser": ^5.36.2 - "@vitest/coverage-c8": ^0.29.8 + "@vitest/coverage-istanbul": ^0.34.3 component-emitter: ^1.3.0 crypto: ^1.0.1 custom-card-helpers: ^1.9.0 date-fns: ^2.29.2 date-fns-tz: ^1.3.7 - embla-carousel: ^7.0.9 - embla-carousel-wheel-gestures: ^3.0.0 + embla-carousel: 8.0.0-rc12 + embla-carousel-wheel-gestures: 8.0.0-rc04 eslint: ^8.23.0 eslint-config-airbnb-base: ^15.0.0 eslint-config-prettier: ^8.5.0 @@ -3143,8 +3380,8 @@ __metadata: vis-data: ^7.1.4 vis-timeline: ^7.7.0 vis-util: ^5.0.2 - vitest: ^0.29.8 - vitest-mock-extended: ^1.1.3 + vitest: ^0.34.3 + vitest-mock-extended: ^1.2.1 web-dialog: ^0.0.11 xss: ^1.0.14 zod: ^3.21.4 @@ -3744,13 +3981,6 @@ __metadata: languageName: node linkType: hard -"is-fullwidth-code-point@npm:^4.0.0": - version: 4.0.0 - resolution: "is-fullwidth-code-point@npm:4.0.0" - checksum: 8ae89bf5057bdf4f57b346fb6c55e9c3dd2549983d54191d722d5c739397a903012cc41a04ee3403fd872e811243ef91a7c5196da7b5841dc6b6aae31a264a8d - languageName: node - linkType: hard - "is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1": version: 4.0.3 resolution: "is-glob@npm:4.0.3" @@ -3902,6 +4132,19 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-instrument@npm:^6.0.0": + version: 6.0.0 + resolution: "istanbul-lib-instrument@npm:6.0.0" + dependencies: + "@babel/core": ^7.12.3 + "@babel/parser": ^7.14.7 + "@istanbuljs/schema": ^0.1.2 + istanbul-lib-coverage: ^3.2.0 + semver: ^7.5.4 + checksum: b9dc3723a769e65dbe1b912f935088ffc07cf393fa78a3ce79022c91aabb0ad01405ffd56083cdd822e514798e9daae3ea7bfe85633b094ecb335d28eb0a3f97 + languageName: node + linkType: hard + "istanbul-lib-report@npm:^3.0.0": version: 3.0.0 resolution: "istanbul-lib-report@npm:3.0.0" @@ -3913,13 +4156,35 @@ __metadata: languageName: node linkType: hard -"istanbul-reports@npm:^3.1.4": - version: 3.1.5 - resolution: "istanbul-reports@npm:3.1.5" +"istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: ^3.0.0 + make-dir: ^4.0.0 + supports-color: ^7.1.0 + checksum: fd17a1b879e7faf9bb1dc8f80b2a16e9f5b7b8498fe6ed580a618c34df0bfe53d2abd35bf8a0a00e628fb7405462576427c7df20bbe4148d19c14b431c974b21 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^4.0.1": + version: 4.0.1 + resolution: "istanbul-lib-source-maps@npm:4.0.1" + dependencies: + debug: ^4.1.1 + istanbul-lib-coverage: ^3.0.0 + source-map: ^0.6.1 + checksum: 21ad3df45db4b81852b662b8d4161f6446cd250c1ddc70ef96a585e2e85c26ed7cd9c2a396a71533cfb981d1a645508bc9618cae431e55d01a0628e7dec62ef2 + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.5": + version: 3.1.6 + resolution: "istanbul-reports@npm:3.1.6" dependencies: html-escaper: ^2.0.0 istanbul-lib-report: ^3.0.0 - checksum: 7867228f83ed39477b188ea07e7ccb9b4f5320b6f73d1db93a0981b7414fa4ef72d3f80c4692c442f90fc250d9406e71d8d7ab65bb615cb334e6292b73192b89 + checksum: 44c4c0582f287f02341e9720997f9e82c071627e1e862895745d5f52ec72c9b9f38e1d12370015d2a71dcead794f34c7732aaef3fab80a24bc617a21c3d911d6 languageName: node linkType: hard @@ -4039,7 +4304,7 @@ __metadata: languageName: node linkType: hard -"json5@npm:^2.1.3, json5@npm:^2.2.2": +"json5@npm:^2.1.3, json5@npm:^2.2.2, json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" bin: @@ -4150,7 +4415,7 @@ __metadata: languageName: node linkType: hard -"local-pkg@npm:^0.4.2": +"local-pkg@npm:^0.4.3": version: 0.4.3 resolution: "local-pkg@npm:0.4.3" checksum: 7825aca531dd6afa3a3712a0208697aa4a5cd009065f32e3fb732aafcc42ed11f277b5ac67229222e96f4def55197171cdf3d5522d0381b489d2e5547b407d55 @@ -4253,6 +4518,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.1": + version: 0.30.3 + resolution: "magic-string@npm:0.30.3" + dependencies: + "@jridgewell/sourcemap-codec": ^1.4.15 + checksum: a5a9ddf9bd3bf49a2de1048bf358464f1bda7b3cc1311550f4a0ba8f81a4070e25445d53a5ee28850161336f1bff3cf28aa3320c6b4aeff45ce3e689f300b2f3 + languageName: node + linkType: hard + "make-dir@npm:^3.0.0, make-dir@npm:^3.0.2": version: 3.1.0 resolution: "make-dir@npm:3.1.0" @@ -4262,6 +4536,15 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: ^7.5.3 + checksum: bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a + languageName: node + linkType: hard + "make-fetch-happen@npm:^10.0.3": version: 10.2.1 resolution: "make-fetch-happen@npm:10.2.1" @@ -4472,15 +4755,15 @@ __metadata: languageName: node linkType: hard -"mlly@npm:^1.1.0, mlly@npm:^1.1.1": - version: 1.2.0 - resolution: "mlly@npm:1.2.0" +"mlly@npm:^1.2.0, mlly@npm:^1.4.0": + version: 1.4.1 + resolution: "mlly@npm:1.4.1" dependencies: - acorn: ^8.8.2 - pathe: ^1.1.0 - pkg-types: ^1.0.2 - ufo: ^1.1.1 - checksum: 640b019eb20e8e556bd623141b861d47e5c05f8af00210376ce1015912695dbd93a38cfe7ba18ca04f00e75645378f0f94a48a90bfa6e1b5dee1f0ec9c14eed1 + acorn: ^8.10.0 + pathe: ^1.1.1 + pkg-types: ^1.0.3 + ufo: ^1.3.0 + checksum: b2b59ab3d70196127be4e54609d2a442bd252345727138940fb245672a238b2fbdd431e8c75ec5c741ff90410ce488c5fd6446d5d3e6476d21dbf4c3fa35d4a0 languageName: node linkType: hard @@ -4555,6 +4838,13 @@ __metadata: languageName: node linkType: hard +"node-releases@npm:^2.0.13": + version: 2.0.13 + resolution: "node-releases@npm:2.0.13" + checksum: 17ec8f315dba62710cae71a8dad3cd0288ba943d2ece43504b3b1aa8625bf138637798ab470b1d9035b0545996f63000a8a926e0f6d35d0996424f8b6d36dda3 + languageName: node + linkType: hard + "node-releases@npm:^2.0.8": version: 2.0.10 resolution: "node-releases@npm:2.0.10" @@ -4897,6 +5187,13 @@ __metadata: languageName: node linkType: hard +"pathe@npm:^1.1.1": + version: 1.1.1 + resolution: "pathe@npm:1.1.1" + checksum: 34ab3da2e5aa832ebc6a330ffe3f73d7ba8aec6e899b53b8ec4f4018de08e40742802deb12cf5add9c73b7bf719b62c0778246bd376ca62b0fb23e0dde44b759 + languageName: node + linkType: hard + "pathval@npm:^1.1.1": version: 1.1.1 resolution: "pathval@npm:1.1.1" @@ -4927,14 +5224,14 @@ __metadata: languageName: node linkType: hard -"pkg-types@npm:^1.0.2": - version: 1.0.2 - resolution: "pkg-types@npm:1.0.2" +"pkg-types@npm:^1.0.3": + version: 1.0.3 + resolution: "pkg-types@npm:1.0.3" dependencies: jsonc-parser: ^3.2.0 - mlly: ^1.1.1 + mlly: ^1.2.0 pathe: ^1.1.0 - checksum: 2d0a70c1721c2ebbe075b912531a4f43136e6658fdcc59dc76c39966201ab5ddf265868d1211943183406d4b70d373c17e3b176487bc2020ea737d030b0fd080 + checksum: 4b305c834b912ddcc8a0fe77530c0b0321fe340396f84cbb87aecdbc126606f47f2178f23b8639e71a4870f9631c7217aef52ffed0ae17ea2dbbe7e43d116a6e languageName: node linkType: hard @@ -5347,14 +5644,14 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^27.5.1": - version: 27.5.1 - resolution: "pretty-format@npm:27.5.1" +"pretty-format@npm:^29.5.0": + version: 29.6.3 + resolution: "pretty-format@npm:29.6.3" dependencies: - ansi-regex: ^5.0.1 + "@jest/schemas": ^29.6.3 ansi-styles: ^5.0.0 - react-is: ^17.0.1 - checksum: cf610cffcb793885d16f184a62162f2dd0df31642d9a18edf4ca298e909a8fe80bdbf556d5c9573992c102ce8bf948691da91bf9739bee0ffb6e79c8a8a6e088 + react-is: ^18.0.0 + checksum: 4e1c0db48e65571c22e80ff92123925ff8b3a2a89b71c3a1683cfde711004d492de32fe60c6bc10eea8bf6c678e5cbe544ac6c56cb8096e1eb7caf856928b1c4 languageName: node linkType: hard @@ -5440,10 +5737,10 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^17.0.1": - version: 17.0.2 - resolution: "react-is@npm:17.0.2" - checksum: 9d6d111d8990dc98bc5402c1266a808b0459b5d54830bbea24c12d908b536df7883f268a7868cfaedde3dd9d4e0d574db456f84d2e6df9c4526f99bb4b5344d8 +"react-is@npm:^18.0.0": + version: 18.2.0 + resolution: "react-is@npm:18.2.0" + checksum: e72d0ba81b5922759e4aff17e0252bd29988f9642ed817f56b25a3e217e13eea8a7f2322af99a06edb779da12d5d636e9fda473d620df9a3da0df2a74141d53e languageName: node linkType: hard @@ -5759,6 +6056,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^6.3.1": + version: 6.3.1 + resolution: "semver@npm:6.3.1" + bin: + semver: bin/semver.js + checksum: ae47d06de28836adb9d3e25f22a92943477371292d9b665fb023fae278d345d508ca1958232af086d85e0155aee22e313e100971898bbb8d5d89b8b1d4054ca2 + languageName: node + linkType: hard + "semver@npm:^7.3.5, semver@npm:^7.3.7": version: 7.3.8 resolution: "semver@npm:7.3.8" @@ -5770,6 +6076,17 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.5.3, semver@npm:^7.5.4": + version: 7.5.4 + resolution: "semver@npm:7.5.4" + dependencies: + lru-cache: ^6.0.0 + bin: + semver: bin/semver.js + checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3 + languageName: node + linkType: hard + "serialize-javascript@npm:^4.0.0": version: 4.0.0 resolution: "serialize-javascript@npm:4.0.0" @@ -5827,7 +6144,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7": +"signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -5841,16 +6158,6 @@ __metadata: languageName: node linkType: hard -"slice-ansi@npm:^5.0.0": - version: 5.0.0 - resolution: "slice-ansi@npm:5.0.0" - dependencies: - ansi-styles: ^6.0.0 - is-fullwidth-code-point: ^4.0.0 - checksum: 7e600a2a55e333a21ef5214b987c8358fe28bfb03c2867ff2cbf919d62143d1812ac27b4297a077fdaf27a03da3678e49551c93e35f9498a3d90221908a1180e - languageName: node - linkType: hard - "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -5947,10 +6254,10 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.3.1": - version: 3.3.2 - resolution: "std-env@npm:3.3.2" - checksum: c02256bb041ba1870d23f8360bc7e47a9cf1fabcd02c8b7c4246d48f2c6bb47b4f45c70964348844e6d36521df84c4a9d09d468654b51e0eb5c600e3392b4570 +"std-env@npm:^3.3.3": + version: 3.4.3 + resolution: "std-env@npm:3.4.3" + checksum: bef186fb2baddda31911234b1e58fa18f181eb6930616aaec3b54f6d5db65f2da5daaa5f3b326b98445a7d50ca81d6fe8809ab4ebab85ecbe4a802f1b40921bf languageName: node linkType: hard @@ -5972,17 +6279,6 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^5.0.0": - version: 5.1.2 - resolution: "string-width@npm:5.1.2" - dependencies: - eastasianwidth: ^0.2.0 - emoji-regex: ^9.2.2 - strip-ansi: ^7.0.1 - checksum: 7369deaa29f21dda9a438686154b62c2c5f661f8dda60449088f9f980196f7908fc39fdd1803e3e01541970287cf5deae336798337e9319a7055af89dafa7193 - languageName: node - linkType: hard - "string.prototype.trimend@npm:^1.0.6": version: 1.0.6 resolution: "string.prototype.trimend@npm:1.0.6" @@ -6023,15 +6319,6 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^7.0.1": - version: 7.0.1 - resolution: "strip-ansi@npm:7.0.1" - dependencies: - ansi-regex: ^6.0.1 - checksum: 257f78fa433520e7f9897722731d78599cb3fce29ff26a20a5e12ba4957463b50a01136f37c43707f4951817a75e90820174853d6ccc240997adc5df8f966039 - languageName: node - linkType: hard - "strip-bom@npm:^3.0.0": version: 3.0.0 resolution: "strip-bom@npm:3.0.0" @@ -6046,12 +6333,12 @@ __metadata: languageName: node linkType: hard -"strip-literal@npm:^1.0.0": - version: 1.0.1 - resolution: "strip-literal@npm:1.0.1" +"strip-literal@npm:^1.0.1": + version: 1.3.0 + resolution: "strip-literal@npm:1.3.0" dependencies: - acorn: ^8.8.2 - checksum: ab40496820f02220390d95cdd620a997168efb69d5bd7d180bc4ef83ca562a95447843d8c7c88b8284879a29cf4eedc89d8001d1e098c1a1e23d12a9c755dff4 + acorn: ^8.10.0 + checksum: f5fa7e289df8ebe82e90091fd393974faf8871be087ca50114327506519323cf15f2f8fee6ebe68b5e58bfc795269cae8bdc7cb5a83e27b02b3fe953f37b0a89 languageName: node linkType: hard @@ -6169,24 +6456,24 @@ __metadata: languageName: node linkType: hard -"tinybench@npm:^2.3.1": - version: 2.4.0 - resolution: "tinybench@npm:2.4.0" - checksum: cfbe90f75755488653dde256019cc810f65e90f63fdd962e71e8b209b49598c5fc90c2227d2087eb807944895fafe7f12fe9ecae2b5e89db5adde66415e9b836 +"tinybench@npm:^2.5.0": + version: 2.5.0 + resolution: "tinybench@npm:2.5.0" + checksum: 284bb9428f197ec8b869c543181315e65e41ccfdad3c4b6c916bb1fdae1b5c6785661b0d90cf135b48d833b03cb84dc5357b2d33ec65a1f5971fae0ab2023821 languageName: node linkType: hard -"tinypool@npm:^0.4.0": - version: 0.4.0 - resolution: "tinypool@npm:0.4.0" - checksum: 8abcac9e784793499f1eeeace8290c026454b9d7338c74029ce6a821643bab8dcab7caeb4051e39006baf681d6a62d57c3319e9c0f6e2317a45ab0fdbd76ee26 +"tinypool@npm:^0.7.0": + version: 0.7.0 + resolution: "tinypool@npm:0.7.0" + checksum: fdcccd5c750574fce51f8801a877f8284e145d12b79cd5f2d72bfbddfe20c895e915555bc848e122bb6aa968098e7ac4fe1e8e88104904d518dc01cccd18a510 languageName: node linkType: hard -"tinyspy@npm:^1.0.2": - version: 1.1.1 - resolution: "tinyspy@npm:1.1.1" - checksum: 4ea908fdfddb92044c4454193ec543f5980ced0bd25c5b3d240a94c1511e47e765ad39cd13ae6d3370fb730f62038eedc357f55e4e239416e126bc418f0eee79 +"tinyspy@npm:^2.1.1": + version: 2.1.1 + resolution: "tinyspy@npm:2.1.1" + checksum: cfe669803a7f11ca912742b84c18dcc4ceecaa7661c69bc5eb608a8a802d541c48aba220df8929f6c8cd09892ad37cb5ba5958ddbbb57940e91d04681d3cee73 languageName: node linkType: hard @@ -6234,12 +6521,15 @@ __metadata: languageName: node linkType: hard -"ts-essentials@npm:^9.3.1": - version: 9.3.1 - resolution: "ts-essentials@npm:9.3.1" +"ts-essentials@npm:^9.3.2": + version: 9.4.0 + resolution: "ts-essentials@npm:9.4.0" peerDependencies: typescript: ">=4.1.0" - checksum: 5accaec3eeea93cf19ad9e1959e763ba8a8253c977cd3f88cd7d8a2c04e697e77cc65fd742d9e88482b564a78b3c2dc1269119505bf7ad06320175b490dd49de + peerDependenciesMeta: + typescript: + optional: true + checksum: 50719782f9bfa31678fd1607de783a92b322ed8c26e6229bd0d1786c0649b9f1a7b9a6ac5bc60435053c4952bd3ef2356b5c930cb3a8d3519dc8f41c5576328f languageName: node linkType: hard @@ -6369,10 +6659,10 @@ __metadata: languageName: node linkType: hard -"ufo@npm:^1.1.1": - version: 1.1.1 - resolution: "ufo@npm:1.1.1" - checksum: 6bd210ed93d8c0dedd76c456b1d1dfb0e3b08c2216ee6080e61f0f545de0bac24b3d3a5530cd6a403810855f8d8fc3922583965296142e04cfc287442635e6c7 +"ufo@npm:^1.3.0": + version: 1.3.0 + resolution: "ufo@npm:1.3.0" + checksum: 01f0be86cd5c205ad1b49ebea985e000a4542c503ee75398302b0f5e4b9a6d9cd8e77af2dc614ab7bea08805fdfd9a85191fb3b5ee3df383cb936cf65e9db30d languageName: node linkType: hard @@ -6434,6 +6724,20 @@ __metadata: languageName: node linkType: hard +"update-browserslist-db@npm:^1.0.11": + version: 1.0.11 + resolution: "update-browserslist-db@npm:1.0.11" + dependencies: + escalade: ^3.1.1 + picocolors: ^1.0.0 + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: b98327518f9a345c7cad5437afae4d2ae7d865f9779554baf2a200fdf4bac4969076b679b1115434bd6557376bdd37ca7583d0f9b8f8e302d7d4cc1e91b5f231 + languageName: node + linkType: hard + "uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" @@ -6469,17 +6773,6 @@ __metadata: languageName: node linkType: hard -"v8-to-istanbul@npm:^9.0.0": - version: 9.1.0 - resolution: "v8-to-istanbul@npm:9.1.0" - dependencies: - "@jridgewell/trace-mapping": ^0.3.12 - "@types/istanbul-lib-coverage": ^2.0.1 - convert-source-map: ^1.6.0 - checksum: 2069d59ee46cf8d83b4adfd8a5c1a90834caffa9f675e4360f1157ffc8578ef0f763c8f32d128334424159bb6b01f3876acd39cd13297b2769405a9da241f8d1 - languageName: node - linkType: hard - "vis-data@npm:^7.1.4": version: 7.1.4 resolution: "vis-data@npm:7.1.4" @@ -6517,19 +6810,19 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:0.29.8": - version: 0.29.8 - resolution: "vite-node@npm:0.29.8" +"vite-node@npm:0.34.3": + version: 0.34.3 + resolution: "vite-node@npm:0.34.3" dependencies: cac: ^6.7.14 debug: ^4.3.4 - mlly: ^1.1.0 - pathe: ^1.1.0 + mlly: ^1.4.0 + pathe: ^1.1.1 picocolors: ^1.0.0 vite: ^3.0.0 || ^4.0.0 bin: vite-node: vite-node.mjs - checksum: b0981d4d63b1f373579eb9da69ca5af9123bf27c81ac246c541cdecf879ef4ef542e0b521cb6ceaafd5ead2cc3d243105d1fb8bf076953d42a6b2203607ce928 + checksum: 366c4f3fb7c038e2180abc6b18cfbac3b8684cd878eaf7ebf1ffb07d95d2ea325713fc575a7949a13bb00cfe264acbc28c02e2836b8647e1f443fe631c17805a languageName: node linkType: hard @@ -6571,45 +6864,45 @@ __metadata: languageName: node linkType: hard -"vitest-mock-extended@npm:^1.1.3": - version: 1.1.3 - resolution: "vitest-mock-extended@npm:1.1.3" +"vitest-mock-extended@npm:^1.2.1": + version: 1.2.1 + resolution: "vitest-mock-extended@npm:1.2.1" dependencies: - ts-essentials: ^9.3.1 + ts-essentials: ^9.3.2 peerDependencies: typescript: 3.x || 4.x || 5.x - vitest: ">=0.29.2" - checksum: d457dc1dcbf6b0570e9845dff7423c2b88504ce6bb189e71c0984b09b4d6c5894d883706a7639262c92e12b0b4b0d5db747e1d0b93e253b4d6c189cba15c70f9 + vitest: ">=0.31.1" + checksum: d3dc3940513cc053166bc912b0b9fdda0b073c5a5d2c2deba4f87ddae7a3b8c53a31227030a9f0eeb9f48c443b6978c451946ef0d36eea6976ef77e5bb4d6c04 languageName: node linkType: hard -"vitest@npm:^0.29.8": - version: 0.29.8 - resolution: "vitest@npm:0.29.8" +"vitest@npm:^0.34.3": + version: 0.34.3 + resolution: "vitest@npm:0.34.3" dependencies: - "@types/chai": ^4.3.4 + "@types/chai": ^4.3.5 "@types/chai-subset": ^1.3.3 "@types/node": "*" - "@vitest/expect": 0.29.8 - "@vitest/runner": 0.29.8 - "@vitest/spy": 0.29.8 - "@vitest/utils": 0.29.8 - acorn: ^8.8.1 + "@vitest/expect": 0.34.3 + "@vitest/runner": 0.34.3 + "@vitest/snapshot": 0.34.3 + "@vitest/spy": 0.34.3 + "@vitest/utils": 0.34.3 + acorn: ^8.9.0 acorn-walk: ^8.2.0 cac: ^6.7.14 chai: ^4.3.7 debug: ^4.3.4 - local-pkg: ^0.4.2 - pathe: ^1.1.0 + local-pkg: ^0.4.3 + magic-string: ^0.30.1 + pathe: ^1.1.1 picocolors: ^1.0.0 - source-map: ^0.6.1 - std-env: ^3.3.1 - strip-literal: ^1.0.0 - tinybench: ^2.3.1 - tinypool: ^0.4.0 - tinyspy: ^1.0.2 + std-env: ^3.3.3 + strip-literal: ^1.0.1 + tinybench: ^2.5.0 + tinypool: ^0.7.0 vite: ^3.0.0 || ^4.0.0 - vite-node: 0.29.8 + vite-node: 0.34.3 why-is-node-running: ^2.2.2 peerDependencies: "@edge-runtime/vm": "*" @@ -6639,7 +6932,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 203e33bf093fdb99a6832c905a6c78175bb15313e06e1dcfbeb010a0e3efb8ff0aba4d317efedb4de76bd0086691bbd2c4bc7d6631f60fb1634b96832cba144f + checksum: 4535d080feede94db5015eb60c6ed5f7b0d8cd67f12072de5ae1faded133cc640043c0c2646ef51ab9b61c2f885589da57458a65e82cf91a25cf954470018a40 languageName: node linkType: hard @@ -6854,13 +7147,6 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.9": - version: 20.2.9 - resolution: "yargs-parser@npm:20.2.9" - checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3 - languageName: node - linkType: hard - "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -6868,21 +7154,6 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^16.2.0": - version: 16.2.0 - resolution: "yargs@npm:16.2.0" - dependencies: - cliui: ^7.0.2 - escalade: ^3.1.1 - get-caller-file: ^2.0.5 - require-directory: ^2.1.1 - string-width: ^4.2.0 - y18n: ^5.0.5 - yargs-parser: ^20.2.2 - checksum: b14afbb51e3251a204d81937c86a7e9d4bdbf9a2bcee38226c900d00f522969ab675703bee2a6f99f8e20103f608382936034e64d921b74df82b63c07c5e8f59 - languageName: node - linkType: hard - "yargs@npm:^17.5.1": version: 17.6.2 resolution: "yargs@npm:17.6.2" From cd84e490a8c9872691c4237de0b4b3bca52abdd2 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 4 Sep 2023 18:40:13 -0700 Subject: [PATCH 24/33] Watch grid DOM for id changes. --- src/components/media-grid.ts | 2 + tests/utils/media-grid-controller.test.ts | 58 +++++++++++++++++++---- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index 913e1aa7..70a7bc69 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -1,3 +1,5 @@ +// TODO: Live non-grid -> live grid -> timeline is different from when the timeline is dragged. + import { CSSResultGroup, html, diff --git a/tests/utils/media-grid-controller.test.ts b/tests/utils/media-grid-controller.test.ts index 4b19b6f9..15da4471 100644 --- a/tests/utils/media-grid-controller.test.ts +++ b/tests/utils/media-grid-controller.test.ts @@ -1,5 +1,5 @@ import Masonry from 'masonry-layout'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { MediaLoadedInfo } from '../../src/types'; import { @@ -12,6 +12,7 @@ import { ResizeObserverMock, createSlot, createSlotHost, + requestAnimationFrameMock, } from '../test-utils'; vi.mock('lodash-es/throttle', () => ({ @@ -60,14 +61,16 @@ const createController = (host: HTMLElement, options?: MediaGridConstructorOptio return new MediaGridController(host, options); }; -const triggerMutationObserver = (): void => { - const mutationObserverTrigger = vi.mocked(global.MutationObserver).mock.calls[0][0]; +const triggerMutationObserver = (hostOrCell: 'cell' | 'host'): void => { + const mutationObserverTrigger = vi.mocked(global.MutationObserver).mock.calls[ + hostOrCell === 'host' ? 0 : 1 + ][0]; mutationObserverTrigger([], mock()); }; -const triggerResizeObserver = (cellOrHost: 'cell' | 'host'): void => { +const triggerResizeObserver = (hostOrCell: 'cell' | 'host'): void => { const resizeObserverTrigger = vi.mocked(global.ResizeObserver).mock.calls[ - cellOrHost === 'cell' ? 0 : 1 + hostOrCell === 'host' ? 0 : 1 ][0]; resizeObserverTrigger([], mock()); }; @@ -79,6 +82,10 @@ describe('MediaGridController', () => { height: 20, }; + beforeAll(() => { + window.requestAnimationFrame = requestAnimationFrameMock; + }); + beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal('MutationObserver', MutationObserverMock); @@ -311,7 +318,7 @@ describe('MediaGridController', () => { const newChildren = createChildren(['one', 'two', 'three']); newChildren.forEach((child) => parent.appendChild(child)); - triggerMutationObserver(); + triggerMutationObserver('host'); expect(controller.getGridContents()).toEqual( new Map([ @@ -323,6 +330,33 @@ describe('MediaGridController', () => { expect(controller.getSelected()).toBeNull(); }); + it('should re-calculate children when id attribute changes', () => { + const children = createChildren(['one', 'two', 'three'], 'test-id'); + const parent = createParent({ children: children }); + const controller = createController(parent, { + selected: 'one', + idAttribute: 'test-id', + }); + + expect(controller.getSelected()).toBe('one'); + expect(controller.getGridSize()).toBe(3); + + children[0].setAttribute('test-id', 'alpha'); + children[1].setAttribute('test-id', 'beta'); + children[2].setAttribute('test-id', 'gamma'); + + triggerMutationObserver('cell'); + + expect(controller.getGridContents()).toEqual( + new Map([ + ['alpha', children[0]], + ['beta', children[1]], + ['gamma', children[2]], + ]), + ); + expect(controller.getSelected()).toBeNull(); + }); + it('should replace children of a slot when they change', () => { const children = createChildren(); const slot = createSlot(); @@ -372,7 +406,9 @@ describe('MediaGridController', () => { columnWidth: 246, }), ); - expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); + expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe( + '246px', + ); }); it('should respect exact columns', () => { @@ -459,7 +495,9 @@ describe('MediaGridController', () => { columnWidth: 246, }), ); - expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px'); + expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe( + '246px', + ); // Clear mock state. vi.mocked(Masonry).mockClear(); @@ -476,7 +514,9 @@ describe('MediaGridController', () => { columnWidth: 667, }), ); - expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px'); + expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe( + '667px', + ); expect(masonry.layout).toBeCalled(); // Clear mock state. From 1554cb41e8a55f2e270d5e2e895b29d35d04c5c3 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 4 Sep 2023 18:41:46 -0700 Subject: [PATCH 25/33] Add missing files changes on prior commit. --- src/utils/media-grid-controller.ts | 49 ++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/src/utils/media-grid-controller.ts b/src/utils/media-grid-controller.ts index f39f013e..7fd38146 100644 --- a/src/utils/media-grid-controller.ts +++ b/src/utils/media-grid-controller.ts @@ -2,7 +2,11 @@ import isEqual from 'lodash-es/isEqual'; import throttle from 'lodash-es/throttle'; import Masonry from 'masonry-layout'; import { MediaLoadedInfo, ViewDisplayConfig } from '../types'; -import { dispatchFrigateCardEvent, getChildrenFromElement, setOrRemoveAttribute } from './basic'; +import { + dispatchFrigateCardEvent, + getChildrenFromElement, + setOrRemoveAttribute, +} from './basic'; import { FrigateMediaLoadedEventTarget, dispatchExistingMediaLoadedInfoAsEvent, @@ -43,20 +47,29 @@ export class MediaGridController { protected _idAttribute: string; protected _throttledLayout = throttle( - () => this._masonry?.layout?.(), + () => { + window.requestAnimationFrame(() => this._masonry?.layout?.()); + }, // Throttle layout calls to larger than the masonry.js transitionDuration // value specified below. - 300, + 400, { trailing: true, leading: false }, ); - protected _mutationObserver = new MutationObserver( + // If the order in which the observers are declared changes, the unittest must + // be updated in triggerResizeObserver and triggerMutationObserver. + protected _hostMutationObserver = new MutationObserver( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_mutations: MutationRecord[], _observer: MutationObserver) => + this._calculateGridContentsFromHost(), + ); + protected _cellMutationObserver = new MutationObserver( // eslint-disable-next-line @typescript-eslint/no-unused-vars (_mutations: MutationRecord[], _observer: MutationObserver) => this._calculateGridContentsFromHost(), ); - protected _cellResizeObserver = new ResizeObserver(this._cellResizeHandler.bind(this)); protected _hostResizeObserver = new ResizeObserver(this._hostResizeHandler.bind(this)); + protected _cellResizeObserver = new ResizeObserver(this._cellResizeHandler.bind(this)); constructor(host: HTMLElement, options?: MediaGridConstructorOptions) { this._host = host; @@ -66,7 +79,10 @@ export class MediaGridController { this._hostResizeObserver.observe(host); this._displayConfig = options?.displayConfig ?? null; - this._mutationObserver.observe(host, { childList: true }); + this._hostMutationObserver.observe(host, { + childList: true, + }); + // Need to separately listen for slotchanges since mutation observer will // not be called for shadom DOM slotted changes. if (host instanceof HTMLSlotElement) { @@ -79,7 +95,9 @@ export class MediaGridController { this._hostResizeObserver.disconnect(); this._cellResizeObserver.disconnect(); - this._mutationObserver.disconnect(); + this._hostMutationObserver.disconnect(); + this._cellMutationObserver.disconnect(); + if (this._host instanceof HTMLSlotElement) { this._host.removeEventListener('slotchange', this._calculateGridContentsFromHost); } @@ -154,13 +172,13 @@ export class MediaGridController { this._setGridContents(gridContents); }; - protected _setGridContents(elements: MediaGridContents): void { - this._gridContents = elements; + protected _setGridContents(gridContents: MediaGridContents): void { + this._gridContents = gridContents; // Remove media loaded info objects that belong to objects no longer in the // grid. for (const key of this._mediaLoadedInfoMap.keys()) { - if (!elements.has(key)) { + if (!gridContents.has(key)) { this._mediaLoadedInfoMap.delete(key); } } @@ -169,7 +187,7 @@ export class MediaGridController { this.unselectAll(); } - for (const element of elements.values()) { + for (const element of gridContents.values()) { this._removeChildEventListeners(element); this._addChildEventListeners(element); } @@ -177,9 +195,14 @@ export class MediaGridController { this._setColumnSizeStyles(); this._createMasonry(); - // Observe grid elements for size changes. + // Observe grid elements for size or id changes. + this._cellMutationObserver.disconnect(); this._cellResizeObserver.disconnect(); - for (const child of elements.values()) { + for (const child of gridContents.values()) { + this._cellMutationObserver.observe(child, { + attributeFilter: [this._idAttribute], + attributes: true, + }); this._cellResizeObserver.observe(child); } From d42545778175713e5ab16fabf12467caf3589be3 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 4 Sep 2023 19:31:54 -0700 Subject: [PATCH 26/33] Resize the container based on media loads. --- src/utils/embla/plugins/auto-size/auto-size.ts | 18 ++++++++++++++++-- src/utils/embla/reinit-controller.ts | 3 --- src/utils/media-grid-controller.ts | 6 ++---- .../embla/plugins/auto-size/auto-size.test.ts | 1 - tests/utils/media-grid-controller.test.ts | 7 +------ 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/utils/embla/plugins/auto-size/auto-size.ts b/src/utils/embla/plugins/auto-size/auto-size.ts index bfadef33..ee226555 100644 --- a/src/utils/embla/plugins/auto-size/auto-size.ts +++ b/src/utils/embla/plugins/auto-size/auto-size.ts @@ -1,6 +1,7 @@ import { EmblaCarouselType } from 'embla-carousel'; import { LooseOptionsType } from 'embla-carousel/components/Options'; import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; +import debounce from 'lodash-es/debounce'; import { EmblaReInitController } from '../../reinit-controller'; declare module 'embla-carousel/components/Plugins' { @@ -37,6 +38,10 @@ function AutoSize(): AutoSizeType { intersectionHandler, ); + const debouncedSetContainerHeight = debounce(() => setContainerHeight(), 200, { + trailing: true, + }); + function init(emblaApiInstance: EmblaCarouselType): void { emblaApi = emblaApiInstance; reInitController = new EmblaReInitController(emblaApi); @@ -47,7 +52,13 @@ function AutoSize(): AutoSizeType { resizeObserver.observe(slide); } - emblaApi.on('settle', setContainerHeight); + // Need to examine container size on both settle and media load, as settle + // may happen before the media is loaded (which they subsequently changes + // the size to large than the maxHeight is set). + emblaApi + .containerNode() + .addEventListener('frigate-card:media:loaded', debouncedSetContainerHeight); + emblaApi.on('settle', debouncedSetContainerHeight); } function destroy(): void { @@ -55,7 +66,10 @@ function AutoSize(): AutoSizeType { resizeObserver.disconnect(); reInitController?.destroy(); - emblaApi.off('settle', setContainerHeight); + emblaApi + .containerNode() + .removeEventListener('frigate-card:media:loaded', debouncedSetContainerHeight); + emblaApi.off('settle', debouncedSetContainerHeight); } function intersectionHandler(entries: IntersectionObserverEntry[]): void { diff --git a/src/utils/embla/reinit-controller.ts b/src/utils/embla/reinit-controller.ts index 572e0f9c..ff3f2287 100644 --- a/src/utils/embla/reinit-controller.ts +++ b/src/utils/embla/reinit-controller.ts @@ -50,9 +50,6 @@ export class EmblaReInitController { protected _debouncedReInit = debounce( () => { - // Allow the browser a moment to paint components that are inflight, to - // ensure accurate measurements are taken during the carousel - // reinitialization. this._scrolling = false; this._shouldReInitOnScrollStop = false; this._emblaApi?.reInit(); diff --git a/src/utils/media-grid-controller.ts b/src/utils/media-grid-controller.ts index 7fd38146..4ce713fa 100644 --- a/src/utils/media-grid-controller.ts +++ b/src/utils/media-grid-controller.ts @@ -47,12 +47,10 @@ export class MediaGridController { protected _idAttribute: string; protected _throttledLayout = throttle( - () => { - window.requestAnimationFrame(() => this._masonry?.layout?.()); - }, + () => this._masonry?.layout?.(), // Throttle layout calls to larger than the masonry.js transitionDuration // value specified below. - 400, + 300, { trailing: true, leading: false }, ); 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 d8e06169..412c0fa5 100644 --- a/tests/utils/embla/plugins/auto-size/auto-size.test.ts +++ b/tests/utils/embla/plugins/auto-size/auto-size.test.ts @@ -15,7 +15,6 @@ import { createTestSlideNodes, } from '../../test-utils'; -// Mock out debouncing (used in the reinit controller). vi.mock('lodash-es/debounce', () => ({ default: vi.fn((fn) => fn), })); diff --git a/tests/utils/media-grid-controller.test.ts b/tests/utils/media-grid-controller.test.ts index 15da4471..4f5b0092 100644 --- a/tests/utils/media-grid-controller.test.ts +++ b/tests/utils/media-grid-controller.test.ts @@ -1,5 +1,5 @@ import Masonry from 'masonry-layout'; -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { MediaLoadedInfo } from '../../src/types'; import { @@ -12,7 +12,6 @@ import { ResizeObserverMock, createSlot, createSlotHost, - requestAnimationFrameMock, } from '../test-utils'; vi.mock('lodash-es/throttle', () => ({ @@ -82,10 +81,6 @@ describe('MediaGridController', () => { height: 20, }; - beforeAll(() => { - window.requestAnimationFrame = requestAnimationFrameMock; - }); - beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal('MutationObserver', MutationObserverMock); From 07bc6391ec524244fadc3abbfb468879785c176b Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 4 Sep 2023 19:51:15 -0700 Subject: [PATCH 27/33] Recalculate timeline cameras when display mode changes. --- src/components/media-grid.ts | 2 -- src/components/surround.ts | 10 ++++++---- src/components/timeline-core.ts | 2 -- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/components/media-grid.ts b/src/components/media-grid.ts index 70a7bc69..913e1aa7 100644 --- a/src/components/media-grid.ts +++ b/src/components/media-grid.ts @@ -1,5 +1,3 @@ -// TODO: Live non-grid -> live grid -> timeline is different from when the timeline is dragged. - import { CSSResultGroup, html, diff --git a/src/components/surround.ts b/src/components/surround.ts index ef4a3995..cdd95ce1 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -113,12 +113,14 @@ export class FrigateCardSurround extends LitElement { import('./timeline.js'); } - // Only reset the timeline cameraIDs when the media materially changes (and - // not on every view change, since the view will change frequently when the - // user is scrubbing video). + // Only reset the timeline cameraIDs when the media or display mode + // materially changes (and not on every view change, since the view will + // change frequently when the user is scrubbing video). + const oldView = changedProperties.get('view'); if ( changedProperties.has('view') && - View.isMajorMediaChange(changedProperties.get('view'), this.view) + (View.isMajorMediaChange(oldView, this.view) || + oldView.displayMode !== this.view?.displayMode) ) { this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined; } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 126ea111..25dcc462 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -1221,8 +1221,6 @@ export class FrigateCardTimelineCore extends LitElement { createdTimeline = true; const noGroups = this.mini && groups.length === 1; if (noGroups) { - // In a mini timeline, if there's only one group don't bother grouping - // at all. this._timeline = new Timeline( this._refTimeline.value, this._timelineSource.dataset, From 25150be36b843f5568e8b750ae8023b2dedaf6de Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 4 Sep 2023 20:31:20 -0700 Subject: [PATCH 28/33] Set the container height on every resize. --- .../embla/plugins/auto-size/auto-size.ts | 10 ++++++---- .../embla/plugins/auto-size/auto-size.test.ts | 20 ++++++++++++++++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/utils/embla/plugins/auto-size/auto-size.ts b/src/utils/embla/plugins/auto-size/auto-size.ts index ee226555..96c41dd9 100644 --- a/src/utils/embla/plugins/auto-size/auto-size.ts +++ b/src/utils/embla/plugins/auto-size/auto-size.ts @@ -97,7 +97,7 @@ function AutoSize(): AutoSizeType { } function resizeHandler(entries: ResizeObserverEntry[]): void { - let callReInit = false; + let resize = false; for (const entry of entries) { const newDimensions: SlideDimensions = { @@ -113,12 +113,12 @@ function AutoSize(): AutoSizeType { oldDimensions?.width !== newDimensions.width) ) { previousDimensions.set(entry.target, newDimensions); - callReInit = true; + resize = true; } } - if (callReInit) { - reInitController?.reinit(); + if (resize) { + debouncedSetContainerHeight(); } } @@ -143,6 +143,8 @@ function AutoSize(): AutoSizeType { if (!isNaN(highest) && highest > 0) { emblaApi.containerNode().style.maxHeight = `${highest}px`; } + + reInitController?.reinit(); } const self: AutoSizeType = { 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 412c0fa5..b04784c3 100644 --- a/tests/utils/embla/plugins/auto-size/auto-size.test.ts +++ b/tests/utils/embla/plugins/auto-size/auto-size.test.ts @@ -69,18 +69,36 @@ describe('AutoSize', () => { it('should correctly handle resize', () => { const plugin = AutoSize(); const parent = createParent(); - const emblaApi = createEmblaApiInstance({ containerNode: parent }); + const children = createTestSlideNodes(); + const emblaApi = createEmblaApiInstance({ + containerNode: parent, + selectedScrollSnap: 0, + slideNodes: children, + slideRegistry: [[0]], + }); plugin.init(emblaApi, createTestEmblaOptionHandler()); + children[0].getBoundingClientRect = vi.fn().mockReturnValue({ + width: 200, + height: 800, + }); + callResizeHandler([{ target: parent, width: 10, height: 20 }]); callResizeHandler([{ target: parent, width: 10, height: 20 }]); callResizeHandler([{ target: parent, width: 10, height: 20 }]); + expect(parent.style.maxHeight).toBe('800px'); expect(emblaApi.reInit).toBeCalledTimes(1); + children[0].getBoundingClientRect = vi.fn().mockReturnValue({ + width: 200, + height: 600, + }); + callResizeHandler([{ target: parent, width: 20, height: 40 }]); + expect(parent.style.maxHeight).toBe('600px'); expect(emblaApi.reInit).toBeCalledTimes(2); }); From 1a4d38e609ad134404e8f08f1463f04776710bff Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 5 Sep 2023 20:39:54 -0700 Subject: [PATCH 29/33] Fix init/select out of sync issues. --- src/components/carousel.ts | 4 +--- src/components/live/live.ts | 4 +++- src/components/viewer.ts | 31 +++++++++++++++----------- src/utils/embla/carousel-controller.ts | 10 ++++----- 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 1b83b78c..b5a2dc68 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -102,9 +102,7 @@ export class FrigateCardCarousel extends LitElement { plugins: this.plugins, }, ); - } - - if (changedProps.has('selected')) { + } else if (changedProps.has('selected')) { this._carousel?.selectSlide(this.selected); } } diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 25bfd7a0..f68bdbdc 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -434,7 +434,9 @@ export class FrigateCardLiveCarousel extends LitElement { protected _getSelectedCameraIndex(): number { const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); - if (!cameraIDs || !this.view) { + if (!cameraIDs || !this.view || this.viewFilterCameraID) { + // If the carousel is limited to a single cameraID, the first (only) + // element is always the selected one. return 0; } return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera)); diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 1b01f5bd..a676a3ae 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -6,7 +6,7 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; @@ -223,8 +223,8 @@ export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) public cameraManager?: CameraManager; - @property({ attribute: false }) - public selected = 0; + @state() + protected _selected = 0; protected _media: ViewMedia[] | null = null; protected _titleTimer = new Timer(); @@ -298,12 +298,12 @@ export class FrigateCardViewerCarousel extends LitElement { */ protected _getMediaNeighbors(): MediaNeighbors | null { const mediaCount = this._media?.length ?? 0; - if (!this._media || this.selected === null) { + if (!this._media) { return null; } - const prevIndex = this.selected > 0 ? this.selected - 1 : null; - const nextIndex = this.selected + 1 < mediaCount ? this.selected + 1 : null; + const prevIndex = this._selected > 0 ? this._selected - 1 : null; + const nextIndex = this._selected + 1 < mediaCount ? this._selected + 1 : null; return { ...(prevIndex !== null && { previous: { @@ -325,7 +325,7 @@ export class FrigateCardViewerCarousel extends LitElement { return; } - if (this.selected === null || this.selected === index) { + if (this._selected === index) { // The slide may already be selected on load, so don't dispatch a new view // unless necessary (i.e. the new index is different from the current // index). @@ -408,10 +408,10 @@ export class FrigateCardViewerCarousel extends LitElement { this.view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0; const newSeek = this.view?.context?.mediaViewer?.seek; - if (newMedia !== this._media || newSelected !== this.selected || !newSeek) { + if (newMedia !== this._media || newSelected !== this._selected || !newSeek) { setOrRemoveAttribute(this, false, 'unseekable'); this._media = newMedia; - this.selected = newSelected; + this._selected = newSelected; } } } @@ -427,7 +427,7 @@ export class FrigateCardViewerCarousel extends LitElement { // If there's no selected media, just choose the last (most recent one) to // avoid rendering a blank. This situation should not occur in practice, as // this view should not be called without a selected media. - const selectedMedia = this._media[this.selected] ?? this._media[mediaCount - 1]; + const selectedMedia = this._media[this._selected] ?? this._media[mediaCount - 1]; if (!this.hass || !this.cameraManager || !selectedMedia) { return; @@ -460,7 +460,7 @@ export class FrigateCardViewerCarousel extends LitElement { ) => { this._setViewSelectedIndex(ev.detail.index); @@ -526,10 +526,15 @@ export class FrigateCardViewerCarousel extends LitElement { */ protected async _seekHandler(): Promise { const seek = this.view?.context?.mediaViewer?.seek; - if (!this.hass || !seek || !this._media || this.selected === null || !this._player) { + if ( + !this.hass || + !seek || + !this._media || + !this._player + ) { return; } - const selectedMedia = this._media[this.selected]; + const selectedMedia = this._media[this._selected]; if (!selectedMedia) { return; } diff --git a/src/utils/embla/carousel-controller.ts b/src/utils/embla/carousel-controller.ts index acfb4849..b80f13c8 100644 --- a/src/utils/embla/carousel-controller.ts +++ b/src/utils/embla/carousel-controller.ts @@ -137,10 +137,11 @@ export class CarouselController { ); const getCarouselSelectedObject = (): CarouselSelected | null => { - const selectedIndex = this.getSelectedIndex(); - const slide = this.getSlide(selectedIndex); - - if (selectedIndex !== null && slide) { + // Caution: Must use methods/accessors of the new carousel, not the public + // API of this controller which may use a different carousel. + const selectedIndex = carousel.selectedScrollSnap(); + const slide = carousel.slideNodes()[selectedIndex] ?? null; + if (slide) { return { index: selectedIndex, element: slide, @@ -160,7 +161,6 @@ export class CarouselController { } }; - carousel.on('init', () => selectSlide()); carousel.on('select', () => selectSlide()); carousel.on('settle', () => { const carouselSelected = getCarouselSelectedObject(); From 7173743d0b43c02e552d04421399d6b7beca69f3 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 5 Sep 2023 20:47:08 -0700 Subject: [PATCH 30/33] Fix test after carousel controller changes. --- tests/utils/embla/carousel-controller.test.ts | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/tests/utils/embla/carousel-controller.test.ts b/tests/utils/embla/carousel-controller.test.ts index bc5eb5be..8eef025f 100644 --- a/tests/utils/embla/carousel-controller.test.ts +++ b/tests/utils/embla/carousel-controller.test.ts @@ -130,30 +130,25 @@ describe('CarouselController', () => { expect(settleHandler).toBeCalled(); }); - describe('should dispatch select event on', () => { - it.each([['init' as const], ['select' as const]])( - '%s', - (emblaApiEvt: 'init' | 'select') => { - const children = createTestSlideNodes(); - const parent = createParent({ children: children }); - new CarouselController(createRoot(), parent); + it('should dispatch select event', () => { + const children = createTestSlideNodes(); + const parent = createParent({ children: children }); + new CarouselController(createRoot(), parent); - const selectHandler = vi.fn(); - parent.addEventListener('frigate-card:carousel:select', selectHandler); + const selectHandler = vi.fn(); + parent.addEventListener('frigate-card:carousel:select', selectHandler); - getEmblaApi()?.selectedScrollSnap.mockReturnValue(6); - getEmblaApi()?.slideNodes.mockReturnValue(children); - callEmblaHandler(getEmblaApi(), emblaApiEvt); + getEmblaApi()?.selectedScrollSnap.mockReturnValue(6); + getEmblaApi()?.slideNodes.mockReturnValue(children); + callEmblaHandler(getEmblaApi(), 'select'); - expect(selectHandler).toBeCalledWith( - expect.objectContaining({ - detail: { - index: 6, - element: children[6], - }, - }), - ); - }, + expect(selectHandler).toBeCalledWith( + expect.objectContaining({ + detail: { + index: 6, + element: children[6], + }, + }), ); }); From 88563b59ac68a483804ca7c947a6bd33404083b2 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 5 Sep 2023 20:58:55 -0700 Subject: [PATCH 31/33] Fix thumbnail fade issue. --- src/components/surround.ts | 1 + src/components/thumbnail-carousel.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/surround.ts b/src/components/surround.ts index cdd95ce1..7d1c246c 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -178,6 +178,7 @@ export class FrigateCardSurround extends LitElement { .hass=${this.hass} .config=${this.thumbnailConfig} .cameraManager=${this.cameraManager} + .fadeThumbnails=${this.view.isViewerView()} .view=${this.view} .selected=${this.view.queryResults?.getSelectedIndex() ?? undefined} @frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')} diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 1248efe0..425d077b 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -37,6 +37,9 @@ export class FrigateCardThumbnailCarousel extends LitElement { @property({ attribute: false }) public config?: ThumbnailsControlConfig; + @property({ attribute: false }) + public fadeThumbnails = false; + protected _thumbnailSlides: TemplateResult[] = []; protected willUpdate(changedProps: PropertyValues): void { @@ -65,7 +68,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { if (changedProps.has('view')) { this.style.setProperty( '--frigate-card-carousel-thumbnail-opacity', - this._getSelectedSlide() === null ? '1.0' : '0.4', + !this.fadeThumbnails || this._getSelectedSlide() === null ? '1.0' : '0.4', ); } } From 1c27bc55dd2a2eab6fe447aa083cad319f51823c Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 5 Sep 2023 21:25:41 -0700 Subject: [PATCH 32/33] Add screenshot. --- README.md | 6 ++++++ images/grid-small.gif | Bin 0 -> 4241252 bytes 2 files changed, 6 insertions(+) create mode 100644 images/grid-small.gif diff --git a/README.md b/README.md index 124f6e59..aa267ef8 100644 --- a/README.md +++ b/README.md @@ -1649,6 +1649,12 @@ Pan around a large camera view to only show part of the video feed in the card a Taking card actions via the URL +### Interacting with a camera grid + +Interacting with a camera grid + +## Examples + ## Examples ### Illustrative Expanded Configuration Reference diff --git a/images/grid-small.gif b/images/grid-small.gif new file mode 100644 index 0000000000000000000000000000000000000000..cdb4a6428f64a5cccec8520918492743ab2879bf GIT binary patch literal 4241252 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%0D@oB12TkmTq_w#6;xfus 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

H^o`-pKE$-|f(-VpTjRrmh8^KIcL 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

1gFNAJb}3JD8$7VkwK6TZ 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

vi0S`N_2#Ztkgogwoc91wXGHypN8#Bd@sT}pI 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