Recalculate grid for a slot change.

This commit is contained in:
Dermot Duffy
2023-08-19 12:28:11 -07:00
parent 5364c828eb
commit 17bfe4f38b
10 changed files with 179 additions and 48 deletions
+4
View File
@@ -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,
});
}
+34 -14
View File
@@ -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 = (<FrigateCardTimelineItem>first).media;
const secondMedia = (<FrigateCardTimelineItem>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,
+9 -2
View File
@@ -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') {
+12 -21
View File
@@ -108,12 +108,14 @@ export class FrigateCardError extends Error {
const viewDisplayModeSchema = z.enum(['single', 'grid']);
export type ViewDisplayMode = z.infer<typeof viewDisplayModeSchema>;
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<typeof viewDisplaySchema>;
/**
@@ -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(),
+22 -6
View File
@@ -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;
+20 -4
View File
@@ -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() };
}
}
}
+19
View File
@@ -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;
}
+27 -1
View File
@@ -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 });
+19
View File
@@ -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);
});
});
+13
View File
@@ -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']);
});
});