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 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/card.ts b/src/card.ts index 7aa66404..1ea14574 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 + ) + } } /** @@ -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..0afb657f 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -105,7 +105,17 @@ 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'); + // This ensures scrolling can work on initial render when the carousel may + // not yet exist. + if (this._carousel) { + scroll(); + } else { + this.updateComplete.then(() => { + scroll(); + }); + } } /** diff --git a/src/components/live.ts b/src/components/live.ts index 9b67c980..5efec367 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -61,13 +61,14 @@ 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'; 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 && @@ -209,13 +213,16 @@ export class FrigateCardLive extends LitElement { // is received when the card is in the background). const result = html`${keyed( this._renderKey, - html`) => { this._renderKey++; this._messageReceivedPostRender = true; @@ -245,7 +252,7 @@ export class FrigateCardLive extends LitElement { .liveOverrides=${this.liveOverrides} > - `, + `, )}`; 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 2b6978a8..00000000 --- a/src/components/surround-thumbnails.ts +++ /dev/null @@ -1,194 +0,0 @@ -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, - FrigateCardView, - ThumbnailsControlConfig, -} from '../types.js'; -import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; -import { - getFirstTrueMediaChildIndex, - multipleBrowseMediaQueryMerged, -} from '../utils/ha/browse-media'; -import { View } from '../view.js'; -import { dispatchFrigateCardErrorEvent } from './message.js'; -import './surround.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 FrigateCardSurround extends LitElement { - @property({ attribute: false }) - public hass?: ExtendedHomeAssistant; - - @property({ attribute: false }) - public view?: Readonly; - - @property({ attribute: false, hasChanged: contentsChanged }) - public config?: ThumbnailsControlConfig; - - @property({ attribute: false }) - public targetView?: FrigateCardView; - - @property({ attribute: true, type: Boolean }) - public fetch?: boolean; - - @property({ attribute: false, hasChanged: contentsChanged }) - public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[]; - - @property({ attribute: false }) - public cameras?: Map; - - /** - * 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.fetch || - !this.hass || - !this.view || - !this.config || - this.config.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({ - ...(this.targetView && { view: this.targetView }), - 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.config && ['left', 'right'].includes(this.config.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', 'targetView', 'fetch', 'browseMediaParams'].some((prop) => - changedProperties.has(prop), - ) - ) { - this._fetchMedia(); - } - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.config) { - 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.config && this._hasDrawer()) { - dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, { - drawer: this.config.mode, - }); - } - }; - - return html` changeDrawer(ev, 'open')} - @frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')} - > - ${this.config && this.config.mode !== 'none' - ? html` changeDrawer(ev, 'close')} - @frigate-card:thumbnail-carousel:tap=${(ev: CustomEvent) => { - // 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]); - }} - > - ` - : ''} - - `; - } - - /** - * Return compiled CSS styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(surroundThumbnailsStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'frigate-card-surround-thumbnails': FrigateCardSurround; - } -} diff --git a/src/components/surround.ts b/src/components/surround.ts index 414058ab..fae4303a 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -1,48 +1,139 @@ -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'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +interface ThumbnailViewContext { + // Whether 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 }) + public fetch = false; + + @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.fetch || + this.inBackground || + !this.hass || + !this.view || + this.view.target || + !this.thumbnailConfig || + this.thumbnailConfig.mode === 'none' || + !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 +142,80 @@ 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; + if (child) { + this.view + ?.evolve({ + view: this.view.is('recording') ? 'recording' : 'media', + target: ev.detail.target, + childIndex: ev.detail.childIndex, + ...(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]); + } + }} + > + ` + : ''} + ${this.timelineConfig && !this.inBackground + ? html` + ` + : ''} - - - - - - - `; + `; } /** @@ -71,7 +227,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/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 9136a54d..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'; @@ -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; @@ -209,6 +207,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..8468515d 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -2,25 +2,28 @@ 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 { stopEventFromActivatingCardWideActions } from '../utils/action.js'; +import { errorToConsole, prettifyTitle } from '../utils/basic.js'; +import { retainEvent } from '../utils/frigate.js'; +import { getEventDurationString } from '../utils/frigate.js'; +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, 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'; -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'; - // The minimum width of a thumbnail with details enabled. export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; @@ -36,24 +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 { @@ -86,6 +128,9 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { @property({ attribute: false }) public event?: FrigateEvent; + @property({ attribute: false }) + public mediaSeek?: MediaSeek; + protected render(): TemplateResult | void { if (!this.event) { return; @@ -101,6 +146,12 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { ${localize('event.duration')}: ${getEventDurationString(this.event)} + ${this.mediaSeek + ? html`
+ ${localize('event.seek')} + ${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')} +
` + : html``}
${score} @@ -117,16 +168,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 +215,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 +320,12 @@ export class FrigateCardThumbnail extends LitElement { ${this.details && event ? html`` : this.details && recording ? html`` : html``} ${this.show_timeline_control @@ -286,6 +345,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 new file mode 100644 index 00000000..dfabd637 --- /dev/null +++ b/src/components/timeline-core.ts @@ -0,0 +1,1317 @@ +import { + add, + differenceInSeconds, + endOfHour, + fromUnixTime, + getUnixTime, + startOfHour, + sub, +} from 'date-fns'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { isEqual, throttle } from 'lodash-es'; +import { ViewContext } from 'view'; +import { DataView, DataSet } from 'vis-data/esnext'; +import { + DataGroupCollectionType, + IdType, + Timeline, + TimelineEventPropertiesResult, + TimelineItem, + TimelineOptions, + TimelineOptionsCluster, + TimelineWindow, +} from 'vis-timeline/esnext'; +import { CAMERA_BIRDSEYE } from '../const'; +import { localize } from '../localize/localize'; +import timelineCoreStyle from '../scss/timeline-core.scss'; +import { + CameraConfig, + ExtendedHomeAssistant, + FrigateBrowseMediaSource, + frigateCardConfigDefaults, + FrigateEvent, + FrigateRecording, + TimelineCoreConfig, +} from '../types'; +import { stopEventFromActivatingCardWideActions } from '../utils/action'; +import { + contentsChanged, + dispatchFrigateCardEvent, + formatDateAndTime, + isHoverableDevice, + prettifyTitle, +} from '../utils/basic'; +import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js'; +import { + getEventMediaContentID, + getEventThumbnailURL, + getEventTitle, + getRecordingMediaContentID, +} from '../utils/frigate'; + +import { createEventParentForChildren, createChild } from '../utils/ha/browse-media'; +import { + FrigateCardTimelineItem, + RecordingSegmentsItem, + sortSegmentsOldestToYoungest, + sortTimelineItemsYoungestToOldest, + TimelineDataManager, +} from '../utils/timeline-data-manager'; +import { View } from '../view'; +import { dispatchMessageEvent } from './message.js'; +import './thumbnail.js'; + +interface FrigateCardGroupData { + id: string; + content: string; +} + +interface TimelineRangeChange extends TimelineWindow { + event: Event & { additionalEvent?: string }; + byUser: boolean; +} + +interface TimelineViewContext { + // Force a particular timeline window rather than taking the time from an + // event / recording. + window?: TimelineWindow; + + // Whether or not to set the timeline window. + noSetWindow?: boolean; + + // Whether or not thumbnails were generated. + generatedThumbnails?: boolean; +} + +declare module 'view' { + interface ViewContext { + timeline?: TimelineViewContext; + } +} + +// An event used to fetch the HASS object. See "Special note" below. +class HASSRequestEvent extends Event { + public hass?: ExtendedHomeAssistant; +} + +const TIMELINE_TARGET_BAR_ID = 'target_bar'; + +/** + * A simgple thumbnail wrapper class for use in the timeline where LIT data + * bindings are not available. + */ +@customElement('frigate-card-timeline-thumbnail') +export class FrigateCardTimelineThumbnail extends LitElement { + @property({ attribute: true }) + public thumbnail?: string; + + @property({ attribute: true, type: Boolean }) + public details = false; + + @property({ attribute: true }) + public event?: string; + + @property({ attribute: true }) + public label?: string; + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + // Don't display tooltips on touch devices, they just get in the way of + // the drawer. + if (!this.thumbnail || !this.event) { + return html``; + } + + /* Special note on what's going on here: + * + * This component does not have access to HASS, as there's no way to pass it + * in via the string-based tooltip that timeline supports. Instead dispatch + * an event to request HASS which the timeline adds to the event object + * before execution continues. + */ + const hassRequest = new HASSRequestEvent(`frigate-card:timeline:hass-request`, { + composed: true, + bubbles: true, + }); + this.dispatchEvent(hassRequest); + if (!hassRequest.hass) { + return html``; + } + + return html` + `; + } +} + +@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 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 = `event='${JSON.stringify(event)}'`; + const detailsAttr = this.thumbnailDetails ? 'details' : ''; + + // Cannot use Lit data-bindings as visjs requires a string for tooltips. + // Note that changes to attributes here must be mirrored in the xss + // whitelist in `_getOptions()` . + return ` + + `; + } + + /** + * 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( + createChild( + `${prettifyTitle(config.frigate.camera_name)} ${formatDateAndTime(hour)}`, + getRecordingMediaContentID({ + clientId: config.frigate.client_id, + year: dayData.day.getFullYear(), + month: dayData.day.getMonth() + 1, + day: dayData.day.getDate(), + hour: hourData.hour, + cameraName: config.frigate.camera_name, + }), + { + recording: { + camera: config.frigate.camera_name, + start_time: getUnixTime(startHour), + end_time: getUnixTime(endHour), + events: hourData.events, + }, + cameraID: cameraID, + }, + ), + ); + } + } + } + } + return children; + } + + /** + * Change the view to a recording. + * @param targetTime The time of the recording to show. + * @param cameraID An optional camera to show a recording of, otherwise all + * cameras are shown at the given time. + */ + protected async _changeViewToRecording( + targetTime: Date, + cameraID?: string, + ): Promise { + 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._pointerHeld) { + this._ignoreClick = true; + } + + if ( + this._timeline && + properties.byUser && + // Do not adjust select children or seek during zoom events. + properties.event.type !== 'wheel' && + properties.event.additionalEvent !== 'pinchin' && + properties.event.additionalEvent !== 'pinchout' + ) { + + const targetTime = this._pointerHeld?.window + ? add(properties.start, { + seconds: + (this._pointerHeld.time.getTime() - + this._pointerHeld.window.start.getTime()) / + 1000, + }) + : properties.end; + + if (this._pointerHeld) { + this._setTargetBarAppropriately(targetTime); + } + + this._throttledSetViewDuringRangeChange(targetTime, properties); + } + } + + /** + * Set the target bar at a given time. + * @param targetTime + */ + protected _setTargetBarAppropriately(targetTime: Date): void { + if (!this._timeline) { + return; + } + + const targetBarOn = + !this._locked || + (!this.view?.is('timeline') && + this._timeline.getSelection().some((id) => { + const item = this._dataview?.get(id); + return ( + item && + item.start && + item.end && + targetTime.getTime() >= item.start && + targetTime.getTime() <= item.end + ); + })); + + if (targetBarOn) { + if (!this._targetBarVisible) { + this._timeline?.addCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); + this._targetBarVisible = true; + } else { + this._timeline?.setCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); + } + } else { + this._removeTargetBar(); + } + } + + /** + * Remove the target bar. + */ + protected _removeTargetBar(): void { + if (this._targetBarVisible) { + this._timeline?.removeCustomTime(TIMELINE_TARGET_BAR_ID); + this._targetBarVisible = false; + } + } + + /** + * Set the view during a range change. + * @param targetTime The target time. + * @param properties The range change properties. + * @returns + */ + protected _setViewDuringRangeChange( + targetTime: Date, + properties: TimelineRangeChange, + ): void { + if (!this._timeline || !this.view || !this.view.target?.children?.length) { + return; + } + + const canSeek = !!this.view?.isViewerView(); + const context = canSeek + ? this._generateMediaViewerContextForChildren( + this.view.target.children, + targetTime, + ) + : null; + + const childIndex = this._locked + ? null + : this._findChildIndex( + this.view.target.children, + targetTime, + this._getTimelineCameraIDs(), + properties.event.additionalEvent === 'panright' ? 'end' : 'start', + ); + + if (canSeek || (childIndex !== null && childIndex !== this.view.childIndex)) { + this.view + .evolve({ + ...(childIndex !== null && { + childIndex: childIndex, + }), + }) // Whether or not to set the timeline window. + .mergeInContext({ + ...this._generateTimelineContext({ noSetWindow: true }), + ...context, + }) + .dispatchChangeEvent(this); + } + } + + /** + * Generate the media view context for a set of media children (used to set + * seek times into each media item). + * @param children The media children. + * @param targetTime The target time. + * @returns The ViewContext. + */ + protected _generateMediaViewerContextForChildren( + children: FrigateBrowseMediaSource[], + targetTime: Date, + ): ViewContext { + if (!this.timelineDataManager) { + return {}; + } + const seek = new Map(); + const segmentsDataset = this.timelineDataManager.recordingSegments; + const hourStart = startOfHour(targetTime); + + children.forEach((child, index) => { + const source = child.frigate?.recording ?? child.frigate?.event; + if (source && source.end_time && child.frigate?.cameraID) { + const start = source.start_time * 1000; + const end = source.end_time * 1000; + let seekSeconds: number | null = null; + + if (targetTime.getTime() >= start && targetTime.getTime() <= end) { + const segments = segmentsDataset.get({ + filter: (segment) => + segment.cameraID === child.frigate?.cameraID && + segment.start >= start && + segment.end <= end, + order: sortSegmentsOldestToYoungest, + }); + seekSeconds = this._getSeekTimeInSegments( + // Recordings start from the top of the hour. + child.frigate.recording ? hourStart : fromUnixTime(source.start_time), + targetTime, + segments, + ); + } + + if (seekSeconds !== null) { + seek.set(index, { + seekSeconds: seekSeconds, + seekTime: targetTime.getTime() / 1000, + }); + } + } + }); + return seek.size > 0 ? { mediaViewer: { seek: seek } } : {}; + } + + /** + * Get the number of seconds to seek into a video stream consisting of the + * provided segments to reach the target time provided. + * @param startTime The earliest allowable time to seek from. + * @param targetTime Target time. + * @param segments An array of segments dataset items. Must be sorted from oldest to youngest. + * @returns + */ + protected _getSeekTimeInSegments( + startTime: Date, + targetTime: Date, + segments: RecordingSegmentsItem[], + ): number | null { + if (!segments.length) { + return null; + } + let seekMilliseconds = 0; + + // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27 + for (const segment of segments) { + if (segment.start > targetTime.getTime()) { + break; + } + const start = + segment.start < startTime.getTime() ? startTime.getTime() : segment.start; + const end = + segment.end > targetTime.getTime() ? targetTime.getTime() : segment.end; + seekMilliseconds += end - start; + } + return seekMilliseconds / 1000; + } + + /** + * Called whenever the timeline is clicked. + * @param properties The properties of the timeline click event. + */ + protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void { + // Calls to stopEventFromActivatingCardWideActions() are included for + // completeness. Timeline does not support card-wide events and they are + // disabled in card.ts in `_getMergedActions`. + if (properties.what === 'item' || this._ignoreClick) { + stopEventFromActivatingCardWideActions(properties.event); + } + + if (!this._ignoreClick && properties.what) { + if ( + this.timelineConfig?.show_recordings && + ['background', 'group-label', 'axis'].includes(properties.what) + ) { + if (['background', 'group-label'].includes(properties.what)) { + stopEventFromActivatingCardWideActions(properties.event); + const window = this._timeline?.getWindow(); + if (window) { + if (properties.group) { + this._changeViewToRecording( + properties.what === 'background' ? properties.time : window.end, + String(properties.group)); + } else if (this.mini && this.view?.camera) { + // In mini mode group may not be displayed / used, so just use the camera directly. + this._changeViewToRecording(window.end, this.view.camera); + } + } + } else { + stopEventFromActivatingCardWideActions(properties.event); + this._changeViewToRecording(properties.time); + } + } else if ( + properties.what === 'item' && + properties.item && + this.view && + this.view.target?.children + ) { + let childIndex: number | null = null; + let target: FrigateBrowseMediaSource | null = null; + let context: ViewContext = {}; + + if (this.view.is('recording')) { + const thumbnails = this._generateThumbnails(properties.item); + + if (thumbnails) { + target = thumbnails.target; + childIndex = thumbnails.childIndex; + if (thumbnails.target?.children?.length) { + context = this._generateMediaViewerContextForChildren( + thumbnails.target.children, + properties.time, + ); + } + } + } else { + childIndex = this.view.target.children.findIndex( + (child) => child.frigate?.event?.id === properties.item, + ); + } + + if (childIndex !== null && childIndex >= 0) { + this.view + ?.evolve({ + childIndex: childIndex, + ...(target && { target: target }), + }) + .mergeInContext(context) + .dispatchChangeEvent(this); + if (this.view.is('timeline')) { + dispatchFrigateCardEvent(this, 'thumbnails:open'); + } + } else if (this.view.is('timeline')) { + dispatchFrigateCardEvent(this, 'thumbnails:close'); + } + } + } + + this._ignoreClick = false; + } + + /** + * Get a broader prefetch window from a start and end basis. + * @param start The earlier date. + * @param end The later date. + * @returns An object with a `start` and `end` key to prefetch. + */ + protected _getPrefetchWindow(start: Date, end: Date): [Date, Date] { + const delta = differenceInSeconds(end, start); + return [sub(start, { seconds: delta }), add(end, { seconds: delta })]; + } + + /** + * Handle a range change in the timeline. + * @param properties vis.js provided range information. + */ + protected _timelineRangeChangedHandler(properties: { + start: Date; + end: Date; + byUser: boolean; + event: Event & { additionalEvent: string }; + }): void { + if (!properties.byUser) { + return; + } + this._removeTargetBar(); + + if (this.hass && this.cameras && this._timeline && this.timelineConfig) { + const [prefetchStart, prefetchEnd] = this._getPrefetchWindow( + properties.start, + properties.end, + ); + this.timelineDataManager + ?.fetchIfNecessary(this, this.hass, prefetchStart, prefetchEnd) + .then(() => { + // Don't show event thumbnails if the user is looking at recordings, + // as the recording "hours" are the media, not the event + // clips/snapshots. + if (this._timeline && this.view && !this.view?.is('recording')) { + const thumbnails = this._generateThumbnails(); + // Update the view to reflect the new thumbnails and the timeline + // window in the context. + this.view + .evolve({ + target: thumbnails?.target ?? null, + childIndex: thumbnails?.childIndex ?? null, + }) + .mergeInContext(this._generateTimelineContext({ noSetWindow: true })) + .dispatchChangeEvent(this); + } + }); + } + } + + /** + * Regenerate the thumbnails from the timeline events. + * @param selectedItem An id to select from the thumbnails (currently selected + * item is used if none is specified). + * @returns An object with two keys, or null on error. The keys are `target` + * containing all the thumbnails, and `childIndex` to refer to the currently + * selected thumbnail. + */ + protected _generateThumbnails(selectedItem?: IdType): { + target: FrigateBrowseMediaSource; + childIndex: number | null; + } | null { + if (!this._timeline) { + return null; + } + + const selected: IdType[] = selectedItem + ? [selectedItem] + : this._timeline.getSelection(); + let childIndex = -1; + const children: FrigateBrowseMediaSource[] = []; + this._dataview?.get({ + filter: (item) => item.type !== 'background', + order: sortTimelineItemsYoungestToOldest } + ).forEach((item) => { + const cameraID = item.group ? String(item.group) : null; + const cameraConfig = cameraID ? this.cameras?.get(cameraID) : null; + const event = item.event; + const media = + event?.has_clip && this.timelineConfig?.media !== 'snapshots' + ? 'clips' + : event?.has_snapshot + ? 'snapshots' + : null; + + if ( + cameraID && + cameraConfig && + event && + media && + cameraConfig.frigate.camera_name + ) { + children.push( + createChild( + getEventTitle(event), + getEventMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + event.id, + media, + ), + { + thumbnail: getEventThumbnailURL(cameraConfig.frigate.client_id, event), + event: event, + cameraID: cameraID, + }, + ), + ); + if (selected.includes(event.id)) { + childIndex = children.length - 1; + } + } + }); + if (!children.length) { + return null; + } + + return { + target: createEventParentForChildren('Timeline events', children), + childIndex: childIndex < 0 ? null : childIndex, + }; + } + + /** + * Build the visjs dataset to render on the timeline. + * @returns The dataset. + */ + protected _getGroups(): DataGroupCollectionType { + const groups: FrigateCardGroupData[] = []; + + this._getTimelineCameraIDs().forEach((cameraID) => { + const cameraConfig = this.cameras?.get(cameraID); + if (cameraConfig) { + if ( + cameraConfig.frigate.camera_name && + cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE + ) { + groups.push({ + id: cameraID, + content: getCameraTitle(this.hass, cameraConfig), + }); + } + } + }); + return new DataSet(groups); + } + + /** + * Given an event get an appropriate start/end time window around the event. + * @param event The FrigateEvent to consider. + * @returns A tuple of start/end date. + */ + protected _getStartEndFromEvent(event: FrigateEvent): [Date, Date] { + const windowSeconds = this._getConfiguredWindowSeconds(); + if (event.end_time) { + if (event.end_time - event.start_time > windowSeconds) { + // If the event is larger than the configured window, only show the most + // recent portion of the event that fits in the window. + return [ + sub(fromUnixTime(event.end_time), { seconds: windowSeconds }), + fromUnixTime(event.end_time), + ]; + } else { + // If the event is shorter than the configured window, center the event + // in the window. + const gap = windowSeconds - (event.end_time - event.start_time); + return [ + sub(fromUnixTime(event.start_time), { seconds: gap / 2 }), + add(fromUnixTime(event.end_time), { seconds: gap / 2 }), + ]; + } + } + // If there's no end-time yet, place the start-time in the center of the + // time window. + return [ + sub(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }), + add(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }), + ]; + } + + /** + * Given a recording get the start/end window. + * @param recording The FrigateRecording to consider. + * @returns A tuple of start/end date. + */ + protected _getStartEndFromRecording(recording: FrigateRecording): [Date, Date] { + return [fromUnixTime(recording.start_time), fromUnixTime(recording.end_time)]; + } + + /** + * Get the configured window length in seconds. + */ + protected _getConfiguredWindowSeconds(): number { + return ( + this.timelineConfig?.window_seconds ?? + frigateCardConfigDefaults.timeline.window_seconds + ); + } + + /** + * Get desired timeline start/end time. + * @returns A tuple of start/end date. + */ + protected _getStartEnd(): [Date, Date] { + const end = new Date(); + const start = sub(end, { + seconds: this._getConfiguredWindowSeconds(), + }); + return [start, end]; + } + + /** + * Determine if the timeline should use clustering. + * @returns `true` if the timeline should cluster, `false` otherwise. + */ + protected _isClustering(): boolean { + return ( + !!this.timelineConfig?.clustering_threshold && + this.timelineConfig.clustering_threshold > 0 + ); + } + + /** + * Get timeline options. + */ + protected _getOptions(): TimelineOptions | null { + if (!this.timelineConfig) { + return null; + } + + const [start, end] = this._getStartEnd(); + + // Configuration for the Timeline, see: + // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options + return { + cluster: this._isClustering() + ? { + // It would be better to automatically calculate `maxItems` from the + // rendered height of the timeline (or group within the timeline) so + // as to not waste vertical space (e.g. after the user changes to + // fullscreen mode). Unfortunately this is not easy to do, as we + // don't know the height of the timeline until after it renders -- + // and if we adjust `maxItems` then we can get into an infinite + // resize loop. Adjusting the `maxItems` of a timeline, after it's + // created, also does not appear to work as expected. + maxItems: this.timelineConfig.clustering_threshold, + + clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { + // Never include the target media in a cluster, and never group + // different object types together (e.g. person and car). + return ( + [first.type, second.type].every((type) => type !== 'background') && + first.type === second.type && + !!first.id && + first.id !== this.view?.media?.frigate?.event?.id && + !!second.id && + second.id != this.view?.media?.frigate?.event?.id && + (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 recording = this.view?.media?.frigate?.recording; + + const [windowStart, windowEnd] = event + ? this._getStartEndFromEvent(event) + : recording + ? this._getStartEndFromRecording(recording) + : this._getStartEnd(); + + let fetched = false; + if (!this._pointerHeld) { + // Don't fetch any data or touch the timeline in any way if the user is + // currently interacting with it. Without this the subsequent data fetches + // (via fetchIfNecessary) may update the timeline contents which causes + // the visjs timeline to stop dragging/panning operations which is very + // disruptive to the user. + const [prefetchStart, prefetchEnd] = this._getPrefetchWindow( + windowStart, + windowEnd, + ); + fetched = !!(await this.timelineDataManager?.fetchIfNecessary( + this, + this.hass, + prefetchStart, + prefetchEnd, + )); + } + + this._timeline.setSelection(event ? [event.id] : [], { + focus: false, + animation: { + animation: false, + zoom: false, + }, + }); + + if (!this._pointerHeld && event && this._isClustering()) { + // Hack: Clustering may not update unless the dataset changes, artifically + // update the dataset to ensure the newly selected item cannot be included + // in a cluster. Only do this when the pointer is not held to avoid + // interrupting the user and to make the timeline smoother. + this.timelineDataManager?.rewriteItem(event.id); + } + + if (!this._pointerHeld && !this.view.context?.timeline?.noSetWindow) { + // Regenerate the thumbnails after the selection, to allow the new selection + // to be in the generated view. + const context = this.view.context?.timeline; + const timelineWindow = this._timeline.getWindow(); + + // If there's a set context window, always move to it. + if (context?.window && !isEqual(context.window, timelineWindow)) { + this._timeline.setWindow(context.window.start, context.window.end); + } else if (event || recording) { + const source = event ?? (recording as FrigateEvent | FrigateRecording); + const start = fromUnixTime(source.start_time); + const end = source.end_time ? fromUnixTime(source.end_time) : 0; + + // If there's an event or recording outside the current window, move to it. + if ( + start < timelineWindow.start || + start > timelineWindow.end || + (end && (end < timelineWindow.start || end > timelineWindow.end)) + ) { + this._timeline.setWindow(windowStart, windowEnd); + } + } + } + + // Only generate thumbnails if an actual fetch occurred, to avoid getting + // stuck in a loop (the subsequent fetches will not actually fetch since the + // data will have been cached). + // + // Timeline receives a new `view` + // -> Events fetched + // -> Thumbnails generated + // -> New view dispatched (to load thumbnails into outer carousel). + // -> New view received ... [loop] + // + // Also don't generate thumbnails in mini-timelines (they will already have + // been generated), or if the media child is a recording. + if ( + (fetched || !this.view.context?.timeline?.generatedThumbnails) && + !this.mini && + !recording + ) { + const thumbnails = this._generateThumbnails(); + this.view + ?.evolve({ + target: thumbnails?.target ?? null, + childIndex: thumbnails?.childIndex ?? null, + }) + .mergeInContext(this._generateTimelineContext()) + .dispatchChangeEvent(this); + } + } + + /** + * Generate the context for timeline views. + * @param options Configure how the context is set. + * @returns The TimelineViewContext object. + */ + protected _generateTimelineContext(options?: { + noSetWindow?: boolean; + generatedThumbnails?: boolean; + }): ViewContext { + const newContext: TimelineViewContext = { + generatedThumbnails: options?.generatedThumbnails ?? true, + }; + + if (options?.noSetWindow) { + newContext.noSetWindow = options.noSetWindow; + } + return { timeline: newContext }; + } + + /** + * Called when an update will occur. + * @param changedProps The changed properties + */ + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('thumbnailSize')) { + if (this.thumbnailSize !== undefined) { + this.style.setProperty( + '--frigate-card-thumbnail-size', + `${this.thumbnailSize}px`, + ); + } else { + this.style.removeProperty('--frigate-card-thumbnail-size'); + } + } + + if (changedProps.has('timelineConfig')) { + if (this.timelineConfig?.show_recordings) { + this.setAttribute('recordings', ''); + } else { + this.removeAttribute('recordings'); + } + } + } + + /** + * Destroy/reset the timeline. + */ + protected _destroy(): void { + this._timeline?.destroy(); + this._timeline = undefined; + } + + /** + * Called when the component is updated. + * @param changedProperties The changed properties if any. + */ + protected updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + if (changedProperties.has('cameras')) { + this._destroy(); + } + + const options = this._getOptions(); + + if ( + this.timelineDataManager && + this._refTimeline.value && + options && + this.timelineConfig && + (changedProperties.has('timelineConfig') || + (this.mini && + changedProperties.has('view') && + this.view?.camera !== changedProperties.get('view').camera)) + ) { + if (this._timeline) { + this._destroy(); + } + + const groups = this._getGroups(); + if (!groups.length) { + if (!this.mini) { + // Don't show an empty timeline, show a message instead. + dispatchMessageEvent(this, localize('error.timeline_no_cameras'), 'info', { + icon: 'mdi:chart-gantt', + }); + } + return; + } + + this._dataview = this.timelineDataManager.createDataView( + this._getTimelineCameraIDs(), + !!this.timelineConfig.show_recordings, + this.timelineConfig.media, + ); + + if (this.mini && groups.length === 1) { + // In a mini timeline, if there's only one group don't bother grouping + // at all. + this._timeline = new Timeline( + this._refTimeline.value, + this._dataview, + options, + ) as Timeline; + this.removeAttribute('groups'); + } else { + this._timeline = new Timeline( + this._refTimeline.value, + this._dataview, + groups, + options, + ) as Timeline; + this.setAttribute('groups', ''); + } + + this._timeline.on('rangechanged', this._timelineRangeChangedHandler.bind(this)); + this._timeline.on('click', this._timelineClickHandler.bind(this)); + this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this)); + + // This complexity exists to ensure we can tell between a click that + // causes the timeline zoom/range to change, and a 'static' click on the + // // timeline (which may need to trigger a card wide event). + this._timeline.on('mouseDown', (ev: TimelineEventPropertiesResult) => { + const window = this._timeline?.getWindow(); + this._pointerHeld = { + ...ev, + ...(window && { window: window }), + }; + this._ignoreClick = false; + }); + this._timeline.on('mouseUp', () => { + this._pointerHeld = null; + this._removeTargetBar(); + }); + } + + if (changedProperties.has('view')) { + this._updateTimelineFromView(); + } + } + + /** + * Return compiled CSS styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(timelineCoreStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-timeline-thumbnail': FrigateCardTimelineThumbnail; + 'frigate-card-timeline-core': FrigateCardTimelineCore; + } +} diff --git a/src/components/timeline.ts b/src/components/timeline.ts index ca009e4e..9186e6a2 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,481 +1,16 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import { - add, - differenceInSeconds, - endOfHour, - format, - fromUnixTime, - getUnixTime, - startOfHour, - sub, -} from 'date-fns'; -import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS, -} from 'lit'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; 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 { isEqual } from 'lodash-es'; -import { ViewContext } from 'view'; -import { DataSet } from 'vis-data/esnext'; -import { - DataGroupCollectionType, - 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 timelineStyle from '../scss/timeline.scss'; -import { - BrowseMediaQueryParameters, - CameraConfig, - ExtendedHomeAssistant, - FrigateBrowseMediaSource, - frigateCardConfigDefaults, - FrigateCardError, - FrigateEvent, - TimelineConfig, -} 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'; -import { - createEventParentForChildren, - createVideoChild, - generateRecordingIdentifier, - getBrowseMediaQueryParameters, - isTrueMedia, - multipleBrowseMediaQuery, -} from '../utils/ha/browse-media'; +import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; +import { TimelineDataManager } from '../utils/timeline-data-manager'; import { View } from '../view'; -import { dispatchFrigateCardErrorEvent, dispatchMessageEvent } from './message.js'; -import './surround-thumbnails.js'; +import './surround.js'; +import './timeline-core.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 TimelineViewContext { - // The selected timeline window. - window?: TimelineWindow; - - // The date of the last event fetch. - dateFetch?: Date; -} - -declare module 'view' { - interface ViewContext { - timeline?: TimelineViewContext; - } -} - -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); - } - } - } -} - -/** - * 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 -> +// FrigateCardSurround -> +// FrigateCardTimelineCore @customElement('frigate-card-timeline') export class FrigateCardTimeline extends LitElement { @@ -491,6 +26,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. @@ -500,20 +38,24 @@ export class FrigateCardTimeline extends LitElement { return html``; } - return html` - `; + `; } /** @@ -524,824 +66,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 }) - public timelineConfig?: TimelineConfig; - - protected _data = new TimelineDataManager(); - - protected _refTimeline: Ref = createRef(); - protected _timeline?: Timeline; - - // 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 _ignoreClick = false; - - protected readonly _isHoverableDevice = isHoverableDevice(); - - /** - * 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.timelineConfig?.controls.thumbnails.show_details - ? '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; - } - - 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)}" - ${ref(this._refTimeline)} - >
`; - } - - /** - * 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 - */ - protected _getSeekTime(time: Date, segments: RecordingSegments): number | null { - if (!segments.length) { - return null; - } - const target = getUnixTime(time); - const hourStart = getUnixTime(startOfHour(time)); - let seekSeconds = 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_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; - } - - /** - * 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 - * for the provided cameras, otherwise shows all hours. - * @returns - */ - protected _createRecordingChildren( - results: Map, - time: Date, - onlyMatchingHour: 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) { - 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 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) { - 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, - ...(isMatchingHour && { - seek_seconds: seekSeconds, - seek_time: time.getTime() / 1000, - }), - }, - }, - ), - ); - } - } - } - } - return children; - } - - /** - * 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 - * cameras are shown at the given time. - */ - protected async _changeViewToRecording(time: Date, camera?: string): Promise { - if (!this.hass) { - 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); - if (!children.length) { - return; - } - - let childIndex = 0; - if (camera) { - childIndex = children.findIndex( - (child) => - child.frigate?.recording && - child.frigate.recording.start_time * 1000 === after.getTime(), - ); - if (childIndex < 0) { - return; - } - } - - this.view - ?.evolve({ - view: 'media', - target: createEventParentForChildren(localize('common.recordings'), children), - childIndex: childIndex, - }) - .dispatchChangeEvent(this); - } - - /** - * 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; - } - } - - /** - * 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 && 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); - } - } - - 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 _timelineRangeHandler(properties: { - start: Date; - end: Date; - byUser: boolean; - event: Event; - }): void { - if (!properties.byUser) { - return; - } - 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, - ) - .then(() => { - if (this._timeline) { - 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); - } - }); - } - } - - /** - * 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. - * @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(): { - target: FrigateBrowseMediaSource; - childIndex: number | null; - } | null { - if (!this._timeline) { - 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(); - let childIndex = -1; - const children: FrigateBrowseMediaSource[] = []; - this._data.dataset.get({ order: sortEvent }).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[] = []; - 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), - }); - } - }); - 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 event = this.view?.target?.frigate?.event; - if (event) { - return this._getStartEndFromEvent(event); - } - 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 | void { - if (!this.timelineConfig) { - return; - } - - 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: 'fixed', - 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) { - return; - } - - const event = this.view?.media?.frigate?.event; - const [windowStart, windowEnd] = event - ? 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; - } - - this._timeline.setSelection(event ? [event.id] : [], { - focus: false, - animation: { - animation: false, - zoom: false, - }, - }); - - // 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); - } - - 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 - // 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) { - 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 = {} - - 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; - } - return Object.keys(newContext) ? {timeline: newContext} : {}; - } - - /** - * Called when an update will occur. - * @param changedProps The changed properties - */ - protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('timelineConfig')) { - if (this.timelineConfig?.controls.thumbnails.size) { - this.style.setProperty( - '--frigate-card-thumbnail-size', - `${this.timelineConfig.controls.thumbnails.size}px`, - ); - } - if (this.timelineConfig?.show_recordings) { - this.setAttribute('recordings', ''); - } else { - this.removeAttribute('recordings'); - } - } - } - - /** - * 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._data.clear(); - this._timeline?.destroy(); - this._timeline = undefined; - } - - const options = this._getOptions(); - if (changedProperties.has('timelineConfig') && this._refTimeline.value && options) { - 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) { - dispatchMessageEvent(this, localize('error.timeline_no_cameras'), 'info', { - icon: 'mdi:chart-gantt', - }); - return; - } - - this._timeline = new Timeline( - this._refTimeline.value, - this._data.dataset, - 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; - }); - } - } - - 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/components/viewer.ts b/src/components/viewer.ts index 6518a123..e1159360 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -51,10 +51,30 @@ 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'; +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. @@ -111,10 +134,13 @@ export class FrigateCardViewer extends LitElement { return renderProgressIndicator(); } - return html` - `; + `; } /** @@ -202,7 +228,7 @@ export class FrigateCardViewerCarousel extends LitElement { if (oldView) { if ( oldView.target === this.view?.target && - this.view.childIndex != oldView.childIndex + oldView.childIndex !== this.view.childIndex ) { const slide = this._getSlideForChild(this.view.childIndex); if ( @@ -215,8 +241,14 @@ export class FrigateCardViewerCarousel extends LitElement { } } } - } + // Seek into the video if the seek time has changed (this is also called + // on media load, since the media may or may not have been loaded at + // this point). + if (this.view?.context?.mediaViewer !== oldView?.context?.mediaViewer) { + this._recordingSeekHandler(); + } + } super.updated(changedProperties); } @@ -663,15 +695,12 @@ export class FrigateCardViewerCarousel extends LitElement { * Fire a media show event when a slide is selected. */ protected _recordingSeekHandler(): void { - // If this is a recording and play is desired to be started from a - // particular point, seek to that point. Use the media off the slide itself - // -- when the slide is changed, the media show event may be dispatched - // before this.view has been updated to reflect the new selection. - const player = this._getPlayer() as FrigateCardMediaPlayer & { - media?: FrigateBrowseMediaSource; - }; - if (player && player.media && player.media.frigate?.recording?.seek_seconds) { - player.seek(player.media.frigate.recording.seek_seconds); + const player = this._getPlayer(); + const childIndex = this.view?.childIndex ?? null; + const seek = + childIndex !== null ? this.view?.context?.mediaViewer?.seek.get(childIndex) : null; + if (player && seek) { + player.seek(seek.seekSeconds); } } @@ -718,7 +747,6 @@ export class FrigateCardViewerCarousel extends LitElement { url=${ifDefined( lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url), )} - .media=${mediaToRender} .hass=${this.hass} @frigate-card:media:loaded=${(e: CustomEvent) => { wrapMediaLoadedEventForCarousel(slideIndex, e); 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 fb13f480..ad366f0d 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", @@ -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", @@ -362,7 +371,8 @@ "duration": "Duration", "in_progress": "In Progress", "score": "Score", - "start": "Start" + "start": "Start", + "seek": "Seek" }, "recording": { "events": "Events", @@ -371,7 +381,11 @@ "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", + "unlock": "Unlock timeline" }, "elements": { "ptz": { 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/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/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/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/scss/timeline-core.scss b/src/scss/timeline-core.scss index f83a47e1..a6851b2a 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..8141c19d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,8 @@ export const THUMBNAIL_WIDTH_MIN = 75; * Internal types. */ +export type ClipsOrSnapshots = 'clips' | 'snapshots'; + export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ 'live', 'clip', @@ -38,6 +40,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', @@ -568,10 +571,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; @@ -655,6 +660,44 @@ 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 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({ + mode: z.enum(['none', 'above', 'below']), +}); +export type MiniTimelineControlConfig = z.infer; + /** * Next/Previous Control configuration section. */ @@ -787,6 +830,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 +1033,7 @@ const viewerConfigSchema = z ), }) .default(viewerConfigDefault.controls.thumbnails), + timeline: miniTimelineConfigSchema.optional(), title: titleControlConfigSchema .extend({ mode: titleControlConfigSchema.shape.mode.default( @@ -1082,10 +1127,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 +1138,9 @@ 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 @@ -1215,6 +1240,7 @@ export const frigateCardConfigDefaults = { event_gallery: galleryConfigDefault, image: imageConfigDefault, timeline: timelineConfigDefault, + mini_timeline: timelineCoreConfigDefault, }; const menuButtonSchema = z.discriminatedUnion('type', [ @@ -1347,31 +1373,12 @@ 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; 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,9 +1386,28 @@ export interface FrigateBrowseMediaSource extends BrowseMediaSource { frigate?: { event?: FrigateEvent; recording?: FrigateRecording; + cameraID?: string; }; } +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({ @@ -1396,19 +1422,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/basic.ts b/src/utils/basic.ts index 31504bbc..d44142fd 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -1,3 +1,4 @@ +import { format } from 'date-fns'; import { isEqual } from 'lodash-es'; import { FrigateCardError } from '../types'; @@ -85,3 +86,28 @@ export function errorToConsole(e: Error, func?: CallableFunction): void { export const isHoverableDevice = (): boolean => 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'); +} + +/** + * 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/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..ade6cf3a 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 { CameraConfig, 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 = @@ -35,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(), @@ -74,9 +88,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 +116,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), @@ -145,26 +159,151 @@ 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 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. + * 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 getUniqueFrigateCameraID = (config: CameraConfig): string => { - return [config.frigate.client_id, config.frigate.camera_name].join('/'); +export const getEvents = async ( + hass: HomeAssistant, + params?: FrigateGetEventsParameters, +): Promise => { + return await homeAssistantWSRequest( + hass, + frigateEventsSchema, + { + type: 'frigate/events/get', + ...params, + }, + true, + ); }; /** - * 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. + * 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 getUniqueFrigateCameraEventsID = (config: CameraConfig): string => { +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 [ - config.frigate.client_id, - config.frigate.camera_name, - config.frigate.label, - config.frigate.zone, + '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 dc411df7..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, @@ -27,7 +20,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 +71,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 +88,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 +111,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 +260,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( @@ -425,19 +406,21 @@ 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 => { - return { + const result: FrigateBrowseMediaSource = { title: title, media_class: MEDIA_CLASS_VIDEO, media_content_type: MEDIA_TYPE_VIDEO, @@ -445,59 +428,19 @@ export const createVideoChild = ( can_play: true, can_expand: false, thumbnail: options?.thumbnail ?? null, - children: null, - ...(options?.recording && { - frigate: { - recording: options.recording, - }, - }), - }; -}; - -/** - * 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'); + children: null } - 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 (options?.recording || options?.cameraID || options?.event) { + result.frigate = {} + if (options?.event) { + result.frigate.event = options.event; + } + if (options?.recording) { + result.frigate.recording = options.recording; + } + if (options?.cameraID) { + result.frigate.cameraID = options.cameraID; + } } - 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('/'); + return result; }; 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()], ); }; diff --git a/src/utils/timeline-data-manager.ts b/src/utils/timeline-data-manager.ts new file mode 100644 index 00000000..680c351b --- /dev/null +++ b/src/utils/timeline-data-manager.ts @@ -0,0 +1,542 @@ +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 { + CameraConfig, + ExtendedHomeAssistant, + FrigateCardError, + FrigateEvent, + FrigateEvents, +} from '../types.js'; +import { errorToConsole, runWhenIdleIfSupported } from '../utils/basic.js'; +import { + FrigateGetEventsParameters, + getEventsMultiple, + getRecordingSegments, + getRecordingsSummary, + RecordingSegments, + 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; +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 + // numbers instead. + start: number; + end?: number; + event?: FrigateEvent; +} + +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; + + // 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; + } + + // 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; + } + + /** + * Create a dataview for a given set of camera. + * @param cameraIDs The cameraIDs to include. + * @param showRecordings Whether or not to show recordings. + * @returns A dataview. + */ + public createDataView( + cameraIDs: Set, + showRecordings: boolean, + mediaType: TimelineMediaType, + ): 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') && + // 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)), + }); + } + + /** + * Create a dataview for segments. + * @returns A dataview. + */ + public createSegmentDataView(): DataView { + return new DataView(this._recordingSegments); + } + + /** + * Get the underlying recording segments dataset. + */ + 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 events for the given camera. + * @param cameraID The camera ID. + * @param events The array of events. + */ + 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 }), + })), + ); + } + + /** + * 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._fetchRecordingSummary(hass), + ...(segmentEnd > segmentStart + ? [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. + * @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: Map = new Map(); + + this._cameras.forEach((cameraConfig, cameraID) => { + 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, + }); + } + }); + + let results: Map; + try { + results = await getEventsMultiple(hass, params); + } catch (e) { + return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); + } + results.forEach((params, cameraID) => this._addEvents(cameraID, params)); + } +} 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); } /** 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"