import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS, PropertyValues, } from 'lit'; import { DataSet } from 'vis-data/esnext'; import { DataGroupCollectionType, Timeline, TimelineEventPropertiesResult, 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_PLAYLIST, TimelineConfig, FrigateEvent, frigateCardConfigDefaults, } from '../types'; import { View, ViewContext } from '../view'; import { dispatchErrorMessageEvent, dispatchFrigateCardEvent, getCameraTitle, stopEventFromActivatingCardWideActions, } from '../common.js'; import timelineCoreStyle from '../scss/timeline-core.scss'; import timelineStyle from '../scss/timeline.scss'; import './surround-thumbnails.js'; const TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS = 10; interface FrigateCardGroupData { id: string; content: string; } interface FrigateCardTimelineItem extends TimelineItem { event: FrigateEvent; source?: FrigateBrowseMediaSource; } interface TimelineViewContext extends ViewContext { // The selected timeline window. window?: TimelineWindow; // The date of the last event fetch. dateFetch?: Date; } type TimelineMediaType = 'all' | 'clips' | 'snapshots'; const isHoverableDevice = window.matchMedia('(hover: hover) and (pointer: fine)'); /** * A manager to maintain/fetch timeline events. */ class TimelineEventManager { protected _dataset = new DataSet(); // The earliest date managed. protected _dateStart?: Date; // The latest date managed. protected _dateEnd?: Date; // The last fetch date. protected _dateFetch?: Date; // The maximum allowable age of fetch data (will not fetch more frequently // than this). protected _maxAgeSeconds: number = TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS; protected _contentCallback?: (source: FrigateBrowseMediaSource) => string; protected _tooltipCallback?: (source: FrigateBrowseMediaSource) => string; constructor(params?: { contentCallback?: (source: FrigateBrowseMediaSource) => string; tooltipCallback?: (source: FrigateBrowseMediaSource) => string; }) { this._contentCallback = params?.contentCallback; this._tooltipCallback = params?.tooltipCallback; } // Get the last event fetch date. get lastFetchDate(): Date | null { return this._dateFetch ?? null; } /** * Retrieve the underlying dataset. */ get dataset(): DataSet { 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, mediaPriority: TimelineMediaType, target: FrigateBrowseMediaSource, ): void { const items: FrigateCardTimelineItem[] = []; target.children?.forEach((child) => { const event = child.frigate?.event; if ( event && BrowseMediaUtil.isTrueMedia(child) && ['video', 'image'].includes(child.media_content_type) ) { let item = this._dataset.get(event.id); if (!item) { item = { id: event.id, group: camera, content: this._contentCallback?.(child) ?? '', title: this._tooltipCallback?.(child) ?? '', start: event.start_time * 1000, event: event, }; } if ( (child.media_content_type === 'video' && ['all', 'clips'].includes(mediaPriority)) || (!item.source && child.media_content_type === 'image' && ['all', 'snapshots'].includes(mediaPriority)) ) { item.source = child; } if (event.end_time) { item['end'] = 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 { const now = new Date().getTime(); // Never fetched: no coverage. if (!this._dateFetch || !this._dateStart || !this._dateEnd) { return false; } // If the most recent fetch is older than maxAgeSeconds: no coverage. if ( this._maxAgeSeconds && now - this._dateFetch.getTime() > this._maxAgeSeconds * 1000 ) { return false; } // If the most requested data is earlier than the earliest stored: no // coverage. if (start < this._dateStart) { return false; } // If there's no end time specified: there IS coverage. if (!end) { return true; } // If the requested end time is older than the oldest requested: there IS // coverage. if (end.getTime() < this._dateEnd.getTime()) { return true; } // If there's no maxAgeSeconds specified: no coverage. if (!this._maxAgeSeconds) { return false; } // If the requested end time is beyond `_maxAgeSeconds` of now: no coverage. if (now - end.getTime() > this._maxAgeSeconds * 1000) { return false; } // End time is within `_maxAgeSeconds` of the latest data: there IS // coverage. return end.getTime() - this._maxAgeSeconds * 1000 <= this._dateEnd.getTime(); } /** * 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, cameras: Map, media: TimelineMediaType, start: Date, end: Date, ): Promise { if (this.hasCoverage(start, end)) { return false; } await this._fetchEvents(element, hass, cameras, media, 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, cameras: Map, media: TimelineMediaType, start?: Date, end?: Date, ): Promise { if (!this._dateStart || (start && start < this._dateStart)) { this._dateStart = start; } if (!this._dateEnd || (end && end > this._dateEnd)) { this._dateEnd = end; } if (!this._dateStart || !this._dateEnd) { return; } this._dateFetch = new Date(); const fetchCameraEvents = async ( camera: string, fetchMedia: 'clips' | 'snapshots', ): Promise => { const cameraConfig = cameras.get(camera); if (!cameraConfig || !this._dateStart || !this._dateEnd) { return; } const browseMediaQueryParametersBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig); if (!browseMediaQueryParametersBase) { return; } try { this._addMediaSource( camera, media, await BrowseMediaUtil.browseMediaQuery(hass, { ...browseMediaQueryParametersBase, // 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, mediaType: fetchMedia, }), ); } catch (e) { return dispatchErrorMessageEvent(element, (e as Error).message); } }; const promises: Promise[] = []; (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => promises.push( ...Array.from(cameras.keys()).map((camera) => fetchCameraEvents(camera, mediaType as 'clips' | 'snapshots'), ), ), ); await Promise.all(promises); } } @customElement('frigate-card-timeline') export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) protected hass?: HomeAssistant; @property({ attribute: false }) protected view?: Readonly; @property({ attribute: false }) protected cameras?: Map; @property({ attribute: false }) protected timelineConfig?: TimelineConfig; /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { if (!this.timelineConfig) { return html``; } return html` `; } /** * 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; @property({ attribute: false }) protected view?: Readonly; @property({ attribute: false }) protected cameras?: Map; @property({ attribute: false }) protected timelineConfig?: TimelineConfig; protected _events = new TimelineEventManager({ tooltipCallback: this._getTooltip.bind(this), }); protected _refTimeline: Ref = createRef(); protected _timeline?: Timeline; /** * Get a tooltip for a given timeline event. * @param source The FrigateBrowseMediaSource in question. * @returns The tooltip as a string to render. */ protected _getTooltip(source: FrigateBrowseMediaSource): string { if (!isHoverableDevice.matches) { // Don't display tooltips on touch devices, they just get in the way of // the drawer. return ''; } const thumbnailSizeAttr = this.timelineConfig ? `thumbnail_size="${this.timelineConfig.controls.thumbnails.size}"` : ''; const eventAttr = source.frigate?.event ? `event='${JSON.stringify(source.frigate.event)}'` : ''; const detailsAttr = this.timelineConfig?.controls.thumbnails.show_details ? 'details' : ''; // Cannot use Lit data-bindings as visjs requires a string for tooltips. // Note that changes to attributes here must be mirrored in the xss // whitelist in `_getOptions()` . return ` `; } /** * 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`
`; } protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void { if (properties.what === 'item') { // Prevent interaction with items on the timeline from activating card // wide actions. stopEventFromActivatingCardWideActions(properties.event); } } /** * 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 { if (!properties.byUser) { return; } if (this.hass && this.cameras && this._timeline && this.timelineConfig) { this._events .fetchEventsIfNecessary( this, this.hass, this.cameras, this.timelineConfig.media, properties.start, properties.end, ) .then(() => { if (this._timeline) { const thumbnails = this._generateThumbnails(); // Update the view to reflect the new thumbnails and the timeline // window in the context. this.view ?.evolve({ target: thumbnails?.target ?? null, childIndex: thumbnails?.childIndex ?? null, context: this._generateViewContext(true), }) .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.view?.target || !this.view?.target.children) { return; } const childIndex = data.items.length ? this.view.target.children.findIndex( (child) => child.frigate?.event.id === data.items[0], ) : null; this.view ?.evolve({ childIndex: childIndex, }) .dispatchChangeEvent(this); if (childIndex !== null && childIndex >= 0) { dispatchFrigateCardEvent(this, 'thumbnails:open'); } else { dispatchFrigateCardEvent(this, 'thumbnails:close'); } } /** * Regenerate the thumbnails from the timeline events. * @returns An object with two keys, or null on error. The keys are `target` * containing all the thumbnails, and `childIndex` to refer to the currently * selected thumbnail. */ protected _generateThumbnails(): { target: FrigateBrowseMediaSource; childIndex: number | null; } | null { if (!this._timeline) { return null; } /** * Sort the timeline items most recent to least recent. * @param a The first item. * @param b The second item. * @returns -1, 0, 1 (standard array sort function configuration). */ const sortEvent = ( a: FrigateCardTimelineItem, b: FrigateCardTimelineItem, ): number => { if (a.start < b.start) { return 1; } if (a.start > b.start) { return -1; } return 0; }; const selected = this._timeline.getSelection(); let childIndex = -1; const children: FrigateBrowseMediaSource[] = []; this._events.dataset.get({ order: sortEvent }).forEach((item) => { if (item.source) { children.push(item.source); if (selected.includes(item.event.id)) { childIndex = children.length - 1; } } }); if (!children.length) { return null; } const target = { title: `Timeline events`, media_class: MEDIA_CLASS_PLAYLIST, media_content_type: MEDIA_TYPE_PLAYLIST, media_content_id: '', can_play: false, can_expand: true, children_media_class: MEDIA_CLASS_PLAYLIST, thumbnail: null, children: children, }; return { target: target, childIndex: childIndex < 0 ? null : childIndex, }; } /** * 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 windowSeconds = this._getConfiguredWindowSeconds(); if (event.end_time) { if (event.end_time - event.start_time > windowSeconds) { // If the event is larger than the configured window, only show the most // recent portion of the event that fits in the window. return [ sub(fromUnixTime(event.end_time), { seconds: windowSeconds }), fromUnixTime(event.end_time), ]; } else { // If the event is shorter than the configured window, center the event // in the window. const gap = windowSeconds - (event.end_time - event.start_time); return [ sub(fromUnixTime(event.start_time), { seconds: gap / 2 }), add(fromUnixTime(event.end_time), { seconds: gap / 2 }), ]; } } // If there's no end-time yet, place the start-time in the center of the // time window. return [ sub(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }), add(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }), ]; } /** * Get the configured window length in seconds. */ protected _getConfiguredWindowSeconds(): number { return ( this.timelineConfig?.window_seconds ?? frigateCardConfigDefaults.timeline.window_seconds ); } /** * 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 end = new Date(); const start = sub(end, { seconds: this._getConfiguredWindowSeconds(), }); 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() ? { // 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?.frigate?.event?.id && !!second.id && second.id != this.view?.media?.frigate?.event?.id && (first).event.label === (second).event.label ); }, } : (false as TimelineOptionsCluster), minHeight: '100%', maxHeight: '100%', zoomMax: 1 * 24 * 60 * 60 * 1000, zoomMin: 1 * 1000, selectable: true, start: start, end: end, groupHeightMode: 'fixed', tooltip: { followMouse: true, overflowMethod: 'cap', }, xss: { disabled: false, filterOptions: { whiteList: { 'frigate-card-thumbnail': [ 'details', 'thumbnail', 'label', 'event', '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 { if ( !this.hass || !this.cameras || !this.view || !this._timeline || !this.timelineConfig ) { return; } const event = this.view?.media?.frigate?.event; const [windowStart, windowEnd] = event ? this._getStartEndFromEvent(event) : this._getStartEnd(); await this._events.fetchEventsIfNecessary( this, this.hass, this.cameras, this.timelineConfig.media, windowStart, windowEnd, ); this._timeline.setSelection(event ? [event.id] : [], { focus: false, animation: { animation: false, zoom: false, }, }); // Regenerate the thumbnails after the selection, to allow the new selection // to be in the generated view. const context = this.view.context as TimelineViewContext | null; const timelineWindow = this._timeline.getWindow(); if (context?.window) { if (!isEqual(context.window, timelineWindow)) { this._timeline.setWindow(context.window.start, context.window.end); } } else if (event) { const eventStart = new Date(event.start_time * 1000); const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0; if ( eventStart < timelineWindow.start || eventStart > timelineWindow.end || (eventEnd && (eventEnd < timelineWindow.start || eventEnd > timelineWindow.end)) ) { this._timeline.setWindow(windowStart, windowEnd); } 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(event.id); if (item) { this._events.dataset.updateOnly(item); } } } else { this._timeline.setWindow(windowStart, windowEnd); } // Compare last date of fetch with that of inbound view to avoid a loop. // Without this comparison it would be: // // Timeline receives a new `view` // -> Events fetched // -> Thumbnails generated // -> New view dispatched (to load thumbnails into outer carousel). // -> New view received ... [loop] const currentContext = this.view.context as TimelineViewContext | null; if (currentContext?.dateFetch !== this._events.lastFetchDate) { const thumbnails = this._generateThumbnails(); this.view ?.evolve({ target: thumbnails?.target ?? null, childIndex: thumbnails?.childIndex ?? null, context: this._generateViewContext(false), }) .dispatchChangeEvent(this); } } /** * Generate the context for timeline views. * @param addWindow Whether or not to include the timeline window. If `false` * the window is preserved if it is already in the context. * @returns The TimelineViewContext object. */ protected _generateViewContext(addWindow: boolean): TimelineViewContext { const currentContext = this.view?.context as TimelineViewContext | undefined; const newContext: TimelineViewContext = {}; if (addWindow && this._timeline) { newContext.window = this._timeline.getWindow(); } else if (currentContext?.window) { newContext.window = currentContext.window; } if (this._events.lastFetchDate) { newContext.dateFetch = this._events.lastFetchDate; } return newContext || null; } /** * 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) { 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)); this._timeline.on('click', this._timelineClickHandler.bind(this)); this._timeline.on('doubleclick', this._timelineClickHandler.bind(this)); } } if (changedProperties.has('view')) { this._updateTimelineFromView(); } } /** * Return compiled CSS styles. */ static get styles(): CSSResultGroup { return unsafeCSS(timelineCoreStyle); } }