Recalculate grid for a slot change.
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user