From 17bfe4f38b074c8d0f34d1ec3adcfadbb1cefd51 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 19 Aug 2023 12:28:11 -0700 Subject: [PATCH] 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']); + }); });