From eb1b44eb27a7621e54113aa258e4f85401dddcb7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 18 Sep 2022 11:34:31 -0700 Subject: [PATCH 01/16] 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); } /** From ca831a7184403ee83d0ed920a9809e054e977d67 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 18 Sep 2022 11:53:13 -0700 Subject: [PATCH 02/16] Break circular dependency. --- src/components/surround-thumbnails.ts | 6 +- src/components/timeline-core.ts | 1253 ++++++++++++++++++++++++ src/components/timeline.ts | 1256 +------------------------ src/utils/frigate.ts | 2 +- 4 files changed, 1266 insertions(+), 1251 deletions(-) create mode 100644 src/components/timeline-core.ts diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 60438afe..281c8dfd 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -1,5 +1,5 @@ import './surround.js'; -import './timeline'; +import './timeline-core.js'; import { CSSResultGroup, @@ -43,7 +43,7 @@ declare module 'view' { } @customElement('frigate-card-surround-thumbnails') -export class FrigateCardSurround extends LitElement { +export class FrigateCardSurroundThumbnails extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @@ -222,6 +222,6 @@ export class FrigateCardSurround extends LitElement { declare global { interface HTMLElementTagNameMap { - 'frigate-card-surround-thumbnails': FrigateCardSurround; + 'frigate-card-surround-thumbnails': FrigateCardSurroundThumbnails; } } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts new file mode 100644 index 00000000..114dc69c --- /dev/null +++ b/src/components/timeline-core.ts @@ -0,0 +1,1253 @@ +import { + add, + differenceInSeconds, + endOfHour, + format, + fromUnixTime, + getUnixTime, + startOfHour, + sub, +} from 'date-fns'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { isEqual, throttle } from 'lodash-es'; +import { ViewContext } from 'view'; +import { DataView, DataSet } from 'vis-data/esnext'; +import { + DataGroupCollectionType, + IdType, + Timeline, + TimelineEventPropertiesResult, + TimelineItem, + TimelineOptions, + TimelineOptionsCluster, + TimelineWindow, +} from 'vis-timeline/esnext'; +import { CAMERA_BIRDSEYE } from '../const'; +import { localize } from '../localize/localize'; +import timelineCoreStyle from '../scss/timeline-core.scss'; +import { + CameraConfig, + ExtendedHomeAssistant, + FrigateBrowseMediaSource, + frigateCardConfigDefaults, + FrigateEvent, + TimelineCoreConfig, +} from '../types'; +import { stopEventFromActivatingCardWideActions } from '../utils/action'; +import { + contentsChanged, + dispatchFrigateCardEvent, + isHoverableDevice, + prettifyTitle, +} from '../utils/basic'; +import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js'; + +import { + createEventParentForChildren, + createVideoChild, + generateRecordingIdentifier, +} from '../utils/ha/browse-media'; +import { + FrigateCardTimelineItem, + RecordingSegmentsItem, + sortSegmentsOldestToYoungest, + sortTimelineItemsYoungestToOldest, + TimelineDataManager, +} from '../utils/timeline-data-manager'; +import { View } from '../view'; +import { dispatchMessageEvent } from './message.js'; +import "./thumbnail.js"; + +interface FrigateCardGroupData { + id: string; + content: string; +} + +interface TimelineRangeChange extends TimelineWindow { + event: Event & { additionalEvent: string }; + byUser: boolean; +} + +interface TimelineViewContext { + // The selected timeline window. + window?: TimelineWindow; + + // The date of the last event fetch. + dateFetch?: Date; + + // Whether or not thumbnails were generated. + generatedThumbnails?: boolean; +} + +declare module 'view' { + interface ViewContext { + timeline?: TimelineViewContext; + } +} + +// An event used to fetch the HASS object. See "Special note" below. +class HASSRequestEvent extends Event { + public hass?: ExtendedHomeAssistant; +} + +const TIMELINE_TARGET_BAR_ID = 'target_bar'; + +/** + * A simgple thumbnail wrapper class for use in the timeline where LIT data + * bindings are not available. + */ +@customElement('frigate-card-timeline-thumbnail') +export class FrigateCardTimelineThumbnail extends LitElement { + @property({ attribute: true }) + public thumbnail?: string; + + @property({ attribute: true, type: Boolean }) + public details = false; + + @property({ attribute: true }) + public event?: string; + + @property({ attribute: true }) + public label?: string; + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + // Don't display tooltips on touch devices, they just get in the way of + // the drawer. + if (!this.thumbnail || !this.event) { + return html``; + } + + /* Special note on what's going on here: + * + * This component does not have access to HASS, as there's no way to pass it + * in via the string-based tooltip that timeline supports. Instead dispatch + * an event to request HASS which the timeline adds to the event object + * before execution continues. + */ + const hassRequest = new HASSRequestEvent(`frigate-card:timeline:hass-request`, { + composed: true, + bubbles: true, + }); + this.dispatchEvent(hassRequest); + if (!hassRequest.hass) { + return html``; + } + + return html` + `; + } +} + +@customElement('frigate-card-timeline-core') +export class FrigateCardTimelineCore extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public cameras?: Map; + + @property({ attribute: false, hasChanged: contentsChanged }) + public timelineConfig?: TimelineCoreConfig; + + @property({ attribute: true, type: Boolean }) + public thumbnailDetails? = false; + + @property({ attribute: false }) + public thumbnailSize?: number; + + // Whether or not this is a mini-timeline for a different view (e.g. media + // viewer). + @property({ attribute: true, type: Boolean, reflect: true }) + public mini = false; + + @property({ attribute: false }) + public timelineDataManager?: TimelineDataManager; + + @state() + protected _locked = false; + + protected _targetBarVisible = false; + protected _refTimeline: Ref = createRef(); + protected _timeline?: Timeline; + protected _dataview?: DataView; + + // Need a way to separate when a user clicks (to pan the timeline) vs when a + // user clicks (to choose a recording (non-event) to play). + protected _pointerHeld: + | (TimelineEventPropertiesResult & { window?: TimelineWindow }) + | null = null; + protected _ignoreClick = false; + + protected readonly _isHoverableDevice = isHoverableDevice(); + + // Range changes are volumonous: throttle the calls on seeking. + protected _throttledSetViewDuringRangeChange = throttle( + this._setViewDuringRangeChange.bind(this), + 1000 / 10, + ); + + /** + * Get a tooltip for a given timeline event. + * @param source The FrigateBrowseMediaSource in question. + * @returns The tooltip as a string to render. + */ + protected _getTooltip(item: TimelineItem): string { + const source = (item).source; + if (!this._isHoverableDevice || !source) { + // Don't display tooltips on touch devices, they just get in the way of + // the drawer. + return ''; + } + + const eventAttr = source.frigate?.event + ? `event='${JSON.stringify(source.frigate.event)}'` + : ''; + const detailsAttr = this.thumbnailDetails ? 'details' : ''; + + // Cannot use Lit data-bindings as visjs requires a string for tooltips. + // Note that changes to attributes here must be mirrored in the xss + // whitelist in `_getOptions()` . + return ` + + `; + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.hass || !this.view || !this.timelineConfig) { + return; + } + return html`
{ + request.hass = this.hass; + }} + class="timeline" + ${ref(this._refTimeline)} + > + { + this._locked = !this._locked; + }} + aria-label="${this._locked + ? localize('timeline.unlock') + : localize('timeline.lock')}" + title="${this._locked ? localize('timeline.unlock') : localize('timeline.lock')}" + > + +
`; + } + + /** + * Get all the keys of the cameras in scope for this timeline. + * @returns A set of camera ids (may be empty). + */ + protected _getTimelineCameraIDs(): Set { + if (!this.mini || !this.cameras) { + return this._getAllCameraIDs(); + } + return getAllDependentCameras(this.cameras, this.view?.camera); + } + + /** + * Get all the keys of all cameras. + * @returns A set of camera ids (may be empty). + */ + protected _getAllCameraIDs(): Set { + return new Set(this.cameras?.keys()); + } + + /** + * Create recording objects. + * @param time The target time for the recordings. + * @param cameraIDs The camera IDs to create recordings for. + * @param onlyShowMatchingHour If `true` only shows the hour matching the target + * for the provided cameras, otherwise shows all hours. + * @returns + */ + protected _createRecordingChildren( + time: Date, + cameraIDs: Set, + onlyShowMatchingHour: boolean, + ): FrigateBrowseMediaSource[] { + const children: FrigateBrowseMediaSource[] = []; + + for (const cameraID of cameraIDs) { + const config = this.cameras?.get(cameraID); + const recordingSummary = + this.timelineDataManager?.getRecordingSummaryForCamera(cameraID); + if (!config?.frigate.camera_name || !recordingSummary) { + continue; + } + + for (const dayData of recordingSummary) { + for (const hourData of dayData.hours) { + const hour = add(dayData.day, { hours: hourData.hour }); + const startHour = startOfHour(hour); + const endHour = endOfHour(hour); + const isMatchingHour = time >= startHour && time <= endHour; + + // If asked to only provide recordings for a given camera show all + // hours, otherwise only show the matching hour from all cameras. + if (!onlyShowMatchingHour || isMatchingHour) { + children.push( + createVideoChild( + `${prettifyTitle(config.frigate.camera_name)} ${format( + hour, + 'yyyy-MM-dd HH:mm', + )}`, + generateRecordingIdentifier({ + clientId: config.frigate.client_id, + year: dayData.day.getFullYear(), + month: dayData.day.getMonth() + 1, + day: dayData.day.getDate(), + hour: hourData.hour, + cameraName: config.frigate.camera_name, + }), + { + recording: { + camera: config.frigate.camera_name, + start_time: getUnixTime(startHour), + end_time: getUnixTime(endHour), + events: hourData.events, + }, + cameraID: cameraID, + }, + ), + ); + } + } + } + } + return children; + } + + /** + * Change the view to a recording. + * @param targetTime The time of the recording to show. + * @param cameraID An optional camera to show a recording of, otherwise all + * cameras are shown at the given time. + */ + protected async _changeViewToRecording( + targetTime: Date, + cameraID?: string, + ): Promise { + if (!this.hass || !this.timelineConfig || !this.cameras) { + return; + } + + const cameraIDs = cameraID ? new Set([cameraID]) : this._getAllCameraIDs(); + const children = this._createRecordingChildren(targetTime, cameraIDs, !cameraID); + if (!children.length) { + return; + } + const viewerContext = this._generateMediaViewerContextForChildren( + children, + targetTime, + ); + const childIndex = this._findChildIndex( + children, + startOfHour(targetTime), + cameraIDs, + ); + const child = childIndex !== null ? children[childIndex] : null; + + if (childIndex !== null && child !== null) { + this.view + ?.evolve({ + view: 'recording', + target: createEventParentForChildren(localize('common.recordings'), children), + childIndex: childIndex, + ...(child.frigate?.cameraID && { camera: child.frigate?.cameraID }), + }) + .mergeInContext(viewerContext) + .dispatchChangeEvent(this); + } + } + + /** + * Find the relevant recording child given a date target. + * @param children The FrigateBrowseMediaSource[] children. Must be sorted + * most recent first. + * @param targetTime The target time used to find the relevant child. + * @param cameraIDs The camera IDs to search for. + * @param refPoint Whether to find based on the start or end of the + * event/recording. If not specified, the first match is returned rather than + * the best match. + * @returns The childindex or null if no matching child is found. + */ + protected _findChildIndex( + children: FrigateBrowseMediaSource[], + targetTime: Date, + cameraIDs: Set, + refPoint?: 'start' | 'end', + ): number | null { + let bestMatch: + | { + index: number; + delta: number; + } + | undefined; + + for (let i = 0; i < children.length; ++i) { + const child = children[i]; + if (child.frigate?.cameraID && cameraIDs.has(child.frigate.cameraID)) { + const source = child.frigate.event ?? child.frigate.recording; + if (!source?.start_time || !source?.end_time) { + continue; + } + const startTime = fromUnixTime(source.start_time); + const endTime = fromUnixTime(source.end_time); + + if (startTime <= targetTime && endTime >= targetTime) { + if (!refPoint) { + return i; + } + const delta = + refPoint === 'end' + ? endTime.getTime() - targetTime.getTime() + : targetTime.getTime() - startTime.getTime(); + if (!bestMatch || delta < bestMatch.delta) { + bestMatch = { index: i, delta: delta }; + } + } + } + } + return bestMatch ? bestMatch.index : null; + } + + /** + * Called whenever the range is in the process of being changed. + * @param properties + */ + protected _timelineRangeChangeHandler(properties: TimelineRangeChange): void { + if (this._timeline && properties.byUser) { + if (this._pointerHeld) { + this._ignoreClick = true; + } + + const targetTime = this._pointerHeld?.window + ? add(properties.start, { + seconds: + (this._pointerHeld.time.getTime() - + this._pointerHeld.window.start.getTime()) / + 1000, + }) + : properties.end; + + if (this._pointerHeld) { + this._setTargetBarAppropriately(targetTime); + } + + this._throttledSetViewDuringRangeChange(targetTime, properties); + } + } + + /** + * Set the target bar at a given time. + * @param targetTime + */ + protected _setTargetBarAppropriately(targetTime: Date): void { + if (!this._timeline) { + return; + } + + const targetBarOn = + !this._locked || + (!this.view?.is('timeline') && + this._timeline.getSelection().some((id) => { + const item = this._dataview?.get(id); + return ( + item && + item.start && + item.end && + targetTime.getTime() >= item.start && + targetTime.getTime() <= item.end + ); + })); + + if (targetBarOn) { + if (!this._targetBarVisible) { + this._timeline?.addCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); + this._targetBarVisible = true; + } else { + this._timeline?.setCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); + } + } else { + this._removeTargetBar(); + } + } + + /** + * Remove the target bar. + */ + protected _removeTargetBar(): void { + if (this._targetBarVisible) { + this._timeline?.removeCustomTime(TIMELINE_TARGET_BAR_ID); + this._targetBarVisible = false; + } + } + + /** + * Set the view during a range change. + * @param targetTime The target time. + * @param properties The range change properties. + * @returns + */ + protected _setViewDuringRangeChange( + targetTime: Date, + properties: TimelineRangeChange, + ): void { + if (!this._timeline || !this.view || !this.view.target?.children?.length) { + return; + } + + const canSeek = !!this.view?.isViewerView(); + const context = canSeek + ? this._generateMediaViewerContextForChildren( + this.view.target.children, + targetTime, + ) + : null; + + const childIndex = this._locked + ? null + : this._findChildIndex( + this.view.target.children, + targetTime, + this._getTimelineCameraIDs(), + properties.event.additionalEvent === 'panright' ? 'end' : 'start', + ); + + if (canSeek || (childIndex !== null && childIndex !== this.view.childIndex)) { + this.view + .evolve({ + ...(childIndex !== null && { + childIndex: childIndex, + }), + }) + .mergeInContext({ ...this._generateTimelineContext(true), ...context }) + .dispatchChangeEvent(this); + } + } + + /** + * Generate the media view context for a set of media children (used to set + * seek times into each media item). + * @param children The media children. + * @param targetTime The target time. + * @returns + */ + protected _generateMediaViewerContextForChildren( + children: FrigateBrowseMediaSource[], + targetTime: Date, + ): ViewContext { + if (!this.timelineDataManager) { + return {}; + } + const seek = new Map(); + const segmentsDataset = this.timelineDataManager.recordingSegments; + const hourStart = startOfHour(targetTime); + + children.forEach((child, index) => { + const source = child.frigate?.recording ?? child.frigate?.event; + if (source && source.end_time && child.frigate?.cameraID) { + const start = source.start_time * 1000; + const end = source.end_time * 1000; + let seekSeconds: number | null = null; + + if (targetTime.getTime() >= start && targetTime.getTime() <= end) { + const segments = segmentsDataset.get({ + filter: (segment) => + segment.cameraID === child.frigate?.cameraID && + segment.start >= start && + segment.end <= end, + order: sortSegmentsOldestToYoungest, + }); + seekSeconds = this._getSeekTimeInSegments( + // Recordings start from the top of the hour. + child.frigate.recording ? hourStart : fromUnixTime(source.start_time), + targetTime, + segments, + ); + } + + if (seekSeconds !== null) { + seek.set(index, { + seekSeconds: seekSeconds, + seekTime: targetTime.getTime() / 1000, + }); + } + } + }); + return seek.size > 0 ? { mediaViewer: { seek: seek } } : {}; + } + + /** + * Get the number of seconds to seek into a video stream consisting of the + * provided segments to reach the target time provided. + * @param startTime The earliest allowable time to seek from. + * @param targetTime Target time. + * @param segments An array of segments dataset items. Must be sorted from oldest to youngest. + * @returns + */ + protected _getSeekTimeInSegments( + startTime: Date, + targetTime: Date, + segments: RecordingSegmentsItem[], + ): number | null { + if (!segments.length) { + return null; + } + let seekMilliseconds = 0; + + // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27 + for (const segment of segments) { + if (segment.start > targetTime.getTime()) { + break; + } + const start = + segment.start < startTime.getTime() ? startTime.getTime() : segment.start; + const end = + segment.end > targetTime.getTime() ? targetTime.getTime() : segment.end; + seekMilliseconds += end - start; + } + return seekMilliseconds / 1000; + } + + /** + * Called whenever the timeline is clicked. + * @param properties The properties of the timeline click event. + */ + protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void { + // Calls to stopEventFromActivatingCardWideActions() are included for + // completeness. Timeline does not support card-wide events and they are + // disabled in card.ts in `_getMergedActions`. + if (properties.what === 'item' || this._ignoreClick) { + stopEventFromActivatingCardWideActions(properties.event); + } + + if (!this._ignoreClick && properties.what) { + if ( + this.timelineConfig?.show_recordings && + ['background', 'group-label', 'axis'].includes(properties.what) + ) { + if (['background', 'group-label'].includes(properties.what)) { + stopEventFromActivatingCardWideActions(properties.event); + const window = this._timeline?.getWindow(); + if (window) { + if (properties.group) { + this._changeViewToRecording(window.end, String(properties.group)); + } else if (this.mini && this.view?.camera) { + // In mini mode group may not be displayed / used, so just use the camera directly. + this._changeViewToRecording(window.end, this.view.camera); + } + } + } else { + stopEventFromActivatingCardWideActions(properties.event); + this._changeViewToRecording(properties.time); + } + } else if ( + properties.what === 'item' && + properties.item && + this.view && + this.view.target?.children + ) { + let childIndex: number | null = null; + let target: FrigateBrowseMediaSource | null = null; + let context: ViewContext = {}; + + if (this.view.is('recording')) { + const thumbnails = this._generateThumbnails(properties.item); + + if (thumbnails) { + target = thumbnails.target; + childIndex = thumbnails.childIndex; + if (thumbnails.target?.children?.length) { + context = this._generateMediaViewerContextForChildren( + thumbnails.target.children, + properties.time, + ); + } + } + } else { + childIndex = this.view.target.children.findIndex( + (child) => child.frigate?.event?.id === properties.item, + ); + } + + if (childIndex !== null && childIndex >= 0) { + this.view + ?.evolve({ + childIndex: childIndex, + ...(target && { target: target }), + }) + .mergeInContext(context) + .dispatchChangeEvent(this); + dispatchFrigateCardEvent(this, 'thumbnails:open'); + } else { + dispatchFrigateCardEvent(this, 'thumbnails:close'); + } + } + } + + this._ignoreClick = false; + } + + /** + * Get a broader prefetch window from a start and end basis. + * @param start The earlier date. + * @param end The later date. + * @returns An object with a `start` and `end` key to prefetch. + */ + protected _getPrefetchWindow(start: Date, end: Date): [Date, Date] { + const delta = differenceInSeconds(end, start); + return [sub(start, { seconds: delta }), add(end, { seconds: delta })]; + } + + /** + * Handle a range change in the timeline. + * @param properties vis.js provided range information. + */ + protected _timelineRangeChangedHandler(properties: { + start: Date; + end: Date; + byUser: boolean; + event: Event; + }): void { + if (!properties.byUser) { + return; + } + this._removeTargetBar(); + + if (this.hass && this.cameras && this._timeline && this.timelineConfig) { + const [prefetchStart, prefetchEnd] = this._getPrefetchWindow( + properties.start, + properties.end, + ); + this.timelineDataManager + ?.fetchIfNecessary(this, this.hass, prefetchStart, prefetchEnd) + .then(() => { + // Don't show event thumbnails if the user is looking at recordings, + // as the recording "hours" are the media, not the event + // clips/snapshots. + if (this._timeline && this.view && !this.view?.is('recording')) { + const thumbnails = this._generateThumbnails(); + // Update the view to reflect the new thumbnails and the timeline + // window in the context. + this.view + .evolve({ + target: thumbnails?.target ?? null, + childIndex: thumbnails?.childIndex ?? null, + }) + .mergeInContext(this._generateTimelineContext(true)) + .dispatchChangeEvent(this); + } + }); + } + } + + /** + * Regenerate the thumbnails from the timeline events. + * @param selectedItem An id to select from the thumbnails (currently selected + * item is used if none is specified). + * @returns An object with two keys, or null on error. The keys are `target` + * containing all the thumbnails, and `childIndex` to refer to the currently + * selected thumbnail. + */ + protected _generateThumbnails(selectedItem?: IdType): { + target: FrigateBrowseMediaSource; + childIndex: number | null; + } | null { + if (!this._timeline) { + return null; + } + + const selected: IdType[] = selectedItem + ? [selectedItem] + : this._timeline.getSelection(); + let childIndex = -1; + const children: FrigateBrowseMediaSource[] = []; + this._dataview?.get({ order: sortTimelineItemsYoungestToOldest }).forEach((item) => { + if (item.event && item.source) { + children.push(item.source); + if (selected.includes(item.event.id)) { + childIndex = children.length - 1; + } + } + }); + if (!children.length) { + return null; + } + + return { + target: createEventParentForChildren('Timeline events', children), + childIndex: childIndex < 0 ? null : childIndex, + }; + } + + /** + * Build the visjs dataset to render on the timeline. + * @returns The dataset. + */ + protected _getGroups(): DataGroupCollectionType { + const groups: FrigateCardGroupData[] = []; + + this._getTimelineCameraIDs().forEach((cameraID) => { + const cameraConfig = this.cameras?.get(cameraID); + if (cameraConfig) { + if ( + cameraConfig.frigate.camera_name && + cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE + ) { + groups.push({ + id: cameraID, + content: getCameraTitle(this.hass, cameraConfig), + }); + } + } + }); + return new DataSet(groups); + } + + /** + * Given an event get an appropriate start/end time window around the event. + * @param event The FrigateEvent to consider. + * @returns A tuple of start/end date. + */ + protected _getStartEndFromEvent(event: FrigateEvent): [Date, Date] { + const windowSeconds = this._getConfiguredWindowSeconds(); + if (event.end_time) { + if (event.end_time - event.start_time > windowSeconds) { + // If the event is larger than the configured window, only show the most + // recent portion of the event that fits in the window. + return [ + sub(fromUnixTime(event.end_time), { seconds: windowSeconds }), + fromUnixTime(event.end_time), + ]; + } else { + // If the event is shorter than the configured window, center the event + // in the window. + const gap = windowSeconds - (event.end_time - event.start_time); + return [ + sub(fromUnixTime(event.start_time), { seconds: gap / 2 }), + add(fromUnixTime(event.end_time), { seconds: gap / 2 }), + ]; + } + } + // If there's no end-time yet, place the start-time in the center of the + // time window. + return [ + sub(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }), + add(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }), + ]; + } + + /** + * Get the configured window length in seconds. + */ + protected _getConfiguredWindowSeconds(): number { + return ( + this.timelineConfig?.window_seconds ?? + frigateCardConfigDefaults.timeline.window_seconds + ); + } + + /** + * Get desired timeline start/end time. + * @returns A tuple of start/end date. + */ + protected _getStartEnd(): [Date, Date] { + const end = new Date(); + const start = sub(end, { + seconds: this._getConfiguredWindowSeconds(), + }); + return [start, end]; + } + + /** + * Determine if the timeline should use clustering. + * @returns `true` if the timeline should cluster, `false` otherwise. + */ + protected _isClustering(): boolean { + return ( + !!this.timelineConfig?.clustering_threshold && + this.timelineConfig.clustering_threshold > 0 + ); + } + + /** + * Handle timeline resize. + */ + protected _getOptions(): TimelineOptions | null { + if (!this.timelineConfig) { + return null; + } + + const [start, end] = this._getStartEnd(); + + // Configuration for the Timeline, see: + // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options + return { + cluster: this._isClustering() + ? { + // It would be better to automatically calculate `maxItems` from the + // rendered height of the timeline (or group within the timeline) so + // as to not waste vertical space (e.g. after the user changes to + // fullscreen mode). Unfortunately this is not easy to do, as we + // don't know the height of the timeline until after it renders -- + // and if we adjust `maxItems` then we can get into an infinite + // resize loop. Adjusting the `maxItems` of a timeline, after it's + // created, also does not appear to work as expected. + maxItems: this.timelineConfig.clustering_threshold, + + clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { + // Never include the target media in a cluster, and never group + // different object types together (e.g. person and car). + return ( + [first.type, second.type].every((type) => type !== 'background') && + first.type === second.type && + !!first.id && + first.id !== this.view?.media?.frigate?.event?.id && + !!second.id && + second.id != this.view?.media?.frigate?.event?.id && + (first).event?.label === + (second).event?.label + ); + }, + } + : (false as TimelineOptionsCluster), + minHeight: '100%', + maxHeight: '100%', + zoomMax: 1 * 24 * 60 * 60 * 1000, + zoomMin: 1 * 1000, + selectable: true, + start: start, + end: end, + groupHeightMode: 'auto', + tooltip: { + followMouse: true, + overflowMethod: 'cap', + template: this._getTooltip.bind(this), + }, + xss: { + disabled: false, + filterOptions: { + whiteList: { + 'frigate-card-timeline-thumbnail': [ + 'details', + 'thumbnail', + 'label', + 'event', + ], + div: ['title'], + span: ['style'], + }, + }, + }, + }; + } + + /** + * Determine if the component should be updated. + * @param _changedProps The changed properties. + * @returns + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected shouldUpdate(_changedProps: PropertyValues): boolean { + return !!this.hass && !!this.cameras && this.cameras.size > 0; + } + + /** + * Update the timeline from the view object. + */ + protected async _updateTimelineFromView(): Promise { + if ( + !this.hass || + !this.cameras || + !this.view || + !this.timelineConfig || + !this._timeline + ) { + return; + } + + const event = this.view?.media?.frigate?.event; + const [windowStart, windowEnd] = event + ? this._getStartEndFromEvent(event) + : this._getStartEnd(); + + let fetched = false; + if (!this._pointerHeld) { + // Don't fetch any data or touch the timeline in any way if the user is + // currently interacting with it. Without this the subsequent data fetches + // (via fetchIfNecessary) may update the timeline contents which causes + // the visjs timeline to stop dragging/panning operations which is very + // disruptive to the user. + const [prefetchStart, prefetchEnd] = this._getPrefetchWindow( + windowStart, + windowEnd, + ); + fetched = !!(await this.timelineDataManager?.fetchIfNecessary( + this, + this.hass, + prefetchStart, + prefetchEnd, + )); + } + + this._timeline.setSelection(event ? [event.id] : [], { + focus: false, + animation: { + animation: false, + zoom: false, + }, + }); + + if (event && this._isClustering()) { + // Hack: Clustering may not update unless the dataset changes, artifically + // update the dataset to ensure the newly selected item cannot be included + // in a cluster. + this.timelineDataManager?.rewriteItem(event.id); + } + + if (!this._pointerHeld) { + // Regenerate the thumbnails after the selection, to allow the new selection + // to be in the generated view. + const context = this.view.context?.timeline; + const timelineWindow = this._timeline.getWindow(); + + if (context?.window) { + if (!isEqual(context.window, timelineWindow)) { + this._timeline.setWindow(context.window.start, context.window.end); + } + } else if (event) { + const eventStart = new Date(event.start_time * 1000); + const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0; + + if ( + eventStart < timelineWindow.start || + eventStart > timelineWindow.end || + (eventEnd && + (eventEnd < timelineWindow.start || eventEnd > timelineWindow.end)) + ) { + this._timeline.setWindow(windowStart, windowEnd); + } + } else { + this._timeline.setWindow(windowStart, windowEnd); + } + } + + // Only generate thumbnails if an actual fetch occurred, to avoid getting + // stuck in a loop (the subsequent fetches will not actually fetch since the + // data will have been cached). + // + // Timeline receives a new `view` + // -> Events fetched + // -> Thumbnails generated + // -> New view dispatched (to load thumbnails into outer carousel). + // -> New view received ... [loop] + + if ((fetched || !this.view.context?.timeline?.generatedThumbnails) && !this.mini) { + const thumbnails = this._generateThumbnails(); + this.view + ?.evolve({ + target: thumbnails?.target ?? null, + childIndex: thumbnails?.childIndex ?? null, + }) + .mergeInContext(this._generateTimelineContext(false)) + .dispatchChangeEvent(this); + } + } + + /** + * Generate the context for timeline views. + * @param addWindow Whether or not to include the timeline window. If `false` + * the window is preserved if it is already in the context. + * @returns The TimelineViewContext object. + */ + protected _generateTimelineContext(addWindow: boolean): ViewContext { + const currentContext = this.view?.context?.timeline; + const newContext: TimelineViewContext = { + generatedThumbnails: true, + }; + + if (addWindow && this._timeline) { + newContext.window = this._timeline.getWindow(); + } else if (currentContext?.window) { + newContext.window = currentContext.window; + } + if (this.timelineDataManager?.lastFetchDate) { + newContext.dateFetch = this.timelineDataManager.lastFetchDate; + } + return { timeline: newContext }; + } + + /** + * Called when an update will occur. + * @param changedProps The changed properties + */ + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('thumbnailSize')) { + if (this.thumbnailSize !== undefined) { + this.style.setProperty( + '--frigate-card-thumbnail-size', + `${this.thumbnailSize}px`, + ); + } else { + this.style.removeProperty('--frigate-card-thumbnail-size'); + } + } + + if (changedProps.has('timelineConfig')) { + if (this.timelineConfig?.show_recordings) { + this.setAttribute('recordings', ''); + } else { + this.removeAttribute('recordings'); + } + } + } + + /** + * Destroy/reset the timeline. + */ + protected _destroy(): void { + this._timeline?.destroy(); + this._timeline = undefined; + } + + /** + * Called when the component is updated. + * @param changedProperties The changed properties if any. + */ + protected updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + if (changedProperties.has('cameras')) { + this._destroy(); + } + + const options = this._getOptions(); + + if ( + this.timelineDataManager && + this._refTimeline.value && + options && + (changedProperties.has('timelineConfig') || + (this.mini && + changedProperties.has('view') && + this.view?.camera !== changedProperties.get('view').camera)) + ) { + if (this._timeline) { + this._destroy(); + } + + const groups = this._getGroups(); + if (!groups.length) { + if (!this.mini) { + // Don't show an empty timeline, show a message instead. + dispatchMessageEvent(this, localize('error.timeline_no_cameras'), 'info', { + icon: 'mdi:chart-gantt', + }); + } + return; + } + + this._dataview = this.timelineDataManager.createDataView( + this._getTimelineCameraIDs(), + ); + + if (this.mini && groups.length === 1) { + // In a mini timeline, if there's only one group don't bother grouping + // at all. + this._timeline = new Timeline( + this._refTimeline.value, + this._dataview, + options, + ) as Timeline; + this.removeAttribute('groups'); + } else { + this._timeline = new Timeline( + this._refTimeline.value, + this._dataview, + groups, + options, + ) as Timeline; + this.setAttribute('groups', ''); + } + + this._timeline.on('rangechanged', this._timelineRangeChangedHandler.bind(this)); + this._timeline.on('click', this._timelineClickHandler.bind(this)); + this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this)); + + // This complexity exists to ensure we can tell between a click that + // causes the timeline zoom/range to change, and a 'static' click on the + // // timeline (which may need to trigger a card wide event). + this._timeline.on('mouseDown', (ev: TimelineEventPropertiesResult) => { + const window = this._timeline?.getWindow(); + this._pointerHeld = { + ...ev, + ...(window && { window: window }), + }; + this._ignoreClick = false; + + // if (ev.what && ['background', 'axis'].includes(ev.what)) { + // this._setTargetBarAppropriately(ev.time); + // } + }); + this._timeline.on('mouseUp', () => { + this._pointerHeld = null; + this._removeTargetBar(); + }); + } + + if (changedProperties.has('view')) { + this._updateTimelineFromView(); + } + } + + /** + * Return compiled CSS styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(timelineCoreStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-timeline-thumbnail': FrigateCardTimelineThumbnail; + 'frigate-card-timeline-core': FrigateCardTimelineCore; + } +} diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 36e88103..7bb95b55 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,172 +1,24 @@ // TODO: When a media viewer is first loaded the selected child won't work (because the underlying carousel has not yet rendered) // TODO: rename surround to surround basic and this file to surround? -// TODO: get rid of circular dependency: src/components/surround-thumbnails.ts -> src/components/timeline.ts -> src/components/surround-thumbnails.ts // TODO: thumbnails in drawers don't work. // TODO: delete segments if not in summary? is this actually necessary? could it create gaps in data? better off stopping access via summary? // TODO: support filtering created dataviews by recordings or mediatype (so storage ) // TODO: dataview refresh instead of rewriteitem? // TODO: Make minitimeline configurable in the editor -import { - add, - differenceInSeconds, - endOfHour, - format, - fromUnixTime, - getUnixTime, - startOfHour, - sub, -} from 'date-fns'; -import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS, -} from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import { isEqual, throttle } from 'lodash-es'; -import { ViewContext } from 'view'; -import { DataView, DataSet } from 'vis-data/esnext'; -import { - DataGroupCollectionType, - IdType, - Timeline, - TimelineEventPropertiesResult, - TimelineItem, - TimelineOptions, - TimelineOptionsCluster, - TimelineWindow, -} from 'vis-timeline/esnext'; -import { CAMERA_BIRDSEYE } from '../const'; -import { localize } from '../localize/localize'; -import timelineCoreStyle from '../scss/timeline-core.scss'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; import timelineStyle from '../scss/timeline.scss'; -import { - CameraConfig, - ExtendedHomeAssistant, - FrigateBrowseMediaSource, - frigateCardConfigDefaults, - FrigateEvent, - TimelineConfig, - TimelineCoreConfig, -} from '../types'; -import { stopEventFromActivatingCardWideActions } from '../utils/action'; -import { - contentsChanged, - dispatchFrigateCardEvent, - isHoverableDevice, - prettifyTitle, -} from '../utils/basic'; -import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js'; - -import { - createEventParentForChildren, - createVideoChild, - generateRecordingIdentifier, -} from '../utils/ha/browse-media'; -import { - FrigateCardTimelineItem, - RecordingSegmentsItem, - sortSegmentsOldestToYoungest, - sortTimelineItemsYoungestToOldest, - TimelineDataManager, -} from '../utils/timeline-data-manager'; +import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; +import { TimelineDataManager } from '../utils/timeline-data-manager'; import { View } from '../view'; -import { dispatchMessageEvent } from './message.js'; import './surround-thumbnails.js'; +import './timeline-core.js'; -interface FrigateCardGroupData { - id: string; - content: string; -} - -interface TimelineRangeChange extends TimelineWindow { - event: Event & { additionalEvent: string }; - byUser: boolean; -} - -interface TimelineViewContext { - // The selected timeline window. - window?: TimelineWindow; - - // The date of the last event fetch. - dateFetch?: Date; - - // Whether or not thumbnails were generated. - generatedThumbnails?: boolean; -} - -declare module 'view' { - interface ViewContext { - timeline?: TimelineViewContext; - } -} - -// An event used to fetch the HASS object. See "Special note" below. -class HASSRequestEvent extends Event { - public hass?: ExtendedHomeAssistant; -} - -const TIMELINE_TARGET_BAR_ID = 'target_bar'; - -/** - * A simgple thumbnail wrapper class for use in the timeline where LIT data - * bindings are not available. - */ -@customElement('frigate-card-timeline-thumbnail') -export class FrigateCardTimelineThumbnail extends LitElement { - @property({ attribute: true }) - public thumbnail?: string; - - @property({ attribute: true, type: Boolean }) - public details = false; - - @property({ attribute: true }) - public event?: string; - - @property({ attribute: true }) - public label?: string; - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - // Don't display tooltips on touch devices, they just get in the way of - // the drawer. - if (!this.thumbnail || !this.event) { - return html``; - } - - /* Special note on what's going on here: - * - * This component does not have access to HASS, as there's no way to pass it - * in via the string-based tooltip that timeline supports. Instead dispatch - * an event to request HASS which the timeline adds to the event object - * before execution continues. - */ - const hassRequest = new HASSRequestEvent(`frigate-card:timeline:hass-request`, { - composed: true, - bubbles: true, - }); - this.dispatchEvent(hassRequest); - if (!hassRequest.hass) { - return html``; - } - - return html` - `; - } -} +// This file is kept separate from timeline-core.ts to avoid a circular dependency: +// FrigateCardTimeline -> +// FrigateCardSurroundThumbnails -> +// FrigateCardTimelineCore @customElement('frigate-card-timeline') export class FrigateCardTimeline extends LitElement { @@ -221,1098 +73,8 @@ export class FrigateCardTimeline extends LitElement { } } -@customElement('frigate-card-timeline-core') -export class FrigateCardTimelineCore extends LitElement { - @property({ attribute: false }) - public hass?: ExtendedHomeAssistant; - - @property({ attribute: false }) - public view?: Readonly; - - @property({ attribute: false }) - public cameras?: Map; - - @property({ attribute: false, hasChanged: contentsChanged }) - public timelineConfig?: TimelineCoreConfig; - - @property({ attribute: true, type: Boolean }) - public thumbnailDetails? = false; - - @property({ attribute: false }) - public thumbnailSize?: number; - - // Whether or not this is a mini-timeline for a different view (e.g. media - // viewer). - @property({ attribute: true, type: Boolean, reflect: true }) - public mini = false; - - @property({ attribute: false }) - public timelineDataManager?: TimelineDataManager; - - @state() - protected _locked = false; - - protected _targetBarVisible = false; - protected _refTimeline: Ref = createRef(); - protected _timeline?: Timeline; - protected _dataview?: DataView; - - // Need a way to separate when a user clicks (to pan the timeline) vs when a - // user clicks (to choose a recording (non-event) to play). - protected _pointerHeld: - | (TimelineEventPropertiesResult & { window?: TimelineWindow }) - | null = null; - protected _ignoreClick = false; - - protected readonly _isHoverableDevice = isHoverableDevice(); - - // Range changes are volumonous: throttle the calls on seeking. - protected _throttledSetViewDuringRangeChange = throttle( - this._setViewDuringRangeChange.bind(this), - 1000 / 10, - ); - - /** - * Get a tooltip for a given timeline event. - * @param source The FrigateBrowseMediaSource in question. - * @returns The tooltip as a string to render. - */ - protected _getTooltip(item: TimelineItem): string { - const source = (item).source; - if (!this._isHoverableDevice || !source) { - // Don't display tooltips on touch devices, they just get in the way of - // the drawer. - return ''; - } - - const eventAttr = source.frigate?.event - ? `event='${JSON.stringify(source.frigate.event)}'` - : ''; - const detailsAttr = this.thumbnailDetails ? 'details' : ''; - - // Cannot use Lit data-bindings as visjs requires a string for tooltips. - // Note that changes to attributes here must be mirrored in the xss - // whitelist in `_getOptions()` . - return ` - - `; - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.timelineConfig) { - return; - } - return html`
{ - request.hass = this.hass; - }} - class="timeline" - ${ref(this._refTimeline)} - > - { - this._locked = !this._locked; - }} - aria-label="${this._locked - ? localize('timeline.unlock') - : localize('timeline.lock')}" - title="${this._locked ? localize('timeline.unlock') : localize('timeline.lock')}" - > - -
`; - } - - /** - * Get all the keys of the cameras in scope for this timeline. - * @returns A set of camera ids (may be empty). - */ - protected _getTimelineCameraIDs(): Set { - if (!this.mini || !this.cameras) { - return this._getAllCameraIDs(); - } - return getAllDependentCameras(this.cameras, this.view?.camera); - } - - /** - * Get all the keys of all cameras. - * @returns A set of camera ids (may be empty). - */ - protected _getAllCameraIDs(): Set { - return new Set(this.cameras?.keys()); - } - - /** - * Create recording objects. - * @param time The target time for the recordings. - * @param cameraIDs The camera IDs to create recordings for. - * @param onlyShowMatchingHour If `true` only shows the hour matching the target - * for the provided cameras, otherwise shows all hours. - * @returns - */ - protected _createRecordingChildren( - time: Date, - cameraIDs: Set, - onlyShowMatchingHour: boolean, - ): FrigateBrowseMediaSource[] { - const children: FrigateBrowseMediaSource[] = []; - - for (const cameraID of cameraIDs) { - const config = this.cameras?.get(cameraID); - const recordingSummary = - this.timelineDataManager?.getRecordingSummaryForCamera(cameraID); - if (!config?.frigate.camera_name || !recordingSummary) { - continue; - } - - for (const dayData of recordingSummary) { - for (const hourData of dayData.hours) { - const hour = add(dayData.day, { hours: hourData.hour }); - const startHour = startOfHour(hour); - const endHour = endOfHour(hour); - const isMatchingHour = time >= startHour && time <= endHour; - - // If asked to only provide recordings for a given camera show all - // hours, otherwise only show the matching hour from all cameras. - if (!onlyShowMatchingHour || isMatchingHour) { - children.push( - createVideoChild( - `${prettifyTitle(config.frigate.camera_name)} ${format( - hour, - 'yyyy-MM-dd HH:mm', - )}`, - generateRecordingIdentifier({ - clientId: config.frigate.client_id, - year: dayData.day.getFullYear(), - month: dayData.day.getMonth() + 1, - day: dayData.day.getDate(), - hour: hourData.hour, - cameraName: config.frigate.camera_name, - }), - { - recording: { - camera: config.frigate.camera_name, - start_time: getUnixTime(startHour), - end_time: getUnixTime(endHour), - events: hourData.events, - }, - cameraID: cameraID, - }, - ), - ); - } - } - } - } - return children; - } - - /** - * Change the view to a recording. - * @param targetTime The time of the recording to show. - * @param cameraID An optional camera to show a recording of, otherwise all - * cameras are shown at the given time. - */ - protected async _changeViewToRecording( - targetTime: Date, - cameraID?: string, - ): Promise { - if (!this.hass || !this.timelineConfig || !this.cameras) { - return; - } - - const cameraIDs = cameraID ? new Set([cameraID]) : this._getAllCameraIDs(); - const children = this._createRecordingChildren(targetTime, cameraIDs, !cameraID); - if (!children.length) { - return; - } - const viewerContext = this._generateMediaViewerContextForChildren( - children, - targetTime, - ); - const childIndex = this._findChildIndex( - children, - startOfHour(targetTime), - cameraIDs, - ); - const child = childIndex !== null ? children[childIndex] : null; - - if (childIndex !== null && child !== null) { - this.view - ?.evolve({ - view: 'recording', - target: createEventParentForChildren(localize('common.recordings'), children), - childIndex: childIndex, - ...(child.frigate?.cameraID && { camera: child.frigate?.cameraID }), - }) - .mergeInContext(viewerContext) - .dispatchChangeEvent(this); - } - } - - /** - * Find the relevant recording child given a date target. - * @param children The FrigateBrowseMediaSource[] children. Must be sorted - * most recent first. - * @param targetTime The target time used to find the relevant child. - * @param cameraIDs The camera IDs to search for. - * @param refPoint Whether to find based on the start or end of the - * event/recording. If not specified, the first match is returned rather than - * the best match. - * @returns The childindex or null if no matching child is found. - */ - protected _findChildIndex( - children: FrigateBrowseMediaSource[], - targetTime: Date, - cameraIDs: Set, - refPoint?: 'start' | 'end', - ): number | null { - let bestMatch: - | { - index: number; - delta: number; - } - | undefined; - - for (let i = 0; i < children.length; ++i) { - const child = children[i]; - if (child.frigate?.cameraID && cameraIDs.has(child.frigate.cameraID)) { - const source = child.frigate.event ?? child.frigate.recording; - if (!source?.start_time || !source?.end_time) { - continue; - } - const startTime = fromUnixTime(source.start_time); - const endTime = fromUnixTime(source.end_time); - - if (startTime <= targetTime && endTime >= targetTime) { - if (!refPoint) { - return i; - } - const delta = - refPoint === 'end' - ? endTime.getTime() - targetTime.getTime() - : targetTime.getTime() - startTime.getTime(); - if (!bestMatch || delta < bestMatch.delta) { - bestMatch = { index: i, delta: delta }; - } - } - } - } - return bestMatch ? bestMatch.index : null; - } - - /** - * Called whenever the range is in the process of being changed. - * @param properties - */ - protected _timelineRangeChangeHandler(properties: TimelineRangeChange): void { - if (this._timeline && properties.byUser) { - if (this._pointerHeld) { - this._ignoreClick = true; - } - - const targetTime = this._pointerHeld?.window - ? add(properties.start, { - seconds: - (this._pointerHeld.time.getTime() - - this._pointerHeld.window.start.getTime()) / - 1000, - }) - : properties.end; - - if (this._pointerHeld) { - this._setTargetBarAppropriately(targetTime); - } - - this._throttledSetViewDuringRangeChange(targetTime, properties); - } - } - - /** - * Set the target bar at a given time. - * @param targetTime - */ - protected _setTargetBarAppropriately(targetTime: Date): void { - if (!this._timeline) { - return; - } - - const targetBarOn = - !this._locked || - (!this.view?.is('timeline') && - this._timeline.getSelection().some((id) => { - const item = this._dataview?.get(id); - return ( - item && - item.start && - item.end && - targetTime.getTime() >= item.start && - targetTime.getTime() <= item.end - ); - })); - - if (targetBarOn) { - if (!this._targetBarVisible) { - this._timeline?.addCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); - this._targetBarVisible = true; - } else { - this._timeline?.setCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); - } - } else { - this._removeTargetBar(); - } - } - - /** - * Remove the target bar. - */ - protected _removeTargetBar(): void { - if (this._targetBarVisible) { - this._timeline?.removeCustomTime(TIMELINE_TARGET_BAR_ID); - this._targetBarVisible = false; - } - } - - /** - * Set the view during a range change. - * @param targetTime The target time. - * @param properties The range change properties. - * @returns - */ - protected _setViewDuringRangeChange( - targetTime: Date, - properties: TimelineRangeChange, - ): void { - if (!this._timeline || !this.view || !this.view.target?.children?.length) { - return; - } - - const canSeek = !!this.view?.isViewerView(); - const context = canSeek - ? this._generateMediaViewerContextForChildren( - this.view.target.children, - targetTime, - ) - : null; - - const childIndex = this._locked - ? null - : this._findChildIndex( - this.view.target.children, - targetTime, - this._getTimelineCameraIDs(), - properties.event.additionalEvent === 'panright' ? 'end' : 'start', - ); - - if (canSeek || (childIndex !== null && childIndex !== this.view.childIndex)) { - this.view - .evolve({ - ...(childIndex !== null && { - childIndex: childIndex, - }), - }) - .mergeInContext({ ...this._generateTimelineContext(true), ...context }) - .dispatchChangeEvent(this); - } - } - - /** - * Generate the media view context for a set of media children (used to set - * seek times into each media item). - * @param children The media children. - * @param targetTime The target time. - * @returns - */ - protected _generateMediaViewerContextForChildren( - children: FrigateBrowseMediaSource[], - targetTime: Date, - ): ViewContext { - if (!this.timelineDataManager) { - return {}; - } - const seek = new Map(); - const segmentsDataset = this.timelineDataManager.recordingSegments; - const hourStart = startOfHour(targetTime); - - children.forEach((child, index) => { - const source = child.frigate?.recording ?? child.frigate?.event; - if (source && source.end_time && child.frigate?.cameraID) { - const start = source.start_time * 1000; - const end = source.end_time * 1000; - let seekSeconds: number | null = null; - - if (targetTime.getTime() >= start && targetTime.getTime() <= end) { - const segments = segmentsDataset.get({ - filter: (segment) => - segment.cameraID === child.frigate?.cameraID && - segment.start >= start && - segment.end <= end, - order: sortSegmentsOldestToYoungest, - }); - seekSeconds = this._getSeekTimeInSegments( - // Recordings start from the top of the hour. - child.frigate.recording ? hourStart : fromUnixTime(source.start_time), - targetTime, - segments, - ); - } - - if (seekSeconds !== null) { - seek.set(index, { - seekSeconds: seekSeconds, - seekTime: targetTime.getTime() / 1000, - }); - } - } - }); - return seek.size > 0 ? { mediaViewer: { seek: seek } } : {}; - } - - /** - * Get the number of seconds to seek into a video stream consisting of the - * provided segments to reach the target time provided. - * @param startTime The earliest allowable time to seek from. - * @param targetTime Target time. - * @param segments An array of segments dataset items. Must be sorted from oldest to youngest. - * @returns - */ - protected _getSeekTimeInSegments( - startTime: Date, - targetTime: Date, - segments: RecordingSegmentsItem[], - ): number | null { - if (!segments.length) { - return null; - } - let seekMilliseconds = 0; - - // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27 - for (const segment of segments) { - if (segment.start > targetTime.getTime()) { - break; - } - const start = - segment.start < startTime.getTime() ? startTime.getTime() : segment.start; - const end = - segment.end > targetTime.getTime() ? targetTime.getTime() : segment.end; - seekMilliseconds += end - start; - } - return seekMilliseconds / 1000; - } - - /** - * Called whenever the timeline is clicked. - * @param properties The properties of the timeline click event. - */ - protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void { - // Calls to stopEventFromActivatingCardWideActions() are included for - // completeness. Timeline does not support card-wide events and they are - // disabled in card.ts in `_getMergedActions`. - if (properties.what === 'item' || this._ignoreClick) { - stopEventFromActivatingCardWideActions(properties.event); - } - - if (!this._ignoreClick && properties.what) { - if ( - this.timelineConfig?.show_recordings && - ['background', 'group-label', 'axis'].includes(properties.what) - ) { - if (['background', 'group-label'].includes(properties.what)) { - stopEventFromActivatingCardWideActions(properties.event); - const window = this._timeline?.getWindow(); - if (window) { - if (properties.group) { - this._changeViewToRecording(window.end, String(properties.group)); - } else if (this.mini && this.view?.camera) { - // In mini mode group may not be displayed / used, so just use the camera directly. - this._changeViewToRecording(window.end, this.view.camera); - } - } - } else { - stopEventFromActivatingCardWideActions(properties.event); - this._changeViewToRecording(properties.time); - } - } else if ( - properties.what === 'item' && - properties.item && - this.view && - this.view.target?.children - ) { - let childIndex: number | null = null; - let target: FrigateBrowseMediaSource | null = null; - let context: ViewContext = {}; - - if (this.view.is('recording')) { - const thumbnails = this._generateThumbnails(properties.item); - - if (thumbnails) { - target = thumbnails.target; - childIndex = thumbnails.childIndex; - if (thumbnails.target?.children?.length) { - context = this._generateMediaViewerContextForChildren( - thumbnails.target.children, - properties.time, - ); - } - } - } else { - childIndex = this.view.target.children.findIndex( - (child) => child.frigate?.event?.id === properties.item, - ); - } - - if (childIndex !== null && childIndex >= 0) { - this.view - ?.evolve({ - childIndex: childIndex, - ...(target && { target: target }), - }) - .mergeInContext(context) - .dispatchChangeEvent(this); - dispatchFrigateCardEvent(this, 'thumbnails:open'); - } else { - dispatchFrigateCardEvent(this, 'thumbnails:close'); - } - } - } - - this._ignoreClick = false; - } - - /** - * Get a broader prefetch window from a start and end basis. - * @param start The earlier date. - * @param end The later date. - * @returns An object with a `start` and `end` key to prefetch. - */ - protected _getPrefetchWindow(start: Date, end: Date): [Date, Date] { - const delta = differenceInSeconds(end, start); - return [sub(start, { seconds: delta }), add(end, { seconds: delta })]; - } - - /** - * Handle a range change in the timeline. - * @param properties vis.js provided range information. - */ - protected _timelineRangeChangedHandler(properties: { - start: Date; - end: Date; - byUser: boolean; - event: Event; - }): void { - if (!properties.byUser) { - return; - } - this._removeTargetBar(); - - if (this.hass && this.cameras && this._timeline && this.timelineConfig) { - const [prefetchStart, prefetchEnd] = this._getPrefetchWindow( - properties.start, - properties.end, - ); - this.timelineDataManager - ?.fetchIfNecessary(this, this.hass, prefetchStart, prefetchEnd) - .then(() => { - // Don't show event thumbnails if the user is looking at recordings, - // as the recording "hours" are the media, not the event - // clips/snapshots. - if (this._timeline && this.view && !this.view?.is('recording')) { - const thumbnails = this._generateThumbnails(); - // Update the view to reflect the new thumbnails and the timeline - // window in the context. - this.view - .evolve({ - target: thumbnails?.target ?? null, - childIndex: thumbnails?.childIndex ?? null, - }) - .mergeInContext(this._generateTimelineContext(true)) - .dispatchChangeEvent(this); - } - }); - } - } - - /** - * Regenerate the thumbnails from the timeline events. - * @param selectedItem An id to select from the thumbnails (currently selected - * item is used if none is specified). - * @returns An object with two keys, or null on error. The keys are `target` - * containing all the thumbnails, and `childIndex` to refer to the currently - * selected thumbnail. - */ - protected _generateThumbnails(selectedItem?: IdType): { - target: FrigateBrowseMediaSource; - childIndex: number | null; - } | null { - if (!this._timeline) { - return null; - } - - const selected: IdType[] = selectedItem - ? [selectedItem] - : this._timeline.getSelection(); - let childIndex = -1; - const children: FrigateBrowseMediaSource[] = []; - this._dataview?.get({ order: sortTimelineItemsYoungestToOldest }).forEach((item) => { - if (item.event && item.source) { - children.push(item.source); - if (selected.includes(item.event.id)) { - childIndex = children.length - 1; - } - } - }); - if (!children.length) { - return null; - } - - return { - target: createEventParentForChildren('Timeline events', children), - childIndex: childIndex < 0 ? null : childIndex, - }; - } - - /** - * Build the visjs dataset to render on the timeline. - * @returns The dataset. - */ - protected _getGroups(): DataGroupCollectionType { - const groups: FrigateCardGroupData[] = []; - - this._getTimelineCameraIDs().forEach((cameraID) => { - const cameraConfig = this.cameras?.get(cameraID); - if (cameraConfig) { - if ( - cameraConfig.frigate.camera_name && - cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE - ) { - groups.push({ - id: cameraID, - content: getCameraTitle(this.hass, cameraConfig), - }); - } - } - }); - return new DataSet(groups); - } - - /** - * Given an event get an appropriate start/end time window around the event. - * @param event The FrigateEvent to consider. - * @returns A tuple of start/end date. - */ - protected _getStartEndFromEvent(event: FrigateEvent): [Date, Date] { - const windowSeconds = this._getConfiguredWindowSeconds(); - if (event.end_time) { - if (event.end_time - event.start_time > windowSeconds) { - // If the event is larger than the configured window, only show the most - // recent portion of the event that fits in the window. - return [ - sub(fromUnixTime(event.end_time), { seconds: windowSeconds }), - fromUnixTime(event.end_time), - ]; - } else { - // If the event is shorter than the configured window, center the event - // in the window. - const gap = windowSeconds - (event.end_time - event.start_time); - return [ - sub(fromUnixTime(event.start_time), { seconds: gap / 2 }), - add(fromUnixTime(event.end_time), { seconds: gap / 2 }), - ]; - } - } - // If there's no end-time yet, place the start-time in the center of the - // time window. - return [ - sub(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }), - add(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }), - ]; - } - - /** - * Get the configured window length in seconds. - */ - protected _getConfiguredWindowSeconds(): number { - return ( - this.timelineConfig?.window_seconds ?? - frigateCardConfigDefaults.timeline.window_seconds - ); - } - - /** - * Get desired timeline start/end time. - * @returns A tuple of start/end date. - */ - protected _getStartEnd(): [Date, Date] { - const end = new Date(); - const start = sub(end, { - seconds: this._getConfiguredWindowSeconds(), - }); - return [start, end]; - } - - /** - * Determine if the timeline should use clustering. - * @returns `true` if the timeline should cluster, `false` otherwise. - */ - protected _isClustering(): boolean { - return ( - !!this.timelineConfig?.clustering_threshold && - this.timelineConfig.clustering_threshold > 0 - ); - } - - /** - * Handle timeline resize. - */ - protected _getOptions(): TimelineOptions | null { - if (!this.timelineConfig) { - return null; - } - - const [start, end] = this._getStartEnd(); - - // Configuration for the Timeline, see: - // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options - return { - cluster: this._isClustering() - ? { - // It would be better to automatically calculate `maxItems` from the - // rendered height of the timeline (or group within the timeline) so - // as to not waste vertical space (e.g. after the user changes to - // fullscreen mode). Unfortunately this is not easy to do, as we - // don't know the height of the timeline until after it renders -- - // and if we adjust `maxItems` then we can get into an infinite - // resize loop. Adjusting the `maxItems` of a timeline, after it's - // created, also does not appear to work as expected. - maxItems: this.timelineConfig.clustering_threshold, - - clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { - // Never include the target media in a cluster, and never group - // different object types together (e.g. person and car). - return ( - [first.type, second.type].every((type) => type !== 'background') && - first.type === second.type && - !!first.id && - first.id !== this.view?.media?.frigate?.event?.id && - !!second.id && - second.id != this.view?.media?.frigate?.event?.id && - (first).event?.label === - (second).event?.label - ); - }, - } - : (false as TimelineOptionsCluster), - minHeight: '100%', - maxHeight: '100%', - zoomMax: 1 * 24 * 60 * 60 * 1000, - zoomMin: 1 * 1000, - selectable: true, - start: start, - end: end, - groupHeightMode: 'auto', - tooltip: { - followMouse: true, - overflowMethod: 'cap', - template: this._getTooltip.bind(this), - }, - xss: { - disabled: false, - filterOptions: { - whiteList: { - 'frigate-card-timeline-thumbnail': [ - 'details', - 'thumbnail', - 'label', - 'event', - ], - div: ['title'], - span: ['style'], - }, - }, - }, - }; - } - - /** - * Determine if the component should be updated. - * @param _changedProps The changed properties. - * @returns - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected shouldUpdate(_changedProps: PropertyValues): boolean { - return !!this.hass && !!this.cameras && this.cameras.size > 0; - } - - /** - * Update the timeline from the view object. - */ - protected async _updateTimelineFromView(): Promise { - if ( - !this.hass || - !this.cameras || - !this.view || - !this.timelineConfig || - !this._timeline - ) { - return; - } - - const event = this.view?.media?.frigate?.event; - const [windowStart, windowEnd] = event - ? this._getStartEndFromEvent(event) - : this._getStartEnd(); - - let fetched = false; - if (!this._pointerHeld) { - // Don't fetch any data or touch the timeline in any way if the user is - // currently interacting with it. Without this the subsequent data fetches - // (via fetchIfNecessary) may update the timeline contents which causes - // the visjs timeline to stop dragging/panning operations which is very - // disruptive to the user. - const [prefetchStart, prefetchEnd] = this._getPrefetchWindow( - windowStart, - windowEnd, - ); - fetched = !!(await this.timelineDataManager?.fetchIfNecessary( - this, - this.hass, - prefetchStart, - prefetchEnd, - )); - } - - this._timeline.setSelection(event ? [event.id] : [], { - focus: false, - animation: { - animation: false, - zoom: false, - }, - }); - - if (event && this._isClustering()) { - // Hack: Clustering may not update unless the dataset changes, artifically - // update the dataset to ensure the newly selected item cannot be included - // in a cluster. - this.timelineDataManager?.rewriteItem(event.id); - } - - if (!this._pointerHeld) { - // Regenerate the thumbnails after the selection, to allow the new selection - // to be in the generated view. - const context = this.view.context?.timeline; - const timelineWindow = this._timeline.getWindow(); - - if (context?.window) { - if (!isEqual(context.window, timelineWindow)) { - this._timeline.setWindow(context.window.start, context.window.end); - } - } else if (event) { - const eventStart = new Date(event.start_time * 1000); - const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0; - - if ( - eventStart < timelineWindow.start || - eventStart > timelineWindow.end || - (eventEnd && - (eventEnd < timelineWindow.start || eventEnd > timelineWindow.end)) - ) { - this._timeline.setWindow(windowStart, windowEnd); - } - } else { - this._timeline.setWindow(windowStart, windowEnd); - } - } - - // Only generate thumbnails if an actual fetch occurred, to avoid getting - // stuck in a loop (the subsequent fetches will not actually fetch since the - // data will have been cached). - // - // Timeline receives a new `view` - // -> Events fetched - // -> Thumbnails generated - // -> New view dispatched (to load thumbnails into outer carousel). - // -> New view received ... [loop] - - if ((fetched || !this.view.context?.timeline?.generatedThumbnails) && !this.mini) { - const thumbnails = this._generateThumbnails(); - this.view - ?.evolve({ - target: thumbnails?.target ?? null, - childIndex: thumbnails?.childIndex ?? null, - }) - .mergeInContext(this._generateTimelineContext(false)) - .dispatchChangeEvent(this); - } - } - - /** - * Generate the context for timeline views. - * @param addWindow Whether or not to include the timeline window. If `false` - * the window is preserved if it is already in the context. - * @returns The TimelineViewContext object. - */ - protected _generateTimelineContext(addWindow: boolean): ViewContext { - const currentContext = this.view?.context?.timeline; - const newContext: TimelineViewContext = { - generatedThumbnails: true, - }; - - if (addWindow && this._timeline) { - newContext.window = this._timeline.getWindow(); - } else if (currentContext?.window) { - newContext.window = currentContext.window; - } - if (this.timelineDataManager?.lastFetchDate) { - newContext.dateFetch = this.timelineDataManager.lastFetchDate; - } - return { timeline: newContext }; - } - - /** - * Called when an update will occur. - * @param changedProps The changed properties - */ - protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('thumbnailSize')) { - if (this.thumbnailSize !== undefined) { - this.style.setProperty( - '--frigate-card-thumbnail-size', - `${this.thumbnailSize}px`, - ); - } else { - this.style.removeProperty('--frigate-card-thumbnail-size'); - } - } - - if (changedProps.has('timelineConfig')) { - if (this.timelineConfig?.show_recordings) { - this.setAttribute('recordings', ''); - } else { - this.removeAttribute('recordings'); - } - } - } - - /** - * Destroy/reset the timeline. - */ - protected _destroy(): void { - this._timeline?.destroy(); - this._timeline = undefined; - } - - /** - * Called when the component is updated. - * @param changedProperties The changed properties if any. - */ - protected updated(changedProperties: PropertyValues): void { - super.updated(changedProperties); - - if (changedProperties.has('cameras')) { - this._destroy(); - } - - const options = this._getOptions(); - - if ( - this.timelineDataManager && - this._refTimeline.value && - options && - (changedProperties.has('timelineConfig') || - (this.mini && - changedProperties.has('view') && - this.view?.camera !== changedProperties.get('view').camera)) - ) { - if (this._timeline) { - this._destroy(); - } - - const groups = this._getGroups(); - if (!groups.length) { - if (!this.mini) { - // Don't show an empty timeline, show a message instead. - dispatchMessageEvent(this, localize('error.timeline_no_cameras'), 'info', { - icon: 'mdi:chart-gantt', - }); - } - return; - } - - this._dataview = this.timelineDataManager.createDataView( - this._getTimelineCameraIDs(), - ); - - if (this.mini && groups.length === 1) { - // In a mini timeline, if there's only one group don't bother grouping - // at all. - this._timeline = new Timeline( - this._refTimeline.value, - this._dataview, - options, - ) as Timeline; - this.removeAttribute('groups'); - } else { - this._timeline = new Timeline( - this._refTimeline.value, - this._dataview, - groups, - options, - ) as Timeline; - this.setAttribute('groups', ''); - } - - this._timeline.on('rangechanged', this._timelineRangeChangedHandler.bind(this)); - this._timeline.on('click', this._timelineClickHandler.bind(this)); - this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this)); - - // This complexity exists to ensure we can tell between a click that - // causes the timeline zoom/range to change, and a 'static' click on the - // // timeline (which may need to trigger a card wide event). - this._timeline.on('mouseDown', (ev: TimelineEventPropertiesResult) => { - const window = this._timeline?.getWindow(); - this._pointerHeld = { - ...ev, - ...(window && { window: window }), - }; - this._ignoreClick = false; - - // if (ev.what && ['background', 'axis'].includes(ev.what)) { - // this._setTargetBarAppropriately(ev.time); - // } - }); - this._timeline.on('mouseUp', () => { - this._pointerHeld = null; - this._removeTargetBar(); - }); - } - - if (changedProperties.has('view')) { - this._updateTimelineFromView(); - } - } - - /** - * Return compiled CSS styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(timelineCoreStyle); - } -} - declare global { interface HTMLElementTagNameMap { - 'frigate-card-timeline-thumbnail': FrigateCardTimelineThumbnail; - 'frigate-card-timeline-core': FrigateCardTimelineCore; 'frigate-card-timeline': FrigateCardTimeline; } } diff --git a/src/utils/frigate.ts b/src/utils/frigate.ts index d7369737..6c61da10 100644 --- a/src/utils/frigate.ts +++ b/src/utils/frigate.ts @@ -1,7 +1,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import { z } from 'zod'; import { localize } from '../localize/localize'; -import { CameraConfig, ExtendedHomeAssistant, FrigateCardError } from '../types'; +import { ExtendedHomeAssistant, FrigateCardError } from '../types'; import { homeAssistantWSRequest } from './ha'; export const FRIGATE_ICON_SVG_PATH = From eb88c6e0e0ed453feda5470941a74894e2c56146 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 18 Sep 2022 12:28:31 -0700 Subject: [PATCH 03/16] Rename surround-thumbnails to just surround. --- src/components/live.ts | 6 +- src/components/surround-basic.ts | 77 +++++++++ src/components/surround-thumbnails.ts | 227 ------------------------- src/components/surround.ts | 230 +++++++++++++++++++++----- src/components/timeline.ts | 10 +- src/components/viewer.ts | 6 +- src/scss/surround-basic.scss | 21 +++ src/scss/surround-thumbnails.scss | 5 - src/scss/surround.scss | 20 +-- src/scss/timeline-core.scss | 2 +- 10 files changed, 302 insertions(+), 302 deletions(-) create mode 100644 src/components/surround-basic.ts delete mode 100644 src/components/surround-thumbnails.ts create mode 100644 src/scss/surround-basic.scss delete mode 100644 src/scss/surround-thumbnails.scss diff --git a/src/components/live.ts b/src/components/live.ts index 0cdec73b..21e22273 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -61,7 +61,7 @@ import { import { dispatchErrorMessageEvent } from './message.js'; import './next-prev-control.js'; import './title-control.js'; -import './surround-thumbnails'; +import './surround.js'; import '../patches/ha-camera-stream'; import { EmblaCarouselPlugins } from './carousel.js'; import { renderTask } from '../utils/task.js'; @@ -213,7 +213,7 @@ export class FrigateCardLive extends LitElement { // is received when the card is in the background). const result = html`${keyed( this._renderKey, - html` - `, + `, )}`; this._messageReceivedPostRender = false; diff --git a/src/components/surround-basic.ts b/src/components/surround-basic.ts new file mode 100644 index 00000000..e8f760a0 --- /dev/null +++ b/src/components/surround-basic.ts @@ -0,0 +1,77 @@ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { customElement } from 'lit/decorators.js'; + +import { FrigateCardDrawer } from './drawer.js'; + +import './drawer.js'; + +import surroundBasicStyle from '../scss/surround-basic.scss'; + +interface FrigateCardDrawerOpen { + drawer: 'left' | 'right'; +} + +@customElement('frigate-card-surround-basic') +export class FrigateCardSurroundBasic extends LitElement { + protected _refDrawerLeft: Ref = createRef(); + protected _refDrawerRight: Ref = createRef(); + protected _boundDrawerHandler = this._drawerHandler.bind(this); + + /** + * Component connected callback. + */ + connectedCallback(): void { + super.connectedCallback(); + this.addEventListener('frigate-card:drawer:open', this._boundDrawerHandler); + this.addEventListener('frigate-card:drawer:close', this._boundDrawerHandler); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener('frigate-card:drawer:open', this._boundDrawerHandler); + this.removeEventListener('frigate-card:drawer:close', this._boundDrawerHandler); + } + + protected _drawerHandler(ev: Event) { + const drawer = (ev as CustomEvent).detail.drawer; + const open = ev.type.endsWith(':open'); + if (drawer === 'left' && this._refDrawerLeft.value) { + this._refDrawerLeft.value.open = open; + } else if (drawer === 'right' && this._refDrawerRight.value) { + this._refDrawerRight.value.open = open; + } + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + return html` + + + + + + + + `; + } + + /** + * Return compiled CSS styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(surroundBasicStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-surround-basic': FrigateCardSurroundBasic; + } +} diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts deleted file mode 100644 index 281c8dfd..00000000 --- a/src/components/surround-thumbnails.ts +++ /dev/null @@ -1,227 +0,0 @@ -import './surround.js'; -import './timeline-core.js'; - -import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS, -} from 'lit'; -import { customElement, property } from 'lit/decorators.js'; - -import surroundThumbnailsStyle from '../scss/surround.scss'; -import { - BrowseMediaQueryParameters, - CameraConfig, - ExtendedHomeAssistant, - FrigateBrowseMediaSource, - FrigateCardError, - MiniTimelineControlConfig, - ThumbnailsControlConfig, -} from '../types.js'; -import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; -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 { ThumbnailCarouselTap } from './thumbnail-carousel.js'; - -interface ThumbnailViewContext { - // Whetherr or not to fetch thumbnails. - fetch?: boolean; -} - -declare module 'view' { - interface ViewContext { - thumbnails?: ThumbnailViewContext; - } -} - -@customElement('frigate-card-surround-thumbnails') -export class FrigateCardSurroundThumbnails extends LitElement { - @property({ attribute: false }) - public hass?: ExtendedHomeAssistant; - - @property({ attribute: false }) - public view?: Readonly; - - @property({ attribute: false, hasChanged: contentsChanged }) - public thumbnailConfig?: ThumbnailsControlConfig; - - @property({ attribute: false, hasChanged: contentsChanged }) - public timelineConfig?: MiniTimelineControlConfig; - - @property({ attribute: false }) - public inBackground?: boolean; - - @property({ attribute: false, hasChanged: contentsChanged }) - public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[]; - - @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). - * @param param Task parameters. - * @returns - */ - protected async _fetchMedia(): Promise { - if ( - this.inBackground || - !this.hass || - !this.view || - !this.thumbnailConfig || - this.thumbnailConfig.mode === 'none' || - this.view.target || - !this.browseMediaParams || - !(this.view.context?.thumbnails?.fetch ?? true) - ) { - return; - } - let parent: FrigateBrowseMediaSource | null; - try { - parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams); - } catch (e) { - return dispatchFrigateCardErrorEvent(this, e as FrigateCardError); - } - if (getFirstTrueMediaChildIndex(parent) !== null) { - this.view - ?.evolve({ - target: parent, - childIndex: null, - - // Don't carry over history of this 'empty' view. - previous: null, - }) - .dispatchChangeEvent(this); - } - } - - /** - * Determine if a drawer is being used. - * @returns `true` if a drawer is used, `false` otherwise. - */ - protected _hasDrawer(): boolean { - return ( - !!this.thumbnailConfig && ['left', 'right'].includes(this.thumbnailConfig.mode) - ); - } - - /** - * Called before each update. - */ - protected willUpdate(changedProperties: PropertyValues): void { - // Once the component will certainly update, dispatch a media request. Only - // do so if properties relevant to the request have changed (as per their - // hasChanged). - if ( - ['view', 'fetch', 'browseMediaParams', 'inBackground'].some((prop) => - changedProperties.has(prop), - ) - ) { - this._fetchMedia(); - } - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.thumbnailConfig) { - return; - } - - const changeDrawer = (ev: CustomEvent, action: 'open' | 'close') => { - // The event catch/re-dispatch below protect encapsulation: Catches the - // request to view thumbnails and re-dispatches a request to open the drawer - // (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.thumbnailConfig && this._hasDrawer()) { - dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, { - drawer: this.thumbnailConfig.mode, - }); - } - }; - - return html` changeDrawer(ev, 'open')} - @frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')} - > - ${this.thumbnailConfig && - this.thumbnailConfig.mode !== 'none' && - !this.inBackground - ? html` changeDrawer(ev, 'close')} - @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). - 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` - ` - : ''} - - `; - } - - /** - * Return compiled CSS styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(surroundThumbnailsStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'frigate-card-surround-thumbnails': FrigateCardSurroundThumbnails; - } -} diff --git a/src/components/surround.ts b/src/components/surround.ts index 414058ab..21ee2038 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -1,48 +1,134 @@ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import { customElement } from 'lit/decorators.js'; - -import { FrigateCardDrawer } from './drawer.js'; - -import './drawer.js'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; import surroundStyle from '../scss/surround.scss'; +import { + BrowseMediaQueryParameters, + CameraConfig, + ExtendedHomeAssistant, + FrigateBrowseMediaSource, + FrigateCardError, + MiniTimelineControlConfig, + ThumbnailsControlConfig, +} from '../types.js'; +import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; +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 { ThumbnailCarouselTap } from './thumbnail-carousel.js'; -interface FrigateCardDrawerOpen { - drawer: 'left' | 'right'; +import './surround-basic.js'; +import './timeline-core.js'; + +interface ThumbnailViewContext { + // Whetherr or not to fetch thumbnails. + fetch?: boolean; +} + +declare module 'view' { + interface ViewContext { + thumbnails?: ThumbnailViewContext; + } } @customElement('frigate-card-surround') export class FrigateCardSurround extends LitElement { - protected _refDrawerLeft: Ref = createRef(); - protected _refDrawerRight: Ref = createRef(); - protected _boundDrawerHandler = this._drawerHandler.bind(this); + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false, hasChanged: contentsChanged }) + public thumbnailConfig?: ThumbnailsControlConfig; + + @property({ attribute: false, hasChanged: contentsChanged }) + public timelineConfig?: MiniTimelineControlConfig; + + @property({ attribute: false }) + public inBackground?: boolean; + + @property({ attribute: false, hasChanged: contentsChanged }) + public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[]; + + @property({ attribute: false }) + public cameras?: Map; + + @property({ attribute: false }) + public timelineDataManager?: TimelineDataManager; /** - * Component connected callback. + * Fetch thumbnail media when a target is not specified in the view (e.g. for + * the live view). + * @param param Task parameters. + * @returns */ - connectedCallback(): void { - super.connectedCallback(); - this.addEventListener('frigate-card:drawer:open', this._boundDrawerHandler); - this.addEventListener('frigate-card:drawer:close', this._boundDrawerHandler); + protected async _fetchMedia(): Promise { + if ( + this.inBackground || + !this.hass || + !this.view || + !this.thumbnailConfig || + this.thumbnailConfig.mode === 'none' || + this.view.target || + !this.browseMediaParams || + !(this.view.context?.thumbnails?.fetch ?? true) + ) { + return; + } + let parent: FrigateBrowseMediaSource | null; + try { + parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams); + } catch (e) { + return dispatchFrigateCardErrorEvent(this, e as FrigateCardError); + } + if (getFirstTrueMediaChildIndex(parent) !== null) { + this.view + ?.evolve({ + target: parent, + childIndex: null, + + // Don't carry over history of this 'empty' view. + previous: null, + }) + .dispatchChangeEvent(this); + } } /** - * Component disconnected callback. + * Determine if a drawer is being used. + * @returns `true` if a drawer is used, `false` otherwise. */ - disconnectedCallback(): void { - super.disconnectedCallback(); - this.removeEventListener('frigate-card:drawer:open', this._boundDrawerHandler); - this.removeEventListener('frigate-card:drawer:close', this._boundDrawerHandler); + protected _hasDrawer(): boolean { + return ( + !!this.thumbnailConfig && ['left', 'right'].includes(this.thumbnailConfig.mode) + ); } - protected _drawerHandler(ev: Event) { - const drawer = (ev as CustomEvent).detail.drawer; - const open = ev.type.endsWith(':open'); - if (drawer === 'left' && this._refDrawerLeft.value) { - this._refDrawerLeft.value.open = open; - } else if (drawer === 'right' && this._refDrawerRight.value) { - this._refDrawerRight.value.open = open; + /** + * Called before each update. + */ + protected willUpdate(changedProperties: PropertyValues): void { + // Once the component will certainly update, dispatch a media request. Only + // do so if properties relevant to the request have changed (as per their + // hasChanged). + if ( + ['view', 'fetch', 'browseMediaParams', 'inBackground'].some((prop) => + changedProperties.has(prop), + ) + ) { + this._fetchMedia(); } } @@ -51,15 +137,79 @@ export class FrigateCardSurround extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - return html` + if (!this.hass || !this.view || !this.thumbnailConfig) { + return; + } + + const changeDrawer = (ev: CustomEvent, action: 'open' | 'close') => { + // The event catch/re-dispatch below protect encapsulation: Catches the + // request to view thumbnails and re-dispatches a request to open the drawer + // (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.thumbnailConfig && this._hasDrawer()) { + dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, { + drawer: this.thumbnailConfig.mode, + }); + } + }; + + return html` changeDrawer(ev, 'open')} + @frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')} + > + ${this.thumbnailConfig && + this.thumbnailConfig.mode !== 'none' && + !this.inBackground + ? html` changeDrawer(ev, 'close')} + @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). + 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` + ` + : ''} - - - - - - - `; + `; } /** @@ -71,7 +221,7 @@ export class FrigateCardSurround extends LitElement { } declare global { - interface HTMLElementTagNameMap { - "frigate-card-surround": FrigateCardSurround - } + interface HTMLElementTagNameMap { + 'frigate-card-surround': FrigateCardSurround; + } } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 7bb95b55..26e39591 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,10 +1,10 @@ // 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: 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 +// TODO: Is it really useful to select the children in the main timeline view on range change? import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; @@ -12,12 +12,12 @@ import timelineStyle from '../scss/timeline.scss'; import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; import { TimelineDataManager } from '../utils/timeline-data-manager'; import { View } from '../view'; -import './surround-thumbnails.js'; +import './surround.js'; import './timeline-core.js'; // This file is kept separate from timeline-core.ts to avoid a circular dependency: // FrigateCardTimeline -> -// FrigateCardSurroundThumbnails -> +// FrigateCardSurround -> // FrigateCardTimelineCore @customElement('frigate-card-timeline') @@ -46,7 +46,7 @@ export class FrigateCardTimeline extends LitElement { return html``; } - return html` - `; + `; } /** diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 701dd89e..8283f680 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -51,7 +51,7 @@ import { import './next-prev-control.js'; import './title-control.js'; import '../patches/ha-hls-player'; -import './surround-thumbnails'; +import './surround.js'; import { EmblaCarouselPlugins } from './carousel.js'; import { renderTask } from '../utils/task.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; @@ -134,7 +134,7 @@ export class FrigateCardViewer extends LitElement { return renderProgressIndicator(); } - return html`
-
`; + `; } /** diff --git a/src/scss/surround-basic.scss b/src/scss/surround-basic.scss new file mode 100644 index 00000000..d6ff998a --- /dev/null +++ b/src/scss/surround-basic.scss @@ -0,0 +1,21 @@ +:host { + width: 100%; + height: 100%; + + // Share the screen space with thumbnails that may be above/below. + display: flex; + flex-direction: column; + + // Set the drawer relative to this host. + position: relative; + + // Hide any content outside the main pane (e.g. side drawers) to ensure the + // user cannot scroll across to the drawers without opening them. + overflow: hidden; +} + +::slotted:not([name]) { + // Expand the main body to fill available content not otherwise used by the + // surround. + flex: 1; +} diff --git a/src/scss/surround-thumbnails.scss b/src/scss/surround-thumbnails.scss deleted file mode 100644 index f0f64c07..00000000 --- a/src/scss/surround-thumbnails.scss +++ /dev/null @@ -1,5 +0,0 @@ -:host { - width: 100%; - height: 100%; - display: block; -} \ No newline at end of file diff --git a/src/scss/surround.scss b/src/scss/surround.scss index d6ff998a..f0f64c07 100644 --- a/src/scss/surround.scss +++ b/src/scss/surround.scss @@ -1,21 +1,5 @@ :host { width: 100%; height: 100%; - - // Share the screen space with thumbnails that may be above/below. - display: flex; - flex-direction: column; - - // Set the drawer relative to this host. - position: relative; - - // Hide any content outside the main pane (e.g. side drawers) to ensure the - // user cannot scroll across to the drawers without opening them. - overflow: hidden; -} - -::slotted:not([name]) { - // Expand the main body to fill available content not otherwise used by the - // surround. - flex: 1; -} + display: block; +} \ No newline at end of file diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss index d755ca05..a6851b2a 100644 --- a/src/scss/timeline-core.scss +++ b/src/scss/timeline-core.scss @@ -60,7 +60,7 @@ div.timeline { // 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 { +:host(:not([groups])) .vis-item.vis-background { min-height: 100%; } From f11822fc872307f421af409b64f62fd70a934180 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 18 Sep 2022 15:18:00 -0700 Subject: [PATCH 04/16] Fix thumbnails for recordings. --- src/components/surround.ts | 11 ++--- src/components/thumbnail.ts | 3 ++ src/components/timeline-core.ts | 79 ++++++++++++++++++++------------- src/localize/languages/en.json | 10 ++--- 4 files changed, 63 insertions(+), 40 deletions(-) diff --git a/src/components/surround.ts b/src/components/surround.ts index 21ee2038..30ff2170 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -175,19 +175,20 @@ export class FrigateCardSurround extends LitElement { ) => { 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). 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, + ...(child.frigate?.cameraID && { + camera: child.frigate?.cameraID, }), }) + .removeContext('timeline') + // 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). .dispatchChangeEvent(ev.composedPath()[0]); } }} diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 6b988055..9f597684 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -303,6 +303,9 @@ export class FrigateCardThumbnail extends LitElement { .removeContext('timeline') .dispatchChangeEvent(this); } else if (recording) { + // Specifically reset the media target/childIndex, as we cannot + // 'select' an hour in the timeline rather we set the window to + // matching values. this.view ?.evolve({ view: 'timeline', diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 114dc69c..ff05bc1d 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -40,6 +40,7 @@ import { FrigateBrowseMediaSource, frigateCardConfigDefaults, FrigateEvent, + FrigateRecording, TimelineCoreConfig, } from '../types'; import { stopEventFromActivatingCardWideActions } from '../utils/action'; @@ -65,7 +66,7 @@ import { } from '../utils/timeline-data-manager'; import { View } from '../view'; import { dispatchMessageEvent } from './message.js'; -import "./thumbnail.js"; +import './thumbnail.js'; interface FrigateCardGroupData { id: string; @@ -557,7 +558,7 @@ export class FrigateCardTimelineCore extends LitElement { childIndex: childIndex, }), }) - .mergeInContext({ ...this._generateTimelineContext(true), ...context }) + .mergeInContext({ ...this._generateTimelineContext(), ...context }) .dispatchChangeEvent(this); } } @@ -567,7 +568,7 @@ export class FrigateCardTimelineCore extends LitElement { * seek times into each media item). * @param children The media children. * @param targetTime The target time. - * @returns + * @returns The ViewContext. */ protected _generateMediaViewerContextForChildren( children: FrigateBrowseMediaSource[], @@ -696,9 +697,9 @@ export class FrigateCardTimelineCore extends LitElement { childIndex = thumbnails.childIndex; if (thumbnails.target?.children?.length) { context = this._generateMediaViewerContextForChildren( - thumbnails.target.children, - properties.time, - ); + thumbnails.target.children, + properties.time, + ); } } } else { @@ -771,7 +772,7 @@ export class FrigateCardTimelineCore extends LitElement { target: thumbnails?.target ?? null, childIndex: thumbnails?.childIndex ?? null, }) - .mergeInContext(this._generateTimelineContext(true)) + .mergeInContext(this._generateTimelineContext()) .dispatchChangeEvent(this); } }); @@ -874,6 +875,15 @@ export class FrigateCardTimelineCore extends LitElement { ]; } + /** + * Given a recording get the start/end window. + * @param recording The FrigateRecording to consider. + * @returns A tuple of start/end date. + */ + protected _getStartEndFromRecording(recording: FrigateRecording): [Date, Date] { + return [fromUnixTime(recording.start_time), fromUnixTime(recording.end_time)]; + } + /** * Get the configured window length in seconds. */ @@ -1004,8 +1014,12 @@ export class FrigateCardTimelineCore extends LitElement { } const event = this.view?.media?.frigate?.event; + const recording = this.view?.media?.frigate?.recording; + const [windowStart, windowEnd] = event ? this._getStartEndFromEvent(event) + : recording + ? this._getStartEndFromRecording(recording) : this._getStartEnd(); let fetched = false; @@ -1042,33 +1056,40 @@ export class FrigateCardTimelineCore extends LitElement { this.timelineDataManager?.rewriteItem(event.id); } + let contextWindow: TimelineWindow | null = null; if (!this._pointerHeld) { // Regenerate the thumbnails after the selection, to allow the new selection // to be in the generated view. const context = this.view.context?.timeline; const timelineWindow = this._timeline.getWindow(); - if (context?.window) { - if (!isEqual(context.window, timelineWindow)) { - this._timeline.setWindow(context.window.start, context.window.end); - } - } else if (event) { - const eventStart = new Date(event.start_time * 1000); - const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0; + // If there's a set context window, always move to it. + if (context?.window && !isEqual(context.window, timelineWindow)) { + contextWindow = {start: context.window.start, end: context.window.end}; + } else if (event || recording) { + const source = event ?? recording as FrigateEvent | FrigateRecording; + const start = fromUnixTime(source.start_time); + const end = source.end_time ? fromUnixTime(source.end_time) : 0; + // If there's an event or recording outside the current window, move to it. if ( - eventStart < timelineWindow.start || - eventStart > timelineWindow.end || - (eventEnd && - (eventEnd < timelineWindow.start || eventEnd > timelineWindow.end)) + start < timelineWindow.start || + start > timelineWindow.end || + (end && + (end < timelineWindow.start || end > timelineWindow.end)) ) { - this._timeline.setWindow(windowStart, windowEnd); + contextWindow = {start: windowStart, end: windowEnd}; } } else { - this._timeline.setWindow(windowStart, windowEnd); + // Otherwise just the default window. + contextWindow = {start: windowStart, end: windowEnd}; } } + if (contextWindow) { + this._timeline.setWindow(contextWindow.start, contextWindow.end); + } + // Only generate thumbnails if an actual fetch occurred, to avoid getting // stuck in a loop (the subsequent fetches will not actually fetch since the // data will have been cached). @@ -1078,15 +1099,17 @@ export class FrigateCardTimelineCore extends LitElement { // -> Thumbnails generated // -> New view dispatched (to load thumbnails into outer carousel). // -> New view received ... [loop] - - if ((fetched || !this.view.context?.timeline?.generatedThumbnails) && !this.mini) { + // + // Also don't generate thumbnails in mini-timelines (they will already have + // been generated), or if the media child is a recording. + if ((fetched || !this.view.context?.timeline?.generatedThumbnails) && !this.mini && !recording) { const thumbnails = this._generateThumbnails(); this.view ?.evolve({ target: thumbnails?.target ?? null, childIndex: thumbnails?.childIndex ?? null, }) - .mergeInContext(this._generateTimelineContext(false)) + .mergeInContext(this._generateTimelineContext(contextWindow)) .dispatchChangeEvent(this); } } @@ -1097,16 +1120,12 @@ export class FrigateCardTimelineCore extends LitElement { * the window is preserved if it is already in the context. * @returns The TimelineViewContext object. */ - protected _generateTimelineContext(addWindow: boolean): ViewContext { - const currentContext = this.view?.context?.timeline; + protected _generateTimelineContext(window?: TimelineWindow | null): ViewContext { const newContext: TimelineViewContext = { generatedThumbnails: true, }; - - if (addWindow && this._timeline) { - newContext.window = this._timeline.getWindow(); - } else if (currentContext?.window) { - newContext.window = currentContext.window; + if (this._timeline) { + newContext.window = window ? window : this._timeline.getWindow(); } if (this.timelineDataManager?.lastFetchDate) { newContext.dateFetch = this.timelineDataManager.lastFetchDate; diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index b30b9813..9e4655bc 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -161,11 +161,11 @@ "thumbnails": { "mode": "Media Viewer thumbnails mode", "modes": { - "above": "Thumbnails above the media", - "below": "Thumbnails below the media", - "left": "Thumbnails in a drawer left of the media", + "above": "Thumbnails above", + "below": "Thumbnails below", + "left": "Thumbnails in a drawer to the left", "none": "No thumbnails", - "right": "Thumbnails in a drawer right of the media" + "right": "Thumbnails in a drawer to the right" }, "show_details": "Show details with thumbnails", "show_favorite_control": "Show favorite control on thumbnails", @@ -372,7 +372,7 @@ "thumbnail": { "no_thumbnail": "No thumbnail available", "retain_indefinitely": "Event will be indefinitely retained", - "timeline": "See event in timeline" + "timeline": "See event/recording in timeline" }, "timeline": { "lock": "Lock timeline to a single event", From c60fff243d867f378e53585c08e71db000276091 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 19 Sep 2022 07:40:55 -0700 Subject: [PATCH 05/16] Improvements to updating timeline from view. --- src/card.ts | 2 +- src/components/timeline-core.ts | 78 ++++++++++++++++-------------- src/components/timeline.ts | 6 +-- src/utils/timeline-data-manager.ts | 15 +++--- 4 files changed, 54 insertions(+), 47 deletions(-) diff --git a/src/card.ts b/src/card.ts index f595f678..1ea14574 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1030,7 +1030,7 @@ 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 + this._cameras, this._config.timeline.media ) } } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index ff05bc1d..ac18e7e3 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -74,16 +74,17 @@ interface FrigateCardGroupData { } interface TimelineRangeChange extends TimelineWindow { - event: Event & { additionalEvent: string }; + event: Event & { additionalEvent?: string }; byUser: boolean; } interface TimelineViewContext { - // The selected timeline window. + // Force a particular timeline window rather than taking the time from an + // event / recording. window?: TimelineWindow; - // The date of the last event fetch. - dateFetch?: Date; + // Whether or not to set the timeline window. + noSetWindow?: boolean; // Whether or not thumbnails were generated. generatedThumbnails?: boolean; @@ -453,7 +454,14 @@ export class FrigateCardTimelineCore extends LitElement { * @param properties */ protected _timelineRangeChangeHandler(properties: TimelineRangeChange): void { - if (this._timeline && properties.byUser) { + if ( + this._timeline && + properties.byUser && + // Do not adjust select children or seek during zoom events. + properties.event.type !== 'wheel' && + properties.event.additionalEvent !== 'pinchin' && + properties.event.additionalEvent !== 'pinchout' + ) { if (this._pointerHeld) { this._ignoreClick = true; } @@ -557,8 +565,11 @@ export class FrigateCardTimelineCore extends LitElement { ...(childIndex !== null && { childIndex: childIndex, }), + }) // Whether or not to set the timeline window. + .mergeInContext({ + ...this._generateTimelineContext({ noSetWindow: true }), + ...context, }) - .mergeInContext({ ...this._generateTimelineContext(), ...context }) .dispatchChangeEvent(this); } } @@ -745,7 +756,7 @@ export class FrigateCardTimelineCore extends LitElement { start: Date; end: Date; byUser: boolean; - event: Event; + event: Event & { additionalEvent: string }; }): void { if (!properties.byUser) { return; @@ -772,7 +783,7 @@ export class FrigateCardTimelineCore extends LitElement { target: thumbnails?.target ?? null, childIndex: thumbnails?.childIndex ?? null, }) - .mergeInContext(this._generateTimelineContext()) + .mergeInContext(this._generateTimelineContext({ noSetWindow: true })) .dispatchChangeEvent(this); } }); @@ -1049,15 +1060,15 @@ export class FrigateCardTimelineCore extends LitElement { }, }); - if (event && this._isClustering()) { + if (!this._pointerHeld && event && this._isClustering()) { // Hack: Clustering may not update unless the dataset changes, artifically // update the dataset to ensure the newly selected item cannot be included - // in a cluster. + // in a cluster. Only do this when the pointer is not held to avoid + // interrupting the user and to make the timeline smoother. this.timelineDataManager?.rewriteItem(event.id); } - let contextWindow: TimelineWindow | null = null; - if (!this._pointerHeld) { + if (!this._pointerHeld && !this.view.context?.timeline?.noSetWindow) { // Regenerate the thumbnails after the selection, to allow the new selection // to be in the generated view. const context = this.view.context?.timeline; @@ -1065,9 +1076,9 @@ export class FrigateCardTimelineCore extends LitElement { // If there's a set context window, always move to it. if (context?.window && !isEqual(context.window, timelineWindow)) { - contextWindow = {start: context.window.start, end: context.window.end}; + this._timeline.setWindow(context.window.start, context.window.end); } else if (event || recording) { - const source = event ?? recording as FrigateEvent | FrigateRecording; + const source = event ?? (recording as FrigateEvent | FrigateRecording); const start = fromUnixTime(source.start_time); const end = source.end_time ? fromUnixTime(source.end_time) : 0; @@ -1075,21 +1086,13 @@ export class FrigateCardTimelineCore extends LitElement { if ( start < timelineWindow.start || start > timelineWindow.end || - (end && - (end < timelineWindow.start || end > timelineWindow.end)) + (end && (end < timelineWindow.start || end > timelineWindow.end)) ) { - contextWindow = {start: windowStart, end: windowEnd}; + this._timeline.setWindow(windowStart, windowEnd); } - } else { - // Otherwise just the default window. - contextWindow = {start: windowStart, end: windowEnd}; } } - if (contextWindow) { - this._timeline.setWindow(contextWindow.start, contextWindow.end); - } - // Only generate thumbnails if an actual fetch occurred, to avoid getting // stuck in a loop (the subsequent fetches will not actually fetch since the // data will have been cached). @@ -1102,33 +1105,37 @@ export class FrigateCardTimelineCore extends LitElement { // // Also don't generate thumbnails in mini-timelines (they will already have // been generated), or if the media child is a recording. - if ((fetched || !this.view.context?.timeline?.generatedThumbnails) && !this.mini && !recording) { + if ( + (fetched || !this.view.context?.timeline?.generatedThumbnails) && + !this.mini && + !recording + ) { const thumbnails = this._generateThumbnails(); this.view ?.evolve({ target: thumbnails?.target ?? null, childIndex: thumbnails?.childIndex ?? null, }) - .mergeInContext(this._generateTimelineContext(contextWindow)) + .mergeInContext(this._generateTimelineContext()) .dispatchChangeEvent(this); } } /** * Generate the context for timeline views. - * @param addWindow Whether or not to include the timeline window. If `false` - * the window is preserved if it is already in the context. + * @param options Configure how the context is set. * @returns The TimelineViewContext object. */ - protected _generateTimelineContext(window?: TimelineWindow | null): ViewContext { + protected _generateTimelineContext(options?: { + noSetWindow?: boolean; + generatedThumbnails?: boolean; + }): ViewContext { const newContext: TimelineViewContext = { - generatedThumbnails: true, + generatedThumbnails: options?.generatedThumbnails ?? true, }; - if (this._timeline) { - newContext.window = window ? window : this._timeline.getWindow(); - } - if (this.timelineDataManager?.lastFetchDate) { - newContext.dateFetch = this.timelineDataManager.lastFetchDate; + + if (options?.noSetWindow) { + newContext.noSetWindow = options.noSetWindow; } return { timeline: newContext }; } @@ -1205,6 +1212,7 @@ export class FrigateCardTimelineCore extends LitElement { this._dataview = this.timelineDataManager.createDataView( this._getTimelineCameraIDs(), + !!this.timelineConfig?.show_recordings, ); if (this.mini && groups.length === 1) { diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 26e39591..b660cabf 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,10 +1,10 @@ // TODO: When a media viewer is first loaded the selected child won't work (because the underlying carousel has not yet rendered) -// TODO: thumbnails in drawers don't work. + +// TODO: unselect in mini timeline should not cause media viewer to render nothing // 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 -// TODO: Is it really useful to select the children in the main timeline view on range change? +// TODO: Make performance okay for 2+ days of events import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; diff --git a/src/utils/timeline-data-manager.ts b/src/utils/timeline-data-manager.ts index 1e2ec918..e00df5f3 100644 --- a/src/utils/timeline-data-manager.ts +++ b/src/utils/timeline-data-manager.ts @@ -107,16 +107,13 @@ export class TimelineDataManager { 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. @@ -128,10 +125,13 @@ export class TimelineDataManager { return this._recordingSummary.get(cameraID) ?? null; } - public createDataView(cameraIDs: Set): DataView { + public createDataView( + cameraIDs: Set, + showRecordings: boolean + ): DataView { return new DataView(this._dataset, { filter: (item: FrigateCardTimelineItem) => - !!item.group && cameraIDs.has(String(item.group)), + !!item.group && cameraIDs.has(String(item.group)) && (showRecordings || item.type !== 'background') }); } @@ -175,7 +175,6 @@ export class TimelineDataManager { ['video', 'image'].includes(child.media_content_type) ) { let item = this._dataset.get(event.id); - //const st = fromUnixTime(); if (!item) { item = { id: event.id, @@ -303,8 +302,8 @@ export class TimelineDataManager { // 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._fetchRecordingSummary(hass), + ...(segmentEnd > segmentStart ? [this._fetchRecordingSegments(hass, segmentStart, segmentEnd)] : []), ]); From b9b539bf72a89b689b004ef2992299fce61dce18 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 20 Sep 2022 20:12:07 -0700 Subject: [PATCH 06/16] Minor type fixing. --- src/types.ts | 46 ++++++++++++------------------ src/utils/timeline-data-manager.ts | 11 ++++--- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/src/types.ts b/src/types.ts index eb2b1e3f..ed213d7c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1368,20 +1368,6 @@ interface BrowseMediaSource { children?: BrowseMediaSource[] | null; } -export interface FrigateEvent { - camera: string; - end_time?: number; - false_positive: boolean; - has_clip: boolean; - has_snapshot: boolean; - id: string; - label: string; - start_time: number; - top_score: number; - zones: string[]; - retain_indefinitely?: boolean; -} - export interface FrigateRecording { // Frigate camera name (may not be unique) camera: string; @@ -1399,6 +1385,24 @@ export interface FrigateBrowseMediaSource extends BrowseMediaSource { }; } +export const frigateEventSchema = z.object({ + camera: z.string(), + end_time: z.number().nullable(), + false_positive: z.boolean().nullable(), + has_clip: z.boolean(), + has_snapshot: z.boolean(), + id: z.string(), + label: z.string(), + start_time: z.number(), + top_score: z.number(), + zones: z.string().array(), + retain_indefinitely: z.boolean().optional(), +}); +export type FrigateEvent = z.infer; + +export const frigateEventsSchema = frigateEventSchema.array(); +export type FrigateEvents = z.infer; + export const frigateBrowseMediaSourceSchema: z.ZodSchema = z.lazy( () => z.object({ @@ -1413,19 +1417,7 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema = z. children: z.array(frigateBrowseMediaSourceSchema).nullable().optional(), frigate: z .object({ - event: z.object({ - camera: z.string(), - end_time: z.number().nullable(), - false_positive: z.boolean().nullable(), - has_clip: z.boolean(), - has_snapshot: z.boolean(), - id: z.string(), - label: z.string(), - start_time: z.number(), - top_score: z.number(), - zones: z.string().array(), - retain_indefinitely: z.boolean().optional(), - }), + event: frigateEventSchema }) .optional(), }), diff --git a/src/utils/timeline-data-manager.ts b/src/utils/timeline-data-manager.ts index e00df5f3..2e6fdcde 100644 --- a/src/utils/timeline-data-manager.ts +++ b/src/utils/timeline-data-manager.ts @@ -108,10 +108,7 @@ export class TimelineDataManager { protected _cameras: Map; protected _mediaType: TimelineMediaType; - constructor( - cameras: Map, - mediaType: TimelineMediaType, - ) { + constructor(cameras: Map, mediaType: TimelineMediaType) { this._cameras = cameras; this._mediaType = mediaType; } @@ -127,11 +124,13 @@ export class TimelineDataManager { public createDataView( cameraIDs: Set, - showRecordings: boolean + showRecordings: boolean, ): DataView { return new DataView(this._dataset, { filter: (item: FrigateCardTimelineItem) => - !!item.group && cameraIDs.has(String(item.group)) && (showRecordings || item.type !== 'background') + !!item.group && + cameraIDs.has(String(item.group)) && + (showRecordings || item.type !== 'background'), }); } From c910b5d2a4cb47e8c57cc83f539f64557fbf02ee Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 21 Sep 2022 20:57:53 -0700 Subject: [PATCH 07/16] Fix substantial performance issue with thumbnails. --- src/components/thumbnail.ts | 68 ++++++++++++++++++++++----- src/scss/thumbnail-feature-event.scss | 1 + src/utils/thumbnail.ts | 24 ++++++---- 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 9f597684..af124630 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -16,6 +16,7 @@ import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js'; import { View } from '../view.js'; import { MediaSeek } from './viewer.js'; +import { TaskStatus } from '@lit-labs/task'; import type { ExtendedHomeAssistant, @@ -38,22 +39,63 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement { this, () => this.hass, () => this.thumbnail, + false, ); + // Only load thumbnails on view in case there is a very large number of them. + protected _intersectionObserver: IntersectionObserver; + + constructor() { + super(); + this._intersectionObserver = new IntersectionObserver( + this._intersectionHandler.bind(this), + ); + } + + /** + * Component connected callback. + */ + connectedCallback(): void { + this._intersectionObserver.observe(this); + super.connectedCallback(); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + super.disconnectedCallback(); + this._intersectionObserver.disconnect(); + } + + /** + * Called when the live view intersects with the viewport. + * @param entries The IntersectionObserverEntry entries (should be only 1). + */ + protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { + if ( + this._embedThumbnailTask.status === TaskStatus.INITIAL && + entries.some((entry) => entry.isIntersecting) + ) { + this._embedThumbnailTask.run(); + } + } + protected render(): TemplateResult | void { - return html` - ${this.thumbnail - ? renderTask( - this, - this._embedThumbnailTask, - (embeddedThumbnail: string | null) => - embeddedThumbnail ? html`` : html``, - ) - : html` `} - `; + const imageOff = html` `; + + return html`${this.thumbnail + ? renderTask( + this, + this._embedThumbnailTask, + (embeddedThumbnail: string | null) => + embeddedThumbnail ? html`` : html``, + () => imageOff, + ) + : imageOff} `; } static get styles(): CSSResult { diff --git a/src/scss/thumbnail-feature-event.scss b/src/scss/thumbnail-feature-event.scss index 04df7733..84590fd3 100644 --- a/src/scss/thumbnail-feature-event.scss +++ b/src/scss/thumbnail-feature-event.scss @@ -30,4 +30,5 @@ ha-icon { align-items: center; border: 1px solid rgba(255, 255, 255, 0.3); box-sizing: border-box; + opacity: 0.2; } diff --git a/src/utils/thumbnail.ts b/src/utils/thumbnail.ts index 2a002adb..6c2f0b53 100644 --- a/src/utils/thumbnail.ts +++ b/src/utils/thumbnail.ts @@ -55,19 +55,23 @@ export const createFetchThumbnailTask = ( host: ReactiveControllerHost, getHASS: () => HomeAssistant | undefined, getThumbnailURL: () => string | undefined, + autoRun = true, ): Task => { return new Task( host, - async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise< - string | null - > => { - const hass = getHASS(); - if (!haveHASS || !hass || !thumbnailURL) { - return null; - } - return fetchThumbnail(hass, thumbnailURL); + { + // Do not re-run the task if hass changes, unless it was previously undefined. + args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()], + task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise< + string | null + > => { + const hass = getHASS(); + if (!haveHASS || !hass || !thumbnailURL) { + return null; + } + return fetchThumbnail(hass, thumbnailURL); + }, + autoRun: autoRun, }, - // Do not re-run the task if hass changes, unless it was previously undefined. - (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()], ); }; From 0491018aab54f6143ce8c916a7434c1e06c18992 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 23 Sep 2022 18:21:17 -0700 Subject: [PATCH 08/16] Minor fixes. --- src/components/timeline.ts | 2 -- src/types.ts | 56 ++++++++++++++++-------------- src/utils/timeline-data-manager.ts | 13 +++++++ 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/components/timeline.ts b/src/components/timeline.ts index b660cabf..55281053 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,10 +1,8 @@ // TODO: When a media viewer is first loaded the selected child won't work (because the underlying carousel has not yet rendered) -// TODO: unselect in mini timeline should not cause media viewer to render nothing // 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: Make minitimeline configurable in the editor -// TODO: Make performance okay for 2+ days of events import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; diff --git a/src/types.ts b/src/types.ts index ed213d7c..d8625026 100644 --- a/src/types.ts +++ b/src/types.ts @@ -569,10 +569,12 @@ export type PictureElements = z.infer; */ const mediaLayoutConfigSchema = z.object({ fit: z.enum(['contain', 'cover', 'fill']).optional(), - position: z.object({ - x: z.number().min(0).max(100).optional(), - y: z.number().min(0).max(100).optional(), - }).optional(), + position: z + .object({ + x: z.number().min(0).max(100).optional(), + y: z.number().min(0).max(100).optional(), + }) + .optional(), }); export type MediaLayoutConfig = z.infer; @@ -667,27 +669,26 @@ const timelineCoreConfigDefault = { 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), - }); +const timelineMediaSchema = z.enum(['all', 'clips', 'snapshots']); +export type TimelineMedia = z.infer; + +const timelineCoreConfigSchema = z.object({ + clustering_threshold: z + .number() + .optional() + .default(timelineCoreConfigDefault.clustering_threshold), + media: timelineMediaSchema.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({ @@ -1136,7 +1137,8 @@ const timelineConfigDefault = { }, }; -const timelineConfigSchema = timelineCoreConfigSchema.extend({ +const timelineConfigSchema = timelineCoreConfigSchema + .extend({ controls: z .object({ thumbnails: thumbnailsControlSchema @@ -1417,7 +1419,7 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema = z. children: z.array(frigateBrowseMediaSourceSchema).nullable().optional(), frigate: z .object({ - event: frigateEventSchema + event: frigateEventSchema, }) .optional(), }), diff --git a/src/utils/timeline-data-manager.ts b/src/utils/timeline-data-manager.ts index 2e6fdcde..af6deb44 100644 --- a/src/utils/timeline-data-manager.ts +++ b/src/utils/timeline-data-manager.ts @@ -122,6 +122,12 @@ export class TimelineDataManager { return this._recordingSummary.get(cameraID) ?? null; } + /** + * Create a dataview for a given set of camera. + * @param cameraIDs The cameraIDs to include. + * @param showRecordings Whether or not to show recordings. + * @returns + */ public createDataView( cameraIDs: Set, showRecordings: boolean, @@ -134,10 +140,17 @@ export class TimelineDataManager { }); } + /** + * Create a dataview for segments. + * @returns + */ public createSegmentDataView(): DataView { return new DataView(this._recordingSegments); } + /** + * Get the underlying recording segments dataset. + */ get recordingSegments(): DataSet { return this._recordingSegments; } From a2e80d93a39fac33efd42044bb52ad9bd07860bd Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 24 Sep 2022 11:31:03 -0700 Subject: [PATCH 09/16] Fetch timeline events from events endpoint not media browser. --- package.json | 1 + src/components/live.ts | 1 + src/components/surround.ts | 8 +- src/components/thumbnail.ts | 2 +- src/components/timeline-core.ts | 78 ++++++++++---- src/components/timeline.ts | 2 +- src/components/viewer.ts | 1 + src/types.ts | 2 + src/utils/basic.ts | 10 ++ src/utils/frigate.ts | 165 ++++++++++++++++++++++++++++- src/utils/ha/browse-media.ts | 65 ++---------- src/utils/timeline-data-manager.ts | 125 ++++++++-------------- yarn.lock | 5 + 13 files changed, 300 insertions(+), 165 deletions(-) diff --git a/package.json b/package.json index 0cbdd131..535bfa79 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "crypto": "^1.0.1", "custom-card-helpers": "^1.9.0", "date-fns": "^2.29.2", + "date-fns-tz": "^1.3.7", "embla-carousel": "^7.0.2", "embla-carousel-wheel-gestures": "^3.0.0", "home-assistant-js-websocket": "^8.0.0", diff --git a/src/components/live.ts b/src/components/live.ts index 21e22273..5efec367 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -216,6 +216,7 @@ export class FrigateCardLive extends LitElement { html` { if ( + !this.fetch || this.inBackground || !this.hass || !this.view || + this.view.target || !this.thumbnailConfig || this.thumbnailConfig.mode === 'none' || - this.view.target || !this.browseMediaParams || !(this.view.context?.thumbnails?.fetch ?? true) ) { diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index af124630..8468515d 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -11,7 +11,7 @@ import thumbnailStyle from '../scss/thumbnail.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { errorToConsole, prettifyTitle } from '../utils/basic.js'; import { retainEvent } from '../utils/frigate.js'; -import { getEventDurationString } from '../utils/ha/browse-media.js'; +import { getEventDurationString } from '../utils/frigate.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js'; import { View } from '../view.js'; diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index ac18e7e3..a02a1771 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -47,16 +47,19 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action'; import { contentsChanged, dispatchFrigateCardEvent, + formatDateAndTime, isHoverableDevice, prettifyTitle, } from '../utils/basic'; import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js'; - import { - createEventParentForChildren, - createVideoChild, - generateRecordingIdentifier, -} from '../utils/ha/browse-media'; + getEventMediaContentID, + getEventThumbnailURL, + getEventTitle, + getRecordingMediaContentID, +} from '../utils/frigate'; + +import { createEventParentForChildren, createChild } from '../utils/ha/browse-media'; import { FrigateCardTimelineItem, RecordingSegmentsItem, @@ -216,16 +219,17 @@ export class FrigateCardTimelineCore extends LitElement { * @returns The tooltip as a string to render. */ protected _getTooltip(item: TimelineItem): string { - const source = (item).source; - if (!this._isHoverableDevice || !source) { + const event = (item).event; + const clientId = item.group + ? this.cameras?.get(String(item.group))?.frigate.client_id + : null; + if (!this._isHoverableDevice || !event || !clientId) { // Don't display tooltips on touch devices, they just get in the way of // the drawer. return ''; } - const eventAttr = source.frigate?.event - ? `event='${JSON.stringify(source.frigate.event)}'` - : ''; + const eventAttr = `event='${JSON.stringify(event)}'`; const detailsAttr = this.thumbnailDetails ? 'details' : ''; // Cannot use Lit data-bindings as visjs requires a string for tooltips. @@ -233,10 +237,10 @@ export class FrigateCardTimelineCore extends LitElement { // whitelist in `_getOptions()` . return ` `; } @@ -324,12 +328,9 @@ export class FrigateCardTimelineCore extends LitElement { // hours, otherwise only show the matching hour from all cameras. if (!onlyShowMatchingHour || isMatchingHour) { children.push( - createVideoChild( - `${prettifyTitle(config.frigate.camera_name)} ${format( - hour, - 'yyyy-MM-dd HH:mm', - )}`, - generateRecordingIdentifier({ + createChild( + `${prettifyTitle(config.frigate.camera_name)} ${formatDateAndTime(hour)}`, + getRecordingMediaContentID({ clientId: config.frigate.client_id, year: dayData.day.getFullYear(), month: dayData.day.getMonth() + 1, @@ -812,9 +813,40 @@ export class FrigateCardTimelineCore extends LitElement { let childIndex = -1; const children: FrigateBrowseMediaSource[] = []; this._dataview?.get({ order: sortTimelineItemsYoungestToOldest }).forEach((item) => { - if (item.event && item.source) { - children.push(item.source); - if (selected.includes(item.event.id)) { + const cameraID = item.group ? String(item.group) : null; + const cameraConfig = cameraID ? this.cameras?.get(cameraID) : null; + const event = item.event; + const media = + event?.has_clip && this.timelineConfig?.media !== 'snapshots' + ? 'clips' + : event?.has_snapshot + ? 'snapshots' + : null; + + if ( + cameraID && + cameraConfig && + event && + media && + cameraConfig.frigate.camera_name + ) { + children.push( + createChild( + getEventTitle(event), + getEventMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + event.id, + media, + ), + { + thumbnail: getEventThumbnailURL(cameraConfig.frigate.client_id, event), + event: event, + cameraID: cameraID, + }, + ), + ); + if (selected.includes(event.id)) { childIndex = children.length - 1; } } @@ -1190,6 +1222,7 @@ export class FrigateCardTimelineCore extends LitElement { this.timelineDataManager && this._refTimeline.value && options && + this.timelineConfig && (changedProperties.has('timelineConfig') || (this.mini && changedProperties.has('view') && @@ -1212,7 +1245,8 @@ export class FrigateCardTimelineCore extends LitElement { this._dataview = this.timelineDataManager.createDataView( this._getTimelineCameraIDs(), - !!this.timelineConfig?.show_recordings, + !!this.timelineConfig.show_recordings, + this.timelineConfig.media, ); if (this.mini && groups.length === 1) { diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 55281053..756ad4d5 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,7 +1,6 @@ // TODO: When a media viewer is first loaded the selected child won't work (because the underlying carousel has not yet rendered) // 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: Make minitimeline configurable in the editor import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; @@ -49,6 +48,7 @@ export class FrigateCardTimeline extends LitElement { .view=${this.view} .thumbnailConfig=${this.timelineConfig.controls.thumbnails} .cameras=${this.cameras} + .fetch=${false} > window.matchMedia( '(hover: hover) and (pointer: fine)', ).matches; + +/** + * Format a date object to RFC3339. + * @param date A Date object. + * @returns A date and time. + */ +export const formatDateAndTime = (date: Date): string => { + return format(date, 'yyyy-MM-dd HH:mm'); +} \ No newline at end of file diff --git a/src/utils/frigate.ts b/src/utils/frigate.ts index 6c61da10..c5ef776a 100644 --- a/src/utils/frigate.ts +++ b/src/utils/frigate.ts @@ -1,7 +1,21 @@ import { HomeAssistant } from 'custom-card-helpers'; +import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; +import differenceInHours from 'date-fns/differenceInHours'; +import differenceInMinutes from 'date-fns/differenceInMinutes'; +import differenceInSeconds from 'date-fns/differenceInSeconds'; +import fromUnixTime from 'date-fns/fromUnixTime'; import { z } from 'zod'; import { localize } from '../localize/localize'; -import { ExtendedHomeAssistant, FrigateCardError } from '../types'; +import { + BrowseRecordingQueryParameters, + ClipsOrSnapshots, + ExtendedHomeAssistant, + FrigateCardError, + FrigateEvent, + FrigateEvents, + frigateEventsSchema, +} from '../types'; +import { formatDateAndTime, prettifyTitle } from './basic'; import { homeAssistantWSRequest } from './ha'; export const FRIGATE_ICON_SVG_PATH = @@ -144,3 +158,152 @@ export async function retainEvent( }); } } + +export interface FrigateGetEventsParameters { + instance_id?: string; + camera?: string; + label?: string; + zone?: string; + after?: number; + before?: number; + limit?: number; + has_clip?: boolean; + has_snapshot?: boolean; +} + +/** + * Get events over websocket. May throw. + * @param hass The Home Assistant object. + * @param params The events search parameters. + * @returns An array of 'FrigateEvent's. + */ +export const getEvents = async ( + hass: HomeAssistant, + params?: FrigateGetEventsParameters, +): Promise => { + return await homeAssistantWSRequest( + hass, + frigateEventsSchema, + { + type: 'frigate/events/get', + ...params, + }, + true, + ); +}; + +/** + * Get multiple sets of events. + * @param hass The Home Assistant object. + * @param params A Map of parameters keyed on any key. + * @returns A Map of key -> events. + */ +export const getEventsMultiple = async ( + hass: HomeAssistant, + params: Map, +): Promise> => { + const output: Map = new Map(); + const getEventsAndStore = async ( + key: T, + param: FrigateGetEventsParameters, + ): Promise => { + output.set(key, await getEvents(hass, param)); + }; + await Promise.all( + Array.from(params).map(([key, param]) => getEventsAndStore(key, param)), + ); + return output; +}; + +/** + * Given an event generate a title. + * @param event + */ +export const getEventTitle = (event: FrigateEvent): string => { + const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const durationSeconds = Math.round( + event.end_time + ? event.end_time - event.start_time + : Date.now() / 1000 - event.start_time, + ); + return `${formatDateAndTime( + utcToZonedTime(event.start_time * 1000, localTimezone), + )} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round( + event.top_score * 100, + )}%]`; +}; + +/** + * Get a thumbnail URL for an event. + * @param clientId The Frigate client id. + * @param event The event. + * @returns A string URL. + */ +export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => { + return `/api/frigate/${clientId}/thumbnail/${event.id}`; +}; + +/** + * Get a media content ID for an event. + * @param clientId The Frigate client id. + * @param cameraName The Frigate camera name. + * @param id The event id. + * @param mediaType The media type required. + * @returns A string media content id. + */ +export const getEventMediaContentID = ( + clientId: string, + cameraName: string, + id: string, + mediaType: ClipsOrSnapshots, +): string => { + return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${id}`; +}; + +/** + * Generate a recording identifier. + * @param hass The HomeAssistant object. + * @param params The recording parameters to use in the identifer. + * @returns A recording identifier. + */ +export const getRecordingMediaContentID = ( + params: BrowseRecordingQueryParameters, +): string => { + return [ + 'media-source://frigate', + params.clientId, + 'recordings', + `${params.year}-${String(params.month).padStart(2, '0')}`, + String(params.day).padStart(2, '0'), + String(params.hour).padStart(2, '0'), + params.cameraName, + ].join('/'); +}; + +/** + * Convenience function to convert a timestamp to hours, minutes and seconds + * string. Heavily inspired by, and returning the same format as, the Frigate + * UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97 + * @param event The Frigate event. + * @returns A duration string. + */ +export function getEventDurationString(event: FrigateEvent): string { + if (!event.end_time) { + return localize('event.in_progress'); + } + const start = fromUnixTime(event.start_time); + const end = fromUnixTime(event.end_time); + const hours = differenceInHours(end, start); + const minutes = differenceInMinutes(end, start) - hours * 60; + const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60; + let duration = ''; + + if (hours) { + duration += `${hours}h `; + } + if (minutes) { + duration += `${minutes}m `; + } + duration += `${seconds}s`; + return duration; +} diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index e1ab9ef7..46086c2c 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -1,10 +1,4 @@ import { HomeAssistant } from 'custom-card-helpers'; -import { - differenceInHours, - differenceInMinutes, - differenceInSeconds, - fromUnixTime, -} from 'date-fns'; import { homeAssistantWSRequest } from '.'; import { dispatchErrorMessageEvent, @@ -14,7 +8,6 @@ import { import { localize } from '../../localize/localize.js'; import { BrowseMediaQueryParameters, - BrowseRecordingQueryParameters, CameraConfig, FrigateBrowseMediaSource, frigateBrowseMediaSourceSchema, @@ -413,16 +406,17 @@ export const createEventParentForChildren = ( /** * Given a media video child with a given media_content_id. * @param title The title to use for the child. - * @param media_con + * @param mediaContentID The media content id to use for the child. * @param children The children media items. * @returns A single parent containing the children. */ -export const createVideoChild = ( +export const createChild = ( title: string, mediaContentID: string, options?: { thumbnail?: string; recording?: FrigateRecording; + event?: FrigateEvent; cameraID?: string, }, ): FrigateBrowseMediaSource => { @@ -436,8 +430,11 @@ export const createVideoChild = ( thumbnail: options?.thumbnail ?? null, children: null } - if (options?.recording || options?.cameraID) { + if (options?.recording || options?.cameraID || options?.event) { result.frigate = {} + if (options?.event) { + result.frigate.event = options.event; + } if (options?.recording) { result.frigate.recording = options.recording; } @@ -447,51 +444,3 @@ export const createVideoChild = ( } return result; }; - -/** - * Convenience function to convert a timestamp to hours, minutes and seconds - * string. Heavily inspired by, and returning the same format as, the Frigate - * UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97 - * @param event The Frigate event. - * @returns A duration string. - */ -export function getEventDurationString(event: FrigateEvent): string { - if (!event.end_time) { - return localize('event.in_progress'); - } - const start = fromUnixTime(event.start_time); - const end = fromUnixTime(event.end_time); - const hours = differenceInHours(end, start); - const minutes = differenceInMinutes(end, start) - hours * 60; - const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60; - let duration = ''; - - if (hours) { - duration += `${hours}h `; - } - if (minutes) { - duration += `${minutes}m `; - } - duration += `${seconds}s`; - return duration; -} - -/** - * Generate a recording identifier. - * @param hass The HomeAssistant object. - * @param params The recording parameters to use in the identifer. - * @returns A recording identifier. - */ -export const generateRecordingIdentifier = ( - params: BrowseRecordingQueryParameters, -): string => { - return [ - 'media-source://frigate', - params.clientId, - 'recordings', - `${params.year}-${String(params.month).padStart(2, '0')}`, - String(params.day).padStart(2, '0'), - String(params.hour).padStart(2, '0'), - params.cameraName, - ].join('/'); -}; diff --git a/src/utils/timeline-data-manager.ts b/src/utils/timeline-data-manager.ts index af6deb44..489e490d 100644 --- a/src/utils/timeline-data-manager.ts +++ b/src/utils/timeline-data-manager.ts @@ -3,29 +3,26 @@ 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, + FrigateEvents, } from '../types.js'; import { errorToConsole } from '../utils/basic.js'; import { + FrigateGetEventsParameters, + getEventsMultiple, 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; +const TIMELINE_DATA_MANAGER_MAX_FETCH_COUNT = 10000; export interface FrigateCardTimelineItem extends TimelineItem { // DataView has issues using datasets with Date objects, so avoid them and use @@ -33,7 +30,6 @@ export interface FrigateCardTimelineItem extends TimelineItem { start: number; end?: number; event?: FrigateEvent; - source?: FrigateBrowseMediaSource; } type TimelineMediaType = 'all' | 'clips' | 'snapshots'; @@ -126,23 +122,27 @@ export class TimelineDataManager { * Create a dataview for a given set of camera. * @param cameraIDs The cameraIDs to include. * @param showRecordings Whether or not to show recordings. - * @returns + * @returns A dataview. */ public createDataView( cameraIDs: Set, showRecordings: boolean, + mediaType: TimelineMediaType, ): DataView { return new DataView(this._dataset, { filter: (item: FrigateCardTimelineItem) => !!item.group && cameraIDs.has(String(item.group)) && - (showRecordings || item.type !== 'background'), + (showRecordings || item.type !== 'background') && + (mediaType === 'all' || + (mediaType === 'clips' && !!item.event?.has_clip) || + (mediaType === 'snapshots' && !!item.event?.has_snapshot)), }); } /** * Create a dataview for segments. - * @returns + * @returns A dataview. */ public createSegmentDataView(): DataView { return new DataView(this._recordingSegments); @@ -171,51 +171,22 @@ export class TimelineDataManager { } /** - * Add a FrigateBrowseMediaSource object to the managed timeline. - * @param cameraID The id the camera this object is from. - * @param target The FrigateBrowseMediaSource to add. + * Add events for the given camera. + * @param cameraID The camera ID. + * @param events The array of events. */ - 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); - 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); + protected _addEvents(cameraID: string, events: FrigateEvents): void { + this._dataset.update( + events.map((event) => ({ + id: event.id, + group: cameraID, + content: '', + event: event, + start: event.start_time * 1000, + type: event.end_time ? 'range' : 'point', + ...(event.end_time && { end: event.end_time * 1000 }), + })), + ); } /** @@ -485,37 +456,31 @@ export class TimelineDataManager { start: Date, end: Date, ): Promise { - const params: BrowseMediaQueryParameters[] = []; + const params: Map = new Map(); + 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 ( + cameraConfig.frigate.camera_name && + cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE + ) { + params.set(cameraID, { + instance_id: cameraConfig.frigate.client_id, + camera: cameraConfig.frigate.camera_name, + ...(cameraConfig.frigate.label && { label: cameraConfig.frigate.label }), + ...(cameraConfig.frigate.zone && { label: cameraConfig.frigate.zone }), + before: Math.floor(end.getTime() / 1000), + after: Math.floor(start.getTime() / 1000), + limit: TIMELINE_DATA_MANAGER_MAX_FETCH_COUNT, + }); + } }); - if (!params.length) { - return; - } - - let results: Map; + let results: Map; try { - results = await multipleBrowseMediaQuery(hass, params); + results = await getEventsMultiple(hass, params); } catch (e) { return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); } - for (const result of results.values()) { - this._addMediaSource(result); - } + results.forEach((params, cameraID) => this._addEvents(cameraID, params)); } } diff --git a/yarn.lock b/yarn.lock index 3816386d..7f92bffb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1060,6 +1060,11 @@ custom-card-helpers@^1.9.0: superstruct "^0.15.3" typescript "^4.5.4" +date-fns-tz@^1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/date-fns-tz/-/date-fns-tz-1.3.7.tgz#e8e9d2aaceba5f1cc0e677631563081fdcb0e69a" + integrity sha512-1t1b8zyJo+UI8aR+g3iqr5fkUHWpd58VBx8J/ZSQ+w7YrGlw80Ag4sA86qkfCXRBLmMc4I2US+aPMd4uKvwj5g== + date-fns@^2.29.2: version "2.29.2" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.2.tgz#0d4b3d0f3dff0f920820a070920f0d9662c51931" From 5ec3cbac54aa6637b6f8e5b584010f043594cfe7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 24 Sep 2022 13:39:17 -0700 Subject: [PATCH 10/16] Garbage collect segments. --- src/components/timeline-core.ts | 7 ++-- src/components/timeline.ts | 2 - src/utils/basic.ts | 16 ++++++++ src/utils/frigate.ts | 2 +- src/utils/timeline-data-manager.ts | 60 +++++++++++++++++++++++++++++- 5 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index a02a1771..c4e51c5d 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -455,6 +455,10 @@ export class FrigateCardTimelineCore extends LitElement { * @param properties */ protected _timelineRangeChangeHandler(properties: TimelineRangeChange): void { + if (this._pointerHeld) { + this._ignoreClick = true; + } + if ( this._timeline && properties.byUser && @@ -463,9 +467,6 @@ export class FrigateCardTimelineCore extends LitElement { properties.event.additionalEvent !== 'pinchin' && properties.event.additionalEvent !== 'pinchout' ) { - if (this._pointerHeld) { - this._ignoreClick = true; - } const targetTime = this._pointerHeld?.window ? add(properties.start, { diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 756ad4d5..e2333bca 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,6 +1,4 @@ // TODO: When a media viewer is first loaded the selected child won't work (because the underlying carousel has not yet rendered) - -// TODO: delete segments if not in summary? is this actually necessary? could it create gaps in data? better off stopping access via summary? // TODO: Make minitimeline configurable in the editor import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 694a5e41..d44142fd 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -94,4 +94,20 @@ export const isHoverableDevice = (): boolean => window.matchMedia( */ export const formatDateAndTime = (date: Date): string => { return format(date, 'yyyy-MM-dd HH:mm'); +} + +/** + * Run a function in idle periods. If idle callbacks are not supported (e.g. + * Safari) the callback is run immediately. + * @param func The function to call. + * @param timeout The maximum number of seconds to wait. + */ +export const runWhenIdleIfSupported = (func: () => void, timeout?: number): void => { + if (window.requestIdleCallback) { + window.requestIdleCallback(func, { + ...(timeout && { timeout: timeout}) + }); + } else { + func(); + } } \ No newline at end of file diff --git a/src/utils/frigate.ts b/src/utils/frigate.ts index c5ef776a..ade6cf3a 100644 --- a/src/utils/frigate.ts +++ b/src/utils/frigate.ts @@ -49,7 +49,7 @@ const recordingSummarySchema = z .object({ day: z.preprocess((arg) => { // Must provide the hour:minute:second on parsing or Javascript will - // assume UTC midnight. + // assume *UTC* midnight. return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg; }, z.date()), events: z.number(), diff --git a/src/utils/timeline-data-manager.ts b/src/utils/timeline-data-manager.ts index 489e490d..680c351b 100644 --- a/src/utils/timeline-data-manager.ts +++ b/src/utils/timeline-data-manager.ts @@ -9,7 +9,7 @@ import { FrigateEvent, FrigateEvents, } from '../types.js'; -import { errorToConsole } from '../utils/basic.js'; +import { errorToConsole, runWhenIdleIfSupported } from '../utils/basic.js'; import { FrigateGetEventsParameters, getEventsMultiple, @@ -19,6 +19,8 @@ import { RecordingSummary, } from './frigate.js'; import { dispatchFrigateCardErrorEvent } from '../components/message.js'; +import fromUnixTime from 'date-fns/fromUnixTime'; +import { throttle } from 'lodash-es'; const RECORDING_SEGMENT_TOLERANCE = 60; const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10; @@ -104,6 +106,15 @@ export class TimelineDataManager { protected _cameras: Map; protected _mediaType: TimelineMediaType; + // Garbage collect segments at most once an hour. + protected _throttledSegmentGarbageCollector = throttle( + () => { + runWhenIdleIfSupported(this._garbageCollectSegments.bind(this)); + }, + 60 * 60 * 1000, + { trailing: true }, + ); + constructor(cameras: Map, mediaType: TimelineMediaType) { this._cameras = cameras; this._mediaType = mediaType; @@ -131,10 +142,14 @@ export class TimelineDataManager { ): DataView { return new DataView(this._dataset, { filter: (item: FrigateCardTimelineItem) => + // Only return items for the given cameras. !!item.group && cameraIDs.has(String(item.group)) && + // Don't return recordings if the user does not want them. (showRecordings || item.type !== 'background') && - (mediaType === 'all' || + // Don't return events that are the wrong media type. + (item.type === 'background' || + mediaType === 'all' || (mediaType === 'clips' && !!item.event?.has_clip) || (mediaType === 'snapshots' && !!item.event?.has_snapshot)), }); @@ -290,9 +305,50 @@ export class TimelineDataManager { ? [this._fetchRecordingSegments(hass, segmentStart, segmentEnd)] : []), ]); + + this._throttledSegmentGarbageCollector(); return true; } + /** + * Garbage collect recording segments that no longer feature in the summary. + */ + protected _garbageCollectSegments(): void { + if (!this._recordingSegments || !this._recordingSummary) { + return; + } + + // Performance: _recordingSegments is potentially very large (e.g. 10K - 1M + // items) and each item must be examined, so care required here to stick to + // nothing worse than O(n) performance. + const getHourID = (cameraID: string, day: number, hour: number): string => { + return `${cameraID}/${day}/${hour}`; + }; + + const goodHours: Set = new Set(); + for (const cameraID of this._recordingSummary.keys()) { + for (const summaryDay of this._recordingSummary?.get(cameraID) ?? []) { + for (const summaryHour of summaryDay.hours) { + goodHours.add(getHourID(cameraID, summaryDay.day.getDate(), summaryHour.hour)); + } + } + } + + const deleteIDs: string[] = []; + this._recordingSegments.forEach((item, id) => { + const startDate = fromUnixTime(item.start / 1000); + const hourID = getHourID(item.cameraID, startDate.getDate(), startDate.getHours()); + + // ~O(1) lookup time for a JS set. + if (!goodHours.has(hourID)) { + deleteIDs.push(String(id)); + } + }); + + this._recordingSegments.remove(deleteIDs); + this._compressRecordingSegmentsOntoTimeline(); + } + /** * Fetch recording segments for cameras. * @param hass The HomeAssistant object. From 69ca62228420fda9abed82775988cfd04e3ced12 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 25 Sep 2022 09:12:36 -0700 Subject: [PATCH 11/16] Make mini timelines editable. --- src/components/timeline.ts | 2 +- src/const.ts | 21 +++++++ src/editor.ts | 111 ++++++++++++++++++++++++++++++--- src/localize/languages/en.json | 9 +++ src/types.ts | 1 + 5 files changed, 136 insertions(+), 8 deletions(-) diff --git a/src/components/timeline.ts b/src/components/timeline.ts index e2333bca..9ec85c35 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,5 +1,5 @@ // TODO: When a media viewer is first loaded the selected child won't work (because the underlying carousel has not yet rendered) -// TODO: Make minitimeline configurable in the editor +// TODO: time on recording != time on timeline in media viewer after scrolling around import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; diff --git a/src/const.ts b/src/const.ts index ca56f40e..ac6b6b6d 100644 --- a/src/const.ts +++ b/src/const.ts @@ -84,6 +84,17 @@ export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const; export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE = `${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD = + `${CONF_MEDIA_VIEWER}.controls.timeline.clustering_threshold` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA = + `${CONF_MEDIA_VIEWER}.controls.timeline.media` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE = + `${CONF_MEDIA_VIEWER}.controls.timeline.mode` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS = + `${CONF_MEDIA_VIEWER}.controls.timeline.show_recordings` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS = + `${CONF_MEDIA_VIEWER}.controls.timeline.window_seconds` as const; + export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE = `${CONF_MEDIA_VIEWER}.controls.title.mode` as const; export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS = @@ -115,6 +126,16 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = `${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = `${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD = + `${CONF_LIVE}.control s.timeline.clustering_threshold` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_MEDIA = + `${CONF_LIVE}.controls.timeline.media` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_MODE = + `${CONF_LIVE}.controls.timeline.mode` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS = + `${CONF_LIVE}.controls.timeline.show_recordings` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS = + `${CONF_LIVE}.controls.timeline.window_seconds` as const; export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const; export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS = `${CONF_LIVE}.controls.title.duration_seconds` as const; diff --git a/src/editor.ts b/src/editor.ts index b5f92116..a5367816 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -54,6 +54,11 @@ import { CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, + CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, + CONF_LIVE_CONTROLS_TIMELINE_MEDIA, + CONF_LIVE_CONTROLS_TIMELINE_MODE, + CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, + CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS, CONF_LIVE_CONTROLS_TITLE_MODE, CONF_LIVE_DRAGGABLE, @@ -76,6 +81,11 @@ import { CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE, CONF_MEDIA_VIEWER_DRAGGABLE, @@ -136,8 +146,10 @@ const MENU_CAMERAS_WEBRTC = 'cameras.webrtc'; const MENU_EVENT_GALLERY_CONTROLS = 'event_gallery.controls'; const MENU_IMAGE_LAYOUT = 'image.layout'; const MENU_LIVE_CONTROLS = 'live.controls'; +const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline'; const MENU_LIVE_LAYOUT = 'live.layout'; const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls'; +const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline'; const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout'; const MENU_TIMELINE_CONTROLS = 'timeline.controls'; const MENU_OPTIONS = 'options'; @@ -421,6 +433,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'fill', label: localize('config.common.layout.fits.fill') }, ]; + protected _miniTimelineModes: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'none', label: localize('config.timeline.mini.modes.none') }, + { value: 'above', label: localize('config.timeline.mini.modes.above') }, + { value: 'below', label: localize('config.timeline.mini.modes.below') }, + ]; + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -784,7 +803,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor /** * Render a media layout section. - * @param domain The submenu domain. + * @param domain The submenu domain. * @param labelPath The path to the label. * @param configPathFit The path to the fit config. * @param configPathPositionX The path to the position.x config. @@ -819,6 +838,71 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ); } + /** + * Render the core timeline controls (mini or full timeline), + * @param configPathWindowSeconds Timeline window config path. + * @param configPathClusteringThreshold Clustering threshold config path. + * @param configPathTimelineMedia Timeline media config path. + * @param configPathShowRecordings Show recordings config path. + * @param defaultShowRecordings Default value of show_recordings. + * @returns A rendered template. + */ + protected _renderTimelineCoreControls( + configPathWindowSeconds: string, + configPathClusteringThreshold: string, + configPathTimelineMedia: string, + configPathShowRecordings: string, + defaultShowRecordings: boolean, + ): TemplateResult { + return html` ${this._renderNumberInput(configPathWindowSeconds, { + label: localize(`config.${CONF_TIMELINE_WINDOW_SECONDS}`), + })} + ${this._renderNumberInput(configPathClusteringThreshold, { + label: localize(`config.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`), + })} + ${this._renderOptionSelector(configPathTimelineMedia, this._timelineMediaTypes, { + label: localize(`config.${CONF_TIMELINE_MEDIA}`), + })} + ${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, { + label: localize(`config.${CONF_TIMELINE_SHOW_RECORDINGS}`), + })}`; + } + + /** + * Render the mini timeline controls. + * @param domain The submenu domain. + * @param configPathWindowSeconds Timeline window config path. + * @param configPathClusteringThreshold Clustering threshold config path. + * @param configPathTimelineMedia Timeline media config path. + * @param configPathShowRecordings Show recordings config path. + * @returns A rendered template. + */ + protected _renderMiniTimeline( + domain: string, + configPathMode: string, + configPathWindowSeconds: string, + configPathClusteringThreshold: string, + configPathTimelineMedia: string, + configPathShowRecordings: string, + ): TemplateResult | void { + return this._putInSubmenu( + domain, + true, + 'config.timeline.mini.options', + { name: 'mdi:chart-gantt' }, + html` ${this._renderOptionSelector(configPathMode, this._miniTimelineModes, { + label: localize('config.timeline.mini.mode'), + })} + ${this._renderTimelineCoreControls( + configPathWindowSeconds, + configPathClusteringThreshold, + configPathTimelineMedia, + configPathShowRecordings, + frigateCardConfigDefaults.mini_timeline.show_recordings, + )}`, + ); + } + /** * Render a camera section. * @param cameras The full array of cameras. @@ -1312,6 +1396,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor max: 60, }, )} + ${this._renderMiniTimeline( + MENU_LIVE_CONTROLS_TIMELINE, + CONF_LIVE_CONTROLS_TIMELINE_MODE, + CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, + CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, + CONF_LIVE_CONTROLS_TIMELINE_MEDIA, + CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, + )} `, )} ${this._renderMediaLayout( @@ -1429,6 +1521,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, { min: 0, max: 60 }, )} + ${this._renderMiniTimeline( + MENU_MEDIA_VIEWER_CONTROLS_TIMELINE, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS, + )} `, )} ${this._renderMediaLayout( @@ -1458,13 +1558,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSetHeader('timeline')} ${this._expandedMenus[MENU_OPTIONS] === 'timeline' ? html`
- ${this._renderNumberInput(CONF_TIMELINE_WINDOW_SECONDS)} - ${this._renderNumberInput(CONF_TIMELINE_CLUSTERING_THRESHOLD)} - ${this._renderOptionSelector( + ${this._renderTimelineCoreControls( + CONF_TIMELINE_WINDOW_SECONDS, + CONF_TIMELINE_CLUSTERING_THRESHOLD, CONF_TIMELINE_MEDIA, - this._timelineMediaTypes, - )} - ${this._renderSwitch( CONF_TIMELINE_SHOW_RECORDINGS, defaults.timeline.show_recordings, )} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 9e4655bc..ad366f0d 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -254,6 +254,15 @@ "size": "Timeline thumbnails size in pixels" } }, + "mini": { + "options": "Mini Timeline", + "mode": "Mode", + "modes": { + "none": "None", + "above": "Above", + "below": "Below" + } + }, "media": "The media the timeline displays", "medias": { "all": "All media types", diff --git a/src/types.ts b/src/types.ts index b9815fc9..8141c19d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1240,6 +1240,7 @@ export const frigateCardConfigDefaults = { event_gallery: galleryConfigDefault, image: imageConfigDefault, timeline: timelineConfigDefault, + mini_timeline: timelineCoreConfigDefault, }; const menuButtonSchema = z.discriminatedUnion('type', [ From e9e5d81c6c655e23f758a0947262026fa8bc0eb7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 25 Sep 2022 09:29:12 -0700 Subject: [PATCH 12/16] Update README for mini timeline. --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index d713b76a..d495c65f 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,23 @@ live: | `style` | `chevrons` | :white_check_mark: | When viewing live cameras, what kind of controls to show to move to the previous/next camera. Acceptable values: `chevrons`, `icons`, `none` . | | `size` | 48 | :white_check_mark: | The size of the next/previous controls in pixels. Must be >= `20`. | +#### Live Controls: Mini Timeline + +All configuration is under: + +```yaml +live: + controls: + timeline: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `window_seconds` | `3600` | :white_check_mark: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. | +| `clustering_threshold` | `3` | :white_check_mark: | The number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. `0` disables clustering entirely.| +| `media` | `all` | :white_check_mark: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.| +| `show_recordings` | `true` | :white_check_mark: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).| + #### Live Controls: Title @@ -470,6 +487,23 @@ media_viewer: | `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.| | `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.| +#### Media Viewer Controls: Mini Timeline + +All configuration is under: + +```yaml +media_viewer: + controls: + timeline: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `window_seconds` | `3600` | :heavy_multiplication_x: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. | +| `clustering_threshold` | `3` | :heavy_multiplication_x: | The number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. `0` disables clustering entirely.| +| `media` | `all` | :heavy_multiplication_x: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.| +| `show_recordings` | `true` | :heavy_multiplication_x: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).| + #### Media Viewer Controls: Title All configuration is under: @@ -1340,6 +1374,12 @@ live: show_favorite_control: true show_timeline_control: true mode: none + timeline: + mode: none + clustering_threshold: 3 + media: all + show_recordings: true + window_seconds: 3600 title: mode: popup-bottom-right duration_seconds: 2 @@ -1390,6 +1430,12 @@ media_viewer: show_details: false show_favorite_control: true show_timeline_control: true + timeline: + mode: none + clustering_threshold: 3 + media: all + show_recordings: true + window_seconds: 3600 title: mode: popup-bottom-right duration_seconds: 2 From 2f34d6e7fa9c979ee1e1c8880f541d56dc972f12 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 25 Sep 2022 13:44:54 -0700 Subject: [PATCH 13/16] Comment cleanups. --- src/components/timeline-core.ts | 1 - src/components/timeline.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index c4e51c5d..1869d9e3 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -2,7 +2,6 @@ import { add, differenceInSeconds, endOfHour, - format, fromUnixTime, getUnixTime, startOfHour, diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 9ec85c35..9186e6a2 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,6 +1,3 @@ -// TODO: When a media viewer is first loaded the selected child won't work (because the underlying carousel has not yet rendered) -// TODO: time on recording != time on timeline in media viewer after scrolling around - import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import timelineStyle from '../scss/timeline.scss'; From 56465e4f92140d75b49f5d4fb3b5feb379ece24c Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 25 Sep 2022 17:50:40 -0700 Subject: [PATCH 14/16] Promote thumbnail carousel selected to a property. --- src/components/surround.ts | 3 ++- src/components/thumbnail-carousel.ts | 30 +++++++++++++--------------- src/components/timeline-core.ts | 9 +++++++-- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/components/surround.ts b/src/components/surround.ts index 45ded48a..fae4303a 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -30,6 +30,7 @@ import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import './surround-basic.js'; import './timeline-core.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; interface ThumbnailViewContext { // Whether or not to fetch thumbnails. @@ -171,8 +172,8 @@ export class FrigateCardSurround extends LitElement { .config=${this.thumbnailConfig} .view=${this.view} .target=${this.view.target} - .selected=${this.view.childIndex} .cameras=${this.cameras} + selected=${ifDefined(this.view.childIndex ?? undefined)} @frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')} @frigate-card:thumbnail-carousel:tap=${( ev: CustomEvent, diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index b405c189..fb1f8917 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -59,8 +59,8 @@ export class FrigateCardThumbnailCarousel extends LitElement { @property({ attribute: false }) public config?: ThumbnailsControlConfig; - @state() - protected _selected: number | null = null; + @property({ attribute: false, type: Number, reflect: true }) + public selected?: number; protected _carouselOptions?: EmblaOptionsType; protected _carouselPlugins: EmblaPluginType[] = [ @@ -76,15 +76,6 @@ export class FrigateCardThumbnailCarousel extends LitElement { this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); } - @property({ attribute: false }) - set selected(selected: number | null) { - this._selected = selected; - this.style.setProperty( - '--frigate-card-carousel-thumbnail-opacity', - selected === null ? '1.0' : '0.4', - ); - } - /** * Handle gallery resize. */ @@ -116,7 +107,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { return { containScroll: 'keepSnaps', dragFree: true, - startIndex: this._selected ?? 0, + startIndex: this.selected ?? 0, }; } /** @@ -155,6 +146,13 @@ export class FrigateCardThumbnailCarousel extends LitElement { } } + if (changedProps.has('selected')) { + this.style.setProperty( + '--frigate-card-carousel-thumbnail-opacity', + this.selected === undefined ? '1.0' : '0.4', + ); + } + if (!this._carouselOptions) { // Want to set the initial carousel options just before the first render // in order to get the startIndex correct in the options. It is not safe @@ -171,10 +169,10 @@ export class FrigateCardThumbnailCarousel extends LitElement { updated(changedProperties: PropertyValues): void { super.updated(changedProperties); - if (changedProperties.has('_selected')) { + if (changedProperties.has('selected')) { this.updateComplete.then(() => { - if (this._selected !== null) { - this._refCarousel.value?.carouselScrollTo(this._selected); + if (this.selected !== undefined) { + this._refCarousel.value?.carouselScrollTo(this.selected); } }); } @@ -200,7 +198,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { const classes = { embla__slide: true, - 'slide-selected': this._selected === childIndex, + 'slide-selected': this.selected === childIndex, }; const cameraConfig = this.view?.camera ? this.cameras?.get(this.view.camera) : null; diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 1869d9e3..7d6d7f5c 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -681,7 +681,9 @@ export class FrigateCardTimelineCore extends LitElement { const window = this._timeline?.getWindow(); if (window) { if (properties.group) { - this._changeViewToRecording(window.end, String(properties.group)); + this._changeViewToRecording( + properties.what === 'background' ? properties.time : window.end, + String(properties.group)); } else if (this.mini && this.view?.camera) { // In mini mode group may not be displayed / used, so just use the camera directly. this._changeViewToRecording(window.end, this.view.camera); @@ -812,7 +814,10 @@ export class FrigateCardTimelineCore extends LitElement { : this._timeline.getSelection(); let childIndex = -1; const children: FrigateBrowseMediaSource[] = []; - this._dataview?.get({ order: sortTimelineItemsYoungestToOldest }).forEach((item) => { + this._dataview?.get({ + filter: (item) => item.type !== 'background', + order: sortTimelineItemsYoungestToOldest } + ).forEach((item) => { const cameraID = item.group ? String(item.group) : null; const cameraConfig = cameraID ? this.cameras?.get(cameraID) : null; const event = item.event; From 046313eb47afbf667e92b0bee05b97e111d3ecb0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 25 Sep 2022 18:03:57 -0700 Subject: [PATCH 15/16] Add comment. --- src/components/carousel.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 3a1fbbc4..0afb657f 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -105,13 +105,16 @@ export class FrigateCardCarousel extends LitElement { * @param index Slide number. */ public carouselScrollTo(index: number): void { - const scroll = () => this._carousel?.scrollTo(index, this.transitionEffect === 'none'); + const scroll = () => + this._carousel?.scrollTo(index, this.transitionEffect === 'none'); + // This ensures scrolling can work on initial render when the carousel may + // not yet exist. if (this._carousel) { scroll(); } else { this.updateComplete.then(() => { scroll(); - }) + }); } } From ac35a5ff23b9029b65f8697542f5a6b35021f4a2 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 25 Sep 2022 18:14:36 -0700 Subject: [PATCH 16/16] Minor codereview fixes. --- src/components/thumbnail-carousel.ts | 2 +- src/components/timeline-core.ts | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index fb1f8917..af9b1e6f 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -8,7 +8,7 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 7d6d7f5c..dfabd637 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -730,8 +730,10 @@ export class FrigateCardTimelineCore extends LitElement { }) .mergeInContext(context) .dispatchChangeEvent(this); - dispatchFrigateCardEvent(this, 'thumbnails:open'); - } else { + if (this.view.is('timeline')) { + dispatchFrigateCardEvent(this, 'thumbnails:open'); + } + } else if (this.view.is('timeline')) { dispatchFrigateCardEvent(this, 'thumbnails:close'); } } @@ -966,7 +968,7 @@ export class FrigateCardTimelineCore extends LitElement { } /** - * Handle timeline resize. + * Get timeline options. */ protected _getOptions(): TimelineOptions | null { if (!this.timelineConfig) { @@ -1287,10 +1289,6 @@ export class FrigateCardTimelineCore extends LitElement { ...(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;