From 343d7ead3a7a6bd30310751bff4b5f21b2394837 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 14 Mar 2022 22:55:54 -0700 Subject: [PATCH] Convert timeline to bars rather than icons. --- package.json | 2 +- src/browse-media-util.ts | 4 +- src/card.ts | 1 + src/components/timeline.ts | 381 ++++++++++++++++++++++++----------- src/scss/timeline-event.scss | 10 +- src/scss/timeline.scss | 26 ++- src/types.ts | 1 + 7 files changed, 291 insertions(+), 134 deletions(-) diff --git a/package.json b/package.json index faebdaf0..506f154c 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^5.1.1", "@egjs/hammerjs": "^2.0.17", - "@lit-labs/task": "^1.0.0", + "@lit-labs/task": "^1.1.1", "@material/image-list": "^13.0.0", "@material/mwc-menu": "^0.25.3", "@material/rtl": "^13.0.0", diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index 9d6966c6..f5d01c24 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -101,7 +101,9 @@ export class BrowseMediaUtil { params.clientId, 'event-search', params.mediaType, - '', // Name/Title to render (not necessary here) + + // If the name field ends in '.all' the integration will return up to 10K events. + params.unlimited ? '.all' : '', params.after ? String(Math.floor(params.after)) : '', params.before ? String(Math.ceil(params.before)) : '', params.cameraName, diff --git a/src/card.ts b/src/card.ts index 5e600b23..0b3f8cae 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1276,6 +1276,7 @@ export class FrigateCard extends LitElement { .hass=${this._hass} .view=${this._view} .cameraConfig=${cameraConfig} + .cameras=${this._cameras} .timelineConfig=${this._getConfig().timeline} > ` diff --git a/src/components/timeline.ts b/src/components/timeline.ts index a0e0b53c..ee985035 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -8,9 +8,14 @@ import { } from 'lit'; import { DataSet } from 'vis-data/esnext'; import { HomeAssistant } from 'custom-card-helpers'; -import { Timeline, TimelineOptions, TimelineOptionsCluster } from 'vis-timeline/esnext'; +import { + DataGroupCollectionType, + Timeline, + TimelineOptions, + TimelineOptionsCluster, +} from 'vis-timeline/esnext'; import { customElement, property, state } from 'lit/decorators.js'; -import { createRef, ref, Ref } from 'lit/directives/ref'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { BrowseMediaUtil } from '../browse-media-util'; import { @@ -21,12 +26,20 @@ import { frigateCardConfigDefaults, } from '../types'; import { View } from '../view'; -import { contentsChanged, dispatchFrigateCardEvent } from '../common.js'; -import { renderProgressIndicator } from './message'; +import { + contentsChanged, + dispatchErrorMessageEvent, + dispatchFrigateCardEvent, + getCameraTitle, +} from '../common.js'; import timelineStyle from '../scss/timeline.scss'; import timelineEventStyle from '../scss/timeline-event.scss'; +interface FrigateCardGroupData { + id: string; + content: string; +} interface FrigateCardTimelineData { id: string; content: string; @@ -84,6 +97,95 @@ export class FrigateCardTimelineEvent extends LitElement { } } +class TimelineEventManager { + protected _dataset = new DataSet(); + + protected _contentCallback?: (FrigateBrowseMediaSource) => string; + protected _tooltipCallback?: (FrigateBrowseMediaSource) => string; + + constructor(params: { + contentCallback?: (source: FrigateBrowseMediaSource) => string; + tooltipCallback?: (source: FrigateBrowseMediaSource) => string; + }) { + this._contentCallback = params.contentCallback; + this._tooltipCallback = params.tooltipCallback; + } + + get dataset(): DataSet { + return this._dataset; + } + + public isEmpty(): boolean { + return this._dataset.length === 0; + } + + public clear(): void { + this._dataset.clear(); + } + + protected _addMediaSource(camera: string, target: FrigateBrowseMediaSource): void { + const items: FrigateCardTimelineData[] = []; + target.children?.forEach((child) => { + if (child.frigate) { + const item = { + id: child.media_content_id, + group: camera, + content: this._contentCallback?.(child) ?? '', + title: this._tooltipCallback?.(child) ?? '', + start: child.frigate.event.start_time * 1000, + }; + 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); + } + + public async fetchEvents( + node: HTMLElement, + hass: HomeAssistant & ExtendedHomeAssistant, + cameras: Map, + start: Date, + end: Date, + ): Promise { + console.info(`fetchEvents: ${start} -> ${end}`); + + // const output = new Map(); + const fetchCameraEvents = async (camera: string): Promise => { + const cameraConfig = cameras.get(camera); + if (!cameraConfig) { + return; + } + const browseMediaQueryParameters = BrowseMediaUtil.getBrowseMediaQueryParameters( + 'clips', + cameraConfig, + ); + if (!browseMediaQueryParameters) { + return; + } + + try { + this._addMediaSource( + camera, + await BrowseMediaUtil.browseMediaQuery(hass, { + ...browseMediaQueryParameters, + unlimited: true, + }), + ); + } catch (e) { + return dispatchErrorMessageEvent(node, (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 }) @@ -93,10 +195,10 @@ export class FrigateCardTimeline extends LitElement { protected view?: Readonly; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + protected cameras?: Map; /** - * Set the Home Assistant object. + * Set the timeline configuration. */ set timelineConfig(timelineConfig: TimelineConfig) { this._timelineConfig = timelineConfig; @@ -111,86 +213,17 @@ export class FrigateCardTimeline extends LitElement { protected _timelineRef: Ref = createRef(); protected _timeline?: Timeline; - protected _boundTimelineSelectHandler = this._timelineSelectHandler.bind(this); - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.cameraConfig) { - return; - } - - if (!this.view.target) { - const browseMediaQueryParameters = - BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError( - this, - this.view, - this.cameraConfig, - ); - if (!browseMediaQueryParameters) { - return; - } - - BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange( - this, - this.hass, - this.view, - browseMediaQueryParameters, - ); - return renderProgressIndicator(); - } - return html`
`; - } - - /** - * Component connected callback. - */ - connectedCallback(): void { - super.connectedCallback(); - document.addEventListener( - 'frigate-card:timeline-select', - this._boundTimelineSelectHandler, - ); - } - - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - document.removeEventListener( - 'frigate-card:timeline-select', - this._boundTimelineSelectHandler, - ); - super.disconnectedCallback(); - } - - /** - * Called when an item on the timeline is selected. - * @param ev The click event. - */ - protected _timelineSelectHandler(ev: Event): void { - const id = (ev as CustomEvent).detail; - const index = - this.view?.target?.children?.findIndex((item) => item.media_content_id == id) ?? - -1; - if (index >= 0) { - this.view - ?.evolve({ - view: 'clip', - childIndex: index, - }) - .dispatchChangeEvent(this); - } - } + protected _events = new TimelineEventManager({ + tooltipCallback: this._generateTooltip.bind(this), + }); /** * Build the content of a single event on the timeline. * @param source The FrigateBrowseMediaSource object for this event. * @returns A string to include on the timeline. */ - protected _buildEventContent(source: FrigateBrowseMediaSource): string { + protected _generateTooltip(source: FrigateBrowseMediaSource): string { return ` `; } + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.hass || !this.view) { + return; + } + return html`
`; + } + + /** + * Component connected callback. + */ + connectedCallback(): void { + super.connectedCallback(); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + super.disconnectedCallback(); + } + + 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 is not performant in that it refetches all events in the time + // range, when some/all may already be fetched. A more optimal approach + // would be to only fetch events in time windows that haven't already been + // fetched PLUS events that did not previously have an end_time. That's + // not trivial to implement, and it's not yet clear it's worth the extra + // complexity. + this._events.fetchEvents( + this, + this.hass, + this.cameras, + properties.start, + properties.end, + ); + } + } + + /** + * Called when an object on the timeline is selected. + * @param data The data about the selection. + * @returns + */ + protected _timelineSelectHandler(data: { items: string[]; event: Event }): void { + if (data.items.length <= 0) { + return; + } + + // TODO: Make a parent, attach a select bunch of children to it and evolve the view. + } + /** * Build the visjs dataset to render on the timeline. * @returns The dataset. */ - protected _buildDataset(): DataSet { - const items: FrigateCardTimelineData[] = []; - - this.view?.target?.children?.forEach((child) => { - if (child.frigate) { - const item = { - id: child.media_content_id, - content: this._buildEventContent(child), - start: child.frigate.event.start_time * 1000, - selectable: false, - }; - if (child.frigate.event.end_time) { - //item['end'] = child.frigate.event.end_time * 1000; - } - items.push(item); - } + protected _getGroups(): DataGroupCollectionType { + const groups: FrigateCardGroupData[] = []; + this.cameras?.forEach((cameraConfig, camera) => { + groups.push({ + id: camera, + content: getCameraTitle(this.hass, cameraConfig), + }); }); - return new DataSet(items); + return new DataSet(groups); } /** @@ -239,31 +327,36 @@ export class FrigateCardTimeline extends LitElement { const thumbnailConfig = this._timelineConfig?.controls.thumbnails ?? frigateCardConfigDefaults.timeline.controls.thumbnails; - const gap = 5; // Configuration for the Timeline, see: // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options this._timelineOptions = { - cluster: thumbnailConfig.clustering_threshold > 0 - ? { - 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: thumbnailConfig.clustering_threshold, - } - : false as TimelineOptionsCluster, + cluster: + thumbnailConfig.clustering_threshold > 0 + ? { + 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: thumbnailConfig.clustering_threshold, + } + : (false as TimelineOptionsCluster), minHeight: '100%', maxHeight: '100%', - margin: { - item: thumbnailConfig.size_pixels - thumbnailConfig.overlap_pixels + gap, - axis: thumbnailConfig.size_pixels + gap, + tooltip: { + followMouse: true, + overflowMethod: 'cap', }, + zoomMax: 31 * 24 * 60 * 60 * 1000, + zoomMin: 1 * 1000, + start: this._getYesterday(), + end: this._getToday(), + groupHeightMode: 'fixed', xss: { disabled: false, filterOptions: { @@ -275,29 +368,85 @@ export class FrigateCardTimeline extends LitElement { 'thumbnail_size', ], div: ['title'], + span: ['style'], }, }, }, }; } + /** + * Get today date object. + * @returns A date object for today. + */ + protected _getToday(): Date { + return new Date(); + } + + /** + * Get yesterday date object. + * @returns A date object for yesterday. + */ + protected _getYesterday(): Date { + const yesterday = new Date(); + yesterday.setDate(this._getToday().getDate() - 1); + return yesterday; + } + + /** + * 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; + } + + /** + * Called on the first update. + * @param changedProps The changed properties. + */ + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + + if (changedProps.has('cameras')) { + this._events.clear(); + } + + if (this._events.isEmpty() && this.hass && this.cameras) { + // Fetch an initial 1-day worth of events. + this._events.fetchEvents( + this, + this.hass, + this.cameras, + this._getToday(), + this._getYesterday(), + ); + } + } + /** * Called when the component is updated. * @param changedProps The changed properties if any. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if (this._timelineRef.value) { if (this._timeline) { this._timeline.destroy(); + this._timeline = undefined; } + this._timeline = new Timeline( this._timelineRef.value, - this._buildDataset(), + this._events.dataset, + this._getGroups(), this._timelineOptions, ); + this._timeline.on('select', this._timelineSelectHandler.bind(this)); + this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this)); } } diff --git a/src/scss/timeline-event.scss b/src/scss/timeline-event.scss index 7d03c7ff..18294c49 100644 --- a/src/scss/timeline-event.scss +++ b/src/scss/timeline-event.scss @@ -4,17 +4,13 @@ height: 100%; --frigate-card-timeline-thumbnail-size: 75px; + + border-radius: 5px; + overflow: hidden; } img { width: var(--frigate-card-timeline-thumbnail-size); height: var(--frigate-card-timeline-thumbnail-size); display: block; - border-radius: 5px; - transition: transform 0.2s linear; - border: 1px solid black; -} - -img:hover { - transform: scale(1.04); } diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index 253fdd29..346e585e 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -17,12 +17,19 @@ div.timeline { } .vis-timeline { - border: hidden; + border: none; +} + +.vis-labelset .vis-label { + // Group labels. + color: var(--primary-text-color); } .vis-item { border-color: var(--primary-color); background: none; + color: var(--primary-text-color); + background-color: var(--primary-color); } .vis-item:hover { @@ -31,7 +38,7 @@ div.timeline { } .vis-item.vis-box { - border-style: hidden; + border: none; } .vis-item .vis-item-content { @@ -39,13 +46,14 @@ div.timeline { } .vis-item.vis-cluster { - width: 30px; - height: 30px; border-style: dotted; - border-radius: 50%; - padding: 5px; - color: var(--primary-text-color); background-color: var(--primary-background-color); - box-shadow: 0px 0px 20px 5px var(--primary-color); -} \ No newline at end of file + box-shadow: 0px 0px 5px 1px var(--primary-color); +} + +div.vis-tooltip { + padding: 0px; + background-color: unset; + border: none; +} diff --git a/src/types.ts b/src/types.ts index df8a8f79..51090315 100644 --- a/src/types.ts +++ b/src/types.ts @@ -868,6 +868,7 @@ export interface BrowseMediaQueryParameters { zone?: string; before?: number; after?: number; + unlimited?: boolean; } export interface BrowseMediaNeighbors {