From 9c372815f25ec789f63ea72741c0c6feb2cfc01a Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 4 Apr 2022 21:25:12 -0700 Subject: [PATCH] Add support for both snapshots/clips in timeline. --- src/browse-media-util.ts | 49 +++++---- src/card.ts | 6 +- src/components/gallery.ts | 55 +++++----- src/components/live.ts | 22 ++-- src/components/surround-thumbnails.ts | 7 +- src/components/timeline.ts | 146 ++++++++++++++++---------- src/components/viewer.ts | 51 +++++---- src/scss/live.scss | 4 - src/types.ts | 22 +++- src/view.ts | 7 +- 10 files changed, 212 insertions(+), 157 deletions(-) diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index 1fbfe1d6..a3d647d2 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -1,6 +1,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import type { + BrowseMediaQueryParametersBase, BrowseMediaQueryParameters, FrigateBrowseMediaSource, CameraConfig, @@ -39,8 +40,8 @@ export class BrowseMediaUtil { * @param media The media object. * @returns `true` if it's truly a media item, `false` otherwise. */ - static isTrueMedia(media: FrigateBrowseMediaSource): boolean { - return !media.can_expand; + static isTrueMedia(media?: FrigateBrowseMediaSource): boolean { + return !!media && !media.can_expand; } /** @@ -113,15 +114,13 @@ export class BrowseMediaUtil { * Get the parameters to search for media. * @returns A BrowseMediaQueryParameters object. */ - static getBrowseMediaQueryParameters( - mediaType: 'clips' | 'snapshots', + static getBrowseMediaQueryParametersBase( cameraConfig?: CameraConfig, - ): BrowseMediaQueryParameters | null { + ): BrowseMediaQueryParametersBase | null { if (!cameraConfig || !cameraConfig.camera_name) { return null; } return { - mediaType: mediaType, clientId: cameraConfig.client_id, cameraName: cameraConfig.camera_name, label: cameraConfig.label, @@ -129,20 +128,37 @@ export class BrowseMediaUtil { }; } + /** + * Set the mediaType parameter from the current view. + * @param browseMediaQueryParametersBase The base media query parameters object. + * @param view The current view. + * @returns A fully populated BrowseMediaQueryParameters or null. + */ + static setMediaTypeFromView( + browseMediaQueryParametersBase: BrowseMediaQueryParametersBase | null, + view: View, + ): BrowseMediaQueryParameters | null { + if ( + !browseMediaQueryParametersBase || + !(view.isClipRelatedView() || view.isSnapshotRelatedView()) + ) { + return null; + } + return { + ...browseMediaQueryParametersBase, + mediaType: view.isClipRelatedView() ? 'clips' : 'snapshots', + }; + } + /** * Get the parameters to search for media related to the current view. * @returns A BrowseMediaQueryParameters object. */ - static getBrowseMediaQueryParametersOrDispatchError( + static getBrowseMediaQueryParametersBaseOrDispatchError( node: HTMLElement, - view: View, cameraConfig: CameraConfig, - ): BrowseMediaQueryParameters | null { - if (!view.isClipRelatedView() && !view.isSnapshotRelatedView()) { - return null; - } - - // Verify there is a camera name, otherwise getBrowseMediaQueryParameters() + ): BrowseMediaQueryParametersBase | null { + // Verify there is a camera name, otherwise getBrowseMediaQueryParametersBase() // will return undefined. if (!cameraConfig.camera_name) { dispatchErrorMessageEvent( @@ -152,10 +168,7 @@ export class BrowseMediaUtil { return null; } - return BrowseMediaUtil.getBrowseMediaQueryParameters( - view.isClipRelatedView() ? 'clips' : 'snapshots', - cameraConfig, - ); + return BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig); } /** diff --git a/src/card.ts b/src/card.ts index 0b3f8cae..5d72ba87 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1089,13 +1089,15 @@ export class FrigateCard extends LitElement { // Do not artifically constrain aspect ratio if: // - It's fullscreen. // - Aspect ratio enforcement is disabled. - // - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the gallery). + // - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the + // gallery) or timeline. // - There is a message to display to the user. return !( (screenfull.isEnabled && screenfull.isFullscreen) || aspectRatioMode == 'unconstrained' || - (aspectRatioMode == 'dynamic' && this._view?.isMediaView()) || + (aspectRatioMode == 'dynamic' && + (this._view?.isMediaView() || this._view?.is('timeline'))) || this._message != null ); } diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 5f4d8137..c2df5dac 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -38,17 +38,18 @@ export class FrigateCardGallery extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.cameraConfig) { + if (!this.hass || !this.view || !this.cameraConfig || !this.view.isGalleryView()) { return; } if (!this.view.target) { - const browseMediaQueryParameters = - BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError( + const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView( + BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError( this, - this.view, this.cameraConfig, - ); + ), + this.view, + ); if (!browseMediaQueryParameters) { return; } @@ -203,27 +204,29 @@ export class FrigateCardGalleryCore extends LitElement { ` : child.thumbnail ? html` { - if (this.view) { - this.view - .evolve({ - view: this.view.is('clips') ? 'clip' : 'snapshot', - childIndex: index, - previous: this.view, - }) - .dispatchChangeEvent(this); - } - stopEventFromActivatingCardWideActions(ev); - }} - />${child.frigate?.event?.retain_indefinitely ? html`` : ``}` + aria-label="${child.title}" + class="mdc-image-list__image" + src="${child.thumbnail}" + title="${child.title}" + @click=${(ev: Event) => { + if (this.view) { + this.view + .evolve({ + view: this.view.is('clips') ? 'clip' : 'snapshot', + childIndex: index, + previous: this.view, + }) + .dispatchChangeEvent(this); + } + stopEventFromActivatingCardWideActions(ev); + }} + />${child.frigate?.event?.retain_indefinitely + ? html`` + : ``}` : ``} `, diff --git a/src/components/live.ts b/src/components/live.ts index 62be17ff..7285fbd2 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -7,7 +7,6 @@ import { PropertyValues, } from 'lit'; import { - FrigateBrowseMediaSource, ExtendedHomeAssistant, CameraConfig, JSMPEGConfig, @@ -20,25 +19,21 @@ import { LiveProvider, TransitionEffect, frigateCardConfigDefaults, + BrowseMediaQueryParameters, } from '../types.js'; import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; import JSMpeg from '@cycjimmy/jsmpeg-player'; import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { Task } from '@lit-labs/task'; -import { customElement, property, query, state } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { until } from 'lit/directives/until.js'; -import { styleMap } from 'lit/directives/style-map.js'; import { AutoMediaPlugin, AutoMediaPluginType } from './embla-plugins/automedia.js'; import { BrowseMediaUtil } from '../browse-media-util.js'; import { ConditionState, getOverriddenConfig } from '../card-condition.js'; import { FrigateCardMediaCarousel } from './media-carousel.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; -import { - FrigateCardThumbnailCarousel, - ThumbnailCarouselTap, -} from './thumbnail-carousel.js'; import { Lazyload } from './embla-plugins/lazyload.js'; import { View } from '../view.js'; import { localize } from '../localize/localize.js'; @@ -104,9 +99,6 @@ export class FrigateCardLive extends LitElement { // pre-loading it may be propagated upwards later. protected _savedMediaShowInfo?: MediaShowInfo; - @query('frigate-card-thumbnail-carousel') - protected _thumbnailCarousel?: FrigateCardThumbnailCarousel; - /** * Handler for media show events that special cases preloaded live views. * @param e The media show event. @@ -135,13 +127,16 @@ export class FrigateCardLive extends LitElement { this.conditionState, ) as LiveConfig; - const browseMediaParams = BrowseMediaUtil.getBrowseMediaQueryParameters( - config.controls.thumbnails.media, + const browseMediaParamsBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase( this.cameras.get(this.view.camera), ); - if (!browseMediaParams) { + if (!browseMediaParamsBase) { return; } + const browseMediaParams: BrowseMediaQueryParameters = { + ...browseMediaParamsBase, + mediaType: config.controls.thumbnails.media, + } // Note use of liveConfig and not config below -- the carousel will // independently override the liveconfig to reflect the camera in the @@ -150,7 +145,6 @@ export class FrigateCardLive extends LitElement { .hass=${this.hass} .view=${this.view} .config=${config.controls.thumbnails} - .targetView=${config.controls.thumbnails.media == 'clips' ? 'clip' : 'snapshot'} .browseMediaParams=${browseMediaParams} > ) => { this.view ?.evolve({ - ...(this.targetView && { view: this.targetView }), + view: this.targetView || 'event', target: ev.detail.target, childIndex: ev.detail.childIndex, }) diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 34e586d4..c4f0aa6d 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,5 +1,4 @@ // TODO: Clips vs snapshots: Should be able to navigate from snapshots view and it should just work. -// TODO: Remove HACK in view.ts on clips // TODO: Hover over an event should show something useful. // TODO: Periodically refetch events. // TODO: Search for TODOs and logging statements. @@ -16,7 +15,6 @@ import { import { DataSet } from 'vis-data/esnext'; import { DataGroupCollectionType, - IdType, Timeline, TimelineItem, TimelineOptions, @@ -36,8 +34,7 @@ import { ExtendedHomeAssistant, FrigateBrowseMediaSource, MEDIA_CLASS_PLAYLIST, - MEDIA_TYPE_VIDEO, - MEDIA_CLASS_VIDEO, + MEDIA_TYPE_PLAYLIST, TimelineConfig, FrigateEvent, } from '../types'; @@ -58,7 +55,9 @@ interface FrigateCardGroupData { content: string; } interface FrigateCardTimelineItem extends TimelineItem { - source: FrigateBrowseMediaSource; + event: FrigateEvent; + clip?: FrigateBrowseMediaSource; + snapshot?: FrigateBrowseMediaSource; } interface TimelineViewContext extends ViewContext { @@ -114,20 +113,29 @@ class TimelineEventManager { protected _addMediaSource(camera: string, target: FrigateBrowseMediaSource): void { const items: FrigateCardTimelineItem[] = []; target.children?.forEach((child) => { - if (child.frigate) { - const item = { - id: child.media_content_id, - group: camera, - content: this._contentCallback?.(child) ?? '', - start: child.frigate.event.start_time * 1000, - source: child, - }; - if (child.frigate.event.end_time) { - item['end'] = child.frigate.event.end_time * 1000; + const event = child.frigate?.event; + if (event && ['video', 'image'].includes(child.media_content_type)) { + let item = this._dataset.get(event.id); + if (!item) { + item = { + id: event.id, + group: camera, + content: this._contentCallback?.(child) ?? '', + start: event.start_time * 1000, + event: event, + }; + } + if (event.end_time) { + item['end'] = event.end_time * 1000; item['type'] = 'range'; } else { item['type'] = 'point'; } + if (child.media_content_type === 'video') { + item['clip'] = child; + } else if (child.media_content_type === 'image') { + item['snapshot'] = child; + } items.push(item); } }); @@ -161,13 +169,14 @@ class TimelineEventManager { element: HTMLElement, hass: HomeAssistant & ExtendedHomeAssistant, cameras: Map, + media: 'all' | 'clips' | 'snapshots', start: Date, end: Date, ): Promise { if (this.hasCoverage(start, end)) { return false; } - await this._fetchEvents(element, hass, cameras, start, end); + await this._fetchEvents(element, hass, cameras, media, start, end); return true; } @@ -183,6 +192,7 @@ class TimelineEventManager { element: HTMLElement, hass: HomeAssistant & ExtendedHomeAssistant, cameras: Map, + media: 'all' | 'clips' | 'snapshots', start: Date, end: Date, ): Promise { @@ -193,24 +203,25 @@ class TimelineEventManager { this._dateEnd = end; } - const fetchCameraEvents = async (camera: string): Promise => { + const fetchCameraEvents = async ( + camera: string, + mediaType: 'clips' | 'snapshots', + ): Promise => { const cameraConfig = cameras.get(camera); if (!cameraConfig || !this._dateStart || !this._dateEnd) { return; } - const browseMediaQueryParameters = BrowseMediaUtil.getBrowseMediaQueryParameters( - 'clips', + const browseMediaQueryParametersBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase( cameraConfig, ); - if (!browseMediaQueryParameters) { + if (!browseMediaQueryParametersBase) { return; } - try { this._addMediaSource( camera, await BrowseMediaUtil.browseMediaQuery(hass, { - ...browseMediaQueryParameters, + ...browseMediaQueryParametersBase, // Events are always fetched for the maximum extent of the managed // range. This is because events may change at any point in time @@ -218,6 +229,7 @@ class TimelineEventManager { before: this._dateEnd.getTime() / 1000, after: this._dateStart.getTime() / 1000, unlimited: true, + mediaType: mediaType, }), ); } catch (e) { @@ -225,7 +237,15 @@ class TimelineEventManager { } }; - await Promise.all(Array.from(cameras.keys()).map(fetchCameraEvents.bind(this))); + const promises: Promise[] = []; + (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => + promises.push( + ...Array.from(cameras.keys()).map((camera) => + fetchCameraEvents(camera, mediaType as 'clips' | 'snapshots'), + ), + ), + ); + await Promise.all(promises); } } @@ -256,7 +276,6 @@ export class FrigateCardTimeline extends LitElement { .hass=${this.hass} .view=${this.view} .config=${this.timelineConfig.controls.thumbnails} - .targetView=${'clip'} > ${properties.end} [${this._events.dataset.length}]`, ); - if (this.hass && this.cameras && this._timeline) { + if (this.hass && this.cameras && this._timeline && this.timelineConfig) { this._events .fetchEventsIfNecessary( this, this.hass, this.cameras, + this.timelineConfig.media, properties.start, properties.end, ) @@ -367,7 +387,9 @@ export class FrigateCardTimelineCore extends LitElement { if (!this._thumbnails || !this._thumbnails.children || data.items.length <= 0) { return; } - const childIndex = this._findThumbnailIndex(data.items[0]); + const childIndex = this._thumbnails.children.findIndex( + (child) => child.frigate?.event.id === data.items[0], + ); if (childIndex >= 0) { this.view ?.evolve({ @@ -379,19 +401,6 @@ export class FrigateCardTimelineCore extends LitElement { } } - /** - * Find the index of the given item in the thumbnails. - * @param id - * @returns The index of the item, or -1 if not found. - */ - public _findThumbnailIndex(id: IdType | IdType[]): number { - if (!this._thumbnails || !this._thumbnails.children) { - return -1; - } - id = Array.isArray(id) ? id[0] : id; - return this._thumbnails.children.findIndex((child) => child.media_content_id === id); - } - /** * Regenerate the thumbnails from the timeline events. * @returns @@ -401,10 +410,33 @@ export class FrigateCardTimelineCore extends LitElement { return; } - const children: FrigateBrowseMediaSource[] = this._events.dataset - .get() - .filter((item) => BrowseMediaUtil.isTrueMedia(item.source)) - .map((item) => item.source); + const selected = this._timeline.getSelection(); + let childIndex = -1; + const children: FrigateBrowseMediaSource[] = []; + this._events.dataset.get().forEach((item) => { + if (this.timelineConfig) { + let added = false; + if ( + item.clip && + ['all', 'clips'].includes(this.timelineConfig.media) && + BrowseMediaUtil.isTrueMedia(item.clip) + ) { + added = true; + children.push(item.clip); + } else if ( + item.snapshot && + ['all', 'snapshots'].includes(this.timelineConfig.media) && + BrowseMediaUtil.isTrueMedia(item.snapshot) + ) { + added = true + children.push(item.snapshot); + } + + if (added && selected.includes(item.event.id)) { + childIndex = children.length-1; + } + } + }); if (!children.length) { return; } @@ -412,17 +444,16 @@ export class FrigateCardTimelineCore extends LitElement { const target = { title: `Timeline events`, media_class: MEDIA_CLASS_PLAYLIST, - media_content_type: MEDIA_TYPE_VIDEO, + media_content_type: MEDIA_TYPE_PLAYLIST, media_content_id: '', can_play: false, can_expand: true, - children_media_class: MEDIA_CLASS_VIDEO, + children_media_class: MEDIA_CLASS_PLAYLIST, thumbnail: null, children: children, }; this._thumbnails = target; - const childIndex = this._findThumbnailIndex(this._timeline.getSelection()); // Update the thumbnail carousel with the regenerated thumbnails. this.view @@ -523,11 +554,11 @@ export class FrigateCardTimelineCore extends LitElement { // different object types together (e.g. person and car). return ( !!first.id && - first.id !== this.view?.media?.media_content_id && + first.id !== this.view?.media?.frigate?.event?.id && !!second.id && - second.id != this.view?.media?.media_content_id && - (first).source.frigate?.event.label === - (second).source.frigate?.event.label + second.id != this.view?.media?.frigate?.event?.id && + (first).event.label === + (second).event.label ); }, } @@ -573,9 +604,15 @@ export class FrigateCardTimelineCore extends LitElement { */ protected async _updateTimelineFromView(): Promise { const event = this.view?.media?.frigate?.event; - const id = this.view?.media?.media_content_id; - if (!this.hass || !this.cameras || !this.view || !event || !id || !this._timeline) { + if ( + !this.hass || + !this.cameras || + !this.view || + !event || + !this._timeline || + !this.timelineConfig + ) { return; } @@ -585,6 +622,7 @@ export class FrigateCardTimelineCore extends LitElement { this, this.hass, this.cameras, + this.timelineConfig.media, eventWindowStart, eventWindowEnd, ) @@ -595,7 +633,7 @@ export class FrigateCardTimelineCore extends LitElement { const eventStart = new Date(event.start_time * 1000); const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0; - this._timeline.setSelection([id], { + this._timeline.setSelection([event.id], { focus: false, animation: { animation: false, @@ -628,7 +666,7 @@ export class FrigateCardTimelineCore extends LitElement { // 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._events.dataset.get(id); + const item = this._events.dataset.get(event.id); if (item) { this._events.dataset.updateOnly(item); } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 75d5f888..90de4107 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -10,14 +10,14 @@ import { BrowseMediaUtil } from '../browse-media-util.js'; import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; import { Task } from '@lit-labs/task'; -import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { ref } from 'lit/directives/ref.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import type { BrowseMediaNeighbors, - BrowseMediaQueryParameters, + BrowseMediaQueryParametersBase, FrigateBrowseMediaSource, CameraConfig, ExtendedHomeAssistant, @@ -25,7 +25,6 @@ import type { TransitionEffect, ViewerConfig, } from '../types.js'; -import { CarouselSelect } from './carousel.js'; import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js'; @@ -35,7 +34,6 @@ import { contentsChanged, createMediaShowInfo, dispatchErrorMessageEvent, - dispatchFrigateCardEvent, stopEventFromActivatingCardWideActions, } from '../common.js'; import { renderProgressIndicator } from '../components/message.js'; @@ -71,17 +69,21 @@ export class FrigateCardViewer extends LitElement { return; } - const browseMediaQueryParameters = - BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError( + const browseMediaQueryParametersBase = + BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError( this, - this.view, this.cameraConfig, ); - if (!browseMediaQueryParameters) { - return; - } if (!this.view.target) { + const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView( + browseMediaQueryParametersBase, + this.view, + ); + if (!browseMediaQueryParameters) { + return; + } + BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange( this, this.hass, @@ -95,13 +97,12 @@ export class FrigateCardViewer extends LitElement { .hass=${this.hass} .view=${this.view} .config=${this.viewerConfig.controls.thumbnails} - .browseMediaParams=${browseMediaQueryParameters} > @@ -133,7 +134,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { protected viewerConfig?: ViewerConfig; @property({ attribute: false }) - protected browseMediaQueryParameters?: BrowseMediaQueryParameters; + protected browseMediaQueryParametersBase?: BrowseMediaQueryParametersBase; @property({ attribute: false }) protected resolvedMediaCache?: ResolvedMediaCache; @@ -267,16 +268,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { ? this._lazyloadSlide.bind(this) : undefined, }), - // Don't need autoplay/pause for snapshots. - ...(this.view?.is('clip') - ? [ - AutoMediaPlugin({ - playerSelector: 'frigate-card-ha-hls-player', - autoPlayWhenVisible: !!this.viewerConfig?.auto_play, - autoUnmuteWhenVisible: !!this.viewerConfig?.auto_unmute, - }), - ] - : []), + AutoMediaPlugin({ + playerSelector: 'frigate-card-ha-hls-player', + autoPlayWhenVisible: !!this.viewerConfig?.auto_play, + autoUnmuteWhenVisible: !!this.viewerConfig?.auto_unmute, + }), ]; } @@ -338,7 +334,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { !this.view.target || !this.view.target.children || !this.view.target.children.length || - !this.browseMediaQueryParameters + !this.browseMediaQueryParametersBase ) { return null; } @@ -381,7 +377,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { try { clips = await BrowseMediaUtil.browseMediaQuery(this.hass, { - ...this.browseMediaQueryParameters, + ...this.browseMediaQueryParametersBase, mediaType: 'clips', before: latest, after: earliest, @@ -628,7 +624,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { if ( !this.view || !this.viewerConfig || - !BrowseMediaUtil.isTrueMedia(mediaToRender) + !BrowseMediaUtil.isTrueMedia(mediaToRender) || + !['video', 'image'].includes(mediaToRender.media_content_type) ) { return; } @@ -641,7 +638,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { return html`
- ${this.view.isClipRelatedView() + ${mediaToRender.media_content_type === 'video' ? html`