Files
advanced-camera-card/src/components/timeline-core.ts
T

1320 lines
41 KiB
TypeScript

import {
add,
differenceInSeconds,
endOfHour,
fromUnixTime,
getUnixTime,
startOfHour,
sub,
} from 'date-fns';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { isEqual, throttle } from 'lodash-es';
import { ViewContext } from 'view';
import { DataView, DataSet } from 'vis-data/esnext';
import {
DataGroupCollectionType,
IdType,
Timeline,
TimelineEventPropertiesResult,
TimelineItem,
TimelineOptions,
TimelineOptionsCluster,
TimelineWindow,
} from 'vis-timeline/esnext';
import { CAMERA_BIRDSEYE } from '../const';
import { localize } from '../localize/localize';
import timelineCoreStyle from '../scss/timeline-core.scss';
import {
CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
frigateCardConfigDefaults,
FrigateEvent,
FrigateRecording,
TimelineCoreConfig,
} from '../types';
import { stopEventFromActivatingCardWideActions } from '../utils/action';
import {
contentsChanged,
dispatchFrigateCardEvent,
formatDateAndTime,
isHoverableDevice,
prettifyTitle,
} from '../utils/basic';
import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js';
import {
getEventMediaContentID,
getEventThumbnailURL,
getEventTitle,
getRecordingMediaContentID,
} from '../utils/frigate';
import { createEventParentForChildren, createChild } from '../utils/ha/browse-media';
import {
FrigateCardTimelineItem,
RecordingSegmentsItem,
sortSegmentsOldestToYoungest,
sortTimelineItemsYoungestToOldest,
TimelineDataManager,
} from '../utils/timeline-data-manager';
import { View } from '../view';
import { dispatchMessageEvent } from './message.js';
import './thumbnail.js';
interface FrigateCardGroupData {
id: string;
content: string;
}
interface TimelineRangeChange extends TimelineWindow {
event: Event & { additionalEvent?: string };
byUser: boolean;
}
interface TimelineViewContext {
// Force a particular timeline window rather than taking the time from an
// event / recording.
window?: TimelineWindow;
// Whether or not to set the timeline window.
noSetWindow?: boolean;
// Whether or not thumbnails were generated.
generatedThumbnails?: boolean;
}
declare module 'view' {
interface ViewContext {
timeline?: TimelineViewContext;
}
}
// An event used to fetch the HASS object. See "Special note" below.
class HASSRequestEvent extends Event {
public hass?: ExtendedHomeAssistant;
}
const TIMELINE_TARGET_BAR_ID = 'target_bar';
/**
* A simgple thumbnail wrapper class for use in the timeline where LIT data
* bindings are not available.
*/
@customElement('frigate-card-timeline-thumbnail')
export class FrigateCardTimelineThumbnail extends LitElement {
@property({ attribute: true })
public thumbnail?: string;
@property({ attribute: true, type: Boolean })
public details = false;
@property({ attribute: true })
public event?: string;
@property({ attribute: true })
public label?: string;
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
// Don't display tooltips on touch devices, they just get in the way of
// the drawer.
if (!this.thumbnail || !this.event) {
return html``;
}
/* Special note on what's going on here:
*
* This component does not have access to HASS, as there's no way to pass it
* in via the string-based tooltip that timeline supports. Instead dispatch
* an event to request HASS which the timeline adds to the event object
* before execution continues.
*/
const hassRequest = new HASSRequestEvent(`frigate-card:timeline:hass-request`, {
composed: true,
bubbles: true,
});
this.dispatchEvent(hassRequest);
if (!hassRequest.hass) {
return html``;
}
return html` <frigate-card-thumbnail
.hass=${hassRequest.hass}
.event=${JSON.parse(this.event)}
.label=${this.label}
.thumbnail=${this.thumbnail}
?details=${this.details}
>
</frigate-card-thumbnail>`;
}
}
@customElement('frigate-card-timeline-core')
export class FrigateCardTimelineCore extends LitElement {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
@property({ attribute: false, hasChanged: contentsChanged })
public timelineConfig?: TimelineCoreConfig;
@property({ attribute: true, type: Boolean })
public thumbnailDetails? = false;
@property({ attribute: false })
public thumbnailSize?: number;
// Whether or not this is a mini-timeline for a different view (e.g. media
// viewer).
@property({ attribute: true, type: Boolean, reflect: true })
public mini = false;
@property({ attribute: false })
public timelineDataManager?: TimelineDataManager;
@state()
protected _locked = false;
protected _targetBarVisible = false;
protected _refTimeline: Ref<HTMLElement> = createRef();
protected _timeline?: Timeline;
protected _dataview?: DataView<FrigateCardTimelineItem>;
// Need a way to separate when a user clicks (to pan the timeline) vs when a
// user clicks (to choose a recording (non-event) to play).
protected _pointerHeld:
| (TimelineEventPropertiesResult & { window?: TimelineWindow })
| null = null;
protected _ignoreClick = false;
protected readonly _isHoverableDevice = isHoverableDevice();
// Range changes are volumonous: throttle the calls on seeking.
protected _throttledSetViewDuringRangeChange = throttle(
this._setViewDuringRangeChange.bind(this),
1000 / 10,
);
/**
* Get a tooltip for a given timeline event.
* @param source The FrigateBrowseMediaSource in question.
* @returns The tooltip as a string to render.
*/
protected _getTooltip(item: TimelineItem): string {
const event = (<FrigateCardTimelineItem>item).event;
const clientId = item.group
? this.cameras?.get(String(item.group))?.frigate.client_id
: null;
if (!this._isHoverableDevice || !event || !clientId) {
// Don't display tooltips on touch devices, they just get in the way of
// the drawer.
return '';
}
const eventAttr = `event='${JSON.stringify(event)}'`;
const detailsAttr = this.thumbnailDetails ? '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 `
<frigate-card-timeline-thumbnail
thumbnail="${getEventThumbnailURL(clientId, event)}"
${detailsAttr}
${eventAttr}
label="${getEventTitle(event)}"
>
</frigate-card-timeline-thumbnail>`;
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (!this.hass || !this.view || !this.timelineConfig) {
return;
}
return html`<div
@frigate-card:timeline:hass-request=${(request: HASSRequestEvent) => {
request.hass = this.hass;
}}
class="timeline"
${ref(this._refTimeline)}
>
<ha-icon
class="lock"
.icon=${`mdi:${this._locked ? 'lock' : 'lock-open-variant'}`}
@click=${() => {
this._locked = !this._locked;
}}
aria-label="${this._locked
? localize('timeline.unlock')
: localize('timeline.lock')}"
title="${this._locked ? localize('timeline.unlock') : localize('timeline.lock')}"
>
</ha-icon>
</div>`;
}
/**
* Get all the keys of the cameras in scope for this timeline.
* @returns A set of camera ids (may be empty).
*/
protected _getTimelineCameraIDs(): Set<string> {
if (!this.mini || !this.cameras) {
return this._getAllCameraIDs();
}
return getAllDependentCameras(this.cameras, this.view?.camera);
}
/**
* Get all the keys of all cameras.
* @returns A set of camera ids (may be empty).
*/
protected _getAllCameraIDs(): Set<string> {
return new Set(this.cameras?.keys());
}
/**
* Create recording objects.
* @param time The target time for the recordings.
* @param cameraIDs The camera IDs to create recordings for.
* @param onlyShowMatchingHour If `true` only shows the hour matching the target
* for the provided cameras, otherwise shows all hours.
* @returns
*/
protected _createRecordingChildren(
time: Date,
cameraIDs: Set<string>,
onlyShowMatchingHour: boolean,
): FrigateBrowseMediaSource[] {
const children: FrigateBrowseMediaSource[] = [];
for (const cameraID of cameraIDs) {
const config = this.cameras?.get(cameraID);
const recordingSummary =
this.timelineDataManager?.getRecordingSummaryForCamera(cameraID);
if (!config?.frigate.camera_name || !recordingSummary) {
continue;
}
for (const dayData of recordingSummary) {
for (const hourData of dayData.hours) {
const hour = add(dayData.day, { hours: hourData.hour });
const startHour = startOfHour(hour);
const endHour = endOfHour(hour);
const isMatchingHour = time >= startHour && time <= endHour;
// If asked to only provide recordings for a given camera show all
// hours, otherwise only show the matching hour from all cameras.
if (!onlyShowMatchingHour || isMatchingHour) {
children.push(
createChild(
`${prettifyTitle(config.frigate.camera_name)} ${formatDateAndTime(
hour,
)}`,
getRecordingMediaContentID({
clientId: config.frigate.client_id,
year: dayData.day.getFullYear(),
month: dayData.day.getMonth() + 1,
day: dayData.day.getDate(),
hour: hourData.hour,
cameraName: config.frigate.camera_name,
}),
{
recording: {
camera: config.frigate.camera_name,
start_time: getUnixTime(startHour),
end_time: getUnixTime(endHour),
events: hourData.events,
},
cameraID: cameraID,
},
),
);
}
}
}
}
return children;
}
/**
* Change the view to a recording.
* @param targetTime The time of the recording to show.
* @param cameraID An optional camera to show a recording of, otherwise all
* cameras are shown at the given time.
*/
protected async _changeViewToRecording(
targetTime: Date,
cameraID?: string,
): Promise<void> {
if (!this.hass || !this.timelineConfig || !this.cameras) {
return;
}
const cameraIDs = cameraID ? new Set([cameraID]) : this._getAllCameraIDs();
const children = this._createRecordingChildren(targetTime, cameraIDs, !cameraID);
if (!children.length) {
return;
}
const viewerContext = this._generateMediaViewerContextForChildren(
children,
targetTime,
);
const childIndex = this._findChildIndex(
children,
startOfHour(targetTime),
cameraIDs,
);
const child = childIndex !== null ? children[childIndex] : null;
if (childIndex !== null && child !== null) {
this.view
?.evolve({
view: 'recording',
target: createEventParentForChildren(localize('common.recordings'), children),
childIndex: childIndex,
...(child.frigate?.cameraID && { camera: child.frigate?.cameraID }),
})
.mergeInContext(viewerContext)
.dispatchChangeEvent(this);
}
}
/**
* Find the relevant recording child given a date target.
* @param children The FrigateBrowseMediaSource[] children. Must be sorted
* most recent first.
* @param targetTime The target time used to find the relevant child.
* @param cameraIDs The camera IDs to search for.
* @param refPoint Whether to find based on the start or end of the
* event/recording. If not specified, the first match is returned rather than
* the best match.
* @returns The childindex or null if no matching child is found.
*/
protected _findChildIndex(
children: FrigateBrowseMediaSource[],
targetTime: Date,
cameraIDs: Set<string>,
refPoint?: 'start' | 'end',
): number | null {
let bestMatch:
| {
index: number;
delta: number;
}
| undefined;
for (let i = 0; i < children.length; ++i) {
const child = children[i];
if (child.frigate?.cameraID && cameraIDs.has(child.frigate.cameraID)) {
const source = child.frigate.event ?? child.frigate.recording;
if (!source?.start_time || !source?.end_time) {
continue;
}
const startTime = fromUnixTime(source.start_time);
const endTime = fromUnixTime(source.end_time);
if (startTime <= targetTime && endTime >= targetTime) {
if (!refPoint) {
return i;
}
const delta =
refPoint === 'end'
? endTime.getTime() - targetTime.getTime()
: targetTime.getTime() - startTime.getTime();
if (!bestMatch || delta < bestMatch.delta) {
bestMatch = { index: i, delta: delta };
}
}
}
}
return bestMatch ? bestMatch.index : null;
}
/**
* Called whenever the range is in the process of being changed.
* @param properties
*/
protected _timelineRangeChangeHandler(properties: TimelineRangeChange): void {
if (this._pointerHeld) {
this._ignoreClick = true;
}
if (
this._timeline &&
properties.byUser &&
// Do not adjust select children or seek during zoom events.
properties.event.type !== 'wheel' &&
properties.event.additionalEvent !== 'pinchin' &&
properties.event.additionalEvent !== 'pinchout'
) {
const targetTime = this._pointerHeld?.window
? add(properties.start, {
seconds:
(this._pointerHeld.time.getTime() -
this._pointerHeld.window.start.getTime()) /
1000,
})
: properties.end;
if (this._pointerHeld) {
this._setTargetBarAppropriately(targetTime);
}
this._throttledSetViewDuringRangeChange(targetTime, properties);
}
}
/**
* Set the target bar at a given time.
* @param targetTime
*/
protected _setTargetBarAppropriately(targetTime: Date): void {
if (!this._timeline) {
return;
}
const targetBarOn =
!this._locked ||
(!this.view?.is('timeline') &&
this._timeline.getSelection().some((id) => {
const item = this._dataview?.get(id);
return (
item &&
item.start &&
item.end &&
targetTime.getTime() >= item.start &&
targetTime.getTime() <= item.end
);
}));
if (targetBarOn) {
if (!this._targetBarVisible) {
this._timeline?.addCustomTime(targetTime, TIMELINE_TARGET_BAR_ID);
this._targetBarVisible = true;
} else {
this._timeline?.setCustomTime(targetTime, TIMELINE_TARGET_BAR_ID);
}
} else {
this._removeTargetBar();
}
}
/**
* Remove the target bar.
*/
protected _removeTargetBar(): void {
if (this._targetBarVisible) {
this._timeline?.removeCustomTime(TIMELINE_TARGET_BAR_ID);
this._targetBarVisible = false;
}
}
/**
* Set the view during a range change.
* @param targetTime The target time.
* @param properties The range change properties.
* @returns
*/
protected _setViewDuringRangeChange(
targetTime: Date,
properties: TimelineRangeChange,
): void {
if (!this._timeline || !this.view || !this.view.target?.children?.length) {
return;
}
const canSeek = !!this.view?.isViewerView();
const context = canSeek
? this._generateMediaViewerContextForChildren(
this.view.target.children,
targetTime,
)
: null;
const childIndex = this._locked
? null
: this._findChildIndex(
this.view.target.children,
targetTime,
this._getTimelineCameraIDs(),
properties.event.additionalEvent === 'panright' ? 'end' : 'start',
);
if (canSeek || (childIndex !== null && childIndex !== this.view.childIndex)) {
this.view
.evolve({
...(childIndex !== null && {
childIndex: childIndex,
}),
}) // Whether or not to set the timeline window.
.mergeInContext({
...this._generateTimelineContext({ noSetWindow: true }),
...context,
})
.dispatchChangeEvent(this);
}
}
/**
* Generate the media view context for a set of media children (used to set
* seek times into each media item).
* @param children The media children.
* @param targetTime The target time.
* @returns The ViewContext.
*/
protected _generateMediaViewerContextForChildren(
children: FrigateBrowseMediaSource[],
targetTime: Date,
): ViewContext {
if (!this.timelineDataManager) {
return {};
}
const seek = new Map();
const segmentsDataset = this.timelineDataManager.recordingSegments;
const hourStart = startOfHour(targetTime);
children.forEach((child, index) => {
const source = child.frigate?.recording ?? child.frigate?.event;
if (source && source.end_time && child.frigate?.cameraID) {
const start = source.start_time * 1000;
const end = source.end_time * 1000;
let seekSeconds: number | null = null;
if (targetTime.getTime() >= start && targetTime.getTime() <= end) {
const segments = segmentsDataset.get({
filter: (segment) =>
segment.cameraID === child.frigate?.cameraID &&
segment.start >= start &&
segment.end <= end,
order: sortSegmentsOldestToYoungest,
});
seekSeconds = this._getSeekTimeInSegments(
// Recordings start from the top of the hour.
child.frigate.recording ? hourStart : fromUnixTime(source.start_time),
targetTime,
segments,
);
}
if (seekSeconds !== null) {
seek.set(index, {
seekSeconds: seekSeconds,
seekTime: targetTime.getTime() / 1000,
});
}
}
});
return seek.size > 0 ? { mediaViewer: { seek: seek } } : {};
}
/**
* Get the number of seconds to seek into a video stream consisting of the
* provided segments to reach the target time provided.
* @param startTime The earliest allowable time to seek from.
* @param targetTime Target time.
* @param segments An array of segments dataset items. Must be sorted from oldest to youngest.
* @returns
*/
protected _getSeekTimeInSegments(
startTime: Date,
targetTime: Date,
segments: RecordingSegmentsItem[],
): number | null {
if (!segments.length) {
return null;
}
let seekMilliseconds = 0;
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
for (const segment of segments) {
if (segment.start > targetTime.getTime()) {
break;
}
const start =
segment.start < startTime.getTime() ? startTime.getTime() : segment.start;
const end =
segment.end > targetTime.getTime() ? targetTime.getTime() : segment.end;
seekMilliseconds += end - start;
}
return seekMilliseconds / 1000;
}
/**
* Called whenever the timeline is clicked.
* @param properties The properties of the timeline click event.
*/
protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void {
// Calls to stopEventFromActivatingCardWideActions() are included for
// completeness. Timeline does not support card-wide events and they are
// disabled in card.ts in `_getMergedActions`.
if (properties.what === 'item' || this._ignoreClick) {
stopEventFromActivatingCardWideActions(properties.event);
}
if (!this._ignoreClick && properties.what) {
if (
this.timelineConfig?.show_recordings &&
['background', 'group-label', 'axis'].includes(properties.what)
) {
if (['background', 'group-label'].includes(properties.what)) {
stopEventFromActivatingCardWideActions(properties.event);
const window = this._timeline?.getWindow();
if (window) {
if (properties.group) {
this._changeViewToRecording(
properties.what === 'background' ? properties.time : window.end,
String(properties.group),
);
} else if (this.mini && this.view?.camera) {
// In mini mode group may not be displayed / used, so just use the camera directly.
this._changeViewToRecording(window.end, this.view.camera);
}
}
} else {
stopEventFromActivatingCardWideActions(properties.event);
this._changeViewToRecording(properties.time);
}
} else if (
properties.what === 'item' &&
properties.item &&
this.view &&
this.view.target?.children
) {
let childIndex: number | null = null;
let target: FrigateBrowseMediaSource | null = null;
let context: ViewContext = {};
if (this.view.is('recording')) {
const thumbnails = this._generateThumbnails(properties.item);
if (thumbnails) {
target = thumbnails.target;
childIndex = thumbnails.childIndex;
if (thumbnails.target?.children?.length) {
context = this._generateMediaViewerContextForChildren(
thumbnails.target.children,
properties.time,
);
}
}
} else {
childIndex = this.view.target.children.findIndex(
(child) => child.frigate?.event?.id === properties.item,
);
}
if (childIndex !== null && childIndex >= 0) {
this.view
?.evolve({
childIndex: childIndex,
...(target && { target: target }),
})
.mergeInContext(context)
.dispatchChangeEvent(this);
if (!this.view.isViewerView()) {
dispatchFrigateCardEvent(this, 'thumbnails:open');
}
} else if (!this.view.isViewerView()) {
dispatchFrigateCardEvent(this, 'thumbnails:close');
}
}
}
this._ignoreClick = false;
}
/**
* Get a broader prefetch window from a start and end basis.
* @param start The earlier date.
* @param end The later date.
* @returns An object with a `start` and `end` key to prefetch.
*/
protected _getPrefetchWindow(start: Date, end: Date): [Date, Date] {
const delta = differenceInSeconds(end, start);
return [sub(start, { seconds: delta }), add(end, { seconds: delta })];
}
/**
* Handle a range change in the timeline.
* @param properties vis.js provided range information.
*/
protected _timelineRangeChangedHandler(properties: {
start: Date;
end: Date;
byUser: boolean;
event: Event & { additionalEvent: string };
}): void {
if (!properties.byUser) {
return;
}
this._removeTargetBar();
if (this.hass && this.cameras && this._timeline && this.timelineConfig) {
const [prefetchStart, prefetchEnd] = this._getPrefetchWindow(
properties.start,
properties.end,
);
this.timelineDataManager
?.fetchIfNecessary(this, this.hass, prefetchStart, prefetchEnd)
.then(() => {
// Don't show event thumbnails if the user is looking at recordings,
// as the recording "hours" are the media, not the event
// clips/snapshots.
if (this._timeline && this.view && !this.view?.is('recording')) {
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,
})
.mergeInContext(this._generateTimelineContext({ noSetWindow: true }))
.dispatchChangeEvent(this);
}
});
}
}
/**
* Regenerate the thumbnails from the timeline events.
* @param selectedItem An id to select from the thumbnails (currently selected
* item is used if none is specified).
* @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(selectedItem?: IdType): {
target: FrigateBrowseMediaSource;
childIndex: number | null;
} | null {
if (!this._timeline) {
return null;
}
const selected: IdType[] = selectedItem
? [selectedItem]
: this._timeline.getSelection();
let childIndex = -1;
const children: FrigateBrowseMediaSource[] = [];
this._dataview
?.get({
filter: (item) => item.type !== 'background',
order: sortTimelineItemsYoungestToOldest,
})
.forEach((item) => {
const cameraID = item.group ? String(item.group) : null;
const cameraConfig = cameraID ? this.cameras?.get(cameraID) : null;
const event = item.event;
const media =
event?.has_clip && this.timelineConfig?.media !== 'snapshots'
? 'clips'
: event?.has_snapshot
? 'snapshots'
: null;
if (
cameraID &&
cameraConfig &&
event &&
media &&
cameraConfig.frigate.camera_name
) {
children.push(
createChild(
getEventTitle(event),
getEventMediaContentID(
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
event.id,
media,
),
{
thumbnail: getEventThumbnailURL(cameraConfig.frigate.client_id, event),
event: event,
cameraID: cameraID,
},
),
);
if (selected.includes(event.id)) {
childIndex = children.length - 1;
}
}
});
if (!children.length) {
return null;
}
return {
target: createEventParentForChildren('Timeline events', children),
childIndex: childIndex < 0 ? null : childIndex,
};
}
/**
* Build the visjs dataset to render on the timeline.
* @returns The dataset.
*/
protected _getGroups(): DataGroupCollectionType {
const groups: FrigateCardGroupData[] = [];
this._getTimelineCameraIDs().forEach((cameraID) => {
const cameraConfig = this.cameras?.get(cameraID);
if (cameraConfig) {
if (
cameraConfig.frigate.camera_name &&
cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE
) {
groups.push({
id: cameraID,
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 }),
];
}
/**
* Given a recording get the start/end window.
* @param recording The FrigateRecording to consider.
* @returns A tuple of start/end date.
*/
protected _getStartEndFromRecording(recording: FrigateRecording): [Date, Date] {
return [fromUnixTime(recording.start_time), fromUnixTime(recording.end_time)];
}
/**
* 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 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
);
}
/**
* Get timeline options.
*/
protected _getOptions(): TimelineOptions | null {
if (!this.timelineConfig) {
return null;
}
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.type, second.type].every((type) => type !== 'background') &&
first.type === second.type &&
!!first.id &&
first.id !== this.view?.media?.frigate?.event?.id &&
!!second.id &&
second.id != this.view?.media?.frigate?.event?.id &&
(<FrigateCardTimelineItem>first).event?.label ===
(<FrigateCardTimelineItem>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: 'auto',
tooltip: {
followMouse: true,
overflowMethod: 'cap',
template: this._getTooltip.bind(this),
},
xss: {
disabled: false,
filterOptions: {
whiteList: {
'frigate-card-timeline-thumbnail': [
'details',
'thumbnail',
'label',
'event',
],
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> {
if (!this.hass || !this.cameras || !this.view || !this.timelineConfig) {
return;
}
const event = this.view?.media?.frigate?.event;
const recording = this.view?.media?.frigate?.recording;
const [windowStart, windowEnd] = event
? this._getStartEndFromEvent(event)
: recording
? this._getStartEndFromRecording(recording)
: this._getStartEnd();
let fetched = false;
if (!this._pointerHeld) {
// Don't fetch any data or touch the timeline in any way if the user is
// currently interacting with it. Without this the subsequent data fetches
// (via fetchIfNecessary) may update the timeline contents which causes
// the visjs timeline to stop dragging/panning operations which is very
// disruptive to the user.
const [prefetchStart, prefetchEnd] = this._getPrefetchWindow(
windowStart,
windowEnd,
);
fetched = !!(await this.timelineDataManager?.fetchIfNecessary(
this,
this.hass,
prefetchStart,
prefetchEnd,
));
}
this._timeline?.setSelection(event ? [event.id] : [], {
focus: false,
animation: {
animation: false,
zoom: false,
},
});
if (!this._pointerHeld && event && 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. Only do this when the pointer is not held to avoid
// interrupting the user and to make the timeline smoother.
this.timelineDataManager?.rewriteItem(event.id);
}
if (
!this._pointerHeld &&
!this.view.context?.timeline?.noSetWindow &&
this._timeline
) {
// Regenerate the thumbnails after the selection, to allow the new selection
// to be in the generated view.
const context = this.view.context?.timeline;
const timelineWindow = this._timeline.getWindow();
// If there's a set context window, always move to it.
if (context?.window && !isEqual(context.window, timelineWindow)) {
this._timeline.setWindow(context.window.start, context.window.end);
} else if (event || recording) {
const source = event ?? (recording as FrigateEvent | FrigateRecording);
const start = fromUnixTime(source.start_time);
const end = source.end_time ? fromUnixTime(source.end_time) : 0;
// If there's an event or recording outside the current window, move to it.
if (
start < timelineWindow.start ||
start > timelineWindow.end ||
(end && (end < timelineWindow.start || end > timelineWindow.end))
) {
this._timeline.setWindow(windowStart, windowEnd);
}
}
}
// Only generate thumbnails if an actual fetch occurred, to avoid getting
// stuck in a loop (the subsequent fetches will not actually fetch since the
// data will have been cached).
//
// Timeline receives a new `view`
// -> Events fetched
// -> Thumbnails generated
// -> New view dispatched (to load thumbnails into outer carousel).
// -> New view received ... [loop]
//
// Also don't generate thumbnails in mini-timelines (they will already have
// been generated), or if the media child is a recording.
if (
(fetched || !this.view.context?.timeline?.generatedThumbnails) &&
!this.mini &&
!recording
) {
const thumbnails = this._generateThumbnails();
this.view
?.evolve({
target: thumbnails?.target ?? null,
childIndex: thumbnails?.childIndex ?? null,
})
.mergeInContext(this._generateTimelineContext())
.dispatchChangeEvent(this);
}
}
/**
* Generate the context for timeline views.
* @param options Configure how the context is set.
* @returns The TimelineViewContext object.
*/
protected _generateTimelineContext(options?: {
noSetWindow?: boolean;
generatedThumbnails?: boolean;
}): ViewContext {
const newContext: TimelineViewContext = {
generatedThumbnails: options?.generatedThumbnails ?? true,
};
if (options?.noSetWindow) {
newContext.noSetWindow = options.noSetWindow;
}
return { timeline: newContext };
}
/**
* Called when an update will occur.
* @param changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('thumbnailSize')) {
if (this.thumbnailSize !== undefined) {
this.style.setProperty(
'--frigate-card-thumbnail-size',
`${this.thumbnailSize}px`,
);
} else {
this.style.removeProperty('--frigate-card-thumbnail-size');
}
}
if (changedProps.has('timelineConfig')) {
if (this.timelineConfig?.show_recordings) {
this.setAttribute('recordings', '');
} else {
this.removeAttribute('recordings');
}
}
}
/**
* Destroy/reset the timeline.
*/
protected _destroy(): void {
this._timeline?.destroy();
this._timeline = undefined;
}
/**
* 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._destroy();
}
const options = this._getOptions();
if (
this.timelineDataManager &&
this._refTimeline.value &&
options &&
this.timelineConfig &&
(changedProperties.has('timelineConfig') ||
(this.mini &&
changedProperties.has('view') &&
this.view?.camera !== changedProperties.get('view').camera))
) {
if (this._timeline) {
this._destroy();
}
const groups = this._getGroups();
if (!groups.length) {
if (!this.mini) {
// Don't show an empty timeline, show a message instead.
dispatchMessageEvent(this, localize('error.timeline_no_cameras'), 'info', {
icon: 'mdi:chart-gantt',
});
}
return;
}
this._dataview = this.timelineDataManager.createDataView(
this._getTimelineCameraIDs(),
!!this.timelineConfig.show_recordings,
this.timelineConfig.media,
);
if (this.mini && groups.length === 1) {
// In a mini timeline, if there's only one group don't bother grouping
// at all.
this._timeline = new Timeline(
this._refTimeline.value,
this._dataview,
options,
) as Timeline;
this.removeAttribute('groups');
} else {
this._timeline = new Timeline(
this._refTimeline.value,
this._dataview,
groups,
options,
) as Timeline;
this.setAttribute('groups', '');
}
this._timeline.on('rangechanged', this._timelineRangeChangedHandler.bind(this));
this._timeline.on('click', this._timelineClickHandler.bind(this));
this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this));
// This complexity exists to ensure we can tell between a click that
// causes the timeline zoom/range to change, and a 'static' click on the
// // timeline (which may need to trigger a card wide event).
this._timeline.on('mouseDown', (ev: TimelineEventPropertiesResult) => {
const window = this._timeline?.getWindow();
this._pointerHeld = {
...ev,
...(window && { window: window }),
};
this._ignoreClick = false;
});
this._timeline.on('mouseUp', () => {
this._pointerHeld = null;
this._removeTargetBar();
});
}
if (changedProperties.has('view')) {
this._updateTimelineFromView();
}
}
/**
* Return compiled CSS styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(timelineCoreStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-timeline-thumbnail': FrigateCardTimelineThumbnail;
'frigate-card-timeline-core': FrigateCardTimelineCore;
}
}