From fb9d5684ec0d0f96cde5c9cff29cffdd4823a934 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 23 Apr 2022 11:22:04 -0700 Subject: [PATCH] Add 'dependent camera' support. --- src/browse-media-util.ts | 271 +++++++++++++++++++++----- src/card.ts | 16 +- src/common.ts | 1 + src/components/gallery.ts | 33 ++-- src/components/live.ts | 19 +- src/components/surround-thumbnails.ts | 13 +- src/components/timeline.ts | 99 +++++----- src/components/viewer.ts | 36 ++-- src/localize/languages/en.json | 2 +- src/types.ts | 23 ++- src/view.ts | 14 +- 11 files changed, 363 insertions(+), 164 deletions(-) diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index 0e8dbd19..0e7b6570 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -1,16 +1,18 @@ import { HomeAssistant } from 'custom-card-helpers'; -import type { - BrowseMediaQueryParametersBase, +import { BrowseMediaQueryParameters, FrigateBrowseMediaSource, CameraConfig, + MEDIA_CLASS_PLAYLIST, + MEDIA_TYPE_PLAYLIST, } from './types.js'; import { View } from './view.js'; import { frigateBrowseMediaSourceSchema } from './types.js'; import { dispatchErrorMessageEvent, dispatchMessageEvent, + getCameraTitle, homeAssistantWSRequest, } from './common.js'; import { localize } from './localize/localize.js'; @@ -73,11 +75,9 @@ export class BrowseMediaUtil { type: 'media_source/browse_media', media_content_id: media_content_id, }; - return homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request); + return await homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request); } - // Browse Frigate media with query parameters. - /** * Browse Frigate media with a media query. May throw. * @param hass The HomeAssistant object. @@ -109,13 +109,100 @@ export class BrowseMediaUtil { ); } + /** + * Browse multiple Frigate media queries. May throw. + * @param hass The HomeAssistant object. + * @param params An array of search parameters to use to search for media. + * @returns A map of FrigateBrowseMediaSource object or null on malformed. + */ + static async multipleBrowseMediaQuery( + hass: HomeAssistant, + params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], + ): Promise> { + params = Array.isArray(params) ? params : [params]; + const output: Map = new Map(); + await Promise.all( + params.map(async (param: BrowseMediaQueryParameters): Promise => { + output.set(param, await this.browseMediaQuery(hass, param)); + }), + ); + return output; + } + + /** + * Browse multiple Frigate media queries, then merged them. May throw. + * @param hass The HomeAssistant object. + * @param params An array of search parameters to use to search for media. + * @returns A single FrigateBrowseMediaSource object or null on malformed. + */ + static async multipleBrowseMediaQueryMerged( + hass: HomeAssistant, + params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], + ): Promise { + return this.mergeFrigateBrowseMediaSources( + await this.multipleBrowseMediaQuery(hass, params), + ); + } + + /** + * Merge multiple FrigateBrowseMediaSource into a single. Note that this may + * use information from the query to differentiate results that may otherwise + * be identical. + * @param input A map of query -> result. + * @returns A single FrigateBrowseMediaSource object. + */ + static mergeFrigateBrowseMediaSources( + input: Map, + ): FrigateBrowseMediaSource { + const children: FrigateBrowseMediaSource[] = []; + + for (const [query, result] of input.entries()) { + for (const child of result.children || []) { + if (this.isTrueMedia(child)) { + children.push(child); + } else { + if (query.title) { + children.push({ ...child, title: `[${query.title}] ${child.title}` }); + } else { + children.push(child); + } + } + } + } + + const eventSort = ( + a: FrigateBrowseMediaSource, + b: FrigateBrowseMediaSource, + ): number => { + if ( + !a.frigate?.event || + (b.frigate?.event && b.frigate.event.start_time < a.frigate.event.start_time) + ) { + return 1; + } + + if ( + !b.frigate?.event || + (a.frigate?.event && b.frigate.event.start_time > a.frigate.event.start_time) + ) { + return -1; + } + return 0; + }; + + return this.createEventParentForChildren('Merged events', children.sort(eventSort)); + } + /** * Get the parameters to search for media. * @returns A BrowseMediaQueryParameters object. */ - static getBrowseMediaQueryParametersBase( + static getBrowseMediaQueryParameters( + hass: HomeAssistant, + cameraID: string, cameraConfig?: CameraConfig, - ): BrowseMediaQueryParametersBase | null { + overrides?: Partial, + ): BrowseMediaQueryParameters | null { if (!cameraConfig || !cameraConfig.camera_name) { return null; } @@ -124,84 +211,143 @@ export class BrowseMediaUtil { cameraName: cameraConfig.camera_name, label: cameraConfig.label, zone: cameraConfig.zone, + title: getCameraTitle(hass, cameraConfig), + cameraID: cameraID, + ...overrides, }; } /** - * 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. + * Apply overrides to multiple query parameters. + * @param parameters An array of query parameters. + * @param overrides The overrides to apply. + * @returns The override query parameters. */ - static setMediaTypeFromView( - browseMediaQueryParametersBase: BrowseMediaQueryParametersBase | null, - view: View, - ): BrowseMediaQueryParameters | null { - if ( - !browseMediaQueryParametersBase || - !(view.isClipRelatedView() || view.isSnapshotRelatedView()) - ) { - return null; + static overrideMultiBrowseMediaQueryParameters( + parameters: BrowseMediaQueryParameters[], + overrides: Partial, + ): BrowseMediaQueryParameters[] { + const output: BrowseMediaQueryParameters[] = []; + parameters.forEach((param) => { + output.push({ ...param, ...overrides }); + }); + return output; + } + + /** + * Get BrowseMediaQueryParameters for a camera (including its dependencies). + * @param hass Home Assistant object. + * @param cameras Cameras map. + * @param camera Name of the current camera. + * @param mediaType Optional media type to include in the parameters. + * @returns An array of query parameters. + */ + static getFullDependentBrowseMediaQueryParameters( + hass: HomeAssistant, + cameras: Map, + 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); + for (const eventCameraID of cameraConfig.dependent_cameras || []) { + if (!cameraIDs.has(eventCameraID)) { + getDependentCameras(eventCameraID); + } + } + } + }; + getDependentCameras(camera); + + const params: BrowseMediaQueryParameters[] = []; + for (const cameraID of cameraIDs) { + const param = BrowseMediaUtil.getBrowseMediaQueryParameters( + hass, + cameraID, + cameras.get(cameraID), + mediaType ? { mediaType: mediaType } : {}, + ); + // Fail on a single bad camera, as it's almost certainly a user error that + // should be fixed rather than hidden. + if (!param) { + return null; + } + params.push(param); } - return { - ...browseMediaQueryParametersBase, - mediaType: view.isClipRelatedView() ? 'clips' : 'snapshots', - }; + return params.length ? params : null; } /** - * Get the parameters to search for media related to the current view. - * @returns A BrowseMediaQueryParameters object. + * Get BrowseMediaQueryParameters for a camera (including its dependencies) or dispatch an error. + * @param element The element from which to dispatch the error. + * @param hass Home Assistant object. + * @param cameras Cameras map. + * @param camera Name of the current camera. + * @param mediaType Optional media type to include in the parameters. + * @returns An array of query parameters. */ - static getBrowseMediaQueryParametersBaseOrDispatchError( - node: HTMLElement, - cameraConfig: CameraConfig, - ): BrowseMediaQueryParametersBase | null { - // Verify there is a camera name, otherwise getBrowseMediaQueryParametersBase() - // will return undefined. - if (!cameraConfig.camera_name) { + static getFullDependentBrowseMediaQueryParametersOrDispatchError( + element: HTMLElement, + hass: HomeAssistant, + cameras: Map, + camera: string, + mediaType?: 'clips' | 'snapshots', + ): BrowseMediaQueryParameters[] | null { + const params = this.getFullDependentBrowseMediaQueryParameters( + hass, + cameras, + camera, + mediaType, + ); + if (!params) { dispatchErrorMessageEvent( - node, - localize('error.no_camera_name') + `: ${JSON.stringify(cameraConfig)}`, + element, + localize('error.no_camera_name'), + cameras.get(camera), ); return null; } - - return BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig); + return params; } /** * Fetch the latest media and dispatch a change view event to reflect the * results. If no media is found a suitable message event will be triggered * instead. - * @param node The HTMLElement to dispatch events from. + * @param element The HTMLElement to dispatch events from. * @param hass The Home Assistant object. * @param view The current view to evolve. * @param browseMediaQueryParameters The media parameters to query with. * @returns */ static async fetchLatestMediaAndDispatchViewChange( - node: HTMLElement, + element: HTMLElement, hass: HomeAssistant, view: Readonly, - browseMediaQueryParameters: BrowseMediaQueryParameters, + browseMediaQueryParameters: + | BrowseMediaQueryParameters + | BrowseMediaQueryParameters[], ): Promise { let parent: FrigateBrowseMediaSource | null; try { - parent = await BrowseMediaUtil.browseMediaQuery(hass, browseMediaQueryParameters); + parent = await BrowseMediaUtil.multipleBrowseMediaQueryMerged( + hass, + browseMediaQueryParameters, + ); } catch (e) { - return dispatchErrorMessageEvent(node, (e as Error).message); + return dispatchErrorMessageEvent(element, (e as Error).message); } const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent); if (!parent || !parent.children || childIndex == null) { return dispatchMessageEvent( - node, - browseMediaQueryParameters.mediaType == 'clips' + element, + view.isClipRelatedView() ? localize('common.no_clip') : localize('common.no_snapshot'), - browseMediaQueryParameters.mediaType == 'clips' - ? 'mdi:filmstrip-off' - : 'mdi:camera-off', + view.isClipRelatedView() ? 'mdi:filmstrip-off' : 'mdi:camera-off', ); } @@ -210,7 +356,7 @@ export class BrowseMediaUtil { target: parent, childIndex: childIndex, }) - .dispatchChangeEvent(node); + .dispatchChangeEvent(element); } /** @@ -223,7 +369,7 @@ export class BrowseMediaUtil { * @returns */ static async fetchChildMediaAndDispatchViewChange( - node: HTMLElement, + element: HTMLElement, hass: HomeAssistant, view: Readonly, child: Readonly, @@ -232,13 +378,36 @@ export class BrowseMediaUtil { try { parent = await BrowseMediaUtil.browseMedia(hass, child.media_content_id); } catch (e) { - return dispatchErrorMessageEvent(node, (e as Error).message); + return dispatchErrorMessageEvent(element, (e as Error).message); } view .evolve({ target: parent, }) - .dispatchChangeEvent(node); + .dispatchChangeEvent(element); + } + + /** + * Given an array of media children, create a parent for them. + * @param title The title to use for the parent. + * @param children The children media items. + * @returns A single parent containing the children. + */ + static createEventParentForChildren( + title: string, + children: FrigateBrowseMediaSource[], + ): FrigateBrowseMediaSource { + return { + title: title, + media_class: MEDIA_CLASS_PLAYLIST, + media_content_type: MEDIA_TYPE_PLAYLIST, + media_content_id: '', + can_play: false, + can_expand: true, + children_media_class: MEDIA_CLASS_PLAYLIST, + thumbnail: null, + children: children, + }; } } diff --git a/src/card.ts b/src/card.ts index 41f2215d..8e919f83 100644 --- a/src/card.ts +++ b/src/card.ts @@ -8,7 +8,7 @@ import { } from 'lit'; import { HomeAssistant, LovelaceCardEditor, getLovelace } from 'custom-card-helpers'; import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; -import { customElement, property, query, state } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import screenfull from 'screenfull'; @@ -328,11 +328,12 @@ export class FrigateCard extends LitElement { const cameraConfig = this._getSelectedCameraConfig(); // Don't show `clips` button if there's no `camera_name` (e.g. non-Frigate - // cameras), or is birdseye. + // cameras), or is birdseye (unless there are dependent cameras). if ( this._getConfig().menu.buttons.clips && cameraConfig?.camera_name && - cameraConfig?.camera_name !== 'birdseye' + (cameraConfig?.camera_name !== 'birdseye' || + cameraConfig?.dependent_cameras?.length) ) { buttons.push({ type: 'custom:frigate-card-menu-icon', @@ -345,11 +346,12 @@ export class FrigateCard extends LitElement { } // Don't show `snapshots` button if there's no `camera_name` (e.g. non-Frigate - // cameras), or is birdseye. + // cameras), or is birdseye (unless there are dependent cameras). if ( this._getConfig().menu.buttons.snapshots && cameraConfig?.camera_name && - cameraConfig?.camera_name !== 'birdseye' + (cameraConfig?.camera_name !== 'birdseye' || + cameraConfig?.dependent_cameras?.length) ) { buttons.push({ type: 'custom:frigate-card-menu-icon', @@ -1299,7 +1301,7 @@ export class FrigateCard extends LitElement { ? html` ` @@ -1308,7 +1310,7 @@ export class FrigateCard extends LitElement { ? html` diff --git a/src/common.ts b/src/common.ts index 68a27adb..c9ca7153 100644 --- a/src/common.ts +++ b/src/common.ts @@ -477,6 +477,7 @@ export function getCameraTitle( config?.title || (config?.camera_entity ? getEntityTitle(hass, config.camera_entity) : '') || (config?.camera_name ? prettifyFrigateName(config.camera_name) : '') || + config?.id || '' ); } diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 9bbc476d..1bdcf2a7 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -11,11 +11,7 @@ import { import { HomeAssistant } from 'custom-card-helpers'; import { customElement, property } from 'lit/decorators.js'; -import { - CameraConfig, - GalleryConfig, - frigateCardConfigDefaults, -} from '../types.js'; +import { CameraConfig, GalleryConfig, frigateCardConfigDefaults } from '../types.js'; import { BrowseMediaUtil } from '../browse-media-util.js'; import { View } from '../view.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; @@ -35,28 +31,37 @@ export class FrigateCardGallery extends LitElement { protected view?: Readonly; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + protected galleryConfig?: GalleryConfig; @property({ attribute: false }) - protected galleryConfig?: GalleryConfig; + protected cameras?: Map; /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.cameraConfig || !this.view.isGalleryView()) { + const mediaType = this.view?.getMediaType(); + if ( + !this.hass || + !this.view || + !this.cameras || + !this.view.isGalleryView() || + !mediaType + ) { return; } if (!this.view.target) { - const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView( - BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError( + const browseMediaQueryParameters = + BrowseMediaUtil.getFullDependentBrowseMediaQueryParametersOrDispatchError( this, - this.cameraConfig, - ), - this.view, - ); + this.hass, + this.cameras, + this.view.camera, + mediaType, + ); + if (!browseMediaQueryParameters) { return; } diff --git a/src/components/live.ts b/src/components/live.ts index b3d1593b..42f649be 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -19,7 +19,6 @@ import { LiveProvider, TransitionEffect, frigateCardConfigDefaults, - BrowseMediaQueryParameters, } from '../types.js'; import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; @@ -127,16 +126,18 @@ export class FrigateCardLive extends LitElement { this.conditionState, ) as LiveConfig; - const browseMediaParamsBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase( - this.cameras.get(this.view.camera), - ); - if (!browseMediaParamsBase) { + const browseMediaParams = + BrowseMediaUtil.getFullDependentBrowseMediaQueryParametersOrDispatchError( + this, + this.hass, + this.cameras, + this.view.camera, + config.controls.thumbnails.media, + ); + + if (!browseMediaParams) { 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 diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 60eb18c6..ce708446 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -34,7 +34,9 @@ export class FrigateCardSurround extends LitElement { protected targetView?: FrigateCardView; @property({ attribute: false }) - protected browseMediaParams?: BrowseMediaQueryParameters; + protected browseMediaParams?: + | BrowseMediaQueryParameters + | BrowseMediaQueryParameters[]; // A task to await the load of the WebRTC component. protected _browseTask = new Task(this, this._fetchMedia.bind(this), () => [ @@ -53,22 +55,25 @@ export class FrigateCardSurround extends LitElement { | HomeAssistant | Readonly | BrowseMediaQueryParameters + | BrowseMediaQueryParameters[] | undefined )[]): Promise { hass = hass as HomeAssistant; view = view as Readonly; - browseMediaParams = browseMediaParams as BrowseMediaQueryParameters; + browseMediaParams = browseMediaParams as + | BrowseMediaQueryParameters + | BrowseMediaQueryParameters[]; if (!hass || !view || view.target || !browseMediaParams) { return; } let parent: FrigateBrowseMediaSource | null; try { - parent = await BrowseMediaUtil.browseMediaQuery(hass, browseMediaParams); + parent = await BrowseMediaUtil.multipleBrowseMediaQueryMerged(hass, browseMediaParams); } catch (e) { return dispatchErrorMessageEvent(this, (e as Error).message); } - if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) { + if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) !== null) { this.view ?.evolve({ ...(this.targetView && { view: this.targetView }), diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 1721b68d..7136f50b 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -25,10 +25,9 @@ import { isEqual } from 'lodash-es'; import { BrowseMediaUtil } from '../browse-media-util'; import { + BrowseMediaQueryParameters, CameraConfig, FrigateBrowseMediaSource, - MEDIA_CLASS_PLAYLIST, - MEDIA_TYPE_PLAYLIST, TimelineConfig, FrigateEvent, frigateCardConfigDefaults, @@ -278,49 +277,47 @@ class TimelineEventManager { } this._dateFetch = new Date(); - const fetchCameraEvents = async ( - camera: string, - fetchMedia: 'clips' | 'snapshots', - ): Promise => { - const cameraConfig = cameras.get(camera); - if (!cameraConfig || !this._dateStart || !this._dateEnd) { - return; - } - const browseMediaQueryParametersBase = - BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig); - if (!browseMediaQueryParametersBase) { - return; - } - try { - this._addMediaSource( - camera, - media, - await BrowseMediaUtil.browseMediaQuery(hass, { - ...browseMediaQueryParametersBase, + const params: BrowseMediaQueryParameters[] = []; + cameras.forEach((cameraConfig, cameraID) => { + (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => { + if (this._dateEnd && this._dateStart) { + const param = BrowseMediaUtil.getBrowseMediaQueryParameters( + hass, + cameraID, + cameraConfig, + { + // 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). + before: this._dateEnd.getTime() / 1000, + after: this._dateStart.getTime() / 1000, + unlimited: true, + mediaType: mediaType as 'clips' | 'snapshots', + }, + ); + if (param) { + params.push(param); + } + } + }); + }); - // 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). - before: this._dateEnd.getTime() / 1000, - after: this._dateStart.getTime() / 1000, - unlimited: true, - mediaType: fetchMedia, - }), - ); - } catch (e) { - return dispatchErrorMessageEvent(element, (e as Error).message); - } - }; + if (!params.length) { + return; + } - 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); + let results: Map; + try { + results = await BrowseMediaUtil.multipleBrowseMediaQuery(hass, params); + } catch (e) { + return dispatchErrorMessageEvent(element, (e as Error).message); + } + + for (const [query, result] of results.entries()) { + if (query.cameraID) { + this._addMediaSource(query.cameraID, media, result); + } + } } } @@ -574,18 +571,10 @@ export class FrigateCardTimelineCore extends LitElement { return null; } - const target = { - title: `Timeline events`, - media_class: MEDIA_CLASS_PLAYLIST, - media_content_type: MEDIA_TYPE_PLAYLIST, - media_content_id: '', - can_play: false, - can_expand: true, - children_media_class: MEDIA_CLASS_PLAYLIST, - thumbnail: null, - children: children, - }; - + const target = BrowseMediaUtil.createEventParentForChildren( + 'Timeline events', + children, + ); return { target: target, childIndex: childIndex < 0 ? null : childIndex, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index c11d65e9..237e7560 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -17,7 +17,7 @@ import { ref } from 'lit/directives/ref.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import type { BrowseMediaNeighbors, - BrowseMediaQueryParametersBase, + BrowseMediaQueryParameters, FrigateBrowseMediaSource, CameraConfig, MediaShowInfo, @@ -54,7 +54,7 @@ export class FrigateCardViewer extends LitElement { protected viewerConfig?: ViewerConfig; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + protected cameras?: Map; @property({ attribute: false }) protected resolvedMediaCache?: ResolvedMediaCache; @@ -64,21 +64,19 @@ export class FrigateCardViewer extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.cameraConfig || !this.viewerConfig) { + if (!this.hass || !this.view || !this.cameras || !this.viewerConfig) { return; } - const browseMediaQueryParametersBase = - BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError( + const browseMediaQueryParameters = + BrowseMediaUtil.getFullDependentBrowseMediaQueryParametersOrDispatchError( this, - this.cameraConfig, + this.hass, + this.cameras, + this.view.camera, ); if (!this.view.target) { - const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView( - browseMediaQueryParametersBase, - this.view, - ); if (!browseMediaQueryParameters) { return; } @@ -101,7 +99,7 @@ export class FrigateCardViewer extends LitElement { .hass=${this.hass} .view=${this.view} .viewerConfig=${this.viewerConfig} - .browseMediaQueryParametersBase=${browseMediaQueryParametersBase} + .browseMediaQueryParameters=${browseMediaQueryParameters} .resolvedMediaCache=${this.resolvedMediaCache} > @@ -133,7 +131,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { protected viewerConfig?: ViewerConfig; @property({ attribute: false }) - protected browseMediaQueryParametersBase?: BrowseMediaQueryParametersBase; + protected browseMediaQueryParameters?: BrowseMediaQueryParameters[] | null; @property({ attribute: false }) protected resolvedMediaCache?: ResolvedMediaCache; @@ -338,7 +336,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { !this.view.target || !this.view.target.children || !this.view.target.children.length || - !this.browseMediaQueryParametersBase + !this.browseMediaQueryParameters ) { return null; } @@ -379,13 +377,17 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { let clips: FrigateBrowseMediaSource | null; - try { - clips = await BrowseMediaUtil.browseMediaQuery(this.hass, { - ...this.browseMediaQueryParametersBase, + const params = BrowseMediaUtil.overrideMultiBrowseMediaQueryParameters( + this.browseMediaQueryParameters, + { mediaType: 'clips', before: latest, after: earliest, - }); + }, + ); + + try { + clips = await BrowseMediaUtil.multipleBrowseMediaQueryMerged(this.hass, params); } catch (e) { // This is best effort. return null; diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 1a1a0969..6d3dfa2f 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -276,7 +276,7 @@ "invalid_elements_config": "Invalid picture elements configuration", "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", "jsmpeg_no_player": "Could not start JSMPEG player", - "no_camera_name": "Could not determine Frigate camera name for camera, please specify either 'camera_entity' or 'camera_name' for the following camera", + "no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'", "download_no_media": "No media to download", "download_no_event_id": "Could not extract Frigate event id from media", "download_sign_failed": "Could not sign media URL for download" diff --git a/src/types.ts b/src/types.ts index e87e198a..00cd0af5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -327,6 +327,9 @@ const cameraConfigSchema = z // Camera identifiers for WebRTC. webrtc_card: webrtcCardCameraConfigSchema.optional(), + + // Set of cameras IDs upon which this camera depends. + dependent_cameras: z.string().array().optional(), }) .default(cameraConfigDefault); export type CameraConfig = z.infer; @@ -936,11 +939,14 @@ export type MenuButton = z.infer; export interface ExtendedHomeAssistant extends HomeAssistant { hassUrl(path?): string; themes: Themes & { - darkMode?: boolean + darkMode?: boolean; }; } -export interface BrowseMediaQueryParametersBase { +export interface BrowseMediaQueryParameters { + // ======================================== + // Parameters used to construct media query + // ======================================== mediaType?: 'clips' | 'snapshots'; clientId: string; cameraName: string; @@ -949,10 +955,17 @@ export interface BrowseMediaQueryParametersBase { before?: number; after?: number; unlimited?: boolean; -} -export interface BrowseMediaQueryParameters extends BrowseMediaQueryParametersBase { - mediaType: 'clips' | 'snapshots'; + // ======================================== + // Parameters used to differentiate results + // ======================================== + // Optional title to be used for separating results when merging multiple + // sets of results. See `mergeFrigateBrowseMediaSources()` . + title?: string; + + // Optional camera-id to which this query is associated. May be used to map + // results to a particular camera within the card. + cameraID?: string; } export interface BrowseMediaNeighbors { diff --git a/src/view.ts b/src/view.ts index 42ad6300..243b4873 100644 --- a/src/view.ts +++ b/src/view.ts @@ -61,7 +61,7 @@ export class View { target: params.target !== undefined ? params.target : this.target, childIndex: params.childIndex !== undefined ? params.childIndex : this.childIndex, context: params.context !== undefined ? params.context : this.context, - + // Special case: Set the previous to this of the evolved view (rather than // the previous of this). previous: params.previous !== undefined ? params.previous : this, @@ -110,6 +110,18 @@ export class View { return ['snapshot', 'snapshots'].includes(this.view); } + /** + * Get the media type for this view if available. + * @returns Whether the media is `clips` or `snapshots` or unknown (`null`) + */ + public getMediaType(): 'clips' | 'snapshots' | null { + return this.isClipRelatedView() + ? 'clips' + : this.isSnapshotRelatedView() + ? 'snapshots' + : null; + } + /** * Get the media item that should be played. **/