From eb1b44eb27a7621e54113aa258e4f85401dddcb7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 18 Sep 2022 11:34:31 -0700 Subject: [PATCH] Initial mini-timeline commit. --- src/card.ts | 18 +- src/components/carousel.ts | 9 +- src/components/live.ts | 14 +- src/components/surround-thumbnails.ts | 89 +- src/components/thumbnail-carousel.ts | 1 + src/components/thumbnail.ts | 39 +- src/components/timeline.ts | 1259 ++++++++++++------------- src/components/viewer.ts | 53 +- src/localize/languages/en.json | 7 +- src/patches/ha-hls-player.ts | 21 +- src/scss/drawer.scss | 2 +- src/scss/thumbnail-details.scss | 4 +- src/scss/timeline-core.scss | 38 +- src/types.ts | 77 +- src/utils/camera.ts | 35 + src/utils/frigate.ts | 30 +- src/utils/ha/browse-media.ts | 60 +- src/utils/timeline-data-manager.ts | 510 ++++++++++ src/view.ts | 14 +- 19 files changed, 1461 insertions(+), 819 deletions(-) create mode 100644 src/utils/timeline-data-manager.ts diff --git a/src/card.ts b/src/card.ts index 7aa66404..f595f678 100644 --- a/src/card.ts +++ b/src/card.ts @@ -100,6 +100,7 @@ import { isValidMediaLoadedInfo } from './utils/media-info.js'; import { View } from './view.js'; import pkg from '../package.json'; import { ViewContext } from 'view'; +import { TimelineDataManager } from './utils/timeline-data-manager.js'; /** A note on media callbacks: * @@ -201,6 +202,9 @@ export class FrigateCard extends LitElement { // A cache of resolved media URLs/mimetypes for use in the whole card. protected _resolvedMediaCache = new ResolvedMediaCache(); + // Shared timeline data manager (for main timeline view and mini-timelines). + protected _timelineDataManager?: TimelineDataManager; + // The mouse handler may be called continually, throttle it to at most once // per second for performance reasons. protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000); @@ -1014,7 +1018,7 @@ export class FrigateCard extends LitElement { /** * Called before each update. */ - protected willUpdate(): void { + protected willUpdate(changedProps: PropertyValues): void { // Side load the necessary elements if not already initialized. if (!this._initialized) { sideLoadHomeAssistantElements().then((success) => { @@ -1023,6 +1027,12 @@ export class FrigateCard extends LitElement { } }); } + + if (this._cameras && (changedProps.has('_config') || changedProps.has('_cameras'))) { + this._timelineDataManager = new TimelineDataManager( + this._cameras, this._config.timeline.media, this._config.timeline.show_recordings + ) + } } /** @@ -1877,7 +1887,7 @@ export class FrigateCard extends LitElement { protected _render(): TemplateResult | void { const cameraConfig = this._getSelectedCameraConfig(); - if (!this._hass || !this._view || !cameraConfig) { + if (!this._hass || !this._view || !cameraConfig || !this._cameras) { return html``; } @@ -1915,6 +1925,7 @@ export class FrigateCard extends LitElement { .cameras=${this._cameras} .viewerConfig=${this._getConfig().media_viewer} .resolvedMediaCache=${this._resolvedMediaCache} + .timelineDataManager=${this._timelineDataManager} > ` : ``} @@ -1922,9 +1933,9 @@ export class FrigateCard extends LitElement { ? html` ` : ``} @@ -1945,6 +1956,7 @@ export class FrigateCard extends LitElement { .conditionState=${this._conditionState} .liveOverrides=${getOverridesByKey(this._getConfig().overrides, 'live')} .cameras=${this._cameras} + .timelineDataManager=${this._timelineDataManager} class="${classMap(liveClasses)}" > diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 4a018780..3a1fbbc4 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -105,7 +105,14 @@ export class FrigateCardCarousel extends LitElement { * @param index Slide number. */ public carouselScrollTo(index: number): void { - this._carousel?.scrollTo(index, this.transitionEffect === 'none'); + const scroll = () => this._carousel?.scrollTo(index, this.transitionEffect === 'none'); + if (this._carousel) { + scroll(); + } else { + this.updateComplete.then(() => { + scroll(); + }) + } } /** diff --git a/src/components/live.ts b/src/components/live.ts index 9b67c980..0cdec73b 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -68,6 +68,7 @@ import { renderTask } from '../utils/task.js'; import { classMap } from 'lit/directives/class-map.js'; import './image'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; +import { TimelineDataManager } from '../utils/timeline-data-manager.js'; // Number of seconds a signed URL is valid for. const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; @@ -95,10 +96,13 @@ export class FrigateCardLive extends LitElement { @property({ attribute: false, hasChanged: contentsChanged }) public liveOverrides?: LiveOverrides; + @property({ attribute: false }) + public timelineDataManager?: TimelineDataManager; + // Whether or not the live view is currently in the background (i.e. preloaded // but not visible) @state() - protected _inBackground?: boolean = true; + protected _inBackground?: boolean = false; // Intersection handler is used to detect when the live view flips between // foreground and background (in preload mode). @@ -122,7 +126,7 @@ export class FrigateCardLive extends LitElement { * @param entries The IntersectionObserverEntry entries (should be only 1). */ protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { - this._inBackground = entries.every((entry) => !entry.isIntersecting); + this._inBackground = !entries.some((entry) => entry.isIntersecting); if ( !this._inBackground && @@ -212,10 +216,12 @@ export class FrigateCardLive extends LitElement { html`) => { this._renderKey++; this._messageReceivedPostRender = true; diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 2b6978a8..60438afe 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -1,3 +1,6 @@ +import './surround.js'; +import './timeline'; + import { CSSResultGroup, html, @@ -7,6 +10,7 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; + import surroundThumbnailsStyle from '../scss/surround.scss'; import { BrowseMediaQueryParameters, @@ -14,7 +18,7 @@ import { ExtendedHomeAssistant, FrigateBrowseMediaSource, FrigateCardError, - FrigateCardView, + MiniTimelineControlConfig, ThumbnailsControlConfig, } from '../types.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; @@ -22,9 +26,9 @@ import { getFirstTrueMediaChildIndex, multipleBrowseMediaQueryMerged, } from '../utils/ha/browse-media'; +import { TimelineDataManager } from '../utils/timeline-data-manager'; import { View } from '../view.js'; import { dispatchFrigateCardErrorEvent } from './message.js'; -import './surround.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; interface ThumbnailViewContext { @@ -47,13 +51,13 @@ export class FrigateCardSurround extends LitElement { public view?: Readonly; @property({ attribute: false, hasChanged: contentsChanged }) - public config?: ThumbnailsControlConfig; + public thumbnailConfig?: ThumbnailsControlConfig; + + @property({ attribute: false, hasChanged: contentsChanged }) + public timelineConfig?: MiniTimelineControlConfig; @property({ attribute: false }) - public targetView?: FrigateCardView; - - @property({ attribute: true, type: Boolean }) - public fetch?: boolean; + public inBackground?: boolean; @property({ attribute: false, hasChanged: contentsChanged }) public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[]; @@ -61,6 +65,9 @@ export class FrigateCardSurround extends LitElement { @property({ attribute: false }) public cameras?: Map; + @property({ attribute: false }) + public timelineDataManager?: TimelineDataManager; + /** * Fetch thumbnail media when a target is not specified in the view (e.g. for * the live view). @@ -69,11 +76,11 @@ export class FrigateCardSurround extends LitElement { */ protected async _fetchMedia(): Promise { if ( - !this.fetch || + this.inBackground || !this.hass || !this.view || - !this.config || - this.config.mode === 'none' || + !this.thumbnailConfig || + this.thumbnailConfig.mode === 'none' || this.view.target || !this.browseMediaParams || !(this.view.context?.thumbnails?.fetch ?? true) @@ -89,7 +96,6 @@ export class FrigateCardSurround extends LitElement { if (getFirstTrueMediaChildIndex(parent) !== null) { this.view ?.evolve({ - ...(this.targetView && { view: this.targetView }), target: parent, childIndex: null, @@ -105,7 +111,9 @@ export class FrigateCardSurround extends LitElement { * @returns `true` if a drawer is used, `false` otherwise. */ protected _hasDrawer(): boolean { - return !!this.config && ['left', 'right'].includes(this.config.mode); + return ( + !!this.thumbnailConfig && ['left', 'right'].includes(this.thumbnailConfig.mode) + ); } /** @@ -116,7 +124,7 @@ export class FrigateCardSurround extends LitElement { // do so if properties relevant to the request have changed (as per their // hasChanged). if ( - ['view', 'targetView', 'fetch', 'browseMediaParams'].some((prop) => + ['view', 'fetch', 'browseMediaParams', 'inBackground'].some((prop) => changedProperties.has(prop), ) ) { @@ -129,7 +137,7 @@ export class FrigateCardSurround extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.config) { + if (!this.hass || !this.view || !this.thumbnailConfig) { return; } @@ -139,9 +147,9 @@ export class FrigateCardSurround extends LitElement { // (if the thumbnails are in a drawer). The new event needs to be dispatched // from the origin of the inbound event, so it can be handled by // . - if (this.config && this._hasDrawer()) { + if (this.thumbnailConfig && this._hasDrawer()) { dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, { - drawer: this.config.mode, + drawer: this.thumbnailConfig.mode, }); } }; @@ -150,31 +158,56 @@ export class FrigateCardSurround extends LitElement { @frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')} @frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')} > - ${this.config && this.config.mode !== 'none' + ${this.thumbnailConfig && + this.thumbnailConfig.mode !== 'none' && + !this.inBackground ? html` changeDrawer(ev, 'close')} - @frigate-card:thumbnail-carousel:tap=${(ev: CustomEvent) => { + @frigate-card:thumbnail-carousel:tap=${( + ev: CustomEvent, + ) => { + const child: FrigateBrowseMediaSource | null = + ev.detail.target?.children?.[ev.detail.childIndex] ?? null; // Send the view change from the source of the tap event, so the // view change will be caught by the handler above (to close the drawer). - this.view - ?.evolve({ - view: this.targetView || 'media', - target: ev.detail.target, - childIndex: ev.detail.childIndex, - context: null, - }) - .dispatchChangeEvent(ev.composedPath()[0]); + if (child) { + this.view + ?.evolve({ + view: this.view.is('recording') ? 'recording' : 'media', + target: ev.detail.target, + childIndex: ev.detail.childIndex, + context: null, + ...(child?.frigate?.cameraID && { + camera: child?.frigate?.cameraID, + }), + }) + .dispatchChangeEvent(ev.composedPath()[0]); + } }} > ` : ''} + ${this.timelineConfig && !this.inBackground + ? html` + ` + : ''} `; } diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 9136a54d..b405c189 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -209,6 +209,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { .view=${this.view} .target=${parent} .childIndex=${childIndex} + .mediaSeek=${this.view?.context?.mediaViewer?.seek.get(childIndex)} .clientID=${cameraConfig?.frigate.client_id} ?details=${this.config?.show_details} ?show_favorite_control=${this.config?.show_favorite_control} diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 30450b60..6b988055 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -2,17 +2,12 @@ import { format, fromUnixTime } from 'date-fns'; import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; + import { localize } from '../localize/localize.js'; import thumbnailDetailsStyle from '../scss/thumbnail-details.scss'; import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss'; import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss'; import thumbnailStyle from '../scss/thumbnail.scss'; -import type { - ExtendedHomeAssistant, - FrigateBrowseMediaSource, - FrigateEvent, - FrigateRecording, -} from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { errorToConsole, prettifyTitle } from '../utils/basic.js'; import { retainEvent } from '../utils/frigate.js'; @@ -20,7 +15,14 @@ import { getEventDurationString } from '../utils/ha/browse-media.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js'; import { View } from '../view.js'; +import { MediaSeek } from './viewer.js'; +import type { + ExtendedHomeAssistant, + FrigateBrowseMediaSource, + FrigateEvent, + FrigateRecording, +} from '../types.js'; // The minimum width of a thumbnail with details enabled. export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; @@ -45,9 +47,7 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement { this, this._embedThumbnailTask, (embeddedThumbnail: string | null) => - embeddedThumbnail - ? html`` - : html`` + embeddedThumbnail ? html`` : html``, ) : html`${localize('event.duration')}: ${getEventDurationString(this.event)} + ${this.mediaSeek + ? html`
+ ${localize('event.seek')} + ${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')} +
` + : html``}
${score} @@ -117,16 +126,19 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement { @property({ attribute: false }) public recording?: FrigateRecording; + @property({ attribute: false }) + public mediaSeek?: MediaSeek; + protected render(): TemplateResult | void { if (!this.recording) { return; } return html`
${prettifyTitle(this.recording.camera) || ''}
- ${this.recording.seek_time + ${this.mediaSeek ? html`
${localize('recording.seek')} - ${format(fromUnixTime(this.recording.seek_time), 'HH:mm:ss')} + ${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')}
` : html``}
@@ -161,6 +173,9 @@ export class FrigateCardThumbnail extends LitElement { @property({ attribute: false }) public childIndex?: number; + @property({ attribute: false }) + public mediaSeek?: MediaSeek; + // =================================================== // Raw interface (can override target-based interface) // =================================================== @@ -263,10 +278,12 @@ export class FrigateCardThumbnail extends LitElement { ${this.details && event ? html`` : this.details && recording ? html`` : html``} ${this.show_timeline_control diff --git a/src/components/timeline.ts b/src/components/timeline.ts index ca009e4e..36e88103 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,4 +1,12 @@ -import { HomeAssistant } from 'custom-card-helpers'; +// 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, @@ -17,14 +25,14 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import { isEqual } from 'lodash-es'; +import { isEqual, throttle } from 'lodash-es'; import { ViewContext } from 'view'; -import { DataSet } from 'vis-data/esnext'; +import { DataView, DataSet } from 'vis-data/esnext'; import { DataGroupCollectionType, + IdType, Timeline, TimelineEventPropertiesResult, TimelineItem, @@ -37,49 +45,47 @@ import { localize } from '../localize/localize'; import timelineCoreStyle from '../scss/timeline-core.scss'; import timelineStyle from '../scss/timeline.scss'; import { - BrowseMediaQueryParameters, CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource, frigateCardConfigDefaults, - FrigateCardError, FrigateEvent, TimelineConfig, + TimelineCoreConfig, } from '../types'; import { stopEventFromActivatingCardWideActions } from '../utils/action'; -import { dispatchFrigateCardEvent, errorToConsole, isHoverableDevice, prettifyTitle } from '../utils/basic'; -import { getCameraTitle } from '../utils/camera.js'; import { - getRecordingSegments, - getRecordingsSummary, - getUniqueFrigateCameraEventsID, - getUniqueFrigateCameraID, - RecordingSegments, - RecordingSummary, -} from '../utils/frigate'; + contentsChanged, + dispatchFrigateCardEvent, + isHoverableDevice, + prettifyTitle, +} from '../utils/basic'; +import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js'; + import { createEventParentForChildren, createVideoChild, generateRecordingIdentifier, - getBrowseMediaQueryParameters, - isTrueMedia, - multipleBrowseMediaQuery, } from '../utils/ha/browse-media'; +import { + FrigateCardTimelineItem, + RecordingSegmentsItem, + sortSegmentsOldestToYoungest, + sortTimelineItemsYoungestToOldest, + TimelineDataManager, +} from '../utils/timeline-data-manager'; import { View } from '../view'; -import { dispatchFrigateCardErrorEvent, dispatchMessageEvent } from './message.js'; +import { dispatchMessageEvent } from './message.js'; import './surround-thumbnails.js'; -const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10; - interface FrigateCardGroupData { id: string; content: string; } -interface FrigateCardTimelineItem extends TimelineItem { - start: number; - end?: number; - event?: FrigateEvent; - source?: FrigateBrowseMediaSource; + +interface TimelineRangeChange extends TimelineWindow { + event: Event & { additionalEvent: string }; + byUser: boolean; } interface TimelineViewContext { @@ -88,6 +94,9 @@ interface TimelineViewContext { // The date of the last event fetch. dateFetch?: Date; + + // Whether or not thumbnails were generated. + generatedThumbnails?: boolean; } declare module 'view' { @@ -96,330 +105,12 @@ declare module 'view' { } } -type TimelineMediaType = 'all' | 'clips' | 'snapshots'; - -interface CameraRecordings { - segments: RecordingSegments; - summary: RecordingSummary; -} - // An event used to fetch the HASS object. See "Special note" below. class HASSRequestEvent extends Event { public hass?: ExtendedHomeAssistant; } -/** - * A manager to maintain/fetch timeline events. - */ -class TimelineDataManager { - protected _dataset = new DataSet(); - - // The earliest date managed. - protected _dateStart?: Date; - - // The latest date managed. - protected _dateEnd?: Date; - - // The last fetch date. - protected _dateFetch?: Date; - - // The maximum allowable age of fetch data (will not fetch more frequently - // than this). - protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS; - - // Get the last event fetch date. - get lastFetchDate(): Date | null { - return this._dateFetch ?? null; - } - - /** - * Retrieve the underlying dataset. - */ - get dataset(): DataSet { - return this._dataset; - } - - /** - * Determine if the dataset is empty. - * @returns - */ - public isEmpty(): boolean { - return this._dataset.length === 0; - } - - /** - * Clear the dataset. - */ - public clear(): void { - this._dataset.clear(); - } - - /** - * Add a FrigateBrowseMediaSource object to the managed timeline. - * @param camera The id the camera this object is from. - * @param target The FrigateBrowseMediaSource to add. - */ - protected _addMediaSource( - camera: string, - mediaPriority: TimelineMediaType, - target: FrigateBrowseMediaSource, - ): void { - const items: FrigateCardTimelineItem[] = []; - target.children?.forEach((child) => { - const event = child.frigate?.event; - if ( - event && - isTrueMedia(child) && - ['video', 'image'].includes(child.media_content_type) - ) { - let item = this._dataset.get(event.id); - if (!item) { - item = { - id: event.id, - group: camera, - content: '', - start: event.start_time * 1000, - event: event, - }; - } - if ( - (child.media_content_type === 'video' && - ['all', 'clips'].includes(mediaPriority)) || - (!item.source && - child.media_content_type === 'image' && - ['all', 'snapshots'].includes(mediaPriority)) - ) { - item.source = child; - } - if (event.end_time) { - item['end'] = event.end_time * 1000; - item['type'] = 'range'; - } else { - item['type'] = 'point'; - } - items.push(item); - } - }); - this._dataset.update(items); - } - - /** - * Determine if the timeline has coverage for a given range of dates. - * @param start The start of the date range. - * @param end An optional end of the date range. - * @returns - */ - public hasCoverage(now: Date, start: Date, end?: Date): boolean { - // Never fetched: no coverage. - if (!this._dateFetch || !this._dateStart || !this._dateEnd) { - return false; - } - - // If the most recent fetch is older than maxAgeSeconds: no coverage. - if ( - this._maxAgeSeconds && - now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000 - ) { - return false; - } - - // If the most requested data is earlier than the earliest stored: no - // coverage. - if (start < this._dateStart) { - return false; - } - - // If there's no end time specified: there IS coverage. - if (!end) { - return true; - } - // If the requested end time is older than the oldest requested: there IS - // coverage. - if (end.getTime() < this._dateEnd.getTime()) { - return true; - } - // If there's no maxAgeSeconds specified: no coverage. - if (!this._maxAgeSeconds) { - return false; - } - // If the requested end time is beyond `_maxAgeSeconds` of now: no coverage. - if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) { - return false; - } - - // End time is within `_maxAgeSeconds` of the latest data: there IS - // coverage. - return end.getTime() - this._maxAgeSeconds * 1000 <= this._dateEnd.getTime(); - } - - /** - * Fetch events if no coverage in given range. - * @param element The element to send error events from. - * @param hass The HomeAssistant object. - * @param cameras The cameras map. - * @param start Fetch events that start later than this date. - * @param end Fetch events that start earlier than this date. - * @returns `true` if events were fetched, `false` otherwise. - */ - public async fetchIfNecessary( - element: HTMLElement, - hass: ExtendedHomeAssistant, - cameras: Map, - eventMedia: TimelineMediaType, - start: Date, - end: Date, - recordings?: boolean, - ): Promise { - // Cannot fetch the future, always clip the end date to now so as to avoid - // checking for coverage that could not possibly exist yet. - const now = new Date(); - end = end > now ? now : end; - - if (this.hasCoverage(now, start, end)) { - return false; - } - - if (!this._dateStart || start < this._dateStart) { - this._dateStart = start; - } - if (!this._dateEnd || end > this._dateEnd) { - this._dateEnd = end; - } - this._dateFetch = new Date(); - - await Promise.all([ - // Events are always fetched for the maximum extent of the managed - // range. This is because events may change at any point in time - // (e.g. a long-running event that ends). - this._fetchEvents( - element, - hass, - cameras, - eventMedia, - this._dateStart, - this._dateEnd, - ), - ...(recordings ? [this._fetchRecordings(hass, cameras)] : []), - ]); - - return true; - } - - /** - * Fetch recording hours for the timeline. - * @param element The element to send error events from. - * @param hass The HomeAssistant object. - * @param cameras The cameras map. - * @param start Fetch events that start later than this date. - * @param end Fetch events that start earlier than this date. - */ - protected async _fetchRecordings( - hass: ExtendedHomeAssistant, - cameras: Map, - ): Promise { - const items: FrigateCardTimelineItem[] = []; - const now = new Date(); - - const storeRecordings = async ( - camera: string, - config: CameraConfig, - ): Promise => { - if (!config.frigate.camera_name) { - return; - } - let summary: RecordingSummary = []; - try { - summary = await getRecordingsSummary( - hass, - config.frigate.client_id, - config.frigate.camera_name, - ); - } catch (e) { - // Recording failure should not disrupt the rest of the timeline - // experience. - errorToConsole(e as Error); - } - - for (const dayData of summary) { - for (const hourData of dayData.hours) { - const hour = add(dayData.day, { hours: hourData.hour }); - const endHour = endOfHour(hour); - items.push({ - id: `recording-${camera}-${format(hour, 'yyyy-MM-dd-HH')}`, - group: camera, - start: getUnixTime(startOfHour(hour)) * 1000, - - // Don't let the recordings show off into the future (even though it - // is intended to be indicative of any recordings within that hour - // -- it still looks strange!) - end: (endHour > now ? getUnixTime(now) : getUnixTime(endHour)) * 1000, - type: 'background', - content: '', - }); - } - } - }; - - await Promise.all( - Array.from(cameras.entries()).map(([camera, config]: [string, CameraConfig]) => - storeRecordings(camera, config), - ), - ); - - this._dataset.update(items); - } - - /** - * Fetch events for the timeline. - * @param element The element to send error events from. - * @param hass The HomeAssistant object. - * @param cameras The cameras map. - * @param start Fetch events that start later than this date. - * @param end Fetch events that start earlier than this date. - */ - protected async _fetchEvents( - element: HTMLElement, - hass: HomeAssistant, - cameras: Map, - media: TimelineMediaType, - start: Date, - end: Date, - ): Promise { - const params: BrowseMediaQueryParameters[] = []; - cameras.forEach((cameraConfig, cameraID) => { - (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => { - if (cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE) { - const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, { - before: end.getTime() / 1000, - after: start.getTime() / 1000, - unlimited: true, - mediaType: mediaType as 'clips' | 'snapshots', - }); - if (param) { - params.push(param); - } - } - }); - }); - - if (!params.length) { - return; - } - - let results: Map; - try { - results = await multipleBrowseMediaQuery(hass, params); - } catch (e) { - return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); - } - - for (const [query, result] of results.entries()) { - if (query.cameraID) { - this._addMediaSource(query.cameraID, media, result); - } - } - } -} +const TIMELINE_TARGET_BAR_ID = 'target_bar'; /** * A simgple thumbnail wrapper class for use in the timeline where LIT data @@ -491,6 +182,9 @@ export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) public timelineConfig?: TimelineConfig; + @property({ attribute: false }) + public timelineDataManager?: TimelineDataManager; + /** * Master render method. * @returns A rendered template. @@ -503,7 +197,7 @@ export class FrigateCardTimeline extends LitElement { return html` `; @@ -535,21 +232,46 @@ export class FrigateCardTimelineCore extends LitElement { @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 timelineConfig?: TimelineConfig; + public thumbnailSize?: number; - protected _data = new TimelineDataManager(); + // 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 = false; + 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. @@ -566,9 +288,7 @@ export class FrigateCardTimelineCore extends LitElement { const eventAttr = source.frigate?.event ? `event='${JSON.stringify(source.frigate.event)}'` : ''; - const detailsAttr = this.timelineConfig?.controls.thumbnails.show_details - ? 'details' - : ''; + 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 @@ -591,97 +311,80 @@ export class FrigateCardTimelineCore extends LitElement { if (!this.hass || !this.view || !this.timelineConfig) { return; } - - const thumbnailsConfig = this.timelineConfig.controls.thumbnails; - const timelineClasses = { - timeline: true, - 'left-margin': thumbnailsConfig.mode === 'left', - 'right-margin': thumbnailsConfig.mode === 'right', - }; - return html`
{ request.hass = this.hass; }} - class="${classMap(timelineClasses)}" + 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 the number of seconds to seek into a video stream consisting of the - * provided segments to reach the target time provided. - * @param time Target time. - * @param segments A RecordingSegments object. - * @returns + * Get all the keys of the cameras in scope for this timeline. + * @returns A set of camera ids (may be empty). */ - protected _getSeekTime(time: Date, segments: RecordingSegments): number | null { - if (!segments.length) { - return null; + protected _getTimelineCameraIDs(): Set { + if (!this.mini || !this.cameras) { + return this._getAllCameraIDs(); } - const target = getUnixTime(time); - const hourStart = getUnixTime(startOfHour(time)); - let seekSeconds = 0; + return getAllDependentCameras(this.cameras, this.view?.camera); + } - // 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_time > target) { - break; - } - const start = segment.start_time < hourStart ? hourStart : segment.start_time; - const end = segment.end_time > target ? target : segment.end_time; - seekSeconds += end - start; - } - return seekSeconds; + /** + * 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 results A map of camera ID to a CameraRecordings object. * @param time The target time for the recordings. - * @param onlyMatchingHour If `true` only shows the hour matching the target + * @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( - results: Map, time: Date, - onlyMatchingHour: boolean, + cameraIDs: Set, + onlyShowMatchingHour: boolean, ): FrigateBrowseMediaSource[] { const children: FrigateBrowseMediaSource[] = []; - const processedCameras: Set = new Set(); - // Get results in the order the cameras are specified in the configuration. - for (const camera of this.cameras?.keys() || []) { - const recording = results.get(camera); - const config = this.cameras?.get(camera); - if (!recording || !config?.frigate.camera_name) { + for (const cameraID of cameraIDs) { + const config = this.cameras?.get(cameraID); + const recordingSummary = + this.timelineDataManager?.getRecordingSummaryForCamera(cameraID); + if (!config?.frigate.camera_name || !recordingSummary) { continue; } - // There is a single set of recordings for a given Frigate camera name. - // Zones on that same camera do not get separate recordings. The card may - // have multiple instances of the same camera for different zones, so - // need to enforce uniqueness here. - const uniqueID = getUniqueFrigateCameraID(config); - if (processedCameras.has(uniqueID)) { - continue; - } - processedCameras.add(uniqueID); - - const seekSeconds = this._getSeekTime(time, recording.segments); - if (seekSeconds === null) { - continue; - } - - for (const dayData of recording.summary) { + 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 (!onlyMatchingHour || isMatchingHour) { + // 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( @@ -702,11 +405,8 @@ export class FrigateCardTimelineCore extends LitElement { start_time: getUnixTime(startHour), end_time: getUnixTime(endHour), events: hourData.events, - ...(isMatchingHour && { - seek_seconds: seekSeconds, - seek_time: time.getTime() / 1000, - }), }, + cameraID: cameraID, }, ), ); @@ -719,86 +419,297 @@ export class FrigateCardTimelineCore extends LitElement { /** * Change the view to a recording. - * @param time The time of the recording to show. - * @param camera An optional camera to show a recording of, otherwise all + * @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(time: Date, camera?: string): Promise { - if (!this.hass) { + protected async _changeViewToRecording( + targetTime: Date, + cameraID?: string, + ): Promise { + if (!this.hass || !this.timelineConfig || !this.cameras) { return; } - const before = endOfHour(time); - const after = startOfHour(time); - const results: Map = new Map(); - - const fetch = async (camera: string, config?: CameraConfig): Promise => { - if (!config || !config.frigate.camera_name || !this.hass) { - return; - } - - try { - const cameraResults = await Promise.all([ - getRecordingSegments( - this.hass, - config.frigate.client_id, - config.frigate.camera_name, - before, - after, - ), - getRecordingsSummary( - this.hass, - config.frigate.client_id, - config.frigate.camera_name, - ), - ]); - results.set(camera, { segments: cameraResults[0], summary: cameraResults[1] }); - } catch (e) { - errorToConsole(e as Error); - } - }; - const cameras = camera ? [camera] : [...(this.cameras?.keys() ?? [])]; - await Promise.all(cameras.map((camera) => fetch(camera, this.cameras?.get(camera)))); - - const children = this._createRecordingChildren(results, time, !camera); + 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; - let childIndex = 0; - if (camera) { - childIndex = children.findIndex( - (child) => - child.frigate?.recording && - child.frigate.recording.start_time * 1000 === after.getTime(), - ); - if (childIndex < 0) { - return; + 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 }; + } + } } } - - this.view - ?.evolve({ - view: 'media', - target: createEventParentForChildren(localize('common.recordings'), children), - childIndex: childIndex, - }) - .dispatchChangeEvent(this); + return bestMatch ? bestMatch.index : null; } /** * Called whenever the range is in the process of being changed. * @param properties */ - protected _timelineRangeChangeHandler( - properties: TimelineEventPropertiesResult, - ): void { - if (properties.event && this._pointerHeld) { - // An event will have been set when it's a human changes the range. - this._ignoreClick = true; + 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. @@ -811,13 +722,67 @@ export class FrigateCardTimelineCore extends LitElement { stopEventFromActivatingCardWideActions(properties.event); } - if (!this._ignoreClick && properties.what && this.timelineConfig?.show_recordings) { - if (['background', 'group-label'].includes(properties.what)) { - stopEventFromActivatingCardWideActions(properties.event); - this._changeViewToRecording(properties.time, String(properties.group)); - } else if (properties.what === 'axis') { - stopEventFromActivatingCardWideActions(properties.event); - this._changeViewToRecording(properties.time); + 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'); + } } } @@ -839,7 +804,7 @@ export class FrigateCardTimelineCore extends LitElement { * Handle a range change in the timeline. * @param properties vis.js provided range information. */ - protected _timelineRangeHandler(properties: { + protected _timelineRangeChangedHandler(properties: { start: Date; end: Date; byUser: boolean; @@ -848,28 +813,25 @@ export class FrigateCardTimelineCore extends LitElement { 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._data - .fetchIfNecessary( - this, - this.hass, - this.cameras, - this.timelineConfig.media, - prefetchStart, - prefetchEnd, - this.timelineConfig.show_recordings, - ) + this.timelineDataManager + ?.fetchIfNecessary(this, this.hass, prefetchStart, prefetchEnd) .then(() => { - if (this._timeline) { + // 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({ + .evolve({ target: thumbnails?.target ?? null, childIndex: thumbnails?.childIndex ?? null, }) @@ -880,43 +842,15 @@ export class FrigateCardTimelineCore extends LitElement { } } - /** - * Called when an object on the timeline is selected. - * @param data The data about the selection. - * @returns - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected _timelineSelectHandler(data: { items: string[]; event: Event }): void { - if (!this.view?.target || !this.view?.target.children) { - return; - } - - const childIndex = data.items.length - ? this.view.target.children.findIndex( - (child) => child.frigate?.event?.id === data.items[0], - ) - : null; - - this.view - ?.evolve({ - childIndex: childIndex, - }) - .dispatchChangeEvent(this); - - if (childIndex !== null && childIndex >= 0) { - dispatchFrigateCardEvent(this, 'thumbnails:open'); - } else { - dispatchFrigateCardEvent(this, 'thumbnails:close'); - } - } - /** * Regenerate the thumbnails from the timeline events. + * @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(): { + protected _generateThumbnails(selectedItem?: IdType): { target: FrigateBrowseMediaSource; childIndex: number | null; } | null { @@ -924,29 +858,12 @@ export class FrigateCardTimelineCore extends LitElement { return null; } - /** - * Sort the timeline items most recent to least recent. - * @param a The first item. - * @param b The second item. - * @returns -1, 0, 1 (standard array sort function configuration). - */ - const sortEvent = ( - a: FrigateCardTimelineItem, - b: FrigateCardTimelineItem, - ): number => { - if (a.start < b.start) { - return 1; - } - if (a.start > b.start) { - return -1; - } - return 0; - }; - - const selected = this._timeline.getSelection(); + const selected: IdType[] = selectedItem + ? [selectedItem] + : this._timeline.getSelection(); let childIndex = -1; const children: FrigateBrowseMediaSource[] = []; - this._data.dataset.get({ order: sortEvent }).forEach((item) => { + this._dataview?.get({ order: sortTimelineItemsYoungestToOldest }).forEach((item) => { if (item.event && item.source) { children.push(item.source); if (selected.includes(item.event.id)) { @@ -970,20 +887,19 @@ export class FrigateCardTimelineCore extends LitElement { */ protected _getGroups(): DataGroupCollectionType { const groups: FrigateCardGroupData[] = []; - const processedCameras: Set = new Set(); - this.cameras?.forEach((cameraConfig, camera) => { - const frigateCameraID = getUniqueFrigateCameraEventsID(cameraConfig); - if ( - cameraConfig.frigate.camera_name && - cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE && - !processedCameras.has(frigateCameraID) - ) { - processedCameras.add(frigateCameraID); - groups.push({ - id: camera, - content: getCameraTitle(this.hass, cameraConfig), - }); + 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); @@ -1037,10 +953,6 @@ export class FrigateCardTimelineCore extends LitElement { * @returns A tuple of start/end date. */ protected _getStartEnd(): [Date, Date] { - const event = this.view?.target?.frigate?.event; - if (event) { - return this._getStartEndFromEvent(event); - } const end = new Date(); const start = sub(end, { seconds: this._getConfiguredWindowSeconds(), @@ -1062,9 +974,9 @@ export class FrigateCardTimelineCore extends LitElement { /** * Handle timeline resize. */ - protected _getOptions(): TimelineOptions | void { + protected _getOptions(): TimelineOptions | null { if (!this.timelineConfig) { - return; + return null; } const [start, end] = this._getStartEnd(); @@ -1107,7 +1019,7 @@ export class FrigateCardTimelineCore extends LitElement { selectable: true, start: start, end: end, - groupHeightMode: 'fixed', + groupHeightMode: 'auto', tooltip: { followMouse: true, overflowMethod: 'cap', @@ -1145,7 +1057,13 @@ export class FrigateCardTimelineCore extends LitElement { * Update the timeline from the view object. */ protected async _updateTimelineFromView(): Promise { - if (!this.hass || !this.cameras || !this.view || !this.timelineConfig) { + if ( + !this.hass || + !this.cameras || + !this.view || + !this.timelineConfig || + !this._timeline + ) { return; } @@ -1154,19 +1072,23 @@ export class FrigateCardTimelineCore extends LitElement { ? this._getStartEndFromEvent(event) : this._getStartEnd(); - const [prefetchStart, prefetchEnd] = this._getPrefetchWindow(windowStart, windowEnd); - const fetched = await this._data.fetchIfNecessary( - this, - this.hass, - this.cameras, - this.timelineConfig.media, - prefetchStart, - prefetchEnd, - this.timelineConfig.show_recordings, - ); - - if (!this._timeline) { - return; + 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] : [], { @@ -1177,38 +1099,38 @@ export class FrigateCardTimelineCore extends LitElement { }, }); - // 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 (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 (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 (!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 ( - eventStart < timelineWindow.start || - eventStart > timelineWindow.end || - (eventEnd && (eventEnd < timelineWindow.start || eventEnd > timelineWindow.end)) - ) { + 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); } - - if (this._isClustering()) { - // Hack: Clustering may not update unless the dataset changes, artifically - // update the dataset to ensure the newly selected item cannot be included - // in a cluster. - const item = this._data.dataset.get(event.id); - if (item) { - this._data.dataset.updateOnly(item); - } - } - } else { - this._timeline.setWindow(windowStart, windowEnd); } // Only generate thumbnails if an actual fetch occurred, to avoid getting @@ -1221,7 +1143,7 @@ export class FrigateCardTimelineCore extends LitElement { // -> New view dispatched (to load thumbnails into outer carousel). // -> New view received ... [loop] - if (fetched) { + if ((fetched || !this.view.context?.timeline?.generatedThumbnails) && !this.mini) { const thumbnails = this._generateThumbnails(); this.view ?.evolve({ @@ -1241,17 +1163,19 @@ export class FrigateCardTimelineCore extends LitElement { */ protected _generateTimelineContext(addWindow: boolean): ViewContext { const currentContext = this.view?.context?.timeline; - const newContext: TimelineViewContext = {} + const newContext: TimelineViewContext = { + generatedThumbnails: true, + }; if (addWindow && this._timeline) { newContext.window = this._timeline.getWindow(); } else if (currentContext?.window) { newContext.window = currentContext.window; } - if (this._data.lastFetchDate) { - newContext.dateFetch = this._data.lastFetchDate; + if (this.timelineDataManager?.lastFetchDate) { + newContext.dateFetch = this.timelineDataManager.lastFetchDate; } - return Object.keys(newContext) ? {timeline: newContext} : {}; + return { timeline: newContext }; } /** @@ -1259,13 +1183,18 @@ export class FrigateCardTimelineCore extends LitElement { * @param changedProps The changed properties */ protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('timelineConfig')) { - if (this.timelineConfig?.controls.thumbnails.size) { + if (changedProps.has('thumbnailSize')) { + if (this.thumbnailSize !== undefined) { this.style.setProperty( '--frigate-card-thumbnail-size', - `${this.timelineConfig.controls.thumbnails.size}px`, + `${this.thumbnailSize}px`, ); + } else { + this.style.removeProperty('--frigate-card-thumbnail-size'); } + } + + if (changedProps.has('timelineConfig')) { if (this.timelineConfig?.show_recordings) { this.setAttribute('recordings', ''); } else { @@ -1274,6 +1203,14 @@ export class FrigateCardTimelineCore extends LitElement { } } + /** + * 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. @@ -1282,47 +1219,81 @@ export class FrigateCardTimelineCore extends LitElement { super.updated(changedProperties); if (changedProperties.has('cameras')) { - this._data.clear(); - this._timeline?.destroy(); - this._timeline = undefined; + this._destroy(); } const options = this._getOptions(); - if (changedProperties.has('timelineConfig') && this._refTimeline.value && options) { + + 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._timeline.setOptions(options); - } else { - // Don't show an empty timeline, show a message instead. - const groups = this._getGroups(); - if (!groups.length) { + 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; } + 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._data.dataset, + this._dataview, + options, + ) as Timeline; + this.removeAttribute('groups'); + } else { + this._timeline = new Timeline( + this._refTimeline.value, + this._dataview, groups, options, - ); - this._timeline.on('select', this._timelineSelectHandler.bind(this)); - this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this)); - this._timeline.on('click', this._timelineClickHandler.bind(this)); - this._timeline.on('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', () => { - this._pointerHeld = true; - this._ignoreClick = false; - }); - this._timeline.on('mouseUp', () => { - this._pointerHeld = false; - }); + ) 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')) { diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 6518a123..701dd89e 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -55,6 +55,26 @@ import './surround-thumbnails'; import { EmblaCarouselPlugins } from './carousel.js'; import { renderTask } from '../utils/task.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; +import { TimelineDataManager } from '../utils/timeline-data-manager.js'; + +export interface MediaSeek { + // Specifies the point at which this recording should be played, the + // seek_time is the date of the desired play point (for display purposes + // usually), and seek_seconds is the number of seconds to seek into the video + // stream to reach that point. + seekTime: number; + seekSeconds: number; +} + +export interface MediaViewerViewContext { + seek: Map; +} + +declare module 'view' { + interface ViewContext { + mediaViewer?: MediaViewerViewContext; + } +} @customElement('frigate-card-viewer') export class FrigateCardViewer extends LitElement { @@ -73,6 +93,9 @@ export class FrigateCardViewer extends LitElement { @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; + @property({ attribute: false }) + public timelineDataManager?: TimelineDataManager; + /** * Master render method. * @returns A rendered template. @@ -114,7 +137,9 @@ export class FrigateCardViewer extends LitElement { return html` ) => { wrapMediaLoadedEventForCarousel(slideIndex, e); diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index fb13f480..b30b9813 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -362,7 +362,8 @@ "duration": "Duration", "in_progress": "In Progress", "score": "Score", - "start": "Start" + "start": "Start", + "seek": "Seek" }, "recording": { "events": "Events", @@ -373,6 +374,10 @@ "retain_indefinitely": "Event will be indefinitely retained", "timeline": "See event in timeline" }, + "timeline": { + "lock": "Lock timeline to a single event", + "unlock": "Unlock timeline" + }, "elements": { "ptz": { "up": "Up", diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index fa910c91..3f0152d6 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -25,6 +25,8 @@ customElements.whenDefined('ha-hls-player').then(() => { @query('#video') protected _video: HTMLVideoElement; + protected _controlsVisibilityTimerID: number | null = null; + /** * Play the video. */ @@ -65,7 +67,20 @@ customElements.whenDefined('ha-hls-player').then(() => { */ public seek(seconds: number): void { if (this._video) { + // Hide the controls while programatically seeking, and make them + // visible again a short time after the last seek (controls are annoying + // during timeline seeking) + this._video.controls = false; + this._video.currentTime = seconds; + + if (this._controlsVisibilityTimerID !== null) { + window.clearTimeout(this._controlsVisibilityTimerID); + } + this._controlsVisibilityTimerID = window.setTimeout(() => { + this._video.controls = true; + this._controlsVisibilityTimerID = null; + }, 1000); } } @@ -112,7 +127,7 @@ customElements.whenDefined('ha-hls-player').then(() => { }); declare global { - interface HTMLElementTagNameMap { - "frigate-card-ha-hls-player": FrigateCardHaHlsPlayer - } + interface HTMLElementTagNameMap { + 'frigate-card-ha-hls-player': FrigateCardHaHlsPlayer; + } } diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss index 6c5288cf..a86b3070 100644 --- a/src/scss/drawer.scss +++ b/src/scss/drawer.scss @@ -37,7 +37,7 @@ div.control-surround { ha-icon.control { color: var(--secondary-color, white); background-color: rgba(0, 0, 0, 0.7); - opacity: 0.7; + opacity: 0.5; pointer-events: all; --mdc-icon-size: #{$drawer-icon-size}; diff --git a/src/scss/thumbnail-details.scss b/src/scss/thumbnail-details.scss index f8ea64e7..44a521f5 100644 --- a/src/scss/thumbnail-details.scss +++ b/src/scss/thumbnail-details.scss @@ -13,6 +13,8 @@ div.left { display: flex; flex-direction: column; justify-content: center; + font-size: 0.8rem; + line-height: normal; } div.right { align-items: center; @@ -42,5 +44,5 @@ span.heading { div.larger, span.larger { - font-size: 1.5rem; + font-size: 1.4rem; } diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss index f83a47e1..d755ca05 100644 --- a/src/scss/timeline-core.scss +++ b/src/scss/timeline-core.scss @@ -4,10 +4,6 @@ :host { width: 100%; - height: 100%; - background-color: var(--card-background-color); - padding-bottom: 5px; - // Share the screen space with thumbnails that may be above/below. display: flex; flex-direction: column; @@ -27,14 +23,6 @@ frigate-card-thumbnail[details] { div.timeline { flex: 1; } -div.timeline.left-margin { - // Clearance for the drawer button. - margin-left: calc(drawer.$drawer-icon-size + 1px); -} -div.timeline.right-margin { - // Clearance for the drawer button. - margin-right: calc(drawer.$drawer-icon-size + 1px); -} .vis-text { color: var(--primary-text-color) !important; @@ -68,6 +56,14 @@ div.timeline.right-margin { opacity: 0.1; } +// If there are no timeline groups shown (e.g. mini mode with a single camera), +// ensure the background (recordings) always span the full height. Otherwise, in +// cases where there are no events, the background is incorrectly rendered too +// short by visjs. +:host:not([groups]) .vis-item.vis-background { + min-height: 100%; +} + .vis-item:not(.vis-background) { cursor: pointer; } @@ -127,3 +123,21 @@ div.vis-tooltip { // Use browser default font-family for tooltips. font-family: unset; } + +.target_bar { + border-left: 2px solid var(--primary-color); + opacity: 0.7; + box-shadow: 0px 0px 3px 1px var(--primary-color); + + // Prevent the mouse interacting with the custom time. + pointer-events: none; +} + +ha-icon.lock { + position: absolute; + right: 2px; + bottom: 2px; + color: var(--primary-color); + z-index: 10; + cursor: pointer; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 9749f550..eb2b1e3f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,6 +38,7 @@ export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ const FRIGATE_CARD_VIEWS = [ ...FRIGATE_CARD_VIEWS_USER_SPECIFIED, + 'recording', // Media: A generic piece of media (could be clip, snapshot, recording). 'media', @@ -655,6 +656,45 @@ const thumbnailsControlSchema = z.object({ }); export type ThumbnailsControlConfig = z.infer; +/** + * Core/Mini timeline controls configuration section. + */ + +const timelineCoreConfigDefault = { + clustering_threshold: 3, + media: 'all' as const, + window_seconds: 60 * 60, + show_recordings: true, +}; + +const timelineCoreConfigSchema = z + .object({ + clustering_threshold: z + .number() + .optional() + .default(timelineCoreConfigDefault.clustering_threshold), + media: z + .enum(['all', 'clips', 'snapshots']) + .optional() + .default(timelineCoreConfigDefault.media), + window_seconds: z + .number() + .min(1 * 60) + .max(24 * 60 * 60) + .optional() + .default(timelineCoreConfigDefault.window_seconds), + show_recordings: z + .boolean() + .optional() + .default(timelineCoreConfigDefault.show_recordings), + }); +export type TimelineCoreConfig = z.infer; + +const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({ + mode: z.enum(['none', 'above', 'below']), +}); +export type MiniTimelineControlConfig = z.infer; + /** * Next/Previous Control configuration section. */ @@ -787,6 +827,7 @@ const liveOverridableConfigSchema = z .default(liveConfigDefault.controls.thumbnails.media), }) .default(liveConfigDefault.controls.thumbnails), + timeline: miniTimelineConfigSchema.optional(), title: titleControlConfigSchema .extend({ mode: titleControlConfigSchema.shape.mode.default( @@ -989,6 +1030,7 @@ const viewerConfigSchema = z ), }) .default(viewerConfigDefault.controls.thumbnails), + timeline: miniTimelineConfigSchema.optional(), title: titleControlConfigSchema .extend({ mode: titleControlConfigSchema.shape.mode.default( @@ -1082,10 +1124,7 @@ const dimensionsConfigSchema = z * Timeline configuration section. */ const timelineConfigDefault = { - clustering_threshold: 3, - media: 'all' as const, - window_seconds: 60 * 60, - show_recordings: true, + ...timelineCoreConfigDefault, controls: { thumbnails: { mode: 'left' as const, @@ -1096,26 +1135,8 @@ const timelineConfigDefault = { }, }, }; -const timelineConfigSchema = z - .object({ - clustering_threshold: z - .number() - .optional() - .default(timelineConfigDefault.clustering_threshold), - media: z - .enum(['all', 'clips', 'snapshots']) - .optional() - .default(timelineConfigDefault.media), - window_seconds: z - .number() - .min(1 * 60) - .max(24 * 60 * 60) - .optional() - .default(timelineConfigDefault.window_seconds), - show_recordings: z - .boolean() - .optional() - .default(timelineConfigDefault.show_recordings), + +const timelineConfigSchema = timelineCoreConfigSchema.extend({ controls: z .object({ thumbnails: thumbnailsControlSchema @@ -1362,16 +1383,11 @@ export interface FrigateEvent { } export interface FrigateRecording { + // Frigate camera name (may not be unique) camera: string; start_time: number; end_time: number; events: number; - - // Specifies the point at which this recording should be played, the - // seek_time is the date of the desired play point, and seek_seconds is the - // number of seconds to seek to reach that point. - seek_time?: number; - seek_seconds?: number; } export interface FrigateBrowseMediaSource extends BrowseMediaSource { @@ -1379,6 +1395,7 @@ export interface FrigateBrowseMediaSource extends BrowseMediaSource { frigate?: { event?: FrigateEvent; recording?: FrigateRecording; + cameraID?: string; }; } diff --git a/src/utils/camera.ts b/src/utils/camera.ts index 81c5d1ec..8ef70904 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -72,3 +72,38 @@ export function getCameraIcon( ): string { return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video'; } + +/** + * Get all cameras that depend on a given camera. + * @param cameras Cameras map. + * @param camera Name of the target camera. + * @returns A set of query parameters. + */ +export const getAllDependentCameras = ( + cameras: Map, + camera?: string, +): Set => { + const cameraIDs: Set = new Set(); + const getDependentCameras = (camera: string): void => { + const cameraConfig = cameras.get(camera); + if (cameraConfig) { + cameraIDs.add(camera); + const dependentCameras: Set = new Set(); + (cameraConfig.dependencies.cameras || []).forEach((item) => + dependentCameras.add(item), + ); + if (cameraConfig.dependencies.all_cameras) { + cameras.forEach((_, key) => dependentCameras.add(key)); + } + for (const eventCameraID of dependentCameras) { + if (!cameraIDs.has(eventCameraID)) { + getDependentCameras(eventCameraID); + } + } + } + }; + if (camera) { + getDependentCameras(camera); + } + return cameraIDs; +}; diff --git a/src/utils/frigate.ts b/src/utils/frigate.ts index 02953681..d7369737 100644 --- a/src/utils/frigate.ts +++ b/src/utils/frigate.ts @@ -74,9 +74,9 @@ export const getRecordingsSummary = async ( hass, recordingSummarySchema, { - type: "frigate/recordings/summary", + type: 'frigate/recordings/summary', instance_id: client_id, - camera: camera_name + camera: camera_name, }, true, ); @@ -102,7 +102,7 @@ export const getRecordingSegments = async ( hass, recordingSegmentsSchema, { - type: "frigate/recordings/get", + type: 'frigate/recordings/get', instance_id: client_id, camera: camera_name, before: Math.floor(before.getTime() / 1000), @@ -144,27 +144,3 @@ export async function retainEvent( }); } } - -/** - * Get an id that unique identifies a particular camera (not zone, object, etc) - * within a particular Frigate instance. ID will not (necessarily) be unique - * within the card. - * @param cameraConfig The camera config. - */ -export const getUniqueFrigateCameraID = (config: CameraConfig): string => { - return [config.frigate.client_id, config.frigate.camera_name].join('/'); -}; - -/** - * Get an id that unique identifies a source of Frigate events. ID will not - * (necessarily) be unique within the card. - * @param cameraConfig The camera config. - */ -export const getUniqueFrigateCameraEventsID = (config: CameraConfig): string => { - return [ - config.frigate.client_id, - config.frigate.camera_name, - config.frigate.label, - config.frigate.zone, - ].join('/'); -}; diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index dc411df7..e1ab9ef7 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -27,7 +27,7 @@ import { MEDIA_TYPE_VIDEO, } from '../../types.js'; import { View } from '../../view.js'; -import { getCameraTitle } from '../camera.js'; +import { getAllDependentCameras, getCameraTitle } from '../camera.js'; /** * Return the Frigate event_id given a FrigateBrowseMediaSource object. @@ -78,7 +78,7 @@ export const getFirstTrueMediaChildIndex = ( * @param media_content_id The media content id to browse. * @returns A FrigateBrowseMediaSource object or null on malformed. */ -export const browseMedia = async ( +const browseMedia = async ( hass: HomeAssistant, media_content_id: string, ): Promise => { @@ -95,11 +95,11 @@ export const browseMedia = async ( * @param params The search parameters to use to search for media. * @returns A FrigateBrowseMediaSource object or null on malformed. */ -export const browseMediaQuery = async ( +const browseMediaQuery = async ( hass: HomeAssistant, params: BrowseMediaQueryParameters, ): Promise => { - return browseMedia( + const result = await browseMedia( hass, // Defined in: // https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py @@ -118,6 +118,14 @@ export const browseMediaQuery = async ( params.zone, ].join('/'), ); + // If a cameraID was specified, imprint each child with that id for + // traceability. + if (params.cameraID) { + result.children?.forEach((child: FrigateBrowseMediaSource) => { + (child.frigate ??= {}).cameraID = params.cameraID; + }) + } + return result; }; /** @@ -259,27 +267,7 @@ export const getFullDependentBrowseMediaQueryParameters = ( camera: string, mediaType?: 'clips' | 'snapshots', ): BrowseMediaQueryParameters[] | null => { - const cameraIDs: Set = new Set(); - const getDependentCameras = (camera: string): void => { - const cameraConfig = cameras.get(camera); - if (cameraConfig) { - cameraIDs.add(camera); - const dependentCameras: Set = new Set(); - (cameraConfig.dependencies.cameras || []).forEach((item) => - dependentCameras.add(item), - ); - if (cameraConfig.dependencies.all_cameras) { - cameras.forEach((_, key) => dependentCameras.add(key)); - } - for (const eventCameraID of dependentCameras) { - if (!cameraIDs.has(eventCameraID)) { - getDependentCameras(eventCameraID); - } - } - } - }; - getDependentCameras(camera); - + const cameraIDs = getAllDependentCameras(cameras, camera); const params: BrowseMediaQueryParameters[] = []; for (const cameraID of cameraIDs) { const param = getBrowseMediaQueryParameters( @@ -435,9 +423,10 @@ export const createVideoChild = ( options?: { thumbnail?: string; recording?: FrigateRecording; + cameraID?: string, }, ): FrigateBrowseMediaSource => { - return { + const result: FrigateBrowseMediaSource = { title: title, media_class: MEDIA_CLASS_VIDEO, media_content_type: MEDIA_TYPE_VIDEO, @@ -445,13 +434,18 @@ export const createVideoChild = ( can_play: true, can_expand: false, thumbnail: options?.thumbnail ?? null, - children: null, - ...(options?.recording && { - frigate: { - recording: options.recording, - }, - }), - }; + children: null + } + if (options?.recording || options?.cameraID) { + result.frigate = {} + if (options?.recording) { + result.frigate.recording = options.recording; + } + if (options?.cameraID) { + result.frigate.cameraID = options.cameraID; + } + } + return result; }; /** diff --git a/src/utils/timeline-data-manager.ts b/src/utils/timeline-data-manager.ts new file mode 100644 index 00000000..1e2ec918 --- /dev/null +++ b/src/utils/timeline-data-manager.ts @@ -0,0 +1,510 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { DataSet, DataView } from 'vis-data/esnext'; +import { IdType, TimelineItem } from 'vis-timeline/esnext'; +import { CAMERA_BIRDSEYE } from '../const.js'; +import { + BrowseMediaQueryParameters, + CameraConfig, + ExtendedHomeAssistant, + FrigateBrowseMediaSource, + FrigateCardError, + FrigateEvent, +} from '../types.js'; +import { errorToConsole } from '../utils/basic.js'; +import { + getRecordingSegments, + getRecordingsSummary, + RecordingSegments, + RecordingSummary, +} from './frigate.js'; +import { + getBrowseMediaQueryParameters, + isTrueMedia, + multipleBrowseMediaQuery, +} from './ha/browse-media.js'; +import { dispatchFrigateCardErrorEvent } from '../components/message.js'; + +const RECORDING_SEGMENT_TOLERANCE = 60; +const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10; + +export interface FrigateCardTimelineItem extends TimelineItem { + // DataView has issues using datasets with Date objects, so avoid them and use + // numbers instead. + start: number; + end?: number; + event?: FrigateEvent; + source?: FrigateBrowseMediaSource; +} + +type TimelineMediaType = 'all' | 'clips' | 'snapshots'; + +export interface RecordingSegmentsItem { + id: string; + cameraID: string; + start: number; + end: number; +} + +/** + * Sort the timeline items most recent to least recent. + * @param a The first item. + * @param b The second item. + * @returns -1, 0, 1 (standard array sort function configuration). + */ +export const sortTimelineItemsYoungestToOldest = ( + a: FrigateCardTimelineItem, + b: FrigateCardTimelineItem, +): number => { + if (a.start < b.start) { + return 1; + } + if (a.start > b.start) { + return -1; + } + return 0; +}; + +/** + * Sort the segments least recent to most recent. + * @param a The first item. + * @param b The second item. + * @returns -1, 0, 1 (standard array sort function configuration). + */ +export const sortSegmentsOldestToYoungest = ( + a: RecordingSegmentsItem, + b: RecordingSegmentsItem, +): number => { + if (a.start < b.start) { + return -1; + } + if (a.start > b.start) { + return 1; + } + return 0; +}; + +/** + * A manager to maintain/fetch timeline events. + */ +export class TimelineDataManager { + protected _recordingSummary: Map = new Map(); + protected _recordingSegments = new DataSet(); + + protected _dataset = new DataSet(); + + // The earliest date managed. + protected _dateStart: Date | null = null; + + // The latest date managed. + protected _dateEnd: Date | null = null; + + // The last fetch date. + protected _dateFetch: Date | null = null; + + // The maximum allowable age of fetch data (will not fetch more frequently + // than this). + protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS; + + protected _cameras: Map; + protected _mediaType: TimelineMediaType; + protected _recordings: boolean; + + constructor( + cameras: Map, + mediaType: TimelineMediaType, + recordings: boolean, + ) { + this._cameras = cameras; + this._mediaType = mediaType; + this._recordings = recordings; + } + + // Get the last event fetch date. + get lastFetchDate(): Date | null { + return this._dateFetch ?? null; + } + + public getRecordingSummaryForCamera(cameraID: string): RecordingSummary | null { + return this._recordingSummary.get(cameraID) ?? null; + } + + public createDataView(cameraIDs: Set): DataView { + return new DataView(this._dataset, { + filter: (item: FrigateCardTimelineItem) => + !!item.group && cameraIDs.has(String(item.group)), + }); + } + + public createSegmentDataView(): DataView { + return new DataView(this._recordingSegments); + } + + get recordingSegments(): DataSet { + return this._recordingSegments; + } + + /** + * Rewrite an item as-is. May be useful in cases where clustering may need to + * be recalculated. + * @param id The id to rewrite. + */ + public rewriteItem(id: IdType): void { + // Hack: Clustering may not update unless the dataset changes, artifically + // update the dataset to ensure the newly selected item cannot be included + // in a cluster. + const item = this._dataset.get(id); + if (item) { + this._dataset.updateOnly(item); + } + } + + /** + * Add a FrigateBrowseMediaSource object to the managed timeline. + * @param cameraID The id the camera this object is from. + * @param target The FrigateBrowseMediaSource to add. + */ + protected _addMediaSource(target: FrigateBrowseMediaSource): void { + const items: FrigateCardTimelineItem[] = []; + target.children?.forEach((child) => { + const event = child.frigate?.event; + const cameraID = child.frigate?.cameraID; + if ( + cameraID && + event && + isTrueMedia(child) && + ['video', 'image'].includes(child.media_content_type) + ) { + let item = this._dataset.get(event.id); + //const st = fromUnixTime(); + if (!item) { + item = { + id: event.id, + group: cameraID, + content: '', + start: event.start_time * 1000, + event: event, + }; + } + if ( + (child.media_content_type === 'video' && + ['all', 'clips'].includes(this._mediaType)) || + (!item.source && + child.media_content_type === 'image' && + ['all', 'snapshots'].includes(this._mediaType)) + ) { + item.source = child; + } + if (event.end_time) { + item['end'] = event.end_time * 1000; + item['type'] = 'range'; + } else { + item['type'] = 'point'; + } + items.push(item); + } + }); + + this._dataset.update(items); + } + + /** + * Determine if the timeline has coverage for a given range of dates. + * @param start The start of the date range. + * @param end An optional end of the date range. + * @returns + */ + public hasCoverage(now: Date, start: Date, end?: Date): boolean { + // Never fetched: no coverage. + if (!this._dateFetch || !this._dateStart || !this._dateEnd) { + return false; + } + + // If the most recent fetch is older than maxAgeSeconds: no coverage. + if ( + this._maxAgeSeconds && + now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000 + ) { + return false; + } + + // If the most requested data is earlier than the earliest stored: no + // coverage. + if (start < this._dateStart) { + return false; + } + + // If there's no end time specified: there IS coverage. + if (!end) { + return true; + } + // If the requested end time is older than the oldest requested: there IS + // coverage. + if (end.getTime() < this._dateEnd.getTime()) { + return true; + } + // If there's no maxAgeSeconds specified: no coverage. + if (!this._maxAgeSeconds) { + return false; + } + // If the requested end time is beyond `_maxAgeSeconds` of now: no coverage. + if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) { + return false; + } + + // End time is within `_maxAgeSeconds` of the latest data: there IS + // coverage. + return end.getTime() - this._maxAgeSeconds * 1000 <= this._dateEnd.getTime(); + } + + /** + * Fetch events if no coverage in given range. + * @param element The element to send error events from. + * @param hass The HomeAssistant object. + * @param start Fetch events that start later than this date. + * @param end Fetch events that start earlier than this date. + * @returns `true` if events were fetched, `false` otherwise. + */ + public async fetchIfNecessary( + element: HTMLElement, + hass: ExtendedHomeAssistant, + start: Date, + end: Date, + ): Promise { + // Cannot fetch the future, always clip the end date to now so as to avoid + // checking for coverage that could not possibly exist yet. + const now = new Date(); + end = end > now ? now : end; + + if (this.hasCoverage(now, start, end)) { + return false; + } + + const oldStart = this._dateStart; + const oldEnd = this._dateEnd; + let segmentStart: Date | null = null; + let segmentEnd: Date | null = null; + if (!this._dateStart || start < this._dateStart) { + this._dateStart = start; + segmentStart = start; + } else { + segmentStart = oldEnd ?? end; + } + if (!this._dateEnd || end > this._dateEnd) { + this._dateEnd = end; + segmentEnd = end; + } else { + segmentEnd = oldStart ?? start; + } + + this._dateFetch = new Date(); + + await Promise.all([ + // Events are always fetched for the maximum extent of the managed + // range. This is because events may change at any point in time + // (e.g. a long-running event that ends). + this._fetchEvents(element, hass, this._dateStart, this._dateEnd), + ...(this._recordings ? [this._fetchRecordingSummary(hass)] : []), + ...(this._recordings && segmentEnd > segmentStart + ? [this._fetchRecordingSegments(hass, segmentStart, segmentEnd)] + : []), + ]); + return true; + } + + /** + * Fetch recording segments for cameras. + * @param hass The HomeAssistant object. + * @param start Fetch segments that start later than this date. + * @param end Fetch segments that start earlier than this date. + */ + protected async _fetchRecordingSegments( + hass: ExtendedHomeAssistant, + start: Date, + end: Date, + ): Promise { + const results: Map = new Map(); + const fetch = async (camera: string, config?: CameraConfig): Promise => { + if (!config || !config.frigate.camera_name || !hass) { + return; + } + + try { + const cameraResults = await getRecordingSegments( + hass, + config.frigate.client_id, + config.frigate.camera_name, + end, + start, + ); + results.set(camera, cameraResults); + } catch (e) { + errorToConsole(e as Error); + } + }; + + await Promise.all( + Array.from(this._cameras.keys()).map((camera) => + fetch(camera, this._cameras.get(camera)), + ), + ); + + const items: RecordingSegmentsItem[] = []; + results.forEach((segments, cameraID) => { + segments.forEach((segment) => { + items.push({ + id: `${cameraID}/${segment.id}`, + cameraID: cameraID, + start: segment.start_time * 1000, + end: segment.end_time * 1000, + }); + }); + }); + this._recordingSegments.update(items); + this._compressRecordingSegmentsOntoTimeline(); + } + + /** + * Compress recording segments into recordings shown on the timeline + * background. + */ + protected _compressRecordingSegmentsOntoTimeline(): void { + if (!this._recordingSegments.length) { + return; + } + + // Delete all the existing background. + this._dataset.remove( + this._dataset.get({ + filter: (item) => item.type === 'background', + }), + ); + + const convertToRecording = ( + segment: RecordingSegmentsItem, + ): FrigateCardTimelineItem => { + return { + id: `recording-${segment.cameraID}-${segment.id}`, + group: segment.cameraID, + start: segment.start, + end: segment.end, + content: ' ', + type: 'background', + }; + }; + + // Iterate through the segments least to most recent, effectively joining + // segments together that are within a certain tolerance to create large + // blocks that are visualized on the timeline as recordings. + const recordings: FrigateCardTimelineItem[] = []; + + this._cameras.forEach((_, cameraID) => { + const segments = this._recordingSegments.get({ + filter: (item) => item.cameraID === cameraID, + order: sortSegmentsOldestToYoungest, + }); + let current: RecordingSegmentsItem | null = null; + for (let i = 0; i < segments.length; ++i) { + const item = segments[i]; + + if (!current) { + current = { ...item }; + } else if (current.end + RECORDING_SEGMENT_TOLERANCE * 1000 >= item.start) { + current.end = item.end; + } else { + recordings.push(convertToRecording(current)); + current = null; + } + if (i === segments.length - 1 && current) { + recordings.push(convertToRecording(current)); + } + } + }); + + this._dataset.update(recordings); + } + + /** + * Fetch recording summary. + * @param hass The HomeAssistant object. + */ + protected async _fetchRecordingSummary(hass: ExtendedHomeAssistant): Promise { + const storeRecordingSummary = async ( + cameraID: string, + cameraConfig: CameraConfig, + ): Promise => { + if (!cameraConfig.frigate.camera_name) { + return; + } + try { + this._recordingSummary.set( + cameraID, + await getRecordingsSummary( + hass, + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + ), + ); + } catch (e) { + // Recording failure should not disrupt the rest of the timeline + // experience. + errorToConsole(e as Error); + } + }; + + await Promise.all( + Array.from(this._cameras.keys()).map(async (cameraID) => { + const cameraConfig = this._cameras.get(cameraID); + if (cameraConfig) { + await storeRecordingSummary(cameraID, cameraConfig); + } + }), + ); + } + + /** + * Fetch events for the timeline. + * @param element The element to send error events from. + * @param hass The HomeAssistant object. + * @param start Fetch events that start later than this date. + * @param end Fetch events that start earlier than this date. + */ + protected async _fetchEvents( + element: HTMLElement, + hass: HomeAssistant, + start: Date, + end: Date, + ): Promise { + const params: BrowseMediaQueryParameters[] = []; + this._cameras.forEach((cameraConfig, cameraID) => { + (this._mediaType === 'all' ? ['clips', 'snapshots'] : [this._mediaType]).forEach( + (mediaType) => { + if (cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE) { + const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, { + before: end.getTime() / 1000, + after: start.getTime() / 1000, + unlimited: true, + mediaType: mediaType as 'clips' | 'snapshots', + }); + if (param) { + params.push(param); + } + } + }, + ); + }); + + if (!params.length) { + return; + } + + let results: Map; + try { + results = await multipleBrowseMediaQuery(hass, params); + } catch (e) { + return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); + } + for (const result of results.values()) { + this._addMediaSource(result); + } + } +} diff --git a/src/view.ts b/src/view.ts index 9c75b90f..dca38507 100644 --- a/src/view.ts +++ b/src/view.ts @@ -23,12 +23,12 @@ export interface ViewParameters extends ViewEvolveParameters { } export class View { - view: FrigateCardView; - camera: string; - target: FrigateBrowseMediaSource | null; - childIndex: number | null; - previous: View | null; - context: ViewContext | null; + public view: FrigateCardView; + public camera: string; + public target: FrigateBrowseMediaSource | null; + public childIndex: number | null; + public previous: View | null; + public context: ViewContext | null; constructor(params: ViewParameters) { this.view = params.view; @@ -162,7 +162,7 @@ export class View { * Determine if a view is for the media viewer. */ public isViewerView(): boolean { - return ['clip', 'snapshot', 'media'].includes(this.view); + return ['clip', 'snapshot', 'media', 'recording'].includes(this.view); } /**