Convert timeline to bars rather than icons.

This commit is contained in:
Dermot Duffy
2022-03-19 09:00:21 -07:00
parent f2c9888ce9
commit 343d7ead3a
7 changed files with 291 additions and 134 deletions
+1 -1
View File
@@ -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",
+3 -1
View File
@@ -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,
+1
View File
@@ -1276,6 +1276,7 @@ export class FrigateCard extends LitElement {
.hass=${this._hass}
.view=${this._view}
.cameraConfig=${cameraConfig}
.cameras=${this._cameras}
.timelineConfig=${this._getConfig().timeline}
>
</frigate-card-timeline>`
+265 -116
View File
@@ -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<FrigateCardTimelineData>();
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<FrigateCardTimelineData> {
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<string, CameraConfig>,
start: Date,
end: Date,
): Promise<void> {
console.info(`fetchEvents: ${start} -> ${end}`);
// const output = new Map<string, FrigateBrowseMediaSource>();
const fetchCameraEvents = async (camera: string): Promise<void> => {
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<View>;
@property({ attribute: false })
protected cameraConfig?: CameraConfig;
protected cameras?: Map<string, CameraConfig>;
/**
* 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<HTMLElement> = 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` <div class="timeline" ${ref(this._timelineRef)}></div>`;
}
/**
* 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<string>).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 `
<frigate-card-timeline-event
media_id=${source.media_content_id}
@@ -204,28 +237,83 @@ export class FrigateCardTimeline extends LitElement {
</frigate-card-timeline-event>`;
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (!this.hass || !this.view) {
return;
}
return html` <div class="timeline" ${ref(this._timelineRef)}></div>`;
}
/**
* 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<FrigateCardTimelineData> {
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));
}
}
+3 -7
View File
@@ -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);
}
+17 -9
View File
@@ -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);
}
box-shadow: 0px 0px 5px 1px var(--primary-color);
}
div.vis-tooltip {
padding: 0px;
background-color: unset;
border: none;
}
+1
View File
@@ -868,6 +868,7 @@ export interface BrowseMediaQueryParameters {
zone?: string;
before?: number;
after?: number;
unlimited?: boolean;
}
export interface BrowseMediaNeighbors {