679 lines
19 KiB
TypeScript
679 lines
19 KiB
TypeScript
// TODO: Clips vs snapshots: Should be able to navigate from snapshots view and it should just work.
|
|
// TODO: Remove HACK in view.ts on clips
|
|
// TODO: Hover over an event should show something useful.
|
|
// TODO: Periodically refetch events.
|
|
// TODO: Search for TODOs and logging statements.
|
|
// TODO: Allow download of selected event in timeline.
|
|
|
|
import {
|
|
CSSResultGroup,
|
|
LitElement,
|
|
TemplateResult,
|
|
html,
|
|
unsafeCSS,
|
|
PropertyValues,
|
|
} from 'lit';
|
|
import { DataSet } from 'vis-data/esnext';
|
|
import {
|
|
DataGroupCollectionType,
|
|
IdType,
|
|
Timeline,
|
|
TimelineItem,
|
|
TimelineOptions,
|
|
TimelineOptionsCluster,
|
|
TimelineWindow,
|
|
} from 'vis-timeline/esnext';
|
|
import { HomeAssistant } from 'custom-card-helpers';
|
|
import { classMap } from 'lit/directives/class-map.js';
|
|
import { customElement, property } from 'lit/decorators.js';
|
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
|
import { add, fromUnixTime, sub } from 'date-fns';
|
|
import { isEqual } from 'lodash-es';
|
|
|
|
import { BrowseMediaUtil } from '../browse-media-util';
|
|
import {
|
|
CameraConfig,
|
|
ExtendedHomeAssistant,
|
|
FrigateBrowseMediaSource,
|
|
MEDIA_CLASS_PLAYLIST,
|
|
MEDIA_TYPE_VIDEO,
|
|
MEDIA_CLASS_VIDEO,
|
|
TimelineConfig,
|
|
FrigateEvent,
|
|
} from '../types';
|
|
import { View, ViewContext } from '../view';
|
|
import {
|
|
dispatchErrorMessageEvent,
|
|
dispatchFrigateCardEvent,
|
|
getCameraTitle,
|
|
} from '../common.js';
|
|
|
|
import timelineCoreStyle from '../scss/timeline-core.scss';
|
|
import timelineStyle from '../scss/timeline.scss';
|
|
|
|
import './surround-thumbnails.js';
|
|
|
|
interface FrigateCardGroupData {
|
|
id: string;
|
|
content: string;
|
|
}
|
|
interface FrigateCardTimelineItem extends TimelineItem {
|
|
source: FrigateBrowseMediaSource;
|
|
}
|
|
|
|
interface TimelineViewContext extends ViewContext {
|
|
window: TimelineWindow;
|
|
}
|
|
|
|
/**
|
|
* A manager to maintain/fetch timeline events.
|
|
*/
|
|
class TimelineEventManager {
|
|
protected _dataset = new DataSet<FrigateCardTimelineItem>();
|
|
|
|
// The earliest date managed.
|
|
protected _dateStart?: Date;
|
|
|
|
// The latest date managed.
|
|
protected _dateEnd?: Date;
|
|
protected _contentCallback?: (source: FrigateBrowseMediaSource) => string;
|
|
|
|
constructor(params?: {
|
|
contentCallback?: (source: FrigateBrowseMediaSource) => string;
|
|
}) {
|
|
this._contentCallback = params?.contentCallback;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the underlying dataset.
|
|
*/
|
|
get dataset(): DataSet<FrigateCardTimelineItem> {
|
|
return this._dataset;
|
|
}
|
|
|
|
/**
|
|
* Determine if the dataset is empty.
|
|
* @returns
|
|
*/
|
|
public isEmpty(): boolean {
|
|
return this._dataset.length === 0;
|
|
}
|
|
|
|
/**
|
|
* Clear the dataset.
|
|
*/
|
|
public clear(): void {
|
|
this._dataset.clear();
|
|
}
|
|
|
|
/**
|
|
* Add a FrigateBrowseMediaSource object to the managed timeline.
|
|
* @param camera The id the camera this object is from.
|
|
* @param target The FrigateBrowseMediaSource to add.
|
|
*/
|
|
protected _addMediaSource(camera: string, target: FrigateBrowseMediaSource): void {
|
|
const items: FrigateCardTimelineItem[] = [];
|
|
target.children?.forEach((child) => {
|
|
if (child.frigate) {
|
|
const item = {
|
|
id: child.media_content_id,
|
|
group: camera,
|
|
content: this._contentCallback?.(child) ?? '',
|
|
start: child.frigate.event.start_time * 1000,
|
|
source: child,
|
|
};
|
|
if (child.frigate.event.end_time) {
|
|
item['end'] = child.frigate.event.end_time * 1000;
|
|
item['type'] = 'range';
|
|
} else {
|
|
item['type'] = 'point';
|
|
}
|
|
items.push(item);
|
|
}
|
|
});
|
|
this._dataset.update(items);
|
|
}
|
|
|
|
/**
|
|
* Determine if the timeline has coverage for a given range of dates.
|
|
* @param start The start of the date range.
|
|
* @param end An optional end of the date range.
|
|
* @returns
|
|
*/
|
|
public hasCoverage(start: Date, end?: Date): boolean {
|
|
return (
|
|
!!this._dateStart &&
|
|
start >= this._dateStart &&
|
|
(!end || (!!this._dateEnd && end <= this._dateEnd))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch events if no coverage in given range.
|
|
* @param element The element to send error events from.
|
|
* @param hass The HomeAssistant object.
|
|
* @param cameras The cameras map.
|
|
* @param start Fetch events that start later than this date.
|
|
* @param end Fetch events that start earlier than this date.
|
|
* @returns `true` if events were fetched, `false` otherwise.
|
|
*/
|
|
public async fetchEventsIfNecessary(
|
|
element: HTMLElement,
|
|
hass: HomeAssistant & ExtendedHomeAssistant,
|
|
cameras: Map<string, CameraConfig>,
|
|
start: Date,
|
|
end: Date,
|
|
): Promise<boolean> {
|
|
if (this.hasCoverage(start, end)) {
|
|
return false;
|
|
}
|
|
await this._fetchEvents(element, hass, cameras, start, end);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Fetch events for the timeline.
|
|
* @param element The element to send error events from.
|
|
* @param hass The HomeAssistant object.
|
|
* @param cameras The cameras map.
|
|
* @param start Fetch events that start later than this date.
|
|
* @param end Fetch events that start earlier than this date.
|
|
*/
|
|
protected async _fetchEvents(
|
|
element: HTMLElement,
|
|
hass: HomeAssistant & ExtendedHomeAssistant,
|
|
cameras: Map<string, CameraConfig>,
|
|
start: Date,
|
|
end: Date,
|
|
): Promise<void> {
|
|
if (!this._dateStart || start < this._dateStart) {
|
|
this._dateStart = start;
|
|
}
|
|
if (!this._dateEnd || end > this._dateEnd) {
|
|
this._dateEnd = end;
|
|
}
|
|
|
|
const fetchCameraEvents = async (camera: string): Promise<void> => {
|
|
const cameraConfig = cameras.get(camera);
|
|
if (!cameraConfig || !this._dateStart || !this._dateEnd) {
|
|
return;
|
|
}
|
|
const browseMediaQueryParameters = BrowseMediaUtil.getBrowseMediaQueryParameters(
|
|
'clips',
|
|
cameraConfig,
|
|
);
|
|
if (!browseMediaQueryParameters) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this._addMediaSource(
|
|
camera,
|
|
await BrowseMediaUtil.browseMediaQuery(hass, {
|
|
...browseMediaQueryParameters,
|
|
|
|
// Events are always fetched for the maximum extent of the managed
|
|
// range. This is because events may change at any point in time
|
|
// (e.g. a long-running event that ends).
|
|
before: this._dateEnd.getTime() / 1000,
|
|
after: this._dateStart.getTime() / 1000,
|
|
unlimited: true,
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
return dispatchErrorMessageEvent(element, (e as Error).message);
|
|
}
|
|
};
|
|
|
|
await Promise.all(Array.from(cameras.keys()).map(fetchCameraEvents.bind(this)));
|
|
}
|
|
}
|
|
|
|
@customElement('frigate-card-timeline')
|
|
export class FrigateCardTimeline extends LitElement {
|
|
@property({ attribute: false })
|
|
protected hass?: HomeAssistant & ExtendedHomeAssistant;
|
|
|
|
@property({ attribute: false })
|
|
protected view?: Readonly<View>;
|
|
|
|
@property({ attribute: false })
|
|
protected cameras?: Map<string, CameraConfig>;
|
|
|
|
@property({ attribute: false })
|
|
protected timelineConfig?: TimelineConfig;
|
|
|
|
/**
|
|
* Master render method.
|
|
* @returns A rendered template.
|
|
*/
|
|
protected render(): TemplateResult | void {
|
|
if (!this.timelineConfig) {
|
|
return html``;
|
|
}
|
|
|
|
return html` <frigate-card-surround-thumbnails
|
|
.hass=${this.hass}
|
|
.view=${this.view}
|
|
.config=${this.timelineConfig.controls.thumbnails}
|
|
.targetView=${'clip'}
|
|
>
|
|
<frigate-card-timeline-core
|
|
.hass=${this.hass}
|
|
.view=${this.view}
|
|
.cameras=${this.cameras}
|
|
.timelineConfig=${this.timelineConfig}
|
|
>
|
|
</frigate-card-timeline-core>
|
|
</frigate-card-surround-thumbnails>`;
|
|
}
|
|
|
|
/**
|
|
* Return compiled CSS styles.
|
|
*/
|
|
static get styles(): CSSResultGroup {
|
|
return unsafeCSS(timelineStyle);
|
|
}
|
|
}
|
|
|
|
@customElement('frigate-card-timeline-core')
|
|
export class FrigateCardTimelineCore extends LitElement {
|
|
@property({ attribute: false })
|
|
protected hass?: HomeAssistant & ExtendedHomeAssistant;
|
|
|
|
@property({ attribute: false })
|
|
protected view?: Readonly<View>;
|
|
|
|
@property({ attribute: false })
|
|
protected cameras?: Map<string, CameraConfig>;
|
|
|
|
@property({ attribute: false })
|
|
protected timelineConfig?: TimelineConfig;
|
|
|
|
protected _events = new TimelineEventManager();
|
|
protected _refTimeline: Ref<HTMLElement> = createRef();
|
|
protected _thumbnails?: FrigateBrowseMediaSource;
|
|
protected _timeline?: Timeline;
|
|
|
|
/**
|
|
* Master render method.
|
|
* @returns A rendered template.
|
|
*/
|
|
protected render(): TemplateResult | void {
|
|
if (!this.hass || !this.view || !this.timelineConfig) {
|
|
return;
|
|
}
|
|
|
|
const thumbnailsConfig = this.timelineConfig.controls.thumbnails;
|
|
const timelineClasses = {
|
|
timeline: true,
|
|
'left-margin': thumbnailsConfig.mode === 'left',
|
|
'right-margin': thumbnailsConfig.mode === 'right',
|
|
};
|
|
|
|
return html`<div
|
|
class="${classMap(timelineClasses)}"
|
|
${ref(this._refTimeline)}
|
|
></div>`;
|
|
}
|
|
|
|
/**
|
|
* Handle a range change in the timeline.
|
|
* @param properties vis.js provided range information.
|
|
*/
|
|
protected _timelineRangeHandler(properties: {
|
|
start: Date;
|
|
end: Date;
|
|
byUser: boolean;
|
|
event: Event;
|
|
}): void {
|
|
console.info(
|
|
`Range changed: ${properties.start} -> ${properties.end} [${this._events.dataset.length}]`,
|
|
);
|
|
if (this.hass && this.cameras && this._timeline) {
|
|
this._events
|
|
.fetchEventsIfNecessary(
|
|
this,
|
|
this.hass,
|
|
this.cameras,
|
|
properties.start,
|
|
properties.end,
|
|
)
|
|
.then((fetched: boolean) => {
|
|
if (fetched) {
|
|
this._generateThumbnails();
|
|
}
|
|
});
|
|
|
|
// Update the view to ensure that future view changes do not cause a
|
|
// scroll.
|
|
this.view
|
|
?.evolve({
|
|
context: {
|
|
window: this._timeline.getWindow(),
|
|
},
|
|
})
|
|
.dispatchChangeEvent(this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called when an object on the timeline is selected.
|
|
* @param data The data about the selection.
|
|
* @returns
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
protected _timelineSelectHandler(data: { items: string[]; event: Event }): void {
|
|
if (!this._thumbnails || !this._thumbnails.children || data.items.length <= 0) {
|
|
return;
|
|
}
|
|
const childIndex = this._findThumbnailIndex(data.items[0]);
|
|
if (childIndex >= 0) {
|
|
this.view
|
|
?.evolve({
|
|
target: this._thumbnails,
|
|
childIndex: childIndex,
|
|
})
|
|
.dispatchChangeEvent(this);
|
|
dispatchFrigateCardEvent(this, 'thumbnails:open');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the index of the given item in the thumbnails.
|
|
* @param id
|
|
* @returns The index of the item, or -1 if not found.
|
|
*/
|
|
public _findThumbnailIndex(id: IdType | IdType[]): number {
|
|
if (!this._thumbnails || !this._thumbnails.children) {
|
|
return -1;
|
|
}
|
|
id = Array.isArray(id) ? id[0] : id;
|
|
return this._thumbnails.children.findIndex((child) => child.media_content_id === id);
|
|
}
|
|
|
|
/**
|
|
* Regenerate the thumbnails from the timeline events.
|
|
* @returns
|
|
*/
|
|
protected _generateThumbnails(): void {
|
|
if (!this._timeline) {
|
|
return;
|
|
}
|
|
|
|
const children: FrigateBrowseMediaSource[] = this._events.dataset
|
|
.get()
|
|
.filter((item) => BrowseMediaUtil.isTrueMedia(item.source))
|
|
.map((item) => item.source);
|
|
if (!children.length) {
|
|
return;
|
|
}
|
|
|
|
const target = {
|
|
title: `Timeline events`,
|
|
media_class: MEDIA_CLASS_PLAYLIST,
|
|
media_content_type: MEDIA_TYPE_VIDEO,
|
|
media_content_id: '',
|
|
can_play: false,
|
|
can_expand: true,
|
|
children_media_class: MEDIA_CLASS_VIDEO,
|
|
thumbnail: null,
|
|
children: children,
|
|
};
|
|
|
|
this._thumbnails = target;
|
|
const childIndex = this._findThumbnailIndex(this._timeline.getSelection());
|
|
|
|
// Update the thumbnail carousel with the regenerated thumbnails.
|
|
this.view
|
|
?.evolve({
|
|
target: this._thumbnails,
|
|
childIndex: childIndex < 0 ? undefined : childIndex,
|
|
})
|
|
.dispatchChangeEvent(this);
|
|
}
|
|
|
|
/**
|
|
* Build the visjs dataset to render on the timeline.
|
|
* @returns The dataset.
|
|
*/
|
|
protected _getGroups(): DataGroupCollectionType {
|
|
const groups: FrigateCardGroupData[] = [];
|
|
this.cameras?.forEach((cameraConfig, camera) => {
|
|
groups.push({
|
|
id: camera,
|
|
content: getCameraTitle(this.hass, cameraConfig),
|
|
});
|
|
});
|
|
return new DataSet(groups);
|
|
}
|
|
|
|
/**
|
|
* Given an event get an appropriate start/end time window around the event.
|
|
* @param event The FrigateEvent to consider.
|
|
* @returns A tuple of start/end date.
|
|
*/
|
|
protected _getStartEndFromEvent(event: FrigateEvent): [Date, Date] {
|
|
const one_hour = { hours: 1 };
|
|
const start = sub(fromUnixTime(event.start_time), one_hour);
|
|
let end: Date;
|
|
|
|
if (event.end_time) {
|
|
end = add(fromUnixTime(event.end_time), one_hour);
|
|
} else {
|
|
end = add(start, one_hour);
|
|
}
|
|
return [start, end];
|
|
}
|
|
|
|
/**
|
|
* Get desired timeline start/end time.
|
|
* @returns A tuple of start/end date.
|
|
*/
|
|
protected _getStartEnd(): [Date, Date] {
|
|
const event = this.view?.target?.frigate?.event;
|
|
if (event) {
|
|
return this._getStartEndFromEvent(event);
|
|
}
|
|
const one_hour = { hours: 1 };
|
|
const end = new Date();
|
|
const start = sub(end, one_hour);
|
|
return [start, end];
|
|
}
|
|
|
|
/**
|
|
* Determine if the timeline should use clustering.
|
|
* @returns `true` if the timeline should cluster, `false` otherwise.
|
|
*/
|
|
protected _isClustering(): boolean {
|
|
return (
|
|
!!this.timelineConfig?.clustering_threshold &&
|
|
this.timelineConfig.clustering_threshold > 0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle timeline resize.
|
|
*/
|
|
protected _getOptions(): TimelineOptions | void {
|
|
if (!this.timelineConfig) {
|
|
return;
|
|
}
|
|
|
|
const [start, end] = this._getStartEnd();
|
|
|
|
// Configuration for the Timeline, see:
|
|
// https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options
|
|
return {
|
|
cluster: this._isClustering()
|
|
? {
|
|
showStipes: true,
|
|
// It would be better to automatically calculate `maxItems` from the
|
|
// rendered height of the timeline (or group within the timeline) so
|
|
// as to not waste vertical space (e.g. after the user changes to
|
|
// fullscreen mode). Unfortunately this is not easy to do, as we
|
|
// don't know the height of the timeline until after it renders --
|
|
// and if we adjust `maxItems` then we can get into an infinite
|
|
// resize loop. Adjusting the `maxItems` of a timeline, after it's
|
|
// created, also does not appear to work as expected.
|
|
maxItems: this.timelineConfig.clustering_threshold,
|
|
|
|
clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => {
|
|
// Never include the target media in a cluster, and never group
|
|
// different object types together (e.g. person and car).
|
|
return (
|
|
!!first.id &&
|
|
first.id !== this.view?.media?.media_content_id &&
|
|
!!second.id &&
|
|
second.id != this.view?.media?.media_content_id &&
|
|
(<FrigateCardTimelineItem>first).source.frigate?.event.label ===
|
|
(<FrigateCardTimelineItem>second).source.frigate?.event.label
|
|
);
|
|
},
|
|
}
|
|
: (false as TimelineOptionsCluster),
|
|
minHeight: '100%',
|
|
maxHeight: '100%',
|
|
zoomMax: 31 * 24 * 60 * 60 * 1000,
|
|
zoomMin: 1 * 1000,
|
|
selectable: true,
|
|
start: start,
|
|
end: end,
|
|
groupHeightMode: 'fixed',
|
|
xss: {
|
|
disabled: false,
|
|
filterOptions: {
|
|
whiteList: {
|
|
'frigate-card-timeline-event': [
|
|
'thumbnail',
|
|
'label',
|
|
'media_id',
|
|
'thumbnail_size',
|
|
],
|
|
div: ['title'],
|
|
span: ['style'],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Determine if the component should be updated.
|
|
* @param _changedProps The changed properties.
|
|
* @returns
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
protected shouldUpdate(_changedProps: PropertyValues): boolean {
|
|
return !!this.hass && !!this.cameras && this.cameras.size > 0;
|
|
}
|
|
|
|
/**
|
|
* Update the timeline from the view object.
|
|
*/
|
|
protected async _updateTimelineFromView(): Promise<void> {
|
|
const event = this.view?.media?.frigate?.event;
|
|
const id = this.view?.media?.media_content_id;
|
|
|
|
if (!this.hass || !this.cameras || !this.view || !event || !id || !this._timeline) {
|
|
return;
|
|
}
|
|
|
|
const [eventWindowStart, eventWindowEnd] = this._getStartEndFromEvent(event);
|
|
if (
|
|
await this._events.fetchEventsIfNecessary(
|
|
this,
|
|
this.hass,
|
|
this.cameras,
|
|
eventWindowStart,
|
|
eventWindowEnd,
|
|
)
|
|
) {
|
|
this._generateThumbnails();
|
|
}
|
|
|
|
const eventStart = new Date(event.start_time * 1000);
|
|
const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0;
|
|
|
|
this._timeline.setSelection([id], {
|
|
focus: false,
|
|
animation: {
|
|
animation: false,
|
|
zoom: false,
|
|
},
|
|
});
|
|
|
|
const timelineWindow = this._timeline.getWindow();
|
|
const context = this.view.context
|
|
? (this.view.context as TimelineViewContext)
|
|
: undefined;
|
|
|
|
if (context?.window) {
|
|
console.info(
|
|
`Setting window from context (${context.window.start} -> ${context.window.end}`,
|
|
);
|
|
if (!isEqual(context.window, timelineWindow)) {
|
|
this._timeline.setWindow(context.window.start, context.window.end);
|
|
}
|
|
} else if (
|
|
eventStart < timelineWindow.start ||
|
|
eventStart > timelineWindow.end ||
|
|
(eventEnd && (eventEnd < timelineWindow.start || eventEnd > timelineWindow.end))
|
|
) {
|
|
console.info(`Setting window from event ${eventWindowStart} -> ${eventWindowEnd}`);
|
|
this._timeline.setWindow(eventWindowStart, eventWindowEnd);
|
|
}
|
|
|
|
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 item = this._events.dataset.get(id);
|
|
if (item) {
|
|
this._events.dataset.updateOnly(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called when the component is updated.
|
|
* @param changedProperties The changed properties if any.
|
|
*/
|
|
protected updated(changedProperties: PropertyValues): void {
|
|
super.updated(changedProperties);
|
|
|
|
if (changedProperties.has('cameras')) {
|
|
this._events.clear();
|
|
this._timeline?.destroy();
|
|
this._timeline = undefined;
|
|
}
|
|
|
|
const options = this._getOptions();
|
|
if (changedProperties.has('timelineConfig') && this._refTimeline.value && options) {
|
|
if (this._timeline) {
|
|
// TODO this._timeline.setOptions(options);
|
|
} else {
|
|
this._timeline = new Timeline(
|
|
this._refTimeline.value,
|
|
this._events.dataset,
|
|
this._getGroups(),
|
|
options,
|
|
);
|
|
this._timeline.on('select', this._timelineSelectHandler.bind(this));
|
|
this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this));
|
|
}
|
|
}
|
|
|
|
if (changedProperties.has('view')) {
|
|
this._updateTimelineFromView();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return compiled CSS styles.
|
|
*/
|
|
static get styles(): CSSResultGroup {
|
|
return unsafeCSS(timelineCoreStyle);
|
|
}
|
|
}
|