diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 60438afe..281c8dfd 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -1,5 +1,5 @@ import './surround.js'; -import './timeline'; +import './timeline-core.js'; import { CSSResultGroup, @@ -43,7 +43,7 @@ declare module 'view' { } @customElement('frigate-card-surround-thumbnails') -export class FrigateCardSurround extends LitElement { +export class FrigateCardSurroundThumbnails extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @@ -222,6 +222,6 @@ export class FrigateCardSurround extends LitElement { declare global { interface HTMLElementTagNameMap { - 'frigate-card-surround-thumbnails': FrigateCardSurround; + 'frigate-card-surround-thumbnails': FrigateCardSurroundThumbnails; } } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts new file mode 100644 index 00000000..114dc69c --- /dev/null +++ b/src/components/timeline-core.ts @@ -0,0 +1,1253 @@ +import { + add, + differenceInSeconds, + endOfHour, + format, + 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, + TimelineCoreConfig, +} from '../types'; +import { stopEventFromActivatingCardWideActions } from '../utils/action'; +import { + contentsChanged, + dispatchFrigateCardEvent, + isHoverableDevice, + prettifyTitle, +} from '../utils/basic'; +import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js'; + +import { + createEventParentForChildren, + createVideoChild, + generateRecordingIdentifier, +} 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 { + // The selected timeline window. + window?: TimelineWindow; + + // The date of the last event fetch. + dateFetch?: Date; + + // 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` + `; + } +} + +@customElement('frigate-card-timeline-core') +export class FrigateCardTimelineCore extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public cameras?: Map; + + @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 = createRef(); + protected _timeline?: Timeline; + protected _dataview?: DataView; + + // 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 source = (item).source; + if (!this._isHoverableDevice || !source) { + // Don't display tooltips on touch devices, they just get in the way of + // the drawer. + return ''; + } + + const eventAttr = source.frigate?.event + ? `event='${JSON.stringify(source.frigate.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 ` + + `; + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.hass || !this.view || !this.timelineConfig) { + return; + } + return html`
{ + request.hass = this.hass; + }} + class="timeline" + ${ref(this._refTimeline)} + > + { + this._locked = !this._locked; + }} + aria-label="${this._locked + ? localize('timeline.unlock') + : localize('timeline.lock')}" + title="${this._locked ? localize('timeline.unlock') : localize('timeline.lock')}" + > + +
`; + } + + /** + * Get all the keys of the cameras in scope for this timeline. + * @returns A set of camera ids (may be empty). + */ + protected _getTimelineCameraIDs(): Set { + 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 { + 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, + 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( + createVideoChild( + `${prettifyTitle(config.frigate.camera_name)} ${format( + hour, + 'yyyy-MM-dd HH:mm', + )}`, + generateRecordingIdentifier({ + 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 { + 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, + 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._timeline && properties.byUser) { + if (this._pointerHeld) { + this._ignoreClick = true; + } + + 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, + }), + }) + .mergeInContext({ ...this._generateTimelineContext(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 + */ + 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(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); + dispatchFrigateCardEvent(this, 'thumbnails:open'); + } else { + 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; + }): 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(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({ order: sortTimelineItemsYoungestToOldest }).forEach((item) => { + if (item.event && item.source) { + children.push(item.source); + if (selected.includes(item.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 }), + ]; + } + + /** + * 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 + ); + } + + /** + * Handle timeline resize. + */ + 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 && + (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: '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 { + if ( + !this.hass || + !this.cameras || + !this.view || + !this.timelineConfig || + !this._timeline + ) { + return; + } + + const event = this.view?.media?.frigate?.event; + const [windowStart, windowEnd] = event + ? this._getStartEndFromEvent(event) + : 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 (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. + this.timelineDataManager?.rewriteItem(event.id); + } + + if (!this._pointerHeld) { + // 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 (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); + } + } else { + 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] + + if ((fetched || !this.view.context?.timeline?.generatedThumbnails) && !this.mini) { + const thumbnails = this._generateThumbnails(); + this.view + ?.evolve({ + target: thumbnails?.target ?? null, + childIndex: thumbnails?.childIndex ?? null, + }) + .mergeInContext(this._generateTimelineContext(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 _generateTimelineContext(addWindow: boolean): ViewContext { + const currentContext = this.view?.context?.timeline; + const newContext: TimelineViewContext = { + generatedThumbnails: true, + }; + + if (addWindow && this._timeline) { + newContext.window = this._timeline.getWindow(); + } else if (currentContext?.window) { + newContext.window = currentContext.window; + } + if (this.timelineDataManager?.lastFetchDate) { + newContext.dateFetch = this.timelineDataManager.lastFetchDate; + } + 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 && + (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(), + ); + + 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; + + // if (ev.what && ['background', 'axis'].includes(ev.what)) { + // this._setTargetBarAppropriately(ev.time); + // } + }); + 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; + } +} diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 36e88103..7bb95b55 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,172 +1,24 @@ // TODO: When a media viewer is first loaded the selected child won't work (because the underlying carousel has not yet rendered) // TODO: rename surround to surround basic and this file to surround? -// TODO: get rid of circular dependency: src/components/surround-thumbnails.ts -> src/components/timeline.ts -> src/components/surround-thumbnails.ts // TODO: thumbnails in drawers don't work. // TODO: delete segments if not in summary? is this actually necessary? could it create gaps in data? better off stopping access via summary? // TODO: support filtering created dataviews by recordings or mediatype (so storage ) // TODO: dataview refresh instead of rewriteitem? // TODO: Make minitimeline configurable in the editor -import { - add, - differenceInSeconds, - endOfHour, - format, - 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 { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; import timelineStyle from '../scss/timeline.scss'; -import { - CameraConfig, - ExtendedHomeAssistant, - FrigateBrowseMediaSource, - frigateCardConfigDefaults, - FrigateEvent, - TimelineConfig, - TimelineCoreConfig, -} from '../types'; -import { stopEventFromActivatingCardWideActions } from '../utils/action'; -import { - contentsChanged, - dispatchFrigateCardEvent, - isHoverableDevice, - prettifyTitle, -} from '../utils/basic'; -import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js'; - -import { - createEventParentForChildren, - createVideoChild, - generateRecordingIdentifier, -} from '../utils/ha/browse-media'; -import { - FrigateCardTimelineItem, - RecordingSegmentsItem, - sortSegmentsOldestToYoungest, - sortTimelineItemsYoungestToOldest, - TimelineDataManager, -} from '../utils/timeline-data-manager'; +import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; +import { TimelineDataManager } from '../utils/timeline-data-manager'; import { View } from '../view'; -import { dispatchMessageEvent } from './message.js'; import './surround-thumbnails.js'; +import './timeline-core.js'; -interface FrigateCardGroupData { - id: string; - content: string; -} - -interface TimelineRangeChange extends TimelineWindow { - event: Event & { additionalEvent: string }; - byUser: boolean; -} - -interface TimelineViewContext { - // The selected timeline window. - window?: TimelineWindow; - - // The date of the last event fetch. - dateFetch?: Date; - - // 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` - `; - } -} +// This file is kept separate from timeline-core.ts to avoid a circular dependency: +// FrigateCardTimeline -> +// FrigateCardSurroundThumbnails -> +// FrigateCardTimelineCore @customElement('frigate-card-timeline') export class FrigateCardTimeline extends LitElement { @@ -221,1098 +73,8 @@ export class FrigateCardTimeline extends LitElement { } } -@customElement('frigate-card-timeline-core') -export class FrigateCardTimelineCore extends LitElement { - @property({ attribute: false }) - public hass?: ExtendedHomeAssistant; - - @property({ attribute: false }) - public view?: Readonly; - - @property({ attribute: false }) - public cameras?: Map; - - @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 = createRef(); - protected _timeline?: Timeline; - protected _dataview?: DataView; - - // 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 source = (item).source; - if (!this._isHoverableDevice || !source) { - // Don't display tooltips on touch devices, they just get in the way of - // the drawer. - return ''; - } - - const eventAttr = source.frigate?.event - ? `event='${JSON.stringify(source.frigate.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 ` - - `; - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.timelineConfig) { - return; - } - return html`
{ - request.hass = this.hass; - }} - class="timeline" - ${ref(this._refTimeline)} - > - { - this._locked = !this._locked; - }} - aria-label="${this._locked - ? localize('timeline.unlock') - : localize('timeline.lock')}" - title="${this._locked ? localize('timeline.unlock') : localize('timeline.lock')}" - > - -
`; - } - - /** - * Get all the keys of the cameras in scope for this timeline. - * @returns A set of camera ids (may be empty). - */ - protected _getTimelineCameraIDs(): Set { - 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 { - 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, - 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( - createVideoChild( - `${prettifyTitle(config.frigate.camera_name)} ${format( - hour, - 'yyyy-MM-dd HH:mm', - )}`, - generateRecordingIdentifier({ - 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 { - 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, - 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._timeline && properties.byUser) { - if (this._pointerHeld) { - this._ignoreClick = true; - } - - 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, - }), - }) - .mergeInContext({ ...this._generateTimelineContext(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 - */ - 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(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); - dispatchFrigateCardEvent(this, 'thumbnails:open'); - } else { - 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; - }): 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(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({ order: sortTimelineItemsYoungestToOldest }).forEach((item) => { - if (item.event && item.source) { - children.push(item.source); - if (selected.includes(item.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 }), - ]; - } - - /** - * 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 - ); - } - - /** - * Handle timeline resize. - */ - 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 && - (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: '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 { - if ( - !this.hass || - !this.cameras || - !this.view || - !this.timelineConfig || - !this._timeline - ) { - return; - } - - const event = this.view?.media?.frigate?.event; - const [windowStart, windowEnd] = event - ? this._getStartEndFromEvent(event) - : 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 (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. - this.timelineDataManager?.rewriteItem(event.id); - } - - if (!this._pointerHeld) { - // 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 (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); - } - } else { - 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] - - if ((fetched || !this.view.context?.timeline?.generatedThumbnails) && !this.mini) { - const thumbnails = this._generateThumbnails(); - this.view - ?.evolve({ - target: thumbnails?.target ?? null, - childIndex: thumbnails?.childIndex ?? null, - }) - .mergeInContext(this._generateTimelineContext(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 _generateTimelineContext(addWindow: boolean): ViewContext { - const currentContext = this.view?.context?.timeline; - const newContext: TimelineViewContext = { - generatedThumbnails: true, - }; - - if (addWindow && this._timeline) { - newContext.window = this._timeline.getWindow(); - } else if (currentContext?.window) { - newContext.window = currentContext.window; - } - if (this.timelineDataManager?.lastFetchDate) { - newContext.dateFetch = this.timelineDataManager.lastFetchDate; - } - 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 && - (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(), - ); - - 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; - - // if (ev.what && ['background', 'axis'].includes(ev.what)) { - // this._setTargetBarAppropriately(ev.time); - // } - }); - 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; 'frigate-card-timeline': FrigateCardTimeline; } } diff --git a/src/utils/frigate.ts b/src/utils/frigate.ts index d7369737..6c61da10 100644 --- a/src/utils/frigate.ts +++ b/src/utils/frigate.ts @@ -1,7 +1,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import { z } from 'zod'; import { localize } from '../localize/localize'; -import { CameraConfig, ExtendedHomeAssistant, FrigateCardError } from '../types'; +import { ExtendedHomeAssistant, FrigateCardError } from '../types'; import { homeAssistantWSRequest } from './ha'; export const FRIGATE_ICON_SVG_PATH =