Refactor views to support dynamic updates.

This commit is contained in:
Dermot Duffy
2024-07-26 19:15:38 -07:00
parent 33dee47205
commit d5c4e56c45
80 changed files with 3714 additions and 4021 deletions
+133 -138
View File
@@ -1,14 +1,14 @@
import { add, differenceInSeconds, sub } from 'date-fns';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
html,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import isEqual from 'lodash-es/isEqual';
import throttle from 'lodash-es/throttle';
import { ViewContext } from 'view';
@@ -26,6 +26,8 @@ import { CameraManager } from '../camera-manager/manager';
import { rangesOverlap } from '../camera-manager/range';
import { MediaQuery } from '../camera-manager/types';
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly';
import { MergeContextViewModifier } from '../card-controller/view/modifiers/merge-context';
import { ViewManagerEpoch } from '../card-controller/view/types';
import {
FrigateCardTimelineItem,
TimelineDataSource,
@@ -33,11 +35,11 @@ import {
import {
CameraConfig,
CardWideConfig,
frigateCardConfigDefaults,
FrigateCardView,
ThumbnailsControlBaseConfig,
TimelineCoreConfig,
TimelinePanMode,
frigateCardConfigDefaults,
} from '../config/types';
import { localize } from '../localize/localize';
import timelineCoreStyle from '../scss/timeline-core.scss';
@@ -51,10 +53,7 @@ import {
isTruthy,
setOrRemoveAttribute,
} from '../utils/basic';
import {
executeMediaQueryForViewWithErrorDispatching,
findBestMediaIndex,
} from '../utils/media-to-view';
import { findBestMediaIndex } from '../utils/find-best-media-index';
import { ViewMedia } from '../view/media';
import { ViewMediaClassifier } from '../view/media-classifier';
import {
@@ -67,7 +66,7 @@ import {
MediaQueriesType,
} from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results';
import { View } from '../view/view';
import { mergeViewContext } from '../view/view';
import './date-picker.js';
import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js';
import './thumbnail.js';
@@ -107,7 +106,7 @@ interface ThumbnailDataRequest {
cameraManager?: CameraManager;
cameraConfig?: CameraConfig;
media?: ViewMedia;
view?: View;
viewManagerEpoch?: ViewManagerEpoch;
}
class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
@@ -115,7 +114,7 @@ class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
const TIMELINE_TARGET_BAR_ID = 'target_bar';
/**
* A simgple thumbnail wrapper class for use in the timeline where LIT data
* A simgple thumbnail wrapper class for use in the timeline where Lit data
* bindings are not available.
*/
@customElement('frigate-card-timeline-thumbnail')
@@ -160,7 +159,7 @@ export class FrigateCardTimelineThumbnail extends LitElement {
!dataRequest.cameraManager ||
!dataRequest.cameraConfig ||
!dataRequest.media ||
!dataRequest.view
!dataRequest.viewManagerEpoch
) {
return html``;
}
@@ -169,7 +168,7 @@ export class FrigateCardTimelineThumbnail extends LitElement {
.hass=${dataRequest.hass}
.cameraManager=${dataRequest.cameraManager}
.media=${dataRequest.media}
.view=${dataRequest.view}
.viewManagerEpoch=${dataRequest.viewManagerEpoch}
?details=${this.details}
>
</frigate-card-thumbnail>`;
@@ -182,12 +181,12 @@ export class FrigateCardTimelineCore extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false, hasChanged: contentsChanged })
public timelineConfig?: TimelineCoreConfig;
@property({ attribute: true, type: Boolean })
@property({ attribute: false })
public thumbnailConfig?: ThumbnailsControlBaseConfig;
// Whether or not this is a mini-timeline (in mini-mode the component takes a
@@ -269,11 +268,11 @@ export class FrigateCardTimelineCore extends LitElement {
request.detail.cameraConfig = cameraConfig;
request.detail.cameraManager = this.cameraManager;
request.detail.media = media;
request.detail.view = this.view;
request.detail.viewManagerEpoch = this.viewManagerEpoch;
}
protected render(): TemplateResult | void {
if (!this.hass || !this.view || !this.timelineConfig || !this.cameraIDs?.size) {
if (!this.hass || !this.timelineConfig || !this.cameraIDs?.size) {
return;
}
@@ -383,6 +382,7 @@ export class FrigateCardTimelineCore extends LitElement {
return;
}
const view = this.viewManagerEpoch?.manager.getView();
const panMode = this._getEffectivePanMode();
const targetBarOn =
this._shouldSupportSeeking() &&
@@ -392,7 +392,7 @@ export class FrigateCardTimelineCore extends LitElement {
const item = this._timelineSource?.dataset?.get(id);
return (
panMode !== 'seek-in-camera' ||
item?.media?.getCameraID() === this.view?.camera,
item?.media?.getCameraID() === view?.camera,
item &&
item.start &&
item.end &&
@@ -450,14 +450,15 @@ export class FrigateCardTimelineCore extends LitElement {
targetTime: Date,
properties: TimelineRangeChange,
): Promise<void> {
const results = this.view?.queryResults;
const view = this.viewManagerEpoch?.manager.getView();
const results = view?.queryResults;
const media = results?.getResults();
const panMode = this._getEffectivePanMode();
if (
!media ||
!results ||
!this._timeline ||
!this.view ||
!view ||
!this.hass ||
!this.cameraManager ||
panMode === 'pan'
@@ -473,7 +474,7 @@ export class FrigateCardTimelineCore extends LitElement {
.clone()
.resetSelectedResult()
.selectBestResult(
(mediaArray) => findBestMediaIndex(mediaArray, targetTime, this.view?.camera),
(mediaArray) => findBestMediaIndex(mediaArray, targetTime, view?.camera),
{
allCameras: true,
main: true,
@@ -484,9 +485,9 @@ export class FrigateCardTimelineCore extends LitElement {
.clone()
.resetSelectedResult()
.selectBestResult((mediaArray) => findBestMediaIndex(mediaArray, targetTime), {
cameraID: this.view.camera,
cameraID: view.camera,
})
.promoteCameraSelectionToMainSelection(this.view.camera);
.promoteCameraSelectionToMainSelection(view.camera);
} else if (panMode === 'seek-in-media') {
newResults = results;
}
@@ -495,20 +496,23 @@ export class FrigateCardTimelineCore extends LitElement {
? targetTime >= new Date()
? 'live'
: 'media'
: this.view.view;
: view.view;
const selectedCamera = newResults?.getSelectedResult()?.getCameraID();
this.view
.evolve({
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
...(selectedCamera && { camera: selectedCamera }),
view: desiredView,
queryResults: newResults,
}) // Whether or not to set the timeline window.
.mergeInContext({
...(canSeek && { mediaViewer: { seek: targetTime } }),
...this._getTimelineContext({ start: properties.start, end: properties.end }),
})
.dispatchChangeEvent(this);
},
modifiers: [
new MergeContextViewModifier({
...(canSeek && { mediaViewer: { seek: targetTime } }),
...this._getTimelineContext({ start: properties.start, end: properties.end }),
}),
],
});
}
protected _getEffectivePanMode(): TimelinePanMode {
@@ -533,22 +537,18 @@ export class FrigateCardTimelineCore extends LitElement {
stopEventFromActivatingCardWideActions(properties.event);
}
const view = this.viewManagerEpoch?.manager.getView();
if (
this._ignoreClick ||
!this.hass ||
!this._timeline ||
!this.view ||
!this.cameraManager ||
!this.cardWideConfig ||
!this.cameraIDs ||
!this.cameraIDs.size ||
!view ||
!this.viewManagerEpoch ||
!this._timelineSource ||
!properties.what
) {
return;
}
let view: View | null = null;
let drawerAction: 'open' | 'close' = 'close';
if (
@@ -558,29 +558,35 @@ export class FrigateCardTimelineCore extends LitElement {
) {
const query = this._createMediaQueries('recording');
if (query) {
view = await executeMediaQueryForViewWithErrorDispatching(
this,
this.cameraManager,
this.view,
query,
{
targetView: 'recording',
targetTime: properties.time,
select: 'time',
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
baseView: view,
params: { view: 'recording', query: query },
queryExecutorOptions: {
selectResult: {
time: {
time: properties.time,
},
},
},
);
});
}
} else if (properties.item && properties.what === 'item') {
const cameraID = String(properties.group);
const id = String(properties.item);
const criteria = {
main: true,
...(cameraID && this.view.isGrid() && { cameraID: cameraID }),
...(cameraID && view.isGrid() && { cameraID: cameraID }),
};
const newResults = this.view.queryResults
const newResults = view.queryResults
?.clone()
.resetSelectedResult()
.selectResultIfFound((media) => media.getID() === properties.item, criteria);
const context: ViewContext = mergeViewContext(this._getTimelineContext(), {
mediaViewer: { seek: properties.time },
});
if (!newResults || !newResults.hasSelectedResult()) {
// This can happen in a few situations:
// - If this is a recording query (with recorded hours) and an event is
@@ -589,36 +595,34 @@ 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._createViewWithMediaQueries(
this._createMediaQueries('event'),
{
selectedItem: properties.item,
targetView: 'media',
},
);
if (fullEventView?.queryResults?.hasResults()) {
view = fullEventView;
const query = this._createMediaQueries('event');
if (query) {
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: { view: 'media', query: query },
queryExecutorOptions: {
selectResult: {
id: id,
},
rejectResults: (results) => !results.hasResults(),
},
modifiers: [new MergeContextViewModifier(context)],
});
}
} else {
view = this.view.evolve({
queryResults: newResults,
view: this.itemClickAction === 'play' ? 'media' : this.view.view,
this.viewManagerEpoch.manager.setViewByParameters({
params: {
queryResults: newResults,
view: this.itemClickAction === 'play' ? 'media' : view.view,
},
modifiers: [new MergeContextViewModifier(context)],
});
}
if (view?.queryResults?.hasResults()) {
view.mergeInContext({ mediaViewer: { seek: properties.time } });
}
view?.mergeInContext(this._getTimelineContext());
if (this.itemClickAction === 'select' && view) {
if (this.itemClickAction === 'select') {
drawerAction = 'open';
}
}
if (view) {
view.dispatchChangeEvent(this);
}
dispatchFrigateCardEvent(this, `thumbnails:${drawerAction}`);
this._ignoreClick = false;
@@ -648,10 +652,11 @@ export class FrigateCardTimelineCore extends LitElement {
event: Event & { additionalEvent: string };
}): Promise<void> {
this._removeTargetBar();
const view = this.viewManagerEpoch?.manager.getView();
if (
!this._timeline ||
!this.view ||
!view ||
// When in mini mode, something else is in charge of the primary media
// population (e.g. the live view), in this case only act when the user
// themselves are interacting with the timeline.
@@ -662,23 +667,34 @@ export class FrigateCardTimelineCore extends LitElement {
await this._timelineSource?.refresh(this._getPrefetchWindow(properties));
const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
const queryType = MediaQueriesClassifier.getQueriesType(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);
}
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: {
query: mediaQuery,
},
queryExecutorOptions: {
// Reject the new results unless there is something to be gained (i.e. they
// are not a subset of the existing results). 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).
rejectResults: (results) => !!view.queryResults?.isSupersetOf(results),
selectResult: {
id:
this.viewManagerEpoch?.manager
.getView()
?.queryResults?.getSelectedResult()
?.getID() ?? undefined,
},
},
modifiers: [new MergeContextViewModifier(this._getTimelineContext())],
});
}
protected _createMediaQueries(
@@ -706,46 +722,6 @@ export class FrigateCardTimelineCore extends LitElement {
return null;
}
protected async _createViewWithMediaQueries(
query: MediaQueries | null,
options?: {
targetView?: FrigateCardView;
selectedItem?: IdType;
},
): Promise<View | null> {
if (!this.hass || !this.cameraManager || !this.view || !query) {
return null;
}
const view = await executeMediaQueryForViewWithErrorDispatching(
this,
this.cameraManager,
this.view,
query,
{
targetView: options?.targetView,
select: 'latest',
},
);
if (!view) {
return null;
}
if (options?.selectedItem) {
view.queryResults?.selectResultIfFound(
(media) => media.getID() === options.selectedItem,
);
} else {
// If not asked to select a new item, persist the currently selected item
// if possible.
const currentlySelectedResult = this.view.queryResults?.getSelectedResult();
if (currentlySelectedResult) {
view.queryResults?.selectResultIfFound(
(media) => media.getID() === currentlySelectedResult.getID(),
);
}
}
return view;
}
/**
* Build the visjs dataset to render on the timeline.
* @returns The dataset.
@@ -937,10 +913,11 @@ export class FrigateCardTimelineCore extends LitElement {
}
protected _getAllSelectedMediaIDsFromView(): IdType[] {
const view = this.viewManagerEpoch?.manager.getView();
return (
this.view?.queryResults?.getMultipleSelectedResults({
view?.queryResults?.getMultipleSelectedResults({
main: true,
...(this.view.isGrid() && { allCameras: true }),
...(view.isGrid() && { allCameras: true }),
}) ?? []
)
.filter((media) => ViewMediaClassifier.isEvent(media))
@@ -952,7 +929,8 @@ export class FrigateCardTimelineCore extends LitElement {
* Update the timeline from the view object.
*/
protected async _updateTimelineFromView(): Promise<void> {
if (!this.view || !this.timelineConfig || !this._timelineSource || !this._timeline) {
const view = this.viewManagerEpoch?.manager.getView();
if (!view || !this.timelineConfig || !this._timelineSource || !this._timeline) {
return;
}
@@ -965,7 +943,7 @@ export class FrigateCardTimelineCore extends LitElement {
// perfectly center on the media.
let desiredWindow = timelineWindow;
const media = this.view.queryResults?.getSelectedResult();
const media = view.queryResults?.getSelectedResult();
const mediaStartTime = media?.getStartTime() ?? null;
const mediaEndTime = media?.getEndTime() ?? null;
const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false;
@@ -976,7 +954,7 @@ export class FrigateCardTimelineCore extends LitElement {
// range effectively starts/ends at the same time.
{ start: mediaStartTime, end: mediaEndTime ?? mediaStartTime }
: null;
const context = this.view.context?.timeline;
const context = view.context?.timeline;
if (context && context.window) {
desiredWindow = context.window;
@@ -1048,7 +1026,7 @@ export class FrigateCardTimelineCore extends LitElement {
// Also don't generate thumbnails in mini-timelines (they will already have
// been generated).
const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
const queryType = MediaQueriesClassifier.getQueriesType(view.query);
if (!queryType) {
return;
}
@@ -1062,15 +1040,31 @@ export class FrigateCardTimelineCore extends LitElement {
freshMediaQuery &&
!this._alreadyHasAcceptableMediaQuery(freshMediaQuery)
) {
(await this._createViewWithMediaQueries(freshMediaQuery))
?.mergeInContext(this._getTimelineContext(desiredWindow))
.dispatchChangeEvent(this);
const currentlySelectedResult = this.viewManagerEpoch?.manager
.getView()
?.queryResults?.getSelectedResult();
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: {
query: freshMediaQuery,
},
queryExecutorOptions: {
selectResult: {
id: currentlySelectedResult?.getID() ?? undefined,
},
},
modifiers: [
new MergeContextViewModifier(this._getTimelineContext(desiredWindow)),
],
});
}
}
protected _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean {
const currentQueries = this.view?.query?.getQueries();
const currentResultTimestamp = this.view?.queryResults?.getResultsTimestamp();
const view = this.viewManagerEpoch?.manager.getView();
const currentQueries = view?.query?.getQueries();
const currentResultTimestamp = view?.queryResults?.getResultsTimestamp();
return (
!!this.cameraManager &&
@@ -1089,10 +1083,11 @@ export class FrigateCardTimelineCore extends LitElement {
* @returns The TimelineViewContext object.
*/
protected _getTimelineContext(window?: TimelineWindow): ViewContext {
const view = this.viewManagerEpoch?.manager.getView();
const newWindow = window ?? this._timeline?.getWindow();
return {
timeline: {
...this.view?.context?.timeline,
...view?.context?.timeline,
...(newWindow && { window: newWindow }),
},
};
@@ -1225,7 +1220,7 @@ export class FrigateCardTimelineCore extends LitElement {
// `this._timeline.setwindow()` being entirely ignored. Example case:
// Clicking the timeline control on a recording thumbnail.
window.requestAnimationFrame(this._updateTimelineFromView.bind(this));
} else if (changedProperties.has('view')) {
} else if (changedProperties.has('viewManagerEpoch')) {
this._updateTimelineFromView();
}
}